當前位置: 妍妍網 > 碼農

.NET9異常(CLR)原理(頂階技術)

2024-04-24碼農

點選上方 藍字 江湖評談 設為關註/星標




前言

.NET9為了追求效能,把異常模組進行了重寫。但異常是CLR裏面較大的模組,PreView3(Pre4裏面經過了充分測試)裏面沒有經過充分測試,如果Pre3的程式遇到極端的情況,可以透過DOTNET_LegacyExceptionHandling這個臨時(以後會刪除這個變量)開關把它開啟,回退到舊有的例外處理模組。關於這一點可以參考:

本篇來看下,.NET9裏面異常的原理。

表象

一個簡單的例子:

static void Main(string[] args){try { throw new Exception(); } catch(Exception ex)  { Console.WriteLine(ex.Message); } Console.ReadLine();}

try裏面報了異常,會跳到catch裏面去執行。執行過程很簡單。但是背後的東西,不可估量。這段程式碼會被JIT編譯成兩段。第一段,例項化Exception,然後呼叫Exception的預設非建構函式ctor([System.Exception:.ctor():this]),後呼叫 CORINFO_HELP_THROW丟擲異常,最後 System.Console:ReadLine暫停。程式碼如下:

00007FFF57CFC30055 push rbp 00007FFF57CFC30148 83 EC 50 sub rsp,50h 00007FFF57CFC30548 8D 6C 24 50 lea rbp,[rsp+50h] 00007FFF57CFC30AC5 D8 57 E4 vxorps xmm4,xmm4,xmm4 00007FFF57CFC30EC5 FE 7F 65 E0 vmovdqu ymmword ptr [rbp-20h],ymm4 00007FFF57CFC31348 89 65 D0 mov qword ptr [rbp-30h],rsp 00007FFF57CFC31748 89 4D 10 mov qword ptr [rbp+10h],rcx 00007FFF57CFC31B83 3D 2E AA 20 00 00 cmp dword ptr [7FFF57F06D50h],0 00007FFF57CFC32274 05 je 00007FFF57CFC329 00007FFF57CFC324E8 E7 46 8C 5E call JIT_DbgIsJustMyCode (07FFFB65C0A10h) 00007FFF57CFC32990 nop 00007FFF57CFC32A90 nop 00007FFF57CFC32B48 B9 88 E1 29 58 FF 7F 00 00 mov rcx,7FFF5829E188h 00007FFF57CFC335E8 76 E8 D2 5E call JIT_TrialAllocSFastMP_InlineGetThread (07FFFB6A2ABB0h) 00007FFF57CFC33A48 89 45 F0 mov qword ptr [rbp-10h],rax 00007FFF57CFC33E48 8B 4D F0 mov rcx,qword ptr [rbp-10h] 00007FFF57CFC342FF 15 A0 0E 58 00 call qword ptr [7FFF5827D1E8h] 00007FFF57CFC34848 8B 4D F0 mov rcx,qword ptr [rbp-10h] 00007FFF57CFC34CE8 3F D8 8B 5E call IL_Throw (07FFFB65B9B90h) 00007FFF57CFC351CC int 3 00007FFF57CFC352FF 15 D0 96 85 00 call qword ptr [7FFF58555A28h] 00007FFF57CFC35848 89 45 E0 mov qword ptr [rbp-20h],rax 00007FFF57CFC35C90 nop 00007FFF57CFC35D90 nop 00007FFF57CFC35E48 83 C4 50 add rsp,50h 00007FFF57CFC3625D pop rbp 00007FFF57CFC363C3 ret

第二段是catch塊內的程式碼,也就是例外處理程式程式碼呼叫了Console.WriteLine(ex.Message);如下:

00007FFF57CFC36455 push rbp 00007FFF57CFC36548 83 EC 30 sub rsp,30h 00007FFF57CFC36948 8B 69 20 mov rbp,qword ptr [rcx+20h] 00007FFF57CFC36D48 89 6C 24 20 mov qword ptr [rsp+20h],rbp 00007FFF57CFC37248 8D 6D 50 lea rbp,[rbp+50h] 00007FFF57CFC37648 89 55 E8 mov qword ptr [rbp-18h],rdx 00007FFF57CFC37A48 8B 45 E8 mov rax,qword ptr [rbp-18h] 00007FFF57CFC37E48 89 45 F8 mov qword ptr [rbp-8],rax 00007FFF57CFC38290 nop 00007FFF57CFC38348 B9 D0 93 00 80 50 02 00 00 mov rcx,250800093D0h 00007FFF57CFC38DFF 15 E5 97 85 00 call qword ptr [7FFF58555B78h] 00007FFF57CFC39390 nop 00007FFF57CFC39490 nop 00007FFF57CFC39590 nop 00007FFF57CFC39648 8D 05 B5 FF FF FF lea rax,[7FFF57CFC352h] 00007FFF57CFC39D48 83 C4 30 add rsp,30h 00007FFF57CFC3A15D pop rbp 00007FFF57CFC3A2C3 ret

