0%

Windows C++ 常见崩溃类型:从空指针到纯虚调用的现象与治理

崩溃类型比想象中更多

上一篇讲了 Windows 的异常处理链路。这一篇进入实战层面,看看客户端程序中最常见的几类崩溃:空指针、堆破坏、栈溢出、未初始化、内存耗尽、纯虚调用。

这些崩溃的共同点是什么?它们大多在 Release 模式下更隐蔽,dump 中的崩溃点往往不是真正的破坏现场。 理解每类崩溃的根因和 Debug/Release 差异,是快速定位问题的关键。

空指针 / 野指针:C++ catch 抓不住

现象

空指针访问会触发 0xC0000005(Access Violation)。但很多人没意识到:C++ 的 try/catch(...) 默认无法捕获硬件异常

1
2
3
4
5
6
7
8
9
void TestCppCatch() {
try {
int* p = nullptr;
*p = 42; // 硬件异常,C++ catch 抓不住
}
catch (...) {
printf("C++ catch (...) executed!\n"); // 不会执行
}
}

要捕获这种系统级异常,必须用 SEH:

1
2
3
4
5
6
7
8
9
10
11
void TestSEHCatch() {
__try {
int* p = nullptr;
*p = 42;
}
__except (EXCEPTION_EXECUTE_HANDLER) {
DWORD code = GetExceptionCode();
printf("SEH caught: 0x%08X\n", code);
}
printf("Program continues.\n");
}

野指针与 use-after-free

野指针和 use-after-free 都属于”解引用无效地址”,但比空指针更难排查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void TestWildPointer() {
int* wild = nullptr;
{
int local = 100;
wild = &local;
} // local 已析构,wild 变成野指针

int value = *wild; // 未定义行为,可能崩溃也可能读到垃圾值
}

void TestUseAfterFree() {
int* p = new int(42);
delete p;
*p = 100; // use-after-free,未定义行为
}

核心教训: 不要依赖”现在没崩溃”判断代码安全。未定义行为在 Debug / Release、不同机器上表现可能完全不同。

治理方向

  • 指针声明时初始化为 nullptr
  • 使用 std::unique_ptr / std::shared_ptr 管理动态内存。
  • 删除对象后将指针置空。
  • 对可能为空的指针做判空保护。

堆破坏:Release 下最隐蔽的崩溃

堆破坏包括缓冲区溢出、double free、use-after-free。它最危险的特点是:Debug 模式下容易暴露,Release 模式下可能”正常”跑完,但内部状态已经损坏

缓冲区溢出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void TestBufferOverflow() {
char* buffer = new char[16];

// 写入 32 字节到 16 字节缓冲区,覆盖堆块头
for (int i = 0; i < 32; i++) {
buffer[i] = 'A';
}

__try {
delete[] buffer;
printf("Released (but heap may be corrupted).\n");
}
__except (EXCEPTION_EXECUTE_HANDLER) {
printf("Release triggered exception, heap corrupted!\n");
}
}

运行结果对比:

模式 行为 原因
Debug 释放时终止 CRT 堆有围栏字节,能检测越界
Release 可能继续运行 堆检查宽松,损坏被延迟

Double Free

1
2
3
4
5
void TestDoubleFree() {
int* p = new int(42);
delete p;
delete p; // 重复释放
}
模式 行为 原因
Debug 抛出可捕获的 SEH 异常 CRT 记录分配/释放状态
Release 通常直接崩溃或进程终止 破坏堆空闲链表,不可恢复

启用堆破坏即终止

Windows 提供了一个进程级策略,让堆破坏时尽早崩溃,调用栈更接近现场:

1
2
3
4
5
6
HeapSetInformation(
GetProcessHeap(),
HeapEnableTerminationOnCorruption,
nullptr,
0
);

启用后,系统堆检测到破坏会立即终止进程,而不是让异常处理程序有机会”恢复”。对于服务端或长期运行程序,建议启动早期开启。

治理方向

  • 使用 RAII(std::vectorstd::string)替代裸数组。
  • 开启 AddressSanitizer 在开发阶段抓越界和 UAF。
  • 使用 gflags + Page Heap 做严格堆检查:
    1
    gflags /p /enable YourApp.exe /full
  • 上线后如果 dump 调用栈看起来 unrelated,要考虑是否是早期堆破坏的延迟表现。

