C++ 常见崩溃问题分析

摘要:
1、 前言在自动测试平台开发的编程实践中,我遇到了几个程序崩溃的问题,这需要花费大量的精力来解决。解决过程中的曲折和彻夜辗转反侧,至今仍历历在目。我一直在想写一些东西来纪念这段难忘的经历,并总结惨痛教训带来的经验,以便通过自己的经历为他人和自己带来快乐:编写更高质量的节目;由于C和C++有着密切的联系,本文也可以作为C编程语言的参考;2、 C++崩溃将所有事故分类,而不是事故。在编程实践中
一、前言

从事自动化测试平台开发的编程实践中,遭遇了几个程序崩溃问题,解决它们颇费了不少心思,解决过程中的曲折和彻夜的辗转反侧却历历在目,一直寻思写点东西,为这段难忘的经历留点纪念,总结惨痛的教训带来的经验,以期通过自己的经历为他人和自己带来福祉:写出更高质量的程序;

由于 C 和 C++ 这两种语言血缘非常近,文本亦对 C 编程语言有借鉴作用;

二、C++ 崩溃分类

一切的偶然并非偶然

在编程实践中,遭遇到了诸如内存无效访问、无效对象、内存泄漏、堆栈溢出等很多C / C++ 程序员常见的问题,最后都是同一个结果:程序崩溃,为解决崩溃问题,过程都是非常让人难以忘怀的;

可谓吃一堑长一智,出现过几次这样的折腾后就寻思找出它们的原理和规律,把这些典型的编程错误一网打尽,经过系统性的分析和梳理,发现其内在机理大同小异,通过对错误表现和原理进行分类分析,把各种导致崩溃的错误进行归类,详细分类如下:

错误类型

具体表现

备注(案例)

声明错误

变量未声明

编译时错误

初始化错误

未初始化或初始化错误

运行不正确

访问错误

1、  数组索引访问越界

2、  指针对象访问越界

3、  访问空指针对象

4、  访问无效指针对象

5、  迭代器访问越界

内存泄漏

1、  内存未释放

2、  内存局部释放

参数错误

本地代理、空指针、强制转换

堆栈溢出

调用堆栈溢出:

1、递归调用

2、循环调用

3、消息循环

4、大对象参数

5、大对象变量

参数、局部变量都在栈(Stack)上分配

转换错误

有符号类型和无符号类型转换

内存碎片

小内存块重复分配释放导致的内存碎片,最后出现内存不足

数据对齐,机器字整数倍分配

其它如内存分配失败、创建对象失败等都是容易理解和相对少见的错误,因为目前的系统大部分情况下内存够用;此外除0错误也是容易理解和防范;

三、C++ 编程回顾

为了更好的理解崩溃错误产生的根源,我们一起回顾一下几个概念和知识点,为后面的讨论打下基础;

由于本篇只谈程序设计,不谈软件设计,故忽略了文档开发、过程管理、软件测试、配置管理等内容,各位看官在审阅文档中的著述时若有分歧请勿忽略这个隐喻;

3.1、程序构造视图

Pascal 之父、结构化程序设计的先驱 Niklaus Wirth提出了著名的公式:算法 + 数据结构 = 程序,以简单直接的方式道出了他对软件开发的理解,简明扼要的说明了程序设计的本质;为了更加全面的暴露程序设计的本源,我们把这个公式稍加扩展:算法 + 数据结构 + 内存管理 = 程序设计,它进一步揭开了程序设计的老底:程序设计需要关注内存空间管理;

从计算机科学的发展趋势来看,Niklaus Wirth 极具远见卓识,因为现代程序设计越来越不需要关注内存空间的使用了,首先是由于科技的发展,存储器件成本越来越低,物理内存容量越来越大;其次是动态语言和以Java为代表的托管语言都具有自动内存分配和垃圾内存回收功能,用户只需要专注于人机交互、数据结构设计和业务逻辑的梳理了;

C / C++ 这类传统的静态语言也追随着这股潮流,在内存空间管理方面添加了越来越多的自动化支持,简化了内存管理,然而简单并不意味着内存管理的复杂性消失,出现崩溃问题时我们一筹莫展正是因为简单性蒙蔽了我们的思维,而崩溃的根源就是内存空间的使用不当造成的,因此对操作系统原理、内存管理、语言语义的透彻理解是我们解决崩溃问题的关键所在;

