目录
1、问题描述
2、使用Windbg静态分析dump文件
3、将Windbg附加到进程上进行动态调试
4、使用Visual Studio进行Debug调试
4.1、使用if条件断点和汇编代码单步调试
4.2、分析消息响应函数入口处为什么会产生崩溃
4.3、解决办法
5、线程栈溢出的相关细节点说明
6、引发线程栈溢出的常见原因和场景总结
7、调试汇编代码
8、排查方法总结
9、最后
VC++常用功能开发汇总(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/124272585C++软件异常排查从入门到精通系列教程(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/125529931C++软件分析工具案例集锦(专栏文章正在更新中...)https://blog.csdn.net/chenlycly/article/details/131405795 上周在新版本软件的测试过程中发现,执行某个点击操作时程序就会发生闪退崩溃,问题是必现的。经排查,是崩溃在校验栈空间的运行时汇编代码文件chkstk.asm中,这个问题有较强的隐蔽性,今天就来总结一下,详细地讲述整个问题的完整排查过程。
在软件中切换到会议列表页面,回去自动刷新会议列表,然后手动去点击列表中的某个会议item,会去自动请求对应会议的详细信息,然后程序就发生闪退崩溃了。这个问题是必现的,所以可能会相对好排查一些,但事实上这个问题并没有最开始想象的那么好查,这个问题具有一定的隐蔽性。
我们在软件中安装了异常捕获模块,异常捕获模块捕获到了这次异常,并自动生成了dump文件。于是我们到对应的目录中取出dump文件使用Windbg进行静态分析。用Windbg打开dump文件后,先是看到了Access violation内存访问违例的异常:
于是使用.ecxr命令切换到发生异常的那个线程,看到发生异常的那条汇编指令,如下所示:
这条汇编指令中访问了一个较小的内存地址,所以导致了上面的Access violation内存访问违例?访问的内存地址是0x00e00000,而我们之前讲的64KB小地址内存区是指0x00000000 - 0x0000FFFF这个地址范围,显然当前的0x00e00000不在这个64KB小地址内存区域中。至于为什么会崩溃,就要继续向下分析了。
于是输入kn命令查看函数调用堆栈:
因为没有加载相关模块的pdb文件,所以函数调用堆栈中看不到具体的函数。这时需要根据函数堆栈中显示的模块,依次使用lm命令查看模块的时间戳信息,查找对应版本的时间戳。以directui.dll为例,如下所示:
然后根据时间戳(模块的编译生成时间)到文件服务器上去找pdb文件。
将函数调用堆栈中相关模块的pdb文件拿来后,将pdb文件路径设置到Windbg中,然后重新输入.ecxr命令切换到发生异常的线程上下文,然后重新输入kn命令查看包含具体函数名等详细信息的函数调用堆栈,如下所示:
从上述函数调用堆栈看,问题好像出在directui的开源框架代码中,这个就比较奇怪了,以前从来没遇到过框架代码出问题的场景。这套框架已经用了好几年了,一直都没问题的,应该不是框架的问题。关于使用Windbg静态分析分析dump文件的一般步骤与要点说明,可以参见我之前写的文章:
使用Windbg静态分析dump文件的一般步骤及要点详解https://blog.csdn.net/chenlycly/article/details/130873143 所以,静态分析dump文件也没有分析出来,陷入了僵局,这比原先想象的要复杂不少。刚开始联调就出现这样的问题,着实有些棘手,耽误联调时间。这是主要功能点,没法绕过去,必须要继续排查。
既然静态分析dump文件查不出问题,所以尝试将程序重新启动起来,然后将Windbg附加到进程上进行动态调试,看看动态调试能否得到有用的线索。问题是必现的,按照之前的复现步骤复现异常,Windbg感知到并中断下来,如下所示:
动态调试果然有效果,看到了软件发生了Stack overflow线程栈溢出的异常。
但使用kn命令看到的函数调用堆栈,和之前dump文件中显示的函数调用堆栈是一样的,如下所示:
虽然知道了发生线程栈溢出的异常,但从函数调用堆栈中还是分析不出来问题,directui框架不应该有问题,都用了好几年了,如果有缺陷,应该之前就会暴露出来了,至此这个问题还是很迷茫。
于是沿着当前的函数调用堆栈,一个函数一个函数看,但始终没看到明显会引发线程栈溢出的代码,比如定义了一个很大的局部结构体变量、函数递归调用或者函数调用上的死循环。但查看了当前线程的函数调用中的所有的函数,都没找到问题。
关于使用Windbg动态调试目标进程的一般步骤与要点说明,可以参见我之前写的文章:
使用Windbg动态调试目标进程的一般步骤及要点详解https://blog.csdn.net/chenlycly/article/details/131029795
既然Windbg静态和动态调试都没定位问题,因为问题是必现的,Debug下也是必现的,所以决定使用Visual Studio进行Debug下的调试。按照问题的复现步骤操作,程序产生异常,Visual Studio中断下来,如下所示:
代码崩溃在汇编文件chkstk.asm中的这行汇编代码上:test dword ptr [eax],eax,这点和Windbg看到的是一致的。弹出的提示框中也提示发生了Stack overflow线程栈溢出异常!
打开调用堆栈页面查看当前的函数调用堆栈:
调用堆栈和Windbg中显示一致,于是进入最后一个函数,到函数内部去详细看一下。
点击会议列表中的会议item时会向服务器发起请求会议详情的请求,等服务器回应后,底层会给UI层投递一个消息EVENT_UI_CONF_DETAILINFO_NTF,在消息中携带会议详细信息数据。如果将获取会议详情的代码注释掉,程序就不会崩溃,所以肯定和这个会议详情请求有关。
既然崩溃在上述函数中,于是在上述函数中打断点进行单步调试。上述函数的功能是处理窗口的自定义消息,收到消息后到映射表中找到消息对应的响应函数去执行响应函数,所以人为地在代码中添加一个条件断点,以EVENT_UI_CONF_DETAILINFO_NTF消息id进行过过滤(消息id宏EVENT_UI_CONF_DETAILINFO_NTF的值为12054),如下所示:
人为添加if条件断点,很好用,比Visual Studio中的条件断点要灵活方便很多,建议使用这种人为添加的if条件断点,我们在项目中基本都用这种方式。
问题应该就出在:
(this->*mmf.pfn_bwlb)( wParam, lParam, bHandled );
这行源码就一行,怎么进行单步调试呢?C++源码是只有一行,但程序最终执行的是汇编代码,一行C++源码可能对应多行汇编代码,可以在运行到断点处,右键点击:
在弹出的右键菜单中点击“转到反汇编”菜单项,跳转到汇编代码页面,单步去调试汇编代码。就在这时,突然想起来,好像忽略了一个问题排查点,有没有可能是EVENT_UI_CONF_DETAILINFO_NTF消息响应函数CXXXXMsgRecver::OnConfDetailInfoInd中有问题,于是又在该函数的入口处添加断点:
然后命中断点后右键查看汇编代码,然后单步调试汇编代码。经调试发现,问题果然出在响应函数CXXXXMsgRecver::OnConfDetailInfoInd的入口处:
单步调试上面的汇编代码,发现最终崩溃在_chkstk这个函数调用的地方,所以就是崩溃在当前函数的入口处,在崩溃时显示的函数调用堆栈中不显示当前的响应函数,可能和崩溃在函数入口处有关。
已经确定问题出在EVENT_UI_CONF_DETAILINFO_NTF消息的响应函数CXXXXMsgRecver::OnConfDetailInfoInd中了。下面就要看看这个函数入口处为啥会发生崩溃了!
于是详细查看了CXXXXMsgRecver::OnConfDetailInfoInd函数的代码,不看不知道,一看就知道,函数中根据不同的会议类型分了几个if条件分支,每个分支都使用对应的结构体定义了局部变量,代码片截图如下所示:
即分别使用了TMTInstantConferenceInfo_Api、TMTBookConferenceInfo_Api和TMTVConfDetailInfo_Api三个结构体定义了局部变量,这三个结构体都是比较大的,定义的局部变量都是在栈上分配的,问题就出在这里了。
此处,需要注意一下,虽然这三个结构体变量是定义在if条件体内部的,作用域位于这些if条件体中,即生命周期在if条件体中,但三个结构体变量占用的栈空间在函数入口处就分配好了(关于这一点可以编写几个分支代码,在Visual Studio中查看汇编代码去验证)。因为结构体很大,所以在分配好三个结构体变量栈内存后,调用_chkstk汇编实现的函数去校验线程栈内存时,发现超过了当前线程的栈的上限,所以触发了Stack overflow线程栈溢出的异常。
为啥同样的代码在之前的版本中运行不会产生线程栈溢出,现在就会出现呢?初步估计可能是TMTInstantConferenceInfo_Api、TMTBookConferenceInfo_Api和TMTVConfDetailInfo_Api这三个结构体的定义有变动了,比如新增了一些字段,于是去这三个结构体的头文件中去查看svn上的修改记录,果然发现前段时间有人修改了TMTBookConferenceInfo_Api结构体,新增了以下若干个字段:
正因为这些新增的字段导致原本不会栈溢出的,现在发生栈溢出了!
问题定位出来了,修改办法其实很简单,只要将在栈上分配内存的局部变量改成指针或者到堆上申请内存的对象即可。修改后的代码片段为:(不再定义局部变量接收,直接定义指针接收即可)
哪些对象会占用线程的栈内存呢?函数中的局部变量是在线程栈上分配的,函数调用时主调函数传给被调函数的参数时通过栈内存传递的(将要传递的参数内存中的值压到栈上),他们会占用线程的栈内存。
系统在创建线程时,会给线程分配指定大小的栈内存,在默认情况下,Windows系统默认会给线程分配1MB栈内存、Linux系统默认会给线程分配2MB栈内存。某个线程在某一时刻函数调用堆栈中所有函数占用的栈内存总和,就是当前线程在此时的总的栈内存占用。如果某时刻线程占用的总的栈内存超过系统给该线程分配的栈内存上限时,就会触发Stack overflow线程栈溢出异常。
引发线程栈溢出问题可能有以下几个可能:
1)函数递归调用的深度过深
因为一直在递归调用,在到达最底下的那层调用之前,递归函数一直没返回,栈空间一直没有释放,导致当前线程占用的栈空间越来越多,达到上限。
2)消息上触发函数的死循环调用
消息触发的函数死循环调用,因为死循环调用了,函数的栈空间一直没释放,导致当前线程占用的栈空间越来越多。这个问题我们在实际项目中遇到过两次。
3)定义了一个占用内存很大的局部变量
比如定义了一个很庞大的结构体,在一个函数中用该结构体定义了一个局部变量,假设该结构体接近或者大于1MB,则会直接导致线程栈溢出。
4)函数中使用switch...case语句,包含了大量的case分支
每个case分支中都定义了局部变量,导致当前函数占用了大量的栈空间。case分支中的局部变量的生命周期是在case分支中的,即代码运行到对应的case分支中时该分支中的局部变量才有“生命”,但其实这个局部变量的栈空间已经在函数入口处分配好栈空间了,并不是代码执行到case子句中才分配栈空间的。这点可以通过编写测试代码,查看函数入口处给当前函数分配栈空间的汇编代码就能看出来了,可以先顶一个变量查看汇编代码看看分配了多少栈空间,然后再增加一个变量,看看分配的栈空间是否变大。5)多个if-else分支,每个分支中都有定义局部变量
引发问题的原因与多个case语句的场景是一样的,此处就不再赘述了。本问题案例的场景,就是与if-else分支有关系的。
CPU中执行的是一句一句汇编代码(或者叫二进制机器码,两者是等价的),看汇编代码才能看到代码的执行细节。一行C++源码可能对应多行汇编代码,有时我们要知道C++源码为何有问题,可以尝试在Visual Studio中转到汇编代码页面,去单步调试汇编代码,通过调试汇编代码去寻找真相。本案例就是一个通过调试汇编代码去排查问题的典型实例。
在本例中先后使用了多种排查方法,从Windbg静态分析dump文件,到Windbg动态调试目标进程,再到使用Visual Studio调试源码,最后去单行调试汇编代码。最终调试汇编代码,才找出问题的原因。
在排查问题的过程中,可以需要使用到多种排查方法,一个方法排查不出来,就换另一种方法;或者将两个或两个以上的方法结合起来使用,直到定位问题为止!此外,除了使用调试器,还可以考虑同时使用其他的软件分析工具去辅助定位,比如Process Explorer或Process Monitor等。
本文详细讲述了一个有一定隐蔽性的线程栈溢出异常的问题排查过程,并详细阐述了线程栈溢出的相关细节,系统总结了引发线程栈溢出常见场景和原因,希望能给大家提供一定的借鉴和参考。