栈溢出:递归和大数组的陷阱

无限递归

1
2
3
4
5
6
7
void InfiniteRecursion(int n) {
char buffer[1024]; // 每次递归占用 1KB 栈空间
buffer[0] = (char)n;
(void)buffer;

InfiniteRecursion(n + 1); // 没有终止条件
}

Windows 默认线程栈大小约 1MB。上面的函数递归到约 1000 层就会触发 0xC00000FD(STATUS_STACK_OVERFLOW)。

安全递归:加深度限制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static thread_local int g_recursionDepth = 0;

void SafeRecursion(int n, int maxDepth) {
g_recursionDepth++;

if (g_recursionDepth > maxDepth) {
printf("Depth %d exceeded, safely return.\n", maxDepth);
g_recursionDepth--;
return;
}

char buffer[64];
buffer[0] = (char)n;
(void)buffer;

SafeRecursion(n + 1, maxDepth);
g_recursionDepth--;
}

大数组栈分配的反直觉点

下面这段代码看起来能捕获 1.5MB 分配的栈溢出,但实际上整个函数会在入口处就崩溃

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void TestLargeStackArray() {
__try {
char buffer[512 * 1024]; // 512KB
buffer[0] = 'A';
}
__except (EXCEPTION_EXECUTE_HANDLER) {
printf("512KB failed\n");
}

__try {
char buffer[1536 * 1024]; // 1.5MB,超过默认 1MB 栈
buffer[0] = 'B';
}
__except (EXCEPTION_EXECUTE_HANDLER) {
printf("1.5MB failed\n");
}
}

原因是:C/C++ 局部变量的栈空间通常在函数入口一次性分配。即使两个数组在不同 __try 块里,MSVC 进入函数时就要为整个栈帧预留空间,超过默认栈大小就会直接触发 STATUS_STACK_OVERFLOW,此时 SEH 作用域还没生效。

正确做法:

  1. 大块内存用堆分配(new / std::vector)。
  2. 或把每个大栈分配放到独立函数中。
  3. 或改用 _alloca,让分配发生在调用点。

尾调用优化的影响

互相递归的函数在 Release 下可能被尾调用优化成循环:

1
2
3
4
5
6
7
8
9
10
void B(int n);

void A(int n) {
if (n % 100 == 0) printf("Depth: %d\n", n);
B(n + 1); // 尾位置调用
}

void B(int n) {
A(n); // 尾位置调用
}

Debug 下每次调用都会新建栈帧,最终栈溢出。Release 下尾调用优化把 call 改成 jmp,栈帧复用,可能永远不会溢出。这说明:不要依赖”栈溢出”来终止递归,终止条件必须由程序逻辑保证

未初始化变量:Debug 帮你填零,Release 不会

局部变量未初始化

1
2
3
4
5
6
7
8
void TestUninitializedLocal() {
int x; // 未初始化
printf("x = %d\n", x); // 垃圾值

if (x > 0) {
// 分支走向随机
}
}

Debug 模式下编译器可能把栈内存初始化为固定模式(如 0xCCCCCCCC),让问题看起来”稳定”。Release 下就是真正的垃圾值,行为完全不可预测。

类成员未初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
class BadWidget {
public:
int width; // 未初始化
int height; // 未初始化
int* data; // 未初始化
};

class GoodWidget {
public:
int width = 0;
int height = 0;
int* data = nullptr;
};

统一初始化

1
2
3
4
5
6
7
8
int a;       // 垃圾值
int b{}; // 0
int c = 42; // 42
int d{42}; // 42
int e = {}; // 0

int* p1; // 垃圾值
int* p2{}; // nullptr

治理方向

  • 声明变量时总是初始化。
  • 类成员使用类内初始化。
  • 指针初始化为 nullptr
  • 开启 /W4 甚至 /Wall,把警告当错误。
  • 使用 Clang-Tidy 的 cppcoreguidelines-pro-type-member-init 检查。

内存耗尽:不要假设分配一定成功

大分配失败

