Skip to content

编译、链接与加载:函数名如何变成运行地址 ​

在编辑器里看到 helper(x),不代表编译器已经知道 helper 的最终地址。独立编译让每个源文件可以单独变成目标文件,再由链接器组合。这个分工解释了为何头文件能让编译通过,却无法自动解决链接错误。

四个阶段与两类产物 ​

flowchart LR
  A[源文件与头文件] --> B[预处理后文本]
  B --> C[汇编代码]
  C --> D[可重定位目标文件]
  D --> E[链接器解析符号与重定位]
  L[库中的目标文件] --> E
  E --> F[可执行文件]
  F --> G[加载器与动态链接器]
  G --> H[进程地址空间]
查看流程图文本
flowchart LR
  A[源文件与头文件] --> B[预处理后文本]
  B --> C[汇编代码]
  C --> D[可重定位目标文件]
  D --> E[链接器解析符号与重定位]
  L[库中的目标文件] --> E
  E --> F[可执行文件]
  F --> G[加载器与动态链接器]
  G --> H[进程地址空间]

cc -E 查看预处理结果,cc -S 停在汇编,cc -c 生成目标文件,普通 cc a.o b.o -o demo 负责最终链接。推荐通过编译器驱动调用链接器,因为它还负责启动对象和运行库等平台细节。

Linux ELF 中,section 服务于链接视角,例如 .text 保存指令、.data 保存已初始化可写数据、.bss 描述零初始化空间;segment 服务于加载视角,把需要相似权限的内容组织成映射。.bss 不需要在文件里存满一大片零,但进程看到的相应内存需要具有零初始化语义。

一次未定义引用的产生和消失 ​

建立两个小文件:

c
/* main.c */
#include <stdio.h>
int twice(int x);
int main(void) { printf("%d\n", twice(21)); }
c
/* twice.c */
int twice(int x) { return 2 * x; }

在 Linux 工具链环境执行:

sh
cc -Wall -Wextra -c main.c twice.c
nm main.o
nm twice.o
readelf -r main.o
cc main.o twice.o -o demo
./demo

预期:main.o 中 twice 是未定义引用,twice.o 中有函数定义,合并后输出 42。目标文件里调用指令的某部分尚不能确定,重定位条目告诉链接器需要修补哪里、引用哪个符号、使用什么计算方式。最终地址未必是绝对常数;相对寻址和位置无关代码减少了对固定加载地址的依赖。

若省去 twice.o,编译阶段依然可能成功,链接阶段会报告未定义符号。补一个同名声明也没有用,因为声明只描述接口,定义才提供实体。若把普通外部函数定义直接放进被多个 .c 包含的头文件,往往导致重复定义;应放到一个 .c 中,头文件保留声明,或根据用途使用正确的 static inline 形式。

静态库为什么可能受顺序影响 ​

静态库是目标文件的归档。常见 GNU 链接方式会在遇到归档时,为当前未解析引用挑选需要的成员,所以 cc main.o -lcourse 与 cc -lcourse main.o 可能不等价。错误排查时查看命令行顺序,而不是先怀疑库内部代码。GNU ld 选项说明

做一个独立实验:

sh
ar rcs libcourse.a twice.o
cc main.o -L. -lcourse -o demo

让 twice 改成 3 倍并重新构建库,但不重新链接 demo;旧可执行文件仍包含先前选入的代码。再次链接才会使用新实现。动态库则把部分地址绑定留到装载或运行阶段,因此部署时还需要考虑库的实际位置与 ABI 兼容性。

动态链接与运行时错误 ​

程序加载时,内核和加载器建立虚拟内存映射,动态链接器定位依赖库、处理必要的重定位,再把控制交给程序启动路径。某些函数可经 PLT/GOT 等间接结构调用,但不能把“所有平台都使用完全相同的延迟绑定过程”当作事实。具体机制受 ABI、链接选项和运行时影响。

Linux 上 readelf -d demo 能显示动态依赖信息,objdump -d demo 可查看调用位置。macOS 使用 Mach-O,可使用 nm、otool -L 等本平台工具;它们的输出不是 ELF 段表。执行 file demo 是跨平台排错的第一步。

链接还揭示接口声明的重要性。若调用者认为参数是 int、定义者却认为参数是指针,即使符号名字匹配,运行也可能出错。C 链接器通常不能替你验证所有跨文件类型约束。把声明放进共享头文件,并让实现文件也包含它,能让编译器更早发现不一致。

练习与检查 ​

为上面的例子添加一个文件内 static int counter,比较 nm 可见性;再把它改成外部定义,明确哪些文件可以引用。最后故意省略头文件中的声明,用严格警告编译,理解“编译诊断”和“链接诊断”处于不同阶段。

完成记录至少包含:三个命令、目标文件中的一个未定义符号、一个重定位条目、最终输出。本站未把这些平台相关命令列为已经在 Linux 实测;本机只运行了课程入口列出的可移植练习。

自测:静态局部变量与函数调用栈是什么关系?

静态局部变量具有静态存储期,不随每次函数返回而销毁;它的作用域仍限制在函数内部。名字可见范围与存储期是不同维度。普通自动局部变量则具有相应块执行的自动存储期,常见实现放在寄存器或栈上。

自测:链接成功能证明参数类型匹配吗?

不能。符号解析主要解决名字与定义的关联,不等于完整的跨模块类型检查。共享声明、编译警告和接口测试仍然必要。

课程参考:CS:APP3e 学生资源。下一章:缓存与局部性。