用 .NET Framework Profiling API 迅速重写 MSIL 代码

Posted on 2006-02-08 14:56  A.Z  阅读(1268)  评论(0编辑  收藏  举报

本文假定您熟悉 CLR 和 C#

下载本文的代码: NETProfilingAPI.exe (2,901KB)

摘要

在本文中,作者说明了如何使用 CLR 的 Profiling API 迅速动态重写 Microsoft 中间语言代码。与基于 Reflection.Emit 的方法不同,该方案使用现有的程序集,并且不需要创建代理和动态程序集。当您希望使更改对客户端透明并且保留类的标识时,会形成对 IL 代码重写的需要。该技术可用于创建拦截器、预处理和后处理方法调用以及代码仪表化和验证。

*
本页内容
CLR 的内部方法表示 CLR 的内部方法表示
MSIL 代码重写基础知识 MSIL 代码重写基础知识
更高级的技术 更高级的技术
分析器示例 分析器示例
小结 小结

作为开发人员,您很可能已经遇到以下情况:一个应用程序需要预处理和后处理对另一个应用程序(其源代码不可用)进行的调用,以便执行输入和输出参数验证,实施特定于应用程序的业务规则,或执行某些调用跟踪。您还可能必须在返回缓存结果而不是调用原始方法时缓存方法调用(假设用相同参数并且在一段特定时间内调用方法)。在这种情况下,可以动态生成新的代理程序集以便进行预处理、调用原始程序集的方法以及执行后处理。

该方案最明显的缺点是客户端必须引用代理程序集而不是原始程序集。因而,一般说来,不会保留类的标识并且必须修改客户端应用程序。

另一个缺点是您必须使用通过 Reflection.Emit 创建的动态程序集。当动态程序集被保存到磁盘并重新加载时,它将不再是动态的,并因此被像其他任何程序集一样对待。Reflection.Emit 的基础结构将不再允许您修改它的模块和类,也不再允许您更改它的中间语言 (IL) 代码。如果您要在已经存在的程序集中动态更改方法的 IL 代码或者修改程序集的类型和方法,则需要查找其他某种方式。

在本文中,我将描述一种简单且强大的方法,以使您能够克服 Reflection.Emit 的很多(如果不是全部)限制。该技术还将使您可以修改现有程序集以及向已经加载的程序集中添加新的类型和方法。您还将能够迅速改写给定方法的 IL 代码。

与基于 Reflection API 和动态发出的程序集的传统方法不同,该方案使用现有的程序集,并且无须创建代理或动态程序集。因而,可以保留其方法被仪表化的类的标识,并且所做更改对于客户端应用程序是透明的。该方法可以使您更加深入地了解 IL 代码的基础以及 Profiling API 的工作原理,但是请注意,该方法并不适用于生产环境中的部署。原因在于它基于Profiling API,后者在注册之后会绕过 CLR 安全基础结构。另外,它还会防止使用其他寻求对应用程序执行真正性能分析的分析器。

该方法的基本思想如下所示。当 CLR 加载类并执行它的方法时,该方法的 IL 代码在实时 (JIT) 编译过程中被编译为本机指令。通过作为 CLR 的一部分提供的 Profiling API 可以截获该过程。在方法被 JIT 编译之前,您可以修改它的 IL 代码。在最简单的方案中,您可以将自定义的起始程序和结束程序插入到该方法的 IL 中,并且将得到的 IL 返回给 JIT 编译器。如果您希望的话,则新生成的 IL 可以在原始方法的代码被调用之前和之后完成其他一些工作。

您还可以向该方法中添加新的局部变量、受保护块和异常处理程序。如果采用更为高级的技术,则可以向加载的类中动态添加新的数据成员和方法,或者创建新类型。


图 1 Reflection API 与 IL 代码重写


图 1 总结了基于Reflection API 的方法和基于动态 IL 代码重写的方法之间的重要差异。我在这里描述的技术针对 Microsoft?.NET Framework 1.0 和 Shared Source CLI (SSCLI) Beta Refresh(“Rotor”)进行了测试。

CLR 的内部方法表示


在我深入探讨 IL 代码重写的细节之前,我希望回顾一下 CLR 的几个重要方面,例如,方法的内部表示和结构化异常处理 (SEH) 表。让我们从简单的“Hello world!”程序开始:

// C#
using System;

class MainApp
{
       public static void Main()
     {
          Console.WriteLine( "Hello World!" );
       }
}

以下是该代码片段的 IL 代码,这是我通过运行 IL 反汇编程序实用工具 (ildasm.exe) 得到的。我还包含了 IL 指令的操作码:

// MSIL
.method public hidebysig static void  Main() cil managed
{
  /* 72 | (70)000001*/ ldstr "Hello World!"
  /* 28 | (0A)000002*/ call  void System.Console::WriteLine(string)
  /* 2A |           */ ret
} 

当该程序被编译为 IL 时,有关 hello.exe 程序集中的类和方法的信息被存储在元数据表中。特别是,Method 元数据表保存了有关该程序集的方法的信息。

该表中的每个条目都为 CLR 提供了有关方法的重要信息。该信息是该方法的 IL 代码的相对虚拟地址 (RVA),即距离加载映像(EXE 或 DLL)的地址的偏移量。该条目描述了该方法的一些属性,例如,托管/非托管、私有/公用、静态/实例、虚拟和抽象。它还包含该方法的名称、签名和一个指向 Param 表(它指定了有关该方法的参数的其他信息)的指针。

映像文件还包含一个名为运行库头的特殊头,它存储了有关应用程序入口点的信息。要查看该信息,请启动 IL 反汇编程序实用工具,然后单击“View”/“COR header”菜单。“Hello world!”程序集的运行库头看起来应当如下所示:

CLR Header:
 72       Header Size
 2        Major Runtime Version
 0        Minor Runtime Version
 1        Flags
 6000001  Entrypoint Token
 207c     [208     ] address [size] of Metadata Directory:        
...

现在,让我们了解一下 CLR 如何在加载和 JIT 编译期间处理特定于方法的元数据。当程序集加载时,CLR 分析运行库头以确定应用程序入口点,并且将识别出这是一个由元数据标记 0x06000001(形式为 0x06XXXXXX 的标记被用于方法)编码的方法。它还将识别出该方法的信息存储在图 2 中所示的 Method 表的第一行中。使用该记录,CLR 将能够找到存放了实际的方法体的内存地址,并且还会获得该方法的说明。

如果将该方法的 RVA 添加到加载地址(加载了“Hello World!”程序集的可执行文件的地址)中,则您会看到与图 3 中所示类似的内存转储布局。在我的计算机上,加载地址为 0x06EA1000,因此方法体的物理地址是 0x06EA1000 + 0x2050,或者是 0x6EA3050。您还可以用任何二进制编辑器打开 hello.exe 文件(在本文的代码下载资料中提供),并且在偏移量 0x250 处找到该方法的体。


图 3 内存转储


尽管程序集文件中的方法的偏移量(文件指针)通常与它的 RVA 不同,但您仍然可以遵循下列步骤在磁盘上的文件内部找到方法的体。首先,用 /Adv 开关运行 ildasm.exe。接下来,打开程序集并选择“View”|“MetaInfo”|“Show!”菜单项(或按 Ctrl-M)。该工具将生成带有该程序集的元数据信息(模块、类型、方法等等)的输出窗口。现在,您可以查找感兴趣的方法并读取它的 RVA。

用 /ALL 开关运行 dumpbin.exe 实用工具(该实用工具位于 Visual Studio® .NET 安装下的 Vc7\bin 文件夹中),,在该工具产生的输出中,您应当找到有关名为 .text 的节(该节包含程序集的 IL 代码)的信息。您会看到该节的文件指针的值和名为“raw data”的节转储。最后,使用该方法的 RVA(由 ILDASM 提供)在转储中查找该方法的体。

正如您可以在图 3 中看到的那样,RVA 列指向方法体,它包括方法头、IL 代码,并且可能包括其他一些节,如图 4 所示。让我们仔细地考察一下这些结构。


图 4 IL 方法体布局


当前,有两种类型的方法头:超小头和超大头。在下列情况下使用超小头:方法小于 64 字节;方法的堆栈深度不超过 8 个槽(堆栈上的每个项对应一个槽,而不管项的大小如何);方法不包含局部变量或 SEH。

超小头的结构在 CorHdr.h 文件中声明,该文件位于 Visual Studio .NET 安装下的 \FrameworkSDK\include 文件夹中(参见图 5)。Flags_CodeSize 字段具有以下二进制形式:XXXXXX10b。高 6 位用来以字节为单位存储 IL 代码大小(头大小不计),低 2 位存放超小头类型代码 (0x02)。如果您需要计算超小方法的大小,可以读取 Flags_CodeSize 字节,然后将其右移 2 位。

超大头具有更复杂的结构,并且必须是 DWORD 对齐的。与超小头不同,超大头具有一个特殊的字段,以存储 IL 代码的大小。CorHdr.h 文件还定义了一个方便的联合:IMAGE_COR_ILMETHOD(也显示在图 5中)。使用该联合,可以容易地编写这两个函数以确定给定头的类型(参见图 6)。

现在,让我们返回到 Main 方法的内存布局。如图 3 所示,Main 方法具有超大头:

// method header
0x13 0x30 0x01 0x00
0x0b 0x00 0x00 0x00 0x00 0x00 0x00 0x00

这提供了图 7 中所示的布局(不要忘记 Intel 的反转字节顺序)。

该方法的 IL 代码(它由一系列 IL 指令组成)恰好位于该方法的头后面:

// IL code of the Main method
0x72 0x01 0x00 0x00
0x70 0x28 0x02 0x00 0x00 0x0a 0x2a // <== this the last 
                                   // "ret" instruction

正如您可以看到的那样,代码大小是 11 字节 (0x0b),与方法头所指定的相同。您还可能注意到最后一个字节是 0x2a,它是返回指令的 IL 操作码。

使用 IL 操作码值和 IL 代码分析技术(我将在本文的后面对其进行解释),可以容易地为 Main 方法生成与 ILDASM 产生的清单类似的清单。由于超大头的大小(以双字为单位)是 3(12 个字节),因此超大方法的 IL 代码总是 DWORD 对齐的。对于超小头而言,显然不是这样。

迄今为止,您可以看到方法的内部表示是比较简单的。但是,当方法使用异常处理时,情况会变得更为复杂一些。在这种情况下,应当有一种使该信息可供执行引擎使用的方式。出于该目的,源代码编译为 IL(在这种情况下使用 C# 编译器)以生成特殊的 SEH 表。编译器还将方法头中的 Flags 字段设置为 0x02(CorHdr.h 中的 CorILMethod_MoreSects 值)以告诉运行库方法体具有额外的节。

尽管我直到在后面讨论 IL 代码重写时才会讨论 SEH 表的所有细节,但您应该了解这些表只是包含跟随在方法的 IL 代码体后面的 DWORD 对齐节,并且以节头开始。在这些表的后面是一系列异常处理子句,如图 4 所示。每个异常处理子句也都是 DWORD 对齐的。因为采用这种对齐方式,所以在最后一个 IL 指令和第一个 SEH 节头之间通常会有一些间隙(最多 3 个字节)。节头和每个异常处理子句都可以具有小格式或超大格式。它们在 CorHdr.h 中进行了描述,并且还显示在图 8 中。

让我们考察一下 SEH 节头。Kind 字段的大小总是 1 个字节,并且保存了一组由 CorILMethodSect 枚举器定义的二进制标志(有关详细信息,请参见 CorHdr.h)。IL 编译器总是设置 CorILMethod_Sect_EHTable 标志 (0x01) 以告诉运行库这是一个 SEH 头。对于超大节头,Kind 字节还包含 CorILMethod_Sect_FatFormat (0x40) 值。其余标志是可选的或者当前未使用。Kind 字段的最典型的值是 0x41 和 0x01。

DataSize 保存了节头和任何相关异常处理程序子句的总大小(以字节为单位)。例如,如果您具有一个超大的 SEH 节和 14 个超大子句组成的序列,则 DataSize 将被设置为以下值:

sizeof(FAT section header) + 14 * sizeof(FAT exception handler clause)

每个异常处理程序子句都提供对该异常处理程序的完整的详细说明,例如,距离受保护代码块(try 块)和异常处理程序本身的方法体开始位置的偏移量,以及 try 块和处理程序块的大小。

异常处理程序子句的类型由 Flags 字段描述,它可以具有下列在 CorExceptionFlag 枚举器中声明的值之一(有关详细信息,请参见 CorHdr.h):

COR_ILEXCEPTION_CLAUSE_NONE 值 (0x0) 对应于 try/catch 块。

COR_ILEXCEPTION_ CLAUSE_FILTER 值 (0x1) 对应于筛选器。

COR_ILEXCEPTION_CLAUSE_ FINALLY (0x2) 被用于 try/finally 块。

COR_ILEXCEPTION_CLAUSE_FAULT (0x4) 被用于在异常处理程序内部调用的 finally 块。

MSIL 代码重写基础知识


现在,让我们转而讨论 IL 代码重写的基本技术;即,将起始程序和结束程序插入到方法中。该方法利用我迄今为止已经描述过的 Profiling API 和内部 CLR 结构。我将开发的应用程序是一个分析器 DLL,它通过 ICorProfilerCallback 和 ICorProfilerInfo 接口与 CLR 相集成。运行库使用 ICorProfilerCallback 将各种事件通知给分析器,例如,类加载和卸载、JIT 编译、垃圾回收和线程处理。分析器调用 ICorProfilerInfo 接口(分析器宿主)获得有关运行库的内部结构的详细信息,并且根据需要修改这些结构。

我对与 JIT 编译过程相关的事件特别感兴趣。在方法得到 JIT 编译之前,运行库调用分析器的 ICorProfilerCallback::JITCompilationStarted 方法,以使您能够在方法的 IL 中进行更改。

现在,让我们更仔细地考察分析器的实现细节。其中一些细节是所有分析器所共有的。例如,初始化时,我存储了一个指向 ICorProfilerInfo 接口(由 CLR 实现)的指针,并且针对我感兴趣的事件进行注册。Profiling API 还要求实现标准 COM 入口点,例如,DllGetClassObject、DllCanUnloadNow、DllRegisterServer 和 DllUnregisterServer 函数。请注意,尽管分析器被实现为 COM DLL,但 CLR 本身不使用 COM API。

那些具有 SSCLI 的读者可以观察一下加载分析器 DLL 的方法的 Rotor 实现。您将看到,运行库实现了它自己的 CoCreateInstance 函数版本。代码加载分析器 DLL,获得类对象,并且调用 IClassFactory 接口以实例化该对象,后者随后实现了 ICorProfilerCallback 接口。(该实现位于文件 \sscli\clr\src\profile\ee\profile.cpp 中。)

在考察 Rotor 实现时,您还将注意到,与 .NET Framework 不同,Rotor 使用环境变量 COR_PROFILER_DLL 查找分析器 DLL 完整路径。在 Rotor 环境中使用 DLL 之前,应当设置该变量。现在,让我们查看一下分析器如何处理 JIT 编译事件。

CLR 调用 ICorProfilerCallback::JITCompilationStarted 以通知代码分析器 JIT 编译器将要开始编译函数。以下为该回调的说明:

HRESULT ICorProfilerCallback::JITCompilationStarted( FunctionID 
    functionId, BOOL fIsSafeToBlock )

CLR 将要进行 JIT 编译的函数的 ID 传递给分析器。在 Rotor 中,该 ID 实际上是一个指向内部运行库的名为 MethodDesc 的结构的指针,并且可以用来查询配置文件宿主以获得有关该函数的信息,但是它只应当用作不透明的标识符。第二个参数告诉您在代码中执行费时的操作是否安全。Profiling API 文档声称忽略该参数不会损坏运行库。此外,我已经在 Rotor 源代码中注意到,该参数总是被设置为 TRUE,因此我将忽略它。

JITCompilationStarted 事件是在方法得到 JIT 编译之前对其进行修改的安全地方。出于该目的,核心分析器宿主 ICorProfilerInfo 提供了两个非常强大的函数 — GetILFunctionBody 和 SetILFunctionBody:

HRESULT GetILFunctionBody( 
    ModuleID moduleId,       // ModuleID of the given module.
    mdMethodDef methodId,    // Metadata token for method.
    LPCBYTE *ppMethodHeader, // Pointer to the IL method body.
    ULONG *pcbMethodSize     // Pointer to the size of the method);
    
HRESULT SetILFunctionBody( 
    ModuleID moduleId,     // ModuleID of the given module.
    mdMethodDef method,    // Metadata token for method. 
    LPCBYTE pbNewILMethod, // Pointer to the new IL method body.
    ULONG cbNewMethod      // Size of the new method.); 

这些参数是一目了然的。第一个方法 (GetILFunctionBody) 为给定方法的元数据标记和模块 ID(元数据是针对每个模块分别创建的)返回一个指向方法体的指针。第二个函数使您可以使用新创建的方法体修改现有方法。Profiling API 还要求使用特殊的 IMethodMalloc 接口(可以通过 ICorProfilerInfo::GetILFunctionBodyAllocator 调用从 CLR 获得该接口)为新方法分配内存。

我的 ICorProfilerCallback::JITCompilationStarted 实现执行了下列主要步骤。它修改了一个方法头(超大和超小)。然后,它添加了一个起始程序,这意味着您必须向该方法的开头添加一组新的 IL 指令,以便使原始的 IL 代码得以移动。它向结尾添加了一个结束程序。由于原始 IL 代码更改了,因此您还必须修改 SEH 表,这些表存储了偏移量的相对值以及受保护块和异常处理程序的长度。

请注意,JIT 编译过程还执行验证过程,以分析代码并尝试确定代码是否安全以及是否无法执行任何隐藏的黑客程序。只要您添加了可验证的 IL 指令,原始代码就仍然保持可验证并因此而通过验证过程。让我们更仔细地考察一下这四个步骤。

方法头中的更改比较简单 — 因为您修改了 IL 代码的大小,所以您必须相应地更改 CodeSize 字段。结束程序和起始程序还可能使用方法的堆栈,声明它们自己的局部变量以及添加 SEH 节。因而,三个字段 — Flags、MaxStack 和 LocalVarSigTok — 也必须进行修改。

插入起始程序是一个简单明了的过程。唯一的要求是方法堆栈在旧的 IL 得到执行之前应当为空。该规则具有非常简单的解释 — 原始方法不知道有关您的代码的任何信息并且假定它的堆栈为空。您还必须为您已经使用的任何局部变量还原方法的参数值和初始值。换句话说,应当将方法和局部变量的求值堆栈还原到它们的初始状态。

插入结束程序需要完成很多工作,并且大多数情况下都非常复杂。首先,必须识别方法可能返回的所有地点。通常,有三种返回情况:

ret(返回)指令,它使得方法返回到调用现场。

throw 指令,它从堆栈中弹出一个异常对象并将其作为托管异常引发。

rethrow 指令,它再次引发已经捕获的异常,并且只能在 SEH 处理程序中使用。

还可以将这三种方法组合在一起,如图 9 中显示的示例所示。在这一特定情况下,没有发出结束程序的容易方式。即使您用分支指令替换了 ret 语句,您仍然必须修改原始的错误处理程序(它在行 IL_0012 开始)。不太明显的问题是,由于 ret 至少比任何分支指令短 1 个字节,因此您还必须修改 IL_0008 处的 bne.un.s 指令(bne.un.s 指令从堆栈中取得两个值,并且如果第一个值不等于第二个值,则分支;“s”后缀意味着它是该指令的短形式)。

出于简单的目的,我将只考虑方法通过 ret 返回的那些情况。我将必须执行几个步骤,例如,分析 IL 代码和标识所有返回指令,然后用我的分支指令替换它们,以便将控制转移给结束程序。由于我已经添加的新分支指令至少比原来长了 1 个字节,因此我还必须纠正原始的分支指令。正如您可以看到的那样,最简单的情况是 IL 代码的结尾只有一个 ret 指令。在这种情况下,我可以用 nop 指令替换 ret 操作码,从而将该方法的执行流传递给结束程序。为此,我必须能够分析、重构和修改 IL 代码。这要求在一定程度上熟悉 IL 代码分析的基础知识。

IL 指令操作码在 \FrameworkSDK\include\opcode.def 文件中声明,该文件还包含有关指令大小以及它们采用的中间语言参数的信息。最初,将指令指针 (IP) 设置为指向 IL 代码的第一个字节。接下来,尝试使用 IL 操作码标识第一个 IL 指令,并且将指令的大小和它的参数添加到 IP 中。完成该工作之后,该过程将重复下去,直到您到达该方法的结尾。它看起来像是一个简单明了的过程,只是有一个方面除外:IL 指令的数量大约为 300,因此您必定有一个带有 250 种以上情况的巨大的开关语句。让我们看一下对于以下简单输入,它是如何工作的:

// IL code (DWORD aligned)
0x14 0x0E 0x00 0x28
0x01 0x00 0x00 0x0A 0x26 0xDE 0x0D 0x26
0x72 0x73 0x00 0x00 0x70 0x28 0x02 0x00
0x00 0x0A 0xDE 0x00
0x2A

该代码的第一个字节是 0x14,对应于 ldnull 指令,它加载堆栈上的空对象引用。该指令不采用任何参数,并且它的大小是 1 个字节。您需要将 IP 值增加 1 以获得下一个操作码,即 0x0E。它是 ldarg.s 指令的操作码,该指令加载堆栈上的方法参数值。该参数的数字由指令的参数指定。由于它采用短参数形式(由 .s 后缀注明),因此该参数是从 0 到 255(或从 -128 到 127,取决于参数类型)的 1 字节整数。换句话说,您必须跳过下一个字节 (0x00) 才能移动到下一个指令 — 该指令的操作码是 0x28。这是调用指令,它采用一个 4 字节标记作为参数。为了读取下一个 IL 指令,我将跳过接下来的 5 个字节(一个字节对应于调用操作码,为 0x28;4 个字节对应于标记,为 0x0A000001)。

完整实现由 VerifyReturn 函数提供,并且可以在本文随附的源代码中找到。SEH 表还需要完成一些额外的工作。

通常情况下,您必须修改 try 块的大小和它们的相对偏移量,因为原始的 IL 代码和它的大小不断更改。然后,必须相应变换处理程序的偏移量。根据应用程序的重写逻辑的不同,处理程序的大小本身也可能需要更改。

最简单的重写方案是在第一个 try 块之前添加起始程序,在最后一个处理程序之后添加结束程序。在这样的情况下,您需要做的所有事情就是修改 try 块和处理程序块的偏移量。为了说明这一点,让我们观察一下图 10。(完整的源代码和 IL 代码可以在本文随附的 cc.il 文件中找到。)请注意,我使用 leave 指令的短参数形式退出受保护块和处理程序块。按照 CLR 异常处理规则,leave 指令是离开 SEH 块的唯一正确方式。分支到 SEH 块中或者从该块中分支出去都是非法的。

如果您使用我在前面描述的步骤在 cc.exe 文件中找到了 TestException 方法体(该方法的 RVA 是 0x000020a0),则您应当已经看到方法头的内存布局,如图 11 中所示。请注意方法头中的 Flags 字段,它现在被设置为 0x02 以告诉 CLR 存在 SEH 表。现在,请观察一下该方法的 IL 代码和 SEH 节,如图 12 所示(我还添加了一些注释以强调与 SEH 相关的详细信息)。

由于采用了 DWORD 对齐方式,因此在最后一个 IL 指令和 SEH 节头之间存在 3 个字节的间隙(增量)。节头的第一个字节(Kind 字段)是 0x01,这意味着这是一个小格式的异常处理程序节。小异常处理程序子句恰好位于节头之后,并且大小为 12 个字节。

正如您可以看到的那样,异常类型元数据标记为 0x01000002,它引用 TypeRef 表中的第二行。该表为另一个模块中定义的每个类都包含一个对应行。在测试应用程序 (cc.exe) 中,该标记对应于 System.Exception 类(它是所有 CLR 异常的基类)。通过使用 ILDASM 工具生成 metainfo 输出,可以了解这一点。

让我们假设我将要添加一个起始程序,它只是将方法的参数(int32 类型)值加载到堆栈上,然后将其删除:

ldarg.0 /*0x02*/
pop /*0x26*/

结束程序也非常简单 — 我将 ret 指令替换为 nop 指令,然后添加了一个新的返回语句:

nop /*0x00*/
ret /*0x2A*/

因此,得到的 IL 代码如下所示,其中结束程序和起始程序被突出显示:

0x02 0x26 0x14 0x0E
0x00 0x28 0x01 0x00 0x00 0x0A 0x26 0xDE
0x0D 0x26 0x72 0x73 0x00 0x00 0x70 0x28
0x02 0x00 0x00 0x0A 0xDE 0x00 0x00 0x00
0x2A

由于我向方法开头另外添加了 2 个字节,因此 try 块和 catch 块都被移动了。我还必须修改异常处理程序子句中的 TryOffset 和 HandlerOffset 字段,以便使 SEH 机制正确工作。修改后的异常处理程序子句显示在图 13 中。请注意,SEH 头保持不变。

在我的分析器中,我已经实现了一个名为 FixSEHSections 的通用函数,它完成了上述所有工作(可以在本文随附的源代码中找到它)。它分析 SEH 表,并且根据异常处理程序子句的类型确定了它们的偏移量和大小。该函数还使用了几个由 .NET Framework SDK 提供并且在 corhlpr.h 中声明的便利结构。特别是,我使用了 COR_ILMETHOD_TINY、COR_ILMETHOD_FAT、COR_ILMETHOD 和 COR_ILMETHOD_DECODER 结构,它们用方法体封装了所有工作。

现在,我准备返回到 JITCompilationStarted 方法的实现细节,该方法的要点如图 14 所示。GetMethodInfoByFunctionID 函数是一个 Helper 函数,它获得方法的信息并且填写 CMethodInfo 信息结构。该结构存放了方法的完整数据集。为了获得方法信息,GetMethodInfoByFunctionID 调用了核心分析器的 GetTokenAndMetaDataFromFunction 函数。该函数采用函数 ID(由运行库通过 JITCompilationStarted 调用提供)并且返回相应方法的元数据标记和一个特殊的 IMetaDataImport 接口。元数据导入接口可以用来通过 GetMethodProps 函数查询方法详细信息。有关这些函数的详细信息,请参见位于 \FrameworkSDK\Tool Developers Guide\docs 文件夹中的 Profiling.doc。

JITCompilationStarted 实现的最困难部分隐藏在 CreateILFunctionBody 函数中,该函数完成了大部分实际工作。图 15 中概括了创建带有超小头的新方法体的部分。它是一目了然的。首先,它使用起始程序和结束程序大小来计算新方法的大小,然后为新的方法体分配内存。接下来,它复制起始程序和旧方法,再将旧 IL 中的最后一个返回指令替换为 nop 代码(从而将方法的执行流传递给结束程序)。最后,它添加结束程序。

带有超大头的方法的相同函数则要复杂得多。首先,需要处理方法头 — 必须修改 MaxStack 和 CodeSize 字段,而这是最容易的部分。其次,必须遍历所有 SEH 头和子句,并且确保它们仍然包含有效的偏移量和大小,尽管事实上原始 IL 代码已经被移动。您还必须正确计算 IL 和第一个 SEH 头之间的间隙,并确保所有 SEH 节都正确对齐。您可以在本文随附的源代码中找到该函数的完整版本。

更高级的技术


现在,我将考虑更复杂的技术,例如,向方法中添加局部变量和异常处理程序,以及在运行时创建新方法。我将从局部变量开始。

方法的局部变量被编码为签名,并且相应的记录作为 0x11XXXXXX 格式化标记存储在 StandAloneSig 元数据表中。该表只有一个列,该列将偏移量存储在名为二进制大对象 (BLOB) 堆的特殊元数据流中。(StandAloneSig 还存放了使用 IL 指令调用的间接调用的签名。)

例如,请考虑 IL 汇编语言中的以下方法(该方法带有两个 int32 类型的局部变量):

// IL assembly language
.method public static int32 SomeFunction( int32, int32 )
{
.maxstack 10
// information about those variables is stored in StandAloneSig
// "init" means that all local variables
// must be initialized
    .locals init ( int32 nParam1, int32 nParam2 )
    ...
}

当该方法被编译为 IL 时,编译器将基于局部变量的数量 (2) 和它们的类型(都是 int32)创建签名。在该签名的开头,编译器还将添加一个特殊的字节,以便标识该签名的类型 — 对于局部变量而言,该签名为 0x07 (IMAGE_CEE_CS_CALLCONV_LOCAL_SIG)。

考虑到 int32 类型由 ELEMENT_TYPE_I4 值 (0x08) 表示,所以得到的签名将如下所示:

0x07 0x02 0x08 0x08

接下来,将在 BLOB 流中存储该签名,并且在 StandAloneSig 表中创建相应的记录。编译器还将更新方法头的 LocalVarSigTok 字段以引用新创建的记录。由于我已经在局部变量声明中指定了 init 关键字,因此应当将方法头中的 Flags 值设置为 0x04。在运行时,这将向 JIT 编译器指明,必须通过调用局部变量的默认构造函数来初始化它们。否则,代码无法通过运行时验证过程。因而,为了向方法中添加局部变量,必须修改 StandAloneSig 表。在运行时修改方法时,也是如此。在更改方法和调用 ICorProfilerInfo::SetILFunctionBody 之前,必须生成一个新的局部签名,并且向 StandAloneSig 表中动态添加一个新记录。

幸运的是,CLR 支持一个名为 IMetaDataEmit 的接口,分析器可以在运行时通过该接口修改现有元数据(与 Reflection.Emit 不同)。要获得指向该接口的指针,您需要用等于 ofRead | ofWrite 的 dwOpenFlags 和等于 IID_IMetaDataEmit 的 riid 调用 ICorProfilerInfo::GetModuleMetaData 方法。以下为该函数的格式:

HRESULT GetModuleMetaData( ModuleID moduleId, DWORD dwOpenFlags,
                           REFIID riid, IUnknown **ppOut )

它将向您回送一个可写的元数据接口(元数据发射器),该接口的功能非常强大。它在 Cor.h 中定义,并且具有多个重要方法(参见图 16)。

使用该接口,您将能够迅速创建新的元数据标记。可以将为给定方法动态创建局部变量的整个过程总结如下:

1.

通过调用 ICorProfilerInfo::GetModuleMetaData 获得 IMetaDataEmit 接口。

2.

分配(作为一个字节序列)局部变量的签名并填充之。

3.

使用 IMetaDataEmit::GetTokenFromSig 方法(该方法会为新添加的记录返回一个元数据标记)向 StandAloneSig 表中添加签名。

4.

修改 LocalVarSigTok 以指向新标记。必须将 Flags 字段设置为 0x04 以初始化局部变量。

完整实现由 CreateILFunctionBodyWithLocalVariables 函数提供,并且可以在本文随附的源代码中找到。

添加新方法需要完成一些额外的工作,并且可以通过调用 IMetaDataEmit::DefineMethod 完成。请注意,对类元数据进行的任何更改 — 添加新方法或整个新类 — 都应该在程序运行中尽可能早地完成。理想位置在您想要向其添加新的类或方法的模块的 ModuleLoadFinished 回调中。该过程可以按如下方式描述:

1.

获得 IMetaDataEmit 接口。

2.

创建局部变量签名并将它们添加到元数据中(如上一节中所述)。

3.

创建新的方法体(有关详细信息,请参见 CreateILFunctionBody),并且将它的头修改为指向正确的签名(通过 LocalVarSigTok 和 Flags 条目)。

4.

计算该方法的签名、属性和实现标志。请注意,与局部变量签名不同,该方法的签名没有被编码为标记。因此,在调用 DefineMethod 之前,不必在单独的表中将其存储为记录。

5.

调用 IMetaDataEmit::DefineMethod 函数,它会修改 Method 元数据表并完成实际工作,并且分别在 Signature、Flags 和 ImplFlags 列中存储方法的签名、属性和实现标志。

CreateILFunctionBodyWithLocalVariables 函数的源代码提供了其他详细信息。

要了解向方法中添加新的异常处理程序的过程,请首先考虑不带任何异常处理程序的通用方法,如下所示:

// IL assembly language
.method ... void SomeMethod(...)
{
    ...
    // method body
    ...

ret
} // SomeMethod

我将通过添加可能存在的最简单的 catch 处理程序修改该方法(请参见图 17)。请注意,在该示例中,我使用短参数 leave 指令,它需要两个字节:

IL_01a7:  /* 26   |                  */ pop
IL_01a8:  /* DE   | 00               */ leave.s    IL_01aa
长参数指令需要 5 个字节: 
IL_01a7:  /* 26   |                  */ pop
IL_01a8:  /* DE   | 00000000         */ leave        IL_01aa

由于我要将原始 IL 代码中的返回语句替换为短 leave 指令(它要比原来的语句长 1 个字节),因此受保护块也将增长 1 个字节。

为了添加异常处理程序,您需要完成下列操作:

1.

将方法头中的 Flags 字段设置为 0x02(这是因为该方法具有额外的节)。

2.

查找最后一个返回指令(就像在发出结束程序和起始程序的常规情形中所做的一样),并且将其替换为 leave 指令(这会退出 SEH 块)。

3.

添加异常处理程序块(例如,catch [mscorlib]System.Exception {...} 块)。

4.

添加一个新的 SEH 头(如果该方法没有的话)。

5.

添加一个新的小型(如果 SEH 头是小头)或超大型异常处理程序子句并设置它的字段。

如果您还添加了起始程序和结束程序,则相应的异常处理程序子句将会具有图 18 中所示的布局。

分析器示例


本文随附的分析器示例为给定类的每个方法插入了一个简单的起始程序和一个结束程序。代码说明了我已经在本文中讨论的大多数技术。它还产生了一个日志文件,因此您可以了解在 CChecker.log 文件中发生的事情。该分析器要求设置下列环境变量:

set Cor_Enable_Profiling=0x1
set COR_PROFILER={97D8965A-8686-4639-9C24-E1F6D13EE105}
set COR_PROFILER_DLL=\CChecker.dll
set CC_PROFILER_ROTOR_APP=YourApp.exe

COR_PROFILER_DLL 和 CC_PROFILER_ROTOR_APP 变量是特定于 Rotor 的。第一个变量将完整路径设置为分析器 DLL 并使其可供 Rotor 使用。第二个变量由分析器本身使用,并且指定要仪表化的程序集名称。例如,如果您要在 Rotor 环境中分析 hello.exe 应用程序,则必须按如下方式设置 CC_PROFILER_ROTOR_APP:

set CC_PROFILER_ROTOR_APP=hello.exe

小结


本文说明了公共语言运行库中的一些高级功能。我已经解释了如何使用 Profiling API 和 CLR 的内部结构来动态修改中间语言代码,同时保留要将其方法仪表化的类的标识。这些技术演示了如何将代码仪表化,以便收集无法通过 Profiling API 的其余部分中提供的事件获得的度量值。此外,所提供的示例使您能够更深入地了解 CLR 的内部工作方式,并且将使您能够开发自己的面向 .NET Framework 的高级应用程序。

相关文章,请参阅:
The Implementation of Model Constraints in .NET
Inside Microsoft .NET IL Assembler by Serge Lidin (Microsoft Press, 2002)
Avoiding DLL Hell: Introducing Application Metadata in the Microsoft .NET Framework
Under the Hood: The .NET Profiling API and the DNProfiler Tool
The Microsoft Shared Source CLI Implementation
Rotor: Shared Source CLI Provides Source Code for a FreeBSD Implementation of .NET
Using Reflection Emit to Cache .NET Assemblies