使用 Mono.Cecil 重织 IL 代码






4.92/5 (28投票s)
这是关于使用 Mono.Cecil 进行 C# 代码重织的系列教程的第一篇。
目录
引言
在过去的一个月里,我有机会接触并广泛使用了 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
,它将是我们获取程序集的类型和方法的入口点(通过 AssemblyDefinition
的 MainModule
属性公开)。该模块将始终是模块集合(Modules 属性)的一部分,并且大多数时候将是唯一的元素。
原因很简单:在 Visual Studio 中,一个项目将被编译成恰好一个模块,该模块包含在一个程序集中。要创建多模块程序集,我们必须要么使用 CSC 的相关编译器选项,要么使用 Assembly Linker。
伪造返回值
我们将从一个经典的“Hello World”示例开始,伪造一个静态函数的返回值。在此过程中,我们将使用一个类似于 Moq 等模拟库的简化接口。我们需要采取的步骤非常直接,如下所示:
- 加载程序集
- 获取我们要覆盖的方法
- 在方法体的开头注入代码
- 保存程序集
以下是我们示例库中伪造返回值的样子。
//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 函数,我们只需要访问 PropertyDefinition
的 GetMethod
属性,现在我们可以再次使用 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.0
和 ldarg.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 编程的博文链接。