C++软件异常分析概述

最近几年工作中很大一部分内容是排查软件运行过程中遇到的各种异常,无论是底层的网络模块、协议模块和组件模块,还是上层的 UI 模块,都处理过多次,见识了各式各样的 C++异常或崩溃,积累了大量的实战经验,在此给大家做个分享。本文将详细讲述 Windows 系统中 C++软件异常的分类以及常用的排查方法,给大家提供一个借鉴与参考。

1、软件异常的分类

常见的软件异常有内存越界、内存访问违例、stack overflow 线程栈溢出、空指针与野指针、死循环、死锁、内存泄露、GDI 对象泄露、函数调用约定不一致导致的栈不平衡等。

有的异常会立即导致软件崩溃。有的异常在运行一段时间或者长时间运行后才会导致崩溃,比如内存泄露和 GDI 对象泄露。有的异常并不会导致崩溃,只会导致软件发生堵塞或卡死,比如死循环和死锁。

还有一类问题会导致业务代码执行上的异常,这类问题不会导致软件崩溃,会导致业务代码没有按照正常逻辑或者分支被执行,导致业务逻辑出异常。比如函数抛出异常导致部分代码被跳过,即该执行的代码没有执行到,导致后续业务代码执行时出现逻辑异常。正常情况下,这些被跳过的代码会执行一些判断,会设置一些变量的值,会直接影响后续代码的判断与执行逻辑,所以会导致后续代码在运行时出现业务逻辑上的异常。这样的问题我们以前遇到过几次。

再比如系统 API 函数的 lasterror 被覆盖,导致后续判断 lasterror 值的条件判断出现逻辑上的错误,即产生了误判。这个问题我们以前也遇到过,在 libjingle 库的开源代码中添加了一个打印,因为开源代码的接口封装的层次比较深,导致我们在添加日志打印的代码时没看出会把 lasterror 值覆盖的问题。具体的是,添加的那句打印日志的代码中调用系统 API 函数,该行打印日志的代码执行完后就会把其前一句的开源库代码产生的 lasterror 值给覆盖了。而在打印日志下面的下一句开源代码中的接口内部会判断上一句开源代码执行后的 lasterror 值,因为该 lasterror 值被覆盖了,所以导致对 lasterror 的判断条件出现误判,导致后续的业务代码出现了逻辑上的异常。

对于 GDI 对象泄露,是使用 GDI 对象去绘制窗口导致的,绘制操作执行完后没有去释放 GDI 对象。GDI 对象包括 Pen 画笔、Brush 画刷、BItmap 位图、Font 字体、DC 设备上下文、Region 区域等。程序中如果有 GDI 对象泄露,不会立即导致异常或崩溃,当程序进程的 GDI 对象总量达到 10000 个左右时就会出现异常,出现闪退崩溃。在 Windows 系统中,单个进程的 GDI 对象上限是 10000 个,当进程的 GDI 对象快要接近 10000 个时,就会出现 GDI 函数绘制异常,接着就会产生崩溃。其实对于 GDI 对象泄露的排查,相对于内存泄露,要简单的多,只需要使用 GDIView 软件工具找出发生泄露的是哪种 GDI 对象,结合代码就能很快查出来。GDIView 工具的界面如下所示:

2、使用 windbg 分析软件异常

windbg 是 Windows 平台一个最强大、最通用的软件调试分析工具,Windows 平台主要使用它来分析各种软件异常。

大部分异常崩溃问题,软件中的异常捕获模块(大家都在使用开源的 carshreport 异常捕获库)都能捕获到,并将发生异常时的上下文保存到 dump 文件中,事后可以使用 windbg 静态分析这些 dump 文件。

对于有些不会导致软件崩溃的异常,比如死锁、死循环和内存泄露,需要我们将 windbg 挂载到目标进程上进行动态分析了。

