C++ ABI(Itanium C++ ABI)—— 虚函数怎么调、异常怎么抛、名字为什么那么长
C ABI 解决了"怎么传函数参数",但 C++ 多了虚函数、继承、异常、RTTI、名字重载这些高级特性——编译器不能随心所欲地实现,否则不同编译器编译的代码(甚至同一编译器不同版本)就无法链接在一起。Itanium C++ ABI(Linux 上 GCC/Clang 共同遵守的规范)就是给这些 C++ 独有概念编排的二进制约定。本篇拆解:名字改编(mangling)、虚表布局、this 指针、异常处理、RTTI、成员函数指针——每一点都讲清楚"它长什么样"和"为什么长这样"。
前置知识:c-abi(C 层面的函数调用约定,C++ ABI 建立在它之上);compile-link-load(链接器怎么解析符号,mangling 就是为符号解析服务的)。
零、C ABI 不够用——C++ 多出来的二进制难题
核心认知:C++ ABI 不是凭空另起炉灶——它在 C ABI 之上搭建。普通成员函数的调用就是个普通 C 调用(this 指针走 rdi)。真正多出来的工作量集中在虚函数分发、异常处理和运行时类型识别这三个领域。
一、名字改编(Name Mangling)
1.1 为什么 C++ 必须改编函数名
C 函数 void draw(int) 在符号表里就是 draw。C++ 允许同名不同参——draw(int), draw(double), draw(int, int)——符号表里不能都叫 draw。解决方案:把函数签名(参数类型+命名空间+模板参数)编码进符号名。
# 看一下 C++ 符号长什么样
echo 'void draw(int); void draw(double);' | g++ -xc++ - -c -o /tmp/mangle.o
nm /tmp/mangle.o | grep draw
# 输出类似:
# _Z4drawi ← draw(int)
# _Z4drawd ← draw(double)1.2 解码规则(Itanium mangling 速查)
Itanium C++ ABI 的 mangling 以 _Z 开头,后面跟嵌套长度编码:
| 部分 | C++ | mangling | 说明 |
|---|---|---|---|
| 前缀 | — | _Z | Itanium mangling 标记 |
| 命名空间 | std:: | St | _ZSt... 开头的是标准库的符号 |
| 函数名 | draw | 4draw | 长度前缀 + 名字 |
参数 int | — | i | i=int, d=double, v=void, P=指针, K=const |
参数 double | — | d | |
| 完整例子 | draw(int) | _Z4drawi | _Z + 4draw + i |
| 完整例子 | draw(double) | _Z4drawd | _Z + 4draw + d |
| 成员函数 | Foo::bar(int, double) | _ZN3Foo3barEid | N=嵌套开始, E=嵌套结束 |
1.3 对性能/排查的影响
- 链接错误:改个参数的 const、换类型、加默认值→可能 mangling 变了→链接找不到符号。
c++filt可以把 mangled 名字还原成人看的。 - perf 报告里的名字:
perf report默认会 demangle(还原),但在脚本处理或符号文件损坏时可能看到_Z...那样的一堆乱码。 extern "C"就是阻止 mangling——告诉编译器"这个函数按 C 的规则来(不加_Z前缀、不编码参数类型)",让 C 代码能调用。
# c++filt 还原 mangled 名
echo '_ZN3Foo3barEid' | c++filt # → Foo::bar(int, double)
echo '_ZSt3minIiERKT_S2_S2_' | c++filt # → std::min<int>(int const&, int const&)1.4 为什么同一个 C++ .so 换编译器版本可能崩
Mangling 格式虽然统一(GCC/Clang 都遵循 Itanium),但一些细节实现可能不同——比如某个编译器对 std::string 的内部名字不一致(__cxx11::basic_string vs basic_string,即 ABI tag),换编译器/标准库版本后符号解析全部错乱。这就是 GCC 5 引入 __cxx11 ABI tag 后引发的血案——新旧 ABI 的 std::string 互相认不出来。
二、this 指针——C++ 成员函数的底层本质
C++ 的非静态成员函数在二进制层面就是个普通的 C 函数,多了一个隐式的第一个参数 this:
class Point {
int x, y;
public:
void move(int dx, int dy); // 源码
};
// 编译器眼中的等价 C 函数:
void Point_move(Point *this, int dx, int dy);
// ↑ 隐式参数 走 rdi| 场景 | this 放在哪 | 备注 |
|---|---|---|
| 普通成员函数 | rdi(隐式第一参数) | 完全按 C ABI 的传参规则 |
| 虚函数调用 | rdi,通过 vtable 分发 | 多了两次间接访存(见下节) |
| 静态成员函数 | 没有 this | 就是普通函数 |
| 构造/析构函数 | rdi(隐式第一参数) | 构造函数返回 this(rax=rdi) |
性能含义:调用非虚成员函数和调用普通 C 函数零开销差异——多传一个
rdi和传其他参数没区别。
三、虚函数与虚表(vtable)——多态的底层代价
3.1 vtable 布局(单继承)
每个包含虚函数的类,编译器生成一个只读的虚函数表(vtable):
一次虚函数调用的实际过程(以 obj->f1() 为例):
mov rax, [obj] # 1. 读 vptr(obj 的前 8 字节)
mov rax, [rax + 8] # 2. 读 vtable[1] = &f1
call rax # 3. 间接跳转两步间接访存(读对象→读 vtable→跳转),比直接 call 至少多两个 load 延迟。如果 vtable 不在 cache 里,再加 cache miss 的几百个周期。
3.2 多重继承——this 指针需要调整
class A { virtual void fa(); };
class B { virtual void fb(); };
class C : public A, public B { virtual void fc(); };C 对象的内存布局:
offset 0: [A 的 vptr] → A-in-C vtable
offset 8: [A 的成员]
offset 16: [B 的 vptr] → B-in-C vtable
offset 24: [B 的成员]
offset 32: [C 的成员]当 B* pb = new C(); pb->fb(); 时,pb 实际指向 offset 16(B 子对象的位置),和 C* 指向的 offset 0 不一样。所以 dynamic_cast 或基类指针赋值时可能要调整 this 指针值——编译器在赋值时自动做了加减偏移。
3.3 虚继承(virtual base class)
"菱形继承"中如果不用虚继承,基类会出现两份拷贝。虚继承解决了这个问题——所有派生类共享唯一一份虚基类——代价是 vtable 里多了一个 vbase offset 表,访问虚基类成员要额外做一次指针加减(通常是走 vtable 的间接偏移量)。
3.4 对性能的量化影响
| 调用方式 | 额外开销 | 优化是否可能 |
|---|---|---|
| 普通成员函数 | 0(和 C 函数一样) | — |
| 非虚成员函数(final) | 0 | 编译器可去虚化(devirtualize) |
| 虚函数 | 2 次间接访存 + 间接跳转 | 去虚化优化:如果编译器能确定具体类型,直接生成 call |
| 虚函数 + vtable cache miss | 2 × cache miss(几百周期) | 把热对象放在一起(连续内存),提高 vtable 缓存命中率 |
四、异常处理(Itanium C++ ABI 的零成本模型)
4.1 零成本异常——try 不花任何指令
Itanium C++ ABI 使用基于表(table-based)的零成本异常模型:
- 正常路径(不抛异常)零开销:
try块入口不插任何代码,不像老式setjmp/longjmp那样保存/恢复上下文。 - 抛异常时:运行时展开栈帧(stack unwinding),查
.eh_frame段的 DWARF 信息找到catch处理器。
4.2 异常抛出时发生了什么

