在使用c/c++這種沒有內存管理機制的語言時,我們都會很注意內存的使用,常見的內存問題如:緩沖區(堆棧)溢出,內存泄露,空指針解引用,雙重釋放(double-free)等。
而在編寫極消耗內存的程序時,我們還需要考慮是否會不夠內存空間,例如最近在靜態分析中的指針分析,就很消耗內存。一般來說,這個內存是指動態分配釋放的堆區,對於這種內存在分配時如果不夠會被系統捕獲並拋出異常,像在Linux的OOM(out of memory)機制,像llvm這種對內存分配進一步封裝還會有更友好的提示。
那如果是棧空間不夠呢?
棧空間不夠這個問題其實離我們不遙遠,因為這里說的棧其實就是函數棧,我們都知道遞歸函數如果沒有設置正確的遞歸終止條件則可能會無限遞歸,然后觸發系統的異常保護機制。那遞歸函數的最大遞歸深度是多少呢?顯然跟具體的函數有關,因為函數中的局部變量也存儲在棧區,即使聲明一個指針變量也是存在棧區的,只不過其指向的位置可能不是棧區罷了;而且像x86下的函數調用約定,參數也是通過棧來傳遞的。所以最大遞歸深度顯然不是一個永恆不變的絕對值。下面展示的代碼和結果是在ubuntu 18.04的默認環境運行的:
1 引言
1.1 無限遞歸
這是一個無限遞歸的例子,全局變量cnt記錄add
函數的調用次數,函數add將遞歸計算從a加到無窮大(不考慮整數溢出的話),我們在add中輸出cnt並將其自增,以此來作為遞歸調用的深度,但其實這樣不能完全說明函數棧最大深度,因為add還調用了printf函數,也會占用函數棧(雖然它會在返回后退棧)。運行后很快就會出現segmentation fault (core dumped),此時cnt為261788。
// example1.c
#include <stdio.h>
unsigned long cnt = 0;
int add(int a)
{
printf("add cnt: %ld\n", cnt++);
return a + add(a+1);
}
int main()
{
printf("%d\n", add(0));
return 0;
}
/* [Output:]
add cnt: 261788
[1] 31440 segmentation fault (core dumped) ./a.out
*/
和上一個例子相比,下面這份代碼在add函數中聲明了一個1kb大小的local[]變量,再運行,同樣很快出現segmentation fault (core dumped),但此時cnt為7817,顯然比上面的261788小的多。這是因為函數里的1kb的local[]變量是局部變量,每次調用add都會比上面代碼多開占用棧區,所以遞歸調用的深度自然要小很多。(這里多說一句,不能寫成char *local = "Stack Bomb:";
,前者字符串是棧區,后者是常量區,而且也不是1kb大小,具體可以多閱讀c語言指針相關的資料)。
// example2.c
#include <stdio.h>
unsigned long cnt = 0;
int add(int a)
{
char local[1024] = "Stack Bomb:";
printf("%s add cnt: %ld\n", local, cnt++);
return a + add(a+1);
}
int main()
{
printf("%d\n", add(0));
return 0;
}
/* [Output:]
Stack Bomb: add cnt: 7817
[1] 19529 segmentation fault (core dumped) ./a.out
*/
但是上面的邏輯要想成立是有一個前提條件的:棧區大小的是固定的!例如兩個程序運行時,系統分配的棧都是n kb大小,否者cnt的大小關系將變得毫無意義。實際上在Linux中確實是這樣的,我們以ubuntu 18.04的默認配置為例。這個函數棧的大小限制可以通過ulimit -s
(-s: stack size (kbytes))查看,默認是8192kb,即8Mb。這個其實通過上述兩個例子也能隱約猜到,尤其是example2.c,一個1k的局部變量,遞歸調用深度7800多,和8192kb這個數字也比較吻合。
所以如果這個棧大小可以調的話,我們運行上述兩個例子后的cnt應該是有增加的。確實如此,例如通過ulimit -s 16384
調成16Mb再運行example1.c,其輸出的cnt為523834,接近2倍,也符合預期。
1.2 函數棧不能無限增長
所以從本質上來說,遞歸調用無非是系統限制了函數棧的無限增長,與調用什么函數是無關的,假設一個程序的函數調用鏈很長,且其中還有較大的局部變量,它將和無限遞歸的情況是一樣的。
1.3 調試難度來源
這種core dump不好調試,因為core dump信息是不固定,至少我目前遇到的好幾個,都不太一樣,在gdb中會發現他既不是空指針,而地址有時還是能訪問的,就很迷人。下面展示了棧區大小為8M時,上述example1和2的coredump函數調用棧信息,實際上,同樣一個程序,在不同棧區大小的環境下運行,得到的bt也不一定是一樣的。
- example1.c - 8192kb stack size - gdb ./a.out core - bt
#0 0x00007efe6efae268 in _IO_new_file_write (
f=0x7efe6f30f760 <_IO_2_1_stdout_>, data=0x56345c59a260, n=16)
at fileops.c:1196
1196 fileops.c: No such file or directory.
(gdb) bt
#0 0x00007efe6efae268 in _IO_new_file_write (
f=0x7efe6f30f760 <_IO_2_1_stdout_>, data=0x56345c59a260, n=16)
at fileops.c:1196
#1 0x00007efe6efb0021 in new_do_write (to_do=16,
data=0x56345c59a260 "add cnt: 261824\n",
fp=0x7efe6f30f760 <_IO_2_1_stdout_>) at fileops.c:457
#2 _IO_new_do_write (fp=0x7efe6f30f760 <_IO_2_1_stdout_>,
data=0x56345c59a260 "add cnt: 261824\n", to_do=16) at fileops.c:433
#3 0x00007efe6efaeabd in _IO_new_file_xsputn (
f=0x7efe6f30f760 <_IO_2_1_stdout_>, data=<optimized out>, n=1)
at fileops.c:1266
#4 0x00007efe6ef7eaaa in _IO_vfprintf_internal (
s=0x7efe6f30f760 <_IO_2_1_stdout_>,
format=0x56345c091744 "add cnt: %ld\n", ap=ap@entry=0x7ffe3d2f0630)
at vfprintf.c:1674
#5 0x00007efe6ef88016 in __printf (format=<optimized out>) at printf.c:33
#6 0x000056345c09167b in add ()
#7 0x000056345c091688 in add ()
......
- example2.c - 8192kb stack size - gdb ./a.out core - bt
Program terminated with signal SIGSEGV, Segmentation fault.
#0 __find_specmb (format=0x560aad235814 "%s add cnt: %ld\n")
at printf-parse.h:108
108 printf-parse.h: No such file or directory.
(gdb) bt
#0 __find_specmb (format=0x560aad235814 "%s add cnt: %ld\n")
at printf-parse.h:108
#1 _IO_vfprintf_internal (s=0x7f2d0fc51760 <_IO_2_1_stdout_>,
format=0x560aad235814 "%s add cnt: %ld\n", ap=ap@entry=0x7ffd91422530)
at vfprintf.c:1320
#2 0x00007f2d0f8ca016 in __printf (format=<optimized out>) at printf.c:33
#3 0x0000560aad23572e in add ()
#4 0x0000560aad23572e in add ()
......
2 問題的發現、定位、修復
我最初遇到這個問題的時候,是寫C++程序,segmentation fault后使用gdb調試core文件時,bt的第0層顯示的是malloc.c: No such file or directory信息,加了些log后又變成了Cannot access memory at address 0x7ffd05543ff8這一類……進一步來說,運行了好幾次,發現觸發位置和時機是不一樣,但有似乎有什么特點。例如我在很多函數里都有用到的stl,像set,map之類的,而容器里存的當然都是指針啦,畢竟是大程序,要注意的是雖然c++的stl容器默認基於堆管理內存,但在一個函數里聲明一個局部容器時,容器本身這個變量是存儲在棧區的,這個和上述指針是同理的。言歸正傳,大多時候bt都是在初始化或者是insert操作時,遇到這種情況我的第一反應是容器的里的元素(即指針)有問題,但是打了一下log,發現是可訪問的,這就很奇怪。
Program received signal SIGSEGV, Segmentation fault.
_int_malloc (source=0x7ffff7201740 <getNormalFlowFunction>, bytes=112) at malloc.c:3570
3570 malloc.c: No such file or directory.
(gdb) bt
#0 _int_malloc (source=0x7ffff7201740 <getNormalFlowFunction>, bytes=112) at malloc.c:3570
#1 0x00007ffff6ecbfb5 in __GI___libc_malloc (bytes=112) at malloc.c:2924
......
在Stack Overflow上,搜malloc.c: No such file or directory,看到不少回到都是沒能直接指出代碼的問題,比較靠譜的回到都是建議用valgrind檢測一下是否有內存泄露等問題。於是我就配置環境跑了跑valgrind:valgrind --tool=memcheck --leak-check=full <exec>
,得到了如下信息(部分):
......
==6118== Stack overflow in thread #1: can't grow stack to 0x1ffe801000
==6118==
==6118== Process terminating with default action of signal 11 (SIGSEGV): dumping core
==6118== Access not within mapped region at address 0x1FFE801EE8
==6118== Stack overflow in thread #1: can't grow stack to 0x1ffe801000
==6118== at 0x21A85CA: readAbbreviatedField(llvm::BitstreamCursor&, llvm::BitCodeAbbrevOp const&) (in <exec>)
==6118== If you believe this happened as a result of a stack
==6118== overflow in your program's main thread (unlikely but
==6118== possible), you can try to increase the size of the
==6118== main thread stack using the --main-stacksize= flag.
==6118== The main thread stack size used in this run was 8388608.
==6118== Stack overflow in thread #1: can't grow stack to 0x1ffe801000
==6118==
==6118== Process terminating with default action of signal 11 (SIGSEGV)
==6118== Access not within mapped region at address 0x1FFE801ED8
==6118== Stack overflow in thread #1: can't grow stack to 0x1ffe801000
==6118== at 0x4A2C650: _vgnU_freeres (in /usr/lib/valgrind/vgpreload_core-amd64-linux.so)
==6118== If you believe this happened as a result of a stack
==6118== overflow in your program's main thread (unlikely but
==6118== possible), you can try to increase the size of the
==6118== main thread stack using the --main-stacksize= flag.
==6118== The main thread stack size used in this run was 8388608.
==6118==
==6118== HEAP SUMMARY:
==6118== in use at exit: 2,530,649,161 bytes in 11,931,838 blocks
==6118== total heap usage: 73,308,707 allocs, 61,376,869 frees, 64,658,848,337 bytes allocated
==6118==
==6118== 64 bytes in 1 blocks are possibly lost in loss record 556 of 2,549
==6118== at 0x4C31B0F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==6118== by 0x71A630: llvm::safe_malloc(unsigned long) (MemAlloc.h:26)
==6118== by 0x739DC1: llvm::SmallVectorTemplateBase<std::pair<void*, unsigned long>, false>::grow(unsigned long) (SmallVector.h:240)
==6118== by 0x7317CF: llvm::SmallVectorTemplateBase<std::pair<void*, unsigned long>, false>::push_back(std::pair<void*, unsigned long>&&) (SmallVector.h:220)
==6118== by 0x72B1EC: llvm::BumpPtrAllocatorImpl<llvm::MallocAllocator, 4096ul, 4096ul>::Allocate(unsigned long, unsigned long) (Allocator.h:249)
==6118== by 0x7A1ACE: llvm::AllocatorBase<llvm::BumpPtrAllocatorImpl<llvm::MallocAllocator, 4096ul, 4096ul> >::Allocate(unsigned long, unsigned long) (Allocator.h:59)
==6118== by 0x7AB5F5: clang::CFGBlock** llvm::AllocatorBase<llvm::BumpPtrAllocatorImpl<llvm::MallocAllocator, 4096ul, 4096ul> >::Allocate<clang::CFGBlock*>(unsigned long) (Allocator.h:81)
==6118== by 0x7A6344: clang::BumpVector<clang::CFGBlock*>::grow(clang::BumpVectorContext&, unsigned long) (BumpVector.h:233)
==6118== by 0x79FBBB: clang::BumpVector<clang::CFGBlock*>::push_back(clang::CFGBlock* const&, clang::BumpVectorContext&) (BumpVector.h:166)
==6118== by 0x78414B: clang::CFG::createBlock() (CFG.cpp:4804)
==6118== by 0x77866E: (anonymous namespace)::CFGBuilder::createBlock(bool) (CFG.cpp:1544)
==6118== by 0x780042: (anonymous namespace)::CFGBuilder::VisitWhileStmt(clang::WhileStmt*) (CFG.cpp:3665)
==6118==
==6118== 64 bytes in 1 blocks are possibly lost in loss record 557 of 2,549
==6118== at 0x4C31B0F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
.....
分析一下,這個log后面部分很多,看着都像是堆內存泄露的問題,這是因為程序coredump了,之前申請的動態內存沒能正確回收,所以參考價值不大。關鍵在於log的一開始,拋出了一個Stack overflow信息,其中最讓我頓悟的一句是Stack overflow in thread #1: can't grow stack to 0x1ffe801000,直譯過來就是棧無法生長了。通過簡單的搜索后,才有了上面的分析。我嘗試了調到stack size,發現程序就能正常運行了。
為了進一步驗證是棧大小不足引起的問題,我打算做一下profile,看看程序運行時的內存使用峰值,尤其是棧的峰值,同樣可以用valgrind進行測試:valgrind --tool=massif --stacks=yes <exec>
,之后會生成一個massif.*的文件,可以使用ms_print massif.*file
輸出統計信息。發現我的程序運行的最大棧區使用峰值差不多到9Mb。
#-----------
snapshot=26
#-----------
time=3341115043485
mem_heap_B=2796240754
mem_heap_extra_B=234456806
mem_stacks_B=8992984
heap_tree=empty
至此可以確定確實是因為程序棧空間不足導致棧溢出引發的segmentation fault。
附錄 No such file or directory
通過上面例子的log發現,雖然bt不盡相同,但似乎第0層都是在malloc.c等glibc庫中,即在libc庫中異常被捕獲,畢竟這些庫有較為完善的異常處理機制。如果想要調試這些庫,但是又有No such file or directory,那解決方案當然是裝源碼並告訴gdb正確的路徑,以malloc.c: No such file or directory為例
sudo apt-get install libc6-dbg
sudo apt install glibc-source
cd /usr/src/glibc
# ls
sudo tar xvf glibc-2.27.tar.xz
# ls
cd glibc-2.27/malloc
# ls
# vim malloc.c
部分參考鏈接
Leokie 設置c++程序的堆棧空間解決棧溢出問題
C/C++調試、跟蹤及性能分析工具綜述
Stack Overflow: malloc causing segmentation fault by _int_malloc
Stack Overflow: Valgrind reporting possible stack overflow - what does it mean?
Stack Overflow: Include source code of malloc.c in gdb?