valgrind 記憶體洩漏分析

廣漠飄羽發表於2021-05-17

概述

valgrind 官網 https://www.valgrind.org/

valgrind 是 Linux 業界主流且非常強大的記憶體洩漏檢查工具。在其官網介紹中,記憶體檢查(memcheck)只是其其中一個功能。由於只用過其記憶體洩漏的檢查,就不擴充分享 valgrind 其他功能了。

valgrind 這個工具不能用於除錯正在執行的程式,因為待分析的程式必須在它特定的環境中執行,它才能分析記憶體。

記憶體洩漏分類

valgrind 將記憶體洩漏分為 4 類。

  • 明確洩漏(definitely lost):記憶體還沒釋放,但已經沒有指標指向記憶體,記憶體已經不可訪問
  • 間接洩漏(indirectly lost):洩漏的記憶體指標儲存在明確洩漏的記憶體中,隨著明確洩漏的記憶體不可訪問,導致間接洩漏的記憶體也不可訪問
  • 可能洩漏(possibly lost):指標並不指向記憶體頭地址,而是指向記憶體內部的位置
  • 仍可訪達(still reachable):指標一直存在且指向記憶體頭部,直至程式退出時記憶體還沒釋放。

明確洩漏

官方使用者手冊描述如下:

This means that no pointer to the block can be found. The block is classified as "lost",
because the programmer could not possibly have freed it at program exit, since no pointer to it exists.
This is likely a symptom of having lost the pointer at some earlier point in the
program. Such cases should be fixed by the programmer.

其實簡單來說,就是 記憶體沒釋放,但已經沒有任何指標指向這片記憶體,記憶體地址已經丟失 。定義比較好理解,就不舉例了。

valgrind 檢查到明確洩漏時,會列印類似下面這樣的日誌:

 ==19182== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1
 ==19182== at 0x1B8FF5CD: malloc (vg_replace_malloc.c:130)
 ==19182== by 0x8048385: f (a.c:5)
 ==19182== by 0x80483AB: main (a.c:11)

明確洩漏的記憶體是強烈建議修復的,這沒啥好爭辯的。

間接洩漏

官方使用者手冊描述如下:

This means that no pointer to the block can
be found. The block is classified as "lost", because the programmer could not possibly have freed it at program
exit, since no pointer to it exists. This is likely a symptom of having lost the pointer at some earlier point in the
program. Such cases should be fixed by the programmer.

間接洩漏就是指標並不直接丟失,但儲存指標的記憶體地址丟失了。比較拗口,我們們看個例子:

struct list {
	struct list *next;
};

int main(int argc, char **argv)
{
	struct list *root;
	
	root = (struct list *)malloc(sizeof(struct list));
	root->next = (struct list *)malloc(sizeof(struct list));
	printf("root %p roop->next %p\n", root, root->next);
	root = NULL;
	return 0;
}

丟失的是 root 指標,導致 root 儲存的 next 指標成為了間接洩漏。

valgrind 檢查會列印如下日誌:

# valgrind --tool=memcheck --leak-check=full --show-reachable=yes /data/demo-c
==10435== Memcheck, a memory error detector
==10435== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==10435== Using Valgrind-3.17.0 and LibVEX; rerun with -h for copyright info
==10435== Command: /data/demo-c
==10435==
root 0x4a33040 roop->next 0x4a33090
==10435==
==10435== HEAP SUMMARY:
==10435==     in use at exit: 16 bytes in 2 blocks
==10435==   total heap usage: 3 allocs, 1 frees, 1,040 bytes allocated
==10435==
==10435== 8 bytes in 1 blocks are indirectly lost in loss record 1 of 2
==10435==    at 0x4845084: malloc (vg_replace_malloc.c:380)
==10435==    by 0x4007BF: main (in /data/demo-c)
==10435==
==10435== 16 (8 direct, 8 indirect) bytes in 1 blocks are definitely lost in loss record 2 of 2
==10435==    at 0x4845084: malloc (vg_replace_malloc.c:380)
==10435==    by 0x4007B3: main (in /data/demo-c)
==10435==
==10435== LEAK SUMMARY:
==10435==    definitely lost: 8 bytes in 1 blocks
==10435==    indirectly lost: 8 bytes in 1 blocks
==10435==      possibly lost: 0 bytes in 0 blocks
==10435==    still reachable: 0 bytes in 0 blocks
==10435==         suppressed: 0 bytes in 0 blocks
==10435==
==10435== For lists of detected and suppressed errors, rerun with: -s
==10435== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)

