引发C++程序内存错误的常见原因分析与总结

目录

1、概述

2、变量未初始化

2.1、变量未初始化的场景说明

2.2、对0xcccccccc、0xcdcdcdcd和0xfeeefeee等常见异常值的辨识度

3、空指针与野指针

3.1、空指针

3.2、野指针

4、线程栈溢出

5、内存越界

6、内存泄漏

7、堆内存被破坏

8、内存访问违例

8.1、访问64KB小地址内存区

8.2、用户态的代码访问了内核态的内存地址 

8.3、代码中访问了不该访问的地址,是否一定会触发访问违例?

9、最后


VC++常用功能开发汇总(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/124272585C++软件异常排查从入门到精通系列教程(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/125529931       在C++程序中,大部分程序运行异常都是内存问题引起的,内存问题也是最让C++程序员头疼的事情。分析C++程序中的各种内存问题,既需要了解引发内存错误的常见原因,也需要有一定的软件调试经验。今天在这里给大家系统全面地总结一下引发内存异常的常见原因,以供大家借鉴和参考。

1、概述

       本文的内容是隶属于C++软件调试与异常排查方面的内容。之前在公司内部做C++软件调试与异常排查专题技术培训时,从刚毕业的C++新手到工作七八年及以上的老程序员,反应都比较强烈,都表示很有价值。通过沟通交流及对身边同事的观察发现,很多C++开发人员在软件调试及异常排查方面都比较欠缺,再遇到异常问题时排查的效率可能会比较低,甚至会直接影响工作效率和项目进度!所以后来决定推出成体系的C++软件调试与异常排查技术专栏,希望能有效地解决大家的痛点问题!专栏的链接如下:

C++软件调试与异常排查从入门到精通系列汇总https://blog.csdn.net/chenlycly/article/details/125529931       C++程序的内存异常或错误,是C++软件调试与异常排查的主体内容之一,借此机会给大家系统地介绍一下引发内存异常或错误的常见场景及原因。C++内存异常或错误一般是变量未初始化、空指针、野指针、线程栈溢出、堆内存被破坏、虚拟内存不足、内存越界、内存访问违例、内存泄漏等原因引发的,大体如下:

下面将对这些场景进行一一介绍。

2、变量未初始化

       这是一个老生常谈的问题,我们一再强调要定义变量之后一定要对变量进行初始化。程序中访问了未初始化的变量可能会导致不可预料的错误,轻则导致程序的运行逻辑出问题,重则会导致程序崩溃或闪退。

2.1、变量未初始化的场景说明

       变量未初始化引发的问题场景,主要分两种:

1)变量一直没有初始化

       这种场景排查起来比较简单,就是访问了一直没初始化的变量引发的,问题可能是必现的。

2)变量有初始化,但因为代码运行时序,在部分场景下还是访问了未初始化的变量

       这种场景具有一定的掩蔽性。我们可能将目标变量初始化的代码封装到一个函数中,即在某个函数中对代码进行了初始化。但在部分场景下,代码运行到另一个函数中,函数中访问了该变量,并且初始化该变量的代码还没有执行到。这样代码中就访问未初始化的变量,引发了异常。这一般是由不同场景下代码执行的先后时序(函数执行的先后顺序)不同导致的,这类问题我们在项目中遇到不止一次了。

       举一个项目中的问题实例,我们在CRtcFullVideoWndUI类中定义了一个CSmallVideoWndUI*类型的成员指针变量m_pBigVideoChildWnd:

CSmallVideoWndUI* m_pBigVideoChildWnd;

然后将对m_pBigVideoChildWnd指针变量的初始化代码封装到CRtcFullVideoWndUI::UpdateRtcBigVideo函数中:

void CRtcFullVideoWndUI::UpdateRtcBigVideo()
{
        ::SetParent(m_pToolBarWnd->GetHWND(), m_hWnd);
        ::SetParent(m_pStatusBarWnd->GetHWND(), m_hWnd);

        ::DestroyWindow(m_pBigVideoChildWnd->GetHWND());
        m_pBigVideoChildLay->SetAutoDestroy(false);
        m_pBigVideoChildLay->RemoveAt(0);
        m_pBigVideoChildLay->SetAutoDestroy(true);

        // 初始化成员指针变量m_pBigVideoChildWnd的代码,封装在当前函数中
        m_pBigVideoChildWnd = new CSmallVideoWndUI();
        m_pBigVideoChildWnd->SetBigVideo(true);
        m_pBigVideoChildWnd->SetWndColor(0xFF000000);
        m_pBigVideoChildWnd->SetFixedHeight(m_pRtcVideoParentLay->GetBigVideoHeight());
        m_pBigVideoChildWnd->SetFixedWidth(m_pRtcVideoParentLay->GetBigVideoWidth());
        m_pBigVideoChildWnd->SetName(_T("SmallVideoWnd"));

       // 省略部分代码
}  

        在实际测试中发现,程序会崩溃在CRtcFullVideoWndUI::OnUpdateBigVideoCursor接口中:

bool CRtcFullVideoWndUI::OnUpdateBigVideoCursor(WPARAM wParam, LPARAM lParam, bool & bHandled)
{
    ::PostMessage(m_pBigVideoChildWnd->GetHWND(), WM_LBUTTONUP, 0, 0);
    ::PostMessage(m_pBigVideoChildWnd->GetHWND(), WM_MOUSELEAVE, 0, 0);
    bHandled = true;
    return true;
}       

经分析发现是访问了未初始化的指针变量m_pBigVideoChildWnd引起的。在某些场景下,代码运行进CRtcFullVideoWndUI::OnUpdateBigVideoCursor函数中,初始化指针变量m_pBigVideoChildWnd的接口CRtcFullVideoWndUI::UpdateRtcBigVideo还没执行到,所以访问了未初始化的指针变量m_pBigVideoChildWnd。

       为了解决这个问题,需要在CRtcFullVideoWndUI类的析构函数中就将指针变量m_pBigVideoChildWnd置为NULL:

CRtcFullVideoWndUI::CRtcFullVideoWndUI(STRINGorID strUI, unsigned int dwTransparent /*= 255*/)
   : CAppWindow(strUI, dwTransparent)
   , m_pRtcVideoLay(NULL)
   , m_bFullScreen(false)
   , m_pBigVideoChildWnd(NULL)  // 初始化变量
{
}

然后在CRtcFullVideoWndUI::OnUpdateBigVideoCursor接口中访问m_pBigVideoChildWnd之前判断一下m_pBigVideoChildWnd是否为NULL,如下所示:

bool CRtcFullVideoWndUI::OnUpdateBigVideoCursor(WPARAM wParam, LPARAM lParam, bool & bHandled)
{
    if ( m_pBigVideoChildWnd != NULL )
    {
        ::PostMessage(m_pBigVideoChildWnd->GetHWND(), WM_LBUTTONUP, 0, 0);
        ::PostMessage(m_pBigVideoChildWnd->GetHWND(), WM_MOUSELEAVE, 0, 0);
    }
        bHandled = true;
        return true;
}

        所以对于C++中的成员变量的初始化,一般在构造函数中就初始化。这个异常实例,之前已整理成一篇文章,感兴趣的话,可以去看看:

0xcdcdcdcd异常值引发C++程序崩溃问题的详细分析https://blog.csdn.net/chenlycly/article/details/128380751

2.2、对0xcccccccc、0xcdcdcdcd和0xfeeefeee等常见异常值的辨识度

       我们在调试程序的过程中,当遇到变量的值为0xcccccccc、0xcdcdcdcd和0xfeeefeee等特殊的异常值时,我们应该察觉出可能是程序出问题了。常见的异常值如下所示:0xcccccccc、0xcdcdcdcd和0xfeeefeee等常见异常值如下所示:

* 0xcccccccc : Used by Microsoft's C++ debugging runtime library to mark uninitialised stack memory
* 0xcdcdcdcd : Used by Microsoft's C++ debugging runtime library to mark uninitialised heap memory
* 0xfeeefeee : Used by Microsoft's HeapFree() to mark freed heap memory
* 0xabababab : Used by Microsoft's HeapAlloc() to mark "no man's land" guard bytes after allocated heap memory
* 0xabadcafe : A startup to this value to initialize all free memory to catch errant pointers
* 0xbaadf00d : Used by Microsoft's LocalAlloc(LMEM_FIXED) to mark uninitialised allocated heap memory
* 0xbadcab1e : Error Code returned to the Microsoft eVC debugger when connection is severed to the debugger
* 0xbeefcace : Used by Microsoft .NET as a magic number in resource files

       对于0xcccccccc和0xcdcdcdcd,在Debug模式下,VS会把未初始化的栈内存全部填充成0xcccccccc,当成字符串看就是“烫烫烫烫……”;VS会把未初始化的堆内存全部填充成0xcdcdcdcd,当成字符串看就是“屯屯屯屯……”。这两类特殊的字符串,很多人应该都见到过。
如果在Debug调试时遇到有变量的值为0xcccccccc或0xcdcdcdcd,一般是变量没有初始化引起的。

       对于0xfeeefeee,是用来标记堆上已经释放掉的内存,即已经释放的堆内存会被填充成0xfeeefeee。注意,如果指针指向的内存被释放了,指针变量本身的地址是没做改动的,还是之前指向的内存的地址,只是指向的堆内存会被填充成0xfeeefeee。

       关于0xfeeefeee的问题实例,可以参看我之前的文章:

排查软件关闭时访问了0xfeeefeee内存地址导致内存访问违例的崩溃https://blog.csdn.net/chenlycly/article/details/125267046

3、空指针与野指针

       指针在C++程序中会频繁地使用,C++程序中到处都能看见指针变量的身影。线程的栈空间是有上限的,程序中使用的大部分内存都是堆内存,用堆内存地方一般都会涉及到指针。在使用指针时的错误,最常见的就是空指针和野指针错误了。

       不管是空指针还是野指针,都会触发内存访问违例,即访问了不该访问的内存(可能是读取目标内存地址中的内容,也可能是向目标内存地址写入内容),触发软件异常。

3.1、空指针

        比如如下的结构体,用结构体定义一个空指针:

// 设备信息结构体
struct TDeviceInfo
{
char achDeviceId[64];   // 设备id
char achDeviceName[64]; // 设备名称
int nDevType;            // 设备类型
};

// 用结构体定义了一个空指针
TDeviceInfo* pDevInfo = NULL;

空指针本身没问题,但我们通过空指针去访问指针对应的结构体或类的数据成员时就会出问题。对于空指针,其指向的结构体或类对象的首地址为NULL(0),如果要通过该空指针去访问对应的结构体或类的数据成员时,数据成员的首地址就是相对其对象首地址的偏移,因为空指针对应的对象的首地址为NULL,所以通过空指针访问的数据成员的首地址就是0+offset,这样的地址就个很小的地址。

       这个很小的内存地址,一般是小于64KB的,对于Windows系统,会专门在进程的虚拟地址空间中预留从0地址开始的64KB的小地址内存区域。该64KB小地址内存区,是专门用来帮助程序员定位空指针问题的,这个地址范围是禁止访问的,既不能读,也不能写,一旦访问到该区域,就会触发内存访问为例,系统会强行将进程终止掉。

       当我们在用Windbg分析软件异常崩溃时,我们如果看到发生异常崩溃的那条汇编指令中访问了小地址内存区,一般可能是空指针引起的。

       这里有个有趣的问题,我们直接对一个空指针执行delete操作是否会产生崩溃呢?答案是否定的,底层会去判断地址是否为空,如果为空,则不做操作。

3.2、野指针

       一般野指针是指指向已经被释放的内存地址的指针。指针最开始被赋了一个有效的堆内存地址,但后面将指向的内存地址释放了,但没有将指针变量置为空,这时的指针就是野指针。比如:

char* p = NULL;
p = (char*)malloc(10); // 申请内存
free(p);                // 释放内存

// 上面将指针p指向的堆内存释放掉,但没有将指针p置为NULL,所以
// 此时的指针p就是野指针了     

