一 前言
本文所討論的“內存”主要指(靜態)數據區、堆區和棧區空間(詳細的布局和描述參考《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(5, '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)
53 {
54 printf("[%s(%d)]Null Pointer!\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)]Null Pointer: (%p), (%p)!\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)]Null Pointer: (%p), (%p)!\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);
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,看附近是否存在內存拷貝和數組越界的操作。
【對策】若申請的內存單位為吉字節(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++;
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));