Caiwen的博客

CSAPP第七章 - 链接

2025-08-08 11:28

1. 编译器驱动程序

有如下的程序:

其中的 main.c 引用了另外一个 sum.c 文件的函数。我们可以通过 gcc -Og -o prog main.c sum.c 来把这两个文件链接到一块编译出一个产物。这里的 gcc 相当于是一个编译器驱动程序,代替用户执行预处理器、编译器、汇编器、链接器来生成一个可执行文件。

上述 gcc 驱动编译的详细过程如下:

  • 首先把源码 main.c 翻译成一个中间文件 main.icpp [other arguments] main.c /tmp/main.i
  • 然后将这个中间文件翻译成汇编的文件 main.scc1 /tmp/main.i -Og -o /tmp/main.s
  • 然后驱动程序运行汇编器,将 main.s 翻译成一个可重定位目标文件 main.oas -o /tmp/main.o /tmp/main.s
  • 然后驱动程序经过相同的过程生成 sum.o
  • 最后驱动程序运行链接器将 main.osum.o 和其他的必要文件组合起来,生成一个可执行目标文件 progld -o prog [system object files and args] /tmp/main.o /tmp/sum.o

2. 目标文件(ELF)

目标文件有三种:

  • 可重定位目标文件(.o 格式):由汇编器生成,仅经过了编译但是还没链接。链接器后续会将其与其他的可重定位目标文件合并,生成可执行目标文件
  • 可执行目标文件:可以被直接被加载到内存中执行
  • 共享目标文件:一种特殊类型的可重定位目标文件,可以在编译时或是加载时被静态或是动态链接到程序中。静态链接对应的是 .a 格式,动态链接对应的是 .so 格式。

在 Linux 系统上,目标文件是 ELF 格式的。

同时内核模块(.ko 文件),内核转储文件(coredump)也会使用 ELF 格式。

ELF 文件总体上分成四个部分:ELF Header、Program Header Table、Section Header Table 和中间的部分。

2.1 ELF 头部

64 位的 ELF 头部有 64 字节:

名称 字节数 说明
e_ident 16 一些标志信息
e_type 2 ELF 文件类型
e_machine 2 指定 ELF 运行的 CPU 架构,具体看 这里
e_version 4 指定 ELF 版本,一般为 1
e_entry 8 elf 代码运行的入口,是一个虚拟地址。操作系统应在加载程序后直接执行该虚拟地址处的代码
e_phoff 8 Program Header Table 在文件中的偏移量
e_shoff 8 Section Header Table 在文件中的偏移量
e_flags 4 处理器特性标签,不太了解
e_ehsize 2 ELF 头的大小,单位为字节
e_phentsize 2 Program Header Table 中每个条目的大小
e_phnum 2 Program Header Table 中条目的数量
e_shentsize 2 Section Header Table 中每个条目的大小
e_shnum 2 Section Header Table 中条目的数量
e_shstrndx 2 每个 Section 都有一个名称。其中有一个 Section (.shstrtab)专门用来存储其他 Section 的名称。该值则表示储存名称的 Section 在 Section Header Table 中的索引

其中要展开说的如下:

  • e_ident,16字节
