1. 程式人生 > 其它 >【程式設計基礎】C語言記憶體使用的常見問題

【程式設計基礎】C語言記憶體使用的常見問題

所討論的“記憶體”主要指(靜態)資料區、堆區和棧區空間。資料區記憶體在程式編譯時分配,該記憶體的生存期為程式的整個執行期間,如全域性變數和static關鍵字所宣告的靜態變數。函式執行時在棧上開闢區域性自動變數的儲存空間,執行結束時自動釋放棧區記憶體。堆區記憶體亦稱動態記憶體,由程式在執行時呼叫malloc/calloc/realloc等庫函式申請,並由使用者顯式地呼叫free庫函式釋放。堆記憶體比棧記憶體分配容量更大,生存期由使用者決定,故非常靈活。然而,堆記憶體使用時很容易出現記憶體洩露、記憶體越界和重複釋放等嚴重問題。

一、 資料區記憶體

1記憶體越界

記憶體越界訪問分為讀越界和寫越界。讀越界表示讀取不屬於自己的資料,如讀取的位元組數多於分配給目標變數的位元組數。若所讀的記憶體地址無效,則程式立即崩潰;若所讀的記憶體地址有效,則可讀到隨機的資料,導致不可預料的後果。寫越界亦稱“緩衝區溢位”,所寫入的資料對目標地址而言也是隨機的,因此同樣導致不可預料的後果。

記憶體越界訪問會嚴重影響程式的穩定性,其危險在於後果和症狀的隨機性。這種隨機性使得故障現象和本源看似無關,給排障帶來極大的困難。

資料區記憶體越界主要指讀寫某一資料區記憶體(如全域性或靜態變數、陣列或結構體等)時,超出該記憶體區域的合法範圍。

寫越界的主要原因有兩種:

1) memset/memcpy/memmove等記憶體覆寫呼叫;

2) 陣列下標超出範圍。

該檢查機制的缺點是僅用於檢測寫越界,且拷貝和解引用次數增多,訪問效率有所降低。讀越界後果通常並不嚴重,除非試圖讀取不可訪問的區域,否則難以也不必檢測。

資料區記憶體越界通常會導致相鄰的全域性變數被意外改寫。因此若已確定被越界改寫的全域性變數,則可通過工具檢視符號表,根據地址順序找到前面(通常向高地址越界)相鄰的全域性資料,然後在程式碼中排查訪問該資料的地方,看看有哪些位置可能存在越界操作。

有時,全域性資料被意外改寫並非記憶體越界導致,而是某指標(通常為野指標)意外地指向該資料地址,導致其內容被改寫。野指標導致的記憶體改寫往往後果嚴重且難以定位。此時,可編碼檢測全域性資料發生變化的時機。若能結合堆疊回溯(Call Backtrace),則通常能很快地定位問題所在。

修改只讀資料區內容會引發段錯誤(Segmentation Fault),但這種低階失誤並不常見。一種比較隱祕的缺陷是函式內試圖修改由指標引數傳入的只讀字串。

因其作用域限制,靜態區域性變數的記憶體越界相比全域性變數越界更易發現和排查。

【對策】 某些工具可幫助檢查記憶體越界的問題,但並非萬能。記憶體越界通常依賴於測試環境和測試資料,甚至在極端情況下才會出現,除非精心設計測試資料,否則工具也無能為力。此外,工具本身也有限制,甚至在某些大型專案中,工具變得完全不可用。

與使用工具類似的是自行新增越界檢測程式碼,如本節上文所示。但為求安全性而封裝檢測機制的做法在某種意義上得不償失,既不及Java等高階語言的優雅,又損失了C語言的簡潔和高效。因此,根本的解決之道還是在於設計和編碼的審慎周密。相比事後檢測,更應注重事前預防。

程式設計時應重點走查程式碼中所有操作全域性資料的地方,杜絕可能導致越界的操作,尤其注意記憶體覆寫和拷貝函式memset/memcpy/memmove和陣列下標訪問。

2 多重定義

函式和定義時已初始化的全域性變數是強符號;未初始化的全域性變數是弱符號。多重定義的符號只允許最多一個強符號。Unix連結器使用以下規則來處理多重定義的符號:

規則一:不允許有多個強符號。在被多個原始檔包含的標頭檔案內定義的全域性變數會被定義多次(預處理階段會將標頭檔案內容展開在原始檔中),若在定義時顯式地賦值(初始化),則會違反此規則。

規則二:若存在一個強符號和多個弱符號,則選擇強符號。

規則三:若存在多個弱符號,則從這些弱符號中任選一個。

