从事自动化测试平台开发的编程实践中,遭遇了几个程序崩溃问题,解决它们颇费了不少心思,解决过程中的曲折和彻夜的辗转反侧却历历在目,一直寻思写点东西,为这段难忘的经历留点纪念,总结惨痛的教训带来的经验,以期通过自己的经历为他人和自己带来福祉:写出更高质量的程序;
从计算机科学的发展趋势来看,Niklaus Wirth 极具远见卓识,因为现代程序设计越来越不需要关注内存空间的使用了,首先是由于科技的发展,存储器件成本越来越低,物理内存容量越来越大;其次是动态语言和以
Java
为代表的托管语言都具有自动内存分配和垃圾内存回收功能,用户只需要专注于人机交互、数据结构设计和业务逻辑的梳理了;
C / C++ 这类传统的静态语言也追随着这股潮流,在内存空间管理方面添加了越来越多的自动化支持,简化了内存管理,然而简单并不意味着内存管理的复杂性消失,出现崩溃问题时我们一筹莫展正是因为简单性蒙蔽了我们的思维,而崩溃的根源就是内存空间的使用不当造成的,因此对
操作系统
原理、内存管理、语言语义的透彻理解是我们解决崩溃问题的关键所在;
3.2、进程内存布局
在介绍详细介绍进程空间内存布局之前,我们首先看一下 Windows 的资源管理器进程的内存布局视图,如下所示:
图(一)Windows资源管理器内存分布图
图(二)Windows资源管理器内存地址空间分布图
从上图可以看出,进程内存地址空间被划分为8块(Managed Heap是另一种内存堆),并且各块内存不是聚集在一起形成连续内存块,而是按需加载使用内存;他们的详细情况如下:
内存块英文名
由于编译器在后台做了大量的内存管理自动化工作,因此程序设计过程中主要关注的内存区域类型有: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.1、字节序列
字节序列对于网络编程的同学尤其熟悉,因为需要把数据包在本机字节序列和网络字节序列(大端序列)来回转换,部分经常使用printf和基于偏移量访问内存的同学也会遇到字节序列带来的烦恼;
字节序列简单的讲是对大于一个字节的数据在内存中如何存放的问题,比如32位整数需要使用4个字节,这个四个字节该如何放置?按照二进制位切割为四个字节吗?下面我们详细介绍一下字节序列的两种定义:
掌握对齐规则后,我们就可以在使用标量类型时、设计结构、联合、类类型时合理选择类型,既可以合理使用内存空间,又可以提高程序性能;下面我们看一个来自实践中的案例:
#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
3.4.2、函数参数能效
参数如何传递才最安全、最有效率?
Windows平台的调用栈空间是在链接时固定分配并写入二进制文件,UNIX类平台则可以通过环境变量设置,他们的默认栈初始空间情况如下:
指针传递、引用传递都是传递对象的地址,传给函数时都是把这个地址压入栈,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();
这段代码在 Visual C++ 编译器下运行的结果如上所示,其中多次执行最后三行代码,第三个窗口表现一致,由此我们可以判断出结构、类的复制构造函数不会深度复制对象,用
值传递时会丢失数据
。
结构、类由于包含多个成员,逐个复制会倍数于标量和指针操作,带来了速度的降低
;
前面的试验探讨了值传递、指针传递、引用传递,标量类型、指针、引用传递参数长度固定,安全高效,但结构、类的值传递方式带来诸多问题,例如堆栈溢出、数据丢失、效率低下等,建议结构、类完全使用指针或者引用传递;
对象创建后的成员数据取决于构造函数及其参数,系统自动生成的构造函数是不会初始化成员变量的;
对于函数、结构实例、类实例中的变量,编译器不会自动初始化,其值是不确定的,故直接使用会导致不确定的行为,这就是实践中经常碰到的程序行为表现莫名其妙的根源所在;
对于动态分配的内存(new/delete、new[]/delete[]、malloc/free),默认是不会置初值的,需要显式的初始化;对于结构和类型实例,new/new[]操作会自动调用构造函数初始化内存,详情请参见【对象初始化】;
【注】使用 VirtualAlloc/VirtualAllocEx 分配的虚拟内存会自动化初始化为零值;
【注】使用 HeapAlloc 分配的堆内存可以通过参数设置初始化为零值
3.5.2、变量初始化
从前面的变量初始化中得知结构实例、类实例、函数中声明的变量是不会自动初始化的,需要用户显式的初始化;值类型相对比较安全,可以声明时即初始化,这是最安全的作法;
声明即初始化
子类的构造函数被 new/new[] 操作时自动触发,它首先调用最底层基类的构造函数对其成员进行初始化,以此类推直到子类构造函数完成整个初始化过程;编译器会自动在子类构造函数的最前面中插装基类的默认构造函数以完成基类数据的初始化,如需要传递特别参数,则需要显示的调用基类构造函数。
由于类存在继承关系,基类和子类的构造函数调用存在着先后顺序关系,这意味着新对象的内存空间初始化会因为构造函数的调用顺序而呈现不同的状态:即这个对象内存块是一部分一部分的初始化; 由于这个特点,缺陷的幽灵就有了可乘之机,我们先看一个案例:
0001 class Base {
0002 public:
0003 Base():m_IntData(0){Initialize();}
0004 ~Base(){}
0005 virtual Initialize() {m_IntData = 10;}
0006 private:
0007 int m_IntData;
0008 }
0010 class Derived : public Base {
0011 public:
0012 Derived() {m_pBuffer = malloc(4096);}
0013 ~Derived() {free(m_pBuffer);}
0014 virtual Initialize() {strncpy(m_pBuffer, "Testing...", _TRUNCATE);}
0016 private:
0017 void* m_pBuffer;
0018 }
0020 Derived * pDerived = new Derived();
0021 Base * pBase = dynamic_cast<Base *>(pDerived);
0022 delete pBase;
上述代码由于继承关系和内存初始化的特点而产生了两处缺陷:
类继承关系带来了两个全新的概念:
多态(类透视)
和
对象切片
;这两类应用在面向对象编程(OOP)语言中都很常见两个技术;
多态常见的应用情况是对象泛化,即已基类视图操作对象。它的典型构成是基类数据结构视图 + 基类成员方法视图,从字面意思我们可以解读透视图只是视野范围的改变,即用户只能看到并调用基类定义视图中的数据和方法,而非数据和方法的改变,所以函数调用的依然是当前对象的方法。如图(四)所示展示的Shape透视图所示;
图(四)类(Shape)多态透视图
下面我们来举例为您演示一下多态类透视效果,通过基类指针指向同一个对象实例,只是透过基类的结构视图来调用相关方法,由于虚拟方法表指针指向同一个虚拟方法表,所以调用的还是同一个类的函数。
// 创建对象
Trapezium objTrapezium;
objTrapezium.Draw();
// 演示多态(类透视)
Shape * pShape = dynamic_cast<Shape *>(&objTrapezium);
if (pShape)
pShape->Draw();
// 演示切片
Shape objShape = (Shape)objTrapezium;
objShape.Draw();
对象切片很好理解,相当于32位整数转换为16位整数时会根据目标类型裁减丢弃一部分数据,对象切片亦会裁减对象数据,它的变换过程是:分配目标类对象空间 è 复制源对象等长内存 è 设置虚拟方法表指针【如果有】,类对象切片与普通数据类型唯一的不同是它会切换对应的函数视图,如果有虚方法则还会切换虚拟方法表指针以确保调用正确的虚函数;
3.5.4、变量对象释放
自动分配的对象在离开其生命周期时会自动释放,这是由编译器自动保证的,一般情况下无需我们担忧;
我们需要关注的是对象指针所指的对象释放情况,尤其是跨越函数的对象值得关注,由于它的 new/delete、new[]/delete[]、malloc/free 等匹配性不明确,很容易被遗落而导致内存泄漏;比如模块A创建一个结构对象通过消息传递给模块B,模块B需要复制对象后即刻释放或者使用完毕后释放;
多态类型是我们需要着重关注的设计案例,它的析构函数在没有标记为虚函数和标记为虚函数的表现截然不同:
未标记为虚函数时它只会析构当前类实例,从对象指针类型开始向基类逐层析构,子类析构函数不会调用,会导致子类分配并持有的资源未释放,造成内存泄漏;
标记为虚函数时会按照对象指针所指对象类型往基类逐层调用其析构函数;
在图(三)所示案例中,
如果基类 Shape 的析构函数未标记为虚函数
,下面的代码会导致啥结果:
Shape * pNewShape = new Trapezium();
pNewShape->Draw();
delete pNewShape;
是的,会发生内存泄漏!!!
释放对象导致内存泄漏的另一个典型案例是对象数组释放不匹配导致的,为了解释清楚这个问题,我们先看一看 delete 操作是如何实现的:
Complex * pc = new Complex(1,2);
......
delete pc;
// 编译器将 delete pc 编译为如下代码:
pc->~Complex(); // 先析构
::operator delete(pc); // 释放对象内存
编译器释放对象的过程分两步:调用其析构函数释放持有的资源,然后释放对象占用的内存;由于对象数组用普通对象释放操作来释放,其结果是
只有第一个对象的析构函数被调用,其它对象都未调用析构函数,导致其它对象持有的内存资源未释放
;我们先看一个具体的案例:
string * pNameArray = new string[3];
// 此处省略 N 行代码
delete pNameArray; // 内存泄漏
您或许会问:字符串对象数组本身是否完全释放?根据技术分析来看,Visual C++ 编译器会完全释放,其它编译器不确定。由于它使用普通对象释放操作,第二个、第三个字符串对象未调用其析构函数,字符串对象持有的资源未释放,导致内存泄漏。
4.2、变量初始化
变量初始化看似平淡无奇,但它却是我们程序运行过程中不确定行为的幕后推手,并且常常在我们意料之外;重视变量的初始化对于我们写出正确的程序非常重要;为了帮助各位认识到其重要性,我们先看几个案例:
CEdit * pNameEditCtrl;
// 此处省略N行代码
CString strUserName
pNameEditCtrl->GetWindowText(strUserName)
上面的代码会导致程序运行崩溃:访问无效的指针;
LOGFONT stLogFont;
stLogFont.lfHeight = 0 - MulDiv(10, this->GetDC()->GetDeviceCaps(LOGPIXELSY), 72);
m_ListView.GetPaintManager()->SetItemsFontIndirect(&stLogFont, TRUE);
上面的代码运行会导致不可预知的行为,实践中表现为字体异常粗大,界面错乱;
void CTestDialogDlg::OnOK()
INT32 nTestData;
UINT32 uTestData;
CString strTestData;
strTestData.AppendFormat(_T("%d, %d"), nTestData, uTestData);
AfxMessageBox(strTestData);
上面的代码在不同的编译版本下表现出不同的行为,具体请看下面的输出:
在 Debug 状态下输出为:-858993460, -858993460,
在 Release 状态下输出为:4381392, 4381392
void CTestDialogDlg::OnOK()
CString strData;
std::string stlText;
CString strDisplay;
strDisplay.AppendFormat(_T("%s, %s"), strData, stlText.c_str());
AfxMessageBox(strDisplay);
上面的代码在不同的编译版本下表现出不同的行为,具体请看下面的输出:
在 Debug 状态下输出为:
在 Release 状态下输出为:
前面我们回顾知识点时介绍了只有全局变量(全局名字空间变量和子名字空间内变量)、静态变量会在首次执行时初始化,其它例如函数内局部变量、类成员变量、结构成员变量都不会自动初始化,每次执行时会为每一个变量分配内存,局部变量、成员变量指向未初始化的内存,于是就出现了上述案例所出现的情况;
局部变量、成员变量不会自动初始化,所以我们要养成声明即初始化的良好习惯;
4.3、内存访问
内存访问错误是所有C/C++开发人员都曾亲密接触的一类错误,这类错误最常见,它常常在我们无意识状态下蹦出来了,下面我们分析一下这类错误的根源;
#define MAX_ARRAY_COUNT 16
LOGFONT arrFonts[MAX_ARRAY_COUNT];
for (int index = 0; index <= MAX_ARRAY_COUNT; index++)
// 此处省略初始化代码N行
arrFonts[index].lfHeight = 0 - MulDiv(10, this->GetDC()->GetDeviceCaps(LOGPIXELSY), 72);
// 此处省略初始化代码N行
内存访问触发的错误时常发生,但总结起来可以归纳为几类,他们分别是:数组访问越界、指针访问越界、字符串访问越界、迭代器访问越界、访问游移指针对象、访问空指针,他们有共同特征,也存在着一些细微的差别,让我们一起来看看:
内存访问出错类别
出错关键点
按照摩尔定律,内存器件的成本迅速下降,但内存紧缺的问题却没有随之解决,内存分配失败的问题依然存在,保持检测内存指针或捕获内存异常的习惯依然有必要;由于内存分配失败的原因是内存不足,故我们把探讨的重点放到内存不足的原因上来。
内存分配释放语义简单、明确,只需要配对使用正确即可,如果不配对使用则会导致内存泄漏,进而导致内存分配失败。我们着重讨论内存泄漏的正常和不正的原因,详细如下:
内存泄漏类型
案例、备注
4.5、参数传递
函数参数传递的不像内存分配、释放那么自由,受到诸多的限制,例如类型限制、常量修饰符限制、传递类型限制等,并且编译器能检测出大部分参数传递方面的错误,然而仍然无法阻止我们犯错误,到底是由于疏忽还是认识不足导致这样的情况呢?
在详细阐述前我们一起来看一个实践中碰到的因为参数传递错误引发的崩溃案例,请看代码:
3 LPMBuffer pBuffer = m_LexerState.buff;
16 this->ResizeBuffer(&pBuffer, newsize);
19 pBuffer->buffer[pBuffer->length++] = cast(char, ch); ------------> 程序崩溃
代码的真实意图是要扩充缓冲区(m_LexerState.buff),但由于通过中间变量的方式传递,并未真正的把扩充后的缓冲区地址传给&m_LexerState.buff,所以对象缓冲区实际没有变化,当访问扩充后的地址空间时,访问越界,程序崩溃;
在堆栈溢出章节我们还将看到类、结构类型的参数以值传递方式带来的危害:堆栈溢出、无法深度复制导致数据丢失,因此这两类参数应该尽量以指针、引用方式传递,对于不需要修改的参数尽量使用常量修饰符修饰(const)。
4.6、堆栈溢出
实践中碰到的另一类典型的崩溃是堆栈溢出,代码能编译通过,运行过程中会出现堆栈溢出而崩溃,为了加深对堆栈溢出的印象我们先看一个案例:[直接摘取自项目代码]
void CPerfJobRunResultModel::Load(LPCTSTR a_pszJobRunResultFilePath)
int iRet = 0;
// 获取头结构元数据
LPTDRMETA pstDrMetaData = tdr_get_meta_by_name(m_pstDrMetaLibrary, "PERF_JOBRUN_RESULT");
// 读取结构头获取整个结构空间大小
TDRDATA tdrHost;
PERF_JOBRUN_RESULT stJobRunResult;
memset(&stJobRunResult, 0, sizeof(PERF_JOBRUN_RESULT));
tdrHost.iBuff = sizeof(PERF_JOBRUN_RESULT);
tdrHost.pszBuff = (char *)&stJobRunResult;
iRet = tdr_input_file(pstDrMetaData, &tdrHost, a_pszJobRunResultFilePath, tdr_get_meta_current_version(pstDrMetaData), TDR_IO_NEW_XML_VERSION);
if (TDR_ERR_IS_ERROR(iRet))
// 错误处理代码,省略之
// 重新分配内存并加载文件
UINT memSize = sizeof(PERF_JOBRUN_RESULT) + (stJobRunResult.dwSuiteNum - 1) * sizeof(PERF_JOBRUN_SUITE_RESULT);
m_pstJobRunResultModel = (LPPERF_JOBRUN_RESULT)malloc(memSize);
if (NULL == m_pstJobRunResultModel)
UserThrowATPException(01005, "分配内存失败!");
memset(m_pstJobRunResultModel, 0, memSize);
tdrHost.iBuff = memSize;
tdrHost.pszBuff = (char *)m_pstJobRunResultModel;
iRet = tdr_input_file(pstDrMetaData, &tdrHost, a_pszJobRunResultFilePath, tdr_get_meta_current_version(pstDrMetaData), TDR_IO_NEW_XML_VERSION);
if (TDR_ERR_IS_ERROR(iRet))
UserThrowATPException(01005, "加载 TDR 实例文件失败: %s\n%s", a_pszJobRunResultFilePath, tdr_error_string(iRet));
m_HasUpdated = FALSE;
这个函数初期运行平稳,没出现啥问题;
后来为了支持扩容,修改了性能测试相关数据结构
,随后被发现出现了堆栈溢出崩溃;扩大程序栈空间(4M è 8M è 16M),仍然出现堆栈溢出;反复调试验证,堆栈溢出都集中出现在同一个函数:即进入函数的瞬间
void CPerfJobRunResultModel::Load(LPCTSTR a_pszJobRunResultFilePath)
随着一个个疑点的排除,问题集中在函数代码内;进一步测试发现性能测试数据结构占用16M空间:【PERF_JOBRUN_RESULT stJobRunResult;】,但还是没办法证实问题根源,于是在网络上搜寻触发函数(_chkstk)原因,终于找到一个说法是:函数内局部变量是在堆栈分配空间,当局部变量空间大于4K时(x86为4K, x64为8K,Itanium为16K)会触发函数(_chkstk)检查;结构变量属于值变量,在栈(Stack)空间分配,结构变量占用16M空间远远大于默认的1M空间,所以引发了堆栈溢出;
堆栈溢出并不可怕,只要我们认识它、掌握它的规律就知道如何防范;这里把常见的堆栈溢出类型一一列举,工作中稍加注意就可以预防;
该段取时间的代码一直运行正常,突然有一天出现了错误,此前运行非常完好的代码怎么会突然出错呢?你百思不得其解。从代码本身来看,主要涉及从 uint64_t 到 int 类型的转换,即从无符号类型向有符号类型转换;据当事人事后分析得出的结论是
由于转换操作是直接截断,而有符号类型的正负是根据最高位来解读的,0 表示该数据为正数,1 表示该数据为负数;
基于此,转换的正确与否基于此那只能求菩萨保佑了。
我们再来看一个类型宽度一样的数据类型转换的案例,由于类型宽度相同,无需做截断处理,有符号类型同样基于最高位来确定数据的数值,于是就看到如下的结果。
int main()
// 有符号到无符号
short i = -3;
unsigned short u = 0;
cout << (u = i) << "\n"; // 输出: 65533
// 无符号到有符号
i = 0;
u = 65533;
cout << (i = u) << "\n"; // 输出: -3
原因:typeNavigate是个char型变量,受ARM编译器编译参数影响,这里的char等同于unsigned char,导致 if 语句永远为真,引起逻辑错误;
【Android NDK 开发】NDK C/C++ 代码崩溃调试 - Tombstone 报错信息日志文件分析 ( 获取 tombstone_0X 崩溃日志信息 )
在_ConnectionPtr析构的时候要将_ConnectionPtr置NULL
ADODB::_ConnectionPtr conn;conn.CreateInstance(__uuidof(ADODB::Connection));.