C++程序运行过程中发生异常闪退,很有可能是这三个原因导致的

目录

1、综述

2、GDI对象泄露

3、Stack Overflow线程栈溢出

4、内存泄露


       Windows应用软件在交付给客户使用或者试用后,可能会因为操作系统版本及硬件上的差异,出现这样那样的软件异常问题。特别是项目即将交互等待客户验收时,出现多种莫名其妙的异常问题,是比较棘手的,在有限的时间内去搞定这些异常问题的压力也是比较大的。下面以以往遇到的多个项目问题为例,简单地说一下三种比较典型的软件异常问题,希望能给大家提供一定的借鉴或参考。

1、综述

C++程序运行过程中发生异常闪退,很有可能是这三个原因导致的_第1张图片

       Windows应用软件在发布之前,在公司内部进行了详细的测试,基本已经达到稳定状态,但公司内部进行的测试是有限的,有限的人力、有限的机器环境,始终是无法覆盖所有的问题场景的。当软件发布到各式各样的客户手中,可能会因为操作系统及硬件上的差异,出现这样那样的异常问题。

       从操作系统上看,Windows操作系统就有多个大版本,比如XP、Win7、Win8、Win10,甚至Win11就要出来。除了大版本之外,每个同系列上还有各种子版本,在系统特性上都有着或大或小的差异。作为Windows软件都要兼容这些常用的、不同版本的操作系统,这给软件的平稳运行带来了挑战。

       除了操作系统之外,还有各式各样的硬件,对应着各自的硬件驱动程序,这给软件的良好运行带来了更大的挑战。所以,当软件拿到多个客户的机器上运行,出现这样那样的问题是在所难免的,作为软件的提供方,我们只能尽力将出问题的概率降到最低,在出现问题后要第一时间去响应、去解决。

       和客户侧的Windows终端应用软件相比,大多数服务器侧的软件则要幸运的多,它们一般不用去面对各式各样的软硬件环境。因为服务器侧的操作系统和硬件设备都是产品提供商定制好了,使用固定的硬件,使用固定版本的Linux操作系统(当然也有服务器使用Windows Server等不同操作系统的),服务器侧产品在发布之前已经能保证在这些固定的软件环境中持续稳定的运行。

        回到本文的主题,本文研究的对象是客户终端侧的Windows C++软件,下面就来切入本文的正题。今天我们要讲的这几类异常有个共同的特点就是,软件刚启动时运行还算平稳,CPU和内存占用都比较正常,但运行一段时间后或较长一段时间后可能会莫名其妙地异常闪退。当出现这种运行一段时间后的异常闪退,很有可能是以下三种原因导致的。一是发生了GDI对象泄露,二是发生了线程栈溢出,三是发生了内存泄露。这三种异常基本上都可能是运行一段时间才会出现的,甚至有时是很难复现的,因为这些异常可能是某些操作才会触发的,如果用户没有执行这些操作,可能就不会就不会爆出这些问题了。

2、GDI对象泄露

C++程序运行过程中发生异常闪退,很有可能是这三个原因导致的_第2张图片

       程序运行一段时间后,当GDI对象达到10000个左右,导致程序崩溃闪退。

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

       如果代码中有Pen、Brush、Bitmap、Font、RegionDC等GDI对象泄露时,且这段代码会频繁的执行,可能指定某一操作后才会频繁的触发泄露代码的执行。在程序运行的过程中GDI对象会快速的增长,当GDI达到10000个左右时,会出现各种GDI函数绘图失败的问题,可以通过GetLastError获取绘制失败的原因。

      紧接着程序可能就会出现异常崩溃闪退了。多久能达到10000万个上限,和泄露的程度有关系,也和程序运行的时间长短有关。有时可能半个小时或几个小时会出现,有时需要长时间拷机才会出现。至于拷机,有多种形式,比如下班后的夜间拷机,周末休息期间的长时间拷机运行。

       GDI对象泄露问题该如何感知并排查呢?可以先查看系统的任务管理器,持续观察目标进程的GDI总数的变化,如果GDI对象有明显增长就说明可能存在GDI对象泄露了。然后再打开GDIView工具,看看具体各类型的GDI对象的数目:

C++程序运行过程中发生异常闪退,很有可能是这三个原因导致的_第3张图片

找出数值异常的那一种GDI对象。知道发生泄露的GDI对象类型后,就可以结合代码进行逐步排查了。

       这种观察方式需要人工进行,对于无人值守的情况该怎么处理呢?比如最简单就是使用按键精灵等自动化测试工具去拷机,去执行某一些操作,看看长时间运行是否会有问题,如果是监测GDI对象是否有泄露,可以每隔若干时间就让按键精灵调用截图程序截一张桌面的图片,并保存到指定的目录中,第二天来上班后可以看这些图片去判断。

       比如如下的代码,在函数结尾的时候不去删除之前创建的GDI对象,就会导致GDI对象泄露:

// 拷贝桌面,lpRect 代表选定区域,bSave 标记是否将图片内容保存到剪切板中
HBITMAP CCatchScreenDlg::CopyScreenToBitmap( LPRECT lpRect ) 
{                           
	// 确保选定区域不为空矩形
	if ( IsRectEmpty( lpRect ) )
	{
		return NULL;
	}

	CUIString strLog;

	HWND hWndDeskTop = ::GetDesktopWindow();

	//HDC hScrDC = ::CreateDC( _T("DISPLAY"), NULL, NULL, NULL );
	HDC hScrDC = ::GetDC( hWndDeskTop ); // 为屏幕创建设备描述表
	if ( hScrDC == NULL )
	{
		strLog.Format( _T("[CCatchScreenDlg::CopyScreenToBitmap] 创建DISPLAY失败, GetLastError: %d"), 
			GetLastError() );
		WriteScreenCatchLog( strLog );

		return NULL;
	}

	HDC hMemDC = ::CreateCompatibleDC( hScrDC ); // 为屏幕设备描述表创建兼容的内存设备描述表
	if ( hMemDC == NULL )
	{
		strLog.Format( _T("[CCatchScreenDlg::CopyScreenToBitmap]创建与hScrDC兼容的hMemDC失败, GetLastError: %d"), 
			GetLastError() );
		WriteScreenCatchLog( strLog );

		//::DeleteDC( hScrDC );

		::ReleaseDC( hWndDeskTop, hScrDC );
		return NULL;
	}

	int nX = 0;
	int nY = 0;
	int nX2 = 0;
	int nY2 = 0;   
	int nWidth = 0; 
	int nHeight = 0;

	// 保证left小于right,top小于bottom
	CDirectRect rc = *lpRect;
	rc.Normalize();

	// 获得选定区域坐标
	nX = rc.left;
	nY = rc.top;
	nX2 = rc.right;
	nY2 = rc.bottom;

	// 确保选定区域是可见的
	if ( nX < 0 )
	{
		nX = 0;
	}

	if ( nY < 0 )
	{
		nY = 0;
	}

	if ( nX2 > m_xScreen )
	{
		nX2 = m_xScreen;
	}

	if ( nY2 > m_yScreen )
	{
		nY2 = m_yScreen;
	}

	nWidth = nX2 - nX;
	nHeight = nY2 - nY;

	//HBITMAP hBitmap = ::CreateCompatibleBitmap( hScrDC, nWidth, nHeight ); // 创建一个与屏幕设备描述表兼容的位图
	HBITMAP hBitmap = CreateDIBBitmap( nWidth, nHeight );
	if ( hBitmap == NULL )
	{
		strLog.Format( _T("[CCatchScreenDlg::CopyScreenToBitmap]创建与hScrDC兼容的Bitmap失败, GetLastError: %d"), 
			GetLastError() );
		WriteScreenCatchLog( strLog );

		//::DeleteDC( hScrDC );
		::ReleaseDC( hWndDeskTop, hScrDC );
		::DeleteDC( hMemDC );
		return NULL;
	}

	::SelectObject( hMemDC, hBitmap ); 	// 把新位图选到内存设备描述表中

	BOOL bRet = FALSE;
	BOOL bProcessed = FALSE;
	if ( IsOSWin7OrAbove() )
	{
		DEVMODE curDevMode;
		memset( &curDevMode, 0, sizeof(curDevMode) );
		curDevMode.dmSize = sizeof(DEVMODE);
		BOOL bEnumRet = ::EnumDisplaySettings( NULL, ENUM_CURRENT_SETTINGS, &curDevMode );

		//strLog.Format( _T("[CCatchScreenDlg::CopyScreenToBitmap]m_xScreen: %d, curDevMode.dmPelsWidth: %d"), 
		//	m_xScreen, curDevMode.dmPelsWidth );
		//WriteScreenCatchLog( strLog );

		if ( bEnumRet && m_xScreen < curDevMode.dmPelsWidth )
		{
			bProcessed = TRUE;
			::SetStretchBltMode( hMemDC, STRETCH_HALFTONE );
			bRet = ::StretchBlt( hMemDC, 0, 0, nWidth, nHeight, hScrDC, 0, 0, curDevMode.dmPelsWidth, 
				curDevMode.dmPelsHeight, SRCCOPY|CAPTUREBLT );
		}
	}

	if ( !bProcessed )
	{
		bRet = ::BitBlt( hMemDC, 0, 0, nWidth, nHeight, hScrDC, nX, nY, SRCCOPY | CAPTUREBLT );  // CAPTUREBLT - 该参数保证能够截到透明窗口
	}
    
	if ( !bRet )
	{
		strLog.Format( _T("[CCatchScreenDlg::CopyScreenToBitmap]将hScrDC拷贝到hMemDC失败, GetLastError: %d"), 
			GetLastError() );
		WriteScreenCatchLog( strLog );

		//::DeleteDC( hScrDC );
		::ReleaseDC( hWndDeskTop, hScrDC );
		::DeleteDC( hMemDC );
		::DeleteObject( hBitmap );
		return NULL;
	}

	if ( hScrDC != NULL )
	{
		//::DeleteDC( hScrDC );
		::ReleaseDC( hWndDeskTop, hScrDC );
	}

	if ( hMemDC != NULL )
	{
		::DeleteDC( hMemDC );
	}

	return hBitmap; // hBitmap资源不能释放,因为函数外部要使用
}