這兩段程式碼的詭異之處在於,第二段程式碼是透過第一段的 CORINFO_HELP_THROW(也即是JIT的IL_Throw)函式呼叫的,然後返回第一段程式碼進行暫停(Console.ReadLine)。這其實也不難理解,當第一段程式碼(try塊)執行的時候,遇到了異常,程式就會處理異常,處理異常程式的程式碼在catch塊內(第二段程式碼),所以就被執行了。

內在

上面說完了表象,看下內在。內在分為兩部份,第一部份是呼叫鏈,第二部份是異常記憶體模型。

1.呼叫鏈

try塊裏面丟擲了異常,但這是在托管層面。所以結果還是會反饋到非托管,JIT編譯後的非托管透過IL_Throw函式接收到異常,然後呼叫RaiseException系統函式丟擲系統級的異常。分別為KernelBase.dll以及ntdll.dll裏面,如下:

IL_Throw(coreclr.dll)-】RaiseException(KernelBase.dll)-】KiUserExceptionDispatch(ntdll.dll)-】RtlDispatchException(ntdll.dll)-】RtlpExecuteHandlerForException(ntdll.dll) -】ProcessCLRException(coreclr.dll)

最後的ProcessCLRException即是.NET9 CLR裏面的例外處理函式。註意了這是第一次呼叫 ProcessCLRException函式,它會對例外處理模組catch進行分析,以及地址賦值。 此後會透過 RtlUnwind 函式 第二次呼叫 ProcessCLRException函式

RtlUnwind( ntdll.dll)-】RtlUnwindEx( ntdll.dll)-】RtlpExecuteHandlerForUnwind(ntdll.dll)-】ProcessCLRException(coreclr.dll)

第二次執行 ProcessCLRException函式之後,它裏面會呼叫catch例外處理模組,進行例外處理。

2.記憶體模型

異常的記憶體模型很少有人提及,所以這裏扼要看下。它主要是透過ProcessCLRException函式來獲取到丟擲異常函式(比如Main裏面try塊報了異常,所以這裏的異常函式指的是Main)的函式頭地址以及例外處理函式(也即是上面表象的第二段程式碼相對於異常函式頭)的偏移地址。這兩者相加的結果即是例外處理函式的函式體地址,跳轉到裏面進行例外處理。

一般的來說,在函式頭的地址處前八字節裏麵包含了偵錯資訊(DebugInfo),異常資訊(EHInfo),GC資訊(GCInfo),函式描述結構體(MethodDesc),以及回滾個數(nUnwindInfos)回滾資訊(UnWinInfos)。設若以下二進制:

0x00007FFF57CFC2F8 00007fff5854baf8 6c8d4850ec834855 fec5e457d8c55024 48d0658948e0657f ??TX....UH??PH?l$P??W???.e?H?e?H0x00007FFF57CFC318 20aa2e3d83104d89 8c46e7e805740000 29e188b94890905e e876e800007fff58 ?M.?=.? ..t.??F?^??H???)X....?v?0x00007FFF57CFC338 8b48f04589485ed2 00580ea015fff04d 8bd83fe8f04d8b48 008596d015ffcc5e ?^H?E?H?M?..?.X.H?M?????^?..???.0x00007FFF57CFC358 83489090e0458948 ec834855c35d50c4 6c894820698b4830 8948506d8d482024 H?E???H??P]?UH??0H?i H?l$ H?mPH?0x00007FFF57CFC378 8948e8458b48e855 0093d0b94890f845 e515ff0000025080 8d48909090008597 U?H?E?H?E??H???.€P.....???.???H?

地址0x00007FFF57CFC2F8處包含的八字節00007fff5854baf8地址裏面指向的即上面所說的函式頭的地址處前八字節,裏麵包含了上面介紹各種資訊。從地址0x00007FFF57CFC300( 0x00007F FF57CFC2F8+0x8字節) 開始,是托管Main函式的函式頭地址。而在偏移0x64處也即是十進制100的地方,是例外處理模組。

