一:背景
1. 一個有趣的話題
最近在看 硬件異常
相關知識,發現一個有意思的空引用異常問題,拿出來和大家分享一下,為了方便講述,先上一段有問題的代碼。
namespace?ConsoleApp2
{internal?class?Program{static?Person?person?=?null;static?void?Main(string[]?args){var?age?=?person.age;Console.WriteLine(age);}}public?class?Person{public?int?age;}
}
由于 person
是一個 null 對象,很顯然這段代碼會拋異常,那為什么會拋異常呢?要想找原因,需要從最底層的匯編研究起。
二:異常原理分析
1. 從匯編上尋找答案
可以使用 Visual Studio 2022
的反匯編窗口,觀察 var age = person.age;
處到底生成了什么。
----------------??var?age?=?person.age;???----------------081D6154??mov?????????ecx,dword?ptr?ds:[4C41F4Ch]??
081D615A??mov?????????ecx,dword?ptr?[ecx+4]??
081D615D??mov?????????dword?ptr?[ebp-3Ch],ecx
這三句匯編還是很好理解的,4C41F4Ch
存放的是 person
對象, ecx+4
是取 person.age,最后一句就是將 age 放在 ebp-3Ch
棧位置上,接下來我們來看下 null 時的 ecx 到底是多少,截圖如下:

從圖中可以看到,此時的 ecx=0000000
,如果大家了解 windows 的虛擬內存布局,應該知道在虛擬內存的 0~0x0000ffff
范圍內是屬于 null 禁入區,凡是落在這個區一概屬訪問違例,畫個圖就像下面這樣。

到這里原理就搞清楚了,因為 [ecx+4] = [4] 是落在這個 null 區所致, 但是。。。。 大家有沒有發現一個問題,對,就是這里的 [ecx+4]
,因為這里有一個 +4
偏移來取 age 字段,那我能不能在 person 中多定義一些字段,然后取最后一個字段從而從 null 區
沖出去。。。哈哈。
2. 真的可以沖出 null 區嗎
有了這個想法之后,我決定在 Person
類中定義 10w 個 age 字段,參考代碼如下:
namespace?ConsoleApp2
{internal?class?Program{static?Person?person?=?null;static?void?Main(string[]?args){var?str?=?@"public?class?Person{{0}}";var?lines?=?Enumerable.Range(0,?100000).Select(m?=>?$"public?int?age{m};");var?fields?=?string.Join("\n",?lines);var?txt?=?str.Replace("{0}",?fields);File.WriteAllText("Person.cs",?txt);Console.WriteLine("person.cs?生成完畢");}}
}
代碼執行后,Person.cs
就會如期生成,接下來讀取 person.age99999
看看有沒有奇跡發生,參考代碼如下:
internal?class?Program{static?Person?person?=?null;static?void?Main(string[]?args){var?age?=?person.age99999;Console.WriteLine(age);}}

我去,萬萬沒想到,把 ClassLoader 給弄崩了。。。。得,那只能改 20000 個 age 試試看吧,參考代碼如下:
internal?class?Program{static?Person?person?=?null;static?void?Main(string[]?args){var?age?=?person.age19999;Console.WriteLine(age);}}
接下來我們將斷點放在 var age = person.age19999;
上繼續看反匯編代碼。
-------------?var?age?=?person.age19999;??-------------
0804657E??mov?????????ecx,dword?ptr?ds:[49F1F4Ch]??
08046584??mov?????????dword?ptr?[ebp-40h],ecx??
08046587??mov?????????ecx,dword?ptr?[ebp-40h]??
0804658A??cmp?????????dword?ptr?[ecx],ecx??
0804658C??mov?????????ecx,dword?ptr?[ebp-40h]??
0804658F??mov?????????ecx,dword?ptr?[ecx+13880h]??
08046595??mov?????????dword?ptr?[ebp-3Ch],ecx
從上面的匯編代碼可以看出幾點信息。
匯編代碼行數多了。
ecx+13880h 沖出了 null 區(FFFF) 的邊界。
接下來單步調試匯編,發現在 cmp dword ptr [ecx],ecx
處拋了異常。。。

大家都知道此時的 ecx 的地址是 0 ,從 ecx
上取內容肯定會拋訪問違例,而且這段代碼很詭異,一般來說 cmp
之后都是類似 jz,jnz
跳轉指令,而它僅僅是個半殘之句。。。
從這些特征看,這是 JIT 故意在取偏移之前嘗試判斷 ecx
是不是 null,動機不純哈。。。。
三:總結
從這些分析中可以得知,JIT 還是很智能的。
當偏移值落在
0~FFFF
禁入區內,JIT 就不生成判斷代碼來減少代碼體積。在偏移值沖出了
0~FFFF
禁入區,JIT 不得不生成代碼來判斷。
哈哈,本篇是不是很有意思,希望對大家有幫助。