這是在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效能瓶頸,如使用APM、dotnet tools等工具
.NET框架底層原理的實現,如垃圾回收器、JIT等等
如何編寫高效能的.NET程式碼,哪些地方存在效能陷阱
希望能有更多志同道合朋友加入,分享一些工作中遇到的.NET效能問題和寶貴的效能分析優化經驗。目前一群已滿,現在開放二群。
如果提示已經達到200人,可以加我微信,我拉你進群: lishi-wk
另外也建立了QQ群,群號: 687779078,歡迎大家加入。
感謝大家對我公眾號的支援與陪伴!為慶祝公眾號一週年,抽獎送出一些書籍,請大家關注公眾號後續推文!