凯发k8天生赢家一触即发

.net8顶级技术:边界检查之ir解析(慎入) -凯发k8天生赢家一触即发

2023-08-17,,

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数组的长度小于或者循环的最大值。通过这种一次性的判断,取代每次循环的判断,最大化提升代码运行效率。

在.net8里面这种情况是可行的。

.net8 jit machine code


g_m000_ig01: ;; offset=0000h
4883ec28 sub rsp, 40
g_m000_ig02: ;; offset=0004h
33c0 xor eax, eax
4885c9 test rcx, rcx
7429 je short g_m000_ig05
81790845230100 cmp dword ptr [rcx 08h], 0x12345
7c20 jl short g_m000_ig05
0f1f40000f1f840000000000 align [12 bytes for ig03]
g_m000_ig03: ;; offset=0020h
8bd0 mov edx, eax
837c91102a cmp dword ptr [rcx 4*rdx 10h], 42
7429 je short g_m000_ig08
ffc0 inc eax
3d45230100 cmp eax, 0x12345
7cee jl short g_m000_ig03
g_m000_ig04: ;; offset=0032h
eb17 jmp short g_m000_ig06
g_m000_ig05: ;; offset=0034h
3b4108 cmp eax, dword ptr [rcx 08h]
7323 jae short g_m000_ig10
8bd0 mov edx, eax
837c91102a cmp dword ptr [rcx 4*rdx 10h], 42
7410 je short g_m000_ig08
ffc0 inc eax
3d45230100 cmp eax, 0x12345
7ce9 jl short g_m000_ig05
g_m000_ig06: ;; offset=004bh
33c0 xor eax, eax
g_m000_ig07: ;; offset=004dh
4883c428 add rsp, 40
c3 ret
g_m00_ig08: ;; offset=0052h
b801000000 mov eax, 1
g_m000_ig09: ;; offset=0057h
4883c428 add rsp, 40
c3 ret
g_m000_ig10: ;; offset=005ch
e89f82c25f call corinfo_help_rngchkfail
cc 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(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代码:

***** bb02
stmt00002 ( 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进行优化变形,最终形成了如上边界检查去除所示的结果。关于这点,下篇再看。

作者:江湖评谈

原文:在此处

.net8顶级技术:边界检查之ir解析()的相关教程结束。

网站地图