名称 字节数 说明
ELF 魔数 4 固定为 0x7f 0x45 0x4c 0x46 ,表示这是一个合法的 elf 文件
EI_CLASS 1 表示文件是 32 位还是 64 位,0x01 为 32 位,0x02 为 64 位
EI_DATA 1 指定大小端,0x01 为小端,0x02 为大端
EI_VERSION 1 ELF 规范版本。目前固定为 0x01
EI_OSABI 1 是否启用一些基于操作系统或者 CPU 特性。一般为 0x00
EI_ABIVERSION 1 指定当前 ABI 版本,配合 EI_OSABI 使用。一般也是为 0x00
EI_PAD 6 预留,一般都为 0x00
EI_NIDENT 1 不太清楚,文档上说的是 e_ident[] 数组大小
  • e_type,2 字节,表示 ELF 文件类型,常用的如下:

    现代的可执行文件中该字段为 ET_DYN,表示该可执行文件中的代码都是位置无关代码,操作系统在加载时会启用 ASLR,以应对缓冲区溢出和 ROP 攻击。在加载时会随机选择一个基址,然后将 ELF 文件的各个部分在虚拟空间的位置对这个基址进行偏移,同时还会把把这个偏移施加到 e_entry 上,作为实际的跳转入口。

    ET_EXEC 则表示传统的可执行文件,其代码会被加载到固定位置。

    使用 g++ -no-pie 选项进行编译,可以使得编译器产生 ET_EXEC 类型的可执行文件。

名称 数值 说明
ET_NONE 0 非文件类型
ET_REL 1 可重定位文件
ET_EXEC 2 可执行文件
ET_DYN 3 共享库(动态链接)
ET_CORE 4 内核转储文件
ET_LOOS 0xfe00 操作系统特定文件
ET_HIOS 0xfeff 操作系统特定文件
ET_LOPROC 0xff00 处理器特定文件
ET_HIPROC 0xffff 处理器特定文件
  • e_phoffe_phentsizee_phnum 表明了 Program Header Table 的信息
  • e_shoffe_shentsizee_shnum 表明了 Section Header Table 的信息

2.2 可重定位目标文件

可重定位目标文件用不到 Program Header Table,所以就没有。

可重定位目标文件将中间的部分称为节。

一个 Section Header Table 的表项占 64 字节,结构如下:

名称 字节数 说明
sh_name 4 表示节的名称。存储的是名称的第一个字符在 .shstrtab 节的偏移
sh_type 4 节的类型
sh_flags 8 节的属性,每一位表示不同的标志
sh_addr 8 如果节将出现在进程的虚拟内存中,那么该字段给出节的虚拟内存地址。否则,此字段设为 0
sh_offset 8 节在文件中的偏移
sh_size 8 表示节的大小,单位字节
sh_link 4 一般为 0。否则表示这个节和某个节有关联,存储的是相关联的节在 Section Header Table 中的索引。具体的解释依赖于节的类型
sh_info 4 节的额外信息。具体的解释依赖于节的类型
sh_addralign 8 某些节的地址有对齐约束。这个值只允许 0 以及 2 的正整数幂。取 0 或是 1 时表示没有对齐约束。当开启对齐约束时,sh_addr 必须是 sh_addralign 的倍数
sh_entsize 8 如果这个节也是一个表(如符号表)的话,那么这个值就表示表里每个条目的大小。反之则为 0。

其中需要具体说明的如下:

  • sh_type,常见取值如下
名称 数值 说明
SHT_PROGBITS 1 表示存放代码或者数据,如 .text.data
SHT_STRTAB 3 表示存放字符串表,如 .shstrtab
SHT_NOBITS 8 表示这个节不在 ELF 文件中存放数据,含有的数据都是未初始化的数据。如 .bss
SHT_REL 4 表示存放重定位项的信息
SHT_SYMTAB 2 符号表
SHT_STRTAB 3 字符串表
  • sh_flags
名称 说明
SHF_WRITE 0x1 表示该节是可写的
SHF_ALLOC 0x2 表示该节需要分配内存
SHF_EXECINSTR 0x4 表示该节可执行
SHF_MERGE 0x10 表示该节可以被合并
SHF_STRINGS 0x20 表示该节包含字符串
SHF_INTO_LINK 0x40 表示 sh_info 字段包含额外的语义信息
SHF_LINK_ORDER 0x80 表示该节的链接顺序依赖于另一个节
SHF_OS_NONCONFORMING 0x100 表示该节需要操作系统特殊处理
SHF_GROUP 0x200 表示该节属于一个节组
SHF_TLS 0x400 表示该节包含线程本地存储
SHF_MASKOS 0x0ff00000 保留给操作系统特定的语义
SHF_MASKPROC 0xf0000000 保留给处理器架构特定的语义

