目录
-
示例代码
-
sub.o 文件内容分析
-
main.o 文件分析
-
段信息
-
符号表信息
-
绝对寻址
-
相对寻址
-
重定位表信息
-
可执行程序 main
-
段信息
-
符号表信息
-
绝对地址重定位
-
相对地址重定位
-
总结
别人的经验,我们的阶梯!
最近因为项目上的需要,利用动态链接库来实现了一个插件系统,顺便就复习了一下关于 Linux 中一些编译、链接的内容。
在链接过程中,符号重定位是比较麻烦的事情,特别是在动态链接的过程中,因为需要考虑到很多不同的情况。
这篇文章作为第一篇,先来聊一聊静态链接中的重定位过程。
按照惯例,还是以一个简短的示例代码作为载体,看一看 GCC 在链接的过程中,是如何根据目标文件(.o文件)来进行重定位,生成最终的可执行文件的。
示例代码
示例代码很简单,一共有两个源文件:main.c 和 sub.c。
在 sub.c 中定义一个全局变量和一个全局函数,然后在 main.c 中使用这个全局变量和全局函数。代码如下:
sub.c
main.c
在一般的开发过程中,都是使用 GCC 工具,直接把这两个源文件编译得到可执行文件。
但是,为了探究编译、链接过程中的一些内部情况,我们需要把编译、链接的过程拆开,从中间过程中产生的目标文件(.o 文件)中,来查看一些情况。
先把这两个源文件编译成目标文件 sub.o 和 main.o:
这样就得到了两个目标文件,先来初步看一下这两个目标文件中的一些信息。
以上这两个文件的编译过程都是独立的,虽然 main.o 中使用了两个符号(全局函数和全局变量),但是此时 main.o 并不知道这两个符号是在哪个文件中定义的。
当链接器把所有的 .o文件链接成可执行文件的过程中,才能确定这两个符号是在哪里。
在 Linux 系统中,目标文件(.o)和可执行文件都是 ELF 格式,因此如何查看ELF格式文件的一些工具指令就非常有帮助。
很久之前总结过这篇文章:《Linux系统中编译、链接的基石-ELF文件:扒开它的层层外衣,从字节码的粒度来探索》,里面详细总结了ELF文件的内部结构,以及一些相关的工具。(Linux系统中编译、链接的基石-ELF文件:扒开它的层层外衣,从字节码的粒度来探索_远近长安的博客-CSDN博客)
sub.o 文件内容分析
段信息
首先来简单瞄一眼 sub.o 中的一些信息。
sub.o 中的段信息如下(指令:readelf -S sub.o):
我们主要关心框里面的代码段和数据段就可以了,可以看出:
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),如下:
因此可以得到:
1、代码段(.text)是紧接在 header 之后,长度是 0x0C 个字节,在文件中占据着 0x34 ~ 0x3F 这部分空间(0x3F = 0x34 + 0x0C - 1);
2、数据段(.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 符号来说:
1、Size = 4:长度是 4 个字节;
2、Type = OBJECT:说明这是一个数据对象;
3、Bind = CLOBAL:说明这个符号是全局可见的,也就是在其他文件中也可以使用;
4、Ndx = 2:说明这个符号是属于第2个段中,也就是数据段(.data);
同样的道理,对于 SubFunc 符号来说:
1、Size = 12:长度是 12 个字节;
2、Type = FUNC:说明这是一个函数;
3、Bind = CLOBAL:说明这个符号是全局可见的,也就是在其他文件中也可以调用;
4、Ndx = 1:说明这个符号是属于第1个段中,也就是代码段(.text);
main.o 文件分析
按照上面的步骤,把 main.o 也分析一下。
段信息
指令:readelf -S main.o
可以看出:
1、代码段(.text):地址Addr是0x0000 0000(因为这是目标文件,不是可执行文件,所以不会安排地址),它在 main.o 文件中的偏移(Off)是0x34,长度是 0x32 字节;
2、数据段(.data):地址Addr是0x0000 0000,它在 main.o 文件中的偏移量(Off)是0x66,长度是 0 字节,因为它没有定义变量;
在文件中的布局如下所示:
符号表信息
指令:readelf -s main.o
重点看一下红框内的三个符号:
main 符号:
1、Size = 50:长度是 50 个字节,对应着代码段的长度 0x32;
2、Type = FUNC:说明这是一个函数;
3、Bind = CLOBAL:说明这个符号是全局可见的,也就是在其他文件中也可以调用;
4、Ndx = 1:说明这个符号是属于第1个段中,也就是代码段(.text);
下面两个符号 SubData 和 SubFunc,他们的 Ndx 都是 UND,表示这两个符号被 main.o 使用,但是定义在其他文件中。
我们知道,当链接成可执行文件时,所有的符号都必须有确定的地址(虚拟地址),所以链接器就需要在链接的过程中找到这两个符号在可执行文件中的地址,然后把这两个地址填写到 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?
按照小端格式计算:0xff ff ff fc,十进制对应 -4,为什么设置成 -4 呢?
对于 x86 平台的 ELF 格式来说,对地址进行修正的方式有两种:绝对寻址和相对寻址。
绝对寻址
对于 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)。
(PS:注意是 xxxx_xxxx 所在的地址)
问题来了,链接器怎么知道 main.o 中代码段的这两个地方,需要进行地址修正?
这就是下面介绍的重定位表的作用了!
重定位表信息
指令:objdump -r main.o
重定位表就表示:该目标文件中,有哪些符号需要在链接的时候进行地址重定位。
从上图框中可以看到:main.o 中代码段(.text)的 SubData 和 SubFunc 这两个符号都需要链接器对它进行重定位。
TYPE列:R_386_32 表示绝对寻址,R_386_PC32 表示相对寻址;
OFFSET列:表示需要重定位的符号在 main.o 文件代码段中的偏移位置。
刚才已经看了 main.o 的反汇编代码,可以看到偏移 0x12 和 0x1b 的地方,就是需要进行地址重定位的两个符号。
可执行程序 main
有了两个目标文件:main.o 和 sub.o,就可以链接得到可执行文件了:
ld -m elf_i386 main.o sub.o -e main -o main
段信息
使用 readelf 工具来看一下 main 可执行文件中的段信息(指令:readelf -S main):
1、红色矩形框是代码段(.text),链接器把它放在虚拟地址 0x0804 8094;
2、黄色矩形框是数据段(.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 中的代码段包括两部分内容:
1、main.o 中的代码段 main 函数;
2、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 字节的位置。
也就是说,需要解决两个问题:
- 需要计算出在执行文件 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
的反汇编代码:
指令:objdump -d main.o
从黄色和红色的矩形框可以看出,二进制指令中的地址值与上面的分析是一致的。
以上就是静态链接过程中地址重定位的基本过程,与动态链接相比,静态链接还是相对简单很多。
以后有机会的话,我们再继续聊一下动态链接中的一些操作,谢谢!