使用C#編寫.NET分析器(完結)

2023-07-13 12:01:09

譯者注

這是在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-3-7d2c59fc017f

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

使用C#編寫.NET分析器-一:https://www.cnblogs.com/InCerry/p/writing-a-net-profiler-in-c-sharp-part-1.html
使用C#編寫.NET分析器-二:https://www.cnblogs.com/InCerry/p/writing-a-net-profiler-in-c-sharp-part-2.html
使用C#編寫.NET分析器-三:https://www.cnblogs.com/InCerry/p/writing-a-net-profiler-in-c-sharp-part-3.html

正文

在第1部分,我們瞭解瞭如何使用NativeAOT讓我們用C#編寫效能分析器,以及如何暴露一個虛假的COM物件來使用效能分析API。在第2部分,我們完善了方案以使用實體方法而不是靜態方法。在第3部分,我們使用源生成器自動化了流程。目前,我們具有暴露ICorProfilerCallback範例所需的一切。然而,為了編寫效能分析器,我們還需要能夠呼叫ICorProfilerInfo的方法,這將是本部分的主題。

提醒一下,我們最後得到了以下實現的ICorProfilerCallback:

public unsafe class CorProfilerCallback2 : ICorProfilerCallback2
{
    private static readonly Guid ICorProfilerCallback2Guid = Guid.Parse("8a8cc829-ccf2-49fe-bbae-0f022228071a");

    private readonly NativeObjects.ICorProfilerCallback2 _corProfilerCallback2;

    public CorProfilerCallback2()
    {
        _corProfilerCallback2 = NativeObjects.ICorProfilerCallback2.Wrap(this);
    }

    public IntPtr Object => _corProfilerCallback2;

    public HResult Initialize(IntPtr pICorProfilerInfoUnk)
    {
        Console.WriteLine("[Profiler] ICorProfilerCallback2 - Initialize");

        // TODO: To be implemented

        return HResult.S_OK;
    }

    public HResult QueryInterface(in Guid guid, out IntPtr ptr)
    {
        if (guid == ICorProfilerCallback2Guid)
        {
            Console.WriteLine("[Profiler] ICorProfilerCallback2 - QueryInterface");

            ptr = Object;
            return HResult.S_OK;
        }

        ptr = IntPtr.Zero;
        return HResult.E_NOTIMPL;
    }

    // 為了簡潔起見,這裡省略了介面中所有70多個方法的預設實現。
}

當呼叫Initialize時,我們會收到一個IUnknown的範例。我們需要在其上呼叫QueryInterface以檢索到ICorProfilerInfo的範例。

要將物件暴露給本機程式碼,我們已經看到如何建立一個虛假的vtable。要使用本地物件,正好相反:我們需要讀取它們的vtable以獲得方法的地址,然後呼叫它們。

讓我們編寫一個包裝器,用於從IUnknown的範例中呼叫方法。因為虛擬物件將其vtable的地址儲存為第一個欄位,我們只需要讀取物件位置處的一個指標即可獲得該vtable。我們將這個邏輯提取到我們的包裝器的一個屬性中,以方便使用:

public unsafe struct Unknown
{
    private readonly IntPtr _self;

    public Unknown(IntPtr self)
    {
        _self = self;
    }

    private IntPtr* VTable => (IntPtr*)*(IntPtr*)_self;

    // TODO: 實現 QueryInterface/AddRef/Release
}

注意,我們將該包裝器宣告為結構(struct),因為它不需要任何狀態。最後,這只是一個帶有一些嵌入式邏輯的精美指標。

要呼叫這些方法,我們從vtable的相應槽中檢索它們的地址,然後將它們轉換為函數指標。然後我們只需要呼叫它們,確保將物件的地址作為第一個引數傳遞,因為它們是實體方法:

public HResult QueryInterface(in Guid guid, out IntPtr ptr)
{
    var func = (delegate* unmanaged<IntPtr, in Guid, out IntPtr, HResult>)(*VTable);

    return func(_self, in guid, out ptr);
}

