文章目录


别人的经验,我们的阶梯!

最近因为项目上的需要,利用动态链接库来实现一个插件系统,顺便就复习了一下关于​​Linux​​中一些编译、链接相关的内容。

在链接的过程中,符号重定位是比较麻烦的事情,特别是在动态链接的过程中,因为需要考虑到很多不同的情况。

这篇文章作为第一篇,先来聊一聊静态链接中的重定位过程。

按照惯例,还是以一个简短的示例代码作为载体,看一看​​GCC​​​在链接的过程中,是如何根据目标文件(​​.o文件​​)来进行重定位,生成最终的可执行文件的。

示例代码

示例代码很简单,一共有​​2​​​个源文件​​main.c​​​和​​sub.c​​。

在​​sub.c​​​中定义了一个全局变量和一个全局函数,然后在​​main.c​​中使用这个全局变量和全局函数。代码如下:

​sub.c​

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_重定位

​main.c​

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_重定位_02

在一般的开发过程中,都是使用​​GCC​​​工具,直接把这​​2​​个源文件编译得到可执行文件。

但是,为了探究编译、链接过程中的一些内部情况,我们需要把编译、链接的过程拆开,从中间过程中产生的目标文件(​​.o 文件​​)中,来查看一些详细信息。

先把这​​2​​​个源文件编译成目标文件​​sub.o​​​和​​main.o​​:

$ gcc -m32 -c sub.c
$ gcc -m32 -c main.c

这样就得到了两个目标文件,先来初步看一下这​​2​​个目标文件中的一些信息。

以上这两个编译过程是各自独立的,虽然​​main.o​​​中使用了两个符号(全局变量和全局函数),但是此时​​main.o​​​并不知道这​​2​​个符号是在哪个文件中定义的。

当链接器把所有的​​.o​​​文件链接成可执行文件的过程中,才能确定这​​2​​个符号是在哪里。

在​​Linux​​​系统中,目标文件(.o) 和可执行文件都是​​ELF​​​格式的,因此如何查看​​ELF​​格式文件的一些工具指令就非常有帮助。

很久之前总结过这篇文章:​​《Linux系统中编译、链接的基石-ELF文件:扒开它的层层外衣,从字节码的粒度来探索》​​​,里面详细总结了​​ELF​​文件的内部结构,以及一些相关的工具。

sub.o 文件内容分析

段信息

首先来简单瞄一眼一下​​sub.o​​中的一些信息。

