资讯详情

资讯详情

【C++入门】编译链接模型 - 07 链接器怎样把符号对上号

博主介绍程序喵大人35 - 资深C/C/Rust/Android/iOS客户端开发10年大厂工作经验嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手《C20高级编程》《C23高级编程》等多本书籍著译者更多原创精品文章首发gzh见文末记得订阅专栏以防走丢C基础系列专栏C语言基础系列专栏C大佬养成攻略专栏C训练营个人网站好文推荐【AIAgent项目】从零构建一个代码PRAgent【C入门】编译链接模型 - 01 一个 C 程序是怎样变成可执行文件的【C入门】编译链接模型 - 02 预处理把头文件怎样塞进源文件【C入门】编译链接模型 - 03 翻译单元才是编译器真正看到的文件【C入门】编译链接模型 - 04 声明让编译器通过检查定义给链接器提供实体【C入门】编译链接模型 - 05 ODR 为什么会让重复定义变成工程炸点【C入门】编译链接模型 - 06 目标文件里到底有什么在 C 开发中有一类错误几乎每个人都会频繁遇到undefined reference to 某某函数。编译全部通过每个.cc都没有语法问题一链接就炸。第 4 章解释过编译通过说明声明齐全链接失败说明定义缺失。第 6 章展示了目标文件里符号表的基本形式U标记未定义符号T标记已定义符号。本章把这两条线索合在一起解释链接器如何遍历所有目标文件的符号表把每个U符号和唯一的T符号对上号。拿第二章以来反复使用的那套例子。math.h声明Addmath.cc实现Addmain.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.oclang-stdc20-Wall-cmain.cc-omain.o clang main.o-oapp链接器输出undefined reference to Add(int, int)。注意错误信息里的Add(int, int)它不只是函数名还包括了参数类型。这是因为 C 的函数重载要求链接器能区分Add(int, int)和Add(double, double)它们的符号名在目标文件中是不同的。链接器按照目标文件中实际存储的、经过name mangling编码之后的符号名去匹配这一点和人类按Add这个名字去理解调用关系完全不同。补上math.o后一切正常clang-stdc20-Wall-cmath.cc-omath.o clang main.o math.o-oapp ./app# 输出42这两条命令之间的差异就是链接器符号解析的完整写照第一条命令中链接器遍历完所有输入只有main.o后发现Add的需求无人认领报错退出第二条命令中math.o在供给清单中提供了Add需求得到满足链接成功。C 函数名在进入目标文件的符号表之前会经历一次系统性的编码变换这个过程叫name mangling名称修饰。编译器需要把函数的命名空间、类名、函数名、参数类型、以及可能的 CV 限定符和引用限定符全部编码进一个扁平的字符串中作为链接器使用的符号名。准备一个包含函数重载的例子来观察这个现象// overload.ccintFormatValue(intvalue){returnvalue;}doubleFormatValue(doublevalue){returnvalue;}namespacedemo{intFormatValueInNamespace(intvalue){returnvalue;}}// namespace demo编译成目标文件然后用nm查看符号表clang-stdc20-Wall-coverload.cc-ooverload.o nm overload.o输出中两个FormatValue分别被编码成了类似__Z11FormatValuei和__Z11FormatValued这样的符号名。末尾的i表示参数是intd表示参数是double。demo命名空间中的函数被编码成了更长的符号名包含了命名空间的名字。用cfilt可以把这些编码后的名字还原nm overload.o|cfilt输出变成了人可读的FormatValue(int)、FormatValue(double)和demo::FormatValueInNamespace(int)。cfilt做的事情就是反转name mangling它不修改目标文件只是把nm的输出管道过来做符号名解码。日常排查链接错误时nm xxx.o | cfilt是一个标准的组合命令用来在可读的名字和实际的符号名之间快速对照。Name mangling 没有统一的跨平台标准。不同编译器Clang、GCC、MSVC使用的编码规则不同同一个函数在不同编译器下生成的符号名不一样。同一个编译器在不同平台上比如 Clang 在 macOS 和 Linux 上生成的符号名前缀也可能有差异。Itanium CABI 规定了 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.ccexternCintAddForC(intleft,intright){returnleftright;}clang-stdc20-Wall-cc_api.cc-oc_api.o nm c_api.onm输出中AddForC被标记为_AddForCmacOS 下带下划线前缀没有任何参数类型编码。这意味着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__cplusplusexternC{#endifintAddForC(intleft,intright);#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不匹配调用方引用的是_Z3AddiiAdd(int, int)但实际上定义方提供的是_Z3AddddAdd(double, double)虽然反 mangling 后看起来都像Add但链接器看到的是两个不同的字符串。nm | cfilt组合是发现这类name mangling不匹配问题的核心工具。一个具体的诊断命令序列链接失败后先nm 报错的.o | cfilt | grep 函数名确认调用方的符号引用长什么样再nm 应该提供定义的.o | cfilt | 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 | cfilt来确认_Z3Addii就是Add(int, int)排除name mangling不匹配的可能性。如果声明和定义的参数类型不完全一致比如一个是int、一个是long在 64 位平台上这两个类型可能不同生成的mangled name就不匹配即使两个函数在你看来都叫Add。另一个常见的排查场景是编译命令和链接命令使用了不同的编译选项导致name mangling规则发生变化。C标准版本的变化比如从 C14 切换到 C17可能会影响某些标准库类型的mangled name如果部分目标文件用-stdc14编译、部分用-stdc17编译链接阶段可能因为符号名不一致而失败。这个教训在实践中经常以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 目标文件的链接结果多出来的就是解析后的符号地址、合并后的段表以及操作系统装载器需要的头部信息。从这个角度看链接器的工作是把分散的、彼此之间只有符号引用关系的目标文件集合转换为一个自洽的、所有内部引用都已闭合的独立可装载单元。理解了这一点之后下一章讲静态库的目标文件集合本质就会非常直观。码字不易欢迎大家点赞关注评论谢谢
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →