C++ 链接器原理:符号解析与 undefined reference
07 链接器怎样把符号对上号
在 C++ 开发中有一类错误几乎每个人都会频繁遇到:undefined reference to '某某函数'。编译全部通过,每个 .cc 都没有语法问题,一链接就炸。第 4 章解释过,编译通过说明声明齐全,链接失败说明定义缺失。第 6 章展示了目标文件里符号表的基本形式,U 标记未定义符号,T 标记已定义符号。本章把这两条线索合在一起,解释链接器如何遍历所有目标文件的符号表,把每个 U 符号和唯一的 T 符号对上号。
拿第二章以来反复使用的那套例子。math.h 声明 Add,math.cc 实现 Add,main.cc 调用 Add。编译后的 main.o 符号表里 Add 标为 U(未定义),math.o 符号表里 Add 标为 T(已定义)。链接命令 clang++ main.o math.o -o app 执行时,链接器在内部做的大致是这样几件事:读入 main.o,记录它的符号供给(main 等 T 符号)和符号需求(Add、std::cout 等 U 符号);读入 math.o,记录它的供给(Add 等 T 符号)和需求(如果有的话);遍历所有需求清单,在供给清单中查找匹配的符号名;把匹配成功的代码段和数据段从各目标文件中提取出来,合入最终的可执行文件;将每条重定位记录中的占位地址替换为符号的最终地址。所有 U 符号都找到匹配的 T 符号时,链接成功。任何一个 U 符号在所有输入中都找不到对应的 T 符号时,链接器报 undefined reference。
用实验来验证。故意只链接 main.o:
clang++ -std=c++20 -Wall -c main.cc -o main.o
clang++ main.o -o app
链接器输出 undefined reference to 'Add(int, int)'。注意错误信息里的 Add(int, int),它不只是函数名,还包括了参数类型。这是因为 C++ 的函数重载要求链接器能区分 Add(int, int) 和 Add(double, double),它们的符号名在目标文件中是不同的。链接器按照目标文件中实际存储的、经过 name mangling 编码之后的符号名去匹配,这一点和人类按 Add 这个名字去理解调用关系完全不同。
补上 math.o 后一切正常:
clang++ -std=c++20 -Wall -c math.cc -o math.o
clang++ main.o math.o -o app
./app
# 输出:42
这两条命令之间的差异就是链接器符号解析的完整写照:第一条命令中链接器遍历完所有输入(只有 main.o)后发现 Add 的需求无人认领,报错退出;第二条命令中 math.o 在供给清单中提供了 Add,需求得到满足,链接成功。
C++ 函数名在进入目标文件的符号表之前,会经历一次系统性的编码变换,这个过程叫 name mangling(名称修饰)。编译器需要把函数的命名空间、类名、函数名、参数类型、以及可能的 CV 限定符和引用限定符全部编码进一个扁平的字符串中,作为链接器使用的符号名。准备一个包含函数重载的例子来观察这个现象:
// overload.cc
int FormatValue(int value) {
return value;
}
double FormatValue(double value) {
return value;
}
namespace demo {
int FormatValueInNamespace(int value) {
return value;
}
} // namespace demo
编译成目标文件然后用 nm 查看符号表:
clang++ -std=c++20 -Wall -c overload.cc -o overload.o
nm overload.o
输出中,两个 FormatValue 分别被编码成了类似 __Z11FormatValuei 和 __Z11FormatValued 这样的符号名。末尾的 i 表示参数是 int,d 表示参数是 double。demo 命名空间中的函数被编码成了更长的符号名,包含了命名空间的名字。用 c++filt 可以把这些编码后的名字还原:
nm overload.o | c++filt
输出变成了人可读的 FormatValue(int)、FormatValue(double) 和 demo::FormatValueInNamespace(int)。c++filt 做的事情就是反转 name mangling,它不修改目标文件,只是把 nm 的输出管道过来做符号名解码。日常排查链接错误时,nm xxx.o | c++filt 是一个标准的组合命令,用来在可读的名字和实际的符号名之间快速对照。
Name mangling 没有统一的跨平台标准。不同编译器(Clang、GCC、MSVC)使用的编码规则不同,同一个函数在不同编译器下生成的符号名不一样。同一个编译器在不同平台上(比如 Clang 在 macOS 和 Linux 上)生成的符号名前缀也可能有差异。Itanium C++ABI 规定了 Linux 上 GCC 和 Clang 共同遵守的 mangling 规则,但在细微之处(特别是涉及模板参数编码和 lambda 表达式时)两个编译器仍然可能产出不同的符号名。MSVC 则使用一套完全独立的编码方案,连修饰前缀都不同。这个事实有一个重要的推论:不同编译器编译出来的目标文件通常不能直接互相链接,即使它们都是 C++代码。这也是为什么系统库(通常是 C 接口)需要 extern "C" 来绕过 name mangling 的原因,因为 C 的符号名没有任何修饰,天然跨编译器兼容。
extern "C" 是 C++ 提供的改变符号名生成规则的机制。在 extern "C" 块内声明的函数,编译器按照 C 的规则生成符号名:函数名就是符号名,不编码参数类型,不编码命名空间。由于没有参数类型编码,extern "C" 函数天然不支持重载,编译器会直接拒绝在 extern "C" 块内定义两个同名的函数。这个限制恰好反映了 name mangling 和函数重载之间的依赖关系:没有 name mangling,链接器无法区分同名函数,重载就无法实现。准备一个最小例子:
// c_api.cc
extern "C" int AddForC(int left, int right) {
return left + right;
}
clang++ -std=c++20 -Wall -c c_api.cc -o c_api.o
nm c_api.o
nm 输出中,AddForC 被标记为 _AddForC(macOS 下带下划线前缀),没有任何参数类型编码。这意味着 extern "C" 函数不能重载(链接器无法区分两个同名但参数不同的 extern "C" 函数),而且它的符号名可以被 C 代码或任何按 C ABI 编译的语言直接引用。extern "C" 最常见的应用场景是 C/C混合编程:C 代码调用 C 库时,用 extern "C" 声明头文件中的函数,让 C++编译器按 C 风格生成外部引用符号,和 C 库中的定义对应上;C++库需要暴露 C 兼容接口时,在导出函数上使用 extern "C"。
extern "C" 通常在头文件中搭配 __cplusplus 宏使用:
#ifdef __cplusplus
extern "C" {
#endif
int AddForC(int left, int right);
#ifdef __cplusplus
}
#endif
这个写法让同一个头文件可以同时被 C 和 C++编译器正确处理。C 编译器(不定义 __cplusplus)看到的只有函数声明,C++ 编译器看到的声明被包在 extern "C" 块中,生成 C 风格的符号引用。
和 extern "C" 相对的是 C++的默认行为:`extern "C"(通常省略不写)。在 extern "C"` 块中声明的函数按 C 规则做 name mangling,支持重载和命名空间。日常 C++代码中所有自由函数和成员函数默认都是 `extern "C"` 的链接属性。
链接器在做符号解析时,对符号的理解是纯粹基于字符串匹配的。它不关心一个符号名代表的是函数还是变量,不关心参数类型是否真的匹配,它只关心字符串是否相等。编译器通过声明检查和 name mangling 来保证符号名的一致性:如果两个翻译单元各自 include 了同一个头文件中的声明,编译器会用相同的参数类型列表生成相同的 mangled name,链接器的字符串匹配自然就不会出错。如果某个翻译单元绕过头文件直接手写了不一致的声明(比如 double Add(double, double) 而其他翻译单元用的是 int Add(int, int)),两个翻译单元生成的 mangled name 不同,链接器报"未定义引用",因为调用方引用的那个 mangled name 在供给方根本不存在。换成更直白的说法:链接器看到的是一个叫 _Z3Addii 的 ASCII 字符串请求,这个字符串是人类眼中的 Add(int, int)。链接器只关心供给方的符号表里有没有同样叫 _Z3Addii 的条目。如果在人类脑子里把"链接器的 Add 符号"等价成"_Z3Addii 这个字符串",对 undefined reference 的理解会准确很多。
这个现象揭示了 C++链接模型的本质:头文件中的声明一致性是 name mangling 一致性的前提,name mangling 一致性是链接器符号匹配正确的前提。整个 C++ 多文件编译模型的正确性,最终落脚在"每个翻译单元对同一个实体看到相同的声明"这个基本要求上。
链接器在符号解析时还要处理符号的强弱属性。大多数编译器将普通函数和全局变量标记为强符号(strong symbol),将未初始化的全局变量和 inline 函数的实例化代码标记为弱符号(weak symbol)。链接器的符号解析规则中,强符号只能出现一次(多次出现即 multiple definition),弱符号允许多次出现(链接器保留一份),强符号可以覆盖同名的弱符号。这套强弱符号规则是 ODR 在链接器层面的技术实现:ODR 允许 inline 函数和模板实例化代码跨翻译单元重复出现,对应的实现方式就是把这些实体的符号标记为弱符号,让链接器自动去重。第 5 章从语言规则的角度讲了 ODR,本章从链接器机制的角度印证了同一件事。
强弱符号的区分解释了为什么 inline 函数放在头文件中被多个翻译单元 include 后不会触发 multiple definition:编译器把 inline 函数的符号标记为弱符号(在 nm 输出中可能显示为 W 或 w),链接器在合并时遇到多个同名的弱符号,自动保留一份而丢弃其余的。普通函数定义被标记为强符号(T),链接器遇到两个同名的强符号时直接拒绝继续。
强弱符号的机制还解释了另一种常见情况:如果一个强符号和一个弱符号同名,链接器选择强符号。这意味着如果你在某个 .cc 中提供了普通函数定义(强符号),而在另一个翻译单元的头文件里同一个函数被标记为 inline(弱符号),链接器最终保留强符号版本。这种设计让用户可以在必要时用显式的强定义"覆盖"头文件中的 inline 弱定义,不过在实际工程中更建议保证所有翻译单元看到一致的函数定义,避免依赖这种覆盖行为。
日常排查链接错误有一套基于符号表的诊断方法。遇到 undefined reference 时,先用 nm 检查报错的目标文件,确认调用方确实引用了某个符号(U 标记)。然后检查是否漏掉了提供该符号定义的目标文件或库。如果目标文件都传入了但仍然报错,用 nm 检查"提供方"的目标文件,确认该符号是否确实被标记为 T 而非其他类型。C++代码中常见的一种情况是 name mangling 不匹配:调用方引用的是 _Z3Addii(Add(int, int)),但实际上定义方提供的是 _Z3Adddd(Add(double, double)),虽然反 mangling 后看起来都像 Add,但链接器看到的是两个不同的字符串。`nm | cfilt` 组合是发现这类 name mangling 不匹配问题的核心工具。
一个具体的诊断命令序列:链接失败后,先 nm 报错的.o | c++filt | grep 函数名 确认调用方的符号引用长什么样;再 nm 应该提供定义的.o | c++filt | grep 函数名 确认供应方的符号签名是否匹配。如果两个目标文件中 mangled name 的确不同,回查头文件中的声明是否一致、两个翻译单元是否真的看到了同一个头文件。
遇到 multiple definition 时,链接器会列出哪两个目标文件提供了同名的强符号。用 nm 分别检查这两个目标文件,找到该符号对应的源码位置。确认这个符号是否应该只出现在一个翻译单元中,把多余的普通定义从头文件移到 .cc 文件,或者给适合头文件实现的函数加上 inline。
链接器本身不处理 include path 和头文件。undefined reference 的原因中,"头文件没有 include"是一个经常被误判的方向。如果头文件没有 include,编译器会报"未声明的标识符"(编译错误),而不是链接错误。undefined reference 意味着声明存在、声明被正确 include 了、编译通过了,但定义所在的翻译单元没有被编译或编译产物没有被传入链接器。
排查 undefined reference 时有一个容易被忽略的检查点:确认提供定义的目标文件是否确实包含那个符号。用 nm 检查目标文件时,如果符号名因为 name mangling 与你预想的不同,可能会误判为"定义不存在"。例如你预期 math.o 提供 Add 符号,但 nm 输出中看到的是 _Z3Addii。这种情况下用 nm math.o | c++filt 来确认 _Z3Addii 就是 Add(int, int),排除 name mangling 不匹配的可能性。如果声明和定义的参数类型不完全一致(比如一个是 int、一个是 long,在 64 位平台上这两个类型可能不同),生成的 mangled name 就不匹配,即使两个函数在你看来都叫 Add。
另一个常见的排查场景是编译命令和链接命令使用了不同的编译选项,导致 name mangling 规则发生变化。C标准版本的变化(比如从 C++14 切换到 C++17)可能会影响某些标准库类型的 mangled name,如果部分目标文件用 `-std=c14编译、部分用-std=c17` 编译,链接阶段可能因为符号名不一致而失败。这个教训在实践中经常以"CI 构建和本地构建行为不一致"的形式出现,因为两个环境的默认 C++ 标准版本可能不同。
编译错误和链接错误之间的阶段区分,是第 10 章诊断体系的基础。现阶段先把一条简单规则记住:看到 error: 前缀且报错位置指向某一行源码,是编译期问题;看到 undefined reference to 或 multiple definition of,是链接期问题。知道错误发生在哪个阶段,排查方向就已经收敛了一大半。
链接器在完成符号解析后,还有一项重要的工作:合并同名的段。main.o 有自己的 .text 段,math.o 也有自己的 .text 段。链接器把两个文件中所有同类型的段合并在一起,形成最终可执行文件中一个统一的 .text 段。.data 段、.rodata 段也做同样的合并。段合并后,链接器为每个段指定在虚拟地址空间中的基地址,然后根据这个布局计算出每个符号的最终运行时地址,填写所有重定位记录。这个过程之后,目标文件中所有的"待定"符号引用都被解析成了确定的地址值,产物从"半成品目标文件集合"变成了"完整的可执行文件"。
段合并不只是简单的拼接。链接器在处理多个目标文件的同类型段时,需要保持每个目标文件内部符号之间的相对偏移,同时为不同目标文件的段之间确定连接顺序。常规做法是按照目标文件在命令行中出现的顺序排列各文件的段片段,然后统一分配地址。链接脚本(linker script)可以覆写这个默认行为,允许精细控制段布局、符号地址和对齐方式。链接脚本是嵌入式开发和操作系统内核开发中的常见工具,应用层开发中通常使用链接器默认的段布局。
链接器的最后一步是生成操作系统装载器需要的程序头表和段映射信息。在 macOS 上,这个信息让内核的 Mach-O 装载器知道把可执行文件的哪个部分映射到进程地址空间的哪个位置;在 Linux 上,ELF 的 program headers 承担同样的职责。程序装载和地址空间映射的细节留给《图解 构建系统与工具链》系列展开,这里只需要知道链接器完成的是从"翻译单元集合"到"可装载程序"的最终跳跃。
链接器输出的可执行文件与目标文件在格式上同源(ELF 可执行文件是 ELF 目标文件的链接结果,Mach-O 可执行文件是 Mach-O 目标文件的链接结果),多出来的就是解析后的符号地址、合并后的段表以及操作系统装载器需要的头部信息。从这个角度看,链接器的工作是把"分散的、彼此之间只有符号引用关系的目标文件集合"转换为"一个自洽的、所有内部引用都已闭合的独立可装载单元"。理解了这一点之后,下一章讲静态库的"目标文件集合"本质就会非常直观。
阅读导航




