C++語言是桌面系統,尤其是系統軟件、大型應用軟件的主流開發語言。C++語言以其靈活性著稱,同時也更復雜。利用C++編寫健壯的代碼,更具有挑戰性。C++允許動態內存管理, 同時也容易導致更多和內存相關的問題。一般而言, 除了系統設計上的缺陷, 基於C++的軟件的缺陷和錯誤大部分都和內存缺陷(主要包括內存訪問錯誤和內存洩漏兩類)相關。 所以,消除代碼中的內存相關缺陷,成為程序員編寫、調試、維護代碼中的任務,也是保證軟件質量的關鍵。
本文的工作基於“863”計劃項目“面向網絡海量空間信息的大型GIS”課題。該系統是基於C++/MFC編寫,開發環境是Visual Studio .net 2003。本文基於此項目的工程實踐,總結了如何使用C++語言機制、開發環境和相關質量保證工具來預防、發現各種編譯期、運行期和內存相關的缺陷的方法和工具。
1 遵循C++相關的編碼規范和慣用法,預防缺陷
編碼規范是語言相關的規則,是經過實踐總結出來的經驗。良好的編程標准將有效地幫助開發人員避免開發有潛在危險的代碼。一般來說,為了減少內存缺陷,應該遵循下列編碼規則[1]:
(1)基類或者帶有虛函數的類應該將其析構函數聲明為虛函數。
(2)在構造函數中防止內存洩漏,在析構函數中不要拋出異常。
(3)使用對應形式的new和delete。即:用delete來釋放new申請的內存,delete[]釋放new[]申請的內存。
(4)指針在使用前必須初始化,指向動態內存的指針在釋放後應立即置為空。
(5)如果類構造函數中分配了資源,那麼需要顯式提供拷貝構造函數和賦值操作符,並且在析構函數中釋放資源。
值得重視的是C++中的慣用法RAII。RAII核心思想是利用對象來管理資源,在對象的構造函數中獲取資源,在其析構函數中釋放資源[2]。為了保證動態申請的內存能在即使出現異常的情況下仍能釋放,比較理想的方法是使用局部變量來管理動態內存的所有權(ownership),就是所謂的智能指針。STL中的auto_ptr就是為解決資源所有權問題設計的,但是缺少對引用數和數組的支持並且不能用在STL容器中。Boost庫[3]提供的智能指針相對成熟,實用價值高。其中,shared_ptr線程安全並且可以用在STL容器中。具體示例參考文獻[3]。
1.1 編碼規范檢查工具 CodeWizard
CodeWizard能夠對源程序直接進行自動掃描、分析和檢查。一旦發現違例,產生信息告知與哪條規則不符並作出解釋。以CodeWizard 4.3 為例,其中內置了超過500條編碼標准。CodeWizard可以選擇對於當前的工程執行哪些編碼標准。CodeWizard可以和VC++緊密集成,安裝完畢以後,VC++中有CodeWizard工具條。
1.2 代碼檢查工具 PC-Lint
PC-Lint可檢查編譯器不易發現的錯誤。PC-Lint可對100多個C庫函數進行檢查,可以發現標准C/C++代碼中的1 000多個常見錯誤。要把PC-lint和Visual Studio集成在一起,需要自己配置。Jon Zyzyck提供了一個報告生成器,可以幫助完成這個工作。 。文獻[4]說明了如何在VC++環境中集成PC-Lint。
2 利用語言機制、開發環境和相關工具以預防和發現內存缺陷
發現問題是解決問題的前提。相對於修復內存缺陷,發現內存缺陷並准確定位導致缺陷的代碼更為費時費力。及早准確地發現內存缺陷,對於提高開發效率非常重要。
2.1 利用斷言及早暴露內存缺陷
斷言是布爾調試語句,用來檢測在程序運行的時候某一條件的值是否總為真。斷言經常用來確認函數的輸入、輸出,檢查對的當前狀態是否合法等。 在以下的場景使用斷言可以幫助發現和內存非法訪問相關的錯誤:
(1)驗證指針是否可讀/寫。在函數的入口處,經常需要驗證指針所指向的內容區域是否可讀/寫。 通常采用assert(p!= NULL)的檢測形式。 但是,指針的值不為空並不代表指針指向了合法可讀/寫內存。Win32 API提供了函數IsBadReadPtr、IsBadWritePtr、IsBadStringPtr、IsBadCodePtr用來檢測指針指向的內存區域是否可讀/寫。C運行時庫提供了_CrtIs ValidPointer、_CrtIsValidHeapPointer等函數,MFC庫提供了AfxIsValidAddress、AfxIsValidString函數來完成類似功能。
(2)對基於MFC的程序,ASSERT_VALID宏通過調用重載的AssertValid函數來確定指向CObject派生類對象的指針是否有效。ASSERT_VALID宏主要調用了AfxIsValidAddress函數和CObject派生類對象的AssertValid函數(參考MFC源代碼afx.h、objcore.cpp)。
2.2 利用C運行時刻庫檢查內存洩漏
VC++的C運行庫(CRT)提供了廣泛的功能,幫助用戶檢測內存洩漏。CRT提供了_CrtMemCheckPoint、_CrtDump MemoryLeaks、_CrtSetDbgFlag等函數來幫助調試內存洩漏。
對於非MFC的工程, 要開啟有效的內存洩漏報告功能, 需要進行如下設置:
(1)在StdAfx.h的頭部添加如下代碼並開啟編譯器/Yu 選項:
1.#define _CRTDBG_MAP_ALLOC
2.#include <stdlib.h>
3.#include <crtdbg.h>
4.#define DEBUG_NEW new(_NORMAL_BLOCK, THIS_FILE, __LINE__)
(2)確保在每個.cpp文件的頭部包含以下內容:
1.#include "stdafx.h"
2.#ifdef _DEBUG
3.#define new DEBUG_NEW
4.#undef THIS_FILE
5.static char THIS_FILE[] = __FILE__;
6.#endif
(3)在程序的開始處開啟報告內存洩漏的開關:
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF|_CRTDBG_LEAK_CHECK_DF);
對於MFC工程, MFC已經做了相關的工作, 只需要確認在每個.cpp文件的頭部包含上述第(2)點的內容。
在某些情況下,需要知道發生內存洩漏的內存塊中的內容,但是標准的內存轉儲只是內存塊頭部的十六進制形式。為了得到更多的有用信息,需要以用戶塊類型(_CLIENT_ BLOCK)申請內存,並利用_CrtSetDumpClient建立用戶塊型內存的轉儲函數。具體的說,對於不是從CObject繼承的類,需要:
(1)為每個類/結構指定一個用戶塊子類型(參考crtdbg.h)。
(2)在申請內存時,采用重載的new形式:void* __cdecl operator new(size_t nSize, int nType, LPCSTR lpszFileName, int nLine)(參考MFC源代碼 afxmem.cpp),其中nType就是用戶塊的子類型。
(3)創建一個用戶塊內存轉儲函數,專門對每種需要轉儲的子類型進行處理(需要包含dbgint.h)。
(4)利用_CrtSetDumpClient對用戶塊內存轉儲函數進行注冊(參考MFC源代碼dumpinit.cpp)。
對於從CObject繼承來的類,MFC 已經按照上述方法做了基礎工作(參考MFC源代碼 afxmem.cpp、dumpinit.cpp)。要有效轉儲從CObject繼承的對象,需要:(1)對每個從CObject繼承的類重載虛函數Dump。(2)在程序的初始化部分 加入代碼 afxDump.SetDepth(1)來開啟深度轉儲。
2.3 利用Purify和Insure++查找運行時內存缺陷
Rational Purify和Parasoft Insure++ 是用於運行時錯誤檢查的工具。Purify主要檢測:數組內存越界讀/寫,使用未初始化的內存,對已釋放的內存進行讀/寫,內存洩漏等。Insure++利用其專利技術(源碼插裝和運行時指針跟蹤)能夠發現大量的內存操作錯誤,報告錯誤的源代碼行和執行軌跡。根據筆者的測試(基於98個有各種內存錯誤的C++程序,涵蓋了典型情形),Insure++ 6.1都能准確檢測。
3 利用VC++環境的調試和診斷功能,檢查和發現常見內存缺陷
理解常見的內存缺陷問題以及在VC++環境下的症狀,能輔助我們減少問題的發生和及時修改問題。
從錯誤的表現形式上看, 和堆棧有關的錯誤主要分為兩大類:堆棧溢出和函數返回信息被破壞。
(1)堆棧溢出(overflow)
此類錯誤主要有兩種情形:
1)過大的局部變量。缺省情況下Windows為每個線程保留1M堆棧空間。在菜單Project->Properties->Configuration Properties -> Linker->System中可以看到Stack Reserve Size選項可以調整保留的堆棧空間大小。
2)遞歸調用層數過深。在調試過程中,調用堆棧(call stack)窗口中可以發現函數遞歸調用的模式。
(2)函數返回信息被破壞
此類錯誤主要有兩種情形:
1)對局部變量的寫操作超出了范圍(上溢)。在調試過程中,函數堆棧被破壞掉的明顯標志是無法顯示調用堆棧,並且錯誤發生在被調用函數即將返回的位置。
2)在調用函數和被調用函數之間如果出現了函數參數的不匹配或者調用規范的不一致。
為了檢查此類錯誤,應該在代碼編譯時打開/GS、/RTCs開關(在菜單Project->Properties->Configuration Properties-> C/C++->Code Generation下設置)。
另外一類錯誤是動態內存錯誤。典型的情況如下:
(1)內存寫越界。在調試版本中,如果是寫上溢,就會收到“Damage:after block...”的跟蹤消息,如果是寫下溢出就會收到“Damage: before block...”的跟蹤消息。
(2)刪除不合法指針。在調試版本中,刪除未初始化的指針或者非堆指針時,會收到_CrtIsValidHeapPointer斷言錯誤。
(3)多次釋放。在調試版本中,如果多次刪除同一指針, 會收到_BLOCK_TYPE_IS_VALID斷言錯誤。要防止此類錯誤,應在delete某個指向動態內存的指針後立即將其置為空。
4 利用Windows結構化異常處理機制處理發布版本軟件的內存崩潰
在程序的發布階段,應盡量減少程序錯誤尤其是內存崩潰。如果崩潰了,應該“優雅”地退出,盡量收集程序崩潰時的運行信息以幫助程序供應商後續的調試。要捕捉內存非法訪問並獲知非法訪問的指令地址、寄存器內容等信息,需要用到Windows的結構化異常處理(Structured Exception Handling,SEH)機制[6]。MiniDumpWriteDump是dbghelp.dll提供的一個 API函數(參考MSDN),用於轉儲用戶模式程序的一些信息(比如堆棧情況等)並存為一個文件(比如.dmp文件),此文件可以被微軟的調試器(VC++或者WinDBG)利用進行事後調試。使用此函數需要dbghelp.h、dbghelp.lib和dbghelp.dll(這些文件可以在Windows Platform SDK中找到)。
要事後根據.dmp文件調試代碼,需要為發布版本軟件產生debug symbols (pdb)文件(打開編譯器/DEBUG選項)。在拿到.dmp文件以後,用VC++打開.dmp文件,然後調試執行(按F5鍵)。這樣,崩潰現場就會重現。文獻[5]基於上述的方法實現了崩潰報告系統。 www.2cto.com
5 結論
實踐證明,在上述方法和工具支持下的減少軟件內存缺陷的方法和工具,可以有效防止和查找代碼中的內存錯誤和內存洩漏,並且能和開發人員日常編碼無縫結合,執行起來非常高效。上述方法配合單元測試、代碼評審、每日構建、Bug追蹤等措施,形成了一個高效的質量保證流程,在我們的大型平台軟件開發過程中起到了重要作用。
參考文獻
1 Sutter H, Alexandrescu A. C++ Coding Standards: 101 Rules, Guidelines, and Best Practices[M]. Addison-Wesley Professional, 2004-10.
2 Stroustrup B. The Design and Evolution of C++[M]. Addison-Wesley Professional, 1994-03.
3 Karlsson B. Beyond the C++ Standard Library: An Introduction to Boost[M]. Addison-Wesley Professional, 2005-08.
4 Zyzyck J. A Report Generator for PC-Lint[J]. Dr. Dobb's Journal, 2003, 28(2): 52.
5 Dietrich H. XCrash Report: Exception Handling and Crash Reporting[Z]. 2003-10. http://www.codeproject.com/debug/ XCrash ReportPt4.asp.
6 Richter J M. Programming Applications for Microsoft Windows[M]. Microsoft Press, 1999-09.
作者:zhongguoren666