CLR 中类型字段的运行时内存布局 (Layout) 原理浅析 [2]

时间:2021-12-19 14:55:26


    在了解了 CLR 对类型的内存布局的访问方式后,回过头来看看 CLR 是如何在载入类型时,对其内存布局进行调整的。

    与 Java 中的 ClassLoader 概念不同,CLR 中将类型的隔离和载入放到 Assembly 和 AppDomain 两个层面来维护。因为 Java 的模型是建立在以 ClassLoader 为聚集点的独立类型集合上,而 CLR 则是以 Assembly 对类型进行物理组织,以 AppDomain 进行逻辑组织。所以在 CLR 中实际上并没有一个完全对应于 Java 的 ClassLoader 的部件,而是由 Fusion 完成 Assembly 中类型载入工作,由 AppDomain 完成类型隔离和安全限定等工作。
    Rotor 中的 ClassLoader 类 (vm/clsloader.hpp:308) 就是负责实际类型信息载入的主要工具类之一。
    其实际进行类型载入的 LoadTypeHandleFromToken 函数 (vm/clsloader.cpp:1814),将首先通过 HasLayoutMetadata 函数判断要载入的类型是否包括 layout 信息。如在 C# 中使用缺省的 struct 定义,或者显式定义 StructLayoutAttribute 为 LayoutKind.Explicit,都将使类型中包括布局信息。
    对包括布局信息的类型,此函数将进一步调用 CollectLayoutFieldMetadata 函数执行布局策略并收集其布局信息,并在构造类型方法表的时候将字段布局信息绑定进去。因此就如前面讨论对布局信息使用的方法一样,类型的布局信息是与其方法表动态绑定到一起的。

    执行布局策略并收集布局信息的 CollectLayoutFieldMetadata 函数 (vm/nstruct.cpp:627),将首先检查此类型的父类型是否是普通值类型或 Object 类型,如果不是则确认其也拥有布局信息。也就是说不能从一个 LayoutKind.Auto 类型的自动布局类型中,继承出一个具有特定布局策略的类型,以保证布局策略执行的完整性。这一点一般来说是通过开发语言的编译器一级做限定的,但 CLR 不排除通过 IL 或其他方式定义的可能性,因此必须做一个检测。

    然后此函数会分三个阶段完成对类型布局的策略执行和信息收集工作。

    1.遍历类型字段定义,初始化后面要用到的辅助结构
    2.遍历并计算每个字段的 Native 形式所需空间
    3.根据字段的对齐策略对其执行布局策略

    首先来看看在执行布局策略全程使用的一个布局信息中间结构 LayoutRawFieldInfo。
以下内容为程序代码:

struct LayoutRawFieldInfo
{
    mdFieldDef  m_MD;             // mdMemberDefNil for end of array
    UINT8       m_nft;            // NFT_* value
    UINT32      m_offset;         // native offset of field
    UINT32      m_cbNativeSize;   // native size of field in bytes
    ULONG       m_sequence;       // sequence # from metadata
    BOOL        m_fIsOverlapped;

    struct {
        private:
            char m_space[MAXFIELDMARSHALERSIZE];
    } m_FieldMarshaler;
}

    在调用 CollectLayoutFieldMetadata 函数前会构造一个 LayoutRawFieldInfo 结构数组,然后在阶段 1 中初始化 m_MD (字段 Token)、m_nft(NStruct 内部使用的字段类型定义)和 m_FieldMarshaler (字段类型相关的 Marshaler 处理对象)字段,并且根据布局策略初始化 m_offset(字段偏移)和 m_sequence (字段在元数据中的顺序);接着在阶段 2 则根据每个字段的 Marshaler 计算其 m_cbNativeSize(字段实际大小);最后在阶段 3 根据布局策略调整并设置所有字段的 m_offset (类型中字段偏移)以及 m_fIsOverlapped(字段是否被覆盖)。因此,实际上 CollectLayoutFieldMetadata 函数的所有操作都是围绕着这个结构数组进行的。

    下面来仔细看看每个阶段是如何操作的。

    阶段 1 通过 IMDInternalImport 接口遍历类型的每个字段,在跳过所有的静态字段后,初始化中间结构数组项;然后调用 ParseNativeType 函数针对每个字段的类型进行分析,将之翻译为 NStruct 内部使用的字段类型 (m_nft),并构造字段相关的 Marshaler 对象;最后根据字段的类型判断是否需要将类型设置为可位复制的。伪代码如下:
以下内容为程序代码:

IMDInternalImport *pInternalImport = pModule->GetMDImport();
LayoutRawFieldInfo *pfwalk = pInfoArrayOut;

