向 CLR 添加新的字节码指令






4.97/5 (14投票s)
如何向 CLR 添加新的字节码指令
现在 CoreCLR 已开源,我们可以做一些有趣的事情,例如,找出是否可以在运行时添加新的 IL (中间语言) 指令。
简而言之,事实证明这比您想象的要容易!!以下是您需要经历的步骤
- 第 0 步 - 引言和背景
- 第 1 步 - 向运行时添加新的 IL 指令
- 第 2 步 - 使解释器正常工作
- 第 3 步 - 允许 JIT 识别新的 OpCode
- 第 4 步 - 通过 Reflection.Emit 进行运行时代码生成
- 第 5 步 - 未来的改进
步骤 0
但首先,一些背景信息。向 CLR 添加新的 IL 指令是一个相当罕见的事件,上一次真正实现是在 .NET 2.0 中添加泛型支持的时候。这是 .NET 代码具有良好向后兼容性的部分原因,来自 向后兼容性和 .NET Framework 4.5
.NET Framework 4.5 及其点版本(4.5.1、4.5.2、4.6、4.6.1、4.6.2 和 4.7)与使用早期 .NET Framework 版本构建的应用程序向后兼容。换句话说,使用先前版本构建的应用程序和组件可以在 .NET Framework 4.5 上无需修改即可运行。
题外话:.NET Framework 在从 1.0 迁移到 2.0 时确实破坏了向后兼容性,正是为了能够将泛型支持深度添加到运行时,即支持 IL。Java 采取了不同的决定,我猜是因为它存在的时间更长,破坏向后兼容性是一个更大的问题。有关更多信息,请参阅精彩的博文 Java 和 C# 泛型比较。
步骤 1
对于这个练习,我计划向 CoreCLR 运行时添加一个新的 IL 指令(操作码),并且因为我是一个狂热的自恋者(其实不是,见下文),我将以我自己的名字来命名它。所以,请允许我介绍 matt
IL 指令,您可以这样使用它
.method private hidebysig static int32 TestMattOpCodeMethod(int32 x, int32 y)
cil managed noinlining
{
.maxstack 2
ldarg.0
ldarg.1
matt // yay, my name as an IL op-code!!!!
ret
}
但因为我实际上有点英国人(也就是说,我不喜欢'自吹自擂'),我将使 matt
操作码几乎完全无意义,它将与调用 Math.Max(x, y)
完全相同,即只返回两个数字中的较大者。
以 matt
命名的另一个原因是,我真的希望有人能制作一个版本的 C# (Roslyn) 编译器,让您可以这样编写代码
Console.WriteLine("{0} m@ {1} = {2}", 1, 7, 1 m@ 7)); // prints '1 m@ 7 = 7'
我绝对希望 m@
运算符成为一个实体(读作“matt”,而不是“m-at”),也许在 Microsoft 工作的另一位 “Matt Warren”,他还在 C# 语言设计团队可以帮忙!!说真的,如果这里有任何人想写一篇类似的博文,展示如何将 m@
运算符添加到 Roslyn 编译器,请告诉我,我很想读读它。
现在我们已经定义了操作码,第一步是确保运行时和工具能够识别它。特别是,我们需要 IL Assembler(也称为 ilasm
)能够将上面的 IL 代码(TestMattOpCodeMethod(..)
)汇编成 .NET 可执行文件。
由于 .NET 运行时源代码的结构很规整(给运行时开发者点赞),要实现这一点,我们只需要在 opcode.def 中进行更改。
--- a/src/inc/opcode.def
+++ b/src/inc/opcode.def
@@ -154,7 +154,7 @@ OPDEF(CEE_NEWOBJ, "newobj", VarPop, Pu
OPDEF(CEE_CASTCLASS, "castclass", PopRef, PushRef, InlineType, IObjModel, 1, 0xFF, 0x74, NEXT)
OPDEF(CEE_ISINST, "isinst", PopRef, PushI, InlineType, IObjModel, 1, 0xFF, 0x75, NEXT)
OPDEF(CEE_CONV_R_UN, "conv.r.un", Pop1, PushR8, InlineNone, IPrimitive, 1, 0xFF, 0x76, NEXT)
-OPDEF(CEE_UNUSED58, "unused", Pop0, Push0, InlineNone, IPrimitive, 1, 0xFF, 0x77, NEXT)
+OPDEF(CEE_MATT, "matt", Pop1+Pop1, Push1, InlineNone, IPrimitive, 1, 0xFF, 0x77, NEXT)
OPDEF(CEE_UNUSED1, "unused", Pop0, Push0, InlineNone, IPrimitive, 1, 0xFF, 0x78, NEXT)
OPDEF(CEE_UNBOX, "unbox", PopRef, PushI, InlineType, IPrimitive, 1, 0xFF, 0x79, NEXT)
OPDEF(CEE_THROW, "throw", PopRef, Push0, InlineNone, IObjModel, 1, 0xFF, 0x7A, THROW)
我只是选择了第一个可用的 unused
插槽,并将 matt
添加了进去。它被定义为 Pop1+Pop1
,因为它从堆栈中获取两个值作为输入,而 Push0
是因为它执行后,将单个结果推回到堆栈上。
注意:我所做的所有更改都可以在 GitHub 上集中查看,如果您更喜欢那种方式。
完成此更改后,ilasm
将成功汇编包含 TestMattOpCodeMethod(..)
的测试代码文件 HelloWorld.il
,如上所示。
λ ilasm /EXE /OUTPUT=HelloWorld.exe -NOLOGO HelloWorld.il
Assembling 'HelloWorld.il' to EXE --> 'HelloWorld.exe'
Source file is ANSI
Assembled method HelloWorld::Main
Assembled method HelloWorld::TestMattOpCodeMethod
Creating PE file
Emitting classes:
Class 1: HelloWorld
Emitting fields and methods:
Global
Class 1 Methods: 2;
Resolving local member refs: 1 -> 1 defs, 0 refs, 0 unresolved
Emitting events and properties:
Global
Class 1
Resolving local member refs: 0 -> 0 defs, 0 refs, 0 unresolved
Writing PE file
Operation completed successfully
第二步
然而,此时 matt
操作码实际上并未执行,在运行时,CoreCLR 只是抛出一个异常,因为它不知道如何处理它。作为第一步(也是更简单的一步),我只想让 .NET Interpreter 工作,所以我对以下进行了一些更改来连接它。
--- a/src/vm/interpreter.cpp
+++ b/src/vm/interpreter.cpp
@@ -2726,6 +2726,9 @@ void Interpreter::ExecuteMethod(ARG_SLOT* retVal, __out bool* pDoJmpCall, __out
case CEE_REM_UN:
BinaryIntOp<BIO_RemUn>();
break;
+ case CEE_MATT:
+ BinaryArithOp<BA_Matt>();
+ break;
case CEE_AND:
BinaryIntOp<BIO_And>();
break;
--- a/src/vm/interpreter.hpp
+++ b/src/vm/interpreter.hpp
@@ -298,10 +298,14 @@ void Interpreter::BinaryArithOpWork(T val1, T val2)
{
res = val1 / val2;
}
- else
+ else if (op == BA_Rem)
{
res = RemFunc(val1, val2);
}
+ else if (op == BA_Matt)
+ {
+ res = MattFunc(val1, val2);
+ }
}
然后我添加了将实际实现解释代码的方法。
--- a/src/vm/interpreter.cpp
+++ b/src/vm/interpreter.cpp
@@ -10801,6 +10804,26 @@ double Interpreter::RemFunc(double v1, double v2)
return fmod(v1, v2);
}
+INT32 Interpreter::MattFunc(INT32 v1, INT32 v2)
+{
+ return v1 > v2 ? v1 : v2;
+}
+
+INT64 Interpreter::MattFunc(INT64 v1, INT64 v2)
+{
+ return v1 > v2 ? v1 : v2;
+}
+
+float Interpreter::MattFunc(float v1, float v2)
+{
+ return v1 > v2 ? v1 : v2;
+}
+
+double Interpreter::MattFunc(double v1, double v2)
+{
+ return v1 > v2 ? v1 : v2;
+}
所以,这相当直接,并且好处是此时 matt
运算符已完全可用,您实际上可以使用它来编写 IL 代码,并且它可以运行(仅限解释模式)。
步骤 3
但是,并非所有人都想重新编译 CoreCLR 来启用解释器,所以我还想让它真正通过即时(JIT)编译器工作。
要使此功能正常工作,对多个文件的完整更改主要集中在维护方面,因此我不会在此全部包含,如果您有兴趣,请查看完整的差异。但重要部分如下。
--- a/src/jit/importer.cpp
+++ b/src/jit/importer.cpp
@@ -11112,6 +11112,10 @@ void Compiler::impImportBlockCode(BasicBlock* block)
oper = GT_UMOD;
goto MATH_MAYBE_CALL_NO_OVF;
+ case CEE_MATT:
+ oper = GT_MATT;
+ goto MATH_MAYBE_CALL_NO_OVF;
+
MATH_MAYBE_CALL_NO_OVF:
ovfl = false;
MATH_MAYBE_CALL_OVF:
--- a/src/vm/jithelpers.cpp
+++ b/src/vm/jithelpers.cpp
@@ -341,6 +341,14 @@ HCIMPL2(UINT32, JIT_UMod, UINT32 dividend, UINT32 divisor)
HCIMPLEND
/*********************************************************************/
+HCIMPL2(INT32, JIT_Matt, INT32 x, INT32 y)
+{
+ FCALL_CONTRACT;
+ return x > y ? x : y;
+}
+HCIMPLEND
+
+/*********************************************************************/
HCIMPL2_VV(INT64, JIT_LDiv, INT64 dividend, INT64 divisor)
{
FCALL_CONTRACT;
总而言之,这些更改意味着在 JIT 的 'Morph phase' 中,包含 matt
操作码的 IL 将从
fgMorphTree BB01, stmt 1 (before)
[000004] ------------ ¦ return int
[000002] ------------ ¦ +--¦ lclVar int V01 arg1
[000003] ------------ +--¦ m@ int
[000001] ------------ +--¦ lclVar int V00 arg0
变成这样
fgMorphTree BB01, stmt 1 (after)
[000004] --C--+------ ¦ return int
[000003] --C--+------ +--¦ call help int HELPER.CORINFO_HELP_MATT
[000001] -----+------ arg0 in rcx +--¦ lclVar int V00 arg0
[000002] -----+------ arg1 in rdx +--¦ lclVar int V01 arg1
请注意对 HELPER.CORINFO_HELP_MATT
的调用。
当它最终编译成汇编代码时,看起来是这样的。
// Assembly listing for method HelloWorld:TestMattOpCodeMethod(int,int):int
// Emitting BLENDED_CODE for X64 CPU with AVX
// optimized code
// rsp based frame
// partially interruptible
// Final local variable assignments
//
// V00 arg0 [V00,T00] ( 3, 3 ) int -> rcx
// V01 arg1 [V01,T01] ( 3, 3 ) int -> rdx
// V02 OutArgs [V02 ] ( 1, 1 ) lclBlk (32) [rsp+0x00]
//
// Lcl frame size = 40
G_M9261_IG01:
4883EC28 sub rsp, 40
G_M9261_IG02:
E8976FEB5E call CORINFO_HELP_MATT
90 nop
G_M9261_IG03:
4883C428 add rsp, 40
C3 ret
我不确定为什么那里有一个 nop
指令?但它有效,这就是最重要的!!
步骤 4
在 CLR 中,您还可以使用 'System.Reflection.Emit' 命名空间下的方法动态发出代码,因此最后一项任务是添加 OpCodes.Matt
字段并使其为 matt
操作码发出正确的值。
--- a/src/mscorlib/src/System/Reflection/Emit/OpCodes.cs
+++ b/src/mscorlib/src/System/Reflection/Emit/OpCodes.cs
@@ -139,6 +139,7 @@ internal enum OpCodeValues
Castclass = 0x74,
Isinst = 0x75,
Conv_R_Un = 0x76,
+ Matt = 0x77,
Unbox = 0x79,
Throw = 0x7a,
Ldfld = 0x7b,
@@ -1450,6 +1451,16 @@ private OpCodes()
(0 << OpCode.StackChangeShift)
);
+ public static readonly OpCode Matt = new OpCode(OpCodeValues.Matt,
+ ((int)OperandType.InlineNone) |
+ ((int)FlowControl.Next << OpCode.FlowControlShift) |
+ ((int)OpCodeType.Primitive << OpCode.OpCodeTypeShift) |
+ ((int)StackBehaviour.Pop1_pop1 << OpCode.StackBehaviourPopShift) |
+ ((int)StackBehaviour.Push1 << OpCode.StackBehaviourPushShift) |
+ (1 << OpCode.SizeShift) |
+ (-1 << OpCode.StackChangeShift)
+ );
+
public static readonly OpCode Unbox = new OpCode(OpCodeValues.Unbox,
((int)OperandType.InlineType) |
((int)FlowControl.Next << OpCode.FlowControlShift) |
这使我们可以编写如下代码,该代码会发出、编译然后执行 matt
操作码。
DynamicMethod method = new DynamicMethod(
"TestMattOpCode",
returnType: typeof(int),
parameterTypes: new [] { typeof(int), typeof(int) },
m: typeof(TestClass).Module);
// Emit the IL
var generator = method.GetILGenerator();
generator.Emit(OpCodes.Ldarg_0);
generator.Emit(OpCodes.Ldarg_1);
generator.Emit(OpCodes.Matt); // Use the new 'matt' IL OpCode
generator.Emit(OpCodes.Ret);
// Compile the IL into a delegate (uses the JITter under-the-hood)
var mattOpCodeInvoker =
(Func<int, int, int>)method.CreateDelegate(typeof(Func<int, int, int>));
// prints "1 m@ 7 = 7"
Console.WriteLine("{0} m@ {1} = {2} (via IL Emit)", 1, 7, mattOpCodeInvoker(1, 7));
// prints "12 m@ 9 = 12"
Console.WriteLine("{0} m@ {1} = {2} (via IL Emit)", 12, 9, mattOpCodeInvoker(12, 9));
步骤 5
最后,您可能已经注意到我在第 3 步中稍微作弊了一下,当时我对 JIT 进行了更改。尽管我所做的有效,但由于调用 CORINFO_HELP_MATT
的额外方法调用,它不是最高效的方式。此外,JIT 通常不这样使用辅助函数,而是直接发出汇编代码。
作为未来练习,对于那些读到这里的人(有人愿意尝试吗?),如果 JIT 发出更高效的代码会很好。例如,如果您编写如下 C# 代码(它执行与 matt
操作码相同的事情)。
private static int MaxMethod(int x, int y)
{
return x > y ? x : y;
}
C# 编译器将其转换为以下 IL。
IL to import:
IL_0000 02 ldarg.0
IL_0001 03 ldarg.1
IL_0002 30 02 bgt.s 2 (IL_0006)
IL_0004 03 ldarg.1
IL_0005 2a ret
IL_0006 02 ldarg.0
IL_0007 2a ret
然后当 JIT 运行时,它被处理为 3 个基本块(BB01
、BB02
和 BB03
)。
Importing BB01 (PC=000) of 'TestNamespace.TestClass:MaxMethod(int,int):int'
[ 0] 0 (0x000) ldarg.0
[ 1] 1 (0x001) ldarg.1
[ 2] 2 (0x002) bgt.s
[000005] ------------ ¦ stmtExpr void (IL 0x000... ???)
[000004] ------------ +--¦ jmpTrue void
[000002] ------------ ¦ +--¦ lclVar int V01 arg1
[000003] ------------ +--¦ > int
[000001] ------------ +--¦ lclVar int V00 arg0
Importing BB03 (PC=006) of 'TestNamespace.TestClass:MaxMethod(int,int):int'
[ 0] 6 (0x006) ldarg.0
[ 1] 7 (0x007) ret
[000009] ------------ ¦ stmtExpr void (IL 0x006... ???)
[000008] ------------ +--¦ return int
[000007] ------------ +--¦ lclVar int V00 arg0
Importing BB02 (PC=004) of 'TestNamespace.TestClass:MaxMethod(int,int):int'
[ 0] 4 (0x004) ldarg.1
[ 1] 5 (0x005) ret
[000013] ------------ ¦ stmtExpr void (IL 0x004... ???)
[000012] ------------ +--¦ return int
[000011] ------------ +--¦ lclVar int V01 arg1
最终被转换为以下汇编代码,这要高效得多。它只包含一个 cmp
、一个 jg
和几个 mov
指令,但关键是所有这些都是内联完成的,它不需要调用另一个方法。
// Assembly listing for method TestNamespace.TestClass:MaxMethod(int,int):int
// Emitting BLENDED_CODE for X64 CPU with AVX
// optimized code
// rsp based frame
// partially interruptible
// Final local variable assignments
//
// V00 arg0 [V00,T00] ( 4, 3.50) int -> rcx
// V01 arg1 [V01,T01] ( 4, 3.50) int -> rdx
// # V02 OutArgs [V02 ] ( 1, 1 ) lclBlk ( 0) [rsp+0x00]
//
// Lcl frame size = 0
G_M32709_IG01:
G_M32709_IG02:
3BCA cmp ecx, edx
7F03 jg SHORT G_M32709_IG04
8BC2 mov eax, edx
G_M32709_IG03:
C3 ret
G_M32709_IG04:
8BC1 mov eax, ecx
G_M32709_IG05:
C3 ret
免责声明/致谢
我从非常棒的书籍 Shared Source CLI Essentials 的附录中获得了这个想法,您也可以下载第二版的副本,如果您不想购买纸质版。
在附录 B 中,这本书的作者重现了 Peter Drayton 为 SSCLI 添加指数操作码所做的工作,这启发了整篇文章,非常感谢!
文章 为 CLR 添加新的字节码指令 最初出现在我的博客 性能即特性! 上。