2.9.5. C库: 使用, 编译和链接

示例程序

请下载下列示例程序.

实现了可供其他程序使用的函数和定义的集合。一个C库由两部分组成:

  1. 库的 应用程序编程接口 ( API),在一个或多个头文件(.h文件)中定义,这些头文件必须包含在计划使用该库的 C 源代码文件中。头文件定义了库向用户导出的内容。这些定义通常包括库函数原型,也可能包括类型、常量或全局变量声明。
  2. 库功能的 实现 通常以预编译的二进制格式提供给程序,该格式链接(或添加)到由 gcc 创建的二进制可执行文件中。预编译的库代码可能位于包含多个 .o 文件的归档文件(libsomelib.a)中,这些文件可以在编译时静态链接到可执行文件中。或者,它可能由一个共享对象文件 (libsomelib.so) 组成,该文件可以在运行时动态链接到正在运行的程序中。

例如,C 字符串库实现了一组函数来操作 C 字符串。头文件 string.h 定义了它的接口,因此任何想要使用字符串库函数的程序都必须 #include <string.h> . C 字符串库的实现是更大的标准 C 库 ( libc) 的一部分,gcc编译器自动链接到它创建的每个可执行文件中。

库的实现由一个或多个模块(.c文件)组成,并且可能还包括库实现内部的头文件;内部头文件不是库 API 的一部分,而是精心设计的模块化库代码的一部分。通常,库的 C 源代码实现不会导出给库的用户。相反,该库以预编译的二进制形式提供。这些二进制格式不是可执行程序(它们不能单独运行),但它们提供可执行代码,可以在编译时通过gcc 链接 到(添加到)可执行文件中。

有许多库可供 C 程序员使用。例如,POSIX 线程库(在第 10 章中讨论)支持多线程 C 程序。 C 程序员还可以实现和使用自己的库(在下一节中讨论)。大型 C 程序往往会使用许多 C 库,其中一些 gcc 隐式链接,而另一些则需要使用 -l 命令行选项显式链接到 gcc

标准 C 库通常不需要与该 -l选项显式链接,但其他库则需要。库函数的文档通常会指定编译时是否需要显式链接该库。例如,POSIX 线程库 ( pthread) 和该 readline库需要在gcc命令行上显式链接:

$ gcc -o myprog myprog.c -pthread -lreadline

请注意,链接 POSIX 线程库是一种特殊情况,不包含 -l 前缀。但是,大多数库都使用gcc命令行上的-l语法显式链接到可执行文件。另请注意,库文件的全名不应包含在gcc-l参数中;库文件的名称类似于libreadline.solibreadline.a,但不包括文件名的lib前缀和.so.a后缀。实际的库文件名还可能包含版本号(例如libreadline.so.8.0),这些版本号也不包含在-l命令行选项(-lreadline)中。通过不强迫用户指定(甚至知道)要链接的库文件的确切名称和位置,gcc可以自由地在用户的库路径中查找库的最新版本。当库的共享对象(.so)和静态库(存档)(.a)版本都可用时,它还允许编译器选择动态链接。如果用户想要静态链接库,那么他们可以在gcc命令行中显式指定静态链接。 --static 选项提供了一种请求静态链接的方法:

$ gcc -o myprog myprog.c --static -pthread -lreadline

编译步骤

描述 C 程序编译步骤将有助于说明库代码如何链接到可执行二进制文件。我们首先介绍编译步骤,然后讨论(通过示例)编译使用库的程序时可能发生的不同类型的错误。