// 遍历类型的每个字段
for (ULONG i = 0; pInternalImport->EnumNext(phEnumField, &fd); i++)
{
  // 跳过所有的静态字段
    if ( !(IsFdStatic(pInternalImport->GetFieldDefProps(fd))) [img]/images/wink.gif[/img]
  {
    // 初始化中间结构数组项
    pfwalk->m_MD = fd;
    pfwalk->m_nft = NULL;
    pfwalk->m_offset = (UINT32) -1;
    pfwalk->m_sequence = 0;

    // 针对每个字段的类型进行分析
    ParseNativeType(...);

    // 根据字段的类型判断是否需要将类型设置为可位复制的
    BOOL    resetBlittable = TRUE;

    if (pfwalk->m_nft == NFT_COPY1    ||
        pfwalk->m_nft == NFT_COPY2    ||
        pfwalk->m_nft == NFT_COPY4    ||
        pfwalk->m_nft == NFT_COPY8)
    {
      resetBlittable = FALSE;
    }

    if (pfwalk->m_nft == NFT_NESTEDVALUECLASS)
    {
      FieldMarshaler *pFM = (FieldMarshaler*)&(pfwalk->m_FieldMarshaler);

      if (((FieldMarshaler_NestedValueClass *) pFM)->IsBlittable())
      {
          resetBlittable = FALSE;
      }
    }

    if (resetBlittable)
      pEEClassLayoutInfoOut->m_fBlittable          = FALSE;
  }
}

    ParseNativeType 函数 (vm/nstruct.cpp:178) 实际上是一个巨大的多级 switch 语句,根据从字段 Signature 中获取的类型信息,初始化 LayoutRawFieldInfo::m_nft 字段,并构造相匹配的 Marshaler 处理对象。这个庞大的 Marshaler 对象树从 FieldMarshaler 基类开始,提供了所有类型的 Marhsal 支持工具函数。如对简单的内建类型 int, long 等,提供了通用的基于值语义的 FieldMarshaler_Copy4 和 FieldMarshaler_Copy8 类型,也对复杂的接口、日期和字符串,提供了 FieldMarshaler_Interface、FieldMarshaler_Date 和 FieldMarshaler_StringAnsi 等类型。这样一来在对字段进行操作的时候,就可以通过多态方便地隔离具体的字段类型信息。

    阶段 1 接着会通过 IMDInternalImport::GetClassLayoutNext 方法,遍历类型的布局信息。伪代码如下:
以下内容为程序代码:

while (SUCCEEDED(hr = pInternalImport->GetClassLayoutNext(&classlayout, &fd, &ulOffset))))
{
  if (!fExplicitOffsets) {
    // ulOffset is the sequence
    pfwalk->m_sequence = ulOffset;
  }
  else {

    // ulOffset is the explicit offset
    pfwalk->m_offset = ulOffset;
    pfwalk->m_sequence = (ULONG) -1;

    if (pParentClass && pParentClass->HasLayout()) {
        // Treat base class as an initial member.
        pfwalk->m_offset += pParentClass->GetLayoutInfo()->GetNativeSize();
    }
  }
}

    对于 LayoutKind.Sequential 策略的类型布局,GetClassLayoutNext 方法返回的 ulOffset 值是字段的序号;而对于 LayoutKind.Explicit 策略则返回用户具体指定的字段偏移。如果类型有具有布局策略的父类型,还需要根据父类型的大小调整字段的绝对偏移量。

    阶段 1 最后会根据布局策略,对前面填充的字段布局信息进行排序或复制。对 LayoutKind.Sequential 策略,会根据每个字段布局信息的 m_sequence 进行排序;而对 LayoutKind.Explicit 策略,则直接将字段布局信息复制到目标数组 pSortArray。

    阶段 2 将根据每个字段的布局信息计算其所占空间大小。部分内部字段类型 (m_nft) 具有固定大小,如 int, long, string 等类型对应的内部类型 NFT_COPY4, NFT_COPY8, NFT_STRINGUNI;而其他的则需要调用字段的 Marshaler 对象,动态计算其所占大小。这也是阶段 1 初始化的字段布局信息中 Marshaler 对象的典型使用方式之一。
以下内容为程序代码:

// Now compute the native size of each field
for (pfwalk = pInfoArrayOut; pfwalk->m_MD != mdFieldDefNil; pfwalk++) {
  UINT8 nft = pfwalk->m_nft;
  pEEClassLayoutInfoOut->m_numCTMFields++;

  // If the NFT's size never changes, it is stored in the database.
  UINT32 cbNativeSize = NFTDataBase[nft].m_cbNativeSize;

  if (cbNativeSize == 0) {
      // Size of 0 means NFT's size is variable, so we have to figure it
      // out case by case.
      cbNativeSize = ((FieldMarshaler*)&(pfwalk->m_FieldMarshaler))->NativeSize();
  }
  pfwalk->m_cbNativeSize = cbNativeSize;
}


    阶段 3 对所有需要进行自动偏移处理的类型,提供类似 VC 实现中的偏移计算算法。
    每个字段都有一个内存对齐需求,包括其字段的最小尺寸和声明的 pack 大小,两者的较小值将作为最后的对其标准。Rotor 的实现中,是根据每个字段的实际大小和对齐要求,顺序填充进行内存分配的;而发行版的 .NET Framework 中,更是实现了对字段进行二次重组来优化内存使用效率。这也是前一篇文章中提到的静态逻辑内存布局和动态物理内存布局的不同的原因所在。
    此外对 LayoutKind.Explicit 模式,阶段 3 还会在每个字段进行布局时,检查是否与其他字段重叠。对结构中字段重叠的讨论,可以参考我另外一篇文章[url=]《》[/url]。
    阶段 3 的最后将根据类型的对齐策略,在类型定义末尾填充适量的空闲位置,保障整个结构能内存对齐。

    此外上篇文章中提到的诸如 LayoutDestroyNative 和 FmtClassUpdateNative 等一系列函数,实际上都是对字段 Marshaler 对象调用的封装。如 LayoutUpdateComPlus 和 LayoutUpdateNative 等函数会根据内存布局,更新结构中引用 COM+ 数据或内建数据的引用等信息,保证在进行诸如 StructureToPtr 的复制操作时,不会导致 CLR 内部维护生命期的一些对象的失控。而 FmtClassUpdateComPlus 和 FmtClassUpdateNative 等函数则根据具体对象的类型,如是否能够位拷贝来决定,是直接复制对象还是调用前面的 LayoutUpdateXXX 去更新引用等信息。这些函数主要是用于在直接复制类型内容时保证结构内容的有效性。