《程序员的自我修养--链接、装载与库》总结

596 阅读9分钟

目标文件里有什么

链接器为目标文件分配地址和空间:

地址和空间有两层含义:
1. 输出的可执行文件中的空间
2. 装载后在虚拟内存中的虚拟地址空间

这是有差别的,比如下面要讲的 bss 段,它在目标文件中不占有空间,但是却占有虚拟地址空间

我们的重点在虚拟地址空间的分配上。

目标文件结构

按照在文件中的位置,依次往下是:

  • ELF Header 文件头
  • .text
  • .data
  • .bss
  • ...
  • other sections
  • section header table 段表
  • string tables
  • symbol tables

文件头

有ELF文件版本、目标机器型号、程序入口地址等

段表

是用来指示各个段的在目标文件中的位置、长度,在虚拟内存中的位置、长度,和段属性的。 段属性是有:ALLOC, LOAD,READONLY等,这些都是给操作系统看的,操作系统一看就能知道是否要加载这个段到内存中,是只读,还是可读可写等。 对于操作系统来说,段名是没有意义的,段属性才是它加载的依据。

string tables

一个目标文件这么长,为了让操作系统知道怎么断句,目标文件中采取两种办法:

  • 第一个字段就表明自身结构的长度,比如段表,其实可以理解成一个数组,数组元素的最开始都先说明自己有多长
  • 固定长度,比如段表元素中有一个字段是段名,因为段名是字符串,是不固定长度的,所以专门提供一个字符串表放字符串,段名那里只需要存放一个指向字符串表的地址就可以了

如果某个段中需要用到字符串表,那就会单独为这个段创建一个段,不会将不同的段混在一起。

代码段

源代码中函数存放的地方

数据段、只读数据段、BSS段

静态数据根据位置(全局和局部)、访问控制(只读、可读可写)和是否已经初始化,分别放在不同的位置:

  • BSS: 未初始化的全局/局部、可读可写数据
  • .rodata:已经初始化的全局/局部、只读数据(当然也不会有没有初始化的只读数据)
  • .data:已经初始化的全局/局部、可读可写数据

这样分布的原因: 首先,只读和可读可写要分开在不同的段中的,因为虚拟内存的访问控制是精确到页的,也就说每个页中的访问控制是一样的,而同一个段的数据可能会被放在同一个页中,所以要分开。

其次,对于已经初始化过的数据,必须要存放在目标文件中,但是未初始化的就不是了,既然没有初始化,那就没有必要在目标文件中占用空间了,只要在 bss 段中声明一下总共需要多少空间,方便操作系统载入程序时分配正确的空间就可以了

但是对于全局的未初始化静态变量,在目标文件的 bss 段中是没有计算进去的,原因稍后再说。

符号表、重定位表、GOT表

在链接中会说明

链接

符号

链接过程的本质是将多个目标文件粘在一起,形成一个整体。为了使不同的目标文件之间能够相互粘合,这些目标文件之间必须有固定的规则才行。

在链接中,目标文件之间的拼合实际上是目标文件之间的地址引用,也就是对函数和变量的地址引用。

比如目标文件 B 要用到目标文件 A 的函数 foo,那么我们就称 A 定义了函数 foo,称 B 引用了 foo。

在链接中,我们将函数和变量统称为符号 Symbol。链接过程中很关键的一部分就是符号的管理,每一个目标文件都会有一个相应的符号表,这个表里面记录了目标文件中所用到的所有符号,每个定义的符号都有一个对应的值,对于函数和变量,就是它们的地址。

符号有如下类型:

  • 全局符号:定义在本目标文件中,可以被其他目标文件引用
  • 外部符号:在本目标文件中引用的全局符号,却没有定义在本目标文件中
  • 段名
  • 局部符号:只在当前编译单元内可见
  • 行号信息

静态链接

静态链接发生在编译过程中。分为两步:空间与地址分配;符号解析与重定位

空间与地址分配

扫描所有的输入目标文件,获得他们的各个段的长度、属性和位置,并将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表

符号解析与重定位

根据上一步的结果,看看有那些需要外部符号还没有确定地址的,找到地址并调整。

每个目标文件都会有一个符号表和一个重定位表(如果引用了外部符号,我觉得应该很难写出没有引用外部符号的源文件吧)。