預設情況下,只會列印 明確洩漏 和 可能洩漏,如果需要同時列印 間接洩漏,需要加上選項 --show-reachable=yes.

間接洩漏的記憶體肯定也要修復的,不過一般會隨著 明確洩漏 的修復而修復

可能洩漏

官方使用者手冊描述如下:

This means that a chain of one or more pointers to the block has been found, but at least one
of the pointers is an interior-pointer. This could just be a
random value in memory that happens to point into a block, and so you shouldn't consider this ok unless you
know you have interior-pointers.

valgrind 之所以會懷疑可能洩漏,是因為指標已經偏移,並沒有指向記憶體頭,而是有記憶體偏移,指向記憶體內部的位置。

有些時候,這並不是洩漏,因為這些程式就是這麼設計的,例如為了實現記憶體對齊,額外申請記憶體,返回對齊後的記憶體地址。但更多時候,是我們不小心 p++ 了。

可能洩漏的情況需要我們根據程式碼情況自己分析確認

仍可訪達

官方使用者手冊描述如下:

This covers cases 1 and 2 (for the BBB blocks) above. A start-pointer or chain of start-pointers
to the block is found. Since the block is still pointed at, the programmer could, at least in principle,
have freed it before program exit. "Still reachable" blocks are very common and arguably not a problem.
So, by default, Memcheck won't report such blocks individually.

仍可訪達 表示在程式退出時,不管是正常退出還是異常退出,記憶體申請了沒釋放,都屬於仍可訪達的洩漏型別。

如果測試的程式是正常退出的,那麼這些 仍可訪達 的記憶體就是洩漏,最好修復了。

如果測試是長期執行的程式,通過訊號提前終止,那麼這些記憶體就大概率並不是洩漏。

其他的記憶體錯誤使用

即使是 memcheck 一個工具,除了檢查記憶體洩漏之外,還支援其他記憶體錯誤使用的檢查。

  • 非法讀/寫記憶體(Illegal read / Illegal write errors)
  • 使用未初始化的變數(Use of uninitialised values)
  • 系統呼叫傳遞不可訪問或未初始化記憶體(Use of uninitialised or unaddressable values in system calls)
  • 非法釋放(Illegal frees)
  • 不對應的記憶體申請和釋放(When a heap block is freed with an inappropriate deallocation function)
  • 源地址和目的地址重疊(Overlapping source and destination blocks)
  • 記憶體申請可疑大小(Fishy argument values)

memcheck 工具的支援的錯誤型別可看官方文件:https://www.valgrind.org/docs/manual/mc-manual.html#mc-manual.errormsgs

本文翻譯幾個感興趣的錯誤型別。

非法讀/寫記憶體

例如:

Invalid read of size 4
   at 0x40F6BBCC: (within /usr/lib/libpng.so.2.1.0.9)
   by 0x40F6B804: (within /usr/lib/libpng.so.2.1.0.9)
   by 0x40B07FF4: read_png_image(QImageIO *) (kernel/qpngio.cpp:326)
   by 0x40AC751B: QImageIO::read() (kernel/qimage.cpp:3621)
 Address 0xBFFFF0E0 is not stack'd, malloc'd or free'd

在你要操作的記憶體超出邊界或者非法地址時,就會有這個錯誤提示。常見的錯誤,例如訪問陣列邊界:

int arr[4];
arr[4] = 10;

例如使用已經釋放了的記憶體:

char *p = malloc(30);
...
free(p);
...
p[1] = '\0';

如果發現這樣的錯誤,最好也修復了。因為這些錯誤大概率會導致段錯誤

使用未初始化的變數

尤其出現在區域性變數未賦值,卻直接讀取的情況。也包括申請了記憶體,沒有賦值卻直接讀取,雖然這情況會讀出 '\0',不會導致異常,但更多時候是異常邏輯。

例如:

int main()
{
  int x;
  printf ("x = %d\n", x);
}

如果要詳細列出哪裡申請的記憶體未初始化,需要使用引數 --track-origins=yes,但也會讓慢很多。

錯誤顯示是這樣的:

Conditional jump or move depends on uninitialised value(s)
   at 0x402DFA94: _IO_vfprintf (_itoa.h:49)
   by 0x402E8476: _IO_printf (printf.c:36)
   by 0x8048472: main (tests/manuel1.c:8)

系統呼叫傳遞不可訪問或未初始化記憶體

memcheck 工具會檢查所有系統呼叫的引數:

  1. 引數是否有初始化
  2. 如果是系統呼叫讀取程式提供的buffer,會產檢整個buffer是否可訪問和已經初始化
  3. 如果是系統呼叫要往使用者的buffer寫入資料,會檢查buffer是否可訪問

錯誤顯示是這樣的:

  Syscall param write(buf) points to uninitialised byte(s)
     at 0x25A48723: __write_nocancel (in /lib/tls/libc-2.3.3.so)
     by 0x259AFAD3: __libc_start_main (in /lib/tls/libc-2.3.3.so)
     by 0x8048348: (within /auto/homes/njn25/grind/head4/a.out)
   Address 0x25AB8028 is 0 bytes inside a block of size 10 alloc'd
     at 0x259852B0: malloc (vg_replace_malloc.c:130)
     by 0x80483F1: main (a.c:5)

  Syscall param exit(error_code) contains uninitialised byte(s)
     at 0x25A21B44: __GI__exit (in /lib/tls/libc-2.3.3.so)
     by 0x8048426: main (a.c:8)

不對應的記憶體申請和釋放

檢查邏輯如下:

  1. malloc, calloc, realloc, valloc 或者 memalign 申請的記憶體,必須用 free 釋放。
  2. new 申請的記憶體,必須用 delete 釋放。
  3. new[] 申請的記憶體,必須用 delete[] 釋放。

錯誤顯示是這樣的:

Mismatched free() / delete / delete []
   at 0x40043249: free (vg_clientfuncs.c:171)
   by 0x4102BB4E: QGArray::~QGArray(void) (tools/qgarray.cpp:149)
   by 0x4C261C41: PptDoc::~PptDoc(void) (include/qmemarray.h:60)
   by 0x4C261F0E: PptXml::~PptXml(void) (pptxml.cc:44)
 Address 0x4BB292A8 is 0 bytes inside a block of size 64 alloc'd
   at 0x4004318C: operator new[](unsigned int) (vg_clientfuncs.c:152)
   by 0x4C21BC15: KLaola::readSBStream(int) const (klaola.cc:314)
   by 0x4C21C155: KLaola::stream(KLaola::OLENode const *) (klaola.cc:416)
   by 0x4C21788F: OLEFilter::convert(QCString const &) (olefilter.cc:272)

源地址和目的地址重疊

這裡的檢查只包括類似 memcpy, strcpy, strncpy, strcat, strncat 這樣的有源地址和目的地址操作的C庫函式,確保源地址和目的地址指標不會重疊。

錯誤顯示是這樣的:

==27492== Source and destination overlap in memcpy(0xbffff294, 0xbffff280, 21)
==27492==    at 0x40026CDC: memcpy (mc_replace_strmem.c:71)
==27492==    by 0x804865A: main (overlap.c:40)

記憶體申請可疑大小

這個問題往往出現在申請的記憶體大小是負數。因為申請大小往往是非負數和不會大的很誇張,但如果傳遞了個負數,直接導致申請大小解析為一個非常大的正數。

錯誤顯示是這樣的:

==32233== Argument 'size' of function malloc has a fishy (possibly negative) value: -3
==32233==    at 0x4C2CFA7: malloc (vg_replace_malloc.c:298)
==32233==    by 0x400555: foo (fishy.c:15)
==32233==    by 0x400583: main (fishy.c:23)

如何使用

valgrind 官方使用者手冊目錄:https://www.valgrind.org/docs/manual/manual.html
valgrind QuickStart:https://www.valgrind.org/docs/manual/quick-start.html

執行

valgrind 的執行命令如下:

valgrind [valgrind_optons] myprog [myprog_arg1 ...]

例如:

valgrind --leak-check=full ls -al

使用valgrind做記憶體檢查,程式的執行效率會比平常慢大約20~30倍,以及用更多的記憶體。在我的測試中,平時60M的實體記憶體,加上valgrind之後,直接飆升到200+M,而且是隨著記錄的增多而記憶體驟增。

