探索 Windows 平台下的 C++ 异常捕获策略:如何让Windows C++应用程序尽可能捕获所有异常?
Don't lie to yourself.
8 人赞同了该文章
前言
这个标题起的有点纠结,感觉不太好起。实际上本文想要讨论的场景,是一个比较经典的Windows C++商业应用软件的开发需求:我们希望能够在程序发生异常并崩溃时,能够弹出对用户比较友好的崩溃提示窗口,并且生成dump文件上传到服务器上,让开发人员能够获取并分析。
因此,本文提出一套捕获Windows平台下C++程序异常的方案,经过长时间的线上验证,是可以捕获到绝大多数的异常的。至于为什么不是所有异常,我们后面再讨论。
程序示例
先给出程序示例,再讨论其中的原理。
void InstallUnexceptedExceptionHandler()
{
//SEH(Windows 结构化异常处理),属于Win32 API
::SetUnhandledExceptionFilter(UnhandledStructuredException);
//C 运行时库 (CRT) 异常处理,由 CRT 提供的异常处理机制。
_set_purecall_handler(PureCallHandler);
_set_new_handler(NewHandler);
_set_invalid_parameter_handler(InvalidParameterHandler);
_set_abort_behavior(_CALL_REPORTFAULT, _CALL_REPORTFAULT);
//C 运行时信号处理,由 CRT 提供的信号处理机制。
signal(SIGABRT, SigabrtHandler);
signal(SIGINT, SigintHandler);
signal(SIGTERM, SigtermHandler);
signal(SIGILL, SigillHandler);
//C++ 运行时异常处理,API由标准库提供
set_terminate(TerminateHandler);
set_unexpected(UnexpectedHandler);
}
可以看到,这些函数调用都会传入一个回调函数(除了_set_abort_behavior),比如UnhandledStructuredException、PureCallHandler等xxxHandler。这些回调函数在项目中,实际只是起到转发作用,最后会调用到统一的异常处理函数中,进行我们想要的统一逻辑,包括弹出用户友好的崩溃提示界面,并生成dump文件等。这主要是因为这些API需要的回调函数签名不一致,需要程序员定义各自需要的回调函数,再在各自的回调函数中调用统一的异常处理函数。在各自的回调函数中调用统一的异常处理函数,并弹出用户友好的崩溃提示界面,并生成dump文件等程序逻辑,这里不进行罗列,这里只进行异常捕获机制相关的讨论。
原理简介
这段程序使用了多种技术来捕获异常。可以对它们进行分类,并解释它们是由哪个技术层面提供的:
1. Windows 结构化异常处理 (SEH):由操作系统提供的异常处理机制。
- SetUnhandledExceptionFilter(UnhandledStructuredException): 为程序设置一个未处理的结构化异常过滤器,当发生 Windows 结构化异常时(如访问违规、整数溢出等),该过滤器会被调用。
2. C 运行时库 (CRT) 异常处理:由 CRT 提供的异常处理机制。
- _set_purecall_handler(PureCallHandler): 设置一个纯虚函数调用处理程序,当调用纯虚函数时(未实现的虚函数),该处理程序会被调用。
- _set_new_handler(NewHandler): 设置一个内存分配失败的处理程序,当 new 运算符无法分配内存时,该处理程序会被调用。
- _set_invalid_parameter_handler(InvalidParameterHandler): 设置一个无效参数处理程序,当程序中的某个函数调用时传入了无效参数,该处理程序会被调用。
- _set_abort_behavior(_CALL_REPORTFAULT, _CALL_REPORTFAULT): 设置abort()在被调用后如何处理错误,_CALL_REPORTFAULT参数将导致Windows错误报告(WER)对话框。
3. C 运行时信号处理:由 CRT 提供的信号处理机制。
- signal(SIGABRT, SigabrtHandler): 设置应用程序终止(abort)信号的处理程序。
- signal(SIGINT, SigintHandler): 设置键盘中断(interrupt)信号的处理程序。
- signal(SIGTERM, SigtermHandler): 设置终止(terminate)信号的处理程序。
- signal(SIGILL, SigillHandler): 设置非法指令(illegal instruction)信号的处理程序。
4. C++ 运行时异常处理:由 C++ 语言标准提供的异常处理机制。
- set_terminate(TerminateHandler): 设置未捕获的 C++ 异常导致程序终止时调用的函数。
- set_unexpected(UnexpectedHandler): 设置异常规格不匹配时调用的函数(在 C++11 之前的 C++ 标准中使用)。
这段程序的主要目的是捕获各种类型的异常,包括 Windows 结构化异常(SEH)、C 运行时库异常、C 运行时信号以及 C++ 运行时异常。这些异常处理机制分别由操作系统、C 运行时库和 C++ 语言标准提供。通过使用这些技术,程序能够更全面地捕获和处理异常。
可以看到,这就是Windows平台下C++异常捕获处理的棘手之处,有好几个技术层面的异常机制需要处理,才能做到尽可能捕获更多的异常。
进一步解析
windows平台下的C++运行时异常,大部分情况是会被SEH和C++ 运行时异常处理机制捕获。
在 Windows 平台下,C++ 运行时异常(如 std::bad_alloc、std::out_of_range 等)通常会被 C++ 运行时异常处理机制捕获,如 try/catch 块,如果C++运行时异常没有被catch块处理,则会走到set_terminate
设置的回调函数中。SEH 主要用于捕获硬件异常、操作系统产生的异常(如访问违规、整数除以零等)以及其他一些异常情况。
C 运行时库 (CRT) 异常处理和 C 运行时信号处理通常用于处理和 C 语言相关的问题。C++ 程序可能会使用 C 语言功能或调用 C 语言库,因此在某些情况下,这些处理机制也可能捕获到异常。然而,对于大部分使用 C++ 标准库和特性的程序来说,这些情况相对较少。所以,在 Windows 平台下的 C++ 程序中,C++ 运行时异常处理和 SEH 通常可以捕获大部分异常,而 C 运行时库 (CRT) 异常处理和 C 运行时信号处理捕获的异常情况相对较少。尽管如此,我们还是应该要处理CRT异常。
SEH具体能捕获哪一些运行时异常?
SEH(Structured Exception Handling)是 Windows 平台上的一种异常处理机制,它主要用于捕获由操作系统引发的异常。以下是一些 SEH 可以捕获的运行时异常:
- 访问违规(Access Violation):当程序尝试访问非法内存地址时,如空指针解引用、越界访问或使用已释放的内存。
- 无效操作(Invalid Operation):当程序尝试执行非法指令时,如无效的机器代码或执行不支持的指令集。
- 数据类型不匹配(Datatype Misalignment):当程序尝试访问未对齐(Alignment)的数据时,这在某些处理器体系结构(如 ARM 和 Itanium)上可能导致异常。
对齐(Alignment)是指数据在内存中的起始地址应满足某种特定的边界要求。这些要求通常取决于底层硬件和处理器体系结构。对齐可以帮助优化处理器访问内存的性能,因为处理器通常更高效地访问对齐的数据。例如,假设 int 类型的数据需要以 4 字节边界对齐。这意味着 int 类型数据的起始地址应该是 4 的倍数(如 0x1000、0x1004、0x1008 等)。如果 int 类型数据位于非 4 字节边界的地址(如 0x1001、0x1005 等),则该数据被认为是未对齐的。在某些处理器体系结构(如 ARM、Itanium)上,访问未对齐的数据可能导致数据类型不匹配异常。在其他体系结构(如 x86、x64)上,处理器通常可以访问未对齐的数据,但这可能导致性能下降。在 C 和 C++ 中,编译器通常会自动处理数据对齐,确保数据位于正确的边界上。但在某些情况下,程序员可能需要手动处理对齐问题,例如在指针类型转换、使用自定义内存分配器或处理硬件相关数据结构时。
- 整数除以零:当程序尝试执行整数除法时,除数为零。
- 堆栈溢出(Stack Overflow):当程序的堆栈使用超过了分配的空间时,如深度递归或分配大量的局部变量。
- 其他硬件异常:如浮点数操作的异常,比如除以零、无穷大相减、非数字(NaN)之间的比较等。
需要强调的是,SEH 主要处理由操作系统引发的异常,而非 C++ 异常。C++ 异常是由 C++ 运行时系统引发的,需要使用 C++ 的 try/catch/throw 语句和set_terminate
来捕获和处理。我们在开发时,最经常遇到的崩溃类型是访问违规。这里有必要提一提可能导致访问违规的常见场景。
- 空指针解引用:当程序尝试通过空指针访问内存时,将触发访问违规异常。例如:
int* ptr = nullptr;
int a = *ptr; // 访问违规,因为 ptr 是空指针
2. 越界访问:当程序尝试访问数组或容器的边界之外的内存时,将触发访问违规异常。例如:
int arr[10];
int a = arr[20]; // 访问违规,因为数组索引越界
3. 释放后使用:当程序尝试访问已经释放的内存时,将触发访问违规异常。例如:
int* ptr = new int;
delete ptr;
int a = *ptr; // 访问违规,因为内存已被释放
4. 未初始化指针解引用:当程序尝试访问未初始化的指针时,将触发访问违规异常。例如:
int* ptr;
int a = *ptr; // 访问违规,因为 ptr 未初始化
5. 无效类型转换:当程序尝试执行无效的指针类型转换时,可能导致访问违规。例如:
int a = 42;
char* ptr = reinterpret_cast<char*>(&a);
int* invalid_ptr = reinterpret_cast<int*>(ptr + 1);
int b = *invalid_ptr; // 访问违规,因为 invalid_ptr 指向非法内存地址
这些场景仅仅是访问违规可能发生的一部分情况,在实际编程过程中,可能还会有其他导致访问违规的情形,而且更加隐蔽。比如,我们使用悬挂的类指针时,可能不会马上在使用悬挂的类指针的位置崩溃,而是在调用成员函数的某一处崩溃,这和操作系统的内存回收机制有关系(Windows操作系统可能不会马上将delete掉的堆区内存马上回收,并在页表上声明为不可访问,这和操作系统的性能优化机制有关系)。为了避免访问违规,C++程序员应该确保指针操作的正确性、内存分配和释放的正确使用以及遵循类型转换的规范。
为什么使用以上机制仍不能捕获所有异常?
有一些异常是发生在操作系统内核层面的,以及硬件层面的。虽然上述程序也能够监控到部分这类异常,但由于异常机制设计上的原因,并非都能捕获。
例如,堆栈溢出异常可能导致程序立即崩溃,而无法执行任何异常处理程序(SEH理论上可以捕获堆栈溢出异常,但在某些情况下可能无法捕获所有堆栈溢出异常。堆栈溢出是一种特殊的异常,因为当堆栈溢出时,程序的堆栈空间已经耗尽。这可能导致在尝试处理异常时遇到问题,因为异常处理程序本身可能需要使用堆栈空间。这就是为什么在某些情况下,SEH可能无法捕获堆栈溢出异常的原因)。
如果在异常处理程序本身中引发了另一个异常,也可能导致程序崩溃。这是因为异常处理程序的主要目的是处理异常并恢复程序的执行。如果异常处理程序本身引发了异常,那么它无法完成其预期的任务。为了避免这种情况,应确保异常处理程序尽可能简单并且稳定。在异常处理程序中避免引入可能导致新异常的代码,例如分配大量内存、执行复杂的算法等。在异常处理程序中进行最小化的操作,并在处理异常时尽量谨慎。
有一些异常,虽然使用上述方案仍捕获不到,但使用WinDbg可以捕获到(当我们使用WinDbg启动应用程序并监控运行,期间发生崩溃的场景)。这是因为WinDbg在操作系统级别附加到目标进程,监视进程的执行并捕获异常。当异常发生时,WinDbg 可以暂停目标进程,分析进程的状态,并让开发者进行调试操作。作为一个内核级调试器,WinDbg 可以直接与操作系统内核交互,访问和控制底层系统资源。这使得 WinDbg 能够在更低级别的层次上监视应用程序的执行,从而捕获那些无法通过应用程序内部异常处理程序捕获的异常。
但是程序发生异常的情况很复杂,使用WinDbg也不一定能捕获所有异常。对于Windows C++应用程序开发者而言,如果用户机器上发现了无法被捕获的异常,尝试在用户环境下使用WinDbg启动程序,或许是值得尝试的方案(但是这也看用户的心情以及工程师的沟通能力了,被拒绝也是常事)。
(WinDbg使用附录)
WinDbg的命令可以分为几类:
- 执行控制命令:比如g,代表继续执行;bp,bc,bl,代表设置断点,清除断点,列出断点。
- 数据结构命令:比如dv,代表显示局部变量;dt,展示一个类或者结构体的类型信息。
- 寄存器命令:比如r,查看或编辑寄存器。
- 线程堆栈命令:比如~,列出所有线程;k,显示当前线程的调用栈信息;
- 符号和模块命令:比如x,搜索符号;lm,列出加载的模块;.reload用于重新加载符号。
- 扩展命令:Windows或者第三方扩展的高级命令,感叹号开头,比如!analyze,自动分析崩溃和异常;!locks,显示锁的状态;!heap,显示堆信息;!teb和!peb用于展示线程环境块和进程环境块。
发布于 2023-05-17 17:55・IP 属地广东