memory leak & double free如何排查?

本文從自己動手構造一個內存泄露分析工具的方面入手,而不對具體內存排查工具的使用進行說明,以展示內存泄露排查的本質,提供一些思路,當在手頭沒有現成工具可以使用的情況下讓自己不至於那麼的無助,至少我們還可以自己構建工具解決它。

memory leak & double free

如果分配的多餘釋放的,在我們的代碼中就是調用malloc(calloc、realloc、memalgin、new)的次數多於調用free(delete)的次數,那麼就叫做內存泄漏;反之,如果釋放的多餘分配的,在我們的代碼中就是free的次數多餘malloc的次數,那麼就是double free。

double free發生的情況不多,而memoryleak的情況卻不少,原因是glibc會自動檢查釋放的地址有效性,如果出現double free則會在打印出backtrace後coredump退出程序,因此只要有double free存在基本都能被發現,具體能不能定位到原因另說。

排查的方法

         根據上面對內存泄露和doublefree的描述,可以得出要排查問題的方法應該由兩部分組成:第一部分,也就是最重要的部分,就是收集分配和釋放的信息,最簡單的方法就是在調用分配函數如malloc(realloc、calloc、memalign、new)的地方記錄下分配點(caller)、大小及所獲得的內存地址,在free(delete)的地方記錄下調用地址(caller)及所釋放的內存地址;而第二部分就是對第一部分收集到的數據進行分析,也就是對分配所得的地址與釋放的地址的差集。

         在及下來對第一部分數據的收集和第二部分數據的分析方法進行簡單說明,最後對一些常用的內存泄露的工具進行介紹。

 

獲取caller地址

爲了能方便的定位到調用分配函數及釋放內存的地方,需要記錄下caller的地址來。如下圖中的第二列就是caller地址,也就是在調用函數後返回時要執行的下一條指令的地址。


在代碼中要獲取此地址需要一點技巧才行,可能平時的編程中不那麼常用到,下面舉例幾種方法。

方法一:

通過在函數中使用標籤,隨後獲取標籤地址,以此地址作爲caller返回地址。一下以獲取執行printf之後的地址作爲caller地址爲例進行說明,示例代碼如下:


通過gcc –glabel.c –o label後得到可執行程序,執行此示例程序輸出爲0x4004b0

此地址就爲所求的在調用printf後的返回地址。下面我們通過addr2line對此地址所表示的代碼行進行翻譯:

通過上面的輸出可以看出指示的是label.c的第7行,這個地址和我們上面的源代碼中的行號剛好相符。但是這地址明顯不是我們要的printf那行地址,那行應該是第五行,這需要對獲得的地址進行簡單的加減運算即可,如下代碼:


編譯執行後得出輸出的地址爲0x4004ac,再一次用addr2line得出:


這纔是我們需要的地址啊。隨着我們使用的優化編譯選項的不同,可能獲得的地址會有差異,但偏差並不大,但結合代碼場景不難分析出具體的行號來。

 

這方法有個唯一的缺點是在一個函數中不能存在重複的label,這樣就導致使用宏替換的方法在一個函數中如果出現兩次就行不通,因此這是個不可取的方法。

 

 

方法二:

既然通過label地址的方式不通用,那麼通過高級語言本身提供的機制看來要獲得caller地址是不可能的了,那麼我只能考慮更低級別的彙編語言了,在彙編面前好像拿什麼都行,想拿什麼拿什麼,獲取個caller地址那是很容易的事情。

廢話就不說了,代碼最容易說明意圖。下面就通過彙編獲取caller地址進行代碼示例:


簡短說明:通過call指令調用標籤1f,目的獲得1f的地址壓入堆棧,緊急着使用pop指令把堆棧頂的數據推入caller變量,因爲此時堆棧頂存的就是1f的地址,因此caller得到的就是1f的地址,此地址就是代碼所在行地址。

下面給出調用演示,獲取caller的地址:


編譯執行後輸出爲0x4004a5, 通過addr2line得出對應的代碼行是6行,這方法看來很靠譜,的確是所調用的那行地址。

 

