65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Mono.Cecil 重织 IL 代码

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (28投票s)

2013年10月20日

CPOL

8分钟阅读

viewsIcon

82402

downloadIcon

1655

这是关于使用 Mono.Cecil 进行 C# 代码重织的系列教程的第一篇。

目录

  1. 引言
  2. Mono.Cecil
  3. 伪造返回值
  4. 抛出异常
  5. 重织属性
  6. 使用解决方案
  7. 结论
  8. 更新历史

引言

在过去的一个月里,我有机会接触并广泛使用了 Mono.Cecil。然而,在学习过程中,我遇到了许多 Mono.Cecil 的教程,其中大部分使用了过时版本的 Mono.Cecil,其中一些类已被移除或替换(例如 AssemblyFactory of CilWorker)。

正是由于这种覆盖不足,我开始撰写 Mono Cecil 的教程。我的目标是创建一个系列,其中前两篇教程侧重于测试,而另外两篇则侧重于使用 IL 代码重织来实现面向切面的编程。

如果您是 IL 编程新手,可以查看我关于IL 编程基础的博文。 

Mono.Cecil 库

Mono.Cecil 是一个用于检查和重织 IL 代码的强大库。尽管本文档的目标语言是 C#,但请记住,我们可以使用 Cecil 检查或重织任何 .NET 兼容语言,甚至可以生成 IL 代码。

正因如此,其类层次结构直接映射到 IL 编程的概念,而不是 C# 语言的概念。这意味着如果您了解 IL 语言,上手 Mono.Cecil 将会非常轻松快捷。但是不用担心,我将在 Cecil 的同时介绍中间语言的概念,因此深入了解 IL 编程知识并不是先决条件。

让我们通过鸟瞰 Mono.Cecil 的类层次结构来开始我们的介绍。

在此结构中,突出显示了 ModuleDefinition,它将是我们获取程序集的类型和方法的入口点(通过 AssemblyDefinitionMainModule 属性公开)。该模块将始终是模块集合(Modules 属性)的一部分,并且大多数时候将是唯一的元素。

原因很简单:在 Visual Studio 中,一个项目将被编译成恰好一个模块,该模块包含在一个程序集中。要创建多模块程序集,我们必须要么使用 CSC 的相关编译器选项,要么使用 Assembly Linker。

伪造返回值

我们将从一个经典的“Hello World”示例开始,伪造一个静态函数的返回值。在此过程中,我们将使用一个类似于 Moq 等模拟库的简化接口。我们需要采取的步骤非常直接,如下所示:

  1. 加载程序集
  2. 获取我们要覆盖的方法
  3. 在方法体的开头注入代码
  4. 保存程序集

以下是我们示例库中伪造返回值的样子。

//Example 1. Faking return value - primitives
ilCodeWeaver.Setup(() => HelloMessages.GetHelloWorld())
            .Returns("All your bases are belong to us");  

首先,我们在构造函数中加载 AssemblyDefinition,不要忘记存储程序集路径(因为它不会存储在 AssemblyDefinition 实例中)。

public ILCodeWeaver(string assemblyPath)
{
    _assemblyPath = assemblyPath;
    _assemblyDefinition = AssemblyDefinition.ReadAssembly(assemblyPath); 
} 

强调这段简单的代码是因为在 Cecil 0.9 版本之前,读取程序集是通过 AssemblyFactory 类实现的,该类已被完全移除,但大多数现存的少数教程仍然使用该类以及其他已弃用的类。

获取程序集后,我们需要推断出我们要覆盖的方法的声明类型和方法信息。在这里,您可以看到将反射与 Mono.Cecil 结合使用的便捷性。

public SetupContext Setup(Expression<Action> expression)
{
    var methodCall = expression.Body as MethodCallExpression;
    var methodDeclaringType = methodCall.Method.DeclaringType;
 
    var type = _assemblyDefinition.MainModule.Types
        .Single(t => t.Name == methodDeclaringType.Name);
    var method = type.Methods
        .Single(m => m.Name == methodCall.Method.Name);
 
    return new SetupContext {  
        MainModule = _assemblyDefinition.MainModule,
        Method = method,
    };
}  

最后,我们插入两条 IL 指令来返回我们选择的值(目前仅限于字符串)。

public void Returns(object returnObject)
{
    var returnString = returnObject as string;
    
    //Get the site of code injection
    var ilProcessor = Method.Body.GetILProcessor();
    var firstInstruction = ilProcessor.Body.Instructions.First();
 
    ilProcessor.InsertBefore(firstInstruction, ilProcessor.Create(
        OpCodes.Ldstr, returnString));
    ilProcessor.InsertBefore(firstInstruction, ilProcessor.Create(OpCodes.Ret));
} 

正如您所见,这仅仅在函数开头注入代码。不干扰现有代码的原因是,更改局部变量等会花费更多时间。考虑以下伪造的用法:

ilCodeWeaver.Setup(() => HelloMessages.GetSumMessage(5,11))
            .Returns("the sum of x and y is none of your concern");  

如果我们替换了 GetSumMessage 函数的函数体,那么它的 IL 代码会像这样:

.method public hidebysig static GetSumMessage (int32 x,  int32 y) cil managed 
{
    .maxstack 2
    .locals init (
        [0] int32
    ) 
    IL_0000: ldstr "the sum of x and y is none of your concern"
    IL_0005: ret
} // end of method HelloMessages::GetSumMessage 

这可以编译并运行而没有问题,但除了未使用的局部变量 int 之外,在这种情况下,这只是不优雅,并且这种方法以后会给我们带来问题(例如,注入多个条件伪代码)。

抛出异常

在接下来的示例中,我们将修改一个函数,使其抛出异常而不是执行其原始功能。要实现这一点,我们必须实例化一个新的 Exception 对象,然后在此之后插入 throw OpCode。生成的 IL 代码将如下所示:

IL_0000: newobj instance void [mscorlib]System.Exception::.ctor()
IL_0005: throw  

要创建此代码,我们首先必须获取一个指向 Exception 类空构造函数的 MethodReference。对我们来说最简单的方法是先使用反射,然后将其导入到我们的主模块中,然后使用之前的 ILProcessor 插入对象创建和抛出 Opcodes。

public void Throws()
{
    //Obtain the class type through reflection
    //Then import it to the target module
    var reflectionType = typeof(Exception);
    var exceptionCtor = reflectionType.GetConstructor(new Type[]{});
 
    var constructorReference = MainModule.Import(exceptionCtor);
 
    //Get the site of code injection
    var ilProcessor = Method.Body.GetILProcessor();
    var firstInstruction = ilProcessor.Body.Instructions.First();
 
    ilProcessor.InsertBefore(firstInstruction, ilProcessor.Create(
        OpCodes.Newobj, constructorReference));
    ilProcessor.InsertBefore(firstInstruction, ilProcessor.Create(OpCodes.Throw));
} 

现在我们知道如何创建对象,我们将创建一个函数,该函数会抛出一个带有必需参数的自定义异常。

public void Throws<TException>(params object[] arguments) where TException : Exception
{
    var reflectionType = typeof(TException);
    var argumentTypes = arguments.Select(a => a.GetType()).ToArray();
    var exceptionCtor = reflectionType.GetConstructor(argumentTypes);
    var constructorReference = MainModule.Import(exceptionCtor);
 
    //Get the site of code injection
    var ilProcessor = Method.Body.GetILProcessor();
    var firstInstruction = ilProcessor.Body.Instructions.First();
 
    //Load arguments to the evaluation stack
    foreach (var argument in arguments)
    {
        ilProcessor.InsertBefore(firstInstruction,
                    ilProcessor.CreateLoadInstruction(argument));
    }
    ilProcessor.InsertBefore(firstInstruction, ilProcessor.Create(
        OpCodes.Newobj, constructorReference));
    ilProcessor.InsertBefore(firstInstruction, ilProcessor.Create(OpCodes.Throw));
} 

基本上,这与我们之前所做的非常不同:我们使用相同的方式通过反射获取所需的构造函数,但现在我们已经将其扩展到获取带有参数的构造函数。通过 MainModule 导入所需的构造函数引用后,我们像往常一样将 IL 代码插入方法开头。

新部分是 ILProcessor 的 CreateLoadInstruction 扩展,它简化了创建加载指令的方法。

public static Instruction CreateLoadInstruction(this ILProcessor self, object obj)
{
    if (obj is string)
        return self.Create(OpCodes.Ldstr, obj as string);
    else if (obj is int)
        return self.Create(OpCodes.Ldc_I4, (int)obj);
 
    throw new NotSupportedException();
}  

重织属性

替换属性的 getter 方法的代码与我们用于伪造静态函数返回值的代码非常相似。首先,我们创建一个名为 ReweavePropContext 的新类,它将保存 PropertyDefinition 实例。

public ReweavePropContext SetupProp(Expression<Func<string>> expression)
{
    var memberExpression = expression.Body as MemberExpression;
    var declaringType = memberExpression.Member.DeclaringType;
    var propertyType = memberExpression.Member;
 
    var typeDef = _assemblyDefinition.MainModule.Types
        .Single(t => t.Name == declaringType.Name);
    var propertyDef = typeDef.Properties
        .Single(p => p.Name == propertyType.Name);
 
    return new ReweavePropContext
    {
        MainModule = _assemblyDefinition.MainModule,
        Property = propertyDef,
    };
}

要获取特定属性,我们只需访问 TypeDefinition 实例的 Properties 集合,然后获取我们要使用的 PropertyDefinition。之后,在重织函数中,我们注入新的返回值。

