[学习/笔记] 程序员的自我修养-第四章
侧边栏壁纸
  • 累计撰写 65 篇文章
  • 累计收到 3 条评论

[学习/笔记] 程序员的自我修养-第四章

x1n
x1n
2021-09-26 / 0 评论 / 23 阅读 / 正在检测是否收录...

不得不说这本书太难啃了,我是先看完一章,在看下一章的同时写前一章的笔记,这样可以补充前一章没仔细看的东西,但是进度好慢。汗,第五章已经看完了,第四章的笔记还没写完....

4. 静态链接

4.1 空间与地址分配

    将目标文件合成链接为可执行文件时,输出文件的空间如何分配给输入文件。

4.1.1 按序叠加

    最简单的做法就是把各个目标文件直接合并,如左图所示。

A5CA110E-33B3-404D-A7F9-1F17A956D01F.jpeg

    这样带来的问题也是显然的。输出文件的段结构过于零散。当目标文件结构过于庞大时,这样的做法浪费空间,因为每个段都有地址和空间对齐要求,所以这样的方案不够好

4.1.2 相似段合并

    将相同性质的段合并到一起,比如将data段、text段、bss段分别合成到一起,如上右所示。链接器所分配的空间不只是在输出的可执行文件中的空间,另一个是程序在装载后的虚拟地址空间。对于有数据的段,比如text和data,既需要在文件中分配空间、又需要在虚拟地址中分配空间。对于bss,只需要分配虚拟地址空间,因为在文件中并没有实际内容。我们更多关注虚拟地址的分配。可执行文件的空间分配和链接过程关系不大。
    当前链接方式基本都采用4.1.2,一般采用“两步链接”的方法。第一步是空间与地址分配。扫描各个文件,获得各个段长度、属性、位置,收集符号表的定义与引用。创立一个全局符号表。之后通过第一步中的信息,将段数据、重定位信息解析,然后进行符号解析与重定位、调整代码地址。其中重定位过程是整个链接过程的核心
下图展示了未链接前的a和b两个目标文件,以及链接之后的ab
41F58704-C065-4731-A31F-4C17780262C4.jpeg
    其中VMA是虚拟地址,LMA是加载地址,我们一般只需要关注VMA即可。
链接之前,目标文件中所有段的VMA都是0,因为虚拟空间暂未分配。而ab中各段就被分配了相应的虚拟空间。
这里解释一下为什么ab的text段在8048094、data在8049108而不是从0开始分配,在Linux下,ELF可执行文件默认从8048000开始分配

4.1.3 符号地址的确定

    在空间分配后,各段的虚拟地址就已经确定了。之后,链接器计算确定各个符号的虚拟地址,因为符号在段内的相对位置是固定的,所以各个符号的地址已经确定了,但是链接器需要给每个符号加上一个偏移量、使得它们能够调整到正确的虚拟地址。
    比如若main对a.o的text偏移是X,text在链接之后位于虚拟地址8048094,那么main在链接之后的地址应当是8048094+x。

4.2 符号解析与重定位

4.2.1 重定位

    完成空间和地址的分配步骤之后,就要进行静态链接的核心:符号解析与重定位。这里有必要贴一下a.o的反汇编,以便我们来看一下它是如何访问变量和函数的。
DDCC8551-8E44-48C8-9148-083BFF04839C.jpeg
    我们可以暂时简单的将c7 44 24 04理解成mov的机器码,而src的地址0x00则是shared变量(这是一个a.c中定义的东西,swap同)的地址,而e8则是call指令的机器码(call near)0xFFFFFFFC是小端序表示下-4的补码,实际上,对于0x2b-4=0x27,它并非swap的地址,只是一个临时的假地址。在完成空间分配后,链接器已经可以确定所有符号的虚拟地址,进而对每个需要重定位的指令地址修正。

4.2.2 重定位表

    链接器通过重定位表来获取有哪些指令需要被调整的信息。对于每个要被重定位的ELF段,都会有一个对应的重定位表,而一个重定位表一般就是ELF的一个段,也叫重定位段,比如用.rel.text/.rel.data来表示text段/data段的重定位信息,使用objdump -r来查看目标文件的重定位表。
    在重定位表中,可以看到引用的所有外部符号的地址。每个要被重定位的地方叫一个重定位入口,Offset表示对应符号在段中的位置。一个Elf32_Rel数组中每个元素拥有r_offset和r_info两个变量,一个表示重定位入口的偏移,一个表示重定位入口的类型和符号,其中info用低8位表示类型,高24位表示其符号在符号表中的下标。

