链接器的世界/ 原理篇/ 17 篇
54 分钟阅读公开阅读

[链接器的世界-原理篇00] 从一条编译命令出发:程序如何连成整体

写下 return add(1, 2); 时,我们只需要知道函数的名字和参数。可处理器执行到这里,需要知道的是:下一步去哪个地址。

如果 add 定义在另一个文件中,编译当前文件时,这个地址往往还没有确定。那个文件可以稍后才编译,也可以早已存在于某个库里。独立生成的机器码,最后怎样接成一个能运行的程序?

这就是本系列的起点。我们将从一次跨文件调用出发,观察链接器如何寻找定义、安排代码与数据的位置、修补地址,再沿着可执行文件进入进程,理解哪些工作留到了运行时。

一条命令怎样处理两个文件

先把跨文件调用写成一个正常的程序。add.c 定义加法函数,call_add.c 中的声明给出函数的返回类型和参数类型,使编译器能检查调用;函数的实现仍然留在另一个文件里。

// add.c
int add(int a, int b) { return a + b; }
// call_add.c
int add(int a, int b);
int main(void) {
return add(1, 2);
}

将它们保存在同一目录,在安装了 C 开发工具的 x86-64 Linux 上执行:

$ gcc call_add.c add.c -o app # -o:将输出文件命名为 app
$ ./app
$ echo $?
3

程序没有打印文字;main 返回的 3 是退出状态,紧接着执行的 echo $? 将它显示出来。我们先用这个结果确认:一个文件中的调用确实执行了另一个文件里的函数。

这里用 GCC1 完成构建。接下来保留中间文件,看看两份源码是在什么时候连起来的。

命令接受两份源码,不代表某一个程序一次完成了所有工作。它首先承担编译器驱动程序(compiler driver)的职责:根据输入文件和选项,组织生成程序所需的步骤。

第一步是预处理。C 源码中的 #include 引入头文件内容,#define 定义的宏在使用处展开,条件编译决定保留哪些代码。编译器随后对预处理后的 C 代码进行语法、类型检查和优化,并生成目标处理器的指令。指令可以先写成便于人阅读的汇编语言,再由汇编器编码成 CPU 执行的机器码。这是编译与汇编两个不同的工作,即使实现它们的软件被放进了同一个程序。

每份源码经过这些步骤,得到一个目标文件,通常以 .o 结尾。它既保存机器码和数据,也保存尚未解决的跨文件引用:编译 call_add.c 时,编译器已经知道怎样传入两个整数,却还不知道 add 最终放在哪个地址。目标文件必须把这个待解决的引用留下来。

最后才是链接。链接器读取这些目标文件,找到 add 的定义,安排代码和数据的位置,修补与地址有关的引用,并生成可执行文件。驱动程序还会自动补入所需的启动代码和标准库;即使源码只有 main,操作系统交出控制权之后也还需要启动代码把执行引到 main。

可以把同一次构建拆开,让中间产物留在目录里:

$ gcc -c call_add.c -o call_add.o # -c:生成目标文件后停止,不链接
$ gcc -c add.c -o add.o
$ gcc call_add.o add.o -o app
$ ./app
$ echo $?
3

第三条命令的输入已经是目标文件,因此驱动程序直接组织链接,不再把 C 源码编译一遍。改动 add.c 后,只需重新生成 add.o,再执行链接;call_add.o 可以复用。这正是把程序拆成文件以后,保留中间产物的价值。

想确认驱动程序实际调用了哪些工具,可以查看它准备执行的命令:

$ gcc -### call_add.c add.c -o app # -###:显示内部命令,但不实际构建

输出可能包含 cc12、as3、collect24 和 ld5。此时只需把它们对应到源码处理、汇编和链接这些职责上;具体名称及 GCC、Clang6 的实现差异见文末附录。同样的阶段划分,不要求工具链启动同样数量的进程。

分开编译让每个文件可以独立处理,也限制了每一步能看到的信息。刚才两个文件对 add 的理解一致:两个整数进去,一个整数出来。如果同一个名字在两份源码里代表了不同的类型,这套流程还能把问题拦下来吗?

同一个名字,两种理解

设想一个变量原本是整数,某处代码却把它当成了浮点数。若定义和使用就在同一个文件里,编译器能看见冲突。把它们分到两个文件,情况就变了:main.c 分配两个整数 x、y,调用 set_x 后打印它们;other.c 只知道自己要改一个名叫 x 的变量。

// main.c
#include <stdio.h>
int y = 5;
int x = 7;
void set_x(void); /* 定义在 other.c 里 */
int main(void) {
set_x();
printf("x = %d, y = %d\n", x, y);
return 0;
}
// other.c
extern double x;
void set_x(void) {
x = 1.0;
}

main.c 定义了两个全局变量 x 和 y,然后调用另一个文件里的 set_x。other.c 用 extern 声明了 x,意思是"这个变量定义在别的文件里,我只是要用它",然后给它赋值 1.0。问题在于,main.c 里的 x 是 int,other.c 以为它是 double。

单独编译 other.c 时,编译器看不到 main.c 中的定义,只能按 extern double x 生成写入代码。这里的 extern 不分配另一份 x,它要求稍后把这次访问接到别处的定义上。

两边显然不一致。剩下的问题是,组合两个文件的那一步能不能发现它。

编译和链接都通过了

