以太坊 - MTCannon 解密

这篇文章深入探讨了MTCannon的实现,MTCannon是Optimistic Rollup中使用的容错证明虚拟机(FPVM),它模拟了支持多线程的MIPS64架构。

深入研究 MTCannon 的实现。

简介: 乐观rollup 依赖于 故障证明(fault proofs) 来验证 状态转换(state transitions),从而在防御者和挑战者群体之间实现 争议游戏(dispute game) 以解决不一致问题。MTCannon 是一个 故障证明虚拟机 (FPVM),它模拟具有多线程的 MIPS64,从而提高执行效率并启用垃圾回收。MIPS64.sol 是一个 Solidity 智能合约(smart contract),用于验证 EVM 中的单个 MIPS 指令,从而确保链上确定性的争议解决。在本博客中,我们将深入研究 MIPS64.sol 智能合约(smart contract) 的实现细节。

什么是 MIPS64.sol?

乐观 rollup,例如 Optimism 和 Base,基于以下假设运行:每个 状态转换(state transition) 都是有效的,除非被证明无效。这些系统批量处理链下 交易(transaction),并将最少的数据发布到以太坊,以提高可扩展性并降低成本。为了确保 状态转换(state transition) 的完整性,Optimism 和 Base 依赖于 故障证明(fault proofs),允许任何人挑战已发布的状态。为每个提议的 状态根(state root) 创建一个 争议游戏(dispute game),利用 二分游戏(bisection game) 来解决争议。任何一方都可以挑战已发布的状态。如果有人挑战已发布的状态,则会在 防御者(defender)(断言新状态正确)和 挑战者(challenger)(对此提出异议)之间展开 争议游戏(dispute game)。此过程将分歧缩小到单个 状态转换(state transition) 步骤,然后使用 MIPS64.sol 智能合约(smart contract) 在链上执行。

多线程 Cannon (MTCannon) 是一个 FPVM,它模拟具有内置多线程支持的 64 位大端架构。MIPS64.sol 由 Optimism 架构和开发,是基于 Solidity 的 FPVM 实现,用于验证链上单个 MIPS 指令证明。与之前的单线程 32 位版本的 Cannon 不同,MTCannon 在模拟环境中引入了多线程逻辑、线程调度、同步和管理,以便启用一项关键功能:垃圾回收(garbage collection)。此外,MTCannon 将内存架构从 32 位扩展到 64 位,这显着增加了可寻址的内存空间,并提高了更复杂计算的性能。

尽管以太坊虚拟机 (EVM) 本身不支持多线程,但 MIPS64.sol 模拟 MTCannon 的多线程模型,以保持与其基于 Golang 的对应项的一致性,这对于确定性地处理 争议游戏(dispute game) 至关重要。虽然两者都复制相同的 状态转换(state transitions),但它们扮演着根本不同的角色。Go 实现充当链下执行器,处理多线程、调度和 垃圾回收(garbage collection) 等复杂职责。另一方面,MIPS64.sol 是链上验证器 - 它不执行完整的程序,而是通过比较前后状态来一次验证一个指令步骤。MIPS64.sol 确定性地模拟多线程效果,但不在链上执行实际的多线程或 垃圾回收(garbage collection)

MTCannon 通过 step() 函数一次执行一个指令,该函数处理 无互锁流水线阶段微处理器 (MIPS) 指令或 系统调用 (syscall),并相应地更新 VM 状态。当 挑战者(challenger)防御者(defender) 达到 二分树(bisection tree) 的最大深度时 - 其中每个叶节点代表一个单独的中间指令 - 活动的 FaultDisputeGame 合约(contract) 使用特定指令调用 step(),以在链上解决争议。step() 函数进一步调用 doStep() 函数,该函数处理执行单个 MIPS 指令的主要逻辑。

结果

在深入研究 MIPS64 的工作原理之前,我们提供一些基准测试数据来展示 MTCannon 的有效性。在之前一篇关于 32 位版本 Cannon 的基准测试的 博客文章(blog post) 中,有两个主要的 预编译(precompiles) 呈现资源约束:p256Verify 是时间密集型的,而 ECAdd 是内存密集型的。我们现在提供 MTCannon 的这些 预编译(precompiles) 的基准测试:

p256Verify