valgrind 會在收到到 1000 個不同的錯誤,或者共計 10,000,000 個錯誤時自動停止繼續收集錯誤資訊。

此外,不建議直接通過 valgrind 來執行指令碼,否則只會得到 shell 或者其他的直譯器相關的錯誤報告。我們可以通過提供選項 --trace-children=yes 來強制解決這個問題,但是仍然有可能出現混淆。

valgrind 只有在程式退出時,才會一次性列印所有的分析結果。

引數

valgrind 有非常多的引數,可以自行通過 valgrind --help 檢視大致說明,也可以翻閱下面常用的文件連結:

本文只對用到的幾個引數進行詳細說明。

--tool=<toolname> [default: memcheck]

valgrind支援不少檢查工具,都有各種功能。但用的更多的還是他的記憶體檢查(memcheck)。--tool= 用於選擇你需要執行的工具,如果不指明則預設為 memcheck

--log-file=<filename> And --log-fd=<number> [default: 2, stderr]

valgrind 列印日誌轉存到指定檔案或者檔案描述符。如果沒有這個引數,valgrind 的日誌會連同使用者程式的日誌一起輸出,對於大多數使用者來說,會顯得非常亂。

Note: valgrind的日誌輸出格式非常有規律,我也寫了個指令碼來根據錯誤型別從混合日誌中過濾,後文提供

把日誌輸出到檔案的話,還支援一些特殊動態變數,可以實現按程式ID或者序號儲存到不同檔案。我之前沒留意到有這個功能,結果發現不同程式寫入到同一個檔案,後面寫入的檢查結果把其他程式的檢查結果覆蓋了。以下是輸出到檔案支援的一些動態變數:

  • %n:會重置為一個程式唯一的檔案序列號
  • %p:表示當前程式的 ID 。多程式時且使能了 trace-children=yes 跟蹤子程式時會非常實用
  • %q{FOO}:實用環境變數 FOO 的值。適用於那種不同程式會設定不同變數的情況。
  • %%:轉意成一個百分號。

如果使用其他還不支援的百分號字元,會導致 abort。

valgrind 還支援把錯誤日誌重定向到 socket 中,由於沒用過,就不展開了。

--leak-check=<no|summary|yes|full> [default: summary]

這個引數決定了輸出洩漏結果時,輸出的是結果內容。 no 沒有輸出,summary 只輸出統計的結果,yesfull 輸出詳細內容。

常見的使用是:--leak-check=full

--show-leak-kinds=<set> [default: definite,possible]

valgrind 有4種洩漏型別,這個引數決定顯示哪些型別洩漏。definite indirect possible reachable 這4種可以設定多個,以逗號相隔,也可以用 all 表示全部型別,none 表示啥都不顯示。

大多數情況,我們直接用 --show-reachable=yes 而不是 --show-leak-kinds=...,見下文。

--show-reachable=<yes | no> , --show-possibly-lost=<yes | no>

  • --show-reachable=no --show-possibly-lost=yes 等效於 --show-leak-kinds=definite,possible。
  • --show-reachable=no --show-possibly-lost=no 等效於 --show-leak-kinds=definite。
  • --show-reachable=yes 等效於 --show-leak-kinds=all。

需要注意的是,在使能 --show-reachable=yes 時,--show-possibly-lost=no 會無效。

常見的,這個引數這麼使用:--show-reachable=yes

--trace-children=<yes | no> [default: no]

是否跟蹤子程式?看自己需求,如果是多程式的程式,則建議使用這個功能。不過單程式使能了也不會有多大影響。

--keep-stacktraces=alloc | free | alloc-and-free | alloc-then-free | none [default: alloc-and-free]

記憶體洩漏不外乎申請和釋放不配對,函式呼叫棧是隻在申請時記錄,還是在申請釋放時都記錄,還是其他?如果我們只關注記憶體洩漏,其實完全沒必要申請釋放都記錄,因為這會佔用非常多的額外記憶體和更多的 CPU 損耗,讓本來就執行慢的程式雪上加霜。

因此,建議這麼使用:--keep-stacktraces=alloc

--track-fds=<yes | no | all> [default: no]

是否跟蹤檔案開啟和關閉?很多時候,檔案開啟後沒關閉也是一個明顯的洩漏。

--track-origins=<yes | no> [default: no]

