前言
C#这种语言之所以号称安全的,面向对象的语言。这个安全两个字可不是瞎叫的哦。因为JIT会检查任何可能超出分配范围的数值,以便使其保持在安全边界内。这里有两个概念,其一边界检查,其二IR解析。后者的生成是前者的功能的保证。啥叫IR,你以为的IL是中间语言,其实并不是,还有一层IR中间表象。.Net8的顶级技术之一,晓者寥寥无几。本篇来看看这两项技术。.
概括
1.边界检查的缺陷
这里边界检查以数组的边界检查为例,看下C#代码
C# Code
using System.Runtime.CompilerServices;class Program{static void Main(){int[] array = new int[10_000_000];for (int i = 0; i < 1_000_000; i++){Test(array);}}[MethodImpl(MethodImplOptions.NoInlining)]private static bool Test(int[] array){for (int i = 0; i < 0x12345; i++){if (array[i] == 42){return true;}}return false;}}
JIT并不知道数组array[i]里面的i索引是否超过了array数组的长度。所以每次循环都会检查索引的大小,如果超过则报异常,不超过继续循环,这种功能就叫做边界检查。是.Net6 JIT自动加上去的,但是它有缺陷。
缺陷就在于,每次循环都检查,极大消耗了代码的运行效率。为了避免这种缺陷,是否可以在循环之前判断array数组的长度小于或者循环的最大值。通过这种一次性的判断,取代每次循环的判断,最大化提升代码运行效率。
在.Net7里面这种情况是可行的。
.Net7 JIT Machine Code
G_M000_IG01: ;; offset=0000H4883EC28 sub rsp, 40G_M000_IG02: ;; offset=0004H33C0 xor eax, eax4885C9 test rcx, rcx7429 je SHORT G_M000_IG0581790845230100 cmp dword ptr [rcx+08H], 0x123457C20 jl SHORT G_M000_IG050F1F40000F1F840000000000 align [12 bytes for IG03]G_M000_IG03: ;; offset=0020H8BD0 mov edx, eax837C91102A cmp dword ptr [rcx+4*rdx+10H], 427429 je SHORT G_M000_IG08FFC0 inc eax3D45230100 cmp eax, 0x123457CEE jl SHORT G_M000_IG03G_M000_IG04: ;; offset=0032HEB17 jmp SHORT G_M000_IG06G_M000_IG05: ;; offset=0034H3B4108 cmp eax, dword ptr [rcx+08H]7323 jae SHORT G_M000_IG108BD0 mov edx, eax837C91102A cmp dword ptr [rcx+4*rdx+10H], 427410 je SHORT G_M000_IG08FFC0 inc eax3D45230100 cmp eax, 0x123457CE9 jl SHORT G_M000_IG05G_M000_IG06: ;; offset=004BH33C0 xor eax, eaxG_M000_IG07: ;; offset=004DH4883C428 add rsp, 40C3 retG_M00_IG08: ;; offset=0052HB801000000 mov eax, 1G_M000_IG09: ;; offset=0057H4883C428 add rsp, 40C3 retG_M000_IG10: ;; offset=005CHE89F82C25F call CORINFO_HELP_RNGCHKFAILCC int3; Total bytes of code 98
诚如上面所言,边界检查的判断放在了for循环的外面。if和else分成快速和慢速路径,前者进行了优化。逆向成C#代码如下
if(array!=null && array.length >=0x12345)//数组不能为空,且数组的长度不能小于循环的长度。否则可能边界溢出{for(int i=0;i<0x12345;i++){if(array[i]==42)//这里不再检查边界{return true;}}return false;}else{for(int i=0;i<0x2345;i++){if(i<array.length){if(array[i]==42)return true;}}return flase;}
边界检查不是本节的重点,重点是这个边界检查是如何通过IR生成的。因为IL代码里面并没有。
2.IR的生成
常规的认为,C#的运行过程是:
C# Code->
IL ->
Machine Code
一般的认为,IL是中间语言,或者字节码。但是实际上还有一层在JIT里面。如下:
C# Code ->
IL ->
IR ->
Machine Code
这个IR是对IL进行各种骚操作。最重要的一点就是各种优化和变形。这里来看看IR是如何对IL进行边界检查优化的。
看下边界检查的核心IR代码:
***** BB02STMT00002 ( 0x004[E-] ... 0x009 )[000013] ---XG+----- * JTRUE void[000012] N--XG+-N-U- \--* EQ int[000034] ---XG+----- +--* COMMA int[000026] ---X-+----- | +--* BOUNDS_CHECK_Rng void[000008] -----+----- | | +--* LCL_VAR int V01 loc0[000025] ---X-+----- | | \--* ARR_LENGTH int[000007] -----+----- | | \--* LCL_VAR ref V00 arg0[000035] n---G+----- | \--* IND int[000033] -----+----- | \--* ARR_ADDR byref int[][000032] -----+----- | \--* ADD byref[000023] -----+----- | +--* LCL_VAR ref V00 arg0[000031] -----+----- | \--* ADD long[000029] -----+----- | +--* LSH long[000027] -----+---U- | | +--* CAST long <- uint[000024] -----+----- | | | \--* LCL_VAR int V01 loc0[000028] -----+-N--- | | \--* CNS_INT long 2[000030] -----+----- | \--* CNS_INT long 16[000011] -----+----- \--* CNS_INT int 42------------ BB03 [00D..019) -> BB02 (cond), preds={BB02} succs={BB04,BB02}
这种看着牛逼轰轰的不知道写的什么的代码,正是IR。从最里面看起,意思在注释里。
[000031] -----+----- | \--* ADD long //把LSH计算的结果加上16,这个16就是下面的CNS_INT long 16的16.[000029] -----+----- | +--* LSH long //LSH表示把数组索引左移2位。这个2就是下面的CNS_INT long 2里面的2[000027] -----+---U- | | +--* CAST long <- uint//把数组索引的类型从uint转换转换成long类型[000024] -----+----- | | | \--* LCL_VAR int V01 loc0 //读取本地变量V01,实际上就是数组arrar的索引。[000028] -----+-N--- | | \--* CNS_INT long 2 //这个2是左移的位数[000030] -----+----- | \--* CNS_INT long 16//被ADD相加的数值16
继续看
| \--* IND int[000033] -----+----- | \--* ARR_ADDR byref int[][000032] -----+----- | \--* ADD byref //把前面计算的结果与array数组的地址相加。实际上就是 array + i*4+-x10。一个索引占4个字节,methodtable和array.length各占8字节,这个表达式的结果就是索引位i的array的值,也就是array[i]这个数值。[000023] -----+----- | +--* LCL_VAR ref V00 arg0 //获取本地变量V00的地址,这个地址实际上就是数组array的地址。[000031] -----+----- | \--* ADD long[000029] -----+----- | +--* LSH long[000027] -----+---U- | | +--* CAST long <- uint[000024] -----+----- | | | \--* LCL_VAR int V01 loc0[000028] -----+-N--- | | \--* CNS_INT long 2[000030] -----+----- | \--* CNS_INT long 16
继续看
[000013] ---XG+----- * JTRUE void //是或者否都进行相应的跳转[000012] N--XG+-N-U- \--* EQ int //判断获取的array[i]是否等于42,这个42是CNS_INT int 42里的42[000034] ---XG+----- +--* COMMA int //计算它的两个值,获取第二个值也就是array[i][000026] ---X-+----- | +--* BOUNDS_CHECK_Rng void[000008] -----+----- | | +--* LCL_VAR int V01 loc0 //数组的索引i值[000025] ---X-+----- | | \--* ARR_LENGTH int //获取数组长度[000007] -----+----- | | \--* LCL_VAR ref V00 arg0 //数组的长度[000035] n---G+----- | \--* IND int //获取array[i]的值[000033] -----+----- | \--* ARR_ADDR byref int[] //获取刚刚array数组地址//中间省略,上面已经写过了。[000011] -----+----- \--* CNS_INT int 42
那么翻译成C# Code如下:
if(array[i]==42){return true;}return false
这里还没有循环,因为循环在其它的Basic Block块,这里是BB02块。那么下面就是对着BB02进行优化变形,最终形成了如上边界检查去除所示的结果。关于这点,下篇再看。