Post

P3 · RV32I 指令集:让 CPU 真正开始执行

P3 · RV32I 指令集:让 CPU 真正开始执行

本章目标

P2 给了这台机器状态(寄存器 + PC + 内存)。P3 给它规则——RV32I 指令集。

读完本章你能回答:

  • 为什么 RV32I 只有 37 条指令,却”什么都能算”?
  • 指令格式的六种分类(R/I/S/B/U/J)不是编号游戏,它的依据是什么?
  • B-type 立即数在指令字里为什么是”乱的”?
  • cpu.pc += 4 为什么是错的——至少是不完整的?
  • 一条 jal ra, 0x100 执行完,CPU 的哪些状态被改了?恰好几个?
  • 我们的 INSTPAT 模式匹配和真硬件的 PLA 到底有什么关系?

动手做到

  • 实现一个可分派 37 条指令的 INSTPAT 驱动译码器
  • 手写汇编的 fib(10),在自己的模拟器上跑出 55
  • si 1 单步看 $pc / $ra / $sp 的变化
  • 让 P1 的 watchpoint 第一次真的触发

本章不涉及(defer 清单):

  • CSR / ECALL / MRET → P6a
  • 压缩指令 C 扩展 / M / A / F / D / V 扩展 → 只提存在,不实现
  • 分支预测 / 流水线 / 超标量 → 仅作历史提及
  • 特权模式 / MMU → P6
  • 差分测试 → P4 会拿 isa_exec_once 当对拍单位

1. 问题动机:stub 的最后一块多米诺

翻开 P2 结尾,exec_once 还是占位:

1
2
3
4
/* 今天的 exec_once 是这样 —— stage 3 之前的版本 */
static void exec_once(void) {
    /* stub: will be filled in P3 */
}

cpu_exec 循环每次叫它,它什么也不做。P1 的 si 10 不会真的前进 10 条指令,P1 的 watchpoint 永远不触发,batch 模式转着空圈直到被 Ctrl+C。

这一章的多米诺:把这一个函数填实,所有之前搭好的脚手架同时上线。

P1/P2 的 stubP3 兑现
si N 不前进真的执行 N 条
watchpoint 永不触发指令改了被监视值,立刻停
itrace 无数据最近 16 条指令的汇编都记下
batch 模式转死循环程序跑到 ebreak 干净退出
isa.c 测试全挂每条指令一个单元测试全绿

整章可以压成一句口号:

核心洞察:CPU = 状态 + 迁移规则。P2 给了状态,P3 给规则。你已经写过一条指令了——P0 toy CPU 的那条 ADDI。这章就是把那个循环做对 37 遍,再把编码精度从 op = inst >> 24 升级成真 ISA 的精度。


2. 核心概念:ISA 是契约

2.1 ISA = 状态 + 规则 + 编码

一句话定义 ISA(Instruction Set Architecture):

1
2
3
ISA = 一组状态 (gpr + pc + mem + csr + ...)
    + 一组迁移规则 (指令 → 状态变化)
    + 它们的二进制编码 (如何把规则写进字节)

这三件事必须一起定义才算一个 ISA。RV32I spec 前 80 页就是这三件事的正式定义。

2.2 理论视角:ISA 是契约,不是实现

ISA 不是”某款芯片能做的事”。它是硬件和软件之间长达几十年的合同

  • 今天写的 x86-64 二进制——还能在 1978 年的 Intel 8086 上跑一部分(通过 16-bit compatibility mode)
  • Intel Skylake 能运行 80386 的代码、80286 的代码、8086 的代码——每剥一层”像一台时间机器”,而所有这些剥层都由同一份 ISA 合同保证

对我们写模拟器来说,这意味着一件关键事:

工程原则:我们实现的是 spec,不是某款芯片。当 spec 和 gcc 产生歧义(比如 SRAI 在 C 里是 implementation-defined behavior),我们按 spec 选,不按 gcc 选。当 gcc 产生的程序跑不对,bug 要么在 gcc、要么在我们理解 spec 错了——不是 “spec 没说清”。

选 RV32I 的另一个务实原因:spec 能一次读完。unprivileged 80 页,privileged 另外 140 页。x86 spec 是几千页分三大卷,手搓一个模拟器是一辈子的事。


3. 核心循环:Fetch → Decode → Execute

在实现任何指令之前,先把循环架起来。

3.1 Decode 结构体:一条指令的”脚手架”

1
2
3
4
5
6
7
// include/cpu.h
typedef struct {
    vaddr_t  pc;    /* address of this instruction                  */
    vaddr_t  snpc;  /* static next PC  (= pc + 4)  — 顺序 fall-through */
    vaddr_t  dnpc;  /* dynamic next PC — what cpu_exec writes back   */
    uint32_t inst;  /* 32-bit encoded instruction                   */
} Decode;

三个 PC 相关字段各有分工:

  • pc——这条指令的地址,用于 PC 相对寻址(auipc、分支/跳转目标计算)
  • snpc static next——pc + 4,顺序下一条指令的地址
  • dnpc dynamic next——真正要写回到 cpu.pc 的值。默认等于 snpc,指令只在想跳转时才覆盖

这个”三字段设计”是 P3 最重要的架构决策。

3.2 为什么不直接 cpu.pc += 4

初学者的本能写法:

1
2
3
4
5
6
7
8
9
10
/* v1 朴素版 —— 不要这样写 */
static void exec_once_v1(void) {
    uint32_t inst = paddr_read(cpu.pc, 4);
    switch (opcode) {
        case ADDI: /* ... */    cpu.pc += 4; break;
        case BEQ: if (cond) cpu.pc += offset; else cpu.pc += 4; break;
        case JAL: cpu.pc = target; break;
        /* ... 每一条都要显式写 pc 更新 */
    }
}

问题:

  • 每条指令都要记得写 cpu.pc += 4——漏一条就死循环
  • 分支指令要同时处理 “taken” 和 “not taken”——两个 cpu.pc = ...
  • 跳转覆盖别的 PC——如果你先 cpu.pc += 4cpu.pc = target,会多做一次加法;顺序错就是 bug

