引发C++软件异常的常见原因分析

Posted IT老张

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了引发C++软件异常的常见原因分析相关的知识,希望对你有一定的参考价值。

目录

1、概述

2、引发软件异常的常见原因

2.1、变量未初始化

2.2、死循环

2.3、内存越界

2.4、内存泄露

2.5、空指针与野指针

2.6、线程栈溢出

2.7、函数调用约定不一致导致栈不平衡

2.8、库与库之间不匹配

2.9、死锁

2.10、GDI对象接近或达到1万个导致异常

2.11、对包含C++类成员的结构体进行memset操作       

2.12、程序中抛出了异常,将部分该执行的代码跳过去了

2.13、模块注入到程序中导致程序出现异常

2.14、添加日志打印覆盖了lasterror的值

2.15、格式化时格式化符与参数不一致

2.16、同一个程序在不同系统中可能会有不同的表现

3、最后


       你还在为开发过程中遇到的多种软件异常崩溃而束手无策吗?还在为赶不上项目进度而一筹莫展吗?我能帮助到你,给你提供多种排查软件异常的思路与方法,助你快速地解决问题。本文根据近几年排查软件异常的实战经历与经验,详细地总结出引发软件异常的常见原因,给大家提供一些借鉴和参考。

1、概述

       本文要讲的软件异常,是广义上的软件运行异常,包括软件运行时逻辑上的异常高CPU占用内存越界内存泄露线程堵死死循环死锁等。本文结合近几年来的异常排查实践,对软件异常的常见原因及排查思路进行一个相对完整的总结,以供参考。

       希望大家了解这些内容之后,既能在写代码时提前感知潜在的问题,也能在出问题之后多一些排查问题的思路与手段。同时借助一些常用的软件工具,有效地提高排查问题的效率。

本文主要以Windows下的C++开发为例进行讲解,不同平台及不同语言在多个方面是相通的,也具有很大的参考价值。

2、引发软件异常的常见原因

       下面大概地讲述一下引起软件异常的常见原因和场景。

2.1、变量未初始化

       这可能是许多人容易忽略的问题。在Debug下某些编译器会自动对变量自动进行初始化,但Release下则是分配内存时内存中的随机值
       变量忘记初始化,可能会导致Debug和Release下运行行为的不一致,也可能会导致不同操作系统上运行行为的不一致(不同操作系统的内存管理机制是有差异的)。
       如果是回调函数指针因为一些原因没有及时地初始化,这将是致命的,导致没有初始化就调用了,因为release下回调函数指针中存放的是随机值,会导致代码“跑飞”,程序会胡乱的崩溃。

如上图所示,对于微软C++编译器,在Debug下,未初始化的栈内存会被编译器初始化为0xCCCCCCCC,未初始化的堆内存会被编译器初始化为0xDDDDDDDD。

在调试代码时,如果遇到0xCCCCCCCC、0xDDDDDDDD和0xFEEEFEEE等典型的异常值时,就要第一时间做出反应,可能是异常码对应的原因导致的。

        可能有些人会说,我只要控制好现有的代码,就能保证不会访问未初始化的变量,不一定非要一上来就将变量初始化。即便是这样,也是不可取的,因为代码后面可能会交由其他人维护,每个人编写代码的水平有所差异,你这里没问题,别人那边可能会出问题。

2.2、死循环

       死循环一般会引发CPU高占用率,一般不会导致软件崩溃。一旦发现进程占用的CPU过高,可能就是死循环触发的,也有可能是线程函数中没有添加sleep导致的。

       死循环一般是for循环或while循环的循环控制条件出了问题,可能是循环控制条件写错了(比如编写代码时的手误),也可能是循环控制条件中变量出现了异常大的值(这个值可能是服务器返回的异常值)。
       另外,还有一种情况是消息触发的函数调用上的死循环。比如调用一个底层的接口,收到底层回应消息后,又调用了该底层接口,又收到底层的这个消息,循环往复地执行同样的函数调用,导致了死循环的发生。
       死循环会导致所在线程的卡顿或堵塞,如果发生在UI线程中,UI界面会出现点击没反应或者反应很慢的情况。对于Windows程序,可以使用Windbg或者Process Explorer去定位死循环所在线程及函数。