3.2、进程内存布局

在介绍详细介绍进程空间内存布局之前,我们首先看一下 Windows 的资源管理器进程的内存布局视图,如下所示:

C++ 常见崩溃问题分析第1张

图(一)Windows资源管理器内存分布图

C++ 常见崩溃问题分析第2张

图(二)Windows资源管理器内存地址空间分布图

 

 

从上图可以看出,进程内存地址空间被划分为8块(Managed Heap是另一种内存堆),并且各块内存不是聚集在一起形成连续内存块,而是按需加载使用内存;他们的详细情况如下:

内存块英文名

中文名

详细说明

Image

映像内存

EXE、DLL等加载到这里

Mapped File

内存映射文件

共享内存,用于进程间通讯

Shareable

可共享内存

Heap

(Managed Heap)

内存堆

堆内存,new/new[]/malloc等都在堆空间分配,默认为1 MB;Managed Heap 供CLR使用的堆

Stack

堆栈

栈内存,用做函数参数、局部变量的存储空间,默认为1 MB

Private Data

私有数据

Page Table

内存页表

内存分配页表

Free

自由内存

可用的内存空间

由于编译器在后台做了大量的内存管理自动化工作,因此程序设计过程中主要关注的内存区域类型有:Stack、Heap、Free(Free Virtual Address Space),下面我们对这几种做一个简要介绍:

Stack 是一块固定大小的连续内存,受运行时管理,无需用户自行分配和回收;当函数调用嵌套层次非常深时会产生 Stack overflow(堆栈溢出)错误,如递归调用、循环调用、消息循环、大对象参数、大对象局部变量等都容易触发堆栈溢出;

Heap 主要用于管理小内存块,是一个内存管理单元,默认为1MB,可动态增长;每一个应用程序默认有一个 Heap,用户也可以创建自己的 Heap,new/delete, malloc/free 都是从堆中直接分配内存块;

Free(Free Virtual Address Space)即进程空间中的整个可用地址空间,它会以两种方式被使用,一种是Heap 自动分配和回收,一种是直接使用VirtualAlloc*/VirtualFree* 分配和回收;用户对它的直接使用是用于分配连续大块内存,分配和释放的速度比使用 Heap 更快;

3.3、数据结构视图

内存始终都还是内存,所不同的是我们解读内存的方式不同;从代码视野来看内存中的数据结构,它就是对一块连续内存的专有解读;对任何一个内存地址,我们可以用数据结构A视图来解读,亦可以用数据结构B视图来解读,使用正确的数据结构视图读到正确的数据,使用错误的数据结构视图我们读到错误的数据;为了简明扼要的说明这个问题,我们来个案例:

char szSentence[] = "this is a example";

int * nValue = (int *)szSentence;           ----> *nValue = 1310540

记住这一点非常重要,C / C++ 程序设计中的很多技术法门都出自这;例如基本类型转换、指针对象转换、面向对象的多态、改写只读对象等都体现为连续内存块的解读视图变化;

由于操作系统已经接管了物理内存的使用,并且提供了透明的访问机制,对内存的使用更直接体现为对操作系统提供的进程地址空间的分配和回收;

在实际的编程实践中,程序员需要把整块空间再细分为8位、16位、32位、64位、8位连续块等数据空间,这里还涉及到两个概念:字节对齐和字节序列(又名端序,有大端小端之说),透彻理解编译器的对齐规则和处理所支持的字节序列,对于正确理解内存中的数据很关键。

3.3.1、字节序列

字节序列对于网络编程的同学尤其熟悉,因为需要把数据包在本机字节序列和网络字节序列(大端序列)来回转换,部分经常使用printf和基于偏移量访问内存的同学也会遇到字节序列带来的烦恼;

字节序列简单的讲是对大于一个字节的数据在内存中如何存放的问题,比如32位整数需要使用4个字节,这个四个字节该如何放置?按照二进制位切割为四个字节吗?下面我们详细介绍一下字节序列的两种定义:

端序

第一字节

中间字节

最末字节

备注

大端(Big Endian)

最高位字节

……

最低位字节

类似于正常书写数字表示

小端(Little Endian)