如果通过野指针去访问其指向的已经释放内存的地址,则可能会导致内存访问违例。去访问已经释放的内存,一定会触发内存访问违例吗?答案是否定的,不一定,你要访问的内存地址能不能访问,这取决于系统,系统允许你访问,就能访问;系统不允许你访问,就会触发内存访问违例。

       当我们对一个野指针进行delete操作,一般会有问题,因为同一段堆内存不能释放两次,第二次释放会崩溃。

4、线程栈溢出

      用户创建线程时,系统会给每个线程分配指定大小的栈空间,Windows系统中默认分配1MB栈空间,Linux系统中默认会分配2MB的栈空间。所以,一个线程的栈空间是有上限的。

       函数中的局部变量是在栈上分配的,函数调用时传递的参数是通过栈传递的。某个线程在某个时刻占用的栈空间大小,等于当前线程函数调用堆栈中所有函数占用的栈空间的和。

    如果一个结构体定义的比较大,比如大于1MB,如果直接在函数中用该结构体定义一个局部变量,则会直接导致线程栈溢出,因为当前线程的栈空间上限就是1MB。如果函数递归调用过深,递归调用的函数占用的栈空间一直没有释放,也可能会导致线程栈溢出。

      引发线程栈溢出问题可能有以下几个可能:

1)函数递归调用的深度过深
       因为一直在递归调用,在到达最底下的那层调用之前,递归函数一直没返回,栈空间一直没有释放,导致当前线程占用的栈空间越来越多,达到上限。
2)消息上触发函数的死循环调用
       消息触发的函数死循环调用,因为死循环调用了,函数的栈空间一直没释放,导致当前线程占用的栈空间越来越多。这个问题我们在实际项目中遇到过两次。
3)定义了一个占用内存很大的局部变量
       比如定义了一个很庞大的结构体,在一个函数中用该结构体定义了一个局部变量,假设该结构体接近或者大于1MB,则会直接导致线程栈溢出。
4)函数中使用switch...case语句,包含了大量的case分支
       每个case分支中都定义了局部变量,导致当前函数占用了大量的栈空间。case分支中的局部变量的生命周期是在case分支中的,即代码运行到对应的case分支中时该分支中的局部变量才有“生命”,但其实这个局部变量的栈空间已经在函数入口处分配好栈空间了,并不是代码执行到case子句中才分配栈空间的。这点可以通过编写测试代码,查看函数入口处给当前函数分配栈空间的汇编代码就能看出来了,可以先顶一个变量查看汇编代码看看分配了多少栈空间,然后再增加一个变量,看看分配的栈空间是否变大。

       对于Stack Overflow线程栈溢出,如果在用Visual Studio调试代码时遇到,Visual Studio会立即退出调试,所以,Visual Studio中在遇到此类异常时看不到函数调用堆栈,也就不好定位问题了。可以使用Windbg去动态调试目标进程,一旦Windbg感知到Stack Overflow线程栈溢出的异常,就会中断下来,此时使用kn/kp/kv这些命令(选一个)就可以插上查看异常发生时的函数调用堆栈,通过函数调用堆栈就能定位到出问题的函数了。

5、内存越界

        所谓内存越界是指在操作某段内存时超过了该段内存的范围,越界到该段内存的前面或后面去了。C++程序占用的内存区域,一般栈内存区、堆内存区、全局内存区、常量内存区和代码内存区:

其中主要看栈内存区、堆内存区和全局内存区。

       根据越界的内存类型,内存越界分栈内存越界、堆内存越界和全局内存越界,这三类内存越界,我们在实际项目中都遇到过。

       此外,根据越界操作是读还是写,可以分为读内存越界和写内存越界。其中读内存越界危害要小一些,最多是读了不该读的内存,引发内存访问违例。写内存越界的危害要大很多,直接窜改了被越界的内存中的内容,比如越界的是其他变量的内存,就是直接篡改了其他变量的值,导致代码逻辑运行出异常,甚至触发程序发生崩溃。

       一般的内存越界是由memset、memcpy等操作(操作时内存的长度控制的不对)触发的,也有可能是通过数组下标访问数据时越界了。大部分内存越界是向后越界(向后面的大地址越界),也有少部分越界到目标内存的前面的,比如通过数组下标访问连续内存块,因为代码缺陷导致了访问小于0的数组下标,比如a[-1],这个场景我们在项目中也遇到过。相关问题实例,可以参见我之前的文章:
C++堆内存错误:C运行时库检测到向堆内存头部写入了内容https://blog.csdn.net/chenlycly/article/details/121800292       还有一种越界比较隐蔽,在调用函数时传入的是引用或者地址,被调用函数中发生越界,直接越界到主调函数的栈内存上。这个问题我们在项目中也遇到过几次。比如在主调函数中用如下的结构体定义了一个局部变量:

// 1、定义结构体TDeviceInfo
struct TDeviceInfo
{
    char achDeviceId[64];   // 设备id
    char achDeviceName[64]; // 设备名称
    int nDevType;            // 设备类型
};

// 2、用结构体定义一个局部变量,调用GetDevInfo接口
TDeviceInfo tDevInfo;
// 调用GetDevInfo接口获取设备信息
GetDevInfo(tDevInfo);

// 3、GetDevInfo函数实现,接口的参数类型是引用,用来传出数据:
BOOL GetDevInfo(TDeviceInfo& tDevInfo)
{
     memcpy( &tDevInfo, ..., ...); // 因为一些原因,这里的memcpy操作发生了内存越界
}

传入的TDeviceInfo结构体引用被越界,这个引用变量对应的内存是在主调函数的栈内存(在主调函数中用TDeviceInfo结构体定义的局部变量)上,所以直接越界到主调函数的栈内存了。

       如果发现程序运行过程中某个变量的值被篡改了(变成了一个不可能的值),则可能是内存越界篡改的。可以在该变量上设置数据断点,对该变量进行监控,一旦该变量的内存被篡改,调试器就会中断下来,查看此时的函数调用堆栈就可以知道是何处代码越界了。

       关于数据断点的使用,可以参见我之前的文章:

巧用Visual Studio中的数据断点去排查C++内存越界问题https://blog.csdn.net/chenlycly/article/details/125626617      关于内存越界的详细说明,可以参见我之前写的文章:

内存越界一定会导致程序崩溃吗?详解内存越界https://blog.csdn.net/chenlycly/article/details/126442611       上面还讲到了堆内存越界,关于堆内存越界的内容,会涉及到堆内存被破坏的问题,下面会有详细讲述,此处就不再赘述了。

6、内存泄漏

       内存泄漏一般指的是堆内存的泄漏,申请的一段堆内存在使用完成后没有释放,这样就造成了内存泄漏。如果有内存泄漏的一段代码,被频繁地执行,则会导致大量的内存被泄漏,可能会导致程序的内存被耗尽,产生Out of memory的崩溃。

       内存泄漏的典型症状是内存一直在持续的增长,可以到任务管理器中观察目标进程的内存变化情况以及变化趋势:

       为什么在内存泄漏的情况下会导致内存被耗尽呢?以一个32位程序为例,系统会给一个32位进程分配4GB的虚拟内存空间,其中2GB划拨给用户态,2GB划拨给内核态,一般代码都是运行在用户态中的,占用的内存也是用户态的,当用户态的内存因为有内存泄漏被占用的越来越多,就会达到用户态内存的上限2GB,就会导致Out of memory内存耗尽的异常。

       关于进程用户态和内核态虚拟内存的划分,可以参见《Windows核心编程》一书中的截图:

       而64位程序就基本没有内存耗尽的问题,因为64位进程的虚拟地址空间比32位进程要大的多。服务器程序一般是64位的,因为服务器操作系统是可以固定的,可以定制64位操作系统。但对于客户端程序,一般要做成32位的,因为要兼容32位和64位的Windows系统(64位程序没法在32位系统上运行的),虽然现在普遍使用的Win10都是64位的,但少数老的系统是32位的,比如有的Win7系统就是32位的。

       对于有些比较耗内存的程序,做成32位的,在内存占用方面的压力是比较大的,此时可以尝试将程序拆成多个进程,这样就可以减少内存占用了。我们常用的chrome浏览器使用的就是多进程模式,chrome浏览器启动后任务管理器中就会看到多个进程。

       使用多个进程模式的好处有不少:

1)可以将容易崩溃的模块分拆到另一个进程中,这样发生崩溃时,就不会影响到主进程。子进程发生崩溃后,可以自动将之重新启动起来。
2)可以减少主进程对内存的占用。将部分任务分拆到另一个进程中,减少主进程对内存的占用。
3)可以将一些杂七杂八的活,交给子进程去做,减少主进程处理事务的压力。

使用多进程模式虽然有很多好处,但多进程之间的通信与协调的难度要大很多。

       关于如何使用Windbg去排查内存泄漏问题,可以参看我之前的文章:

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

7、堆内存被破坏

       用户申请一段堆内存时,系统会在用户申请的内存的前后区域增加一部分额外的内存,用来存放堆内存的头信息和尾信息,大概如下:

系统正是通过这些头信息和尾信息来管理这些堆内存块的。但我们去释放堆内存时,系统会去读取这块堆内存的头尾信息,去做对应的处理。

       上面讲的内存越界,其中有一种就是堆内存越界,堆内存越界是很危险的,可能篡改了当前堆内存的尾信息,也可以篡改了相邻堆内存块的头信息,会导致后续释放堆内存和申请堆内存时出异常。当程序中发生堆内存越界,导致堆内存被破坏,会导致程序莫名其妙的崩溃,从崩溃堆栈上看,可能一会崩溃在申请堆内存时,可能一会崩溃在释放堆内存时,程序处于胡乱崩溃的状态。

       堆内存被破坏,将非常难查,比栈内存越界要难查许多。一般只能注释代码,注释模块,逐步缩小排查范围。

8、内存访问违例

       上面很多内存错误,都会触发Access Violation内存访问违例。访问了系统禁止访问的内存区域,就会触发内存访问违例,如下:

有几个典型的场景一定会触发内存访问违例,比如访问了64KB小地址内存区、用户态的代码访问了内核态的内存地址等。

       内存访问违例分读内存访问违例和写内存访问违例。

8.1、访问64KB小地址内存区

       64KB的小地址内存区是禁止访问的,一旦访问,就会触发内存访问违例。在Windows系统中,会专门在进程的虚拟地址空间中预留从0地址开始的64KB的小地址内存区域,如下所示:

该64KB小地址内存区,是专门用来帮助程序员定位空指针问题的,这个地址范围是禁止访问的,既不能读,也不能写,一旦访问到该区域,就会触发内存访问为例,系统会强行将进程终止掉。

       当我们在用Windbg分析软件异常崩溃时,我们如果看到发生异常崩溃的那条汇编指令中访问了小地址内存区,一般可能是空指针引起的,如下:

8.2、用户态的代码访问了内核态的内存地址 

       用户态的代码是禁止访问内核态的内存地址,一旦访问,就会触发内存访问违例。在Windows系统中,系统会给一个32位进程分配4GB的虚拟内存空间,其中2GB划拨给用户态,2GB划拨给内核态,一般程序的代码都是运行在用户态中的,使用的是用户态的内存;系统内核代码是运行在内核态的,使用的是内核态的内存。处于安全考虑,用户态的代码是禁止访问内核内存地址的。

       当我们在用Windbg分析软件异常崩溃时,我们如果看到发生异常崩溃的那条汇编指令中访问了一个内核态的内存地址,此时肯定是内存访问违例的异常。

8.3、代码中访问了不该访问的地址,是否一定会触发访问违例?

       比如某个指针变量的值被篡改,我们通过该指针变量访问了一个不该访问的内存地址,是否一定会触发内存访问违例呢?答案是否定的,不一定会触发内存访问违例,这要看系统的“脸色”。系统允许你访问,就能访问;系统不允许你访问,就会触发内存访问违例。

9、最后

       以上内容都是根据项目中遇到的多个异常问题的排查经历总结和整理出来的,具有较大的实战参考价值,希望能帮到大家,给大家提供一个较全面的借鉴或参考。

猜你喜欢

转载自blog.csdn.net/chenlycly/article/details/128599525