Windows上发生异常时抓取dump

本文深入探讨Windows下的异常处理机制,包括SEH、CRT异常处理、SetUnhandledExceptionFilter的应用及限制,以及如何通过自定义异常处理函数捕获和处理未处理异常。

正文

声明一点,这篇主要是探讨Windows下的异常捕获。

首先要说明一点,操作系统中,不论是用户态还是内核态的程序,出现了异常情况或者严重错误,操作系统也是按照一定规则处理的,并不是随便地终止了程序。不然的话,操作系统就会发生资源泄露,或者其他错误。

Windows系统检测到我们写的程序发生了未处理异常或者其他严重错误(具体什么错误呢?)时,一般会将其终止掉,在此之前,默认会弹出一个应用程序错误对话框(Application Fault Dialog),或者叫GPF(General Protection Fault 通用保护错误)对话框。如果系统中配置了JIT(Just-In-time)调试器,那么在崩溃的时候会启动设置的调试器。Windows系统中默认的JIT是一个叫做Dr. Watson的JIT调试器(参考Wiki,其可执行文件名字在不同代Windows OS中有所不同)。

配置JIT调试器可以在注册表 Computer\HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\AeDebug 位置设置。我的机器上由于装有VS,默认这个选项被替换为了VS调试器 “C:\WINDOWS\system32\vsjitdebugger.exe”。msdn上有一篇文档介绍JIT设置:How to disable or enable Dr. Watson for Windows

RaiseException函数

看msdn上,介绍到RaiseException()的Remark部分有提到:

The RaiseException function enables a process to use structured exception handling to handle private, software-generated, application-defined exceptions.

也就是说这个函数会直接触发一个SEH异常。

另外,这个函数中的Remark部分还提到具体搜索异常的过程:

  1. 系统首先通知进程的调试器,如果存在的话
  2. 如果未调试进程,或者关联的调试器不处理这个异常,系统会尝试搜索发生异常的线程的栈帧来定位基于栈帧的异常处理函数。系统首先是收缩当前的栈帧,然后向后遍历之前的栈帧。(也就是向调用函数的方向回溯)
  3. 如果找不到基于栈帧的处理函数,或者没有基于栈帧的处理函数处理异常,系统将再次尝试通知进程的调试器。
  4. 如果未调试进程,或者关联的调试器未处理异常,则系统将根据异常类型做出默认处理。对于大多数异常,默认操作是调用ExitProcess函数。

所谓 基于栈帧的处理函数 就是SEH的__try{}__catch{}代码块,参考 Frame-based Exception Handling

捕获大部分崩溃 - SetUnhandledExceptionFilter

这部分内容主要参考开源软件crashrpt的文档 About Exceptions and Exception Handling

我们写程序一般是通过 SetUnhandledExceptionFilter 配合 MiniDumpWriteDump 来完成崩溃的捕获和dump抓取的。 SetUnhandledExceptionFilter的自定义处理函数的返回值中:

  • EXCEPTION_EXECUTE_HANDLER 一般是终止进程,我写代码测试了一下,这样处理的话,系统的Event Viewer中不会记录异常。
  • EXCEPTION_CONTINUE_EXECUTION 在异常发生的代码处,继续执行代码,不过有一个修改异常信息的机会。我写的测试代码中,这种处理返回值,最后会在系统的Event Viewer中找到崩溃记录。
  • EXCEPTION_CONTINUE_SEARCH 执行一般的异常处理流程,依赖于SetErrorMode设置的标记

Windows 10上,我测试下来,自定义未处理异常函数的返回值与SetErrorMode的配合,对系统事件(Event Viewer)中应用程序错误产生记录的对应关系是:

  1. 自定义未处理异常返回 EXCEPTION_EXECUTE_HANDLER,设置错误模式为 SetErrorMode(SEM_NOGPFAULTERRORBOX),生成dump,不产生事件记录。因为这类异常背后的逻辑是程序预知到了这种错误,处理好了,就不需要系统记录了。
  2. 自定义未处理异常返回 EXCEPTION_CONTINUE_EXECUTION,设置错误模式为 SetErrorMode(SEM_NOGPFAULTERRORBOX),程序卡死无法产生dump和事件记录。卡死的原因是让程序继续执行,但是之前碰到的异常并没有解决,导致无法继续执行。
  3. 自定义未处理异常返回 EXCEPTION_CONTINUE_SEARCH,设置错误模式为 SetErrorMode(SEM_NOGPFAULTERRORBOX),生成dump,不产生事件记录,但是如果不设置ErrorMode,也就是说ErrorMode为0的话,产生事件记录。

Windows上这个处理过程可以抓取到大多数崩溃。参考资料中的CrashRpt也使用了这个方法。

那还有小部分呢?

SetUnhandledExceptionFilter并不能处理Windows上C++代码的所有未处理,简单地说是因为CRT有自己的处理逻辑。

