記一次 .NET某工控自動化系統 崩潰分析

一:背景

1. 講故事

前些天微信上有位朋友找到我,說他的程序偶發崩潰,分析了個把星期也沒找到問題,耗費了不少人力物力,讓我能不能幫他看一下,給我申請了經費,哈哈,遇到這樣的朋友就是爽快,剛好週二晚上給調試訓練營的朋友分享 GC標記階段 相關知識,而這個dump所展示的問題是對這塊知識的一個很好的鞏固,接下來我們開始分析吧。

二:WinDbg分析

1. 爲什麼會崩潰

要想找到崩潰原因,還是用老命令 !analyze -v ,輸出如下:


0:005> !analyze -v
CONTEXT:  (.ecxr)
eax=063ce258 ebx=07b90000 ecx=0063552e edx=0063552e esi=03070909 edi=03070909
eip=71954432 esp=063ce220 ebp=063ce23c iopl=0         nv up ei pl nz na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00010206
clr!WKS::gc_heap::mark_object_simple+0x12:
71954432 8b0f            mov     ecx,dword ptr [edi]  ds:002b:03070909=????????
Resetting default scope

EXCEPTION_RECORD:  (.exr -1)
ExceptionAddress: 71954432 (clr!WKS::gc_heap::mark_object_simple+0x00000012)
   ExceptionCode: c0000005 (Access violation)
  ExceptionFlags: 00000001
NumberParameters: 2
   Parameter[0]: 00000000
   Parameter[1]: 03070909
Attempt to read from address 03070909

STACK_TEXT:  
063ce23c 719543fc     063ce258 0a76cc88 71954260 clr!WKS::gc_heap::mark_object_simple+0x12
063ce25c 71950b62     0a76cc88 063cec88 00000000 clr!WKS::GCHeap::Promote+0xa8
...
063cec28 71950fa3     71950da0 063cec40 00000500 clr!Thread::StackWalkFrames+0x9d
063cec4c 7195103e     063cec88 00000002 00000000 clr!standalone::ScanStackRoots+0x43
063cec68 71954038     0079cb88 063cec88 00080101 clr!GCToEEInterface::GcScanRoots+0xdb
063cecc0 71953225     00080101 00000000 00000001 clr!WKS::gc_heap::mark_phase+0x17e
063cece0 7195355b     71f75da0 00000000 00000001 clr!WKS::gc_heap::gc1+0xae
063cecf8 71953665     71f75fb4 71f75fb4 00000000 clr!WKS::gc_heap::garbage_collect+0x367
063ced18 7195376a     00000000 00000000 71f75fb4 clr!WKS::GCHeap::GarbageCollectGeneration+0x1bd
...

從卦中信息看,當前執行流處於GC標記階段,並且是在各個線程棧上尋找用戶根,在尋找的過程中踩到了壞內存,接下來需要捋一下是什麼邏輯踩到的,可以用 u 反彙編一下。


0:005> u WKS::gc_heap::mark_object_simple
clr!WKS::gc_heap::mark_object_simple:
71954420 55              push    ebp
71954421 8bec            mov     ebp,esp
71954423 83ec18          sub     esp,18h
71954426 8b4508          mov     eax,dword ptr [ebp+8]
71954429 57              push    edi
7195442a 8b38            mov     edi,dword ptr [eax]
7195442c 89bde8ffffff    mov     dword ptr [ebp-18h],edi
71954432 8b0f            mov     ecx,dword ptr [edi]
...

從彙編邏輯看,這是將方法的第一個參數進行解引用,參考 coreclr 的源碼。


void gc_heap::mark_object_simple(uint8_t** po THREAD_NUMBER_DCL)
{
	uint8_t* o = *po;

	if (gc_mark1(o))
	{
        ...
	}
}

結合C++代碼,edi=03070909 就是上面的o,也就是需要標記的託管對象,但現在這個 o 是一個壞對象,那爲什麼會壞掉呢?

2. 爲什麼 o 壞掉了

按照過往經驗肯定是託管堆損壞了,可以用 !verifyheap 觀察下。


0:005> !verifyheap
No heap corruption detected.

從卦中看,我去,託管堆居然是好的,過往經驗在這個dump裏被擊的粉碎,接下來要往哪裏突破呢? 可以觀察下這個託管地址和當前的託管segment在空間距離上的特徵,命令輸出如下:


0:005> !address 03070909

Usage:                  <unknown>
Base Address:           02ca2000
End Address:            036f0000
Region Size:            00a4e000 (  10.305 MB)
State:                  00002000          MEM_RESERVE
Protect:                <info not present at the target>
Type:                   00020000          MEM_PRIVATE
Allocation Base:        026f0000
Allocation Protect:     00000004          PAGE_READWRITE

0:005> !eeheap -gc
Number of GC Heaps: 1
generation 0 starts at 0x06ca7a7c
generation 1 starts at 0x06b91000
generation 2 starts at 0x026f1000
ephemeral segment allocation context: none
 segment     begin  allocated      size
026f0000  026f1000  02c98f8c  0x5a7f8c(5930892)
06b90000  06b91000  0732b3d0  0x79a3d0(7971792)
Large object heap starts at 0x036f1000
 segment     begin  allocated      size
036f0000  036f1000  03c78da0  0x587da0(5799328)
Total Size:              Size: 0x12ca0fc (19702012) bytes.
------------------------------
GC Heap Size:    Size: 0x12ca0fc (19702012) bytes.

0:005> !address

  BaseAddr EndAddr+1 RgnSize     Type       State                 Protect             Usage