4.2.3 符号解析

    链接器需要对某个符号的引用进行重定位时,需要确定符号的目标地址,去查询全局符号表之后进行重定位。对于目标文件,它的符号表中存在表项有UND类型的情况,这种未定义的符号都是因为这个目标文件中有他们的重定位项,在链接器扫描完所有的输入文件后,所有的UND符号都须在全局符号表中找到

4.2.4 指令修正方式

   不同指令对地址处理的格式、方式都不一样。大体上有这些区别:
 • 近址寻址或远址寻址
 • 绝对寻址或相对寻址
 • 寻址长度为8/16/32/64位
 这里只讨论对于32位x86ELF重定位的寻址方式:绝对/相对近址32位寻址。它们被修正的位置长度均为32位,使用r_info的低8位控制。
 假设a.o,b.o链接后,main的虚拟地址为0x1000,swap为0x2000,shared为0x3000,借此例子来看一下链接器的修正方式。
 绝对寻址,对于share的修正方式是R_386_32,修正结果应当是S+A(实际地址+修正位置的值),修正之后应当为0x3000,也就是c7 44 24 04 00 30 00 00 。
 相对寻址,swap这条call的修正方式是R_386_PC32,相对寻址,应当是S+A-P,
(P是被修正位置的虚拟地址)也就是0x2000+(-4)- (0x1000+0x27)= 0xFD5,而调用时调用的是下一条指令的地址加偏移,0x102B+0xFD5,正是swap的地址0x2000.

4.3 COMMON块

 若一个弱符号定义在多个目标文件中,而链接器并不能识别符号类型,应当如何处理呢?多个符号定义类型不一致主要有三种情况:
 • 两个或两个以上强符号类型不同
 • 一个强符号,其他为弱符号,类型不同
 • 两个或两个以上弱符号类型不同。
 第一种情况显然会直接报错,主要讨论后两种情况。
 编译器支持COMMON块,当不同目标文件需要的COMMON块大小不一致时,以最大的那块为准,当然,这种情况说的是都是弱符号的情况,如果有一个强符号,编译器会选择强符号的大小分配空间,如果链接过程存在弱符号大于强符号,链接器会给出警告。
 如果一个未初始化的全局变量不是以COMMON块的形式存在,那么它就相当于一个强符号,如果其他目标文件中还有同一个变量的强符号,就会报符号重复定义错误。

4.4 C++相关

 主要讨论重复代码消除、全局构造析构以及C++的二进制兼容性

4.4.1 重复代码消除

 由于C++的特性,在多种情况下都可能在不同的编译单元中生成相同的代码。比如当一个模版在不同编译单元被实例化为相同类型的时候,就会产生重复代码。如果把这些代码都保留下来,会产生如下几个问题:
 • 空间浪费
 • 地址出错:两个指向同一函数的指针不相等
 • Cache命中率降低,直接导致运行效率低
 目前主流编译器通常采用将每个模版的实例代码都单独存放在一个段里,GNU GCC称之为Link Once,命名为gnu.linkonce.name,name是模版函数修饰名称,VSC++则把这种类型的段叫做COMDAT,链接的时候将重复的段丢弃。
 对于虚函数表等的做法也类似。这样的做法基本上解决了代码重复的问题,但是仍然存在一些问题,比如同名代码段可能拥有不同内容,在这种情况下,编译器将会随意选择一个副本链接,并抛出一个警告。

4.4.1.1 函数级别链接

 为了减少空间浪费,VSC++提供了一个函数级别链接的编译选项,将所有函数都单独保存到一个段里,需要用到某函数时,就将它合并到输出中,否则抛弃。但是它会减慢编译链接的过程,段的增加也导致了目标文件变大。

4.4.2 全局构造与析构

 main执行之前,为了程序能正常执行,往往要初始化执行环境,比如堆分配。C++的全局对象构造也是在此时被执行,同时main执行之后也会有一些清理工作。所以ELF定义了两种特殊的段.init和.fini。Glibc会分别在main前和main后执行对应的代码

