已经熟悉C编程语言的朋友们应该已经很清楚了——C语言程序的构建过程包括了 编译(compile) 和 连接(link) 这两个部分。C语言编译器先将每个源文件( .c 文件)编译成对应的目标文件(Windows系统一般为 .obj 文件;类Unix系统一般为 .o 文件)。随后,再将这些编译完的目标文件连接为一单个可执行文件。
下面我们先列出一些源文件用于后续例子的使用,此外后续的例子若没指明,则均是在Linux+GCC环境下使用:
// afunc.c
#include <stdio.h>
static void InternalFunc(void)
{
puts("This is InternalFunc for FuncA!");
}
void FuncA(void)
{
puts("This is FuncA!");
InternalFunc();
}
void FuncDummyA(void)
{
puts("This is DummyA!");
}
// bfunc.c
#include <stdio.h>
static void InternalFunc(void)
{
puts("This is InternalFunc for FuncB!");
}
void FuncB(void)
{
puts("This is FuncB!");
InternalFunc();
}
void FuncDummyB(void)
{
puts("This is DummyB!");
}
// main_test.c
#include <stdio.h>
extern void FuncA(void);
extern void FuncB(void);
int main(int argc, const char* argv[])
{
FuncA();
FuncB();
}
以上我们编写了三个C语言源文件:afunc.c、bfunc.c、main_test.c。其中,afunc.c与bfunc.c源文件各有自己的 内部连接(internal linkage) 函数——InternalFunc()
,还有各自的 外部连接(external linkage) 函数,分别是 FuncA()
与 FuncB()
。然后在 main_test.c 源文件中将会调用 afunc.c 里的 FuncA()
以及 bfunc.c 里的 FuncB()
。我们可以用下面命令对上述三个文件进行编译并直接生成最终可执行文件——main_test。
gcc main_test.c afunc.c bfunc.c -o main_test -std=gnu17
当我们执行了 main_test 程序之后就会得到以下输出结果:
This is FuncA!
This is InternalFunc for FuncA!
This is FuncB!
This is InternalFunc for FuncB!
而上述 gcc 命令实际上是以下命令的一个聚合,两者生成出来的可执行文件是完全相同的:
#! /bin/sh
# build_test.sh
# gcc main_test.c afunc.c bfunc.c -o main_test -std=gnu17
gcc afunc.c -o afunc.c.o -c -std=gnu17
gcc bfunc.c -o bfunc.c.o -c -std=gnu17
gcc main_test.c -o main_test.c.o -c -std=gnu17
gcc main_test.c.o afunc.c.o bfunc.c.o -o main_test
rm afunc.c.o bfunc.c.o main_test.c.o
我们可以看到上述具体的编译和连接过程:以上 build_test.sh 脚本中,先是用三条 gcc
命令,通过添加 -c
命令选项,先后将 afunc.c、bfunc.c 和 main_test.c 编译为对应的目标文件 afunc.c.o、bfunc.c.o 和 main_test.c.o。而最后一行 gcc 命令则是将这三个目标文件(.o文件)连接在一起,生成最终的可执行文件 main_test。而最后一行 rm
命令则是将生成的三个目标文件进行删除,由于我们已经不再需要了。我们可以通过以下命令来执行此sh脚本:
sh build_test.sh
得到的可执行文件 main_test 对它执行后同样能得到第一种编译后的输出结果。
在Linux环境下,使用GCC等编译器一般是将C语言源文件编译为遵循 ELF 格式的目标文件。因此,我们要查看这些目标文件或是下面介绍的静态库与动态库文件,乃至可执行文件,可使用 readelf 命令来查看包含在其中的各种符号。
readelf -s main_test
执行上述命令之后将会列出包含在此 main_test 可执行文件中的所有符号。这里一般包含了两个表,一个是动态库符号表(Symbol table '.dynsym'),还有一个则是普通符号表(Symbol table '.symtab')。我们这里先看普通符号表。
由于系统以及编译环境自带的符号比较多,这里就列出我们关心的一些符号:
Num | Value | Size | Type | Bind | Vis | Ndx | Name |
---|---|---|---|---|---|---|---|
36 | 0000 | 0 | FILE | LOCAL | DEFAULT | ABS | main_test.c |
37 | 0000 | 0 | FILE | LOCAL | DEFAULT | ABS | afunc.c |
38 | 116d | 23 | FUNC | LOCAL | DEFAULT | 16 | InternalFunc |
39 | 0000 | 0 | FILE | LOCAL | DEFAULT | ABS | bfunc.c |
40 | 11b7 | 23 | FUNC | LOCAL | DEFAULT | 16 | InternalFunc |
57 | 1184 | 28 | FUNC | GLOBAL | DEFAULT | 16 | FuncA |
61 | 11ea | 23 | FUNC | GLOBAL | DEFAULT | 16 | FuncDummyB |
67 | 1149 | 36 | FUNC | GLOBAL | DEFAULT | 16 | main |
70 | 11ce | 28 | FUNC | GLOBAL | DEFAULT | 16 | FuncB |
72 | 11a0 | 23 | FUNC | GLOBAL | DEFAULT | 16 | FuncDummyA |
上述列表中一共有8个字段,下面将对这8个字段分别进行说明:
- Num:表示当前符号的序号,用十进制数表示。在一个ELF文件中符号从序号0开始,然后依次递增。
- Value:表示当前符号位于当前ELF文件中的偏移地址,用十六进制数表示。
- Size:表示当前符号所占存储空间大小(以字节为单位)。
- Type:表示当前符号类型。基本有这几类——NOTYPE 表示一个没有任何类型的纯符号或是一个未定义的符号;SECTION 表示一个存储器段;FILE 表示一个源文件;OBJECT 表示一个对象(比如一个全局变量);FUNC 表示一个函数。
- Bind:表征了当前符号的连接属性,主要有这三种值——LOCAL 表示当前符号具有内部连接(比如一个
static
变量或函数);GLOBAL 表示当前符号具有外部连接(比如一个全局变量或函数);WEAK 表示当前符号具有外部连接,但可被其他同名的 GLOBAL 或 WEAK 符号所覆盖(对于GCC而言,相当于一个被__attribute__((weak))
所修饰的全局变量或函数),因而对于多个完全相同名称的 WEAK 符号而言不会导致连接失败。 - Vis:表示当前符号的可见性。与GCC的
__attribute__((visibility("vis")))
相对应。共有四种值,分别是——default
、protected
、internal
和hidden
。 - Ndx:当前符号所处的段序号(section number)。值 ABS 意味着绝对值,从而不会调整到任一段地址的重定向。
- Name:当前符号名。
从上表我们可以知晓,main_test 可执行文件中切切实实地存放了我们在 afunc.c、bfunc.c以及 main_test.c 中所有定义的函数。即使是没有被调用到的 FuncDummyA()
和 FuncDummyB()
也均在其中。
以上描述的是对于一般C语言源文件如何经过编译、连接,最后生成一个可执行文件的详细过程。那么我们有一定经验的程序员知道,我们在企业工作中,往往会把一个较大、较通用的功能进行模块化,以供多个项目使用。当然,最简单粗暴的方式可以直接将实现该功能的所有源文件直接复制黏贴到所需要的项目工程中,但这会带来不少弊病——比如,如果一个项目工程导入的源文件太多会导致编译过程变得十分缓慢,尤其是改动了某个需要被许多源文件所包含的头文件而言,更为如此!所以,为了能提升模块独立化,与其他项目进行解耦,并且提升编译构建、接口抽象性等诸多软件工程上的益处,我们往往会将一个通用的功能模块打包成一个库。
C语言可支持的库有两种,一种是静态连接库,还有一种是动态连接库。下面我们将分别予以介绍。
静态连接库
C语言中的一个静态连接库(static library)是一组目标文件(.obj 文件(Windows系统) 或 .o 文件(类Unix系统))的聚合。用不太严谨的说法,静态库文件相当于是对一组目标文件进行打包“压缩”后的文件。而事实上,GCC工具链中的 ar 命令也提供了 ar -x
命令选项能从指定的静态库文件中萃取出指定的目标文件。所以无论是Windows系统还是其他类Unix系统,生成一个静态连接库的过程中均 没有连接过程。这也就意味着 ar
命令在打包生成静态连接库文件的时候是不做连接(link)操作的。
我们下面来看一下,Linux环境下用GCC工具链生成静态库文件的方法:
#! /bin/sh
# build_test.sh
gcc afunc.c -o afunc.c.o -c -std=gnu17
gcc bfunc.c -o bfunc.c.o -c -std=gnu17
# Create a static library from afunc.c.o and bfunc.c.o
ar -crv libfunc.a afunc.c.o bfunc.c.o
gcc main_test.c -o main_test.c.o -c -std=gnu17
gcc main_test.c.o -o main_test -L./ -lfunc
# Equivalent
gcc main_test.c.o libfunc.a -o main_test
rm afunc.c.o bfunc.c.o main_test.c.o libfunc.a
我们可以通过执行 sh build_test.sh
这条命令来执行上述shell脚本。该脚本前面两条语句与之前的一样,分别用于产生 afunc.c.o 与 bfunc.c.o 这两个目标文件。而后面则用了 ar -crv
命令将这两个目标文件合并在一起,生成了静态连接库文件——libfunc.a。最后,我们将 main_test.c.o目标文件连接此 libfunc.a 静态连接库文件,最终生成可执行文件 main_test。
这里,第8行的 gcc main_test.c.o -o main_test -L./ -lfunc
与第10行的 gcc main_test.c.o libfunc.a -o main_test
这两条命令在执行结果上是等价的,而且最终生成的可执行文件在笔者的环境中均为16952个字节。前者使用了 -l
命令,用于指示在指定的搜索路径中查找名为 libfunc 文件名的库。而且默认先查找相应的动态库,即 libfunc.so;若找不到,则查找对应的静态库,即 libfunc.a。在类Unix系统中,静态连接库往往以 .a 作为文件扩展名,并且以 lib 作为库文件名的前缀,所以在使用 -l
命令时需要缺省这个 lib 字符串。因而我们在上述shell脚本中也能看到,我们在指定静态库时使用的是 -lfunc
,表示要连接的是 libfunc 这个库。而在Windows系统中则往往以 .lib 作为静态库的文件扩展名,并且不要求任何文件前缀名。而如果我们要显式指定去连接某一个静态连接库,则可使用第10行的那条gcc命令。这里直接指明了使用 libfunc.a 这个静态库。
当我们生成完可执行文件之后,可以再使用 readelf -s main_test
命令去观察 main_test 可执行文件里的符号,可发现与之前直接用 .o 文件生成的可执行文件没啥区别,该有的符号都有,并且符号属性也没有受到更改。
网友评论