public int AddRef()
{
    var func = (delegate* unmanaged<IntPtr, int>)(*(VTable + 1));

    return func(_self);
}

public int Release()
{
    var func = (delegate* unmanaged<IntPtr, int>)(*(VTable + 2));

    return func(_self);
}

我們的包裝器可以直接在ICorProfilerCallback.Initialize中使用,以檢索ICorProfilerInfo的範例:

public HResult Initialize(IntPtr pICorProfilerInfoUnk)
{
    Console.WriteLine("[Profiler] ICorProfilerCallback2 - Initialize");

    var iCorProfilerInfo3Guid = Guid.Parse("B555ED4F-452A-4E54-8B39-B5360BAD32A0");

    var unknown = new Unknown(pICorProfilerInfoUnk);

    var result = unknown.QueryInterface(iCorProfilerInfo3Guid, out var ptr);

    if (result == HResult.S_OK)
    {
        Console.WriteLine($"[Profiler] Successfully retrieved an instance of ICorProfilerInfo3: {ptr:x2}");
    }
    else
    {
        Console.WriteLine($"[Profiler] Failed with error code: {result:x2}");
    }

    return HResult.S_OK;
}

要實際使用我們的ICorProfilerInfo範例,我們需要編寫相同型別的包裝器。但是,由於該介面宣告了數十個方法,我們不會手動操作,而是將擴充套件我們在第3部分編寫的原始碼生成器。

我們的原始碼生成器將填充以下模板:

public unsafe struct {invokerName}
      {
          private readonly IntPtr _self;

          public {invokerName}(IntPtr self)
          {
              _self = self;
          }

          private IntPtr* VTable => (IntPtr*)*(IntPtr*)_self;

          {invokerFunctions}
      }

我們將所有這些內容實現在上一篇文章中描述的EmitStubForInterface(GeneratorExecutionContext context, INamedTypeSymbol symbol)方法中。

對於包裝器的名稱,我們只需使用符號的名稱並追加一個字尾:

var invokerName = $"{symbol.Name}Invoker";

然後,我們需要填充函數列表。我們宣告一個StringBuilder並開始遍歷目標介面及其父介面的所有函數:

var invokerFunctions = new StringBuilder();

var interfaceList = symbol.AllInterfaces.ToList();
interfaceList.Reverse();
interfaceList.Add(symbol);

foreach (var @interface in interfaceList)
{
    foreach (var member in @interface.GetMembers())
    {
        if (member is not IMethodSymbol method)
        {
            continue;
        }

        // TODO
    }
}

對於每個方法,我們首先編寫簽名:

invokerFunctions.Append($"public {method.ReturnType} {method.Name}(");


for (int i = 0; i < method.Parameters.Length; i++)
{
    if (i > 0)
    {
        invokerFunctions.Append(", ");
    }

    var refKind = method.Parameters[i].RefKind;

    switch (refKind)
    {
        case RefKind.In:
            invokerFunctions.Append("in ");
            break;
        case RefKind.Out:
            invokerFunctions.Append("out ");
            break;
        case RefKind.Ref:
            invokerFunctions.Append("ref ");
            break;
    }

    invokerFunctions.Append($"{method.Parameters[i].Type} a{i}");
}

invokerFunctions.AppendLine(")");

請注意,所有引數均被重新命名為a1、a2、a3...,以避免在原始方法的引數具有奇怪名稱時可能發生的衝突。
現在我們可以生成方法的主體,從vtable中獲取方法的地址,並用預期引數呼叫它:

invokerFunctions.AppendLine("{");
invokerFunctions.Append("var func = (delegate* unmanaged[Stdcall]<IntPtr");