下面固定使用 x86-64 Linux、GCC 15.2 和 musl7 1.2.5,以便逐字节观察同一份静态可执行文件。musl-gcc8 为本机 GCC 选择 musl;它仍在这台 Linux 上编译、链接和运行。开头的普通函数调用不需要这项选择,这里引入它是为了固定后续检查的 C 库与启动代码。

先建立实验目录,再将上面的两段源码分别保存为该目录中的 main.c、other.c:

mkdir -p linker-example-ch00
cd linker-example-ch00

下面分别编译两个文件,再把它们静态链接成一个程序。这里的静态链接把所需的库代码纳入可执行文件,因此这个例子运行时不需要另找 musl 的共享库。

这两份源码的声明类型不兼容,程序具有 UB9。下面的输出是所列构建的一次观察,用来分析具体机器码,不是 C 语言保证的结果。

$ musl-gcc -O2 -Wall -Wextra -c main.c -o main.o # -O2:优化;-Wall -Wextra:启用两组警告
$ musl-gcc -O2 -Wall -Wextra -c other.c -o other.o
$ musl-gcc -static main.o other.o -o prog # -static:静态链接所需的库代码
$ ./prog
x = 0, y = 1072693248

编译没有一条警告,链接也没有任何报错。运行结果里,x 变成了 0,而 y 变成了一个十亿多的数。整个程序里没有任何一行代码给 y 赋过值。

这里仍用 -c 分别生成目标文件 main.o、other.o,第三条命令再组织链接,生成可执行文件 prog。最后直接执行 ./prog,编译器、链接器与加载程序使用同一台 x86-64 Linux。

这个程序已经违反了 C 对同一对象的声明必须具有兼容类型的要求,产生 UB9:语言标准不保证它输出任何特定数字(C11 草案 N1570,§6.2.7 第 2 段)。下面解释的是这组命令生成的机器码为何产生了这次输出,不能据此把某个输出当成此程序在所有平台上的结果。

这次观察到的结果还与编译选项有关。这一版 GCC 在 -O2 下把 main.c 的两个全局变量按源码的逆序摆放,x 在前,y 紧随其后。改用 -O0、加上 -fno-toplevel-reorder,或者换成 clang 编译,变量都按源码顺序摆放,y 落在 x 前面,这三组 Linux 实测都先打印 x = 0, y = 5,随后以 SIGSEGV 终止;y 保持原值,不代表越界写入消失。两个变量在 .data 里谁先谁后,是编译器生成 main.o 时定下的,链接器只是把这一整块数据原样搬进 prog,并不重新排列。

要解释这份产物里的两个数,需要把赋值看成一次内存写入。1.0 这个 double 的位模式是 0x3FF0000000000000,一共 8 个字节。x86-64 是小端序(little-endian)的,也就是一个多字节的数在内存里低位字节放在低地址。所以 set_x 往 x 的地址写入这 8 个字节时,低 4 个字节 0x00000000 写进了 x,x 变成 0;高 4 个字节 0x3FF00000 落到了紧随其后的 y 上,0x3FF00000 换成十进制正好是 1072693248。main.c 只给 x 留了 4 个字节,other.c 却按 8 个字节去写。

这次写入没有把浮点数转换成整数:两份机器码只是最终访问了同一个地址。把不同文件里同名的引用和定义关联起来,叫符号解析;目标文件中记录这些名字、位置和属性的条目,叫符号(symbol)。负责完成这项工作的链接器,把 other.o 对 x 的引用接到了 main.o 的定义上。

符号表可以记录对象占多少字节,却没有完整的 C 类型约束,不能凭同名就证明这次写入合法。普通的本机目标文件链接通常不会检查两边的 C 类型;开启链接期优化时,编译器可以借助保留下来的中间表示发现部分跨文件不匹配,这与只读取符号表的链接路径不同。关于检查能力的边界,GCC 的 LTO 文档专门说明了跨翻译单元类型不匹配的诊断。

眼前还有另一件事值得注意:这个程序虽然写错了,操作系统仍然把它启动起来了。链接器显然生成了一个操作系统能够接受的文件。内核检查的究竟是什么?

内核接受了怎样的文件

prog 除了机器指令和变量内容,还必须说明这些字节应当放到哪里、哪些区域允许写入、从哪个地址开始执行。内核根据这些信息建立进程,不会重新检查 C 源码。

本例以及通常的 Linux 本机工具链生成的可执行文件、目标文件和共享库使用 ELF10 格式,它是一套规定"文件里每个字节放什么"的规范。工具 readelf11 可以把 ELF 文件的结构打印出来。下面用 -l 选项查看 prog 的程序头,-W 让每行不折断。

$ readelf -lW prog
Elf file type is EXEC (Executable file)
Entry point 0x40109e
There are 6 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x0000000000400000 0x0000000000400000 0x000190 0x000190 R 0x1000
LOAD 0x001000 0x0000000000401000 0x0000000000401000 0x004947 0x004947 R E 0x1000
LOAD 0x006000 0x0000000000406000 0x0000000000406000 0x000cc8 0x000cc8 R 0x1000
LOAD 0x006fc0 0x0000000000407fc0 0x0000000000407fc0 0x000150 0x0007f8 RW 0x1000
GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10
GNU_RELRO 0x006fc0 0x0000000000407fc0 0x0000000000407fc0 0x000040 0x000040 R 0x1
Section to Segment mapping:
Segment Sections...
00
01 .init .text .fini
02 .rodata .eh_frame
03 .init_array .fini_array .data.rel.ro .got .got.plt .data .bss
04
05 .init_array .fini_array .data.rel.ro .got .got.plt

