菜鸟笔记
提升您的技术认知

C++编译知识笔记(三)——静态链接

目录

  • 一、空间与地址的分配
    • 1.1 相似段合并并确定装载地址
    • 1.2 确定符号地址
  • 二、符号解析与重定位
    • 2.1 符号解析
    • 2.2 重定位
  • 三、总结

编译过后我们会得到.o格式的目标文件,每个c或者cpp文件都会生成一个.o,也就是一个编译单元对应一个.o,而要生成一个可执行程序,则需要各个编译单元之间协调配合,各个编译单元之间存在着各种调用关系,比如跨编译单元访问变量或者调用函数,简单来说将各个.o的内容组织成一个可执行文件的过程就是链接,而静态链接之所以叫静态是因为是在编译期提前做好,相对的有动态链接,后面也会写文章介绍。现在的链接器普遍都是采用一种叫做两步链接的方法来进行静态链接,也就是整个链接过程分为
空间与地址的分配
符号解析与重定位两个阶段。上一篇文章我们已经了解了目标文件的内容,这篇就来看看编译器是如何组织目标文件进行链接的。

这里基于《程序员的自我修养》一书中的一个简单实例来看看链接器执行静态链接的过程,有以下两个c文件,也就是说有两个编译单元:

/*a.c*/
extern int shared;
int main() {
  
    int a = 100;
    swap(&a, &shared);
    return 0;
}
/*b.c*/
int shared = 1;
void swap(int *a,int *b) {
  
    *a ^= *b ^= *a ^= *b; 
}

执行gcc -c a.c b.c后得到a.o和b.o,ld a.o b.o -e main -o ab链接得到ab可执行文件。

一、空间与地址的分配

因为是要将多个目标文件合并到一起,自然首先就涉及到了空间和地址的分配,因为各个目标文件都有自己的段内容,这些内容首先是要保存到文件里,在执行的时候还需要加载到内存里,因此就涉及到了两类地址和空间的分配:
(1)可执行文件中的空间和地址分配,也就是各个目标文件的内容怎么在文件里组织保存。
(2)装载时用到的虚拟地址空间的分配,也就是各部分在运行时如何在内存里组织,现代主流操作系统都是依据虚拟地址空间对每个程序的内存进行管理的。

对于(1),比较简单和直接,将各个目标文件的相似段保存在一起汇总成一个文件即可:

比较复杂的是(2),因为程序运行的时候需要按照虚拟地址进行各部分的装载,而我们的代码在使用函数和变量的时候都是根据地址找到要用的对象,现在统一合并到了新文件里,各个符号的地址也自然会发生变化。因此我们一是要确定合并后的段装载地址,二是要知道各个符号在合并之后的地址,这样后面的符号解析和重定位才能够正确进行。我们关注的空间地址分配主要就是这部分。

1.1 相似段合并并确定装载地址

可以用objdump命令查看链接前后的地址分配情况:



可以看到,.text段在链接后的size恰好等于a.o和b.o对应段的size之和,0x2c+0x4b=0x77,而两个.o的所有段的vma(Virtual Memory Address)都是0,也就是没有分配虚拟地址空间,而可执行文件ab的各段除了.comment都分配到了用于装载的虚拟地址,.comment不需要装载到内存所以不需要。注意虚拟内存地址空间不是从0开始的,通常32位程序是从0x08048000开始,而64位程序则是从0x400000开始,所以上图中的401000也就不奇怪了。

这里贴一张自我修养里的总结图,书中用的是32位编译的,因此地址略有不同。

1.2 确定符号地址

确定了最终输出可执行文件的段的起始地址后,我们也就能确定各个符号的虚拟地址了,因为各个符号在段内的相对offset是固定的,因此我们可以通过段的start加上符号的offset来确定符号的虚拟地址,而且段内部的顺序是由ld命令中目标文件的顺序决定的,比如我们用的ld a.o b.o -e main -o ab会让最终的代码段里main在swap之前。上例中我们的自定义全局符号有三个,根据这种计算方式我们可以得到

符号 类型 虚拟地址
main .text段中函数 0x401000 + 0x0(a.o代码段内offset) = 0x401000
swap .text段中函数 0x401000 + 0x2c(排在前面的main的大小) + 0x0(b.o代码段内offset) = 0x40102c
shared .data段中变量 0x404000 + 0x0(b.o数据段内offset) = 0x404000

我们可以用nm命令验证下正确性:

这样,我们就能得到所有目标文件中的各个符号的地址,保存到一个全局符号表供符号解析与重定位阶段使用。

二、符号解析与重定位

完成空间地址分配后,链接器就要执行重定位,它也是静态链接的核心内容,指的是将编译单元独立编译过程中无法确定的一些地址换为真正的目标地址,也就是从全局符号表获取所需符号的地址,根据各种寻址方式进行地址的修正,而在这个过程中,需要进行符号解析(根据符号名获得符号的虚拟地址)。

2.1 符号解析

所谓的符号解析可以理解为找到要用的符号的目标地址,比如前面例子中的a.o用到了外部符号shared和swap,在重定位的过程中需要替换为真正的目标虚拟地址,链接器通过查找所有输入目标文件的符号表组成的全局符号表来找所要用的符号。

比如nm命令可以看到a.o的两个未定义符号:

这两个未定义符号都是因为用到了相应的外部符号有它的重定位项,重定位过程需要能在全局符号表中找到,否则就会报喜闻乐见的符号未定义错误。

2.2 重定位

我们先看下a.o的代码段,根据反汇编的指令我们知道红框圈出来的就是需要重定位的两条指令,分别对应“将shared的地址作为二号参数”和“调用swap函数”,右侧是要用到的目标地址,都是0,显然需要修正。

而在ab里,可以看到已经修正成了正确的地址。

因为是小端序,所以shared是0x00404000,和上面计算相符,比较明确,而call指令是以下一条指令为基准的相对调用,因此这里调用的是(下面mov的地址 + 0x07000000)= 0x00401025 + 0x00000007 = 0x40102c,也和我们上面计算的地址相符。

那么问题来了,链接器是如何知道代码段里这么多指令哪些是需要重定位的呢?这就要用到前一篇介绍ELF文件内容的文章里所讲过的重定位表了,a.o的重定位表如下:

可以看到代码段有两处需要重定位,正是上面我们人工圈出来的那两处。以shared为例,翻译成人话就是.text段的0x14的位置用到了shared的地址,需要重定位,重定位的类型是R_X86_64_32,这种类型是直接寻址,直接改为绝对地址就行。而swap用到的R_X86_64_PLT32则是按相对寻址的方式进行修正,老版gcc是R_X86_64_PC32,这里不多展开。

三、总结

本篇文章总结介绍了静态链接中的核心内容,实际上涉及到的细节有很多,有兴趣的可以去看看《程序员的自我修养》里的相关内容。