​sub.o​​​中的段信息如下(指令:​​$ readelf -S sub.o​​):

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HvCXpopP-1647519981261)(http://iottown.sewain100.cn/iot0305_sub_seg_readelf.png)]

我们主要关心黄色的代码段和数据段就可以了,可以看出:

  1. 代码段(.text):地址Addr是 0x0000_0000(因为这是目标文件,不是可执行文件,所以不会安排地址),它在 sub.o 文件中的偏移量(Off)是 0x34,长度是 0x0C 字节;
  2. 数据段(.data):地址Addr是 0x0000_0000,它在 sub.o 文件中的偏移量(Off)是 0x40,长度是 0x04 字节;

简单算一下:​​sub.o​​​的开始部分是​​ELF​​​的​​header​​​,通过 ​​readelf -h sub.o​​​ 指令可以看出来​​header​​​部分是​​52​​​个字节(即:​​0x34​​),如下:

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_代码段_03

因此可以得到:

  1. 代码段(.text)是紧接在 header 之后,长度是 0x0C 个字节,在文件中占据着 0x34 ~ 0x3F 这部分空间(0x3F = 0x34 + 0x0C - 1);
  2. 数据段(.data)是进阶在代码段之后,在文件中占据着 0x40 ~ 0x43 这部分空间;

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_linux_04

符号表信息

下面再来说说符号表的事情。

简单来说,符号表就是一个文件中定义的所有符号、引用的外部符号(在其它文件中定义),包括:变量名、函数名、段名等等,都属于符号。

当然了,在​​ELF​​​文件中会详细的说明每一个符号的类型、大小、可见性等信息。如果对​​ELF​​文件格式有过了解的话,一定知道每一条符号信息,都是通过一个结构体来描述具体含义的,描述符号表的结构体如下:

// Symbol table entries for ELF32.
struct Elf32_Sym {
Elf32_Word st_name; // Symbol name (index into string table)
Elf32_Addr st_value; // Value or address associated with the symbol
Elf32_Word st_size; // Size of the symbol
unsigned char st_info; // Symbol's type and binding attributes
unsigned char st_other; // Must be zero; reserved
Elf32_Half st_shndx; // Which section (header table index) it's defined in
};

再来看一下​​sub.o​​​中的符号表,下面这张图(指令:​​readelf -s sub.o​​):

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_代码段_05

关注上图中黄色矩形中的两个符号:​​SubData​​​和​​SubFunc​​​,很明显它们就是​​sub.c​​中定义的两个符号:全局变量和全局函数。

对于​​SubData​​符号来说:

  1. Size=4: 长度是 4 个字节;
  2. Type=OBJECT:说明这是一个数据对象;
  3. Bind=GLOBAL:说明这个符号是全局可见的,也就是在其他文件中可以使用;
  4. Ndx=2:说明这个符号是属于第 2 个 段中,就是数据段(.data);

同样的道理,对于​​SubFunc​​符号来说:

  1. Size=12: 长度是 12 个字节;
  2. Type=FUNC:说明这是一个函数;
  3. Bind=GLOBAL:说明这个符号是全局可见的,也就是在其他文件中可以调用;
  4. Ndx=1:说明这个符号是属于第 1 个 段中,就是代码段(.text);

main.o 文件分析

按照上面的步骤,把​​main.o​​中的这几个信息也查看一下。

段信息

指令:​​readelf -S main.o​

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_重定位_06

可以看出:

  1. 代码段(.text):地址Addr是 0x0000_0000(因为这是目标文件,不是可执行文件,所以不会安排地址),它在 sub.o 文件中的偏移量(Off)是 0x34,长度是 0x32 字节;
  2. 数据段(.data):地址Addr是 0x0000_0000,它在 sub.o 文件中的偏移量(Off)是 0x66,长度是 0 个字节,因为它没有定义变量;

在文件中的布局如下所示:

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_linux_07

符号表信息

指令:​​readelf -s main.o​

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_运维_08

重点看一下黄色矩形中的​​3​​个符号。

​main​​符号:

  1. Size=50: 长度是 30 个字节,也就对应着代码段的长度 0x32 ;
  2. Type=FUNC:说明这是一个函数;
  3. Bind=GLOBAL:说明这个符号是全局可见的,也就是在其他文件中可以调用;
  4. Ndx=1:说明这个符号是属于第 1 个 段中,就是代码段(.text);

下面两个符号​​SubData​​​和​​SubFunc​​​,他们的​​Ndx​​​都是​​UND​​​,表示这​​2​​​个符号被​​main.o​​使用,但是定义在其他文件中。

我们知道,当链接成可执行文件时,所有的符号都必须有确定的地址(虚拟地址),所以链接器就需要在链接的过程中找到这​​2​​​个符号在可执行文件中的地址,然后把这两个地址填写到​​main​​的代码段中。

可以先来看一下​​main.o​​的反汇编代码:

指令: ​​objdump -d main.o​

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_linux_09

黄色矩形框中是把数值​​0​​​存储到​​eax​​​寄存器中,然后把​​eax​​ 压到栈中,然后红色矩形框调用了一个函数。

从示例代码(​​.c​​​文件)中可知:​​main​​​函数在调用​​sub.c​​​中的​​SubFunc​​​函数时,传入了变量​​SubData​​。

黄色部分的​​00 00 00 00​​​就应该是符号​​SubData​​​的地址,只不过此时​​main.o​​​还不知道这个符号的将会被链接器安排在什么地址,所以只能空着(以​​4​​​个字节的​​00​​来占位)。

红色部分的调用(​​call​​​)地址为什么是​​fc ff ff ff​​?

按照小端格式计算一下:​​0xfffffffc​​​,十进制的值就是​​-4​​​,为什么设置成​​-4​​呢?

对于​​x86​​​平台的​​ELF​​​格式来说,对地址进行修正的方式有​​2​​种:绝对寻址和相对寻址。

绝对寻址

对于​​SubData​​​符号就是绝对寻址,在链接成可执行文件时,这个地址在代码段中偏移​​0x12​​​个字节(黄色矩形框指令码偏移​​0x11​​​个字节,跨过一个字节的指令码​​a1​​​就是​​0x12​​​个字节),这个地方​​4​​​个字节的当前值是​​00 00 00 00​​。

链接器在修正的时候(就是链接成可执行文件的时候),会把这​​4​​​个字节修改为​​SubData​​变量在可执行文件中的实际地址(虚拟地址)。

相对寻址

红色矩形框中的函数调用(​​SubFunc​​​符号),就是相对寻址,就是说:当​​CPU​​​执行到这条指令的时候,把​​PC​​寄存中的值加上这个偏移地址,就是被调用对象的实际地址。

链接器在重定位的时候,目的就是计算出相对地址,然后替换掉​​fc ff ff ff​​这四个字节。

​PC​​​寄存器中的值是确定的,当​​call​​​这条指令被​​CPU​​​取到之后,​​PC​​​寄存器被自动增加,指向下一条指令的开始地址(偏移​​0x1f​​地址处)。

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_运维_10

​实际地址 = PC值 + xxxx_xxxx​​​,所以得到:​​xxxx_xxxx = 实际地址 - PC值​​。

而​​PC​​​值与 ​​xxxx_xxxx​​​ 所在的地址之间是有关系的:​​PC值 + (-4)​​​就得到 ​​xxxx_xxxx​​​ 所在的地址,因此在​​main.o​​​中预先在这个地址处填​​fc ff ff ff(-4)​​。

问题来了,链接器怎么知道​​main.o​​中代码段的这两个地方,需要进行地址修正?

这就是下面介绍的重定位表的作用了!

重定位表信息

指令:​​objdump -r main.o​

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_linux_11

重定位表就表示: 该目标文件中,有哪些符号需要在链接的时候进行地址重定位。

从图中黄色矩形框可以看出:​​main.o​​​中代码段(​​.text​​​)的 ​​SubData​​​和​​SubFunc​​这 2 个符号都需要链接器对它进行重定位。

​TYPE​​​列:​​R_386_32​​​表示绝对寻址, ​​R_386_PC32​​​表示相对寻址; ​​OFFSET​​​列表示需要重定位的符号在​​main.o​​文件代码段中的偏移位置。

刚才已经看了​​main.o​​​的反汇编代码,可以看到偏移​​0x12 和 0x1b​​的地方,就是需要进行地址重定位的两个符号。

可执行程序 main

有了 2 个目标文件:​​sub.o​​​和​​main.o​​,就可以链接得到可执行程序了:

​$ ld -m elf_i386 main.o sub.o -e main -o main​

段信息

使用​​readelf​​​工具来看一下​​main​​​可执行文件中的段信息(指令:​​readelf -S main​​):

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_linux_12

  1. 红色矩形框是代码段(.text),链接器把它放在虚拟地址 0x0804_8094;
  2. 黄色矩形框是数据段(.data),链接器把它放在虚拟地址 0x0804_9138;

从段信息中可以看到​​main​​文件中代码段和数据段的布局如下:

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_代码段_13

可执行程序​​main​​​是由​​main.o​​​和​​sub.o​​​这两个目标文件组成的,所以​​main​​​中的代码段是由​​main.o​​​中的代码段和​​sub.o​​​中的代码段组合得到的;对于数据段,由于 ​​main.o​​​中数据段的长度为​​0​​​,所以​​main​​​中的数据段就是​​sub.o​​​中的数据段(长度为​​4​​),如下图所示:

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_代码段_14

符号表信息

指令:​​readelf -s main​

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_代码段_15

黄色矩形框中的​​SubData​​​属于数据段,长度是 4 个字节,虚拟地址是​​0x0804_9138​​,与段信息中的值是一致的。

红色矩形框中的​​SubFunc​​​属于代码段,长度是 12 个字节,虚拟地址是​​0x0804_80c6​​。

因为​​main​​中的代码段包括 2 部分内容:

  1. main.o 中的代码段 main 函数;
  2. sub.o 中的代码段 SubFunc 函数;

所以,可执行文件​​main​​​中的代码段,先存放的是​​main​​​函数,虚拟地址:​​0x0804_8094​​​,长度是​​0x32​​(50 个字节);

紧接着存放的是​​SubFunc​​​函数,虚拟地址:​​0x0804_80c6​​​,长度是​​0x0c​​(12 个字节)。

如下图所示:

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_重定位_16

链接器在第一遍扫描所有的目标文件时,把所有相同类型的段进行合并,安排到相应的虚拟地址,如上图所示。

所谓的安排虚拟地址,就是指定这块内容被加载到虚拟内存的什么地方。当可执行文件被执行的时候,加载器就把每一块内容复制到虚拟内存相应的地址处。

同时,链接器还会建立一个全局符号表,把每一个目标文件中的符号信息都复制到这个全局符号表中。

对于我们的实例程序,全局符号表中包括:

SubData: 属于 sub.o 文件,数据段,安排在虚拟地址 0x0804_9138;

SubFunc: 属于 sub.o 文件,代码段,安排在虚拟地址 0x0804_80c6;

其它符号信息…

绝对地址重定位

然后,链接器第二遍扫描所有的目标文件,检查哪些目标文件中的符号需要进行重定位。

对于我们的示例程序,首先来看一下​​main.o​​​中使用的外部变量​​SubData​​的重定位。

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_linux_11

从​​main.o​​​的重定位表中可知:​​SubData​​​符号需要进行重定位,需要把这个符号在执行时刻的绝对寻址(虚拟地址),写入到​​main​​​可执行文件中代码段中偏移​​0x12​​字节处。

也就是说需要解决​​2 个问题​​:

  1. 需要计算出在执行文件 main 中的什么位置来填写绝对地址(虚拟地址);
  2. 填写的绝对地址(虚拟地址)的值是多少;

首先来解决第一个问题。

从可执行文件的段表中可以看出:目标文件​​main.o​​​和​​sub.o​​​中的代码段被存放到可执行文件​​main​​​中代码段的开始位置,先放​​main.o​​​代码段,再放​​sub.o​​代码段。

代码段的开始地址距离文件开始的偏移量是​​0x94​​​,再加上偏移量​​0x12​​​,结果就是​​0xa6​​。

也就是说:需要在​​main​​​文件中偏移​​0xa6​​​处填入​​SubData​​在执行时刻的绝对地址(虚拟地址)。

再来解决第二个问题。

链接器从全局符号表中发现:​​SubData​​​符号属于​​sub.o​​​文件,已经被安排在虚拟地址​​0x0804_9138​​​处,因此只需要把​​0x0804_9138​​​填写到可执行文件​​main​​​中偏移​​0xa6​​的地方。

我们来读取​​main​​文件,验证一下这个位置处的虚拟地址是否正确:

指令:​​od -Ax -t x1 -j 166 -N 4 main​

-Ax: 显示地址的时候,用十六进制来表示。如果使用 -Ad,意思就是用十进制来显示地址;

-t -x1: 显示字节码内容的时候,使用十六进制(x),每次显示一个字节(1);

-j 166: 跨过 166 个字节(十六进制 0xa6);

-N 4:只需要读取 4 个字节;

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_重定位_18

注意:显示的是小端格式。

相对地址重定位

从上面描述的重定位表中看出:​​main.o​​​代码段中的​​SubFunc​​符号也需要重定位,而且是相对寻址。

链接器需要把​​SunFunc​​​符号在执行时刻的绝对地址(虚拟地址),减去​​call​​​指令的下一条指令(​​PC 寄存器​​​) 之后的差值,填写到执行文件​​main​​​中的​​main.o​​​代码段偏移​​0x1b​​的地方。

同样的道理,需要解决​​2 个问题​​:

  1. 需要计算出在执行文件 main 中的什么位置来填写相对地址;
  2. 填写的相对地址的值是多少;

首先来解决第一个问题。

从​​main.o​​​的重定位表中可知:需要修正的位置距离​​main.o​​​中代码段的偏移量是​​0x1b​​字节。

可执行文件​​main​​​中代码段的开始地址距离文件开始的偏移量是​​0x94​​​,再加上偏移量​​0x1b​​​就是​​0xaf​​。

也就是说:需要在​​main​​​文件中​​0xaf​​​偏移处填入一个相对地址,这个相对地址的值就是​​SubFunc​​​在执行时刻的绝对地址(虚拟地址)、距离​​call​​指令的下一条指令的偏移量。

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_代码段_19

再来解决第二个问题。

链接器在第一遍扫描的时候,已经把​​sub.o​​​中的符号​​SubFunc​​​记录到全局符号表中了,知道​​SubFunc​​​函数被安排在虚拟地址​​0x0804_80c6​​的地方。

但是不能把这个绝对地址直接填写进去,因​​为 call​​指令需要的是相对地址(偏移地址)。

链接器把​​main​​​代码段起始位置安排在 ​​0x0804_8094​​​,那么偏移​​0x1b​​​处的虚拟地址就是:​​0x0804_80af​​​,然后还需要再跨过​​4​​​个字节(因为执行​​call​​​指令时,​​PC​​​的值自动增加到下一条指令的开始地址)才是此刻​​PC​​​寄存器的值,即:​​0x0804_80b3​​,如下图中红色部分:

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_代码段_20

两个虚拟地址都知道了,计算一下差值就可以了:​​0x0804_80c6 - 0x0804_80b3 = 0x13​​。

也就是说:在可执行文件​​main​​​中偏移为​​0xaf​​​的地方,填入相对地址​​0x0000_0013​​​就完成了​​SubFunc​​符号的重定位。

还是用​​od​​​指令来读取​​main​​文件的内容来验证一下:

指令:​​od -Ax -t x1 -j 175 -N 4 main​

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_重定位_21

总结

经过以上两个重定位操作,​​main.c​​中使用的两个外部符号就解决了地址重定位问题。

再来看一下可执行文件​​main​​的反汇编代码:

【图片+代码】:GCC 链接过程中的【重定位】过程分析.md_重定位_22

从黄色和红色的矩形框可以看出,二进制指令中的地址值与上面的分析是一致的。

以上就是静态链接过程中地址重定位的基本过程,与动态链接相比,静态链接还是相对简单很多。

以后有机会的话,我们再继续聊一下动态链接中的一些操作,谢谢!



------ End ------