引发C++异常的常见原因(一)从报错地址到错误症状

在进行C++软件开发的过程中,会遇到很多问题,网上差不到,或者查到了也没什么信息可以用,所以这里想到了就将一些常见的问题放在一起,归纳整理一下。

本文主要的内容来源于CSDN的大佬文章:https://blog.csdn.net/chenlycly/article/details/125529931 ,我主要是做个笔记

常见问题

1.变量未初始化

有的时候,有的编译器对Debug下会自动对一些变量初始化,但在Release下不会对变量进行初始化,此时变量的值就是内存地址中的随机值。

对于微软C++编译器,在Debug下,未初始化的栈内存会被编译器初始化为OxCCCCCCCC,未初始化的堆内存会被编译器初始化 成OxDDDDDDDD,已经释放的堆内存中会被设置为OxFEEEFEEE,这些特殊的异常值说明如下:一(这些异常值标识符中不区分大小写)

常见的异常值如下:

*Oxcccccccc : Used by Microsofts C++ debugging runtime library to mark uninitialised stack memory.

*Oxcdcdcdcd : Used by Microsoft's C++ debugging runtime library to mark uninitialised heap memory.

*0xfeeefeee : Used by Microsoft's HeapFree() to mark freed heap memory.

Oxddddddd: Used by MicroQuil's SmartHeap and Microsoft's C/C++ debug free() function to mark freed heap memory.

*Oxabababab : Used by Microsoft's HeapAlloc() to mark "no man's land" guard bytes after allocated heap memory.

*Oxabadcafe : A startup to this value to initialize all free memory to catch errant pointers.

*Oxbaadf00d : Used by Microsoft's LocalAlloc(LMEM_FIXED) to mark uninitialised allocated heap memory.

Oxbadcab1e : Eror Code returned to the Microsoft eVC debugger when connection is severed to the debugger.*Oxbeefcace : Used by Microsoft .NET as a magic number in resource files.

常见异常值说明:

1.1、异常值0xcccccccc和0xcdcdcdcd:

对于0xccccccc和0xcdcdcdcd,在debug模式下,Visual Studio会把未初始化的栈内存全部填充成Oxcaccc,当成字符串看就是烫烫烫烫……";Visual Studio会把未初始化的堆内存全部填充成0xcdcdcdcd,当成字符串看就是“屯屯屯中...….。这两类特殊的字符串,很多人应该都见到过。

那么调试器为什么要这么做呢?Visual Studio在debug 下把未初始化的内存自动填充成0xccccccc或0xcdcdcdcd,而不是随机值,是为了方便我们快速识别出问题,如果内存中出现这两个值,很可能就是因为变量没有初始化导致的。如果内存中的值是随机的,那么每次调试程序时就可能出现不一样的内存值,比如这次程序崩掉,下次却能正常运行,这样显然对我们排解bug是非常不利的。

1.2、异常值Oxfeeefeee

对于0xfeeefece,是Debug下用来标记堆上已经释放掉的内存,即已经释放的堆内存中会被填充成0xfeeefeee。注意,如果指针指向的内存被释放了,指针变量本身的地址是没做改变的,还是其之前指向的内存的地址,只是其指向的堆内存中被填充成Oxfeeeieee。如果该指针是一个类的成员变量,并且该类对象是在堆上分配内存的,则该类对象的堆内存被释放后(对于C++类,通常是执行delete操作),类对象中的指针变量就会被赋值为0xfeeefeee。

1.3、异常值Oxdddddddd

对于Oxdddddddd,是Debug下用来标记堆上已经释放掉的内存,即已经释放的堆内存会被填充成Oxdddddddd。Oxfeeefeee也是Debug下用来填充已经释放的堆内存,但0xdddd和Oxfeeefee的使用场景应该是有区别的,具体区别我也不太清楚,仅从上面的说明文字中没法区分。

之前在项目问题中看到的基本都是Oxfeeefeee,没见到过Oxdddddddd,但前段时间在Debug下调试代码时遇到了,代码中访问了已经释放的内存,内存中都被置为Oxdddddddd,这还是第一次遇到Oxdddddddd异常值!正是通过Oxddddd的说明,得知这个0xdddddddd是用来填充已经释放的堆内存,以这个为线索,快速地定位了问题!

