使用C#編寫一個.NET分析器(一)

語言: CN / TW / HK

譯者注

這是在Datadog公司任職的Kevin Gosse大佬使用C#編寫.NET分析器的系列文章之一,在國內只有很少很少的人瞭解和研究.NET分析器,它常被用於APM(應用效能診斷)、IDE、診斷工具中,比如Datadog的APM,Visual Studio的分析器以及Rider和Reshaper等等。之前只能使用C++編寫,自從.NET NativeAOT釋出以後,使用C#編寫變為可能。

筆者最近也在嘗試開發一個執行時方法注入的工具,歡迎熟悉MSIL 、PE Metadata 佈局、CLR 原始碼、CLR Profiler API的大佬,或者對這個感興趣的朋友留聯絡方式或者在公眾號留言,一起交流學習。

原作者:Kevin Gosse

原文連結: https://minidump.net/writing-a-net-profiler-in-c-part-1-d3978aae9b12

專案連結: https://github.com/kevingosse/ManagedDotnetProfiler

簡介

.NET具有非常強大的分析器API(Profiler API,它類似於Java Agent提供的API,但能做的事情比Java Agent多),我們可以通過它密切的監視.NET執行時、在程式執行期間動態的重寫方法、在任意時間點遍歷執行緒呼叫棧等等。但是學習如果使用該API的入門成本非常高。

第一個原因是,你必須要你充分了解.NET元資料系統以及工作原理才能實現一些分析器功能。

第二個原因是,它所有的文件和示例都是使用C++編寫的,而且目前也沒有C#的示例。

從理論上來說,大多數語言都可以來編寫.NET分析器。例如, 這裡有人使用Rust的Demo 。使用C#幾乎是不可能的,如果使用C#和.NET編寫一個Profiler,它將與分析的應用程式同事執行,這會導致一些問題:

  • 由於分析器是一個.NET庫,因此它最終會分析自身。列如,當JIT編譯所分析的應用程式方法時,會引發一些分析的事件,比如 JITCompilationStarted JITCompilationStartedJITCompilationStarted 等等。這些事件都會呼叫分析器的回撥方法,而由於分析器是.NET庫,所以也需要進行編譯,又會產生上面的事件,你應該明白我的觀點。
  • 即使你設法找到了該問題的修復方法,還有一個更實際的問題:在執行時初始化的過程中,分析器被很早的載入,而這時系統還沒有準備好執行.NET程式碼。

我一直覺得這很可惜,因為C#是所有C#開發人員最熟悉的開發語言。幸運的是,現在情況已經改變了。

我已經在 之前的一篇文章 中提到過,微軟正在積極的研究Native AOT。這個工具允許我們將.NET庫編譯Native的獨立庫。 獨立 這是關鍵:因為它帶有自己的執行時(自己的GC、自己的執行緒池、自己的型別系統....),所以可以將它載入到程序中,看起來和C++、Rust任何Native庫一樣。這意味我們可以使用Native AOT工具和C#語言來編寫一個.NET分析器。

讓我們開始

學習如果編寫.NET分析器,你可以參考 Christophe Nasarre 編寫的文章。簡而言之,我們需要公開一個返回IClassFactory例項的DllGetClassObject方法(熟悉微軟COM程式設計的朋友是不是感覺似曾相識?)。然後.NET Runtime將呼叫ClassFactory上的CreateInstance方法,該方法將返回一個 ICorProfilerCallback 例項(或者後面新增的ICorProfilerCallback2,ICorProfilerCallback3,... ,這取決於我們希望支援哪個版本的Profiler API),最後但並非最不重要的是,.NET Runtime將使用一個IUnknown引數呼叫該例項上的Initialize方法,我們可以使用它來獲取我們需要查詢Profiler API 的 ICorProfilerInfo (或 ICorProfilerInfo2,ICorProfilerInfo3,...)的例項。

話不多說。讓我們從第一步開始: 匯出 DllGetClassObject 方法。首先我們建立一個。NET 6類庫專案,並新增對 Microsoft.DotNet.ILCompiler 引用,使用 7.0.0-preview.* 版本。然後,我們使用 DllGetClassObject 方法建立一個 DllMain 類(名稱並不重要)。我們還用一個 UnmanagedCallersOnly 屬性裝飾這個方法,以指示NativeAOT工具鏈匯出該方法。

using System;
using System.Runtime.InteropServices;

namespace ManagedDotnetProfiler;

public class DllMain
{
    [UnmanagedCallersOnly(EntryPoint = "DllGetClassObject")]
    public static unsafe int DllGetClassObject(Guid* rclsid, Guid* riid, IntPtr* ppv)
    {
        Console.WriteLine("Hello from the profiling API");

        return 0;
    }
}

然後我們使用 dotnet publish 命令,並且帶上 /p:NativeLib=Shared 來發佈一個Native庫。

dotnet publish /p:NativeLib=Shared /p:SelfContained=true -r win-x64 -c Release

輸出是一個.dll檔案(在linux上會是一個.so檔案)。為了測試一切正常工作,我們可以啟動任何.NET控制檯應用在設定正確的環境變數後:

set CORECLR_ENABLE_PROFILING=1  # 啟用分析器
set CORECLR_PROFILER={B3A10128-F10D-4044-AB27-A799DB8B7E4F} # 分析器 COM Guid
set CORECLR_PROFILER_PATH=C:\git\ManagedDotnetProfiler\ManagedDotnetProfiler\bin\Release\net6.0\win-x64\publish\ManagedDotnetProfiler.dll # 分析器.dll路徑

CORECLR_ENABLE_PROFILING指示執行庫載入分析器。CORECLR_PROFILER 是唯一標識分析器的 GUID (現在任何值都可以)。CORECLR_PROFILER_ ATH是我們用NativeAOT釋出的 dll的路徑。如果一切正常,你應該看到在載入目標應用程式期間顯示的訊息:

C:\console\bin\Debug\net6.0>console.exe  
Hello from the profiling API  
Hello, World!

很好,但是現在還沒有什麼用。如何編寫一個真正的分析器?現在我們需要了解如何公開 IClassFactory 的例項。

公開一個C++介面(類似的行為)

MSDN 文件指出 IClassFactory 是一個介面。但是"介面"在C++和C#中意味著不同的東西,所以我們不能僅僅在我們的.NET程式碼中定義一個介面,然後收工。

事實上,介面的概念在C++中並不存在。實際上,它只是指定一個只包含純虛擬函式的抽象類。因此,我們需要構建和公開一個看起來像C++抽象類的物件。為此,我們需要理解 vtable 的概念。

假設我們有一個帶有單個方法 DoSomething 的介面 IInterface,以及兩個實現ClassA和ClassB。因為ClassA和ClassB都可以宣告它們自己的DoSomething實現,所以當給定 IInterface例項的指標時,執行時需要間接的知道應該呼叫哪個實現。這種間接方式稱為虛表或 vtable。

按照約定,當類實現虛方法時,C++編譯器在物件的開頭設定一個隱藏欄位。該隱藏欄位包含一個指向vtable的指標。vtable是一個記憶體塊,按照宣告的順序包含每個虛方法實現的地址。當呼叫虛方法時,執行時將首先獲取vtable,然後使用它獲取實現的地址。

vtable有更多的特性,例如處理多重繼承,但是我們不需要了解這些。

總而言之,要建立一個可供C++執行時使用的IClassFactory物件,我們需要分配一塊記憶體來儲存函式的地址。這是我們的vtable。然後,我們需要另一塊記憶體,其中包含一個指向 vtable 的指標。如下圖所示:

為了簡單的實現它,我們可以將例項和 vtable 合併到一個記憶體塊中:

那麼它在C#中是什麼樣子的呢?首先,我們為 IClassFactory 介面中的每個函式宣告一個靜態方法,並打上UnmanagedCallersOnly的特性:

[UnmanagedCallersOnly]
    public static unsafe int QueryInterface(IntPtr self, Guid* guid, IntPtr* ptr)
    {
        Console.WriteLine("QueryInterface");
        *ptr = IntPtr.Zero;
        return 0;
    }

    [UnmanagedCallersOnly]
    public static int AddRef(IntPtr self)
    {
        Console.WriteLine("AddRef");
        return 1;
    }

    [UnmanagedCallersOnly]
    public static int Release(IntPtr self)
    {
        Console.WriteLine("Release");
        return 1;
    }

    [UnmanagedCallersOnly]
    public static unsafe int CreateInstance(IntPtr self, IntPtr outer, Guid* guid, IntPtr* instance)
    {
        Console.WriteLine("CreateInstance");
        *instance = IntPtr.Zero;
        return 0;
    }

    [UnmanagedCallersOnly]
    public static int LockServer(IntPtr self, bool @lock)
    {
        return 0;
    }

然後,在DllGetClassObject中,我們分配用於儲存指向vtable(我們的假例項)和vtable本身的指標的記憶體塊。由於此記憶體將由本機程式碼使用,因此必須確保它不會被垃圾收集器移動。我們可以宣告一個IntPtr陣列並固定它,但是我更喜歡使用NativeMemory。分配GC不會跟蹤的記憶體。要獲取靜態方法的地址,我們可以將它們轉換為函式指標,然後轉換為IntPtr。最後,我們通過函式的ppv引數返回記憶體塊的地址。

[UnmanagedCallersOnly(EntryPoint = "DllGetClassObject")]
    public static unsafe int DllGetClassObject(Guid* rclsid, Guid* riid, IntPtr* ppv)
    {
        Console.WriteLine("Hello from the profiling API");

        // 為vtable指標+指向5個方法的指標分配記憶體塊
        var chunk = (IntPtr*)NativeMemory.Alloc(1 + 5, (nuint)IntPtr.Size);

        // 指向 vtable
        *chunk = (IntPtr)(chunk + 1);

        // 指向介面的每個方法的指標
        *(chunk + 1) = (IntPtr)(delegate* unmanaged<IntPtr, Guid*, IntPtr*, int>)&QueryInterface;
        *(chunk + 2) = (IntPtr)(delegate* unmanaged<IntPtr, int>)&AddRef;
        *(chunk + 3) = (IntPtr)(delegate* unmanaged<IntPtr, int>)&Release;
        *(chunk + 4) = (IntPtr)(delegate* unmanaged<IntPtr, IntPtr, Guid*, IntPtr*, int>)&CreateInstance;
        *(chunk + 5) = (IntPtr)(delegate* unmanaged<IntPtr, bool, int>)&LockServer;
        
        *ppv = (IntPtr)chunk;
        
        return HResult.S_OK;
    }

在編譯和測試之後,我們可以看到我們的假 IClassFactory 的 CreateInstance 方法如預期的那樣被呼叫:

C:\console\bin\Debug\net6.0> .\console.exe  
Hello from the profiling API  
CreateInstance  
Release  
Hello, World!

征程才剛剛開始

下一步是實現CreateInstance方法。如前所述,我們希望返回ICorProfilerCallback的例項。為了實現這個介面,我們可以像對 IClassFactory 那樣做同樣的事情,但是 ICorProfilerCallback包含近70個方法!要編寫的樣板程式碼太多了,更不用說 ICorProfilerCallback2、 ICorProfilerCallback3等等了。另外,我們當前的解決方案只能使用靜態方法,如果能有一些可以使用例項方法的東西就太好了。在本系列的下一篇文章中,我們將看到如何編寫一個源生成器來為我們完成所有枯燥無聊的工作。