目录
2、堆内存越界,如果破坏了被越界的堆内存区域的头部或尾部信息,会导致程序到处乱崩(崩溃在malloc/new或free/delete的代码处)
3、堆内存越界,越界到某个变量的内存上,篡改了变量的值,导致异常(此种越界发生在一段堆内存内部,并没有破坏这段堆内存前后的头部信息和尾部信息)
4、栈内存越界,导致栈上保存的函数调用时的返回地址,导致栈回溯出异常
5、堆内存越界破坏了堆内存头部或尾部信息导致程序胡乱崩溃的问题排查
C++软件异常排查从入门到精通系列教程(核心精品专栏,订阅量已达10000多个,欢迎订阅,持续更新...)
https://blog.csdn.net/chenlycly/article/details/125529931C/C++实战专栏(重点专栏,专栏文章已更新500多篇,订阅量已达10000多个,欢迎订阅,持续更新中...)
https://blog.csdn.net/chenlycly/article/details/140824370C++ 软件开发从入门到实战(重点专栏,专栏文章已更新300多篇,欢迎订阅,持续更新中...)
https://blog.csdn.net/chenlycly/category_12695902.htmlVC++常用功能开发汇总(专栏文章列表,欢迎订阅,持续更新...)
https://blog.csdn.net/chenlycly/article/details/124272585C++软件分析工具从入门到精通案例集锦(专栏文章,持续更新中...)
https://blog.csdn.net/chenlycly/article/details/131405795开源组件及数据库技术(专栏文章,持续更新中...)
https://blog.csdn.net/chenlycly/category_12458859.html网络编程与网络问题分享(专栏文章,持续更新中...)
https://blog.csdn.net/chenlycly/category_2276111.html 最近项目中遇到了一个软件到处胡乱崩溃的问题,每次崩溃的点不一样,崩溃时的堆栈不一样。经排查,是堆内存越界导致的,堆内存越界破坏了用于管理堆内存区域的头部信息或者尾部信息,导致代码后续执行到malloc/new或free/delete时产生了异常崩溃。今天借排查该问题的时机,结合以往排查的C++软件异常的多个案例,详细总结一下内存越界对崩溃时函数调用堆栈回溯的影响(包含程序到处乱崩,每次崩溃时的堆栈不一样),供大家借鉴或参考。
1、内存越界的分类
根据被越界内存的类型,一般可以将内存越界分为全局内存越界、堆内存越界和栈内存越界。其中,栈内存越界比较直接,排查起来要容易很多。堆内存越界,可能会引发程序到处胡乱崩溃,是C++最难排查的一类内存异常问题。
内存越界是C/C++软件的一种常见内存错误,关于其他的常见内存错误,可以查看我之前总结的文章:
引发C++程序内存错误的常见原因分析与总结
https://blog.csdn.net/chenlycly/article/details/139596707
对于内存越界,我们可以借助一些PC-Lint、TScanCode等静态代码分析工具去提前发现发生越界的点,但发生越界的代码具有很强的隐蔽性,有些问题代码可能这些静态分析工具也无法侦测到。我们可以使用AddressSanitizer和Valgrind等动态分析工具在程序运行过程中去动态监测,但AddressSanitizer和Valgrind只支持Linux平台。在Linux平台上,从gcc 4.8开始,gcc开始集成google的AddressSanitizer工具。

之前Windows平台上AddressSanitizer和Valgrind这类专用的动态内存检测工具,遇到内存越界问题时,排查起来会很吃力,特别是引发程序乱崩的堆内存越界,更难排查。不过好消息是,从Visual Studio 2019的16.9版本开始,集成AddressSanitizer:

但支持还不够全面,随着Visual Studio版本的迭代,会持续的深化支持。但集成AddressSanitizer的Visual Studio好不好用,还有待后面验证。
对于如何在VS中如何使用AddressSanitizer内存分析工具,可以看一下微软官方文章的详细说明:
在Visual Studio中使用AddressSanitizer
https://learn.microsoft.com/zh-cn/cpp/sanitizers/asan?view=msvc-170此处就不详细展开了,大家需要使用的话,可以去详细研究一下。
关于AddressSanitizer的详细介绍以及如何在Linux系统中使用AddressSanitizer,可以查看我的文章:
2、堆内存越界,如果破坏了被越界的堆内存区域的头部或尾部信息,会导致程序到处乱崩(崩溃在malloc/new或free/delete的代码处)
用户申请一段堆内存时,系统会在用户申请的内存的前后区域增加一部分额外的内存,用来存放堆内存的头信息和尾信息,大概如下:

