点击上方蓝字 江湖评谈设为关注/星标
前言
.NET9为了追求性能,把异常模块进行了重写。但异常是CLR里面较大的模块,PreView3(Pre4里面经过了充分测试)里面没有经过充分测试,如果Pre3的程序遇到极端的情况,可以通过DOTNET_LegacyExceptionHandling这个临时(以后会删除这个变量)开关把它开启,回退到旧有的异常处理模块。关于这一点可以参考:.NET9 Pre3 CLR的改进
本篇来看下,.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暂停。代码如下:
00007FFF57CFC300 55 push rbp
00007FFF57CFC301 48 83 EC 50 sub rsp,50h
00007FFF57CFC305 48 8D 6C 24 50 lea rbp,[rsp+50h]
00007FFF57CFC30A C5 D8 57 E4 vxorps xmm4,xmm4,xmm4
00007FFF57CFC30E C5 FE 7F 65 E0 vmovdqu ymmword ptr [rbp-20h],ymm4
00007FFF57CFC313 48 89 65 D0 mov qword ptr [rbp-30h],rsp
00007FFF57CFC317 48 89 4D 10 mov qword ptr [rbp+10h],rcx
00007FFF57CFC31B 83 3D 2E AA 20 00 00 cmp dword ptr [7FFF57F06D50h],0
00007FFF57CFC322 74 05 je 00007FFF57CFC329
00007FFF57CFC324 E8 E7 46 8C 5E call JIT_DbgIsJustMyCode (07FFFB65C0A10h)
00007FFF57CFC329 90 nop
00007FFF57CFC32A 90 nop
00007FFF57CFC32B 48 B9 88 E1 29 58 FF 7F 00 00 mov rcx,7FFF5829E188h
00007FFF57CFC335 E8 76 E8 D2 5E call JIT_TrialAllocSFastMP_InlineGetThread (07FFFB6A2ABB0h)
00007FFF57CFC33A 48 89 45 F0 mov qword ptr [rbp-10h],rax
00007FFF57CFC33E 48 8B 4D F0 mov rcx,qword ptr [rbp-10h]
00007FFF57CFC342 FF 15 A0 0E 58 00 call qword ptr [7FFF5827D1E8h]
00007FFF57CFC348 48 8B 4D F0 mov rcx,qword ptr [rbp-10h]
00007FFF57CFC34C E8 3F D8 8B 5E call IL_Throw (07FFFB65B9B90h)
00007FFF57CFC351 CC int 3
00007FFF57CFC352 FF 15 D0 96 85 00 call qword ptr [7FFF58555A28h]
00007FFF57CFC358 48 89 45 E0 mov qword ptr [rbp-20h],rax
00007FFF57CFC35C 90 nop
00007FFF57CFC35D 90 nop
00007FFF57CFC35E 48 83 C4 50 add rsp,50h
00007FFF57CFC362 5D pop rbp
00007FFF57CFC363 C3 ret
第二段是catch块内的代码,也就是异常处理程序代码调用了Console.WriteLine(ex.Message);如下:
00007FFF57CFC364 55 push rbp
00007FFF57CFC365 48 83 EC 30 sub rsp,30h
00007FFF57CFC369 48 8B 69 20 mov rbp,qword ptr [rcx+20h]
00007FFF57CFC36D 48 89 6C 24 20 mov qword ptr [rsp+20h],rbp
00007FFF57CFC372 48 8D 6D 50 lea rbp,[rbp+50h]
00007FFF57CFC376 48 89 55 E8 mov qword ptr [rbp-18h],rdx
00007FFF57CFC37A 48 8B 45 E8 mov rax,qword ptr [rbp-18h]
00007FFF57CFC37E 48 89 45 F8 mov qword ptr [rbp-8],rax
00007FFF57CFC382 90 nop
00007FFF57CFC383 48 B9 D0 93 00 80 50 02 00 00 mov rcx,250800093D0h
00007FFF57CFC38D FF 15 E5 97 85 00 call qword ptr [7FFF58555B78h]
00007FFF57CFC393 90 nop
00007FFF57CFC394 90 nop
00007FFF57CFC395 90 nop
00007FFF57CFC396 48 8D 05 B5 FF FF FF lea rax,[7FFF57CFC352h]
00007FFF57CFC39D 48 83 C4 30 add rsp,30h
00007FFF57CFC3A1 5D pop rbp
00007FFF57CFC3A2 C3 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?H
0x00007FFF57CFC318 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(0x00007FFF57CFC2F8+0x8字节)开始,是托管Main函数的函数头地址。而在偏移0x64处也即是十进制100的地方,是异常处理模块。
我们来看一个结构体:
struct EE_ILEXCEPTION_CLAUSE {
CorExceptionFlag Flags;
DWORD TryStartPC;
DWORD TryEndPC;
DWORD HandlerStartPC;
DWORD HandlerEndPC;
union {
TypeHandle;
mdToken ClassToken;
DWORD FilterOffset;
};
};
JIT编译的时候,会把异常处理的catch块相对于托管Main函数函数头地址的偏移放入到HandlerStartPC字段,此后即可通过函数头+HandlerStartPC获取到异常处理块的地址了。0x00007FFF57CFC300+0x64==0x00007FFF57CFC364,我们看到上面表象第二段代码的起始地址刚好是0x00007FFF57CFC364
00007FFF57CFC364 55 push rbp
00007FFF57CFC365 48 83 EC 30 sub rsp,30h
00007FFF57CFC369 48 8B 69 20 mov rbp,qword ptr [rcx+20h]
00007FFF57CFC36D 48 89 6C 24 20 mov qword ptr [rsp+20h],rbp
00007FFF57CFC372 48 8D 6D 50 lea rbp,[rbp+50h]
......... //后面省略
而00007FFF57CFC300则正好是托管Main函数的函数头地址,如下:
00007FFF57CFC300 55 push rbp
00007FFF57CFC301 48 83 EC 50 sub rsp,50h
00007FFF57CFC305 48 8D 6C 24 50 lea rbp,[rsp+50h]
00007FFF57CFC30A C5 D8 57 E4 vxorps xmm4,xmm4,xmm4
00007FFF57CFC30E C5 FE 7F 65 E0 vmovdqu ymmword ptr [rbp-20h],ymm4
00007FFF57CFC313 48 89 65 D0 mov qword ptr [rbp-30h],rsp
00007FFF57CFC317 48 89 4D 10 mov qword ptr [rbp+10h],rcx
00007FFF57CFC31B 83 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最新技术交流群(扫一扫或者长按加入)
往期精彩回顾