最低位字节

……

最高位字节

类似数学计算法则,反序列

端序的内存案例:

端序

内存案例(0x44332211)

处理器家族

大端(Big Endian)

0x44 0x33 0x22 0x11

SUN SPARC/IBM PowerPC

小端(Little Endian)

0x11 0x22 0x33 0x44

Intel 80x86 系列

下面我们来看一个实践中产生的和端序想关联的问题,案例来自 Vimer的程序世界

int64_t a = 1;

int b = 2;

printf("%d, %d", a, b);  ====> 1, 0

为什么会这样?有同事对该问题做了精辟的注解,为了尊重作者版权,故截图分享,请看下图:

C++ 常见崩溃问题分析第3张

【注】这个Bug依赖于编译器实现,可能在某些编译器上不会重现。

3.3.2、字节对齐

字节对齐涉及内存分配的问题,具体涉及到结构、联合类型、类成员数据的对齐分配,编译器根据不同的对齐规则分配不同的内存空间。

平台编译器

支持对齐规则

修改方法(四字节对齐)

Microsoft C/C++

1/2/4/8/16,default: 8

#pragma pack(4)

__declspec(align(4))

GNU GCC 4.6

1/2/4/8/16,default: by ABI

__attribute__(packed|aligned|…)

packed :自动使用最少内存对齐字节

aligined:按指定字节对齐

int x __attribute__((aligned (4))) = 0;

掌握对齐规则后,我们就可以在使用标量类型时、设计结构、联合、类类型时合理选择类型,既可以合理使用内存空间,又可以提高程序性能;下面我们看一个来自实践中的案例:

#pragma pack(1)

struct tagPROJECTPROPERTY

{

    char szBusiness[SCHEMA_NAME_MAX_LEN];       // 64 byte

    char szTeamName[SCHEMA_NAME_MAX_LEN];       // 64 byte

    char szLanguage[SCHEMA_NAME_MAX_LEN];       // 64 byte

    char szExtension[SCHEMA_FILE_EXT_LEN];      // 64 byte

    char szProjectGUID[SCHEMA_UUID_MAX_LEN];    // 7 byte

    char szProjectName[SCHEMA_NAME_MAX_LEN];    // 64 byte

    uint32_t dwEntryTotal;                        // 4 byte    

};

#pragma pop

类型定义

对齐(1 B)

对齐(2 B)

对齐(4 B)

默认(8 B)

sizeof(tagPROJECTPROPERTY)

331 BYTE

332 BYTE

332 BYTE

336 BYTE

从上面我们可以看出,在默认对齐规则下,单个实例会浪费5个字节的内存,如果1万实例则会浪费 48 K内存,如果再加上不合理的长度定义,可能浪费更多的内存空间,在小内存空间限制的系统中,这显然是巨大的优化空间。

3.4、函数参数传递

C/C++ 的入口程序就是函数,函数需要传入参数,详细了解参数分类、传递规则、传递过程对写出正确且高效的程序起着至关重要的作用。笔者就曾因为传错了一个参数而导致程序崩溃,最后费了非常多的时间来查找原因,最后找出的原因是取址符(&)用错了,这让我下定决心彻底搞明白参数是怎么回事。

3.4.1、函数参数详解

参数分为输入参数输入输出参数输出参数、返回参数四种,分别适用于用于不同的场景,其作用和值得关注的细节如下:

参数类型

核心用途

黄金建议

输入参数

从函数外部传递数据给函数内部

const typename

输入输出参数

用于传递数据也接收数据

先行初始化

输出参数

用于往函数外传递数据

无需初始化

返回参数

用于函数返回数据(return),C/C++函数都有返回参数

1、const typename

2、禁止返回函数内局部对象的指针和引用

在输入参数和返回参数添加常量修饰符const 是一个非常好的编程习惯,能显著的预防很多错误,因为我们不知道编译器自动生成的参数入栈代码和参数出栈代码的具体模样,亦不知它何时何地执行,只能最大化的防范它的风险。

参数传递顺序有从左到右传递从右到左传递两种,由于参数也是一个表达式,关注参数的求值顺序对写出正确的程序非常关键,例如:calc (origin++, origin+inc),如果不清楚参数表达式求值顺序就无法正确理解程序;

概念名称

简要说明

