valgrind 內存泄漏分析

概述

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 來不及輸出就掛了。

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