如何將一個範例的記憶體二進位制內容讀出來?

2023-06-08 12:01:31

在《如何計算一個範例佔用多少記憶體?》中我們知道一個值型別或者參照型別的範例在記憶體中佔多少位元組。如果我們知道這段連續的位元組序列的初始地址,我們就能夠將代表該範例的位元組內容讀取出來。在接下來的內容中,我們將利用一個簡單的方法輸出指定範例的位元組序列,並此次分析值型別和參照型別範例在記憶體的佈局。

一、讀取範例在記憶體中的位元組
二、檢視值型別和參照型別範例的記憶體位元組
三、儲存方法表地址
四、Object Header的記憶體佈局
五、儲存「瘦鎖」
六、儲存雜湊碼
七、儲存SyncBlock Index

一、讀取範例在記憶體中的位元組

如下所示的PrintBytes<T>會將指定範例在記憶體中的位元組輸出到控制檯上。如程式碼片段所示,我們先呼叫《如何計算一個範例佔用多少記憶體?》中定義了SizeCalculator將承載範例內容的位元組數計算出來,並建立對應長度的位元組陣列來存放讀取的位元組。如果指定的變數value是一個結構體(值型別),意味著變數會直接指向結構體的首位元組。在這種情況下,我們只需要將該變數的參照轉換成指標(void*),然後將其轉換成IntPtr物件,並作為起始地址呼叫Marshal的Copy方法將指定數量的位元組拷貝到建立的位元組陣列就可以了。

public static class BytesPrinter
{
    public unsafe static void PrintBytes<T>(T value)
    {
        var size = SizeCalculator.Instance.SizeOf(() => value);
        var bytes = new byte[size];

        var pointer = Unsafe.AsPointer(ref value);
        IntPtr head = typeof(T).IsValueType ? new IntPtr(pointer) : *(IntPtr*)pointer - IntPtr.Size;
        Marshal.Copy(head, bytes, 0, size);
        Console.WriteLine($"[{size}]{BitConverter.ToString(bytes)}");
    }

    public static string AsString(this IntPtr ptr) => BitConverter.ToString(BitConverter.GetBytes(ptr.ToInt64()));
}

對於參照型別,整個過程就要複雜一些。此時指定的變數value指向的是目標物件的地址,所以在將此變數參照轉換成void*指標後,還需要將其轉換成IntPtr*指標,並最終將指標的內容(也就是目標物件的地址)解析出來。由於變數指向的地址並非目標範例對映記憶體位元組的首地址,僅僅是儲存方法表地址的地方,所以還需要向前移動一個身位(IntPtr.Size)才是範例所在記憶體片段的首地址。在將所需位元組拷貝到建立的位元組陣列之後,我們將其格式化成字串輸出到控制檯上。另一個AsString擴充套件方法會將指定IntPtr物件表示的記憶體地址輸出到控制檯上,我們會在後續的演示中使用到它。

二、檢視值型別和參照型別範例的記憶體位元組

在如下的程式碼片段中,我們定義的結構體FoobarStructure和類FoobarClass具有兩個欄位Foo和Bar,對應型別分別是Byte和Int32。我們分別建立了它們的範例,並將這兩個欄位設定成255(0xFF)和65535(0xFFFF)。我們將它們作為引數呼叫了上面定義的PrintBytes方法。

BytesPrinter.PrintBytes(new FoobarStructure(255, 65535));
BytesPrinter.PrintBytes(new FoobarClass(255, 65535));

public struct FoobarStructure
{
    public byte Foo;
    public int Bar;

    public FoobarStructure(byte foo, int bar)
    {
        Foo = foo;
        Bar = bar;
    }
}

public class FoobarClass
{
    public byte Foo;
    public int Bar;

    public FoobarClass(byte foo, int bar)
    {
        Foo = foo;
        Bar = bar;
    }

}