對使用非初始化的變數的異常,是否跟蹤其來源。

在確定要分析 使用未初始化記憶體 錯誤時使能即可,平時使能這個會導致程式執行非常慢。

--keep-debuginfo=<yes | no> [default: no]

如果程式有使用 動態載入庫(dlopen),在動態庫解除安裝時(dlclose),debug資訊都會被清除。使能這個選項後,即使動態庫被解除安裝,也會保留呼叫棧資訊。

日誌過濾指令碼

實踐中發現,錯誤型別一大堆,錯誤日誌更多。人工一個個分類檢查太慢了,於是乾脆寫了個指令碼來自動過濾:

#!/bin/bash

# dump_lost <log_file> <key words>
dump_lost()
{
    echo "====== $2 ======"
    awk "
        BEGIN {
            cnt=0
        };
        /$2/ {
            printf \"=== %d ===\\n\", ++cnt;
            print \$0;
            getline;
            while (\$2 != NULL) {
                print \$0;
                getline;
            };
            print \"\"
        }
        END {
            printf \"====== $2 Total: %d ======\\n\", cnt;
        };
    " $1
}

dump_lost valgrind.log "definitely lost" > 0.definitely_lost.log
dump_lost valgrind.log "indirectly lost" > 1.indirectly_lost.log
dump_lost valgrind.log "possibly lost" > 2.possibly_lost.log
dump_lost valgrind.log "still reachable" > 3.still_reachable.log
dump_lost valgrind.log "Invalid read" > 4.invalid_used.log
dump_lost valgrind.log "Invalid write" >> 4.invalid_used.log
dump_lost valgrind.log "Invalid free" >> 4.invalid_used.log
dump_lost valgrind.log "Conditional jump or move depends on uninitialised value" > 5.uninitialised_used.log
dump_lost valgrind.log "Syscall param write(buf) points to uninitialised byte" >> 5.uninitialised_used.log
dump_lost valgrind.log "Source and destination overlap in memcpy" > 6.overlap_used.log

記憶體洩漏日誌解析

這裡只講解使能 --leak-check=full 時列印出來的洩漏細節。

例如:

==3334== 8 bytes in 1 blocks are definitely lost in loss record 1 of 14
==3334==    at 0x........: malloc (vg_replace_malloc.c:...)
==3334==    by 0x........: mk (leak-tree.c:11)
==3334==    by 0x........: main (leak-tree.c:39)

上述日誌表示,在程式號 3334 的程式中,發現了8位元組的確切洩漏(definitely lost)。洩漏記錄的編號並不表示任何東西(我剛開始也是誤解為申請順序),只用於在 gdb 除錯時定位洩漏的記憶體塊。

緊跟著標題的,是具體的洩漏呼叫棧。

valgrind 會合並相同的洩漏,因此這裡看到的記憶體洩漏大小,往往指在統計結束時的總洩漏大小。我們如果加上 -v 選項,則會顯示更多細節,例如洩漏出現次數。

其他使用經驗

編譯引數

為了在出問題時能詳細列印出來棧資訊,其實我們最好在編譯時新增 -g 選項,以及不要 strip 掉符號表。

如果有動態載入的庫,需要加上 --keep-debuginfo=yes ,否則如果發現是動態載入的庫出現洩漏,由於動態庫被解除安裝了,導致找不到符號表,洩漏細節的呼叫棧只能是 ???

程式碼編譯優化,不建議使用 -O2既以上。-O0可能會導致執行更慢,建議使用-O1

除錯常駐服務

valgrind 只有在程式退出時,才會一次性列印所有的分析結果。

在我的實踐中,需要用 valgrind 來統計一個常駐服務的記憶體洩漏。由於一些程式碼缺陷,服務退出的邏輯並沒有完善好。所以不能正常退出服務。最終導致記憶體洩漏結果不能正常列印出來。

我的解決方法是,在記憶體使用將近達到極限時,使用 訊號 讓程式異常退出。這種情況下,仍可訪達 型別的記憶體洩漏就需要仔細判斷是否洩漏了。

千萬不要在達到極限後,被核心 oom 來關閉,不然是列印不出任何統計結果的。因為 OOM 使用 KILL 訊號殺掉程式,而這個訊號是不可捕捉的,valgrind 來不及輸出就掛了。

相關文章