1
2
3
4
5
6
7
8
9
10
11
void TestLargeAllocation() {
const size_t hugeSize = 100ULL * 1024 * 1024 * 1024; // 100 GB
void* p = malloc(hugeSize);

if (p == nullptr) {
printf("Allocation failed, got nullptr.\n");
// 优雅降级
} else {
free(p);
}
}

渐进式降级

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void TestGracefulDegradation() {
size_t desiredCacheSize = 500 * 1024 * 1024; // 500 MB
size_t actualCacheSize = desiredCacheSize;
void* cache = nullptr;

while (actualCacheSize >= 1024 * 1024) {
cache = malloc(actualCacheSize);
if (cache != nullptr) {
printf("Allocated %.2f MB cache.\n",
actualCacheSize / (1024.0 * 1024));
break;
}
actualCacheSize /= 2; // 逐步减半
}

if (cache == nullptr) {
printf("Even 1MB failed, enter fallback mode.\n");
} else {
free(cache);
}
}

内存泄漏

1
2
3
4
5
6
7
void TestMemoryLeak() {
for (int i = 0; i < 100; i++) {
void* p = malloc(1024 * 1024); // 1MB
if (p) memset(p, 0xCD, 1024 * 1024);
// 忘记 free
}
}

治理方向

  • 检查 new / malloc 返回值。
  • 内存不足时优雅降级,而不是直接崩溃。
  • 使用智能指针避免泄漏。
  • 开发阶段用 AddressSanitizer 检测泄漏。
  • 长期运行程序监控内存使用,设置告警阈值。

纯虚函数调用:R6025 与生命周期

纯虚函数调用错误通常表现为运行时错误 R6025 - pure virtual function call。它最常见于对象已析构后仍通过悬空指针调用虚函数,或在构造/析构期间调用虚函数。

构造 / 析构期间调用虚函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Base {
public:
virtual void DoWork() = 0;
virtual ~Base() = default;
};

class Derived : public Base {
public:
void DoWork() override {
printf("Derived::DoWork()\n");
}
~Derived() { printf("Derived destruct\n"); }
};

class BaseWithCtorCall : public Base {
public:
BaseWithCtorCall() {
printf("BaseWithCtorCall ctor\n");
DoWork(); // 调用的是 BaseWithCtorCall::DoWork,不是 Derived 版本
}
void DoWork() override {
printf("BaseWithCtorCall::DoWork()\n");
}
};

class DerivedFromCtorCall : public BaseWithCtorCall {
public:
void DoWork() override {
printf("DerivedFromCtorCall::DoWork()\n");
}
};

输出:

1
2
3
BaseWithCtorCall ctor
BaseWithCtorCall::DoWork()
DerivedFromCtorCall::DoWork()

C++ 规则:在基类构造函数执行期间,对象的动态类型是基类,调用的是基类版本的虚函数。析构函数同理。

析构后调用

1
2
3
4
5
6
7
8
9
void TestCallAfterDestruction() {
Base* ptr = nullptr;
{
Derived d;
ptr = &d;
} // d 析构,ptr 悬空

// 危险:ptr->DoWork();
}

Debug 下通常会触发 R6025 错误框;Release 下是未定义行为,可能”碰巧正常”,也可能崩溃。

治理方向

  • 构造函数和析构函数中避免调用虚函数。
  • 使用智能指针管理生命周期。
  • 删除对象后把指针置 nullptr
  • 观察者模式中注意注销回调。
  • dump 中出现 R6025 时重点检查对象生命周期。

各类崩溃的 Debug / Release 差异总结

崩溃类型 Debug 行为 Release 行为 排查难点
空指针 立即崩溃 立即崩溃 通常容易定位
堆破坏 释放时触发断言 可能延迟崩溃 崩溃点远离破坏现场
栈溢出 达到 1MB 时触发 尾调用优化可能不溢出 终止条件必须逻辑保证
未初始化 编译器可能填零 真正的垃圾值 行为随机,难复现
内存耗尽 分配返回 nullptr 分配返回 nullptr 需检查返回值
纯虚调用 触发 R6025 可能不崩溃 生命周期问题

下篇预告

下一篇会讲并发与工程问题:多线程竞争、DLL 加载、断言、静态初始化顺序(SIOF)、跨模块内存分配。这些问题在小项目里很少出现,但在大型 C++ 工程中是导致”启动就崩”和”客户环境崩”的典型原因。