程序头表(program header table)由若干条程序头记录组成。其中 LOAD 描述需要映射进内存的区域,其他类型则传递解释器路径、栈权限等加载信息。在 shell 里执行 ./prog 后,启动它的执行路径通过 execve 将文件交给内核。对于这个静态可执行文件,可以先沿着两类信息理解启动过程:入口地址(entry point),以及类型为 LOAD 的四行。内核还要验证 ELF 头,并处理栈权限等属性;这两类信息不是它做的全部检查。

一行 LOAD 的意思是:从文件的 Offset 处取 FileSiz 个字节,放到内存地址 VirtAddr 处,这块内存一共占 MemSiz 个字节,权限是 Flg。R 表示可读,W 表示可写,E 表示可执行。VirtAddr 是虚拟地址,也就是程序自己看到的地址,操作系统会把它映射到某块实际的物理内存上。

把四行连起来读,内核做的事情就清楚了。文件开头 0x190 个字节放到 0x400000,只读;接着 0x4947 个字节的机器码放到 0x401000,可读可执行;然后是只读数据;最后一段放到 0x407fc0,可读可写,文件里只有 0x150 个字节,内存里却要占 0x7f8 个字节,多出来的部分由内核填零。四段都放好后,内核再给程序建好栈,也就是函数调用时存放局部变量和返回地址的那块内存,并在栈上放好命令行参数和环境变量。最后它把 CPU 的指令指针(x86-64 上叫 %rip,存着下一条要执行的指令的地址)设成 0x40109e,程序就开始运行了。

GNU_STACK 这一行也会被内核读到,它声明栈要不要可执行。可执行栈指栈上的字节能被当作指令执行,这曾经是某些老技巧需要的,也给攻击者往栈上注入代码提供了方便,这里的 RW 表示不需要。其他程序头各有自己的使用者。例如 GNU_RELRO 标明重定位完成后可改为只读的范围,GNU_PROPERTY 携带处理器相关属性;它们是否以及怎样生效,要结合目标架构、内核和运行时实现判断。

下半部分的 Section to Segment mapping 是另一种视角。链接器在拼装程序时,处理的单位是节(section),比如放机器码的 .text、放已初始化全局变量的 .data、放未初始化全局变量的 .bss,其余的节后面各章讲。这里的四个 LOAD 描述可加载段(segment),一个可加载段可以覆盖若干节。常规 ELF 装载依赖程序头描述的段,不靠节名逐个装载 .text 或 .data。我们的 x 和 y 就在 .data 里,位于第四个段。

这些地址和范围由链接器写入。编译器生成的 main.o 里根本没有程序头,用 readelf -l main.o 只会得到一句 There are no program headers in this file.。0x40109e 这个入口地址、四个段的划分、每段的权限、.data 被放在 0x408000,这些全是链接器在拼装时决定的。main.o 里 x 的地址还是 0,到了 prog 里才变成 0x408008,这也是链接器算出来的。

至此能分清两件事:程序头足以让内核建立进程,却不足以证明进程执行的操作正确。类型冲突、找不到函数、找不到共享库,也不能都指望同一个组件来发现。它们分别暴露在程序形成和启动的不同阶段。

四个时期,四种错误

一个程序从源码到运行,要经过四个时期。编译期,编译器把每个 .c 文件单独翻译成目标文件;链接期,链接器把所有目标文件和库拼成可执行文件;加载期,内核和动态链接器把可执行文件放进内存,准备好运行环境;运行期,CPU 从入口开始执行指令。这里把预处理、编译和汇编合在生成目标文件这一侧;“加载期”指启动路径,运行中的 dlopen 仍然可以再次触发加载与动态链接。

这个划分对排查问题很有用,因为每个时期能看到的信息不同,能发现的错误也不同。越早的时期发现错误,报错信息越贴近源码,修起来越容易。下面用同一个小程序在每个时期各制造一个错误,观察错误究竟在哪一步被发现。为保留前面的类型不匹配示例,在它旁边建立独立目录;本节的 main.c、main.o 都属于这个新例子。

mkdir -p ../linker-example-phases
cd ../linker-example-phases
// add.c
int add(int a, int b) { return a + b; }
// main.c
int add(int a, int b);
int main(void) {
return add(1, 2);
}

编译期:类型错误

编译器手里有一个完整的翻译单元,也就是一个 .c 文件加上它 #include 进来的全部头文件。在这个范围里,它知道每个名字的类型,可以检查调用是否和声明一致。把 main.c 里的调用改成 add(1),另存为 bad.c:

$ musl-gcc -c bad.c
bad.c: In function 'main':
bad.c:5:12: error: too few arguments to function 'add'; expected 2, have 1
5 | return add(1);
| ^~~

(节选,后面还有指向声明位置的 note。)报错精确到行和列。编译器之所以能发现,是因为 add 的声明就写在同一个文件里。

链接期:undefined reference

再用正确的 main.c,这次编译单独通过,链接时却忘了带上 add.c:

$ musl-gcc -c main.c
$ musl-gcc -static main.o -o app
ld: main.o: in function `main':
main.c:(.text+0x13): undefined reference to `add'
collect2: error: ld returned 1 exit status