系统正是通过这些头信息和尾信息来管理这些堆内存块的。但我们去释放堆内存时,系统会去读取这块堆内存的头尾信息,去做对应的处理。
堆内存越界,如果破坏了被越界的堆内存区域的头部或尾部信息,会导致系统对进程的堆内存管理出现问题,系统正是通过进程中每个堆内存区域的头信息和尾部信息,对进程中的堆内存进行管理的。
**当系统对进程的堆管理出问题后,代码中执行到malloc/new或free/delete操作可能就会发生异常。**malloc/new用来申请堆内存,free/delete用来释放堆内存,执行这些操作时都是向进程的堆内存管理模块发出操作请求,因为堆内存管理出现问题,堆内存管理模块在处理这些堆内存请求可能就会发生异常,产生崩溃。这类问题最典型的特征就是,程序到处乱崩,每次崩溃时的函数调用堆栈可能都不一样,但基本都是崩在malloc/new或free/delete的代码处(从函数调用堆栈中可以看出)。
比如我们这次遇到的这个堆内存越界导致堆内存被破坏的问题,每次崩溃的点都不一样,崩溃在不同的dll模块中,崩溃时的函数堆栈都不一样,如下所示:(给出三次崩溃时的函数调用堆栈)



上述三次崩溃,有个共同的特征,基本都崩在malloc/new或free/delete的代码处。
所以,如果后面再遇到软件胡乱崩溃的问题,即每次崩溃的点都不一样,且堆栈中能看到崩在malloc/new或free/delete的操作代码上,那基本可以确定是堆内存越界破坏了堆内存导致的。
在这里,给大家重点推荐一下我的几个热门专栏,欢迎订阅:(博客主页还有其他专栏,可以去查看)
专栏1:【C++软件异常与异常排查从入门到精通系列教程】 (该精品技术专栏的订阅量已达到50000多个,专栏中包含大量项目实战分析案例,有很强的实战参考价值,广受好评!专栏文章持续更新中,已经更新到200篇以上!欢迎订阅!)
C++软件调试与异常排查从入门到精通系列文章汇总
https://blog.csdn.net/chenlycly/article/details/125529931
本专栏根据多年C++软件异常排查的项目实践,系统地总结了引发C++软件异常的常见原因以及排查C++软件异常的常用思路与方法 ,详细讲述了C++软件的调试方法与手段 ,详细介绍分析C++软件问题的常用分析工具, 以图文并茂的方式给出具体的项目问题实战分析实例(详细讲述分析排查过程,很有实战参考价值),带领大家逐步掌握C++软件调试与异常排查的相关技术,适合基础进阶和想做技术提升的相关C++开发人员!
考察一个开发人员的水平,一是看其编码及设计能力,二是要看其软件调试能力 !所以软件调试能力(排查软件异常的能力)很重要,必须重视起来!能解决一般人解决不了的问题,既能提升个人能力及价值,也能体现对团队及公司的贡献!
**专栏中的文章都是通过项目实战总结出来的,包含大量项目问题实战分析案例,有很强的实战参考价值!**专栏文章还在持续更新中,预计文章篇数能更新到200篇以上!
专栏2:【C/C++实战进阶】 (该专栏涵盖了C++多方面的内容,是当前重点打造的专栏,订阅量已达30000多个,专栏文章已经更新到500多篇,持续更新中...)
C/C++实战进阶(专栏文章,持续更新中...)
https://blog.csdn.net/chenlycly/category_11931267.html
以多年的开发实战为基础,总结并讲解一些的C/C++基础与项目实战进阶内容,以图文并茂的方式对相关知识点进行详细地展开与阐述!专栏涉及了C/C++领域多个方面的内容,包括C++基础及编程要点 (模版泛型编程、STL容器及算法函数的使用等)、数据结构与算法 、C++11及以上新特性 (开源代码中可能会用到很多新特性(比如WebRTC开源库),日常编码中也会用到部分新特性,面试时也会频繁地涉及到,学习新特性很有必要)、常用C++开源库的介绍与使用 (比如SQLite、libcurl、libwebsockets、libevent、jsoncpp/RapidJson、Redis、RabbitMQ、MongoDB、MQTT、ZooKeeper、OpenCV、FFmpeg、SDL、GStreamer、Live555、ReactOS等)、代码分享 (调用系统API、使用开源库)、常用编程技术 (动态库、多线程、多进程、数据库及网络编程等)、软件UI编程 (Win32/duilib/QT/MFC)、C++软件调试技术 (引发C++软件异常的常见原因分析与总结、排查C++软件异常的手段与方法、分析C++软件异常的基础知识、使用常用软件分析工具分析C++软件问题、多个项目实战问题分析案例分享等)、设计模式 (单例模式、工厂模式、观察者模式、状态模式等)、网络基础知识与网络问题分析进阶内容(实战问题分析实例分享)等。本专栏的内容都是建立在项目实践的基础上,来源于项目实战,服务于项目实战,很有实战参考价值!
专栏3:【分析C++软件问题的实用软件与高效工具实战案例集锦】
C++常用软件分析工具从入门到精通案例集锦汇总(专栏文章,持续更新中...)
https://blog.csdn.net/chenlycly/article/details/131405795
常用的C++软件辅助分析工具有SPY++、PE工具、Dependency Walker、GDIView、Process Explorer、Process Monitor、API Monitor、Clumsy、Windbg、IDA Pro以及内存泄漏检测工具等,本专栏详细介绍如何使用这些工具去巧妙地分析和解决日常工作中遇到的问题,很有实战参考价值!
专栏4:【VC++常用功能代码封装】
VC++常用功能开发汇总(专栏文章,持续更新中...)
https://blog.csdn.net/chenlycly/article/details/124272585
将10多年C++开发实践中常用的功能,以高质量的代码展现出来。这些常用的高质量规范代码,可以直接拿到项目中使用,能有效地解决软件开发过程中遇到的问题。
专栏5:【C/C++软件开发从入门到实战】 (本专栏涵盖了C++多方面的内容,是当前重点打造的专栏,专栏文章已经更新到300多篇 ,持续更新中!欢迎订阅!)
C++ 软件开发从入门到精通(专栏文章,持续更新中...)
https://blog.csdn.net/chenlycly/category_12695902.html
根据多年C++软件开发实践,详细地总结了C/C++软件开发相关技术实现细节,分享了大量的实战案例,很有实战参考价值。
3、堆内存越界,越界到某个变量的内存上,篡改了变量的值,导致异常(此种越界发生在一段堆内存内部,并没有破坏这段堆内存前后的头部信息和尾部信息)
堆内存越界,不一定都会破坏堆内存的头信息和尾部信息。有一种情况是,**越界发生在一段连续堆内存内部(比如可能是某个new出来的类对象占用的一段堆内存),即只越界到该内存区域内部其他变量的内存上(比如越界到类对象的其他成员变量内存上),并没有破坏这段堆内存前后的头部信息和尾部信息,此时不会导致程序到处胡乱崩溃的,崩溃时的堆栈也是相对固定的。**越界到类对象的某个成员变量的内存上,会篡改了该成员变量内存中的内容,当我们访问这个成员变量时可能会出现异常。
比如一个C++类对象中包含了多个成员变量,且该C++对象的内存是new出来的,占用的是堆内存。在操作该对象中的某个成员变量时发生了越界(越界发生在堆内存上,属于堆内存越界),导致后面的成员内存被越界,内存内容被篡改,代码后面访问到该被篡改的变量时产生异常。
比如在操作类中的一个成员 char m_achCallId[64]时,执行:
cpp
memcpy(m_achCallId, **, sizeof(structB));
**在该类的某个成员函数中执行上述memcpy的代码,**错误地将第三个参数的长度写成其他结构体的长度(我们在项目中遇到过该问题场景),应该写成siezof(m_achCallId),导致操作m_achCallId成员变量内存时发生越界,导致m_achCallId后面的其他成员变量的内存被篡改。可能被篡改的成员变量是个int型基本类型值,也可能是被篡改的变量是另一个类的指针变量(该指针变量指向的是另一个类对象,即存放另一个类对象的首地址),我们来分别说明:
1)被篡改的变量是int基本型变量
成员变量m_achCallId后面跟着一个int型变量m_nCalleRate,如下:
cpp
class ClassNameA
{
// ...
char m_achCallId[64];
int m_nCalleRate; // int型成员变量
// ...
}
该int型变量值被篡改,被改成一个超大的值,当将变量传给另一个函数时导致该函数内部产生异常,进而产生崩溃,这个问题场景我们在项目中遇到过。
2)被篡改的变量是指向另一个类对象的指针
成员变量m_achCallId后面跟着一个类指针型变量m_pDataLoadProcPtr,如下:
cpp
class ClassNameA
{
// ...
char m_achCallId[64];
CDataLoadProc* m_pDataLoadProcPtr; // 指针成员变量
// ...
}
成员变量指针m_pDataLoadProcPtr,指向一个CDataLoadProc类对象,即指针中保存是CDataLoadProc类对象的首地址。如果因为内存越界将被指针变量m_pDataLoadProcPtr的值篡改成0,后续执行的代码中用该指针变量去访问指向的CDataLoadProc类对象,当访问到该类对象中的数据成员时,因为当前对象地址因为越界被篡改为0,所以访问对象中的数据成员时,会访问很小的内存地址,导致内存访问违例,导致程序崩溃。
4、栈内存越界,导致栈上保存的函数调用时的返回地址,导致栈回溯出异常
函数调用时的栈分布图如下所示:

如图所示,函数a调用函数b之前,会将传给函数b的参数压到栈上(不是所有参数都是通过栈传递给被调用函数的,可能借助寄存器传递的),然后调用call指令去调用函数b,这点从汇编代码上可以清晰看出来。比如下图中,在main函数中调用Add函数:

在Visual Studio调试代码的状态下,可以查看上述C++代码的汇编代码:

可以看到先把参数push到栈上,然后调用call指令调用Add函数。
**call指令会执行两个操作,一是将返回地址(主调函数中下一条要执行的汇编指令地址,代码段地址)压到栈中,**二是跳转到被调用函数处。
**C/C++程序员很有必要去了解汇编,了解汇编不仅可以很好地搞清楚高级语言代码难以理解的代码执行细节,还可以去辅助排查C/C++程序在运行过程中遇到的多种异常崩溃问题。**我们在学习汇编时,要尽量将汇编与日常工作结合起来,从汇编的角度去理解工作中遇到的一些问题,必要时查看汇编代码上下文去辅助排查C/C++软件异常问题。通过日常实践,逐渐熟悉汇编,加深对汇编的认识与理解。
关于C/C++程序员为什么要学习汇编、学习汇编的好处以及如何学习汇编,可以查看我的专题文章:
C/C++程序员为什么要了解汇编?了解汇编有哪些好处?如何学习汇编?
https://blog.csdn.net/chenlycly/article/details/142795872
线程占用的栈内存是从大地址向小地址使用的,但memcpy等内存拷贝操作从某个起始地址向后面的大地址进行拷贝操作的 ,如果栈内存拷贝时发生越界,并且越界到存放返回地址的栈内存区域,就会篡改函数调用call时存到栈上的主调函数的返回地址,导致后续栈回溯出问题。查看此时的函数调用堆栈可能只有几行,不完整,不好定位问题。该问题我们在项目中也遇到过,不止一次。
函数调用堆栈的栈回溯原理,如下图所示:

通过当前函数的ebp栈基址,找到主调函数的ebp栈基址,然后在栈上找到主调函数的返回地址,然后根据主调函数的起始地址找到主调函数。再通过主调函数的ebp栈基址,继续向上回溯,最终找到线程的入口处,完成当前线程函数调用堆栈的回溯。
5、堆内存越界破坏了堆内存头部或尾部信息导致程序胡乱崩溃的问题排查
堆内存越界破坏了堆内存头部或尾部信息导致程序胡乱崩溃的问题,是难查的一类内存问题。除了使用一些专用的动态分析工具之外,如何去人工排查呢?
有时我们在Debug下调试代码时,CRT运行时库可能检测到堆内存越界,报HEAP_CORRUPTION_DETECTED堆内存被破坏的提示,如下所示:

提示堆内存被破坏:
HEAP_CORRUPTION_DETECTED: after Normal block (#3741294) at 0xOEC31840. CRT detected that the application wrote to memory after end of heap buffer.
越界到堆内存的尾部了,破坏了堆内存的尾部信息。但一个软件可能包含上百个dll,不可能把所有模块的代码拿过来调试,依靠CRT监测出堆内存被破坏,是不够的。
此外,如果我们通过打印日志确定某个变量值被篡改了,但无法确定是何处发生的越界篡改的,可以尝试使用Visual Studio的数据断点功能,在该被篡改的变量上设置数据断点,一旦该变量的内存被篡改,Visual Studio就会中断下来,查看此时的函数调用堆栈就知道是何处篡改的了。关于如何在Visual Studio中使用数据断点以及相关项目实战分析案例,可以查看我的文章:
使用Visual Studio中的数据断点快速高效定位内存越界问题的实战案例分享
https://blog.csdn.net/chenlycly/article/details/154335456 但崩溃有延后性,比如内存越界引发的堆内存被破坏导致new/malloc或delete/free时产生异常崩溃,往往越界的地方没有崩溃(越界时没有产生内存访问违例),只是越界导致堆内存的头部或尾部信息被破坏,只有当代码中执行new/malloc或delete/free时堆内存管理出异常,才会崩溃。所以崩溃具有延后性,可能发生崩溃时引发问题的内存越界操作执行过有一会了,而且崩溃点的函数调用堆栈和引发内存越界的点也没关系,多次崩溃时的函数调用堆栈都不一样(一会崩溃在malloc或者new的地方,一会崩溃在free或者delete的地方,每次的堆栈都不一样),崩溃堆栈中只能看到new/malloc或delete/free释放或申请堆内存的操作。只与查看此时的打印日志也比较难查。
如果能找到复现的规律,猜测出可能和哪个操作有关,或者和哪个业务消息有关,然后去查看相关的代码,可能能找到破坏堆内存的内存越界点。但底层或后台一直有业务在不断的跑,可能是某个业务消息触发的,不一定是在客户端点击某个界面触发的。
必要时,只能不断的注释代码,缩小排查范围,逐步的找到问题。