[技术向] CSAPP (深入理解计算机系统) 编译与链接

7

链接是指将多个代码/数据片段合并成单个可执行文件的过程, 可以发生在编译期/装载期/运行期, 大部分情况下由专门的链接器来完成. 链接器 (ld) 对于程序开发是必不可少的, 因为它允许分别编译 (separate compilation), 代码修改后只需要重新编译变更过的部分, 再重新链接, 无需编译其他不变的部分. 它也是共享库的基础.​

编译驱动程序

大多数编译系统会提供编译驱动程序, 在用户需要时调用 <语言预处理器, 编译器, 汇编器, 链接器>. ​

编译驱动程序的工作流程,以 gcc 为例:

  1. 先运行 C 预处理器 (cpp), 将源码文件 main.c 转译成中间文件 main.i

  2. 运行 C 编译器 (cc1), 将中间文件 main.i 转译成汇编文件 main.s

  3. 运行汇编器, 将汇编文件 main.s 转译成可重定位 (relocatable) 的目标文件 main.o

  4. 对于 sum.c , 也是同样的步骤​

  5. 最后, 链接 main.osum.o 得到可执行的目标文件​

通过 shell 运行 ./prog, 此时 shell 会调用操作系统的装载程序, 将可执行文件的代码和数据拷贝到内存中, 再把控制权转移到程序第一条指令的位置.

下面这张图更清晰:​

静态链接

Linux 中的 LD 程序就属于静态链接, 接收若干可重定位的目标文件和命令行参数, 生成一个完整链接 (fully-linked, 相对于 dynamic-linked) 的可执行文件.​

链接器的主要工作包括:

  • [ 符号解析 ] 目标文件定义和引用了许多 symbol, 可以代表一个函数/全局变量/静态变量. 符号解析的目的是准确建立 symbol 的 “定义” 和 “引用” 之间的联系.​

  • [ 重定位 ] 目标文件中的各个代码区和数据区均以 0 为起始地址. 链接器会给每个 symbol 分配一个虚拟内存地址, 在此基础上修改符号的引用, 实现重定位.​

目标文件

目标文件仅仅是一些二进制块, 分成若干区域 (section), 有些包含程序代码, 有些包含程序数据, 还有一些包含指导链接器和加载器的数据结构. 链接器负责将这些二进制块连接在一起, 决定运行时的各种位置. 大部分与目标机器相关的工作是由编译器和汇编器完成的, 链接器对此了解甚少.​

目标文件有 3 种形式:​

  • [ 可重定位的 ] 包含二进制代码和数据, 仍未链接.​

  • [ 可执行的 ] 链接好的, 能够被 load 进内存直接执行.​

  • [ 共享的 ] 可以被动态链接和 load.​

不同系统的目标文件格式各异, 现代的 Linux 系统使用 ELF 格式 (Executable & Linkable Format).​

可重定位的目标文件

ELF 格式下, 可重定位的目标文件内部划分如下:​

符号表

符号表是汇编器创建的, 保存在 .symtab 区域, 其初始依据是编译器输出的符号, 但并不是完全照搬.

目标文件中的符号表只包含函数和全局变量 (以及诸如 C 语言中的静态变量); 编译过程的符号表则还包括局部变量.

局部变量是在运行期间在栈上管理的, 不在链接器的职责范围内.

一个符号表的条目一般包含以下内容:

符号解析

链接器接收的输入是若干个可重定位的目标文件, 也就是说, 需要处理若干张符号表.

如前面说的, 所谓的符号解析就是准确建立符号定义和其引用之间的联系. 对于本地符号 (在模块内部定义, 并且没有被其他模块引用), 建立这一联系是比较简单的, 由编译器自己完成就可以了. 但是对于全局符号 (在一个模块内部定义, 被其他模块引用), 编译器会生成一个特殊条目, 让链接器去处理. 如果链接器在其他模块中没有找到这个符号的定义, 就会报错.

比如下面的 C 语言代码:​

void foo(void);

int main() {
    foo();
    return 0;
}

// undefined reference to ‘foo’

显然, 不同模块也有可能定义相同名字的全局符号. 为了应对这种情况, 编译器在将结果导出到汇编器的时候, 会给每个全局符号打上 strong/weak 的标志, 汇编器则会把这一信息编码到符号表中. 函数和已初始化的全局变量对应 strong 标志, 未初始化的全局变量对应 weak 标志. 接下来, 链接器会按照下列规则处理同名的全局符号:​

  1. 不允许多个 strong 变量同名​

  2. 对于一个 strong 变量和多个 weak 变量同名, 选择 strong 变量 (初始化的)​

  3. 对于多个 weak 变量同名, 选择任意一个​

对于规则 2 和 3, 如果变量的类型不一致 (比如 int 和 double), 链接器不一定会报错, 这样在运行时就会出问题, 也很难定位. 因此, 以 GCC 为例, 最好是加上 -fno-common 标志, 把同名全局符号一律当做错误处理.​

链接的结果除了可执行的目标文件外, 也可以是静态库. 以静态库作为输入, 链接器只会链接被应用程序引用的那些模块. 在下图中, 对于静态库 libvector.a, 应用程序只引用了其中的 addvec 函数, 因此链接器只抽取了 addvec.o 和应用程序进行链接.​

