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

譯者注

這是在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
原文鏈接://minidump.net/writing-a-net-profiler-in-c-part-1-d3978aae9b12
項目鏈接://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編譯所分析的應用程序方法時,會引發一些分析的事件,比如JITCompilationStartedJITCompilationStartedJITCompilationStarted等等。這些事件都會調用分析器的回調方法,而由於分析器是.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等等了。另外,我們當前的解決方案只能使用靜態方法,如果能有一些可以使用實例方法的東西就太好了。在本系列的下一篇文章中,我們將看到如何編寫一個源生成器來為我們完成所有枯燥無聊的工作。