重定位段的段名是 .rel.xxx,xxx 是被重定位的段的段名。比如 .text 段的重定位段的段名是 .rel.text。 为什么不把它们放在一个段,因为它们的段属性不一样。

重定位表中记录着目标文件中的哪个位置引用了哪些外部符号,引用的方式是什么。

在把所有的目标文件都合并在一个文件中之后,每个符号的地址就都确定了,找到这些符号的地址,根据重定位项中记录的地址,把外部符号的真实地址填写进去。静态链接就完成。

动态链接

不同的程序可能会用到同一个库,如果每个程序加载一遍这个库,那将会浪费虚拟内存空间。

所以为了节省空间,最好能让不同的程序用同一份库的镜像,但是这份镜像的地址不能固定,因为不同的程序空闲的位置是不一样的,很难调和让多个程序空出一样的空间存放这个镜像。那就只能把这个库加载在不同程序空间的不同位置。

那有以下调用问题:

  • 库调用内部的函数:因为下一条指令的地址与被调函数的地址的间隔是固定,所以可以采取相对地址的方式
  • 库使用内部的变量:被调函数与变量的间隔也是固定的,但是访问变量不支持相对地址的方式,所以要获取下一条指令的地址,再加上固定间隔,算出来变量的地址,拿着这个地址去访问。
  • 库使用外部的变量:因为间隔不固定,所以不能采用上面的两种方法,如果像静态链接采取重定位方式,把重定位操作放在装载时,就不能做到共享库了,因为重定位的话,需要修改代码段,每个程序加载时都改一遍库的代码段,那几没法用了。所以把需要重定位的内容都放在数据段中,因为没有对于每个使用该库的程序,该库的数据段一定时不一样的,所以把重定位内容放在数据段中,程序启动时修改一下保存在数据段中的地址就可以了。这种数据段叫 GOT(Global Offset Table 全局偏移表),这样的代码叫做 地址无关代码
  • 库调用外部的函数:也是采用 GOT 的方式
  • 数据段的地址

数据段的地址,举例:

extern static int a = 10;
static void *p = &a

这个变量也会被记录在重定位表中,在装载后会被重定位

延迟绑定

动态链接的问题是启动程序时比较慢,所以采取了延迟绑定。 延迟绑定的实现非常精巧:

假设 liba.so 调用 libb.so 中的 bar() 函数,如果要找到这个函数地址,至少需要知道:模块ID和函数名。

我们再假设找这个函数地址的函数是 lookup(moduleId, function)。

原来调用 bar() 时,是直接去 GOT 中找到的,我们假设 GOT 中 bar 对应的项是 bar@GOT。

为了延迟加载,又新增了一个 Procedure Linkage Table(PLT),假设 PLT 中 bar 对应的项是 bar@PLT。现在调用 bar() 时,是来 PLT 中找函数地址的。

bar@PLT 中代码如下:

jmp *(bar@GOT)
push n
push moduleID
jump lookup

在程序装载后,bar@GOT 中的地址是 push n 指令的地址,这个操作是非常容易的。

第一次调用 bar 时,来到了 bar@PLT,跳转到 bar@GOT,于是就来到了 push n。

n是 bar 这个符号引用在重定位表 .rel.plt 中的下标,再把 moduleID 压进栈,跳转到 lookup,这也就是在调用 lookup 函数,这个函数中进行符号解析和重定位的工作,会把找到的 bar 函数地址放在 bar@GOT 中,等下一次再调用 bar 函数时,就会跳转到 bar@PLT 之后,跳转到 bar 的地址。

ELF 将 GOT 拆分成了两个表叫做".got" 和 ".got.plt",其中".got" 用来保存全局变量引用的地址,".got.plt" 用来保存函数引用的地址。

动态链接的段

静态链接需要符号表和重定位表,动态链接同样需要,动态链接符号表是 .symtab,对代码段的重定位表是 .rel.dyn,修正的位置位于.got 以及数据段,对代码段的重定位表是 .rel.plt,修正的位置位于 .got.plt。

分析

链接过程中修改函数地址的方法有:

  • 重定位:直接把找到的符号地址写进某个位置
  • 地址无关代码

这里所谓的对代码段的重定位是指