(第一行开头的 ld 原本是本机的 /usr/bin/x86_64-linux-gnu-ld.bfd 路径,这里截短了。)

这两行诊断来自前面看到的调用链:链接器 ld 报告缺少定义,GCC 的辅助程序 collect2 接着报告链接器执行失败。编译 main.c 时,编译器只看到了 add 的声明,就在 main.o 里留下一个未定义的符号 add,并在机器码里空出一个"地址待填"的位置。到了链接期,链接器在所有输入里都找不到 add 的定义,于是报错。(.text+0x13) 说的是 main.o 的 .text 节第 0x13 个字节处有个地址填不上。

换成 LLVM12 的 LLD13 链接器,通过它处理 ELF 文件的命令 ld.lld 执行同一次缺定义检查,措辞有所不同:

$ ld.lld -static main.o -o /dev/null
ld.lld: error: undefined symbol: add
>>> referenced by main.c
>>> main.o:(main)

普通链接过程能汇集输入目标文件的符号信息,却不会因此恢复 C 的函数声明。这里的 main.o 留下了对 add 的引用,常规符号表没有记录它接受几个参数;链接器能检查是否存在可用定义,不能据此核对这次调用的参数列表。

加载期:找不到共享库

前面的程序都是静态链接的。更常见的做法是动态链接:库代码放在一个单独的共享库文件里(Linux 上以 .so 结尾),可执行文件里只记下要用哪个库,等程序运行时再把库装进来。负责在运行前把共享库找到、装好、把地址填上的程序叫动态链接器,常简称 ld.so,在 musl 系统上它是 /lib/ld-musl-x86_64.so.1,在使用 glibc14 的系统上通常是 ld-linux-x86-64.so.2。

把 add.c 做成共享库 libadd.so,再让 main.c 动态链接到它:

$ musl-gcc -shared -fPIC add.c -o libadd.so
$ musl-gcc main.c -L. -ladd -o calc_dyn
$ readelf -lW calc_dyn | grep -A1 INTERP
INTERP 0x000238 0x0000000000000238 0x0000000000000238 0x000019 0x000019 R 0x1
[Requesting program interpreter: /lib/ld-musl-x86_64.so.1]
$ readelf -dW calc_dyn | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libadd.so]
0x0000000000000001 (NEEDED) Shared library: [libc.so]

-shared 让链接器输出共享库;-fPIC 让编译器生成不依赖固定加载地址的代码,因为共享库每次被装到哪里事先说不准。-L. 告诉链接器去当前目录找库,-ladd 表示链接 libadd.so。链接成功了,因为链接器在当前目录找到了 libadd.so,确认里面有 add。程序头里多出一行 INTERP,内核读到它,就知道要先把动态链接器装进来,由它接手后面的工作。动态链接器再去读 NEEDED 这两条记录,挨个寻找需要的共享库。

直接运行时,libadd.so 所在的目录不在默认的搜索路径里:

$ ./calc_dyn
Error loading shared library libadd.so: No such file or directory (needed by ./calc_dyn)
Error relocating ./calc_dyn: add: symbol not found
$ echo $?
127

$? 是 shell 里上一条命令的退出码,也就是 main 的返回值或者传给 exit 的那个数,0 表示成功。这里的 127 是动态链接器失败时给出的。main 的第一条指令还没执行,程序就失败了。用环境变量 LD_LIBRARY_PATH 把那个目录加进搜索路径,程序就能正常运行,退出码是 1 + 2 = 3:

$ LD_LIBRARY_PATH=. ./calc_dyn
$ echo $?
3

这类错误在加载期才暴露,原因很直接:链接时找得到的文件,到了运行它的那台机器上不一定还在原处。开发机上编译、链接、运行都好好的程序,拷到一台没装这个库、或者库装在别的目录的机器上,第一条指令都执行不了,部署时最常见的就是这一类报错。加载期还会拦下别的问题,比如 glibc 2.41 起,动态链接器会拒绝用 dlopen(程序在运行中途主动加载共享库的函数)加载一个声明需要可执行栈的共享库(glibc 2.41 的 NEWS)。这一项本章没有实测,只作示意。

运行期:链接成功也可能是错的

回到本章开头的程序。它在编译期、链接期、加载期都顺利通过,错误到了运行期才以"打印出一个怪数"的形式出现。这类错误最难查,因为没有任何工具在出错的那一刻报告。如果 y 是一个很少被读到的配置项,这个程序可能要过很久才露出问题。

几个例子中的检查对象并不相同。编译器按当前翻译单元中的声明检查调用;普通链接器按目标文件中的符号和重定位建立引用关系;加载器检查文件是否能够按要求映射和启动。它们都成功,也不能证明 other.c 对 x 的类型理解正确。把 extern int x 放进公共头文件,并让定义方和使用方都包含它,才使编译器有机会在各自编译时发现声明与使用的冲突。

反过来,拿到一条报错,也能从它的样子认出是哪个时期发出的。编译器的报错带着源文件名、行号和列号,比如 bad.c:5:12,因为它手里有源码。链接器的报错开头是链接器自己的名字,ld: 或者 ld.lld:,位置写成"哪个目标文件、哪个节、第几个字节",比如 main.c:(.text+0x13),经过 gcc 调用时最后还会跟一行 collect2。加载期的报错来自动态链接器,musl 上以 Error loading shared library 开头,这时 main 还没开始执行。运行期的错误往往根本没有报错,只有一个不对的输出,或者 shell 报告被信号终止。常见 shell 会用 128 + 信号编号 表示这种终止,但程序也能主动返回同样的数值,不能仅凭“大于 128”就下结论。排查问题的第一步,就是先认出它属于哪个时期,再去找那个时期手里有什么信息。

