持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第7天,点击查看活动详情
这一系列是《程序员的自我修养》的阅读笔记:
程序员的自我修养之静态链接
前言
当我们有两个目标文件时,如何将它们链接起来形成一个可执行文件?这个过程中发生了什么?这基本上就是链接的核心内容:静态链接。
链接过程分两步:
第一步,空间与地址分配。
- 获得各个段的长度、属性和位置并合并。
- 创建全局符号表。
第二步,符号解析与重定位。
- 使用上面第一步中收集到的所有信息,读取输入文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等。事实上第二步是链接过程的核心,特别是重定位过程。
我们将使用下面这两个源代码文件“a.c”和“b.c”作为例子展开分析。
假设我们的程序只有这两个模块“a.c”和“b.c”。首先我们使用gcc将“a.c”和“b.c”分别编译成目标文件“a.o”和“b.o”:经过编译以后我们就得到了“a.o”和“b.o”这两个目标文件。
从代码中可以看到,“b.c”总共定义了两个全局符号,一个是变量“shared”,另外一个是函数“swap”;“a.c”里面定义了一个全局符号就是“main”。模块“a.c”里面引用到了“b.c”里面的“swap”和“shared”。我们接下来要做的就是把“a.o”和“b.o”这两个目标文件链接在一起合并最终形成一个可执行文件“ab”。
空间与地址分配
我们在前面详细分析了ELF文件的格式,我们知道,可执行文件中的代码段和数据段都是由输入的目标文件中合并而来的。那么我们链接过程就很明显产生了第一个问题:对于多个输入目标文件,链接器如何将它们的各个段合并到输出文件?或者说,如何将输出文件中的空间如何分配给输入文件?
- 按序叠加
一个最简单的方案就是将输入的目标文件按照次序叠加起来。但是这样会造成一个问题,在有很多输入文件的情况下,输出文件将会有很多零散的段。如果每个目标文件都分别有.text段、.data段和.bss段,那最后的输出文件将会有成百上千个零散的段。这种做法非常浪费空间,因为每个段都须要有一定的地址和空间对齐要求。
- 相似段合并
一个更实际的方法是将相同性质的段合并到一起,比如将所有输入文件的“.text”合并到输出文件的“.text”段,接着是“.data”段、“.bss”段等。
我们使用ld链接器将“a.o”和“b.o”链接起来:
$ ld a.o b.o -e main -o ab
-e main 表示将main函数作为程序入口,ld链接器默认的程序入口为_start。-o ab 表示链接输出文件名为ab,默认为a.out。
让我们使用objdump来查看链接前后地址的分配情况。
$ objdump -h a.o
$ objdump -h b.o
$ objdump -h ab
VMA表示Virtual Memory Address,即虚拟地址。
在链接之前,目标文件中的所有段的VMA都是0,因为虚拟空间还没有被分配,所以它们默认都为0。等到链接之后,可执行文件“ab”中的各个段都被分配到了相应的虚拟地址。
当空间分配完成之后,链接器开始计算各个符号的虚拟地址。因为各个符号在段内的相对位置是固定的,所以这时候其实“main”、“shared”和“swap”的地址也已经是确定的了,只不过链接器须要给每个符号加上一个偏移量,使它们能够调整到正确的虚拟地址。
比如我们假设“a.o”中的“main”函数相对于“a.o”的“.text”段的偏移是X,但是经过链接合并以后,“a.o”的“.text”段位于虚拟地址 0x00000000004000e8,那么“main”的地址应该是0x00000000004000e8 + X。
查看a.o的符号表:
可以看到,“main”位于“a.o”的“.text”段的最开始,也就是偏移为0,所以“main”这个符号在最终的输出文件中的地址应该是 0x00000000004000e8 + 0。
查看ab的符号表,可以看到确实如此。
符号解析与重定位
在完成空间和地址的分配步骤以后,链接器就进入了符号解析与重定位的步骤,这也是静态链接的核心内容。
- 重定位
首先让我们来看看“a.o”里面是怎么使用“shared”变量和“swap”函数,编译器在将“a.c”编译成指令时,它如何访问“shared”变量?如何调用“swap”函数?
使用objdump的“-d”参数可以看到“a.o”的代码段反汇编结果。
$ objdump -d a.o
我们可以很清楚地看到“a.o”的反汇编结果中,“a.o”定义了一个函数main。这个函数占用0x2b个字节,共12条指令。对于“shared”的引用是一条“mov”指令,“shared”的地址部分为“0x00000000”。对于“swap”的引用是“callq”指令,跟前面“shared”一样,地址部分也是一个临时的假地址“0x00000000”,因为在编译的时候,编译器并不知道“swap”的真正地址。
链接器在完成地址和空间分配之后就已经可以确定所有符号的虚拟地址了,那么就可以根据符号的地址对每个需要重定位的指令进行位置修正。我们用objdump来反汇编输出程序“ab”的代码段,可以看到main函数的两个重定位入口都已经被修正到正确的位置:
经过修正以后,“shared”和“swap”的地址分别为0x601000和0x000007(小端字节序)。shared的地址查看上面的符号表确实是0x601000,swap的地址应该是0x400114,为什么指令的地址写的是0x000007,这个和指令修正方式有关,待后续理解。
- 重定位表
链接器是怎么知道哪些指令是要被调整的呢?
我们之前提到过重定位表。在ELF文件中,有一个叫重定位表(Relocation Table)的结构专门用来保存这些与重定位相关的信息。
我们使用objdump来查看a.o的重定位表。
$ objdump -r a.o
我们可以看到“a.o”里面有两个重定位入口。重定位入口的偏移(Offset)表示该入口在要被重定位的段中的位置。对照前面的反汇编结果可以知道,这里的0x14和0x21分别就是代码段中“mov”指令和“callq”指令的地址部分。
总结
目标文件在被链接成最终可执行文件时,输入目标文件中的各个段以相同类型的段合并的方式合并到输出文件中,链接器为它们分配在输出文件中的空间和地址。一旦输入段的最终地址被确定,接下来就可以进行符号的解析与重定位,链接器会把各个输入目标文件中对于外部符号的引用进行解析,把每个段中须重定位的指令和数据进行“修补”,使它们都指向正确的位置。