CKB/CKB-VM 指令解码器与指令缓存

如果你已经读过前面的几篇 CKB 文章, 你大概会对虚拟机的执行主循环有一个大致印象: 取指, 解码, 执行, 计费, 再回到下一条指令.

我们在前几篇文章里, 主要关注了执行和计费这两块, 但正如你所见的, 还有一个环节也很重要, 那就是取指和解码.

很多人第一次看虚拟机代码时, 关注点通常都放在执行逻辑上: add 怎么算, lw 怎么读内存, ecall 怎么进入系统调用. 但对一台真正要跑大量链上脚本的虚拟机来说, 这些执行逻辑并不是唯一成本. 在执行之前, CPU 还得先把内存里的原始比特翻译成一条条内部指令. 如果这一步做得不好, 会大幅度拖累整个系统的性能.

本文就来讲 CKB-VM 的指令解码器是怎么工作的.

整体架构

在 CKB-VM 的执行路径里, 解码器处在一个非常基础的位置. 不管是 Rust 解释器模式, 还是 ASM 模式, 最终都要先把内存中的字节翻译成内部表示, 然后才能进入真正的执行逻辑.

从源码上看, 这一步由 src/decoder.rs 负责. 它做的事情其实可以拆成三层:

  1. 从内存里读出原始指令比特.
  2. 把比特解析成统一的 Instruction.
  3. 必要时做一次缓存, 避免同一条指令被重复解码.

RISC-V 编码格式

RISC-V 不是只有 32 位定长指令. 它还有压缩指令 RVC, 也就是 16 位编码. 这意味着解码器不能想当然地每次都读 4 个字节, 然后直接把它们解释成一条指令.

CKB-VM 的做法很聪明: 先从内存里读 2 个字节, 看最低两位.

... XX XX 11 -> 32-bit 指令
... XX XX 00 -> 16-bit 压缩指令
... XX XX 01 -> 16-bit 压缩指令
... XX XX 10 -> 16-bit 压缩指令

原因很简单. 按 RISC-V 规范, 32 位指令的低两位固定是 11, 而 RVC 指令的低两位则不是 11. 于是只要先读出 16 位, 就能决定这条指令到底是 16 位还是 32 位. 如果是 32 位, 再去读后面 2 个字节拼上去就行了.

这种做法的正确性是显而易见的, 但也存在一个折中: 你可能会多读一次内存. 对于 32 位指令, 你先读了 2 个字节, 再读后面 2 个字节, 总共读了 4 个字节, 触发了两次内存权限检查和内存访问. 因此我们在后面会看到, CKB-VM 还做了一个小优化, 尽量减少这种重复访问.

decode_bits: 快速路径与保守路径

函数 decode_bits 是整个解码器里最底层的函数. 它只做一件事: 从内存里拿到原始指令比特. 它有一个很实用的优化: 如果当前 PC 不在页面末尾附近, 就直接走 32 位读取路径; 如果已经靠近页尾, 就退回到 16 位读取, 防止越界. 前者我们可以称它为快速路径, 后者称为保守路径. 当我们通过快速路径读到 32 位指令时, 会检查一次低两位, 如果它是 16 位指令, 那么就抛弃后面 2 个字节, 只保留前面 2 个字节. 在快速路径下, 只要 PC 不在页尾附近, 无论下一条指令是 16 位还是 32 位, 都只会触发一次内存检查和内存访问, 这就是为什么它被称为快速路径.

一条 32 位指令可能跨页. 在页尾时, 直接读 4 字节既可能触发额外检查, 也可能踩到内存边界. 因此它需要一个更保守的路径.

decode_raw: 真正的解码入口

更上层的入口是 decode_raw.

它先检查地址是否越界, 然后会去查一个小型指令缓存. 如果缓存命中, 直接返回上次解码好的内部指令; 如果没命中, 才会调用 decode_bits, 再依次尝试各个 InstructionFactory.