一般包含如下的几个节:

名称 类型 说明
.text SHT_PROGBITS 已编译的程序的机器代码
.rodata SHT_PROGBITS 常量信息,是只读的
.data SHT_PROGBITS 有初始值的全局变量和静态局部变量
.bss SHT_NOBITS 没有初始值的全局变量和静态局部变量。这个节并不在 ELF 中占用实际的大小,而是在运行的时候直接在虚拟内存中进行映射,并由程序来给内存清零。.bss 可以认为是 Better Save Space ,一种节约文件大小的方式(尽管实际上 .bss 的来源不是这个)
.rel.text SHT_REL 存放 .text 节中需要重定位的位置,用于后续重定位
.rel.data SHT_REL 存放 .data 节中需要重定位的位置,同上
.debug SHT_PROGBITS 调试符号表,只有在编译时添加 -g 选项才会被生成。调试符号表将会存放程序中定义的各种变量的信息,以及原始的 C 语言文件,用于调试
.line SHT_PROGBITS 编译时添加 -g 选项才会被生成。存放原始的 C 语言文件中的行号和 .text 中机器指令的映射关系
.symtab SHT_SYMTAB 符号表,程序中定义的全局变量和引用的全局变量的信息都存放在这里。符号表将用于后续的链接的重定位
.shstrtab SHT_STRTAB 节头部表的节名字并没有直接存到相应的位置,而是存储到 .shstrtab 中。原来的位置上只存储文本在 .shstrtab 中的偏移。
.strtab SHT_STRTAB 同上,但是 .strtab 是存 .symtab.debug 中的符号的

其中的字符串表的类型的节主要长这样:

2.3 可执行目标文件

可执行目标文件和可重定位目标文件大致差不多,区别在于:

  • .text.rodata.data 中的内容已经经过重定位了。
  • 新增了一个 .init 节,里面定义了一个小函数,叫做 __init,用于程序的初始化,后文的加载过程会细说。
  • 由于重定位完毕,所以不需要 .rel 相关的节。

有意思的是,尽管可执行目标文件不需要再与其他文件链接了,.symtab 还是存在。我们可以使用 strip 命令特意去掉。

节提供了非常细粒度的程序组织方式,这对于编译器和链接器很有用。但是对于加载器来说就不需要这么细粒度的组织了。于是在加载器的视角,把文件的中间部分称作段,将具有相似属性(如内存权限)的多个节组合在一起,一个段可能包含若干个节,从而简化了加载过程。

由于加载器不从节的角度看待 ELF,所以最后不带 Section Header Table 似乎问题不大。

一个 Program Header Table 里的表项占 56 字节,结构如下:

名称 字节数 说明
p_type 4 段的类型
p_flags 4 标记,主要用于 PT_LOAD 中的虚拟内存权限位,而对于其他类型的段可能有不同的含义
p_offset 8 段内的内容在 elf 文件内的偏移
p_vaddr 8 段加载到的虚拟内存地址,主要用于 PT_LOAD 类型的段。其他类型的段可能用不到
p_paddr 8 段加载到的物理内存地址,主要用于一些不支持虚拟内存的硬件上面。对于大部分支持虚拟内存的平台上,这个值用不到。
p_filesz 8 该段在文件中的大小
p_memsz 8 该段在内存中的大小。存在 p_memsz > p_filesz 的情况,比如 .bss
p_align 8 该段的对齐方式

对于 p_type,有如下的取值:

类型 说明
PT_NULL 好像没啥用,会被忽略掉
PT_LOAD 表示当前段需要从文件中加载到虚拟内存
PT_DYNAMIC 包含动态链接器所需要的信息,比如共享库的依赖,符号表位置等
PT_INTEPR 包含动态链接器的路径的字符串
其他 还有很多其他的类型,不过好像是可有可无的

