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 字节,无头无间接
u8、u16、u32、u64、u128、u256、bool、address、signer这些基本类型按 N 字节扁平存储——没有头部,没有间接层:
┌─────────────┐ │ value │ N bytes └─────────────┘各基本类型的具体尺寸与对齐(来自 memory_alignment.md 的 §4):
| 类型 | 尺寸(字节) | 对齐(字节) |
|---|---|---|
bool、u8、i8 | 1 | 1 |
u16、i16 | 2 | 2 |
u32、i32 | 4 | 4 |
u64、i64 | 8 | 8 |
u128、i128 | 16 | 8 |
u256、i256 | 32 | 8 |
address | 32 | 8 |
signer | 32 | 8 |
注意这里有一条关键取舍:所有 8 字节及以上的类型(含u128、u256、address、signer)的对齐都被封顶为 8,即使其尺寸远超 8。原因在于更强的对齐会迫使每个容纳这类字段的容器产生级联填充(cascading padding)。这是与 Rust/C++ 自然对齐规则的唯一偏离点,具体权衡见 memory_alignment.md 的 §8.2。
在源码层面,基本类型的读写由 core/src/memory.rs 中的一组类型化读写函数完成(如read_u8、read_bool、read_u64、read_ptr、read_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_id(u32,位于obj_ptr - 8):索引到描述符表(descriptor table),告诉 GC 如何追踪对象内部的指针。描述符的具体形态(Trivial、Vector、Struct、Enum、CapturedData、Closure)定义在 core/src/object_descriptor.rs。size(u32,位于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 }| 字段 | 偏移 | 尺寸 | 备注 |
|---|---|---|---|
a | 0 | 1 | u8 |
| 填充 | 1–3 | 3 | 为b对齐到 4 |
b | 4 | 4 | u32 |
c | 8 | 8 | u64 |
结构体总尺寸 = 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]。
两点值得注意的设计细节:
- tag 当前按
u64存储。这在 memory_alignment.md §5.6 中有解释:概念上 tag 只有 1 字节,多出的 7 字节是变体区域对齐前的填充;把 tag 视为 1 字节是为了给未来回收这些字节留下空间。 - 布局尚未完全定案。当前实现把所有变体填充到最大变体尺寸,但另一种候选方案是:切换变体时分配新的堆对象,从而让每个变体按自身尺寸精确分配。
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_size与descriptor_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 半部)。VecLen、VecPushBack、VecPopBack、VecLoadElem、VecStoreElem等微操作都通过 16 字节vec_ref定位向量的堆指针槽位。
8.2 备选方案:裸指针引用(Raw Pointer References)
另一种候选表示是单一裸指针,直接指向目标值,不做 base/offset 拆分:
- 优点:更紧凑(8 字节而非 16),每次访问省去一次偏移加法;
- 代价:GC 无法再轻易识别引用指向哪个堆对象。GC 需要通过二分查找等机制恢复包含对象的基地址,正确处理搬移过程中的内部指针(interior pointers),这显著增加 GC 实现复杂度与正确性论证难度。
文档的判断是:新增代码复杂度可能是更大的担忧,但 GC 期间基地址恢复的性能成本也值得实测。目前运行时采用 Fat Pointer 方案。
9. 向量扩容:为什么向量操作必须经由引用
当 push 超出容量时,向量必须重新分配:分配一个更大的新堆对象、拷贝数据、旧对象被遗弃(由 GC 回收)。这意味着向量在扩容时堆指针会改变。
问题在于:向量指针可能存在于各种位置——栈上的局部变量、堆结构体的字段、另一个向量元素的字段……执行 push 的代码需要把更新后的指针写回向量「所有者」所在之处。如果向量操作直接持有指向堆对象的裸指针,重新分配后就无法更新所有者。
因此,向量操作(VecPushBack、VecPopBack等)通过指向「持有向量堆指针槽位」的 Fat Pointer 引用(vec_ref)进行:
- 扩容时,
realloc_vec分配新对象并拷贝旧数据; - 新的堆指针经由该引用写回,原地更新所有者——无论所有者是栈局部变量、结构体字段还是其他任何位置。
源码层面,这一流程完整实现在 runtime/src/heap/mod.rs 的realloc_vec与grow_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):
- 小类型自然对齐:尺寸 ≤ 8 字节的值放在「地址是自身尺寸倍数」的位置(
bool按 1 字节计); - 对齐封顶 8:所有 ≥ 8 字节的原始类型统一 8 字节对齐;
- 布局是 VM 内部决策:BCS 无对齐,存储往返可自由重排内存布局。
MAX_ALIGN的约束(§2):必须是 2 的幂;必须 ≥ 8(堆对象头 8 字节、帧元数据块需要 8 字节粒度);必须是所有值与 VM 内部布局对齐的倍数(当前对齐集合 {1,2,4,8} 下,任何 8 的倍数都满足)。
三条运行时结构性不变量(§6)保障端到端对齐安全:
MemoryRegion::new以MAX_ALIGN分配,堆与栈基地址天然足够对齐;- bump 分配器按
MAX_ALIGN对齐步进(heap_alloc中checked_align_max),每个对象地址满足MAX_ALIGN; - 每个帧指针
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 Pointer | core/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),仅供参考