C進階指南(1):整型溢位和型別提升、記憶體申請和管理

醉酒屠夫發表於2014-07-07

C語言可用於系統程式設計、嵌入式系統中,同時也是其他應用程式可能的實現工具之一。 當你對計算機程式設計懷有強烈興趣的時候,卻對C語言不感冒,這種可能性不大。想全方位地理解C語言是一件極具挑戰性的事。

Peter Fačka 在2014年1月份寫下了這篇長文,內容包括:型別提升、記憶體分配,陣列轉指標、顯式內聯、打樁(interpositioning)和向量變換。原文挺長,伯樂線上分三篇發出,這是第一篇。

 

一、整型溢位和型別提升

多數C程式設計師以為,整型間的基本操作都是安全的。事實上,整型間基本操作也容易出現問題,例如下面的程式碼:

上述程式碼中,變數 被轉換為無符號整型。這樣一來,它的值不再是-1,而是 size_t 的最大值。變數i的型別之所以被轉換,是因為 sizeof 操作符的返回型別是無符號的。具體參見C99/C11標準之常用算術轉換一章:

“If the operand that has unsigned integer type has rank greater or equal to the rank of the type of the other operand, then the operand with signed integer type is converted to the type of the operand with unsigned integer type.”

若無符號整型型別的運算元的轉換優先順序不低於另一運算元,則有符號數轉為無符號數的型別。

C標準中,size_t 被定義為不低於16位的無符號整型。通常 size_t 完全對應於 long。這樣一來,int 和 size_t 的大小至少相等,可基於上述準則,強轉為無符號整型。

(譯者注:本人印象深刻的相關問題是“if(-1U > 0L)”在32、64位機器上的判斷結果分別是什麼,為什麼;除long long外,long 型別在涉及相容性的產品程式碼中應被禁用)

這個故事給了我們一個關於整型大小可移植性的觀念。C標準並未定義shortintlonglong long 的確切大小及其無符號形式。標準僅限定了它們的最小長度。以x86_64架構為例,long 在Linux環境中是64位元,但在64位Windows系統中是32位元。為了使程式碼更具移植性,常見的方法是使用C99的 stdint.h 檔案中定義的、指定長度的特殊型別,包括 uint16_tint32_t 等。此檔案定義了三種整型型別:

  • 有確切長度的:uint8_t uint16_t,int32_t等
  • 有長度最小值的最短型別:uint_least8_t,uint_least16_t,int_least32_t等
  • 執行效率最高的有長度最小值的型別:uint_fast8_t,uint_fast16_t,int_fast32_t等

但不幸的是,僅依靠 stdint.h 並不能根除型別轉換的困擾。C標準中“整型提升規則”中寫道:

若int的表達範圍足以覆蓋所有的基礎型別,此值將被轉換為int;否則將轉為unsigned int。這就叫做整型提升。整型提升過程中,所有其他的型別保持不變。

下述程式碼在32位平臺中將返回65536,在16位平臺上返回0:

無論C語言實現中,是否把未修飾的char看做有符號的,整型提升都連同符號一起把值保留下來。

如何實現char型別通常取決於硬體體系或作業系統,常由其平臺的ABI(應用程式二進位制介面)指定。如果你願意自己嘗試的話,char會被轉為signed char,下述程式碼將列印出-128和-127,而不是128和129。x86架構中可用GCC的-funsigned-char引數切換到強制無符號提升。

二、記憶體申請和管理

malloc, calloc, realloc, free

使用malloc分配指定位元組大小的、未初始化的記憶體物件。若入參值為0,其行為取決於作業系統實現,或者說,這是C和POSIX標準均未定義的行為。

若請求的空間大小為0,則結果視具體實現而定:返回值可以是空指標或特殊指標。

malloc(0) 通常返回有效的特殊指標。或者返回的值可成為 free 函式的引數,且函式不會錯誤退出。例如 free 函式對NULL指標不做任何操作。

因此,若空間大小引數是某個表示式的結果的話,要確保測試過整型溢位的情況。

一般說來,要分配一個元素大小相同的序列,可考慮使用 calloc 而非用表示式計算大小。同時 calloc 將把分配的記憶體初始化為0。像往常一樣使用 free 釋放分配的記憶體。

