译者注

这是在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编译所分析的应用程序方法时,会引发一些分析的事件,比如JITCompilationStartedJITCompilationStartedJITCompilationStarted等等。这些事件都会调用分析器的回调方法,而由于分析器是.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分析器(一)的更多相关文章

  1. 手把手教你编写一个具有基本功能的shell(已开源)

    刚接触Linux时,对shell总有种神秘感:在对shell的工作原理有所了解之后,便尝试着动手写一个shell.下面是一个从最简单的情况开始,一步步完成一个模拟的shell(我命名之为wshell) ...

  2. javascript编写一个简单的编译器(理解抽象语法树AST)

    javascript编写一个简单的编译器(理解抽象语法树AST) 编译器 是一种接收一段代码,然后把它转成一些其他一种机制.我们现在来做一个在一张纸上画出一条线,那么我们画出一条线需要定义的条件如下: ...

  3. 用 C 语言编写一个简单的垃圾回收器

    人们似乎觉得编写垃圾回收机制是非常难的,是一种仅仅有少数智者和Hans Boehm(et al)才干理解的高深魔法.我觉得编写垃圾回收最难的地方就是内存分配,这和阅读K&R所写的malloc例 ...

  4. 编写一个通用的Makefile文件

    1.1在这之前,我们需要了解程序的编译过程 a.预处理:检查语法错误,展开宏,包含头文件等 b.编译:*.c-->*.S c.汇编:*.S-->*.o d.链接:.o +库文件=*.exe ...

  5. CSharpGL(34)以从零编写一个KleinBottle渲染器为例学习如何使用CSharpGL

    CSharpGL(34)以从零编写一个KleinBottle渲染器为例学习如何使用CSharpGL +BIT祝威+悄悄在此留下版了个权的信息说: 开始 本文用step by step的方式,讲述如何使 ...

  6. .NET Core RC2发布在即,我们试着用记事本编写一个ASP.NET Core RC2 MVC程序

    在.NET Core 1.0.0 RC2即将正式发布之际,我也应应景,针对RC2 Preview版本编写一个史上最简单的MVC应用.由于VS 2015目前尚不支持,VS Code的智能感知尚欠火候,所 ...

  7. 网络爬虫:使用Scrapy框架编写一个抓取书籍信息的爬虫服务

      上周学习了BeautifulSoup的基础知识并用它完成了一个网络爬虫( 使用Beautiful Soup编写一个爬虫 系列随笔汇总 ), BeautifulSoup是一个非常流行的Python网 ...

  8. 作业二:个人编程项目——编写一个能自动生成小学四则运算题目的程序

    1. 编写一个能自动生成小学四则运算题目的程序.(10分)   基本要求: 除了整数以外,还能支持真分数的四则运算. 对实现的功能进行描述,并且对实现结果要求截图.   本题发一篇随笔,内容包括: 题 ...

  9. 用Java语言编写一个简易画板

    讲了三篇概博客的概念,今天,我们来一点实际的东西.我们来探讨一下如何用Java语言,编写一块简易的画图板. 一.需求分析 无论我们使用什么语言,去编写一个什么样的项目,我们的第一步,总是去分析这个项目 ...

随机推荐

  1. application.yml 常用基本配置

    前言 在平时的项目开发中,自己对application.yml的配置的写法较为熟悉,现在自己就application.yml常用的配置进行总结如下: 1.Tomcat 配置 server: #设置请求 ...

  2. Spring框架系列(12) - Spring AOP实现原理详解之JDK代理实现

    上文我们学习了SpringAOP Cglib动态代理的实现,本文主要是SpringAOP JDK动态代理的案例和实现部分.@pdai Spring框架系列(12) - Spring AOP实现原理详解 ...

  3. 初始化二维列表时使用[ [0]* N ] * K会出现的问题

    声明二维列表使用[ [0]* N ] * K会出现的问题 初始化二维列表时使用[ [0]* N ] * K创建,外层列表的每一个元素地址相同: 创造了一个二维列表: 修改其中的一个元素a[1][1], ...

  4. 【Unity基础知识】认识常用的生命周期函数(Awake、Start、Update...)

    一.了解帧的概念 游戏的本质就是一个死循环 每一次循环都会处理游戏逻辑 并 更新一次游戏画面 之所以能看到画面在动 是因为 切换画面速度达到一定速度时 人眼就会认为画面是动态且流畅的 一帧就是执行了一 ...

  5. 4-16译码器(always,case用法)

    4-16译码器学习记录 模块文件 1 module decoder_4_16( 2 a, 3 b, 4 c, 5 d, 6 out, 7 ); 8 input a; 9 input b; 10 inp ...

  6. docker数据卷技术

    数据卷技术 数据卷手动挂载 数据卷容器 part1:数据卷挂载方式 数据卷手动挂载 -v 主机目录:容器目录 #核心参数 #示例 docker run -it --name=centos_test - ...

  7. CF Round #805 (Div. 3) 题解

    A 直接模拟即可,注意 \(10^k\) 的情况(罚时!罚时!罚时!). A Code using namespace std; typedef long long ll; typedef pair& ...

  8. Vue 模板语法 && 数据绑定

    1 <!DOCTYPE html> 2 <html> 3 <head> 4 <meta charset="UTF-8" /> 5 & ...

  9. RISC-CPU设计和 FPGA 实现

    摸鱼的时候找到一份单周期和多周期的riscv的fpga实现,还是挺符合我的预期的 知乎专栏地址:https://www.zhihu.com/column/c_1530950608688910336

  10. Excel 文本函数(二):CONCATENATE 和 CONCAT

    在 Excel 2016.Excel Mobile 和 Excel 网页版中,CONCATENATE 函数已替换为 CONCAT 函数. CONCATENATE 函数只能拼接单个单元格或文本字符,不能 ...