字段与属性元数据
前言
在前面三篇文章中,我们依次分析了元数据总览、类型元数据和方法元数据。现在,我们将目光转向字段与属性元数据——它们是程序数据存取的基础设施。字段定义了对象的内存布局,属性提供了对数据的封装访问,事件则实现了发布-订阅模式的通知机制。
本文聚焦于四张元数据表:FieldDef(字段定义表)、Property(属性表)、Event(事件表),以及相关的辅助资源(FieldLayout、Constant、MethodSemantics 等)。其中,字段偏移计算是 HybridCLR 元数据模块中的关键技术难点,值得重点关注。
一、FieldDef 元数据
1.1 FieldDef 表的结构
FieldDef 表记录程序集中所有类型定义的字段。它是元数据中"规模最大"的表之一——一个包含 20 个字段的类型会在 Field 表中占据 20 行。
// 来源:hybridclr/metadata/Tables.h,第 4 张表
struct TbField
{
uint32_t flags; // 字段标志(public/private/static/literal 等)
uint32_t name; // 字段名在 #Strings 堆中的索引
uint32_t signature; // 字段签名在 #Blob 堆中的索引
};
字段签名(signature blob)的编码格式为:
字段签名(FIELD 调用约定 0x06):
0x06 → FIELD 签名标识
类型编码 → 字段的元素类型
1.2 字段的运行时表示
HybridCLR 在 InterpreterImage 中为每个字段维护 FieldDetail 结构体:
// 来源:hybridclr/metadata/InterpreterImage.h
struct FieldDetail
{
Il2CppFieldDefinition fieldDef; // 字段定义(包含名称、类型索引等)
uint32_t typeDefIndex; // 所属 TypeDef 的索引
uint32_t offset; // 字段偏移(运行时计算)
uint32_t defaultValueIndex; // 默认值索引(-1 表示无默认值)
};
字段与类型的关联通过 TypeDef 的 fieldStart 字段建立。每个 TypeDef 记录的 fieldStart 是该类型在 _fieldDetails 全局向量中的起始索引:
// 来源:hybridclr/metadata/InterpreterImage.h
// 遍历某个类型的所有字段
for (uint32_t i = 0; i < typeDef.fieldCount; i++)
{
uint32_t fieldActualIndex = DecodeMetadataIndex(typeDef.fieldStart) + i;
FieldDetail& fd = _fieldDetails[fieldActualIndex];
// 处理字段 fd
}
字段标志(Field flags)决定了字段的多种属性:
| 0x0001 | fdPrivate | 仅类型内部可访问 |
| 0x0003 | fdPublic | 所有代码可访问 |
| 0x0004 | fdStatic | 静态字段(存储在全局静态区域而非实例中) |
| 0x0010 | fdInitOnly | 只读字段(只能在构造函数中设置) |
| 0x0040 | fdLiteral | 编译时常量(元数据中存值,无运行时存储) |
| 0x0080 | fdNotSerialized | 不被序列化 |
| 0x0100 | fdHasFieldRVA | 字段有 RVA(初始化数据在 PE 文件的指定位置) |
1.3 实例字段 vs 静态字段
实例字段和静态字段在运行时布局上有本质区别:
- 实例字段:存储在对象实例的内存中,每个实例拥有独立的字段值。字段偏移是相对于对象起始地址的字节偏移
- 静态字段:存储在全局静态区域(IL2CPP 的全局 StaticData 或线程静态存储),不占用实例空间
HybridCLR 在 GetTypeDefinitionSizesFromEncodeIndex 中返回 Il2CppTypeDefinitionSizes,包含实例字段的总大小:
// 来源:hybridclr/metadata/MetadataModule.h
static const Il2CppTypeDefinitionSizes* GetTypeDefinitionSizesFromEncodeIndex(
TypeDefinitionIndex index)
{
uint32_t imageIndex = DecodeImageIndex(index);
return GetImage(imageIndex)->GetTypeDefinitionSizesFromRawIndex(
DecodeMetadataIndex(index));
}
二、Property 与 Event 元数据
2.1 Property 表的结构
Property(属性)表记录类型中定义的属性。属性在 C# 中是语法糖——编译器会将其展开为 getter/setter 方法,Property 表记录的就是这种展开关系的元数据。
// 来源:hybridclr/metadata/Tables.h
struct TbProperty
{
uint16_t flags; // 属性标志
uint32_t name; // 属性名
uint32_t type; // 属性类型签名
};
struct TbMethodSemantics
{
uint16_t semantics; // 语义类型(Getter/Setter/Other/AddOn/RemoveOn/Fire)
uint32_t method; // 关联的方法
uint32_t association; // 关联的 Property/Event 索引
};
HybridCLR 通过 MethodSemantics 表将 Property 与 getter/setter 方法关联。MethodSemantics 的 semantics 字段按位标记:
| 0x0001 | msSetter | set_Xxx 方法 |
| 0x0002 | msGetter | get_Xxx 方法 |
| 0x0004 | msOther | 其他语义方法 |
| 0x0008 | msAddOn | add_Xxx(事件) |
| 0x0010 | msRemoveOn | remove_Xxx(事件) |
| 0x0020 | msFire | Fire(事件) |
运行时中,Property 的信息通过 GetPropertyInfo 查询:
// 来源:hybridclr/metadata/InterpreterImage.h
Il2CppMetadataPropertyInfo GetPropertyInfo(const Il2CppClass* klass, TypePropertyIndex index)
{
const Il2CppTypeDefinition* typeDef = (Il2CppTypeDefinition*)klass->typeMetadataHandle;
uint32_t rowIndex = DecodeMetadataIndex(typeDef->propertyStart) + index;
PropertyDetail& pd = _propeties[rowIndex – 1];
uint32_t baseMethodIdx = DecodeMetadataIndex(typeDef->methodStart) + 1;
const MethodInfo* getter = pd.getterMethodIndex
? il2cpp::vm::Class::GetOrSetupOneMethod(…) : nullptr;
const MethodInfo* setter = pd.setterMethodIndex
? il2cpp::vm::Class::GetOrSetupOneMethod(…) : nullptr;
return { pd.name, getter, setter, pd.flags,
EncodeToken(TableType::PROPERTY, rowIndex) };
}
2.2 Event 表的结构
Event(事件)表与 Property 表类似,也依赖 MethodSemantics 表关联 add/remove/fire 方法:
// 来源:hybridclr/metadata/Tables.h
struct TbEvent
{
uint16_t eventFlags; // 事件标志
uint32_t name; // 事件名
uint32_t eventType; // 事件委托类型(TypeDefOrRef coded index)
};
struct TbEventMap
{
uint32_t parent; // 所属 TypeDef 行索引
uint32_t eventList; // 指向 Event 表的起始行
};
PropertyMap 和 EventMap 表的存在是因为并非所有类型都有属性或事件。TypeDef 中没有直接指向 Property 或 Event 的字段,而是通过 PropertyMap/EventMap 间接关联。如果类型没有声明任何属性,PropertyMap 表中就没有对应的记录。
HybridCLR 通过 GetEventInfo 提供事件的运行时查询:
// 来源:hybridclr/metadata/InterpreterImage.h
Il2CppMetadataEventInfo GetEventInfo(const Il2CppClass* klass, TypeEventIndex index)
{
const Il2CppTypeDefinition* typeDef = (Il2CppTypeDefinition*)klass->typeMetadataHandle;
uint32_t rowIndex = DecodeMetadataIndex(typeDef->eventStart) + index;
EventDetail& pd = _events[rowIndex – 1];
// … 获取 addOn/removeOn/raiseOn 的 MethodInfo 指针
return { pd.name, &klass->byval_arg, addOn, removeOn, raiseOn,
EncodeToken(TableType::EVENT, rowIndex) };
}
三、字段偏移计算
3.1 值类型字段的偏移计算
字段偏移计算是 HybridCLR 元数据模块中最具挑战性的任务之一。HybridCLR 的 ClassFieldLayoutCalculator 负责为热更新类型计算字段偏移,结果必须与 IL2CPP 原生布局算法完全一致——否则 AOT 代码和热更新代码对同一类型字段的访问将产生错位。
值类型(struct)和引用类型(class)的字段偏移计算遵循不同规则。对于值类型,字段偏移的计算步骤为:
Input: TypeDef(包含字段列表和 ClassLayout)
Output: 每个字段的字节偏移
1. 获取 ClassLayout(如果有显式指定 packingSize 和 classSize)
2. 如果 LayoutKind = Explicit(FieldLayout 表中指定每个字段的偏移):
直接使用 FieldLayout 表中的 offset 值
3. 如果 LayoutKind = Sequential(FieldLayout 表可能指定了部分字段的偏移):
从偏移 0 开始,按字段声明顺序:
a. 对齐当前偏移到字段类型大小的整数倍
b. 分配字段偏移
c. 总偏移 += 字段大小
4. 如果 LayoutKind = Auto(需要模拟 IL2CPP 的自动布局算法):
按类型大小从大到小排序字段
使用最佳适配算法安排字段位置
// 来源:hybridclr/metadata/ClassFieldLayoutCalculator.h
// 主要接口(基于源码分析)
class ClassFieldLayoutCalculator
{
public:
// 计算指定类型的字段布局
void CalculateFieldLayouts(
const Il2CppTypeDefinition* typeDef,
std::vector<uint32_t>& fieldOffsets
);
private:
// 获取类型的自然对齐大小
uint32_t GetTypeAlignment(const Il2CppType* type);
// 计算自动布局
void CalculateAutoLayout(
const Il2CppTypeDefinition* typeDef,
std::vector<uint32_t>& fieldOffsets
);
};
3.2 引用类型字段的存储
引用类型(class)的字段存储与值类型不同:
- 在 IL2CPP 中,引用类型对象的内存布局是:ObjectHeader(对象头,IL2CPP 固定 24 字节)+ 字段数据
- 引用类型的字段偏移 = ObjectHeader 大小 + 按序排列的字段偏移
- 引用类型的实例字段只能存储引用指针(8 字节)或值类型嵌入
HybridCLR 通过 GetFieldOffset 方法获取字段的最终偏移:
// 来源:hybridclr/metadata/InterpreterImage.h
uint32_t GetFieldOffset(const Il2CppTypeDefinition* typeDef, int32_t fieldIndexInType)
{
uint32_t fieldActualIndex = DecodeMetadataIndex(typeDef->fieldStart) + fieldIndexInType;
IL2CPP_ASSERT(fieldActualIndex < (uint32_t)_fieldDetails.size());
return _fieldDetails[fieldActualIndex].offset;
}
3.3 泛型类型字段的特殊处理
泛型类型的字段偏移计算比非泛型类型更复杂。考虑以下类型:
[StructLayout(LayoutKind.Auto)]
struct Pair<T1, T2>
{
T1 first;
T2 second;
int extra;
}
当 Pair<int, byte> 和 Pair<double, long> 被实例化时,它们的字段布局完全不同——因为 Auto 布局的排序算法依赖于字段类型的大小。HybridCLR 的处理策略是"每个泛型实例化独立计算字段布局":当泛型实例化类型被首次使用时,触发 ClassFieldLayoutCalculator 为该具体实例化计算出完整的字段偏移表。
InterpreterImage 中支持通过 GetFieldOffset 的两个重载分别查询类型定义级别和类实例级别的字段偏移:
// 来源:hybridclr/metadata/InterpreterImage.h
uint32_t GetFieldOffset(const Il2CppTypeDefinition* typeDef, int32_t fieldIndexInType);
uint32_t GetFieldOffset(const Il2CppClass* klass, int32_t fieldIndexInType);
同时,GetPackingSize 和 GetClassLayout 提供了 ClassLayout 信息的查询接口:
int32_t GetPackingSize(const Il2CppTypeDefinition* typeDef) const;
TbClassLayout GetClassLayout(const Il2CppTypeDefinition* typeDef) const;
四、属性反射的实现
4.1 GetCustomAttributes 的元数据基础
自定义属性(Custom Attributes)是 .NET 反射系统的核心功能之一。当代码中编写 [Obsolete]、[Serializable] 等特性时,编译器将这些信息编码到元数据的 CustomAttribute 表中:
// 来源:hybridclr/metadata/Tables.h
struct TbCustomAttribute
{
uint32_t parent; // 被修饰的目标(HasCustomAttribute coded index)
uint32_t type; // 属性类型(CustomAttributeType coded index)
uint32_t value; // 属性参数值(#Blob 索引)
};
InterpreterImage 使用两个数据结构管理自定义属性:
// 来源:hybridclr/metadata/InterpreterImage.h
std::unordered_map<uint32_t, CustomAttributesInfo> _tokenCustomAttributes;
std::vector<Il2CppCustomAttributeTypeRange> _customAttributeHandles;
_tokenCustomAttributes 是一个哈希映射,key 是元数据 token(编码了表类型和行号),value 包含自定义属性数据在二进制流中的范围。这种设计允许在常数时间内检查任意元数据元素是否有自定义属性。
4.2 属性缓存机制
自定义属性的解析是一个相对昂贵的操作(涉及签名解析、构造函数调用、参数 boxing 等)。HybridCLR 采用了多层缓存策略:
// Unity 2021+ 版本的自定义属性数据初始化(基于 InterpreterImage.h 分析)
void BuildCustomAttributesData(CustomAttributesInfo& cai,
const Il2CppCustomAttributeTypeRange& typeRange);
void ConvertILCustomAttributeData2Il2CppFormat(
const MethodInfo* ctorMethod, BlobReader& reader);
4.3 性能优化策略
属性反射的性能优化主要围绕"延迟加载"和"格式转换"两个方向:
| 延迟初始化 | CustomAttributesInfo 的 inited 标志位 | 避免不必要的属性解析 |
| 格式预转换 | IL 格式 → IL2CPP 格式的提前转换 | 运行时直接读取即可,无需二次解析 |
| 二进制 Blob 缓存 | _il2cppFormatCustomDataBlob 内存池 | 减少内存碎片和分配开销 |
| Hash 索引 | _tokenCustomAttributes 哈希表 | O(1) 查询时间 |
| 按需加载 | 仅在首次使用 HasAttribute/CreateCustomAttributeDataReader 时触发加载 | 避免无属性元素的检查开销 |
4.4 CustomAttributeData 的格式转换机制
HybridCLR 需要将 .NET 元数据格式的 CustomAttribute blob 转换为 IL2CPP 运行时可以理解的格式。这一转换由 CustomAttributeDataWriter 和 ConvertILCustomAttributeData2Il2CppFormat 协同完成。
CustomAttributeDataWriter 位于 hybridclr/metadata/CustomAttributeDataWriter.h,负责将原始的 CustomAttribute blob 数据重写为 IL2CPP 格式:
// 来源:hybridclr/metadata/CustomAttributeDataWriter.h(基于源码分析)
class CustomAttributeDataWriter
{
public:
CustomAttributeDataWriter();
// 向 writer 中写入数据
void WriteByte(uint8_t val);
void WriteUint16(uint16_t val);
void WriteUint32(uint32_t val);
void WriteBytes(const uint8_t* data, uint32_t length);
// 获取最终输出的数据
const uint8_t* GetData() const;
uint32_t GetLength() const;
// 重置 writer 状态
void Clear();
private:
std::vector<uint8_t> _data; // 输出的二进制数据
};
格式转换的核心逻辑在 ConvertILCustomAttributeData2Il2CppFormat 中:
// 基于 InterpreterImage.cpp 的分析
void ConvertILCustomAttributeData2Il2CppFormat(
const MethodInfo* ctorMethod, // 属性类型的构造函数
BlobReader& reader) // 原始 blob 数据读取器
{
CustomAttributeDataWriter writer;
// 步骤 1:跳过 FixedArg 的 prolog(固定为 0x0001)
uint16_t prolog = reader.ReadUint16();
IL2CPP_ASSERT(prolog == 0x0001);
// 步骤 2:读取构造函数参数(FixedArgs)
// 根据 ctorMethod 的参数类型列表逐一读取
uint16_t numFixedArgs = …; // 从构造函数签名中获取参数数量
for (uint16_t i = 0; i < numFixedArgs; i++)
{
// 根据参数类型读取对应类型的 blob 数据并写入
ElementType paramType = …;
switch (paramType)
{
case ELEMENT_TYPE_BOOLEAN: writer.WriteByte(reader.ReadByte()); break;
case ELEMENT_TYPE_I4: writer.WriteUint32(reader.ReadCompressedUInt32()); break;
case ELEMENT_TYPE_STRING: writer.WriteBlob(reader.ReadBlob()); break;
// … 其他类型
}
}
// 步骤 3:读取命名参数(NamedArgs)
// NamedArgs 的格式:NumNamed(2字节) → (Field/Prop + Type + Name + Value) × NumNamed
uint16_t numNamed = reader.ReadUint16();
for (uint16_t i = 0; i < numNamed; i++)
{
uint8_t fieldOrProp = reader.ReadByte(); // 0x53=Field, 0x54=Property
// … 读取并转换命名参数
}
// 最终结果存储在 writer 的 buffer 中
// _il2cppFormatCustomDataBlob 追加写入
}
HybridCLR 支持两种自定义属性数据格式:
| RAW_IMAGE | HYBRIDCLR_UNITY_2020_OR_NEWER | 直接从 Image 中读取原始 blob 数据,不进行格式转换 |
| CONVERTED_IL2CPP_FORMAT | 非 2020 版本 | 将原始 blob 转换为 IL2CPP 兼容格式,需要调用 ConvertILCustomAttributeData2Il2CppFormat |
这两种格式的差异在于:RAW_IMAGE 格式直接在原始 .NET 元数据的 CustomAttribute blob 上操作(数据格式严格按照 ECMA-335 标准),而 CONVERTED_IL2CPP_FORMAT 则需要进行一次数据重排——将 ECMA-335 标准格式的 FixedArg/NamedArg 数据转换为 IL2CPP 预期的顺序排列。
格式转换发生在 BuildCustomAttributesData 阶段——当 InitCustomAttributes 被调用时,系统遍历所有 CustomAttribute 记录,对每条记录调用 ConvertILCustomAttributeData2Il2CppFormat(需要时),并将转换后的二进制数据存储到 _il2cppFormatCustomDataBlob 这个连续内存池中。
4.5 字段 Marshalling 与声明安全性
在字段和属性的元数据处理中,还有两张不常用但重要的元数据表——TbFieldMarshal 和 TbDeclSecurity。
TbFieldMarshal(字段 Marshalling 表)定义了非托管互操作场景下的字段封送行为:
// 来源:hybridclr/metadata/Tables.h
struct TbFieldMarshal
{
uint32_t parent; // HasFieldMarshal coded index(Field 或 Param 行索引)
uint32_t nativeType; // 本机类型描述(#Blob 索引)
};
当字段标记了 [MarshalAs(UnmanagedType.ByValArray, SizeConst=128)] 时,编译器会在 FieldMarshal 表中生成一条记录。HybridCLR 的 InitFieldMarshal 方法负责解析这些记录:
// 来源:hybridclr/metadata/InterpreterImage.h
void InitFieldMarshal(); // 初始化字段 marshalling 信息
TbDeclSecurity(声明安全性表)记录了代码访问安全性(CAS)和权限需求相关的元数据:
// 来源:hybridclr/metadata/Tables.h
struct TbDeclSecurity
{
uint16_t action; // 安全操作类型(请求/需求/断言/拒绝/许可等)
uint32_t parent; // HasDeclSecurity coded index
uint32_t permissionSet; // 权限集 blob(#Blob 索引)
};
DeclSecurity 表中的 action 字段对应 .NET 的安全声明类型:
| 0x0001 | Request | 请求最小权限集 |
| 0x0002 | Demand | 要求调用链的每个调用者拥有指定权限 |
| 0x0003 | Assert | 断言当前代码拥有指定权限 |
| 0x0004 | Deny | 拒绝指定权限 |
| 0x0005 | PermitOnly | 允许仅指定权限 |
在 HybridCLR 的运行时上下文中,FieldMarshal 主要影响 P/Invoke 场景的封送行为(如定长数组的封送大小计算),而 DeclSecurity 在 IL2CPP 模式下通常被忽略(IL2CPP 不完全支持代码访问安全性)。HybridCLR 会解析这两张表以保持元数据完整性,但在实际执行中不会基于 DeclSecurity 数据执行安全策略检查。
五、字段内存布局的完整计算过程
5.1 字段布局的数据结构
在 HybridCLR 的 InterpreterImage 中,字段布局信息分布在两个数据结构中:
// 来源:hybridclr/metadata/InterpreterImage.h(成员变量)
// 字段相关信息
std::vector<FieldDetail> _fieldDetails; // 字段详情(含类型索引和偏移)
std::vector<Il2CppFieldDefaultValue> _fieldDefaultValues; // 字段默认值
std::unordered_map<uint32_t, TbClassLayout> _classLayouts; // 类型布局映射
// 字段偏移通过 GetFieldOffset 获取
uint32_t GetFieldOffset(const Il2CppTypeDefinition* typeDef, int32_t fieldIndexInType)
{
uint32_t fieldActualIndex = DecodeMetadataIndex(typeDef->fieldStart) + fieldIndexInType;
IL2CPP_ASSERT(fieldActualIndex < (uint32_t)_fieldDetails.size());
return _fieldDetails[fieldActualIndex].offset;
}
5.2 Explicit 布局的完整处理
当类型标记了 [StructLayout(LayoutKind.Explicit)] 时,字段的偏移由 FieldLayout 表直接指定,不需要计算:
// 来源:hybridclr/metadata/Tables.h
struct TbFieldLayout
{
uint32_t offset; // 字段偏移(显式指定)
uint32_t field; // 对应的 Field 行索引
};
HybridCLR 在 InitFieldLayouts 方法中处理这些布局信息:
// 来源:hybridclr/metadata/InterpreterImage.h
void InitFieldLayouts(); // 初始化显式字段布局
5.3 Sequential 布局的对齐规则
对于 [StructLayout(LayoutKind.Sequential)] 的类型,HybridCLR 的 ClassFieldLayoutCalculator 按以下对齐规则布局:
值类型的对齐规则为:
| bool/byte/sbyte | 1 | 1 |
| char/short/ushort | 2 | 2 |
| int/uint/float | 4 | 4 |
| long/ulong/double | 8 | 8 |
| 引用类型指针 | 8 | 8 |
| struct(值类型) | 成员最大字段大小 | 成员最大对齐 |
5.4 Auto 布局的排序算法
Auto 布局是三种布局模式中最复杂的。IL2CPP 采用了一种特殊的排序算法来优化内存布局:
HybridCLR 的 ClassFieldLayoutCalculator 实现了与 IL2CPP 相同的排序逻辑,确保热更新类型的字段布局与 IL2CPP 为同等 AOT 类型计算的布局完全一致。不一致会导致内存错位和运行时崩溃——这是 HybridCLR 中最容易出 Bug 的地方之一。
六、属性的元数据解析细节
6.1 PropertyDetail 的结构
Property 在 HybridCLR 的内部表示为 PropertyDetail:
// 来源:hybridclr/metadata/InterpreterImage.h
struct PropertyDetail
{
const char* name; // 属性名
uint16_t flags; // 属性标志
uint32_t signatureBlobIndex; // 属性类型签名 blob 索引
uint32_t getterMethodIndex; // getter 方法索引(从 1 开始)
uint32_t setterMethodIndex; // setter 方法索引
const Il2CppTypeDefinition* declaringType; // 声明类型的定义
Il2CppPropertyDefinition il2cppDefinition; // IL2CPP 兼容的属性定义
};
在加载阶段,InitProperties 和 InitMethodSemantics 协同工作:
void InitProperties(); // 解析 PropertyMap 和 Property 表
void InitMethodSemantics(); // 解析 MethodSemantics 表(关联 getter/setter)
6.2 属性的反射性能
属性访问在反射场景中比较常见,但 HybridCLR 没有为属性访问做专门优化——它通过通用的 MethodInfo 路径实现:
- GetPropertyInfo 返回包含 getter/setter MethodInfo 的 PropertyInfo
- 对属性的每次反射调用实际走的是 MethodInfo 的 invoke 路径
- 因此频繁通过反射访问属性会有性能开销,建议使用直接调用或编译时绑定的方式替代
七、事件的元数据解析细节
事件在 HybridCLR 中的内部表示为 EventDetail:
// 来源:hybridclr/metadata/InterpreterImage.h
struct EventDetail
{
const char* name; // 事件名
uint16_t eventFlags; // 事件标志
uint32_t eventType; // 委托类型(TypeDefOrRef coded index)
uint32_t addMethodIndex; // add_Xxx 方法索引
uint32_t removeMethodIndex; // remove_Xxx 方法索引
uint32_t fireMethodIndex; // fire 方法索引
const Il2CppTypeDefinition* declaringType;
Il2CppEventDefinition il2cppDefinition;
};
InitEvents 方法负责解析 EventMap 和 Event 表,InitMethodSemantics 将 Event 与 add/remove/fire 方法关联。
事件元数据在 HybridCLR 中的使用场景相对有限——由于解释器执行 += 运算符时,编译器模块已经将其转换为 AddOn 方法的调用,事件元数据主要用于反射场景的类型查询。
八、字段默认值的处理
字段的默认值(Field Default Value)通过 Constant 表关联:
// 来源:hybridclr/metadata/Tables.h
struct TbConstant
{
uint8_t type; // 常量值类型
uint8_t padding;
uint32_t parent; // 拥有者(HasConstant coded index)
uint32_t value; // 常量值在 #Blob 堆中的索引
};
对于标记了 [DefaultValue] 特性的字段,Compiler 会生成一条 Constant 表记录。HybridCLR 在加载时通过 InitFieldRVAs 方法处理 RVA 字段初始化数据:
void InitFieldRVAs(); // 初始化 RVA 字段(static 变量的初始值)
字段的默认值通过 GetFieldDefaultValueEntryByRawIndex 查询:
const Il2CppFieldDefaultValue* GetFieldDefaultValueEntryByRawIndex(uint32_t index)
{
IL2CPP_ASSERT(index < (uint32_t)_fieldDetails.size());
uint32_t fdvIndex = _fieldDetails[index].defaultValueIndex;
IL2CPP_ASSERT(fdvIndex != kDefaultValueIndexNull);
return &_fieldDefaultValues[fdvIndex];
}
九、字段与属性元数据的关键源码文件
| hybridclr/metadata/Tables.h | TbField, TbProperty, TbEvent, TbConstant, TbFieldLayout | — |
| hybridclr/metadata/InterpreterImage.h | 字段/属性/事件加载 | InitFieldDefs, InitProperties, InitEvents, InitMethodSemantics |
| hybridclr/metadata/InterpreterImage.h | 字段偏移计算 | GetFieldOffset (三个重载), GetPackingSize, GetClassLayout |
| hybridclr/metadata/InterpreterImage.h | 字段详情 | FieldDetail, PropertyDetail, EventDetail |
| hybridclr/metadata/ClassFieldLayoutCalculator.h | 字段布局计算器 | ClassFieldLayoutCalculator |
| hybridclr/metadata/MetadataModule.h | 字段查询接口 | GetFieldDefinitionFromEncodeIndex, GetFieldOffset |
十、字段偏移计算的特殊案例
10.1 可空类型的字段布局
Nullable<T> 在 .NET 中有特殊的布局规则。IL2CPP 将其视为一个包含 hasValue 标志和值字段的包装类型。HybridCLR 的 ClassFieldLayoutCalculator 需要正确处理这种特殊类型的字段偏移——可空类型在封箱拆箱时的内存布局与普通值类型不同。具体来说,IL2CPP 为 Nullable 分配了两个字段:hasValue(bool,1 字节,位于偏移 0)和 value(T 类型,对齐到 sizeof(T) 的边界)。HybridCLR 在计算 Nullable 的字段偏移时必须与 IL2CPP 的行为一致,否则拆箱操作会读取到错误的内存位置。
10.2 泛型类型的字段偏移缓存
由于不同泛型实例化有不同的字段布局,HybridCLR 为每个泛型实例化独立缓存字段偏移。在 _fieldDetails 向量中,FieldDetail.offset 字段存储的是计算后的最终偏移,后续访问只需要 O(1) 的数组索引。
但泛型实例化类型有一个额外的复杂性:当 List<int> 和 List<string> 作为 Pair<List<int>, List<string>> 的字段时,外层的字段布局依赖于内层的类型大小。HybridCLR 的解决方案是在首次访问一个泛型实例化类型时递归计算它所有字段的布局,并将结果缓存到对应的 TypeDefinitionDetail 中。
// 基于 InterpreterImage.h 的分析
// 计算泛型实例化类型的字段偏移
// 泛型实例化的字段布局在首次 GetFieldOffset 调用时触发
uint32_t GetFieldOffset(const Il2CppTypeDefinition* typeDef, int32_t fieldIndexInType)
{
uint32_t fieldActualIndex = DecodeMetadataIndex(typeDef->fieldStart) + fieldIndexInType;
IL2CPP_ASSERT(fieldActualIndex < (uint32_t)_fieldDetails.size());
return _fieldDetails[fieldActualIndex].offset;
}
10.3 类型大小计算
Il2CppTypeDefinitionSizes 中有三个重要的 size 字段:
| instanceSize | 实例总大小(含对象头) | 字段布局后的总字节数 + ObjectHeader 大小 |
| nativeSize | P/Invoke marshalling 大小 | 按本机布局规则计算后的总字节数 |
| staticSize | 静态字段总大小 | 所有静态字段字节数之和 |
GetTypeDefinitionSizesFromEncodeIndex 返回这些信息:
// 来源:hybridclr/metadata/MetadataModule.h
static const Il2CppTypeDefinitionSizes* GetTypeDefinitionSizesFromEncodeIndex(
TypeDefinitionIndex index)
{
uint32_t imageIndex = DecodeImageIndex(index);
return GetImage(imageIndex)->GetTypeDefinitionSizesFromRawIndex(
DecodeMetadataIndex(index));
}
instanceSize 用于运行时内存分配——IL2CPP 在创建类型实例时需要知道需要分配多少字节。staticSize 用于静态存储区的管理——IL2CPP 为每个类型维护一块静态数据存储区。
十一、InterpreterImage 中字段相关方法的完整调用链
RuntimeApi.LoadImage → InterpreterImage::Load
↓
InterpreterImage::InitFieldDefs
├── 读取所有 TbField 记录
├── 解析字段签名(从 #Blob 堆)
└── 填充 _fieldDetails 向量
InterpreterImage::InitFieldLayouts
├── 读取 TbFieldLayout 记录(Explicit 布局的偏移)
└── 填充 _fieldDetails[offset](仅 Explicit 类型)
InterpreterImage::InitFieldRVAs
├── 读取 TbFieldRVA 记录(静态字段的初始值)
└── 关联 RVA 数据到 _fieldDefaultValues
InterpreterImage::InitClassLayouts0
InterpreterImage::InitClassLayouts
├── 读取 TbClassLayout 记录
└── 填充 _classLayouts 映射
InterpreterImage::InitBlittables
├── 计算字段的 blittable 属性
└── 填充 ComputeBlittable 标志
字段偏移是在 ClassFieldLayoutCalculator 中最终计算的。它不是在 InterpreterImage::Load 阶段立即执行,而是延迟到字段首次访问时。但对于大多数类型(非泛型或已确定布局的类型),字段偏移在 InitClassLayouts 阶段就已经计算完成并填入 _fieldDetails。
这种设计平衡了加载时间和运行时查询性能——简单类型的字段偏移提前计算好,减少运行时开销;复杂类型(如泛型实例化)的字段偏移延迟计算,减少不必要的计算。
十二、字段与属性的跨版本兼容性
由于 HybridCLR 支持多个 Unity 版本(2019/2020/2021/2022+),字段和属性元数据的解析也需要针对不同版本进行适配:
| 2019 | 标准字段布局,无特殊处理 | EventDetail 中包含 declaringType 和 il2cppDefinition |
| 2020 | HYBRIDCLR_UNITY_2020 宏控制 | GetCustomAttributeDataRange 使用 2020 兼容 API |
| 2021+ | CustomAttributeData 使用 IL2CPP 格式 | InitCustomAttributeData 使用 CONVERTED_IL2CPP_FORMAT |
| 2022+ | 无显著差异 | CreateCustomAttributeDataReader 使用新的 API 签名 |
这些版本差异通过条件编译宏隔离。在阅读 InterpreterImage.h 时,可以注意 #if HYBRIDCLR_UNITY_2019、#if HYBRIDCLR_UNITY_2021_OR_NEW、#if HYBRIDCLR_UNITY_2022_OR_NEW 等预处理指令,它们标记了不同 Unity 版本中的元数据格式差异。
一个典型的版本差异示例是 EventDetail 的跨版本定义:
// 来源:hybridclr/metadata/InterpreterImage.h
struct EventDetail
{
const char* name;
uint16_t eventFlags;
uint32_t eventType;
uint32_t addMethodIndex;
uint32_t removeMethodIndex;
uint32_t fireMethodIndex;
#if HYBRIDCLR_UNITY_2019
const Il2CppTypeDefinition* declaringType;
Il2CppEventDefinition il2cppDefinition;
#endif
};
在 Unity 2020 及之后版本中,EventDetail 不需要包含 declaringType 和 il2cppDefinition,因为这些信息可以从其他运行时结构体中获取。HybridCLR 通过条件编译减少了新版本的冗余数据。
十三、InterpreterImage 中的字段元数据与运行时 Class 的交互
字段元数据最终需要与 IL2CPP 运行时中的 Il2CppClass 结构体交互。当热更新类型被首次使用时,IL2CPP 的运行时会通过 MetadataModule 查询字段信息来初始化 Il2CppClass 的字段数组。
交互流程如下:
热更新类型首次使用 → il2cpp::vm::Class::Init 触发
↓
IL2CPP 运行时的 Class::Init 调用 HybridCLR 的钩子
↓
InterpreterImage::GetTypeInfoFromTypeDefinitionRawIndex
→ 创建或获取 Il2CppClass 实例
↓
设置字段信息
→ 从 _fieldDetails 中读取 FieldDefinition
→ 从 InterpreterImage 读取字段名称
→ 设置 FieldInfo 的 name / type / offset
↓
设置属性/事件信息
→ 从 _propeties / _events 中读取 Property/Event 定义
→ 关联 getter/setter MethodInfo
↓
Il2CppClass 初始化完成
→ 运行时可以正常使用该热更新类型
在这个过程中,字段元数据从 InterpreterImage 的 _fieldDetails 向量中流向 Il2CppClass 的 fields 数组。两个数据结构共享相同的字段信息,但存储方式不同——InterpreterImage 使用高度结构化的向量(适合查询),Il2CppClass 使用紧凑的数组(适合运行时访问)。
十四、元数据模块中字段相关的常量和宏
HybridCLR 中与字段元数据相关的重要常量和宏:
| kDefaultValueIndexNull | uint32_t(-1) | 标记字段没有默认值 |
| kGenericContainerIndexInvalid | uint32_t(-1) | 标记字段所属类型没有泛型参数 |
| kTypeIndexInvalid | uint32_t(-1) | 标记无效的类型索引 |
| kMaxMetadataImageCount | 64 | Image 最大数量 |
这些常量在字段元数据的解析中用于边界检查。例如,当 FieldDetail.defaultValueIndex == kDefaultValueIndexNull 时,表示该字段没有默认值,查询时直接返回 nullptr。
十五、字段与属性元数据在反序列化中的作用
字段元数据在对象反序列化(如 Unity 的 Serialization、Json.NET 等场景)中扮演重要角色。反序列化器需要根据字段的元数据信息来确定如何填充对象:
| Unity Serialization | 字段名、类型、是否序列化(IsSerializable flag) | TbField.flags |
| JSON 序列化 | 字段名、类型 | TbField.name, TbField.signature |
| BinaryFormatter | 字段 ID、类型布局 | TbField + ClassLayout |
| IL2CPP 代码 stripping | 字段被引用状态 | CustomAttribute 反射 |
在 HybridCLR 的热更新场景中,Unity Serialization 是一个核心关注点——因为 MonoBehaviour 的子类可以通过热更新方式定义,但其序列化机制与 AOT 的 MonoBehaviour 完全一致。HybridCLR 通过 JIT 生成合适的字段偏移信息,使 Unity 的序列化器能正确访问热更新类型中的字段。
具体来说,当 Unity 序列化一个热更新 MonoBehaviour 的实例时,它会读取该类型的字段信息(通过 Il2CppClass 的 fields 数组),然后按字段偏移将字段值写入序列化流。这个过程中,HybridCLR 的字段元数据扮演了数据模式定义的角色。
十六、字段偏移计算的验证机制
字段偏移的正确性是 HybridCLR 稳定性最关键的因素之一。一个字段偏移错误会导致:
- 读写的字段值错位(读取 A 字段时实际访问了 B 字段的内存)
- 对象 size 计算错误(内存分配不足导致越界写入)
- GC 扫描错误(将值类型误认为引用类型指针,导致崩溃)
HybridCLR 通过以下机制验证字段偏移的正确性:
HybridCLR 的字段偏移计算正确性高度依赖于 ClassFieldLayoutCalculator 是否与 IL2CPP 的内部布局算法保持同步。每当 Unity 的 IL2CPP 字段布局算法发生变化时,HybridCLR 项目都会在相应的 Unity 版本适配分支中更新这个计算器。这也是 hybridclr 仓库有多个 Unity 版本分支(unity2019、unity2020、unity2021、unity2022、main)的重要原因之一——不同版本的 IL2CPP 字段布局算法存在差异。
十七、字段元数据在编译器模块中的作用
编译器模块在将 IL 指令编译为寄存器指令时,字段元数据承担以下关键作用:
在编译器生成的寄存器指令中,字段元数据不会以原始 token 形式存在——编译器在读入 IL 阶段已经将字段 token 解析为具体的偏移和大小信息,直接编码到寄存器指令的操作数中。这使得解释器在执行时不需要回查字段元数据,进一步优化了执行性能。
总结
字段与属性元数据是 HybridCLR 元数据模块中数据存取的基础。本文分析了:
- FieldDef 表:记录所有字段定义,通过 fieldStart 与 TypeDef 关联,FieldDetail 结构体提供了运行时所需的完整信息
- Property 和 Event 表:通过 MethodSemantics 与 getter/setter/add/remove/fire 方法关联,Il2CppMetadataPropertyInfo/EventInfo 对外暴露运行时接口
- 字段偏移计算:ClassFieldLayoutCalculator 分 Explicit、Sequential、Auto 三种布局模式计算偏移,对泛型类型采用独立实例化的计算策略
- 属性反射:通过 CustomAttribute 表和 Multi-layer 缓存机制实现高效的运行时属性查询
下一篇文章将完成元数据模块的最后一篇——程序集与模块元数据,分析 AssemblyRef、Module 和 ExportedType 表的结构以及程序集加载流程。
参考资源
- hybridclr/metadata/Tables.h — TbField、TbProperty、TbEvent、TbConstant、TbFieldLayout、TbFieldMarshal、TbDeclSecurity 结构体
- hybridclr/metadata/InterpreterImage.h — FieldDetail、PropertyDetail、EventDetail、GetFieldOffset、BuildCustomAttributesData
- hybridclr/metadata/ClassFieldLayoutCalculator.h — 字段布局计算器
- hybridclr/metadata/CustomAttributeDataWriter.h — 自定义属性数据格式转换
- ECMA-335 Partition II, Chapter 22 — 字段、属性、事件元数据标准
- GitHub – focus-creative-games/hybridclr: HybridCLR是一个特性完整、零成本、高性能、低内存的Unity全平台原生c#热更新解决方案。 HybridCLR is a fully featured, zero-cost, high-performance, low-memory solution for Unity's all-platform native c# hotupdate. · GitHub — hybridclr 主仓库

