1. 程式人生 > >linux c 程式碼測試之記憶體越界及記憶體洩露

linux c 程式碼測試之記憶體越界及記憶體洩露

 記憶體越界是我們軟體開發中經常遇到的一個問題。不經意間的複製常常導致很嚴重的後果。經常使用memset、memmove、strcpy、strncpy、strcat、sprintf的朋友肯定對此印象深刻,下面就是我個人在開發中實際遇到的一個開發問題,頗具典型。

  1. #define MAX_SET_STR_LENGTH  50
  2. #define MAX_GET_STR_LENGTH 100
  3. int* process(char* pMem, int size)  
  4. {  
  5.     char localMemory[MAX_SET_STR_LENGTH] = {0};  
  6.     int* pData = NULL;  
  7.     /*  code process */
  8.     memset(localMemory, 1, MAX_GET_STR_LENGTH);  
  9.     memmove(pMem, localMemory, MAX_GET_STR_LENGTH);  
  10.     return pData;  
  11. }  

    這段程式碼看上去沒有什麼問題。我們本意是對localMemory進行賦值,然後拷貝到pMem指向的記憶體中去。其實問題就出在這一句memset的大小。根據localMemory初始化定義語句,我們可以看出localMemory其實最初的申明大小隻有MAX_SET_STR_LENGTH,但是我們賦值的時候,卻設定成了MAX_GET_STR_LENGTH。之所以會犯這樣的錯誤,主要是因為MAX_GET_STR_LENGTH和MAX_SET_STR_LENGTH極其相似。這段程式碼編譯後,產生的後果是非常嚴重的,不斷沖垮了堆疊資訊,還把返回的int*設定成了非法值。

    那麼有沒有什麼好的辦法來處理這樣一個問題?我們可以換一個方向來看。首先我們檢視,在軟體中存在的資料型別主要有哪些?無非就是全域性資料、堆資料、棧臨時資料。搞清楚了需要控制的資料之後,我們應該怎麼對這些資料進行監控呢,一個簡單有效的辦法就是把memset這些函式替換成我們自己的函式,在這些函式中我們嚴格對指標的複製、拷貝進行判斷和監督。

    (1)事實上,一般來說malloc的資料是不需要我們監督的,因為記憶體分配的時候,通常庫函式會比我們要求的size多分配幾個位元組,這樣在free的時候就可以判斷記憶體的開頭和結尾處有沒有指標溢位。朋友們可以試一下下面這段程式碼。

  1. void
     heap_memory_leak()  
  2. {  
  3.     char* pMem = (char*)malloc(100);  
  4.     pMem[-1] = 100;  
  5.     pMem[100] = 100;  
  6.     free(pMem);  
  7. }  
    pMem[-1] = 100是堆左溢位, pMem[100]是堆右溢位。

    (2)堆全域性資料和棧臨時資料進行處理時,我們利用memset初始化記錄全域性指標或者是堆疊臨時指標

  a) 首先對memset處理,新增下面一句巨集語句

    #define memset(param, value, size)      MEMORY_SET_PROCESS(__FUNCTION__, __LINE__, param, value, size)

    b) 定義記憶體節點結構

  1. typedefstruct _MEMORY_NODE  
  2. {  
  3.     char functionName[64];  
  4.     int line;  
  5.     void* pAddress;  
  6.     int size;  
  7.     struct _MEMORY_NODE* next;  
  8. }MEMORY_NODE;  

    其中functionName記錄了函式名稱,line記錄檔案行數, pAddress記錄了指標地址, size指向了pAddress指向的記憶體大小,next指向下一個結構節點。

     c)記錄記憶體節點屬性

    在MEMORY_SET_PROCESS處理過程中,不僅需要呼叫memset函式,還需要對當前記憶體節點進行記錄和儲存。可以通過使用單鏈表節點的方法進行記錄。但是如果發現pAddress指向的記憶體是malloc時候分配過的,此時就不需要記錄了,因為堆記憶體指標溢位的問題lib庫已經幫我們解決了。

    d)改造原有記憶體指標操作函式

    比如對memmove等函式進行改造,不失去一般性,我們就以memmove作為範例。

    新增巨集語句 #define memmove(dst, src, size)        MEMMOVE_PROCESS(dst, src, size)

  1. void MEMMOVE_PROCESS(void* dst, constvoid* src, int size)  
  2. {  
  3.     MEMORY_NODE* pMemNode = check_node_exist(dst);  
  4.     if(NULL == pMemNode) return;  
  5.     assert(dst >= (pMemNode->pAddress));  
  6.     assert(((char*)dst + size) <= ((char*)pMemNode->pAddress + pMemNode->size));  
  7.         memmove(dst, src, size);  
  8.     return;  
  9. }  

  e)下面就是記憶體節點的刪除工作。

    我們知道函式是需要反覆使用堆疊的。不同時間相同的堆疊地址對應的是完全不同的指標內容,這就要求我們在函式返回的時候對記憶體地址進行清理,把記憶體節點從對應的連結串列刪除。

    我們知道在函式執行後,ebp和esp之間的記憶體就是通常意義上臨時變數的生存空間,所以下面的一段巨集就可以記錄函式的記憶體空間。

  1. #ifdef MEMORY_LEAK_TEST
  2. #define FUNCTION_LOCAL_SPACE_RECORD()\
  3. {\  
  4.     int* functionBpRecord = 0;\  
  5.     int*  functionSpRecord = 0;\  
  6. }  
  7. #else
  8. #define FUNCTION_LOCAL_SPACE_RECORD()
  9. #endif
  10. #ifdef MEMORY_LEAK_TEST
  11. #define FUNCTION_LEAVE_PROCESS()\
  12. {\  
  13. __asm { mov functionBpRecord, bp\  
  14.     mov functionSpRecord, sp}\  
  15.     FREE_MEMORY_NODE(functionBpRecord, functionSpRecord)\  
  16. }  
  17. #else
  18. #define FUNCTION_LEAVE_PROCESS()
  19. #endif

    這兩段巨集程式碼,需要插在函式的起始位置和結束的位置,這樣在函式結束的時候就可以根據ebp和esp刪除堆疊空間中的所有記憶體,方便了堆疊的重複使用。如果是全域性記憶體,因為函式的變化不會導致地址的變化,所以沒有必要進行全域性記憶體節點的處理。

