內存調試技巧

引言

C 和 C++ 程序中的內存錯誤很是有害:它們很常見,而且可能致使嚴重的後果。來自計算機應急響應小組(請參見參考資料)和供應商的許多最嚴重的安全公告都是由簡單的內存錯誤形成的。自從 70 年代末期以來,C 程序員就一直討論此類錯誤,但其影響在 2007 年仍然很大。更糟的是,若是按個人思路考慮,當今的許多 C 和 C++ 程序員可能都會認爲內存錯誤是不可控制而又神祕的頑症,它們只能糾正,沒法預防。 html

但事實並不是如此。本文將讓您在短期內理解與良好內存相關的編碼的全部本質: 程序員

正確的內存管理的重要性

存在內存錯誤的 C 和 C++ 程序會致使各類問題。若是它們泄漏內存,則運行速度會逐漸變慢,並最終中止運行;若是覆蓋內存,則會變得很是脆弱,很容易受到惡意用戶的攻擊。從 1988 年著名的莫里斯蠕蟲 攻擊到有關 Flash Player 和其餘關鍵的零售級程序的最新安全警報都與緩衝區溢出有關:「大多數計算機安全漏洞都是緩衝區溢出」,Rodney Bates 在 2004 年寫道。 數據庫

在可使用 C 或 C++ 的地方,也普遍支持使用其餘許多通用語言(如 Java™、Ruby、Haskell、C#、Perl、Smalltalk 等),每種語言都有衆多的愛好者和各自的優勢。可是,從計算角度來看,每種編程語言優於 C 或 C++ 的主要優勢都與便於內存管理密切相關。與內存相關的編程是如此重要,而在實踐中正確應用又是如此困難,以至於它支配着面向對象編程語言、功能性編程語言、高級編程語言、聲明性編程語言和另一些編程語言的全部其餘變量或理論。 編程

與少數其餘類型的常見錯誤同樣,內存錯誤仍是一種隱性危害:它們很難再現,症狀一般不能在相應的源代碼中找到。例如,不管什麼時候何地發生內存泄漏,均可能表現爲應用程序徹底沒法接受,同時內存泄漏不是顯而易見。 數組

所以,出於全部這些緣由,須要特別關注 C 和 C++ 編程的內存問題。讓咱們看一看如何解決這些問題,先不談是哪一種語言。 安全

內存錯誤的類別

首先,不要失去信心。有不少辦法能夠對付內存問題。咱們先列出全部可能存在的實際問題: 網絡

  • 內存泄漏
  • 錯誤分配,包括大量增長 free() 釋放的內存和未初始化的引用
  • 懸空指針
  • 數組邊界違規

這是全部類型。即便遷移到 C++ 面向對象的語言,這些類型也不會有明顯變化;不管數據是簡單類型仍是 C 語言的 struct 或 C++ 的類,C 和 C++ 中內存管理和引用的模型在原理上都是相同的。如下內容絕大部分是「純 C」語言,對於擴展到 C++ 主要留做練習使用。 編程語言

內存泄漏

在分配資源時會發生內存泄漏,可是它從不回收。下面是一個可能出錯的模型(請參見清單 1): 函數

清單 1. 簡單的潛在堆內存丟失和緩衝區覆蓋
void f1(char *explanation)
	{
	    char p1;

	    p1 = malloc(100);
            (void) sprintf(p1,
                           "The f1 error occurred because of '%s'.",
                           explanation);
            local_log(p1);
	}

您看到問題了嗎?除非 local_log() 對 free() 釋放的內存具備不尋常的響應能力,不然每次對 f1 的調用都會泄漏 100 字節。在記憶棒增量分發數兆字節內存時,一次泄漏是微不足道的,可是連續操做數小時後,即便如此小的泄漏也會削弱應用程序。 工具

在實際的 C 和 C++ 編程中,這不足以影響您對 malloc() 或 new 的使用,本部分開頭的句子提到了「資源」不是僅指「內存」,由於還有相似如下內容的示例(請參見清單 2)。FILE 句柄可能與內存塊不一樣,可是必須對它們給予同等關注:

清單 2. 來自資源錯誤管理的潛在堆內存丟失
int getkey(char *filename)
	{
	    FILE *fp;
	    int key;

	    fp = fopen(filename, "r");
	    fscanf(fp, "%d", &key);
	    return key;
        }

fopen 的語義須要補充性的 fclose。在沒有 fclose() 的狀況下,C 標準不能指定發生的狀況時,極可能是內存泄漏。其餘資源(如信號量、網絡句柄、數據庫鏈接等)一樣值得考慮。

內存錯誤分配

錯誤分配的管理不是很困難。下面是一個示例(請參見清單 3):

清單 3. 未初始化的指針
void f2(int datum)
	{
	    int *p2;

                /* Uh-oh!  No one has initialized p2. */
            *p2 = datum;
	       ...
        }

關於此類錯誤的好消息是,它們通常具備顯著結果。在 AIX® 下,對未初始化指針的分配一般會當即致使 segmentation fault 錯誤。它的好處是任何此類錯誤都會被快速地檢測到;與花費數月時間才能肯定且難以再現的錯誤相比,檢測此類錯誤的代價要小得多。

在此錯誤類型中存在多個變種。free() 釋放的內存比 malloc() 更頻繁(請參見清單 4):

清單 4. 兩個錯誤的內存釋放
/* Allocate once, free twice. */
	void f3()
	{
	    char *p;

	    p = malloc(10);
	     ...
            free(p);
	     ...
            free(p);
        }

        /* Allocate zero times, free once. */
	void f4()
	{
	    char *p;

                /* Note that p remains uninitialized here. */
	    free(p);
	}

這些錯誤一般也不太嚴重。儘管 C 標準在這些情形中沒有定義具體行爲,但典型的實現將忽略錯誤,或者快速而明確地對它們進行標記;總之,這些都是安全情形。

懸空指針

懸空指針比較棘手。當程序員在內存資源釋放後使用資源時會發生懸空指針(請參見清單 5):

清單 5. 懸空指針
void f8() 
       {
	   struct x *xp;

	   xp = (struct x *) malloc(sizeof (struct x));
	   xp.q = 13;
	   ...
	   free(xp);
	   ...
	       /* Problem!  There's no guarantee that
		  the memory block to which xp points
		  hasn't been overwritten. */
	   return xp.q;
       }

傳統的「調試」難以隔離懸空指針。因爲下面兩個明顯緣由,它們很難再現:

  • 即便影響提早釋放內存範圍的代碼已本地化,內存的使用仍然可能取決於應用程序甚至(在極端狀況下)不一樣進程中的其餘執行位置。
  • 懸空指針可能發生在以微妙方式使用內存的代碼中。結果是,即便內存在釋放後當即被覆蓋,而且新指向的值不一樣於預期值,也很難識別出新值是錯誤值。

懸空指針不斷威脅着 C 或 C++ 程序的運行狀態。

數組邊界違規

數組邊界違規十分危險,它是內存錯誤管理的最後一個主要類別。回頭看一下清單 1;若是 explanation 的長度超過 80,則會發生什麼狀況?回答:難以預料,可是它可能與良好情形相差甚遠。特別是,C 複製一個字符串,該字符串不適於爲它分配的 100 個字符。在任何常規實現中,「超過的」字符會覆蓋內存中的其餘數據。內存中數據分配的佈局很是複雜而且難以再現,因此任何症狀都不可能追溯到源代碼級別的具體錯誤。這些錯誤一般會致使數百萬美圓的損失。

內存編程的策略

勤奮和自律可讓這些錯誤形成的影響降至最低限度。下面咱們介紹一下您能夠採用的幾個特定步驟;我在各類組織中處理它們的經驗是,至少能夠按必定的數量級持續減小內存錯誤。

編碼風格

編碼風格是最重要的,我還從沒有看到過其餘任何做者對此加以強調。影響資源(特別是內存)的函數和方法須要顯式地解釋自己。下面是有關標頭、註釋或名稱的一些示例(請參見清單 6)。

清單 6. 識別資源的源代碼示例
/********
	 * ...
	 *
	 * Note that any function invoking protected_file_read()
	 * assumes responsibility eventually to fclose() its
	 * return value, UNLESS that value is NULL.
	 *
	 ********/
	FILE *protected_file_read(char *filename)
	{
	    FILE *fp;

	    fp = fopen(filename, "r");
	    if (fp) {
		...
	    } else {
		...
	    }
	    return fp;
	}

        /*******
	 * ...
	 *
	 * Note that the return value of get_message points to a
	 * fixed memory location.  Do NOT free() it; remember to
	 * make a copy if it must be retained ...
	 *
	 ********/
	char *get_message()
	{
	    static char this_buffer[400];

            ...
	    (void) sprintf(this_buffer, ...);
	    return this_buffer;
        }


        /********
	 * ...
	 * While this function uses heap memory, and so 
	 * temporarily might expand the over-all memory
	 * footprint, it properly cleans up after itself.
	 * 
	 ********/
        int f6(char *item1)
	{
	    my_class c1;
	    int result;
            ...
	    c1 = new my_class(item1);
	    ...
            result = c1.x;
	    delete c1;
	    return result;
	}
	/********
	 * ...
	 * Note that f8() is documented to return a value
	 * which needs to be returned to heap; as f7 thinly
	 * wraps f8, any code which invokes f7() must be
	 * careful to free() the return value.
	 *
	 ********/
	int *f7()
	{
	    int *p;

	    p = f8(...);
	    ...
	    return p;
	}

使這些格式元素成爲您平常工做的一部分。可使用各類方法解決內存問題:

  • 專用庫
  • 語言
  • 軟件工具
  • 硬件檢查器

在這整個領域中,我始終認爲最有用而且投資回報率最大的是考慮改進源代碼的風格。它不須要昂貴的代價或嚴格的形式;能夠始終取消與內存無關的段的註釋,但影響內存的定義固然須要顯式註釋。添加幾個簡單的單詞可以使內存結果更清楚,而且內存編程會獲得改進。

我沒有作受控實驗來驗證此風格的效果。若是您的經歷與我同樣,您將發現沒有說明資源影響的策略簡直沒法忍受。這樣作很簡單,但帶來的好處太多了。

檢測

檢測是編碼標準的補充。兩者各有裨益,但結合使用效果特別好。機靈的 C 或 C++ 專業人員甚至能夠瀏覽不熟悉的源代碼,並以極低的成本檢測內存問題。經過少許的實踐和適當的文本搜索,您可以快速驗證平衡的 *alloc() 和 free() 或者 new 和 delete 的源主體。人工查看此類內容一般會出現像清單 7 中同樣的問題。

清單 7. 棘手的內存泄漏
static char *important_pointer = NULL;
	void f9()
	{
	    if (!important_pointer) 
		important_pointer = malloc(IMPORTANT_SIZE);
            ...
	    if (condition)
		    /* Ooops!  We just lost the reference 
		       important_pointer already held. */
		important_pointer = malloc(DIFFERENT_SIZE);
            ...
        }

若是 condition 爲真,簡單使用自動運行時工具不能檢測發生的內存泄漏。仔細進行源分析能夠今後類條件推理出證明正確的結論。我重複一下我寫的關於風格的內容:儘管大量發佈的內存問題描述都強調工具和語言,對於我來講,最大的收穫來自「軟的」以開發人員爲中心的流程變動。您在風格和檢測上所作的任何改進均可以幫助您理解由自動化工具產生的診斷。

靜態的自動語法分析

固然,並非只有人類才能讀取源代碼。您還應使靜態語法分析 成爲開發流程的一部分。靜態語法分析是 lint、嚴格編譯 和幾種商業產品執行的內容:掃描編譯器接受的源文本和目標項,但這多是錯誤的症狀。

但願讓您的代碼無 lint。儘管 lint 已過期,並有必定的侷限性,可是,沒有使用它(或其較高級的後代)的許多程序員犯了很大的錯誤。一般狀況下,您可以編寫忽略 lint 的優秀的專業質量代碼,但努力這樣作的結果一般會發生重大錯誤。其中一些錯誤影響內存的正確性。與讓客戶首先發現內存錯誤的代價相比,即便對這種類別的產品支付最昂貴的許可費也失去了意義。清除源代碼。如今,即便 lint 標記的編碼可能向您提供所需的功能,但極可能存在更簡單的方法,該方法可知足 lint,而且比較強鍵又可移植。

內存庫

補救方法的最後兩個類別與前三個明顯不一樣。前者是輕量級 的;一我的能夠容易地理解並實現它們。另外一方面,內存庫和工具一般具備較高的許可費用,對部分開發人員來講,它們須要進一步完善和調整。有效地使用庫和工具的程序員是理解輕量級的靜態 方法的人員。可用的庫和工具給人的印象很深:其做爲組的質量很高。可是,即便最優秀的編程人員也可能會被忽略內存管理基本原則的很是任性的編程人員攪亂。據我觀察,普通的編程人員在嘗試利用內存庫和工具進行隔離工做時也只能感到灰心。

因爲這些緣由,咱們催促 C 和 C++ 程序員爲解決內存問題先了解一下本身的源。在這完成以後,纔去考慮庫。

使用幾個庫可以編寫常規的 C 或 C++ 代碼,並保證改進內存管理。Jonathan Bartlett 在 developerWorks 的 2004 評論專欄中介紹了主要的候選項,能夠在下面的參考資料部分得到。庫能夠解決多種不一樣的內存問題,以至於直接對它們進行比較是很是困難的;這方面的常見主題包括垃圾收集智能指針 和 智能容器。大致上說,庫能夠自動進行較多的內存管理,這樣程序員能夠犯更少的錯誤。

我對內存庫有各類感覺。他們在努力工做,但我看到他們在項目中得到的成功比預期要小,尤爲在 C 方面。我還沒有對這些使人失望的結果進行仔細分析。例如,業績應該與相應的手動 內存管理同樣好,可是這是一個灰色區域——尤爲在垃圾收集庫處理速度緩慢的狀況下。經過這方面的實踐得出的最明確的結論是,與 C 關注的代碼組相比,C++ 彷佛能夠較好地接受智能指針。

內存工具

開發真正基於 C 的應用程序的開發團隊須要運行時內存工具做爲其開發策略的一部分。已介紹的技術頗有價值,並且不可或缺。在您親自嘗試使用內存工具以前,其質量和功能您可能還不瞭解。

本文主要討論了基於軟件的內存工具。還有硬件內存調試器;在很是特殊的狀況下(主要是在使用不支持其餘工具的專用主機時)才考慮它們。

市場上的軟件內存工具包括專有工具(如 IBM Rational® Purify 和 Electric Fence)和其餘開放源代碼工具。其中有許多能夠很好地與 AIX 和其餘操做系統一塊兒使用。

全部內存工具的功能基本相同:構建可執行文件的特定版本(很像在編譯時經過使用 -g 標記生成的調試版本)、練習相關應用程序和研究由工具自動生成的報告。請考慮如清單 8 所示的程序。

清單 8. 示例錯誤
int main()
	{
	    char p[5];
	    strcpy(p, "Hello, world.");
	    puts(p);
	}

此程序能夠在許多環境中「運行」,它編譯、執行並將「Hello, world.\n」打印到屏幕。使用內存工具運行相同應用程序會在第四行產生一個數組邊界違規的報告。在瞭解軟件錯誤(將十四個字符複製到了只能容納五個字符的空間中)方面,這種方法比在客戶處查找錯誤症狀的花費小得多。這是內存工具的功勞。

結束語

做爲一名成熟的 C 或 C++ 程序員,您認識到內存問題值得特別關注。經過制訂一些計劃和實踐,能夠找到控制內存錯誤的方法。學習內存使用的正確模式,快速發現可能發生的錯誤,使本文介紹的技術成爲您平常工做的一部分。您能夠在開始時就消除應用程序中的症狀,不然可能要花費數天或數週時間來調試。

相關文章
相關標籤/搜索