目录
最近因为一重要的紧急项目中从上层到底层在短时间内根据客户需求做了很多修改,因为时间紧急仓促,产生了一系列的崩溃问题。其中有个异常崩溃有一定的代表性,通过查看Windbg中崩溃的那条汇编指令以及内存中的值去大概推测出问题的点,在这里给大家分享一下这个案例,以供参考。
VC++常用功能开发汇总(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/124272585C++软件异常排查从入门到精通系列教程(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/125529931
1、在Windbg中分析dump文件的一般步骤
之前我们一直在将软件异常排查,很多人想明确的知道使用windbg分析dump文件的详细步骤是什么样子的。今天我们在讲述排查案例之前,先来详细地讲述一下使用Windbg分析dump文件的一般步骤。
1.1、查看异常的类型
使用windbg打开dump文件后,一般我们先去看windbg中显示的异常类型,对后续的问题分析有个初步的认知,有时甚至能快速锁定问题。比如本例中异常类型为Access Violation,如下所示:
Access Violation内存访问违例的异常,是C++软件中一种很常见的且频繁出现的异常,一般是因为有内存读越界或写越界了(访问了不该访问的内存)。
如果异常类型为Stack Overflow,则能快速地定位问题,Stack Overflow表示当前发生了线程的栈内存溢出,此时去查看函数的调用堆栈就能找到原因了。在了解几种引发线程栈溢出的常见原因之后,结合当前的函数调用堆栈及源代码的上下文,就能快速地锁定问题了。
线程在某一时刻占用的栈空间,是当前线程的函数调用堆栈中所有函数的占用的栈空间之和,如果总的栈空间超过了线程栈空间的上限,则会触发Stack Overflow的线程栈溢出的异常。
从以往排查这类问题的经验来看,导致线程栈溢出的原因主要有以下几种:
1)函数的递归调用层次过深,导致函数中占用的栈空间一直未被释放。对于递归调用,只有最底层次的函数调用返回后,上面层次的函数才会逐一返回,每个层次的函数占用的栈空间才会释放。如果函数调用一直还没走到最底下的那一层,递归调用中的所有函数的栈空间一直不会释放,函数所在线程的栈空间会被占用的越来越多。解决办法是减小递归调用的层次,或者修改变量的存储类型。2)函数中定义局部变量的结构体定义比较大(结构体比较庞大,包含了很多字段或者嵌套了很多其他的结构体),超过了当前线程的栈空间的上限。解决办法是,结构体变量不要定义成函数的局部变量,选择new或malloc去申请内存,即变量在堆上申请内存。
3)因为某些机制的存在,导致两个函数不断相互调用,陷入函数调用上的死循环。导致函数占用的栈内存始终没有机会释放,导致所在线程的栈空间被消耗完了,达到了上限。解决办法是,掐断这种死循环调用机制。
4)switch语句中的case分支过多。可能这些case分支是用来处理服务器给过来的多个消息,每个case分支对应一个消息处理分支,我们会在case分支中定义生命周期在此case分支中的局部变量。虽然代码执行到case分支中这些变量才有“生命”,但其实这些变量已经在所在函数入口处就分配好栈内存了。可以编写C++测试代码进入调试状态查看一下汇编代码,就能看出来的,这点我特别验证过!
上述四种类型的栈溢出问题,我们在项目中都遇到过,有的甚至多次遇到过。
1.2、查看崩溃的那条汇编指令及相关寄存器的值
接下来,输入.ecxr命令切换到发生异常时的上下文,去查看崩溃的那条汇编指令以及崩溃时相关寄存器的值,有时这些信息也能辅助定位问题。
比如汇编指令中访问了一个很小的内存或者访问了0x00000000的地址(在Windows中,小于64KB的内存地址是禁止访问的),或者是访问了一个很大的内核态的内存地址(用户态的模块是禁止访问内核态地址的),都会触发Access Violation内存访问违例。
如果是访问了一个类的空指针(通过该空指针访问到了类的数据成员),就会触发访问小于64KB地址的问题。本例中就是访问了了一个很小的内存地址,如下所示:(本例不是空指针引发的,是内存越界访问了未知的内存引发的)
1.3、查看函数调用堆栈
接下来输入kn/kv/kp命令查看崩溃时的函数调用堆栈。首先,根据函数调用堆栈,能确定崩溃具体是发生在哪个模块中。在没有pdb的情况下去查看函数调用堆栈,看不到具体的函数名及代码的行号,所以要先根据堆栈中函数都在哪些模块中,使用“lm vm 模块名*”去查看模块的时间戳(二进制文件的生成时间),然后根据时间到服务器上找到对应的pdb文件。本例中看到函数调用堆栈中最后几个函数都在xxxxxxpdll.dll中,所以使用lm命令查看该dll的时间戳:
时间戳为20220901-15:23:58,到服务器上找到了这个时间点的pdb文件。然后将pdb文件路径设置到windbg中,重新输入.ecxr和kn命令,重新查看带有具体函数名及行号的函数调用堆栈:
此时就可以根据调用堆栈中显示的具体函数名和代码的行号,到源代码中找到对应的位置,然后去分析源码的上下文去定位问题了。
1.4、查看相关变量在内存中的值
有时,我们需要在函数调用堆栈中查看函数中局部变量或者函数所属类对象的成员变量的值(变量内存中的内容),这是个技巧,很多人可能会忽略掉。
我们通过查看函数调用堆栈找到发生崩溃的函数后,到C++源码中去查看代码的上下文,有时很难确定是啥导致的崩溃。而崩溃可能是与某个变量有直接的关系,和函数中的其他变量可能也有关系,我们在Windbg中可以直接去查看这些变量值,有了变量的值之后,结合源代码,可能很快就能分析出崩溃的原因了。
具体查看方法是,点击函数调用堆栈中的某行记录最前面的序号,如下:
Windbg会自动将该函数中的局部变量信息及函数所属类对象的this指针展开,这样就能查看到局部变量的值。点击this指针的超链接,就能查看到当前类对象的成员变量的值了。
不过这里有个细节需要注意一下,下面在分析问题时会讲到。
1.5、有时可能需要使用IDA查看汇编代码上下文
有时,为了最终定位问题还需要使用IDA工具去查看二进制文件中的汇编代码的上下文。仅仅定位到函数及代码的行号,可能会因为此行代码涉及的对象和接口较多,无法确定具体是哪一小块的代码引发的。
程序最终是崩溃在某条汇编指令上,汇编代码才能最直观地反映出问题的所在。所以,此时我们需要使用IDA反汇编工具去查看发生崩溃的那条汇编指令的汇编上下文,通过上下文确定崩溃的汇编指令对应哪一小部分代码,然后去查看C++源码的上下文,去看看为什么会引发崩溃。
一行C++代码可能会对应多条汇编指令,并且release下编译器会对代码进行优化,汇编代码可能较难直接和我们的C++源代码完全对应上,我们只能通过汇编代码的上下文去找与C++源码的对应关系。但直接去阅读汇编代码会比较费劲。
为了方便查看汇编代码的上下文,在使用IDA也需要用到pdb符号库文件。只需要将pdb文件放在目标二进制文件的同级目录中,这样IDA在打开二进制文件时,会自动去加载其对应的pdb文件,这样IDA会在解析出来的汇编代码中会显示具体的函数名及变量信息,并添加一些注释信息,有了这些信息,阅读汇编代码就方便多了,就比较容易将汇编代码与C++源代码对应起来了。
2、Windbg打开dump文件,初步分析
我们打开崩溃时捕获到的dump文件,查看崩溃时的函数调用堆栈,然后查看相关模块的时间戳,找到对应的pdb文件,然后将pdb路径设置到windbg中,然后查看到详细的函数调用堆栈,如下所示:
在调用堆栈中看到了具体的函数名称、函数所在的cpp文件名及代码的行号,如下:
00 22b1f9c8 16967a57 xxxxxmpdll!webrtc::RTCStatsObtainer::OnStatsDelivered+0x16a2 [k:\cbb\xxxxxxxxxxx\git\20220825_xxcomponent_xxxx_xx\xxcbb\xxxxxmp\include\rtc_stats_obtainer.h @ 479]
然后到源码中找到了对应的位置,问题处在479这一行上:
该句代码是if判断语句,判断pOutRtp->frame_width.is_defined()函数的返回值,返回的是bool值,即判断返回的bool值是否为0,从而确定条件是否为真。这行源代码和崩溃的那条汇编指令是吻合的:cmp byte ptr [eax+2A8h],0。那是不是pOutRtp指针值有问题呢?
3、查看内存中变量的值,定位问题的原因
上述出问题的代码位于一个for循环中,难道是for循环的循环次数有问题?有内存越界读的情况?想到可以直接在windbg中可以查看到函数局部变量及函数所属类对象中成员变量的值,所以点击函数调用堆栈前面的序号,看到了函数中局部变量pOutRtp指针的值为0x00000002,如下:
这是个异常值,显然是有问题的。这是个很小的内存地址,和空指针类似了。
注意,dump文件主要有两大类,一类是很小的mini dump文件(文件大小在几MB之内),一类是很大的全dump文件(文件大小大概有好几百MB或者上GB,文件大小比较接近崩溃时进程占用的虚拟内存的大小)。在mini dump文件中只能看到部分变量的值,很多变量的值是看到不到的,这个要看运气的,这点需要注意一下。而全dump文件,则是包含了进程所有内存的信息,可以看到所有变量的值的。
最后经业务上的确实,确实是for循环次数有问题,不应该使用固定的值(3次)作为循环次数的,有时底层上来的数据只有两层,没有第三层的,那么强行访问到第三层就内存越界了。应该将底层上来的实际层数作为循环的次数的。
4、最后
本例中查看崩溃的那条汇编以及内存中变量的值,找到了线索并定位了问题。问题本身虽然并不复杂,但具有一定的代表性,所以在这里分享出来,供大家参考。