tips:关于VS中Debug和Release模式下变量初始值问题

大家应该都遇到过,Visual Studio编译出来的程序,在debug下和release下运行效果不一样的情况(表现出不同的现象),可能的原因之一,就是与两个模式下的变量初始化有直接的关系。对于未人为初始化的变量,debug下会将栈内存填充成Oxccccc,会把堆内存填充成Oxcdcdcdcd,即debug下会被自动初始化;但release下变量不会被初始化,变量的值会是随机的,是分配内存时内存中“残留的值”,所以引发了程序在debug和release下的不同表现。

如果你的程序中的某个变量没被初始化就被访问,有可能出现如下的异常:

  1. 未初始化的变量用作控制变量时,可能会导致业务流程和代码走向不一致;

  2. 未初始化的变量用作数组下标时,会导致数组越界,引发程序崩溃;

  3. 未初始化的C++类指针,可能会导致内存访问违例,引发程序崩溃。

2.死循环

一般是for或者while循环的控制条件没有想清楚出现的问题,也可能是出发的函数调用上出现了死循环。死循环会导致线拥塞,这个原因其实大家都看得出,如果某个应用程序占用异常大的内存空间或者线程直接处于卡死的状态,基本上就可以确定是进入了死循环了。判断死循环需要一定的经验。

这里只聊常见问题,不谈解决方案。解决方案在别的文章中

3.内存越界

这个大家其实都懂,就是指操作变量内存的时候,超过了该变量的内存范围,访问了一些不该访问的地方,越界到变量内存以外的虚空地址去了。内存越界不一定导致应用程序崩溃,但是会导致各种各样的问题。

内存越界包含 栈内存越界、堆内存越界以及全局内存越界。

  1. 函数的局部变量是栈上分配内存的,对栈内存的越界称之为栈内存越界;

  2. 通过new和malloc等动态申请内存的,都是在堆上分配的,对堆内存的越界称之为堆内存越界。

  3. 全局变量和static静态变量都在全局内存上分配内存的,对全局内存的越界称之为全局内存越界。

对于栈内存越界,有可能越界到当前函数的其他局部变量上,另外在函数调用时,主调函数的返回地址、主调函数的ebp栈基址、用于esp校验的cookie值等都存在栈上的,有可能会越界到这些内存区域上,如果内存越界将这些内存破坏掉了,则会引起比较致命的错误。

比如篡改了主调函数的返回地址,那么等被调函数返回时,要执行主调函数返回地址处的汇编代码,但这个返回地址被篡改了,是有问题的,所以程序就"跑飞"了,会产生莫名其妙的崩溃。

主调函数的ebp栈基址是被用来回溯函数调用堆栈的,如果主调函数的栈基址被破坏,则会导致崩溃时无法回溯出函数调用堆栈,即出现崩溃时看不到有效的函数调用堆栈了。

内存越界常见的表现形式有数组越界,操作指针指向的buffer越界等。

一般都是对这些内存操作时,超过了内存的范围,主要是向后越界,可能是通过数组下标操作数组或buffer的内存,下标超过了申请内存的最大长度。

以前我们遇到一种向前越界的情况,我们使用数组下标操作一段buffer内存,结果出现了下标为-1的情况,比如sZBuf[-1],这样就越界到szBuf buffer的前面去了。

其中有一种情形下的越界(被调用函数越界越到主调函数的栈内存上),很具有隐蔽性,排查起来比较困难。

比如A库依赖B库,B库定义了结构体Struct1,A库调用了B库的GetData接口,GetData接口是Struct1结构体作为参数的(传入的结构体对象引用或地址),GetData函数内部进行了数据的memcpy操作。因为库发布的问题,导致两个库版本不一致,假设A库是老版本,B库是新版本。新版本B库中在Struct1结构体中新增了字段(使用了新版本的结构体),但是A库中使用的还是老的结构体,这样在调用GetData传入结构体地址或引用,由于GetData中进行了memcpy操作导致内存越界:

点击查看代码
// 1、A库中的代码:
Struct1 st1; // A库使用的是老版本的结构体,
GetData(&st1);
 
// 2、B库提供的GetData接口,传入的参数是结构体引用
void GetData( out Struct1& st )
{
    // 假设B库中定义的是一个全局变量g_st,即:Struct1 g_st,该变量存的是B库中的信息
    // 把全局变量g_st中的信息拷贝到st中,传出去(参数st是传出参数)    
    memcpy(&st, &g_st, sizeof(st));
}

因为B库在编译时使用了新的Struct1结构体(结构体末尾新增了一个成员字段),所以memcpy中的sizeof(st)是新的结构体字段,所乂memcpy执行内存拷贝时的内存操作长度是新结构体的长度。但Getdata传入的是引用,所以memcpy的目标内存是在A模块的主调函数的栈内存上,而A模块中的主调函数传入的结构体对象用的是老的结构体(没有新增字段),所以GetData中产生的内存越界直接越界到立于A模块中的主调函数的栈内存上了。可能篡改了主调函数中其他栈变量的内存或者其他信息,可能会导致代码出现unexpected不可预明、不可解释的异常运行行为。这样的问题我们已经遇到过多次了。这类问题比较有隐蔽性,如果没有经验,排查起来会很困难。

这个问题有点像我之前出现的那个关于内存的所有权问题,在这个问题非常隐蔽,我也是花了两三天才找到这个问题。

典中典之内存泄漏

内存泄漏是指程序中通过new、malloc动态申请的堆内存在使用完后没有释放,长时间频繁执行这些没有释放堆内存的代码,会导致程序的内存会逐渐被消耗,程序运行会变慢,直到内存被耗尽(Out of memory),程序闪退。程序闪退时,系统会弹出如下的Out ofMemory的报错提示框:

image

不过内存泄漏的问题也好查,程序挂在那里,只要看到内存异常飞涨,那肯定是内存在泄露了。不过这个问题也很常见,之前维护的大飞老师的代码就天天泄漏。

排查方案后面再说,这里只说常见问题。

空指针和野指针

都有典中典的内存泄漏了,那空指针和野指针那相比也是家常便饭。

空指针和野指针是使用指针时两类很常见的错误,访问空指针和野指针都会导致内存访问违例,导致程序崩溃。

所谓空指针是指一个指针变量的值为空,如果把该指针变量的值(值为0)作为地址去访问其指向的数据类型,就会引发问题。

对于Windows系统,访问空指针会之所以会产生崩溃,是因为访问了Windows系统预留的64KB禁止访问的空指针内存区(即0-64KB这个区间的小地址内存区域),这是Windows系统故意预留的一块小地址内存区域,是为了方便程序员定位问题使用的。一旦访问到该内存区就会触发内存访问违例,系统就会强制将进程强制结束掉。

关于64KB禁止访问的小地址内存区域,在《Windows核心编程》一书中内存管理的章节,有专门的描述,相关截图如下所示:

image

比如一个C++指针变量值为空(NULL对应的值为0),如果通过该指针去访问其指向的类对象的数据成员,就会访问到64KB的小地址内存区,就会触发异常。因为会将指针变量中的NULL值作为C++对象的首地址,通过类数据成员的内存分布,C++类对象的数据成员的内存地址等于类对象的首地址加上一个ofiset偏移地址,这样该C++类对象的数据成员的内存地址比较小(小于64KB),要读该数据成员变量的值,就是对其内存地址进行寻址(从内存中读取内存中存放的内容),这样就访问了很小的内存地址,所以触发了内存访问违例。

所谓野指针,是指该指针指向的内存(指针变量中存放的值,就是其指向的内存的地址)已经被释放了,但还去访问该指针指向的内存,一般会导致内存访问违例,导致软件崩溃。还有一种情形是同一段堆内存被delete或free了两次,也会触发崩溃。

windows下,这段禁忌的空间是0x00000000到0x0000FFFF

内存访问违例

内存访问违例不仅是内存越界,或者说内存访问违例应该是内存访问违例的一部分。

image