名字之外,链接器还决定了什么

从 other.c 的一条赋值到 prog 的程序头,中间至少有三件不同的事。链接器先确认这次访问对应哪个定义,再为定义所在的数据分配输出位置,最后把访问所需的地址或位移写进代码。这三件事通常称为符号解析、布局和重定位。

main.c ──编译──▶ main.o ─┐
├──▶ 链接器 ──▶ prog ──▶ 加载器 ──▶ 进程
other.c ──编译──▶ other.o ─┘ (可执行文件)

源码中的名字到这里有了地址,文件中的字节也有了内存位置。符号解析选错定义、布局给错权限、重定位填错位移,可能产生完全不同的症状。即使这些工作都做对,源码中的类型冲突仍然可能被原样带进进程。判断链接器是否正确,需要同时检查它如何理解输入,以及它的输出会怎样被执行。

反过来看,分开编译也带来了实际收益:修改 other.c 时可以复用 main.o,库的实现可以独立构建和分发。代价是每个半成品必须留下足够的信息,说明自己定义了什么、还需要什么、哪些位置等待修补。目标文件因此不能只是一串机器指令;链接器也不能只把文件首尾相接。

这套分工形成之前,程序同样遇到过“把别处写好的代码接进来”的需求。没有目标文件、没有符号表时,搬动一段代码,程序员就得亲手修改里面的地址。链接器的来历,要从这件具体的工作讲起。

实验环境

Linux:编译与运行在同一台机器上

ELF 主线以 x86-64 Linux 为目标。最直接的环境是一台 x86-64 Linux 主机或虚拟机:编译器生成本机机器码,Linux 内核直接加载程序。以下全部命令都在这台 Linux 机器的终端执行;从其他电脑通过 SSH 登录也一样,不在登录端生成实验产物。

先确认系统与处理器:

uname -sm

系统应为 Linux,处理器为 x86_64。如果是在跨架构容器里执行,uname 可能反映模拟出的架构,还要检查宿主内核和运行方式,不能仅凭这行输出判断是否原生执行。

以 Debian/Ubuntu 的原生环境为例,基础工具可以这样安装:

sudo apt-get update
sudo apt-get install build-essential binutils file gdb coreutils musl-tools clang llvm lld \
python3 git strace
gcc -dumpmachine
gcc --version
ld --version
mkdir -p "$HOME/linker-lab"
cd "$HOME/linker-lab"
printf 'int main(void) { return 42; }\n' > hello42.c
musl-gcc -static hello42.c -o hello42
file hello42
readelf -h hello42
./hello42
echo $?

gcc -dumpmachine 的目标应包含 x86_64 和 linux;最后的退出状态应为 42。build-essential 提供 GCC、C/C++ 开发文件和 make,binutils15 提供 ld、readelf、objdump16、nm17 等工具。Debian 的 musl-tools提供本机架构的 musl-gcc 包装器:在本机 GCC 上选择 musl 的头文件、库和链接设置,它不是把 ARM 主机变成 x86-64 交叉编译器。

本文记录来自 Ubuntu 26.04、GCC 15.2、GNU18 binutils 2.46、Clang/LLD 21.1.8。musl-gcc 用于 musl C 实验,gcc/g++ 用于系统 glibc 与 C++ 实验;切换 C 库会改变启动文件和动态解释器,不能混用其输出。后续各篇也沿用这一 Linux 环境。

练习

“观察”使用 linker-example-ch00 中类型不匹配示例的 prog、main.o 和 other.o,不使用后面加法示例的 main.o。“预测”与 A–F 各使用独立目录;“改坏”使用类型不匹配示例的源码副本。相同文件名在各例中代表不同的输入。

观察。

  1. 用 readelf -h prog 或 readelf -l prog 找出入口地址,再用 nm prog 查这个地址上是哪个符号。nm 会列出文件里的符号和它们的地址。入口是 main 吗?
  2. 第四个 LOAD 段的 FileSiz 是 0x150,MemSiz 是 0x7f8。先用 readelf -lW prog 下半部分的 Section to Segment mapping 找出这个段装了哪些节,再用 readelf -SW prog 查这些节的地址和大小,解释多出来的那部分内存对应什么。
  3. 分别运行 nm main.o 和 nm other.o。x 在两个文件里各显示成什么?再用 readelf -s other.o 看 x 的类型和大小一栏,能不能从中看出 other.c 以为它是 double?

预测。下面两个文件能不能链接?运行后会打印什么,还是会崩溃?先写下答案再动手。

// table.c
int table[2] = {1, 2};
// use.c
#include <stdio.h>
extern int *table;
int main(void) {
printf("table[1] = %d\n", table[1]);
return 0;
}

定位。下面六个程序各有一个错误。不要运行,先判断每个错误会在编译、链接、加载、运行哪个时期暴露。除特别说明外,都用 musl-gcc -static 把列出的文件一起编译链接。

A,单个文件:

#include <stdio.h>
int square(int n) { return n * n; }
int main(void) { printf("%d\n", squre(3)); return 0; }

B,两个文件都声明了一个不带初值的全局变量:

// a.c
int count;
void bump(void) { count++; }
// main.c
#include <stdio.h>
int count;
void bump(void);
int main(void) { bump(); printf("%d\n", count); return 0; }

C,main.c 想调用 util.c 里的辅助函数:

// util.c
static int helper(int n) { return n + 1; }
int twice(int n) { return helper(n) * 2; }
// main.c
int helper(int n);
int main(void) { return helper(41); }

D,头文件和定义不一致,两个 .c 都包含了头文件:

// scale.h
int scale(int v);
// scale.c
#include "scale.h"
long scale(long v) { return v * 10; }
// main.c
#include "scale.h"
int main(void) { return scale(4); }

E,把 greet.c 做成共享库,main.c 动态链接它,然后不设置 LD_LIBRARY_PATH,直接在 Linux 上运行可执行文件:

// greet.c
#include <stdio.h>
void greet(void) { puts("hello from libgreet"); }
// main.c
void greet(void);
int main(void) { greet(); return 0; }
$ musl-gcc -shared -fPIC greet.c -o libgreet.so
$ musl-gcc main.c -L. -lgreet -o prog

F,打开 -Wall -Wextra 编译:

// limit.c
long long limit = 5000000000LL;
// main.c
#include <stdio.h>
extern int limit;
int main(void) { printf("limit = %d\n", limit); return 0; }

改坏。同一对文件,只改一边的写法,看结果怎么变。把本章开头 other.c 的 extern double x; 改成带初值的定义 double x = 1.0;,set_x 的函数体留空,main.c 不动。先预测这次在哪个时期出错,再用 GNU ld 和 ld.lld 各链接一次,读懂两条报错,并用 readelf -s 比较两个 .o 里 x 的大小一栏。最后把 other.c 修好:写成 extern int x;,函数里赋值 x = 1;,重新编译运行,确认 y 回到了 5。

退出状态还可以成为自动测试的输入:正常退出与信号终止要分开表达,再由测试驱动核对。进程测试记录各条命令的进程返回状态;Python 的 subprocess 用负信号编号表示信号终止,便于与正常退出区分。

答案

以下结果在本机 Linux 的独立临时目录实测;每个案例都应保留自己的输入与产物,避免同名文件互相覆盖。

观察

观察 1。入口地址是 0x40109e,nm 显示这个地址上的符号是 _start,main 在 0x401070:

$ readelf -hW prog | grep Entry
Entry point address: 0x40109e
$ nm prog | grep -E " _start$| _start_c$| main$"
000000000040109e T _start
00000000004010c0 T _start_c
0000000000401070 T main

内核跳进去的第一条指令不在 main 里。_start 来自 C 库的启动文件,它从栈上取出参数、做完初始化,才去调用 main,main 返回后再替你调用 exit。这些启动代码是链接器从 C 库里拉进来的,第 6 章会细讲。

观察 2。Section to Segment mapping 里第 03 行对应第四个 LOAD,包括 .init_array、.fini_array、GOT19、.data、.bss:

$ readelf -lW prog | grep -E "^ 03"
03 .init_array .fini_array .data.rel.ro .got .got.plt .data .bss
$ readelf -SW prog | grep -E "init_array|fini_array|data|bss"
[ 4] .rodata PROGBITS 0000000000406000 006000 000c7a 00 A 0 0 32
[ 6] .init_array INIT_ARRAY 0000000000407fc0 006fc0 000008 08 WA 0 0 8
[ 7] .fini_array FINI_ARRAY 0000000000407fc8 006fc8 000008 08 WA 0 0 8
[ 8] .data.rel.ro PROGBITS 0000000000407fd0 006fd0 000010 00 WA 0 0 8
[11] .data PROGBITS 0000000000408000 007000 000110 00 WA 0 0 32
[12] .bss NOBITS 0000000000408120 007110 000698 00 WA 0 0 32

可写段从 0x407fc0 开始,先有 .init_array、.fini_array、只读重定位数据和 GOT,.data 从 0x408000 到 0x408110,因此文件内容总长 0x408110 - 0x407fc0 = 0x150。.bss 不占文件字节,起点按 32 字节对齐到 0x408120,长度 0x698,末端为 0x4087b8。段内存总长是 0x4087b8 - 0x407fc0 = 0x7f8,恰好符合程序头。多出来的内存包含 0x10 字节对齐空隙和 .bss,由加载器清零。

观察 3。

$ nm main.o
0000000000000000 T main
U printf
U set_x
0000000000000000 D x
0000000000000004 D y
$ nm other.o
0000000000000000 r .LC0
0000000000000000 T set_x
U x
$ readelf -sW other.o | grep -E " x$"
5: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND x

T 表示定义在代码区,D 表示定义在已初始化数据区,U 表示未定义,也就是"要用但不在本文件里"。.LC0 是编译器给常量 1.0 起的内部名字。other.o 里的 x 类型一栏是 NOTYPE,大小是 0,UND 表示未定义。整个符号表里找不到 double 这个词。这条普通符号表记录没有提供核对 C 类型所需的信息;重定位还能指出哪些位置引用 x,却同样不能证明两边的声明类型兼容。这里还能看到,main.o 里 x 的地址是 0,y 是 4,它们只是相对于本文件数据区起点的偏移,到了 prog 里才变成 0x408008 和 0x40800c。

预测

能链接,运行时崩溃:

$ musl-gcc -O2 -Wall -Wextra -static table.c use.c -o arr
$ ./arr
$ echo $?
139