标签(Label) Gas 使用量(Gas Usage) 指令(Instructions) 内存使用量(Memory Usage) 运行时间(Runtime)
1MGas 1,000,000 3,064,043,857 57.82 MB <br>(减少 56%) 132308 ms <br>(减少 90%)
2MGas 2,000,000 6,201,994,165 65.42 MB <br>(减少 50%) 270057 ms <br>(减少 86%)
3MGas 3,000,000 8,853,403,266 70.99 MB <br>(减少 47%) 390384 ms <br>(减少 85%)
20MGas 20,000,000 27,255,575,058 72.31 MB <br>(减少 53%) 1071496 ms <br>(减少 91%)

ECAdd

标签(Label) Gas 使用量(Gas Usage) 指令(Instructions) 内存使用量(Memory Usage) 运行时间(Runtime)
1MGas 1,000,000 1,782,332,570 58.95 MB <br>(减少 59%) 91368 ms<br>(减少 83%)
2MGas 2,000,000 3,636,652,866 66.30 MB <br>(减少 55%) 183194 ms<br>(减少 68%)
3MGas 3,000,000 5,901,580,072 76.88 MB <br>(减少 52%) 305114 ms <br>(减少 52%)
20MGas 20,000,000 7,877,161,385 86.16 MB <br>(减少 65%) 359238 ms<br>(减少 71%)

我们看到运行时间和内存使用量都比以前低得多,从而提高了 Base 的可扩展性。


MIPS64.sol 的执行流程

状态初始化和内存验证

doStep() 函数首先分配两个内存 结构体(structs)StateThreadState。这些 结构体(structs) 使用 _stateData_proof calldata 填充,这些数据存储重要的执行细节,例如 VM 的当前状态、活动线程的执行上下文、指令集等。State 结构体(struct) 维护全局 VM 数据,例如内存根和执行步骤,而 ThreadState 跟踪每个线程的执行细节,包括寄存器和程序计数器。State 中的 exited 字段确定 VM 是否已达到终止状态。

在加载任何状态之前,合约(contract) 会执行一系列检查,以确保编译器实际上将 statethread 结构体(structs) 放置在预期的内存地址,验证空闲内存指针,并检查 _stateData.offset_proof.offset 以确保 calldata 布局实际上与函数期望的匹配。我们在下表中总结了检查及其背后的原因:

偏移描述(Offset Description) 字节数(Number of bytes) 备注(Notes)
State 结构体(struct) 0x80 = 128 State 结构体(struct) 应放置在内存偏移量 0x80 处,即函数开始处的空闲内存指针。
Thread 结构体(struct) 0x260 = 608 Thread 结构体(struct) 应放置在内存偏移量 0x260 处。这是通过以下方式计算的:<br>State 结构体(struct) 偏移量 (128) + state 结构体(struct) 的大小 (15 个字段 * 32 字节) = 608。<br>ThreadState 结构体(struct) 包括:7 个单独的 32 字节字段(例如,threadID、exitCode、pc 等)、32 个寄存器(每个寄存器 32 字节大小)存储为 数组(array),1 个额外的 槽(slot) 用于存储 数组(array) 的长度。
空闲内存指针 59 字节 必须更新空闲内存指针以反映预期的内存布局,其中包括:<br>- 4 个保留的内存 槽(slots)(每个 32 字节),<br><br>- 来自 State 结构体(struct) 的 15 个字段,<br><br>- 来自 ThreadState 结构体(struct) 的 40 个字段。<br><br>将这些加在一起:4 + 15 + 40 = 59 字节。
State 数据偏移量 132 字节 验证 _stateData 偏移量,确保它设置为 132 字节,计算方法如下:<br>- 函数签名的 4 字节<br><br>- 指向 _stateData 的指针的 32 字节(第一个参数)<br><br>- _localContext 的 32 字节(第三个参数,固定 bytes32)<br><br>- _stateData 长度的 32 字节,这是动态参数所必需的。<br><br>将这些加在一起:4 + (32 × 4) = 132 字节。
Proof 数据偏移量 356 字节 验证 _proof 偏移量,确保它设置为 356 字节,计算方法如下:<br>- _stateData 偏移量的 132 字节。<br><br>- _stateData 为 188 字节,四舍五入到最接近的 32 的倍数,即 192。<br><br>- 接下来 32 字节分配给 _proof 的长度,这是动态参数所必需的。<br><br>将这些加在一起:132 + 192 + 32 = 356 字节。

加载 VM 状态

通过验证后,以下代码块将 VM 状态从 calldata 加载到内存中。putField 函数从 _stateData 读取状态字段并将其存储在内存中。