realloc 將改變已分配記憶體物件的大小。此函式返回一個指標,指標可能指向新的記憶體起始位置,記憶體大小取決於入參中請求的空間大小,內容不變。若新的空間更大,額外的空間未被初始化。若 realloc 入參中,指向舊物件的指標為NULL,並且大小非0,此行為等價於 malloc。若新的大小為0,且提供的指標非空,此時 realloc 的行為依賴於作業系統。

多數實現將嘗試釋放物件記憶體,返回NULL或與malloc(0)相同的返回值。例如在Windows中,此操作會釋放記憶體並返回NULL。OpenBSD也會釋放記憶體,但返回的指標指向的空間大小為0。

realloc 失敗時會返回NULL,也因此斷開與舊的記憶體物件的關聯。所以不但要檢查空間大小引數是否存在整型溢位,還要正確處理 realloc 失敗時的物件大小。

2.1 避免致命錯誤

一般避免動態記憶體分配問題的方法無非是儘可能把程式碼寫得謹慎、有防禦性。本文列舉了一些常見問題和少量避免這些問題的方法。

1) 重複釋放記憶體

呼叫 free 可能導致此問題,此時入參指標可能為NULL(依照《C++ Primer Plus》,free(0)不會出現問題。譯者注)、未使用 malloc 類函式分配的指標,或已經呼叫過 free realloc(realloc引數中大小填0,可釋放記憶體。譯者注)的指標。考慮下列幾點可讓程式碼更健壯:

  • 指標初始化為NULL,以防不能立即傳給它有效值的情況
  • GCC和Clang都有-Wuninitialized引數來警告未初始化的變數
  • 靜態和動態分配的記憶體不要用同一個變數
  • 呼叫 free 後要把指標設回為NULL,這樣一來即便無意中重複釋放也不會導致錯誤
  • 測試或除錯時使用assert之類的斷言(如C11中靜態斷言,譯者注)

2) 訪問未初始化的記憶體或空指標

程式碼中的檢查規則應只用於NULL或有效的指標。對於去除指標和分配的動態記憶體間聯絡的函式或程式碼塊,可在開頭檢查空指標。

3) 越界訪問記憶體

(孔乙己式譯者注:你能說出strcpy / strncpy / strlcpy的區別麼,能的話這節就不必看)

訪問記憶體物件邊界之外的地方並不一定導致程式崩潰。程式可能使用損壞了的資料繼續執行,其行為可能很危險,也可能是故意而為之,利用此越界操作來改變程式的行為,以此獲取其他受限的資料,甚至注入可執行程式碼。 老套地人工檢查陣列和動態分配記憶體的邊界是避免此類問題的主要方法。記憶體物件邊界的相關資訊必須人工跟蹤。陣列的大小可由sizeof操作符指出,但陣列被轉換為指標後,函式呼叫sizeof僅返回指標大小(視機器位數而定,譯者注),而非原來的陣列大小。

C11標準中邊界檢查介面Annex K定義了一些新的庫函式集合,這些函式可用於替換標準庫(如字串和I/O操作)常見部分,它們更安全、更易於使用。例如[the slibc library][slibc]都是上述函式的開源實現,但介面不被廣泛採用。基於BSD(或基於Mac OS X)的系統提供了strlcpystrlcat 函式來完成更好的字串操作。其他系統可通過libbsd庫呼叫它們。

許多作業系統提供了通過記憶體區域間接控制受保護記憶體的介面,以防止意外讀/寫操作,入Posxi mprotect。類似的間接訪問的保護機制常用於所有的記憶體頁。

2.2 避免記憶體洩露

記憶體洩露,常由於程式中未釋放不再使用的動態分配的記憶體導致。因此,真正理解所需要的分配的記憶體物件的範圍大小是很有必要的。更重要的是,要明白何時呼叫 free。但當程式複雜度增加時,要確定 free 的呼叫時機將變得更加困難。早期設計決策時,規劃記憶體很重要。

以下是處理記憶體洩露的技能表:

1) 啟動時分配

想讓記憶體管理保持簡單,一個方法是在啟動時在堆中分配所有所需的記憶體。程式結束時,釋放記憶體的重任就交給了作業系統。這種方法在許多場景中的效果令人滿意,特別是當程式在一個批量操作中完成對輸入的處理的情況。

2) 變長陣列

