C/C++ 现代语言与底层 · 第 六 篇

名字与符号:ABI、修饰、可见性、PLT 与 IAT

2026-10-1315,924 字36 张配图

本文由@溯光 出品 站点:https://yyy3d.online/blog/ | 本系列:https://yyy3d.online/blog/#topic-cpp-language-foundations 符号链路总览——源码名/名字修饰/目标文件符号表/链接器/动态加载五层脉络 图 1 符号链路总览——源码名/名字修饰/目标文件符号表/链接器/动态加载 @ 本系列绘制


本系列已发表篇目(全系列共 12 篇,分四波连载):

本篇是第二波「编译器与链接器」的第三篇。 ep04 算编译花了多少时间,ep05 算优化器改了什么,这一篇算链接器靠什么把一堆 .obj 拼起来。

目录总览:本篇共十一章。零章划边界,一章点题,二章讲为什么需要名字修饰,三章把两套修饰文法逐段拆开对账,四章讲反修饰工具,五章讲 extern "C" 与调用约定,六章给跨编译器链接的决定性实验,七章讲可见性与导出,八章解剖符号表,九章讲动态链接(IAT 实测 + PLT/GOT 引原文),十章收束。


背景与边界

0.1 从"优化器的选择"到"链接器的语言"

前三篇(ep04–ep05)都停在单个翻译单元(TU)内部:编译器怎么翻译你的代码、花了多少时间、做了哪些改写。这一篇跨出那一步——当编译器把每个 .cpp 变成 .obj 之后,链接器靠什么把它们拼成一个可执行文件?

答案是一个词:符号(symbol)。

链接器不看你的源码,也不看你的类型。它看的是一张张"符号表":这个目标文件定义了哪些名字,又需要哪些名字。 定义方的名字要能对上引用方的名字——名字对不上,链接就断。 而 C++ 里你在源码写的一个名字(app::add(int, int)),到了目标文件里会变成一个经过名字修饰(name mangling)的字符串。

这一层字符串,就是这一篇的主角。

0.2 本篇的边界(四条不写)

  • 不逐模块讲任何具体引擎/库:渲染算法归《实时渲染管线探析》,抓帧工具归《图形性能剖析工具链》。本系列的工具是编译器与链接器。
  • 不做 C++ 入门教学:不写"什么是链接"的语法导览、不写"怎么用 g++ 编译多文件"的教程。
  • 不做"最佳实践清单":无一手证据的泛泛总结违反站规。本篇的建议都附本机实测。
  • 不深入安全攻防:PLT/GOT 与 RELRO 只讲到"机制是什么",不做利用手法分析。

0.3 工具链声明(先说清楚什么能实测、什么只能引原文)

这一篇的工具链边界比前几篇都关键,因为符号层是平台相关的:

目标 工具 本机状态
PE/COFF(Windows) MSVC cl / dumpbin / lib / undname ✅ 实测可用
PE/COFF(Windows) MSYS2 g++ / nm / objdump / c++filt ✅ 实测可用(产出 PE,不是 ELF)
ELF(Linux) readelf / PLT/GOT 观察 ⚠️ readelf.exe 存在,但本机产不出 ELF
ELF(Linux) WSL ❌ 不可用

这条边界必须写清楚:PLT/GOT、RELRO、-fno-plt 都是 ELF 概念。本机没有可用的 ELF 环境,所以第九章的 ELF 部分只引权威原文,不做本机实测——而 PE 侧的对应机制(IAT、延迟加载)本机可以完整实测。正文会把"实测"与"引原文"严格分开标注。

本篇所有读数均为本机实测。编译器为 MSVC 19.44.35228(VS 2022 Community 17.14.35)与 g++ 16.2.0(MSYS2 ucrt64)。方法比数字重要——本文给的是可复现的量化路径。

0.4 本篇的五个反直觉发现(先给结论)

这一篇的写法跟前面几篇一样:每个结论都配本机实测。 下面五条是"跟流行说法不太一样"的发现:

  1. x64 下 __cdecl/__stdcall/__fastcall 产出同一个符号名——本机实测三者符号完全相同,调用约定在 x64 上被忽略。
  2. dllexport 写在 class 前面和后面,导出数差 5 倍——实测 1 条 vs 5 条。
  3. __declspec(dllexport) 会连带导出编译器隐式生成的成员——本机导出表里出现了拷贝赋值与移动赋值运算符,而你从没写过它们。
  4. extern "C" 不只是"关掉修饰",它是跨编译器链接的唯一可行通道——实测:C 符号 mingw↔MSVC 链接成功,C++ 符号第一步就失败。
  5. -fvisibility=hidden 在 PE 目标上是空操作——MSYS2 g++ 接受选项并警告,但符号仍然全部导出。

这五条都不是靠读文档能得到的,必须动手量。

0.5 符号在流水线的哪几步产生

在往下读之前,先建立一张"符号什么时候出现"的地图——因为这一篇涉及的环节比前几篇多:

环节 谁在干活 与符号的关系
① 预处理 cl /P 或 cpp 与符号无关(只处理宏与 #include)
② 编译(前端+后端) c1xx.dll / c2.dll 产生定义:把每个函数、变量、vtable 做成一个带修饰名的符号
③ 汇编 编译器内置 把符号写进目标文件的符号表
④ 归档 lib.exe / ar 把多个 .obj 打包,外加一份符号索引
⑤ 静态链接 link.exe / ld 匹配定义与引用;匹配不上就是 undefined reference
⑥ 动态链接 加载器 / PLT / IAT 在加载时或首次调用时填写地址

这张表解释了本篇的章节顺序:第二到四章讲②产生名字;第五章讲名字怎么被"关掉";第六章用⑤的成败做验证;第七、八章讲②③④产出的符号长什么样;第九章讲⑥。

也提醒一件事:符号问题从来不是"编译出错"——②③④⑤各环节都可能出问题,而报错信息的形式完全不同。看清"卡在哪一环",比记住某个具体报错更有用。


一、一句话点题:符号名是链接器唯一听得懂的语言

先把核心结论放在最前面:

链接器不认识你的类型,也不认识你的重载——它只认识符号名。而符号名是修饰出来的。

这句话可以换个更直白的说法:你写的 app::add(int, int) 只是给人看的;链接器看到的是一串像 ?add@app@@YAHHH@Z 这样的东西。 名字对得上就链,对不上就断。

这个性质带来三个后果,正好构成这一篇的三条主线:

第一条:修饰不是"编码游戏",它是 C++ 语义在二进制层的载体。 为什么需要修饰?因为 C 语言里"一个名字对应一个函数",而 C++ 里同一个 add 可以有十几个重载、藏在不同命名空间里、还可以是模板实例。修饰名就是"把重载、命名空间、模板参数、类型信息全部压进一个字符串"——只有这样,链接器才能区分它们。

第二条:这套字符串不同编译器不一样,所以 C++ 的目标文件不能跨编译器直接链接。 本机实测:同一份源码,MSVC 编出 ?add@app@@YAHHH@Z,GCC 编出 _ZN3app3addEii。两个字符串毫无共同点——链接器连"这是同一个函数"都判断不出来。

第三条:extern "C" 之所以是解药,因为它关掉的正是"修饰"这一步。 本机做了一个决定性实验:C 符号可以让 mingw 的 main 链接上 MSVC 编出的 provider,链接成功(rc=0);换成 C++ 符号,链接失败(rc=1)。 同一个 main、同一份 provider,差别只在名字有没有被修饰。

1.1 为什么这一层值得单独写一篇

因为它是跨模块、跨库、跨语言、跨编译器问题的共同底层。三类最常见的疑难问题,根子都在符号层:

  • "undefined reference to xxx":链接器找不到某个名字的定义。而这个 xxx 可能是修饰后的形式,肉眼完全认不出它对应源码的哪个函数。
  • DLL 导出不完全、运行时报"找不到序数/入口点":导出表里少了你要的符号,而原因常常是 dllexport 写错位置(第 0.4 节的第 2 条)。
  • 同一个库在两个项目里行为不一致:可能是 ABI 不同(比如 libstdc++ 的 Dual ABI,第九章)。

这三类问题用调试器查不出来——它们在程序跑起来之前就已经决定了。

1.2 三类疑难问题的共同底层

上一节列了三种典型症状。这一节把它们和"符号层"的对应关系讲透——因为很多工程师会把它们分别归因到"构建配置问题""DLL 配置问题""版本问题",而它们共享同一个底层。

第一类:undefined reference / LNK2019。

链接器说的是"我找不到某个名字的定义"。而这个名字是修饰后的形式。所以排查的第一步永远不是"去改构建脚本",而是先把那个名字反修饰,看清它到底是什么(第四、六章)。本机的跨编译器实验给了最直接的例证:同一个函数 cpp_api(int),在两个编译器里符号名毫无共同点,链接器根本无法把它们匹配上。

第二类:DLL 导出不完全 / 运行时找不到入口点。

这类问题的根源在导出表——它是一份显式的清单,而不是"我写了就自动导出"。本机实测的 dllexport 位置差异(1 条 vs 5 条)就是最典型的例子:你以为导出了整个类,实际只导出了旁边那个自由函数(第七章)。

第三类:同一个库在不同工程里行为不一致。

这类最难查,因为链接往往成功。原因可能是 ABI 不同——本机第九章要讲的 libstdc++ Dual ABI 就是一个纯粹符号层的例子:源码里同样是 std::string,二进制层却可能有两个不同的名字(第九章)。

把三类问题放在一起看,会发现它们的排查入口是同一个:先看符号。 这也解释了为什么这一篇要花三个章节(二、三、四)先讲"名字从哪来、长什么样、怎么还原"——那是所有后续排查的前提。


二、为什么需要修饰:C++ 语义在二进制层的载体

先回答最基础的问题:为什么 C 不需要修饰,C++ 却需要?

2.1 C 的世界:一个名字一个符号

C 没有重载、没有命名空间、没有模板、没有类。一个函数名在程序里是唯一的。 所以 C 的符号名基本就是函数名本身(加上平台约定的少量前缀):

语言 声明 符号名(Windows/x86 约定)
C int c_add(int, int) _c_add(__cdecl)
C int __stdcall f(int) _f@4(@ 后是参数字节数)
C int __fastcall f(int) @f@4

规则简单到可以手写。 这也是 C 的 ABI 能稳定几十年的原因。

2.2 C++ 的世界:同一个名字可以是很多东西

C++ 引入了四类"同名不同物":

namespace app {
  int add(int, int);            // ① 重载
  double add(double, double);   // ① 重载(参数类型不同)
  struct Widget { int get() const; };   // ② 类成员(还有 this 指针的隐式参数)
  template <typename T> T twice(T);     // ③ 模板(同一模板的不同实例是不同函数)
}
int global_var;                  // ④ 全局变量也是符号

这四类在源码里可以共用一个名字,但在链接器眼里必须是不同的符号。 否则链接器无法区分"你要的是 add(int,int) 还是 add(double,double)"。

修饰(mangling)就是解决这件事的手段:把"名字 + 命名空间路径 + 参数类型 + 模板实参 + 类作用域"等全部信息编码进一个字符串。

2.3 修饰名里到底装了什么

用本机实测的一个例子来感受。MSVC 把 app::add(int, int) 编成 ?add@app@@YAHHH@Z,逐段读(反修饰工具 undname 会直接告诉你答案,见第四章):

片段 含义
? MSVC C++ 修饰名的起始标志
add 函数名
@app@ 所属命名空间 app
@@ 名字部分结束
YA 全局函数 + __cdecl 调用约定
H 返回类型 int
HH 两个参数都是 int
@Z 结束标记

同一个 add,只是参数从 int 换成 double,编码就从 ?add@app@@YAHHH@Z 变成 ?add@app@@YANNN@Z——注意 H 全变成了 N(double 的编码)。重载区分就靠这几个字母。

为什么需要名字修饰——重载、命名空间、模板、成员函数四类同名不同物
题图2 为什么需要名字修饰——四类"同名不同物"都必须编码进符号名@ 本系列绘制

这张图想强调的是"必要性"这件事:修饰的作用只有一个:让链接器能区分四类同名不同物。 如果不做修饰,app::add(int,int) 与 app::add(double,double) 会得到同一个符号,链接器就无法判断调用方要的是哪一个——重载机制在二进制层会直接失效。

2.4 边界声明:本篇不涉及

  • 不逐条讲完两套文法的全部产生式(那是规范的活)——只讲能读懂符号所需的部分。
  • 不涉及异常表、调试信息、RTTI 的完整二进制格式——只在符号命名层面提及。
  • 不做 ELF 与 PE 的完整格式对比——只讲与"符号与动态链接"相关的部分。

2.5 修饰名的长度代价

修饰名不只是"名字"——它要占用目标文件空间、影响链接时间。把源码里的短名字和修饰后的长名字放在一起对比,差距相当直观:

源码里的声明 MSVC 修饰名 字符数比
add ?add@app@@YAHHH@Z 3 → 17
twice<int> ??$twice@H@app@@YAHH@Z 10 → 22
operator=(拷贝 Exported) ??4Exported@@QEAAAEAV0@AEBV0@@Z 10 → 31

越"现代 C++"的写法,修饰名越长。 嵌套命名空间、模板、const 引用参数、返回值类型,全都要编码进去。Itanium 侧虽然有 S_ 替换机制能压缩长度,但 MSVC 侧没有——这就是为什么同一个工程在 MSVC 下的符号表往往比在 GCC 下更大。

这条有一个实际后果,正好接上 ep04:符号名越长,符号表越大,链接时要比较的字符串越长。 一个重度使用模板的工程,符号表本身可以占目标文件相当可观的比例。"模板膨胀"不只是代码膨胀,也是符号膨胀。


三、两套文法对账:Itanium 与 MSVC

C++ 没有统一的二进制 ABI,所以名字修饰也就没有统一标准。实际存在两套主流文法:Linux/macOS/多数 Unix 用 Itanium C++ ABI,Windows 用 MSVC 的文法。

3.1 同一份源码,两种修饰名(本机实测)

本机编译了同一份 mangling.cpp(含命名空间、重载、模板、extern "C"、全局变量),分别用 MSVC 与 g++ 编译,然后从符号表里取出修饰名:

源码实体 MSVC 修饰名 GCC(Itanium)修饰名
app::add(int, int) ?add@app@@YAHHH@Z _ZN3app3addEii
app::add(double, double) ?add@app@@YANNN@Z _ZN3app3addEdd
app::twice<int>(int) ??$twice@H@app@@YAHH@Z _ZN3app5twiceIiEET_S1_
global_var ?global_var@@3HA global_var
MSVC 侧修饰名清单(dumpbin /symbols)
图 6-13 MSVC 侧修饰名清单——dumpbin /symbols 的原始输出@ MSVC 19.44 实测

把这份原始输出与下一张放在一起看,能获得这一篇最重要的一个整体印象:同一份源码,两个编译器给出的符号名毫无共同点。 这种"完全不同"不是编码风格的差异,而是两套独立设计的文法——所以它们之间不存在"转换"的可能。

GCC 侧修饰名清单(nm)
图 6-24 GCC 侧修饰名清单——nm 的原始输出(PE/COFF 目标)@ g++ 16.2.0 实测

注意 GCC 这份清单里 global_var 是个例外:它没有被修饰。原因在 Itanium 文法里——全局变量名在某些情况下可以直接使用(这也是为什么某些"变量链接不上"的现象与函数的表现不同)。这属于边界细节,遇到时用 c++filt 反解一下即可确认。

双编译器修饰名对照表
图 6-35 同一份源码的双编译器修饰名对照:四个实体两套编码@ 本机实测

这张表最该记住的是:两列之间没有任何"可以机械转换"的关系。 它们是两套独立设计的文法,不存在同一套编码的不同写法这回事。

3.2 Itanium 文法:_Z + 长度前缀

Itanium C++ ABI 的 mangling 规则(官方规范)有几个鲜明特征:

特征一:入口是 _Z。 所有 C++ 修饰名以 _Z 开头,一眼就能识别。

特征二:名字用"长度 + 字符"编码。 3app 表示 3 个字符的名字 app;3add 表示 add。这与 MSVC 用 @ 分隔的记法完全不同。

特征三:限定名用 N...E 包裹。 _ZN3app3addEii 里的 N3app3addE 表示"限定名 app::add",紧跟的 ii 是两个参数类型(int, int)。

特征四:类型用单个字母编码。 常见的有:

编码 类型 编码 类型
i int d double
f float c char
v void b bool
K const 前缀 P 指针前缀
R 左值引用前缀 O 右值引用前缀

(完整表见 Itanium ABI mangling 与 示例页。)

于是 _ZN3app3addEii 可以逐段读成:_Z(C++ 修饰) + N3app3addE(限定名 app::add) + ii(int, int)。

特征五:有"替换"机制。 Itanium 文法会用 S_/S0_ 这样的缩写复用前面出现过的类型或名字——这让长名字明显变短(示例页 里 _ZplR1XS0_ 就是 operator+(X&, X&))。MSVC 文法没有对应的机制,所以长签名的名字往往更长。

Itanium 文法拆解——_ZN3app3addEii 逐段读
图 6-46 Itanium 文法拆解:_Z + 限定名 N…E + 类型编码@ 本系列绘制

拆解的价值在于"能顺读":_Z 认出这是 C++ 符号,3app / 3add 用长度前缀切出名字,N…E 认出这是限定名,最后的 ii 认出参数类型。掌握这四个动作,绝大多数 Itanium 符号都能半分钟内读懂,不必每次都依赖工具。

Itanium 编码表(官方来源面板)
图 6-57 Itanium ABI 主要编码字母表——官方规范来源面板@ itanium-cxx-abi.github.io

3.3 MSVC 文法:? + @ 分隔

MSVC 的规则(官方文档 Decorated names)与 Itanium 完全不同:

特征一:入口是 ?。 C++ 名字以 ? 开头;C 名字则是直接的名字(加上平台前缀)。

特征二:用 @ 分隔层级。 ?add@app@@YAHHH@Z 里,@ 把"函数名/命名空间/名字结束"切成几段。空的一段(@@)表示"名字部分到此结束"。

特征三:访问权限、调用约定、返回类型都编进去。 这是 MSVC 文法最"重"的地方——看第三章 3.5 节的实测:

修饰名 反修饰结果
?add@app@@YAHHH@Z int __cdecl app::add(int,int)
?get@Exported@@QEBAHXZ public: int __cdecl Exported::get(void)const __ptr64
??4Exported@@QEAAAEAV0@AEBV0@@Z public: class Exported & __ptr64 __cdecl Exported::operator=(class Exported const & __ptr64) __ptr64

注意 get 那条里的 QEBA ——字母里编了"public 成员函数""const 方法""x64 下 __ptr64"这些信息。MSVC 把"源码里怎么声明的"也放进了名字,而 Itanium 主要放"类型和结构"。

特征四:C++ 名以 ? 开头,C 名则按调用约定加前缀。 官方文档给出的 C 名对照(Name decoration):

调用约定 C 符号名
__cdecl _test
__stdcall _test@0(@ 后接参数字节数)
__fastcall @test@0
MSVC 文法拆解——?add@app@@YAHHH@Z 逐段读
图 6-68 MSVC 文法拆解:? + @ 分隔 + 访问/约定/返回类型编码@ 本系列绘制

这张图里最需要留意的是 @ 的作用:它既分隔层级,也用"连续两个 @"表示名字段结束。初读 MSVC 符号时最容易在这里迷路——把 ?add@app@@ 切成"函数名 = add、命名空间 = app、名字结束",后面剩下的 YAHHH@Z 就全是类型信息了。

MSVC 修饰名官方文档(来源面板)
图 6-79 MSVC Decorated names 官方文档来源面板@ learn.microsoft.com

3.4 两套文法的对照小结

维度 Itanium MSVC
起始标志 _Z ?
层级分隔 长度前缀(3app) @
限定名包裹 N...E @ 分段 + @@ 结束
类型编码 单字母(i/d/P/K) 单字母(H/N/A…),但字母表不同
是否编访问权限 否 是
是否编调用约定 是(但用 Y 等简写) 是(A/E/G/I 等)
是否有类型替换 有(S_/S0_) 无
反修饰工具 c++filt undname

这张表最后的结论只有一句:两套文法没有共同点,所以两套工具链的目标文件不能直接互链。 第六章会用实测把这件事钉死。

3.5 修饰不是"绝对统一"的

值得加一句诚实的注脚:即便是同一套 ABI,实现之间也可能有细微差异。 例如 GCC 有一个长期存在的 bug(GCC Bug 88413):对含"未解析名"的模板实参,GCC 用了与 ABI 规范不同的产生式。这类差异极罕见,但足以说明——"ABI 一致"是一份工程承诺,不是一个可以随便假设的事实。

3.5 修饰不是"绝对统一"的

值得加一句诚实的注脚:即便是同一套 ABI,实现之间也可能有细微差异。 例如 GCC 有一个长期存在的 bug(GCC Bug 88413):对含"未解析名"的模板实参,GCC 用了与 ABI 规范不同的产生式。这类差异极罕见,但足以说明——"ABI 一致"是一份工程承诺,不是一个可以随便假设的事实。

3.6 把一个真实修饰名完整拆开

前几节都在讲规则。这一节把一条真实的长修饰名从头拆到尾,把前面讲的东西串起来。用第四章会讲的反修饰工具,这条对应的是"拷贝赋值运算符":

??4Exported@@QEAAAEAV0@AEBV0@@Z

逐段读:

片段 含义
??4 运算符编码 —— ??4 是 operator=(4 对应赋值号)
Exported@ 所属类 Exported
@ 名字部分结束
QEAA Q = public 成员函数;E = __ptr64 指针;A = __cdecl;A = 返回的是引用
AEAV0@ 返回类型:A(引用)+ E(__ptr64)+ A(__cdecl)+ V0@(类类型,0 表示"前面出现过的那一类"= Exported)
AEBV0@ 参数类型:A(引用)+ E(__ptr64)+ B(const)+ V0@(Exported)
@Z 结束

最终反修饰结果:public: class Exported & __ptr64 __cdecl Exported::operator=(class Exported const & __ptr64) __ptr64

这条拆解里有三个值得留意的点:

  • 0 是"复用前面出现过的类型"的简写:V0@ 表示 Exported,因为 Exported 在名字开头已经出现过一次。MSVC 也有自己的压缩机制(只是形式与 Itanium 的 S_ 不同)。
  • B 表示 const:所以参数是 const Exported&——正是一个拷贝赋值运算符应有的签名。
  • 访问权限(Q = public)被编进了名字:这是 MSVC 文法独有的特征(第 3.4 节的对照表里列过)。Itanium 不编访问权限。

为什么要花一节讲这个? 因为真实报错里给你的就是这样一长串。能把它拆成"public 的、带 const 引用的拷贝赋值运算符",就意味着你能在几百行符号里一眼认出问题所在——这是第四章工具的用法,也是本篇最实用的一项技能。

3.7 修饰名里编不进去的东西

最后补一个反向的视角:哪些信息不在修饰名里。 这一点对排查很重要,因为"符号名相同"不等于"函数完全相同"。

信息 是否编进名字 后果
参数类型 ✅ 都编 参数不同 → 不同符号(这正是重载区分的基础)
返回类型 Itanium:不编(多数情况);MSVC:编 同名同参但返回类型不同 → Itanium 下符号相同,是病态用法
默认参数 ❌ 不编 默认参数是调用方的事,不影响符号;改了默认值不必重编被调方
noexcept ⚠️ 编进函数类型(C++17,属类型系统) 所以它会影响符号(这也是 ep03 讲 noexcept 重要性的一个侧面)
inline / constexpr ❌ 不编 多个 TU 各自定义同一符号,靠 COMDAT 去重(第八、九章)
函数体 ❌ 不编 链接器只看到名字,看不到实现——这正是 9.7 节"视野墙"的根源
调试信息 ❌ 不在符号表 在独立段/独立文件里,与链接无关

这张表里最该记住的是第 2 行与最后两行:

  • 返回类型在 Itanium 下不进符号名,所以"两个只差返回类型的函数"在 Itanium 工具链上会产生同一个符号——这是 C++ 不允许仅按返回类型重载的原因之一在二进制层的体现。
  • 函数体不进符号名,所以链接器永远不知道另一个 .obj 里那个函数做了什么。这一条直接推出了 ep05 讲的"优化器视野"与本篇 9.7 节讲的"动态链接的性能代价"——两道墙是同一个原因。

四、反修饰:把机器名还原成人话

修饰名的第一用途是给链接器用,但给人看的版本同样重要——因为报错信息里全是修饰名,肉眼根本无法阅读。所以两套工具链都配了反修饰工具。

4.1 MSVC 侧:undname

MSVC 自带 undname.exe(路径在 VC\Tools\MSVC\<版本>\bin\Hostx64\x64\ 下,不在 PATH 里)。本机用它对第一章的符号逐个反解:

输入(修饰名) 输出(反修饰)
?add@app@@YAHHH@Z int __cdecl app::add(int,int)
?add@app@@YANNN@Z double __cdecl app::add(double,double)
??$twice@H@app@@YAHH@Z int __cdecl app::twice<int>(int)
?global_var@@3HA int global_var
?get@Exported@@QEBAHXZ public: int __cdecl Exported::get(void)const __ptr64
??4Exported@@QEAAAEAV0@AEBV0@@Z public: class Exported & __ptr64 __cdecl Exported::operator=(class Exported const & __ptr64) __ptr64
undname 反修饰输出
图 6-810 undname.exe 对 6 条 MSVC 修饰名的反解结果@ MSVC 19.44 实测

注意最后两条。 它们反解出来的信息比源码声明还多——__ptr64、__cdecl、public:、const 全都是从名字里读出来的。这说明 MSVC 的修饰名里确实编进了"源码怎么声明的"这一层信息(第 3.3 节的特征三)。

4.2 GCC 侧:c++filt

Itanium 侧对应的工具是 c++filt(binutils 的一部分):

输入 输出
_ZN3app3addEdd app::add(double, double)
c++filt 反修饰输出
图 6-911 c++filt 对 Itanium 修饰名的反解结果@ g++ 16.2.0 实测

这条反解结果值得逐字读一遍:app::add(double, double)——命名空间、成员名、两个参数类型全部还原正确。这正是反修饰工具的用途所在:把无法阅读的字符串变回人类可读的声明,从而让"链接错误里那一串乱码"变成一条可以定位的信息。

用法提示:nm 有一个 -C(--demangle)选项,等于"输出符号时自动调 c++filt"。本系列的实测里两者都用了——nm 原始输出看修饰名,nm -C 看还原后的名字。

4.3 反修饰的实用价值

反修饰不只是"看着舒服",它是排查链接错误的必备技能:

  • undefined reference to '_ZN3app3addEii' ——反修饰之后立刻知道缺的是 app::add(int, int)。
  • DLL 导出表里一串 ?… ——反修饰之后能看出哪个是构造函数、哪个是赋值运算符。
  • nm 输出几百行符号 ——用 c++filt 或 nm -C 过滤,能快速定位你要找的那个。

所以工具链的用法是成对的:dumpbin /symbols 配 undname;nm 配 c++filt(或直接 nm -C)。

4.4 批量反修饰的实操

单条反修饰很简单,但真实排查场景里你面对的是几百行符号。本机实测中形成的做法分三步:

第一步:把符号导出来,别在终端里翻。

dumpbin /symbols my.obj > syms.txt     # MSVC 侧
nm my.o > syms.txt                     # GCC 侧

第二步:按"修饰名特征"过滤,而不是按名字过滤。

因为你要找的东西可能连名字都不知道,只能按特征找:

想找什么 MSVC 特征 GCC 特征
构造函数 ??0 C1/C2
析构函数 ??1 D1/D2
赋值运算符 ??4 aS
虚表 ??_7 _ZTV
类型信息 ??_7type_info _ZTI
模板实例 ??$ IiE(模板实参段)

第三步:对过滤结果批量反修饰。

反修饰工具都支持从标准输入读,所以能直接接管道。本机实测里,?… 一串喂给 undname、_Z… 一串喂给 c++filt,都能一次拿到全部还原结果——这一步做完,符号表就从"天书"变成了"声明清单"。

顺手记一个坑:undname 的输出里带着 Microsoft (R) C++ Name Undecorator 的版权头,脚本里解析时要注意剥掉;而 c++filt 只输出名字,干净得多。


五、extern "C" 与调用约定:x64 下约定被忽略

这一章讲"关掉修饰"这条通道,以及一个被广泛误解的细节。

5.1 extern "C" 到底做了什么

extern "C" 的作用不是"像 C 一样编译"——它只做一件事:告诉编译器"这个名字不要修饰"。

extern "C" int c_add(int a, int b);   // 符号名就是 c_add
int cpp_add(int a, int b);            // 符号名是修饰过的

本机实测的对照(同一份源码、两个编译器):

源码 MSVC 符号 GCC 符号
extern "C" int c_add(int,int) c_add c_add
int cpp_add(int,int) ?cpp_add@@YAHHH@Z _Z7cpp_addii

c_add 在两个编译器下是完全一样的符号名。 这就是 extern "C" 的威力——它让两个编译器对同一个函数产生同一个名字。

5.2 一个被忽略的事实:x64 下调用约定不再影响符号名

学过 x86 的人都知道"调用约定会改变符号名":__cdecl 是 _test,__stdcall 是 _test@0,__fastcall 是 @test@0。

但在 x64 上,这件事不成立了。 本机实测:

源码(x64,extern "C") MSVC 符号 GCC 符号
int __cdecl cdecl_fn(int) cdecl_fn cdecl_fn
int __stdcall stdcall_fn(int) stdcall_fn stdcall_fn
int __fastcall fastcall_fn(int) fastcall_fn fastcall_fn

三种调用约定产出三个完全不同的符号名(cdecl_fn/stdcall_fn/fastcall_fn——因为我给它们起了不同的名字),但每个名字里都没有任何约定编码:cdecl_fn 就是裸名,没有 _ 前缀也没有 @4 后缀。

原因很直接:x64 的 ABI 只有一种统一的调用约定(前四个整数参数走 rcx/rdx/r8/r9,其余走栈),__cdecl/__stdcall/__fastcall 这些关键字在 x64 上被忽略——它们只是为了让老代码能编译而保留的语法糖。

x64 下调用约定符号对照
图 6-1012 x64 下三种调用约定的符号名对照——名字里没有任何约定编码,说明约定被忽略@ 本机实测

这张表的读法要小心:三个符号名之所以不同,是因为我给三个函数起了不同的名字(cdecl_fn/stdcall_fn/fastcall_fn),而不是因为调用约定不同。关键证据是每个名字里都没有任何约定编码——没有 _ 前缀、没有 @N 后缀,说明约定在 x64 上确实被忽略。

实用含义:如果你的知识来自 x86 时代的资料,那么在 x64 上"调用约定导致链接失败"这一类经验已经失效。 真正还在影响符号名的,是 extern "C" 与否、以及修饰文法本身。

5.3 dllimport:不只是"另一个标记"

extern "C" 解决的是"名字要不要修饰"。而在 Windows 上还有一个与名字无关、但与链接方式有关的标记:__declspec(dllimport)。

标记 作用 本机实测到的差异
dllexport 把符号放进 DLL 的导出表 第七、八章的实测都在看这个表
dllimport 告诉编译器"这个符号来自 DLL",生成经 IAT 的间接调用 生成 call [__imp_xxx] 而不是 call xxx

为什么 dllimport 值得单独提?因为它改变的是"调用怎么写",而不是"名字叫什么"。 如果头文件漏了 dllimport,代码仍然能链接成功(链接器会用 __imp_ 前缀的符号去补 thunk),但每次调用都会先跳一次本地的 thunk 再去 IAT ——多一次跳转。

这条的实用含义:dllimport 写与不写,链接结果可能一样,性能却不一样。 而它不会报任何错——这类"静默差异"正是符号层最容易踩的坑。

相关做法:把"导入还是导出"做成一个宏,通过 MYLIB_EXPORTS 之类的宏在编译 DLL 与使用 DLL 时切换(MSVC 官方的 dllexport/dllimport 文档 给的正是这个套路)。


六、跨编译器鸿沟:一个决定性的链接实验

前五章都在讲"名字不一样"。这一章把它变成一个可执行的实验:如果名字不一样,能把两个编译器产出的目标文件链在一起吗?

6.1 实验设计

准备一份 provider 和两份不同的 main:

// xp.cpp —— 由 MSVC 编译成 xp_msvc.obj
extern "C" int c_api(int x){ return x * 3; }   // C 符号
int cpp_api(int x){ return x * 5; }            // C++ 符号

// xm_c.cpp —— 由 mingw g++ 编译,引用 C 符号
extern "C" int c_api(int);
int main(){ printf("%d\n", c_api(7)); return 0; }

// xm_cpp.cpp —— 由 mingw g++ 编译,引用 C++ 符号
int cpp_api(int);
int main(){ printf("%d\n", cpp_api(7)); return 0; }

然后用 mingw 的 g++ 去链接 MSVC 编出的 .obj:一组引用 C 符号,一组引用 C++ 符号。

6.2 实测结果

组合 结果
C 符号:mingw main + MSVC provider rc=0:链接成功 ✅
C++ 符号:mingw main + MSVC provider rc=1:链接失败 ❌
对照组:mingw 内部(main + provider) rc=0
对照组:MSVC 内部(main + provider) rc=0

第一行是这一篇最值得记住的一条实测:跨编译器的目标文件可以链接——只要走 C 符号。 mingw 的链接器真的读懂了 MSVC 产出的 .obj,并且找到了 c_api。

跨编译器链接成功(C 符号)
图 6-1113 C 符号跨编译器链接成功:mingw 的 main 链接上 MSVC 编出的 provider(rc=0)@ 本机实测

这一条的结论比它看起来更强:跨编译器的目标文件之间并不存在"格式壁垒"——mingw 的链接器完整读懂了 MSVC 产出的 COFF 目标文件,并成功解析了 c_api。真正挡住 C++ 的,是名字与更上层的约定,而不是容器格式。

跨编译器链接失败(C++ 符号)
图 6-1214 C++ 符号跨编译器链接失败:同一份 provider,只因名字被修饰而失败(rc=1)@ 本机实测

把这张图与上一张并排看,是整篇最有说服力的一组对照:输入只有一个字符的差别——名字有没有被修饰。 结果一边成功、一边失败。这也是为什么"跨编译器接口必须走 C 符号"这条经验如此牢固:它是唯一能绕开名字分歧的路径,与风格无关。

失败那组的报错原文(真实输出):

Warning: corrupt .drectve at end of def file
ld.exe: ...xm_cpp_g.o: undefined reference to `cpp_api(int)'
collect2.exe: error: ld returned 1 exit status
链接失败的报错原文
图 6-1315 链接失败的报错原文——undefined reference,且 MSVC 的 .drectve 段被 mingw 链接器判为损坏@ 本机实测

报错信息本身也印证了主题:链接器说的是 undefined reference to 'cpp_api(int)'——它知道"有个叫 cpp_api(int) 的东西没找到",但两个编译器给这个函数起的名不一样,所以永远对不上。

6.3 这个实验说明了什么

三点结论,按重要性排:

第一,extern "C" 是跨编译器二进制互操作的唯一可行通道。 这不是"风格问题",而是唯一能让两个编译器对同一个函数产生同一个名字的机制。这也是为什么几乎所有跨语言/跨编译器的接口(系统 API、插件 ABI、C 库、FFI 绑定)都长着 C 的面孔。

第二,"ABI 不兼容"这句话要说得更准确。 不是"目标文件格式不兼容"——mingw 明明能读 MSVC 的 .obj。不兼容的是名字与更上层的约定:名字修饰、异常模型、标准库布局、虚表结构。C 符号恰好绕过了这一整层。

第三,跨编译器可以链,但不等于可以混用 C++。 即使你靠 extern "C" 让链接通过了,两边的 C++ 对象(std::string、std::vector)仍然不能互相传递——因为标准库的内部布局不同。这一条在第九章的 Dual ABI 会再讲一次。

6.4 「名字对不上」的两种形态

值得把"名字对不上"分清楚,因为它们的报错信息不同、排查手段也不同:

形态 症状 原因
完全不同的编码 undefined reference to 'cpp_api(int)' 两个编译器的文法不同(本章实验)
同文法但签名不一致 undefined reference to '_Z7cpp_apiPKc' 同一个编译器,但声明与定义的类型不一致(比如一边 char* 一边 const char*)

第二种更常见、也更隐蔽:同一个编译器下,参数类型差一个 const 或一个引用符号,修饰名就完全不同。 反修饰之后一眼能看出来(第四章的工具就派上用场了)。

跨编译器鸿沟示意
图 6-1416 跨编译器鸿沟——C 符号可链、C++ 符号不可链;分界线在"名字是否被修饰"@ 本系列绘制

这张图刻意把"分界线"画在"名字是否被修饰"上——因为这是实测给出的结论:链接器不认识语义,它只做字符串匹配。 只要名字一致,它就把两边接起来;名字不一致,它连"这两者可能是同一个函数"都无法推断。

6.5 为什么"能链"不等于"能用"

第六章的实验容易让人得出一个过于乐观的结论:"既然 C 符号能跨编译器链接,那混用就是了。"这个结论危险。 链接成功只是第一关,后面还有三道:

第一关:异常模型。 MSVC 与 GCC 的 C++ 异常实现(展开表格式、运行时库、栈展开协议)完全不同。一旦异常跨过编译器边界抛出,行为没有保证。 所以跨编译器接口的常规做法是在边界上 catch 全部异常并转换成错误码,绝不让异常穿过去。

第二关:标准库容器布局。 前面反复说过:std::string、std::vector 的内部布局在两个工具链下不同(第九章的 Dual ABI 会再给一个更极端的例子)。你可以在接口上传指针与长度,不能传容器对象。

第三关:内存分配与释放的归属。 一个模块 new 出来的对象,能不能在另一个模块 delete?只有当两边用同一个运行时库时才行。 跨编译器(甚至同一编译器不同 /MT /MD 配置)时,堆不一致 → 崩溃。标准做法是"谁分配谁释放",把释放封成一个接口函数。

把这三关合起来,跨编译器接口的写法就很清楚了:

// 导出的 C 接口:只传基本类型与不透明指针
extern "C" {
  typedef struct ConnHandle* Conn;
  Conn conn_create(void);
  int  conn_send(Conn, const char* data, int len);   // 不抛异常,返回错误码
  void conn_destroy(Conn);                            // 谁创建谁销毁
}

这段代码里的每一条约束,都能在本文前面的实测里找到依据:extern "C" 对应第六章;"不抛异常"对应异常模型差异;"只传基本类型"对应标准库布局差异;"谁创建谁销毁"对应堆归属。跨语言/跨编译器的接口规范不是风格偏好,是符号与 ABI 层的硬约束。


七、可见性与导出:符号的"对外程度"

前面讲的是"符号叫什么"。这一章讲"符号要不要被外面看见"。

7.1 两套平台、两套机制

平台 机制 默认行为
ELF(Linux/macOS) visibility 属性:default / hidden / protected / internal 默认 全部导出(default)
PE(Windows DLL) 必须显式标记 __declspec(dllexport),或用 .def 文件 默认 全部不导出

注意这两套默认值是反的:ELF 默认全导出、PE 默认全不导出。这个差异导致两个很常见的现象:

  • Linux 上的 .so 常常导出几千个符号(包括它内部用的所有辅助函数),于是需要 -fvisibility=hidden 主动收紧(GCC 文档:该选项可显著改善加载/链接,并做出"接近完美"的导出 API)。
  • Windows 上的 DLL 常常"忘了导出",于是运行时找不到入口点。

7.2 dllexport 的位置会改变结果(本机实测)

这是本篇第二条反直觉发现。本机编译了两个几乎一样的 DLL,唯一差别是 __declspec(dllexport) 的位置:

// 写法 A:dllexport 写在 class 前面
__declspec(dllexport) class WidgetA { public: int v; int get() const; WidgetA(); };
__declspec(dllexport) int helperA(int);

// 写法 B:dllexport 写在 class 后面
class __declspec(dllexport) WidgetB { public: int v; int get() const; WidgetB(); };
__declspec(dllexport) int helperB(int);
写法 导出条目数 导出内容
A(写在前面) 1 仅 ?helperA@@YAHH@Z
B(写在后面) 5 构造函数、移动赋值、拷贝赋值、get、helperB

同一个标记、同一份类、只换了书写位置,导出条目从 1 变成 5。 类级的 __declspec(dllexport) 只有在写在 class 关键字之后(即 class __declspec(...) Name)时才把类成员纳入导出集合。

dllexport 位置对照(1 条 vs 5 条)
图 6-1517 __declspec(dllexport) 写在 class 前 vs 后:导出条目 1 条 vs 5 条@ MSVC 19.44 实测

这条差异的实用价值在于它极易被忽略:两种写法在语法上都能通过编译,没有任何警告。只有在检查导出表时才会发现结果不同。所以"我的 DLL 导出了什么"这个问题的唯一可靠答案是 dumpbin /exports,而不是"我写了 dllexport 应该就够了"。

7.3 隐式生成的成员也会被导出(本机实测)

本篇第三条反直觉发现紧接着上一条:B 写法导出的 5 条里,有两条是你从来没写过的——拷贝赋值与移动赋值运算符:

1    0 00001000 ??0WidgetB@@QEAA@XZ                    ← 构造函数
2    1 00001010 ??4WidgetB@@QEAAAEAV0@$$QEAV0@@Z        ← 移动赋值(你没写)
3    2 00001010 ??4WidgetB@@QEAAAEAV0@AEBV0@@Z          ← 拷贝赋值(你没写)
4    3 00001020 ?get@WidgetB@@QEBAHXZ                   ← get()
5    4 00001030 ?helperB@@YAHH@Z                        ← 自由函数

__declspec(dllexport) 导出的是"这个类的所有成员",包括编译器隐式合成的那几个。 这有两个实用含义:

  • 你的 DLL 导出表比你预期的长——这是正常的,不是配置出错。
  • 导出的隐式成员同时是一份 ABI 承诺:一旦这些运算符被外部链接引用,它们的签名就冻结了。
mydll.dll 的导出表(含隐式生成的赋值运算符)
图 6-1618 mydll.dll 的导出表——除显式导出的函数与变量外,还包含编译器隐式生成的拷贝/移动赋值运算符@ MSVC 19.44 实测

这份导出表里最值得停下来看的是那两条 ??4… ——它们是编译器隐式合成的赋值运算符,源码里从未出现。这说明 dllexport 的作用对象是"类的成员集合",而不是"你手写过的东西"。同时它们也构成一份 ABI 承诺:一旦被外部引用,签名就不能再改。

7.4 一个必须如实说的边界:-fvisibility=hidden 在 PE 上不生效

ELF 侧的 -fvisibility=hidden 是收紧导出的标准手段。但在本机的 PE 目标上,它是空操作。 实测:

g++ -std=c++20 -O2 -fvisibility=hidden -c vis.cpp
# 编译器接受选项,但产生警告
nm --defined-only vis_g.o
# 输出:pub_fn / hid_fn / plain_fn 三个都是 T(全局文本符号)

三个函数(包括明确标了 visibility("hidden") 的 hid_fn)在符号表里都是 T——没有变成局部符号。

-fvisibility=hidden 在 PE 目标上的实测
图 6-1719 -fvisibility=hidden 在 MSYS2/PE 目标上的实测结果:选项被接受但符号仍全部为全局@ g++ 16.2.0 实测

这条实测的意义在于划清工具链的能力边界:选项被接受、也产生了警告,但符号可见性没有改变。排查这类问题时,正确的顺序是先确认"这个机制在目标格式上是否适用",再去怀疑自己的写法——把 ELF 的经验套到 PE 上,会浪费很多时间。

这条实测的意义是划边界:-fvisibility 是 ELF 的机制,在 PE/COFF 目标上不适用——PE 的对应手段是 dllexport / .def 文件(默认全不导出)。不要把 Linux 上的导出收紧经验直接搬到 Windows。

可见性模型对照——ELF 四级 visibility vs PE 导出标记
图 6-1820 可见性模型对照:ELF 默认全导出(四级 visibility 收紧)vs PE 默认全不导出(需 dllexport/.def)@ 本系列绘制

7.5 .def 文件与 PRIVATE

除了 __declspec,Windows 还有另一条路:.def 文件。它用文本列出要导出的符号,链接时喂给链接器。它的一个特殊能力值得知道:

.def 的 PRIVATE 修饰符可以让符号进入 DLL 的导出表,但不进入静态导入库(import lib)(见 binutils 文档)。含义是:链接期谁也解析不到它,只能靠运行时 GetProcAddress 动态取址。

这条特性适合"我不想公开这个 API,但内部另一模块要跨 DLL 调用"这类场景——它导出的是"可运行时查找",不是"可静态链接"。

7.6 导出序号与"找不到序数"的排查

第 9.2 节展示导出表时提过:DLL 的导出可以按序号引用。这里把它作为一个独立问题说清,因为它是 Windows 上最常见的一类运行时故障。

症状:程序启动或调用时报"无法在 DLL 中找到序号 N"(ordinal N could not be located)。

根因:按序号导入,而序号是位置索引——它由导出表里的排列顺序决定,不是稳定的标识。DLL 加了一个导出函数、或改了导出顺序,序号就整体位移。

排查三步:

  1. 看导入方用的是名字还是序号:dumpbin /imports 的输出里,如果名字列是数字(或形如 Ordinal N),就是按序号导入。
  2. 看导出方的序号分布:dumpbin /exports,对照第 9.2 节那张表的"序号 / 序号值 / 名字"三列。
  3. 改回按名字导入:绝大多数情况下,按名字导入更稳(名字是符号,不会随顺序漂移)。按序号只在"故意隐藏名字"或"兼容极老二进制"时才用。

顺带一个相关的坑:加了导出函数却仍然报旧序号找不到,常见原因是导出的顺序变了(比如新函数插在了前面)。用 .def 文件显式写 NONAME 或固定序号,可以把序号钉住——这是 .def 文件在 PRIVATE 之外的另一个用途。


八、符号表解剖:一个 .obj 里到底有什么

这一章打开目标文件看内部结构。链接器读的所有信息,都在符号表里。

8.1 符号表条目的构成

一个目标文件的符号表(symbol table)里,每个条目大致回答四个问题:

字段 含义 本机看到的例子
名字 修饰后的符号名 ?get@Exported@@QEBAHXZ
类型 代码 / 数据 / 未定义 T(文本)、R(只读数据)、U(未定义)
绑定性 全局 / 局部 外部可链接 / 文件内私有
所属段 定义在哪个段、什么偏移 .text+0x1020

链接器做的就是最朴素的两件事:收集所有"定义",匹配所有"引用"。 匹配得上就填地址,匹配不上就报 undefined reference。

这里有个容易被忽略的细节:未定义的符号(U)和已定义的符号(T)同样重要。 本机 nm 输出里的 U 就是"我需要这个词,但这里没有定义"——它就是链接器要去别处找的东西。

8.2 静态库:符号索引的容器

静态库(.lib / .a)其实只是一堆 .obj 的归档,外加一份符号索引。链接器的用法是:看到 undefined reference to X,就去索引里查"哪个 .obj 定义了 X",然后把那个 .obj 拉进来。

本机实测:把 mangling.obj 打成静态库再读符号表,符号照旧可读(数量与直接读 .obj 一致)——归档不改变符号,只提供一个查找入口。

静态库符号表
图 6-1921 静态库(.lib)的符号表——归档后符号仍可读,链接器按需抽取成员@ MSVC 19.44 实测

归档这件事本身不改变任何符号,它只是提供了一个查找入口。这个细节解释了链接器的一条基本工作方式:它不需要读遍库里所有成员,只需要在索引里查到「哪个成员定义了缺失的那个符号」,再把那个成员拉进来——这也是静态库能承载成千上万个目标文件却仍能快速链接的原因。

这也是"静态库越大,链接越慢"的原因:链接器要在索引里反复查找。而模板实例化会让静态库里的符号数爆炸——这一点正好接上 ep04。

8.3 模板实例的符号

模板实例化会产生符号,而且是每个实例一个符号。本机实测:

实例 MSVC 符号(反修饰后)
pick<int>(int,int) int __cdecl pick<int>(int,int)
pick<double>(double,double) double __cdecl pick<double>(double,double)

注意反修饰结果里的 <int> 是被编码进符号名的——这正是 ep04 讲的"每个唯一参数组合一份独立代码"在符号层的体现。ep04 从编译时间角度看实例化,这一章从符号角度看:实例化就是"多出来一批符号"。

模板实例符号
图 6-2022 模板实例化产生的符号——每个参数组合一个符号(pick<int> / pick<double>)@ MSVC 19.44 实测

8.4 vtable 与 RTTI:编译器生成的符号

类有虚函数时,编译器会生成虚函数表(vtable)和运行时类型信息(RTTI)——它们也占用符号,而且命名规则同样两套。本机实测:

实体 MSVC 符号(反修饰) GCC 符号
vtable ??_7Base@@6B@ → const Base::\vftable'|_ZTV7Derived`
typeinfo ??_7type_info@@6B@ _ZTI4Base / _ZTI7Derived
typeinfo 名 — _ZTS4Base / _ZTS7Derived
删除析构器 ??_EBase@@UEAAPEAXI@Z(vector)/ ??_GBase@@…(scalar) —
全局运算符 ??2@YAPEAX_K@Z(operator new)/ ??3@YAXPEAX_K@Z(operator delete) —
MSVC 侧 vtable/RTTI 符号
图 6-2123 MSVC 侧的 vtable 与 RTTI 符号——??_7…(虚表)、??_E/??_G(删除析构器)、??2/??3(全局运算符)@ MSVC 19.44 实测

这份清单里有一组容易被忽略的符号:??2 与 ??3——它们是全局 operator new / operator delete。之所以值得留意,是因为它们的存在解释了"重载全局 new 能生效"这件事的机制根源:替换的就是这两个符号的定义。

GCC 侧 vtable/typeinfo 符号
图 6-2224 GCC 侧的 vtable 与 typeinfo 符号——_ZTV / _ZTI / _ZTS 三段命名@ g++ 16.2.0 实测

与上一张对照能看出两套实现的组织方式完全不同:MSVC 把虚表与类型信息分别做成独立的符号(??_7…),而 GCC 用 _ZTV/_ZTI/_ZTS 三段命名把它们区分开。这又一次印证了本篇的主线:同一个语言概念,在二进制层可能是完全不同的形状。

这张表值得记住三点:

  • MSVC 把"删除析构器"拆成 vector/scalar 两个(??_E 与 ??_G)——这是 MSVC 特有的实现细节,源码里完全看不见。
  • GCC 把 vtable / typeinfo / typeinfo 名拆成三段(_ZTV / _ZTI / _ZTS)。
  • 全局 operator new / operator delete 也是符号,而且可被替换——这就是"重载全局 new"能生效的机制根源。

mingw 侧还能看到 COFF 的按符号分段:每个符号被放进带名字后缀的段里(.rdata$_ZTI4Base、.rdata$_ZTV7Derived)。这是 COFF 的 COMDAT 机制——ep04 讲链接器去重时提到的"一个实例一个段",在符号表里看得见。

objdump 符号表
图 6-2325 objdump -t 的符号表输出(PE/COFF 目标)@ g++ 16.2.0 实测

8.5 符号表结构的示意

把上面这些合起来,一个 .obj 的符号表大致是这样组织的:

符号表结构解剖
图 6-2426 目标文件符号表结构——名字/类型/绑定性/所属段四要素,以及定义与引用的匹配关系@ 本系列绘制

把这份结构图与前面的实测对照起来看:符号表的每一行都能对应到本机输出里的某一列。 名字对应反修饰的输入,类型对应 nm 里的字母(T/R/U),绑定性对应全局与局部之分,所属段对应 COFF 的段名(如 .rdata$_ZTI4Base)。读懂符号表这件事,本质就是把这四列读熟。

8.6 符号数量与构建代价

把第八、九两章的内容合起来,能得到一条工程判断:符号越多,链接越慢、产物越大。

本机统计的几个目标文件已经给出趋势(图 33):只用 C 符号的文件符号数最少;引入模板实例与虚函数后明显上升。这与 ep04 的结论完全同向——ep04 从"编译时间"看模板实例化,这一章从"符号数量"看同一件事:

现象 ep04 的账 ep06 的账
一个模板被 100 种类型实例化 100 份实例化时间 100 组符号
一个类有虚函数 生成 vtable 与 RTTI 的时间 vftable/typeinfo 等若干符号
显式实例化 那个 TU 自伤 该 TU 的符号表变大

两条账的交点在链接器:ep04 说过"实例化的时间在编译阶段就花掉了,退不回来";而符号层面的代价还要再加一笔——链接器要处理这些符号。

由此得到两条可操作的结论:

  • 减少模板实例化,同时省编译时间与符号量(extern template、改非模板重载——但记得 ep04 的实测:extern template 在 MSVC 上没生效,要先验证)。
  • 静态库的成员抽取是有代价的:库越大,链接器查找的时间越长。把常用符号集中、把冷门符号拆到独立库,能明显改善链接时间。

九、动态链接:IAT 实测与 PLT/GOT 引原文

前面几章都在讲静态链接(链接时把符号解析完)。这一章讲动态链接——符号的解析推迟到程序启动或首次调用。

9.1 动态链接要解决什么问题

一个 DLL 在被加载到内存之前,它自己也不知道会落在哪个地址。所以"调用 DLL 里的函数"不能编译成一条直接的 call 固定地址——必须经过一层间接:

  • 调用方:不直接调函数,而是通过一张表(表里存着函数真实地址)跳过去;
  • 表里的地址:由动态加载器在加载时(或首次调用时)填好。

这层间接的表,PE 叫 IAT(Import Address Table),ELF 叫 GOT(Global Offset Table)。

9.2 PE 侧实测:导出表 → 导入表 → IAT

本机完整走了一遍 DLL 的动态链接链路。

第一步:DLL 的导出表(本机实测,mydll.dll)

序号 序号值 名字
1 0 ??4Exported@@QEAAAEAV0@$$QEAV0@@Z(移动赋值)
2 1 ??4Exported@@QEAAAEAV0@AEBV0@@Z(拷贝赋值)
3 2 ?dll_add@@YAHHH@Z
4 3 ?dll_counter@@3HA
5 4 ?get@Exported@@QEBAHXZ

注意导出是按"序号 + 名字"两套索引给出的:序号是位置索引(可被 GetProcAddress 用序号取),名字是符号索引。这也是"找不到序数"这类报错的来源——按序号导入时,DLL 换了版本、导出顺序变了,就对不上了。

第二步:EXE 的导入表(本机实测)

mydll.dll
    2    ?dll_add@@YAHHH@Z        ← 按名字导入,序号 2

第三步:PE 头里的 IAT(本机实测,dumpbin /headers)

数据目录 RVA 大小
Import Directory 2000C 3C
Import Address Table Directory 16000 278
Delay Import Directory 0 0
EXE 的导入表
图 6-2527 EXE 的导入表——mydll.dll 的 ?dll_add@@YAHHH@Z 按名字导入(序号 2)@ MSVC 19.44 实测

注意这份导入表里同时出现了序号(2)与名字(?dll_add@@YAHHH@Z)。这解释了 Windows 上"找不到序数"这类故障的来源:如果导入方只记序号,而导出方的导出顺序发生变化,序号就会指向错误的位置——名字是稳定的标识,序号不是。

PE 数据目录(含 IAT)
图 6-2628 PE 头的数据目录——Import Directory 与 Import Address Table Directory(RVA 16000 / 0x278)@ MSVC 19.44 实测

这里的结构值得说清:导入表(Import Directory)描述"我依赖哪些 DLL、各要哪些函数";而 IAT 是"函数真实地址"那几张表本身——加载器读导入表,把解析出的地址写进 IAT。所以:

  • 静态链接:链接时地址就确定了,调用是直接 call;
  • 动态链接:调用先读 IAT,再跳到真实地址。多一次内存访问。

9.3 延迟加载:把解析再往后推

PE 还有一层更进一步的机制:延迟加载(delay load)。官方文档(Linker support for delay-loaded DLLs)描述得很清楚:

  • 用 /DELAYLOAD:mydll.dll 链接,并连上 delayimp.lib;
  • 该 DLL 的函数首次被调用前,IAT 里的指针指向一个 thunk(桩),不是真实函数;
  • thunk 第一次执行时调 LoadLibrary + GetProcAddress,把真实地址写回 IAT,然后跳过去;
  • 代价与陷阱:不支持数据导入(只支持函数);而且如果你把函数地址存进了静态函数指针,它指向的是 thunk,首次调用后不会自动更新——必须重新取址。

延迟加载的实用价值:把"启动时必须加载的 DLL"变成"用到才加载",缩短启动时间;同时让"DLL 缺失"从"启动失败"变成"调用该函数时才失败"。

9.4 ELF 侧:PLT/GOT(引原文,本机未实测)

必须如实声明:本机没有可用的 ELF 环境(无 WSL,MSYS2 产出 PE),所以下面这部分只引权威原文,不做本机实测。

ELF 的对应机制由两部分组成:

  • GOT(Global Offset Table):存外部符号的真实地址,与 PE 的 IAT 角色相同。
  • PLT(Procedure Linkage Table):一段跳板代码。首次调用某外部函数时,PLT 跳到动态解析器去解析地址并写回 GOT(惰性绑定,lazy binding);之后的调用直接经 PLT → GOT → 真实函数。

关于"何时解析",ELF 规范(LSB 动态链接章节)说得很直接:环境变量 LD_BIND_NOW 非空时,加载时就处理全部 R_*_JMP_SLOT 重定位;为空时采用惰性求值,推迟到首次执行。

安全性上还有个常被一起提的机制——RELRO:

级别 效果
Partial RELRO 把部分段设为只读,但 GOT 仍可写(以支持惰性绑定)
Full RELRO(-Wl,-z,relro,-z,now) 把 GOT 等移入只读段(PT_GNU_RELRO),动态链接器在 main 之前解析全部外部符号

(参考 RELRO 说明 与 理解 RELRO。)

注意 Full RELRO 与惰性绑定是互斥的:要一次解析完所有符号,就必须放弃"用到才解析"。这正好是 PE 的 IAT + 延迟加载组合的镜像——两边都在"启动快"与"首次调用快/安全"之间做同一个取舍。

9.5 两套机制的对照

维度 PE(Windows) ELF(Linux/macOS)
间接表 IAT GOT
跳板 无(IAT 直接存地址) PLT
解析时机 加载时(默认) 惰性(默认)或加载时(LD_BIND_NOW / -z now)
推迟机制 延迟加载(/DELAYLOAD) 惰性绑定(默认就推迟)
只读保护 — RELRO(Full RELRO 需 -z now)
本机实测 ✅ 导出表/导入表/IAT 数据目录 ❌ 无 ELF 环境,仅引原文
动态链接的两套机制——PE 的 IAT vs ELF 的 PLT/GOT
图 6-2729 动态链接的两套机制:PE 的 IAT(+ 延迟加载)vs ELF 的 PLT/GOT(+ 惰性绑定/RELRO)@ 本系列绘制

把这两套机制并排放,会发现它们解决的是同一个问题——运行时才知道地址——只是把间接层放在了不同的位置。PE 把地址表(IAT)与调用点分开,ELF 额外加了一层跳板(PLT)。理解了这一层「为什么需要间接」,再去看延迟加载与惰性绑定,就会发现它们是同一个取舍的两种实现。

PE 格式官方规范(来源面板)
图 6-2830 PE 格式官方规范来源面板——数据目录与 IAT 定义@ learn.microsoft.com

这份面板的作用是把前面实测到的数字「接回规范」:本机读到的 Import Directory 与 Import Address Table Directory 两个条目,正是 PE 数据目录里定义的标准项。实测与规范对得上,结论才站得住——这也是本系列每一篇都坚持「引原文 + 本机验证」的原因。

9.6 顺带说清:Dual ABI 为什么会出现

第九章开头提到"跨编译器能链但 C++ 对象不能互传"。同一个编译器内部也有类似问题,这就是 libstdc++ 的 Dual ABI。

背景:C++11 要求 std::string 的某些接口(如 c_str() 返回保证带终止符的缓冲区)与 C++98 时代的实现不兼容。libstdc++ 既想修正实现,又不能破坏已有二进制,于是用了一招:把新实现放进一个 inline namespace __cxx11(官方文档)。

结果是:源码里同样的 std::string,在二进制层有两个不同的符号名(一个在 std::__cxx11 下、一个不在)。源码层面完全看不出差别,但链接层能对上就链、对不上就断。 受影响的符号包括 std::string、std::list、std::ios_base 等。

libstdc++ 官方在 ABI Policy 里还明确列了几类"会造成 ABI 泄漏"的东西:模板隐式实例化、typeinfo、虚表——全都是符号层面的东西,这正好呼应第八、九两章。

而 MSVC 走的是另一条路:官方承诺 VS2015 起的工具集保持二进制兼容(V140–V143 可互链)——用"冻结实现"换"不破坏二进制"。WG21 的 N4028 专门讨论过这条路线分歧:GCC 牺牲一部分标准符合性保 std:: 稳定,MSVC 反其道而行。这篇提案的结论至今仍然有效:C++ 没有可移植 ABI。

libstdc++ Dual ABI——同一份源码两个符号名
图 6-2931 libstdc++ Dual ABI:__cxx11 inline namespace 让同一个 std::string 拥有两个二进制名@ 本系列绘制

9.7 动态链接的性能代价

最后算一笔账:动态链接比静态链接慢多少,慢在哪。

从第 9.2 节的结构可以直接推出代价的三个来源:

代价来源 说明 能否避免
一次间接访问 调用要经 IAT/GOT 取地址,比直接 call 多一次内存读 不能完全避免;但取到的地址会被缓存,实际开销很小
解析开销 加载时(或首次调用时)要解析所有外部符号 加载时解析:启动变慢;惰性绑定:首次调用变慢。两者必选其一
优化受阻 编译器看不见 DLL 里的函数体,无法内联 用 LTO / 静态链接可以,但那要放弃动态加载的其它好处

第三条值得展开,因为它接上了 ep05:如果函数体在另一个 DLL 里,优化器永远看不见它。这正是 ep05 第 2.5 节说的"优化器的视野只覆盖它同时看得见的定义"——DLL 边界就是一道视野墙。

所以"要不要把某个模块做成 DLL",不只是部署问题,也是优化问题:

  • 做成 DLL:热更新方便、模块解耦、启动可以按需加载;代价是调用多一次间接、跨边界无法内联。
  • 做成静态链接:优化器可以跨模块内联与推断;代价是每次改动都要全量重链。

这个取舍没有普适答案,但知道"代价是什么"之后,就能按场景选——比如"性能关键的小模块静态链、可选功能做 DLL"。这跟 ep04 里"PCH 收益大但要看场景"、ep05 里"/arch 要按部署目标选"是同一种判断方式。


十、收束与下一篇钩子

10.1 全篇读数总汇

实验 指标 读数
双编译器修饰名 app::add(int,int) MSVC ?add@app@@YAHHH@Z / GCC _ZN3app3addEii
全局变量 global_var MSVC ?global_var@@3HA / GCC global_var
x64 调用约定 三者符号 cdecl_fn / stdcall_fn / fastcall_fn(均无约定编码)
跨编译器链接(C 符号) mingw main + MSVC provider rc=0 成功
跨编译器链接(C++ 符号) 同上 rc=1 失败
同编译器链接(对照) GCC / MSVC 各自内部 均 rc=0
dllexport 位置 class 前 / class 后 导出 1 条 / 5 条
DLL 导出表 mydll.dll 5 条(含编译器隐式生成的赋值运算符)
EXE 导入表 IAT 来源 ?dll_add@@YAHHH@Z(序号 2)
PE 数据目录 Import / IAT RVA 2000C / 16000(size 278)
模板实例符号 pick<int> / pick<double> 两个独立符号
vtable 符号 MSVC / GCC ??_7Base@@6B@ / _ZTV7Derived
-fvisibility=hidden PE 目标 不生效(三个符号仍为 T)
全篇实测读数总表
图 6-3032 全篇实测读数总表@ MSVC 19.44 / g++ 16.2.0 实测

读这张表建议按「结论 → 证据」的顺序看:左边两列是结论性的判定,右边一列是实测读数。凡是标了具体数字的条目,都能在 evidence 目录里找到原始输出——这是本系列的证据纪律:不下无法复核的结论。

10.2 各目标文件的符号数对照

本机统计了几个目标文件的符号规模,用来直观感受"什么东西会产生符号":

各目标文件符号数对照
图 6-3133 各目标文件的符号数对照——模板与虚函数显著抬高符号数量@ 本机实测

规律很清楚:只用 C 符号的文件符号数最少;一旦引入模板实例与虚函数,符号数明显上升——因为一个模板实例是若干符号、一个带虚函数的类是若干符号。

10.3 全量清单与二次归类

把本篇出现过的观测手段列全(全量清单),再按"你用它看什么"二次归类:

  1. dumpbin /symbols——读 MSVC 目标文件的符号表(含修饰名)
  2. dumpbin /exports——读 DLL 导出表
  3. dumpbin /imports——读 EXE/ DLL 的导入表
  4. dumpbin /headers——读 PE 数据目录(Import / IAT / Delay Import)
  5. lib.exe——打静态库并读其符号
  6. undname——MSVC 修饰名反解
  7. nm / nm -C——GCC 侧符号表(可选自动反修饰)
  8. objdump -t——符号表(更细的段/类型信息)
  9. c++filt——Itanium 修饰名反解
  10. g++ -fvisibility=hidden——尝试收紧导出(PE 上无效,本机已实测)
  11. __declspec(dllexport) / dllimport——PE 侧显式导出
  12. .def 文件(含 PRIVATE)——文本式导出控制
  13. 跨编译器链接实验——用实际链接成败验证"名字是否对得上"
  14. 对照实验(同编译器内部链接)——排除"格式问题"这个干扰因素
档位 作用 包含手段
看名字 确认符号被修饰成了什么 1、7、8
看名字的还原 把机器名读成人话 6、9
看导出/导入 确认对外暴露与依赖 2、3、4、5
控制可见性 决定导出多少 10、11、12
验证 用链接成败与对照实验收口 13、14

行动顺序:遇到链接问题时,先"看名字"(反修饰)→ 再"看导出/导入"→ 最后才动可见性设置。顺序反了会浪费大量时间在错误的假设上。

符号层工具与选项速查
图 6-3234 从源码到动态加载的符号流水线——每一步对应哪些工具@ 本系列绘制

10.4 六条可以带走的结论

  1. 链接器只认符号名:源码里的重载、命名空间、模板全部被编码进名字,两套主流文法(Itanium / MSVC)没有共同点。
  2. extern "C" 是跨编译器互操作的唯一通道:本机实测 C 符号可链、C++ 符号不可链。
  3. x64 下调用约定不再影响符号名:__cdecl/__stdcall/__fastcall 被忽略(x86 时代的经验失效)。
  4. dllexport 的位置会改变导出集合:本机实测 1 条 vs 5 条,且会连带导出编译器隐式生成的成员。
  5. -fvisibility=hidden 属于 ELF:PE 目标上不生效(本机实测),Windows 对应手段是 dllexport/.def。
  6. 动态链接的两套机制是对称的:PE 的 IAT+延迟加载 vs ELF 的 PLT/GOT+惰性绑定,取舍点相同。

10.5 本篇边界

本篇只讲符号层与链接期。不涉及:

  • 跨 TU 的优化(LTO / PGO 如何基于符号做全局推断):ep07 讲。
  • 异常与 RTTI 的完整实现(展开表、.pdata/.xdata 细节):ep09 讲。
  • 虚函数的分发代价(vtable 布局与多态调用成本):ep08 讲。
  • ELF 侧的实测(PLT/GOT、RELRO 的本机验证):需要 ELF 环境,本机不具备,本篇只引原文。

10.6 怎么在自己工程里复现这套量法

复现流程——四步在自己的工程里量符号
图 6-3335 复现流程:① 取符号表 ② 反修饰 ③ 看导出/导入 ④ 验证链接@ 本系列绘制
  1. 取符号表:MSVC 用 dumpbin /symbols,GCC 用 nm(加 -C 自动反修饰)。
  2. 反修饰:undname 或 c++filt,把 ?…/_ZN… 读成人话。
  3. 看导出/导入:dumpbin /exports 看 DLL 给了什么;dumpbin /imports + /headers 看它依赖什么、IAT 在哪。
  4. 验证:用"链接是否成功"作为最终判据——名字对不对得上,链接器说了算。
符号层自检清单
图 6-3436 符号层自检清单——六条可直接抄走的检查项@ 本系列绘制

10.7 一张决策表

把整篇的结论压成一张"遇到问题先查这里"的表:

你观察到的现象 最可能的原因 先做什么
undefined reference to '_Z...' 名字对不上(修饰名) c++filt 反修饰,看清缺的是哪个声明
undefined reference to '...@@...' 同上(MSVC 侧) undname 反修饰
同一个编译器内部链接失败 声明与定义的类型有细微差异 反修饰后逐个比对参数类型(const/引用最容易漏)
DLL 里导出太少 dllexport 写在了 class 前面 改成 class __declspec(dllexport) Name(实测 1 条 → 5 条)
DLL 导出表比预期长 dllexport 会连带导出隐式成员 正常现象;不想导出就用显式逐成员导出
Linux 的 .so 导出太多符号 ELF 默认全导出 -fvisibility=hidden(仅 ELF 有效)
Windows 上 -fvisibility=hidden 没效果 这是 ELF 机制 改用 dllexport / .def(实测在 PE 上不生效)
运行时"找不到序数" 按序号导入而序号变了 改成按名字导入;或用 .def 固定序号
调用 DLL 函数明显慢 无 dllimport(多一次 thunk)或无法内联 补 dllimport;或评估静态链接
跨编译器混用崩溃 异常/容器/堆三关之一没过 边界上只传基本类型;不跨边界抛异常;谁分配谁释放

这张表和第 10.3 节的工具清单配合使用:决策表告诉你"往哪个方向查",工具清单告诉你"用哪把工具查"。

10.8 与本系列前作的呼应

这一篇接的是第二波「编译器与链接器」的第三条腿,与前作的关系可以这样看:

前作 本篇的呼应
ep01 对象布局 对象布局决定了"类型大小",而类型大小被编进修饰名(H=int、N=double 背后是类型)
ep02 分配器 堆归属问题是跨模块内存传递的硬约束(6.5 节第三关)
ep03 值语义 移动/拷贝赋值运算符会作为符号出现在导出表里(7.3 节实测)
ep04 编译期账本 模板实例化既是编译时间,也是符号量(8.6 节的两条账)
ep05 优化器边界 "优化器只看见它同时看得见的定义"——DLL 边界就是一道视野墙(9.7 节)

第二波「编译器与链接器」到这里走完四分之三:ep04 看编译器花的时间,ep05 看编译器做的改写,ep06 看链接器怎么靠符号把一切拼起来,ep07 收在跨 TU 的优化(LTO/PGO)——那一篇会把"视野墙"这件事推到极致:拆掉它,代价是什么。


资源清单

下篇预告

【C/C++ 现代语言与底层】(七)链在一起:LTO 与 PGO 的收益从哪来——为什么开了 LTO 之后,内联能跨过 .cpp 的边界?PGO 采集的 profile 数据到底喂给了优化器什么?-flto 与 /GL+/LTCG 是同一件事吗?全程序优化为什么会让编译时间和内存占用暴涨?用实际构建对比把"跨 TU 优化的收益与代价"算清楚。

希望能对大家有所帮助。下一篇见。


本文由@溯光 出品 站点:https://yyy3d.online/blog/ | 本系列:https://yyy3d.online/blog/#topic-cpp-language-foundations