检查 VMO 文件格式

本文档介绍了组件检查文件格式(检查格式)。

使用检查格式设置的文件称为检查文件,通常具有 .inspect 文件扩展名。

如需了解如何更改格式,请参阅请参阅扩展 Inspect 文件格式

概览

组件检查可让组件在运行时公开有关其状态的结构化分层信息。

组件使用检查格式托管映射的虚拟内存对象 (VMO),以公开包含此内部状态的检查层次结构

检查层次结构由包含类型化属性的嵌套节点组成。

目标

本文档中介绍的检查格式具有以下目标:

  • 对数据进行开销较低的更改

    借助检查文件格式,您可以就地更改数据。例如,递增一个整数的开销大约为 2 次原子递增。

  • 支持非静态层次结构

    您可以在运行时修改存储在检查文件中的层次结构。您可以随时向层次结构中添加或从中移除子级。这样,该层次结构就可以近似地表示组件工作集中的对象层次结构。

  • 单个写入器、多个读取器并发(无显式同步)

    与写入器并发运行的读取器会映射 VMO 并尝试获取数据的快照。写入器通过生成计数器指示自己位于关键部分,而无需与读取器进行显式同步。读取器使用生成计数器来确定 VMO 的快照何时保持一致且可以安全读取。

  • 组件终止后,数据可能仍可用

    即使写入组件终止,读取器也可能会保留对包含 Inspect 数据的 VMO 的句柄。

术语

本部分定义了本文档中使用的常用术语。

  • 检查文件 - 采用本文档中所述格式的有限字节序列。
  • 检查 VMO - 存储在虚拟内存对象 (VMO) 中的检查文件。
  • 代码块 - 检查文件中大小固定的部分。块具有索引和顺序。
  • 编号 - 特定分块的唯一标识符。byte_offset = index * 16
  • 有序 - 块的大小,以从最小大小的位移表示。size_in_bytes = 16 << order。根据大小(2 的幂次方)将块分为类。
  • 节点 - 层次结构中的一个命名值,其他值可以嵌套在该值下。只有节点可以在层次结构中充当父级。
  • 属性 - 包含类型化数据(例如字符串、整数等)的有名称的值。
  • 层次结构 - 由单个“根”节点向下延伸的节点树,每个节点都可能包含属性。检查文件包含单个层次结构。

本文档使用 RFC 2119 中定义的“必须”“应/建议”和“可以”关键字

所有位字段图表均采用小端字节序存储。

版本

当前版本:2

分块

检查文件会拆分为多个 Blocks,其大小必须为 2 的幂。

最小块大小必须为 16 字节 (MIN_BLOCK_SIZE),并且最大块大小必须为 16 字节的倍数。建议实现者指定的最大块大小小于页面大小(通常为 4096 字节)。在我们的参考实现中,块大小上限为 2048 字节 (MAX_BLOCK_SIZE)。

所有块都必须按 16 字节边界对齐,并且在 VMO 中的寻址是基于索引的,指定 16 字节的偏移量 (offset = index * 16)。

我们使用 24 位索引,因此“检查文件”功能最多可处理 256 MiB 的数据。

block_header 由 16 个字节组成,如下所示:

每个分块都有一个 order,用于指定其大小。

如果块大小上限为 2048 字节,则有 8 种可能的块顺序 (NUM_ORDERS),编号为 0...7,分别对应于大小为 16、32、64、128、256、512、1024 和 2048 字节的块。

每个分块还有一个类型,用于确定如何解读分块中的其余字节。

好友分配

这种块布局允许使用伙伴分配高效地分配块。伙伴分配是推荐的分配策略,但并非使用检查格式的必要条件。

类型

所有受支持的类型均在 //zircon/system/ulib/inspect/include/lib/inspect/cpp/vmo/block.h 中定义,并分为以下类别:

枚举 value 类型名称 类别
kFree 0 FREE 内部
kReserved 1 RESERVED 内部
kHeader 2 HEADER 标题
kNodeValue 3 NODE_VALUE
kIntValue 4 INT_VALUE
kUintValue 5 UINT_VALUE
kDoubleValue 6 DOUBLE_VALUE
kBufferValue 7 BUFFER_VALUE
kExtent 8 EXTENT 范围
kName 9 NAME 名称
kTombstone 10 TOMBSTONE
kArrayValue 11 ARRAY_VALUE
kLinkValue 12 LINK_VALUE
kBoolValue 13 BOOL_VALUE
kStringReference 14 STRING_REFERENCE 参考文档
  • 内部 - 这些类型用于实现块分配,读取器必须忽略它们。

  • 标头 - 此类型允许读取器检测检查文件并推理快照一致性。此代码块必须位于索引 0 处。

  • - 这些类型会直接显示在层次结构中。值必须具有 Name 和父项(必须是 NODE_VALUE)。

  • Extent - 此类型用于存储可能无法放入单个分块中的长二进制数据。

  • 名称 - 此类型用于存储可放入单个分块中的二进制数据,通常用于存储值的名称。

  • 引用 - 此类型用于存储其他分块可以引用的单个规范值。

每种类型都会以不同的方式解读载荷,如下所示:

免费

有一个 FREE 块可供分配。重要的是,值为零的块(16 字节的 \0)会被解读为阶数为 0 的 FREE 块,因此可以简单地将缓冲区设为零来释放所有块。

写入器实现可以将 FREE 块中 8..63 的未用位用于任何用途。写入器实现必须将所有其他未使用的位设置为 0。

我们建议写入者使用上述位置存储同一顺序的下一个空闲块的索引。使用此字段,可为每个大小的空闲块创建单链表,以便快速分配。当 NextFreeBlock 指向的不是 FREE 或不是相同顺序的位置(通常是索引为 0 的 Header 块)时,表示已到达列表的末尾。

已预订

RESERVED 块可以直接更改为其他类型。它是分配块和设置其类型之间的可选过渡状态,对实现的正确性检查非常有用(以确保即将使用的块不会被视为空闲)。

文件开头必须有一个 HEADER 代码块。它由魔法数字(“INSP”)、版本(目前为 2)、用于并发控制的生成次数以及以字节为单位分配的 VMO 部分的大小组成。标头的第一个字节不得是有效的 ASCII 字符。

如需了解如何使用生成计数实现并发控制,请参阅下一部分

NODE_VALUE 和 TOMBSTONE

节点是进一步嵌套的锚点,并且值的 ParentID 字段只能引用类型为 NODE_VALUE 的块。

NODE_VALUE 块支持可选的引用计数墓碑化,以允许高效实现,如下所示:

Refcount 字段可能包含引用给定 NODE_VALUE 作为其父项的值的数量。删除后,NODE_VALUE 会变为名为 TOMBSTONE 的新特殊类型。只有当 TOMBSTONERefcount 为 0 时,系统才会删除 TOMBSTONE

这样一来,写入器实现就无需显式跟踪节点的子节点,并且可以防止以下情况:

// "b" has a parent "a"
Index | Value
0     | HEADER
1     | NODE "a", parent 0
2     | NODE "b", parent 1

/* delete "a", allocate "c" as a child of "b" which reuses index 1 */

// "b"'s parent is now suddenly "c", introducing a cycle!
Index | Value
0     | HEADER
1     | NODE "c", parent 2
2     | NODE "b", parent 1

{INT,UINT,DOUBLE,BOOL}_VALUE

数值 VALUE 块都包含内嵌到块的第二个 8 个字节中的 64 位数值类型。数字值采用小端字节序。

BUFFER_VALUE

常规 BUFFER_VALUE 块会引用一个或多个关联的 EXTENT 块中的任意字节数据。

BUFFER_VALUE 块包含包含二进制数据的第一个 EXTENT 块的索引,并且包含所有 extent 中数据的总长度(以字节为单位)。

格式标志指定了应如何解读字节数据,如下所示:

枚举 含义
kUtf8 0 字节数据可以被解读为 UTF-8 字符串。
kBinary 1 字节数据是任意二进制数据,可能无法打印。

EXTENT

EXTENT 块包含任意字节数据载荷和链中下一个 EXTENT 的索引。通过按顺序读取每个 EXTENT 直到读取 Total Length 字节,检索 buffer_value 的字节数据。

载荷是字节数据,最多可到达块的末尾。大小取决于订单。

姓名

NAME 块可为对象和值提供人类可读的标识符。它们由完全适合给定分块的 UTF-8 载荷组成。载荷是名称的内容。大小取决于订单。

STRING_REFERENCE

STRING_REFERENCE 块用于在 VMO 中实现具有引用语义的字符串。它们是 EXTENT 链表的开头,这意味着它们的值不受大小限制。在需要使用 NAME 时,可以使用 STRING_REFERENCE 块。

注意:

  • 总长度是有效载荷的大小(以字节为单位)。如果“总长度 > (16 << 顺序) - 12”,则载荷会溢出到“下一个 extent”。
  • 载荷是字符串的规范实例。载荷的大小取决于订单。如果载荷大小 + 12 大于“16 << order”,则载荷太大而无法放入一个分块中,并会溢出到下一个 extent。
  • 下一个 extent 索引是第一个溢出 EXTENT 的索引,如果载荷没有溢出,则为 0。

ARRAY_VALUE

ARRAY_VALUEPayload 的格式取决于存储的值类型 T,其解读方式与 Type 字段完全相同。对于 T ∊ {4, 5, 6}Payload 应为按字节边界压缩的 64 位数值。对于 T ∊ {14}Payload 应由 32 位值组成,表示类型为 T 的块的 24 位索引,这些值应沿字节边界进行压缩。在这种情况下,仅允许使用 F = 0(一个平面数组)。

如果为 F = 0,则应默认实例化 ARRAY_VALUE。对于数值类型,此值应为关联的零值。对于字符串情形,此值应为空字符串,由特殊值 0 表示。

给定存储的值类型(或其编号)的 Count 条目会出现在区块的偏移量 16 处的字节中。

显示格式字段用于影响数组的显示方式,其解读如下:

枚举 说明
kFlat 0 以有序的扁平数组显示,不进行额外的格式设置。
kLinearHistogram 1 将前两个条目解释为线性直方图的 floorstep_size 参数,如下所定义。
kExponentialHistogram 2 对于指数直方图,将前三个条目解读为 floorinitial_stepstep_multiplier,如下所定义。

线性直方图

该数组是一个线性直方图,以内嵌方式存储其参数,并包含溢出和下溢分桶。

前两个元素分别是参数 floorstep_size(如下所定义)。

分桶数 (N) 是隐式 Count - 4

其余元素是存储分区:

2:     (-inf, floor),
3:     [floor, floor+step_size),
i+3:   [floor + step_size*i, floor + step_size*(i+1)),
...
N+3:   [floor+step_size*N, +inf)

指数直方图

该数组是一个指数直方图,以内嵌方式存储其参数,并包含溢出和下溢分桶。

前三个元素分别是参数 floorinitial_stepstep_multiplier(如下所定义)。

分桶数 (N) 是 Count - 5 的隐式值。

其余为存储分区:

3:     (-inf, floor),
4:     [floor, floor+initial_step),
i+4:   [floor + initial_step * step_multiplier^i, floor + initial_step * step_multiplier^(i+1))
N+4:   [floor + initial_step * step_multiplier^N, +inf)

LINK_VALUE 块允许节点支持位于单独的检查文件中的子项。

内容索引指定了另一个 NAME 块,其内容是指向另一个检查文件的唯一标识符。读取器应获取一组 (Identifier, File) 对(通过目录读取或其他接口),并且可能会尝试使用存储的标识符将树拼接在一起,以跟踪链接。

处置标志会指示读者如何拼接树,如下所示:

枚举 说明
kChildDisposition 0 关联的文件中存储的层次结构应为 LINK_VALUE 的父级的子级。
kInlineDisposition 1 应将存储在关联文件中的根的子项和属性添加到 LINK_VALUE 的父级。

例如:

// root.inspect
root:
  int_value = 10
  child = LINK("other.inspect")

// other.inspect
root:
  test = "Hello World"
  next:
    value = 0


// kChildDisposition produces:
root:
  int_value = 10
  child:
    test = "Hello World"
    next:
      value = 0

// kInlineDisposition produces:
root:
  int_value = 10
  test = "Hello World"
  next:
    value = 0

如果节点的子名称与其内嵌链接的子项添加的值发生冲突,则优先级由读取器定义。不过,大多数读者会发现,让关联的值优先,以便它们可以替换原始值,会很有用。

并发控制

写入器必须使用全局版本计数器,以便读取器能够在不与写入器通信的情况下检测正在进行的修改和读取之间的修改。这支持单写入器多读取器并发。

该策略是,写入操作在开始和结束时都递增全局生成计数器

这是一种简单的策略,具有显著的好处:在递增版本号以开始和结束写入操作之间,写入器可以对缓冲区执行任意数量的操作,而无需考虑数据更新的原子性。

主要缺点是,由于写入器频繁更新,读取可能会无限期延迟,但在实践中,读取器可以采取缓解措施。

阅读器算法

读取器使用以下算法获取 Inspect VMO 的一致快照:

  1. 旋转锁定,直到版本号为偶数(无并发写入),
  2. 复制整个 VMO 缓冲区,
  3. 检查缓冲区中的版本号是否与第 1 步中的版本号相同。

只要版本号匹配,客户端就可以读取其本地副本以构建共享状态。如果版本号不匹配,客户端可能会重试整个过程。

写入器锁定算法

写入器可以通过执行以下操作锁定 Inspect VMO 以进行修改:

atomically_increment(generation_counter, acquire_ordering);

这会通过将生成版本设置为奇数来锁定文件,以防止并发读取。获取有序确保在发生此更改之前不会重新排列加载。

写入器解锁算法

写入器可以通过执行以下操作,在修改后解锁 Inspect VMO:

atomically_increment(generation_counter, release_ordering);

通过将生成版本设置为新的偶数,解锁文件以允许并发读取。版本排序可确保在更新生成计数之前,对文件的写入操作可见。