对于少数异常捕获模块捕获不到的异常,比如程序在运行过程中发生闪退,需要将 windbg 附加到目标进程上跑,即 windbg 和其附加的目标进程绑定在一起运行,一旦目标进程出现异常,windbg 就能立即感知到并中断下来。将 windbg 附加到目标进程上后,我们需要想办法去复现异常,异常产生后 windbg 会捕获到并中断下来,此时可以直接去使用 windbg 命令去分析了,也可以将使用.dump 命令将异常上下文导出到 dump 文件中供事后分析。可能分析问题会比较耗时,可能出问题的电脑是同事的或者是领导的,不能一直占着别人的电脑,此时可以选择导出 dump 文件供事后用 windbg 分析。

对于一些弹框报错或者软件发生卡死的异常,软件一直卡在这个点上(目标进程还在),此时可以直接将 windbg 挂上去,这个时间点已经出现了异常,但挂载 windbg 的时机也不晚,也是可以获取到异常的上下文信息的。对于这类问题,不要点击报错的确定按钮,或者不要急于通过资源管理器去强杀目标进程,将目标进程保留住,此时将 windbg 挂上去正是时候,也能获取到完整的异常上下文信息。这些异常可能是事后很难复现的,必须要逮住这个机会,将 windbg 直接挂载到出问题的进程上进行分析。错过这个机会,下次可能就很难复现了,这样软件中就留下了很大的隐患。

3、除 windbg 之外的常用异常排查方法

当然除了 windbg 的静态分析和动态调试,还有一些其他的常用方法,这些方法也很重要,也需要掌握,比如使用 VS 直接调试(Debug 或 Release 调试)、附加到进程中调试、添加打印日志、历史版本比对法(找出开始出问题的那个时间点)、分块注释代码、设置数据断点(对内存进行实时监控)等。有时,我们需要将多种方法结合起来使用。

对于业务上的一些逻辑异常,一般需要通过添加日志打印来排查。还有一种典型的异常是软件运行过程中遇到了错误,软件自己判断出来了,并认为是致命性错误,会直接调用 abort 或者 exit 强行终止进程的。比如在开源的 jsoncpp 库中,在解析到异常的 json 节点时出错,会直接调用 abort 强行终止整个进程。再比如在开源的 webrtc 库中,在用 new 申请堆内存失败时,webrtc 库内部会认为发生了致命性的错误,也会调用 abort 强行终止进程。这类主动强行终止进程的,异常捕获模块是捕获不到的异常,因此是不会生成 dump 文件的。

这类情况可以将 windbg 挂载到目标进程上跑,一旦调用 abort 接口,windbg 就会中断下来,此时查看函数的调用堆栈就能看到是何处代码触发的问题了。为啥 windbg 能感知到,软件中安装的异常捕获模块捕获不到呢?因为软件本身并没有 RaiseException,是软件主动终止进程的。那为啥 windbg 能感知到呢?因为 abort 接口中会产生一个 SIGABRT 终止信号通知,这个调试器能感知到,于是 windbg 就产生了中断。此时使用 kn 命令查看函数调用堆栈,就可以看到是哪些接口触发的问题了。可以直接在 Visual Studio 中 go 到 abort 函数实现处,查看 abort 函数的内部实现了,如下:

/***
*void abort() - abort the current program by raising SIGABRT
*
*Purpose:
*   print out an abort message and raise the SIGABRT signal.  If the user
*   hasn't defined an abort handler routine, terminate the program
*   with exit status of 3 without cleaning up.
*
*   Multi-thread version does not raise SIGABRT -- this isn't supported
*   under multi-thread.
*
*Entry:
*   None.
*
*Exit:
*   Does not return.
*
*Uses:
*
*Exceptions:
*
*******************************************************************************/
 
