有如下的程序:
其中的 main.c
引用了另外一个 sum.c
文件的函数。我们可以通过 gcc -Og -o prog main.c sum.c
来把这两个文件链接到一块编译出一个产物。这里的 gcc
相当于是一个编译器驱动程序,代替用户执行预处理器、编译器、汇编器、链接器来生成一个可执行文件。
上述 gcc
驱动编译的详细过程如下:
main.c
翻译成一个中间文件 main.i
:cpp [other arguments] main.c /tmp/main.i
main.s
:cc1 /tmp/main.i -Og -o /tmp/main.s
main.s
翻译成一个可重定位目标文件 main.o
:as -o /tmp/main.o /tmp/main.s
sum.o
main.o
和 sum.o
和其他的必要文件组合起来,生成一个可执行目标文件 prog
:ld -o prog [system object files and args] /tmp/main.o /tmp/sum.o
目标文件有三种:
在 Linux 系统上,目标文件是 ELF 格式的。ELF 文件的开头会有一个 ELF 头,结尾会有一个节头部表,中间则是由若干个节组成。ELF 头指明当前文件的格式、机器类型等信息,还包括了节头部表起始位置偏移、节头部表条目的数量和大小。节头部表指明了 ELF 文件中间的节的位置和大小信息等信息。
其中包含的节如下:
.text
:已编译的程序的机器代码.rodata
:常量信息,是只读的.data
:有初始值的全局变量和静态局部变量.bss
:没有初始值的全局变量和静态局部变量。这个节并不在 ELF 中占用实际的大小,而是在运行的时候直接在虚拟内存中进行映射,并由程序来给内存清零。.bss
可以认为是 Better Save Space ,一种节约文件大小的方式(尽管实际上 .bss
的来源不是这个).symtab
:符号表,程序中定义的全局变量和引用的全局变量的信息都存放在这里。符号表将用于后续的链接的重定位.rel.text
:存放 .text
节中需要重定位的位置,用于后续重定位.rel.data
:存放 .data
节中需要重定位的位置,同上.debug
:调试符号表,只有在编译时添加 -g
选项才会被生成。调试符号表将会存放程序中定义的各种变量的信息,以及原始的 C 语言文件,用于调试.line
:编译时添加 -g
选项才会被生成。存放原始的 C 语言文件中的行号和 .text
中机器指令的映射关系.strtab
:.symtab
和 .debug
中的符号和节头部表的节名字这些 ELF 中的文本数据并没有直接存到相应的位置,而是存储到 .strtab
中。原来的位置上只存储文本在 .strtab
中的偏移。和可重定位目标文件大致差不多,区别在于: ELF 头中还包含了程序的入口点。.text
、.rodata
、.data
中的内容已经经过重定位了。.init
节定义了一个小函数,叫做 __init
,用于程序的初始化。由于重定位完毕,所以不需要 .rel
相关的节。
可执行目标文件中还包含一个段头部表,这个表中表明了 ELF 中每个节要映射到的虚拟内存的地址、占用的虚拟内存大小、访问权限等。一般映射 .data
段的时候会多映射一些空间,留给 .bss
。同时,段头部表中会指明一个节在内存中的对齐要求。对于一个对其要求是 的节,该节映射到虚拟内存中的首地址 应满足 ,其中 是可执行目标文件中第一个节的偏移量。
有意思的是,尽管可执行目标文件不需要再与其他文件链接了,.symtab
还是存在。我们可以使用 strip
命令特意去掉。
C 语言规范中定义了一组函数,这些函数放在 libc 库中,如 printf
、scanf
等。为了支持这些库函数,一种方式是直接让编译器识别出这些函数,然后生成相应的代码。但是这样会增加编译器的复杂性。
另一种方法是将所有的函数放到一个单独的可重定位目标文件中,如 libc.o
,然后 gcc main.c /usr/lib/libc.o
即可让引入库函数。不过这样的话,每个编译出来的程序都会携带一个完整的 libc.o
,会浪费内存。
又一种方法是为每个函数创建一个单独的可重定位目标文件。不过这样做需要程序员自行链接合适的文件:gcc main.c /usr/lib/printf.o /usr/lib/scanf.o
。但是这样很麻烦又容易出错。
静态库将若干个可重定位目标文件组合到一个 .a
后缀的静态库文件中。这个文件的头部描述了其包含成员目标的信息。使用静态库文件时,链接器会自动抽取静态库文件中被使用到的成员,没被使用的则不会被抽取。
如我们创建一个 libvector
库:
使用 gcc -c addvec.c multvec.c
可以编译得到 addvec.o
和 multvec.o
然后我们可以使用 ar rcs libvector.a addvec.o multvec.o
来打包得到静态库文件。
使用时需要先有个头文件,头文件包含库函数的定义。
然后使用如下命令编译:
gcc -static -o prog2c main2.o -L. -lvector
其中 -static
参数告诉编译驱动程序,链接器应该构建一个完全链接的可执行目标文件,可以直接加载到内存中运行。这个参数使得可执行文件不需要任何动态链接。
-L.
表明寻找静态库的位置
-lvector
是 libvector.a
的缩写
静态库直接编译进程序了,这导致程序和静态库两者之间不能独立更新。同时,每个程序都会含有重复的静态库代码,仍然造成了很大的浪费。
共享库可以解决上述缺陷。程序在编译时不去将共享库加入编译产物中,而是简单的记录共享库的名称,程序在加载的时候再去载入共享库。同时,利用虚拟内存技术,我们可以只加载一个共享库文件到内存中,让多个程序共享这块内存。
我们可以使用如下命令生成共享库:
gcc -shared -fpic -o libvector.so addvec.c multvec.c
-shared
指明打包成共享库,-fpic
指明要生成与位置无关代码(这个参数是必须的)
编译时链接
gcc -o prog2l main2.c ./libvector.so
运行时使用
需添加编译参数 -ldl
来使得可执行文件链接动态加载器,以使用运行时链接的功能。
在 dlfcn.h
头文件中,有如下的函数:
void *dlopen(const char *filename, int flag);
filename
flag
可选如下选项
RTLD_GLOBAL
将共享库的符号直接合并到当前程序的全局符号空间。
extern
,然后在 dlopen
之后直接调用这个符号即可,不用再使用 dlsym
了。链接器在链接时,默认是允许动态链接,如果链接时 extern
符号没有被解析的话就视为后续运行时动态链接,除非添加 -static
,这样的话没有被解析的符号会报错。extern
。-rdynamic
选项,那么共享库还能去使用当前可执行文件中的符号。RTLD_NOW
立即解析共享库的符号引用,这样加载时比较耗时,但是使用符号的时候非常快。RTLD_LAZY
将符号解析推迟到使用时,这样加载时很快,但是初次使用符号的时候比较慢void *dlsym(void *handle, char *symbol);
获取加载的共享库中的某个符号的地址
handle
为 dlopen
返回的句柄指针,symbol
指明要引用的符号名称
成功则返回指向符号的指针,出错则返回 NULL
int dlclose(void *handle);
handle
为 dlopen
返回的句柄指针const char *dlerror(void);
一个 C 文件中定义的全局符号,有可能只是占位,实际上是要引用另一个文件的全局符号。同时一个 C 文件中定义的全局符号有可能也要被其他 C 文件引用。全局符号将会存在 ELF 的 .symtab
中,在于其他文件链接时提供信息。、
.symtab
中的条目结构如下:
name
是符号的名称,实际存储的是该名称在 .strtab
中的偏移。
type
指明这个符号是变量还是函数。
binding
指明这个符号是局部的还是全局的(局部的符号说明这个局部变量是被 static
修饰了)。
section
指明这个符号的定义是存在于哪个节中。实际存储的是一个到头部表的索引。
value
指明这个符号的定义存在于哪个位置。对于可重定位目标文件,这里存储的是相对于其所在的节中的偏移(配合 section
信息就知道其绝对位置)。对于可执行目标文件,这里存储的直接就是符号的定义的虚拟内存地址。
size
指明目标的大小,单位为字节。
其中 section
中可取一些特殊值,称为伪节:
ABS
表明这个符号不应该被重定位。UNDEF
表明这个符号的定义在当前文件中虽被定义了,但具体定义在外部的文件中,如没有函数体的函数定义、被 extern
修饰的函数或是变量。COMMON
则存储弱符号。伪节只会在可重定位目标文件中。
在 C 语言中,有一个强弱符号机制来处理多个文件之间全局符号的引用。所有的函数和有初始值的全局变量归为强符号,没有初始值的全局变量归为弱符号。
对于多个重名的全局符号,链接器有如下的规则进行选取:
编译器在处理 C 文件时,如果遇到一个强符号,如果其初始值为 0,则会判定这个符号属于 .bss
节。如果其初始值不为 0,则会判定这个符号属于 .data
节。如果遇到一个弱符号,则编译器不知道这个符号是定义在当前文件中还是其他文件中的,因此就判定其处于 COMMON
节中,等待链接器进一步决定。
如:
上述文件无法链接在一起,因为有两个强符号。
这个可以链接到一起,但是根据上述规则, bar3.c
中的 x
被判定为是在 foo3.c
中定义那个 x
,所以调用 f()
时 x
发生了改变。由于只有弱符号的话会随机选择一个,因此也会带来类似的问题。
现代的链接器默认自动开启 -fno-common
选项,使得存在重名符号的时候(即使不是强符号重名)也会直接报错。我们可以手动添加 -fcommon
来允许弱符号重名。
而对于 C++,没有了强弱符号机制,而是有一个 ODR (One Definition Rule),全局符号一律不允许重名。如果在一个文件中想要引用另一个文件中的符号,则需要在当前文件的定义中添加 extern
修饰。编译器会将这个符号判定为属于 UNDEF
节中。
上述过程则会为每个 C 文件构建了一个符号表。
接着,链接器维护一个将要被合并的目标文件集合 ,一个未解析的符号集合 ,一个在前面输入文件中已经定义的符号集合 。
根据上述过程,我们在编译时,命令行上放置的文件的顺序非常重要,必须要使得前面未被解析的符号在后面的文件中存在定义。如:
gcc -static ./libvector.a main2.c
,处理 libvector.a
时, 是空的,这个库文件不会解决后面的 main2.c
的符号解析,所以会报错。所以我们最好是把静态库文件放在命令行的后面。
同时我们还需要注意静态库文件的依赖关系。比如 foo.c
调用 libx.a
和 libz.a
,而这两个库又调用 liby.a
,那么我们需要这么写:gcc foo.c libx.a libz.a liby.a
。
如果出现循环依赖,我们可以在命令行上重复添加文件,比如 libx.a
调用 liby.a
,而 liby.a
也调用了 libx.a
,则有:gcc foo.c libx.a liby.a libx.a
。当然另一个解决方法是把 libx.a
和 liby.a
合并成一个单独的静态库文件。
完成符号解析后,链接器就知道所有的符号引用对应于哪个符号定义。
链接器需要把多个可重定位目标文件中,名字相同的节合并,比如把各个文件的 .text
合并成一个 .text
。但是合并之后,一些全局符号的地址就会发生改变。编译器会提前知道哪些全局符号的地址暂时无法确定,于是在引用这些符号的地方,先不设置具体的地址,而是先留空,并记录这些位置,生成重定位条目,放到 .rel
相关的节上。文件合并之后,各个符号的地址就确定下来了,链接器会进行重定位,就是根据重定位条目,设置好具体的地址。
重定位条目的结构如下:
其中的 offset
表明需要重定位的地址。地址是相对于其所在节的偏移。
symbol
表明这个要被重定位的引用,是引用了哪个符号,存储的是在符号表中的下标。
addend
表明计算出重定位地址之后还需要进行的调整量。常用于重定位为 PC 相对地址时。
type
表明重定位类型。这里讲两种最基本的类型:
R_X86_64_PC32
:重定位为一个 PC 相对地址R_X86_64_32
:重定位为一个绝对的地址上述类型重定位的地址都是 32 位的,这是因为这两个类型是基于 x86-64 小型代码模型。这个模型假设可执行目标文件的大小小于 2GB,32 位的寻址就足够,GCC 默认使用这个模型。
重定位算法如下:
其先枚举了要重定位的节,然后枚举该节下的重定位条目。现在我们已经知道每个节的真实首地址了,用 ADDR(s)
表示。已经知道引用的符号所被定义的真实地址了,用 ADDR(r.symbol)
表示。
在编译时,链接器不将共享库中的代码和数据复制到可执行目标文件中,而是复制了一些重定位和符号信息,以便后续加载时链接共享库。
可执行目标文件在加载时,加载器注意到其包含了一个 .interp
节,这一节包含了动态链接器的路径名(动态链接器本身可视为一个共享库,在 Linux 中是 ld-linux.so
),然后加载器加载并调用动态链接器。
动态链接器会寻找程序中引用的共享库,将共享库所在的内存映射到当前程序的虚拟内存空间中,然后动态链接器此时重定位程序中对共享库的引用。
传统的动态链接机制有一些缺陷。首先这样会使得 .text
节可写,会有漏洞隐患。其次,这样的话,每个进程都需要独立的代码段的副本,而现代的操作系统,每个进程都共享一个代码段以节省内存。而且对于一个比较大的程序,加载时重定位所有引用的话也会严重拖慢程序的加载速度。
并且,如果是可执行文件调用共享库还好,如果是共享库之间进行调用的话,需要对共享库进行重定位,那么就失去了共享库的性质了。
现代系统有一个机制,可以生成位置无关代码,也就是无论代码被加载到哪里,都可以被正常执行,无需重定位代码段。现代编译器产生的共享库和可执行文件都是位置无关代码。
位置无关代码的原理是,同一个共享库/可执行文件中的数据段和代码段的相对距离是不变的,也就是代码段中可以 PC 相对地址来引用代码段。
编译器在数据段最开始的地方创建了一个叫做 GOT(全局偏移量表),这个表中每个条目都是 8 字节,表示一个地址。编译器会在 GOT 中给程序中的所有全局符号都建立一个条目,表示这个全局符号的绝对地址,初始时为空,等待重定位,GOT 会对应一个重定位表。这样,加载程序的时候,动态链接器无需重定位代码段,只需要重定位 GOT 表即可。每个可执行文件和共享库实例都有一个独立 GOT 表,也就是加载他们的时候只需要建立 GOT 表和重定位 GOT 表的代价。
使用 GOT 表就可以解决很多问题了。但是现代编译系统引入了 PLT 来支持动态绑定机制。比如一个像 libc.so
的库,可能会输出成百上千个函数,如果在加载时,将这些函数对应在 GOT 表中的项目全部重定位,会造成很大的性能开销,且可执行程序可能只需要使用到库中的一部分函数,这也就使得很多重定位是浪费的。
PLT 中每个条目都是一个 16 字节代码。 PLT 拥有可执行权限。GOT 和 PLT 联合使用时,GOT[0]
表明 .dynamic
节的地址,GOT[1]
表明重定位条目的地址,GOT[2]
表明动态链接器的入口地址,PLT[0]
将 GOT[1]
对应的地址压入栈中并调用动态链接器。
每个外部函数都会有一个 ID,并在 PLT 中有一个对应的条目。
当程序调用外部函数的时候,会先跳转到外部函数对应的 PLT 条目中,PLT 条目中的第一个执行是跳转指令,跳转到其对应的 GOT 条目中指向的地址。这个地址初始时又指向 PLT 条目的第二条执行,这个指令会将外部函数的 ID 压入栈中,然后跳到 PLT[0]
中,来调用动态链接器。
动态链接器根据压入栈中的外部函数 ID 和重定位表来确定外部函数真实的运行地址,并重写 GOT 表,然后再调用外部函数。
后续再次调用外部函数的时候,从 PLT 跳到 GOT 对应的条目时,这个条目已经指明了外部函数的地址,可以直接跳过去:
这也就实现了延迟绑定
库打桩可以让我们拦截对一个共享库的调用,反而执行自己的代码。
首先有一个我们自己的函数:
然后编译 gcc -DCOMPILETIME -c mymalloc.c
然后我们再编写一个自己的头文件,在这个头文件里通过宏来更改调用函数的名称:
编译:gcc -I. -o intc int.c mymalloc.o
其中 -I.
是打桩的关键。这个参数将会让编译器优先从当前目录下寻找头文件,所以编译器会使用我们的 malloc.h
而不是标准的 malloc.h
。
链接器支持使用 --wrap f
的选项进行链接时打桩。这个选项告诉链接器,把对符号 f
的引用全部解析为对 __wrap_f
(用于拦截),还把对 __real_f
的引用解析为对 f
的引用(用于调用真实函数),于是我们有:
编译:gcc -DLINKTIME -c mymalloc.c
,gcc -c int.c
链接:gcc -Wl,--wrap,malloc -Wl,--wrap,free -o intc int.o mymalloc.o
其中 -Wl,option
用于将 option
传递给链接器,并且 option
中的每个逗号都被替换成空格。
有一个全局变量 LD_PRELOAD
,程序在加载共享库时,动态链接器会先从 LD_PRELOAD
中寻找对应的共享库。我们把要拦截的函数写成一个共享库之后,把路径添加到 LD_PRELOAD
中就可以实现打桩。
ar
:创建静态库,插入、删除、列出、提取成员。strings
:列出一个目标文件中所有可打印的字符串。strip
:从目标文件中删除符号表信息。nm
:列出一个目标文件的符号表中定义的符号。size
:列出目标文件中节的名字和大小。readelf
:显示一个目标文件的完整结构,包括 ELF 头中编码的所有信息。包含 size
和 nm
的功能。objdump
:显示目标文件的所有信息,主要用于反编译 .text
的指令ldd
:列出可执行文件在运行时需要的共享库