源码里这个过程大致是这样的:

这里有两个值得注意的点.

  1. CKB-VM 并不是把指令字节缓存起来, 而是把已经解码好的内部指令缓存起来. 这样下次命中时, 它可以直接跳过解析步骤.
  2. 这个缓存是按 PC 来查的, 不是按比特值来查的. 这很合理. 同样的机器码字节, 出现在不同地址时, 仍然应该被视作不同位置的指令. 对虚拟机来说, 地址是语义的一部分.

指令缓存

CKB-VM 的指令缓存大小是 4096, 对应一个很小的哈希表. 但它并没有直接拿 pc % 4096 当键, 而是用了一个更精心设计的混合方式:

((pc & 0xFF) | (pc >> 12 << 8)) as usize % INSTRUCTION_CACHE_SIZE

这行代码的意思是: 取 PC 的低 8 位, 再取一部分更高位, 拼成一个缓存索引.

为什么不直接用低位?

因为低位很容易只覆盖局部代码区域. 很多程序在一段很短的时间内会围绕几个连续地址反复跳转, 如果缓存键只看低位, 那些地址就可能集中撞到同一个槽位, 造成不必要的冲突.

为什么又要保留低位?

因为低位能很好地区分同一段附近代码. 如果只看高位, 你会把很多彼此接近的指令映射到一起, 反而失去局部性.

所以这个键值本质上是在做一个折中: 既照顾局部连续代码, 也尽量让远距离跳转后的代码不要太容易和原来的热点冲突. 这是一种很典型的工程优化写法: 不追求数学上最漂亮, 但追求实际足够好.

这个方案是为了平衡局部代码和远端代码, 参数里的 12 和 8 也带有经验成分.

InstructionFactory: 把不同 ISA 模块拼起来

解码器内部并不是写死一大堆 if-else 来识别全部指令. 它采用的是一组 InstructionFactory. 在 DefaultDecoder::new 里, 不同 ISA 模块会按顺序注册:

  • rvc::factory 负责压缩指令.
  • i::factory 负责基础整数指令.
  • m::factory 负责乘除法扩展.
  • 如果启用了 B 扩展, 还会注册 b::factory.

这意味着解码器本身并不知道每一条指令的全部细节. 它更像一个组装器: 先拿到原始比特, 再交给各个模块去认领. 这种分层设计有两个好处:

  1. 结构清楚. 新增扩展指令集时, 只要新增一个 factory, 而不必把所有逻辑都塞进一个巨大的函数里.
  2. 兼容性更好. 不同版本的 CKB-VM 可以通过 version 参数切换不同行为, 让旧脚本继续保持预期语义.

decode_mop: 解码器里的第二层优化

如果 ISA_MOP 打开, decode() 走的就不是纯 decode_raw, 而是 decode_mop. 这部分和前面的缓存不是同一类优化. 前面的缓存是不要重复解码同一条指令, 这里的 MOP(宏指令融合) 是把几条相邻指令融合成一条内部伪指令.

例如 div + rem, mulh + mul, lui + jalr 这类组合, 都可能在解码阶段被折叠为更高层的内部指令. 这样做的原因很简单: 有些指令组合在硬件上本来就天然适合一起处理, 没必要拆成两次执行.

所以从层次上看, CKB-VM 的解码器不只是识别字节码, 它还顺手承担了一部分指令重写的工作.

小结

CKB-VM 的指令解码器并不只是把机器码翻译成内部指令这么简单. 它同时在处理三件事:

  1. 用最小代价识别 16 位和 32 位编码.
  2. 通过小型缓存减少重复解码.
  3. 为 MOP 之类的更高层优化提供入口.

这也是我觉得 CKB-VM 很值得写的一个点: 它的工程实现不是单纯堆功能, 而是把每一层的责任都切得很清楚, 让每个小优化都能真正落到执行路径上.