Aptos MonoMove 值表示(Value Representation)深入解析:扁平内存布局、堆对象头与 Fat Pointer 引用
2026/9/18 12:06:31 网站建设 项目流程

Aptos MonoMove 值表示(Value Representation)深入解析:扁平内存布局、堆对象头与 Fat Pointer 引用

【免费下载链接】aptos-coreAptos is a layer 1 blockchain built to support the widespread use of blockchain through better technology and user experience.项目地址: https://gitcode.com/GitHub_Trending/ap/aptos-core

导读

本文以 Aptos 仓库内 MonoMove 子项目的设计文档 value_representation.md 为主体,系统讲解 MonoMove 运行时在内存中如何表示 Move 的值:基本类型以 N 字节扁平存放、堆对象统一携带 8 字节头、结构体与枚举分为内联(inline)与堆(heap)两种形态、向量以「指针 + 堆对象」承载、引用则采用 16 字节 Fat Pointer。读完本文,你将掌握 MonoMove 值的内存布局规则、GC 如何借助对象描述符(ObjectDescriptor)追踪指针、向量扩容为何必须经由引用完成,以及对齐约束(MAX_ALIGN)如何贯穿整个布局体系——这些知识对理解 MonoMove 的解释器、分配器与复制式 GC 的协同工作至关重要。

MonoMove 是 Aptos 对 Move VM 的一次重写尝试,位于仓库 third_party/move/mono-move 目录。相关设计文档(内存对齐、堆与 GC、栈与调用约定、闭包设计等)与源码同处一个目录,便于交叉阅读。

1. 总体原则:值在内存中「扁平存放」

Values are represented flat in memory. All values created by the VM and any modifications are allocated in the transaction's memory region.

MonoMove 的所有值都以扁平(flat)方式存放在内存中:VM 创建的任何值、对值的任何修改,都分配在事务的内存区域(transaction's memory region)内。这一设计带来两个直接推论:

  • 无隐式装箱:基本类型的值就是连续的 N 个字节,没有头部、没有指针间接层;
  • 分配与 GC 范围明确:整个事务生命周期内的对象都在同一个区域中分配,由 Cheney 复制式 GC 统一管理。

与 BCS(Move 的链上序列化格式)形成对照的是:BCS 是无对齐(alignment-free)的规范磁盘编码,而内存布局是 VM 内部决策。因此,内存中的字段排布可以在每次「存储往返」时重新洗牌而不产生兼容性成本——这一点在 memory_alignment.md 中被明确为布局设计的第一性原理。

2. 基本类型(Primitives):N 字节,无头无间接

u8u16u32u64u128u256booladdresssigner这些基本类型按 N 字节扁平存储——没有头部,没有间接层:

┌─────────────┐ │ value │ N bytes └─────────────┘

各基本类型的具体尺寸与对齐(来自 memory_alignment.md 的 §4):

类型尺寸(字节)对齐(字节)
boolu8i811
u16i1622
u32i3244
u64i6488
u128i128168
u256i256328
address328
signer328

注意这里有一条关键取舍:所有 8 字节及以上的类型(含u128u256addresssigner)的对齐都被封顶为 8,即使其尺寸远超 8。原因在于更强的对齐会迫使每个容纳这类字段的容器产生级联填充(cascading padding)。这是与 Rust/C++ 自然对齐规则的唯一偏离点,具体权衡见 memory_alignment.md 的 §8.2。

在源码层面,基本类型的读写由 core/src/memory.rs 中的一组类型化读写函数完成(如read_u8read_boolread_u64read_ptrread_fat_ptr),其中read_bool还带有debug_assert!(byte <= 1)的槽位不变量检查。

3. 堆对象头:所有堆对象的通用 8 字节头部

所有堆对象(结构体、枚举、向量,以及未来的任何堆类型)共享一个通用 8 字节头

[desc_id: u32 | size: u32]

这个头位于调用者所持对象指针的负偏移处——obj_ptr指向数据区域的起始,而头部紧挨在它前面的字节中:

obj_ptr │ ▼ ┌──────────────────────┬──────────────────────────┐ │ desc_id(4) | size(4) │ data region │ └──────────────────────┴──────────────────────────┘ header (-8..-4..0)

两个字段的含义:

  • desc_idu32,位于obj_ptr - 8):索引到描述符表(descriptor table),告诉 GC 如何追踪对象内部的指针。描述符的具体形态(TrivialVectorStructEnumCapturedDataClosure)定义在 core/src/object_descriptor.rs。
  • sizeu32,位于obj_ptr - 4):对象总字节数(头 + 数据,且按MAX_ALIGN对齐)。GC 在线性堆扫描(Cheney 算法)时依靠它跳过整个对象。

3.1 头部与MAX_ALIGN的关系

分配器在每个数据区域前预留OBJECT_HEADER_SIZE = MAX_ALIGN字节(见 core/src/instruction/mod.rs),以保证obj_ptr本身是MAX_ALIGN对齐的。当MAX_ALIGN > 8时:

  • 预留区中多出的字节是未使用的填充,排在desc_id之前(desc_id始终紧邻数据区,位于偏移-8);
  • 负偏移常量(-8/-4不会移动

关键设计:把头部当作「分配器专属簿记」之后,每种类型的布局(结构体字段、枚举 tag/变体、向量长度/数据、闭包字段、捕获数据)都只描述自己的数据区域——堆结构体的字段 0 位于偏移 0,向量长度位于偏移 0,等等。MAX_ALIGN增长时,所有按类型布局的常量都不必改变。

源码中这些常量集中定义于 core/src/instruction/mod.rs:

pub const OBJECT_HEADER_SIZE: usize = MAX_ALIGN; // 头部预留 = MAX_ALIGN(当前 8) pub const ENUM_TAG_OFFSET: usize = 0; // 枚举 tag 位于数据区偏移 0 pub const ENUM_DATA_OFFSET: usize = 8; // 枚举变体数据起始(tag 之后) pub const VEC_LENGTH_OFFSET: usize = 0; // 向量长度位于数据区偏移 0 pub const VEC_DATA_OFFSET: usize = 8; // 向量元素数据起始(length 之后)

MAX_ALIGN本身定义在 core/src/align.rs:

pub const MAX_ALIGN: usize = 8;

并带有一组编译期断言(必须是 2 的幂、必须 ≥ 8)。align_max/checked_align_max提供了按MAX_ALIGN向上取整的工具函数,heap_alloc在 runtime/src/heap/mod.rs 中正是先用checked_align_max对齐总大小、再零初始化整块区域、最后写入对象头的。

4. 结构体(Structs):内联与堆两种形态

运行时同时支持内联结构体堆结构体

4.1 内联结构体(Inline structs)

字段在编译期确定好偏移,直接铺设在所在内存区域中(栈帧,或另一个堆对象的数据区)。无堆分配、无指针间接——字段访问就是一次「基址 + 偏移」的直载:

┌─────────────────────────────────┐ │ field_0 │ field_1 │ field_2 ... │ N bytes total, flat └─────────────────────────────────┘

值得注意:内联结构体可能不会在运行时代码中显式出现。因为它已经被底层的微操作(micro-ops)天然支持——数据搬运、带偏移算术的借用(borrow)——无需任何特殊运行时支持。编译器对其实现拥有相当大的控制权,未来可能增加更多微操作使其更高效。

4.2 堆结构体(Heap structs)

所在内存区域中的一个 8 字节指针,指向堆对象的数据区域;头部位于指针目标的前方字节:

Owner region Heap ┌────────┐ ┌──────────────────────────┐ │ │ │ desc_id(4) | size(4) │ header (-8..0) │ ●────┼─────────────────►│ field_0 │ obj_ptr │ │ │ field_1 │ └────────┘ │ ... │ 8 bytes └──────────────────────────┘

desc_id索引到描述符表中ObjectDescriptor::Struct条目,GC 据此得知哪些字段偏移处持有堆指针

从源码看,ObjectDescriptor::Struct(core/src/object_descriptor.rs)携带两个字段:

  • size:负载总字节数(不含对象头);
  • pointer_offsets:负载内持有「拥有型堆指针」的字节偏移列表。由于 Move 禁止结构体内出现引用,这些永远是 8 字节的、指向其他堆对象的指针。

4.3 结构体字段对齐示例

以 memory_alignment.md 的 §5.9 示例 1 为例(MAX_ALIGN = 8):

struct Foo { a: u8, b: u32, c: u64 }
字段偏移尺寸备注
a01u8
填充1–33b对齐到 4
b44u32
c88u64

结构体总尺寸 = 16,对齐 = 8。同样的结构体放上堆,则数据区域偏移不变(a@0、b@4、c@8),只是头部[desc_id | size]占用obj_ptr - 8的 8 字节预留,总对象尺寸 = 24——正好是MAX_ALIGN = 8的倍数。

5. 枚举(Enums):tag + 变体区

运行时将支持内联与堆两种枚举形态。

5.1 内联枚举(Inline enums,设计预览)

内联枚举采用零填充策略,使所有变体占据相同尺寸,形成定宽表示。tag 与变体字段直接铺设在所在内存区域中。布局为[tag | pad | variant_region]

  • tag 概念上是 1 字节(单一判别值);
  • 变体区域按最大变体定尺寸、按所有变体字段中最大的对齐定对齐;较小的变体零填充到变体区域尺寸;
  • tag 与变体区域之间的填充,将变体区域带到其对齐边界。

整体对齐 =max(tag_align, variant_region_align)

5.2 堆枚举(Heap enums,当前已实现)

当前运行时只实现了堆枚举

Owner region Heap ┌────────┐ ┌──────────────────────────┐ │ │ │ desc_id(4) | size(4) │ header (-8..0) │ ●────┼─────────────────►│ tag │ obj_ptr │ │ ├──────────────────────────┤ └────────┘ │ variant fields │ 8 bytes └──────────────────────────┘

GC 通过ObjectDescriptor::Enum追踪枚举:该描述符按 tag 索引提供每个变体各自的指针偏移列表variant_pointer_offsets,相对ENUM_DATA_OFFSET计算)。源码实现见 core/src/object_descriptor.rs,其中明确注释了数据区布局:[tag: u64(8)] [fields padded to max variant size]

两点值得注意的设计细节:

  1. tag 当前按u64存储。这在 memory_alignment.md §5.6 中有解释:概念上 tag 只有 1 字节,多出的 7 字节是变体区域对齐前的填充;把 tag 视为 1 字节是为了给未来回收这些字节留下空间。
  2. 布局尚未完全定案。当前实现把所有变体填充到最大变体尺寸,但另一种候选方案是:切换变体时分配新的堆对象,从而让每个变体按自身尺寸精确分配。

5.3 为什么枚举暂时必须留在堆上

设计文档明确指出:Move 允许通过兼容的模块升级(module upgrades)添加新变体,这会改变布局。因此在变体集合可能演化的前提下,内联枚举缺乏稳定性保证。团队的目标是支持内联枚举,并正在考虑引入类似[frozen]的属性,用以承诺「未来不会添加新变体」,从而启用内联表示。

附注:对于具有显式表示(如#[repr(u64)])的简单枚举,应完全避免堆分配——这可以在语言层面强制。

6. 向量(Vectors):指针 + 堆对象,容量由头部推导

所在内存区域中的一个 8 字节指针指向堆对象(空/未初始化向量则为 null):

Owner region Heap ┌────────┐ ┌──────────────────────────┐ │ │ │ desc_id(4) | size(4) │ header (-8..0) │ ●────┼────────────►│ length (u64) │ obj_ptr (offset 0) │ │ ├──────────────────────────┤ └────────┘ │ elem_0 │ 8 bytes │ elem_1 │ (or null │ ... │ if empty) └──────────────────────────┘

布局要点:

  • 元数据与数据同堆共存:向量的length(u64)与元素数据一起放在堆对象的数据区内;
  • GC 依赖 length:GC 需要知道元素个数,才能确定要追踪多少元素的内部指针;
  • 容量不显式存储,由头部推导:cap = (size - OBJECT_HEADER_SIZE - VEC_DATA_OFFSET) / elem_size。源码中的推导逻辑可见于 runtime/src/heap/mod.rs 的realloc_vec
  • null 指针表示空向量VecNew微操作只写 null 而不分配;第一次VecPushBack才惰性分配。微操作定义见 core/src/instruction/mod.rs,其中VecPushBack携带elem_sizedescriptor_id,若容量不足则重新分配(bump)并通过vec_ref写回新指针,且「MAY TRIGGER GC」;
  • desc_id指引元素追踪:通过ObjectDescriptor::Vector描述符,其记录元素尺寸(elem_size)与每个元素内持有堆指针的字节偏移(elem_pointer_offsets)。

元素 i 的地址计算公式为:vec_ptr + VEC_DATA_OFFSET + i * elem_size,其中elem_size向上取整到元素对齐(保证连续元素保持对齐)。

7. 复合布局:嵌套对象的内存全貌

7.1 堆结构体包含向量字段

Owner region Heap (struct) Heap (vector) ┌────────┐ ┌──────────────────────┐ ┌──────────────────────┐ │ │ │ desc_id(4) | size(4) │ │ desc_id(4) | size(4) │ │ ●────┼───►│ some_field │ │ length (u64) │ └────────┘ │ vec_ptr ●───────────┼────►│ elem_0 │ └──────────────────────┘ │ elem_1 │ │ ... │ └──────────────────────┘

7.2 堆结构体包含另一个堆结构体

Owner region Heap (outer) Heap (inner) ┌────────┐ ┌──────────────────────┐ ┌──────────────────────┐ │ │ │ desc_id(4) | size(4) │ │ desc_id(4) | size(4) │ │ ●────┼───►│ inner_ptr ●──────────┼───►│ field_0 │ └────────┘ │ other_field │ │ field_1 │ └──────────────────────┘ └──────────────────────┘

两个图例展示了同一规律:每一层堆对象都有独立头部,指针只能向下(更深层)指向新的堆对象。这正是 GC 采用「对象自描述」而非全局结构图的原因——描述符只描述一层间接,被指向的对象通过自身头部继续自描述(core/src/object_descriptor.rs 的模块注释明确说明:Only one level of indirection is described; pointed-to objects are self-describing via their own headers)。

8. 引用(References):16 字节 Fat Pointer

引用是16 字节的 Fat Pointer(base_ptr: *mut u8, byte_offset: u64)。实际目标地址 =base_ptr + byte_offset

The ref Target memory ┌────────┐ ┌────────┐ │ base ●─┼──────────────────────►│ ... │ │ offset │ │ value │ ← base + offset └────────┘ │ ... │ 16 bytes └────────┘

设计要点:

  • GC 只追踪并更新 base 指针;byte offset 是标量,在 GC 移动对象期间保持稳定;
  • GC 扫描时,只有 base 指针半部被列在pointer_offsets中(引用本身的 offset 半部不会被当作堆指针处理)。

8.1 三种典型的引用形态

指向栈局部变量ref = (slot_ptr, 0)。base 直接指向局部的槽位,offset 恒为 0。由于该地址属于栈而非堆,GC 不会尝试追踪或搬移它。

指向堆结构体字段ref = (heap_ptr, field_offset)。base 指向堆对象的数据区域,field_offset 是字段在数据区域内的字节偏移(头部在负偏移处,对引用不可见)。GC 可以搬移结构体并更新 base,字段偏移保持不变。

指向向量元素ref = (vec_heap_ptr, VEC_DATA_OFFSET + idx * elem_size)。base 指向向量的堆对象,VEC_DATA_OFFSET(= 8)跳过数据区起始处的长度字段。安全前提是借用存活期间向量不发生扩容——这由 Move 的借用检查器强制执行。

在源码中,Fat Pointer 的读写由 core/src/memory.rs 的read_fat_ptr/write_fat_ptr完成:base 半部占用前 8 字节(FAT_PTR_OFFSET_HALF = 8处是 offset 半部)。VecLenVecPushBackVecPopBackVecLoadElemVecStoreElem等微操作都通过 16 字节vec_ref定位向量的堆指针槽位。

8.2 备选方案:裸指针引用(Raw Pointer References)

另一种候选表示是单一裸指针,直接指向目标值,不做 base/offset 拆分:

  • 优点:更紧凑(8 字节而非 16),每次访问省去一次偏移加法;
  • 代价:GC 无法再轻易识别引用指向哪个堆对象。GC 需要通过二分查找等机制恢复包含对象的基地址,正确处理搬移过程中的内部指针(interior pointers),这显著增加 GC 实现复杂度与正确性论证难度。

文档的判断是:新增代码复杂度可能是更大的担忧,但 GC 期间基地址恢复的性能成本也值得实测。目前运行时采用 Fat Pointer 方案。

9. 向量扩容:为什么向量操作必须经由引用

当 push 超出容量时,向量必须重新分配:分配一个更大的新堆对象、拷贝数据、旧对象被遗弃(由 GC 回收)。这意味着向量在扩容时堆指针会改变

问题在于:向量指针可能存在于各种位置——栈上的局部变量、堆结构体的字段、另一个向量元素的字段……执行 push 的代码需要把更新后的指针写回向量「所有者」所在之处。如果向量操作直接持有指向堆对象的裸指针,重新分配后就无法更新所有者。

因此,向量操作(VecPushBackVecPopBack等)通过指向「持有向量堆指针槽位」的 Fat Pointer 引用(vec_ref)进行

  1. 扩容时,realloc_vec分配新对象并拷贝旧数据;
  2. 新的堆指针经由该引用写回,原地更新所有者——无论所有者是栈局部变量、结构体字段还是其他任何位置。

源码层面,这一流程完整实现在 runtime/src/heap/mod.rs 的realloc_vecgrow_vec_ref中:

  • realloc_vec先读取旧长度、从头部推导旧容量,再按摊销倍增old_cap == 0时初始为 4,否则old_cap * 2,与required取较大者)计算新容量,分配新向量、copy_nonoverlapping拷贝元素、写回长度;
  • grow_vec_ref通过read_fat_ptr语义(read_ptr(fp, vec_ref_offset)+read_u64(fp, vec_ref_offset + 8))解出槽位地址,读取旧向量指针,调用realloc_vec,最后再次经引用把新指针写回槽位——这正是文档所描述的「扩容经由引用、原地更新所有者」机制。

值得注意,grow_vec_ref中有一处防御性检查:空向量的槽位是 null,GrowNullVector属于调用方不变量违规(空向量应直接分配而非扩容)。

10. 函数值与闭包(Function Values / Closures):TBD

设计文档对闭包的内存布局标注为TBD。不过从配套源码可以一窥其规划方向(core/src/instruction/mod.rs):

  • 闭包对象(堆分配,数据区固定 32 字节):[func_ref(16)] [mask(8)] [captured_data_ptr(8)]
  • ClosureCapturedData对象(Materialized):数据区为[tag: u8 @ 0] [pad(3)] [values_size: u32 @ 4] [captured values @ 8],捕获值按参数位置顺序紧密打包;
  • 闭包与捕获数据的完整布局设计见 closure_design.md。

闭包对象自身以ObjectDescriptor::Closure描述(固定运行时布局,所有闭包共享),捕获数据则通过ObjectDescriptor::CapturedData描述(无指针捕获时复用保留的Trivial描述符)。

11. 对齐体系补充:MAX_ALIGN 如何贯穿全局

value_representation.md多次引用 memory_alignment.md 处理对齐细节。这里提炼其核心,作为理解值布局的必备上下文:

对齐三原则(memory_alignment.md §1):

  1. 小类型自然对齐:尺寸 ≤ 8 字节的值放在「地址是自身尺寸倍数」的位置(bool按 1 字节计);
  2. 对齐封顶 8:所有 ≥ 8 字节的原始类型统一 8 字节对齐;
  3. 布局是 VM 内部决策:BCS 无对齐,存储往返可自由重排内存布局。

MAX_ALIGN的约束(§2):必须是 2 的幂;必须 ≥ 8(堆对象头 8 字节、帧元数据块需要 8 字节粒度);必须是所有值与 VM 内部布局对齐的倍数(当前对齐集合 {1,2,4,8} 下,任何 8 的倍数都满足)。

三条运行时结构性不变量(§6)保障端到端对齐安全:

  1. MemoryRegion::newMAX_ALIGN分配,堆与栈基地址天然足够对齐;
  2. bump 分配器按MAX_ALIGN对齐步进(heap_allocchecked_align_max),每个对象地址满足MAX_ALIGN
  3. 每个帧指针fp都是MAX_ALIGN对齐的,帧内偏移与之复合产生对齐地址。

静态验证器runtime/src/verifier.rs)在验证阶段检查帧访问边界、跳转目标、描述符有效性及描述符内指针偏移的 8 字节对齐;对齐偏移由 Specializer 的布局通道(LoweringContext::layout_slots)在编译期计算。三者结合,热路径上无需运行时对齐检查。

对齐的优化空间(§7):字段重排(按对齐降序排列可消除填充洞,类似 Rust 默认repr(Rust);BCS 仍按声明序序列化,纯内存优化、不影响磁盘)、局部变量重排(参数不重排——它们是公共 ABI)。

开放问题(§8):u128/u256/address/signer更强对齐的 SIMD 收益 vs 填充成本;向量元素对齐 > 8 时VEC_DATA_OFFSET如何按向量变化(当前微操作设计无此通道,MAX_ALIGN = 8下不阻塞)。

12. 源码印证速查

主题位置
值表示设计文档docs/value_representation.md
对齐设计文档docs/memory_alignment.md
堆与 GC 设计文档docs/heap_and_gc.md
栈与调用约定设计文档docs/stack_and_calling_convention.md
闭包设计文档docs/closure_design.md
MAX_ALIGN与对齐工具core/src/align.rs
对象描述符(GC 追踪依据)core/src/object_descriptor.rs
布局常量(头部/枚举/向量/闭包)core/src/instruction/mod.rs
向量微操作(VecNew等)core/src/instruction/mod.rs
类型化内存读写与 Fat Pointercore/src/memory.rs
bump 分配、Cheney GC、向量扩容runtime/src/heap/mod.rs
静态验证器runtime/src/verifier.rs

结语

MonoMove 的值表示设计围绕「扁平、自描述、分配器与类型布局解耦」三个核心展开:基本类型 N 字节扁平存放;所有堆对象共享负偏移的 8 字节头(desc_id+size),头部属于分配器簿记、各类型布局只描述数据区域;结构体与枚举区分内联与堆形态(枚举因模块升级可增变体而暂留堆上);向量容量由头部推导、空向量以 null 表示、首次 push 惰性分配;引用采用 16 字节 Fat Pointer 使 GC 只追踪 base;向量扩容必须经引用写回以原地更新任意位置的所有者。这套布局与 memory_alignment.md、heap_and_gc.md、stack_and_calling_convention.md 三份文档共同构成 MonoMove 运行时内存子系统的完整蓝图,也为后续理解其复制式 GC、调用约定与闭包实现提供了必要基础。

【免费下载链接】aptos-coreAptos is a layer 1 blockchain built to support the widespread use of blockchain through better technology and user experience.项目地址: https://gitcode.com/GitHub_Trending/ap/aptos-core

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询