v2 正式版:PC 更新的权力属于循环,不属于指令。每条指令只写 s->dnpc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/cpu/cpu_exec.c 的 exec_once 真实版 (暂去掉 itrace/trap/difftest)
static void exec_once(void) {
    Decode s;
    s.pc   = cpu.pc;
    s.snpc = cpu.pc + 4;
    s.dnpc = s.snpc;                /* 默认顺序 fall-through */
    s.inst = (uint32_t)paddr_read(s.pc, 4);

    isa_exec_once(&s);              /* 指令体只写 s.dnpc(可能) */

    if (g.state == TEMU_RUNNING) {
        cpu.pc = s.dnpc;            /* 循环接管 PC 写回 */
    }
    cpu.gpr[0] = 0;                 /* x0 硬连线:见 §8 */
}

现在每条指令的职责变成:

  • 算术指令addi / add …):写 R(rd)不碰 s->dnpc(默认顺序继续)
  • 分支指令beq …):满足条件时 s->dnpc = s->pc + offset
  • 跳转指令jal / jalr):无条件 s->dnpc = target,同时写 R(rd) = s->snpc(链接地址)

指令不可能忘记 PC 更新——默认就是对的。

3.3 cpu_exec 主循环

P1 写过的循环,现在真正兑现:

1
2
3
4
5
6
7
8
9
void cpu_exec(uint64_t n) {
    g.state = TEMU_RUNNING;
    for (uint64_t i = 0; i < n; i++) {
        exec_once();
        if (g.state != TEMU_RUNNING) break;
        if (wp_check()) { g.state = TEMU_STOP; break; }  /* P1 的 hook */
    }
    /* ... 打印 HIT END / HIT ABORT */
}

P1 那行 wp_check()——从今天起第一次真的能触发。指令真正修改了寄存器和内存,watchpoint 才有东西可以”被改”。

核心洞察Decode { pc, snpc, dnpc, inst } 是 TEMU 最重要的通信约定isa_exec_oncepc / snpc / inst,写 dnpc(可选)和 cpu.gpr[]。这个”四字段契约”让我们能写 37 条独立指令而不互相干扰。


4. 指令编码家族:六种格式

37 条指令分为六种编码格式。格式不是”人为分类”——它由指令需要哪些字段决定。

4.1 六种格式一览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  31      25 24    20 19    15 14  12 11     7 6      0
┌──────────┬────────┬────────┬──────┬────────┬────────┐
R│ funct7   │  rs2   │  rs1   │funct3│   rd   │opcode  │   add/sub/sll...
├──────────┴────────┼────────┼──────┼────────┼────────┤
I│    imm[11:0]     │  rs1   │funct3│   rd   │opcode  │   addi/lw/jalr/csrrw...
├──────────┬────────┼────────┼──────┼────────┼────────┤
S│imm[11:5] │  rs2   │  rs1   │funct3│imm[4:0]│opcode  │   sw/sh/sb
├─┬────────┬────────┼────────┼──────┼────────┼─┬──────┤
B│i│imm     │  rs2   │  rs1   │funct3│imm     │i│opcode│   beq/bne...
 │12│[10:5] │        │        │      │[4:1]   │11│     │
├─┴────────┴────────┴────────┴──────┼────────┼────────┤
U│          imm[31:12]               │   rd   │opcode  │   lui/auipc
├─┬────────┬─┬──────────────────────┼────────┼────────┤
J│i│imm[10: │i│    imm[19:12]         │   rd   │opcode │   jal
 │20│1]     │11│                      │        │       │
└─┴────────┴─┴──────────────────────┴────────┴────────┘

4.2 为什么是这六种

关键观察:rs1、rs2、rd 字段在所有用到它们的格式里都在固定位置rs1 永远是 bits [19:15],rs2 永远是 [24:20],rd 永远是 [11:7]。

这不是巧合——这是硬件并行译码的条件。译码时硬件可以无条件把 bits[19:15] 送去寄存器文件读 rs1,不需要先判断这条指令是 R/I/S/B 哪一种。

格式分类的真相是:指令需要几个寄存器读端口 + 多少立即数位 = 决定哪个格式

格式典型指令寄存器字段立即数宽度特点
Radd / sub / sllrs1, rs2, rd三地址寄存器运算
Iaddi / lw / jalrrs1, rd12-bit signed一源 + 立即数
Ssw / sh / sbrs1, rs212-bit(拆成两段)两源,无 rd(store 不产生值)
Bbeq / bne / …rs1, rs213-bit(LSB 固定 0)两源比较 + 分支偏移
Ului / auipcrd20-bit(直接放高位)上位立即数
Jjalrd21-bit(LSB 固定 0)大跳转偏移

4.3 TEMU 里的格式声明

1
2
3
4
5
6
7
8
// src/isa/riscv32/local-include/inst.h
typedef enum {
    TYPE_R, TYPE_I, TYPE_S, TYPE_B, TYPE_U, TYPE_J,
    TYPE_N,    /* no operands (ecall / ebreak / fence) */
} operand_type_t;

void decode_operand(Decode *s, operand_type_t type,
                    int *rd, word_t *src1, word_t *src2, word_t *imm);

一个 decode_operand 函数按 type 枚举分派——把寄存器和立即数解出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/isa/riscv32/inst.c
void decode_operand(Decode *s, operand_type_t type,
                    int *rd, word_t *src1, word_t *src2, word_t *imm) {
    uint32_t i = s->inst;
    int rs1 = (int)BITS(i, 19, 15);
    int rs2 = (int)BITS(i, 24, 20);
    *rd   = (int)BITS(i, 11, 7);
    *src1 = 0; *src2 = 0; *imm = 0;

    switch (type) {
        case TYPE_R: *src1 = R(rs1); *src2 = R(rs2);                   break;
        case TYPE_I: *src1 = R(rs1); *imm  = immI(i);                  break;
        case TYPE_S: *src1 = R(rs1); *src2 = R(rs2); *imm = immS(i);   break;
        case TYPE_B: *src1 = R(rs1); *src2 = R(rs2); *imm = immB(i);   break;
        case TYPE_U: *imm  = immU(i);                                  break;
        case TYPE_J: *imm  = immJ(i);                                  break;
        case TYPE_N:                                                   break;
    }
}

六种格式,七行代码——因为 rs1/rs2/rd 字段位置固定,译码器的结构就是固定的。这正是 §4.4 理论视角要讲的。

4.4 理论视角:正交性与立即数的”乱序”