C 编译器通过四个不同的步骤(加上运行时发生的第五步)将 C 源文件(例如myprog.c)转换为可执行二进制文件(例如a.out).

  1. 预编译器步骤首先运行并扩展预处理器指令:出现在 C 程序中的 # 指令,例如#define#include。此步骤中的编译错误包括预处理器指令中的语法错误或gcc未找到与#include指令关联的头文件。要查看预编译器步骤的中间结果,请将-E标志传递给gcc(输出可以重定向到可以通过文本编辑器查看的文件):

    $ gcc -E  myprog.c
    $ gcc -E  myprog.c  > out
    $ vim out
    
  2. 接下来运行编译步骤并执行大部分编译任务。它将 C 程序源代码 (myprog.c) 转换为机器特定的汇编代码 (myprog.s)。汇编代码是计算机可以执行的二进制机器代码指令的人类可读形式。此步骤的编译错误包括C语言语法错误、未定义符号警告以及缺少定义和函数原型的错误。要查看编​​译步骤的中间结果,请将-S标志传递给gcc(此选项创建一个名为myprog.s的文本文件,其中包含myprog.c的汇编翻译,可以在文本编辑器查看):

    $ gcc -S  myprog.c
    $ vim myprog.s
    
  3. 汇编步骤将汇编代码转换为可重定位的二进制目标代码(myprog.o)。生成的目标文件包含机器代码指令,但它不是可以独立运行的完整可执行程序。 Unix 和 Linux 系统上的 gcc 编译器会生成名为 ELF(可执行和可链接格式)的特定格式的二进制文件。要在此步骤之后停止编译,请将-c标志传递给gcc(这会生成一个名为myprog.o的文件)。可以使用objdump或用于显示二进制文件的类似工具来查看二进制文件(例如a.out.o文件):

    $ gcc -c  myprog.c
    
    # disassemble functions in myprog.o with objdump:
    $ objdump -d myprog.o
  1. 链接编辑步骤最后运行,并从可重定位二进制文​​件(.o)和库(.a.so)创建单个可执行文件(a.out)。在此步骤中,链接器验证.o文件中对名称(符号)的任何引用是否存在于其他.o.a.so文件中。例如,链接器将在标准 C 库 (libc.so) 中找到printf函数。如果链接器找不到符号的定义,则此步骤将失败并显示错误,指出符号未定义。运行不带部分编译标志的gcc会执行将 C 源代码文件(myprog.c)编译为可以运行的可执行二进制文件(a.out)的所有四个步骤:

    $ gcc myprog.c
    $ ./a.out
    
    # disassemble functions in a.out with objdump:
    $ objdump -d a.out
    

    如果二进制可执行文件(a.out)静态链接到库代码(来自.a库文件),则gcc会将.a文件中的库函数副本嵌入到生成的a.out文件中。应用程序对库函数的所有调用都绑定到库函数复制到的a.out文件中的位置。绑定将名称与程序存储器中的位置相关联。例如,绑定对名为gofish的库函数的调用意味着将函数名称的使用替换为函数内存中的地址(在后面的章节中我们将讨论内存地址)。

    但是,如果a.out是通过动态链接库(从库共享对象.so文件)创建的,则a.out不包含这些库中的库函数代码的副本。相反,它包含有关a.out文件运行它所需的动态链接库的信息。此类可执行文件需要在运行时执行额外的链接步骤。

  2. 如果在链接编辑期间a.out与共享对象文件链接(步骤 4),则需要运行时链接步骤。在这种情况下,动态库代码(在.so文件中)必须在运行时加载并与正在运行的程序链接。这种共享对象库的运行时加载和链接称为动态链接。当用户运行具有共享对象依赖项的a.out可执行文件时,系统会在程序开始执行其main函数之前执行动态链接。

    编译器在链接编辑编译步骤(步骤 4)期间将有关共享对象依赖项的信息添加到a.out文件中。当程序开始执行时,动态链接器检查共享对象依赖项列表,找到共享对象文件并将其加载到正在运行的程序中。然后,它更新a.out文件中的重定位表(relocation table)条目,将程序对共享对象中符号的使用(例如对库函数的调用)绑定到运行时加载的.so文件中的位置。如果动态链接器找不到可执行文件所需的共享对象(.so)文件,则运行时链接会报告错误。

    ldd 程序列出可执行文件的共享对象依赖项:

    $ ldd a.out
    

    GNU 调试器 (GDB) 可以检查正在运行的程序并显示在运行时加载和链接的共享对象代码。我们在第 3 章 中介绍了 GDB。然而,检查过程查找表 (PLT)(用于动态链接库函数调用的运行时链接)的详细信息超出了本教科书的范围。

有关编译阶段以及用于检查不同阶段的工具的更多详细信息,请参阅:编译阶段

与编译和链接库相关的常见编译错误

由于程序员忘记包含库头文件或忘记显式链接库代码,可能会出现一些编译和链接错误。识别gcc与每个错误相关的编译器错误或警告将有助于调试与使用 C 库相关的错误。

考虑以下 C 程序,该程序从examplelib库调用libraryfunc函数,该库可作为共享对象文件libexamplelib.so使用:

#include <stdio.h>
#include <examplelib.h>

int main(int argc, char *argv[]) {
    int result;
    result = libraryfunc(6, MAX);
    printf("result is %d\n", result);
    return 0;
}

假设头文件examplelib.h包含以下示例中的定义:

#define MAX 10   // a constant exported by the library

// a function exported by the library
extern int libraryfunc(int x, int y);

函数原型的 extern 前缀意味着该函数的定义来自另一个文件——它不在 examplelib.h 文件中,而是由库实现之一的.c文件(一个头文件对应多个实现文件.c文件)提供。

忘记包含头文件(没有包含声明符号的位置)

如果程序员忘记在程序中包含examplelib.h,则编译器会生成有关程序使用它不知道的库函数和常量的警告和错误。例如,如果用户在没有#include <examplelib.h>的情况下编译程序,gcc将产生以下输出:

