記一次有教益的內(nèi)存碎片轉(zhuǎn)儲(chǔ)文件分析經(jīng)歷
前言
其實(shí),這篇文章早在 2021 年就完成了初稿,后面一直沒(méi)來(lái)得及完善(各種加班各種忙),所以一直沒(méi)來(lái)得及整理發(fā)布。而且,我從這個(gè)案例里學(xué)到的東西太多了,很多內(nèi)容并沒(méi)有體現(xiàn)在本篇文章中,后續(xù)有機(jī)會(huì)一定會(huì)再寫(xiě)文章分享。話不多說(shuō),一起來(lái)看正文吧。
緣起
前一陣子,有朋友在微信上發(fā)了一段 windbg 的輸出信息,大概內(nèi)容如下:
This dump file has an exception of interest stored in it. The stored exception information can be accessed via .ecxr. (fd0.9bc): Security check failure or stack buffer overrun - code c0000409 (first/second chance not available) For analysis of this file, run !analyze -v
... 省略 N 行
看樣子像棧相關(guān)的問(wèn)題。于是簡(jiǎn)單跟朋友說(shuō)了下我的猜想,有可能是棧破壞了。沒(méi)想到,遠(yuǎn)不是這么簡(jiǎn)單。
說(shuō)明: 之所以一定要寫(xiě)這篇總結(jié),是因?yàn)槲以诜治鲞^(guò)程中犯了很多想當(dāng)然的錯(cuò)誤,記錄下來(lái),以后不要再犯。
另有隱情
過(guò)了一會(huì),朋友發(fā)了一段更貼近真相的錯(cuò)誤提示,并且發(fā)送了更詳細(xì)的語(yǔ)音描述。
FAILURE_ID_HASH_STRING: um:fail_fast_fatal_app_exit_c0000409_server.exe!std::_xbad_alloc
這個(gè)提示跟最開(kāi)始的提示風(fēng)馬牛不相及。一個(gè)是棧相關(guān)的問(wèn)題,一個(gè)是內(nèi)存分配的問(wèn)題。
說(shuō)明: 經(jīng)常分析轉(zhuǎn)儲(chǔ)文件的小伙伴兒應(yīng)該都知道,直接打開(kāi)轉(zhuǎn)儲(chǔ)文件時(shí),
windbg給出的提示有可能是不準(zhǔn)確的,如果想獲取最準(zhǔn)確的信息,最好通過(guò).cxr切換上下文,然后再執(zhí)行k系列命令查看。但是,很多時(shí)候直接執(zhí)行!analayze -v就能拿到正確信息。正式分析之前,不妨先試試!analyze -v。
又跟朋友又聊了幾個(gè)相關(guān)問(wèn)題,得到了更多的關(guān)鍵信息。比如,
之前解決過(guò)類(lèi)似的內(nèi)存泄漏問(wèn)題,當(dāng)時(shí)抓的
dump有3GB多。這次抓取的
dump只有905MB,但是確實(shí)是full dump。說(shuō)明: 內(nèi)存相關(guān)問(wèn)題,最好抓一個(gè)
full dump,否則分析到一半,由于轉(zhuǎn)儲(chǔ)文件缺少關(guān)鍵信息,沒(méi)法繼續(xù)確認(rèn),就太尷尬了。程序是
32位的,并且開(kāi)啟了大地址。說(shuō)明:
64位程序的虛擬地址空間比32位程序大多了,不太容易在短時(shí)間內(nèi)看出問(wèn)題。前幾次出問(wèn)題時(shí)都是運(yùn)行了很久才出問(wèn)題,這次只運(yùn)行了
7個(gè)小時(shí)左右就出問(wèn)題了。幾次出問(wèn)題時(shí),都是在內(nèi)存比較緊張的時(shí)候。
聊了一會(huì),問(wèn)朋友是否方便發(fā)送完整的 !analyze -v 結(jié)果。沒(méi)想到,朋友除了發(fā)送 !analyze -v 的分析結(jié)果外,還特別貼心的發(fā)送了 轉(zhuǎn)儲(chǔ)文件和對(duì)應(yīng)的符號(hào)文件。必須為朋友點(diǎn)贊,看來(lái)沒(méi)少分析轉(zhuǎn)儲(chǔ)文件。
查看分析結(jié)果
由于當(dāng)時(shí)在北京出差的路上,于是在地鐵里用手機(jī)查看了一下 !analyze -v 的分析結(jié)果(旁邊的小哥哥小姐姐會(huì)不會(huì)以為我在看小說(shuō)?),跟朋友說(shuō)的一樣,打開(kāi)轉(zhuǎn)儲(chǔ)文件后,windbg 給出的錯(cuò)誤提示確實(shí)是棧相關(guān)的,但是 !analyze -v 卻指向了內(nèi)存分配相關(guān)的問(wèn)題。
這里簡(jiǎn)單摘錄幾個(gè)關(guān)鍵的信息:
轉(zhuǎn)儲(chǔ)文件中的 comment
Comment: '
*** "D:\software\Procdump_installer\procdump.exe" -accepteula -ma -j "D:\software\Procdump_installer\dumps" 4048 384 00990000
*** Just-In-Time debugger. PID: 4048 Event Handle: 384 JIT Context: .jdinfo 0x990000'
說(shuō)明此 dump 是注冊(cè)為 JIT debugger 的 procdump 在程序異常的時(shí)候抓取的。
運(yùn)行環(huán)境
Windows 10 Version 14393 MP (2 procs) Free x86 compatible
Product: Server, suite: TerminalServer DataCenter SingleUserTS
10.0.14393.2430 (rs1_release_inmarket_aim.180806-1810)
Debug session time: Wed Aug 18 20:32:43.000 2021 (UTC + 8:00)
System Uptime: 40 days 1:00:55.704
Process Uptime: 0 days 7:00:35.000
win10 服務(wù)器系統(tǒng),系統(tǒng)運(yùn)行時(shí)間是 40 days 1:00:55.704 ,程序運(yùn)行時(shí)間是 7:00:35.000。跟朋友的描述是一致的。
上下文相關(guān)信息
CONTEXT: 0f77ef58 -- (.cxr 0xf77ef58)
eax=0f77f3b8 ebx=0f77f468 ecx=00000003 edx=00000000 esi=00eceba8 edi=00ef7dd4
eip=76b6c232 esp=0f77f3b8 ebp=0f77f414 iopl=0 nv up ei pl nz ac pe nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000216
KERNELBASE!RaiseException+0x62:
76b6c232 8b4c2454 mov ecx,dword ptr [esp+54h] ss:002b:0f77f40c=93fc57d7
Resetting default scope
FAULTING_IP:
Server!abort+28 [d:\th\minkernel\crts\ucrt\src\appcrt\startup\abort.cpp @ 77]
00ea0d4f cd29 int 29h
EXCEPTION_RECORD: 0f77ef08 -- (.exr 0xf77ef08)
ExceptionAddress: 76b6c232 (KERNELBASE!RaiseException+0x00000062)
ExceptionCode: e06d7363 (C++ EH exception)
ExceptionFlags: 00000001
NumberParameters: 3
Parameter[0]: 19930520
Parameter[1]: 0f77f468
Parameter[2]: 00ef7dd4
pExceptionObject: 0f77f468
_s_ThrowInfo : 00ef7dd4
Type : class std::bad_alloc
Type : class std::exception
可以使用 .cxr 0xf77ef58 切換上下文,使用 .exr 0xf77ef08 查看異常信息。
調(diào)用棧