function putField(callOffset, memOffset, size) -> callOffsetOut, memOffsetOut {
...
}
// 将状态从 calldata 解包到内存中
.
c, m := putField(c, m, 1)
c, m := putField(c, m, 1)
exited := mload(sub(m, 32))
.

验证执行约束

然后检查状态的有效性,即

  • 退出代码有效,如果已退出,则停止执行。

  • 当前线程堆栈非空。

加载和验证线程状态

就像从 calldata 加载 VM 状态一样,以下函数从 _proof (calldata) 中提取 ThreadState 结构体(struct)。此过程包括:

  1. 验证 calldata 大小以确保它包含足够的字节来容纳完整的 ThreadState。

  2. 依次提取每个字段(例如,threadID、exitCode、pc 等)。

  3. 加载所有 32 个 通用寄存器 (GPR),这对于 MIPS 执行至关重要。

注意:MIPS64 恰好有 32 个 GPR。

setThreadStateFromCalldata(thread);

合约(contract) 维护两个线程堆栈:leftThreadStack 和 rightThreadStack,以结构化的方式管理并发。traverseRight 标志确定哪个堆栈当前处于活动状态:

  • 如果 traverseRight == true,则右侧堆栈 (rightThreadStack) 处于活动状态。

  • 如果 traverseRight == false,则左侧堆栈 (leftThreadStack) 处于活动状态。

将线程加载到内存后,以下函数计算线程的 哈希(hash),并将其与活动堆栈的 哈希(hash)(leftThreadStack 或 rightThreadStack)进行比较。如果计算出的 哈希(hash) 与预期的 哈希(hash) 不匹配,则执行恢复以防止篡改。

validateCalldataThreadWitness(state, thread);

根据线程状态,如果线程已退出,或者线程已用的时间量超过其 量子(quantum),则程序可以结束执行。

if (thread.exited) {
     popThread(state);
     return outputState();
}

if (state.stepsSinceLastContextSwitch >= sys.SCHED_QUANTUM) {
     preemptThread(state, thread);
     return outputState();
}

指令获取和解码

加载和验证状态后,从内存中提取要执行的当前指令。

uint256 insnProofOffset = MIPS64Memory.memoryProofOffset(MEM_PROOF_OFFSET, 0);
(uint32 insn, uint32 opcode, uint32 fun) =
     ins.getInstructionDetails(thread.pc, state.memRoot, insnProofOffset);

此指令有三种可能的情况:

  1. 它是 Linux 系统调用 (syscall)

  2. 它是 读取-修改-写入 (RMW) 操作,即 load-linked 或 store-conditional

  3. 所有其他指令

处理系统调用和原子操作

Syscall

在 MIPS64 中,所有指令的长度均为 32 位。每种指令格式由指令的前 6 位决定,这 6 位称为 操作码(opcode)。指令有三种类型:R 型、I 型和 J 型。R 型指令依赖于 func 字段(指令的最后 6 位),该字段与 操作码(opcode) 一起确定 R 型指令中的特定操作。

在 MIPS64 中,系统调用(syscalls) 被编码为 R 型指令。对于 系统调用(syscalls),MIPS64 规范保留 操作码(opcode) = 0(这意味着它是 R 型指令)和函数代码 0xC,这唯一地标识了 系统调用(syscalls)

if (opcode == 0 && fun == 0xC) { return handleSyscall(_localContext); }

系统调用(syscalls) 的参数首先从 GPR 中检索。

// 从寄存器加载 syscall 编号和参数

(uint64 syscall_no, uint64 a0, uint64 a1, uint64 a2) =

     sys.getSyscallArgs(thread.registers);

虽然有很多 系统调用(syscalls),但很少有影响 VM 状态的。因此,只有以下 系统调用(syscalls) 是有意义的:

  • mmap, brk, clone, exit_group, read, write, fcntl, gettid, exit, futex, sched_yield, nanosleep, open, clock_gettime, getpid.

忽略了几个 系统调用(syscalls),而未按其编号指定的那些系统调用将导致调用恢复。执行 系统调用(syscall) 后,将更新并返回状态。

读取-修改-写入 (RMW)

Load-Linked (LL) 和 Store-Conditional (SC) 指令用于在 MIPS64 中执行原子操作。LL 从内存地址读取并设置“预留”,目的是对其进行更改。SC 尝试将新值存储到同一内存位置,前提是自 LL 指令发出后,没有其他线程修改过该内存(预留)。原子操作对于防止多线程运行时中的 竞争条件 (race conditions) 至关重要。

