在方法体内进行反射





5.00/5 (11投票s)
扩展.NET反射以解码System.Reflection.MethodBody.GetILByteArray()返回的字节数组,讨论实现此目的的技术,并提供.NET反射的简要入门。
背景
在任何.NET程序集中,您都会看到元数据和指令的混合。元数据描述了各种实体:程序集、模块、类型、字段、方法、属性等。例如,方法的元数据提供了其名称、返回类型、参数和调用约定。指令就是指令:它们告诉您的程序集/应用程序要做什么。
虽然不完美,但.NET提供了一个功能丰富的框架来访问元数据(在System.Reflection
命名空间中)。它还提供了一个相当不错的框架来创建新指令(在System.Reflection.Emit
命名空间中)。它甚至允许您通过System.Reflection.MethodBody.GetILAsByteArray()
方法访问现有指令。
奇怪的是,虽然创建新指令很容易,但对现有指令进行反射却变得相当困难。对现有指令所提供的只是一个字节数组。.NET框架对解码该字节数组没有提供真正的支持(从反射的角度来看)。
引言
我认为尝试弥合System.Reflection
和System.Reflection.Emit
之间这个奇怪的空白可能会是一个有趣的练习。本文将描述一个可用于此目的的框架。它还将描述用于创建该框架的一些技术。
这个框架不是设计为反汇编程序。已经有一些优秀的工具可以用于此目的。我对重新发明这个特定的轮子没什么兴趣。
该框架的目标如下:
- 无缝融入现有的.NET反射框架。尽可能使用现有的.NET类型和方法。
- 提供一种可视化指令的方法。由于.NET框架的限制,很难实现真正的保真度。也就是说,利用.NET反射提供的功能,可以非常接近。这正是此框架的目标:接近但不完美。
- 提供一套不错的测试用例。这应该有助于任何想要进一步开发此代码的人。
对于更广泛、更健壮的解决方案,一位读者建议使用Mono.Cecil
。虽然它不是基于反射的,并且我没有亲自使用过它,但我确实听到了很多好评。本文末尾的“附加阅读”部分包含一个链接。
Using the Code
要使用此框架,请利用GetIL()
扩展方法。此方法扩展了System.Reflection.MethodBase
类,使其将返回指令列表。其用法示例(在Program.cs
中找到)如下:
// Get the instructions for the Main method in this Program
var instructions = typeof(Program)
.GetMethod("Main")
.GetIL();
// Display all of the instructions in the Main method
Console.WriteLine("********** Main (all instructions) **********");
foreach (Instruction instruction in instructions)
Console.WriteLine(instruction);
所有指令的通用接口是IInstruction
。它具有以下成员:
成员 | 描述 |
---|---|
IsTarget | 一个值,指示指令是否是分支或switch指令的目标。 |
Label | 指令的标签。 |
偏移量 | 指令起始的字节偏移量。 |
OpCode | 指令的操作码 (opcode)。 |
Parent | 包含指令的指令列表。 |
GetOperand() | 指令的操作数。 |
GetValue() | 指令操作数的已解析值。例如,对于方法指令,GetOperand() 返回一个元数据标记,GetValue() 返回一个System.Reflection.MethodBase 实例。 |
Resolve() | 仅限内部使用:在可能的情况下,将操作数解析为更有意义的值。 |
如果您熟悉.NET反射和通用中间语言 (CIL),那么这些就是您所需要的一切。如果不是,本文后面将提供这些主题的简要入门。
解码数据
大多数解码都相当简单。
每个指令都以操作码 (opcode) 开头。例如,如果代码正在调用方法,您可能会期望一个call
操作码 (System.Reflection.Emit.OpCodes.Call
)。
在数据中,这目前表示为8位或16位代码。如果第一个字节是0xFE
(System.Reflections.Emit.OpCodes.Prefix1.Value
),则为16位代码(两个字节);否则,为8位代码(一个字节)。在本文首次编写时,总共226个操作码中只有27个需要两个字节。
两个字节的操作码如下:arglist
(FE 00
), ceq
(FE 01
), cgt
(FE 02
), cgt.un
(FE 03
), clt
(FE 04
), clt.un
(FE 05
), ldftn
(FE 06
), ldvirtftn
(FE 07
), ldarg
(FE 09
), ldarga
(FE 0A
), starg
(FE 0B
), ldloc
(FE 0C
), ldloca
(FE 0D
), stloc
(FE 0E
), localloc
(FE 0F
), endfilter
(FE 11
), unaligned.
(FE 12
), volatile.
(FE 13
), tail.
(FE 14
), initobj
(FE 15
), constrained.
(FE 16
), cpblk
(FE 17
), initblk
(FE 18
), rethrow
(FE 1A
), sizeof
(FE 1C
), refanytype
(FE 1D
), readonly.
(FE 1E
)。
对于许多操作数类型为OperandType.InlineNone
的指令,这就是指令的全部数据。对于具有其他操作数类型的其他指令,操作数的数据紧随操作码之后。System.Reflection.Emit.OperandType
枚举描述的完整操作数类型集如下:
操作数类型 | Count | 描述 |
---|---|---|
InlineBrTarget | 14 | 操作数是一个32位整数分支目标。 |
InlineField | 6 | 操作数是一个32位元数据标记。 |
InlineI | 1 | 操作数是一个32位整数。 |
InlineI8 | 1 | 操作数是一个64位整数。 |
InlineMethod | 6 | 操作数是一个32位元数据标记。 |
InlineNone | 147 | 无操作数。 |
InlinePhi | 0 | 操作数是保留的,不应使用。 |
InlineR | 1 | 操作数是一个64位IEEE浮点数。 |
InlineSig | 1 | 操作数是一个32位元数据签名标记。 |
InlineString | 1 | 操作数是一个32位元数据字符串标记。 |
InlineSwitch | 1 | 操作数是switch指令的32位整数参数。 |
InlineTok | 1 | 操作数是一个32位FieldRef、MethodRef或TypeRef元数据标记。 |
InlineType | 17 | 操作数是一个32位元数据标记。 |
InlineVar | 6 | 操作数是一个16位整数,包含局部变量或参数的序号。 |
ShortInlineBrTarget | 14 | 操作数是一个8位整数分支目标。 |
ShortInlineI | 2 | 操作数是一个8位整数。 |
ShortInlineR | 1 | 操作数是一个32位IEEE浮点数。 |
ShortInlineVar | 6 | 操作数是一个8位整数,包含局部变量或参数的序号。 |
InstructionList.TryCreate
方法从字节数组中的数据创建IInstruction
实例。
单例类型AllOpCodes
通过反射System.Reflection.Emit.OpCodes
中的字段,构建了一个包含所有可用操作码 (opcodes) 信息的表。此信息包括操作码的数值及其操作数类型。
值得注意的是,几乎所有数据都序列化为小端值,其中最低有效字节位于最低偏移量。然而,也有几个值得注意的例外。操作码和压缩的整数值都以大端格式存储。反序列化的复杂性由Transeric.Reflection.ReadOnlyListExtensions
类中的扩展方法处理。
中间语言序列化的设计者显然倾向于紧凑性。这就是大多数反序列化复杂性的来源。
这种意图在简单的例子中显而易见,例如提供一个单字节的ldloc.1
指令,而五字节的ldloc
指令可以提供相同的功能。
在稍微复杂的例子中,例如元数据标记,这种意图也很明显。例如,对于每个调用指令,不是重复方法的参数,而是提供一个引用这些参数的四字节元数据标记。
这种复杂性在calli
指令中达到了最高点。在这里,提供了一个四字节的元数据标记(用于签名)。这个标记反过来引用了方法签名的压缩表示。在其他地方对紧凑性的关注是值得称赞的。然而,我怀疑在这种特定情况下,复杂性与紧凑性之间的权衡。更坦率地说,编写签名标记的描述真是一件苦差事 :)
元数据标记
许多操作码都有一个操作数,它是一个“32位元数据标记”。这个标记本质上是一个唯一的数字,可以用来定位元数据信息。标记的高8位表示标记的类型(字段、方法、类型等)。低24位在该标记池中提供唯一的身份。
Transeric.Reflection.Token
类型,包含在本文附带的代码中,使得分离元数据标记的各个部分变得容易。
元数据标记本身用处不大。有必要将标记“解析”为其关联的元数据信息。System.Reflection.Module
类提供了以下方法来实现此目标:ResolveField
、ResolveMember
、ResolveMethod
、ResolveSignature
、ResolveString
和ResolveType
。考虑以下示例:
偏移量 | Data | 描述 |
---|---|---|
00 | 02 00 00 | 唯一标识符是2。 |
03 | 70 | 令牌类型指示一个字符串令牌(TokenType.String )。 |
总的来说,这些数据表示对字符串元数据表中第二个字符串的引用。System.Reflection.Module.ResolveString
方法用于按如下方式解析此令牌:
string text = module.ResolveString(metadataToken);
对于可以接受泛型参数的实体,情况要复杂一些。考虑以下对DoSomething
方法的调用:
public class MyType<T1>
{
public static void MyMethod<T2>(T2 arg) =>
DoSomething<T1, T2>();
}
要完全解析DoSomething
方法的令牌,需要知道封闭类型(MyType
)的类型参数以及包含指令的方法(MyMethod
)的类型参数。由于包含指令的方法已知,因此这些信息很容易获取。
要获取封闭类型(MyType
)的类型参数,需要进行以下调用:
Type[] typeArguments = parentMethod.DeclaringType.GetGenericArguments();
要获取封闭方法(MyMethod
)的类型参数,需要进行以下调用:
Type[] methodArguments = parentMethod.GetGenericArguments();
有了这些信息,我们可以将DoSomething
的令牌解析为对应的方法信息,如下所示:
MethodBase method = parentMethod.Module.ResolveMethod(metadataToken, typeArguments, methodArguments);
InlineBrTarget
类型: BranchInstruction<int>
操作数是一个32位有符号整数,指定从指令末尾开始的字节偏移量。它最初由ReadOnlyListExtensions.ReadInt32
方法解码,该方法读取包含此值的四个字节。
之后,使用Transeric.Reflection.MethodIL.ResolveInstruction
将此偏移量解析为Transeric.Reflection.IInstruction
实例。这是通过对在该偏移量处发生的指令进行二分查找来实现的。考虑以下示例:
偏移量 | Data | 描述 |
---|---|---|
00 | 38 | 操作码表示分支指令 (OpCodes.Br )。 |
01 | 0F 00 00 00 | 偏移量是指令末尾起15个字节 (0F )。这里,从数据开头算起的偏移量是20:5(指令末尾)加上15(分支)。 |
05 | 指令结束。 |
操作码 (14): br
(38
), brfalse
(39
), brtrue
(3A
), beq
(3B
), bge
(3C
), bgt
(3D
), ble
(3E
), blt
(3F
), bne.un
(40
), bge.un
(41
), bgt.un
(42
), ble.un
(43
), blt.un
(44
), leave
(DD
)
InlineField
类型: FieldInstruction
操作数是一个32位元数据标记。它最初由ReadOnlyListExtensions.ReadToken
方法解码,该方法读取包含此值的四个字节。之后,使用System.Reflection.Module.ResolveField
将标记解析为System.Reflection.FieldInfo
实例。考虑以下示例:
偏移量 | Data | 描述 |
---|---|---|
00 | 7B | 操作码表示加载字段指令 (OpCodes.Ldfld )。 |
01 | 02 00 00 | 元数据标记的唯一标识符是2。 |
04 | 04 | 令牌类型指示字段定义(TokenType.FieldDef )。 |
操作码 (6): ldfld
(7B
), ldflda
(7C
), stfld
(7D
), ldsfld
(7E
), ldsflda
(7F
), stsfld
(80
)
InlineI
类型: Instruction<int>
操作数是一个32位有符号整数。它由ReadOnlyListExtensions.ReadInt32
方法解码,该方法读取包含此值的四个字节。由于没有引用元数据,因此不需要解析。考虑以下示例:
偏移量 | Data | 描述 |
---|---|---|
00 | 20 | 操作码表示加载32位常量指令 (OpCodes.Ldc_I4 )。 |
01 | 00 01 00 00 | 加载值256。 |
操作码 (1): ldc.i4
(20
)
InlineI8
类型: Instruction<long>
操作数是一个64位有符号整数。它由ReadOnlyListExtensions.ReadInt64
方法解码,该方法读取包含此值的八个字节。由于没有引用元数据,因此不需要解析。考虑以下示例:
偏移量 | Data | 描述 |
---|---|---|
00 | 21 | 操作码表示加载64位常量指令 (OpCodes.Ldc_I8 )。 |
01 | 00 01 00 00 00 00 00 00 | 加载值256。 |
操作码 (1): ldc.i8
(21
)
InlineMethod
类型: MethodInstruction
操作数是一个32位元数据标记。它最初由ReadOnlyListExtensions.ReadToken
方法解码,该方法读取包含此值的四个字节。之后,使用System.Reflection.Module.ResolveMethod
将标记解析为System.Reflection.MethodBase
实例。
偏移量 | Data | 描述 |
---|---|---|
00 | 28 | 操作码表示调用指令 (OpCodes.Call )。 |
01 | 02 00 00 | 元数据标记的唯一标识符是2。 |
04 | 06 | 令牌类型指示方法定义(TokenType.MethodDef )。 |
操作码 (6): jmp
(27
), call
(28
), callvirt
(6F
), newobj
(73
), ldftn
(FE 06
), ldvirtftn
(FE 07
)
InlineNone
类型: Instruction
, ParameterInstruction<byte>
或 VariableInstruction<byte>
由于没有操作数,这种类型最容易解码。它还具有最多的操作码(147个)与之关联。考虑以下示例:
偏移量 | Data | 描述 |
---|---|---|
00 | 2A | 操作码表示返回指令 (OpCodes.Ret )。 |
虽然这些操作码都没有操作数,但许多都具有隐式操作数。为了简化反射,在指令与参数或局部变量关联的情况下,代码将创建ParameterInstruction<byte>
或VariableInstruction<byte>
的实例,表现得好像隐式操作数存在一样。
例如,ldloc.1
指令(如下)隐式表示操作数为“1”。因此,代码将创建一个VariableInstruction<byte>
的实例,以便操作数的值将解析为System.Reflection.LocalVariableInfo
的实例。
偏移量 | Data | 描述 |
---|---|---|
00 | 07 | 操作码表示加载局部变量指令 (OpCodes.Ldloc_1 )。 |
类似地,ldarg.1
指令(如下)也隐式表示操作数为“1”。因此,代码将创建一个ParameterInstruction<byte>
的实例,以便操作数的值将解析为System.Reflection.ParameterInfo
的实例。
偏移量 | Data | 描述 |
---|---|---|
00 | 03 | 操作码表示加载局部参数指令 (OpCodes.Ldarg_1 )。 |
有关如何解析局部变量和参数的信息,请参阅操作数类型ShortInlineVar
的描述。
操作码 (147): nop
(00
), break
(01
), ldarg.0
(02
), ldarg.1
(03
), ldarg.2
(04
), ldarg.3
(05
), ldloc.0
(06
), ldloc.1
(07
), ldloc.2
(08
), ldloc.3
(09
), stloc.0
(0A
), stloc.1
(0B
), stloc.2
(0C
), stloc.3
(0D
), ldnull
(14
), ldc.i4.m1
(15
), ldc.i4.0
(16
), ldc.i4.1
(17
), ldc.i4.2
(18
), ldc.i4.3
(19
), ldc.i4.4
(1A
), ldc.i4.5
(1B
), ldc.i4.6
(1C
), ldc.i4.7
(1D
), ldc.i4.8
(1E
), dup
(25
), pop
(26
), ret
(2A
), ldind.i1
(46
), ldind.u1
(47
), ldind.i2
(48
), ldind.u2
(49
), ldind.i4
(4A
), ldind.u4
(4B
), ldind.i8
(4C
), ldind.i
(4D
), ldind.r4
(4E
), ldind.r8
(4F
), ldind.ref
(50
), stind.ref
(51
), stind.i1
(52
), stind.i2
(53
), stind.i4
(54
), stind.i8
(55
), stind.r4
(56
), stind.r8
(57
), add
(58
), sub
(59
), mul
(5A
), div
(5B
), div.un
(5C
), rem
(5D
), rem.un
(5E
), and
(5F
), or
(60
), xor
(61
), shl
(62
), shr
(63
), shr.un
(64
), neg
(65
), not
(66
), conv.i1
(67
), conv.i2
(68
), conv.i4
(69
), conv.i8
(6A
), conv.r4
(6B
), conv.r8
(6C
), conv.u4
(6D
), conv.u8
(6E
), conv.r.un
(76
), throw
(7A
), conv.ovf.i1.un
(82
), conv.ovf.i2.un
(83
), conv.ovf.i4.un
(84
), conv.ovf.i8.un
(85
), conv.ovf.u1.un
(86
), conv.ovf.u2.un
(87
), conv.ovf.u4.un
(88
), conv.ovf.u8.un
(89
), conv.ovf.i.un
(8A
), conv.ovf.u.un
(8B
), ldlen
(8E
), ldelem.i1
(90
), ldelem.u1
(91
), ldelem.i2
(92
), ldelem.u2
(93
), ldelem.i4
(94
), ldelem.u4
(95
), ldelem.i8
(96
), ldelem.i
(97
), ldelem.r4
(98
), ldelem.r8
(99
), ldelem.ref
(9A
), stelem.i
(9B
), stelem.i1
(9C
), stelem.i2
(9D
), stelem.i4
(9E
), stelem.i8
(9F
), stelem.r4
(A0
), stelem.r8
(A1
), stelem.ref
(A2
), conv.ovf.i1
(B3
), conv.ovf.u1
(B4
), conv.ovf.i2
(B5
), conv.ovf.u2
(B6
), conv.ovf.i4
(B7
), conv.ovf.u4
(B8
), conv.ovf.i8
(B9
), conv.ovf.u8
(BA
), ckfinite
(C3
), conv.u2
(D1
), conv.u1
(D2
), conv.i
(D3
), conv.ovf.i
(D4
), conv.ovf.u
(D5
), add.ovf
(D6
), add.ovf.un
(D7
), mul.ovf
(D8
), mul.ovf.un
(D9
), sub.ovf
(DA
), sub.ovf.un
(DB
), endfinally
(DC
), stind.i
(DF
), conv.u
(E0
), prefix7
(F8
), prefix6
(F9
), prefix5
(FA
), prefix4
(FB
), prefix3
(FC
), prefix2
(FD
), prefix1
(FE
), prefixref
(FF
), arglist
(FE 00
), ceq
(FE 01
), cgt
(FE 02
), cgt.un
(FE 03
), clt
(FE 04
), clt.un
(FE 05
), localloc
(FE 0F
), endfilter
(FE 11
), volatile.
(FE 13
), tail.
(FE 14
), cpblk
(FE 17
), initblk
(FE 18
), rethrow
(FE 1A
), refanytype
(FE 1D
), readonly.
(FE 1E
)
InlinePhi
类型: 无
System.Reflection.Emit.OpCodes
中的操作码均未引用此操作数类型。根据OperandType.InlinePhi
的文档:“操作数已保留且不应使用”。
InlineR
类型: Instruction<double>
操作数是一个64位IEEE浮点数。它由ReadOnlyListExtensions.ReadDouble
方法解码,该方法读取包含此值的八个字节。由于没有引用元数据,因此不需要解析。考虑以下示例:
偏移量 | Data | 描述 |
---|---|---|
00 | 23 | 操作码表示加载64位常量指令 (OpCodes.Ldc_R8 )。 |
01 | 00 00 00 00 00 00 F0 3F | 加载了64位浮点数1.0。 |
操作码 (1): ldc.r8
(23
)
InlineSig
类型: SignatureInstruction
操作数是一个32位元数据标记。它最初由ReadOnlyListExtensions.ReadToken
方法解码,该方法读取包含此值的四个字节。之后,使用System.Reflection.Module.ResolveSignature
将标记解析为包含签名数据的字节数组。由于.NET没有提供太多帮助来解码此字节数组,因此它由Transeric.Reflection.MethodSignature
类进一步解析。
偏移量 | Data | 描述 |
---|---|---|
00 | 29 | 操作码表示间接调用指令 (OpCodes.Calli )。 |
01 | 02 00 00 | 元数据标记的唯一标识符是2。 |
04 | 11 | 令牌类型指示签名(TokenType.Signature )。 |
请耐心,这个很难解释。我花了很长时间才弄明白。
在这里,System.Reflection.Module.ResolveSignature
简单地返回一个字节数组,它是目标方法签名的压缩表示。当我们尝试将此字节数组解码为有意义的内容时,我们基本上是独自完成的。
从高层次来看,方法签名很简单。它提供了:调用约定、参数计数、返回类型以及零个或多个参数类型的序列。
调用约定很容易解码。它始终是单个字节,因此无需担心压缩。可能的值在Transeric.Reflection.CilCallingConvention
枚举中描述。
本文描述的框架通过将此值转换为.NET标准System.Runtime.InteropServices.CallingConvention
和System.Reflection.CallingConventions
枚举,进一步简化了交互。
对于方法签名的其他部分,所有整数值,我们需要担心压缩。为了最大化紧凑性,IL序列化的设计者设计了一个相当简单的压缩方案。整数值可以存储在一个、两个或四个字节中。字节以大端顺序序列化。第一个字节的高位描述值的长度。剩余的位提供数据。该值有三种可能的形式,如下所示:
位模式 | 描述 |
---|---|
0XXXXXXX | 第一位为0,表示一个单字节值。其余位包含该值的数据。 |
10XXXXXX XXXXXXXX | 前两位表示这是一个两字节值。其余位包含该值的数据。 |
11XXXXXX XXXXXXXX XXXXXXXX XXXXXXXX | 前两位表示这是一个四字节值。其余位包含该值的数据。 |
使用上述方案,我们首先解码参数计数。这只是方法签名中提供的参数类型数量。
接下来我们解码返回类型和每个参数类型。两者的过程相同,并且(遗憾地)复杂。
类型有两种主要类型:简单类型和复杂类型。
要解码一个简单类型,我们首先读取一个表示该类型的字节。此字节使用枚举Transeric.Reflection.ElementType
进行解释。该枚举识别以下常见/简单类型。
元素类型 | 值 | 描述 |
---|---|---|
Void | 01 | “void”类型 (System.Void )。 |
布尔值 | 02 | 布尔类型 (System.Boolean )。 |
Char | 03 | 字符类型 (System.Char )。 |
SByte | 04 | 有符号8位整数类型 (System.SByte )。 |
字节型 | 05 | 无符号8位整数类型 (System.Byte )。 |
Int16 | 06 | 有符号16位整数类型 (System.Int16 )。 |
UInt16 | 07 | 无符号16位整数类型 (System.UInt16 )。 |
Int32 | 08 | 有符号32位整数类型 (System.Int32 )。 |
UInt32 | 09 | 无符号32位整数类型 (System.UInt32 )。 |
Int64 | 0A | 有符号64位整数类型 (System.Int64 )。 |
UInt64 | 0B | 无符号64位整数类型 (System.UInt64 )。 |
Single | 0C | 32位IEEE浮点数类型 (System.Single )。 |
双精度浮点型 | 0D | 64位IEEE浮点数类型 (System.Double )。 |
字符串 | 0E | 字符串类型 (System.String )。 |
TypedReference | 16 | 类型化引用类型 (System.TypedReference )。 |
IntPtr | 18 | 一种平台特定的有符号整型,用于表示指针或句柄 (System.IntPtr )。 |
UIntPtr | 19 | 一种平台特定的无符号整型,用于表示指针或句柄 (System.UIntPtr )。 |
对象 | 1C | 一种可用于传递任何类型的对象类型 (System.Object )。 |
对于这些简单类型,此单个字节足以序列化该类型。
对于复杂类型(字节值为ElementType.Class
或ElementType.ValueType
),我们需要做更多的工作。在这些情况下,还会提供一个额外的值:类型的编码元数据标记。我们首先解压缩一个整数值。
遗憾的是,工作并未止步于此。整数值被进一步编码。最低两位指示标记类型,并按如下方式解释:
位 | 描述 |
---|---|
00 | 一个类型定义元数据标记(TokenType.TypeDef )。 |
01 | 一个类型引用元数据标记(TokenType.TypeRef )。 |
10 | 一个类型规范元数据标记(TokenType.TypeSpec )。 |
11 | 这不是一个预期的、有效的代码。 |
剩余的位(下移后)提供了标记的唯一标识。解码元数据标记后,我们可以通过使用System.Reflection.Module.ResolveType
方法将其解析为System.Type
。让我们考虑一个不那么简单的例子:
偏移量 | Data | 描述 |
---|---|---|
00 | 00 | 指示标准调用 (CilCallingConvention.Standard )。 |
01 | 02 | 有两个参数/参数类型。 |
02 | 08 | 返回类型是一个有符号32位整数 (ElementType.Int32 )。 |
03 | 0E | 第一个参数是一个字符串 (ElementType.String )。 |
04 | 12 | 第二个参数是类 (ElementType.Class )。 |
05 | 08 | 类的编码元数据标记是08 。 |
要解码上例中的标记,我们考虑编码标记中的位 (00001000
)。低两位 (00
) 指示该标记是类型定义 (TokenType.TypeDef
)。剩余的位 (000010
) 提供了该类型定义的唯一标识 (2)。解码后的元数据标记如下:
偏移量 | Data | 描述 |
---|---|---|
00 | 01 00 00 | 元数据标记的唯一标识符是1。 |
03 | 02 | 令牌类型指示类型定义(TokenType.TypeDef )。 |
然后我们可以使用System.Reflection.Module.ResolveType
方法将此元数据标记解析为System.Type
。
还有一个额外的复杂性。可以指示某些参数是可选的。这通过在第一个可选参数之前放置一个ElementType.Sentinel
值来实现。因此,修改上一个示例,我们将第二个参数设置为可选,如下所示:
偏移量 | Data | 描述 |
---|---|---|
00 | 00 | 指示标准调用 (CilCallingConvention.Standard )。 |
01 | 02 | 有两个参数/参数类型。 |
02 | 08 | 返回类型是一个有符号32位整数 (ElementType.Int32 )。 |
03 | 0E | 第一个参数是一个字符串 (ElementType.String )。 |
04 | 41 | 哨兵值 (ElementType.Sentinel ) 表示所有后续参数都是可选的。 |
05 | 12 | 第二个参数是类 (ElementType.Class )。 |
06 | 08 | 类的编码元数据标记是08 。 |
操作码 (1): calli
(29
)
InlineString
类型: StringInstruction
操作数是一个32位元数据标记。它最初由ReadOnlyListExtensions.ReadToken
方法解码,该方法读取包含此值的四个字节。之后,使用System.Reflection.Module.ResolveString
将标记解析为System.String
实例。考虑以下示例:
偏移量 | Data | 描述 |
---|---|---|
00 | 72 | 操作码表示加载字符串指令 (OpCodes.Ldstr )。 |
01 | 02 00 00 | 元数据标记的唯一标识符是2。 |
04 | 70 | 令牌类型指示字符串(TokenType.String )。 |
操作码 (1): ldstr
(72
)
InlineSwitch
类型: SwitchInstruction
这是少数几个操作数大小可变且由多个部分组成的情况之一。
操作数的第一部分是一个带符号的32位整数,它指示与此switch指令关联的分支数量。它由ReadOnlyListExtensions.ReadInt32
方法解码,该方法读取包含该值的四个字节。
在此之后,提供一个或多个分支偏移量。每个分支偏移量都是一个带符号的32位整数,它提供从指令末尾开始的字节偏移量。这些最初由ReadOnlyListExtensions.ReadInt32
方法解码,该方法读取包含该值的四个字节。
之后,使用System.Reflection.Module.ResolveInstruction
将每个偏移量解析为Transeric.Reflection.IInstruction
的实例。这是通过对在该偏移量处出现的指令进行二分查找来实现的。考虑以下示例:
偏移量 | Data | 描述 |
---|---|---|
00 | 45 | 操作码表示 switch 指令 (OpCodes.Switch )。 |
01 | 02 00 00 00 | 此 switch 指令有2个分支偏移量。 |
05 | 0E 00 00 00 | 第一个分支偏移量距离指令末尾14个字节 (0E )。这里,从数据开头算起的偏移量是27:13(指令末尾)加上14(分支)。 |
09 | 0F 00 00 00 | 第二个分支偏移量距离指令末尾15个字节 (0F )。这里,从数据开头算起的偏移量是28:13(指令末尾)加上15(分支)。 |
0D | 指令结束(从数据开头算起13个字节)。 |
操作码 (1): switch
(45
)
InlineTok
类型:FieldInstruction
、MemberInstruction
、MethodInstruction
、SignatureInstruction
、StringInstruction
、TypeInstruction
操作数是一个32位元数据标记。它最初由ReadOnlyListExtensions.ReadToken
方法解码,该方法读取包含此值的四个字节。根据元数据标记类型,将创建以下类型之一的实例:FieldInstruction
、MemberInstruction
、MethodInstruction
、SignatureInstruction
、StringInstruction
或TypeInstruction
。标记后续解析为其相应的元数据信息取决于该类型。考虑以下示例:
偏移量 | Data | 描述 |
---|---|---|
00 | D0 | 操作码表示加载令牌指令 (OpCodes.Ldtoken )。 |
01 | 02 00 00 | 元数据标记的唯一标识符是2。 |
04 | 04 | 令牌类型指示字段定义(TokenType.FieldDef )。 |
由于令牌类型是TokenType.FieldDef
,因此创建了一个FieldInstruction
实例。
操作码 (1): ldtoken
(D0
)
InlineType
类型: TypeInstruction
操作数是一个32位元数据标记。它最初由ReadOnlyListExtensions.ReadToken
方法解码,该方法读取包含此值的四个字节。之后,使用System.Reflection.Module.ResolveType
将标记解析为System.Type
实例。考虑以下示例:
偏移量 | Data | 描述 |
---|---|---|
00 | 8C | 操作码表示装箱指令 (OpCodes.Box )。 |
01 | 02 00 00 | 元数据标记的唯一标识符是2。 |
04 | 02 | 令牌类型指示类型定义 (TokenType.TypeDef )。 |
操作码 (17): cpobj
(70
), ldobj
(71
), castclass
(74
), isinst
(75
), unbox
(79
), stobj
(81
), box
(8C
), newarr
(8D
), ldelema
(8F
), ldelem
(A3
), stelem
(A4
), unbox.any
(A5
), refanyval
(C2
), mkrefany
(C6
), initobj
(FE 15
), constrained.
(FE 16
), sizeof
(FE 1C
)
InlineVar
类型: ParameterInstruction<ushort>
或 VariableInstruction<ushort>
操作数是一个无符号16位整数。它最初由ReadOnlyExtensions.ReadUInt16
方法解码,该方法读取包含此值的两个字节。根据指令,将创建ParameterInstruction<ushort>
或VariableInstruction<ushort>
的实例。
注意:如果System.OperandType
为这两个不同的操作数类型定义了单独的操作数类型(例如InlineArg
和InlineVar
),那就太好了。遗憾的是,它没有。
ParameterInstruction<ushort>
为指令ldarg
、ldarga
和starg
创建ParameterInstruction<ushort>
实例。之后,操作数通过Transeric.Reflection.MethodIL.ResolveParameter
解析为ParameterInfo
实例。它通过使用System.Reflection.GetParameters
方法来实现这一点。由于GetParameters
不返回this
参数,因此索引值根据包含方法的调用约定(特别是CallingConventions.HasThis
)进行解释。考虑以下示例:
偏移量 | Data | 描述 |
---|---|---|
00 | FE 09 | 操作码表示加载参数指令 (OpCodes.Ldarg )。 |
02 | 01 00 00 00 | 基于零的索引值 (1) 表示第二个参数。 |
VariableInstruction<ushort>
为指令ldloc
、ldloca
和stloc
创建一个VariableInstruction<ushort>
实例。之后,操作数通过Transeric.Reflection.MethodIL.ResolveVariable
解析为LocalVariableInfo
实例。它通过使用MethodBody.LocalVariables
属性来实现这一点。考虑以下示例:
偏移量 | Data | 描述 |
---|---|---|
00 | FE 0C | 操作码表示加载局部变量指令 (OpCodes.Ldloc )。 |
02 | 01 00 00 00 | 基于零的索引值 (1) 表示第二个局部变量。 |
操作码 (6): ldarg
(FE 09
), ldarga
(FE 0A
), starg
(FE 0B
), ldloc
(FE 0C
), ldloca
(FE 0D
), stloc
(FE 0E
)
ShortInlineBrTarget
类型: BranchInstruction<sbyte>
操作数是一个8位有符号整数,指定从指令末尾开始的字节偏移量。它最初由ReadOnlyListExtensions.ReadSByte
方法解码,该方法读取包含此值的字节。
之后,使用Transeric.Reflection.MethodIL.ResolveInstruction
将此偏移量解析为Transeric.Reflection.IInstruction
实例。这是通过对在该偏移量处发生的指令进行二分查找来实现的。考虑以下示例:
偏移量 | Data | 描述 |
---|---|---|
00 | 2B | 操作码表示分支指令 (OpCodes.Br_S )。 |
01 | 0F | 偏移量是指令末尾起15个字节 (0F )。这里,从数据开头算起的偏移量是17:2(指令末尾)加上15(分支)。 |
02 | 指令结束。 |
操作码 (14): br.s
(2B
), brfalse.s
(2C
), brtrue.s
(2D
), beq.s
(2E
), bge.s
(2F
), bgt.s
(30
), ble.s
(31
), blt.s
(32
), bne.un.s
(33
), bge.un.s
(34
), bgt.un.s
(35
), ble.un.s
(36
), blt.un.s
(37
), leave.s
(DE
)
ShortInlineI
类型: Instruction<byte>
或 Instruction<sbyte>
操作数是一个8位整数。根据指令,它由ReadOnlyListExtensions.ReadByte
(指令unaligned.
)或ReadOnlyListExtensions.ReadSByte
(指令ldc.i4.s
)解码,读取包含此值的字节。由于未引用元数据,因此不需要解析。考虑以下示例:
偏移量 | Data | 描述 |
---|---|---|
00 | 1F | 操作码表示加载8位常量指令 (OpCodes.Ldc_I4_S )。 |
01 | FF | 加载值 -1。 |
操作码 (2): ldc.i4.s
(1F
), unaligned.
(FE 12
)
ShortInlineR
类型: Instruction<float>
操作数是一个32位IEEE浮点数。它由ReadOnlyListExtensions.ReadSingle
方法解码,该方法读取包含此值的四个字节。由于没有引用元数据,因此不需要解析。考虑以下示例:
偏移量 | Data | 描述 |
---|---|---|
00 | 22 | 操作码表示加载32位常量指令 (OpCodes.Ldc_R4 )。 |
01 | 00 00 80 3F | 加载32位IEEE浮点数1.0。 |
操作码 (1): ldc.r4
(22
)
ShortInlineVar
类型: ParameterInstruction<byte>
或 VariableInstruction<byte>
操作数是一个无符号8位整数。它最初由ReadOnlyExtensions.ReadByte
方法解码,该方法读取包含此值的字节。根据指令,将创建ParameterInstruction<byte>
或VariableInstruction<byte>
的实例。
注意:如果System.OperandType为这两个不同的操作数类型定义了单独的操作数类型(例如ShortInlineArg
和ShortInlineVar
),那就太好了。遗憾的是,它没有。
ParameterInstruction<byte>
为指令ldarg.s
、ldarga.s
和starg.s
创建ParameterInstruction<byte>
实例。之后,操作数通过Transeric.Reflection.MethodIL.ResolveParameter
解析为ParameterInfo
实例。它通过使用System.Reflection.GetParameters
方法来实现这一点。由于GetParameters
不返回this
参数,因此索引值根据包含方法的调用约定(特别是CallingConventions.HasThis
)进行解释。考虑以下示例:
偏移量 | Data | 描述 |
---|---|---|
00 | 0E | 操作码表示加载参数指令 (OpCodes.Ldarg_S )。 |
01 | 01 00 00 00 | 基于零的索引值 (1) 表示第二个参数。 |
VariableInstruction<byte>
为指令ldloc.s
、ldloca.s
和stloc.s
创建一个VariableInstruction<byte>
实例。之后,操作数通过Transeric.Reflection.MethodIL.ResolveVariable
解析为LocalVariableInfo
实例。它通过使用MethodBody.LocalVariables
属性来实现这一点。考虑以下示例:
偏移量 | Data | 描述 |
---|---|---|
01 | 11 | 操作码表示加载局部变量指令 (OpCodes.Ldloc_S )。 |
02 | 01 00 00 00 | 基于零的索引值 (1) 表示第二个局部变量。 |
操作码 (6): ldarg.s
(0E
), ldarga.s
(0F
), starg.s
(10
), ldloc.s
(11
), ldloca.s
(12
), stloc.s
(13
)
.NET反射入门
对于对这个主题感兴趣的读者来说,不太可能也是反射的初学者。如果是这样,无疑有更好的文章来讲解这个主题。话虽如此,如果我至少不提供一个简要的介绍,我会感到很不好意思。
如前所述,.NET提供了一个功能相对丰富的框架,用于检查与程序集/应用程序关联的元数据。探索这个框架最简单的方法可能是在本文源代码中提供的Program.ReflectionPrimer
方法中进行调试。在那里,我提供了许多常见用例的示例。
下面我们将介绍框架中的一些主要类型:
类型 | 描述 |
---|---|
程序集 | 每个应用程序由一个或多个程序集组成。从Visual Studio的角度来看,构建一个项目基本上会导致创建Assembly (.exe 或.dll )。Assembly 提供的一些常见相关元素包括:名称、版本、产品、标题、版权信息、文件位置、模块和其他引用的程序集。 |
模块 | 每个程序集包含一个或多个模块。在大多数情况下,只有一个模块。Module 提供的一些常见相关元素包括:名称、类型以及解析元数据标记的方法。 |
类型 | 每个模块由一个或多个类型组成。每次创建class 或struct 时,都会创建一个Type 。还有大量的系统类型(例如System.Int32 和System.String )。Type 提供的一些常见相关元素包括:名称、基类型、字段、方法和属性。 |
MethodInfo | 每个Type 可能包含方法。当您创建一个函数/方法时,该方法会有相应的元数据 (MethodInfo )。MethodInfo 提供的一些常见相关元素包括:名称、返回类型、参数、调用约定、声明类型和方法体。在本文中,我们扩展了MethodBase (MethodInfo 由此派生)以提供方法中的中间语言指令。 |
PropertyInfo | 每个Type 还可以包含属性。当您创建一个属性时,该属性会有相应的元数据 (PropertyInfo )。PropertyInfo 提供的一些常见相关元素包括:名称、类型、get方法和set方法。 |
FieldInfo | 每个Type 还可以包含字段。当您创建一个字段时,该字段会有相应的元数据 (FieldInfo )。FieldInfo 提供的一些常见相关元素包括:名称和类型。 |
通用中间语言 (CIL) 简介
通用中间语言 (CIL) 的话题太过广泛,无法在本文中涵盖。此外,我对该话题不具备专业知识。IL只是类似于一种汇编语言。因为我有点老派(可以追溯到理解汇编语言是一项关键技能的时代),所以我对IL的理解刚好足以熟练阅读它。这对我来说有点像一种与生俱来的技能。
我想关于这个话题肯定有整本书。遗憾的是,我没有读过,也无法凭良心亲自推荐一本。话虽如此,我注意到论坛里有人推荐 Serge Lidin 的“Expert .NET 2.0 IL Assembler”。然而,我担心,由于我们已经发展到.NET 4.7.1,这个推荐可能有点过时了。同一作者似乎有更近期的书籍。
在我们开始描述一些IL之前,为了进行比较,让我们考虑一个简单的C# HelloWorld
程序:
using System;
namespace HelloWorld
{
public class Program
{
public static void Main(string[] args) =>
Console.WriteLine("Hello World!");
}
}
Main
方法中对应的指令如下:
IL_0000: ldstr "Hello World!"
IL_0005: call void [mscorlib]System.Console::WriteLine(string)
IL_000a: nop
IL_000b: ret
让我们考虑第一行:
IL_0000: ldstr "Hello World!"
此指令只是将一个字符串推送到堆栈上。堆栈除其他用途外,还用于向方法传递参数。堆栈在中间语言(和大多数汇编语言)中是非常重要的。大多数指令都会以某种方式修改堆栈。
此指令的不同部分如下:
零件 | 描述 |
---|---|
IL_0000 | 这只是方法中指令位置的标签。它实际上并不构成存储指令的字节数组。IL 部分代表中间语言。0000 是距方法开头的字节偏移量。虽然标签可以任意选择(在合理范围内),但大多数反汇编程序似乎更喜欢这种命名约定。 |
: | 这表示“: ”之前的部分是一个标签。 |
ldstr | 这是此指令的操作码 (OpCodes.Ldstr )。 |
"Hello World!" | 这只是传递给方法的字符串字面量。 |
转到第二行:
IL_0005: call void [mscorlib]System.Console::WriteLine(string)
此指令仅调用指定的方法。假设参数已先前推送到堆栈上。此指令的显著部分包括以下内容:
零件 | 描述 |
---|---|
call | 这是指令的操作码 (OpCodes.Call )。 |
void | 这是被调用方法的返回类型。在这种情况下,不返回任何内容。 |
[mscorlib] | 包含该方法的程序集名称 (mscorlib )。 |
System. | 包含该方法的命名空间 (System )。 |
Console: | 包含该方法的类名 (Console )。 |
WriteLine | 方法的名称。 |
(string) | 方法的参数类型。在这种情况下,有一个类型为string (System.String ) 的单个参数。 |
转到第三行:
IL_000a: nop
此指令不执行任何操作(“无操作”)。
转到第四行,也是最后一行:
IL_000b: ret
此指令只是从当前方法返回。
要实际构建程序(使用ILASM)并运行它,我们需要一些额外的元数据。以下最小程序可以构建并运行:
.assembly HelloWorld
{
}
.method static void Main()
{
.entrypoint
.maxstack 1
ldstr "Hello World!"
call void [mscorlib]System.Console::WriteLine(string)
ret
}
注意:如果我们将示例 C# 程序真正反汇编,它将包含更多元数据。为简单起见,此处省略了。
其他阅读
元数据和自描述组件
https://docs.microsoft.com/zh-cn/dotnet/standard/metadata-and-self-describing-components
ECMA C# 和通用语言基础设施标准
https://www.visualstudio.com/license-terms/ecma-c-common-language-infrastructure-standards/
ECMA 通用语言基础设施 (CLI) 第一至第六部分
http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-335.pdf
通用中间语言
https://zh.wikipedia.org/wiki/Common_Intermediate_Language
Mono.Cecil
http://www.mono-project.com/docs/tools+libraries/libraries/Mono.Cecil/
历史
- 2018年5月15日 - 原始版本上传
- 2018年5月20日 - 在Additional Reading中添加了对
Mono.Cecil
的引用和几个更有用的链接。 - 2018年6月15日 - 修复MetadataTokens部分末尾的错别字(缺少“.ResolveMethod”)。