﻿# C++ ABI（Itanium C++ ABI）—— 虚函数怎么调、异常怎么抛、名字为什么那么长

> C ABI 解决了"怎么传函数参数"，但 C++ 多了虚函数、继承、异常、RTTI、名字重载这些高级特性——编译器不能随心所欲地实现，否则不同编译器编译的代码（甚至同一编译器不同版本）就无法链接在一起。**Itanium C++ ABI**（Linux 上 GCC/Clang 共同遵守的规范）就是给这些 C++ 独有概念编排的二进制约定。本篇拆解：**名字改编（mangling）、虚表布局、this 指针、异常处理、RTTI、成员函数指针**——每一点都讲清楚"它长什么样"和"为什么长这样"。


> 前置知识：[c-abi.md](/concepts/elf/c-abi.md)（C 层面的函数调用约定，C++ ABI 建立在它之上）；[compile-link-load.md](/concepts/elf/compile-link-load.md)（链接器怎么解析符号，mangling 就是为符号解析服务的）。

## 零、C ABI 不够用——C++ 多出来的二进制难题

```plantuml
@startuml
skinparam shadowing false
skinparam defaultFontSize 13
skinparam rectangle {
  BackgroundColor<<c>> #C8E6C9
  BorderColor<<c>> #388E3C
  BackgroundColor<<cpp>> #BBDEFB
  BorderColor<<cpp>> #1976D2
}
rectangle "C ABI 定义了\n——\n· 寄存器传参哪几个\n· 栈怎么对齐\n· 数据类型大小\n· 返回值放哪" <<c>> as C
rectangle "C++ ABI 额外定义了\n——\n· 重载区分 → 名字改编(mangling)\n· 虚函数 → 虚表(vtable)布局\n· 多继承下 this 指针调整\n· 异常 → 栈展开(unwinding)\n· 运行时类型信息(RTTI)\n· 成员函数指针\n· 全局对象构造/析构顺序\n· thread_local 存储" <<cpp>> as CPP
@enduml
```

**核心认知**：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`。解决方案：**把函数签名（参数类型+命名空间+模板参数）编码进符号名**。

```bash
# 看一下 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 代码能调用。

```bash
# 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`**：

```cpp
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）**：

```plantuml
@startuml
skinparam shadowing false
skinparam defaultFontSize 13
skinparam rectangle {
  BackgroundColor<<obj>> #E3F2FD
  BorderColor<<obj>> #1976D2
  BackgroundColor<<vtbl>> #FFF9C4
  BorderColor<<vtbl>> #F9A825
  BackgroundColor<<code>> #C8E6C9
  BorderColor<<code>> #388E3C
}
rectangle "对象实例 Base b\n——\n[0] vptr →\n[8] 成员1\n[16] 成员2" <<obj>> as OBJ
rectangle "Base 的 vtable\n(只读数据段)\n——\n[0] type_info* → RTTI\n[8] &Base::f1()\n[16] &Base::f2()" <<vtbl>> as VTBL
rectangle "代码段\n真正执行的函数体" <<code>> as CODE
OBJ -right-> VTBL : vptr 指向
VTBL -down-> CODE : 函数指针指向
@enduml
```

**一次虚函数调用的实际过程**（以 `obj->f1()` 为例）：

```asm
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 指针需要调整

```cpp
class A { virtual void fa(); };
class B { virtual void fb(); };
class C : public A, public B { virtual void fc(); };
```

`C` 对象的内存布局：

```bash
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 异常抛出时发生了什么

```plantuml
@startuml
skinparam shadowing false
skinparam defaultFontSize 13
skinparam participant {
  BackgroundColor<<user>> #E3F2FD
  BorderColor<<user>> #1976D2
  BackgroundColor<<runtime>> #FFCDD2
  BorderColor<<runtime>> #C62828
  BackgroundColor<<personality>> #FFF9C4
  BorderColor<<personality>> #F9A825
}
participant "throw 指令" as T <<user>>
participant "__cxa_throw\n(C++ runtime)" as R <<runtime>>
participant "_Unwind_RaiseException\n(栈展开器)" as U <<runtime>>
participant "personality routine\n(__gxx_personality_v0)" as P <<personality>>
participant "catch 代码块" as C <<user>>
T -> R : 1. 调用 __cxa_allocate_exception\n分配异常对象+调用__cxa_throw
R -> U : 2. 开始栈展开
activate U
U -> P : 3. 对每一帧调用 personality routine\n"这帧有 try/catch 吗？"
P --> U : 4. "没有" → 继续往上查
U -> P : 5. 对下一帧再问
P --> U : 6. "有 catch!" → 返回 handler 地址
U -> U : 7. 执行每个已跳过帧的\ncleanup 代码(局部对象析构)
U -> C : 8. 跳转到 catch 块
deactivate U
@enduml
```

### 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。

```cpp
class Base { virtual ~Base(); };
class Derived : public Base {};
Derived d;
Base *pb = &d;
auto &ti = typeid(*pb);  // 运行时读 pb→vptr→vtable[0]→type_info
```

**`dynamic_cast` 的实际开销**：不是简单的 vtable 查表——运行时需要**沿继承链逐个比较 type_info 是否匹配**。如果是多重继承 + 虚基类，比较过程更复杂（要查 vtable 中的偏移量表）。这也是为什么高频路径应该用 `static_cast`（零开销）或在基类提供虚函数来避免 `dynamic_cast`（开销接近一次虚函数调用）。

```c++
// 开销对比
Derived *pd = static_cast<Derived*>(pb);     // 零开销
Derived *pd = dynamic_cast<Derived*>(pb);    // RTTI 查表 → 数十~上百周期
void Base::visit() { /*虚函数分发做类型相关工作*/ }  // 一次间接调用 → ~10 周期
```

## 六、成员函数指针——比普通指针复杂

普通函数指针在 x86-64 下就是 8 字节（地址）。**成员函数指针**需要处理两种可能：

```cpp
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_array` ELF 段——见 [elf-format.md](/concepts/elf/elf-format.md)。
- **`thread_local`**：每个线程一份拷贝。实现走 `.tbss`/`.tdata` ELF 段 + `__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`、能禁异常就禁异常。**

