基于Cecil源码的IL练级攻略(2)Metadata存储结构
简介
上文提到CLR Runtime Header
中包含metadata directory
,我们可以通过这个字段访问对应的元数据信息。
本篇文章会大致介绍一下元数据metadata
以及它的存储结构。后续的文章都将基于本篇文章,因此如果有不理解的地方,推荐多读几遍或者查阅相关的资料。
以及一定要对照着Mono.Cecil
源码阅读本文。
什么是元数据
元数据(metadata)是存储于PE文件中的二进制信息(binary information),用于补充描述程序的信息。由于公共语言运行时CLR的编程模型天生就是面向对象的,因此元数据包括每个类和类中的成员,成员对应的注解,以及它们彼此之间的关系。metadata是托管程序(managed module)必不可少的一部分,每个托管程序(managed module)总是会携带完整的逻辑结构用于描述所在的托管程序。
具体实现上,元数据(metadata)是规范化的(normalized)关系型数据库。这意味着元数据是一组相互引用的长方形表(table),而不是树状结构。元数据表的每一列包含数据或者一个引用,指向另一个表的某一行。整个元数据表没有冗余的数据,如果另一个表需要用到相同的数据,它会引用(references)持有数据的表。
Metadata Header
metadata directory
指向的数据格式如下,Cecil
中ImageReader.ReadMetadata
函数负责处理这部分的信息。
internal class MetadataHeader
{
public DWORD Signature;
public WORD MajorVersion;
public WORD MinorVersion;
public DWORD Reserved; //reserved
public DWORD LengthOfVersion;
public string Version; // CLR version
public WORD Flags; //reserved
public WORD StreamCount;
public MetadataStreamBlockInfo[] MetadataStreamInfos;
}
internal class MetadataStreamBlockInfo
{
public DWORD Offset;
public DWORD Size;
/*Name of the stream; a zero-terminated ASCII string
no longer than 31 characters (plus zero terminator).
The name might be shorter, in which case the size
of the stream header is correspondingly reduced,
padded to the 4-byte boundary.
*/
public string Name;
}
我们可以用CFF Explorer查看该部分数据结构的信息。
Signature
字段的值恒为0x424A5342
,是几个开发人员名字的缩写。
MajorVersion
和MinorVersion
表示metadata所使用的的版本。
Version
字段实际上是CLR的字符串版本。
接着是StreamCount
表示有几个metadata的数据流,至少会有3个数据流。一个metadata table stream(#~ 或者 #-) ,一个 a string stream (#Strings), 以及一个 GUID stream (#GUID) 。对于本篇文章测试的Assembly-CSharp.dll
我们可以看到一共有5个数据流。
Cecil
在读取StreamCount
字段后,会调用ReadMetadataStream
读取各个metadata数据流的偏移和大小信息。
Heaps 和 Tables
元数据就像上文中看到的,实际上是用一组命名的数据流stream表示的。
每个数据流表示一类metadata:metadata heaps
或者 metadata tables
。
Heaps
metadata heap
是一类较为次要的数据结构,heap
上存储着连续且相邻的数据,经常用于存储字符串或者是二进制对象。一共有三种类型的metadata heap
:
String heap
: 包含以Null结尾的字符串(UTF-8编码)。字符串紧密排列,中间没有空隙。该Heap的第一个字节总是0,表示String.Empty
。GUID heap
: 存储了16字节的二进制对象。每个对象紧密排列,没有空隙。由于长度固定,所以该Heap不需要长度信息或者是终止符。Blob heap
: 可以存储任意长度的二进制数据。每个二进制数据紧随一个长度信息(压缩格式)。压缩格式可以查看Cecil
中的ReadCompressedUInt32
函数
接着,我们回头看上文图片中出现的数据流Streams。
#Strings
: 这个数据流是一个String heap
,它存储了元数据项(metadata items)的名称(类名,函数名,字段名,等等)。值得一提的是,函数中定义的字符串常量不在这个数据流里。#Blob
:Blob heap
, 存储了二进制数据,比如签名,默认值等。#GUID
: AGUID heap
containing all sorts of globally unique identifiers#US
:Blob heap
,包含了用户定义的字符串(user defined strings)。编码为UTF16。并且有一个额外的尾追字节,值为0或者1,表示字符串中是否出现了编码大于0x007F
的字符。字符串开头的长度信息会统计尾追字节。 而且该数据流中的用户字符串不会被任何metadata table引用,只会被IL代码显式访问(ldstr
指令)。#~
: 压缩格式的metadata tables的数据流。这个数据流存储的实际上不是Heap,而是metadata tables。
Tables
通过CFF Explorer观察,我们可以发现#~
数据流以Tables Header
这个结构开头。
对应到Cecil
为ImageReader.ReadTableHeap
函数的前半部分代码。
var heap = image.TableHeap;
//设置当前数据流位置为 table heap的起始位置
MoveTo (table_heap_offset + image.MetadataSection.PointerToRawData);
// Reserved 4
// MajorVersion 1
// MinorVersion 1
Advance (6);
// HeapOffsetSizes 1
var sizes = ReadByte ();
// Reserved2 1
Advance (1);
// Mask Valid 8
heap.Valid = ReadInt64 ();
// Mask Sorted 8
heap.Sorted = ReadInt64 ();
其中MajorVersion
和MinorVersion
是Table表的版本。
HeapOffsetSizes
字段中特定的比特位为1,则表示对应的metadata heap
的偏移大小Offset为4字节,否则为2字节。0x01
比特位对应string heap
,0x02
比特位对应GUID heap
, 0x04
比特位对应blob heap
。
MaskValid
大小是8个字节,一共64比特,从最低位开始,我们记作第0位,最高位为第63位,比特所在的位对应着值TableIndex的表,如果被设置为1,则表示存在该表。
MaskSorted
如果对应的比特被设置为1,则表示该表里面的数据是有序的。
MaskValid
中有多少个比特位1,就有多少个整数紧接这个header,每个整数表示MaskValid
中标记为1的表的有多少行数据。
for (int i = 0; i < Mixin.TableCount; i++) {
if (!heap.HasTable ((Table) i))
continue;
heap.Tables [i].Length = ReadUInt32 (); //存储 该表一共有几行数据
}
//存储heap offset 大小信息
SetIndexSize (image.StringHeap, heapOffsetSizes, 0x1);
SetIndexSize (image.GuidHeap, heapOffsetSizes, 0x2);
SetIndexSize (image.BlobHeap, heapOffsetSizes, 0x4);
ComputeTableInformations ();
Tokens 和 Coded Tokens
上文提到了,元数据是规范化(normalized)的数据库。表和表之间会相互引用,比如TypeDef
表的FieldList
列会引用Field
表中的一行数据。
RID
如果一个列只引用一种Table,最直观的想法就是用一个数字表示对应表中的记录。CLR中的RID(record identifier)就是这样一类信息,它从1开始计数(one-based),引用了其他表中对应行的数据。RID只在metadata内部使用。
Tokens
但实际上,有时候一个表的列引用的数据可能会指向不同的表,TypeDef
表中Extends
列表示当前Type派生自哪个类型,这里基类可能是在当前的Module中定义的,也可能是在其他Module或者其他Assembly中定义的。根据情况,Extends
列可能会引用TypeDef
, TypeRef
, TypeSpec
这三个不同的表。
这个时候,我们需要将行数和对应的表的类型结合,我们将其称为token。 一个token是4字节无符号整数,它的最高有效字节(most significant byte)里存储着对应表的TableIndex。其余3字节用于存储RID。
需要注意的是token里面存储的表只是TableIndex
的一个子集,比如不会用到TableIndex.ParamPtr = 0x07
token在metadata API和IL指令中都会被用到。
打开 MetadataFlags ,拉倒最下面TokenTypeIds
类,里面就是对应token中表的信息。
Coded Tokens
上面提到的MetaToken 最高有效字节
存储了表的类型,其余3字节
存储RID,具有良好的可读性,是内存中解压缩后的表现形式。但元数据中实际存储的token是经过压缩的,至于它是如何被压缩的,且听我细细道来。
首先,最高有效字节
中存储的表类型会被移动到最低有效字节。这样的话,如果RID够小,我们就能用2字节表示这个token。
接着,考虑token的使用场景,很多时候,我们并不需要指向所有的表,而是所有表的一个子集。比如TypeDef
表的Extends
列,可能会指向三个的表。我们可以将这三个表分为一个组(group)
,这个组就是这个列对应的压缩过的token的类型(coded token type),其中group里面每个表被赋予的值被称为tag
。
对于TypeDef
表来说,Extends
列的 coded token type
为 TypeDefOrRef
TypeDefOrRef: 2 bits to encode tag | Tag |
---|---|
TypeDef | 0 |
TypeRef | 1 |
TypeSpec | 2 |
如果需要引用位于TypeRef
表第3行的数据,对应CodedToken
值为 0x0D
,解压缩后的形式为0x01 00 00 03
Cecil
中对应读取Coded Token
并将其转化为MetadataToken
的代码位于Mono.Cecil.Metadata
目录的Utilities.cs
文件中的GetMetadataToken
函数。
最后 压缩过的token的字节长度也是不固定的,我们首先遍历coded token type
中所有表的行数,找到最大的那个值,maximal RID。假设我们需要m个比特位去表示这个最大的RID,以及tag的值需要n个比特位。 如果m+n的比特位和小于2字节,那么对于这个coded token type,我们就用2字节来表示。如果不能,那么就用4字节。
计算对应coded token type
的字节长度的函数位于Utilities.cs
这个文件的GetSize
函数。
记录Table的数据大小信息
细心的读者可能发现了,ImageReader.ReadTableHeap
函数的最后一行还调用了ComputeTableInformations
函数。这个函数计算并存储了每个Table数据块的信息。其中Offset
是Table相对metadata tables header(#~数据流)的偏移,Length
是这个Table有几行数据,RowSize
是每行数据有多少个字节。
struct TableInformation {
public uint Offset;
public uint Length;
public uint RowSize;
public bool IsLarge {
get { return Length > ushort.MaxValue; }
}
}
这里让我们看下TypeDef
这个表的RowSize
是如何计算的。
case Table.TypeDef:
size = 4 // Flags
+ (stridx_size * 2) // Name, Namespace
+ GetCodedIndexSize (CodedIndex.TypeDefOrRef) // BaseType
+ GetTableIndexSize (Table.Field) // FieldList
+ GetTableIndexSize (Table.Method); // MethodList
break;
关于TypeDef
这个表的详细信息可以阅读《.NET IL Assembler》第7章 TypeDef Metadata Table 小节。
这里第一列Flags
大小是固定4字节,表示这个类的属性。
然后是两个 #String 数据流中的偏移。偏移值占用的字节大小由之前头文件的heapOffsetSize
可得。
//BaseType
注释的这一列就是Extends
列,是压缩过的coded token
,压缩过的coded token
的字节大小取决于coded token type
这个组里面行数最多的那个表的行数,以及组里面的表的个数。如果他们分别用m和n个比特位就能表示,m+n<16,则GetCodedIndexSize
返回的大小为2字节,否则为4字节。
// FieldList
和 // MethodList
相对 Extends
列来说会简单一点,因为它们分别只指向一个Table,token不需要包含表的信息,那么就是纯粹的RID。2字节能装下就是2字节,不能就4字节。
最后
如果感觉看不太懂,请从简介开始从头再看一遍。