這是在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
在第一部分中,我們瞭解瞭如何使用NativeAOT
讓我們用C#編寫一個分析器,以及如何暴露一個偽造的COM
物件來使用分析API。在第二部分中,我們改進了解決方案,使用實體方法替代靜態方法。現在我們知道了如何與分析API進行互動,我們將編寫一個原始碼生成器,自動生成實現ICorProfilerCallback
介面中宣告的70多個方法所需的樣板程式碼。
首先,我們需要手動將ICorProfilerCallback
介面轉換為C#。從技術上講,本可以從C++標頭檔案中自動生成這些程式碼,但是相同的C++程式碼在C#中可以用不同的方式翻譯,因此瞭解函數的目的以正確語意進行轉換十分重要。
以JITInlining
函數為實際例子。在C++中的原型是:
HRESULT JITInlining(FunctionID callerId, FunctionID calleeId, BOOL *pfShouldInline);
一個簡單的C#版本轉換可能是:
HResult JITInlining(FunctionId callerId, FunctionId calleeId, in bool pfShouldInline);
但是,如果我們檢視函數的檔案,我們可以瞭解到pfShouldInline是一個應由函數自身設定的值。所以我們應該使用out關鍵字:
Result JITInlining(FunctionId callerId, FunctionId calleeId, out bool pfShouldInline);
在其他情況下,我們會根據意圖使用in或ref關鍵字。這就是為什麼我們無法完全自動化這個過程。
在將介面轉換為C#之後,我們可以繼續建立原始碼生成器。請注意,我並不打算編寫一個最先進的原始碼生成器,主要原因是API非常複雜(是的,這話來自於一個教你如何用C#編寫分析器的人),你可以檢視Andrew Lock的精彩文章來了解如何編寫高階原始碼生成器。
要建立原始碼生成器,我們在解決方案中新增一個針對netstandard2.0
的類庫專案,並新增對Microsoft.CodeAnalysis.CSharp
和Microsoft.CodeAnalysis.Analyzers
的參照:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<IsRoslynComponent>true</IsRoslynComponent>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
接下來,我們新增一個實現ISourceGenerator
介面的類,並用[Generator]
屬性進行修飾:
[Generator]
public class NativeObjectGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
}
public void Execute(GeneratorExecutionContext context)
{
}
}
我們要做的第一件事是生成一個[NativeObject]
屬性。我們將用它來修飾我們想要在原始碼生成器上執行的介面。我們使用RegisterForPostInitialization
在管道早期執行這段程式碼:
[Generator]
public class NativeObjectGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
context.RegisterForPostInitialization(EmitAttribute);
}
public void Execute(GeneratorExecutionContext context)
{
}
private void EmitAttribute(GeneratorPostInitializationContext context)
{
context.AddSource("NativeObjectAttribute.g.cs", """
using System;
[AttributeUsage(AttributeTargets.Interface, Inherited = false, AllowMultiple = false)]
internal class NativeObjectAttribute : Attribute { }
""");
}
}
現在我們需要註冊一個ISyntaxContextReceiver
來檢查型別並檢測哪些型別被我們的 [NativeObject]
屬性修飾。
public class SyntaxReceiver : ISyntaxContextReceiver
{
public List<INamedTypeSymbol> Interfaces { get; } = new();
public void OnVisitSyntaxNode(GeneratorSyntaxContext context)
{
if (context.Node is InterfaceDeclarationSyntax classDeclarationSyntax
&& classDeclarationSyntax.AttributeLists.Count > 0)
{
var symbol = (INamedTypeSymbol)context.SemanticModel.GetDeclaredSymbol(classDeclarationSyntax);
if (symbol.GetAttributes().Any(a => a.AttributeClass.ToDisplayString() == "NativeObjectAttribute"))
{
Interfaces.Add(symbol);
}
}
}
}
基本上,語法接收器將被用於存取語法樹中的每個節點。我們檢查該節點是否是一個介面宣告,如果是,我們檢查屬性以查詢NativeObjectAttribute
。可能有很多事情都可以改進,特別是確認它是否是我們的NativeObjectAttribute
,但我們認為對於我們的目的來說這已經足夠好了。
在原始碼生成器初始化期間,需要註冊語法接收器:
public void Initialize(GeneratorInitializationContext context)
{
context.RegisterForPostInitialization(EmitAttribute);
context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
}
最後,在Execute
方法中,我們獲取儲存在語法接收器中的介面列表,併為其生成程式碼:
public void Execute(GeneratorExecutionContext context)
{
if (!(context.SyntaxContextReceiver is SyntaxReceiver receiver))
{
return;
}
foreach (var symbol in receiver.Interfaces)
{
EmitStubForInterface(context, symbol);
}
}
對於EmitStubForInterface方法,我們可以使用模板引擎,但是我們將依賴於一個經典的StringBuilder和Replace呼叫。
首先,我們建立我們的模板:
var sourceBuilder = new StringBuilder("""
using System;
using System.Runtime.InteropServices;
namespace NativeObjects
{
{visibility} unsafe class {typeName} : IDisposable
{
private {typeName}({interfaceName} implementation)
{
const int delegateCount = {delegateCount};
var obj = (IntPtr*)NativeMemory.Alloc((nuint)2 + delegateCount, (nuint)IntPtr.Size);
var vtable = obj + 2;
*obj = (IntPtr)vtable;
var handle = GCHandle.Alloc(implementation);
*(obj + 1) = GCHandle.ToIntPtr(handle);
{functionPointers}
Object = (IntPtr)obj;
}
public IntPtr Object { get; private set; }
public static {typeName} Wrap({interfaceName} implementation) => new(implementation);
public static implicit operator IntPtr({typeName} stub) => stub.Object;
~{typeName}()
{
Dispose();
}
public void Dispose()
{
if (Object != IntPtr.Zero)
{
NativeMemory.Free((void*)Object);
Object = IntPtr.Zero;
}
GC.SuppressFinalize(this);
}
private static class Exports
{
{exports}
}
}
}
""");
如果你對某些部分不理解,請記得檢視前一篇文章。這裡唯一的新內容是解構函式和Dispose
方法,我們在其中呼叫NativeMemory.Free
來釋放為該物件分配的記憶體。接下來,我們需要填充所有的模板部分:{visibility}
、{typeName}
、{interfaceName}
、{delegateCount}
、{functionPointers}
和 {exports}
。
首先是簡單的部分:
var interfaceName = symbol.ToString();
var typeName = $"{symbol.Name}";
var visibility = symbol.DeclaredAccessibility.ToString().ToLower();
// To be filled later
int delegateCount = 0;
var exports = new StringBuilder();
var functionPointers = new StringBuilder();
對於一個介面MyProfiler.ICorProfilerCallback
,我們將生成一個型別為NativeObjects.ICorProfilerCallback
的包裝器。這就是為什麼我們將完全限定名儲存在interfaceName
(= MyProfiler.ICorProfilerCallback
)中,而僅將型別名儲存在typeName
(= ICorProfilerCallback
)中。
接下來我們想要生成匯出列表及其函數指標。我希望原始碼生成器支援繼承,以避免程式碼重複,因為ICorProfilerCallback13
實現了ICorProfilerCallback12
,而ICorProfilerCallback12
本身又實現了ICorProfilerCallback11
,依此類推。因此我們提取目標介面繼承自的介面列表,併為它們中的每一個提取方法:
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: Inspect the method
}
}
對於一個QueryInterface(in Guid guid, out IntPtr ptr)
方法,我們將生成的匯出看起來像這樣:
[UnmanagedCallersOnly]
public static int QueryInterface(IntPtr* self, Guid* __arg1, IntPtr* __arg2)
{
var handleAddress = *(self + 1);
var handle = GCHandle.FromIntPtr(handleAddress);
var obj = (IUnknown)handle.Target;
var result = obj.QueryInterface(*__arg1, out var __local2);
*__arg2 = __local2;
return result;
}
由於這些方法是實體方法,我們新增了IntPtr* self
引數。另外,如果託管介面中的函數帶有in/out/ref
關鍵字修飾,我們將引數宣告為指標型別,因為UnmanagedCallersOnly
方法不支援in/out/ref
。
生成匯出所需的程式碼為:
var parameterList = new StringBuilder();
parameterList.Append("IntPtr* self");
foreach (var parameter in method.Parameters)
{
var isPointer = parameter.RefKind == RefKind.None ? "" : "*";
parameterList.Append($", {parameter.Type}{isPointer} __arg{parameter.Ordinal}");
}
exports.AppendLine($" [UnmanagedCallersOnly]");
exports.AppendLine($" public static {method.ReturnType} {method.Name}({parameterList})");
exports.AppendLine($" {{");
exports.AppendLine($" var handle = GCHandle.FromIntPtr(*(self + 1));");
exports.AppendLine($" var obj = ({interfaceName})handle.Target;");
exports.Append($" ");
if (!method.ReturnsVoid)
{
exports.Append("var result = ");
}
exports.Append($"obj.{method.Name}(");
for (int i = 0; i < method.Parameters.Length; i++)
{
if (i > 0)
{
exports.Append(", ");
}
if (method.Parameters[i].RefKind == RefKind.In)
{
exports.Append($"*__arg{i}");
}
else if (method.Parameters[i].RefKind is RefKind.Out)
{
exports.Append($"out var __local{i}");
}
else
{
exports.Append($"__arg{i}");
}
}
exports.AppendLine(");");
for (int i = 0; i < method.Parameters.Length; i++)
{
if (method.Parameters[i].RefKind is RefKind.Out)
{
exports.AppendLine($" *__arg{i} = __local{i};");
}
}
if (!method.ReturnsVoid)
{
exports.AppendLine($" return result;");
}
exports.AppendLine($" }}");
exports.AppendLine();
exports.AppendLine();
對於函數指標,給定與前面相同的方法,我們希望建立:
*(vtable + 1) = (IntPtr)(delegate* unmanaged<IntPtr*, Guid*, IntPtr*>)&Exports.QueryInterface;
生成程式碼如下:
var sourceArgsList = new StringBuilder();
sourceArgsList.Append("IntPtr _");
for (int i = 0; i < method.Parameters.Length; i++)
{
sourceArgsList.Append($", {method.Parameters[i].OriginalDefinition} a{i}");
}
functionPointers.Append($" *(vtable + {delegateCount}) = (IntPtr)(delegate* unmanaged<IntPtr*");
for (int i = 0; i < method.Parameters.Length; i++)
{
functionPointers.Append($", {method.Parameters[i].Type}");
if (method.Parameters[i].RefKind != RefKind.None)
{
functionPointers.Append("*");
}
}
if (method.ReturnsVoid)
{
functionPointers.Append(", void");
}
else
{
functionPointers.Append($", {method.ReturnType}");
}
functionPointers.AppendLine($">)&Exports.{method.Name};");
delegateCount++;
我們在介面的每個方法都完成了這個操作後,我們只需替換模板中的值並新增生成的原始檔:
sourceBuilder.Replace("{typeName}", typeName);
sourceBuilder.Replace("{visibility}", visibility);
sourceBuilder.Replace("{exports}", exports.ToString());
sourceBuilder.Replace("{interfaceName}", interfaceName);
sourceBuilder.Replace("{delegateCount}", delegateCount.ToString());
sourceBuilder.Replace("{functionPointers}", functionPointers.ToString());
context.AddSource($"{symbol.ContainingNamespace?.Name ?? "_"}.{symbol.Name}.g.cs", sourceBuilder.ToString());
就這樣,我們的原始碼生成器現在準備好了。
要使用我們的原始碼生成器,我們可以宣告IUnknown
、IClassFactory
和ICorProfilerCallback
介面,並用[NativeObject]
屬性修飾它們:
[NativeObject]
public interface IUnknown
{
HResult QueryInterface(in Guid guid, out IntPtr ptr);
int AddRef();
int Release();
}
[NativeObject]
internal interface IClassFactory : IUnknown
{
HResult CreateInstance(IntPtr outer, in Guid guid, out IntPtr instance);
HResult LockServer(bool @lock);
}
[NativeObject]
public unsafe interface ICorProfilerCallback : IUnknown
{
HResult Initialize(IntPtr pICorProfilerInfoUnk);
// 70+ 多個方法,在這裡省略
}
然後我們實現IClassFactory
並呼叫NativeObjects.IClassFactory.Wrap
來建立本機包裝器並暴露我們的ICorProfilerCallback
範例:
public unsafe class ClassFactory : IClassFactory
{
private NativeObjects.IClassFactory _classFactory;
private CorProfilerCallback2 _corProfilerCallback;
public ClassFactory()
{
_classFactory = NativeObjects.IClassFactory.Wrap(this);
}
// The native wrapper has an implicit cast operator to IntPtr
public IntPtr Object => _classFactory;
public HResult CreateInstance(IntPtr outer, in Guid guid, out IntPtr instance)
{
Console.WriteLine("[Profiler] ClassFactory - CreateInstance");
_corProfilerCallback = new();
instance = _corProfilerCallback.Object;
return HResult.S_OK;
}
public HResult LockServer(bool @lock)
{
return default;
}
public HResult QueryInterface(in Guid guid, out IntPtr ptr)
{
Console.WriteLine("[Profiler] ClassFactory - QueryInterface - " + guid);
if (guid == KnownGuids.ClassFactoryGuid)
{
ptr = Object;
return HResult.S_OK;
}
ptr = IntPtr.Zero;
return HResult.E_NOTIMPL;
}
public int AddRef()
{
return 1; // TODO: 做實際的參照計數
}
public int Release()
{
return 0; // TODO: 做實際的參照計數
}
}
並在DllGetClassObject
中暴露它:
public class DllMain
{
private static ClassFactory Instance;
[UnmanagedCallersOnly(EntryPoint = "DllGetClassObject")]
public static unsafe int DllGetClassObject(void* rclsid, void* riid, nint* ppv)
{
Console.WriteLine("[Profiler] DllGetClassObject");
Instance = new ClassFactory();
*ppv = Instance.Object;
return 0;
}
}
最後,我們可以實現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 in next article
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;
}
// Stripped for brevity: the default implementation of all 70+ methods of the interface
// Automatically generated by the IDE
}
如果我們使用一個測試應用程式執行它,我們會發現這些功能能按預期工作:
[Profiler] DllGetClassObject
[Profiler] ClassFactory - CreateInstance
[Profiler] ICorProfilerCallback2 - QueryInterface
[Profiler] ICorProfilerCallback2 - Initialize
Hello, World!
在下一步中,我們將處理拼圖的最後一個缺失部分:實現ICorProfilerCallback.Initialize方法並獲取ICorProfilerInfo的範例。這樣我們就擁有了與效能分析器API實際互動所需的一切。
相信大家在開發中經常會遇到一些效能問題,苦於沒有有效的工具去發現效能瓶頸,或者是發現瓶頸以後不知道該如何優化。之前一直有讀者朋友詢問有沒有技術交流群,但是由於各種原因一直都沒建立,現在很高興的在這裡宣佈,我建立了一個專門交流.NET效能優化經驗的群組,主題包括但不限於:
如何找到.NET效能瓶頸,如使用APM、dotnet tools等工具
.NET框架底層原理的實現,如垃圾回收器、JIT等等
如何編寫高效能的.NET程式碼,哪些地方存在效能陷阱
希望能有更多志同道合朋友加入,分享一些工作中遇到的.NET效能問題和寶貴的效能分析優化經驗。目前一群已滿,現在開放二群。
如果提示已經達到200人,可以加我微信,我拉你進群: lishi-wk
另外也建立了QQ群,群號: 687779078,歡迎大家加入。
感謝大家對我公眾號的支援與陪伴!為慶祝公眾號一週年,抽獎送出一些書籍,請大家關注公眾號後續推文!