备注(案例)

从左到右传递

对函数的多个输入参数从左到右求值并压入栈

入栈为在堆栈中分配内存

出栈为释放参数所占内存

从右到左传递

对函数的多个输入参数从右到左求值并压入栈

参数传递方式有值传递引用传递指针传递三种,三种参数本质上都是【值传递】,基本类型由于地址所指即为真实数据,传递时会生成真实数据的拷贝,将会消耗更多的堆栈内存,而引用传递和指针传递乃间接指向对象,传递时只是生成地址的拷贝,堆栈内存消耗比较少;我们首先对各个概念做一个简要回顾:

概念名称

简要说明

备注(案例)

值传递

对参数求值后把其所指数据生成一份拷贝再压入栈

堆栈内存消耗大户

引用传递

对参数求值后把它的引用地址压入栈

平台字节宽度,例如32位占4字节,64位占8字节。

指针传递

对参数求值后把它的引用地址压入栈

很多函数调用阶段的细微错误就是忽略了参数传递的细节造成的,例如参数求值顺序、值传递还是引用传递等因素,堆栈溢出有很大一部分因素是因为错误的把结构和类对象以值传递方式传给函数导致的;

3.4.2、函数参数约定

参数传递顺序和传递形式组合形成了几种不同的函数调用约定,下面我们一起回顾一下编程实践中常见的几种约定:function resize(void ** p)

传递方式

简要说明

编译约定

cdecl

C调用约定,参数从右到左求值并入栈,调用函数清理堆栈;实现可变参数的函数(如printf)只能使用该调用约定

C: _resize

C++:

?resize@@YA*@Z

thiscall

C++ 成员函数调用约定,this指针存放于ECX寄存器中,参数从右到左求值入栈,被调函数在退出时清理堆栈

stdcall

Windows API的缺省调用方式,参数以值传递方式从右到左求值入栈,被调函数在退出时清理堆栈

C:_resize@4

C++:

?resize@@YG*@Z

fastcall

前两个参数是DWORD类型或更小的数据则传入ECX、EDX,其它参数以从右到左的方式求值入栈,被调函数退出时清理堆栈

C:@resize@4

C++:

?resize@@YI*@Z

clrcall

从左到右加载参数到CLR expression stack

与thiscall 合用

pascal

不再使用

参见 stdcall

syscall

不再使用

fortran

不再使用

【注】VC++对函数的省缺声明是"__cedcl",将只能被C/C++调用,C++调用约定中的符号(*)需要根据参数填充;

由于函数调用清理代码由编译器自动生成,例如C/C++ 函数调用由调用函数清理堆栈,编译器会把清理代码生成在紧挨着被调用函数位置还是在函数退出前位置?对于我们来说是未知的,这里产生了潜在的未知风险;

3.4.2、函数参数能效

参数如何传递才最安全、最有效率?

Windows平台的调用栈空间是在链接时固定分配并写入二进制文件,UNIX类平台则可以通过环境变量设置,他们的默认栈初始空间情况如下:

平台

默认堆栈

最大堆栈

说明

SunOS/Solaris

8192KB

无上限

可通过环境变量配置

Unix/Linux

8192KB

???

Windows

x86/x64

1024KB

32768K

链接时修改

Itanium

4096KB

32768K

cygwin

2048KB

???

指针传递、引用传递都是传递对象的地址,传给函数时都是把这个地址压入栈,32位平台为四个字节,64位平台为八个字节,除结构实例、类实例外的标量类型,其数据长度均固定,可以精确的计算参数所需空间;我们做个简单的计算:取平台位宽为参数空间平均长度,以平均每个函数三个参数、1000级函数调用来计算,他们的占用空间如下:

32位平台(固定长度参数):1000 * 3 * 4Byte/ 1024Byte = 11.71875KB

64位平台(固定长度参数):1000 * 3 * 8Byte/ 1024Byte = 23.4375KB

由此可以看出,标量类型、指针、引用等数据类型长度都小于等于机器字长,占用的空间小,入栈、出栈速度都是非常块的,一般情况下默认栈空间足够使用,不会出现堆栈溢出的问题;