4.4.3 C++与ABI

 不同编译器编译出来的目标文件往往不能相互链接。我们把符号修饰标准、变量内存布局等与二进制兼容性相关的内容称为ABI。因为不同编译器的ABI标准不同,所以无法相互链接。ABI与API非常类似,但是API一般指源码级别的接口,ABI是二进制层面的接口,往往比API要严格。比如C++的对象内存分布,是C++的ABI的一部分,API则更关注源码,比如POSIX规定printf的定义,但是不要求printf的具体实现,比如压栈方式、参数分布等问题。
 硬件、语言、编译、链接器、操作系统都会影响ABI,这里通过C语言来看编程语言如何影响的ABI。
 • 类型的大小与在储存器中的放置方式 (int/float), (MSB/LSB)
 • 组合类型(Struct)的存储方式和内存分布
 • 外部符号、用户定义符号的命名和解析方式
 • 函数调用方式
 • 堆栈分布约定
 • 寄存器使用约定
 到了C++时代,还有更多的内容使得ABI必须有所调整,二进制兼容也更为不易
 • 继承类的内存分布
 • 成员函数的指针的内存分布
 • 虚函数、虚函数表的内容与分布
 • 模型的实例化
 • 外部符号的修饰
 • ...等等,更多的列举在笔记中意义不大,如果想进一步了解请直接看书吧
 C++的二进制兼容性没有C好,甚至同一个编译器的不同版本都可能存在二进制不兼容的情况。现存的C++ ABI主要分为VSC++和GCC两大阵营,仍未能统一标准

4.5 静态库链接。

 一个静态库可以看成是一组目标文件的集合。将庞大的库通过ar压缩到一起,形成如libc.a这样的静态库文件(GCC,Linux下),Win下也有相应的工具lib.exe。通过objdump或者readelf加上grep就可以查看libc.a中目标符号的情况。ld链接器将会自动在链接库中寻找需要的目标文件,最后链接成可执行文件。当然,目标文件间也有一些依赖关系,ld会自动的处理好这些东西。在实际的编译过程中,一个libc.a往往是不够的,还有一些其他的库和目标文件会被链接进来。

4.6 链接过程控制

 当面向某些特殊使用场景编程时(比如内核驱动),我们可能需要指定段的名称、地址、顺序等,这时候就需要控制链接过程。

4.6.1 链接控制脚本

 通过命令参数、存放指令在目标文件中的控制方式都是控制链接的方式,但是我们需要一个更灵活强大的控制方式:链接控制脚本。这部分暂时不打算深入了解,对我目前的学习用处不大,日后出题的时候可能深入了解一下。

4.6.2 最小的程序

 通过编写一个不利用C库、程序入口非main、仅一个段的小程序,了解链接的控制过程

72D5642B-A4D7-40B6-BF3E-285506C09352.jpeg
 还是用了书上的代码,希望读这个的还是能买本原书支持一下作者。
 代码内容不解释了,熟悉 Linux 系统调用很容易就能读懂。直接编译的话,需要为 ld 指定入口函数与连接方式,我们选择 -fno_builtin/-static 链接,它能够正常运行并拥有四个段,为了把它们放在一个段中,我们需要使用 ld 链接脚本。

4.6.3 使用 ld 链接脚本

 ld链接脚本并不复杂,ld以库文件、目标文件为输入、结果为输出。控制链接无非是指定输入如何变成输出:比如哪些段要合并,哪些段要丢弃,并指定输出段的名字、装载地址、属性等。书上的lds指定了程序入口,SECTION中包含一个赋值以及两条转换规则。通过ld的-T参数来使用这样的脚本。

4.6.4 ld链接脚本语法

 语法没什么可以记录的,需要的时候查书就好了

4.7 BFD库

 BFD作为GNU的一个项目,意在希望通过统一的接口处理不同的目标文件格式。这样,gcc、binutils这种工具可以专注于实现自己的功能,只需要使用BFD的接口就可以了,当需要支持一种新的文件格式,只需要在BFD中添加一种格式即可。

0

评论 (0)

取消