有限 RV32I 参考执行器

本篇的核心问题是:怎样写一个足够小、边界清楚、后续还能复用的指令参考执行器?答案不是一次写完整 CPU,而是先固定状态、输入、输出和失败规则。参考执行器只回答“这条指令按声明的 ISA 子集提交后,架构状态是什么”。它不回答周期数、流水线冒险、缓存命中、异常恢复或操作系统交互。

本篇产出位于 examples/computer-architecture/base/。执行器用 C11 编写,输入是文本形式的 32 位机器码和数据字,输出是 JSONL 提交轨迹。后续 08–11 篇可以把这条提交轨迹作为正确性参照,再在 Python 时序模型里引入阶段、停顿和分支恢复。

证据等级:功能执行。已执行 bash examples/computer-architecture/base/scripts/run_all.sh,结果为 base regression: PASS。原始输出保存在 examples/computer-architecture/base/outputs/

同名素材目录也保留了本篇可下载附件:rv32i_ref.crun_all.shsum.hexsum.jsonlbranch_loop.hexbranch_loop.jsonlsigned_compare.hexsigned_compare.jsonlx0.jsonlfail_unsupported.jsonlfail_misaligned.jsonlfail_oob.jsonlfail_store_text.jsonlfail_text_junk.jsonlfail_data_extra.jsonlfail_data_overlap.jsonlfail_branch_misaligned.jsonlenvironment.txt

最小状态:PC、32 个寄存器和一块小内存

执行器的状态只有三类:

1
2
3
pc: uint32
x[32]: uint32
mem[4096]: byte

pc 从 0 开始,.text 每个 32 位机器字按小端写入内存。.data<byte-address> <word> 形式初始化数据。执行器每步从 pc 取 4 字节,解码字段,执行状态转移,最后写一条 JSONL。

这种设计有两个刻意的限制。第一,内存只有 4096 字节,越界必须失败,不能把宿主进程地址空间当成模拟内存。第二,停止条件是 pc == text_size,没有 ecallebreak 或系统调用语义。这样做可以先把普通指令的提交轨迹跑通,不把环境调用和异常处理混进第一版模型。

输入格式是后续模型的公共契约

programs/sum.hex 的形状如下:

1
2
3
4
5
6
7
8
9
10
.text
0x08000093 # addi x1,x0,128
0x00500113 # addi x2,x0,5
...
.data
128 3
132 4
136 5
140 6
144 7

.text 只放机器字,不要求执行器理解汇编注释。.data 的地址必须 4 字节对齐,数据按小端写入,且不能覆盖 .text.data 后重新进入 .text、指令字尾部垃圾、.data 多余字段都会被拒绝。这个格式对 C 执行器和后续 Python 模型都友好:C 程序负责产生权威提交轨迹,Python 程序可以读取同一份机器码,比较每条提交的寄存器和内存结果。

输出的一条典型记录:

1
{"step":3,"pc":12,"inst":"0x0000a203","op":"lw","rd":4,"value":3,"addr":128,"next_pc":16,"x0":0}

字段含义固定:step 是提交序号,pc 是取指地址,inst 是机器码,op 是执行器识别的操作,后面的 rd/value/addr/taken/target 按指令类型出现。每条记录都带 next_pcx0,方便回归检查。

支持清单比“跑通样例”更重要

当前支持:

类别 指令
R-type addsubandor
I-type addiandiori
访存 lwsw
分支 beqbnebltbge

当前不支持 shift、jump、lui/auipcecall/ebreak、乘除法、压缩指令、原子指令、特权指令、自修改代码和异常恢复。遇到不支持指令时,执行器必须失败,而不是把它当作 nop。失败用例 programs/fail_unsupported.hex 的输出是:

1
{"step":0,"pc":0,"inst":"0x00101093","error":"unsupported_i_type"}

这个错误来自 sllislli 属于 RV32I,但尚未进入第一版教学子集,所以执行器必须如实报告“不支持”,不能因为它在官方 ISA 内就静默执行错误语义。

x0、非对齐和越界是第一批回归用例

x0 是硬零寄存器。执行器每次写寄存器时都检查 rd != 0,每步结束又强制 x[0] = 0programs/x0.hex 先尝试写 x0=123,再用 x0 计算 x1=1。这个用例防止后续改写写回逻辑时误让 x0 漏出状态。