for (int i = 0; i < method.Parameters.Length; i++)
{
    invokerFunctions.Append(", ");

    var refKind = method.Parameters[i].RefKind;

    switch (refKind)
    {
        case RefKind.In:
            invokerFunctions.Append("in ");
            break;
        case RefKind.Out:
            invokerFunctions.Append("out ");
            break;
        case RefKind.Ref:
            invokerFunctions.Append("ref ");
            break;
    }

    invokerFunctions.Append(method.Parameters[i].Type);
}

invokerFunctions.AppendLine($", {method.ReturnType}>)*(VTable + {delegateCount});");

if (method.ReturnType.SpecialType != SpecialType.System_Void)
{
    invokerFunctions.Append("return ");
}

invokerFunctions.Append("func(_self");

for (int i = 0; i < method.Parameters.Length; i++)
{
    invokerFunctions.Append($", ");

    var refKind = method.Parameters[i].RefKind;

    switch (refKind)
    {
        case RefKind.In:
            invokerFunctions.Append("in ");
            break;
        case RefKind.Out:
            invokerFunctions.Append("out ");
            break;
        case RefKind.Ref:
            invokerFunctions.Append("ref ");
            break;
    }

    invokerFunctions.Append($"a{i}");
}

invokerFunctions.AppendLine(");");
invokerFunctions.AppendLine("}");

這有很多程式碼,但主要是列舉引數以生成方法呼叫,以及在方法返回void時進行特殊處理。

最後但同樣重要的是,我們替換模板中的預留位置:

sourceBuilder.Replace("{invokerFunctions}", invokerFunctions.ToString());  
sourceBuilder.Replace("{invokerName}", invokerName);

有了這個,我們可以回到ICorProfilerCallback.Initialize的實現,並用我們自動生成的實現替換Unknown

public HResult Initialize(IntPtr pICorProfilerInfoUnk)
  {
      Console.WriteLine("[Profiler] ICorProfilerCallback2 - Initialize");

      var iCorProfilerInfo3Guid = Guid.Parse("B555ED4F-452A-4E54-8B39-B5360BAD32A0");

      var unknown = new NativeObjects.IUnknownInvoker(pICorProfilerInfoUnk);

      var result = unknown.QueryInterface(iCorProfilerInfo3Guid, out var ptr);

      if (result == HResult.S_OK)
      {
          Console.WriteLine($"[Profiler] Successfully retrieved an instance of ICorProfilerInfo3: {ptr:x2}");

          var corProfilerInfo = new NativeObjects.ICorProfilerInfo3Invoker(ptr);
          // Can start interacting with ICorProfilerInfo
      }
      else
      {
          Console.WriteLine($"[Profiler] Failed with error code: {result:x2}");
      }

      return HResult.S_OK;
  }

有了這些,我們終於擁有了編寫探查器所需的所有拼圖碎片。

作為提醒,所有程式碼均可在GitHub上找到。

.NET效能優化交流群

相信大家在開發中經常會遇到一些效能問題,苦於沒有有效的工具去發現效能瓶頸,或者是發現瓶頸以後不知道該如何優化。之前一直有讀者朋友詢問有沒有技術交流群,但是由於各種原因一直都沒建立,現在很高興的在這裡宣佈,我建立了一個專門交流.NET效能優化經驗的群組,主題包括但不限於:

  • 如何找到.NET效能瓶頸,如使用APM、dotnet tools等工具

  • .NET框架底層原理的實現,如垃圾回收器、JIT等等

  • 如何編寫高效能的.NET程式碼,哪些地方存在效能陷阱

希望能有更多志同道合朋友加入,分享一些工作中遇到的.NET效能問題和寶貴的效能分析優化經驗。目前一群已滿,現在開放二群。

如果提示已經達到200人,可以加我微信,我拉你進群: lishi-wk

另外也建立了QQ群,群號: 687779078,歡迎大家加入。

抽獎送書活動預熱!!!

感謝大家對我公眾號的支援與陪伴!為慶祝公眾號一週年,抽獎送出一些書籍,請大家關注公眾號後續推文!