使用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

原文鏈接: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,歡迎大家加入。

抽獎送書活動預熱!!!

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章