哪些数据类型会潜在的降低程序效率呢?答案是结构类型、类类型,他们是程序低效率的潜在幕后黑手;由于标量类型、指针、引用占用的空间等都是机器字长,标量类型无论使用哪种方式传递,和指针、引用都是同样的速度;结构类型、类类型的值传递方式呢?

咱们需要了解一下结构类型、类类型的值传递过程:调用参数类的复制构造函数生成新的类型实例并入栈,复制构造函数编译器自动生成,用户亦可以自己编写一个;我们可先看一个案例:

struct TestClass

{

public:

~TestClass() {AfxMessageBox("~TestClass()");}

    TestClass() {AfxMessageBox("TestClass()");}

    TestClass(INT32 publicData, INT32 privateData, const CString & strName, constCString & strValue) : m_PublicData(publicData), m_PrivateData(privateData), m_DataName(strName), m_DataValue(strValue)

    {

        m_PublicWindow = new CWnd();

        m_PrivateWindow = new CWnd();

        AfxMessageBox("TestClass(INT32, INT32, CString, CString)");

    }

 

    explicit TestClass(const TestClass & obj) {AfxMessageBox("TestClass(const TestClass & obj)");}

    void operator=(const TestClass & obj) {AfxMessageBox("void operator=(const TestClass & obj)");}

    void Click()

    {

        CString strText("");

        strText.AppendFormat("Click(%d, %d, %s, %s, %p, %p)", m_PublicData, m_PrivateData, m_DataName, m_DataValue, m_PublicWindow, m_PrivateWindow);

        AfxMessageBox(strText);

    }

public:

    INT32 m_PublicData;

    CString m_DataName;

    CWnd * m_PublicWindow;

private:

    INT32 m_PrivateData;

    CString m_DataValue;

    CWnd * m_PrivateWindow;

};

void DoValueArgs( TestClass obj )

{

    obj.Click();

}

TestClass object(10000, 99999, "10001", "88888");

DoValueArgs(object);

object.Click();

 

案例代码运行图谱,从左到右从上到下顺序摆放

C++ 常见崩溃问题分析第4张

C++ 常见崩溃问题分析第5张

 

C++ 常见崩溃问题分析第6张

C++ 常见崩溃问题分析第7张

C++ 常见崩溃问题分析第8张

C++ 常见崩溃问题分析第7张

这段代码在 Visual C++ 编译器下运行的结果如上所示,其中多次执行最后三行代码,第三个窗口表现一致,由此我们可以判断出结构、类的复制构造函数不会深度复制对象,用值传递时会丢失数据结构、类由于包含多个成员,逐个复制会倍数于标量和指针操作,带来了速度的降低

前面的试验探讨了值传递、指针传递、引用传递,标量类型、指针、引用传递参数长度固定,安全高效,但结构、类的值传递方式带来诸多问题,例如堆栈溢出、数据丢失、效率低下等,建议结构、类完全使用指针或者引用传递;

安全隐患

简要说明

备注

堆栈溢出

如果结构和类都是很大,创建其副本会消耗大量的空间和时间,最终产生溢出错误

数据丢失

类对象创建副本时,会受到类实现的影响而无法完全复制,参见文档《Effective C++》第二章

效率降低

3.5、变量生命周期

变量声明了,是不是直接使用就万事大吉了呢?我们当然希望就是这么简单,动态语言和托管类型语言确实实施了严格初始化机制:变量只要声明就初始化为用户设置的初始值或者零值;然而 C/C++ 不是这种实施了保姆级初始化机制的语言,透彻了解 C/C++ 的初始化规则对帮助我们写出健壮的程序大有裨益;

3.5.1、变量内存分配

C / C++ 支持声明静态变量(对象)、全局变量(对象)、局部变量(对象)、静态常量等,这些变量在分配时机、内存分配位置、初始化等方面上有些细微上的差别,熟悉并掌握他们对于写出正确的程序非常有帮助,请看下表:

生命周期

变量类型

分配时机

初始化

全局生命周期

(Global lifetime)

(C: Static)

函数

编译时,

虚拟方发表

全局变量

编译时,

首次执行,默认置零或赋值

全局对象

编译时,

首次执行,构造函数

全局静态变量

编译时,

首次执行,默认置零或赋值

全局静态对象

编译时,

首次执行,构造函数

局部静态变量

编译时,

首次执行,默认置零或赋值

局部静态对象