这次运行由 SIGSEGV 终止,shell 将状态显示为 139。两份源码对 table 的声明类型不兼容,程序具有 UB;这个状态不是 C 语言保证的结果。use.c 以为 table 是一个指针变量,于是先从 table 所在的地址读出 8 个字节当作指针,再去那个指针加 4 的位置取一个 int。另用 musl-gcc -O2 -Wall -Wextra -c use.c -o use.o 保留目标文件,再用 objdump -dr 反汇编,能看到这两步(节选):

$ objdump -dr --no-show-raw-insn use.o
8: mov 0x0(%rip),%rax # f <main+0xf>
b: R_X86_64_PC32 table-0x4
...
16: mov 0x4(%rax),%esi

第一条指令用 RIP 相对寻址读取 table:CPU 以下一条指令的地址为基准,加上指令中记录的位移。目标文件里的位移暂时还是 0。它下面那行 R_X86_64_PC32 是一条重定位记录,意思是"这里有 4 个字节的地址待填,链接时按 table 的位置填上",第 4 章专门讲这种记录。第二条指令把读到的值当作地址,再加 4 去取数。可 table 实际上是两个 int,1 和 2,按小端序读成 8 字节的指针就是 0x0000000200000001,再加 4 就是一个根本没有映射的地址。数组和指针在 C 源码里经常可以混用,在目标文件里却是两种完全不同的东西。

定位

A 在编译期失败(节选):

$ musl-gcc -static main.c -o prog
main.c: In function 'main':
main.c:3:33: error: implicit declaration of function 'squre'; did you mean 'square'? [-Wimplicit-function-declaration]

这一题的答案和编译器版本有关。在 GCC 14 之前,调用一个没有声明的函数只是警告,编译器会假定它返回 int,照样生成目标文件,错误要等到链接期才以 undefined reference to 'squre' 的形式出现。GCC 14 把它改成了默认报错(GCC 14 移植说明),错误就提前到了编译期。

B 在链接期失败:

$ musl-gcc -c a.c && musl-gcc -c main.c
$ musl-gcc -static a.o main.o -o prog
ld: main.o:(.bss+0x0): multiple definition of `count'; a.o:(.bss+0x0): first defined here
collect2: error: ld returned 1 exit status

这一题同样和版本有关。GCC 10 起默认使用 -fno-common,两个文件里不带初值的 int count; 都算正式定义,于是重复定义(GCC 10 移植说明)。用 -fcommon 编译两个文件,链接就会成功,两个 count 被合并成同一个变量,程序打印 1。15-213 讲义里的 double x; 在今天的默认设置下也会撞上这条规则,所以本章开头的程序改用了 extern double x;。为什么会有这种"合并"规则,第 3 章讲 common 符号时会解释。

C 在链接期失败:

$ musl-gcc -c util.c && musl-gcc -c main.c
$ musl-gcc -static util.o main.o -o prog
ld: main.o: in function `main':
main.c:(.text+0xe): undefined reference to `helper'
collect2: error: ld returned 1 exit status
$ nm util.o
0000000000000000 t helper
0000000000000013 T twice

static 让 helper 只在 util.c 内部可见。nm 里小写的 t 表示局部符号,链接器不会拿它去满足别的文件的引用。

D 在编译期失败(节选):

scale.c:3:6: error: conflicting types for 'scale'; have 'long int(long int)'
3 | long scale(long v) { return v * 10; }
| ^~~~~
In file included from scale.c:2:
scale.h:2:5: note: previous declaration of 'scale' with type 'int(int)'

能在编译期抓住,全靠 scale.c 自己也包含了 scale.h,声明和定义进了同一个翻译单元。去掉 scale.c 里的 #include,同样打开 -Wall -Wextra,链接没有任何提示,程序在本机运行后的退出码是 40,碰巧看起来完全正确。这类错误潜伏到哪一天暴露,取决于调用方传进去的参数和寄存器里残留的值。

E 在加载期失败。编译和链接都成功,运行时:

$ ./prog
Error loading shared library libgreet.so: No such file or directory (needed by ./prog)
Error relocating ./prog: greet: symbol not found
$ echo $?
127

F 在运行期才暴露,编译和链接都没有任何警告:

$ ./prog
limit = 705032704

5000000000 写成十六进制是 0x12A05F200,按小端序存放时低 4 个字节在前,main.c 只读了这 4 个字节 0x2A05F200,也就是 705032704。这一题和本章开头的陷阱是同一类问题,方向反过来:开头是写多了,这里是读少了。A 和 B 两题的答案都随编译器版本变过,A 从链接期提前到了编译期,B 在老版本里根本不报错,现在会在链接期被拦下。F 和去掉 #include 的 D 则在本机的工具链上一声不响地生成了程序。

改坏

这次在链接期失败:

$ musl-gcc -O2 -Wall -Wextra -c main.c && musl-gcc -O2 -Wall -Wextra -c other.c
$ musl-gcc -static main.o other.o -o prog
ld: other.o:(.data+0x0): multiple definition of `x'; main.o:(.data+0x0): first defined here
collect2: error: ld returned 1 exit status
$ ld.lld -static main.o other.o -o /dev/null
ld.lld: error: duplicate symbol: x
>>> defined at main.c
>>> main.o:(x)
>>> defined at other.c
>>> other.o:(.data+0x0)
$ readelf -sW main.o | grep -E " x$"
7: 0000000000000000 4 OBJECT GLOBAL DEFAULT 2 x
$ readelf -sW other.o | grep -E " x$"
4: 0000000000000000 8 OBJECT GLOBAL DEFAULT 2 x

