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

2023-07-12 12:02:38

譯者注

這是在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.CSharpMicrosoft.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);
        }
    }

生成Native包裝器

對於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());

就這樣,我們的原始碼生成器現在準備好了。

使用生成的程式碼

要使用我們的原始碼生成器,我們可以宣告IUnknownIClassFactoryICorProfilerCallback介面,並用[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效能優化經驗的群組,主題包括但不限於:

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

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

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

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

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

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

抽獎送書活動預熱!!!

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