GCC 链接过程中的【重定位】过程分析
The following article is from IOT物联网小镇 Author 道哥
目录
示例代码
sub.o 文件内容分析
段信息
符号表信息
main.o 文件分析
段信息
符号表信息
绝对寻址
相对寻址
重定位表信息
可执行程序 main
段信息
符号表信息
绝对地址重定位
相对地址重定位
总结
最近因为项目上的需要,利用动态链接库来实现一个插件系统,顺便就复习了一下关于Linux
中一些编译、链接相关的内容。
在链接的过程中,符号重定位是比较麻烦的事情,特别是在动态链接的过程中,因为需要考虑到很多不同的情况。
这篇文章作为第一篇,先来聊一聊静态链接中的重定位过程。
按照惯例,还是以一个简短的示例代码作为载体,看一看GCC
在链接的过程中,是如何根据目标文件(.o文件
)来进行重定位,生成最终的可执行文件的。
示例代码
示例代码很简单,一共有2
个源文件main.c
和 sub.c
。
在sub.c
中定义了一个全局变量和一个全局函数,然后在main.c
中使用这个全局变量和全局函数。代码如下:
sub.c
main.c
在一般的开发过程中,都是使用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
):
我们主要关心黄色的代码段和数据段就可以了,可以看出:
代码段(.text):地址Addr是 0x0000_0000(因为这是目标文件,不是可执行文件,所以不会安排地址),它在 sub.o 文件中的偏移量(Off)是 0x34,长度是 0x0C 字节;
数据段(.data):地址Addr是 0x0000_0000,它在 sub.o 文件中的偏移量(Off)是 0x40,长度是 0x04 字节;
简单算一下:sub.o
的开始部分是ELF
的 header
,通过 readelf -h sub.o
指令可以看出来header
部分是52
个字节(即:0x34
),如下:
因此可以得到:
代码段(.text)是紧接在 header 之后,长度是 0x0C 个字节,在文件中占据着 0x34 ~ 0x3F 这部分空间(0x3F = 0x34 + 0x0C - 1);
数据段(.data)是进阶在代码段之后,在文件中占据着 0x40 ~ 0x43 这部分空间;
符号表信息
下面再来说说符号表的事情。
简单来说,符号表就是一个文件中定义的所有符号、引用的外部符号(在其它文件中定义),包括:变量名、函数名、段名等等,都属于符号。
当然了,在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
):
关注上图中黄色矩形中的两个符号:SubData
和SubFunc
,很明显它们就是sub.c
中定义的两个符号:全局变量和全局函数。
对于SubData
符号来说:
Size=4: 长度是 4 个字节;
Type=OBJECT:说明这是一个数据对象;
Bind=GLOBAL:说明这个符号是全局可见的,也就是在其他文件中可以使用;
Ndx=2:说明这个符号是属于第 2 个 段中,就是数据段(.data);
同样的道理,对于SubFunc
符号来说:
Size=12: 长度是 12 个字节;
Type=FUNC:说明这是一个函数;
Bind=GLOBAL:说明这个符号是全局可见的,也就是在其他文件中可以调用;
Ndx=1:说明这个符号是属于第 1 个 段中,就是代码段(.text);
main.o 文件分析
按照上面的步骤,把main.o
中的这几个信息也查看一下。
段信息
指令:readelf -S main.o
可以看出:
代码段(.text):地址Addr是 0x0000_0000(因为这是目标文件,不是可执行文件,所以不会安排地址),它在 sub.o 文件中的偏移量(Off)是 0x34,长度是 0x32 字节;
数据段(.data):地址Addr是 0x0000_0000,它在 sub.o 文件中的偏移量(Off)是 0x66,长度是 0 个字节,因为它没有定义变量;
在文件中的布局如下所示:
符号表信息
指令:readelf -s main.o
重点看一下黄色矩形中的3
个符号。
main
符号:
Size=50: 长度是 30 个字节,也就对应着代码段的长度 0x32 ;
Type=FUNC:说明这是一个函数;
Bind=GLOBAL:说明这个符号是全局可见的,也就是在其他文件中可以调用;
Ndx=1:说明这个符号是属于第 1 个 段中,就是代码段(.text);
下面两个符号SubData
和SubFunc
,他们的Ndx
都是UND
,表示这2
个符号被main.o
使用,但是定义在其他文件中。
我们知道,当链接成可执行文件时,所有的符号都必须有确定的地址(虚拟地址),所以链接器就需要在链接的过程中找到这2
个符号在可执行文件中的地址,然后把这两个地址填写到main
的代码段中。
可以先来看一下main.o
的反汇编代码:
指令:objdump -d main.o
黄色矩形框中是把数值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
地址处)。
实际地址 = 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
重定位表就表示: 该目标文件中,有哪些符号需要在链接的时候进行地址重定位。
从图中黄色矩形框可以看出: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
):
红色矩形框是代码段(.text),链接器把它放在虚拟地址 0x0804_8094;
黄色矩形框是数据段(.data),链接器把它放在虚拟地址 0x0804_9138;
从段信息中可以看到main
文件中代码段和数据段的布局如下:
可执行程序main
是由main.o
和sub.o
这两个目标文件组成的,所以main
中的代码段是由main.o
中的代码段和sub.o
中的代码段组合得到的;对于数据段,由于 main.o
中数据段的长度为0
,所以main
中的数据段就是sub.o
中的数据段(长度为4
),如下图所示:
符号表信息
指令:readelf -s main
黄色矩形框中的SubData
属于数据段,长度是 4 个字节,虚拟地址是 0x0804_9138
,与段信息中的值是一致的。
红色矩形框中的SubFunc
属于代码段,长度是 12 个字节,虚拟地址是 0x0804_80c6
。
因为main
中的代码段包括 2 部分内容:
main.o 中的代码段 main 函数;
sub.o 中的代码段 SubFunc 函数;
所以,可执行文件main
中的代码段,先存放的是main
函数,虚拟地址:0x0804_8094
,长度是0x32
(50 个字节);
紧接着存放的是SubFunc
函数,虚拟地址:0x0804_80c6
,长度是0x0c
(12 个字节)。
如下图所示:
链接器在第一遍扫描所有的目标文件时,把所有相同类型的段进行合并,安排到相应的虚拟地址,如上图所示。
所谓的安排虚拟地址,就是指定这块内容被加载到虚拟内存的什么地方。当可执行文件被执行的时候,加载器就把每一块内容复制到虚拟内存相应的地址处。
同时,链接器还会建立一个全局符号表,把每一个目标文件中的符号信息都复制到这个全局符号表中。
对于我们的实例程序,全局符号表中包括:
SubData: 属于 sub.o 文件,数据段,安排在虚拟地址 0x0804_9138;
SubFunc: 属于 sub.o 文件,代码段,安排在虚拟地址 0x0804_80c6;
其它符号信息...
绝对地址重定位
然后,链接器第二遍扫描所有的目标文件,检查哪些目标文件中的符号需要进行重定位。
对于我们的示例程序,首先来看一下main.o
中使用的外部变量SubData
的重定位。
从main.o
的重定位表中可知:SubData
符号需要进行重定位,需要把这个符号在执行时刻的绝对寻址(虚拟地址),写入到 main
可执行文件中代码段中偏移0x12
字节处。
也就是说需要解决 2 个问题
:
需要计算出在执行文件 main 中的什么位置来填写绝对地址(虚拟地址);
填写的绝对地址(虚拟地址)的值是多少;
首先来解决第一个问题。
从可执行文件的段表中可以看出:目标文件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 个字节;
注意:显示的是小端格式。
相对地址重定位
从上面描述的重定位表中看出:main.o
代码段中的SubFunc
符号也需要重定位,而且是相对寻址。
链接器需要把SunFunc
符号在执行时刻的绝对地址(虚拟地址),减去call
指令的下一条指令(PC 寄存器
) 之后的差值,填写到执行文件main
中的main.o
代码段偏移0x1b
的地方。
同样的道理,需要解决 2 个问题
:
需要计算出在执行文件 main 中的什么位置来填写相对地址;
填写的相对地址的值是多少;
首先来解决第一个问题。
从main.o
的重定位表中可知:需要修正的位置距离main.o
中代码段的偏移量是0x1b
字节。
可执行文件main
中代码段的开始地址距离文件开始的偏移量是0x94
,再加上偏移量0x1b
就是0xaf
。
也就是说:需要在main
文件中0xaf
偏移处填入一个相对地址,这个相对地址的值就是SubFunc
在执行时刻的绝对地址(虚拟地址)、距离call
指令的下一条指令的偏移量。
再来解决第二个问题。
链接器在第一遍扫描的时候,已经把sub.o
中的符号SubFunc
记录到全局符号表中了,知道SubFunc
函数被安排在虚拟地址0x0804_80c6
的地方。
但是不能把这个绝对地址直接填写进去,因为 call
指令需要的是相对地址(偏移地址)。
链接器把main
代码段起始位置安排在 0x0804_8094
,那么偏移0x1b
处的虚拟地址就是:0x0804_80af
,然后还需要再跨过4
个字节(因为执行call
指令时,PC
的值自动增加到下一条指令的开始地址)才是此刻PC
寄存器的值,即:0x0804_80b3
,如下图中红色部分:
两个虚拟地址都知道了,计算一下差值就可以了:0x0804_80c6 - 0x0804_80b3 = 0x13
。
也就是说:在可执行文件main
中偏移为0xaf
的地方,填入相对地址0x0000_0013
就完成了SubFunc
符号的重定位。
还是用od
指令来读取main
文件的内容来验证一下:
指令:od -Ax -t x1 -j 175 -N 4 main
总结
经过以上两个重定位操作,main.c
中使用的两个外部符号就解决了地址重定位问题。
再来看一下可执行文件main
的反汇编代码:
从黄色和红色的矩形框可以看出,二进制指令中的地址值与上面的分析是一致的。
以上就是静态链接过程中地址重定位的基本过程,与动态链接相比,静态链接还是相对简单很多。
- EOF -
关注『CPP开发者』
看精选C/C++技术文章
点赞和在看就是最大的支持❤️