方法三:

通過backtrace分析到caller的地址,是否想過有一天自己也能拿到和在gdb上輸入bt後顯示的backtrace一樣的調用棧幀數據呢。其實要拿到這個數據自己通過分析調用棧可以得到,但這是個很繁瑣的過程,首先需要懂一些底層的彙編知識,其次需要了解編譯器在函數調用、傳參方面怎麼如何堆棧進行佈局的。

只需要強大的基礎功底,只有很少的人掌握,好在存在一些給菜鳥用的工具,直接使用高手們提供的功能就可以了,比如我們可以使用現成的api搞定這事。

下面我們就通過backtrace()和backtrace_symbols()兩個api來獲取調用棧框數據:


說明:通過上面的代碼片段模擬出平時函數調用時的場景,在frame2()中調用frame1(),那麼我們就能得到一個嵌套的調用關係frame2——> frame1,在在frame1()中打印出此時的棧幀來應該是frame1在前frame2在後。下面我們編譯並執行得出:


其中中括號中的數字所表示的就是caller地址,這和在gdb上敲入bt時返回的第二列完全一樣。下面是通過addr2line對獲得的地址進行查找後得出的文件行號及所處的函數名:

 

方法四:

使用gcc提供的編譯選項-finstrument-functions並結合__cyg_profile_func_enter()__cyg_profile_func_exit()獲取caller的地址,如下面的code所示,其中的函數參數call_site就是caller地址。


通過gcc加選項-finstrument-functions編譯並執行後得到下面的結果:


可以看到通過這種方式也是一種得出caller地址的方法,但這種方法有一個問題是需要在源代碼中把不需要收集caller地址的函數屬性聲明爲__attribute__((__no_instrument_function__))這在一個已經存在成百上千個函數的項目中可不是個容易的活。

 

收集內存分配和釋放信息

通過前面介紹的獲取caller地址的方法,我們可以獲取到所發起調用的函數中的地址,有了這些知識後,我們要收集發起內存分配(malloc、realloc、calloc、memalgin、new)與釋放(free、delete)的數據那就很容易了。

而排查memory leak和double free也就是在分配和釋放之間記錄下所發起的活動過程而已,說白了也就是一個如何組織分配內存時獲得的信息與釋放過程時釋放掉的內存的過程罷了。

下面介紹兩種數據收集的方法,兩種方法其實都差不多,只是調度的時機不同而已。

方法一:

通過宏替換掉髮起內存分配的函數(malloc、realloc、calloc、memalgin、new),在自己提供的宏或者函數中收集分配信息。通過這個簡單的替換,不聲不響的做了自己需要的事情,而對調用者卻是透明的。這和設計模式上的包裝器模式類似,提供更多的功能卻不破壞原來的接口,當然這裏不屬於設計模式的範疇。

下面給出示例代碼,只是在調用具體的內存函數前先獲取caller地址,並把這些信息收集起來打印到標準錯誤流裏而已。我這裏使用的是收集調試數據與分析數據分開的方法,所以我只是把收集到的輸出來而已,而如果要做成在線實時收集處理需要構造自己的數據結果用於存儲收集到的數據和實時分析處理。

下面是一些用來替換內存分配函數的宏定義。


上面的宏覆蓋了主要的內存分配請求函數,但在c++中常用的內存分配和釋放分別是new和delete兩個操作符,而此兩個操作符結構比較特殊,不好通過一個宏函數的方式解決,因此這個比較麻煩,好在無所不能的宏加上操作符重載勉強能搞定,雖然不那麼美觀,但至少的確是一種解決方法。

下面是爲new、delete操作符定義的宏:


再加上精心準備的重載操作符函數new、delete:


在宏中獲取caller地址,而在重載操作符中得到返回的內存地址和請求的內存大小,一起配合起來解決要獲取caller和請求得到的內存地址的問題。

 

說明:這個方法比較靈活,也很方便,只需要把內存函數用宏替換掉就可以,當不需要調試的時候把宏註銷即可,並不影響系統性能。但這個方法有個不足,它只能在自己手邊能拿到源代碼的情況下使用,如果沒有代碼那就無能爲力了。

 