B-type 立即数的位映射长这样(从 local-include/inst.h):

1
2
3
4
5
#define immB(i) SEXT((BITS(i,31,31) << 12) |    /* 指令 bit 31 → imm bit 12 */
                     (BITS(i,7,7)   << 11) |    /* 指令 bit  7 → imm bit 11 */
                     (BITS(i,30,25) << 5)  |    /* 指令 bits[30:25] → imm[10:5] */
                     (BITS(i,11,8)  << 1),      /* 指令 bits[11:8]  → imm[4:1] */
                     13)

新手第一眼看会皱眉——为什么不把 13 位立即数连续放一起? 为什么拆成四段还交错摆?

答案是正交性。RISC-V 设计者优先保证:

  • rs1 永远在 bits[19:15]
  • rs2 永远在 bits[24:20]
  • funct3 永远在 bits[14:12]
  • opcode 永远在 bits[6:0]

这些字段先”占座”,立即数只能往剩下的空隙里塞。13 位立即数 = 8 + 5 个间断的位,没办法——就拆成四段。

换来什么? 硬件译码的硅面积

  • 由于 rs1/rs2/rd 位置固定,无论指令是哪种格式,硬件都能把 bits[19:15] 无条件送去读 rs1——不需要先判断格式
  • 立即数的每一位都来自指令字的固定位置——一根导线直连,不需要 mux 选择
  • “判断格式”只影响”要不要用 rs2 / 立即数 / rd”,不影响”从哪读”——这让译码可以完全并行

对比:x86 有变长编码(1-15 字节)+ prefix + ModR/M + SIB + 变长立即数——这是为什么 x86 译码器要数千门逻辑,而 RV32I 译码器几百门就够了。

核心洞察:正交性不是美学选择,它是硅面积的直接体现。立即数看起来”乱”,是因为它让位给了寄存器字段的固定位置。一根走线省的面积,乘以每个芯片上千万个译码器副本,就是晶圆的实际成本。


5. 立即数解码:SEXT 宏

5.1 SEXT 符号扩展的”一行魔法”

1
2
3
// src/isa/riscv32/local-include/inst.h
#define SEXT(x, n) \
    ((word_t)((int32_t)((uint32_t)(x) << (32 - (n))) >> (32 - (n))))

三步变换:

  1. n 位的值左移 32 - n → 原来的最高位(符号位)现在在 bit 31
  2. 强转 int32_t算术右移 32 - n → 右移过程中 bit 31 被重复复制到高位
  3. 转回 word_t 返回

一条表达式,无分支,无条件判断。是所有 RISC 模拟器的”第二常用代码”(第一是 guest_to_host)。

Caveat:C 标准对”有符号数右移”是 implementation-defined。但在 clang / gcc / MSVC 的所有目标平台上,它都是算术右移。我们依赖这个约定。

5.2 naive vs refined:立即数提取

初学者的写法:

1
2
3
4
5
6
7
8
/* v1 naive — 手动 bit surgery,不要这样写 */
static void execute_addi(uint32_t inst, ...) {
    int rs1 = (inst >> 15) & 0x1f;
    int rd  = (inst >>  7) & 0x1f;
    int imm = (inst >> 20);                 /* 12-bit */
    if (imm & 0x800) imm |= 0xFFFFF000;     /* 手动符号扩展 */
    /* ... */
}

问题:

  • 每条 I-type 指令都要重复这三行
  • B-type / J-type 的拆分更复杂,重复 6 种格式 × 37 条 ≈ 几百行 bit surgery
  • bit surgery 写错编译器不会报错——SEXT 符号扩展位数差 1 就是静默 bug

v2 正式版:为每种格式写一个宏,全仓库用 37 次:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/isa/riscv32/local-include/inst.h
#define BITS(x, hi, lo) \
    (((uint32_t)(x) >> (lo)) & ((1u << ((hi) - (lo) + 1)) - 1u))

#define immI(i) SEXT(BITS(i, 31, 20), 12)

#define immS(i) SEXT((BITS(i, 31, 25) << 5) | BITS(i, 11, 7), 12)

#define immB(i) SEXT((BITS(i, 31, 31) << 12) | (BITS(i, 7,  7) << 11) |
                     (BITS(i, 30, 25) << 5)  | (BITS(i, 11, 8) << 1),  13)

#define immU(i) ((word_t)(BITS(i, 31, 12) << 12))

#define immJ(i) SEXT((BITS(i, 31, 31) << 20) | (BITS(i, 19, 12) << 12) |
                     (BITS(i, 20, 20) << 11) | (BITS(i, 30, 21) << 1),  21)

注意两个细节:

  • B-type 和 J-type 缺 bit 0——因为分支/跳转目标必须 2 字节对齐,LSB 永远是 0,spec 干脆不把它编进指令immB 里最低的 shift 是 << 1(产生 bit 1)
  • U-type 不符号扩展——lui a0, 0x12345 的意图就是”把 0x12345 放到 a0 的高 20 位”,不涉及正负号

6. 模式匹配派发:INSTPAT

6.1 naive vs refined(大头)

v1 naive:嵌套 switch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* v1 朴素版 —— 不要这样写 */
switch (BITS(inst, 6, 0)) {                 /* opcode */
    case 0x13: {                             /* OP-IMM */
        switch (BITS(inst, 14, 12)) {       /* funct3 */
            case 0x0: /* addi */    break;
            case 0x1:                        /* SLLI —— 还要看 funct7 */
                switch (BITS(inst, 31, 25)) {
                    case 0x00: /* slli */ break;
                    default: invalid();
                }
                break;
            /* ... 6 more funct3 cases ... */
        }
        break;
    }
    case 0x33: {                             /* OP */
        /* 同样三层 switch */
    }
    /* ... 每个 opcode 一段这样的代码 ... */
}

问题:

  • 三层嵌套 × 多个 opcode 家族——几百行难以阅读
  • 加一条指令要改 1-3 处 switch
  • 看不到”这条指令的完整规则是什么”——规则散在三层里

v2 refined:INSTPAT 模式字符串

每条指令一行,模式字符串直接就是译码规则:

1
2
3
4
INSTPAT("??????? ????? ????? 000 ????? 0110011", "add",  R,
        R(rd) = src1 + src2);