两个文件现在都定义了 x,链接器发现同一个名字有两个定义,于是报错。它报的是"重复定义",不是"类型不一致":符号表里两个 x 的大小一个是 4、一个是 8,类型一栏都是 OBJECT,链接器手里只有这些,依然不知道一边是 int、一边是 double。原来的写法只有一个定义,另一边只是引用,链接器挑不出任何毛病,错误才拖到了运行期。

修好之后:

$ cat other.c
extern int x;
void set_x(void) {
x = 1;
}
$ musl-gcc -O2 -Wall -Wextra -c other.c
$ musl-gcc -static main.o other.o -o prog
$ ./prog
x = 1, y = 5

这样改只是让两边碰巧一致。更可靠的做法是把声明写进一个头文件,让定义所在的 main.c 也包含它,编译器就能在同一个翻译单元里核对,第 3 章会讲。

参考

附录:术语与工具

  1. GCC — GCC(GNU Compiler Collection)是一组语言编译器。命令 gcc 是驱动入口,会组织编译、汇编和链接;在终端调用它,并不意味着后续工作都在同一个进程里完成。 官方文档。 ↩

  2. cc1 — cc1 是 GCC 处理 C 源码的内部程序,通常由驱动调用。Clang 命令中的 -cc1 是 Clang 自己的内部模式,不能据此判断它调用了 GCC。 官方文档。 ↩

  3. as — GNU as 将汇编输入编码为目标文件。汇编指令描述机器操作,.section、.globl 等伪指令则指导汇编器组织节和符号。 官方文档。 ↩

  4. collect2 — collect2 是 GCC 调用链接器时可能经过的辅助程序。它属于驱动调用链;诊断中出现这个名字,不代表又多了一种目标文件格式。 官方文档。 ↩

  5. ld — ld 是常见的链接器命令名;本系列写 GNU ld 时特指 GNU binutils 的链接器。它读取目标文件、库与链接选项,完成符号解析、布局和重定位。 官方文档。 ↩

  6. Clang — Clang 是 LLVM 项目中的 C、C++ 等语言前端及驱动程序。它通常使用集成汇编器,但仍需调用链接器;最终使用哪个链接器取决于目标平台和配置。 官方文档。 ↩

  7. musl — musl 是 Linux 的一种 C 标准库实现,提供 printf 等库函数及运行时支持。本系列在需要分析或链接较小的静态运行库时使用它;普通 Linux 服务器不一定预装 musl。 官方文档。 ↩

  8. musl-gcc — musl-gcc 是 GCC 的包装器,为编译和链接选择 musl 头文件、启动文件及库。它本身不意味着交叉编译;是否静态链接由 -static 等选项决定。 官方文档。 ↩

  9. UB — UB(undefined behavior,未定义行为)表示语言标准不再对该次执行提出行为要求。观察到某次输出可以解释具体产物,却不能把它当作可移植的程序结果;链接错误 undefined reference 是另一类问题。 官方文档。 ↩ ↩2

  10. ELF — ELF(Executable and Linkable Format)规定目标文件、可执行文件与共享对象的结构。通用规则见 gABI,架构相关的调用约定和重定位规则见对应 psABI。 官方文档。 ↩

  11. readelf — readelf 检查 ELF 头、节、段、符号及重定位等结构;它读取文件而不执行其中的程序。GNU readelf 与 LLVM llvm-readelf 的显示格式可能不同。 官方文档。 ↩

  12. LLVM — LLVM 是一组编译器与工具链项目的名称,包括优化基础设施、目标代码生成和相关工具。Clang、LLD 与 LLVM IR 各有职责,不能互作同义词。 官方文档。 ↩

  13. LLD — LLD 是 LLVM 项目的链接器。ELF 平台通常通过 ld.lld 调用;lld-link 则提供兼容 Windows 工具链的接口。它与负责处理源码的 Clang 是不同组件。 官方文档。 ↩

  14. glibc — glibc(GNU C Library)是许多 Linux 发行版默认使用的 C 库。库的启动文件、共享库和动态链接器共同参与程序构建与运行。 官方文档。 ↩

  15. binutils — GNU binutils 是一组处理目标文件的工具,包含汇编器 as、链接器 ld,以及 readelf、nm、objdump、ar 等检查与归档工具。 官方文档。 ↩

  16. objdump — objdump 可反汇编机器码,也能显示节和重定位信息。GNU 与 LLVM 版本的排版、指令写法和默认选项并不完全相同,本文命令保留具体工具名。 官方文档。 ↩

  17. nm — nm 列出目标文件的符号。字母标记概括符号所在节或绑定等属性;需要判断准确语义时,应继续对照 ELF 符号表字段。 官方文档。 ↩

  18. GNU — GNU 是 “GNU’s Not Unix” 的递归缩写,指自由软件操作系统项目。GCC、binutils 和 glibc 都属于 GNU 项目,但分别承担编译、二进制处理和 C 运行库职责。 官方文档。 ↩

  19. GOT — GOT(Global Offset Table)保存供代码间接访问的地址或相关偏移。它让部分地址修补集中到数据表中;表项的具体用途由重定位类型与 ABI 决定。 官方文档。 ↩