方法二:

由於上面提到的在沒有源代碼的情況下方法一就不好使了,因此我們需要尋找別的替換方案在沒有源代碼的情況下也能跑得起來纔是王道。

能不能存在一種方法在沒有源代碼的情況下也能獲取內存分配信息呢?答案是肯定存在的,不然如valgrind之類的內存分析工具怎麼能存在呢?的確存在這樣的一些方法,比如glibc內存分配就以鉤子的方式提供了支持,下面是malloc.h給出的hook聲明,具體可以通過查看man文檔,比如man__malloc_hook


根據man文檔所描述,當調用malloc的時候會觸發鉤子__malloc_hook,在鉤子函數中傳入的參數爲所請求的內存塊大小__size和所發起內存分配請求的caller地址。因此只要用自己內存分配函數的函數替換掉__malloc_hook既可以同時獲得所請求的內存大小尺寸,同時能得到caller地址,當然更重要的是能把自己分配的內存記錄下來。

話題說到此,如果只關心c語言的使用場景,那麼直接實現__malloc_hook已經可以算一段落完滿的劃下句號了。但是如果我們涉及到c++的開發,就不可能光使用malloc而不涉及到new操作符了。在c++中使用new獲得內存,大家都知道最後也是調用底層的c內存分配函數malloc等。因此如果我們不進行特殊處理,那麼在new的使用場景下__malloc_hook獲取到的caller地址就不是我們所關心的地址,那個地址是new操作符實現函數裏面發起malloc調用時的地址,這種情況下獲取的caller就沒有意義。因此針對c++的此種情況進行特使處理。

此時,我們在上面談到的獲取caller地址的方法三,用backtrace分析調用棧的方法就有用武之地了。通過對得到的backtrace進行分析發現在__malloc_hook所得到的調用堆棧中前面兩幀分別是libc.o 和libstdc++.so庫裏面的函數調用,因此我們可以把這兩幀跳過後就能得到我們代碼中發起內存分配請求的地址。下面所示的宏就是獲得caller地址所在的棧幀,存放在symbol中返回:


下面給出__malloc_hook的示例代碼實現,別的hook函數類似,由於篇幅的原因這裏就不貼出來了,可以查看附件代碼:

 

從收集的數據中分析內存問題

具體的分析方法取決於前面談到的收集方式,而我們在這裏是直接把信息打印到標準錯誤流輸出,因此把錯誤輸出重定向到一個文件就能收集到,而收集到的數據格式是根據數據收集的組織而定的,我們這裏按下面的格式組織數據:


其中等號後面的值是分配內存是返回的地址,括號中的size代表所請求的內存分配大小,而free、delete中的ptr後面的值是要釋放的內存地址。分配和釋放的內存值一一對應,如果對應不上就說明發生內存問題了,要麼泄露要麼double free。我們只需要用腳本寫個工具對收集得到的數據進行parse即可。

在這裏需要注意的一點技巧性問題是,parse的時候最後從文件後面開始,否則如果按順序從前往後處理的過程中,再分析double free的時候就不容易處理,因爲爲了分析double free的發生我們需要一直記錄下alloc的地點來,這樣如果數據量很大會對機器造成很大影響,但是如果從後往前分析的話就可以把free和malloc配對上的清除掉,配對上說明不存在內存問題。只要遇到alloc的地方都可以丟掉前面累積下來的配對數據,這樣需要保存下來的場景數據很小。

由於篇幅的關係,這裏不準備把parse腳本貼出來了。

 

示例演示

通過上面的這麼多的鋪墊,下面給出一個完整示例,演示內存泄露檢查的一般方法。

1、  編寫測試代碼

 

2、  編譯測試程序

使用的是malloc hook的方式:

g++  -g  -o  test1  test.c mem_debug.c

使用的是宏替換malloc的方式:

g++ -g  -DMEM_DEBUG  -o test2  test.c

 

3、  執行程序收集調試數據

執行test1及輸出結果:


執行test2及輸出結果:


由於調試數據是通過標準錯誤流輸出的,因此可以通過重定向標準錯誤輸出到文件中進行數據收集,如下所示:


程序test1和test2的輸出分別被收集到文件debug_info_test1.txt和debug_info_test2.txt中

說明:由於此給出的程序存在double free的示例成分,因此在執行的時候可能會導致coredump的現象發生,並伴隨着會在屏幕上打印Backtrace的現象,打出的Backtrace如下圖所示:

 

4、  分析收集到的數據

由於上面已經收集到用於調試memory leak和double free的數據,因此這裏用分析程序mem_debug_analyzer.py對數據進行分析,看看是否程序存在內存泄露和double free的問題。

先對test1進行分析:

./mem_debug_analyzer.py test1 debug_info_test1.txt


通過上面的分析得出,程序在test.c中的第9行分配了內存,但沒有釋放,存在內存泄露現象;在第6行分配的內存空間,分別在第14行、15行進行了free操作,存在double free現象。

下面對test2進行分析:

./mem_debug_analyzer.py test2 debug_info_test2.txt


通過上面的分析得出,程序在test.c中的第9行分配了內存,但沒有釋放,存在內存泄露現象;在第6行分配的內存空間,分別在第14行、13行進行了free操作,存在double free現象。

說明:由於malloc hook的行號是從堆棧上的caller地址進行收集得出的,而caller地址是把當前執行指令位置處的下一條指令地址作爲caller地址的,因此最終得出的行號會有稍微的出入,特別是在代碼進過優化選項處理後差異更大,但可以結合代碼上下文進行分析也很容易定位到代碼點。同時爲了更準確的定位到代碼行號,推薦使用宏替換malloc的方式。

 

一些常用工具

mtrace

mtrace是glibc提供的內存trace功能,可以從man文檔獲取關於它的說明,及具體使用方法。它裏面使用的也是鉤子回調的方式,也是實現了提供了自己的__malloc_hook實現的,他在頭文件mcheck.h中提供了mtrace()muntrace()的函數聲明.


對於mtrace的具體用法參考glibc手冊《The GNU C Library Reference Manual》,有興趣的同學可以直接從glib獲取幫助,這裏不再廢話。

 

valgrind

概述

Valgrind是一款用於內存調試、內存泄漏檢測以及性能分析的軟件開發工具。Valgrind這個名字取自北歐神話中英靈殿的入口。

Valgrind的最初作者是JulianSeward,他於2006年由於在開發Valgrind上的工作獲得了第二屆Google-O'Reilly開源代碼獎。

Valgrind遵守GNU通用公共許可證條款,是一款自由軟件。

網址爲:http://valgrind.org/

 

演示


從上面的代碼片段中我們人爲的構造了兩個內存問題,一個是memory leack,一個是數組越界,valgrind最擅長於排查這方面的內存問題。同時爲了揭示它排查內存的本質,我們懷疑它是使用__malloc_hook鉤子的方式進行內存分配行爲收集的,因此我們把__malloc_hook的地址打印出來,如果它用的不是hook方式那麼__malloc_hook值爲NULL,否則爲它提供的鉤子函數地址,下面我們進行驗證:

首先我們進行編譯後不使用valgrind而直接執行程序,檢查原先的__malloc_hook的值,如下所示:


可以看到__malloc_hook爲null,因爲我們並沒有給它設置它肯定是null啊,接下來使用valgrind對程序進行分析,如下所示:


其中可以看到__malloc_hook=0x3664675770,雖然不知道valgrind內部是如何具體實現的,但至少我們發現了一些很有意思的細節,很有可能它就是一提供hook的方式搞的,當然有興趣的話可以拿valgrind的代碼來讀讀,看看它是怎麼搞的。

而這裏我們主要關心的是valgrind的使用,關心它是否能真正給我們找出內存問題來,從上面的輸出看出它不負所望兩個bug都已經找到了,並且告訴問題所發生的地方、以及問題的類型。

valgrind的能力不僅於此,具體更多的功能可以從官網得到,這裏就不在廢話了。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章