返回「计算机、信息技术与工程」

从C++到汇编:面向对象如何落地

更多
Markdown 结构化数据
本文目录 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 xxcall 相对跳转等),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->foomov (%rdi), %rax / call *(%rax)
  • .rodata 段里 _ZTV* 表头结构。

结语

“面向对象靠什么落地?” —— 固定的内存布局 + 隐含指针 + 表查找 + 间接跳转。 CPU 依然只懂“读 / 写 / 跳转”;而编译器把高层语义编排成对寄存器和内存的严格约定,从而让机器码在运行时呈现出多态、继承、封装等 OO 行为。