p_type == PT_LOAD 时,p_flags 有如下的可选选项(p_flags 的值是下面的取值按位与得到的)

名称 说明
0x1 PF_X 可执行
0x2 PF_W 可写
0x4 PF_R 可读

PT_LOAD 段会将文件内容加载到内存,但中间不会产生数据的复制,而是把文件的内容映射到虚拟内存中,按需加载。

因此每个段还需要满足如下的对齐要求:

p_vaddrmodp_align=p_offsetmodp_align\text{p\_vaddr}\bmod{\text{p\_align}} = \text{p\_offset} \bmod {\text{p\_align}}

p_align 一般就是取当前操作系统的分页大小。不过好像还可能取分页大小的倍数,这就不太清楚为什么了。

这个对齐要求可以保证操作系统可以以页为单位将文件和虚拟内存映射起来。

3. 符号解析

一个 C 文件中定义的全局符号,有可能只是占位,实际上是要引用另一个文件的全局符号。同时一个 C 文件中定义的全局符号有可能也要被其他 C 文件引用。全局符号将会存在 ELF 的 .symtab 中,在与其他文件链接时提供信息。

.symtab 中的条目结构如下:

字段 字节数 说明
st_name 4 符号名字在字符串表(.strtab)中的索引。如果该值为 0,说明该符号没有名称
st_info 1 低四位表示符号的类型(比如是数据对象、变量、函数、文件名等符号)。高四位表示符号的链接性
st_other 1 暂时为 0,含义没有被定义
st_shndx 2 这个符号的定义是存在于哪个节中,存储的是 Section Header Table 的索引
st_value 8 指明这个符号的定义存在于哪个位置。对于可重定位目标文件,这里存储的是相对于其所在的节中的偏移(配合 section 信息就知道其绝对位置)。对于可执行目标文件,这里存储的直接就是符号的定义的虚拟内存地址。
st_size 8 该符号对应的目标所占用的大小,如果符号没有大小或大小未知,则该成员为 0

符号链接性部分可取如下的值:

  • STB_LOCAL:局部符号
  • STB_GLOBAL:全局符号
  • STB_WEAK:弱符号

st_shndx 可取一些特殊值,称为伪节:

  • ABS 表明这个符号不应该被重定位。
  • UNDEF 表明这个符号的定义在当前文件中虽被定义了,但具体定义在外部的文件中。
  • COMMON 则存储弱符号。

伪节只会在可重定位目标文件中。

C 语言中,链接器首先对每一个翻译单元的符号的链接性和符号位置进行判定:

  • 链接性

    • 如果符号被 static 修饰,那么这个符号直接被判定为内部链接性。非全局符号也都是内部链接性。均为 STB_LOCAL,不参与跨文件的符号解析。
    • 对于 STB_WEAK 的符号,不同编译器有不同的语法来声明。GCC/Clang 中使用 __attribute__((weak)) 修饰的符号的链接性为 STB_WEAK
    • 其余的符号均为 STB_GLOBAL
  • 符号位置

    • 带函数体、有初值的变量,符号的 st_shndx 均为其定义的节中

      如果其初始值为 0,则会判定这个符号属于 .bss 节。如果其初始值不为 0,则会判定这个符号属于 .data

    • 没有函数体的函数定义、被 extern 修饰的函数或是变量,被判定在 UNDEF 节中

    • 没有初始值的全局变量,被判定为 COMMON 节中。