記憶體溢位檢查流程總結:

    (1)對memset進行重新設計,記錄除了malloc指標外的一切記憶體;

    (2)對memmove, strcpy, strncpy,strcat,sprintf等全部函式進行重新設計,因為我們需要對他們的指標執行範圍進行判斷;

    (3)在函式的開頭和結尾位置新增巨集處理。函式執行返回前進行節點清除。

  在我們個人程式設計的過程當中,記憶體洩露雖然不會像記憶體溢位那樣造成各種莫名奇妙的問題,但是它的危害也是不可忽視的。一方面,記憶體的洩露導致我們的軟體在執行過程中佔用了越來越多的記憶體,佔有資源而又得不到及時清理,這會導致我們程式的效率越來越低;另一方面,它會影響我們使用者的體驗,失去市場的競爭能力。

    常見的記憶體洩露是這樣的:

  1. void process(int size)  
  2. {  
  3.     char* pData = (char*)malloc(size);  
  4.     /* other code  */
  5.     return/* forget to free pData */
  6. }  
    如上圖所示,我們在函式process的處理過程中,每一次都需要對記憶體進行申請,但是在函式結束的時候卻沒有進行釋放。如果這樣的一段程式碼出現在業務側,那麼後果是難以想象的。舉個例子來說,如果我們伺服器每秒鐘需要接受100個使用者的併發訪問,每個使用者過來的資料,我們都需要本地申請記憶體重新儲存一份。處理結束之後,如果記憶體沒有得到很好地釋放,就會導致我們伺服器可用的實體記憶體越來越少。一旦達到某一個臨界點之後,作業系統不得不通過內外存的排程來滿足我們申請新記憶體的需求,這在另一方面來講又會降低伺服器服務的質量。

    記憶體洩露的危害是不言而喻的,但是查詢記憶體洩露卻是一件苦難而且複雜的工作。我們都知道,解決bug是一件非常簡單的事情,但是尋找bug的出處卻是一件非常吃力的事情。因此,我們有必要在自己編寫程式碼的時候,就把查詢記憶體洩露的工作放在很重要的位置上面。那麼有沒有什麼辦法來解決這一問題呢?

    我想要做到解決記憶體洩露,必須做到下面兩個方面:

    (1)必須記錄記憶體在哪個函式申請的,具體檔案的行數是多少

    (2)記憶體應該什麼時候被釋放

   要完成第1個條件其實並不困難。我們可以用節點的方法記錄我們申請的記憶體:

    a)設定節點的資料結構

  1. typedefstruct _MEMORY_NODE  
  2. {  
  3.     char functionName[64];  
  4.     int line;  
  5.     void* pAddress;  
  6.     struct _MEMORY_NODE* next;  
  7. }MEMORY_NODE;  
    其中 functionName記錄函式名稱,line記錄行數, pAddress記錄分配的地址, next記錄下一個記憶體節點。

    b)修改記憶體的分配函式

    對業務側的malloc進行函式修改,新增下面一句巨集語句

    #define malloc(param)  MemoryMalloc(__FUNCTION__, __LINE__, param)

    在樁函式側書寫下面的程式碼

  1. void* MemoryMalloc(constchar* name, int line, int size)  
  2. {  
  3.     void* pData = (void*)malloc(size);  
  4.     MEMORY_NODE* pMemNode = NULL;  
  5.     if(NULL == pData) return NULL;  
  6.     memset((char*)pData, 0, size);  
  7.     pMemNode = (MEMORY_NODE*)malloc(sizeof(MEMORY_NODE));  
  8.     if(NULL == pMemNode){  
  9.         free(pData);  
  10.         return NULL;  
  11.     }  
  12.     memset((char*)pMemNode, 0, sizeof(MEMORY_NODE));  
  13.     memmove(pMemNode->functionName, name, strlen(name));  
  14.     pMemNode->line = line;  
  15.     pMemNode->pAddress = pData;  
  16.     pMemNode->next = NULL;  
  17.     add_memory_node(pMemNode);  
  18.     return pData;  
  19. }  

    記憶體的分配過程中還涉及到了節點的新增,所以我們還需要新增下面的程式碼
  1. static MEMORY_NODE* gMemNode = NULL;  
  2. void add_memory_node(MEMORY_NODE* pMemNode)  
  3. {  
  4.     MEMORY_NODE* pNode = gMemNode;  
  5.     if(NULL == pMemNode) return;  
  6.     if(NULL == gMemNode){  
  7.         gMemNode = pMemNode;  
  8.         return;  
  9.     }  
  10.     while(NULL != pNode->next){  
  11.         pNode = pNode->next;  
  12.     }  
  13.     pNode->next = pMemNode;  
  14.     return;  
  15. }  
    文中gMemNode表示所有記憶體節點的根節點,我們每增加一次malloc過程就會對記憶體節點進行記錄。在記錄過程中,我們還會記錄呼叫malloc的函式名稱和具體檔案行數,這主要是為了方便我們在後面進行故障定位的時候更好地查詢。

   完成了第一個條件之後,我們就要對第二個條件進行完成。