内存访问违例包含读内存违例和写内存违例。比如上面讲到的Windows下的小内存地址(64KB内存区域)是禁止访问的,一旦访问就会触发内存访问违例,系统会强制将进程终止掉。再比如上面讲的内存越界,也会触发内存访问违例。

再比如系统出于安全考虑,用户态的模块是禁止访问内核态地址的,比如32位Windows程序,系统会分配4GB的虚拟地址空间,一般用户态和内核态各占2GB,用户态的内存地址是小于2GB的,如果我们通过一些分析软件发现发生崩溃的那条汇编指令中访问的内存地址大于2GB,则肯定是禁止访问内核态地址触发的内存访问违例,肯定是代码中把地址搞错了,访问了不该访问的地址。

栈内存被堆内存去释放

在栈上分配内存的C++类对象,是不能用delete去释放内存的,delete释放的是堆内存,否则会导致异常崩溃。

之前在使用一个框架库导出类ClassA(假设类名叫ClassA),在框架库内部的框架中会自动去delete这个类对象。但我们是在一个函数中使用该类定义一个局部变量(类对象),即:

点击查看代码
void Func()
{
    ClassA clsA;
    // ......
}

该类对象在栈上分配内存的,是不能用delete去释放的,应该调用接口给该对象设置不需要框架自动销毁。对于栈上分配内存的局部变量clsA,在函数退出时其占用的栈内存会自动释放。

线程栈溢出

单个线程的栈空间是有限的,比如Windows线程的默认栈空间大小是1MB,当然我们创建线程时也可以指定线程栈的大小,但一般不宜过大。

线程的栈空间是用来干嘛的呢?某个时刻某个线程实际使用的梯空间,等于当前线程函数调用堆栈中所有函数占用的栈空间总和。函数中的局部变量是在所在线程的栈内存上分配的,函数调用的参数也是通过栈内存传递(参数值入栈)给被调用函数的,这两点就是函数占用栈内存的主要对象。

一旦当前线程的调用堆栈中占用的总的栈空间超过当前线程的栈空间上限,就会产生stack overfiow线程栈溢出的异常,如下所示:
image

函数调用约定不一定导致栈不平衡

C++中常用的调用约定有_cdecl C调用、_stdcall标准调用、_fastcall快速调用。其中,_cdecl是C/C++默认的调用方式,C/C++运行时库中的函数都是_cdecl调用。_stdcall标准调用是Windows系统提供的系统API函数的调用约定。

函数的调用约定不仅决定着函数多个参数压入栈中的先后顺序,还决定了应该由谁来释放主调函数给被调函数传递的参数所占用的梯空间(是主调函数释放参数占用的栈空间,还是被调函数去释放参数占用的栈空间)。函数调用时栈分布如下:

image

对于由谁来释放栈空间,以常用的_stdcall标准调用约定和_cdecl调用约定为例,如果被调用函数是_stdcall标准约定,则由被调函数去释放传给被调函数的参数占用的栈空间。如果被调函数是_cdecl调用,则由主调函数去释放参数占用的栈空间。

关于谁来负责释放参数占用的栈空间,大家很容易混淆,给大家一个容易记住的办法。比如我们经常用到的C函数printf:

image

该函数支持多个可变参数的格式化,设定的是C调用约定,因为被调函数是无法知道传入了哪些参数,只有主调函数才知道传入了哪些参数,才知道传入参数占用的栈内存的大小,所以只能是主调函数去释放参数占用的栈内存。

函数调用约定引发的栈不平衡问题在设置回调函数时比较常见,特别是跨语言设置回调函数时。因为调用约定的不一致,可能会导致参数栈空间多释放了一次,会直接影响主调函数的ebp栈基址出错,导致主调函数中的内存地址错乱出现异常或崩溃。比如C#程序调用C++实现的SDK,因为C++语言中默认使用_cdecl C调用约定,C#默认使用_stdcall标准调用,如果回调函数没有显式地指明调用约定,在实际使用时就会出问题。

在Debug 下,Visual Studio默认开启了IRTC (Run-Time Check)运行时检测,如下:

image

image

image

库与库之间不匹配

因为一些原因,导致d库与dl库之间的版本不一致或不匹配,从而导致程序运行异常或崩溃。

