為什麼 C# 存取 null 欄位會拋異常?

2022-06-21 09:00:44

一:背景

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 不得不生成程式碼來判斷。

哈哈,本篇是不是很有意思,希望對大家有幫助。

圖片名稱