当前位置: 首页 > 科技观察

【搬迁】GCC链接过程中的过程分析

时间:2023-03-13 05:41:28 科技观察

最近由于项目需要,使用了一个动态链接库来实现一个插件系统。顺便复习了一些Linux中编译链接相关的内容。在链接的过程中,符号重定位是一件比较麻烦的事情,尤其是在动态链接的过程中,因为需要考虑很多不同的情况。这篇文章是第一篇,先说说静态链接中的重定位过程。和往常一样,还是以一段简短的示例代码为载体,看看GCC在链接过程中是如何根据目标文件(.o文件)重定位生成最终的可执行文件的。范例代码范例代码非常简单,有2个源文件main.c和sub.c。在sub.c中定义一个全局变量和一个全局函数,然后在main.c中使用这个全局变量和全局函数。代码如下:sub.cmain.c使用一般开发过程中的GCC工具,直接编译两个源文件,得到一个可执行文件。但是,为了探究编译链接过程中的一些内部情况,我们需要对编译链接过程进行拆解,从中间过程生成的目标文件(.o文件)中查看一些详细信息。首先将这两个源文件编译成目标文件sub.o和main.o:$gcc-m32-csub.c$gcc-m32-cmain.c这样就得到了两个目标文件。我们先来看看这2个目标文件中的一些信息。以上两个编译过程是独立的。虽然在main.o中使用了两个符号(全局变量和全局函数),但是main.o并不知道这两个符号是在哪个文件中定义的。当链接器将所有.o文件链接成一个可执行文件时,它可以确定这两个符号在哪里。在Linux系统中,目标文件(.o)和可执行文件都是ELF格式的,所以一些查看ELF格式文件的工具说明非常有帮助。sub.o文件内容分析段信息首先,我们简单的看一下sub.o中的一些信息。sub.o中的段信息如下(命令:$readelf-Ssub.o):我们主要关心黄色代码段和数据段。可以看出:代码段(.text):地址Addr为0x0000_0000(因为这是一个目标文件,不是可执行文件,所以不会安排地址),它在sub.o文件中的偏移量(Off)为0x34,长度为0x0C字节;数据段(.data):地址Addr为0x0000_0000,其在sub.o文件中的偏移量(Off)为0x40,长度为0x04字节;简单计算:sub.o开头的是ELF的header,readelf-hsub.o命令可以看出header部分是52字节(即:0x34),如下:因此,可以得到代码段(.text)紧跟在header之后,长度为0x0C字节,在文件中占据0x34~0x3F。部分空格(0x3F=0x34+0x0C-1);数据段(.data)在代码段之后,占用文件中0x40~0x43的空间;符号表信息下面说说符号表。简单来说,符号表就是一个文件中定义的所有符号,引用的外部符号(定义在其他文件中),包括:变量名、函数名、段名等,都属于符号。当然,每个符号的类型、大小、可见性等信息都会在ELF文件中详细描述。如果对ELF文件格式有所了解,就一定知道每一条符号信息都是用结构来描述具体含义的。描述符号表的结构如下://SymboltableentriesforELF32.structElf32_Sym{Elf32_Wordst_name;//符号名称(字符串表的索引)Elf32_Addrst_value;//与符号关联的值或地址Elf32_Wordst_size;//符号的大小unsignedcharst_info;//符号的类型和绑定属性unsignedcharst_other;//必须为零;保留Elf32_Halfst_shndx;//它定义在哪个部分(头表索引)};再来看看sub.o中的符号表,下图(命令:readelf-ssub.o):注意上图中间黄色矩形框内的两个符号:SubData和SubFunc,很明显它们是sub.c中定义的两个符号:全局变量和全局函数。对于SubData符号:Size=4:长度为4个字节;Type=OBJECT:表示这是一个数据对象;bind=GLOBAL:表示这个符号是全局可见的,即可以在其他文件中使用;Ndx=2:表示这个符号属于第二段,也就是数据段(.data);同理,对于SubFunc符号:Size=12:长度为12字节;Type=FUNC:这是一个函数;bind=GLOBAL:表示该符号全局可见,即可以在其他文件中调用;Ndx=1:表示该符号属于第一个段,即代码段(.text);main.o文件分析按照上面的步骤查看main.o中的信息。段信息指令:readelf-Smain.o可以看到:codesegment(.text):addressAddr为0x0000_0000(因为这是目标文件,不是可执行文件,所以不会安排地址),它在子o;文件中的偏移量(Off)为0x34,长度为0x32字节;数据段(.data):地址Addr为0x0000_0000,其在sub.o文件中的偏移量(Off)为0x66,长度为0字节,因为它没有定义变量;文件中的布局如下:符号表信息指令:readelf-smain.o关注黄色矩形中的3个符号。主符号:Size=50:长度为30个字节,对应代码段0x32的长度;Type=FUNC:表示这是一个函数;bind=GLOBAL:表示这个符号是全局可见的,即在其他文件中是可以调用的;Ndx=1:表示这个符号属于第一个段,即代码段(.text);下面两个符号SubData和SubFunc,它们的Ndx都是UND,说明这两个符号是被main.o使用的,但是定义在其他文件中。我们知道,在链接成一个可执行文件的时候,所有的符号都必须有一个确定的地址(虚地址),所以链接器在链接的过程中需要找到这两个符号在可执行文件中的地址,然后把这两个地址填写main的代码段。可以先看main.o的反汇编代码:指令:objdump-dmain.o黄色矩形框将值0存入eax寄存器,然后将eax入栈,然后红色矩形框调用一个函数.从示例代码(.c文件)可以看出:当main函数调用sub.c中的SubFunc函数时,传入了变量SubData,黄色部分的00000000应该是符号的地址SubData,但是此时main.o不知道链接器会把这个符号安排在什么地址,所以只能留空(在4字节00处)。为什么红色部分的调用地址是fcffffff?按照little-endian格式计算:0xffffffffc,十进制值为-4,为什么设置为-4?对于x86平台的ELF格式,地址的修正有两种方式:绝对寻址和相对寻址。绝对寻址是SubData符号的绝对寻址。当链接成一个可执行文件时,这个地址在代码段中偏移0x12字节(黄色矩形框的指令代码偏移0x11字节,跨越一个字节指令代码a1为0x12字节),而当前值这个地方的4个字节是00000000,链接器在纠错的时候(也就是链接成一个可执行文件的时候),会把这4个字节修改为SubData变量在可执行文件中的实际地址(虚拟地址).相对寻址红色矩形框内的函数调用(SubFunc符号)是相对寻址,也就是说:CPU执行这条指令时,将偏移地址加上PC寄存器中的值,即为被调用对象的实际地址。链接器重定位时,目的是计算出相对地址,然后将四个字节fcffffff替换掉。PC寄存器中的值是确定的。当调用指令被CPU取出时,PC寄存器自动增加指向下一条指令的起始地址(偏移0x1f地址)。实际地址=PC值+xxxx_xxxx,所以得到:xxxx_xxxx=实际地址-PC值。PC值和xxxx_xxxx所在地址有一个关系:PC值+(-4)会得到xxxx_xxxx所在地址,所以在main.o中这个地址填写fcffffff(-4)提前。问题来了,链接器怎么知道main.o中代码段的这两个地方需要改正呢?这就是下面介绍的重定位表的作用!重定位表信息指令:objdump-rmain.oheavy定位表表示:目标文件中哪些符号在链接时需要重定位。从图中黄色矩形可以看出,main.o中代码段(.text)的两个符号SubData和SubFunc需要链接器重定位。TYPE栏:R_386_32表示绝对寻址,R_386_PC32表示相对寻址;OFFSET列表示需要重定位的符号在main.o文件的代码段中的偏移位置。刚才看了main.o的反汇编代码,可以看到偏移量0x12和0x1b是需要重定位的两个符号。可执行程序main有两个目标文件:sub.o和main.o,可执行程序通过readelf工具链接获得:$ld-melf_i386main.osub.o-emain-omain段信息查看主执行文件(命令:readelf-Smain)中的段信息:红色矩形框为代码段(.text),链接器将其放置在虚拟地址0x0804_8094;黄色矩形是数据段(.data),链接器把它放在虚拟地址0x0804_9138;从段信息我们可以看出main文件中代码段和数据段的布局如下:可执行程序main是由两个目标文件main.o和sub.o组成的,所以代码段inmain是由main.o中的代码段和sub.o中的代码段合并得到的;对于数据段,由于main.o中的数据段长度为0,所以main.o中的数据段就是sub.o中的数据段(长度为4),如下图:符号表信息指令:readelf-smain黄色矩形框内的SubData属于数据段,长度为4字节,虚拟地址为0x0804_9138,与段信息中的值一致。红色矩形中的SubFunc属于代码段,长度为12字节,虚拟地址为0x0804_80c6。因为main中的代码段包括两部分: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符号需要重定位,需要在代码中写入这个符号在执行时的绝对寻址(虚拟地址)到偏移量0x12字节主要可执行文件的片段。地方。也就是说,需要解决两个问题:需要计算在执行文件main中在哪里填写绝对地址(虚拟地址);填充的绝对地址(虚拟地址)的值是多少;首先,解决第一个问题。从可执行文件的段表可以看出,目标文件main.o和sub.o中的代码段存放在可执行文件main中代码段的开头,main.o代码段为先放,然后是sub.o代码段。代码段的起始地址在文件开头偏移量0x94处,加上偏移量0x12,结果为0xa6。也就是说:需要在主文件中偏移0xa6处填写SubData在执行时的绝对地址(虚拟地址)。我们来解决第二个问题。链接器从全局符号表中发现SubData符号属于sub.o文件,并且已经安排在虚拟地址0x0804_9138处,所以只需要将0x0804_9138填入可执行文件main中的偏移量0xa6即可。我们来读取主文件,验证一下这个位置的虚拟地址是否正确:命令:od-Ax-tx1-j166-N4main-Ax:显示地址时,以十六进制表示。如果使用-Ad,表示以十进制显示地址;-t-x1:显示字节码内容时,使用十六进制(x),一次显示一个字节(1);-j166:跨度超过166字节(十六进制0xa6);-N4:只需要读取4个字节;注意:显示的格式是小端。相对地址重定位从上面描述的重定位表可以看出:main.o代码段中的SubFunc符号也需要重定位,而且是相对寻址。链接器需要填入执行时SunFunc符号的绝对地址(虚拟地址)与call指令的下一条指令(PC寄存器)的差值,填入main.o的偏移量0x1b执行文件main中的代码段。地方。同理,需要解决两个问题:需要计算在执行文件main中的相对地址填入何处;填充的相对地址的值是多少;首先,解决第一个问题。从main.o的重定位表可以看出,要修正的位置距离main.o中代码段的偏移量为0x1b字节。可执行文件main中代码段的起始地址距离文件开头的偏移量是0x94,加上偏移量0x1b就是0xaf。也就是说:需要在主文件中0xaf的偏移处填入一个相对地址。这个相对地址的值是SubFunc在执行时的绝对地址(虚拟地址)和调用指令的下一条指令的偏移量。我们来解决第二个问题。在第一次扫描时,链接器已经将sub.o中的符号SubFunc记录到全局符号表中,知道SubFunc函数被安排在虚拟地址0x0804_80c6处。但是不能直接填这个绝对地址,因为call指令需要相对地址(偏移地址)。链接器将主代码段的起始位置安排在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-tx1-j175-N4main总结经过以上两次重定位操作,main.c中使用的两个外部符号是固定的解决搬迁问题。我们看一下可执行文件main的反汇编代码:从黄色和红色矩形框可以看出,二进制指令中的地址值与上面的分析是一致的。以上就是静态链接过程中地址重定位的基本过程。与动态链接相比,静态链接还是比较简单的。以后有机会的话,再在动态链接里继续讲一些操作,谢谢!