Windbg和Process Explorer是排查死循环和CPU高占用问题的利器,都可以查看到问题线程的函数调用堆栈。相比较而言,Process Explorer要更易操作一点,但就功能而言,还是Windbg要强大很多。哪些场景下使用哪种工具,视个人喜好及问题的具体情况而定吧。

2.3、内存越界

       内存越界不一定会导致崩溃,有可能会越界到附近变量的内存上,即篡改了附近变量的值,导致代码的运行控制逻辑出异常。内存越界包含栈内存越界堆内存越界以及全局内存越界,这些类型的越界,我们之前都遇到过。

就问题排查的难度而言,堆内存越界和全局内存越界比较难查,栈内存越界排查起来要容易很多。

       其中有一种情形下的越界(被调用函数越界主调函数的栈内存上),很具有隐蔽性,排查起来比较困难。比如A库依赖B库,B库定义了结构体Struct1,A库调用了B库的GetData接口, GetData接口是Struct1结构体作为参数的,GetData中进行了数据的memcpy操作。因为库发布的问题,导致两个库不匹配。B库中在Struct1结构体中新增了字段,但是A库中使用的还是老的结构体,这样在调用GetData传入结构体地址或引用,由于GetData中进行了memcpy操作导致内存越界,在调用完该接口后,因为传入的Struct1结构体对象参数是引用,所以越界直接越到主调函数的栈上,即直接篡改了主调函数中其他栈变量的内存,导致代码出现不可解释的异常运行行为。
        当时的场景对应的代码如下所示:

调用pcAudEncoder->GetAudEncStatis接口,传入了tAudEncStatis结构体对象,由于该接口参数是引用类型,该接口内部的内存越界越到主调函数CMiscCtrl::OnCodecStaisTimer中的局部变量tCodecStatis的栈内存上,即篡改了tCodecStatis变量内存中的值,导致后续代码在访问tCodecStatis变量的内存时产生了异常。
       对于内存越界,我们可以通过分块注释代码添加数据断点(Visual Studio中支持数据断点)等手段进行定位。

2.4、内存泄露

       内存泄漏是指程序中通过new/malloc动态申请的堆内存没有释放,长时间频繁执行这些没有释放堆内存的代码,导致程序的内存逐步被消耗,程序运行变慢,最后导致内存被耗尽(Out of memory),程序闪退。程序闪退时,系统会弹出类似下面的报错提示框。 

       目前很多内存泄露检测工具都比较陈旧,不再支持VS2017及以上版本的编译器编译出来的程序了。之前尝试过使用腾讯的tMemMonitor内存泄露检测工具 ,但是很多场景下的泄露该工具都检测不出来,后来改用Windbg才将问题定位出来。
        Windbg检测内存泄露的步骤,可以自行搜索一下,此处不再赘述。后面可能也会写专门的文章来介绍Windbg排查内存泄漏的具体用法。

2.5、空指针与野指针

       这两种是很常见的关于指针使用方面的错误。
       对于Windows系统,访问空指针会之所以会产生崩溃,是因为访问了Windows系统的64KB禁止访问的空指针内存区(即0-64KB这个区间的内存区域),这是Windows故意设定的一块小地址内存区域,是为了方便程序员定位问题使用的。一旦访问到该内存区就会触发内存访问违例,系统就会强制将进程强制结束掉。
       关于64KB禁止访问的内存区域,在《Windows核心编程》一书内存管理的章节,有专门的描述,相关截图如下所示:

一旦访问类的空指针,可能会访问到类中的数据成员,就会访问到64KB的小地址内存区,就会触发异常。       

        所谓野指针,是指该指针指向的内存已经被释放了,但还去访问该指针指向的内存,就会导致内存访问违例,导致软件崩溃。还有一种情形是同一段堆内存被delete或free了两次,也会触发崩溃。
       无论是空指针还是野指针,都可能会触发内存访问违例,都可能导致软件的崩溃。

