目录
1、问题背景
2、将Windbg附加到进程上,发现软件发生异常时中断在DebugBreak接口上
3、根据Windbg中显示的函数调用堆栈,查看Webrtc库的开源代码,发现是new失败了
4、malloc或new失败的可能原因分析
5、发生内存泄漏导致进程的用户态虚拟内存即将被用完,导致malloc失败
6、最后
近日业务组件组的同事在联调webrtc集成硬编硬解库功能时,发现我们的C++软件会频繁出现闪退的问题,邀请我过去帮忙分析一下。我们将Windbg工具附加到软件进程上调试运行,最终找出了原因,今天就来分享这一问题的排查过程。
我们的软件在入会后会进行繁重的视频编解码操作,一方面要执行本端图像的采集与编码,另一方面要将会议中接收的多路视频码流进行解码显示。大家都知道,音视频编解码操作会消耗大量的CPU资源,目前我们的视频编解码库使用的都是CPU软解,没有使用到GPU硬解。
在一些散热比较差的电脑上,比如微软Surface平板电脑上,入会后一直在进行视频编解码操作,会占用大量的CPU资源,会导致CPU超频,超频后机器会产生更多的热量,而Surface平板散热很差,导致热量不能及时散发出去,温度会持续升高,当温度达到上限时就会降频(这是CPU防止温度过高的自我保护策略),比如如下的截图:
当前CPU的基准主频是2.5GHz,实际主频已经降频降到了1.22GHz。 降频后,CPU的处理能力会严重下降,会导致CPU跑满,导致系统严重卡顿,导致整个系统都没法操作了。所以我们想使用GPU进行硬编硬解,减少对CPU的占用,以解决使用CPU软编软解导致的系统卡顿问题。
我们当前使用的是开源的webrtc库,视频的编解码操作都是在库中完成的。webrtc移动版本是自带硬编硬解的编解码库的,在移动平台上默认情况下也是启用硬编硬解的。但webrtc Windows平台的版本是不支持硬编硬解的,需要开发者自己去开发硬编硬解的编解码库,以插件的方式集成到webrtc库中的。
开源组件组研究了硬编硬解的编解码算法,提交到业务组件这边进行集成联调。运行软件,加入到会议中,结果一会之后软件就闪退了,软件的异常捕获模块CrashReport没捕获到异常,很是奇怪!这个问题是必现的!
既然异常捕获模块在软件发生异常时没有捕获到异常,那我们可以尝试将Windbg附加到进程上进行调试运行,看看Windbg动态调试时能否感知到异常。
根据多年来排查C++软件异常的经验,有些异常,异常捕获模块是捕获不到的,所以也就不会生成包含异常上下文的dump文件的。此时可以尝试直接用Visaul Studio调试Release版本的代码,或者是将Windbg附加到目标进程上,看看Windbg能否感知到异常。
将Windbg附加到进程上的方式,基本都能捕获到异常捕获模块捕获不到的异常。当然,这个还要看bug是否好复现。如果问题不是必现的,需要去研究问题复现的规律,尽量找到问题的复现办法。但有些问题是没有规律的,是很难复现的。
加入会议后跑一小会,Windbg就感知到了,中断了下来。于是使用kn命令查看此时的函数函数调用堆栈,发现是DebugBreak接口触发Windbg中断下来的,如下所示:
DebugBreak接口是Windows系统API接口,目的是为了让当前的调试器中断下来,DebugBreak的调用者一般是为了提示软件发生了问题。
其实,DebugBreak接口内部的实现类似下面的代码:
#ifdef _X86_
#define DebugBreak() _asm { int 3 }
#endif
内部类似一个INT 3软中断,目的是让调试器中断下来。
x86系列处理器从其第一代产品英特尔8086开始就提供了一条专门用来支持调试的指令,即INT 3。简单地说,这条指令的目的就是使CPU中断(break)到调试器,以供调试者对执行现场进行各种分析。当我们调试程序时,可以在可能有问题的地方插 入一条INT 3指令,使CPU执行到这一点时停下来。这便是软件调试中经常用到的断点(breakpoint)功能,因此INT 3指令又被称为断点指令。
根据windbg中显示的函数调用堆栈,调用DebugBreak接口的代码位于webrtc开源库中。于是查看webrtc的开源代码,发现是执行malloc操作时失败了,webrtc框架中认为malloc失败是致命的问题,先是调用DebugBreak,然后紧接着调用abort将进程强制结束掉。这就对了,这就和软件的运行现象一致的。
webrtc库中的相关代码如下所示:
上面调用malloc申请内存失败,返回NULL空指针,进入RTC_CHECK宏中:
紧接着进入到rtc_FatalMessage函数中:
然后又进入FatalLog接口中:
最终在该接口中先是调用了DebugBreak接口,尝试让当前正在调试的调试器中断下来,然后紧接着调用abort接口将进程强行终止掉。
那为啥会出现malloc或new操作失败的问题呢?之前我们总结过,一般new操作失败可能是有四种可能的原因。
1)申请的内存过大,进程中没有这么大内存可用了
可能受一些异常数据的影响,申请了很大尺寸的内存。比如前段时间排查一个崩溃问题,当时因为数据有异常,一次性申请了9999*9999*4*2 = 762MB的堆内存,进程中没有这么大可用的堆内存了,所以申请失败了。
2)用户态的内存已经达到了上限,申请不到内存了
有可能是虚拟内存占用太多,也有可能代码中有内存泄露,导致用户态的虚拟内存被消耗完了。对于一个32程序,一个进程分配了4GB的虚拟地址空间,而用户态和内核态内存各占一半,即用户态的虚拟内存只有2GB,如果程序占用的虚拟内存比较大,比如接近2GB的用户虚拟内存了,再申请大的内存就会申请失败了。或者程序中有内存泄露,快要把用户态的2GB的虚拟内存给占用完了,再申请内存可能会申请失败的。
3)进程中的内存碎片过多
如果进程中在大量的new和delete,产生了大量的小块内存碎片,可用的内存被切割成一小块一小块的小内存块,如果要申请一块长度很长的内存,因为到处是内存碎片,没有这么一大块连续的可用内存,可能会导致内存申请失败的。
4)发生堆内存越界
堆内存被破坏,导致new操作产生异常(此时new不会返回NULL,会抛出异常)。我们可以在出问题的地方,对该处的new添加一个保护(但不可能对代码中所有new的地方都加这样的保护),我们通过添加try...catch去捕获new抛出的异常,并将异常码打印出来,如下所示:(下面的代码在循环申请内存,直到内存申请失败为止,主要用来测试用)
#include
using namespace std;
int main(){
char *p;
int i = 0;
try
{
do{
p = new char[10*1024*1024];
i++;
Sleep(5);
}
while(p);
}
catch(const std::exception& e)
{
std::cout << e.what() << "\n"
<< "分配了" << i*10 << "M" << std::endl;
}
return 0;
}
还有一种方式,在new时传如一个std::nothrow参数,让new在申请不到内存时不要抛出异常,直接返回为NULL,这样我们就可以通过返回的地址是否为NULL(空),判断是否是内存申请失败了,示例代码如下:
#include
int main(){
char *p = NULL;
int i = 0;
do{
p = new(std::nothrow) char[10*1024*1024]; // 每次申请10MB
i++;
Sleep(5);
}
while(p);
if(NULL == p){
std::cout << "分配了 " << (i-1)*10 << " M内存" //分配了 1890 Mn内存第 1891 次内存分配失败
<< "第 " << i << " 次内存分配失败";
}
return 0;
}
对于本问题,我们初步怀疑,可能是第二种情况引起的,即用户态的内存已经用完了。我们程序是32位的,所以用户态的虚拟内存只有2GB,如果程序的虚拟内存已经接近或者达到2GB,再去malloc申请内存可能就会失败。于是再将windbg附加上去,入会后一会windbg就中断了下来,此时去查看我们软件进程占用的内存大小。正好Windbg中断时会将进程挂起,我们正好有机会使用工具其查看进程的虚拟内存占用情况。在崩溃的情况,都没有机会去查看进程占用的内存,程序直接闪退了。
Windows任务管理器中是看不到进程总的虚拟内存占用的,只能看到其他类型的内存占用,必须要使用Process Explorer工具去查看。
又轮到Process Explorer上场了,Process Explorer工具默认是不显示虚拟内存占用大小的,需要在列表栏中右键点击,在弹出的右键菜单中点击“Select Columns”菜单项,在打开的窗口中点击“Process Memory”标签页,在页面中勾选“Virtual Size”选项:
该选项就是进程的虚拟内存了。勾选后,在Process Explorer的主窗口的列表中就能看到虚拟内存列了:
查看到我们的进程确实快达到2GB了,所以出现malloc失败了。
后来经webrtc开源组同事的确认,他们添加的代码中有内存泄漏,内存泄漏导致用户态的虚拟内存逐步被耗尽,导致接近2GB的情况,导致malloc内存失败的。
webrtc库在malloc申请内存失败后,先是调用了DebugBreak接口,然后是调用abort强制将进程终止了。所以在没挂windbg时就直接闪退了,是主动终止,不是异常崩溃,所以异常捕获模块CrashReport没有捕获到异常,没有生成相应的dump文件。