如果你需要有著變長大小的臨時儲存,並且其生命週期在變數內部時,可考慮VLA(Variable Length Array,變長陣列)。但這有個限制:每個函式的空間不能超過數百位元組。因為C99指出邊長陣列能自動儲存,它們像其他自動變數一樣受限於同一作用域。即便標準未明確規定,VLA的實現都是把記憶體資料放到棧中。VLA的最大長度為SIZE_MAX位元組。考慮到目標平臺的棧大小,我們必須更加謹慎小心,以保證程式不會面臨棧溢位、下個記憶體段的資料損壞的尷尬局面。

3) 自己編寫引用計數

這個技術的想法是對某個記憶體物件的每次引用、去引用計數。賦值時,計數器會增加;去引用時,計數器減少。當引用計數變為0時,這意味著此記憶體物件不再被使用,可以釋放。因為C不提供自動析構(事實上,GCC和Clang都支援cleanup語言擴充套件), 也不是重寫賦值運算子,引用計數由呼叫retain/release的函式手動完成。更好的方式,是把它作為程式的可變部分,能通過這部分獲取和釋放一個記憶體物件的擁有權。但是,使用這種方法需要很多(程式設計)規範來防止忘記呼叫release(停止記憶體洩露)或不必要地呼叫釋放函式(這將導致記憶體釋放地過早)。若記憶體物件的生命期需要外部事件指出,或應用程式的資料結構隱含了某個記憶體物件的持有權的處理,無論何種情況,都容易導致問題。下述程式碼塊含有簡化了的記憶體管理引用計數。

如果你關心編譯器的相容性,可用 cleanup 屬性在C中模擬自動析構。

上述方案的另一缺陷是提供物件地址讓 cleanup_release 釋放,而非引用計數值。這樣一來,cleanup_release 必須在 references 陣列中做開銷大的查詢操作。一種解決辦法是,改變填充的介面為返回一個指向 struct mem_obj_t 的指標。另一種辦法是使用下面的巨集集合,這些巨集能夠建立儲存引用計數值的變數並追加 clean 屬性。

(譯者注:##符號源自C99,用於連線兩個變數的名稱,一般用在巨集裡。如int a##b就會定義一個叫做ab的變數;__LINE__指程式碼行號,類似的還有__FUNCTION__或__func__和__FILE__,可用於列印除錯資訊;__attribute__符號來自gcc,主要用於指導編譯器優化,也提供了一些如構造、析構、位元組對齊等功能)

4) 記憶體池

若一個程式經過數階段才能徹底執行,每階段的開頭都分配有記憶體池,需要分配記憶體時,就使用記憶體池的一部分。記憶體池的選擇,要考慮分配的記憶體物件的生命週期,以及物件在程式中所屬的階段。每個階段一旦結束,整個記憶體池就要立即釋放。這種方法在記錄型執行程式中特別有用,例如守護程式,它可能隨著時間減少記憶體分段。下述程式碼是個記憶體池記憶體管理的模擬:

記憶體池的實現涉及非常艱難的任務。可能一些現有的庫能很好地滿足你的需求:

5) 資料結構

把資料存到正確的資料結構裡,能解決很多記憶體管理問題。而資料結構的選擇,大多取決於演算法,這些演算法訪問資料、把資料儲存到例如連結串列、雜湊表或樹中。按演算法選擇資料結構有額外的好處,例如能夠遍歷資料結構一次就能釋放資料。因為標準庫並未提供對資料結構的支援,這裡列出幾個支援資料結構的庫:

6) 標記並清除垃圾收集器

處理記憶體問題的另一種方式,就是利用自動垃圾收集器的優勢,自此從自己清除記憶體中解放出來。於引用計數中記憶體不再需要時清除機制相反,垃圾收集器在發生指定事件是被呼叫,如記憶體分配錯誤,或分配後超過了確切的閥值。標記清除演算法是實現垃圾收集器的一種方式。此演算法先為每個引用到分配記憶體的物件遍歷堆,標記這些仍然可用的記憶體物件,然後清除未標記的記憶體物件。

可能C中最有名的類似垃圾收集器的實現是Boehm-Demers-Weiser conservative garbage collector 。使用垃圾收集器的瑕疵可能是效能問題,或向程式引入非確定性的延緩。另一問題是,這可能導致庫函式使用 malloc,這些庫函式申請的記憶體不受垃圾處理器監管,必須手動釋放。

雖然實時環境無法接受不可預料的卡頓,仍有許多環境從中獲取的好處遠超過不足。從效能的角度看,甚至有效能提升。一些專案使用含有Mono專案GNU Objective C執行環境或Irssi IRC客戶端的Boehm垃圾收集器。

其餘兩篇:

相關文章