然后对于 STB_GLOBALSTB_WEAK 类型的符号进行跨文件引用解析:

  • 如果存在 STB_GLOBAL + 真实节定义,那么其余所有的同名引用都绑定到这个定义上。如果这样的定义不止一个,则报告链接错误。

  • 否则的话,再去找 STB_GLOBAL + COMMON 定义。如果有多个这样的定义,则随机选取一个(或者说具体怎么选取看链接器的实现),然后其余所有的同名引用都绑定到这个定义上。这个定义会被判定在 .bss 上。

  • 否则的话,再去找 STB_WEAK + 非 UNDEF 定义。如果有多个这样的定义,则随机选取一个,然后其余所有的同名引用都绑定到这个定义上。

  • 否则的话,说明这个符号没有任何定义

    于是将所有的 STB_WEAK + UNDEF 引用全部解析到地址 0。

    如果存在 STB_GLOBAL + UNDEF 引用则报告链接错误(不过好像并不会立刻报告链接错误,默认情况下链接器会允许这种情况,因为可能后面存在动态链接。编译时如果添加 -static 参数,那么才会立刻报告链接错误)。

如果我们仅考虑 STB_GLOBAL 的话,可以总结成如下的强弱符号规则来处理多个文件之间的全局符号引用:

  • 强符号:所有的函数和有初始值的全局变量
  • 弱符号:没有初始值的全局变量

对于多个重名的全局符号,链接器有如下的规则进行选取:

  • 规则 1:不允许有多个同名的的强符号
  • 规则 2:如果有一个强符号和多个弱符号,选择强符号
  • 规则 3:如果有多个弱符号,随机选择一个

现代的链接器默认自动开启 -fno-common 选项,使得存在重名符号的时候(即使不是强符号重名)也会直接报错。我们可以手动添加 -fcommon 来允许弱符号重名。

而对于 C++,没有了强弱符号机制,而是有一个 ODR (One Definition Rule),全局符号一律不允许重名。如果在一个文件中想要引用另一个文件中的符号,则需要在当前文件的定义中添加 extern 修饰。编译器会将这个符号判定为属于 UNDEF 节中。

Also see :Effective C++ 读书笔记,链接:TODO

4. 链接

C 语言规范中定义了一组函数,这些函数放在 libc 库中,如 printfscanf 等。为了支持这些库函数,一种方式是直接让编译器识别出这些函数,然后生成相应的代码。但是这样会增加编译器的复杂性。

另一种方法是将所有的函数放到一个单独的可重定位目标文件中,如 libc.o ,然后 gcc main.c /usr/lib/libc.o 即可让引入库函数。不过这样的话,每个编译出来的程序都会携带一个完整的 libc.o ,会浪费内存。

又一种方法是为每个函数创建一个单独的可重定位目标文件。不过这样做需要程序员自行链接合适的文件:gcc main.c /usr/lib/printf.o /usr/lib/scanf.o。但是这样很麻烦又容易出错。

4.1 静态链接

静态库将若干个可重定位目标文件组合到一个 .a 后缀的静态库文件中。这个文件的头部描述了其包含成员目标的信息。使用静态库文件时,链接器会自动抽取静态库文件中被使用到的成员,没被使用的则不会被抽取。

4.1.1 生成共享库

如我们创建一个 libvector 库:

使用 gcc -c addvec.c multvec.c 可以编译得到 addvec.omultvec.o

然后我们可以使用 ar rcs libvector.a addvec.o multvec.o 来打包得到静态库文件。

4.1.2 使用共享库

使用时需要先有个头文件,头文件包含库函数的定义。

然后使用如下命令编译:

gcc -static -o prog2c main2.o -L. -lvector

其中 -static 参数告诉编译驱动程序,链接器应该构建一个完全链接的可执行目标文件,可以直接加载到内存中运行。这个参数使得可执行文件不需要任何动态链接。

-L. 表明寻找静态库的位置

-lvectorlibvector.a 的缩写

4.1.3 链接过程