-----------------------------------------------------------------------------------------------
...
+  26f0000  2ca2000   5b2000 MEM_PRIVATE MEM_COMMIT  PAGE_READWRITE                     <unknown>  [..........o.....]
   2ca2000  36f0000   a4e000 MEM_PRIVATE MEM_RESERVE                                    <unknown>  
...

說實話,有經驗的朋友看到這卦中信息馬上就知道是怎麼回事了,步驟大概是這樣的。

  • 03070909 曾經實打實的分配在 SOH 上
  • GC 觸發後,03070909 所在的 segment 被收縮,同時對象被移走。
  • 但不知爲何,線程棧還保留了這個老地址 03070909,而不是新地址

出現這種情況的原因,大多是 C# 和 C++ 交互時沒有把 03070909 給固定住(GCHandle.Alloc),導致GC觸發對象移動之後,會存在兩種情況的崩潰。

  1. C++ 層面的崩潰:因爲此時的C++拿的地址不再有效了,導致在非託管層崩潰。

  2. CLR 層面的崩潰:線程如果在C++層面僵持,託管層GC觸發時會誤認爲這個無效的地址還是一個有效的對象,進而在標記階段導致程序崩潰。

有些朋友可能被我說懵了,畫個簡圖如下:

由於這個dump屬於第二種崩潰,即存在僵死的線程,接下來就是想辦法找到這個線程。

3. 僵死的線程在哪裏

如果你瞭解GC標記階段的底層運作,我相信你很容易找出這個答案的,對,只需要找到 ScanStackRoots 函數的第一個參數即可,參考代碼如下:


void GCToEEInterface::GcScanRoots(promote_func* fn, int condemned, int max_gen, ScanContext* sc)
{
	Thread* pThread = NULL;
	while ((pThread = ThreadStore::GetThreadList(pThread)) != NULL)
	{
		ScanStackRoots(pThread, fn, sc);
	}
}

接下來上 windbg 在崩潰的線程棧上實操一下。


0:005> kb 8
 # ChildEBP RetAddr      Args to Child              
00 063ce23c 719543fc     063ce258 0a76cc88 71954260 clr!WKS::gc_heap::mark_object_simple+0x12
01 063ce25c 71950b62     0a76cc88 063cec88 00000000 clr!WKS::GCHeap::Promote+0xa8
02 063ce274 71951a35     063cec40 0a76cc88 00000000 clr!GcEnumObject+0x37
03 063ce5d8 71950e6f     063ce920 063ce870 00000000 clr!EECodeManager::EnumGcRefs+0x72b
04 063ce628 717bfaa4     063ce650 063cec40 71950da0 clr!GcStackCrawlCallBack+0x139
05 063ce8f4 717bfbaa     063ce920 71950da0 063cec40 clr!Thread::StackWalkFramesEx+0x92
06 063cec28 71950fa3     71950da0 063cec40 00000500 clr!Thread::StackWalkFrames+0x9d
07 063cec4c 7195103e     063cec88 00000002 00000000 clr!standalone::ScanStackRoots+0x43

0:005> dp 063cec88 L1
063cec88  08debbf8

0:005> !t
ThreadCount:      30
UnstartedThread:  0
BackgroundThread: 29
PendingThread:    0
DeadThread:       0
Hosted Runtime:   no
                                                                         Lock  
       ID OSID ThreadOBJ    State GC Mode     GC Alloc Context  Domain   Count Apt Exception
       ...
       30   26 3e98 08debbf8     2b220 Preemptive  00000000:00000000 0079cb88 0     MTA 
       ...

從卦中看,30號線程就是我苦苦尋找的僵死線程,接下來趕緊切過去看看,果然發現了C++的函數xxx.Driver.xxx,由於私密性,我就模糊一下了哈。


0:030> ~30s
eax=00000000 ebx=08debbf8 ecx=00000000 edx=00000000 esi=00000000 edi=00000244
eip=77872aac esp=0a76c9fc ebp=0a76ca6c iopl=0         nv up ei pl nz na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000206
ntdll!NtWaitForSingleObject+0xc:
77872aac c20c00          ret     0Ch
0:030> !clrstack 
OS Thread Id: 0x3e98 (30)
Child SP       IP Call Site
0a76cc18 77872aac [InlinedCallFrame: 0a76cc18] 
0a76cc0c 00aa8047 DomainBoundILStubClass.IL_STUB_PInvoke(UInt32, xxx ByRef)
0a76cc18 00aa6c67 [InlinedCallFrame: 0a76cc18] xxx.Driver.xxx(UInt32, xxx ByRef)
0a76ccc0 00aa6c67 xxx.Driver.xxxFault(UInt32, System.String)
...

既然發現了C++方法,最後還剩一個疑問,就是此時的03070909真的在非託管層嗎?這個可以通過搜索它的線程棧地址。


0:030> s-d poi(@$teb+0x8) poi(@$teb+0x4) 03070909
0a76cc88  03070909 728f5d01 68d8c642 5c654b42  .....].rB..hBKe\

從代碼中可以看到確實是在xxx.Driver.xxxFault方法裏傳給了C++,有了這些信息接下來就是告訴朋友,重點關注下這個方法,捋一下邏輯。

三:總結

說實話這個dump分析起來還是有一定難度的,它考驗着你對GC標記階段玩法的底層理解,即使這位朋友是C#編程高手,分析了個把星期找不出問題是能夠理解的,畢竟術業有專攻,很開心的是這位朋友因此加了.NET高級調試訓練營,哈哈,以dump會友。

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