程式執行後會將指定的FoobarStructure和FoobarClass實際對應的位元組輸出控制檯上。為了更好地理解該位元組序列每一部分的內容,我特意按照如下的方式新增了方括號對它們進行了分割。從下面的內容可以看出,雖然Byte和Int32對應的位元組數分別為1和4,但是FoobarStructure這個結構體的位元組數卻是8,三個空白位元組(紅色標記)是為了記憶體對齊額外新增的「留白(Padding,紅色標註)」。從位元組的內容還可以看出,記憶體中體現的欄位順序預設與它們在結構體中定義的順序是一致的(Foo:FF;Bar:FF-FF-00-00)。順便提一下,基元型別在記憶體中是按照「小端序」儲存的。

[8][FF-00-00-00]-[FF-FF-00-00]

[24][00-00-00-00-00-00-00-00]-[38-39-78-B3-FD-7F-00-00]-[FF-FF-00-00-FF-00-00-00]

FoobarClass範例在記憶體中的位元組數要多很多,變成了24。第一組8位元組是代表ObjectHeader(包含4位元組用於記憶體對齊的空位元組),第2組8位元組代表FoobarClass型別的方法表的記憶體地址。兩個欄位的內容體現在最後一組8位元組中,可以看出它們內容與FoobarStructure不一樣,這是因為在預設的情況下,結構體採用Sequential(與定義一致),而類則採用Auto,其目的是為了滿足記憶體對其規則的情況下對欄位進行重新排序,以節省記憶體空間。在這裡Bar欄位(FF-FF-00-00)被放在Foo欄位(FF)的前面。由於24是參照型別範例在記憶體中的最小位元組數(針對x64架構),欄位重排針對記憶體的「壓縮」沒有體現出來。

三、儲存方法表地址

.NET執行時中針對「型別」的描述資訊幾乎都來自於方法表這個內部的資料結構。參照型別範例在記憶體中的第二部分內容(ObjectHeader之後)存放的就是對應方法表的地址,範例和型別就是通過這種方式關聯起來的。在C#中,我們也可以利用表示「型別控制程式碼(Type Handle)」的RuntimeTypeHandle物件得到對應型別方法表的地址。在如下所示的程式碼片段中,我們在輸出FoobarClass物件的記憶體位元組序列後,我們進一步獲得了FoobarClass型別的TypeHandle物件,該物件的Value屬性返回的就是方法表地址。我們呼叫上面定義的AsString擴充套件方法將其轉換成格式化字串後輸出到控制檯上。

BytesPrinter.PrintBytes(new FoobarClass(255, 65535));
Console.WriteLine("[TypeHandle]{0}",typeof(FoobarClass).TypeHandle.Value.AsString());

從如下所示的輸出結果可以看出,範例記憶體位元組承載的和TypeHandle提供的方法表地址是一致的。

[24]00-00-00-00-00-00-00-00-38-37-78-B3-FD-7F-00-00-FF-FF-00-00-FF-00-00-00

[TypeHandle]38-37-78-B3-FD-7F-00-00

四、Object Header的記憶體佈局

我看到一些檔案將Object Header命名為SyncBlock Index/Number,這種命名不能算錯,但至少沒有完整地體現Object Header的作用以及儲存方式。當我們對某個物件加鎖的時候,系統會使用一個名為SyncBlock的內部資料結果與之關聯,SyncBlock中會包含當前執行緒ID和遞迴等級等資訊。這樣的SyncBlock被儲存在一個SyncBlock Table中,它在這個表中的索引會儲存在Object Header。

clip_image001

實際上SyncBlock Index只體現了Object Header只體現了Object Header的一種使用場景而已。這種將SyncBlock Index儲存在Object Header中實現的鎖被稱為 「胖鎖(Fat Lock)」 ,既然有胖鎖,自然就有瘦鎖(Thin Lock),瘦鎖直接將同步資訊儲存在Object Header中。由於不需要存取SyncBlock Table,瘦鎖的效能要高很多。除了用於儲存同步資訊,Object Header還可以用來快取物件的Hash碼。上圖體現了Object Header典型的三種儲存場景:

  • 瘦鎖:使用Object Header的低27位儲存當前AppDomain索引(16-26)、鎖的遞迴等級(10-15)和執行緒ID(0-9);
  • 雜湊碼:使用Object Header的低26位儲存物件的雜湊碼;
  • SyncBlock Index: 使用Object Header的低26位儲存關聯的SyncBlock 在SyncBlock Table的索引。

