使用C#编写一个.NET分析器(一)
译者注
这是在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等等了。另外,我们当前的解决方案只能使用静态方法,如果能有一些可以使用实例方法的东西就太好了。在本系列的下一篇文章中,我们将看到如何编写一个源生成器来为我们完成所有枯燥无聊的工作。
使用C#编写一个.NET分析器(一)的更多相关文章
- 手把手教你编写一个具有基本功能的shell(已开源)
刚接触Linux时,对shell总有种神秘感:在对shell的工作原理有所了解之后,便尝试着动手写一个shell.下面是一个从最简单的情况开始,一步步完成一个模拟的shell(我命名之为wshell) ...
- javascript编写一个简单的编译器(理解抽象语法树AST)
javascript编写一个简单的编译器(理解抽象语法树AST) 编译器 是一种接收一段代码,然后把它转成一些其他一种机制.我们现在来做一个在一张纸上画出一条线,那么我们画出一条线需要定义的条件如下: ...
- 用 C 语言编写一个简单的垃圾回收器
人们似乎觉得编写垃圾回收机制是非常难的,是一种仅仅有少数智者和Hans Boehm(et al)才干理解的高深魔法.我觉得编写垃圾回收最难的地方就是内存分配,这和阅读K&R所写的malloc例 ...
- 编写一个通用的Makefile文件
1.1在这之前,我们需要了解程序的编译过程 a.预处理:检查语法错误,展开宏,包含头文件等 b.编译:*.c-->*.S c.汇编:*.S-->*.o d.链接:.o +库文件=*.exe ...
- CSharpGL(34)以从零编写一个KleinBottle渲染器为例学习如何使用CSharpGL
CSharpGL(34)以从零编写一个KleinBottle渲染器为例学习如何使用CSharpGL +BIT祝威+悄悄在此留下版了个权的信息说: 开始 本文用step by step的方式,讲述如何使 ...
- .NET Core RC2发布在即,我们试着用记事本编写一个ASP.NET Core RC2 MVC程序
在.NET Core 1.0.0 RC2即将正式发布之际,我也应应景,针对RC2 Preview版本编写一个史上最简单的MVC应用.由于VS 2015目前尚不支持,VS Code的智能感知尚欠火候,所 ...
- 网络爬虫:使用Scrapy框架编写一个抓取书籍信息的爬虫服务
上周学习了BeautifulSoup的基础知识并用它完成了一个网络爬虫( 使用Beautiful Soup编写一个爬虫 系列随笔汇总 ), BeautifulSoup是一个非常流行的Python网 ...
- 作业二:个人编程项目——编写一个能自动生成小学四则运算题目的程序
1. 编写一个能自动生成小学四则运算题目的程序.(10分) 基本要求: 除了整数以外,还能支持真分数的四则运算. 对实现的功能进行描述,并且对实现结果要求截图. 本题发一篇随笔,内容包括: 题 ...
- 用Java语言编写一个简易画板
讲了三篇概博客的概念,今天,我们来一点实际的东西.我们来探讨一下如何用Java语言,编写一块简易的画图板. 一.需求分析 无论我们使用什么语言,去编写一个什么样的项目,我们的第一步,总是去分析这个项目 ...
随机推荐
- Java中时间方法大全01(持续更新)
下面这些方法都可以封装到一个工具类中 /** * 获取当前时间的时间戳 */ public static int getCurrentTimeIntValue() { return (int) (Sy ...
- NC224938 加减
NC224938 加减 题目 题目描述 小红拿到了一个长度为 \(n\) 的数组.她每次操作可以让某个数加 \(1\) 或者某个数减 \(1\) . 小红最多能进行 \(k\) 次操作.她希望操作结束 ...
- 螣龙安科反入侵:EDR的缺点
EDR解决方案提供了比传统终结点安全解决方案更高的功能,并且可以增加人员数量,但是这些功能都有不少的缺点. EDR功能付出巨大代价 在过去四年中,虽然产品成本平均每年下降约35%,但即使到今天,产品的 ...
- 近期碰到的一些面试题--WPF、C#、数据库
最近想换工作的念头特别强烈,面了几家公司没有拿到满意的offer,心仪的公司面完锁HC,有点无奈,感觉今年有点卷,把碰到过的面试题总结下. WPF相关: 1.定义依赖属性需要注意哪些地方? (1)依赖 ...
- C++类中的常成员和静态成员
常变量.常对象.常引用.指向常对象或常变量的指针等在定义时都使用了const关键字,这是C++语言引入的一种数据保护机制,称为const数据保护机制.例如通过const关键字主动地将被调函数形参进行限 ...
- NOI / 2.1基本算法之枚举 1749:数字方格
描述: 如上图,有3个方格,每个方格里面都有一个整数a1,a2,a3.已知0 <= a1, a2, a3 <= n,而且a1 + a2是2的倍数,a2 + a3是3的倍数, a1 + a2 ...
- github碰到的问题
下载问题 自己编译一下 mvn clear mvn compile mvn package 自己编译之后的文件,然后解压即可,第一次自己傻傻的,直接用源码跑,少报错! 项目预览问题 添加1s即可 下载 ...
- 从零开始在centos搭建博客(一)
本篇为安装篇. 基于centos 7.9,大部分东西使用docker安装. 软件列表:docker + mysql + wordpress 安装docker yum install -y yum-ut ...
- 字符串的操作和MAth工具类
字符串的操作 常用方法 判断功能方法 equals(String s)判断两个字符串是否相同,区分大小写 equsalsignorecase(String s) 判断两个字符串是否相同,不区分大小写 ...
- 魔怔愉悦之 Vizing 定理
Vizing 定理 定义 \(\Delta(G)\) 表示图 \(G\) 的点的最大度数,即 \(\displaystyle\Delta G=\max_{i=1}^{|V|}\deg(i)\) . 边 ...