4.3 ELF 中的相关段
| 段名 | 内容 |
|---|---|
.eh_frame | 函数调用的异常处理帧表(每个函数一条记录,记录怎么展开、哪段代码受哪些 catch 保护) |
.eh_frame_hdr | .eh_frame 的索引(二分搜索用的 fast path) |
.gcc_except_table | Language-Specific Data Area(LSDA):try/catch 的对应关系、catch 的类型信息 |
.tbss/.tdata | thread-local 变量的初始数据 |
4.4 为什么性能敏感代码禁止异常
虽然 try 不花指令,但 throw 的开销极大:
- 分配异常对象(可能走堆分配)
- 查
.eh_frame逐帧展开(每一步都查表) - 执行每帧的所有析构函数
- 最终的
catch跳转是间接跳转(CPU 分支预测大概率失败)
游戏引擎、高频交易系统、内核模块一律禁用异常(
-fno-exceptions),不是因为它"让代码乱",而是它让性能不可预测。
五、RTTI(运行时类型识别)
typeid 和 dynamic_cast 依赖每个类的 vtable 第一条目——指向 std::type_info 对象的指针。同一个类的所有对象共享同一个 type_info。
class Base { virtual ~Base(); };
class Derived : public Base {};
Derived d;
Base *pb = &d;
auto &ti = typeid(*pb); // 运行时读 pb→vptr→vtable[0]→type_infodynamic_cast 的实际开销:不是简单的 vtable 查表——运行时需要沿继承链逐个比较 type_info 是否匹配。如果是多重继承 + 虚基类,比较过程更复杂(要查 vtable 中的偏移量表)。这也是为什么高频路径应该用 static_cast(零开销)或在基类提供虚函数来避免 dynamic_cast(开销接近一次虚函数调用)。
// 开销对比
Derived *pd = static_cast<Derived*>(pb); // 零开销
Derived *pd = dynamic_cast<Derived*>(pb); // RTTI 查表 → 数十~上百周期
void Base::visit() { /*虚函数分发做类型相关工作*/ } // 一次间接调用 → ~10 周期六、成员函数指针——比普通指针复杂
普通函数指针在 x86-64 下就是 8 字节(地址)。成员函数指针需要处理两种可能:
void (Foo::*pmf)(int);
// 如果是非虚函数,pmf 里存的就是函数的实际地址(8 字节)
// 如果是虚函数,pmf 里存的是 vtable 的偏移量 + 1(标记这是虚函数)实际上 Itanium ABI 中成员函数指针 ≠ 8 字节——它是一个结构体(至少 16 字节),包含函数地址/偏移量 和 this 指针调整值(用来处理多重继承)。所以 sizeof(&Foo::bar) 通常是 16 字节,而不是 8 字节。
七、全局对象构造/析构、thread_local
- 全局对象:构造函数在
main()之前调用,析构函数在main()之后或atexit中调用。实现依赖.init_array和.fini_arrayELF 段——见 elf-format。 thread_local:每个线程一份拷贝。实现走.tbss/.tdataELF 段 +__tls_get_addr运行时分发——访问 thread_local 变量比普通全局变量慢(多一次间接取得当前线程的 TLS 基址)。- 静态局部变量:第一次执行到时构造,线程安全的(编译器生成 guard 变量 +
__cxa_guard_acquire)。
八、与性能分析的关联
| 性能场景 | ABI 相关的点 |
|---|---|
| 虚函数调用了热路径 | vtable 间接调用 → 分支预测可能失败、cache miss 翻倍 |
perf 报告里满屏 _Z... | c++filt 还原或 perf report --demangle |
| 换个编译器版本后崩了 | ABI 不兼容(GCC5 __cxx11 tag、std::string 布局变更) |
dynamic_cast 占了热点 | 改成虚函数分发或 static_cast |
| 异常抛出延迟毛刺(latency spike) | 禁止异常(-fno-exceptions)或不在热路径 throw |
| thread_local 访问拖慢 | 减少热路径的 thread_local 读取次数、缓存到栈变量 |
| 对象布局的 cache miss | 把多态对象热数据放前 64 字节、vptr 在开头 |
| 链接 .so 找不到符号 | mangling 不匹配 → nm -C xxx.so | grep 符号 检查 |
九、一句话总结
C++ ABI 是在 C ABI 之上为虚函数、异常、RTTI 等高级特性编排的二进制规则:this 指针走 rdi(和普通参数没有性能差异);虚函数通过 vptr→vtable→函数指针两步间接调用(多 2 次 load + 可能 cache miss);Itanium 异常用零成本表模型(try 不花指令、throw 花巨大代价查 .eh_frame 展开栈+执行清理);名字改编把重载/模板/命名空间编码进符号名(
_Z...),换编译器版本可能 mangling 不兼容导致链接全炸。性能上能不用虚函数就不用、能不改 AB 就不改、能用static_cast就不dynamic_cast、能禁异常就禁异常。