windbg-analyze-v-callstack 注意:上圖中紅色部分,表示正在處理
std::_Xbad_alloc異常。黃色部分是一個(gè)警告,意思是說(shuō)下面的調(diào)用棧可能不準(zhǔn)確。
看樣子,極有可能是內(nèi)存分配的問(wèn)題了。當(dāng)時(shí)想著一定要找個(gè)時(shí)間分析一下。過(guò)了兩三天,終于有了一點(diǎn)兒時(shí)間(今年加班是真的多),折騰完調(diào)試環(huán)境(主要是加載調(diào)試符號(hào),從微軟服務(wù)器下載符號(hào)有時(shí)候需要梯子),就可以開(kāi)始分析了。
初次分析
加載好符號(hào)后,根據(jù) !analyze -v 的結(jié)果,先執(zhí)行 .cxr 0xf77ef58,切換上下文,然后執(zhí)行 kp 查看調(diào)用棧(p 表示顯示參數(shù),應(yīng)該是 parameter 的縮寫(xiě))。如下圖:

從整個(gè)調(diào)用棧來(lái)看,是在拼接兩個(gè)字符串時(shí),需要分配 0x8e52d 字節(jié)的空間,但是失敗了,拋出了 std::_Xbad_alloc 異常。
說(shuō)明: 看到
std::_Xbad_alloc時(shí),腦子里自然而然地想到了兩種可能:
分配的內(nèi)存超級(jí)大(本例不屬于這種情況) 內(nèi)存不夠用了,沒(méi)有一塊內(nèi)存可以滿足本次的分配請(qǐng)求。
之前遇到的 std::_Xbad_alloc 異常都是嘗試分配的內(nèi)存超級(jí)大(非常典型的是在反序列化 vector, string 的時(shí)候,由于內(nèi)存錯(cuò)位導(dǎo)致嘗試分配超大內(nèi)存),但是本次的分配請(qǐng)求看上去相對(duì)合理。
既然是跟內(nèi)存分配相關(guān)的問(wèn)題(而且是標(biāo)準(zhǔn)庫(kù)中涉及到的內(nèi)存分配),很可能跟堆有關(guān),可以查看堆相關(guān)的信息。在 windbg 中執(zhí)行 !heap -s 命令,查看堆概要信息。

