探索 Windows 平台下的 C++ 异常捕获策略:如何让Windows C++应用程序尽可能捕获所有异常?

前言

这个标题起的有点纠结,感觉不太好起。实际上本文想要讨论的场景,是一个比较经典的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);
}

可以看到,这些函数调用都会传入一个回调函数,比如UnhandledStructuredException、PureCallHandler等。这些回调函数在项目中,实际只是起到转发作用,最后会调用到统一的异常处理函数中,进行我们想要的统一逻辑,包括弹出用户友好的崩溃提示界面,并生成dump文件等。这主要是因为这些API需要的回调函数签名不一致,需要程序员定义各自需要的回调函数,再在各自的回调函数中调用统一的异常处理函数。在各自的回调函数中调用统一的异常处理函数,并弹出用户友好的崩溃提示界面,并生成dump文件等程序逻辑,这里不进行罗列,这里只进行异常捕获机制相关的讨论。

原理简介

这段程序使用了多种技术来捕获异常。可以对它们进行分类,并解释它们是由哪个技术层面提供的:

  1. Windows 结构化异常处理 (SEH):由操作系统提供的异常处理机制。
  • SetUnhandledExceptionFilter(UnhandledStructuredException): 为程序设置一个未处理的结构化异常过滤器,当发生 Windows 结构化异常时(如访问违规、整数溢出等),该过滤器会被调用。
  1. 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() 函数的行为,在调用 abort() 时将触发。
  1. C 运行时信号处理:由 CRT 提供的信号处理机制。
  • signal(SIGABRT, SigabrtHandler): 设置应用程序终止(abort)信号的处理程序。
  • signal(SIGINT, SigintHandler): 设置键盘中断(interrupt)信号的处理程序。
  • signal(SIGTERM, SigtermHandler): 设置终止(terminate)信号的处理程序。
  • signal(SIGILL, SigillHandler): 设置非法指令(illegal instruction)信号的处理程序。
  1. 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来捕获和处理。我们在开发时,最经常遇到的崩溃类型是访问违规。这里有必要提一提可能导致访问违规的常见场景。

  1. 空指针解引用:当程序尝试通过空指针访问内存时,将触发访问违规异常。例如:
int* ptr = nullptr;
int a = *ptr; // 访问违规,因为 ptr 是空指针
  1. 越界访问:当程序尝试访问数组或容器的边界之外的内存时,将触发访问违规异常。例如:
int arr[10];
int a = arr[20]; // 访问违规,因为数组索引越界
  1. 释放后使用:当程序尝试访问已经释放的内存时,将触发访问违规异常。例如:
int* ptr = new int;
delete ptr;
int a = *ptr; // 访问违规,因为内存已被释放
  1. 未初始化指针解引用:当程序尝试访问未初始化的指针时,将触发访问违规异常。例如:
int* ptr;
int a = *ptr; // 访问违规,因为 ptr 未初始化
  1. 无效类型转换:当程序尝试执行无效的指针类型转换时,可能导致访问违规。例如:
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启动程序,或许是值得尝试的方案(但是这也看用户的心情以及工程师的沟通能力了,被拒绝也是常事)。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容