// 处理 RMW (read-modify-write) 操作
if (opcode == ins.OP_LOAD_LINKED || opcode == ins.OP_STORE_CONDITIONAL) {
     return handleRMWOps(state, thread, insn, opcode);
}

if (opcode == ins.OP_LOAD_LINKED64 || opcode == ins.OP_STORE_CONDITIONAL64) {
     return handleRMWOps(state, thread, insn, opcode);
}

执行指令

每个线程都维护自己的执行上下文,包括 CPU 标量(scalar) 值,例如寄存器、程序计数器 (PC) 和其他架构状态变量。在执行指令之前,代码首先使用 getCpuScalars 函数检索当前线程的 CPU 状态,并构造一个 coreStepArgs 结构体(struct),将当前执行环境上下文传递给 MIPS 执行引擎。

st.CpuScalars memory cpu = getCpuScalars(thread);
ins.CoreStepLogicParams memory coreStepArgs = ins.CoreStepLogicParams({
     .
     memProofOffset: MIPS64Memory.memoryProofOffset(MEM_PROOF_OFFSET, 1),
     .
});

MIPS 指令的实际执行发生在 execMipsCoreStepLogic 函数中:

(state.memRoot, memUpdated, effMemAddr) = ins.execMipsCoreStepLogic(coreStepArgs);

从高层次上讲,execMipsCoreStepLogic 函数执行以下步骤:

1. 指令解码和分类

  • 根据 操作码(opcode) 确定指令类型(R 型、I 型或 J 型)。

  • 如果指令是跳转(J 型),它会通过计算新的程序计数器 (PC) 来处理跳转,并提前返回而无需进一步执行。

2. 操作数检索

  • 根据需要从寄存器中提取源操作数(rs、rt)。

  • R 型指令检索两个源操作数(rs 和 rt),而 I 型指令使用一个寄存器操作数(rs)和一个立即数。

  • 处理特殊的 64 位加载指令。

3. 执行算术和逻辑运算 (ALU)

  • 使用 ALU 执行核心算术/逻辑运算。

  • 如果指令是 R 型,则函数代码 (funct) 确定确切的操作。

  • 如果指令是 I 型,则某些指令会映射到等效的 R 型函数。

  • 如果涉及内存操作(加载/存储),则会获取内存内容或准备存储一个值。

4. 状态更新 – 将计算结果存储在适当的寄存器或内存位置。

更新 CPU 状态

执行指令后,内存状态可能会根据执行的操作而更改。execMipsCoreStepLogic() 的返回值提供有关这些更新的信息:

  • 内存是否被修改 (`memUpdated`)。

  • 新的内存状态根 (`state.memRoot`)。

  • 受影响的内存地址 (`effMemAddr`)。

此外,CPU 标量(scalar) 值会更新以反映执行状态,并且会重新计算线程根以保持多线程环境中的一致性。

(state.memRoot, memUpdated, effMemAddr) = ins.execMipsCoreStepLogic(coreStepArgs);

setStateCpuScalars(thread, cpu);
updateCurrentThreadRoot();

if (memUpdated) {
     handleMemoryUpdate(state, effMemAddr);
}

最后,该函数通过返回 MIPS 状态的 哈希(hash) 来结束,该 哈希(hash) 封装了关键信息,例如内存承诺、堆分配、多线程堆栈状态和执行元数据。

随着乐观 rollup 向完全去中心化成熟,像 MTCannon 这样的 故障证明(fault proof) 系统在确保无信任和可验证的 状态转换(state transition) 方面发挥着至关重要的作用。通过扩展到 64 位架构并引入多线程,MTCannon 能够高效地执行复杂的工作负载,同时支持明确的争议解决。


让我们构建并保护 Layer 2 的未来

感谢 Optimism 团队的努力,MIPS64 架构突破了 Layer 2 可扩展性、安全性和去中心化的可能性界限。如果你有兴趣从事保护 L2 系统的工作,请在此处浏览我们的空缺职位。你还可以关注我们的 XBase 团队Base 社区领导者)、FarcasterDiscord 以了解有关 Base 的最新信息。

  • 原文链接: blog.base.dev/demystifyi...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Base 中文
Base 中文
江湖只有他的大名,没有他的介绍。