比如底层的库只发布了Debug版本的库,忘记发布Release版本,导致Debug版本库与Release库混用,因为Debug与Release下的内存管理机制的不同会导致崩溃。Debug下申请内存时会额外分配一些用于存放调试信息的内存,而Release下是没有的。

再比如,底层库的API以文件发生了改动(比如结构体中新增或删减了若干字段),但只发布了库文件,忘记发布头文件,导致使用该底层库的上层库使用的还是老版本的头文件。即底层库是用新的头文件编译的,而上层库使用的是老版本头文件编译的,用到改动的结构体时在内存上就会有问题,上面的有个小节就提到这样的问题。

还比如,我们修改了头文件,但发布时有若干关联的库没有编译或者编译失败了,导致程序安装包中使用的还是之前的老版本的库,这样也会导致库与库之间的不匹配。

一般这类库的不匹配会触发内存上的问题,会让程序出现异常或崩溃,比如Debug下弹出如下的提示框:

image

死锁

这位更是重量级。

死锁一般发生在多线程同步的时候,比如线程1占用了锁A,在等待获取锁B,而线程2占用了锁B,在等待获取锁A,两个线程各不相让,在没有获取到自己要获取的锁之前,都不会释放另一个锁,这样就导致了死锁。我们需要做好多个线程间协调,避免死锁问题的出现。很多时候我们能够根据现象及相关的打印日志,初步估计出可能发生死锁的地方。

如果UI线程出现堵塞,或者是底层业务模块出现拥堵,业务出现异常,可能就是死锁引起的。可以将windbg挂在到目标进程上,查看所有线程的函数调用堆栈,确定发生死锁的是哪些线程,发生死锁的线程一般都会停留在WaitForSingleObject这个函数的调用上,比如这样的截图:

image

如图所示,当前线程卡在了WaitForSingleObject的函数调用上。通过函数调用堆栈,可以确定是调用了哪个函数触发的。

对于使用临界区的死锁,使用Windbg排查比较容易分析,因为临界区是属于用户态的,我们只需要使用为Windbg进行用户态的调试即可。如果是信号量等其他的锁,则要使用Windbg进行内核态的调试,内核态的调试则要复杂很多。

GDI对象接近一万可能会导致异常

image

当程序中有GDI对象泄露时,程序长时间拷机运行,可能就会出现GDI对象接近或达到1万个,导致GDI绘图函数调用出现异常,出现窗口绘制不出来等情况。

除了GDI泄漏会导致GDI总数达到系统上限,打开程序的多个窗口可能也会导致这个问题,比如之前我们用MFC做UI界面时,每个控件都是一个窗口,每个窗口都包含若干个GDI对象,这样导致一个聊天窗口就占用了200多个GDI对象。这样在测试同事做极限测试时,同时打开了好几十个聊天窗口,就出现了GDI对象达到或接近上万个的问题。这也是当时我们要将MFC界面库换成duilib界面库的原因之一。

对包含C++类成员的结构体进行memset操作

大家在使用结构体对象时,在使用结构体之前,都会习惯性地对结构体对象进行memset操作,但如果结构体中包含C++类时,是不能进行memset操作的,我们需要在构造函数中对结构体对象成员进行初始化。

对包含C++类的结构体对象进行memset操作导致的崩溃问题,我们已经遇到过几次了,特别是新人容易犯这样的错误。有的C++类除了有存放数据的成员,还有维护内部内存结构的字段,比如string类、CString类、st类等,如果对结构体对象进行memset操作,则会破坏维护内部内存结构的字段的内存,会导致不可预期的错误。

他在使用该结构体对象之前,习惯性对结构体对象进行了memset操作,问题就出在这个memset操作上了。memset操作破坏了st列表内部的内存结构,在我们读取这个列表中的数据时,stl内部抛出了异常,直接将当前函数余下的代码给跳过去了,导致本该执行到的代码没有执行,导致了程序逻辑上的异常。

上述结构体中已经在构造函数中对结构体的成员变量进行了初始化,在用结构体定义变量时就不需要额外初始化了。

posted @ 2023-11-23 14:57  轩先生。  阅读(225)  评论(0编辑  收藏  举报