在静态链接时,链接器维护一个将要被合并的目标文件集合 EE,一个未解析的符号集合 UU,一个在前面输入文件中已经定义的符号集合 DD

  • 对于命令行上的每个输入文件 ff,链接器则会判断 ff 是一个目标文件还是一个存档文件
  • 如果 ff 是目标文件,则会把 ff 放入 EE 中,用 DD 尝试解决 ff 中未解析的符号,再用把剩余未被解析的符号加入 UU
  • 如果 ff 是静态库文件,则在 ff 中的成员(即静态库中包含的目标文件)所定义的符号中寻找当前未被解析的元素。把对解析有帮助的成员目标文件放入 EE,更新 UUDD。对解析没有帮助的则会直接丢弃
  • 进行完毕之后,如果 UU 是非空的,则会报错

根据上述过程,我们在编译时,命令行上放置的文件的顺序非常重要,必须要使得前面未被解析的符号在后面的文件中存在定义。如:

gcc -static ./libvector.a main2.c,处理 libvector.a 时,UU 是空的,这个库文件不会解决后面的 main2.c 的符号解析,所以会报错。所以我们最好是把静态库文件放在命令行的后面。

同时我们还需要注意静态库文件的依赖关系。比如 foo.c 调用 libx.alibz.a,而这两个库又调用 liby.a,那么我们需要这么写:gcc foo.c libx.a libz.a liby.a

如果出现循环依赖,我们可以在命令行上重复添加文件,比如 libx.a 调用 liby.a,而 liby.a 也调用了 libx.a,则有:gcc foo.c libx.a liby.a libx.a。当然另一个解决方法是把 libx.aliby.a 合并成一个单独的静态库文件。

4.1.4 重定位

链接器需要把多个可重定位目标文件中,名字相同的节合并,比如把各个文件的 .text 合并成一个 .text。但是合并之后,一些全局符号的地址就会发生改变。

编译器会提前知道哪些全局符号的地址暂时无法确定,于是在引用这些符号的地方,先不设置具体的地址,而是先留空,并记录这些位置,生成重定位条目,放到 .rel 相关的节上。文件合并之后,各个符号的地址就确定下来了,链接器会进行重定位,就是根据重定位条目,设置好具体的地址。

重定位条目的结构如下:

其中的 offset 表明需要重定位的地址。地址是相对于其所在节的偏移。

symbol 表明这个要被重定位的引用,是引用了哪个符号,存储的是在符号表中的下标。

addend 表明计算出重定位地址之后还需要进行的调整量。常用于重定位为 PC 相对地址时。

type 表明重定位类型。这里讲两种最基本的类型:

  • R_X86_64_PC32:重定位为一个 PC 相对地址
  • R_X86_64_32:重定位为一个绝对的地址

上述类型重定位的地址都是 32 位的,这是因为这两个类型是基于 x86-64 小型代码模型。这个模型假设可执行目标文件的大小小于 2GB,32 位的寻址就足够,GCC 默认使用这个模型。

重定位算法如下:

其先枚举了要重定位的节,然后枚举该节下的重定位条目。现在我们已经知道每个节的真实首地址了,用 ADDR(s) 表示。已经知道引用的符号所被定义的真实地址了,用 ADDR(r.symbol) 表示。

4.2 动态链接

静态库直接编译进程序了,这导致程序和静态库两者之间不能独立更新。同时,每个程序都会含有重复的静态库代码,仍然造成了很大的浪费。

共享库可以解决上述缺陷。程序在编译时不去将共享库加入编译产物中,而是简单的记录共享库的名称,程序在加载的时候再去载入共享库。同时,利用虚拟内存技术,我们可以只加载一个共享库文件到内存中,让多个程序共享这块内存。

4.2.1 生成共享库

我们可以使用如下命令生成共享库:

gcc -shared -fpic -o libvector.so addvec.c multvec.c

-shared 指明打包成共享库,-fpic 指明要生成与位置无关代码(这个参数是必须的)

4.2.2 使用共享库

编译时链接

gcc -o prog2l main2.c ./libvector.so

运行时使用