非对齐和越界是两个独立错误。lw x1,2(x0) 触发:

1
{"step":0,"pc":0,"inst":"0x00202083","error":"misaligned_lw"}

lw x1,-4(x0) 的有效地址按 32 位无符号计算为 0xfffffffc,它是 4 字节对齐的,但超过 4096 字节教学内存,所以触发:

1
{"step":0,"pc":0,"inst":"0xffc02083","error":"load_out_of_bounds"}

这个用例暴露过一次真实 bug:最初代码用 addr + 4 > MEM_SIZE 判断越界,addr 接近 UINT32_MAX 时加法发生无符号回绕,错误地通过检查。修复后改为 addr > MEM_SIZE - 4。这说明执行器也是普通 C 程序,不能把宿主语言的边界错误包装成硬件行为。

写 text 区也被显式拒绝:

1
{"step":0,"pc":0,"inst":"0x00002023","error":"store_to_text"}

分支目标在分支指令本身检查。RISC-V 固定 32 位指令环境下,taken branch 目标不满足指令对齐时,错误归在分支指令而不是下一轮取指。本模型把这类错误记为 branch_target_out_of_text

1
{"step":0,"pc":0,"inst":"0x00000163","error":"branch_target_out_of_text"}

成功程序与边界回归

求和程序把地址 128–144 的 5 个字相加,结果写到地址 148:

1
{"halt":"pc_at_end","steps":29,"x0":0,"x1":148,"x3":25,"mem148":25,"mem160":0}

条件循环程序用 blt 计算 1+2+3+4,结果写到地址 160:

1
{"halt":"pc_at_end","steps":16,"x0":0,"x1":4,"x3":10,"mem148":0,"mem160":10}

另一个 signed_compare 程序检查有符号比较边界:x1=-1x2=1 时,blt x1,x2,+8 必须跳过错误赋值,最后停在 x3=7

1
{"halt":"pc_at_end","steps":4,"x0":0,"x1":4294967295,"x3":7,"mem148":0,"mem160":0}

这些程序覆盖顺序执行、load/store、回跳分支、分支退出、寄存器累加和内存结果。它们仍然很小。小不是缺点,因为第一版执行器的目标是成为稳定参照,而不是一次吃下完整 ISA。

运行与验收

运行命令:

1
bash examples/computer-architecture/base/scripts/run_all.sh

脚本会做四件事:用 cc -std=c11 -Wall -Wextra -pedantic -O2 编译执行器,记录环境,运行 ALU 手算辅助,执行 4 个成功程序和 8 个失败程序,最后用 grep 检查关键结果。真实输出:

1
base regression: PASS

验收标准:

  1. sum 得到 mem148=25
  2. branch_loop 得到 mem160=10
  3. x0 轨迹中 x0 始终为 0。
  4. signed_compare 证明 blt 的跨符号比较不是宿主 C 的实现定义强转。
  5. 不支持指令、非对齐、越界、写 text 区、坏输入和坏分支目标分别显式失败。
  6. 所有原始输出保存在 outputs/,不是只在文章里口头描述。

后续扩展边界

下一批可以加 lui/auipc/jal/jalr,也可以把 .text.data 拆成更接近链接器的镜像;但每次扩展都要先补失败用例和成功轨迹。流水线模型不应直接复制 C 执行器的控制流,而应读取同一份输入,并用提交结果对齐架构状态。这样后续讨论周期、冒险和预测时,正确性参照和时序假设能分开。

练习一:为什么不把不支持指令当 nop

题目:如果执行器遇到未知 opcode 时直接把 PC 加 4,会有什么问题?

解答:错误程序会被伪装成成功程序。后续流水线模型可能继续对齐一个本不该存在的提交轨迹,文章也会把“没有实现”误写成“执行正确”。参考执行器的价值来自明确边界,不来自尽量跑下去。

练习二:修复越界判断

题目:为什么 addr + 4 > MEM_SIZE 不是可靠的 32 位越界判断?

解答:addruint32_t,当它接近 UINT32_MAX 时,加 4 会按无符号规则回绕到小数。应改成 addr > MEM_SIZE - 4,先把最大合法起始地址算出来,再比较起始地址本身。

参考资料