链接命令中的 -static 表示输出结果是完整的可执行目标文件:​

gcc -c main2.c
gcc -static -o prog2c main2.o /libvector.a

链接器对输入文件是按从左到右处理, 并且每个文件只处理一次. 如果把静态库放在最前面, 那么链接器会认为其中没有任何模块被引用, 这样在处理后面的应用程序时可能就会报错. 因此, 链接的输入顺序也是要仔细确定的.​

重定位

链接器完成符号解析之后, 就可以进行重定位:​

  1. 合并区域: 对输入的各目标文件中相同类型的区域 (section) 进行合并​

  2. 确定地址: 区域合并后, 为所有指令和已定义的符号确定运行时的内存地址​

  3. 更新引用: 将符号引用定位到正确的地址​

链接器不会通过扫描所有区域来找到需要重定位的符号引用, 而是通过汇编器给出的重定位条目 (relocation entry, 存放在 .rel.text.rel.data 中). 每个重定位条目包含了符号引用在区域中的偏移量, 以及对应的符号. 链接器只需要遍历这些条目, 再根据区域的地址和符号的地址作一些基本运算就好了.​

可执行的目标文件

ELF 格式下, 可执行的目标文件内部划分如下:​

ELF header 包含了该目标文件的格式信息, 以及程序的入口点 (entry point), 即第一条指令的地址.​

装载

装载是指 OS 在正式运行应用程序之前, 将可执行目标文件中的代码和数据拷贝到内存.

对应的内存结构如下:​

程序在运行之初 (发起 execve 系统调用) 并不会将可执行目标文件的所有代码和数据拷贝到内存中, 而是建立内存映射 (见虚拟内存), 由操作系统按需加载.​

共享的目标文件

静态库的缺点是, 更新后需要再次链接, 并且对于一些常见的库 (如标准 I/O), 会在内存中产生冗余. ​

共享库 (shared library) 则克服了这些缺陷, 能在运行时被装载进内存的任意地址, 与应用程序进行动态链接. 在 Linux 中, 共享库以 .so 作为后缀; 在 Windows 中, 共享库被称为 DLLs (dynamic link libraries). ​

共享体现在两个方面:​

  1. 从文件系统的角度看, 特定的库只有一个 .so 文件, 不会以任何形式拷贝或嵌入到其他文件中

  2. 从内存管理的角度看, 单个共享库的 .text 区域可以被多个程序映射​ (见 虚拟内存)

动态链接

链接命令中的 -shared 表示输出一个共享的目标文件, -fpic 指的是位置独立代码, 后面会介绍:​

# 生成共享库 -> libvector.so
gcc -shared -fpic -o libvector.so addvec.c multivec.c

动态链接的基本思想其实是将整个链接过程分成两阶段:​

第一阶段, 在生成可执行目标文件的时候, 只生成共享库的符号表信息, 不拷贝其中的代码和数据​.

第二阶段, 在运行时链接代码和数据, 这部分工作由系统的动态链接器 (ld-linux.so) 完成​, 进行重定位:

  1. 对共享库 (libc.so, libvector.so) 的代码和数据进行重定位​

  2. 对应用程序 (prog2l) 对共享库的符号引用进行重定位​

动态链接还有另外一种情况, 那就是应用程序在运行时要求动态链接器装载并链接任意的共享库, 这一行为在编译期是不可知的. 典型场景如: 自动链接最新版本的库, Web 服务的动态加载, 以及 Java 中的本地接口 (JNI).​

Linux 为应用程序提供了 dlopen 接口进行这种"完全"的动态链接:​

装载并链接成功后, 可以通过 dlsym 接口访问其中的符号 (比如函数, 返回的是一个函数指针):​

void *handle;
void (*addvec)(int *, int *, int *, int);

handle = dlopen("./libvector.so", RTLD_LAZY);
addvec = dlsym(handle, "addvec");
addvec(x, y, z, 2);
// 省略了错误处理

位置独立代码 (PIC)

位置独立代码 (Position-Independent-Code) 是指不用考虑重定位的代码, 也就是可以被装载到任意位置的内存中.

共享库必须满足 PIC 性质.

编译器在生成 PIC 引用的时候, 会借助全局偏移表 (Global Offset Table, GOT), 记录每个全局变量的偏移值, 并且令代码段中的指令和 GOT 的记录有一个 (在运行时) 固定的距离, 这样就实现了位置独立.​

对于函数, 编译器会通过过程链接表 (Procedure Linkage Table, PLT), 结合 GOT 来实现懒绑定 (lazy binding).​

库插入

库插入 (interpositioning) 是指截断对共享库函数的调用, 转而执行自己的代码, 本质是对原来的函数进行包装, 这与设计模式中的装饰器模式、面向切面编程十分类似.

库插入可以发生在编译期、链接期或运行期, 对应不同的机制.​

Make

http://makefiletutorial.foofun.cn/​

Make 工具最主要的功能是通过 Makefile 文件来描述源程序之间的相互关系, 并自动维护编译工作. ​

Makefile 文件需要说明各个源文件的编译和链接命令, 并通过 : 定义源文件之间的依赖关系. ​

Makefile 的基本构成是一组规则:​

targets: prerequisites # rule 1
    command

Make 通过检查目标与依赖项的当前时间戳, 来确定目标是否最新.​