看到上圖中的輸出結(jié)果,我很(草)快(率)得(湊)出了一個(gè)結(jié)論。
草率了
我犯的第一個(gè)超級(jí)愚蠢的錯(cuò)誤是:一看 Free 這一列顯示的值是 334147。而 0x8e52d 的十進(jìn)制值是 582957(可以在 windbg 中通過(guò) .formats 0x8e52d 方便的顯示出來(lái)),就跟朋友說(shuō)是堆空間不足導(dǎo)致的,建議從內(nèi)存泄漏這個(gè)角度排查一下。做出這個(gè)結(jié)論的依據(jù)是:Free 一列給出的值比要分配的值"小"。
為什么草率了呢?
首先,我忽略了 Free 的單位,在 Free 的下方明顯的寫(xiě)著 (k),說(shuō)明這一列是按 KB 計(jì)算的 。
其次,這一列給出的數(shù)不是 10 進(jìn)制的,如果仔細(xì)看其它相關(guān)信息,可以推斷出這里的值是以 16 進(jìn)制表示的,比如 Lock cont. 的值是 1b61,明顯是 16 進(jìn)制。
既然是分配一個(gè)正常大小的內(nèi)存塊失敗了,那肯定是堆里沒(méi)有能滿足請(qǐng)求大小的空閑堆塊了。
如果能找出堆中空閑的堆塊,而且每個(gè)堆塊的大小都比本次分配請(qǐng)求的大小(0x8e52d )要小,說(shuō)明沒(méi)有一個(gè)空閑堆塊能滿足本次的分配請(qǐng)求,那么拋出 std::_Xbad_alloc 就很正常了。
接著分析
可以通過(guò) !heap -a 打印出所有信息。在 windbg 中輸入 !heap -a 00a80000,因?yàn)檩敵鼋Y(jié)果太多了,在執(zhí)行此命令前,先執(zhí)行 .logopen d:\heap.txt,這樣輸出結(jié)果會(huì)同時(shí)保存到日志文件 d:\heap.txt 中,執(zhí)行完需要的命令后,可以通過(guò)執(zhí)行 .logclose 關(guān)閉日志文件。
接下來(lái),從輸出信息中查找空閑堆塊。
我不會(huì)告訴你,最開(kāi)始找空閑堆塊的時(shí)候我是傻傻的到每一個(gè) Segment 里面找里面包含的 free 堆塊(flags 是 100)。
下圖是 Sement27 中的部分 Free 堆塊截圖:

由于實(shí)在是太多了,找到一半的時(shí)候真的是找(累)不(成)動(dòng)(狗)了!于是停下來(lái)開(kāi)始思考:
windbg難道沒(méi)有直接查找空閑堆塊的命令嗎?有插件可以做這事嗎?
自己寫(xiě)個(gè)腳本(程序)整理下輸出結(jié)果?
想個(gè)辦法過(guò)濾一下空閑塊,然后再排序?
最后一個(gè)想法簡(jiǎn)單易行:把所有包含 [100] 的行找出來(lái),然后保存為 .csv 文件。按 [100] 前面的字段(當(dāng)前堆塊的大小)降序排列,就可以很快找出最大空閑堆塊了。
整個(gè)過(guò)程參考下面視頻:
最后發(fā)現(xiàn)最大空閑堆塊的大小是 0x7f000,比 0x8e52d 要小。
意外發(fā)現(xiàn)
在查找過(guò)程中偶然發(fā)現(xiàn)了一個(gè)有意思的現(xiàn)象,同一個(gè)地址會(huì)出現(xiàn)兩次,比如 03c8da98 這個(gè)地址:

仔細(xì)一看,所有的空閑堆塊會(huì)被單獨(dú)整理出來(lái),放到 FreeList 下。趕緊通過(guò) .hh !heap 查看幫助手冊(cè),關(guān)鍵部分截圖如下:

原來(lái)可以通過(guò) !heap -f heap_address 直接顯示所有的空閑堆塊。于是在 windbg 中輸入 !heap -f 00a80000 驗(yàn)證一下,果然可以列出所有空閑堆塊。

而且,空閑塊是按大小排序的,找到最后一個(gè)空閑塊,就找到了最大的空閑塊(大小是 0x7f000),比上面的方法簡(jiǎn)單太多了。
又草率了
查找完所有空閑堆塊后發(fā)現(xiàn),沒(méi)有一個(gè)空閑堆塊的大小比 0x8e52d 還要大。于是,趕緊跟朋友說(shuō)上次分析的結(jié)果應(yīng)該沒(méi)錯(cuò),但是判斷依據(jù)不對(duì)。告訴朋友可以在 windbg 中執(zhí)行!heap -a a80000 ,然后查找 FreeList 對(duì)應(yīng)的記錄,就可以確定沒(méi)有一塊空閑堆塊滿足本次的分配請(qǐng)求了。
跟朋友說(shuō)完之后長(zhǎng)出了一口氣,心想這次肯定穩(wěn)了,應(yīng)該沒(méi)問(wèn)題了。
不對(duì)勁兒
過(guò)了一兩天,忙完手頭工作后,又想起這件事,隱約感到有些不對(duì)勁。趕緊翻開(kāi)《軟件調(diào)試》第一版查找關(guān)于堆的介紹,在第 23 章 23.4.1 節(jié) 655 頁(yè)中有非常清晰的描述,摘錄如下:
HEAP_ENTRY 結(jié)構(gòu)的前兩個(gè)字節(jié)是以分配粒度表示的堆塊大小。分配粒度通常為
8,這意味著每個(gè)堆塊的最大值是2的16次方乘以8,即0x10000 * 8 = 0x80000 = 524288 字節(jié) = 512 KB,因?yàn)槊總€(gè)堆塊至少要有8字節(jié)的管理信息,因此應(yīng)用程序可以使用的最大堆塊便是0x80000-8=0x7FFF8,這也正是 SDK 文檔中所給出的數(shù)值(位于 HeapCreate 函數(shù)dwMaximumSize參數(shù)的說(shuō)明中)。不過(guò)這并不意味著不可以從 Win32 堆上分配到更大的內(nèi)存塊。當(dāng)一個(gè)應(yīng)用程序要分配大于 512KB 的堆塊時(shí),如果堆標(biāo)志包含 HEAP_GROWABLE(2),那么堆管理器便會(huì)直接調(diào)用ZwAllocateVirtualMemory來(lái)滿足這次分配,并把分得的地址記錄在 HEAP 結(jié)構(gòu)的VirtualAllocatedBlocks所指向的鏈表中。
有了理論依據(jù),實(shí)際用 windbg 查看一下。該看哪些內(nèi)容呢?
堆是否是可增長(zhǎng)的?
ntdll!_HEAP中的Flags字段如果為2則表示堆是可以自動(dòng)增長(zhǎng)的。閾值是多少?本次請(qǐng)求是否超出了閾值?
閾值是由
VirtualMemoryThreshold中的值乘以分配粒度計(jì)算得到的。說(shuō)明: 分配粒度與
_HEAP_ENTRY的大小一致,32位程序是8字節(jié),64位程序是16字節(jié)。可以在!heap -f 00a80000的輸出結(jié)果中看到,Granularity是8 bytes。
在 windbg 中輸入如下命令 dt _HEAP -y Flags VirtualMemoryThreshold a80000 即可查看這兩個(gè)關(guān)鍵的信息,如下圖:

可以確認(rèn)堆是可增長(zhǎng)的,而且閾值是 0xfe00 * 8 = 0x7f000。本次請(qǐng)求大小( 0x8e52d )超出了閾值,會(huì)直接通過(guò) ZwAllocVirtualMemory 進(jìn)行分配。
說(shuō)明: 空閑列表中的最大空閑塊的尺寸也是
0x7f000。前面做了這么多無(wú)用功,真是浪費(fèi)時(shí)間。
既然請(qǐng)求大小超出了閾值,接下來(lái)的任務(wù)是找到整個(gè)內(nèi)存空間中最大空閑區(qū)域,看看其是否能滿足本次分配請(qǐng)求(應(yīng)該是不滿足,要不然也不會(huì)拋出異常了)。
查找空閑區(qū)域
相信,很多小伙伴兒都知道使用 !address 查看地址信息。我經(jīng)常在排查內(nèi)存訪問(wèn)異常的時(shí)候,通過(guò)該命令查看某個(gè)地址的詳細(xì)信息。比如,下圖是通過(guò) !address 0x12345678 查看到的關(guān)于地址 0x12345678 的相關(guān)信息:

另外一個(gè)非常有用的命令是 !address -summary, 可以查看地址空間概要信息。

通過(guò)查看 Usage Summary 的數(shù)據(jù)可以發(fā)現(xiàn),在整個(gè)內(nèi)存空間中,堆空間大小是 3.930GB,占比 98.25%。通過(guò)查看 State Summary 的數(shù)據(jù)可知,保留( MEM_RESERVE )大小是 3.105GB,提交(MEM_COMMIT)大小是 910.848MB。通過(guò)查看 Largest Region by Usage 可以發(fā)現(xiàn),最大的空閑內(nèi)存大小是 0x1c000,要比本次申請(qǐng)的大小 0x8e52d 小很多。
內(nèi)存嚴(yán)重碎片化
說(shuō)實(shí)話,剛開(kāi)始我是有點(diǎn)不敢相信 !address -summary 給出的結(jié)果:最大的空閑空間居然只有區(qū)區(qū)的 112KB!
繼續(xù)使用 !address -f:Free -o:csv 命令打印出所有的空閑內(nèi)存。整理后的結(jié)果如下圖所示:
-o:csv表示以逗號(hào)分隔的csv格式輸出,這樣可以把結(jié)果直接粘貼到csv文件中。

可以看出,跟 !address -summary 給出的結(jié)論是一致的。最大空閑區(qū)域的大小是 0n114688(十六進(jìn)制對(duì)應(yīng)的數(shù)值是 0x1c000)。空閑內(nèi)存的總大小是 0n5791744,只有區(qū)區(qū)的 5656KB(也就是 5.23MB)。碎片化真的是太嚴(yán)重了。
再回過(guò)頭仔細(xì)看 !heap -s 的輸出結(jié)果。其中有如下提示:
Virtual address fragmentation 77 % (1713 uncommited ranges)
意思是說(shuō)虛擬地址空間的碎片率已經(jīng)高達(dá) 77% 。看來(lái)這次的崩潰確實(shí)是由內(nèi)存碎片導(dǎo)致的。
總結(jié)
之前只是理論上知道如果碎片太嚴(yán)重可能導(dǎo)致分配失敗,沒(méi)想到這次真遇到了。剛開(kāi)始給出的結(jié)論雖然正確,但是理由太牽強(qiáng),站不住腳。
使用 windbg分析轉(zhuǎn)儲(chǔ)文件,先執(zhí)行!analyze -v,很多情況下問(wèn)題就解決了。發(fā)生異常的時(shí)候會(huì)保存線程上下文和異常相關(guān)信息。異常信息可以通過(guò) .exr address查看,線程上下文可以通過(guò).cxr address查看并切換。在 windbg中可以通過(guò).hh command查看對(duì)應(yīng)命令的幫助。windbg中的!heap擴(kuò)展命令是查看堆信息的好幫手,!heap -s可以給出堆的概要信息。!heap -f heap_address可以顯示所有空閑堆塊,!heap -a heap_address可以顯示所有堆塊。!address命令相當(dāng)強(qiáng)大。!address address可以查看指定內(nèi)存地址的信息,!address -summary可以給出一個(gè)概要信息,!address -f:Type可以給出特定的內(nèi)存區(qū)域,甚至后面還可以跟命令。堆中分配內(nèi)存時(shí),如果堆大小可以增長(zhǎng)并且申請(qǐng)的大小超出閾值( VirtualMemoryThreshold * Granularity),會(huì)直接使用ZwAllocVirtualMemory進(jìn)行分配。
參考資料
《軟件調(diào)試》第一版
windbg 幫助文檔
未完待續(xù)
后面又跟朋友折騰了一些其它問(wèn)題,頗有收獲,希望能有時(shí)間做個(gè)總結(jié)。
為什么
32位程序可以使用的內(nèi)存空間是4GB?高2GB應(yīng)該是系統(tǒng)使用的才對(duì)?用戶態(tài)代碼也可以使用?如果一條命令輸出結(jié)果太多,直接顯示的話太慢,怎么辦?
轉(zhuǎn)儲(chǔ)文件中包含了一些珍貴的數(shù)據(jù)(比如某些充值記錄),如何找出來(lái)?
...