编译时,

首次执行,构造函数

局部生命周期

(Local lifetime)

(C: Automatic)

局部变量

执行时,栈(Stack)

可选:赋值操作

局部对象

执行时,堆(Heap)

构造函数

对象创建后的成员数据取决于构造函数及其参数,系统自动生成的构造函数是不会初始化成员变量的;

对于函数、结构实例、类实例中的变量,编译器不会自动初始化,其值是不确定的,故直接使用会导致不确定的行为,这就是实践中经常碰到的程序行为表现莫名其妙的根源所在;

对于动态分配的内存(new/delete、new[]/delete[]、malloc/free),默认是不会置初值的,需要显式的初始化;对于结构和类型实例,new/new[]操作会自动调用构造函数初始化内存,详情请参见【对象初始化】;

【注】使用 VirtualAlloc/VirtualAllocEx 分配的虚拟内存会自动化初始化为零值;

【注】使用 HeapAlloc 分配的堆内存可以通过参数设置初始化为零值

3.5.2、变量初始化

从前面的变量初始化中得知结构实例、类实例、函数中声明的变量是不会自动初始化的,需要用户显式的初始化;值类型相对比较安全,可以声明时即初始化,这是最安全的作法;

数据类型

声明即初始化

备注

标量类型

int data = 10;

double cost = 999.22;

所有算数类型和指针类型

聚合类型

int x[ ] = { 0, 1, 2 };

char s[] = {'a', 'b', 'c', '

免责声明:内容来源于网络,仅用于学习参考。如对内容有疑问,请及时联系本站处理。

上篇(stm32f103学习总结)—ADC模数转换实验mysql表切换引擎的几种方法下篇

宿迁高防,2C2G15M,22元/月;香港BGP,2C5G5M,25元/月 雨云优惠码:MjYwNzM=

相关文章

【VS开发】#pragma pack(push,1)与#pragma pack(1)的区别

这是给编译器用的参数设置,有关结构体字节对齐方式设置, #pragma pack是指定数据在内存中的对齐方式。 #pragma pack (n)             作用:C编译器将按照n个字节对齐。 #pragma pack ()               作用:取消自定义字节对齐方式。 #pragma  pack (push,1)     ...

IOS创建一个单件实例

Foundation和Application Kit框架中的一些类只允许创建单件对象,即这些类在当前进程中的唯一实例。举例来说,NSFileManager和NSWorkspace类在使用时都是基于进程进行单件对象的实例化。当您向这些类请求实例的时候,它们会向您传递单一实例的一个引用,如果该实例还不存在,则首先进行实例的分配和初始化。 单件对象充当控制中心的...

STM32学习笔记——USART串口(向原子哥和火哥学习)

一、USART简介 通用同步异步收发器(USART)提供了一种灵活的方法与使用工业标准NRZ异步串行数据格式的外部设备之间进行全双工数据交换。USART利用分数波特率发生器提供宽范围的波特率选择。 STM32 的串口资源相当丰富的,功能也相当强劲。STM32F103ZET6 最多可提供 5 路串口,有分数波特率发生器,支持同步单向通信和半双工单线通信,支持...

Conservative GC (Part one)

[toc] 保守式GC 保守式GC(Conservative GC)指“不能识别指针和非指针的GC” 不明确的根 不明确的根(ambiguous roots),下面三类都可以作为根。事实上是不明确的根 寄存器 调用栈 全局变量空间 以栈为例:在调用栈中有调用帧(call frame),调用帧里面装着函数内的局部变量和参数值。不过局变量中如果有c语言里面...

java笔试练习题

选择题(共50题,每题1.5分,共75分。多选题选不全或选错都不得分。)1. 以下属于面向对象的特征的是(C,D)。(两项)A) 重载B) 重写C) 封装D) 继承2. 以下代码运行输出是(C)public class Person{private String name=”Person”;int age=0;}public class Child ext...

Redis实现之压缩列表

压缩列表 压缩列表(ziplist)是列表键和哈希键的底层实现之一,当一个列表键只包含少量列表项,并且每个列表项要嘛是整数值,要嘛是比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。例如,执行以下命令将创建一个压缩列表键的底层实现 127.0.0.1:6379> RPUSH lst 1 3 5 10086 "hello" "worl...