3、Stack Overflow线程栈溢出

       如果某个时刻线程的函数调用堆栈中所有函数占用栈空间总数超过当前线程的栈空间上限,就会产生Stack Overflow的线程栈溢出的异常,程序就会闪退崩溃。

每个线程的栈空间是有上限的,在Windows中,每个线程的栈空间默认是1MB,在创建线程时可以自定义线程的栈空间上限值。

       线程栈溢出的异常,一般发生在刚进入到被调用的函数时,函数的栈空间是在函数入口的地方分配的。程序在运行过程中发生异常或者闪退,可能就是有线程发生栈溢出导致的。对应栈溢出的异常,在VS中调试是看不到异常时的函数调用堆栈的,因为发生异常时进程直接退出调试了,只能看到发生了栈溢出的提示文字,但看不到发生栈溢出时的函数调用堆栈。

       可以使用windbg查看到异常时的函数调用堆栈。将windbg附加到目标进程上,当目标进程发生栈溢出时windbg就能捕获到异常中断下来,此时输入kn等命令就可以查看到此刻的函数调用堆栈,就能找到产生异常的线索了。

线程发生栈溢出一般有一下几个原因:

1)函数递归调用的深度过深,函数一直没返回,栈空间一直没有释放;

2)消息上触发函数的死循环调用,函数始终退不出来,函数的栈空间释放;

3)定义了一个占用内存很大的局部变量

4)函数中使用switch...case语句,包含了大量的case分支,每个case分支中都定义了局部变量,导致当前函数占用了大量的栈空间。

       对于switch...case...语句中有多个case分支的情况,case分支中的局部变量的声明周期是在case分支中的,即代码运行到对应的case分支中时该分支中的局部变量才有“生命”,但其实这个局部变量的栈空间已经在函数入口处分配好栈空间了,并不是代码执行到case子句中才分配栈空间的。这点可以通过编写测试代码,查看函数入口处给当前函数分配栈空间的汇编代码就能看出来了,可以先顶一个变量查看汇编代码看看分配了多少栈空间,然后再增加一个变量,看看分配的栈空间是否变大。

4、内存泄露

       当代码中有内存泄露,当将所在进程的内存耗尽时,就会出现“run out of memory”的崩溃:

C++程序运行过程中发生异常闪退,很有可能是这三个原因导致的_第4张图片

       和GDI泄露类似的,有可能是某一块的代码有内存泄露,在执行某些操作时才会执行有内存泄露的代码,才会触发内存泄漏。同样,何时会导致“run out of memory”的崩溃,与泄露的程度、运行的时长有直接的关系。

       对于32位程序,系统会给该进程分配4GB的虚拟地址空间,一般是2GB的用户态内存和2GB内核态的内存。随着泄露的内存越来越多,将4GB的虚拟地址空间耗完了,就会出现“run out of memory”的崩溃了。

       发现和排查内存泄露,也GDI泄露的处理方式也是类似的。先通过查看资源管理器中的目标进程的内存占用情况,一般情况下,程序正常运行时只会占用几百MB的内存,如果在资源管理器中发现目标进程的内存都涨到1GB以上,并且长时间处于1GB以上的占用,且不会回落,那大概率是有内存泄露了。

       现在有很多内存泄露检测工具,比如BoundsChecker,但是很多已经过时了,不能使用了。Windows下主要用Windbg调试器去排查,Linux下主要使用Valgrind内存检测工程。关于windbg如何排查内存泄露,参看我的这篇文章:

使用Windbg定位Windows C++程序中的内存泄露https://blog.csdn.net/chenlycly/article/details/121295720https://blog.csdn.net/chenlycly/article/details/121295720

你可能感兴趣的:(软件运行报错异常分析,C++,异常闪退,GDI对象泄露,线程栈溢出,内存泄露)