一、前言
從事自動化測試平台開發的編程實踐中,遭遇了幾個程序崩潰問題,解決它們頗費了不少心思,解決過程中的曲折和徹夜的輾轉反側卻歷歷在目,一直尋思寫點東西,為這段難忘的經歷留點紀念,總結慘痛的教訓帶來的經驗,以期通過自己的經歷為他人和自己帶來福祉:寫出更高質量的程序;
由於 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 的資源管理器進程的內存布局視圖,如下所示:
圖(一)Windows資源管理器內存分布圖
圖(二)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\n", a, b); ====> 1, 0 |
為什么會這樣?有同事對該問題做了精辟的注解,為了尊重作者版權,故截圖分享,請看下圖:
【注】這個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(); |
案例代碼運行圖譜,從左到右從上到下順序擺放 |
|||
|
|
||
|
|||
|
|
||
|
|
這段代碼在 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', '\0'}; POINT stPoint = {0, 0};
|
數組、結構、聯合類型 |
字符串類型 |
char code[ ] = "abc"; char code[3] = "abcd"; |
Microsoft C/C++ 支持最長2048字節的字符串 |
C/C++ 提供了兩種初始化的機制可以完成結構實例和類實例的初始化,他們是:
初始化機制 |
簡要說明 |
備注 |
構造函數 |
1、用戶使用 new/new[] 操作時自動調用 2、構造函數順序:從基類到子類逐層調用 3、成員變量可在構造函數主體執行前初始化 |
編譯器會自動安插基類構造函數調用代碼 |
用戶函數 |
用戶自定義並顯式調用完成實例對象初始化, 例如:Initialize(); |
容易忘記調用 |
子類的構造函數被 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 } 0009 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);} 0015 0016 private: 0017 void* m_pBuffer; 0018 } 0019 0020 Derived * pDerived = new Derived(); 0021 Base * pBase = dynamic_cast<Base *>(pDerived); 0022 delete pBase; 0023 |
上述代碼由於繼承關系和內存初始化的特點而產生了兩處缺陷:
代碼位置 |
缺陷說明 |
備注 |
Line 20 |
由於 Initialize 函數是虛擬的並且在子類中覆蓋了子類的定義,當基類構造函數調用 Initialize 時,它使用了子類未分配的內存; |
產生崩潰 |
Line 22 |
delete 操作調用Base類的析構函數,然后釋放對象所占用的內存,導致未釋放分配的內存; |
局部釋放 |
【經驗總結】
構造函數中要避免調用虛函數;
析構函數中要避免拋出異常;
3.5.3、變量多態與切片
在我們深入探討這個問題前我們先看一個代碼案例,然后我們基於這個案例講解本節:
class Shape { public: virtual ~Shape(); virtual void Draw() const {} protected: uint32 m_lineWidth; uint32 m_lineColor; };
class Rectangle : public Shape { public: virtual ~Rectangle(); virtual void Draw(); protected: uint32 m_width; uint32 m_height; };
class Trapezium : public Rectangle { public: virtual ~Trapezium(); virtual void Draw(); private: uint32 m_widthUp; }; |
圖(三)類(Trapezium)實例內存空間分布圖
類繼承關系帶來了兩個全新的概念:多態(類透視)和對象切片;這兩類應用在面向對象編程(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++ 編譯器會完全釋放,其它編譯器不確定。由於它使用普通對象釋放操作,第二個、第三個字符串對象未調用其析構函數,字符串對象持有的資源未釋放,導致內存泄漏。
四、C++ 錯誤根源分析
前面我們回顧了各個方面的技術點,分析和解決實踐中遇到的案例就比較容易了,下面請跟我一起來看看一些常見案例;
4.1、變量未聲明
由於 C/C++ 是靜態類型編譯語言,這類型錯誤一般都在編譯階段就會發現,不會帶入到運行時階段,但是這種類型的錯誤客觀存在,並且會增大我們的排錯時間;
出現這種類型的錯誤一般源自兩種情況,一種是從動態語言轉為使用 C/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行 } |
內存訪問觸發的錯誤時常發生,但總結起來可以歸納為幾類,他們分別是:數組訪問越界、指針訪問越界、字符串訪問越界、迭代器訪問越界、訪問游移指針對象、訪問空指針,他們有共同特征,也存在着一些細微的差別,讓我們一起來看看:
內存訪問出錯類別 |
出錯關鍵點 |
數組訪問越界 |
索引序號大於等於最大個數 |
指針訪問越界 |
指針超出最大分配范圍 |
字符串訪問越界 |
1、字符串結束符不存在 2、目標字符串緩沖區小於源字符串 |
迭代器訪問越界 |
1、迭代器越過右邊界 2、用其它容器迭代位置賦值 |
訪問游移指針 |
指針所指內存被釋放並回收再分配使用 |
訪問野指針 |
變量聲明時未初始化,鏈接器分配地址對應的隨機值 例如:0xCDCDCDCD |
訪問空指針 |
指針所指地址為零(NULL) |
為節省篇幅,這里不准備一一列舉案例,有興趣的同學可收集和羅列一下案例。
對指針加強檢測自始至終都是一個良好的習慣,這是防御性編程的核心;
4.4、分配和釋放
內存分配和釋放在我們的程序中分分秒秒的進行着,它分為隱式分配回收和顯式分配回收兩種,我們詳細說明一下這兩種情況:
分配回收類型 |
表現特征 |
案例、說明 |
隱式分配回收 |
1、直接聲明並使用 2、編譯器生成分配、回收代碼 |
適用於自動變量 CListCtrl m_listProject; |
顯示分配回收 |
1、間接聲明並使用 2、用戶編寫分配、回收代碼 |
new/delete new[]/delete[] malloc/realloc/free OS提供的分配回收API |
按照摩爾定律,內存器件的成本迅速下降,但內存緊缺的問題卻沒有隨之解決,內存分配失敗的問題依然存在,保持檢測內存指針或捕獲內存異常的習慣依然有必要;由於內存分配失敗的原因是內存不足,故我們把探討的重點放到內存不足的原因上來。
內存分配釋放語義簡單、明確,只需要配對使用正確即可,如果不配對使用則會導致內存泄漏,進而導致內存分配失敗。我們着重討論內存泄漏的正常和不正的原因,詳細如下:
內存泄漏類型 |
原因分析 |
案例、備注 |
對象內存未釋放 |
分配、釋放操作未配對使用導致: new/delete new[]/delete[] malloc/free 其它 API |
|
對象內存局部釋放 |
基類指針指向子類對象,釋放該指針對象 |
基類析構函數未定義為虛函數 |
對象數組釋放錯誤 |
未逐個調用對象的構造函數 |
new[]/delete[] 配對使用 |
內存碎片 |
由於數據對齊、內存分塊分配后出現無法使用的小內存塊 |
這個難以避免,可以忽略它 |
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空間,所以引發了堆棧溢出;
堆棧溢出並不可怕,只要我們認識它、掌握它的規律就知道如何防范;這里把常見的堆棧溢出類型一一列舉,工作中稍加注意就可以預防;
實現類型 |
核心表現 |
備注 |
遞歸調用 |
結束條件不能滿足而無法返回 |
|
循環調用 |
間接的函數調用循環 |
|
消息循環 |
消息處理不當導致消息構成循環 |
|
大對象參數 |
結構、對象以值傳遞方式使用 |
應以指針、引用傳遞 |
大對象局部變量 |
函數中結構、類變量直接定義 |
使用 new 操作創建 |
4.7、轉換錯誤
標量類型強制轉換出錯是比較隱秘,因為 C/C++ 中本身就隱藏着大量的類型轉換,不易為人察覺;但它經常來得莫名欺騙,排查起來亦痛苦萬分。
我們看一個實踐中發生的案例:http://km.oa.com/group/728/articles/show/14051
|
該段取時間的代碼一直運行正常,突然有一天出現了錯誤,此前運行非常完好的代碼怎么會突然出錯呢?你百思不得其解。從代碼本身來看,主要涉及從 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 } |
我們再看一個實踐中的案例:http://km.oa.com/group/533/topics/show/14900
//導航提示邏輯,提示顯示3秒 if (oper->typeNavigate >=0) { oper->typeNavigateCount++; if (oper->typeNavigateCount > 2) { oper->typeNavigate = -1; oper->typeNavigateCount = 0; TCtrlBase_Invalidate((TCtrlBase*)object, NULL); } } |
現象:這段代碼在模擬器運行正常,在MTK真機有問題;
原因:typeNavigate是個char型變量,受ARM編譯器編譯參數影響,這里的char等同於unsigned char,導致 if 語句永遠為真,引起邏輯錯誤;
【問題】如果要逐字節操作大塊內存時應該使用什么類型?char or signed char or unsigned char ?
五、C++ 編程最佳實踐
熟讀唐詩三百首,不會作詩也會吟 |
1、遵循編程規范,例如公司的編程規范、Google C++ 編程規范等;
2、小就是美、簡單就是美;
3、盡可能多的使用 const 修飾符;
4、聲明即初始化:變量、對象聲明時就初始化;
5、結構、類等實例變量都以指針變量的方式使用;
6、始終在使用前檢測指針變量的有效性;
7、指針和標量類型使用值傳遞,其它都使用指針和引用傳遞;
8、多用智能指針: auto_ptr, shared_ptr,少用原始指針;
9、多用 new/delete/new[]/delete[],少用malloc/free/realloc;
10、多用只讀常量、局部變量,少用全局變量、靜態變量;
11、識別無符號數和有符號數的應用場景並正確選擇數據類型;
12、重試編譯器警告:重視並修復編譯器警告;