a)記憶體什麼時候釋放,這取決於我們在函式中是怎麼實現的,但是我們在編寫測試用例的時候卻是應該知道記憶體釋放沒有,比如說如果測試用例全部結束了,我們有理由相信assert(gMemNode == NULL)這應該是恆等於真的。

    b)記憶體釋放的時候,我們應該做些什麼?和節點的新增一樣,我們在記憶體釋放的時候需要free指定的記憶體,free節點,free節點的記憶體,下面就是在釋放的時候我們需要進行的操作

    對業務側的free函式進行修改,新增下面一句巨集程式碼,

    #define free(param)      MemoryFree(param)

    在樁函式側輸入下面的程式碼:

  1. void MemoryFree(void* pAddress)  
  2. {  
  3.     if(NULL == pAddress) return;  
  4.     delete_memory_node(pAddress);  
  5.     free(pAddress);  
  6. }  

    在刪除記憶體的時候,需要刪除節點,刪除節點的記憶體
  1. void delete_memory_node(void* pAddress)  
  2. {  
  3.     MEMORY_NODE* pHead = gMemNode;  
  4.     MEMORY_NODE* pMemNode = gMemNode;  
  5.     while(NULL != pMemNode){  
  6.         if(pAddress == pMemNode->pAddress)  
  7.             break;  
  8.         pMemNode = pMemNode->next;  
  9.     }  
  10.     if(NULL == pMemNode) {  
  11.         assert(1 == 0);  
  12.         return;  
  13.     }  
  14.     while(pMemNode != pHead->next){  
  15.         pHead = pHead->next;  
  16.     }