2.6、线程栈溢出

       单个线程的栈空间是有限的,Windows上线程的默认栈空间是1MB,一旦当前线程的调用堆栈中占用的栈空间超过当前线程的栈空间上限,就会产生stack overflow线程栈溢出异常,导致程序崩溃。

       从以往的排查这类问题的经验来看,导致线程栈溢出的原因主要有以下几种:
       1)函数的递归调用过深,导致函数中占用的栈空间一直未被释放;
       2)作为函数参数的结构体定义比较大,超过了当前栈空间的上限(在堆内存上定义结构体变量);
       3)因为某些机制的存在,导致两个函数不断相互调用,陷入函数的死循环调用(掐断死循环调用的机制);
       4)switch语句中的case分支过多(将部分case分支封装到新的函数中)。

此处要特别说明一下switch-case语句中case分支过多的场景。可能这些case分支是用来处理服务器给过来的多个消息,每个case分支对应一个消息处理分支,我们会在case分支中定义生命周期在此case分支中的局部变量。

虽然代码执行到case分支中这些变量才有“生命”,但其实这些变量已经在所在函数入口处几句分配好栈内存了,可以编写C++测试代码进入调试状态查看一下汇编代码,就能看出来的,这点我特别验证过!

2.7、函数调用约定不一致导致栈不平衡

       函数的调用约定不仅决定着函数参数压入栈中的先后顺序,还决定了应该由谁来释放给被调函数传递的参数所占用的栈空间
       比如,我们常用的stdcall标准调用约定和C调用约定,如果被调用函数是标准约定,则由被调函数释放传给被调函数的参数的栈空间。如果被调函数是C调用,则由主调函数去释放栈空间。
关于谁来负责释放参数占用的栈空间,大家很容易混淆,给大家一个容易记住的办法。比如我们经常用到的C函数printf

该函数支持多个可变参数的格式化,设定的是C调用约定,因为被调函数无法知道传入了哪些参数,只有主调函数才知道传入了哪些参数,才知道传入参数占用的栈内存的大小,才好去释放参数占用的栈内存。
       这个问题在设置回调函数时比较常见,特别是跨语言设置回调函数时。因为调用约定的不一致,可能会导致参数栈空间多释放了一次,会直接影响主调函数的ebp栈基址出错,导致主调函数中的内存地址错乱出现异常或崩溃。比如C++的SDK给C#程序调用,因为C++语言中默认使用C调用约定,C#默认使用标准调用,如果回调函数没有显式地指明调用约定,在实际使用时就会出问题。
       在Debug下,Visual Studio默认开启了/RTC运行时检测,一旦检测到栈不平衡,一般都会弹出如下的提示:

默认情况下,/RTC编译选项只在Debug下是开启的,Release下该选项是关闭的。有的模块为了方便排查问题,在Release版本中开启了该编译选项。开启该选项会向代码添加很多额外的跟踪代码,会对程序的执行效率产生一定的影响。

2.8、库与库之间不匹配

       因为一些原因,导致库与库之间的版本不一致或不匹配,从而导致程序运行异常或崩溃。比如底层的库只发布了debug版本的库,忘记发布release版本,导致Debug版本库与Release库混用,因为Debug于Release下的内存管理机制的不同导致崩溃。

       再比如,底层库的API头文件发生了改动(比如结构体中新增或删减了若干字段),但是只发布了库文件,忘记发布头文件;还比如,我们修改了头文件,但是发布时有若干关联的库没有编译或者编译失败,导致这些库用的还是老版本。
       一般这类不匹配会触发内存上的问题,使程序出现异常或崩溃,比如Debug下弹出如下的提示框:

       处理这类问题的办法是,查看svn上的库发布记录,可能要重新发布库,也可能需要将相关的模块重新编译一下。

2.9、死锁

        死锁一般发生在多线程同步的时候,比如线程1占用了锁A,在等待获取锁B,而线程2占用了锁B,在等待获取锁A,两个线程各不相让,在没有获取到自己要获取的锁之前,都不会释放另一个锁,这样就导致了死锁。我们需要做好多个线程间协调,避免死锁问题的出现。很多时候我们能够根据现象及相关的打印日志,初步估计出可能发生死锁的地方。
       如果UI线程出现堵塞,或者是底层业务模块出现拥堵,业务出现异常,可能就是死锁引起的。可以将windbg挂在到目标进程上,查看所有线程的函数调用堆栈,确定发生死锁的是哪些线程,发生死锁的线程一般都会停留在WaitForSingleObject这个函数的调用或者类似函数的调用上,比如这样的截图:

如图所示,当前线程卡在了WaitForSingleObject的函数调用上。通过函数调用堆栈,可以确定是调用了哪个函数触发的。
       对于使用临界区的死锁,使用Windbg排查比较容易分析,因为临界区是属于用户态的,我们只需要使用为Windbg进行用户态的调试即可。如果是信号量等其他的锁,则要使用Windbg进行内核态的调试,内核态的调试则要复杂很多。

此外,可以使用《Windows核心编程》一书中附带的、有源码的工具LockCop来检测一下,但该工具有局限性,只适用于部分内核对象的死锁检测,有些内核的死锁检测不到,后面会有专门的文章来介绍该工具的使用详情。

2.10、GDI对象接近或达到1万个导致异常

        通过查看任务管理器就可以看到目标进程的GDI占用情况:

当程序中有GDI对象泄露时,程序长时间拷机运行,可能就会出现GDI对象接近或达到1万个,导致GDI绘图函数调用出现异常,出现窗口绘制不出来等问题,比如下图中的窗口绘制不出来的异常:

左侧的工具栏窗口和右上角的关闭等按钮都绘制不出来了,紧接着程序就会出现崩溃。很多Windows老程序员可能都遇到过类似的问题。

Windows系统中,进程中的GDI对象总数不能超过10000个。当进程的GDI对象总数接近或超过10000个时就会导致GDI绘图出现异常,API函数调用返回失败,甚至出现闪退崩溃。

       除了GDI泄漏会导致GDI总数达到系统上限,打开程序的多个窗口可能也会导致这个问题,比如之前我们用MFC做UI界面时,每个控件都是一个窗口,每个窗口都包含若干个GDI对象,这样导致一个聊天窗口就占用了200多个GDI对象。这样在测试同事做极限测试时,同时打开了好几十个聊天窗口,就出现了GDI对象达到或接近上万个的问题。这也是当时我们要将MFC界面库换成duilib界面库的原因之一。

在MFC框架中,每个控件都是一个真实的窗口,都会占用若干个GDI对象,而一个程序窗口中可能会包含多个控件,这样单个窗口占用的GDI对象就不少了。

在duilib框架中,窗口中的dui控件都是绘图绘制出来的,控件本身并不是窗口,所以dui窗口相对MFC窗口,占用的GDI对象可能要少很多。

       对于GDI对象泄露,可以使用GDIView工具去实时查看目标进程中的GDI对象的占用及增长情况,就能确定哪种GDI对象数比较高了,这样就能有针对性地去排查代码了。在我们的项目中,我们已经多次使用GDIView工具去排查GDI对象泄露的问题。

2.11、对包含C++类成员的结构体进行memset操作       

       大家在使用结构体对象时,在使用结构体之前,都会习惯性地对结构体对象进行memset操作,但如果结构体中包含C++类时,是不能进行memset操作的,我们需要在构造函数中对结构体对象成员进行初始化。
       对包含C++类的结构体对象进行memset操作导致的崩溃问题,我们已经遇到过几次了,特别是新人容易犯这样的错误。有的C++类除了有存放数据的成员,还有维护内部内存结构的字段,比如string类、CString类、stl类等,如果对结构体对象进行memset操作,则会破坏维护内部内存结构的字段的内存,会导致不可预期的错误。例如CString类中就包含了一个额外的用于维护类内部内存结构的类:

具体的问题实例,比如在以前遇到的一个问题场景中,调试时发现,传给C++类对象的数据是对的,但是用到这个C++类对象时获取的值却是有问题的。
       再比如,几年前同事在开发新功能时,遇到了一个很诡异的问题,代码是顺序执行的,按顺序执行下来肯定是没问题的,但是程序跑下来,逻辑却有明显的异常。于是我过去看了一下,发现他定义的结构体中包含有stl列表:

他在使用该结构体对象之前,对结构体对象进行了memset操作,问题就出在这个memset操作上了。memset操作破坏了stl列表内部的内存结构,在我们读取这个列表中的数据时,stl内部抛出了异常,直接将当前函数余下的代码给跳过去了,导致本该执行到的代码没有执行,导致了程序逻辑上的异常。