當不同檔案內定義同名(即便型別和含義不同)的全域性變數時,該變數共享同一塊記憶體(地址相同)。若變數定義時均初始化,則會產生重定義(multiple definition)的連結錯誤;若某處變數定義時未初始化,則無連結錯誤,僅在因型別不同而大小不同時可能產生符號大小變化(size of symbol `XXX' changed)的編譯警告。在最壞情況下,編譯連結正常,但不同檔案對同名全域性變數讀寫時相互影響,引發非常詭異的問題。這種風險在使用無法接觸原始碼的第三方庫時尤為突出。

【對策】 儘量避免使用全域性變數。若確有必要,應採用靜態全域性變數(無強弱之分,且不會和其他全域性符號產生衝突),並封裝訪問函式供外部檔案呼叫。

3 volatile修飾

關鍵字volatile用於修飾易變的變數,告訴編譯器該變數值可能會在任意時刻被意外地改變,因此不要試圖對其進行任何優化。每次訪問(讀寫)volatile所修飾的變數時,都必須從該變數的記憶體區域中重新讀取,而不要使用暫存器(CPU)中儲存的值。這樣可保證資料的一致性,防止由於變數優化而出錯。

以下幾種情況通常需要volatile關鍵字:

  • 外圍並行裝置的硬體暫存器(如狀態暫存器);
  • 中斷服務程式(ISR)中所訪問的非自動變數(Non-automatic Variable),即全域性變數;
  • 多執行緒併發環境中被多個執行緒所共享的全域性變數。

變數可同時由const和volatile修飾(如只讀的狀態暫存器),表明它可能被意想不到地改變,但程式不應試圖修改它。指標可由volatile修飾(儘管並不常見),如中斷服務子程式修改一個指向某buffer的指標時。

多執行緒環境下,指標pVal所指向值在函式CalcSquare執行時可能被意想不到地該變,因此dwTemp1和dwTemp2的取值可能不同,最終未必返回期望的平方值。

編譯器優化這段程式碼時,若addr地址的資料讀取太頻繁,優化器會將該地址上的值存入暫存器中,後續對該地址的訪問就轉變為直接從暫存器中讀取資料,如此將大大加快資料讀取速度。但在併發操作時,一個程序讀取資料,另一程序修改資料,這種優化就會造成資料不一致。此時,必須使用volatile修飾符。

【對策】 合理使用volatile修飾符。

二、 棧區記憶體

1 記憶體未初始化

未初始化的棧區變數其內容為隨機值。直接使用這些變數會導致不可預料的後果,且難以排查。

指標未初始化(野指標)或未有效初始化(如空指標)時非常危險,尤以野指標為甚。

【對策】 在定義變數時就對其進行初始化。某些編譯器會對未初始化發出警告資訊,便於定位和修改。

2 堆疊溢位

每個執行緒堆疊空間有限,稍不注意就會引起堆疊溢位錯誤。注意,此處“堆疊”實指棧區。

堆疊溢位主要有兩大原因:
1) 過大的自動變數;
2) 遞迴或巢狀呼叫層數過深。

有時,函式自身並未定義過大的自動變數,但其呼叫的系統庫函式或第三方介面內使用了較大的堆疊空間(如printf呼叫就要使用2k位元組的棧空間)。此時也會導致堆疊溢位,並且不易排查。

在多執行緒環境下,所有執行緒棧共享同一虛擬地址空間。若應用程式建立過多執行緒,可能導致執行緒棧的累計大小超過可用的虛擬地址空間。在用pthread_create反覆建立一個執行緒(每次正常退出)時,可能最終因記憶體不足而建立失敗。此時,可在主執行緒建立新執行緒時指定其屬性為PTHREAD_CREATE_DETACHED,或建立後呼叫pthread_join,或在新執行緒內呼叫pthread_detach,以便新執行緒函式返回退出或pthread_exit時釋放執行緒所佔用的堆疊資源和執行緒描述符。

【對策】 應該清楚所用平臺的資源限制,充分考慮函式自身及其呼叫所佔用的棧空間。對於過大的自動變數,可用全域性變數、靜態變數或堆記憶體代替。此外,巢狀呼叫最好不要超過三層。

3 記憶體越界

因其作用域和生存期限制,發生在棧區的記憶體越界相比資料區更易發現和排查。

錯誤的指標偏移運算也常導致記憶體越界。例如,指標p+n等於(char*)p + n * sizeof(*p),而非(char*)p + n。若後者才是本意,則p+n的寫法很可能導致記憶體越界。

棧區記憶體越界還可能導致函式返回地址被改寫,詳見《緩衝區溢位詳解》一文。

兩種情況可能改寫函式返回地址:1) 對自動變數的寫操作超出其範圍(上溢);2) 主調函式和被調函式的引數不匹配或呼叫約定不一致。

【對策】 與資料區記憶體越界對策相似,但更注重程式碼走查而非越界檢測。

4 返回棧記憶體地址

(被調)函式內的區域性變數在函式返回時被釋放,不應被外部引用。雖然並非真正的釋放,通過記憶體地址仍可能訪問該棧區變數,但其安全性不被保證。因為指標做為函式引數時,函式內部只能改變指標所指向地址的內容,並不能改變指標的指向。

若執行緒在自身棧上分配一個數據結構並將指向該結構的指標傳遞給pthread_exit,則呼叫pthread_join的執行緒試圖使用該結構時,原先的棧區記憶體可能已被釋放或另作他用。

【對策】 不要用return語句返回指向棧內變數的指標,可改為返回指向靜態變數或動態記憶體的指標。但兩者都存在重入性問題,而且後者還存在記憶體洩露的危險。

三、 堆區記憶體

1 記憶體未初始化

通過malloc庫函式分配的動態記憶體,其初值未定義。若訪問未初始化或未賦初值的記憶體,則會獲得垃圾值。當基於這些垃圾值控制程式邏輯時,會產生不可預測的行為。

【對策】 在malloc之後呼叫 memset 將記憶體初值清零

2 記憶體分配失敗

動態記憶體成功分配的前提是系統具有足夠大且連續可用的記憶體。記憶體分配失敗的主要原因有:

1) 剩餘記憶體空間不足;

2) 剩餘記憶體空間充足,但記憶體碎片太多,導致申請大塊記憶體時失敗;

3) 記憶體越界,導致malloc等分配函式所維護的管理資訊被破壞。

記憶體越界導致記憶體分配失敗的情況更為常見。此時,可從分配失敗的地方開始回溯最近那個分配成功的malloc,看附近是否存在記憶體拷貝和陣列越界的操作。

【對策】 若申請的記憶體單位為吉位元組(GigaByte),可考慮選用64位定址空間的機器,或將資料暫存於硬碟檔案中。此外,申請動態記憶體後,必須判斷記憶體是否是為NULL,並進行防錯處理,比如使用return語句終止本函式或呼叫exit(1)終止整個程式的執行。

3 記憶體釋放失敗

記憶體釋放失敗的主要原因有:

1) 釋放未指向動態記憶體的指標;

2) 指向動態記憶體的指標在釋放前被修改;

3) 記憶體越界,導致malloc等分配函式所維護的管理資訊被破壞;

4) 記憶體重複釋放(Double Free)。

【對策】 幸運的是,記憶體釋放失敗會導致程式崩潰,故障明顯。並且,可藉助靜態或動態的記憶體檢測技術進行排查。

4 記憶體分配與釋放不配對

編碼者一般能保證malloc和free配對使用,但可能呼叫不同的實現。例如,同樣是free介面,其除錯版與釋出版、單執行緒庫與多執行緒庫的實現均有所不同。一旦連結錯誤的庫,則可能出現某個記憶體管理器中分配的記憶體,在另一個記憶體管理器中釋放的問題。此外,模組封裝的記憶體管理介面(如GetBuffer和FreeBuffer)在使用時也可能出現GetBuffer配free,或malloc配FreeBuffer的情況,尤其是跨函式的動態記憶體使用。

【對策】 動態記憶體的申請與釋放介面呼叫方式和次數必須配對,防止記憶體洩漏。分配和釋放最好由同一方管理,並提供專門的記憶體管理介面。

5 記憶體越界

【對策】 當模組提供動態記憶體管理的封裝介面時,可採用“紅區”技術檢測記憶體越界。例如,介面內每次申請比呼叫者所需更大的記憶體,將其首尾若干位元組設定為特殊值,僅將中間部分的記憶體返回給呼叫者使用。這樣,通過檢查特殊位元組是否被改寫,即可獲知是否發生記憶體越界。

6 記憶體洩露

記憶體洩漏指由於疏忽或錯誤造成程式未能釋放已不再使用的記憶體。這時,記憶體並未在物理上消失,但程式因設計錯誤導致在釋放該塊記憶體之前就失去對它的控制權,從而造成記憶體浪費。只發生一次的少量記憶體洩漏可能並不明顯,但記憶體大量或不斷洩漏時可能會表現出各種徵兆:如效能逐漸降低、全部或部分裝置停止正常工作、程式崩潰以及系統提示記憶體耗盡。當發生洩漏的程式消耗過多記憶體以致其他程式失敗時,查詢問題的真正根源將會非常棘手。此外,即使無害的記憶體洩漏也可能是其他問題的徵兆。

短暫執行的程式發生記憶體洩漏時通常不會導致嚴重後果,但以下各種記憶體洩漏將導致較嚴重的後果:

  • Ÿ 程式執行後置之不理,並隨著時間流逝不斷消耗記憶體(如伺服器後臺任務,可能默默執行若干年);
  • Ÿ 頻繁分配新的記憶體,如顯示電腦遊戲或動畫視訊畫面時;
  • Ÿ 程式能夠請求未被釋放的記憶體(如共享記憶體),甚至在程式終止時;
  • Ÿ 洩漏發生在作業系統內部或關鍵驅動中;
  • Ÿ 記憶體受限,如嵌入式系統或便攜裝置;
  • Ÿ 某些作業系統在程式執行終止時並不自動釋放記憶體,且一旦記憶體丟失只能通過重啟來恢復。

通常所說的記憶體洩漏指堆記憶體的洩漏。廣義的記憶體洩漏還包括系統資源的洩漏(Resource Leak),而且比堆記憶體的洩漏更為嚴重。

記憶體洩漏按照發生頻率可分為四類:

1) 常發性記憶體洩漏。即發生記憶體洩漏的程式碼被多次執行,每次執行都會洩漏一塊記憶體。

2) 偶發性記憶體洩漏。即發生記憶體洩漏的程式碼只發生在特定環境或操作下。特定的環境或操作下,偶發性洩漏也會成為常發性洩漏。

3) 一次性記憶體洩漏。即發生記憶體洩漏的程式碼只執行一次,導致有且僅有一塊記憶體發生洩漏。若程式結束時未釋放gpszFileName指向的字串,則即使多次呼叫SetFileName函式,也總有且僅有一塊記憶體發生洩漏。

4) 隱式記憶體洩漏。即程式在執行過程中不停地分配記憶體,但直到結束時才釋放記憶體。例如,一個執行緒不斷分配記憶體,並將指向記憶體的指標儲存在一個數據儲存(如連結串列)中。但在執行過程中,一直沒有任何執行緒進行記憶體釋放。或者,N個執行緒分配記憶體,並將指向記憶體的指標傳遞給一個數據儲存,M個執行緒訪問資料儲存進行資料處理和記憶體釋放。若N遠大於M,或M個執行緒資料處理的時間過長,則分配記憶體的速度遠大於釋放記憶體的速度。嚴格地說這兩種場景下均未發生記憶體洩漏,因為最終程式會釋放所有已申請的記憶體。但對於長期執行(如伺服器)或記憶體受限(如嵌入式)的系統,若不及時釋放記憶體可能會耗盡系統的所有記憶體。

記憶體洩漏的真正危害在於其累積性,這將最終耗盡系統所有的記憶體。因此,一次性記憶體洩漏並無大礙,因為它不會累積;而隱式記憶體洩漏危害巨大,因其相比常發性和偶發性記憶體洩漏更難檢測。

記憶體洩漏的主要原因有:

1) 指向已申請記憶體的指標被挪作他用並被改寫;

2) 因函式內分支語句提前退出,導致釋放記憶體的操作未被執行;

3) 資料結構或處理流程複雜,導致某些應該釋放記憶體的地方被遺忘;

4) 試圖通過函式指標引數申請並傳遞動態記憶體;

5) 執行緒A分配記憶體,執行緒B操作並釋放記憶體,但分配速度遠大於釋放速度。

與之相似的是,為完成某功能需要連續申請一系列動態記憶體。但當某次分配失敗退出時,未釋放系列中其他已成功分配的記憶體。

7 使用已釋放堆記憶體

動態記憶體被釋放後,其中的資料可能被應用程式或堆分配管理器修改。不要再試圖訪問這塊已被釋放的記憶體,否則可能導致不可預料的後果。

在多執行緒環境下,執行緒A通過非同步訊息通知執行緒B操作某塊全域性動態記憶體,通知後稍等片刻(以便執行緒B完成操作)再釋放該記憶體。若延時不足無法保證其先操作後釋放的順序,則可能因訪問已釋放的動態記憶體而導致程序崩潰。

【對策】 務必保證已分配的記憶體塊被且僅被釋放一次,禁止訪問執行已釋放記憶體的指標。若該指標還存在多個副本,則必須保證當它所指向的動態記憶體被釋放後,不再使用所有其他副本。

避免上述錯誤發生的常用方法是釋放記憶體後立即將對應的指標設定為空(NULL)。

本文摘自部落格園:clover_toeic