ISA 是机器能执行的契约

本篇的核心问题是:给定一串 32 位比特,机器怎样知道这是加法、访存还是分支?ISA(Instruction Set Architecture,指令集架构)回答的不是“电路怎样做得快”,而是“软件交给硬件的这串比特,在架构状态上必须产生什么效果”。架构状态至少包括 PC、通用寄存器、内存中可见的字节,以及遇到不支持指令或非法访问时的处理边界。

本系列第一条实验线选择 RV32I 作为共同语言。RISC-V 官方非特权规范把 RV32I 定义为 32 位整数寄存器宽度的基础整数 ISA;基础整数 ISA 提供算术、load/store 和控制流指令,扩展如乘除法、压缩指令、原子操作和特权机制另行定义。本文只使用一个更小的教学子集:add/sub/and/oraddi/andi/orilw/swbeq/bne/blt/bge。这个子集足够执行一个求和循环,但不能代表完整 RV32I,更不能代表一个操作系统可用的处理器。

证据等级:手算、功能执行。功能执行证据来自 examples/computer-architecture/base/ 中的 C11 参考执行器。当前机器缺少可用的 RISC-V 后端工具链,Apple clang 21 对 --target=riscv32-unknown-elf 报告没有兼容 target,Apple llvm-objdump 也没有列出 RISC-V target。因此本文不声称完成了本机真实 RISC-V 编译和反汇编,只使用手写机器码、官方编码规则和执行器轨迹作证。

ISA 把程序约束在少数状态变化上

对一条指令来说,最重要的问题只有三个:从哪里取操作数,怎样计算,下一个 PC 是多少。微架构可以单周期执行、五级流水、乱序推测,最后提交给软件看的架构状态必须符合 ISA 的规则。比如 add x3,x3,x4 的规则是读取 x3x4,把低 XLEN 位的和写入 x3。RV32I 的 XLEN 是 32,整数加法溢出不会产生算术异常;硬件是否有进位链、旁路网络或乱序执行窗口,不属于这条指令的语义。

RV32I 有 32 个整数寄存器,编号 x0x31。其中 x0 是硬零寄存器:读出总是 0,写入会被丢弃。这个规则让很多伪操作可以由普通指令表达,例如 mv x5,x6 可以写成 addi x5,x6,0。教学执行器每一步都会把 x0 强制恢复为 0,并用 programs/x0.hex 检查这个不变量。

1
2
0x07b00013  # addi x0,x0,123
0x00100093 # addi x1,x0,1

如果第一条写入真的生效,第二条会得到 124;实际轨迹最后仍显示 x0:0x1 来自硬零寄存器加 1。这是 ISA 级规则,不是编译器优化。

从 32 位机器码拆出字段

RV32I 基础指令固定为 32 位。不同格式复用若干字段:最低 7 位是 opcode,rd 是目的寄存器,rs1/rs2 是源寄存器,funct3/funct7 进一步区分具体操作。以求和程序中的 add x3,x3,x4 为例,机器码是:

1
0x004181b3

按 R-type 格式拆开:

字段 位段 含义
opcode 6:0 0x33 整数寄存器-寄存器运算
rd 11:7 3 结果写入 x3
funct3 14:12 0 加减类
rs1 19:15 3 读取 x3
rs2 24:20 4 读取 x4
funct7 31:25 0 add,不是 sub

同一个 opcode 不能单独决定操作。addsub 都使用 0x33,区别在 funct7。这就是“编码”与“汇编助记符”的关系:助记符方便人读,机器真正执行的是字段组合。

load/store 架构把内存访问限制在专门指令上

RV32I 是 load-store 架构。整数算术指令只读写寄存器;访问内存必须使用 lw/sw 等 load/store 指令。求和程序的核心循环先从地址 x1 + 0 读一个 32 位字到 x4,再把它加到累加器 x3

1
2
0x0000a203  # lw   x4,0(x1)
0x004181b3 # add x3,x3,x4

本系列教学环境把内存定义为 4096 字节、小端、字访问必须自然对齐。RISC-V 规范把自然对齐访问的行为说清楚,非对齐访问是否被环境透明处理取决于 EEI(Execution Environment Interface)。教学执行器选择更严格的边界:lw 地址不是 4 的倍数就显式失败。这个选择不是在声称所有 RISC-V 机器都必须失败,而是在为后续模型固定一个可检查的环境。

失败用例:

1
0x00202083  # lw x1,2(x0)

真实输出为:

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

错误边界同样属于教学模型的契约。支持什么、不支持什么,必须比“能跑某个例子”更明确。

分支的立即数字段不是顺着写的

循环离不开分支。求和程序用 bne x2,x0,-16 回到循环体开头:

1
0xfe0118e3  # bne x2,x0,-16

B-type 立即数看起来最容易出错,因为偏移的各个位被分散在机器码的不同位置。官方规范解释了 B/J 格式为什么采用这种“旋转”布置:它让若干立即数字段位置和其他格式重叠,减少硬件里动态移位和多路选择的成本。对读者来说,重要的是不要把机器码里的位段直接按书写顺序拼成一个普通 12 位数。

手算这条分支时,先从字段恢复有符号偏移 -16。执行到 PC=28 时,如果 x2 != x0,下一个 PC 就是 28 + (-16) = 12;否则顺序执行到 PC=32。执行器输出的分支事件包含 takentarget,因此可以逐条核对。

求和循环的机器状态轨迹

examples/computer-architecture/base/programs/sum.hex 先初始化指针、计数器和累加器,再循环读取 5 个字:

1
2
3
4
5
6
7
8
9
0x08000093  # addi x1,x0,128
0x00500113 # addi x2,x0,5
0x00000193 # addi x3,x0,0
0x0000a203 # lw x4,0(x1)
0x004181b3 # add x3,x3,x4
0x00408093 # addi x1,x1,4
0xfff10113 # addi x2,x2,-1
0xfe0118e3 # bne x2,x0,-16
0x08302a23 # sw x3,148(x0)

数据区是地址 128、132、136、140、144 上的 3,4,5,6,7。运行命令:

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

关键结果:

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

可下载附件:sum.hexsum.jsonlenvironment.txt

这个结果只证明在声明的子集和环境中,机器码按预期修改了寄存器与内存。它不证明完整 RV32I 合规,不证明流水线周期数,也不证明真实 CPU 性能。

验收

本篇验收标准是能完成三件事。第一,给定一条简单 RV32I 机器码,能拆出 opcode、寄存器字段和立即数。第二,能沿求和循环说清 PC、寄存器、内存如何变化。第三,能指出证据边界:手写机器码加功能执行轨迹不是完整工具链编译,也不是微架构时序。

练习一:拆 addi

题目:0x00408093 是哪条指令?它修改什么状态?

解答:最低 7 位是 0x13,属于 I-type 整数立即数运算。rd=1rs1=1funct3=0,立即数为 4,所以是 addi x1,x1,4。它把 x1 加 4 后写回 x1,PC 顺序加 4。

练习二:预测循环次数

题目:求和程序中 x2 初值为 5,每轮执行 addi x2,x2,-1,随后 bne x2,x0,-16。循环体中的 lw 执行几次?

解答:lw 在计数器递减之前执行。五轮中 x2 进入循环时分别为 5、4、3、2、1;第五轮递减后变为 0,分支不再跳转。所以 lw 执行 5 次,累加 5 个字,最终 mem148=25

参考资料