计算机体系结构 07:有限 RV32I 参考执行器
有限 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.c、run_all.sh、sum.hex、sum.jsonl、branch_loop.hex、branch_loop.jsonl、signed_compare.hex、signed_compare.jsonl、x0.jsonl、fail_unsupported.jsonl、fail_misaligned.jsonl、fail_oob.jsonl、fail_store_text.jsonl、fail_text_junk.jsonl、fail_data_extra.jsonl、fail_data_overlap.jsonl、fail_branch_misaligned.jsonl、environment.txt。
最小状态:PC、32 个寄存器和一块小内存
执行器的状态只有三类:
1 | |
pc 从 0 开始,.text 每个 32 位机器字按小端写入内存。.data 用 <byte-address> <word> 形式初始化数据。执行器每步从 pc 取 4 字节,解码字段,执行状态转移,最后写一条 JSONL。
这种设计有两个刻意的限制。第一,内存只有 4096 字节,越界必须失败,不能把宿主进程地址空间当成模拟内存。第二,停止条件是 pc == text_size,没有 ecall、ebreak 或系统调用语义。这样做可以先把普通指令的提交轨迹跑通,不把环境调用和异常处理混进第一版模型。
输入格式是后续模型的公共契约
programs/sum.hex 的形状如下:
1 | |
.text 只放机器字,不要求执行器理解汇编注释。.data 的地址必须 4 字节对齐,数据按小端写入,且不能覆盖 .text。.data 后重新进入 .text、指令字尾部垃圾、.data 多余字段都会被拒绝。这个格式对 C 执行器和后续 Python 模型都友好:C 程序负责产生权威提交轨迹,Python 程序可以读取同一份机器码,比较每条提交的寄存器和内存结果。
输出的一条典型记录:
1 | |
字段含义固定:step 是提交序号,pc 是取指地址,inst 是机器码,op 是执行器识别的操作,后面的 rd/value/addr/taken/target 按指令类型出现。每条记录都带 next_pc 和 x0,方便回归检查。
支持清单比“跑通样例”更重要
当前支持:
| 类别 | 指令 |
|---|---|
| R-type | add、sub、and、or |
| I-type | addi、andi、ori |
| 访存 | lw、sw |
| 分支 | beq、bne、blt、bge |
当前不支持 shift、jump、lui/auipc、ecall/ebreak、乘除法、压缩指令、原子指令、特权指令、自修改代码和异常恢复。遇到不支持指令时,执行器必须失败,而不是把它当作 nop。失败用例 programs/fail_unsupported.hex 的输出是:
1 | |
这个错误来自 slli。slli 属于 RV32I,但尚未进入第一版教学子集,所以执行器必须如实报告“不支持”,不能因为它在官方 ISA 内就静默执行错误语义。
x0、非对齐和越界是第一批回归用例
x0 是硬零寄存器。执行器每次写寄存器时都检查 rd != 0,每步结束又强制 x[0] = 0。programs/x0.hex 先尝试写 x0=123,再用 x0 计算 x1=1。这个用例防止后续改写写回逻辑时误让 x0 漏出状态。
非对齐和越界是两个独立错误。lw x1,2(x0) 触发:
1 | |
lw x1,-4(x0) 的有效地址按 32 位无符号计算为 0xfffffffc,它是 4 字节对齐的,但超过 4096 字节教学内存,所以触发:
1 | |
这个用例暴露过一次真实 bug:最初代码用 addr + 4 > MEM_SIZE 判断越界,addr 接近 UINT32_MAX 时加法发生无符号回绕,错误地通过检查。修复后改为 addr > MEM_SIZE - 4。这说明执行器也是普通 C 程序,不能把宿主语言的边界错误包装成硬件行为。
写 text 区也被显式拒绝:
1 | |
分支目标在分支指令本身检查。RISC-V 固定 32 位指令环境下,taken branch 目标不满足指令对齐时,错误归在分支指令而不是下一轮取指。本模型把这类错误记为 branch_target_out_of_text:
1 | |
成功程序与边界回归
求和程序把地址 128–144 的 5 个字相加,结果写到地址 148:
1 | |
条件循环程序用 blt 计算 1+2+3+4,结果写到地址 160:
1 | |
另一个 signed_compare 程序检查有符号比较边界:x1=-1、x2=1 时,blt x1,x2,+8 必须跳过错误赋值,最后停在 x3=7:
1 | |
这些程序覆盖顺序执行、load/store、回跳分支、分支退出、寄存器累加和内存结果。它们仍然很小。小不是缺点,因为第一版执行器的目标是成为稳定参照,而不是一次吃下完整 ISA。
运行与验收
运行命令:
1 | |
脚本会做四件事:用 cc -std=c11 -Wall -Wextra -pedantic -O2 编译执行器,记录环境,运行 ALU 手算辅助,执行 4 个成功程序和 8 个失败程序,最后用 grep 检查关键结果。真实输出:
1 | |
验收标准:
sum得到mem148=25。branch_loop得到mem160=10。x0轨迹中x0始终为 0。signed_compare证明blt的跨符号比较不是宿主 C 的实现定义强转。- 不支持指令、非对齐、越界、写 text 区、坏输入和坏分支目标分别显式失败。
- 所有原始输出保存在
outputs/,不是只在文章里口头描述。
后续扩展边界
下一批可以加 lui/auipc/jal/jalr,也可以把 .text 和 .data 拆成更接近链接器的镜像;但每次扩展都要先补失败用例和成功轨迹。流水线模型不应直接复制 C 执行器的控制流,而应读取同一份输入,并用提交结果对齐架构状态。这样后续讨论周期、冒险和预测时,正确性参照和时序假设能分开。
练习一:为什么不把不支持指令当 nop
题目:如果执行器遇到未知 opcode 时直接把 PC 加 4,会有什么问题?
解答:错误程序会被伪装成成功程序。后续流水线模型可能继续对齐一个本不该存在的提交轨迹,文章也会把“没有实现”误写成“执行正确”。参考执行器的价值来自明确边界,不来自尽量跑下去。
练习二:修复越界判断
题目:为什么 addr + 4 > MEM_SIZE 不是可靠的 32 位越界判断?
解答:addr 是 uint32_t,当它接近 UINT32_MAX 时,加 4 会按无符号规则回绕到小数。应改成 addr > MEM_SIZE - 4,先把最大合法起始地址算出来,再比较起始地址本身。