2.12、程序中抛出了异常,将部分该执行的代码跳过去了

       程序中执行了非法的操作,抛出了异常,但程序并没有崩溃,直接将当前函数余下的代码跳过去了, 导致部分该执行的代码没有被执行到,导致后续的代码逻辑出现了问题,进而导致程序出现异常。

       这个问题我们遇到过几次,比如给MFC中的CTime类的构造函数传入了非法的参数值,触发CTime类接口抛出了异常。再比如,我们把一个包含stl列表vector成员的结构体给memset了,破坏了该结构体中的vector成员的内存结构,引发了vector内部抛出了异常。

2.13、模块注入到程序中导致程序出现异常

       我们之前遇到过很多次这样的问题,输入法的库注入到我们软件的进程中,导致了我们软件的崩溃。通过分析发现,崩溃是发生在输入法的模块中,但因为这个模块是注入到我们的进程中的,所以直接导致了我们软件的崩溃。此外,输入法注入到我的进程后还出现了软件卡顿、CPU占用不间断跳高的问题。
        除了输入法,还有一些安全软件也会注入到我们的进程中,导致我们的软件出现异常,这样的问题我们也遇到过几次。比如前段时间我们就遇到这样一个问题,有个客户的机器上安装了多个安全防护软件,运行我们的软件一段时间后就会崩溃闪退,经远程到客户的机器上查看到任务管理器中的进程的内存在持续增长,估计是软件中发生了内存泄露,增长到一定程度后就导致内存耗尽,发生了闪退。最终定位到内存泄露发生在安全软件的注入库中。
       再比如几年前遇到的一个客户问题,他们的Windows系统中安装了VPN软件,注入到我们的进程中,hook了网络通信的相关接口,以监控软件的网络数据包的收发,其中hook的recvfrom接口实现有bug,我们代码中有处调用recvfrom接口的地方传入了NULL参数(对于系统API函数recvfrom,传入NULL值是允许的),结果直接导致该注入模块产生了崩溃,进而导致了我们软件的崩溃。
       查看微软MSDN上对套接字API函数recvfrom的说明,函数的最后两个参数是可选的,可以不传入,直接设置NULL就可以了,如下所示:

但客户VPN软件注入模块,将系统的recvfrom函数hook成了他们实现的recvfrom函数,在实现他们自己的recvfrom函数时,直接访问了recvfrom最后的两个参数,因为我们的代码直接传入了NULL值:

这样在他们的recvfrom内部没有做空指针的判断,访问了NULL指针,触发了内存访问违例,导致VPN软件的注入模块发生崩溃,从而导致了我们整个程序的崩溃。对于这个问题,我们有临时的规避办法,我们只要传入两个有效的参数即可,当然在对应的代码中,我们并不关心这两个参数在函数调用完成之后的返回值,不再传入两个NULL参数。

像这类出在第三方安全软件中的问题,必须要拿出足够的证据,证明问题是出在安全软件上,客户才会认可排查的结论,客户才会找第三方安全软件开发商去反馈问题。

2.14、添加日志打印覆盖了lasterror的值

       最近在开发新版本的需求时,发现我们的客户端软件始终连不上某个业务服务器,抓包看到客户端发起了三次握手的流程,客户端发出了SYN包,服务器收到SYN包后给出了ACK回应,但是客户端始终没给出ACK报应答,导致建链失败。

       最开始我们怀疑是客户端所在的系统的防火墙拦截了服务器的数据包,导致客户端应用层无法收到服务器的数据包。于是将程序添加到防火墙的信任列表中,允许通过防火墙进行通信,但还是有问题。于是分别将防火墙和杀毒软件都关闭掉,但是问题还是照旧。
       后来通过排查代码得知,建链时用的是非阻塞式套接字,发起connect之后会检测connect设置的lasterror值,结果在检查该lasterror之前,添加了一句打印:

就是这句打印引起的。打印接口中调用了系统API函数或者C函数,覆盖了调用connect时设置的lasterror,导致后续判断connect设置的lasterror值出错了

