.NET9异常(CLR)原理(顶阶技术)

文摘   科技   2024-04-23 07:10   湖北  

点击上方蓝字 江湖评谈设为关注/星标




前言

.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?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(0x00007FFF57CFC2F8+0x8字节)开始,是托管Main函数的函数头地址。而在偏移0x64处也即是十进制100的地方,是异常处理模块。

我们来看一个结构体:

struct EE_ILEXCEPTION_CLAUSE  {    CorExceptionFlag    Flags;    DWORD               TryStartPC;    DWORD               TryEndPC;    DWORD               HandlerStartPC;    DWORD               HandlerEndPC;    union {        void*           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最新技术交流群(扫一扫或者长按加入)

往期精彩回顾

.NET9 Pre3 CLR的优化细节

新版.Net性能有没有达到C++90%?


江湖评谈
记录,分享,自由。
 最新文章