一 前言
本文所討論的“內存”主要指(靜態)數據區、堆區和棧區空間(詳細的布局和描述參考《Linux虛擬地址空間布局》一文)。數據區內存在程序編譯時分配,該內存的生存期為程序的整個運行期間,如全局變量和static關鍵字所聲明的靜態變量。函數執行時在棧上開辟局部自動變量的儲存空間,執行結束時自動釋放棧區內存。堆區內存亦稱動態內存,由程序在運行時調用malloc/calloc/realloc等庫函數申請,並由使用者顯式地調用free庫函數釋放。堆內存比棧內存分配容量更大,生存期由使用者決定,故非常靈活。然而,堆內存使用時很容易出現內存泄露、內存越界和重復釋放等嚴重問題。
本文將詳細討論三種內存使用時常見的問題及其對策,並對各種內存問題給出簡單的示例代碼。示例代碼的運行環境如下:

二 內存問題
2.1 數據區內存
2.1.1 內存越界
內存越界訪問分為讀越界和寫越界。讀越界表示讀取不屬於自己的數據,如讀取的字節數多於分配給目標變量的字節數。若所讀的內存地址無效,則程序立即崩潰;若所讀的內存地址有效,則可讀到隨機的數據,導致不可預料的后果。寫越界亦稱“緩沖區溢出”,所寫入的數據對目標地址而言也是隨機的,因此同樣導致不可預料的后果。
內存越界訪問會嚴重影響程序的穩定性,其危險在於后果和症狀的隨機性。這種隨機性使得故障現象和本源看似無關,給排障帶來極大的困難。
數據區內存越界主要指讀寫某一數據區內存(如全局或靜態變量、數組或結構體等)時,超出該內存區域的合法范圍。
寫越界的主要原因有兩種:1) memset/memcpy/memmove等內存覆寫調用;2) 數組下標超出范圍。
1 #define NAME_SIZE 5 2 #define NAME_LEN NAME_SIZE-1/*Terminator*/ 3 char gszName[NAME_SIZE] = "Mike"; 4 char *pszName = "Jason"; 5 int main(void) 6 { 7 memset(gszName, 0, NAME_SIZE+1); //越界1 8 gszName[NAME_SIZE] = 0; //越界2 9 10 if(strlen(pszName) <= NAME_SIZE) //越界3(注意'='號) 11 strcpy(gszName, pszName); 12 13 int dwSrcLen = strlen(pszName); 14 if(dwSrcLen < NAME_SIZE) 15 memcpy(gszName, pszName, dwSrcLen); //未拷貝結束符('\0') 16 17 return 0; 18 }
使用數組時,經常發生下標“多1”或“少1”的操作,特別是當下標用於for循環條件表達式時。此外,當數組下標由函數參數傳入或經過復雜運算時,更易發生越界。
1 void ModifyNameChar(unsigned char ucCharIdx, char cModChar) 2 { 3 gszName[ucCharIdx] = cModChar; //寫越界 4 } 5 int main(void) 6 { 7 ModifyNameChar(NAME_SIZE, 'L'); 8 unsigned char ucIdx = 0; 9 for(; ucIdx <= NAME_SIZE; ucIdx++) //'='號導致讀越界 10 printf("NameChar = %c\n", gszName[ucIdx]); 11 12 return 0; 13 }
對於重要的全局數據,可將其植入結構體內並添加CHK_HEAD和CHK_TAIL進行越界保護和檢查:
1 #define CODE_SIZE 4 //越界保護碼的字節數 2 #if (1 == CODE_SIZE) 3 #define CODE_TYPE char 4 #define CHK_CODE 0xCC //除0外的特殊值 5 #elif (2 == CODE_SIZE) 6 #define CODE_TYPE short 7 #define CHK_CODE 0xCDDC //除0外的特殊值 8 #else 9 #define CODE_TYPE int 10 #define CHK_CODE 0xABCDDCBA //除0外的特殊值 11 #endif 12 #define CHK_HEAD CODE_TYPE ChkHead; 13 #define CHK_TAIL CODE_TYPE ChkTail; 14 #define INIT_CHECK(ptChkMem) do{ \ 15 (ptChkMem)->ChkHead = CHK_CODE; \ 16 (ptChkMem)->ChkTail = CHK_CODE; \ 17 }while(0) 18 #define CHK_OVERRUN(ptChkMem) do{ \ 19 if((ptChkMem)->ChkHead != CHK_CODE || (ptChkMem)->ChkTail != CHK_CODE) { \ 20 printf("[%s(%d)<%s>]Memory Overrun(ChkHead:0x%X,ChkTail:0x%X)!\n", __FILE__, __LINE__, FUNC_NAME, \ 21 (ptChkMem)->ChkHead, (ptChkMem)->ChkTail); \ 22 } \ 23 }while(0) 24 typedef struct{ 25 CHK_HEAD; 26 char szName[NAME_SIZE]; 27 CHK_TAIL; 28 }T_CHK_MEM; 29 T_CHK_MEM gtChkMem; 30 int main(void) 31 { 32 memset(>ChkMem, 0, sizeof(T_CHK_MEM)); 33 INIT_CHECK(>ChkMem); 34 35 memset(>ChkMem, 11, 6); 36 CHK_OVERRUN(>ChkMem); 37 strcpy(gtChkMem.szName, "Elizabeth"); 38 CHK_OVERRUN(>ChkMem); 39 40 return 0; 41 }
執行結果如下,可見被檢查的szName數組其頭尾地址均發生越界:
1 [test.c(177)<main>]Memory Overrun(dwChkHead:0xB0B0B0B,dwChkTail:0xABCDDCBA)! 2 [test.c(179)<main>]Memory Overrun(dwChkHead:0xB0B0B0B,dwChkTail:0xABCD0068)!
若模塊提供有全局數據的訪問函數,則可將越界檢查置於訪問函數內:
1 #ifdef CHK_GLOBAL_OVERRUN 2 #define CODE_SIZE 4 //越界保護碼的字節數 3 #if (1 == CODE_SIZE) 4 #define CODE_TYPE char 5 #define CHK_CODE (CODE_TYPE)0xCC //除0外的特殊值 6 #elif (2 == CODE_SIZE) 7 #define CODE_TYPE short 8 #define CHK_CODE (CODE_TYPE)0xCDDC //除0外的特殊值 9 #else 10 #define CODE_TYPE int 11 #define CHK_CODE (CODE_TYPE)0xABCDDCBA //除0外的特殊值 12 #endif 13 #define CHK_HEAD CODE_TYPE ChkHead 14 #define CHK_TAIL CODE_TYPE ChkTail 15 #define HEAD_VAL(pvGlblAddr) (*(CODE_TYPE*)(pvGlblAddr)) 16 #define TAIL_VAL(pvGlblAddr, dwGlbSize) (*(CODE_TYPE*)((char*)pvGlblAddr+dwGlbSize-sizeof(CODE_TYPE))) 17 18 #define INIT_CHECK(pvGlblAddr, dwGlbSize) do{\ 19 HEAD_VAL(pvGlblAddr) = TAIL_VAL(pvGlblAddr, dwGlbSize) = CHK_CODE;}while(0) 20 #define CHK_OVERRUN(pvGlblAddr, dwGlbSize, pFileName, dwCodeLine) do{\ 21 if((HEAD_VAL(pvGlblAddr) != CHK_CODE) || (TAIL_VAL(pvGlblAddr, dwGlbSize) != CHK_CODE)) {\ 22 printf("[%s(%d)]Memory Overrun(ChkHead:0x%X,ChkTail:0x%X)!\n", pFileName, dwCodeLine, \ 23 HEAD_VAL(pvGlblAddr), TAIL_VAL(pvGlblAddr, dwGlbSize)); \ 24 }}while(0) 25 26 #define INIT_GLOBAL(pvGlblAddr, dwInitVal, dwGlbSize) \ 27 InitGlobal(pvGlblAddr, dwInitVal, dwGlbSize, __FILE__, __LINE__) 28 #define SET_GLOBAL(pvGlblAddr, pvGlblVal, dwGlbSize) \ 29 SetGlobal(pvGlblAddr, pvGlblVal, dwGlbSize, __FILE__, __LINE__) 30 #define GET_GLOBAL(pvGlblAddr, pvGlblVal, dwGlbSize) \ 31 GetGlobal(pvGlblAddr, pvGlblVal, dwGlbSize, __FILE__, __LINE__) 32 #else 33 #define CHK_CODE 0 34 #define CHK_HEAD 35 #define CHK_TAIL 36 #define HEAD_VAL(pvGlblAddr) 0 37 #define TAIL_VAL(pvGlblAddr, dwGlbSize) 0 38 #define INIT_CHECK(pvGlblAddr, dwGlbSize) 39 #define CHK_OVERRUN(pvGlblAddr, dwGlbSize, pFileName, dwCodeLine) 40 41 #define INIT_GLOBAL(pvGlblAddr, dwInitVal, dwGlbSize) do{\ 42 memset(pvGlblAddr, dwInitVal, dwGlbSize);}while(0) 43 #define SET_GLOBAL(pvGlblAddr, pvGlblVal, dwGlbSize) do{\ 44 memcpy(pvGlblAddr, pvGlblVal, dwGlbSize);}while(0) 45 #define GET_GLOBAL(pvGlblAddr, pvGlblVal, dwGlbSize) do{\ 46 memcpy(pvGlblVal, pvGlblAddr, dwGlbSize);}while(0) 47 #endif 48 49 void InitGlobal(void* pvGlblAddr, int dwInitVal, unsigned int dwGlbSize, 50 const char* pFileName, INT32U dwCodeLine) 51 { 52 if(NULL == pvGlblAddr) //理論上pFileName必不為空 53 { 54 printf("[%s(%d)]Arg1 Null!\n", pFileName, dwCodeLine); 55 return; 56 } 57 58 memset(pvGlblAddr, dwInitVal, dwGlbSize); 59 INIT_CHECK(pvGlblAddr, dwGlbSize); 60 } 61 void SetGlobal(void* pvGlblAddr, void* pvGlblVal, unsigned int dwGlbSize, 62 const char* pFileName, INT32U dwCodeLine) 63 { 64 if((NULL == pvGlblAddr) || (NULL == pvGlblVal)) 65 { 66 printf("[%s(%d)]Arg1(%p) or Arg2(%p) Null!\n", pFileName, dwCodeLine, pvGlblAddr, pvGlblVal); 67 return; 68 } 69 70 memcpy(pvGlblAddr, pvGlblVal, dwGlbSize); 71 CHK_OVERRUN(pvGlblAddr, dwGlbSize, pFileName, dwCodeLine); 72 } 73 void GetGlobal(void* pvGlblAddr, void* pvGlblVal, unsigned int dwGlbSize, 74 const char* pFileName, INT32U dwCodeLine) 75 { 76 if((NULL == pvGlblAddr) || (NULL == pvGlblVal)) 77 { 78 printf("[%s(%d)]Arg1(%p) or Arg2(%p) Null!\n", pFileName, dwCodeLine, pvGlblAddr, pvGlblVal); 79 return; 80 } 81 82 memcpy(pvGlblVal, pvGlblAddr, dwGlbSize); 83 CHK_OVERRUN(pvGlblAddr, dwGlbSize, pFileName, dwCodeLine); 84 } 85 86 int main(void) 87 { 88 INIT_GLOBAL(>ChkMem, 0, sizeof(T_CHK_MEM)); 89 printf("[%d]ChkHead:0x%X,ChkTail:0x%X!\n", __LINE__, HEAD_VAL(>ChkMem), TAIL_VAL(>ChkMem, sizeof(T_CHK_MEM))); 90 T_CHK_MEM tChkMem; 91 GET_GLOBAL(>ChkMem, &tChkMem, sizeof(T_CHK_MEM)); 92 93 strcpy(tChkMem.szName, "Elizabeth"); 94 SET_GLOBAL(>ChkMem, &tChkMem, sizeof(T_CHK_MEM)); 95 96 return 0; 97 }
其中,TAIL_VAL宏假定系統為1字節對齊(否則請置CODE_SIZE為4字節)。因0xCC默認為四字節(對應於0xFFFFFFCC),故需用(CODE_TYPE)0xCC做類型轉換,否則CHK_OVERRUN宏內if判斷恆為真。
該檢查機制的缺點是僅用於檢測寫越界,且拷貝和解引用次數增多,訪問效率有所降低。讀越界后果通常並不嚴重,除非試圖讀取不可訪問的區域,否則難以也不必檢測。
數據區內存越界通常會導致相鄰的全局變量被意外改寫。因此若已確定被越界改寫的全局變量,則可通過工具查看符號表,根據地址順序找到前面(通常向高地址越界)相鄰的全局數據,然后在代碼中排查訪問該數據的地方,看看有哪些位置可能存在越界操作。
有時,全局數據被意外改寫並非內存越界導致,而是某指針(通常為野指針)意外地指向該數據地址,導致其內容被改寫。野指針導致的內存改寫往往后果嚴重且難以定位。此時,可編碼檢測全局數據發生變化的時機。若能結合堆棧回溯(Call Backtrace),則通常能很快地定位問題所在。
修改只讀數據區內容會引發段錯誤(Segmentation Fault),但這種低級失誤並不常見。一種比較隱秘的缺陷是函數內試圖修改由指針參數傳入的只讀字符串,詳見《關於Linux系統basename函數缺陷的思考》一文。
因其作用域限制,靜態局部變量的內存越界相比全局變量越界更易發現和排查。
【對策】某些工具可幫助檢查內存越界的問題,但並非萬能。內存越界通常依賴於測試環境和測試數據,甚至在極端情況下才會出現,除非精心設計測試數據,否則工具也無能為力。此外,工具本身也有限制,甚至在某些大型項目中,工具變得完全不可用。
與使用工具類似的是自行添加越界檢測代碼,如本節上文所示。但為求安全性而封裝檢測機制的做法在某種意義上得不償失,既不及Java等高級語言的優雅,又損失了C語言的簡潔和高效。因此,根本的解決之道還是在於設計和編碼時的審慎周密。相比事后檢測,更應注重事前預防。
編程時應重點走查代碼中所有操作全局數據的地方,杜絕可能導致越界的操作,尤其注意內存覆寫和拷貝函數memset/memcpy/memmove和數組下標訪問。
在內存拷貝時,必須確保目的空間大於或等於源空間。也可封裝庫函數使之具備安全校驗功能,如:
1 /****************************************************************************** 2 * 函數名稱: StrCopy 3 * 功能說明: 帶長度安全拷貝字符串 4 * 輸入參數: dwSrcLen : 目的字符串緩沖區長度 5 pSrcStr : 源字符串 6 dwSrcLen : 源字符串長度(含終止符'\0') 7 * 輸出參數: pDstStr : 目的字符串緩沖區 8 * 返回值 : 成功: ptDest; 失敗: "Nil" 9 * 用法示例: char *pSrcStr = "HelloWorld"; char szDstStr[20] = {0}; 10 StrCopy(szDstStr, sizeof(szDstStr), pSrcStr, strlen(pSrcStr))+1); 11 * 注意事項: 拷貝長度為min(dwDstLen, dwSrcLen) - 1{Terminator} 12 ******************************************************************************/ 13 char *StrCopy(char *pDstStr, int dwDstLen, char *pSrcStr, int dwSrcLen) 14 { 15 if(((NULL == pDstStr) || (NULL == pSrcStr)) || 16 ((0 == dwDstLen) || (0 == dwSrcLen))) 17 return (char *)"Nil"; 18 19 int dwActLen = (dwDstLen <= dwSrcLen) ? dwDstLen : dwSrcLen; 20 pDstStr[dwActLen - 1] = '\0'; 21 22 return strncpy(pDstStr, pSrcStr, dwActLen - 1); 23 }
在使用memcpy和strcpy拷貝字符串時應注意是否包括結束符(memcpy不自動拷貝’\0’)。
按照下標訪問數組元素前,可進行下標合法性校驗:
1 /* 數組下標合法性校驗宏 */ 2 #define CHECK_ARRAY_INDEX(index, maxIndex) do{\ 3 if(index > maxIndex) { \ 4 printf("Too large "#index": %d(Max: %d)!!!\n\r", index, maxIndex); \ 5 index = maxIndex; \ 6 } \ 7 }while(0)
2.1.2 多重定義
函數和定義時已初始化的全局變量是強符號;未初始化的全局變量是弱符號。多重定義的符號只允許最多一個強符號。Unix鏈接器使用以下規則來處理多重定義的符號:
規則一:不允許有多個強符號。在被多個源文件包含的頭文件內定義的全局變量會被定義多次(預處理階段會將頭文件內容展開在源文件中),若在定義時顯式地賦值(初始化),則會違反此規則。
規則二:若存在一個強符號和多個弱符號,則選擇強符號。
規則三:若存在多個弱符號,則從這些弱符號中任選一個。
當不同文件內定義同名(即便類型和含義不同)的全局變量時,該變量共享同一塊內存(地址相同)。若變量定義時均初始化,則會產生重定義(multiple definition)的鏈接錯誤;若某處變量定義時未初始化,則無鏈接錯誤,僅在因類型不同而大小不同時可能產生符號大小變化(size of symbol `XXX' changed)的編譯警告。在最壞情況下,編譯鏈接正常,但不同文件對同名全局變量讀寫時相互影響,引發非常詭異的問題。這種風險在使用無法接觸源碼的第三方庫時尤為突出。
下面的例子編譯鏈接時沒有任何警告和錯誤,但結果並非所願:
1 //test.c 2 int gdwCount = 0; 3 int GetCount(void) 4 { 5 return gdwCount; 6 } 7 8 9 //main.c 10 extern int GetCount(void); 11 int gdwCount; 12 int main(void) 13 { 14 gdwCount = 10; 15 printf("GetCount=%d\n", GetCount()); 16 return 0; 17 }
編碼者期望函數GetCount的返回值打印出來是0,但其實是10。若將main.c中的int gdwCount語句改為int gdwCount = 0,編譯鏈接時就會報告multiple definition of 'gdwCount'的錯誤。因此盡量不要依賴和假設這種符號規則。
關於全局符號多重定義的討論,詳見《C語言頭文件組織與包含原則》一文。
【對策】盡量避免使用全局變量。若確有必要,應采用靜態全局變量(無強弱之分,且不會和其他全局符號產生沖突),並封裝訪問函數供外部文件調用。
2.1.3 volatile修飾
關鍵字volatile用於修飾易變的變量,告訴編譯器該變量值可能會在任意時刻被意外地改變,因此不要試圖對其進行任何優化。每次訪問(讀寫)volatile所修飾的變量時,都必須從該變量的內存區域中重新讀取,而不要使用寄存器(CPU)中保存的值。這樣可保證數據的一致性,防止由於變量優化而出錯。
以下幾種情況通常需要volatile關鍵字:
- 外圍並行設備的硬件寄存器(如狀態寄存器);
- 中斷服務程序(ISR)中所訪問的非自動變量(Non-automatic Variable),即全局變量;
- 多線程並發環境中被多個線程所共享的全局變量。
變量可同時由const和volatile修飾(如只讀的狀態寄存器),表明它可能被意想不到地改變,但程序不應試圖修改它。指針可由volatile修飾(盡管並不常見),如中斷服務子程序修改一個指向某buffer的指針時。又如:
1 //只讀端口(I/O與內存共享地址空間,非IA架構) 2 const volatile char *port = (const volatile char *)0x01F7
誤用volatile關鍵字可能帶來意想不到的錯誤,例如:
1 int CalcSquare(volatile int *pVal) 2 { 3 return (*pVal) * (*pVal); 4 } //deficient
函數CalcSquare返回指針pVal所指向值的平方,但由於該值被volatile修飾,編譯器將產生類似下面的代碼:
1 int CalcSquare(volatile int *pVal) 2 { 3 int dwTemp1, dwTemp2; 4 dwTemp1 = *pVal; 5 dwTemp2 = *pVal; 6 return dwTemp1 * dwTemp2; 7 }//deficient
多線程環境下,指針pVal所指向值在函數CalcSquare執行兩次賦值操作時可能被意想不到地該變,因此dwTemp1和dwTemp2的取值可能不同,最終未必返回期望的平方值。
正確的代碼如下(使用全局變量的拷貝也是提高線程安全性的一種方法):
1 long CalcSquare(volatile int *pVal) 2 { 3 int dwTemp; 4 dwTemp = *pVal; 5 return dwTemp * dwTemp; 6 }//deficient
再舉一例:
1 #define READ(val, addr) (val = *(unsigned long *)addr)
編譯器優化這段代碼時,若addr地址的數據讀取太頻繁,優化器會將該地址上的值存入寄存器中,后續對該地址的訪問就轉變為直接從寄存器中讀取數據,如此將大大加快數據讀取速度。但在並發操作時,一個進程讀取數據,另一進程修改數據,這種優化就會造成數據不一致。此時,必須使用volatile修飾符。
【對策】合理使用volatile修飾符。
2.2 棧區內存
2.2.1 內存未初始化
未初始化的棧區變量其內容為隨機值。直接使用這些變量會導致不可預料的后果,且難以排查。
指針未初始化(野指針)或未有效初始化(如空指針)時非常危險,尤以野指針為甚。
【對策】在定義變量時就對其進行初始化。某些編譯器會對未初始化發出警告信息,便於定位和修改。
2.2.2 堆棧溢出
每個線程堆棧空間有限,稍不注意就會引起堆棧溢出錯誤。注意,此處“堆棧”實指棧區。
1 #define MAX_SIZE 3200000 //系統不同該值不同(ulimit –s: 10240kbytes) 2 int main(void){ 3 int aStackCrasher[MAX_SIZE] = {0}; //可能導致Segmentation fault 4 aStackCrasher[0] = 1; 5 return 0; 6 }
堆棧溢出主要有兩大原因:1) 過大的自動變量;2) 遞歸或嵌套調用層數過深。
有時,函數自身並未定義過大的自動變量,但其調用的系統庫函數或第三方接口內使用了較大的堆棧空間(如printf調用就要使用2k字節的棧空間)。此時也會導致堆棧溢出,並且不易排查。
此外,直接使用接口模塊定義的數據結構或表征數據長度的宏時也存在堆棧溢出的風險,如:
1 typedef struct{ 2 unsigned short wVid; 3 unsigned char aMacAddr[6]; 4 unsigned char ucMacType; 5 }T_MAC_ADDR_ENTRY; 6 typedef struct{ 7 unsigned int dwTotalAddrNum; 8 T_MAC_ADDR_ENTRY tMacAddrEntry[MAX_MACTABLE_SIZE]; 9 }T_MAC_ADDR_TABLE;
上層模塊在自行定義的T_MAC_ADDR_TABLE結構中,使用底層接口定義的MAX_MACTABLE_SIZE宏指定MAC地址表最大條目數。接口內可能會將該宏定義為較大的值(如8000個條目),上層若直接在棧區使用TABLE結構則可能引發堆棧溢出。
在多線程環境下,所有線程棧共享同一虛擬地址空間。若應用程序創建過多線程,可能導致線程棧的累計大小超過可用的虛擬地址空間。在用pthread_create反復創建一個線程(每次正常退出)時,可能最終因內存不足而創建失敗。此時,可在主線程創建新線程時指定其屬性為PTHREAD_CREATE_DETACHED,或創建后調用pthread_join,或在新線程內調用pthread_detach,以便新線程函數返回退出或pthread_exit時釋放線程所占用的堆棧資源和線程描述符。
【對策】應該清楚所用平台的資源限制,充分考慮函數自身及其調用所占用的棧空間。對於過大的自動變量,可用全局變量、靜態變量或堆內存代替。此外,嵌套調用最好不要超過三層。
2.2.3 內存越界
因其作用域和生存期限制,發生在棧區的內存越界相比數據區更易發現和排查。
下面的例子存在內存越界,並可能導致段錯誤:
1 int bIsUniCommBlv = 1; 2 int main(void) 3 { 4 char szWanName[] = "OAM_WAN_VOIP"; 5 if(bIsUniCommBlv) 6 strcpy(szWanName, "OAM_WAN_MNGIP"); 7 8 return 0; 9 }
但該例的另一寫法則更為糟糕:
1 int bIsUniCommBlv = 1; 2 int main(void) 3 { 4 char szWanName[] = ""; //字符數組szWanName僅能容納1個元素('\0')! 5 if(bIsUniCommBlv) 6 strcpy(szWanName, "OAM_WAN_MNGIP"); 7 else 8 strcpy(szWanName, " OAM_WAN_VOIP"); 9 10 return 0; 11 }
函數傳遞指針參數時也可能發生內存越界:
1 typedef struct{ 2 int dwErrNo; 3 int aErrInfo[6]; 4 }T_ERR_INFO; 5 int PortDftDot1p(int dwPort, int dwDot1p, void *pvOut) 6 { 7 int dwRet = 0; 8 T_ERR_INFO *ptErrInfo = (T_ERR_INFO *)pvOut; 9 //dwRet = DoSomething(); 10 ptErrInfo->dwErrNo = dwRet; 11 ptErrInfo->aErrInfo[0] = dwPort; 12 return dwRet; 13 } 14 15 int main(void) 16 { 17 int dwOut = 0; 18 PortDftDot1p(0, 5, &dwOut); 19 return 0; 20 }
上例中,接口函數PortDftDot1p使用T_ERR_INFO結構向調用者傳遞出錯信息,但該結構並非調用者必知和必需。出於隱藏細節或其他原因,接口將出參指針聲明為void*類型,而非T_ERR_INFO*類型。這樣,當調用者傳遞的相關參數為其他類型時,編譯器也無法發現類型不匹配的錯誤。此外,接口內未對pvOut指針判空就進行類型轉換,非常危險(即使判空依舊危險)。從安全和實用角度考慮,該接口應該允許pvOut指針為空,此時不向調用者傳遞出錯信息(調用方也許並不想要這些信息);同時要求傳入pvOut指針所指緩沖區的字節數,以便在指針非空時安全地傳遞出錯信息。
錯誤的指針偏移運算也常導致內存越界。例如,指針p+n等於(char*)p + n * sizeof(*p),而非(char*)p + n。若后者才是本意,則p+n的寫法很可能導致內存越界。
棧區內存越界還可能導致函數返回地址被改寫,詳見《緩沖區溢出詳解》一文。
兩種情況可能改寫函數返回地址:1) 對自動變量的寫操作超出其范圍(上溢);2) 主調函數和被調函數的參數不匹配或調用約定不一致。
函數返回地址被改寫為有效地址時,通過堆棧回溯可看到函數調用關系不符合預期。當返回地址被改寫為非法地址(如0)時,會發生段錯誤,並且堆棧無法回溯:
1 Program received signal SIGSEGV, Segmentation fault. 2 0x00000000 in ?? ()
這種故障從代碼上看特征非常明顯,即發生在被調函數即將返回的位置。
【對策】與數據區內存越界對策相似,但更注重代碼走查而非越界檢測。
2.2.4 返回棧內存地址
(被調)函數內的局部變量在函數返回時被釋放,不應被外部引用。雖然並非真正的釋放,通過內存地址仍可能訪問該棧區變量,但其安全性不被保證。詳見《已釋放的棧內存》一文。
1 const static char *paMsgNameMap[] = { 2 /* 0 */ "0", 3 /* 1 */ "1", 4 /* 2 */ "2", 5 /* 3 */ "3", 6 /* 4 */ "Create", 7 /* 5 */ "5", 8 /* 6 */ "Delete", 9 /* 7 */ "7", 10 /* 8 */ "Set", 11 /* 9 */ "Get", 12 //... ... 13 /*28 */ "GetCurData", 14 /*29 */ "SetTable" 15 }; 16 const static unsigned char ucMsgNameNum = sizeof(paMsgNameMap) / sizeof(paMsgNameMap[0]); 17 18 char *ParseOmciMsgType(unsigned char ucMsgType) 19 { 20 if(ucMsgType < ucMsgNameNum) 21 return paMsgNameMap[ucMsgType]; 22 23 char szStrMsgType[sizeof("255")] = {0}; /* Max:"255" */ 24 sprintf(szStrMsgType, "%u", ucMsgType); 25 return szStrMsgType; //編譯警告: 26 }
編譯上述代碼,函數ParseOmciMsgType在返回szStrMsgType處產生function returns address of local variable的警告。可將szStrMsgType定義為靜態變量:
1 char *ParseOmciMsgType(unsigned char ucMsgType) 2 { 3 if(ucMsgType < ucMsgNameNum) 4 return paMsgNameMap[ucMsgType]; 5 6 static char szStrMsgType[sizeof("255")] = {0}; /* Max:"255" */ 7 sprintf(szStrMsgType, "%u", ucMsgType); 8 return szStrMsgType; 9 }
若將結果通過函數參數而非返回值傳遞,則代碼會更為安全:
1 void ParseOmciMsgType(unsigned char ucMsgType, char *pszMsgType) 2 { 3 if(ucMsgType < ucMsgNameNum) 4 strcpy(pszMsgType, paMsgNameMap[ucMsgType]); 5 else 6 sprintf(pszMsgType, "%u", ucMsgType); 7 }
注意,不可采用下面的寫法:
1 void ParseOmciMsgType(unsigned char ucMsgType, char *pszMsgType) 2 { 3 if(ucMsgType < ucMsgNameNum) 4 pszMsgType = paMsgNameMap[ucMsgType]; 5 else 6 sprintf(pszMsgType, "%u", ucMsgType); 7 }
因為指針作為函數參數時,函數內部只能改變指針所指向地址的內容,並不能改變指針的指向。
若線程在自身棧上分配一個數據結構並將指向該結構的指針傳遞給pthread_exit,則調用pthread_join的線程試圖使用該結構時,原先的棧區內存可能已被釋放或另作他用。
【對策】不要用return語句返回指向棧內變量的指針,可改為返回指向靜態變量或動態內存的指針。但兩者都存在重入性問題,而且后者還存在內存泄露的危險。
2.3 堆區內存
2.3.1 內存未初始化
通過malloc庫函數分配的動態內存,其初值未定義。若訪問未初始化或未賦初值的內存,則會獲得垃圾值。當基於這些垃圾值控制程序邏輯時,會產生不可預測的行為。
【對策】在malloc之后調用 memset 將內存初值清零,或使用 calloc代替malloc。
1 char *pMem = malloc (10); 2 memset(pMem, 0, 10); // memset前應對申請的動態內存做有效性檢查 3 //Or 4 char *pMem = calloc (10, 1);
注意, memset函數按字節操作,且第三個參數以字節為單位。因此,若將內存初值全部置為1,則應使用memset(pMem, 0xFF, 10),而不是memset(pMem, 1, 10)。
2.3.2 內存分配失敗
動態內存成功分配的前提是系統具有足夠大且連續可用的內存。內存分配失敗的主要原因有:
1) 剩余內存空間不足;
2) 剩余內存空間充足,但內存碎片太多,導致申請大塊內存時失敗;
3) 內存越界,導致malloc等分配函數所維護的管理信息被破壞。
剩余內存空間不足的情況相對少見,通常發生在申請超大塊內存時。例如:
1 #include <stdlib.h> 2 #include <errno.h> 3 #define ALLOC_BYTES (1024*1024*1024) 4 int main(void){ 5 unsigned int dwRound = 0; 6 while(1){ 7 char *pMem = malloc(ALLOC_BYTES); 8 if(NULL == pMem){ 9 printf("Alloc failed(%s)!\n", strerror(errno)); 10 return -1; 11 } 12 printf("%d -> 0x%p\n", dwRound, pMem); 13 dwRound++; 14 } 15 return 0; 16 }
執行后產生內存分配失敗的錯誤:
1 0 -> 0x77f6b008 2 1 -> 0x37f6a008 3 Alloc failed(Cannot allocate memory)!
內存越界導致內存分配失敗的情況更為常見。此時,可從分配失敗的地方開始回溯最近那個分配成功的malloc,看附近是否存在內存拷貝和數組越界的操作。
注意,調用malloc(0)時,某些庫實現將其等同malloc(1)處理,某些則返回空指針。
【對策】若申請的內存單位為吉字節(GigaByte),可考慮選用64位尋址空間的機器,或將數據暫存於硬盤文件中。此外,申請動態內存后,必須判斷指向該內存的指針是否為NULL,並進行防錯處理,比如使用return語句終止本函數或調用exit(1)終止整個程序的運行。
2.3.3 內存釋放失敗
內存釋放失敗的主要原因有:
1) 釋放未指向動態內存的指針;
2) 指向動態內存的指針在釋放前被修改;
3) 內存越界,導致malloc等分配函數所維護的管理信息被破壞;
4) 內存重復釋放(Double Free)。
情況1屬於低級錯誤,即指針並未執行malloc分配,卻調用free釋放該指針指向的內存。
1 int main(void) 2 { 3 int dwMem = 0; //具有迷惑性的變量名 4 int *pBuf = &dwMem; 5 free(pBuf); 6 7 return 0; 8 } 9 //執行后報錯:*** glibc detected *** ./test: free(): invalid pointer: 0xbf84b35c ***
情況2多發生在從申請內存到最后釋放跨越多個模塊歷經大量處理邏輯時,指針初始值被修改掉。簡單示例如下:
1 int main(void) 2 { 3 char *pMem = malloc(10); 4 if(NULL == pMem) 5 return -1; 6 7 *pMem++; //即*(pMem++),等效於*pMem; pMem++; 8 free(pMem); 9 10 return 0; 11 } 12 //執行后報錯:*** glibc detected *** ./test: free(): invalid pointer: 0x082b5009 ***
內存越界也可能導致內存釋放失敗:
1 int main(void) 2 { 3 char *pMem = malloc(2); 4 if(NULL == pMem) 5 return -1; 6 7 memset(pMem, 0, sizeof(int)*10); 8 free(pMem); 9 return 0; 10 } 11 //執行后報錯:*** glibc detected *** ./test: free(): invalid next size (fast): 0x09efa008 ***
內存重復釋放最簡單但最不可能出現的示例如下:
1 int main(void) 2 { 3 char *pMem = malloc(10); 4 if(NULL == pMem) 5 return -1; 6 7 free(pMem); 8 free(pMem); 9 10 return 0; 11 } 12 //執行后報錯:*** glibc detected *** ./test: double free or corruption (fasttop): 0x09709008 ***
通常,編碼者會封裝接口以更好地管理內存的申請和釋放。若釋放接口內部在釋放前未判斷指向動態內存的指針是否為空,或釋放后未將指向該內存的指針設置為空。當程序中調用關系或處理邏輯過於復雜(尤其是對於全局性的動態內存),難以搞清內存何時或是否釋放,加之接口未作必要的防護,極易出現內存重復釋放。
此外,當程序中存在多份動態內存指針的副本時,很容易經由原內存指針及其副本釋放同一塊內存。
1 int main(void) 2 { 3 char *pMem = malloc(sizeof(char)*10); 4 if(NULL == pMem) 5 return -1; 6 7 char *pMemTemp = pMem; 8 //Do Something... 9 10 free(pMem); 11 free(pMemTemp); 12 return 0; 13 }
上例中僅需釋放pMem或pMemTemp其一即可。
【對策】幸運的是,內存釋放失敗會導致程序崩潰,故障明顯。並且,可借助靜態或動態的內存檢測技術進行排查。
對於重復釋放,可仿照《C語言通用雙向循環鏈表操作函數集》一文中介紹的SAFE_FREE宏,盡可能地“規避”其危害(但當內存指針存在多個副本時無能為力)。
1 #define SAFE_FREE(pointer) SafeFree(&(pointer)) //與SAFE_ALLOC入參指針形式一致 2 void SafeFree(void **pointer) 3 { 4 if(pointer != NULL) 5 { 6 free(*pointer); 7 *pointer = NULL; 8 } 9 }
此外,應在設計階段保證數據結構和流程盡量地簡潔合理,從根本上解決對象管理的混亂。
2.3.4 內存分配與釋放不配對
編碼者一般能保證malloc和free配對使用,但可能調用不同的實現。例如,同樣是free接口,其調試版與發布版、單線程庫與多線程庫的實現均有所不同。一旦鏈接錯誤的庫,則可能出現某個內存管理器中分配的內存,在另一個內存管理器中釋放的問題。此外,模塊封裝的內存管理接口(如GetBuffer和FreeBuffer)在使用時也可能出現GetBuffer配free,或malloc配FreeBuffer的情況,尤其是跨函數的動態內存使用。
【對策】動態內存的申請與釋放接口調用方式和次數必須配對,防止內存泄漏。分配和釋放最好由同一方管理,並提供專門的內存管理接口。若不能堅持誰申請誰釋放,則應進行協商或加代碼注釋說明。
2.3.5 內存越界
除明顯的讀寫越界外,關於動態內存還存在一種sizeof計算錯誤導致的越界:
1 int main(void) 2 { 3 T_CHK_MEM *pMem = malloc(sizeof(pMem)); 4 if(NULL == pMem) 5 return -1; 6 7 memset(pMem, 0, sizeof(T_CHK_MEM)); 8 free(pMem); 9 return 0; 10 } 11 //執行后報錯:*** glibc detected *** ./test: free(): invalid next size (fast): 0x09239008 ***
這種越界也是內存釋放失敗的一個原因。正確的內存申請寫法應該是:
1 T_CHK_MEM *pMem = malloc(sizeof(*pMem)); 2 //Or 3 T_CHK_MEM *pMem = malloc(sizeof(T_CHK_MEM));
【對策】當模塊提供動態內存管理的封裝接口時,可采用“紅區”技術檢測內存越界。例如,接口內每次申請比調用者所需更大的內存,將其首尾若干字節設置為特殊值,僅將中間部分的內存返回給調用者使用。這樣,通過檢查特殊字節是否被改寫,即可獲知是否發生內存越界。其結構示意圖如下:
2.3.6 內存泄露
內存泄漏指由於疏忽或錯誤造成程序未能釋放已不再使用的內存。這時,內存並未在物理上消失,但程序因設計錯誤導致在釋放該塊內存之前就失去對它的控制權,從而造成內存浪費。只發生一次的少量內存泄漏可能並不明顯,但內存大量或不斷泄漏時可能會表現出各種征兆:如性能逐漸降低、全部或部分設備停止正常工作、程序崩潰以及系統提示內存耗盡。當發生泄漏的程序消耗過多內存以致其他程序失敗時,查找問題的真正根源將會非常棘手。此外,即使無害的內存泄漏也可能是其他問題的征兆。
短暫運行的程序發生內存泄漏時通常不會導致嚴重后果,但以下各種內存泄漏將導致較嚴重的后果:
- 程序運行后置之不理,並隨着時間流逝不斷消耗內存(如服務器后台任務,可能默默運行若干年);
- 頻繁分配新的內存,如顯示電腦游戲或動畫視頻畫面時;
- 程序能夠請求未被釋放的內存(如共享內存),甚至在程序終止時;
- 泄漏發生在操作系統內部或關鍵驅動中;
- 內存受限,如嵌入式系統或便攜設備;
- 某些操作系統在程序運行終止時並不自動釋放內存,且一旦內存丟失只能通過重啟來恢復。
通常所說的內存泄漏指堆內存的泄漏。廣義的內存泄漏還包括系統資源的泄漏(Resource Leak),而且比堆內存的泄漏更為嚴重。
內存泄漏按照發生頻率可分為四類:
1) 常發性內存泄漏。即發生內存泄漏的代碼被多次執行,每次執行都會泄漏一塊內存。
2) 偶發性內存泄漏。即發生內存泄漏的代碼只發生在特定環境或操作下。特定的環境或操作下,偶發性泄漏也會成為常發性泄漏。
3) 一次性內存泄漏。即發生內存泄漏的代碼只執行一次,導致有且僅有一塊內存發生泄漏。例如:
1 char* gpszFileName = NULL; 2 void SetFileName(const char* pszFileName) 3 { 4 if(gpszFileName != NULL) 5 free(gpszFileName); 6 7 gpszFileName = strdup(pszFileName); 8 } 9 int main(void) 10 { 11 SetFileName("test.c"); 12 SetFileName("test.h"); 13 return 0; 14 }
若程序結束時未釋放gpszFileName指向的字符串,則即使多次調用SetFileName函數,也總有且僅有一塊內存發生泄漏。
4) 隱式內存泄漏。即程序在運行過程中不停地分配內存,但直到結束時才釋放內存。例如,一個線程不斷分配內存,並將指向內存的指針保存在一個數據存儲(如鏈表)中。但在運行過程中,一直沒有任何線程進行內存釋放。或者,N個線程分配內存,並將指向內存的指針傳遞給一個數據存儲,M個線程訪問數據存儲進行數據處理和內存釋放。若N遠大於M,或M個線程數據處理的時間過長,則分配內存的速度遠大於釋放內存的速度。嚴格地說這兩種場景下均未發生內存泄漏,因為最終程序會釋放所有已申請的內存。但對於長期運行(如服務器)或內存受限(如嵌入式)的系統,若不及時釋放內存可能會耗盡系統的所有內存。
內存泄漏的真正危害在於其累積性,這將最終耗盡系統所有的內存。因此,一次性內存泄漏並無大礙,因為它不會累積;而隱式內存泄漏危害巨大,因其相比常發性和偶發性內存泄漏更難檢測。
內存泄漏的主要原因有:
1) 指向已申請內存的指針被挪作他用並被改寫;
2) 因函數內分支語句提前退出,導致釋放內存的操作未被執行;
3) 數據結構或處理流程復雜,導致某些應該釋放內存的地方被遺忘;
4) 試圖通過函數指針參數申請並傳遞動態內存;
5) 線程A分配內存,線程B操作並釋放內存,但分配速度遠大於釋放速度。
情況1屬於低級錯誤,通常發生在同時管理多塊動態內存時。
1 int main(void) 2 { 3 char *pPrevMem = malloc(sizeof(char)*10); 4 if(NULL == pPrevMem) 5 return -1; 6 char *pNextMem = malloc(sizeof(char)*10); 7 if(NULL == pNextMem) 8 return -1; 9 10 pNextMem = pPrevMem; 11 free(pNextMem); 12 return 0; 13 }
上例將指針pPrevMem賦值給指針pNextMem,從而導致pNextMem以前所指向的動態內存無法釋放,因為已經丟失指向該位置的引用。
情況2是最為常見的內存泄漏案例,尤其是在分支語句為異常和錯誤處理時。
1 int IsSthElseValid(void) {return 0; /*dummy*/} 2 int main(void) 3 { 4 char *pMem = malloc(sizeof(char)*10); 5 if(NULL == pMem) 6 return -1; 7 8 if(!IsSthElseValid()) 9 return -2; 10 11 free(pMem); 12 return 0; 13 }
上例當函數IsSthElseValid()返回值不為真時,指針pMem指向的內存將就不被釋放。通常程序在入口處分配內存,在出口處釋放內存。但C函數可在任何地方退出,一旦某個出口處未釋放應該釋放的內存,就會發生內存泄漏。
與之相似的是,為完成某功能需要連續申請一系列動態內存。但當某次分配失敗退出時,未釋放系列中其他已成功分配的內存。
情況3多發生在內存掛接(分配的動態內存中某些元素又指向其他動態內存)時,容易出現僅釋放父內存或先釋放父內存后釋放子內存的錯誤。
1 struct book{ 2 char szTitle[50]; 3 char szAuthor[40]; 4 float fPrice; 5 int dwMask[4]; 6 }gtRef = { .fPrice = 11.62, 7 .szAuthor = "F. Scott Fitzgerald", 8 .szTitle = "The Great Gatsby", 9 .dwMask[0 ... 3] = 1}; 10 11 typedef struct{ 12 int dwDataLen; 13 char *pData; 14 }T_DATA_BUF; 15 16 int main(void) 17 { 18 T_DATA_BUF *ptBuf = (T_DATA_BUF *)calloc(sizeof(T_DATA_BUF), 1); 19 ptBuf->dwDataLen = sizeof(struct book); 20 ptBuf->pData = (char *)calloc(ptBuf->dwDataLen, 1); 21 memcpy(ptBuf->pData, >Ref, ptBuf->dwDataLen); 22 23 struct book *ptBook = (struct book *)ptBuf->pData; 24 printf("Reference: '%s' by %s, $%.2f, [%d-%d-%d-%d].\n", ptBook->szTitle, 25 ptBook->szAuthor, ptBook->fPrice, ptBook->dwMask[0], 26 ptBook->dwMask[1], ptBook->dwMask[2], ptBook->dwMask[3]); 27 28 free(ptBuf->pData); 29 free(ptBuf); 30 return 0; 31 }
若只執行free(ptBuf)語句,則pData指向的子內存泄露;若先執行free(ptBuf)后執行free(ptBuf->pData),則釋放ptBuf所指內存后,該內存無效且ptBuf成為迷途指針,無法保證能通過pData釋放子內存。當分配的掛接內存提供給外部使用時,很難保證調用者進行兩次釋放操作,並且順序正確。
在消息驅動通信中,同一消息的處理往往跨越多個模塊。處於消息接收末端的模塊,需要釋放消息內的消息體。一旦忘記釋放,在消息轉發頻繁時將不斷泄露內存。這種錯誤從代碼層面很難發現,需要設計時對流程有很強的理解。
情況4根源在於對C語言函數參數傳遞方式(傳值調用)的誤解。
1 void GetMemory(char *pMem, int dwMemBytes) 2 { 3 pMem = (char *)malloc(sizeof(char) * dwMemBytes); 4 } 5 int main(void) 6 { 7 char *pStr = NULL; 8 GetMemory(pStr, 100); //pStr仍為NULL 9 //strcpy(pStr, "hello"); //Segmentation fault 10 free(pStr); 11 return 0; 12 }
編譯器為函數GetMemory的每個參數制作臨時副本。假設指針參數pMem的副本是_ pMem,則編譯器使_ pMem初值等於pMem。在函數體內修改_pMem的值(即所指的內存地址),並不會影響到pMem的取值。因此pMem仍為空指針,自然無法借此釋放函數GetMemory內所申請的動態內存。若函數main中循環調用GetMemory ,則內存將不斷泄露。
若非要用指針參數申請內存,可改用指向指針的指針,或用函數返回值來傳遞動態內存。
1 void GetMemory(char **ppMem, int dwMemBytes){ 2 *ppMem = (char *)malloc(sizeof(char) * dwMemBytes); 3 } //調用方式:GetMemory(&pStr, 100); 4 //Or 5 char *GetMemory(int dwMemBytes){ 6 pMem = (char *)malloc(sizeof(char) * dwMemBytes); 7 return pMem; 8 } //調用方式:pStr = GetMemory(pStr, 100);
【對策】設計時應規范各動態內存的用途及申請釋放的流程,避免指針多用和忘記釋放。
函數內部若存在退出分支,則每個返回之前都應確保釋放已成功分配的內存。
對於掛接內存,應按照分配順序反向遍歷釋放各子內存,最后釋放父內存(最好能為其提供專門的分配和釋放接口)。也可借助柔性數組特性來簡化釋放操作,尤其是當掛接內存提供給外部調用者使用時:
1 typedef struct{ 2 int dwDataLen; 3 char aData[0]; //Or 'char aData[]' 4 }T_DATA_BUF2; 5 int main(void) 6 { 7 T_DATA_BUF2 *ptBuf = (T_DATA_BUF2 *)calloc(sizeof(T_DATA_BUF)+sizeof(struct book), 1); 8 ptBuf->dwDataLen = sizeof(struct book); 9 memcpy(ptBuf->aData, >Ref, ptBuf->dwDataLen); 10 11 struct book *ptBook = (struct book *)ptBuf->aData; 12 printf("Reference: '%s' by %s, $%.2f, [%d-%d-%d-%d].\n", ptBook->szTitle, 13 ptBook->szAuthor, ptBook->fPrice, ptBook->dwMask[0], 14 ptBook->dwMask[1], ptBook->dwMask[2], ptBook->dwMask[3]); 15 16 free(ptBuf); 17 return 0; 18 }
這種寫法分配的內存連續,而且只需一次free即可釋放,易用性更好。
消息通信過程中,消息處理結束時必須釋放消息內的消息體,異步通信時可由接收末端的模塊釋放,同步通信時可由發送前端的模塊釋放。這需要設計時規范消息的轉發和處理流程。
不要試圖通過函數指針參數申請並傳遞動態內存,可改由二級指針或函數返回值傳遞。
當程序代碼龐雜且邏輯復雜時,可考慮增加內存泄漏檢測機制。其基本原理是截獲對內存分配和釋放函數的調用,從而跟蹤每塊內存的生命周期。例如,每次成功分配一塊內存后,將內存分配信息(如指向它的指針、文件名、函數名、行號和申請字節數等)加入一個全局鏈表中;每當釋放一塊內存時,再從鏈表中刪除相應的內存信息。這樣,當程序結束時,鏈表中剩余的內存信息結點就對應那些未被釋放的內存。詳細算法見《基於鏈表的C語言堆內存檢測》一文。
對於隱式內存泄露,可在程序運行過程中監控當前內存的總使用量和分配釋放情況。以分配內存時的文件名和行號為索引,遍歷鏈表結點即可計算出各處已分配但未釋放的內存總量。若在連續多個時間間隔內,某文件中某行所分配的內存總量不斷增長,則基本可確定屬於隱式內存泄露(尤其是多線程引起的)。
最后,頻繁地調用庫函數申請和釋放內存效率較低,且易產生內存碎片。可采用內存池技術,以高效地管理和檢測內存。設計和編碼時應仔細分析需求,以減少不必要的動態內存使用。例如,解析定長的短消息內容時,就無需分配動態內存,定義固定長度的數組即可。
2.2.7 使用已釋放堆內存
動態內存被釋放后,其中的數據可能被應用程序或堆分配管理器修改。不要再試圖訪問這塊已被釋放的內存,否則可能導致不可預料的后果。
1 int main(void) 2 { 3 char *pStr = (char *)malloc(100); 4 strcpy(pStr, "hello"); 5 free(pStr); 6 if(pStr != NULL) //出現迷途指針 7 { 8 strcpy(pStr, "world"); 9 printf("%s\n", pStr); 10 } 11 return 0; 12 }
上例通常不會導致程序異常。但若使用迷途指針時,已釋放的動態內存恰好被重新分配給其他數據,則strcpy語句可能造成意想不到的錯誤。除非法訪問外,迷途指針還可能導致重復釋放內存等故障。
在多線程環境下,線程A通過異步消息通知線程B操作某塊全局動態內存,通知后稍等片刻(以便線程B完成操作)再釋放該內存。若延時不足無法保證其先操作后釋放的順序,則可能因訪問已釋放的動態內存而導致進程崩潰。
【對策】務必保證已分配的內存塊被且僅被釋放一次,禁止訪問指向已釋放內存的指針。若該指針還存在多個副本,則必須保證當它所指向的動態內存被釋放后,不再使用所有其他副本。
避免上述錯誤發生的常用方法是釋放內存后立即將對應的指針設置為空(NULL)。
2.3.8 誤用realloc函數
realloc函數極易誤用,因此專門作為一節以強調其常見性。
realloc函數原型為:
| #include <stdlib.h> void *realloc(void *ptr, size_t newsize); |
該函數將參數ptr指向的內存塊更改為newsize字節。其行為因參數取值而變:
1) 若參數ptr為空,則等同malloc(newsize),即分配一塊newsize字節的內存。若成功返回該內存地址,否則返回NULL。
2) 若參數newsize為0,則等同free(ptr),即釋放ptr指向的內存塊,並返回NULL。
3) 若ptr指向的內存后面有足夠的空閑且連續空間,則在原內存區位置上向高地址方向擴充,並返回原ptr指針值;若原內存區后面空間不足,則重新分配一塊newsize字節的未初始化內存空間,將原內存區數據拷貝到新分配的內存區,然后自動釋放原內存區,返回新分配的內存區首地址。若分配失敗則返回NULL,且原內存塊保持不變(不會釋放或移動)。注意,若newsize值小於ptr所指向的原內存區長度,則原內存區尾部多出的oldsize-newsize字節內存可能會被釋放(導致數據丟失)。
【對策】使用realloc函數時需注意以下幾點:
1) 參數ptr指向的內存塊必須位於堆區,即ptr必須是malloc/calloc/realloc的返回值,或者為NULL。否則運行時會報告"realloc(): invalid pointer"的錯誤。
2) realloc函數分配內存時,返回的指針一定是適當對齊的,使其可用於任何數據對象。
3) 若參數newsize為0,則realloc函數釋放ptr所指的原內存塊並返回NULL。此時ptr成為迷途指針,若不檢查realloc返回值而直接使用ptr,會導致程序崩潰。
4) 若newsize大於原內存區長度,則realloc函數可能釋放ptr指向的原內存塊並重新分配內存。此時ptr成為迷途指針,再次訪問時會導致程序崩潰。正因為內存區域可能移動位置,所以不應使任何指針指向該區。
1 int main(void) 2 { 3 char *pMem1 = (char *)malloc(10); 4 if(NULL == pMem1) 5 return -1; 6 7 char *pMem2 = pMem1; 8 pMem1 = (char *)realloc(pMem2, 100); 9 if(NULL == pMem1) 10 return -1; 11 //… 12 return 0; 13 }
上例中若realloc函數重新分配內存,則pMem2所記錄的原pMem2內存區被自動釋放,從而pMem2成為迷途指針。
此外,若重新分配內存時失敗(返回NULL),則調用者需調用free函數顯式地釋放ptr指向的原內存塊。
5) 不要將realloc函數返回結果再賦值給ptr,即避免使用ptr=realloc(ptr, newsize)。如:
1 int main(void) 2 { 3 char *pMem = (char *)malloc(10); 4 if(NULL == pMem) 5 return -1; 6 7 pMem = (char *)realloc(pMem, 100); 8 if(NULL == pMem) 9 return -1; 10 //… 11 return 0; 12 }
若realloc函數分配內存失敗,則pMem會變為空指針,從而丟失其原先指向的10字節內存空間(造成內存泄露)。為避免內存泄露,可將realloc函數返回值賦給pNewMem指針,成功分配內存后再將pNewMem指針值賦給pMem指針。
循環調用realloc函數時,可先定義指針pMem並初始化為NULL。然后在循環體內將pMem作為入參調用realloc(pMem為空時等同malloc),並將返回值賦給指針pReMem,成功分配內存后再將pReMem指針值賦給pMem指針。這樣,初次分配和再次分配都調用realloc,程序比較清晰健壯。該方式的實例可參考《守護進程接收終端輸入的一種變通性方法》一文中的ReadLine函數。
三 總結
本文已詳細討論了三種內存使用時常見的問題及其對策。除設計和編碼時加以注意外,還可借助內存檢測工具(如Valgrind等)靜態或動態地檢查代碼中的內存缺陷。但對於用戶終端或大型工程,外部工具往往不可用,此時內置的內存檢測代碼就可派上用場。
除本文所述內容外,設備或模塊間通信還涉及內存對齊和字節順序等問題。某一方(尤其是DLL庫)增刪接口結構體內成員或調整成員順序時,若另一方忘記同步更新,則必然導致解析錯誤。