public void Returns(object returnValue)
{
    var getterMethod = Property.GetMethod;
    var returnString = returnValue as string;
 
    //Get the site of code injection
    var ilProcessor = getterMethod.Body.GetILProcessor();
    var firstInstruction = ilProcessor.Body.Instructions.First();
 
    ilProcessor.InsertBefore(firstInstruction, ilProcessor.Create(
        OpCodes.Ldstr, returnString));
    ilProcessor.InsertBefore(firstInstruction, ilProcessor.Create(OpCodes.Ret));
}

要获取 getter 函数,我们只需要访问 PropertyDefinitionGetMethod 属性,现在我们可以再次使用 MethodDefinition 来覆盖 getter 的返回值。从这里开始,我们可以使用之前用于方法重织的相同代码。

在我们开始重织属性的 setter 之前,让我们先检查一下它的 IL 代码。

.method public hidebysig specialname instance void set_Occupation 
    (string 'value') cil managed {
    //omitted code that indicates this is a compiler generated code
    .maxstack 8
    IL_0000: ldarg.0
    IL_0001: ldarg.1
    IL_0002: stfld string TestLibrary.Person::'<Occupation>k__BackingField'
    IL_0007: ret
}

在查看此 IL 代码时,我们首先遇到的问题是存在两条指令,它们将参数 0 和 1 加载到评估堆栈上(ldarg.0ldarg.1)。但是,我们在方法描述中只有一个参数,即我们要设置属性的值。那么,为什么我们只有一个参数却有两个加载指令呢?答案是,对于每个用 instance 关键字注释的方法,都有一个 0. 参数,即实例本身。当我们查看 Occupation 属性的 setter 用法时,这一点会很清楚:

IL_00c9: ldloc.2
IL_00ca: ldstr "Programmer"
IL_00cf: callvirt instance void [TestLibrary]TestLibrary.Person::set_Occupation(string) 

在这里,ldloc.2 指令将索引为 2 的局部变量加载到评估堆栈上(即 Person 实例),然后 ldstr 指令也将字符串 Programmer 加载到评估堆栈上。最后,使用 callvirt 指令调用 Occupation 的 setter 函数,该函数有两个参数,它们从评估堆栈中获取值(实例和字符串)。

在这种情况下,重织函数将有所不同,因为我们不仅仅是插入新指令,而是替换将新值加载到评估堆栈上的加载指令。

public void Sets(object valueToSet)
{
    var setterMethod = Property.SetMethod;
    var stringValue = valueToSet as string;
 
    //Get the load instruction to replace
    var ilProcessor = setterMethod.Body.GetILProcessor();
    var argumentLoadInstructions = ilProcessor.Body.Instructions
        .Where(l => l.OpCode == OpCodes.Ldarg_1)
        .ToList();
 
    var fakeValueLoad = ilProcessor.Create(OpCodes.Ldstr, stringValue);
    foreach (var instruction in argumentLoadInstructions)
    {
        ilProcessor.Replace(instruction, fakeValueLoad);
    }
}

通过这一点,我们已经重写了 setter 函数中 value 变量的使用方式,而不仅仅是设置自动创建的 backing field 的地方,还包括所有其他可能的使用方式。

使用解决方案

示例解决方案包含四个项目,其中最有趣的是 ILCodeWeaving 项目,因为它包含了重织 .NET DLL 代码的实际逻辑。将被覆盖的代码位于 TestLibrary 项目中。另外两个项目用于运行示例(TestRunnerConsole)和调用重织代码(TestWeaverConsole)。

让我们构建解决方案并运行 TestRunnerConsole。现在您应该看到以下输出:

要覆盖 TestLibrary.dll,您只需启动 TestWeaverConsole 项目并让它运行。完成后,它将自动关闭,下次运行 TestRunnerConsole 时,您将看到使用我们重织过的 DLL 生成的输出。

现在我们可以看到输出不同,除了示例标题。还有一点需要注意,TestLibrary.dll 在构建时会被复制到 TestDlls 文件夹,并且会引用该 DLL 而不是项目。这样做的原因是为了能够通过我们选择的 IL 代码读取器(例如 ILSpy)打开重织过的 DLL 和原始 DLL。

结论

 

只需深入代码,就会发现使用 Mono.Cecil 重织代码是多么容易。我强烈建议您尝试修改这段代码。是的,我希望您喜欢这篇文章,并且我打算继续撰写关于 Mono.Cecil 及其在测试和面向切面编程中的使用的文章。

 

如果您有任何问题,请随时在评论中提问,我将很乐意回答!

 

更新历史

  • 2013.12.13 - 修正了文章的部分内容。
  • 2014.05.25 - 添加了文章的“使用解决方案”部分。
  • 2015.10.21 - 添加了关于 IL 编程的博文链接。 
© . All rights reserved.