刨根问底:从反汇编看 C++ 对象的生与死

18 阅读4分钟

最近在研究 C++ 底层原理,与其背八股文,不如直接看汇编代码来得实在。今天我就从汇编视角,把 C++ 对象的构造和析构过程扒个底朝天,顺便破除几个常见的误区。

1. 实验环境与准备

我写了两段简单的代码来对比,一段是有显式构造/析构函数的,一段是什么都没写的(使用默认)。环境是 Visual Studio 2022 (MSVC),Debug 模式 x64。

实验一:显式定义构造与析构

class TestObj {
public:
    const char* name;
    TestObj(const char* n) { name = n; }
    ~TestObj() { printf("Bye %s\n", name); }
};

int main() {
    TestObj obj("MyObject");
    return 0;
}

实验二:全默认(Trivial)

class DefaultObj {
public:
    int x, y;
    // 啥都没写,全靠编译器默认
};

int main() {
    DefaultObj obj;
    obj.x = 10;
    return 0;
}

2. 构造函数到底在做什么?

误区一:是构造函数分配了内存吗?

错! 在进入构造函数之前,内存就已经分好了。

main 函数入口处的汇编:

sub     rsp, 40h        ; 1. 分配栈空间(圈地)
lea     rcx, [rbp-20h]  ; 2. 获取这块内存的地址,放入 rcx (即 this 指针)
call    TestObj::TestObj; 3. 调用构造函数(装修)

看到了吗?sub rsp 才是真正的“分配内存”。构造函数(call 进去的那部分)拿到的已经是分配好的地址(this),它只负责往这块内存里填数据。

误区二:Debug 模式下的神秘指令 rep stos

在 VS Debug 下,经常看到这样的指令:

lea     rdi, [rbp-xx]   ; 目标地址
mov     ecx, xx         ; 长度
mov     eax, 0CCCCCCCCh ; 填充内容
rep stos dword ptr [rdi]; 重复填充

或者更高级的封装:

call    __autoclassinit2

不是构造函数逻辑!这是编译器强插的“保洁”代码,把刚分配的栈内存填满 0xCC(烫烫烫)或 0。目的是为了调试安全,防止你读取到未初始化的垃圾值。

真正的构造函数流程是:

  1. 分配空间 (sub rsp)
  2. 清理现场 (rep stos / __autoclassinit2, Debug 专属)
  3. 正式构造 (call TestObj::TestObj)

3. 默认构造函数真的存在吗?

这是最颠覆认知的地方。

对于实验二(DefaultObj),我原本以为会看到一个 call DefaultObj::DefaultObj,结果根本没有!

汇编里是这样的:

sub     rsp, 30h                ; 分配空间
mov     dword ptr [rbp-4h], 0Ah ; 直接赋值 obj.x = 10

连个函数调用的影子都没有。

结论:编译器不做无用功

  • Trivial 类型(平凡类型):如果你的类全是基本数据类型(int, char, 指针),且没有显式写构造函数。编译器一看,“这玩意儿不需要初始化”,它就直接罢工,根本不生成默认构造函数。物理上不存在。
  • Non-Trivial 类型:如果成员里有个 std::string 或其他复杂的类,编译器为了保证成员能正常工作,会被迫生成一个默认构造函数来初始化这些成员。

所以,“默认构造函数”在汇编层面不一定存在。


4. 析构函数:自动触发的秘密

对于局部变量,析构函数的调用时机是完全确定的。

看实验一的反汇编:

; ... main 函数的业务逻辑 ...

lea     rcx, [rbp-20h]      ; 再次取出对象地址 (this)
call    TestObj::~TestObj   ; 【编译器自动插入的析构调用】

xor     eax, eax            ; return 0
add     rsp, 40h            ; 回收栈空间
ret

编译器就像一个尽职的管家,在函数返回(ret)或离开作用域之前,硬编码插入了 call ~TestObj

那全局对象呢? 全局对象在 main 还没开始时就构造了,那谁负责析构它? 答案是 atexit

call    TestObj::TestObj    ; 构造全局对象
lea     rcx, [TestObj::~TestObj]
call    atexit              ; 【登记遗言】

构造完立刻调用 atexit,把析构函数的地址注册给系统。等程序退出时,系统会按名单回调这些析构函数。


5. 总结

通过看汇编,很多概念瞬间清晰了:

  1. 构造函数不分配内存:它只是拿着 this 指针去初始化已有的内存。
  2. 默认构造/析构不一定存在:对于简单类,编译器会直接优化掉,根本不生成代码。
  3. Debug 里的乱码初始化rep stos__autoclassinit2 是编译器的安全检查手段,不是 C++ 标准行为。
  4. 析构的自动化:局部对象是编译器硬编码插入 call,全局对象是靠 atexit 动态注册。

以后再也不用死记硬背生命周期了,看一眼反汇编,全都写在脸上。