基于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函数负责处理这部分的信息。 我们也可以在规范的24.2.1 Metadata root 小节 找到对应的信息。
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;
}
void ReadGeneralMetadataHeader ()
{
MoveTo (metadataDataDirectory);
var section = image.GetSectionAtVirtualAddress(metadataDataDirectory.VirtualAddress);
if (section == null)
throw new BadImageFormatException();
image.MetadataSection = section;
if (ReadUInt32 () != 0x424a5342)
throw new BadImageFormatException ();
// MajorVersion 2
// MinorVersion 2
// Reserved 4
Advance (8);
image.RuntimeVersion = ReadStringByLength (ReadInt32 ());
// Flags 2
Advance (2);
var streamCount = ReadUInt16 ();
for (int i = 0; i < streamCount; i++)
ReadMetadataStream ();
if (image.PdbHeapStream != null)
ReadPdbHeap ();
if (image.MetadataTableStream != null)
ReadTableHeap ();
}
我们可以用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 heapcontaining 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这个结构开头。

规范(24.2.6)中指出#~数据流包含了下面的数据
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 4 | Reserved | Reserved, always 0 (§24.1). |
| 4 | 1 | MajorVersion | Major version of table schemata; shall be 2 (§24.1). |
| 5 | 1 | MinorVersion | Minor version of table schemata; shall be 0 (§24.1). |
| 6 | 1 | HeapSizes | Bit vector for heap sizes. |
| 7 | 1 | Reserved | Reserved, always 1 (§24.1). |
| 8 | 8 | Valid | Bit vector of present tables, let n be the number of bits that are 1. |
| 16 | 8 | Sorted | Bit vector of sorted tables. |
| 24 | 4*n | Rows | Array of n 4-byte unsigned integers indicating the number of rows for each present table. |
| 24+4*n | — | Tables | The sequence of physical tables. |
对应到Cecil为ImageReader.ReadTableHeap函数。
var table = image.MetadataTableStream;
var filePointer = image.RVA2FilePointer(meta_table_stream_rva);
MoveTo (filePointer);
// Reserved 4
// MajorVersion 1
// MinorVersion 1
Advance (6);
// HeapSizes 1
//Binary flags indicate the offset sizes to be used within the heaps.
var sizeFlags = ReadByte ();
// Reserved2 1
Advance (1);
// Valid 8
table.Valid = ReadInt64 ();
// Sorted 8
table.Sorted = ReadInt64 ();
for (int i = 0; i < Mixin.TableCount; i++) {
if (!table.HasTable ((MetadataTableEnum) i))
continue;
// Rows 4*n
table.Tables [i].NumberOfRecords = ReadUInt32 ();
}
SetIndexSize (image.StringHeapStream, sizeFlags, 0x1);
SetIndexSize (image.GuidHeapStream, sizeFlags, 0x2);
SetIndexSize (image.BlobHeapStream, sizeFlags, 0x4);
//Tables
ComputeTableInformations ();
其中MajorVersion和MinorVersion是Table表的版本。
The HeapSizes field is a bitvector that encodes the width of indexes into the various heaps. If bit 0 is set, indexes into the ―#String‖ heap are 4 bytes wide; if bit 1 is set, indexes into the ―#GUID‖ heap are 4 bytes wide; if bit 2 is set, indexes into the ―#Blob‖ heap are 4 bytes wide. Conversely, if the HeapSize bit for a particular heap is not set, indexes into that heap are 2 bytes wide.
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的表的有多少行数据。
接着在 MaskValid这个字段中的对应bit位置为1的表,它们有多少的行会存储到Rows这个字段中
读完Rows字段,接着就是实际存储着不同Table的二进制数据Tables。
记录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; }
}
}
struct TableInfo
{
/// <summary>
/// 相对Metadata Table Stream的偏移。
/// Relative to Metadata Table Stream
/// </summary>
public uint Offset;
/// <summary>
/// 整个表总共有几行
/// </summary>
public uint NumberOfRecords;
/// <summary>
/// 一行有多少字节
/// </summary>
public uint RecordSize;
public bool IsLarge
{
get { return NumberOfRecords > ushort.MaxValue; }
}
}
点我展开: ComputeTableInformations 函数代码
void ComputeTableInformations ()
{
//relative to Metadata Table Stream Header
var metaTableFilePointer = image.RVA2FilePointer(meta_table_stream_rva);
uint offset = (uint) BaseStream.Position - metaTableFilePointer; // header
int stridx_size = image.StringHeapStream != null ? image.StringHeapStream.IndexSize : 2;
int guididx_size = image.GuidHeapStream != null ? image.GuidHeapStream.IndexSize : 2;
int blobidx_size = image.BlobHeapStream != null ? image.BlobHeapStream.IndexSize : 2;
var heap = image.MetadataTableStream;
var tables = heap.Tables;
for (int i = 0; i < Mixin.TableCount; i++) {
var table = (MetadataTableEnum) i;
if (!heap.HasTable (table))
continue;
int size;
switch (table) {
case MetadataTableEnum.Module:
size = 2 // Generation
+ stridx_size // Name
+ (guididx_size * 3); // Mvid, EncId, EncBaseId
break;
case MetadataTableEnum.TypeRef:
size = GetCodedIndexSize (CodedTokenType.ResolutionScope) // ResolutionScope
+ (stridx_size * 2); // Name, Namespace
break;
case MetadataTableEnum.TypeDef:
size = 4 // Flags
+ (stridx_size * 2) // Name, Namespace
+ GetCodedIndexSize (CodedTokenType.TypeDefOrRef) // BaseType
+ GetTableIndexSize (MetadataTableEnum.Field) // FieldList
+ GetTableIndexSize (MetadataTableEnum.Method); // MethodList
break;
case MetadataTableEnum.FieldPtr:
size = GetTableIndexSize (MetadataTableEnum.Field); // Field
break;
case MetadataTableEnum.Field:
size = 2 // Flags
+ stridx_size // Name
+ blobidx_size; // Signature
break;
case MetadataTableEnum.MethodPtr:
size = GetTableIndexSize (MetadataTableEnum.Method); // Method
break;
case MetadataTableEnum.Method:
size = 8 // Rva 4, ImplFlags 2, Flags 2
+ stridx_size // Name
+ blobidx_size // Signature
+ GetTableIndexSize (MetadataTableEnum.Param); // ParamList
break;
case MetadataTableEnum.ParamPtr:
size = GetTableIndexSize (MetadataTableEnum.Param); // Param
break;
case MetadataTableEnum.Param:
size = 4 // Flags 2, Sequence 2
+ stridx_size; // Name
break;
case MetadataTableEnum.InterfaceImpl:
size = GetTableIndexSize (MetadataTableEnum.TypeDef) // Class
+ GetCodedIndexSize (CodedTokenType.TypeDefOrRef); // Interface
break;
case MetadataTableEnum.MemberRef:
size = GetCodedIndexSize (CodedTokenType.MemberRefParent) // Class
+ stridx_size // Name
+ blobidx_size; // Signature
break;
case MetadataTableEnum.Constant:
size = 2 // Type
+ GetCodedIndexSize (CodedTokenType.HasConstant) // Parent
+ blobidx_size; // Value
break;
case MetadataTableEnum.CustomAttribute:
size = GetCodedIndexSize (CodedTokenType.HasCustomAttribute) // Parent
+ GetCodedIndexSize (CodedTokenType.CustomAttributeType) // Type
+ blobidx_size; // Value
break;
case MetadataTableEnum.FieldMarshal:
size = GetCodedIndexSize (CodedTokenType.HasFieldMarshal) // Parent
+ blobidx_size; // NativeType
break;
case MetadataTableEnum.DeclSecurity:
size = 2 // Action
+ GetCodedIndexSize (CodedTokenType.HasDeclSecurity) // Parent
+ blobidx_size; // PermissionSet
break;
case MetadataTableEnum.ClassLayout:
size = 6 // PackingSize 2, ClassSize 4
+ GetTableIndexSize (MetadataTableEnum.TypeDef); // Parent
break;
case MetadataTableEnum.FieldLayout:
size = 4 // Offset
+ GetTableIndexSize (MetadataTableEnum.Field); // Field
break;
case MetadataTableEnum.StandAloneSig:
size = blobidx_size; // Signature
break;
case MetadataTableEnum.EventMap:
size = GetTableIndexSize (MetadataTableEnum.TypeDef) // Parent
+ GetTableIndexSize (MetadataTableEnum.Event); // EventList
break;
case MetadataTableEnum.EventPtr:
size = GetTableIndexSize (MetadataTableEnum.Event); // Event
break;
case MetadataTableEnum.Event:
size = 2 // Flags
+ stridx_size // Name
+ GetCodedIndexSize (CodedTokenType.TypeDefOrRef); // EventType
break;
case MetadataTableEnum.PropertyMap:
size = GetTableIndexSize (MetadataTableEnum.TypeDef) // Parent
+ GetTableIndexSize (MetadataTableEnum.Property); // PropertyList
break;
case MetadataTableEnum.PropertyPtr:
size = GetTableIndexSize (MetadataTableEnum.Property); // Property
break;
case MetadataTableEnum.Property:
size = 2 // Flags
+ stridx_size // Name
+ blobidx_size; // Type
break;
case MetadataTableEnum.MethodSemantics:
size = 2 // Semantics
+ GetTableIndexSize (MetadataTableEnum.Method) // Method
+ GetCodedIndexSize (CodedTokenType.HasSemantics); // Association
break;
case MetadataTableEnum.MethodImpl:
size = GetTableIndexSize (MetadataTableEnum.TypeDef) // Class
+ GetCodedIndexSize (CodedTokenType.MethodDefOrRef) // MethodBody
+ GetCodedIndexSize (CodedTokenType.MethodDefOrRef); // MethodDeclaration
break;
case MetadataTableEnum.ModuleRef:
size = stridx_size; // Name
break;
case MetadataTableEnum.TypeSpec:
size = blobidx_size; // Signature
break;
case MetadataTableEnum.ImplMap:
size = 2 // MappingFlags
+ GetCodedIndexSize (CodedTokenType.MemberForwarded) // MemberForwarded
+ stridx_size // ImportName
+ GetTableIndexSize (MetadataTableEnum.ModuleRef); // ImportScope
break;
case MetadataTableEnum.FieldRVA:
size = 4 // RVA
+ GetTableIndexSize (MetadataTableEnum.Field); // Field
break;
case MetadataTableEnum.EncLog:
size = 8;
break;
case MetadataTableEnum.EncMap:
size = 4;
break;
case MetadataTableEnum.Assembly:
size = 16 // HashAlgId 4, Version 4 * 2, Flags 4
+ blobidx_size // PublicKey
+ (stridx_size * 2); // Name, Culture
break;
case MetadataTableEnum.AssemblyProcessor:
size = 4; // Processor
break;
case MetadataTableEnum.AssemblyOS:
size = 12; // Platform 4, Version 2 * 4
break;
case MetadataTableEnum.AssemblyRef:
size = 12 // Version 2 * 4 + Flags 4
+ (blobidx_size * 2) // PublicKeyOrToken, HashValue
+ (stridx_size * 2); // Name, Culture
break;
case MetadataTableEnum.AssemblyRefProcessor:
size = 4 // Processor
+ GetTableIndexSize (MetadataTableEnum.AssemblyRef); // AssemblyRef
break;
case MetadataTableEnum.AssemblyRefOS:
size = 12 // Platform 4, Version 2 * 4
+ GetTableIndexSize (MetadataTableEnum.AssemblyRef); // AssemblyRef
break;
case MetadataTableEnum.File:
size = 4 // Flags
+ stridx_size // Name
+ blobidx_size; // HashValue
break;
case MetadataTableEnum.ExportedType:
size = 8 // Flags 4, TypeDefId 4
+ (stridx_size * 2) // Name, Namespace
+ GetCodedIndexSize (CodedTokenType.Implementation); // Implementation
break;
case MetadataTableEnum.ManifestResource:
size = 8 // Offset, Flags
+ stridx_size // Name
+ GetCodedIndexSize (CodedTokenType.Implementation); // Implementation
break;
case MetadataTableEnum.NestedClass:
size = GetTableIndexSize (MetadataTableEnum.TypeDef) // NestedClass
+ GetTableIndexSize (MetadataTableEnum.TypeDef); // EnclosingClass
break;
case MetadataTableEnum.GenericParam:
size = 4 // Number, Flags
+ GetCodedIndexSize (CodedTokenType.TypeOrMethodDef) // Owner
+ stridx_size; // Name
break;
case MetadataTableEnum.MethodSpec:
size = GetCodedIndexSize (CodedTokenType.MethodDefOrRef) // Method
+ blobidx_size; // Instantiation
break;
case MetadataTableEnum.GenericParamConstraint:
size = GetTableIndexSize (MetadataTableEnum.GenericParam) // Owner
+ GetCodedIndexSize (CodedTokenType.TypeDefOrRef); // Constraint
break;
case MetadataTableEnum.Document:
size = blobidx_size // Name
+ guididx_size // HashAlgorithm
+ blobidx_size // Hash
+ guididx_size; // Language
break;
case MetadataTableEnum.MethodDebugInformation:
size = GetTableIndexSize (MetadataTableEnum.Document) // Document
+ blobidx_size; // SequencePoints
break;
case MetadataTableEnum.LocalScope:
size = GetTableIndexSize (MetadataTableEnum.Method) // Method
+ GetTableIndexSize (MetadataTableEnum.ImportScope) // ImportScope
+ GetTableIndexSize (MetadataTableEnum.LocalVariable) // VariableList
+ GetTableIndexSize (MetadataTableEnum.LocalConstant) // ConstantList
+ 4 * 2; // StartOffset, Length
break;
case MetadataTableEnum.LocalVariable:
size = 2 // Attributes
+ 2 // Index
+ stridx_size; // Name
break;
case MetadataTableEnum.LocalConstant:
size = stridx_size // Name
+ blobidx_size; // Signature
break;
case MetadataTableEnum.ImportScope:
size = GetTableIndexSize (MetadataTableEnum.ImportScope) // Parent
+ blobidx_size;
break;
case MetadataTableEnum.StateMachineMethod:
size = GetTableIndexSize (MetadataTableEnum.Method) // MoveNextMethod
+ GetTableIndexSize (MetadataTableEnum.Method); // KickOffMethod
break;
case MetadataTableEnum.CustomDebugInformation:
size = GetCodedIndexSize (CodedTokenType.HasCustomDebugInformation) // Parent
+ guididx_size // Kind
+ blobidx_size; // Value
break;
default:
throw new NotSupportedException ();
}
tables [i].RecordSize = (uint) size;
tables [i].Offset = offset;
offset += (uint) size * tables [i].NumberOfRecords;
}
}
这里我们挑选 TypeDef这个表的计算 来详细描述一下,
case MetadataTableEnum.TypeDef:
size = 4 // Flags
+ (stridx_size * 2) // Name, Namespace
+ GetCodedIndexSize (CodedTokenType.TypeDefOrRef) // BaseType
+ GetTableIndexSize (MetadataTableEnum.Field) // FieldList
+ GetTableIndexSize (MetadataTableEnum.Method); // MethodList
break;
关于TypeDef这个表的详细信息可以阅读《.NET IL Assembler》第7章 TypeDef Metadata Table 小节,或者是规范的22.37小节。
-
size = 4这里第一列Flags大小是固定4字节,里面不同的bit位,表示这个类的不同属性。 比如 VisibilityMask 是0x00000007,即最低位3个bit位用于表示这个类的Visibility。 0x00000000表示 nonpublic,0x00000001表示public. -
+ (stridx_size * 2) // Name, Namespace然后是两个 #String 数据流中的偏移。偏移值占用的字节大小由之前头文件的heapOffsetSize可得。 -
+ GetCodedIndexSize (CodedTokenType.TypeDefOrRef)这行代码用于计算Extends列所占用的字节,Extends列中的值是一个索引index,这个index可能指向TypeDef, TypeRef, or TypeSpec这三个表。 实际存储的值是压缩过的coded token,这个token中包含了指向的是哪个表,以及表中的索引,压缩过的coded token的字节大小取决于coded token type这个组里面行数最多的那个表的行数,以及组里面的表的个数。如果他们分别用m和n个比特位就能表示,m+n<16,则GetCodedIndexSize返回的大小为2字节,否则为4字节。 关于更多coded token的内容,可以查看下面Coded Tokens小节。 -
// FieldList和// MethodList相对Extends列来说会简单一点,因为它们分别只指向一个Table,token不需要包含表的信息,那么就是纯粹的RID。2字节能装下就是2字节,不能就4字节。
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函数。
最后
如果感觉看不太懂,请从简介开始从头再看一遍。

浙公网安备 33010602011771号