返回「计算机、信息技术与工程」
从C++到汇编:面向对象如何落地
本文目录 11 个章节
从C++到汇编:面向对象如何落地
// hello.cpp
#include <iostream>
class Base {
public:
int x; // 数据成员
Base(int v = 0) : x(v) {} // 构造函数
virtual void foo(); // 虚成员函数
virtual ~Base(); // 虚析构
};
class Derived : public Base {
public:
int y;
Derived(int v1, int v2) : Base(v1), y(v2) {}
void foo() override; // 重写
};
void Base::foo() { std::cout << "Base " << x << '\n'; }
Base::~Base() { std::cout << "~Base\n"; }
void Derived::foo(){ std::cout << "Derived "<< y << '\n'; }
int main() {
Base* p = new Derived(1, 2); // 多态:基类指针指向派生对象
p->foo(); // 动态分派
delete p; // 触发虚析构
return 0;
}
1 编译流水线全景
| 阶段 | 产物 | 说明(与 OO 相关的要点) |
|---|---|---|
| 预处理 | hello.i |
展开头文件、宏 |
| 编译 | hello.s |
语法/语义分析 → 生成 vtable 常量、构造/析构函数、thunk;完成优化 |
| 汇编 | hello.o |
把 .s 汇编为机器码 + 重定位表 |
| 链接 | a.out (ELF) |
合并段、解析外部符号;各 vtable 落在 .rodata |
| 加载 | 运行时 ELF 加载器 ld.so |
把节映射入虚拟地址,做最后重定位;执行 .init_array 里的静态构造 |
| 运行 | CPU | 指令流 + 对象内 vptr 间接跳转实现面向对象 |
2 对象物理布局(Derived 单继承,含虚函数)
偏移 大小 成员 / 隐含字段
----- ---- -----------------------------
0 8 vptr → _ZTV7Derived + 16
8 4 Base::x
12 4 padding (保持 y 对齐)
16 4 Derived::y
20 4 padding (整体 8 字节对齐)
- 只要类层次里某处出现虚函数,最派生对象必有 单一 vptr(单继承)。
- vtable 前两槽是 RTTI (
typeinfo);+16 跳过这两槽即第一虚函数地址。
3 关键符号与 vtable 片段
# 从C++到汇编:面向对象如何落地
_ZTV4Base: .quad 0 # [–2] (填充,供实现内部使用)
.quad _ZTI4Base # [–1] RTTI
.quad _ZN4Base3fooEv # [0] Base::foo
.quad _ZN4BaseD1Ev # [1] Base::~Base (non-deleting)
.quad _ZN4BaseD0Ev # [2] Base::~Base (deleting)
_ZTV7Derived: .quad 0
.quad _ZTI7Derived
.quad _ZN7Derived3fooEv # 覆写 foo
.quad _ZN7DerivedD1Ev
.quad _ZN7DerivedD0Ev
4 构造函数落地(避免覆盖的两步 vptr 写入)
## Derived::Derived(int, int) – 伪反汇编
_ZN7DerivedC2Eii:
push %rbp
mov %rbp, %rsp # rdi=this, esi=v1, edx=v2
## (1) 调用 Base 子对象构造 —— 写入 Base vptr & 初始化 x
mov %esi, %esi # 参数映射
mov %rdi, %rdi
call _ZN4BaseC2Ei # Base::Base(int)
# └→ 在 Base 构造中:
# mov $_ZTV4Base+16,(%rdi) ; vptr = Base
# mov %esi,8(%rdi) ; x = v1
## (2) 初始化 Derived 自身成员
mov %edx, 16(%rdi) # y = v2
## (3) 覆盖 vptr 为 Derived
mov $_ZTV7Derived+16, %rax
mov %rax, (%rdi)
pop %rbp
ret
为什么一定要先写 Base 然后再写 Derived? C++ 规则规定:
- 构造基类阶段,对象“身份”是 Base;若此时调用虚函数必须静态绑定到 Base 实现。
- 只有所有基类构造完毕,才进入派生阶段并把 vptr 最终定向 Derived。
5 虚函数调用 = “对象 → vptr → 间接 call”
在 main() 内的 p->foo() 反汇编
## rdi = p
mov (%rdi), %rax # rax = *(p) = vptr
callq *(%rax) # 间接跳转到 vtable[0] → Derived::foo
- 比直接
call _ZN7Derived3fooEv多一次 内存读取(取表),即动态分派成本。
6 delete 触发虚析构
mov (%rdi), %rax # 取 vptr
callq *(%rax+8) # 第 1 槽 (index = 1) 是 ~DerivedD1
callq _ZdlPv # operator delete 由编译器插入
析构链:Derived::~Derived() → Base::~Base() → operator delete。
7 目标文件到机器码
- 链接阶段把所有
call _ZN4BaseC2Ei之类符号解析为绝对/相对地址。 - 若函数来自共享库,机器码里是跳转到 PLT;首次执行由动态链接器填 GOT,后续直达真实地址。
- 经过加载器重定位后,最终直接执行的就是 x86-64 机器码字节序列(
E8 xx xx xx xx为call相对跳转等),CPU 毫无面向对象概念,只按地址取指令。
8 成本与扩展
| 机制 | 空间成本 | 时间成本(热点) | 说明 |
|---|---|---|---|
| vptr / vtable | 每对象 8 B(单继承) | 1 次 L1 读 | 支撑动态多态 |
| 构造双写 vptr | 0 | 构造期两次写 | 只发生一次 |
| RTTI | 每类 1 条记录 | – | 存放在只读段 |
| 多重继承 | 对象多 vptr;thunk 调整 this |
< 5 ns/跳 | 额外偏移修正 |
| 虚继承 | VTT 表、vbptr | 同上 | 菱形继承场景 |
9 自行验证
g++ -std=c++20 -O1 -g hello.cpp # 适度优化保留符号
objdump -dC a.out | less # 反汇编 + 解码 C++ 名字
readelf -r a.out | grep ZTV # 看虚表重定位
聚焦:
Derived::Derived中 2 次对(%rdi)的写入;p->foo的mov (%rdi), %rax / call *(%rax);.rodata段里_ZTV*表头结构。
结语
“面向对象靠什么落地?” —— 固定的内存布局 + 隐含指针 + 表查找 + 间接跳转。 CPU 依然只懂“读 / 写 / 跳转”;而编译器把高层语义编排成对寄存器和内存的严格约定,从而让机器码在运行时呈现出多态、继承、封装等 OO 行为。