INSTPAT("0100000 ????? ????? 000 ????? 0110011", "sub",  R,
        R(rd) = src1 - src2);

模式字符串按 MSB → LSB 读,0 / 1 / ?

  • funct7(7) rs2(5) rs1(5) funct3(3) rd(5) opcode(7) = 32 bits
  • 0/1 是必须匹配;? 是 don’t-care
  • 每条指令都是 “pattern + 名字 + 格式 + 语义表达式” 的四元组

6.2 pattern_match 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/isa/riscv32/inst.c
bool pattern_match(uint32_t inst, const char *pat) {
    uint32_t key = 0, mask = 0;
    int bit = 31;
    for (const char *p = pat; *p; p++) {
        char c = *p;
        if (c == ' ' || c == '\t') continue;        /* 空白忽略 */
        if      (c == '?') { /* don't care */ }
        else if (c == '0') { mask |= (1u << bit); }
        else if (c == '1') { mask |= (1u << bit); key  |= (1u << bit); }
        else panic("bad character '%c' in pattern '%s'", c, pat);
        bit--;
    }
    return (inst & mask) == key;
}

本质是一句(inst & mask) == key

  • mask = “我关心哪些位”
  • key = “这些位应该是什么值”
  • ? 对应 mask 里的 0——这位不关心