# '-g': add debug information, '-c': compile to .o
gcc -g -c myprog.c

myprog.c: In function main:
myprog.c:8:12: warning: implicit declaration of function libraryfunc
   result = libraryfunc(6, MAX);
            ^~~~~~~~~~~

myprog.c:8:27: error: MAX undeclared (first use in this function)
   result = libraryfunc(6, MAX);
                           ^~~

第一个编译器警告(函数libraryfunc的隐式声明)告诉程序员编译器无法找到libraryfunc函数的函数原型。这只是一个编译器警告,因为gcc会猜测函数的返回类型是整数,并将继续编译程序。然而,程序员不应该忽略诸如此类的警告!它们表明程序在使用myprog.c文件之前未包含函数原型,这通常是由于未包含包含函数原型的头文件所致。

第二个编译器错误(MAX undeclared (first use in this function))是由于缺少常量定义而产生的。编译器无法猜测缺少的常量的值,因此缺少的定义失败并出现错误。这种类型的“未声明”消息通常表明定义常量或全局变量的头文件丢失或尚未正确包含。

忘记链接库

如果程序员包含库头文件(如前面的清单所示),但忘记在编译的链接编辑步骤(步骤 4)期间显式链接到库中,则 gcc 会显示未定义的引用错误(undefined reference):

$ gcc -g myprog.c

In function main:
myprog.c:9: undefined reference to libraryfunc
collect2: error: ld returned 1 exit status

此错误源自编译器的链接器组件 ld。它表明链接器找不到在 myprog.c 中第 9 行调用的库函数 libraryfunc 的实现。未定义的引用(undefined reference)错误表示需要将库显式链接到可执行文件中。在此示例中,在 gcc 命令行上指定 -lexamplelib 将修复错误:

$ gcc -g myprog.c  -lexamplelib
gcc找不到头文件或库文件

如果gcc默认搜索的目录中不存在库的头文件或实现文件,编译也会失败并出现错误。例如,如果gcc找不到examplelib.h文件,它将产生如下错误消息:

$ gcc -c myprog.c -lexamplelib
myprog.c:1:10: fatal error: examplelib.h: No such file or directory
 #include <examplelib.h>
          ^~~~~~~
compilation terminated.

如果链接器在编译的链接编辑步骤中找不到要链接的 .a.so 版本的库,gcc 将退出并出现如下错误:

$ gcc -c myprog.c -lexamplelib
/usr/bin/ld: cannot find -lexamplelib
collect2: error: ld returned 1 exit status

同样,如果动态链接的可执行文件无法找到共享对象文件(例如libexamplelib.so),它将无法在运行时执行,并出现如下错误:

$ ./a.out
./a.out: error while loading shared libraries:
        libexamplelib.so: cannot open shared object file: No such file or directory

要解决这些类型的错误,程序员必须为 gcc 指定其他选项,以指示可以找到库文件的位置。他们可能还需要修改运行时链接器的 LD_LIBRARY_PATH 环境变量以查找库的 .so 文件。

库和包含路径(Library and Include Paths)

编译器自动在标准目录位置搜索头文件和库文件。例如,系统通常将标准头文件存储在/usr/include中,将库文件存储在/usr/lib中,而gcc会自动在这些目录中查找头文件和库; gcc 还会自动搜索当前工作目录中的头文件。

如果gcc找不到头文件或库文件,则用户必须使用-I-L在命令行上显式提供路径。例如,假设名为libexamplelib.so的库存在于/home/me/lib中,其头文件examplelib.h位于/home/me/include中。因为默认情况下gcc对这些路径一无所知,所以必须明确告知它包含其中的文件才能成功编译使用该库的程序:

$ gcc  -I/home/me/include -o myprog myprog.c -L/home/me/lib -lexamplelib

要在启动动态链接的可执行文件时指定动态库(例如libexamplelib.so)的位置,请设置LD_LIBRARY_PATH环境变量以包含该库的路径。下面是一个 bash 命令示例,可以在 shell 提示符下运行或添加到.bashrc文件中:

export LD_LIBRARY_PATH=/home/me/lib:$LD_LIBRARY_PATH

gcc命令行变长,或者可执行文件需要许多源文件和头文件时,使用makeMakefile有助于简化编译。这里是有关 make 和 Makefiles 的更多信息。

rpath && runpath

nixos, manylinux 以及其他一些分发linux二进制程序的方案. 这些方案的内涵就是设置特别的库搜索路径, 保持相对路径层级进行加载, 确保程序可以在不同的机器上运行, 而且单独的库搜素路径可以和系统本身的依赖保持隔离, 便于设置沙箱环境.