我們來看一個結構體:

struct EE_ILEXCEPTION_CLAUSE {CorExceptionFlagFlags;DWORDTryStartPC;DWORDTryEndPC;DWORDHandlerStartPC;DWORDHandlerEndPC;union{void*TypeHandle;mdToken classToken;DWORDFilterOffset;};};

JIT編譯的時候,會把例外處理的catch塊相對於托管Main函式函式頭地址的偏移放入 到HandlerStartPC欄位,此後即可透過函式頭+ HandlerStartPC獲取到例外處理塊的地址了。 0x00007FFF57CFC30 0+0x64== 0x00007FFF57CFC364,我們看到上面表象第二段程式碼的起始地址剛好是 0x00007FFF57CFC3 64

00007FFF57CFC36455 push rbp 00007FFF57CFC36548 83 EC 30 sub rsp,30h 00007FFF57CFC36948 8B 69 20 mov rbp,qword ptr [rcx+20h] 00007FFF57CFC36D48 89 6C 24 20 mov qword ptr [rsp+20h],rbp 00007FFF57CFC37248 8D 6D 50 lea rbp,[rbp+50h] ......... //後面省略

而00007FFF57CFC300則正好是托管Main函式的函式頭地址,如下:

00007FFF57CFC30055 push rbp 00007FFF57CFC30148 83 EC 50 sub rsp,50h 00007FFF57CFC30548 8D 6C 24 50 lea rbp,[rsp+50h] 00007FFF57CFC30AC5 D8 57 E4 vxorps xmm4,xmm4,xmm4 00007FFF57CFC30EC5 FE 7F 65 E0 vmovdqu ymmword ptr [rbp-20h],ymm4 00007FFF57CFC31348 89 65 D0 mov qword ptr [rbp-30h],rsp 00007FFF57CFC31748 89 4D 10 mov qword ptr [rbp+10h],rcx 00007FFF57CFC31B83 3D 2E AA 20 00 00 cmp dword ptr [7FFF57F06D50h],0 ...............//後面省略

系統

CLR是底層的高階技術,所以它作業系統函式必不可少,這裏拓展下。首先看下RtlUnwind函式,函式原型:

RtlUnwind(_In_opt_PVOID TargetFrame,_In_opt_PVOID TargetIp,_In_opt_PEXCEPTION_RECORD ExceptionRecord,_In_PVOID ReturnValue);

這個函式是對堆疊進行掃描,找到合適的例外處理模組進行處理。它的第一個參數 TargetFrame 是例外處理模組呼叫之後返回,x64寄存器rsp所在的地址。舉個例子,上面托管Main函式在進行了例外處理之後,會跳轉到IL_Throw後面的程式碼繼續執行,這裏的TargetFrame即是IL_Throw後面程式碼所在的rsp包含的地址。

它的二個參數TargetIp, 例外處理模組呼叫之後 返回之後x64-rip所在的地址。但是實測CLR裏面這個異常 rip地址並不是TargetIp,而是CLR裏面的另一個變量dwResumePC。當CLR進行了異常模組處理之後, TargetFrame的設定正確,但rip的所在地址則是透過 dwResumePC來決定。

dwResumePC = pfnHandler(sf.SP, OBJECTREFToObject(throwable));

pfnHandler呼叫了例外處理模組,返回值dwResumePC則是例外處理模組完成之後,需要跳轉的地址,本例即是(Console.ReadLine)。這裏有一個疑問,CLR是如何跳轉到dwResumePC所在的地址呢?首先透過SetIP設定RIP到上下文,然後透過ResumeExecution把當前上下文設定為SetIP設定的上下文即可跳轉。如下:

SetIP(pContextRecord, (PCODE)uResumePC);ExceptionTracker::ResumeExecution(pContextRecord);

結尾

系統級關鍵點梳理下,即是:

  • 透過系統函式RaiseException丟擲異常

  • 透過RtlUnwind尋找例外處理模組函式

  • 透過RtlRestoreContext(ResumeExecution呼叫)恢復到異常之後的程式碼

  • 以上透過LLDB分析的結果,由於過程過於繁雜,某些細節並未完全展示。

    歡迎加入.NET9最新技術交流群(掃一掃或者長按加入)

    往期精彩回顧