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

向 CLR 添加新的字节码指令

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (14投票s)

2017年5月19日

CPOL

6分钟阅读

viewsIcon

10173

如何向 CLR 添加新的字节码指令

现在 CoreCLR 已开源,我们可以做一些有趣的事情,例如,找出是否可以在运行时添加新的 IL (中间语言) 指令。

简而言之,事实证明这比您想象的要容易!!以下是您需要经历的步骤

步骤 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 个基本块(BB01BB02BB03)。

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 添加指数操作码所做的工作,这启发了整篇文章,非常感谢!

Appendix B - Add a new CIL opcode.png

文章 为 CLR 添加新的字节码指令 最初出现在我的博客 性能即特性! 上。

© . All rights reserved.