我们C++代码中常见的异常类型有

  1. 访问无效内存,比如空指针,内存访问越界,
int* pI = nullptr;
*p = 1;
  1. 栈耗尽,比如无限递归。导致Stack overflow错误
void InfiniteFunc(int a) {
   
   
    int b = 2;
    b++;
    InfiniteFunc(a + b);
}
  1. 缓存溢出,大数据块写入小数据块,导致内存非法访问。现在VC++编译器一般会启用Buffer Security Check : /GS (Buffer Security Check) 编译选项。

  2. 调用C++的纯虚指针,参考 _get_purecall_handler, _set_purecall_handler 的示例代码。

  3. 内存耗尽,申请内存失败 目前的操作系统中由于使用了虚拟内存的技术,一般不会碰到

  4. 非法参数传入C++系统函数
    参考 _set_invalid_parameter_handler, _set_thread_local_invalid_parameter_handler 里面的示例代码。

// crt_set_invalid_parameter_handler.c
// compile with: /Zi /MTd
#include <stdio.h>
#include <stdlib.h>
#include <crtdbg.h>  // For _CrtSetReportMode

void myInvalidParameterHandler(const wchar_t* expression,
   const wchar_t* function,
   const wchar_t* file,
   unsigned int line,
   uintptr_t pReserved)
{
   
   
   wprintf(L"Invalid parameter detected in function %s."
            L" File: %s Line: %d\n", function, file, line);
   wprintf(L"Expression: %s\n", expression);
   abort();
}

int main( )
{
   
   
   char* formatString;

   _invalid_parameter_handler oldHandler, newHandler;
   newHandler = myInvalidParameterHandler;
   oldHandler = _set_invalid_parameter_handler(newHandler);

   // When the debug CRT library is used, invalid parameter errors also raise an assertion
   // Disable the message box for assertions.
   _CrtSetReportMode(_CRT_ASSERT, 0);

   // Call printf_s with invalid parameters.
   formatString = NULL;
   printf(formatString);
}

里面提到一个设置函数 _CrtSetReportMode

  1. CRT检测到异常并请求强制退出进程

Windows中的有两种可以捕获的异常:

  1. 一种是C++的异常,一般用try{ /throw xxx;/ }catch{}的结构
  2. 另外一种是使用SEH,这个是VC++编译器独有的,不可用于移植的代码。__try{}__catch{}结构的,而且SEH只能用于C类型的函数,不能用于C++类内部。

不过,SEH的异常可以通过_set_se_translator()转为C++的异常,参考:_set_se_translator里面的示例代码,将一个除零的异常,转为了自定义的C++类型异常 SE_Exception 。

对于使用SEH不能保护的代码,就属于unhandled Exception范畴了,可以使用SetUnhandledExceptionFilter设置函数来处理,这个函数是SEH的top-level处理过程。不过使用这个函数要注意,如果异常处理函数是在DLL中,并且这个DLL还没有加载,那么行为是未定义的。

所以,SEH和SetUnhandledExceptionFilter的关系就是,SEH没有包住的异常栈帧,就会被当作未处理异常,交给SetUnhandledExceptionFilter设置的函数处理

XP中引入的VEH(Vectored Exception Handling)是对SEH的扩展。如果你想监控所有类型的异常,就像是调试器那样,那么VEH是非常适合的,不过问题是你要决定哪些异常要处理,哪些不要处理。

CRT错误处理过程
除了C++类型异常和SEH异常之外,还有CRT异常,crt遇到C++类型的异常之后会调用terminate()函数,所以你最好用set_terminate()设置一个错误处理过程。
CRT错误处理过程可以设置:

以上主要在 MSDN Process and Environment Control查找资料。

C++信号处理 Signal Handling,也就是C++中的程序中断机制。通过signal()函数处理。

ANSI标准中一共有六种:

  1. SIGABRT Abnormal termination
  2. SIGFPE Floating-point error,当浮点运算出错时由CRT调用,一般情况下不会生成。Windows系统 默认关闭 了这个信号,取而代之的是生成一个NaN或者无限大的数字,可以通过_controlfp_s函数打开这个异常。参考 Floating-Point Exceptions
  3. (*)SIGILL Illegal instruction Windows下 不产生 这个信号
  4. SIGINT CTRL+C signal, win32程序 不支持 这个信号,当CTRL+C中断发生时,Win32系统会生成一个新的线程处理该中断,这样的话,比如一些在unix上的单线程可能会变成多线程,并出现不可知的错误。这里强调了UNIX中的单线程程序,我试了一下及时创建一个最简单的console程序也会有3个线程(一个Main Thread,2个Work Thread),原因参考 Raymond Why does my single-threaded program have multiple threads?中提到console application会有线程专门用来“handle and deliver console control notifications”
  5. SIGSEGV Illegal storage access
  6. (*)SIGTERM Termination request Windows下 不产生 这个信号

这部分可以参考 msdn signal函数介绍