為了確定Object Header儲存的內容,它的高5位被預留了下來,它們分別表示:

  • 27-BIT_SBLK_IS_HASH_OR_SYNCBLKINDEX:確定儲存的內容是否是雜湊或者SyncBlock Index;
  • 28-BIT_SBLK_SPIN_LOCK:CLR使用它以原子操作的方式修改Object Header的內容;
  • 29- BIT_SBLK_GC_RESERVE :GC在執行過程中用於標記物件是否被固定(pined)
  • 30- BIT_SBLK_FINALIZER_RUN:GC用於確定物件的解構函式是否被呼叫;
  • 31- BIT_SBLK_AGILE_IN_PROGRESS:在debug build下被用來確定兩個跨AppDomain應用的物件之間是否存在死迴圈。

對於上圖中的第2/3中儲存場景下,由於BIT_SBLK_IS_HASH_OR_SYNCBLKINDEX只能確定承載的內容是否是雜湊碼還是SyncBlock Index,我們還得使用第26位(0-base)作進一步區分。這個位元被稱為BIT_SBLK_IS_HASHCODE,顧名思義,它表示承載得內容是否是物件得雜湊碼。

五、儲存「瘦鎖」

在瞭解了Object Header的位元組佈局後,我們利用我們定義的方法將物件的Object Header的內容讀取出來,看看它的內容是否與描述的一致。我們先來看看基於「瘦鎖」的儲存方式。

await Task.Yield();
PrintThreadId();
var foobar = new Foobar();
lock (foobar)
{
    BytesPrinter.PrintBytes(foobar);
    lock (foobar)
    {
        BytesPrinter.PrintBytes(foobar);
        lock (foobar)
        {
            BytesPrinter.PrintBytes(foobar);
            Debugger.Break();
        }
    }
}

static void PrintThreadId()
{
    var bytes = BitConverter.GetBytes(Environment.CurrentManagedThreadId);
    Console.WriteLine($"Thread Id: {BitConverter.ToString(bytes)}");
}
public class Foobar{}

在如下所示的演示程式中,我們定義了一個「空」的類Foobar。await Task.Yield()之後的操作將以非同步的方式執行,為了確定Object Header中是否包含當前執行緒的ID,我們將執行緒ID以16進位制的形式輸出到控制檯上。然後我們建立了一個Foobar物件,然後巢狀的方式鎖定它,並在鎖定上下文中將改物件的記憶體位元組輸出來。

Thread Id: 06-00-00-00

[24]00-00-00-00-06-00-00-00-10-02-66-4C-FA-7F-00-00-00-00-00-00-00-00-00-00

[24]00-00-00-00-06-04-00-00-10-02-66-4C-FA-7F-00-00-00-00-00-00-00-00-00-00

[24]00-00-00-00-06-08-00-00-10-02-66-4C-FA-7F-00-00-00-00-00-00-00-00-00-00

如下所示的程式執行後在控制檯上的輸出,我們可以看到當前執行緒ID是6(採用小端位元組序)。按照我們上面介紹的記憶體佈局,0-9這10位用來表示執行緒,由於三次輸出都是在同一個執行緒中進行的,所以這10位位元(紅色)是一致的(0000000110),對應的值位6,剛好是當前執行緒ID。10-15這6位(紫色)表示遞迴等級,解析出來值分別是0,1和2,與我們的程式正好吻合。

[0000 0][000 0000 0000] [0000 00][00 0000 0110]
[0000 0][000 0000 0000] [0000 01][00 0000 0110]
[0000 0][000 0000 0000] [0000 10][00 0000 0110]

我們在最裡層的lock語句中呼叫了Debugger的Break方法,所以程式會在這裡停下來。如果此時我們將當前程序的Dump抓下來,通過執行dumpheap -thinlock命令會將所有「瘦鎖」列出來,從輸出的巢狀等級(2)和dumpobj的顯式結果可以看出這個瘦鎖就是Foobar物件。

