这是在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-2-8039da001e43
项目链接:https://github.com/kevingosse/ManagedDotnetProfiler
使用C#编写.NET分析器-第一部分:https://mp.weixin.qq.com/s/faa9CFD2sEyGdiLMFJnyxw
正文在第一部分中,我们看到了如何模仿COM对象的布局,并用它来暴露一个假的IClassFactory
实例。它运行得很好,但是我们的解决方案使用了静态方法,所以在需要处理多个实例时跟踪对象状态不太方便。如果我们能将COM对象映射到.NET中的一个实际对象实例,那就太好了。
目前,我们的代码看起来是这样的:
public class DllMain { private static ClassFactory Instance; [UnmanagedCallersOnly(EntryPoint = "DllGetClassObject")] public static unsafe int DllGetClassObject(void* rclsid, void* riid, nint* ppv) { Console.WriteLine("Hello from the profiling API"); // 为虚方法表指针和指向5个方法的指针分配内存块 var chunk = (IntPtr*)NativeMemory.Alloc(1 + 5, (nuint)IntPtr.Size); // 虚方法表指针 *chunk = (IntPtr)(chunk + 1); // 指向接口的每个方法的指针 *(chunk + 1) = (IntPtr)(delegate* unmanaged)&QueryInterface; *(chunk + 2) = (IntPtr)(delegate* unmanaged)&AddRef; *(chunk + 3) = (IntPtr)(delegate* unmanaged)&Release; *(chunk + 4) = (IntPtr)(delegate* unmanaged)&CreateInstance; *(chunk + 5) = (IntPtr)(delegate* unmanaged)&LockServer; *ppv = (IntPtr)chunk; return HResult.S_OK; } [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; } }
理想情况下,我们希望有一个实际的对象,带有实例方法,如下所示:
public class ClassFactory{ public unsafe int QueryInterface(IntPtr self, Guid* guid, IntPtr* ptr) { Console.WriteLine("QueryInterface"); *ptr = IntPtr.Zero; return 0; } public int AddRef(IntPtr self) { Console.WriteLine("AddRef"); return 1; } public int Release(IntPtr self) { Console.WriteLine("Release"); return 1; } public unsafe int CreateInstance(IntPtr self, IntPtr outer, Guid* guid, IntPtr* instance) { Console.WriteLine("CreateInstance"); *instance = IntPtr.Zero; return 0; } public int LockServer(IntPtr self, bool @lock) { return 0; }}
然而,原生端只能调用用UnmanagedCallersOnly
属性修饰的方法,而这个属性只能应用于静态方法。因此,我们需要一组静态方法,以及从这些静态方法中检索对象实例的方法。
实现这一点的关键是这些方法的self
参数。因为我们模仿C++对象的布局,本地对象实例的地址作为第一个参数传递。我们可以使用它来检索我们的托管对象并调用非静态版本的方法。例如:
public unsafe class ClassFactory{ private static Dictionary _instances = new(); public ClassFactory() { // 为虚拟表指针和指向5个方法的指针分配内存块 var chunk = (IntPtr*)NativeMemory.Alloc(1 + 5, (nuint)IntPtr.Size); // 指向虚拟表的指针 chunk = (IntPtr)(chunk + 1); // 指向接口中每个方法的指针 (chunk + 1) = (IntPtr)(delegate unmanaged)&QueryInterfaceNative; // [...] (为简洁起见,已省略) _instances.Add((IntPtr)chunk, this); } public int QueryInterface(Guid* guid, IntPtr* ptr) { Console.WriteLine("QueryInterface"); ptr = IntPtr.Zero; return 0; } // [...] (对于ClassFactory的其他实例方法也是如此) [UnmanagedCallersOnly] public static int QueryInterfaceNative(IntPtr self, Guid guid, IntPtr* ptr) { var instance = _instances[self]; return instance.QueryInterface(guid, ptr); } // [...] (对于ClassFactory的其他静态方法也是如此)}
在构造函数中,我们将ClassFactory
的实例添加到一个静态字典中,并关联到相应的本地对象的地址。在静态的QueryInterfaceNative
方法中,我们从静态字典中检索该实例,并调用非静态的QueryInterface
方法。
这是可行的,但每次调用方法时都要进行字典查找是很遗憾的。而且,我们需要处理并发(可能需要使用ConcurrentDictionary
)。有没有更好的解决方案?
我们已经有了一个指向本地对象的指针,所以如果本地对象可以存储一个指向托管对象的指针就太好了。像这样:
public ClassFactory(){ // 为虚拟表指针+托管对象地址+指向5个方法的指针分配内存块 var chunk = (IntPtr*)NativeMemory.Alloc(2 + 5, (nuint)IntPtr.Size); // 指向虚拟表的指针 *chunk = (IntPtr)(chunk + 2); // 指向托管对象的指针 *(chunk + 1) = &this; // [...]}
如果我们有了这个,那么从静态方法中只需获取指向托管对象的指针就可以了:
[UnmanagedCallersOnly]public static unsafe int QueryInterfaceNative(IntPtr* self, Guid* guid, IntPtr* ptr){ var instance = *(ClassFactory*)(self + 1); return instance.QueryInterface(guid, ptr);}
但是&this不能编译*,原因很充分:托管对象可能会在任何时候被垃圾回收器移动,所以指针在下一次垃圾回收时可能变得无效。
*: 我撒谎了。如果你使用的是最新版本的C#,那么你可以获取this的地址:
var classFactory = this;(chunk + 1) = (nint)(nint)&classFactory;
但是由于上述原因,这是不安全的,所以除非你知道自己在做什么,否则请不要这样做。
你可能会想要将对象固定来解决这个问题,但是你不能将一个有对其他托管对象引用的对象固定,所以这也不好。
我们需要的是一种指向托管对象的固定引用,幸运的是,GCHandle
正好提供了这样的功能。如果我们为一个托管对象分配一个GCHandle
,我们可以使用GCHandle.ToIntPtr
获取与该句柄关联的固定地址,并使用GCHandle.FromIntPtr
从该地址检索句柄。因此,我们可以这样做:
public ClassFactory(){ // 为虚拟表指针、托管对象地址以及5个方法的指针分配内存块 var chunk = (IntPtr*)NativeMemory.Alloc(2 + 5, (nuint)IntPtr.Size); // 虚拟表指针 *chunk = (IntPtr)(chunk + 2); // 托管对象指针 var handle = GCHandle.Alloc(this); *(chunk + 1) = GCHandle.ToIntPtr(handle); // [...]}
接着,我们可以从静态方法中检索句柄和关联对象:
[UnmanagedCallersOnly]public static unsafe int QueryInterfaceNative(IntPtr\* self, Guid* guid, IntPtr* ptr){ var handleAddress = *(self + 1); var handle = GCHandle.FromIntPtr(handleAddress); var instance = (ClassFactory)handle.Target; return instance.QueryInterface(guid, ptr);}
将所有内容整合在一起,我们的ClassFactory现在看起来像这样:
public unsafe class ClassFactory{ public ClassFactory() { // Allocate the chunk of memory for the vtable pointer + the address of the managed object + the pointers to the 5 methods var chunk = (IntPtr*)NativeMemory.Alloc(2 + 5, (nuint)IntPtr.Size); // Pointer to the vtable *chunk = (IntPtr)(chunk + 2); // Pointer to the managed object var handle = GCHandle.Alloc(this); *(chunk + 1) = GCHandle.ToIntPtr(handle); *(chunk + 2) = (IntPtr)(delegate* unmanaged)&Exports.QueryInterface; *(chunk + 3) = (IntPtr)(delegate* unmanaged)&Exports.AddRef; *(chunk + 4) = (IntPtr)(delegate* unmanaged)&Exports.Release; *(chunk + 5) = (IntPtr)(delegate* unmanaged)&Exports.CreateInstance; *(chunk + 6) = (IntPtr)(delegate* unmanaged)&Exports.LockServer; Object = (IntPtr)chunk; } public IntPtr Object { get; } public int QueryInterface(Guid* guid, IntPtr* ptr) { Console.WriteLine("QueryInterface"); *ptr = IntPtr.Zero; return 0; } public int AddRef() { Console.WriteLine("AddRef"); return 1; } public int Release() { Console.WriteLine("Release"); return 1; } public int CreateInstance(IntPtr outer, Guid* guid, IntPtr* instance) { Console.WriteLine("CreateInstance"); *instance = IntPtr.Zero; return 0; } public int LockServer(bool @lock) { Console.WriteLine("LockServer"); return 0; } private class Exports { [UnmanagedCallersOnly] public static int QueryInterface(IntPtr* self, Guid* guid, IntPtr* ptr) { var handleAddress = *(self + 1); var handle = GCHandle.FromIntPtr(handleAddress); var obj = (ClassFactory)handle.Target; return obj.QueryInterface(guid, ptr); } [UnmanagedCallersOnly] public static int AddRef(IntPtr* self) { var handleAddress = *(self + 1); var handle = GCHandle.FromIntPtr(handleAddress); var obj = (ClassFactory)handle.Target; return obj.AddRef(); } [UnmanagedCallersOnly] public static int Release(IntPtr* self) { var handleAddress = *(self + 1); var handle = GCHandle.FromIntPtr(handleAddress); var obj = (ClassFactory)handle.Target; return obj.Release(); } [UnmanagedCallersOnly] public static unsafe int CreateInstance(IntPtr* self, IntPtr outer, Guid* guid, IntPtr* instance) { var handleAddress = *(self + 1); var handle = GCHandle.FromIntPtr(handleAddress); var obj = (ClassFactory)handle.Target; return obj.CreateInstance(outer, guid, instance); } [UnmanagedCallersOnly] public static int LockServer(IntPtr* self, bool @lock) { var handleAddress = *(self + 1); var handle = GCHandle.FromIntPtr(handleAddress); var obj = (ClassFactory)handle.Target; return obj.LockServer(@lock); } }}
(注意,我将静态方法移到了一个嵌套类中,以避免名称冲突)
我们可以从入口点使用它:
public class DllMain{ private static ClassFactory Instance; [UnmanagedCallersOnly(EntryPoint = "DllGetClassObject")] public static unsafe int DllGetClassObject(void* rclsid, void* riid, nint* ppv) { Instance = new ClassFactory(); Console.WriteLine("来自分析API的问候"); *ppv = Instance.Object; return HResult.S_OK; }}
剩下的就是为ICorProfilerCallback
及其约70个方法做这个。我们不打算手动完成这个任务,所以下一篇文章中我们将编写一个源代码生成器来自动化这个过程。