.NET 文件格式 - 底层签名,第 1 部分(共 2 部分)





5.00/5 (47投票s)
.NET 文件格式中签名的完整描述
目录
1. 引言
前段时间,我不得不自己编写 .NET 反射引擎,我需要它来构建 .NET 文档生成器。几周后,我发现了 Mono.Cecil,(在一些修改后)它对我来说非常理想,因此我放弃了开发自己的反射引擎。我认为这是一个绝佳的机会,感谢所有 CodeProject 社区成员分享他们近年来辛勤积累的知识,并撰写一篇文章。
这篇分两部分的文章涵盖了签名,这是 .NET 文件中仅次于元数据(metadata)的第二重要部分。关于元数据,Daniel Pistelli 写了一篇出色的文章,可以在这里找到。强烈建议在继续之前阅读这篇文章,此外,你还可以阅读 MSDN 杂志上的 深入了解 Win32 可移植可执行文件格式,该文章描述了构成 .NET 元数据、签名和中间语言(IL)代码基础的 PE 文件格式。当然,几乎所有内容都可以在 分区 II 元数据规范中找到,但一如既往,规范为了完整性牺牲了可读性,这也是我写这篇文章的另一个原因。
2. 什么是签名?
简而言之,签名存储了无法在元数据表中紧凑地存储的数据,例如,参数类型、传递给自定义属性的参数、封送描述符等。将参数类型等信息存储在表中会导致数据过度碎片化、难以理解并带来性能损失,因此 CLI/CLR 工程师发明了签名,允许以紧凑且体面的方式存储前面提到的数据。在接下来的章节中,你将清楚地看到它们为何如此重要。
3. 入门
在本章中,你将学习一些理解文章其余部分所需的内容,所以不要低估它,这里包含的信息将在后续章节中得到广泛应用。与签名相关的、但在此未涵盖的术语将在之后沿途解释。
3.1 CFF Explorer
我们将使用由 Daniel Pistelli 编写的 CFF Explorer 来查看 .NET 元数据和签名代码。CFF Explorer 是一款免费工具,能够查看和编辑 PE 头、资源以及 .NET 元数据的某些字段和标志,你可以在 此网站 下载。在下面的图片中,你可以看到 CFF Explorer 运行并加载了示例程序集。通常,签名由“签名”列索引,红色圆圈内是指向 `#Blob` 堆中 `MethodDefSig` 签名的位置,该签名由 `Method.Signature` 索引,可以通过单击绿色圆圈来探索。
3.2 字节序
我认为最好的描述来自 Wikipedia
“在计算中,字节序是指用于表示某种数据的字节(有时是位)顺序。典型情况是整数值在计算机内存中按字节存储的顺序(相对于给定的内存寻址方案)以及在网络或其他介质上传输的顺序。当专门讨论字节时,字节序也简单地称为字节顺序。”
在我们的情况下,我们将字节序视为字节顺序,即存储在文件中的数据(通常是整数)。在文件中表示数据有两种方法(顺序):大端序(big-endian)和小端序(little-endian)。PE/.NET 文件同时使用这两种方法,因此下面我们将分别讨论它们。
大端序
在这种排序方法中,最高有效字节存储在文件偏移量最低的位置,下一个字节值存储在随后的文件偏移量,依此类推。在下面的示例中,我们想在偏移量 `100` 处存储值 `0x1B5680DA`,那么内存会是这样的
100 | 101 | 102 | 103 | ||
... | 1B | 56 | 80 | DA | ... |
小端序
与大端序相比,小端序以相反的顺序存储数据,即最低有效字节存储在最低偏移量处。在这种情况下,我们的值 `0x1B5680DA` 存储为小端序将是这样的
100 | 101 | 102 | 103 | ||
... | DA | 80 | 56 | 1B | ... |
3.3 压缩整数
签名在存储到 `#Blob` 堆之前会被压缩,方法是压缩签名中嵌入的整数。与具有固定大小的常规整数不同,压缩整数仅使用所需的空间,几乎所有签名都使用整数压缩而不是常规的固定大小整数。因为签名中绝大多数数字小于 128,所以节省的空间非常可观。下面,你可以看到从规范复制的编码算法
如果值介于 `0`(`0x00`)和 `127`(`0x7F`)之间(包括),则编码为一个字节整数(第 7 位为 0,值保存在第 6 位到第 0 位)。
如果值介于 `2^8`(`0x80`)和 `2^14` - 1(`0x3FFF`)之间(包括),则编码为 2 字节整数,其中第 15 位为 1,第 14 位为 0(值保存在第 13 位到第 0 位)。
否则,编码为 4 字节整数,其中第 31 位为 1,第 30 位为 1,第 29 位为 0(值保存在第 28 位到第 0 位)。
空字符串应由保留的单个字节 `0xFF` 表示,后面没有数据。
示例 1
值小于 `0x80`,因此这是第一种情况,我们省略了三个不必要的字节。
原始值(32 位) | 压缩值 | 节省的字节 | |
十六进制 | 00 00 00 03 | 0x03 | 3 |
二进制 | 00000000 0000000 00000000 00000011 | 00000011 | - |
示例 2
与示例 1 相同。
原始值(32 位) | 压缩值 | 节省的字节 | |
十六进制 | 00 00 00 7F | 7F | 3 |
二进制 | 00000000 0000000 00000000 01111111 | 01111111 | - |
示例 3
在此示例中,原始值等于 `0x80`。虽然一个字节足以保存 `0x80`,但使用压缩整数需要清除最后一个位,因此要将值 `0x80` 保存为压缩整数,我们必须额外使用一个字节。
原始值(32 位) | 压缩值 | 节省的字节 | |
十六进制 | 00 00 00 80 | 80 80 | 2 |
二进制 | 00000000 0000000 00000000 10000000 | 10000000 10000000 | - |
示例 4
我们节省了两个不必要的字节
原始值(32 位) | 压缩值 | 节省的字节 | |
十六进制 | 00 00 2E 57 | AE 57 | 2 |
二进制 | 00000000 0000000 00101110 01010111 | 10101110 01010111 | - |
显然,压缩是有代价的,必须保留一些位来指示压缩整数占用的字节数,因此最大可编码整数是 29 位长,值为 0x1FFFFFFF。压缩整数在物理上使用大端字节序进行编码。
3.4 常量
以下列表代表几乎所有签名中经常使用的常见常量。在文章的后续部分,我们将经常通过缩写引用它们,只使用姓氏成员,例如 `ELEMENT_TYPE_I8` 缩写为 `I8`,`ELEMENT_TYPE_STRING` 缩写为 `STRING`,依此类推。
名称 | 值 | 备注 |
ELEMENT_TYPE_END | 0x00 | 标记列表的结束 |
ELEMENT_TYPE_VOID | 0x01 | System.Void |
ELEMENT_TYPE_BOOLEAN | 0x02 | System.Boolean |
ELEMENT_TYPE_CHAR | 0x03 | System.Char |
ELEMENT_TYPE_I1 | 0x04 | System.SByte |
ELEMENT_TYPE_U1 | 0x05 | System.Byte |
ELEMENT_TYPE_I2 | 0x06 | System.Int16 |
ELEMENT_TYPE_U2 | 0x07 | System.UInt16 |
ELEMENT_TYPE_I4 | 0x08 | System.Int32 |
ELEMENT_TYPE_U4 | 0x09 | System.UInt32 |
ELEMENT_TYPE_I8 | 0x0A | System.Int64 |
ELEMENT_TYPE_U8 | 0x0B | System.UInt64 |
ELEMENT_TYPE_R4 | 0x0C | System.Single |
ELEMENT_TYPE_R8 | 0x0D | System.Double |
ELEMENT_TYPE_STRING | 0x0E | System.String |
ELEMENT_TYPE_PTR | 0x0F | 非托管指针,后跟 `Type` 元素。 |
ELEMENT_TYPE_BYREF | 0x10 | 托管指针,后跟 `Type` 元素。 |
ELEMENT_TYPE_VALUETYPE | 0x11 | 值类型修饰符,后跟 `TypeDef` 或 `TypeRef` 令牌 |
ELEMENT_TYPE_CLASS | 0x12 | 类类型修饰符,后跟 `TypeDef` 或 `TypeRef` 令牌 |
ELEMENT_TYPE_VAR | 0x13 | 泛型类型定义中的泛型参数,表示为数字 |
ELEMENT_TYPE_ARRAY | 0x14 | 多维数组类型修饰符。 |
ELEMENT_TYPE_GENERICINST | 0x15 | 泛型类型实例化。后跟类型 type-arg-count type-1 ... type-n |
ELEMENT_TYPE_TYPEDBYREF | 0x16 | 类型化引用 |
ELEMENT_TYPE_I | 0x18 | System.IntPtr |
ELEMENT_TYPE_U | 0x19 | System.UIntPtr |
ELEMENT_TYPE_FNPTR | 0x1B | 函数指针,后跟完整的函数签名 |
ELEMENT_TYPE_OBJECT | 0x1C | System.Object |
ELEMENT_TYPE_SZARRAY | 0x1D | 单维、零下界的数组类型修饰符。 |
ELEMENT_TYPE_MVAR | 0x1E | 泛型方法定义中的泛型参数,表示为数字 |
ELEMENT_TYPE_CMOD_REQD | 0x1F | 必需修饰符,后跟 `TypeDef` 或 `TypeRef` 令牌 |
ELEMENT_TYPE_CMOD_OPT | 0x20 | 可选修饰符,后跟 `TypeDef` 或 `TypeRef` 令牌 |
ELEMENT_TYPE_INTERNAL | 0x21 | 在 CLI 中实现 |
ELEMENT_TYPE_MODIFIER | 0x40 | 与后续元素类型进行 OR 运算 |
ELEMENT_TYPE_SENTINEL | 0x41 | `vararg` 函数签名的哨兵 |
ELEMENT_TYPE_PINNED | 0x45 | 表示指向被固定对象的局部变量 |
0x50 | 表示 `System.Type` 类型的参数 | |
0x51 | 在自定义属性中使用,用于指定装箱对象(ECMA-355 规范 §23.3)。 | |
0x52 | 保留 | |
0x53 | 在自定义属性中使用,用于指示 FIELD(字段)(ECMA-355 规范 §22.10, §23.3)。 | |
0x54 | 在自定义属性中使用,用于指示 PROPERTY(属性)(ECMA-355 规范 §22.10, §23.3)。 | |
0x55 | 在自定义属性中使用,用于指定枚举(ECMA-355 规范 §23.3)。 |
4. 签名
我们已经完成了几乎所有的准备工作,现在可以开始讨论签名了,但仍有几点值得提及,可能并非人人都能理解。首先,几乎所有签名中的整数都是压缩的。第二件你应该记住的事情是,所有签名都以其在 `#Blob` 堆上占用的字节大小开始,当然,这个值是使用整数压缩存储的。最后但同样重要的是,位于 `#Blob` 堆上签名的值是绝对的,也就是说,你无需对主值(例如图 1 中红色圆圈中的值)进行任何加减操作即可在堆上找到签名。
同时请记住,当你重新编译附加的源代码(即使不修改它)时,结果程序集中的签名偏移量可能会发生变化。
因为本文更像是一篇指南,所以在本章中,我们将逐字节讨论所有签名,从最简单的签名开始,到最复杂的签名结束,每个讨论的签名都附有来自规范的描述、图示或语法,以及一组示例,其完整二进制文件和源代码可以在本文顶部下载,如果可能,应用程序将使用 C# 编写,否则使用 CIL(以前称为 MSIL)。
4.1 FieldSig
如上所述,我们从最简单的签名开始,其中之一是 `FieldSig` 签名。它主要描述字段的类型以及附加到字段的自定义修饰符,由 `Field.Signature` 列索引。当然,`Field` 的签名从整个签名大小开始,然后是具有常量值 `0x6` 的 `FIELD` prologue,零个或多个自定义修饰符,以及字段的类型。`FieldSig` 的语法图示如下,在图 2 中。
注意:请不要混淆自定义修饰符与自定义属性!它们是完全不同的东西。由于自定义修饰符构成了多个签名的一部分,它们将在下一章进行讨论。在本章的示例中,我们不会使用任何自定义修饰符。
示例 1
这个例子很简单,我们创建了一个 `int32` 类型的简单字段,如下所示
// Full source: FieldSig\1.cs
// Binary: FieldSig\1.dll
// (...)
public int TestField;
现在,我们需要将二进制程序集 `FieldSig\1.dll` 加载到 CFF Explorer 中,然后转到 `Field` 表,以查找与我们的字段关联的行(应该只有一行),下图应有所帮助。
找到了!让我们转到 `#Blob` 中的 `0x000A`。
现在我们将在下表逐字节解析签名
偏移量 | 值 | 含义 |
0x0A | 0x02 | 签名大小 |
0x0B | 0x06 | Prolog |
0x0C | 0x08 | 字段的类型值为 `int32`,参见常量 |
示例 2
这次,我们将字段类型更改为 `string`,如以下代码列表所示
// Full source: FieldSig\2.cs
// Binary: FieldSig\2.dll
// (...)
public string TestField;
我们 `TestField` 字段的 `FieldSig` 签名仍然位于 `0x000A`,你可以看到只有最后一个字节从 `0x08` 变为 `0x0E`。
偏移量 | 值 | 含义 |
0x0A | 0x02 | 签名大小 |
0x0B | 0x06 | Prolog |
0x0C | 0x0E | 字段的类型值为 `string`,参见常量 |
4.2 PropertySig
`PropertySig` 签名由 `Property.Type` 列索引,它存储有关属性的信息,即用于获取数据的参数数量、零个或多个自定义修饰符、返回值类型、提供的每个参数的类型,但 `PropertySig` 签名中还有一个新出现的项,即 `HASTHIS` 标志(常量值为 `0x20`),它指示在运行时,调用方法时是否将目标对象的指针作为第一个参数(`this` 指针)传递。正如你所推断的,当属性(实际上是它的 setter 和 getter)是实例或虚拟属性时,`HASTHIS` 标志被设置;当属性(getter 和 setter)是 `static` 时,则不设置。如果设置了该标志,它将与签名的 prologue 值进行 OR 运算。下面你可以看到此签名的完整语法图。
示例 1
第一个例子很简单,我们创建了一个 `int32` 类型的实例属性,如下所示
// Full source: PropertySig\1.cs
// Binary: PropertySig\1.dll
// (...)
public int TestProperty { get; set; }
签名在 `#Blob` 堆上的偏移量为 0x001A。
偏移量 | 值 | 含义 |
0x1A | 0x03 | 签名大小。 |
0x1B | 0x28 | Prolog 与 `HASTHIS` 常量进行 OR 运算,因为 `0x20 OR 0x08 = 0x28`。 |
0x1C | 0x00 | 提供给属性的 getter 方法的参数数量,参见上面的图 5。 |
0x1D | 0x08 | 属性返回值的类型(`int32`),参见常量。 |
示例 2
这个例子稍微复杂一些,因为它使用了索引属性,该属性根据提供的参数返回不同的值。正如你下面看到的,这种类型的属性(在 C# 中)没有名称,但在元数据 `Field` 表中总是声明为 `Item`。每个类/结构只能定义一个索引属性,但可以对其进行重载。
// Full source: PropertySig\2.cs
// Binary: PropertySig\2.dll
// (...)
public int this [int Param1, string Param2]
{
get { return 0; }
set { }
}
前面提到的字段的签名位于 `#Blob` 上的偏移量 `0x001B`,并在下表中进行讨论
偏移量 | 值 | 含义 |
0x1B | 0x05 | 签名大小 |
0x1C | 0x28 | 属性仍为实例类型,因此再次将签名的 prologue 与 `HASTHIS` 常量进行 OR 运算 |
0x1D | 0x02 | 提供给属性的 getter 方法的参数数量,参见上面的图 5 |
0x1E | 0x08 | 属性返回值的类型(`int32`),参见常量。 |
0x1F | 0x08 | 属性的第一个参数的类型(`int32`),参见常量。 |
0x20 | 0x0E | 属性的第二个参数的类型(`string`),参见常量。 |
示例 3
在此示例中,我们将尝试通过将属性声明为 `static` 来禁用 `HASTHIS` 标志。
// Full source: PropertySig\3.cs
// Binary: PropertySig\3.dll
// (...)
public class TestClas
{
public static int TestProperty { get; set; }
}
上述属性的签名本次从 `#Blob` 上的 0x001A 偏移量开始。
偏移量 | 值 | 含义 |
0x1A | 0x03 | 签名大小。 |
0x1B | 0x08 | Prolog 的常量值(仅此项)。 |
0x1C | 0x00 | 提供给属性的 getter 方法的参数数量,参见上面的图 5。 |
0x1D | 0x08 | 属性返回值的类型(`int32`),参见常量。 |
4.3 MethodDefSig
顾名思义,此签名存储与当前程序集中定义的方法相关的信息,例如调用约定类型、泛型参数数量、普通方法参数数量、返回类型以及提供给方法的每个参数的类型。它由 `MethodDef.Signature` 列索引。
此外,还使用了一些标志(列在下表中),它们被 OR 运算在一起并放置在签名的第二个字节中(第一个字节是签名的大小)。
名称 | 值 | 含义 |
HASTHIS | 0x20 | 传递给方法的第一个参数是 `this` 指针。当方法是实例或虚拟方法时,此标志被设置。你也可以在上一小节中找到 `HASTHIS` 标志的解释。 |
EXPLICITTHIS | 0x40 | 规范说:“通常,参数列表(始终跟随调用约定)不提供 `this` 指针类型的信息,因为这可以从其他信息推断出来。但是,当指定实例显式组合时,后续参数列表中的第一个类型指定 `this` 指针的类型,后续条目指定参数本身的类型。” 请注意,如果设置了 `EXPLICITTHIS`,则 `HASTHIS` 也必须设置。 |
DEFAULT | 0x00 | 让公共语言运行时确定调用约定。当调用静态方法时,此标志被设置。 |
VARARG | 0x05 | 指定具有可变参数的方法的调用约定。 |
GENERIC | 0x10 | 方法有一个或多个泛型参数。 |
示例 1
一如既往,让我们从一个简单的例子开始。这次我们创建了一个具有两个泛型参数和两个普通参数的实例方法,为了清晰起见,该方法没有主体。
// Full source: MethodDefSig\1.cs
// Binary: MethodDefSig\1.dll
// (...)
public void TestMethod<GenArg1, GenArg2>(int Param1, object Param2) { }
示例方法的 `MethodDefSig` 签名位于偏移量 `0x000A`,外观如下
偏移量 | 值 | 含义 |
0x0A | 0x06 | 签名大小 |
0x0B | 0x30 | 因为这是实例和泛型方法,所以设置了 `HASTHIS` 和 `GENERIC` 标志,`0x20 OR 0x10 = 0x30` |
0x0C | 0x02 | 泛型参数的数量 |
0x0D | 0x02 | 普通参数的数量 |
0x0E | 0x01 | 返回值的类型(`void`),参见常量。 |
0x0F | 0x08 | 第一个参数的类型(`int32`),参见常量。 |
0x10 | 0x1C | 第二个参数的类型(`object`),参见常量。 |
示例 2
在此示例中,我们将再次演示 `HASTHIS` 标志的用法。上面定义的方法如下所示
// Full source: MethodDefSig\2.cs
// Binary: MethodDefSig\2.dll
// (...)
public class TestClas
{
public static void TestMethod(int Param1, object Param2) { }
}
签名再次位于 `#Blob` 堆上的 `0x000A`,外观如下
偏移量 | 值 | 含义 |
0x0A | 0x05 | 签名大小 |
0x0B | 0x00 | 只有一个标志被设置,即 `DEFAULT`,这意味着该方法是 `static` 的,并允许 CLR 确定使用的调用约定。该方法也不是泛型方法,因为未设置 `GENERIC` 标志,因此下一个字节指定了提供给方法的普通(非泛型)参数的数量。 |
0x0C | 0x02 | 普通参数的数量 |
0x0D | 0x01 | 返回值的类型(`void`),参见常量。 |
0x0E | 0x08 | 第一个参数的类型(`int32`),参见常量。 |
0x0F | 0x1C | 第二个参数的类型(`object`),参见常量。 |
示例 3
现在让我们看看 `EXPLICITTHIS` 标志如何工作。我们可以通过在 CIL 语言的方法定义中使用 `explicit` 关键字来启用它。
// Full source: MethodDefSig\3.il
// Binary: MethodDefSig\3.dll
// (...)
.method instance explicit void TestMethod () cil managed
{
.maxstack 2
ret
}
上述方法的 `MethodDefSig` 如下所示
偏移量 | 值 | 含义 |
0x01 | 0x03 | 签名大小 |
0x02 | 0x60 | `HASTHIS` 和 `EXPLICITTHIS` 标志被设置,因为 `0x20 OR 0x40 = 0x60` |
0x03 | 0x00 | 方法接受的参数数量 |
0x04 | 0x01 | 返回值的类型(`void`),参见常量。 |
示例 4
在此示例中,我们创建了一个接受可变参数的方法,即除了声明中的普通参数外,它还接受可变数量的可变类型参数。在 CIL 语言中,将 `vararg` 关键字添加到方法定义使其接受可变参数,如下面的代码列表所示。
重要:在 C# 中使用 `params` 关键字不会在相关方法的签名中设置 `VARARG` 标志。我的调查结果是,使用 C# 中 `params` 关键字的方法只是被 C# 编译器用 `ParamArray` 属性修饰,额外的参数被视为普通数组。你也可以通过遵循 此说明使 C# 方法真正成为 `VARARG`,但这不符合 CLS 标准。
// Full source: MethodDefSig\4.il
// Binary: MethodDefSig\4.dll
// (...)
.method instance vararg void TestMethod () cil managed
{
.maxstack 2
ret
}
方法的签名在下表中进行探索
偏移量 | 值 | 含义 |
0x01 | 0x03 | 签名大小 |
0x02 | 0x25 | 该方法是实例方法并接受可变参数,因此设置了 `HASTHIS` 和 `VARARG` 标志,所以 `0x20 OR 0x05 = 0x25` |
0x03 | 0x00 | 方法接受的参数数量 |
0x04 | 0x01 | 返回值的类型(`void`),参见常量。 |
4.4 MethodRefSig
此签名与前面提到的 `MethodDefSig` 非常相似(如果不是相同的话),但就其而言,`MethodRefSig` 在方法被调用(也称为调用站点)的点上描述了方法的调用约定、参数等。签名由 `MemberRef.Signature` 列索引,如果方法不接受可变参数,则它与 `MethodDefSig` 相同,并且必须完全匹配目标方法定义中指定的签名,否则如下所示
正如你所见,当调用 `VARARG` 方法时,在其关联的 `MethodRefSig` 中,有一个额外的常量,即 `SENTINEL`。此值只有一个简单的目的:它表示提供给方法的必需参数的结束,以及附加(可变)参数的开始。你可以在这里找到更多关于哨兵值的信息。同时请注意,`ParamCount` 整数表示提供给方法的参数总数。下表是 `MethodRefSig` 签名中使用的缩写的完整列表(当它与 `MethodDefSig` 不同时)。
名称 | 值 | 含义 |
HASTHIS | 0x20 | 传递给方法的第一个参数是 `this` 指针。当方法是实例或虚拟方法时,此标志被设置。你也可以在子节 4.2 中找到 `HASTHIS` 标志的解释。 |
EXPLICITTHIS | 0x40 | 规范说:“通常,参数列表(始终跟随调用约定)不提供 `this` 指针类型的信息,因为这可以从其他信息推断出来。但是,当指定实例显式组合时,后续参数列表中的第一个类型指定 `this` 指针的类型,后续条目指定参数本身的类型。”请注意,如果设置了 `EXPLICITTHIS`,则 `HASTHIS` 也必须设置。 |
VARARG | 0x05 | 指定具有可变参数的方法的调用约定。 |
SENTINEL | 0x41 | 表示必需参数的结束。 |
示例 1
为了让你相信在调用非 `VARARG` 方法时,`MethodDefSig` 和其关联的 `MethodRefSig` 签名之间没有区别,我创建了以下代码
// Full source: MethodRefSig\1a.cs
// Binary: MethodRefSig\1a.dll
// (...)
public void TestMethod(int Param1, string Param2) { }
// Full source: MethodRefSig\1b.cs
// Binary: MethodRefSig\1b.dll
// (...)
new TestClass().TestMethod(0, "A simple parameter");
现在让我们看看 `MethodRefSig\1a.dll` 文件中 `TestMethod` 的 `MethodDefSig` 签名。
偏移量 | 值 | 含义 |
0x0A | 0x05 | 签名大小 |
0x0B | 0x20 | 该方法是实例方法,因此设置了 `HASTHIS` 标志,这意味着传递给方法的第一个参数是 `this` 指针。 |
0x0C | 0x02 | 方法正好需要两个参数。 |
0x0D | 0x01 | 返回值的类型(`void`),参见常量。 |
0x0E | 0x08 | 第一个参数的类型(`int32`),参见常量。 |
0x0F | 0x0E | 第二个参数的类型(`string`),参见常量。 |
而其相关的 `MethodRefSig` 外观完全相同,但位于不同的偏移量。
偏移量 | 值 | 含义 |
0x13 | 0x05 | 签名大小 |
0x14 | 0x20 | 该方法是实例方法,因此设置了 `HASTHIS` 标志,这意味着传递给方法的第一个参数是 `this` 指针。 |
0x15 | 0x02 | 方法正好需要两个参数。 |
0x16 | 0x01 | 返回值的类型(`void`),参见常量。 |
0x17 | 0x08 | 第一个参数的类型(`int32`),参见常量。 |
0x18 | 0x0E | 第二个参数的类型(`string`),参见常量。 |
示例 2
在此示例中,我们将演示 `MethodRefSig` 签名如何处理调用 `VARARG` 方法。为此,我们创建了一个真正的 `VARARG` 方法,该方法接受一个必需参数和其他可变参数。请记住,在 C# 中使用 `params` 关键字不会在相关方法的签名中设置 `VARARG` 标志,因为 `params` 仅用 `ParamArray` 属性修饰方法,额外的参数被视为对象数组。为了在签名中设置 `VARARG` 标志,你必须在方法定义中将 `__arglist` 添加为最后一个参数,但这不符合 CLS 标准。有关更多信息,请访问此处。
// Full source: MethodRefSig\2a.cs
// Binary: MethodRefSig\2a.dll
// (...)
[CLSCompliant(false)]
public void TestMethod(string RequiredParam, __arglist)
{
Console.WriteLine("Required parameter is: " + RequiredParam);
Console.WriteLine("Additional parameters are: ");
ArgIterator argumentIterator = new ArgIterator(__arglist);
for (int i = 0; i < argumentIterator.GetRemainingCount(); i++)
{
Console.WriteLine(__refvalue(argumentIterator.GetNextArg(), string));
}
}
现在是时候从一个单独的程序集中调用我们的方法了。该方法用一个类型为 `string` 的必需参数和两个类型为 `int32` 的附加参数进行调用,如下所示
// Full source: MethodRefSig\2b.cs
// Binary: MethodRefSig\2b.dll
// (...)
[CLSCompliant(false)]
public void TestRunMethod()
{
new TestClass().TestMethod(
"I am required parameter.",
__arglist(0, 1));
}
我发现对于上述调用,`MemberRef` 表中有两个行。我不知道为什么会这样,但我知道从第一个遇到的行开始的签名设置了 `HASTHIS` 标志,但不包含关于提供给方法的任何可变参数的信息。规范对此奇怪的行为没有说明。但是第二个行索引的签名是 OK 的,让我们看看。
偏移量 | 值 | 含义 |
0x23 | 0x07 | 签名大小 |
0x24 | 0x25 | 该方法是实例方法,并且接受可变参数,因此 `HASTHIS OR VARARG = 0x20 OR 0x05 = 0x25` |
0x25 | 0x03 | 提供给方法的参数总数为 3,一个必需参数和两个附加参数 |
0x26 | 0x01 | 返回值的类型(`void`),参见常量。 |
0x27 | 0x0E | 第一个必需参数的类型(`string`),参见常量。 |
0x28 | 0x41 | `SENTINEL` 常量,此值之后的所有参数都是附加参数。 |
0x29 | 0x08 | 第一个附加参数的类型(`int32`),参见常量。 |
0x30 | 0x08 | 第二个附加参数的类型(`int32`),参见常量。 |
4.5 StandAloneMethodSig
此签名类型与 `MethodRefSig` 非常相似,它为方法提供调用站点签名,但有两个关键区别。第一个是 `StandAloneSig` 可以指定一个非托管目标方法。`StandAloneSig` 通常是为了准备执行 `calli` 指令而创建的,该指令调用托管或非托管代码。第二个重要区别是 `StandAloneSig` 签名由 `StandAloneSig.Signature` 列索引,这是 `StandAloneSig` 元数据表中的唯一一列。此外,此表中的每一行都不被任何其他表引用(因此称为“独立”),此表由代码生成器填充。`StandAloneSig.Signature` 列中的签名应该是 `StandAloneMethodSig` 签名(用于每次执行 `calli` 指令)或 `LocalVarSig` 签名(描述方法中的局部变量),后者将在下一小节中进一步阐明。`StandAloneSig` 签名的语法图如下
由于此签名与 `MethodRefSig` 签名不同,仅在于 `StansAloneMethodSig` 可以调用非托管方法,因此添加了一些其他常量来描述调用非托管方法时使用的调用约定。
重要:如你很快就会看到的,用于调用接受可变参数的托管和非托管代码的方法的调用约定是不同的。每种情况下的图示可能看起来不同。例如,`VARARG` 调用约定调用接受可变参数的托管方法,在这种情况下,签名包含附加元素 `SENTINEL` 和一个或多个 `Param`(阴影框);然而,`C` 调用约定也调用接受可变参数的方法(非托管代码),但这种情况下的签名在 `Param` 元素之前结束。根据我的观察,编译器生成签名如上所述,但不幸的是,我的示例代码可以编译,但会抛出异常,我不知道问题出在哪里,所以我不能肯定地说我的观察是正确的,而且规范也不够清晰。
“本图合并了两个独立的图表,并使用阴影来区分它们。因此,对于以下调用约定:DEFAULT(托管)、STDCALL、THISCALL 和 FASTCALL(非托管),签名在 SENTINEL 项目之前结束(这些都是非 vararg 签名)。但是,对于托管和非托管 vararg 调用约定:VARARG(托管)和 C(非托管),签名可以包括 SENTINEL 和最终的 Param 项目(但它们不是必需的)。这些选项通过语法图中的阴影框指示。”
你看到这个了吗?为什么使用 `C` 调用约定调用非托管方法(接受或不接受可变参数)时,`C` 框没有被阴影化,因为使用 `C` 调用约定可能添加 `SENTINEL` 和 `Param` 元素?在什么情况下 `Param` 元素不是必需的?`calli` 指令在 100% 正确工作的调用非托管方法的代码中非常罕见(我 GAC 中的 392 个程序集只执行了`calli` 指令两次,而且只针对托管方法!),所以我不能说我对本小节中以下示例代码的解释绝对正确。如果有人知道在正确调用非托管方法时(无论是接受还是不接受可变参数 - 在这两种情况下,代码都会抛出异常)`StandAloneMethodSig` 签名是什么样的,请告诉我,我将非常感激。
名称 | 值 | 含义 |
HASTHIS | 0x20 | 传递给方法的第一个参数是 `this` 指针。当方法是实例或虚拟方法时,此标志被设置。你也可以在子节 4.2 中找到 `HASTHIS` 标志的解释。 |
EXPLICITTHIS | 0x40 | 规范说:“通常,参数列表(始终跟随调用约定)不提供 `this` 指针类型的信息,因为这可以从其他信息推断出来。但是,当指定实例显式组合时,后续参数列表中的第一个类型指定 `this` 指针的类型,后续条目指定参数本身的类型。”请注意,如果设置了 `EXPLICITTHIS`,则 `HASTHIS` 也必须设置。 |
DEFAULT | 0x00 | 让公共语言运行时确定调用约定。当调用静态方法时,此标志被设置。 |
VARARG | 0x05 | 指定具有可变参数的托管方法的调用约定。 |
C | 0x01 | 非托管方法目标的调用约定,此约定的具体细节如下: 参数从右到左传递。 调用方法的调用者负责清理堆栈。 只有此调用约定允许调用非托管方法,这些方法具有可变参数(`vararg` 用于托管方法)。 你可以通过在 CIL 语言的方法定义中添加 `unmanaged cdecl` 关键字来使用此调用约定。 |
STDCALL | 0x02 | 非托管方法目标的调用约定,此约定的具体细节如下: 参数从右到左传递。 被调用方法负责清理堆栈。 你可以通过在 CIL 语言的方法定义中添加 `unmanaged stdcall` 关键字来使用此调用约定。 |
THISCALL | 0x03 | 非托管方法目标的调用约定,此约定的具体细节如下: 参数从右到左传递。 被调用方法负责清理堆栈。 `this` 指针被放置在 `ECX` 寄存器中。 你可以通过在 CIL 语言的方法定义中添加 `unmanaged thiscall` 关键字来使用此调用约定。 |
FASTCALL | 0x04 | 非托管方法目标的调用约定,此约定的具体细节如下: 一些参数被放置在 `ECX` 和 `EDX` 寄存器中,其余参数从右到左(压入)堆栈。 被调用方法负责清理堆栈。 你可以通过在 CIL 语言的方法定义中添加 `unmanaged fastcall` 关键字来使用此调用约定。 |
SENTINEL | 0x41 | 表示必需参数的结束 |
注意:这里有一点值得一提,与 `CL`(Microsoft C\C++ 编译器)不同,`ILASM`(Microsoft CIL 编译器)在使用任何非托管目标的调用约定时,不会在方法名称前添加任何特殊字符(如“@”、“_”、“?”等)。CIL 编译器不会用特殊字符修饰任何方法名称,因为它只生成字节码,该字节码之后可以由 CLR 的即时编译器编译成机器码。因此,当你在 CIL 中编码时选择某个调用约定,`ILASM` 编译器不会确定谁(调用者还是被调用方法)清理堆栈,不会确定参数传递给方法的顺序,也不会更改方法名称,这些是在JIT 编译/优化期间完成的。如果你不知道我在说什么,你可以阅读 Nemanja Trifunovic 的文章,题为Calling Conventions Demystified,该文章详细描述了 C 和 C++ 的不同调用约定类型、它们的含义、工作原理等。
示例 1
在示例代码列表中,我们有两个托管方法。第一个方法有一个类型为 `int32` 的固定参数,也返回 `int32`(实际上,它什么也不返回,因为没有数据被推送到评估堆栈上);第二个列出的方法只是执行第一个方法,你可以看到它如下所示
// Full source: StandAloneMethodSig\1.il
// Binary: StandAloneMethodSig\1.dll
// (...)
.method public static int32 TestMethod(int32 required)
{
ret
}
.method public static void TestRunMethod()
{
.maxstack 8
ldc.i4.1
ldftn int32 TestMethod(int32)
calli int32(int32)
ret
}
在 `TestRunMethod` 方法执行 `TestMethod` 之前,它使用 `ldc.i4.1` 指令将一个 `int32` 值(参数)压入评估堆栈,然后使用 `ldftn` 指令将指向第一个方法的指针压入评估堆栈,最后执行 `calli` 调用我们的测试“无操作”托管方法,而最后这个指令会生成下面表中解释的 `StandAloneMethodSig` 签名。
偏移量 | 值 | 含义 |
0x01 | 0x04 | 签名大小 |
0x02 | 0x00 | 该方法不使用任何特定的调用约定,该方法不是实例方法,因为没有设置 `HASTHIS` 标志。 |
0x03 | 0x01 | 该方法需要提供一个固定参数和零个可变参数。 |
0x04 | 0x08 | 返回值的类型(`int32`),参见常量。 |
0x05 | 0x08 | 第一个必需参数的类型(`int32`),参见常量。 |
示例 2
在此示例中,我们将使示例方法接受可变参数,并通过 `calli` 调用它,带有一个必需参数和一个附加参数。固定参数与附加参数用省略号(`...`)分隔,如下所示
// Full source: StandAloneMethodSig\2.il
// Binary: StandAloneMethodSig\2.dll
// (...)
.method public hidebysig static vararg void TestMethod(int32 required)
{
ret
}
.method public hidebysig static void TestRunMethod()
{
.maxstack 3
ldc.i4.1
ldc.i4.2
ldftn vararg void TestMethod(int32, ..., int32)
calli vararg void(int32, ..., int32)
ret
}
在这种情况下,由 `calli` 指令生成的签名与上一小节讨论的 `MethodRefSig` 签名看起来相同,让我们看看。
偏移量 | 值 | 含义 |
0x01 | 0x06 | 签名大小 |
0x02 | 0x05 | 该方法是 `static` 的,并接受可变参数 |
0x03 | 0x02 | 提供给方法的参数总数为 2,一个必需和一个附加 |
0x04 | 0x01 | 返回值的类型(`void`),参见常量。 |
0x05 | 0x08 | 第一个必需参数的类型(`int32`),参见常量。 |
0x06 | 0x41 | `SENTINEL` 常量,此值之后的所有参数都是附加参数。 |
0x07 | 0x08 | 第一个附加参数的类型(`int32`),参见常量。 |
示例 3
这是整个文章中最有问题的示例。示例代码中的方法调用非托管方法,该方法接受可变参数。代码编译但会抛出 `TypeLoadException` 异常(“签名不正确”)。不幸的是,规范对此情况不清楚(请参阅本小节开头的重点说明)。下面显示的示例代码与第一个示例一样,调用接受可变参数的方法,但这次,被调用的方法是非托管的。
// Full source: StandAloneMethodSig\3.il
// Binary: StandAloneMethodSig\3.dll
// (...)
.method public hidebysig static unmanaged cdecl void TestMethod(int32 required, ...)
{
ret
}
.method public hidebysig static void TestRunMethod()
{
.maxstack 3
ldc.i4.1
ldc.i4.2
ldftn unmanaged cdecl void TestMethod(int32, ...)
calli unmanaged cdecl void(int32, ...)
ret
}
由 `calli` 指令生成的签名非常奇怪,它在我们将第一个附加 `Param` 元素提供给方法之前就结束了。
偏移量 | 值 | 含义 |
0x01 | 0x05 | 签名大小 |
0x02 | 0x01 | 该方法是 `static` 的且是非托管的,调用约定类型是 `C`(由 `unmanaged cdecl` 关键字设置),因此接受可变参数。 |
0x03 | 0x01 | 提供给方法的参数总数为 1,一个必需和一个省略(我一点也不知道为什么) |
0x04 | 0x01 | 返回值的类型(`void`),参见常量。 |
0x05 | 0x08 | 第一个必需参数的类型(`int32`),参见常量。 |
0x06 | 0x41 | `SENTINEL` 常量,此值之后的所有参数都是附加参数,但不幸的是此值之后没有附加参数。如果你知道原因,请联系我。 |
5. 下一部分
暂时就到这里,下一部分可以在这里找到。