标*的MSDN中提示说Windows NT不会生成,留着只是为了兼容ANSI。但是如果在主线程中设置了SIGSEGV信号函数,那么就会由CRT而不是SEH设置的SetUnhandledExceptionFilter()过滤函数来调用,并且有一个全局的变量_pxcptinfoptrs包含异常信息。如果是在其他线程的话,异常处理过程是由SEH的SetUnhandledExceptionFilter()过滤函数调用的。(这部分删除掉是因为我现在(2020.10)没在msdn上找到这个说法,可能是后来有变动,毕竟这篇文档成文早于2010年,之后Windows上的CRT有较大的变化)

除了函数之外,还有编译链接选项上的一些事情。CRT可以以MD(动态链接)和MT(静态链接)的方式编译进模块(exe/DLL)里面。参考:/MT、/MD编译选项,以及可能引起在不同堆中申请、释放内存的问题,/MD, /MT, /LD (Use Run-Time Library)。
MD的方式时推荐的,多个模块公用一个CRT的DLL库的方式;以MT的方式使用CRT的话,需要把函数写成static,并且使用/NODEFAULTLIB链接标记,链接到所有模块中,还需要每个模块中都注册CRT错误处理过程。

上面提到这几种异常也不全,可以参考操作系统的IDT表项看看系统支持哪些中断/异常处理。比如说还有:

  • 除零异常
  • 页错误
  • 栈段错误
  • 浮点错误
  • 内存对齐错误
  • SIMD浮点错误
  • 无效TSS
  • 段不存在

为什么调试器可以抓到所有崩溃?

这部分参考《软件调试》中介绍的内容。这里再次向张老师致敬。

简单地说,因为Windows提供的中断和异常管理机制中,如果碰到有未处理的异常,或者是CRT中发生错误的时候,会先判断进程是否在调试器下运行,是的话就把控制权交给了调试器。 也就是说程序的执行流程中会主动请求调试器协助。

详细一点说的话,需要从Windows的中断和异常说起。

Windows保护模式下如果发生中断或者异常,会通过查找IDT(中断描述符)表来寻找处理函数,而Windows启动早期会初始化这个表。

IDT中有三种表项,也就是所谓的门描述符(Gate Descriptor)结构。

  • 任务门 切换任务用;由于x64架构不支持硬件方式的任务切换,所以没有任务门了。
  • 中断门 处理中断例程
  • 陷阱门 描述异常处理例程入口

本篇提到的中断和异常处理,主要靠后二者。

Windows中的异常,除了CPU产生的硬件异常外,还有软件模拟出的异常,比如调用RaiseException或者语言层面throw抛出的异常。Windows会以统一的方式处理这两类软硬件异常。用于描述异常的数据结构是struct _EXCEPTION_RECORD。

  • CPU异常(硬件异常)会调用 CommonDispatchException
  • 软件异常 是通过内核服务 NtRaiseException 产生的,用户态代码可以通过RaiseException来间接调用这个函数。

上述二者产生异常之后最终都会调用到KiDispatchException。

在这里插入图片描述

从上图中可以看出本节简述部分的内容。因为这个函数会尝试请求调试器介入,所以调试器可以捕获所以的异常。

那么如果进程执行中发生一个异常,如何捕获并处理异常,Windows提供了哪些机制?

Windows中创建一个进程的过程中会调用一个函数BaseProcessStart,这个函数的实现比较简单,但是整个函数体被一个SEH的__try包裹着,这里是Windows进程为异常发生托底的最后一重关卡。由于很多程序有全局变量,还有使用了CRT的程序,还需要处理CRT的初始化,处理命令行参数等,而crt的实现中还会做基于信号的异常处理,所以CRT也有一层SEH的__try块,这个由编译器负责插入这段代码,我电脑上是在路径 C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\VC\Tools\MSVC\14.16.27023\crt\src\vcruntime\exe_common.inl 下(书中提到代码是位于crt0.cpp中,但是考虑到该书写于2008年前可能会有变化)。

/// C:\Program Files (x86)\Microsoft Visual Studio\2017\Professional\VC\Tools\MSVC\14.16.27023\crt\src\vcruntime\exe_common.inl
static __declspec(noinline) int __cdecl __scrt_common_main_seh() throw()
{
   
   
    if (!__scrt_initialize_crt(__scrt_module_type::exe))
        __scrt_fastfail(FAST_FAIL_FATAL_APP_EXIT);

    bool has_cctor = false;
    __try
    {
   
   
        bool const is_nested = __scrt_acquire_startup_lock();

        if (__scrt_current_native_startup_state == __scrt_native_startup_state::initializing)
        {
   
   
            __scrt_fastfail(FAST_FAIL_FATAL_APP_EXIT);
        }
        else if (__scrt_current_native_startup_state == __scrt_native_startup_state::uninitialized)
        {
   
   
            __scrt_current_native_startup_stat
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值