然后客户端直接关闭了套接字,结束了三次握手的流程,所以客户端始终连接不上服务器。
       类似的问题我们遇到过几次了。本例中的问题出在开源的libjingle代码(XMPP客户端代码)中,开源代码中做了多层函数封装,在多个函数返回后才去判断connect设置的lasterror值,所以该问题具有很强的隐蔽性。在我们不太熟悉代码的情况下,添加了一句打印导致了这个问题。

2.15、格式化时格式化符与参数不一致

       在数据格式化时,如果格式化符与参数类型不一致,可能会引发内存访问违例,导致软件崩溃。比如一个整型的变量(保存了一个很小的整数),结果错误的对应了%s格式化符,这样格式化函数内部会把这个整型值当成一个字符串首地址去对待,会读取这个内存地址中的字符串,但是这个地址是个很小的地址,即程序访问了64KB范围内的小地址存储区,这个区域是禁止访问的,会触发内存访问违例,系统会直接将进程终止掉。
       几年前,我们遇到这么一个看似很奇怪的格式化时的崩溃,崩溃的那行代码如下所示:

参与格式化的两个参数是duilib中的CStdString字符串类对象,既然是字符串,上面的代码看上去是不会有问题的,但为啥运行时会产生崩溃呢?百思不得其解!后来我们才发现,CStdString字符串类中有两个数据成员,正是这两个数据成员导致的问题。
       CStdString类的成员如下所示:

在调用格式化函数时,待格式化的参数会将内存中的内容压到栈上,格式化函数内部正是从栈上读取待格式化数据的,这里就涉及到函数调用时的栈分布的知识了。

        对于CStdString类对象,两个数据成员都会压入到栈上,第一个参数m_pstr,压入的是字符串的首地址(正是要格式化的字符串),第二个数据成员m_szBuffer压入栈的也是内存地址中的内容,不是m_szBuffer数组的首地址,这样格式化函数会把m_szBuffer数组中的内容作为%s对应的字符串的首地址,这明显是有问题的。m_szBuffer数组中保存的是字符串各个字符的ASCII码,根本不是什么字符串的首地址。硬要作为字符串地址去解析,可能地址是不可访问的,会触发内存访问违例,导致崩溃。

       下面给出调用格式化函数的栈分布图,便于理解上面的问题:

2.16、同一个程序在不同系统中可能会有不同的表现

       当前用的最多的系统是win7和win10,也有少部分人还在用XP,不同的系统底层的实现会有一定的差异,比如说内存管理模块,同一个程序在win7上运行可能是没问题的,但是拿到win10系统上跑可能会有问题。这个我们之前就遇到过。
       即便是同一个型号的系统,比如都是win7或win10的系统,不同时期的版本也可能会有一定的差异。比如最近我们还遇到过这么一个问题,在安装2015版win10系统(微软surface平板)上有CPU占用过高的情况,但在2019版的win10系统中则没有问题。
        2017年的时候,我们也遇到过不同系统上有不同表现的实例,测试同事无意发现一个掩藏很深的崩溃,并且是必现的,在win7系统上将windbg附加到进程上后,竟然不再崩溃,但是直接运行程序就有异常,这个着实有点诡异!我们将程序拿到另一台装有win8的电脑上运行,用windbg附加到进程上调试,竟然是能抓到异常信息的。这个案例也给了我们一个启示,当遇到问题时可以到不同的系统上去跑一跑,说不定有意外的收获。特别是在软件发版本之前,一定要在多个版本的系统上做测试。
       不同系统中程序的表现可能会有差异,同一类型的系统在不同时期的版本中也可能会有一定的差异,所以我们在测试的时候,要把程序拿到多个系统中去跑一跑,去测一测!

3、最后

       上面简单地介绍了一下引发软件异常的常见原因,紧接着下一篇文章我们将讲述定位这些异常的方法。

以上是关于引发C++软件异常的常见原因分析的主要内容,如果未能解决你的问题,请参考以下文章

两例典型的C++软件异常排查实例分享

排查软件异常的常见思路与方法

0xcdcdcdcd异常值引发C++程序崩溃问题的详细分析

C++软件异常排查从入门到精通

0xcdcdcdcd异常值引发C++程序崩溃问题的详细分析

C++软件异常排查从入门到精通系列汇总