clip_image003

六、儲存雜湊碼

我們接下來採用類似的方式演示Object Header針對雜湊碼的快取。如下面的程式碼片段所示,我們建立了上面定義的Foobar物件,在將其記憶體位元組列印出來之前,我們先將其GetHashCode方法返回的雜湊碼列印來。

var foobar = new Foobar();
var hashCode = foobar.GetHashCode();
PrintHashCode(hashCode);
BytesPrinter.PrintBytes(foobar);
static void PrintHashCode(int hashCode)
{
    var bytes = BitConverter.GetBytes(hashCode);
    Console.WriteLine($"Hash Code: {BitConverter.ToString(bytes)}");
}

從下面的輸出可以看出整個Object Header的內容應該和雜湊碼是有關係,因為至少可以看到前面3個位元組內容(9D-0D-3C)的完全一致的,但是為什麼最後一個位元組不同呢?

Hash Code: 9D-0D-3C-03

[24]00-00-00-00-9D-0D-3C-0F-10-86-78-E0-FA-7F-00-00-00-00-00-00-00-00-00-00

再次回到上面的描述,在第二種用於儲存雜湊碼的場景中,Object Header利用低26位來儲存雜湊,所以我們按照如下的方式將其低26位提取出來後就會發現對應的值就是雜湊碼。在看前面的6位,BIT_SBLK_IS_HASH_OR_SYNCBLKINDEXBIT_SBLK_IS_HASHCODE位均為1,這樣就可以確定後26位儲存的就是雜湊碼了。

0F 3C 0D 9D

00001111 00111100 00001101 10011101

00000011 00111100 00001101 10011101

03 3C 0D 9D

由於Object型別的GetHashCode方法的返回型別為Int32,如果我們重寫了這個方法,就可能導致ObjectHeader無法使用26位來存放雜湊值。比如我們將重寫了演示範例所用的Foobar型別,讓重寫的GetHashCode返回Int32.MaxValue。

public class Foobar
{
    public override int GetHashCode() => int.MaxValue;
}

很顯然Foobar物件的雜湊碼就無法儲存在Object Header中,如下的輸出體現了這一點。其實不管計算出來的雜湊碼能否使用26個位元來表示,只要型別重寫了GetHashCode方法且沒有直接返回base.GetHashCode(),使用Object Header來快取雜湊碼的策略就會失效。這一點告訴我們:當我們需要試圖去重寫某個類的GetHashCode方法,先考慮一下這個型別是否應該定義成結構體。

Hash Code: FF-FF-FF-7F

[24]00-00-00-00-00-00-00-00-70-27-7A-E0-FA-7F-00-00-00-00-00-00-00-00-00-00

七、儲存SyncBlock Index

我們使用如下的程式碼來演示Object Header針對SyncBlock Index的儲存。在將Foobar物件建立出來後,我們先呼叫其GetHashCode方法,並在針對該物件的lock上下文中完成針對記憶體位元組的輸出。

var foobar = new Foobar();
foobar.GetHashCode();
lock (foobar)
{
    BytesPrinter.PrintBytes(foobar);
    Debugger.Break();
}
public class Foobar{}

如下所示的是程式執行後的輸出結果,紅色標註的正是儲存SyncBlock Index的Object Header的內容。

[24]00-00-00-00-0F-00-00-08-20-BD-87-E0-FA-7F-00-00-00-00-00-00-00-00-00-00

我們按照與上面一樣的方式將這4個位元組轉換成二進位制,可以確定BIT_SBLK_IS_HASH_OR_SYNCBLKINDEX和BIT_SBLK_IS_HASHCODE位分別為1和0,所以可以確定低26位儲存的就是SyncBlock Index,對應的值位15(0b111)。

08 00 00 0F

00001000 00000000 00000000 00001111

我們在lock上下文中同樣呼叫了Debugger的Break方法,所以程式會在這裡停下來。如果此時我們將當前程序的Dump抓下來,通過執行syncblk將正在被使用的SyncBlock顯式出來,唯一的那個的Index正是15

clip_image005