本文將帶您瞭解一些良好的和記憶體相關的編碼實踐,以將記憶體錯誤保持在控制範圍內。記憶體錯誤是 C 和 C++ 編程的禍根:它們很普遍,認識其嚴重性已有二十多年,但始終沒有徹底解決,它們可能嚴重影響應用程式,並且很少有Team Dev對其制定明確的管理計劃。但好訊息是,它們並不怎麼神秘。引言
C 和 C++ 程式中的記憶體錯誤非常有害:它們很常見,並且可能導致嚴重的後果。來自電腦應急響應小組(請參見參考資料)和供應商的許多最嚴重的資訊安全諮詢都是由簡單的記憶體錯誤造成的。自從 70 年代末期以來,C 程式員就一直討論此類錯誤,但其影響在 2007 年仍然很大。更糟的是,如果按我的思路考慮,當今的許多 C 和 C++ 程式員可能都會認為記憶體錯誤是不可控制而又神秘的頑症,它們只能糾正,無法預防。
但事實並非如此。本文將讓您在短時間內理解與良好記憶體相關的編碼的所有本質:
正確的記憶體管理的重要性
存在記憶體錯誤的 C 和 C++ 程式會導致各種問題。如果它們泄漏記憶體,則運行速度會逐漸層慢,並最終停止運行;如果覆蓋記憶體,則會變得非常脆弱,很容易受到惡意使用者的攻擊。從 1988 年著名的莫裡斯蠕蟲攻擊到有關 Flash Player 和其他關鍵的零售級程式的最新安全警報都與緩衝區溢位有關:“大多數電腦安全性漏洞都是緩衝區溢位”,Rodney Bates 在 2004 年寫道。
在可以使用 C 或 C++ 的地方,也廣泛支援使用其他許多通用語言(如 Java?、Ruby、Haskell、C#、Perl、Smalltalk 等),每種語言都有眾多的愛好者和各自的優點。但是,從計算角度來看,每種程式設計語言優於 C 或 C++ 的主要優點都與便於記憶體管理密切相關。與記憶體相關的編程是如此重要,而在實踐中正確應用又是如此困難,以致於它支配著物件導向程式設計語言、功能性程式設計語言、進階程式設計語言、宣告式程式設計語言和另外一些程式設計語言的所有其他變數或理論。
與少數其他類型的常見錯誤一樣,記憶體錯誤還是一種隱性危害:它們很難再現,癥狀通常不能在相應的原始碼中找到。例如,無論何時何地發生記憶體流失,都可能表現為應用程式完全無法接受,同時記憶體流失不是顯而易見。
因此,出於所有這些原因,需要特別關注 C 和 C++ 編程的記憶體問題。讓我們看一看如何解決這些問題,先不談是哪種語言。
記憶體錯誤的類別
首先,不要失去信心。有很多辦法可以對付記憶體問題。我們先列出所有可能存在的實際問題:
1.記憶體流失
2.錯誤分配,包括大量增加 free()釋放的記憶體和未初始化的引用
3.懸null 指標
4.數組邊界違規
這是所有類型。即使遷移到 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 位元組。在記憶棒增量分發數MB記憶體時,一次泄漏是微不足道的,但是連續運算元小時後,即使如此小的泄漏也會削弱應用程式。
在實際的 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 標準在這些情形中沒有定義具體行為,但典型的實現將忽略錯誤,或者快速而明確地對它們進行標記;總之,這些都是安全情形。
懸null 指標
懸null 指標比較棘手。當程式員在記憶體資源釋放後使用資源時會發生懸null 指標(請參見清單 5):
清單 5. 懸null 指標
以下是引用片段: 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; } |
傳統的“調試”難以隔離懸null 指標。由於下面兩個明顯原因,它們很難再現:
即使影響提前釋放記憶體範圍的代碼已本地化,記憶體的使用仍然可能取決於應用程式甚至(在極端情況下)不同進程中的其他執行位置。
懸null 指標可能發生在以微妙方式使用記憶體的代碼中。結果是,即使記憶體在釋放後立即被覆蓋,並且新指向的值不同於預期值,也很難識別出新值是錯誤值。懸null 指標不斷威脅著 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 的應用程式的Team Dev需要運行時記憶體工具作為其開發策略的一部分。已介紹的技術很有價值,而且不可或缺。在您親自嘗試使用記憶體工具之前,其品質和功能您可能還不瞭解。
本文主要討論了基於軟體的記憶體工具。還有硬體記憶體調試器;在非常特殊的情況下(主要是在使用不支援其他工具的專用主機時)才考慮它們。
市場上的軟體記憶體工具包括專有工具(如 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++ 程式員,您認識到記憶體問題值得特別關注。通過制訂一些計劃和實踐,可以找到控制記憶體錯誤的方法。學習記憶體使用量的正確模式,快速發現可能發生的錯誤,使本文介紹的技術成為您日常工作的一部分。您可以在開始時就消除應用程式中的癥狀,否則可能要花費數天或數周時間來調試。