void __cdecl abort (
        void
        )
{
    _PHNDLR sigabrt_act = SIG_DFL;
 
#ifdef _DEBUG
    if (__abort_behavior & _WRITE_ABORT_MSG)
    {
        /* write the abort message */
        _NMSG_WRITE(_RT_ABORT);
    }
#endif  /* _DEBUG */
 
 
    /* Check if the user installed a handler for SIGABRT.
     * We need to read the user handler atomically in the case
     * another thread is aborting while we change the signal
     * handler.
     */
    sigabrt_act = __get_sigabrt();
    if (sigabrt_act != SIG_DFL)
    {
        raise(SIGABRT);
    }
 
    /* If there is no user handler for SIGABRT or if the user
     * handler returns, then exit from the program anyway
     */
 
    if (__abort_behavior & _CALL_REPORTFAULT)
    {
        _call_reportfault(_CRT_DEBUGGER_ABORT, STATUS_FATAL_APP_EXIT, EXCEPTION_NONCONTINUABLE);
    }
 
 
    /* If we don't want to call ReportFault, then we call _exit(3), which is the
     * same as invoking the default handler for SIGABRT
     */
 
 
    _exit(3);
}

从代码中可以看出,产生了一个 SIGABRT 终止信号通知。

有的模块在检测到异常时,还会调用 DebugBreak API 函数,会让调试器中断下来。关于 DebugBreak 函数的说明如下:

比如有一次在排查开源 webrtc 库中申请内存失败导致闪退问题时,webrtc 库内部认为内存申请失败是致命错误,会调用 abort 函数强行终止进程。在调用 abort 函数之前,会先调用 DebugBreak 函数,如果当前 windbg 正挂在问题进程上,DebugBreak 函数的调用会让 windbg 中断下来,这样就能让调试人员感知到问题了,就可以直接在 windbg 中直接查看此时的函数调用堆栈了,就能确定是什么操作触发的问题了。

4、开源的 CrashReport 库存在的问题

很多厂商都在使用开源的 crashreport 异常捕获库,但原生的 CrashReport 库是有缺陷的。很多大厂使用的应该是经过深度改进的 crashreport 库。

开源的 crashreport 异常捕获库是动态地将已加载的库的导入表中创建线程的 API 函数 CreateThread HOOK 成我们自定义的 MyCreateThread 函数(不管调用哪个创建线程的接口,最终都会走到 CreateThread 接口中的),这样就能在 MyCreateThread 中调用系统 API 函数 SetUnhandledExceptionFilter 给每个创建的线程挂载异常处理函数了。

但这种机制是有缺陷的,不能给软件的所有模块的线程挂载异常处理函数,只能给在 crashreport 库之前加载的库挂载异常处理函数,在 crashreport 之后加载的库就没法去 hook 了,这样就会导致没进行 hook 操作的那些库中发生的异常都捕获不到了。在 exe 启动时,会把所有依赖的库加载到进程空间中,我们没法控制所有的库都在 crashreport 库之前被加载的,这也导致了有些异常崩溃 crashreport 时捕获不到的。

后来我们对 crashreport 库进行了改进,使用微软开源的 detours 项目中的代码将 windows 系统库中的 UnhandledExceptionFilter 接口给 HOOK 掉。因为基本所有的异常都会最终进入到该函数中,我们将 UnhandledExceptionFilter 接口 hook 成我们自定义的接口,我们就能在该自定义的接口中感知到几乎所有的异常了。感知到异常后,就可以生成包含异常上下文的 dump 文件了。这样就能很好的解决老版本 crashreport 不能 hook 后加载的库的问题,新版本的 crashreport 就可以作用于当前进程的所有模块了,基本可以捕获到进程的所有异常了。

当然改进后的 crashreport 也不是 100%的异常都能捕获到,但可以捕获大概 90%以上的异常。对于捕获不到的场景,就需要将 windbg 挂载到目标进程上,让 windbg 去捕获了。

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/a4055aed96a91a4385c1c2401
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码关注腾讯云开发者

领取腾讯云代金券