需添加编译参数 -ldl 来使得可执行文件链接动态加载器,以使用运行时链接的功能。

dlfcn.h 头文件中,有如下的函数:

void *dlopen(const char *filename, int flag);

  • 加载和链接共享库 filename
  • flag 可选如下选项
    • RTLD_GLOBAL 将共享库的符号直接合并到当前程序的全局符号空间。
      • 这样,可执行文件中只需要修饰全局符号为 extern ,然后在 dlopen 之后直接调用这个符号即可,不用再使用 dlsym 了。
      • 同时,合并到全局符号空间之后,后续再次加载的共享库能够调用之前加载的共享库,也只需要定义符号的时候声明为 extern
      • 如果当前可执行文件编译时添加了 -rdynamic 选项,那么共享库还能去使用当前可执行文件中的符号。
    • RTLD_NOW 立即解析共享库的符号引用,这样加载时比较耗时,但是使用符号的时候非常快。
    • RTLD_LAZY 将符号解析推迟到使用时,这样加载时很快,但是初次使用符号的时候比较慢
  • 函数调用成功则返回一个指向句柄的指针,如果出错则返回 NULL

void *dlsym(void *handle, char *symbol);

  • 获取加载的共享库中的某个符号的地址

  • handledlopen 返回的句柄指针,symbol 指明要引用的符号名称

  • 成功则返回指向符号的指针,出错则返回 NULL

int dlclose(void *handle);

  • 卸载某个共享库
  • handledlopen 返回的句柄指针
  • 成功则返回 0,失败返回 -1

const char *dlerror(void);

  • 如果前面的函数调用失败了,那么可以根据这个函数获取最近一次失败的消息
  • 如果前面的函数存在错误则返回错误文本的指针,如果前面没发生过错误则返回 NULL

4.2.3 链接过程

在编译时,链接器不将共享库中的代码和数据复制到可执行目标文件中,而是复制了一些重定位和符号信息,以便后续加载时链接共享库。

可执行目标文件在加载时,加载器注意到其包含了一个 .interp 节,这一节包含了动态链接器的路径名(动态链接器本身可视为一个共享库,在 Linux 中是 ld-linux.so),然后加载器加载并调用动态链接器。

在程序的 .dynamic 节存储当前程序引用的所有共享库文件的名称。动态链接器会寻找并加载这些共享库,将共享库映射到当前程序的虚拟内存空间中。

共享库的 .dynsym 包含了当前共享库到处的所有函数的符号信息。动态链接器可以根据已经加载的共享库的 .dynsym 来寻找符号。

传统动态链接

传统动态链接过程中,动态链接器此时会重定位程序中所有对共享库的引用。

但这存在缺陷,首先这样会使得 .text 节可写,会有漏洞隐患。其次,这样的话,每个进程都需要独立的代码段的副本,而现代的操作系统,每个进程都共享一个代码段以节省内存。而且对于一个比较大的程序,加载时重定位所有引用的话也会严重拖慢程序的加载速度。

并且,如果是可执行文件调用共享库还好,如果是共享库之间进行调用的话,需要对共享库进行重定位,那么就失去了共享库的性质了。

GOT

现代系统有一个机制,可以生成位置无关代码,也就是无论代码被加载到哪里,都可以被正常执行,无需重定位代码段。现代编译器产生的共享库和可执行文件都是位置无关代码。

位置无关代码的原理是,同一个共享库/可执行文件中的数据段和代码段的相对距离是不变的,也就是代码段中可以 PC 相对地址来引用代码段。

编译器在数据段最开始的地方创建了一个叫做 GOT(全局偏移量表),这个表中每个条目都是 8 字节,表示一个地址。编译器会在 GOT 中给程序中的所有全局符号都建立一个条目,表示这个全局符号的绝对地址,初始时为空,等待重定位,GOT 会对应一个重定位表。这样,加载程序的时候,动态链接器无需重定位代码段,只需要重定位 GOT 表即可。每个可执行文件和共享库实例都有一个独立 GOT 表,也就是加载他们的时候只需要建立 GOT 表和重定位 GOT 表的代价。