6.3 INSTPAT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define INSTPAT(pat, mnem, ty, body)                                 \
    if (!matched && pattern_match(inst, pat)) {                      \
        decode_operand(s, TYPE_##ty, &rd, &src1, &src2, &imm);       \
        g_last_disasm.name = (mnem);                                 \
        g_last_disasm.type = TYPE_##ty;                              \
        g_last_disasm.inst = inst;                                   \
        g_last_disasm.pc   = s->pc;                                  \
        g_last_disasm.rd   = rd;                                     \
        g_last_disasm.rs1  = (int)BITS(inst, 19, 15);                \
        g_last_disasm.rs2  = (int)BITS(inst, 24, 20);                \
        g_last_disasm.imm  = imm;                                    \
        body;                                                        \
        matched = true;                                              \
    }

body 是一小段 C 代码——在 INSTPAT 展开的地方,rd / src1 / src2 / imm 都已经被 decode_operand 设置好了,body 可以直接用。

first match wins:从上往下扫描,第一个匹配的就执行。顺序是语义——SUB(funct7=0100000)必须在 ADD 之前,不然会被后者”抢先”。

6.4 理论视角:模式匹配 ≡ PLA ≡ 真值表

我们写的 pattern_match 和硬件 CPU 的 PLA(Programmable Logic Array,可编程逻辑阵列)是同一个抽象的两种实现。

PLA 是什么:一种标准数字逻辑模块,由一层 AND 门 + 一层 OR 门组成,”可编程”的部分是选择哪些连接导通。任何真值表都能编成 PLA。

我们的 (inst & mask) == key 本质:一个 AND-term ——”这些位必须等于这些值”。37 条指令 = 37 个 AND-term。

1
2
3
4
5
6
7
8
9
10
软件(顺序扫描)                 硬件(PLA,并行)

 for i = 0..36:                     inst[31:0]
    if (inst & mask[i]) == key[i]:     │
        match[i] = 1                  ┌┴───────┐
        break                         │ PLA    │
                                      │ 37 行  │
                                      └┬───────┘
                                        │
                                      match[0..36]   一个周期完成 37 次匹配

算法等价

  • 软件 O(N):最多扫描 N 条模式才找到
  • 硬件 O(1):所有模式并行匹配,一个门延迟

历史:早期 Intel 芯片(8086、80286)用 PLA 做指令译码。现代 x86 因为变长编码用 PLA 不合算,改成 快速路径 PLA + 复杂指令微码 ROM 混合。RISC-V 由于编码规整,纯 PLA 就能覆盖全部译码——我们的代码规模和它的硬件门数对比是 ~100 倍到 ~10000 倍的差距,但规范描述是相同的 37 条规则。

核心洞察你的 INSTPAT 表就是一张纸上的 PLA。每条模式 = 一根 AND 线;first match wins = 硬件的优先级编码器。软件顺序扫 + 硬件并行匹——是一回事的两种实现。


7. 实现指令集

7.1 isa_exec_once 骨架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/isa/riscv32/inst.c
int isa_exec_once(Decode *s) {
    uint32_t inst = s->inst;
    int    rd   = 0;
    word_t src1 = 0, src2 = 0, imm = 0;
    bool   matched = false;

    #define INSTPAT(pat, mnem, ty, body) /* ... as above ... */

    /* --- upper-immediate (U-type) ---------------------------------- */
    INSTPAT("??????? ????? ????? ??? ????? 0110111", "lui",   U,
            R(rd) = imm);
    INSTPAT("??????? ????? ????? ??? ????? 0010111", "auipc", U,
            R(rd) = s->pc + imm);

    /* 其余 35 条接着 INSTPAT 下去 ... */

    /* --- catch-all: anything not matched above is illegal ---------- */
    INSTPAT("??????? ????? ????? ??? ????? ???????", "invalid", N,
            invalid_inst(s));

    #undef INSTPAT
    return 0;
}

下面分组展开 37 条。

7.2 算术逻辑:I/R-type(19 条)

I-type 算术(6 条,带 12-bit 立即数):

1
2
3
4
5
6
7
8
9
10
11
12
INSTPAT("??????? ????? ????? 000 ????? 0010011", "addi",  I,
        R(rd) = src1 + imm);
INSTPAT("??????? ????? ????? 010 ????? 0010011", "slti",  I,
        R(rd) = (sword_t)src1 <  (sword_t)imm ? 1 : 0);
INSTPAT("??????? ????? ????? 011 ????? 0010011", "sltiu", I,
        R(rd) = src1 < imm ? 1 : 0);
INSTPAT("??????? ????? ????? 100 ????? 0010011", "xori",  I,
        R(rd) = src1 ^ imm);
INSTPAT("??????? ????? ????? 110 ????? 0010011", "ori",   I,
        R(rd) = src1 | imm);
INSTPAT("??????? ????? ????? 111 ????? 0010011", "andi",  I,
        R(rd) = src1 & imm);

注意 slti / sltiu 的区别——前者有符号比较(需要 (sword_t) 强转),后者无符号。立即数在两者里都是符号扩展后再参与比较(spec 规定)——所以 sltiu a0, zero, -10 < 0xffffffff 为真。

I-type 移位(3 条,shamt = imm[4:0]):

1
2
3
4
5
6
INSTPAT("0000000 ????? ????? 001 ????? 0010011", "slli", I,
        R(rd) = src1 << BITS(imm, 4, 0));
INSTPAT("0000000 ????? ????? 101 ????? 0010011", "srli", I,
        R(rd) = src1 >> BITS(imm, 4, 0));
INSTPAT("0100000 ????? ????? 101 ????? 0010011", "srai", I,
        R(rd) = (word_t)((sword_t)src1 >> BITS(imm, 4, 0)));

srai(算术右移)用 funct7 = 0100000srli 区分。spec 规定 RV32I 的移位量只取低 5 位。

R-type 算术(10 条):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
INSTPAT("0000000 ????? ????? 000 ????? 0110011", "add",  R,
        R(rd) = src1 + src2);
INSTPAT("0100000 ????? ????? 000 ????? 0110011", "sub",  R,
        R(rd) = src1 - src2);
INSTPAT("0000000 ????? ????? 001 ????? 0110011", "sll",  R,
        R(rd) = src1 << (src2 & 31));
INSTPAT("0000000 ????? ????? 010 ????? 0110011", "slt",  R,
        R(rd) = (sword_t)src1 <  (sword_t)src2 ? 1 : 0);
INSTPAT("0000000 ????? ????? 011 ????? 0110011", "sltu", R,
        R(rd) = src1 < src2 ? 1 : 0);
INSTPAT("0000000 ????? ????? 100 ????? 0110011", "xor",  R,
        R(rd) = src1 ^ src2);
INSTPAT("0000000 ????? ????? 101 ????? 0110011", "srl",  R,
        R(rd) = src1 >> (src2 & 31));
INSTPAT("0100000 ????? ????? 101 ????? 0110011", "sra",  R,
        R(rd) = (word_t)((sword_t)src1 >> (src2 & 31)));
INSTPAT("0000000 ????? ????? 110 ????? 0110011", "or",   R,
        R(rd) = src1 | src2);
INSTPAT("0000000 ????? ????? 111 ????? 0110011", "and",  R,
        R(rd) = src1 & src2);

sll / srl / sra 的移位量用 src2 & 31——硬件只看低 5 位。spec 原话:“The operand to be shifted is in rs1, and the shift amount is the low 5 bits of rs2”

7.3 分支:B-type(6 条)

1
2
3
4
5
6
7
8
9
10
11
12
INSTPAT("??????? ????? ????? 000 ????? 1100011", "beq",  B,
        if (src1 == src2) s->dnpc = s->pc + imm);
INSTPAT("??????? ????? ????? 001 ????? 1100011", "bne",  B,
        if (src1 != src2) s->dnpc = s->pc + imm);
INSTPAT("??????? ????? ????? 100 ????? 1100011", "blt",  B,
        if ((sword_t)src1 <  (sword_t)src2) s->dnpc = s->pc + imm);
INSTPAT("??????? ????? ????? 101 ????? 1100011", "bge",  B,
        if ((sword_t)src1 >= (sword_t)src2) s->dnpc = s->pc + imm);
INSTPAT("??????? ????? ????? 110 ????? 1100011", "bltu", B,
        if (src1 <  src2) s->dnpc = s->pc + imm);
INSTPAT("??????? ????? ????? 111 ????? 1100011", "bgeu", B,
        if (src1 >= src2) s->dnpc = s->pc + imm);

s->dnpc 的”静默默认”在这里闪光exec_once 开头已经把 s->dnpc = s->snpc。分支指令只在条件成立时覆盖,条件不成立什么都不用做——fall-through 是免费的。这就是 §3.2 铺垫的 refined 设计的回报。

blt/bge(sword_t)有符号比较;bltu/bgeu 不强转是无符号。spec 明确区分。

7.4 跳转:JAL 和 JALR

1
2
3
4
5
6
7
8
9
10
INSTPAT("??????? ????? ????? ??? ????? 1101111", "jal",  J, {
    word_t link = s->snpc;          /* ① 先存 link(= pc + 4)*/
    s->dnpc = s->pc + imm;           /* ② 再改 dnpc */
    R(rd) = link;                    /* ③ 写 link 寄存器 */
});
INSTPAT("??????? ????? ????? 000 ????? 1100111", "jalr", I, {
    word_t link = s->snpc;
    s->dnpc = (src1 + imm) & ~(word_t)1;   /* 强制低位清零 */
    R(rd) = link;
});

两处subtle but critical

  1. link = s->snpc,不是 s->pc:链接寄存器(通常 ra = x1)应该指向跳转指令的下一条,不是跳转本身。写错的话函数 ret 返回到 jal 上,无限递归。
  2. & ~(word_t)1jalr 目标强制 2 字节对齐。spec 要求的——为了兼容未来压缩指令(C extension 允许 2 字节指令)。即使纯 RV32I 我们也按 spec 写,签合同要守字条。

先存 link 再改 dnpc显式的写入顺序——即使今天无所谓,这种”捕获所有输入后再改输出”的模式是状态机代码的好习惯。

调用-返回示意

1
2
3
4
5
0x80000000  jal   ra, fib        ; ra = 0x80000004, pc = fib
0x80000004  addi  a0, a0, 1      ; 返回点
...
fib:        ...
            jalr  x0, ra, 0       ; pc = ra = 0x80000004, rd=x0 所以 link 丢弃

jalr x0, ra, 0 就是”return”——跳到 ra,不需要链接(rd=x0 丢弃)。

7.5 Load / Store(8 条)

Load(5 条,从内存读到寄存器):

1
2
3
4
5
6
7
8
9
10
INSTPAT("??????? ????? ????? 000 ????? 0000011", "lb",  I,
        R(rd) = SEXT(paddr_read(src1 + imm, 1),  8));
INSTPAT("??????? ????? ????? 001 ????? 0000011", "lh",  I,
        R(rd) = SEXT(paddr_read(src1 + imm, 2), 16));
INSTPAT("??????? ????? ????? 010 ????? 0000011", "lw",  I,
        R(rd) = paddr_read(src1 + imm, 4));
INSTPAT("??????? ????? ????? 100 ????? 0000011", "lbu", I,
        R(rd) = paddr_read(src1 + imm, 1));
INSTPAT("??????? ????? ????? 101 ????? 0000011", "lhu", I,
        R(rd) = paddr_read(src1 + imm, 2));

三处关键:

  • lb / lh 符号扩展——读一字节 0xFF 应得 0xFFFFFFFF(-1),而不是 0x000000FF
  • lbu / lhu 零扩展——P2 里 paddr_read 内部 word_t ret = 0 自动零扩展高位。P2 埋的伏笔在这里兑现:无符号 load 不用任何额外代码
  • lw 不需要扩展——已经是 32 位

Store(3 条,从寄存器写进内存):

1
2
3
4
5
6
INSTPAT("??????? ????? ????? 000 ????? 0100011", "sb", S,
        paddr_write(src1 + imm, 1, src2));
INSTPAT("??????? ????? ????? 001 ????? 0100011", "sh", S,
        paddr_write(src1 + imm, 2, src2));
INSTPAT("??????? ????? ????? 010 ????? 0100011", "sw", S,
        paddr_write(src1 + imm, 4, src2));

S-type 编码:rs2 是数据源(要写的值),rs1 是基址。立即数拆成两段 [31:25][11:7]——immS 宏负责拼回来。

没有 lwu / sw 不需要变种——lwu 在 RV64 里才有(32→64 的零扩展),RV32 的 lw 已经是全宽度。

7.6 FENCE / EBREAK / 非法指令

1
2
3
4
5
6
7
8
9
10
11
/* FENCE 和 FENCE.I:内存屏障,我们的模拟器是顺序一致的,NOP */
INSTPAT("??????? ????? ????? 000 ????? 0001111", "fence",   I, (void)0);
INSTPAT("??????? ????? ????? 001 ????? 0001111", "fence.i", I, (void)0);

/* EBREAK:简化版——halt 模式下直接结束,halt_ret = a0 */
INSTPAT("0000000 00001 00000 000 00000 1110011", "ebreak", N,
        temu_set_end(s->pc, R(10)));

/* 兜底:任何上面没匹配的指令 */
INSTPAT("??????? ????? ????? ??? ????? ???????", "invalid", N,
        invalid_inst(s));

FENCE:真硬件上 FENCE 保证内存操作的可见性次序(多核场景);FENCE.I 保证 “写指令内存” 和 “取指” 的次序(自修改代码)。我们的模拟器单线程 + 每次取指都从 pmem 新读,两件事天然满足——NOP 合法。

EBREAK:用于测试退出。调用约定:把返回值放 a0 = x10,执行 ebreaktemu_set_end 打印 HIT END halt_ret=0x... 并让 cpu_exec 循环停下。完整的 EBREAK 语义(作为 BREAKPOINT 异常陷入)要等 P6a。

invalid_inst:打印红字错误,设 TEMU_ABORT 状态——不 panic,这样 P1 的调试器还能被用来检查寄存器。


8. x0 硬连线:分层的兑现

P2 承诺过:“x0 硬连线为 0 是 ISA 语义,不是 C 数据结构的职责——P3 的指令执行路径处理”

8.1 两种实现的对比

v1 naive:每个写入点都 guard

1
2
3
4
/* 不要这样 */
if (rd != 0) R(rd) = src1 + imm;    /* ADDI */
if (rd != 0) R(rd) = src1 + src2;   /* ADD */
/* ... 37 条指令里 30+ 条有 rd 写入 ... */

v2 refined:循环末尾一次清零

1
2
3
4
// src/cpu/cpu_exec.c
isa_exec_once(&s);              /* 指令随便写 R(0) */
if (g.state == TEMU_RUNNING) cpu.pc = s.dnpc;
cpu.gpr[0] = 0;                 /* 一行覆盖所有 "写 x0" 的企图 */

8.2 为什么 v2 赢

  • 一行 vs 37 处——维护成本相差 30 倍
  • INSTPAT body 保持 clean——指令语义不被”特殊 case”污染
  • 没有”写 x0 的中间可观测状态”isa_exec_once 返回前后没有别的代码跑,gpr[0] = 0 立刻生效,外部不可见

工程原则不变量应该在循环边界实现,不在每个调用点。这条原则和 P2 的 “显式 cpu_init 替代隐式零初始化” 是同一个品味——控制流的”接缝处” 是放置不变量的正确位置。


9. Itrace:第一个真实的调试工具

P2 讲过”itrace 还没数据”。P3 的 exec_once 跑起来,ring buffer 就有东西可写了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/cpu/cpu_exec.c
#define ITRACE_SIZE     16
#define ITRACE_DISASM   48

static struct {
    vaddr_t  pc;
    uint32_t inst;
    char     disasm[ITRACE_DISASM];
} itrace_ring[ITRACE_SIZE];
static uint64_t itrace_head = 0;

static void itrace_record(vaddr_t pc, uint32_t inst) {
    size_t idx = (size_t)(itrace_head % ITRACE_SIZE);
    itrace_ring[idx].pc   = pc;
    itrace_ring[idx].inst = inst;
    disasm(itrace_ring[idx].disasm, ITRACE_DISASM, &g_last_disasm);
    itrace_head++;
}

Ring buffer 模式:head % SIZE 做下标,旧条目被新的默默覆盖。head 单调递增——”现有多少条” = min(head, SIZE)

触发条件cpu_exec 异常结束(invalid inst、watchpoint、ebreak abort)时 dump 最近 16 条:

1
2
3
4
5
--- itrace (last 16 instructions) ---
  0x80000000:  00500513  addi    a0, zero, 5
  0x80000004:  00a00613  addi    a2, zero, 10
  0x80000008:  00c50733  add     a4, a0, a2
  ...

g_last_disasm 是一个全局——INSTPAT 宏里每次匹配都会覆盖写它,记录最近一条指令的译码信息。itrace 把 g_last_disasm 喂给 disasm() 格式化输出。

Itrace 大小是 tunable 的——Medium 练习会让你通过命令行改它。


10. 跑真程序:fib 和 sum

到了兑现的时刻。我们手写一个 fib(10),不调用 gcc,就用 isa-encoder.h 里的编码宏:

1
2
3
4
5
6
// tests/isa/isa-encoder.h 片段
#define ADDI(rd, rs1, imm)   itype(imm, rs1, 0x0, rd, 0x13)
#define ADD(rd, rs1, rs2)    rtype(0x00, rs2, rs1, 0x0, rd, 0x33)
#define BEQ(rs1, rs2, imm)   btype(imm, rs2, rs1, 0x0, 0x63)
#define JAL(rd, imm)         jtype(imm, rd, 0x6f)
/* ... */

iterative fib(10)

1
2
3
4
5
6
7
8
9
10
11
RUN("iterative fib(10) = 55", 55,
    ADDI(A0, ZERO, 10),              /* n = 10             */
    ADDI(T0, ZERO, 0),               /* a = 0              */
    ADDI(T1, ZERO, 1),               /* b = 1              */
    BEQ(A0, ZERO, 24),               /* loop: if n==0 done */
    ADD(T2, T0, T1),                 /*   t = a + b        */
    ADD(T0, ZERO, T1),               /*   a = b            */
    ADD(T1, ZERO, T2),               /*   b = t            */
    ADDI(A0, A0, -1),                /*   n--              */
    JAL(ZERO, -20),                  /*   goto loop        */
    ADD(A0, ZERO, T0));              /* done: a0 = a (ret) */

测试框架拼装成二进制 → 喂给 ./build/temu -b → 检查 halt_rethalt_ret = 55 就通过。

里程碑你的 CPU 现在能算斐波那契。四周前它还不存在——P0 搭了壳,P1 装了调试器,P2 分配了状态,P3 让它会动。你可以 si 1 单步看 $a0 从 10 慢慢减到 0;可以 w $t1 看 b 每一轮被更新;可以 x 10 $pc 看自己刚编码的指令。

sum(1..10) 更短:

1
2
3
4
5
6
RUN("sum(1..10) = 55", 55,
    ADDI(T0, ZERO, 10),              /* i = 10 */
    ADDI(A0, ZERO, 0),                /* s = 0  */
    ADD(A0, A0, T0),                  /* loop: s += i */
    ADDI(T0, T0, -1),
    BNE(T0, ZERO, -8));               /* if i != 0, back to loop */

5 条指令搞定 1-10 求和。halt_ret = 55


11. 理论视角:RISC vs CISC——1980 到 2025

我们花了大量篇幅在 RV32I 上。作为章节的历史收尾,值得看看为什么是 RISC

11.1 1980 年的起点

Patterson 和 Ditzel 1980 年在 ACM Computer Architecture News 发表 The Case for the Reduced Instruction Set Computer——现代 RISC 运动的起点。核心论点:

  1. 复杂指令很少被用到——编译器 emit 的常用指令就十几条
  2. 简单指令更容易做快(流水线友好、固定长度并行译码)
  3. 省下的译码硅面积可以换更多寄存器、更大 cache、更高频率
  4. 固定长度 → 并行译码 → 更高 IPC(instructions per cycle)

当时的 CISC 代表(VAX)有一条 POLY 指令:按给定系数表求多项式的值。写在 ISA 里,用它的编译器几乎没有。设计一条几百万晶体管都没人用的指令——这就是 Patterson & Ditzel 的”Case”。

11.2 然后发生了什么

年代RISC 代表CISC 代表裁判
1985MIPS, SPARCx86, VAX工作站市场偏 RISC
1995PowerPC, AlphaPentium Pro桌面 x86 赢,服务器分庭抗礼
2005ARM 移动端起势x86-64Intel “CISC 前端 + RISC 后端”(P6 微架构起)
2015ARM 移动x86 桌面+服务器两极化
2025ARM + RISC-V 反攻x86 市占下降Apple Silicon / AWS Graviton / RISC-V 数据中心起

关键转折:1995 年后的 x86 内部就是 RISC 了。Pentium Pro(P6 微架构)首创”x86 前端译码成 μop(micro-operation),RISC-like 后端执行 μop”——所谓 CISC vs RISC 的战争在芯片内部早就结束了,外部 ISA 兼容性只是用户可见的一层皮。

ARM 自己也承认了:AArch64(2011 发布)几乎抄了 RISC 的每条规则——固定 32 位指令、31 个 GPR、去掉条件执行、PC 独立出 GPR。RISC 赢得如此彻底,以至于”CISC” 这个词不再有人严肃使用

11.3 对你意味着什么

你刚刚在这章里用约 200 行 C 写了 37 条完整指令。写一个 x86 base ISA 模拟器(甚至不考虑 SSE/AVX)要几千行,原因:

  • 变长编码(1-15 字节)
  • 6 种可选 prefix(REX、操作数大小覆盖、段覆盖…)
  • ModR/M 字节 + SIB 字节 + 8/16/32/64-bit 立即数的所有组合
  • 几十条为 8086 兼容性保留的老指令

ISA 复杂度是一个税——每个实现都要交。今天 Apple / Google / AWS / SiFive 选 RISC-derived 架构,不是因为 RISC “更正确”,是因为“不需要交那个税”

核心洞察“RISC vs CISC” 今天不再是技术辩论,是一种文化差异。你本章写的每一行都站在 RISC 这一边——不是因为它”赢了”,而是因为它让你能在一章里写完


12. 踩坑清单

  1. PC 更新放 INSTPAT body 里——用 s->dnpc,循环负责写回 cpu.pc
  2. x0 硬连线在循环末尾——cpu.gpr[0] = 0,不在每条 INSTPAT 里 guard
  3. 模式顺序——SUB 必须在 ADD 前(funct7 区分);invalid catch-all 必须最后
  4. JAL link = snpc 不是 pc——否则 ret 返回到跳转自身,死循环
  5. lb/lh 符号扩展 vs lbu/lhu 零扩展——混了就是负字节变成巨大正数
  6. B-type 偏移是字节,不是指令数——BEQ(..., 8) 跳 2 条指令
  7. B-type 和 J-type 的 LSB 固定 0——spec 不编码 bit 0,因为地址 2 字节对齐
  8. SRAI / SRA 要 (sword_t) 强转——否则 C 按逻辑右移处理
  9. 移位量 mask 低 5 位——RV32 硬件只看低 5 位,C 的 UB 行为不可靠
  10. JALR 目标 & ~1——spec 要求,兼容未来压缩指令
  11. Invalid 指令要 temu_set_abort 不要 panic——让 P1 调试器仍可检查
  12. EBREAK 编码:rs1=0, rd=0, imm=1(vs ECALL 的 imm=0)

13. 动手练习

Easy 1 · 伪指令反汇编

disasm.c 里加规则:ADDI rd, zero, imm 打印成 li rd, immADDI rd, rs1, 0 打印成 mv rd, rs1JAL x0, offset 打印成 j offset只改显示,不动执行

学到:伪指令是汇编器/反汇编器的约定,不是 ISA 的一部分。

Easy 2 · --itrace=N 配置 ring buffer 大小

命令行选项让你指定 ring buffer 大小,默认 16。需要从 static 数组改成 malloc

学到:CLI 配置 + 动态分配替代静态数组的模式。

Medium 1 · info p 命令

新增 REPL 命令 info p 打印 g_last_disasm 的所有字段(name/type/inst/pc/rd/rs1/rs2/imm)。整合进 P1 的 info r / info w 家族。

学到:调试即特性;复用已有的 struct;P0 表驱动命令分派的模式。

Medium 2 · si N 单步时打印反汇编

cmd_si 让每步打印 pc + disasm。单纯改输出,不动 CPU 逻辑。

学到:执行和追踪的交织;什么时机调 disasm()(要在 exec_once 之后,因为 g_last_disasm 是它填的)。

Medium 3 · 手写 strlen 汇编测试

0x80001000 预置字符串(用 SB 一字节一字节写),然后循环 LB + BEQ 计数直到遇到 \0,结果放 a0。

学到:循环 + 访存的组合;真正的”程序”是这些小指令堆出来的。

Hard 1 · 实现 M 扩展(乘除)

加 8 条 INSTPAT:MUL, MULH, MULHU, MULHSU, DIV, DIVU, REM, REMU。

学到

  • MULH 要 (int64_t)(int32_t)rs1 * (int64_t)(int32_t)rs2 >> 32——64 位中间结果
  • RISC-V 的除零不陷入异常,返回 -1(DIV)或 rs1(REM)——spec 的意外决定,要按 spec 写
  • 签名/无签名的三种乘法为什么都要:编译器做 64 位乘法时要用到

Hard 2 · 独立反汇编工具 ./temu-disasm image.bin

复用 pattern_match + decode_operand + disasm()——不跑 CPU,只打印。

学到:译码路径可以独立于执行复用;这正是 objdump 的核心。

Hard 3 · 递归 fib(6) = 8

用正确的调用约定:sp 作为栈指针、ra 保存返回地址、栈帧保存/恢复。

学到:栈纪律、寄存器保存、函数调用如何由 jump + load + store 组合而成。这是”理解 C 调用”的必经之路。


14. 本章小结

你应该能做到

  • 画出 RV32I 六种编码格式的字段布局
  • 写出 SEXT 的左移-算术右移实现并解释原理
  • 写出 immB 的位重组(B-type 立即数的拆分规则)
  • 解释 pattern_match?/0/1 如何变成 (inst & mask) == key
  • 解释为什么 PC 更新走 Decode{snpc, dnpc} 而不是 cpu.pc += 4
  • 写出 JAL 的 link 捕获顺序(为什么先存 link 再改 dnpc
  • isa-encoder.h 手写一个 fib(10) 的测试
  • 解释 x0 硬连线为什么在 cpu_exec 循环末尾实现

你应该能解释

  • ISA 为什么是”契约”而不是”实现”——举 Intel time machine 为例
  • 为什么 B-type 立即数在指令字里是”乱的”——关联到硅面积和 PLA 并行性
  • 为什么我们的模式匹配和硬件 PLA 是同一抽象的两种实现
  • RISC vs CISC 辩论 1980 的起点、今天的现状、你选 RV32I 的原因
  • 为什么 lb 符号扩展、lbu 零扩展——以及 P2 的 word_t ret = 0 怎么让零扩展免费
  • 为什么分支指令的 dnpc 只在条件成立时写入

15. 延伸阅读

  • RISC-V Unprivileged ISA Spec, Chapters 2 & 19 — base ISA + instruction listing。Chapter 2 是本章的”圣经”,15 页可以一口气读完
  • Patterson & Ditzel · The Case for the RISC(ACM CAN 1980) — 现代 RISC 的起点,8 页,今天读仍然 fresh
  • Hennessy & Patterson · Computer Architecture: A Quantitative Approach, Appendix A — ISA 设计准则的形式化讨论
  • Hennessy & Patterson · Computer Organization and Design: RISC-V Edition, Chapter 2 — 同两位作者写给本科生的版本,和我们的实现并行阅读
  • Shen & Lipasti · Modern Processor Design, Chapter 4 — 真 CPU 的译码流水线;PLA vs 微码 ROM 的 tradeoff
  • riscv-isa-manual on GitHub — spec 的 LaTeX 源码。“Base Integer Instruction Set, RV32I” 只有 15 页
  • Jim Keller 的访谈(Lex Fridman / Anandtech) — Alpha / K7/K8 / Apple A4-A5 / Zen / 现在的 RISC-V 首席设计师。听他讲”为什么 ISA 不如微架构重要”,作为对本章”RISC 万岁”的平衡

与后续章节的连接

下一章本章埋下的伏笔
P4 差分测试isa_exec_once 是对拍单位;g_last_disasm 直接喂 difftest 的错误报告
P5 I/O 设备lw/sw 路径自动覆盖 MMIO 地址——P2 留的 mmio_access stub 填实
P6a 异常 + CSRINSTPAT 表的 1110011 opcode 槽位预留给 ECALL / EBREAK-trap / MRET / CSRR*;trap_pending hook 已经在 exec_once 末尾
P6b 虚拟内存Decode.pc 是 vaddr;paddr_read 上方加一层 vaddr_read,取指和访存都走翻译
全书temu_set_end(s->pc, R(10)) 把 a0 吐到 host 的 exit code——是未来 “RV32I 上跑真程序” 的交接点

本章之后 CPU 是”真的 CPU”了。P4 开始我们会怀疑自己写错——差分测试的主题就是怎么证明自己对。

下一站:P4——让另一个 CPU 来检查我们的 CPU。

This post is licensed under CC BY 4.0 by the author.