.NET 文件格式 - 签名的幕后,第 2 部分(共 2 部分)





5.00/5 (31投票s)
.NET 文件格式中包含的签名的完整描述
目录
1. 签名(续)
这是第一部分的续篇。
1.1 LocalVarSig
LocalVarSig
签名也由 StandAloneSig.Signature
列索引,它存储方法运行时分配的所有局部变量的类型。LOCAL_SIG
元素是签名的前导符,其常量值为 0x07
,Count
元素是一个无符号整数(当然是压缩过的!),它存储与该方法关联的局部变量数量。BYREF
元素是 ELEMENT_TYPE_BYREF
常量的缩写(参见第一部分中的常量),它指示 Type
元素指向实际变量。还有一个值得一提的元素是 Constraint
元素,它表示目标类型在执行内存回收时不会被垃圾回收器移动,因为局部变量位于堆栈上(GC 不会在那里执行任何操作),变量的 Type
应该是引用类型(如 System.Object
- 分配在堆上)或值类型(如 System.Decimal
- 分配在堆栈上)。但当目标类型(固定)是值类型时,其定义应包含 BYREF
元素,在这种情况下,变量的引用存储在堆栈上,但变量本身分配在堆上。您可以在此处了解更多关于固定(pinning)的信息。在下面的图 1 中,您可以看到此签名的完整语法图。
我想特别提请您注意下面图中的 TYPEDBYREF
元素,这是类型化引用(typed reference),它不仅包含一个指向某个位置的托管指针(像普通引用一样),还包含数据在运行时表示。我从规范中引用它的描述:
"类型化引用局部变量签名表明,局部变量将同时包含指向某个位置的托管指针以及可以存储在该位置的类型的运行时表示。类型化引用签名类似于 byref 约束,但 byref 在类型描述中将类型作为 byref 约束的一部分(因此是静态的),而类型化引用则动态地提供类型信息。类型化引用本身就是一个完整的签名,不能与其他约束结合。特别是,无法指定一个类型化引用为 byref。"
当将未装箱(unboxed)的数据(即存储在堆栈上的数据,这些数据始终是值类型)通过 byref
传递给那些不严格限制其接受类型并除了传递指向某个位置的托管指针外还需要该位置的静态类型的方法时,类型化引用非常有用。类型化引用满足这些需求。还要注意,类型化引用参数可以指向堆栈上的一个位置,并且该位置的生命周期仅限于方法运行期间(在类型化引用分配期间),因此 CIL 编译器会对 byref 和类型化引用参数的生命周期应用适当的检查,请参阅ECMA-355 规范 §12.4.1.5.2。
示例 1
此示例代表在堆栈上(仅)声明 byref
值类型,示例代码以 CIL 语言编写,如下所示:
// Full source: LocalVarSig\1.il
// Binary: LocalVarSig\1.dll
// (...)
.method public static void TestMethod()
{
.locals init(int32 &IntVarByRef)
ret
}
下面的表格详细分析了此示例代码的 LocalVarSig
签名:
偏移量 | 值 | 含义 |
0x05 | 0x04 | 签名大小 |
0x06 | 0x07 | 签名前导符 (LOCAL_SIG 常量) |
0x07 | 0x01 | 此方法中声明的总变量数为一个 |
0x08 | 0x10 | 因为实际变量位于运行时堆上,所以存在值为 0x10 的 BYREF 元素 |
0x09 | 0x08 | 变量的类型 (int32 ),参见第一部分中的常量 |
示例 2
下面的示例演示了如果我们使用类型化引用,签名会发生什么变化。开始时,我们声明 IntVar
变量,下一行,我们使用 __makeref
关键字(此关键字未公开且不符合 CLS 标准)获取一个类型化引用,并将其保存在 TypedByRefVar
变量中。
// Full source: LocalVarSig\2.cs
// Binary: LocalVarSig\2.dll
// (...)
[CLSCompliant(false)]
public void TestMethod()
{
int IntVar = 0;
TypedReference TypedByRefVar = __makeref(IntVar);
}
此示例的 LocalVarSig
如下所示:
偏移量 | 值 | 含义 |
0x1E | 0x04 | 签名大小 |
0x1F | 0x07 | 签名前导符 (LOCAL_SIG 常量) |
0x20 | 0x02 | 此方法中声明的总变量数为两个 |
0x21 | 0x08 | 第一个变量的类型 (int32 ),参见第一部分中的常量 |
0x22 | 0x16 | 第二个变量的类型 (TYPEDBYREF ),参见第一部分中的常量 |
示例 3
现在来看一个稍微复杂一点的示例。在此示例代码中,我们创建了 TestDataClass
类,该类只有一个名为 StringVarToBePinned
的成员,类型为 string
。在 TestMethod
方法(标记为unsafe
)中,我们实例化 TestDataClass
类。在下面一行,我们尝试“固定”(pin)StringVarToBePinned
成员,并将它们的引用分配给 FixedVar
指针,使用fixed
关键字。此处理确保在 {
和 }
大括号之间,dataClass.StringVarToBePinned
成员不会被垃圾回收器移动,因此 FixedVar
指向该成员在 fixed
关键字大括号内始终有效。请注意,我们不能直接在方法中声明要固定的变量,因为这样的值已经固定(放在堆栈上),因此变量必须包装在 TestDataClass
类中(该类放在堆上)。
// Full source: LocalVarSig\3.cs
// Binary: LocalVarSig\3.dll
// compile with "/unsafe" switch
// (...)
public class TestDataClass
{
public string StringVarToBePinned;
}
public class TestClass
{
public unsafe void TestMethod()
{
TestDataClass dataClass = new TestDataClass();
fixed (char* FixedVar = dataClass.StringVarToBePinned) { }
}
}
此示例还因另一个原因而复杂:它在某个点使用了尚未描述的元素,即 TypeDefOrRefEncoded
。此元素定义了在哪个元数据表(TypeDef
、TypeRef
或 TypeSpec
)的哪一行描述了指定的类型。我们在此不深入探讨此元素的细节,如果您愿意,可以直接跳转到该元素2.2 TypeDefOrRefEncoded 小节的描述。
偏移量 | 值 | 含义 |
0x20 | 0x08 | 签名大小 |
0x21 | 0x07 | 签名前导符 (LOCAL_SIG 常量) |
0x22 | 0x03 | 此方法中声明的总变量数为三个 |
0x23 | 0x12 | 第一个变量的类型(CLASS - 后跟 TypeDefOrRefEncoded 元素),参见第一部分中的常量 |
0x24 | 0x08 | 第一个变量的类型在 TypeDef 元数据表的第 2 行中描述,即 TestDataClass 类。这是当前章节未解释的 TypeDefOrRefEncoded 元素。 |
0x25 | 0x0F | 第二个变量的类型(PTR - 后跟 Type 元素),参见第一部分中的常量 |
0x26 | 0x03 | 前一个字节指针的类型(char - 最终是 char* ),参见第一部分中的常量 |
0x27 | 0x45 | 第三个变量被固定,参见常量 |
0x28 | 0x0E | 第三个(被固定的)变量的类型(string ),参见常量 |
1.2 CustomAttrib
正如您所料,此签名存储自定义特性的实例,但与之前讨论的签名略有不同。关键区别在于,与 MethodRefSig
签名相比,CustomAttrib
存储提供给自定义特性的参数的值,而不存储参数的类型。换句话说,CustomAttrib
签名仅存储在实例化自定义特性时提供的参数(固定和命名)的值,关于它们的类型和数量的信息未在签名中重复。该签名由 CustomAttribute.Value
列索引。Parent
列指示在哪个表(TypeDef
- 用于类型,MethodDef
- 用于方法,依此类推)和在哪一行描述了被标注的元素(方法、类型等)。与其他签名相比,还有第二个显著区别:在 CustomAttrib
签名中,所有二进制值均以未压缩的 little-endian 字节顺序存储,PackedLen
项(如下文所述)和签名大小除外。我再说一遍,请勿混淆自定义特性(custom attribute)和自定义修饰符(custom modifier)!完整的语法图由四个部分组成,我们来看第一个。
到目前为止,这很简单。它以 Prolog
开始,其常量值为 0x0001
,占用两个字节(unsigned int16
- 未压缩且为 little-endian)。接下来是固定参数(FixedArg
在图 2b 中说明),它们的数量和类型可以通过检查 MethodDef
或 MemberRef
(当特性的类位于另一个程序集中时)元数据表中的关联构造函数行来获取。请注意,vararg
方法不能用作特性的构造函数。接着是命名参数的数量(NumNamed
是一个两字节的 unsigned int16
- 也未压缩且为 little-endian),最后是命名参数本身,重复 NumNamed
次。
这部分比前一部分稍微难一些,但也很简单。图中的上方路径表示参数不是单维、零基的数组(SZARRAY
,参见第一部分中的常量)。下方路径表示 SZARRAY
参数,即参数是数组。SZARRAY
数组中的元素数量存储在 int32
类型的 NumElem
元素中(未压缩且为 little-endian),占用四个字节。如果 SZARRAY
参数为 null
,则 NumNamed
设置为 0xFFFFFFFF
值。CLI完全不允许使用除一维数组(下界为零)之外的数组(SZARRAY
)。int32
类型的一维零基数组是 int32[]
,而不是 int32[,,]
,也不是 int32[3...8]
。如果您想了解更多关于 .NET 中数组的信息,请阅读 MSDN 杂志上的 .NET 中的数组类型文章。
这部分可能是四个部分中最奇怪的。Elem
的格式根据以下条件而变化(引用自规范):
如果参数类型是简单的(上面图中的第一行)(bool
、char
、float32
、float64
、int8
、int16
、int32
、int64
、unsigned int8
、unsigned int16
、unsigned int32
或 unsigned int64
),那么 'blob' 包含其二进制值(Val
)。(bool
是一个值为 0
(false
)或 1
(true
)的单字节;char
是一个两字节的 Unicode 字符;其他类型则具有其显而易见的含义。)如果参数类型是enum
,也使用此模式——只需存储 enum 底层整数类型的值。
如果参数类型是字符串(上面图中的中间行),那么 blob 包含一个 SerString
——一个 PackedLen
计数的字节数(压缩且为 big-endian - 由作者添加),后跟 UTF8 字符。如果字符串为 null,则其 PackedLen
值为 0xFF
(没有后续字符)。如果字符串为空(""
),则 PackedLen
值为 0x00
(没有后续字符)。
如果参数类型是 System.Type
(参见typeof
关键字 - 由文章作者添加)(同样,上面图中的中间行),则其值存储为 SerString
(如前一段定义),表示其规范名称。规范名称由定义它的程序集、其版本、区域性和公钥令牌组成。如果省略程序集名称,CLI 会首先在当前程序集中查找,然后是系统库(mscorlib);在这两种特殊情况下,允许省略程序集名称、版本、区域性和公钥令牌。
如果参数类型是 System.Object
(上面图中的第三行),则存储的值表示该值类型的“装箱”(boxed)实例。在这种情况下,blob 包含实际类型的 FieldOrPropType
(见下文),后跟参数的未装箱值。[注意:在这种情况下,无法传递 null 值。结束注释]
最后一部分说明了 NamedArg
元素(代表命名参数,可以是字段或属性)的格式。因为字段和属性可以同名,第一个元素是 FIELD
(当命名参数引用字段时,常量值为单字节 0x53
)或 PROPERTY
(当命名参数引用属性时,常量值为单字节 0x54
)。接下来是 FieldOrPropType
元素,它描述命名属性或字段的类型,占一到两个字节。如果命名参数的类型是未装箱的简单值类型(上面定义),则 FieldOrPropType
应包含一个关联类型的常量值(BOOLEAN
、CHAR
、I1
、U1
、I2
、U2
、I4
、U4
、I8
、U8
、R4
、R8
、STRING
- 参见第一部分中的常量表);但如果命名参数的类型是装箱的简单值类型,则 FieldOrPropType
元素前面有一个字节,值为 0x51
,在这种情况下,FieldOrPropType
占两个字节。FieldOrPropName
元素是 SerString
(上面解释),包含属性或字段的名称。最后是一个单独的 FixedArg
元素(前面显示)。所以,正如您所见,NamedArg
元素是正常的 FixedArg
,前面有一些附加信息,用于标识它代表哪个字段或属性。我希望我没有吓到您,正如您很快就会看到的,签名并不像看起来那么复杂。
示例 1
本例主要展示了 SerString
元素以及 CustomAttrib
如何区分用作命名参数的字段和属性。在下面的示例中,我们有一个 TestAttribute
属性,它需要提供一个类型为 int32
的固定参数 Fixed1
。此外,我们还可以(并且确实)提供两个额外的、类型分别为 int16
和 string
的命名参数,如下面的代码列表所示。
// Full source: CustomAttrib\1.cs
// Binary: CustomAttrib\1.dll
// (...)
[AttributeUsage(AttributeTargets.Class)]
public class TestAttribute : Attribute
{
public TestAttribute(int Fixed1) { }
public short Named1 { get; set; }
public string Named2;
}
[Test(1, Named1 = 1, Named2 = "Abcd")]
public class TestClass { }
这种情况下的完整 CustomAttrib
签名是 33 字节长,因此在某些点,我们将几个字节合并到一行中,并带有单个描述。
偏移量 | 值 | 含义 |
0x3E | 0x21 | 签名大小,存储为压缩整数,big-endian 字节顺序 |
0x3F
| 0x01
| Prolog,存储为未压缩且为 little-endian 的 unsigned int16 ,值为 0x0001 |
0x41
| 0x01
| 属性的第一个固定参数(Fixed1 )的值,该值为 0x00000001 ,并存储为未压缩的 little-endian int32 。这由图 2b 的上方行和图 2c 的第一条路径表示。 |
0x45
| 0x02
| 提供给属性的命名参数数量,由图 2a 上的 NumNamed 元素表示,并存储为 unsigned int16 ,little-endian。我们提供了正好两个可选参数,该两字节元素的值自然是 0x0002 。 |
0x47 | 0x54 | 此字节的值表示目标命名参数由属性(property)表示(参见第一部分中的常量),这是图 2d 上的 PROPOERTY 元素。 |
0x48 | 0x06 | 目标属性的类型(int16 ,参见第一部分中的常量)。此字节由图 2d 上的 FieldOrPropType 元素表示。 |
0x49
| 0x06
| 这是 SerString 字符串,它指定目标属性的名称(由图 2d 上的 FieldOrPropName 元素表示)。SerString 是一个普通的 Unicode 字符串,前面是其字节大小,大小以压缩整数存储,使用 big-endian 字节顺序。因此,我们有一个 6 字节长的字符串(偏移量 0x49 ),因为字符串名称不包含ASCII 表之外的任何字符,每个字符占用一个字节,我们可以轻松读取字符串文本,它是 Named1 。 |
0x50
| 0x01
| 属性的第一个命名参数(Named1 )的值,该值为 0x0001 ,并存储为未压缩的 little-endian int16 。这由图 2b 的上方行和图 2c 的第一条路径表示。 |
0x52 | 0x53 | 此字节的值表示目标命名参数由字段(field)表示(参见第一部分中的常量),这是图 2d 上的 FIELD 元素。 |
0x53 | 0x0E | 目标字段的类型(string ,参见第一部分中的常量)。此字节由图 2d 上的 FieldOrPropType 元素表示。 |
0x54
| 0x06
| 这是再次的 SerString 字符串,它指定目标属性的名称(由图 2d 上的 FieldOrPropName 元素表示)。此字符串的长度为 6 字节(查看偏移量 0x54 ),其余字节与前一个字符串非常相似,仅最后一个字节不同,字符串文本是 Named2 ,参见ASCII 表。 |
0x5B
| 0x04
| 属性的第二个命名参数(Named2 )的值,该值为 Abcd (参见ASCII 表)并存储为 SerString 。这由图 2b 的上方行和图 2c 的中间路径表示。因为 0x5F - 0x3E = 0x21 ,即最后一个偏移量 - 第一个偏移量 = 签名大小,所以签名在此结束。 |
示例 2
在本例中,我们将演示使用 System.Type
、SZARRAY
和装箱值类型作为下面定义的 TestAttribute
属性的参数时的签名格式。
// Full source: CustomAttrib\2.cs
// Binary: CustomAttrib\2.dll
// (...)
[AttributeUsage(AttributeTargets.Class)]
public class TestAttribute : Attribute
{
public TestAttribute(object Param1, int[] Param2, Type Param3) { }
}
[Test(1, new int[] {1, 2, 3}, typeof(string))]
public class TestClass { }
与前一个示例一样,签名非常长(有 116 字节!),我将其分成几个部分。
偏移量 | 值 | 含义 |
0x2B | 0x74 | 签名大小,存储为压缩整数,big-endian 字节顺序 |
0x2C
| 0x01
| Prolog,存储为未压缩且为 little-endian 的 unsigned int16 ,值为 0x0001 |
0x2E | 0x08 | 第一个固定参数的类型(int32 - 装箱在 System.Object 中),这种情况由图 2c 的第三条路径表示,其中值前面紧跟着值的类型。 |
0x2F
| 0x01
| 前面字节中指定的类型的值。因为该值类型是 int32 ,所以它正好占用 4 个字节。它以 little-endian 字节顺序存储,因此值为 0x00000001 。 |
0x33
| 0x03
| 接下来是第二个参数的定义。因为第二个参数是单维零基数组(SZARRAY ),这四个字节指定了提供给第二个参数数组的元素数量。此值以 little-endian 字节顺序存储为 unsigned int32 。 |
0x37
| 0x01
| 第二个参数中数组的第一个元素的值。由于数组类型是 int32 ,因此它是四字节长,值为 0x00000001 。 |
0x3B
| 0x02
| 第二个参数中数组的第二个元素的值。由于数组类型是 int32 ,因此它是四字节长,值为 0x00000002 。 |
0x3F
| 0x03
| 第二个参数中数组的第三个元素的值。由于数组类型是 int32 ,因此它是四字节长,值为 0x00000003 。 |
0x43
| 0x5A
| 这个 90 字节长的 SerString 描述了提供给第三个参数的类型的规范名称,其值为 System.String, mscorlib, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 。这由图 2c 的中间路径表示。 |
0x9E
| 0x00
| 两个结束字节,不是前一个 SerString 的一部分(0x9F - 0x44 != 0x5A ),但它们构成了整个 CustomAttrib 的一部分(0x9F - 0x2C = 0x74 ),并且不包含任何数据。我认为规范名称存在某些对齐,因此这些零是存在的,不幸的是规范对此没有任何说明。 |
1.3 MethodSpec
MethodSpec
签名很简单。它描述了一个泛型方法的每个实例化,由 MethodSpec.Signature
列索引,其语法如下:它以 GENRICINST
(您看到丢失的“E”了吗?)前导符开始,其单字节值为 0x0A
(此常量与第一部分中的常量表定义的 ELEMENT_TYPE_GENERICINST
值不同),其中 Type
重复 GenArgCount
次。
MethodSpecBlob ::=
GENRICINST GenArgCount Type Type*
示例 1
在下面的示例中,我们实例化了 TestMethod
泛型方法,提供了三个泛型参数。
// Full source: MethodSpec\1.cs
// Binary: MethodSpec\1.dll
// (...)
public class TestClass
{
public void TestMethod<GenArg1, GenArg2, GenArg3>() { }
}
public class TestRunClass
{
public void TestRunMethod()
{
new TestClass().TestMethod<short, int, string>();
}
}
此情况下的 MethodSpec
如下所示:
偏移量 | 值 | 含义 |
0x18 | 0x05 | 签名大小 |
0x19 | 0x0A | 前导符 |
0x1A | 0x03 | 提供给泛型方法的泛型参数的数量 |
0x1B | 0x06 | 第一个参数的类型(int16 ),参见第一部分中的常量 |
0x1C | 0x08 | 第二个参数的类型(int32 ),参见第一部分中的常量 |
0x1D | 0x0E | 第三个参数的类型(string ),参见第一部分中的常量 |
1.4 TypeSpec
TypeSpec
签名由 TypeSpec.Signature
列索引,并在以下情况下使用:实例化多维数组类型的类型、实例化前面带有自定义修饰符的单维数组类型的类型、实例化泛型类型以及其他操作,如下面图所示。由于某些元素尚未解释(如自定义修饰符、数组形状),我们仅使用 TypeSpec
签名的有限功能。在下一章中,我们将重点介绍 CustomMod
、ArrayShape
、TypeDefOrRefEncoded
元素,然后回到 TypeSpec
签名并使用其剩余功能。另请注意,与前面的示例不同,其中也使用了 GENRICINST
(缺少“E”)常量/前导符,在 TypeSpec
中,使用了 ELEMENT_TYPE_GENERICINST
常量,该常量在常规常量表(在文章第一部分)中定义。
TypeSpecBlob ::=
PTR CustomMod* VOID
| PTR CustomMod* Type
| FNPTR MethodDefSig
| FNPTR MethodRefSig
| ARRAY Type ArrayShape
| SZARRAY CustomMod* Type
| GENERICINST (CLASS | VALUETYPE) TypeDefOrRefEncoded GenArgCount Type Type*
示例 1
在此示例中,我们实例化了 TypeSpec
泛型类型,如下面的代码列表所示。
// Full source: TypeSpec\1.cs
// Binary: TypeSpec\1.dll
// (...)
public class TestClass<GenArg1, GenArg2> { }
public class TestRunClass
{
public void TestRunMethod()
{
TestClass<int, string> TestVar = new TestClass<int, string>();
}
}
这种情况下的 TypeSpec
如下所示:
偏移量 | 值 | 含义 |
0x13 | 0x06 | 签名大小 |
0x14 | 0x15 | ELEMENT_TYPE_GENERICINST 常量,参见第一部分中的常量表 |
0x15 | 0x12 | 泛型类型的类型(CLASS ),参见第一部分中的常量表 |
0x16 | 0x08 | 实例化的泛型类型在 TypeDef 元数据表的第 2 行中描述。这是当前章节未解释的 TypeDefOrRefEncoded 元素。 |
0x17 | 0x02 | 提供给该类型的泛型参数数量为两个。 |
0x18 | 0x08 | 第一个泛型参数的类型(int32 ),参见第一部分中的常量 |
0x19 | 0x0E | 第二个泛型参数的类型(string ),参见第一部分中的常量 |
1.5 MarshalSpec
当在字段、参数和返回参数上使用 MarshalAs 属性时,会生成 MarshalSpec
签名。它指定通过平台调用(Platform Invoke)与非托管代码调用/返回时数据的封送方式。该签名在 FieldMarshal.NativeType
列中索引。元数据表的名称有点误导,事实上,MarshalSpec
描述的是字段、参数还是返回参数并不重要,它始终由前面提到的列索引。下面的语法列表中的 ParamNum
和 NumElem
元素分别描述了方法调用中提供元素数量的参数、元素数量或附加元素。这两个元素都在签名中以压缩整数形式存储,其目的是帮助计算数组在内存中占用的总字节大小。Microsoft 特有的封送描述符实现比这里描述的更丰富,并使用了额外的常量和扩展语法。如果您想了解更多关于 Microsoft 实现 MarshalSpec
的信息,请参阅元数据规范第 II 部分 - §23.4。
MarshalSpec ::=
NativeIntrinsic
| ARRAY ArrayElemType
| ARRAY ArrayElemType ParamNum
| ARRAY ArrayElemType ParamNum NumElem
ArrayElemType ::=
NativeIntrinsic
NativeIntrinsic ::=
BOOLEAN | I1 | U1 | I2 | U2 | I4 | U4 | I8 | U8 | R4 | R8
| LPSTR | LPSTR | INT | UINT | FUNC
要计算数组的字节大小,将使用以下伪代码,其中 @ParamNum
代表为参数 ParamNum
传递的值。
if ParamNum = 0
SizeInBytes = NumElem * sizeof (elem)
else
SizeInBytes = ( @ParamNum + NumElem ) * sizeof (elem)
endif
此签名的常量表如下表所示。在上面的语法描述和本小节的示例中,使用了常量的缩写而不是全名。
名称 | 值 |
NATIVE_TYPE_BOOLEAN | 0x02 |
NATIVE_TYPE_I1 | 0x03 |
NATIVE_TYPE_U1 | 0x04 |
NATIVE_TYPE_I2 | 0x05 |
NATIVE_TYPE_U2 | 0x06 |
NATIVE_TYPE_I4 | 0x07 |
NATIVE_TYPE_U4 | 0x08 |
NATIVE_TYPE_I8 | 0x09 |
NATIVE_TYPE_U8 | 0x0A |
NATIVE_TYPE_R4 | 0x0B |
NATIVE_TYPE_R8 | 0x0C |
NATIVE_TYPE_LPSTR | 0x14 |
NATIVE_TYPE_LPWSTR | 0x15 |
NATIVE_TYPE_INT | 0x1F |
NATIVE_TYPE_UINT | 0x20 |
NATIVE_TYPE_FUNC | 0x26 |
NATIVE_TYPE_ARRAY | 0x2A |
NATIVE_TYPE_MAX | 0x50 |
示例 1
让我们从下面代码列表中所示的最简单的可能示例开始。
// Full source: MarshalSpec\1.cs
// Binary: MarshalSpec\1.dll
// (...)
[MarshalAs(UnmanagedType.LPWStr)]
public string TestField;
此代码生成了以下 MarshalSpec
签名:
偏移量 | 值 | 含义 |
0x1C | 0x01 | 签名大小 |
0x1D | 0x15 | TestField 字段在非托管代码中被封送为LPWSTR 。 |
示例 2
现在是更复杂的示例了。我们将把 int32
类型数组封送为LPArray(C 风格数组第一个元素的指针)。由于此数组类型不提供关于关联数组数据秩(rank)和边界(bounds)的信息,因此我们必须指定方法的哪个参数负责提供数组元素数量的信息。这通过指定 SizeParamIndex 可选参数来实现。此外,我们还设置了 SizeConst 可选参数,该参数指定 Param1
数组包含的元素比 ArraySize
参数指定的元素多 10 个。请注意,还有一个 SafeArray 数组类型,它是一个自描述数组,包含类型、秩和边界信息,并且不需要在 MarshalAsAttribute 中设置任何可选参数,但它是 Microsoft 特有的,因此在此不作描述。
// Full source: MarshalSpec\2.cs
// Binary: MarshalSpec\2.dll
// (...)
public void TestMethod(
[MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 2, SizeConst = 10)] int[] Param1,
int ArraySize)
{
// nop
}
上述代码应生成以下 MarshalSpec
签名。
偏移量 | 值 | 含义 |
0x1B | 0x05 | 签名大小 |
0x1C | 0x2A | 封送参数的类型(ARRAY ),参见封送描述符的常量表 |
0x1D | 0x50 | MAX 常量(参见封送描述符的常量表)表示此数组不提供关于数组元素类型的信息。 |
0x1E | 0x02 | ParamNum 参数,存储为压缩整数 |
0x1F | 0x0A | NumElem 参数,存储为压缩整数 |
0x20 | 0x01 | ElemMult 参数,存储为压缩整数。这是一个奇怪的参数,整个规范只提到它两次,说如果封送类型是 ARRAY ,则 ElemMult 必须设置为 0x01 ,但没有指定其含义及其在 MarshalSpec 签名中的位置(请参阅元数据规范第 II 部分 §22.17)。 |
2. 元素
我们已经讨论了所有的签名,但这还不是全部。签名由更小的部分组成,称为“元素”(我这样称呼它们)。它们之所以分开,是因为它们构成多个签名的一部分,因此无需在每个签名中重复解释特定元素。在本章中,我们将仔细研究它们。
2.1 CustomMod
这个元素在讨论过的签名中经常重复出现,因此我们从它开始。自定义修饰符(custom modifiers)类似于自定义特性(custom attributes),但与它们不同的是,自定义修饰符是签名的一部分。自定义修饰符在 CIL 中使用 modreq
(必需修饰符)和 modopt
(可选修饰符)关键字在方法声明中定义。两者都需要提供一个类型(类或结构)作为其“参数”。仅因添加了自定义修饰符(必需或可选)而不同的两个签名不被视为匹配,并且正如规范所说:
必需修饰符和可选修饰符之间的区别对于 CLI 以外处理元数据的工具(通常是编译器和程序分析器)很重要。必需修饰符表示被修饰项具有不应被忽略的特殊语义,而可选修饰符可以被忽略。例如,C 编程语言中的 const 限定符可以使用可选修饰符建模,因为具有 const 限定参数的方法的调用者不必以任何特殊方式处理它。另一方面,C++ 中应被复制构造的参数应标记为必需自定义属性,因为是调用者进行复制。
不幸的是,C# 在处理带有自定义修饰符的参数方面存在一些问题。您可以在 CodeBetter.com 上的Modopt、方法签名和不完整的规范!和更多关于 modopt 的信息文章中阅读相关内容。
CMOD_OPT
和 CMOD_REQD
只是在第一部分中的常量表中定义的常量。TypeDefEncoded
和 TypeRefEncoded
元素实际上是单个 TypeDefOrRefEncoded
元素,将在下一个子节中得到全面讨论。请注意,字段、属性、参数或返回参数可以附加零个、一个或多个 CustomMod
。据我所知,除了使用System.Reflection.Emit 之外,没有办法使用 C# 定义自定义修饰符。在System.Runtime.CompilerServices 命名空间中,您可以找到一些指示符(我这样称呼它们),可以应用于自定义修饰符,例如CallConvCdecl、IsConst、IsLong。
示例 1
在下面的示例中,我们使用 modreq
修饰符标注了 TestField
字段,因此 CustomMod
位于 FieldSig
签名内,该签名在文章开头、图 2 中显示。IsLong 指示符区分了 C++ 中的 long
和整数,但实际上,在本例中,此自定义修饰符没有特殊语义,我们只是想演示签名中 CustomMod
元素 的格式。TypeDefOrRefEncoded
元素的值显示了两次,使用两种数字系统——十六进制(<sub>16</sub>
下标)和二进制(<sub>2</sub>
下标)。在下一节中,您将看到原因。
// Full source: CustomMod\1.il
// Binary: CustomMod\1.dll
// (...)
.field public int64 modreq([mscorlib]System.CompilerServices.IsLong) TestField
下表展示了由 modreq
关键字生成的、由 Field.Signature
列索引的整个 FieldSig
签名,以及嵌入的自定义修饰符。
偏移量 | 值 | 含义 |
0x01 | 0x04 | 签名大小 |
0x02 | 0x06 | FieldSig 的前导符 |
0x03 | 0x1F | 遇到自定义必需修饰符(modreq ),参见第一部分中的常量 |
0x04 | 0x05 16
| TypeDefOrRefEncoded 元素,在此情况下,它指向 TypeRef 表的第一行,即IsLong 类。该元素将在下一节中描述。 |
0x05 | 0x0A | 字段的类型(int64 ),参见第一部分中的常量 |
2.2 TypeDefOrRefEncoded
现在我们将尝试揭开目前最神秘的元素,幸运的是,即 TypeDefOrRefEncoded
,它并不像看起来那么复杂。该元素确定引用的类型信息位于哪个元数据表以及该表的哪一行。前两个最低有效位编码元数据表:0
表示 TypeDef
(引用的类型位于当前程序集中),1
表示 TypeRef
(引用的类型位于单独的程序集中),2
表示 TypeSpec
(引用的类型是泛型类型、数组等,参见4.9 TypeSpec
)。其余位编码行索引。请注意,索引是从 1 开始的,换句话说,每个元数据表中的第一行始终是 1
,而不是 0
。
示例 1
在此示例中,我们声明了一个带有自定义必需修饰符的单个字段。modreq
接受声明在同一程序集中的 TestClass
类型作为参数,如下所示。
// Full source: TypeDefOrRefEncoded\1.il
// Binary: TypeDefOrRefEncoded\1.dll
// (...)
.class public TestClass extends [mscorlib]System.Object { }
.field public int64 modreq(TestClass) TestField
上面示例代码的 FieldSig
如下所示:
偏移量 | 值 | 含义 |
0x01 | 0x04 | 签名大小 |
0x02 | 0x06 | FieldSig 的前导符 |
0x03 | 0x1F | 遇到自定义必需修饰符(modreq ),参见第一部分中的常量 |
0x04 | 0x08 16
| TypeDefOrRefEncoded 元素,这次它指向 TypeDef 表的第二行。前两位最低有效位表示表类型(00 2 - TypeDef ),位 3 到 8 表示表中的行号(000010 2 - 2 ),即 TestClass 。现在,将其与上一节中的 TypeDefOrRefEncoded 元素进行比较。 |
0x05 | 0x0A | 字段的类型(int64 ),参见第一部分中的常量 |
2.3 Param
此元素描述了提供给方法或属性的单个参数,因此它是 PropertySig、MethodDefSig、MethodRefSig 等的一部分。这是 Param
元素的语法图:
示例 1
在下面的 TestMethod
方法中,单个参数附加了两个自定义修饰符。此示例的目的是演示 Param
元素 的格式,并再次展示 TypeDefOrRefEncoded
元素 的工作方式。
// Full source: Param\1.il
// Binary: Param\1.dll
// (...)
.class public TestClass extends [mscorlib]System.Object { }
.method public static void TestMethod(int32 modopt(TestClass)
modreq([mscorlib]System.Runtime.CompilerServices.IsLong) Param1)
{
ret
}
该方法的关联 MethodDefSig
签名是:
偏移量 | 值 | 含义 |
0x01 | 0x08 | 签名大小 |
0x02 | 0x00 | 方法是static |
0x03 | 0x01 | 参数数量 |
0x04 | 0x01 | 返回值的类型(void ),参见第一部分中的常量 |
0x05 | 0x1F | 遇到自定义必需修饰符(modreq ),参见第一部分中的常量 |
0x06 | 0x09 16
| 引用的行是 TypeRef 元数据表中的第 2 行,即 IsLong 类型 |
0x07 | 0x20 | 遇到自定义可选修饰符(modopt ),参见第一部分中的常量 |
0x08 | 0x08 16
| 引用的行是 TypeDef 元数据表中的第 2 行,即 TestClass 类型 |
0x09 | 0x08 | 第一个参数的类型(int32 ),参见第一部分中的常量 |
2.4 RetType
此元素几乎与 Param
元素相同,它多了一个可以包含 VOID
类型的额外路径。由于下面该元素的语法图具有自解释性,因此本小节不提供示例。
RetType
元素语法图2.5 Type
Type
元素描述类型(int32
、bool
、string
等)以及数组、泛型实例类型和复杂类型(类和结构),这难道不令人惊讶吗?下面的列表展示了该元素的语法图,当然,用大写字母书写的词是常量,其值可以在第一部分中的常量表中找到。您可能会想,为什么 GENERICINST
常量是该元素的一部分,但请记住,TypeSpec
、MethodSpec
和 MethodDefSig
签名有不同的目的!
Type ::=
BOOLEAN | CHAR | I1 | U1 | I2 | U2 | I4 | U4 | I8 | U8 | R4 | R8 | I | U |
| ARRAY Type ArrayShape
| CLASS TypeDefOrRefEncoded
| FNPTR MethodDefSig
| FNPTR MethodRefSig
| GENERICINST (CLASS | VALUETYPE) TypeDefOrRefEncoded GenArgCount Type *
| MVAR number
| OBJECT
| PTR CustomMod* Type
| PTR CustomMod* VOID
| STRING
| SZARRAY CustomMod* Type
| VALUETYPE TypeDefOrRefEncoded
| VAR number
示例 1
让我们看看当方法接受泛型类型作为普通参数时,MethodDefSig
签名会发生什么。
// Full source: Type\1.cs
// Binary: Type\1.dll
// (...)
public class TestClass<GenArg1, GenArg2> { }
public class TestRunClass
{
public void TestRunMethod()
{
TestMethod(new TestClass<int, string>());
}
public void TestMethod(TestClass<int, string> Param1) { }
}
剖析 TestMethod
方法的 MethodDefSig
签名。
偏移量 | 值 | 含义 |
0x0E | 0x09 | 签名大小 |
0x0F | 0x20 | 该方法是实例方法 |
0x10 | 0x01 | 普通参数的数量 |
0x11 | 0x01 | 返回值的类型(void ),参见第一部分中的常量 |
0x12 | 0x15 | 第一个参数的类型是泛型类型(GENERICINST ),参见第一部分中的常量 |
0x13 | 0x12 | 第一个参数的类型是泛型类(CLASS ),参见第一部分中的常量 |
2.6 ArrayShape
我认为许多使用 .NET 平台的人知道数组可以有多个维度,但不知道数组中的每个维度都可以有一个下界,这可能是因为大多数开发人员使用 C# 语言,该语言不允许使用下界,除非使用 Array.CreateInstance
方法来创建此类数组类型。ArrayShape
元素保存了多维数组的完整定义。它存储数组的维度数量、每个维度的长度和下边界。下面的语法图以及从规范中复制的简要描述。
Rank
是一个整数(以压缩形式存储,参见 §23.2),指定数组中的维度数(应为 1 或更多)。NumSizes
是一个压缩整数,表示有多少个维度具有指定的大小(应为 0 或更多)。Size
是一个压缩整数,指定该维度的长度——序列从第一个维度开始,总共包含 NumSizes
项。类似地,NumLoBounds
是一个压缩整数,表示有多少个维度具有指定的下界(应为 0 或更多)。LoBound
是一个压缩整数,指定该维度的下界——序列从第一个维度开始,总共包含 NumLoBounds
项。这两个序列中的任何维度都不能跳过,但指定维度的数量可以少于 Rank
。
注意:请勿混淆多维数组和交错数组(jagged arrays)。CIL 中的多维数组可以是例如 int32[,]
,而交错数组是 int32[][]
。另请注意,ArrayShape
仅存储关于多维数组的信息!单维数组表示为 SZARRAY
常量——仅此而已(参见Type
元素)。要了解有关 .NET 中数组的更多信息,请参阅 MSDN 杂志上的 .NET 中的数组类型文章。
重要提示:不幸的是,正如我们在第二个示例中将看到的,ILASM
编译器在处理数组的下界(图 6 上的 LoBound
字段)时存在一些问题,下界乘以了两次!这当然是不正确的,因为规范说明下界应在签名中存储而无需进行任何更改。下面,您可以看到一个从规范复制的表,其中显示了示例数组声明及其在 ArrayShape
元素中的正确参数。此外,规范没有指定 NumSizes
和 NumLoBounds
字段在什么情况下可能小于 Rank
字段。根据我的观察,NumSizes
和 NumLoBounds
字段仅在一种情况下小于 Rank
——当未为所有维度指定下界时(这在下表第二行中表示);否则,NumSizes
和 NumLoBounds
始终等于 Rank
。这与下表中第三行和第五行的情况相矛盾。
声明 | 类型 | 秩 | NumSizes | 大小 | NumLoBounds | LoBound |
[0...2] | I4 | 1 | 1 | 3 | 0 | - |
[,,,,,,] | I4 | 7 | 0 | - | 0 | - |
[0...3, 0...2,,,,] | I4 | 6 | 2 | 4 3 | 2 | 0 0 |
[1...2, 6...8] | I4 | 2 | 2 | 2 3 | 2 | 1 6 |
[5, 3...5, , ] | I4 | 4 | 2 | 5 3 | 2 | 0 3 |
示例 1
让我们看看 ArrayShape
的实际工作原理。
// Full source: ArrayShape\1.il
// Binary: ArrayShape\1.dll
// (...)
.field public int32[,,] TestField
上述多维数组应生成以下 FieldSig
签名。
偏移量 | 值 | 含义 |
0x01 | 0x06 | 签名大小 |
0x02 | 0x06 | FieldSig 的前导符 |
0x03 | 0x14 | 字段类型值为 ARRAY ,参见第一部分中的常量 |
0x04 | 0x08 | 数组类型为 int32 ,参见第一部分中的常量 |
0x05 | 0x03 | 数组的维度数(图 6 上的 Rank 字段) |
0x06 | 0x00 | 未指定数组维度的大小(图 6 上的 NumSizes 字段) |
0x07 | 0x00 | 未指定数组维度的下界(图 6 上的 NumLoBounds 字段) |
示例 2
此示例旨在向您展示当声明带有指定下界的多维数组时 ArrayShape
元素 的行为。
// Full source: ArrayShape\2.il
// Binary: ArrayShape\2.dll
// (...)
.field public int32[0...5,,4...6] TestField
整个 FieldSig
签名如下:
偏移量 | 值 | 含义 |
0x01 | 0x0C | 签名大小 |
0x02 | 0x06 | FieldSig 的前导符 |
0x03 | 0x14 | 字段类型值为 ARRAY ,参见第一部分中的常量 |
0x04 | 0x08 | 数组类型为 int32 ,参见第一部分中的常量 |
0x05 | 0x03 | 数组的维度数(图 6 上的 Rank 字段) |
0x06 | 0x03 | 此数组的大小数(图 6 上的 NumSizes 字段) |
0x07 | 0x06 | 数组第一个维度的长度(图 6 上的 Size 字段) |
0x08 | 0x00 | 第二个数组维度的长度,零表示未指定(图 6 上的 Size 字段) |
0x09 | 0x03 | 第三个数组维度的长度(图 6 上的 Size 字段) |
0x0A | 0x03 | 此数组的下界数量(图 6 上的 NumLoBounds 字段) |
0x0B | 0x00 | 数组第一个维度的下界(图 6 上的 LoBound 字段) |
0x0C | 0x00 | 数组第二个维度的下界(图 6 上的 LoBound 字段) |
0x0D | 0x08 | 数组第三个维度的下界(图 6 上的 LoBound 字段)。边界乘以二,参见当前小节开头的重要说明。 |
示例 3
现在让我们看看 ArrayShape
元素 在现实中的样子,并将结果与规范进行比较。
// Full source: ArrayShape\3.il
// Binary: ArrayShape\3.dll
// (...)
.field public int32[0...2] TestField
是的,NumLoBounds
等于 Rank
,尽管规范说明 NumLoBounds
应等于零。
偏移量 | 值 | 含义 |
0x01 | 0x08 | 签名大小 |
0x02 | 0x06 | FieldSig 的前导符 |
0x03 | 0x14 | 字段类型值为 ARRAY ,参见第一部分中的常量 |
0x04 | 0x08 | 数组类型为 int32 ,参见第一部分中的常量 |
0x05 | 0x01 | 数组的维度数(图 6 上的 Rank 字段) |
0x06 | 0x01 | 此数组的大小数(图 6 上的 NumSizes 字段) |
0x07 | 0x03 | 数组第一个维度的长度(图 6 上的 Size 字段) |
0x08 | 0x01 | 此数组的下界数量(图 6 上的 NumLoBounds 字段) |
0x09 | 0x00 | 数组第一个维度的下界(图 6 上的 LoBound 字段) |
3. 结论
正如您所见,签名是一个复杂的怪物,但它使得 .NET 可执行文件小巧、紧凑且一致。如果您有任何疑问、提示或请求,请随时在下方添加评论,建设性评论始终受到欢迎。
4. 参考资料
- .NET 文件格式 - Daniel Pistelli 的 CodeProject 文章
- 调用约定揭秘 - Nemanja Trifunovic 的 CodeProject 文章深入了解 Win32 可移植可执行文件格式
- .NET 中的数组类型
- 元数据规范第 II 部分
- ECMA-335 规范 - 所有六个部分
- Mono.Cecil - .NET 的反射库
- CFF Explorer - 用于查看/编辑 .NET 元数据和 PE 头文件的工具
- 端序 - 维基百科上的文章
5. 修订历史
- 1.0:2009 年 9 月 26 日:初始发布