GOT + PLT

使用 GOT 表就可以解决很多问题了。但是现代编译系统引入了 PLT 来支持动态绑定机制。比如我们的代码用到了外部库中成千上百个函数,如果如果在加载时,将这些函数对应在 GOT 表中的项目全部重定位,会造成很大的性能开销。

PLT 中每个条目都是一个 16 字节代码。 PLT 拥有可执行权限。

GOT 和 PLT 联合使用时,GOT[0] 表明 .dynamic 节的地址,GOT[1] 表明重定位条目的地址,GOT[2] 表明动态链接器的入口地址。PLT[0] 处对应的代码是将 GOT[1] 压入栈中并调用动态链接器。

每个外部函数在 .rel.plt 段会有一项,表明该函数的符号名称之类的。同时还会在 PLT 中有一个对应的条目。

当程序调用外部函数的时候,会先跳转到外部函数对应的 PLT 条目中,PLT 条目中的第一个执行是跳转指令,跳转到其对应的 GOT 条目中指向的地址。

在初始时,这个地址又指向 PLT 条目的第二条指令,这个指令会将其对应的函数在 .rel.plt 段的索引放入栈中,然后跳到 PLT[0] 中,来调用动态链接器。

动态链接器根据压入栈中的 .rel.plt 段的索引来拿到要调用函数的符号,然后寻找到对应函数的真实地址,然后再写回 GOT 中,然后再调用对应函数。

后续再次调用外部函数的时候,从 PLT 跳到 GOT 对应的条目时,这个条目已经指明了外部函数的地址,可以直接跳过去:

这也就实现了延迟绑定。

6. 加载

TODO

5. 库打桩

库打桩可以让我们拦截对一个共享库的调用,反而执行自己的代码。

5.1 编译时打桩

首先有一个我们自己的函数:

然后编译 gcc -DCOMPILETIME -c mymalloc.c

然后我们再编写一个自己的头文件,在这个头文件里通过宏来更改调用函数的名称:

编译:gcc -I. -o intc int.c mymalloc.o

其中 -I. 是打桩的关键。这个参数将会让编译器优先从当前目录下寻找头文件,所以编译器会使用我们的 malloc.h 而不是标准的 malloc.h

5.2 链接时打桩

链接器支持使用 --wrap f 的选项进行链接时打桩。这个选项告诉链接器,把对符号 f 的引用全部解析为对 __wrap_f (用于拦截),还把对 __real_f 的引用解析为对 f 的引用(用于调用真实函数),于是我们有:

编译:gcc -DLINKTIME -c mymalloc.cgcc -c int.c

链接:gcc -Wl,--wrap,malloc -Wl,--wrap,free -o intc int.o mymalloc.o

其中 -Wl,option 用于将 option 传递给链接器,并且 option 中的每个逗号都被替换成空格。

5.3 运行时打桩

有一个全局变量 LD_PRELOAD,程序在加载共享库时,动态链接器会先从 LD_PRELOAD 中寻找对应的共享库。我们把要拦截的函数写成一个共享库之后,把路径添加到 LD_PRELOAD 中就可以实现打桩。

6. 相关工具

  • ar:创建静态库,插入、删除、列出、提取成员。
  • strings:列出一个目标文件中所有可打印的字符串。
  • strip:从目标文件中删除符号表信息。
  • nm:列出一个目标文件的符号表中定义的符号。
  • size:列出目标文件中节的名字和大小。
  • readelf:显示一个目标文件的完整结构,包括 ELF 头中编码的所有信息。包含 sizenm 的功能。
  • objdump:显示目标文件的所有信息,主要用于反编译 .text 的指令
  • ldd:列出可执行文件在运行时需要的共享库