5

GCC 链接过程中的【重定位】过程分析

 2 years ago
source link: https://os.51cto.com/article/703290.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
GCC 链接过程中的【重定位】过程分析-51CTO.COM
GCC 链接过程中的【重定位】过程分析
作者:道哥 2022-03-07 07:57:04
所谓的安排虚拟地址,就是指定这块内容被加载到虚拟内存的什么地方。当可执行文件被执行的时候,加载器就把每一块内容复制到虚拟内存相应的地址处。

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

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

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

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

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

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

sub.c

23d743785e4a515dbfc5975faa4f4142b5e5e8.png

main.c

21070b844f22a21c2322221ca2a1824b27ac8c.png

在一般的开发过程中,都是使用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格式文件的一些工具指令就非常有帮助。

sub.o 文件内容分析

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

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

a8bb86c292c4b54b154756d11d8e196af9eef0.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),如下:

7641f8310cbe21af484414236261b075a9b5c4.png

因此可以得到:

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

69b9fdb89c6a037d3ba979e2c7c2ec8eb1e4e0.png

符号表信息

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

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

当然了,在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):

054dccb33994f95817c9805665137a1e2fa7bf.png

关注上图中黄色矩形中的两个符号: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

62b6971923703665767778137d1669246cdb4c.png

可以看出:

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

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

b260081447195e95b3e7580eebb92fbe4660cc.png

符号表信息

指令:readelf -s main.o

410280575ae47d2ff7e221b22bc5ddc7bafa98.png

重点看一下黄色矩形中的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

52225a657091c380a6d82484fcb6fe989da96c.png

黄色矩形框中是把数值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地址处)。

93560df321f879cfb837982ce763d57d30fdd2.png

实际地址 = 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

f21d1d4603258ca837a019169dd1f1c9107b9d.png

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

从图中黄色矩形框可以看出: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):

c472fd107026c8b7b3b017ef8299b96f6c2d96.png

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

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

4240195121fdb08c8d7531dd4fbdb668012ddf.png

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

d8627e5302c778d2cbd629e944051029bcaae6.png

符号表信息

指令:readelf -s main

b72d46c38ed52a6d4af1313d0d6eb35f452fb4.png

黄色矩形框中的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 个字节)。

如下图所示:

696a61c251f89ceb413700d0d4adde99d0819b.png

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

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

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

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

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

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

其它符号信息...

绝对地址重定位

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

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

d2492c613dd80cf4261432df3027d3b8a79dc6.png

从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 个字节;

681ec8529580f88d528140a4b21aadbcb10619.gif019105c47ab539bb523131fd8ffe96a3511809.png

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

相对地址重定位

从上面描述的重定位表中看出: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指令的下一条指令的偏移量。

f73c57659e94ccdce9436717fb94d4edb3642f.png

再来解决第二个问题。

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

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

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

653933a27e2c4c3d88e73879f80ee2ed317312.png

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

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

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

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

523a6d834e674104f55168aaa5d36be7a52f5c.png

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

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

31e0969378392cf9b1175809e1e588f8137e71.png

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

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK