译者注
这是在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-1-d3978aae9b12
项目链接:https://github.com/kevingosse/ManagedDotnetProfiler
简介
.NET具有非常强大的分析器API(Profiler API,它类似于Java Agent提供的API,但能做的事情比Java Agent多),我们可以通过它密切的监视.NET运行时、在程序运行期间动态的重写方法、在任意时间点遍历线程调用栈等等。但是学习如果使用该API的入门成本非常高。
第一个原因是,你必须要你充分了解.NET元数据系统以及工作原理才能实现一些分析器功能。
第二个原因是,它所有的文档和示例都是使用C++编写的,而且目前也没有C#的示例。
从理论上来说,大多数语言都可以来编写.NET分析器。例如,这里有人使用Rust的Demo。使用C#几乎是不可能的,如果使用C#和.NET编写一个Profiler,它将与分析的应用程序同事运行,这会导致一些问题:
- 由于分析器是一个.NET库,因此它最终会分析自身。列如,当JIT编译所分析的应用程序方法时,会引发一些分析的事件,比如
JITCompilationStarted
、JITCompilationStarted
、JITCompilationStarted
等等。这些事件都会调用分析器的回调方法,而由于分析器是.NET库,所以也需要进行编译,又会产生上面的事件,你应该明白我的观点。 - 即使你设法找到了该问题的修复方法,还有一个更实际的问题:在运行时初始化的过程中,分析器被很早的加载,而这时系统还没有准备好运行.NET代码。
我一直觉得这很可惜,因为C#是所有C#开发人员最熟悉的开发语言。幸运的是,现在情况已经改变了。
我已经在之前的一篇文章中提到过,微软正在积极的研究Native AOT。这个工具允许我们将.NET库编译Native的独立库。独立这是关键:因为它带有自己的运行时(自己的GC、自己的线程池、自己的类型系统....),所以可以将它加载到进程中,看起来和C++、Rust任何Native库一样。这意味我们可以使用Native AOT工具和C#语言来编写一个.NET分析器。
让我们开始
学习如果编写.NET分析器,你可以参考Christophe Nasarre编写的文章。简而言之,我们需要公开一个返回IClassFactory实例的DllGetClassObject方法(熟悉微软COM编程的朋友是不是感觉似曾相识?)。然后.NET Runtime将调用ClassFactory上的CreateInstance方法,该方法将返回一个ICorProfilerCallback实例(或者后面新增的ICorProfilerCallback2,ICorProfilerCallback3,... ,这取决于我们希望支持哪个版本的Profiler API),最后但并非最不重要的是,.NET Runtime将使用一个IUnknown参数调用该实例上的Initialize方法,我们可以使用它来获取我们需要查询Profiler API 的 ICorProfilerInfo (或 ICorProfilerInfo2,ICorProfilerInfo3,...)的实例。
话不多说。让我们从第一步开始: 导出 DllGetClassObject 方法。首先我们创建一个。NET 6类库项目,并添加对Microsoft.DotNet.ILCompiler引用,使用7.0.0-preview.*
版本。然后,我们使用 DllGetClassObject 方法创建一个 DllMain 类(名称并不重要)。我们还用一个 UnmanagedCallersOnly属性装饰这个方法,以指示NativeAOT工具链导出该方法。
using System;
using System.Runtime.InteropServices;
namespace ManagedDotnetProfiler;
public class DllMain
{
[UnmanagedCallersOnly(EntryPoint = "DllGetClassObject")]
public static unsafe int DllGetClassObject(Guid* rclsid, Guid* riid, IntPtr* ppv)
{
Console.WriteLine("Hello from the profiling API");
return 0;
}
}
然后我们使用dotnet publish
命令,并且带上/p:NativeLib=Shared
来发布一个Native库。
dotnet publish /p:NativeLib=Shared /p:SelfContained=true -r win-x64 -c Release
输出是一个.dll文件(在linux上会是一个.so文件)。为了测试一切正常工作,我们可以启动任何.NET控制台应用在设定正确的环境变量后:
set CORECLR_ENABLE_PROFILING=1 # 启用分析器
set CORECLR_PROFILER={B3A10128-F10D-4044-AB27-A799DB8B7E4F} # 分析器 COM Guid
set CORECLR_PROFILER_PATH=C:\git\ManagedDotnetProfiler\ManagedDotnetProfiler\bin\Release\net6.0\win-x64\publish\ManagedDotnetProfiler.dll # 分析器.dll路径
CORECLR_ENABLE_PROFILING指示运行库加载分析器。CORECLR_PROFILER 是唯一标识分析器的 GUID (现在任何值都可以)。CORECLR_PROFILER_ ATH是我们用NativeAOT发布的 dll的路径。如果一切正常,你应该看到在加载目标应用程序期间显示的消息:
C:\console\bin\Debug\net6.0>console.exe
Hello from the profiling API
Hello, World!
很好,但是现在还没有什么用。如何编写一个真正的分析器?现在我们需要了解如何公开 IClassFactory 的实例。
公开一个C++接口(类似的行为)
MSDN 文档指出 IClassFactory 是一个接口。但是"接口"在C++和C#中意味着不同的东西,所以我们不能仅仅在我们的.NET代码中定义一个接口,然后收工。
事实上,接口的概念在C++中并不存在。实际上,它只是指定一个只包含纯虚函数的抽象类。因此,我们需要构建和公开一个看起来像C++抽象类的对象。为此,我们需要理解vtable的概念。
假设我们有一个带有单个方法 DoSomething 的接口 IInterface,以及两个实现ClassA和ClassB。因为ClassA和ClassB都可以声明它们自己的DoSomething实现,所以当给定 IInterface实例的指针时,运行时需要间接的知道应该调用哪个实现。这种间接方式称为虚表或 vtable。
按照约定,当类实现虚方法时,C++编译器在对象的开头设置一个隐藏字段。该隐藏字段包含一个指向vtable的指针。vtable是一个内存块,按照声明的顺序包含每个虚方法实现的地址。当调用虚方法时,运行时将首先获取vtable,然后使用它获取实现的地址。
vtable有更多的特性,例如处理多重继承,但是我们不需要了解这些。
总而言之,要创建一个可供C++运行时使用的IClassFactory对象,我们需要分配一块内存来存储函数的地址。这是我们的vtable。然后,我们需要另一块内存,其中包含一个指向 vtable 的指针。如下图所示:
为了简单的实现它,我们可以将实例和 vtable 合并到一个内存块中:
那么它在C#中是什么样子的呢?首先,我们为 IClassFactory 接口中的每个函数声明一个静态方法,并打上UnmanagedCallersOnly的特性:
[UnmanagedCallersOnly]
public static unsafe int QueryInterface(IntPtr self, Guid* guid, IntPtr* ptr)
{
Console.WriteLine("QueryInterface");
*ptr = IntPtr.Zero;
return 0;
}
[UnmanagedCallersOnly]
public static int AddRef(IntPtr self)
{
Console.WriteLine("AddRef");
return 1;
}
[UnmanagedCallersOnly]
public static int Release(IntPtr self)
{
Console.WriteLine("Release");
return 1;
}
[UnmanagedCallersOnly]
public static unsafe int CreateInstance(IntPtr self, IntPtr outer, Guid* guid, IntPtr* instance)
{
Console.WriteLine("CreateInstance");
*instance = IntPtr.Zero;
return 0;
}
[UnmanagedCallersOnly]
public static int LockServer(IntPtr self, bool @lock)
{
return 0;
}
然后,在DllGetClassObject中,我们分配用于存储指向vtable(我们的假实例)和vtable本身的指针的内存块。由于此内存将由本机代码使用,因此必须确保它不会被垃圾收集器移动。我们可以声明一个IntPtr数组并固定它,但是我更喜欢使用NativeMemory。分配GC不会跟踪的内存。要获取静态方法的地址,我们可以将它们转换为函数指针,然后转换为IntPtr。最后,我们通过函数的ppv参数返回内存块的地址。
[UnmanagedCallersOnly(EntryPoint = "DllGetClassObject")]
public static unsafe int DllGetClassObject(Guid* rclsid, Guid* riid, IntPtr* ppv)
{
Console.WriteLine("Hello from the profiling API");
// 为vtable指针+指向5个方法的指针分配内存块
var chunk = (IntPtr*)NativeMemory.Alloc(1 + 5, (nuint)IntPtr.Size);
// 指向 vtable
*chunk = (IntPtr)(chunk + 1);
// 指向接口的每个方法的指针
*(chunk + 1) = (IntPtr)(delegate* unmanaged<IntPtr, Guid*, IntPtr*, int>)&QueryInterface;
*(chunk + 2) = (IntPtr)(delegate* unmanaged<IntPtr, int>)&AddRef;
*(chunk + 3) = (IntPtr)(delegate* unmanaged<IntPtr, int>)&Release;
*(chunk + 4) = (IntPtr)(delegate* unmanaged<IntPtr, IntPtr, Guid*, IntPtr*, int>)&CreateInstance;
*(chunk + 5) = (IntPtr)(delegate* unmanaged<IntPtr, bool, int>)&LockServer;
*ppv = (IntPtr)chunk;
return HResult.S_OK;
}
在编译和测试之后,我们可以看到我们的假 IClassFactory 的 CreateInstance 方法如预期的那样被调用:
C:\console\bin\Debug\net6.0> .\console.exe
Hello from the profiling API
CreateInstance
Release
Hello, World!
征程才刚刚开始
下一步是实现CreateInstance方法。如前所述,我们希望返回ICorProfilerCallback的实例。为了实现这个接口,我们可以像对 IClassFactory 那样做同样的事情,但是 ICorProfilerCallback包含近70个方法!要编写的样板代码太多了,更不用说 ICorProfilerCallback2、 ICorProfilerCallback3等等了。另外,我们当前的解决方案只能使用静态方法,如果能有一些可以使用实例方法的东西就太好了。在本系列的下一篇文章中,我们将看到如何编写一个源生成器来为我们完成所有枯燥无聊的工作。