P4 · 差分测试:让另一个 CPU 检查我们的 CPU
本章目标
读完本章你能回答:
- 37 条指令单元测试全绿,你为什么仍然不能相信它们都对?
- 怎样给一个完整的 CPU 找一个 “oracle”?
- 为什么 TEMU 要手写两套 RV32I 实现,而不是直接用 Spike?
- MMIO 写入(
putchar、timer 读取)怎么在对拍里处理,才不会双倍副作用? - Correlated failure 是什么——差分测试最漂亮的失败方式?
动手做到:
- 读完两套风格完全不同的 RV32I decoder——一套表驱动(
INSTPAT),一套嵌套 switch - 给任一
INSTPAT指令注入 bug,观察difftest在第一条错误指令立刻定位 - 把
paddr_touched_mmio从 P2 的”声明”推到 P4 的”使用” - 把手写 oracle 换成 Spike 做三方对拍(Hard 练习)
本章不涉及:
- Cycle-accurate lockstep(流水线 / 缓存级别对拍)——那是硬件验证
- MMIO 设备级 diff——P5 领地
- 虚拟地址空间的 diff——P6b 领地
- CSR / trap 的 diff 实现细节——P6a 专属;本章只预留接口
1. 问题动机:单元测试的上限
P3 结束时你跑的是:
1
2
3
4
$ make test-isa
isa tests: 64 passed, 0 failed
$ make test-prog
program tests: 6 passed, 0 failed
所有 37 条指令都走过至少一个单元测试。你有理由觉得 “CPU 是对的”。
但设想这个场景:你写 immB 时不小心把两个 bit 位置写反了——BITS(i, 11, 8) 和 BITS(i, 7, 7) 换了位置。beq 的分支偏移在某些 imm 值下算错 4 字节。单元测试挂吗?
不一定。isa.c 的 test_branches 测的是 BEQ(ZERO, ZERO, 12)、BNE(..., 12) 等有限几个偏移。如果你换错的那两个 bit 在偏移 12 里都是 0,测试全绿、实现有 bug。
这就是单元测试的根本上限:它覆盖你想到的 case,不覆盖你没想到的 case。可 spec 有 $2^{32}$ 种可能的指令,你想得过来吗?
核心洞察:写 CPU 的人和测 CPU 的人是同一个脑子——你想不到用
BEQ偏移0x55A当测试向量,是因为你写immB时也没考虑过这个边界。两个任务失败在同一个盲点。单元测试的价值上限就是作者的想象力。
P1 §7.7 已经给出了这个问题的理论名字:Oracle problem。答案:找一个独立的计算源做参考。P1 用的是宿主 cc 当表达式求值器的 oracle。本章把尺度从”表达式”抬到”整个 CPU”——给整台 guest 机器找 oracle。
2. 核心思路:两套实现 + lockstep
差分测试(differential testing,McKeeman 1998)的定义只有三件事:
- 两套独立实现——对同一份 spec
- 相同输入——喂给两边
- 按步比较——每一步都对齐,不是只比最终状态
“按步”很关键。如果你让两个 CPU 各跑 10 万条指令,最后比寄存器,看到的只能是 “pc 飞到某个莫名其妙的地方”——定位到第 47 条指令出问题需要二分 + 大量工具。lockstep 的做法:每条指令后立刻比对,第一次分歧就 abort,分歧报告精确到那一条指令的反汇编。
2.1 TEMU 的 difftest API
1
2
3
4
5
6
7
8
9
10
11
12
// include/difftest.h
void difftest_enable(bool on);
bool difftest_is_enabled(void);
/* 初始化参考 CPU 状态,和主 CPU 对齐。cpu_init() 之后、cpu_exec() 之前调一次。*/
void difftest_init(void);
/* cpu_exec 每执行完一条主 CPU 指令后调用:
* 1. 在参考 CPU 跑同一条指令
* 2. 比较 GPR / PC / CSR
* 3. 分歧时打 diff、abort */
void difftest_step(void);
三个接口、不到 30 行头文件——整个差分测试系统的对外边界。
2.2 pseudo-oracle 的词汇
P1 §7.7 给过 Weyuker 1982 的词:pseudo-oracle ——当”正确答案”本身难算时,用”另一个计算”冒充 oracle。差分测试里,两套实现互为pseudo-oracle。谁是”主”、谁是”参考”是实现选择,理论上对称。
TEMU 里我们把 INSTPAT 风格那套叫”主”,switch-dispatch 那套叫”ref”。一个历史原因:主的先写、ref 后加;一个实用原因:主的跑 batch 模式、ref 只在 -d 开关打开时介入。
3. ref_exec_one:第二套风格的 decoder
把 TEMU 的参考实现 src/difftest/difftest.c 摆在你面前。同一份 RV32I spec,完全不同的代码形状。
3.1 顶层骨架:opcode 分派
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// src/difftest/difftest.c (简化展示)
static void ref_exec_one(void) {
if (ref_halted || ref_aborted) return;
uint32_t pc_cur = ref_cpu.pc;
uint32_t pc_next = pc_cur + 4;
uint32_t inst = (uint32_t)paddr_read(pc_cur, 4);
uint32_t op = (inst >> 0) & 0x7f;
uint32_t rd = (inst >> 7) & 0x1f;
uint32_t f3 = (inst >> 12) & 0x07;
uint32_t rs1 = (inst >> 15) & 0x1f;
uint32_t rs2 = (inst >> 20) & 0x1f;
uint32_t f7 = (inst >> 25) & 0x7f;
switch (op) {
case 0x37: /* LUI */ G(rd) = (uint32_t)imm_u(inst); break;
case 0x17: /* AUIPC */ G(rd) = pc_cur + (uint32_t)imm_u(inst); break;
case 0x13: /* OP-IMM */ /* ... 嵌套 switch on f3 ... */ break;
case 0x33: /* OP */ /* ... 嵌套 switch on f3, f7 ... */ break;
case 0x63: /* BRANCH */ /* ... */ break;
case 0x03: /* LOAD */ /* ... */ break;
case 0x23: /* STORE */ /* ... */ break;
case 0x6f: /* JAL */ /* ... */ break;
case 0x67: /* JALR */ /* ... */ break;
case 0x0f: /* FENCE */ /* NOP */ break;
case 0x73: /* SYSTEM */ /* ... ecall/mret/csr ... */ break;
default: ref_invalid(pc_cur, inst); return;
}
ref_cpu.pc = pc_next;
ref_cpu.gpr[0] = 0; /* 同样的 x0 硬连线 */
}
先看形状:一层大 switch 按 opcode 展开,LUI / AUIPC 这种独苗一行搞定,其余分支跳进嵌套 switch 继续分派。这和 P3 §6 讲过的 naive v1——”嵌套 switch 方案”——几乎就是那个反面教材。
但这里它不是反面教材。它是故意的风格选择——给 INSTPAT 当对照组。
3.2 OP-IMM 嵌套:9 条 I 型指令
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
case 0x13: { /* OP-IMM */
uint32_t a = G(rs1);
int32_t sign_i = imm_i(inst);
uint32_t uns_i = (uint32_t)sign_i;
switch (f3) {
case 0x0: G(rd) = a + uns_i; break; /* ADDI */
case 0x2: G(rd) = ((int32_t)a < sign_i) ? 1 : 0; break; /* SLTI */
case 0x3: G(rd) = (a < uns_i) ? 1 : 0; break; /* SLTIU */
case 0x4: G(rd) = a ^ uns_i; break; /* XORI */
case 0x6: G(rd) = a | uns_i; break; /* ORI */
case 0x7: G(rd) = a & uns_i; break; /* ANDI */
case 0x1: G(rd) = a << (uns_i & 0x1f); break; /* SLLI */
case 0x5:
if (f7 == 0x00) G(rd) = a >> (uns_i & 0x1f); /* SRLI */
else if (f7 == 0x20) G(rd) = (uint32_t)((int32_t)a >> (uns_i & 0x1f));
else { ref_invalid(pc_cur, inst); return; /* SRAI */ }
break;
default: ref_invalid(pc_cur, inst); return;
}
break;
}
把这 17 行和 P3 §7.2 的九条 INSTPAT 对比。一模一样的 9 条指令,两种讲法:
| 维度 | INSTPAT(主路径) | switch(ref 路径) |
|---|---|---|
| 控制流 | 顺序扫描模式表 | 嵌套 switch 分派 |
| 立即数 | immI(i) 宏展开 | imm_i(inst) 手写函数 |
| 位工具 | BITS / SEXT 宏 | 内联的 >> & | + 单独的 sext32 函数 |
| 加指令 | 加一行 INSTPAT(...) | 加一行 case 0x??: |
3.3 立即数的独立重写
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/difftest/difftest.c (片段)
static inline uint32_t sext32(uint32_t v, int width) {
uint32_t sign_bit = 1u << (width - 1);
uint32_t mask = ((uint32_t)-1) << width;
return (v & sign_bit) ? (v | mask) : v;
}
static inline int32_t imm_i(uint32_t inst) {
return (int32_t)sext32((inst >> 20) & 0xfff, 12);
}
static inline int32_t imm_b(uint32_t inst) {
uint32_t raw =
(((inst >> 31) & 0x1) << 12) |
(((inst >> 7) & 0x1) << 11) |
(((inst >> 25) & 0x3f) << 5) |
(((inst >> 8) & 0xf) << 1);
return (int32_t)sext32(raw, 13);
}
刻意和 P3 的 immB / SEXT 不共享代码。原因:
immB写错一位,主路径全部 B 型指令受影响imm_b也写错同一位,对拍不报分歧——bug 溜过去
唯一逃生方式:两边各自独立把 spec 翻成代码。同一个 bit,两份代码都得对,或者两份代码错得不一样(那对拍会抓)。
核心洞察:oracle 的价值等于代码路径独立度。共享宏省下来的代码量,换来的是”两份实现同错”时 oracle 静默失效。DRY 原则对功能代码适用,对 oracle 不适用——这里,写两遍是特性不是缺陷。
4. 编排:init / step / compare
4.1 difftest_init——一次对齐
1
2
3
4
5
6
7
8
// src/difftest/difftest.c
void difftest_init(void) {
memcpy(&ref_cpu, &cpu, sizeof(CPU_state));
memcpy(&ref_csr, &csr, sizeof(CSR_state)); /* P6a 的 CSR 也同步 */
ref_halted = false;
ref_aborted = false;
Log("difftest: initialized — ref pc=0x%08" PRIx32, ref_cpu.pc);
}
memcpy 一把搞定——P2 定义的 CPU_state 是个单纯的值类型 struct(没有指针),浅拷贝就是全拷贝。这是 P2 cpu_init 设计的红利——状态是 POD 的,所以同步一次初态就像复制一个整数。
为什么不拷 pmem? 两边共享同一块 pmem。load_img 只调一次,主 CPU 和参考 CPU 看到的是同一份字节。
4.2 difftest_step 骨架
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// src/difftest/difftest.c (简化:省略 MMIO 和 CSR)
void difftest_step(void) {
if (!enabled) return;
ref_exec_one(); /* 参考 CPU 走一步 */
bool mismatch = false;
for (int i = 0; i < 32; i++) { /* 比 GPR */
if (cpu.gpr[i] != ref_cpu.gpr[i]) {
fprintf(stderr, " gpr[%2d] (%-4s): mine=0x%08" PRIx32
" ref=0x%08" PRIx32 "\n",
i, reg_name(i), cpu.gpr[i], ref_cpu.gpr[i]);
mismatch = true;
}
}
if (cpu.pc != ref_cpu.pc) { /* 比 PC */
fprintf(stderr, " pc : mine=0x%08" PRIx32
" ref=0x%08" PRIx32 "\n",
cpu.pc, ref_cpu.pc);
mismatch = true;
}
if (mismatch) {
fprintf(stderr, "\33[1;31mdifftest: CPU state diverged from reference\33[0m\n");
temu_set_abort();
}
}
核心就这么多。ref_exec_one() 内部更新 ref_cpu;对比阶段逐字段对 ref_cpu vs cpu;任何不等就 abort。
4.3 主循环 hook
P3 的 exec_once 在末尾多了一行:
1
2
3
4
5
6
7
8
// src/cpu/cpu_exec.c (片段)
static void exec_once(void) {
/* ... isa_exec_once + pc 更新 + gpr[0]=0 + trap commit ... */
if (difftest_is_enabled() && g.state != TEMU_ABORT) {
difftest_step();
}
}
一行接入——difftest_is_enabled() 是一个 flag 查询,关着的时候几乎零开销。主循环的形状不变,差分测试像一个可拔插的显微镜挂在循环上。
4.4 Naive vs refined #1:让 ref 影响 main 吗?
读者第一反应可能是:”参考 CPU 跑得对,把它的结果覆盖回主 CPU 不就好了?”
1
2
3
4
5
6
7
/* v1 朴素版:参考 CPU 把主 CPU 改对 */
void difftest_step_v1(void) {
ref_exec_one();
if (cpu.gpr_bad_somewhere) {
memcpy(&cpu, &ref_cpu, sizeof(CPU_state)); /* "修正"主 CPU */
}
}
为什么这是错的:
- oracle 的职责是观察,不是修正。一旦 oracle 把主 CPU 改对,你就看不到 bug 了——对拍退化成”正确答案的来源”
- 测试的意义消失——你永远不知道主 CPU 错了,因为它的错被默默修好
- 下游继续执行时,基于”已修正”的状态,后续指令的 bug 也被掩盖
v2 正式:参考 CPU 独立前进,只在比较阶段读主 CPU,从不写:
1
2
3
4
void difftest_step_v2(void) {
ref_exec_one();
/* 只读、只报、只 abort */
}
工程原则:oracle 无副作用地观察被测对象。一旦 oracle 能修改被测对象,它就不是 oracle 了,是”备份”。备份是 P6b 的话题,不是 P4 的。
5. 共享 pmem:一个诚实的 trade-off
difftest.c 顶部有一段注释,值得原样贴出来:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*
* difftest.c — second-opinion RV32I executor for differential testing.
*
* The main implementation in src/isa/riscv32/inst.c uses an INSTPAT
* pattern-match table. This file implements the same ISA with a
* deliberately different structure: a nested switch on
* opcode/funct3/funct7, inline bit extraction, hand-written immediate
* assembly. Any bug that manifests only in one of the two code styles
* will cause the CPU states to diverge and be caught by
* difftest_step() at the first offending instruction.
*
* The reference CPU shares pmem with the main CPU — a deliberate
* trade-off (store bugs that both implementations agree on are
* invisible), chosen so the difftest path does not double memory
* usage. In practice a wrong store nearly always manifests as a
* downstream register divergence, which we do catch.
*/
作者把设计思考留在代码里——这是好系统软件的标志。我们从这段注释提炼三件事:
- 共享 pmem 的代价:两边对 store 的实现都错且错法一致时,参考 CPU 的 store 覆盖了主 CPU 的 store,两边 pmem 一样,对拍看不见。
- 共享 pmem 的收益:不翻倍 128 MB 内存;load/store 路径不需要维护两份相同内容
- 经验依据:wrong store 几乎总会在几条指令内被 load 回到某个寄存器——此时寄存器对拍会把这个 bug 显形。共享 pmem 不是”堵不住的洞”,是”洞在三步内会被另一个入口抓住”
工程原则:trade-off 不隐藏——显式写在注释里,让后来者知道这是选择,不是疏漏。
6. MMIO 快照:副作用的出口
差分测试唯一真正棘手的情况是有副作用的指令:
- 写 UART MMIO →
putchar()真的打字到 stdout - 读 timer MMIO → 返回墙钟时间,两次读值不同
如果让参考 CPU 也执行:
- 写 UART:stdout 打印双倍字符
- 读 timer:主 CPU 和参考 CPU 读到不同墙钟值,对拍立刻报假阳
6.1 解法:paddr_touched_mmio
P2 §3.4 里我们在 paddr_read 中埋了一行:
1
2
3
4
5
6
7
// src/memory/pmem.c
if (mmio_in_range(addr)) {
word_t data = 0;
mmio_access(addr, len, false, &data);
paddr_touched_mmio = true; /* ← 这里 */
return data;
}
P4 终于用到它了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/difftest/difftest.c
void difftest_step(void) {
if (!enabled) return;
if (paddr_touched_mmio) {
/* 主 CPU 刚做了 MMIO:不要让 ref 重放副作用,
* 而是把主 CPU 的最终状态快照到 ref,从下一条指令继续 lockstep。*/
memcpy(&ref_cpu, &cpu, sizeof(CPU_state));
memcpy(&ref_csr, &csr, sizeof(CSR_state));
paddr_touched_mmio = false;
return; /* 跳过本步比较 */
}
ref_exec_one();
/* ... normal compare ... */
}
时序看起来是这样:
1
2
3
4
5
6
7
8
main CPU: [ADDI] [SW uart] [ADDI] [ADDI] ...
│ │ │ │
main pmem write──────────────────→putchar('h')
│ │ │ │
paddr_touched_mmio: F T─────清零 F F
│ │ │ │
difftest_step: ref_exec snapshot ref_exec ref_exec
+ compare + skip + compare + compare
6.2 Naive vs refined #2:谁该执行 MMIO
| v1 朴素 | v2 正式 |
|---|---|
让 ref 也执行 SW uart → putchar 被调两次 | 主 CPU 执行一次 MMIO;ref 跳过这一步,从主 CPU 快照拿到后续状态 |
LW timer 两次读值不同 → 对拍瞬间报假阳 | ref 不读 timer,直接用主 CPU 读到的值继续 lockstep |
v2 的代价是诚实承认的已知盲点:这条 MMIO 指令本身的正确性不被对拍审查。
缓解:
- MMIO 指令是 Load/Store 的子集——LB/LH/LW/SB/SH/SW 的主要逻辑(地址算、对齐、大小)已经在普通 pmem 访问里被对拍覆盖
- MMIO 设备自身的对拍 = 另一个层次的问题,留给 P5
核心洞察:副作用的处理不是”两边都做”,是承认这一步不可测,换同步来保下一步。这是snapshot 模式在差分测试里的身影——同一模式出现在 QEMU VM snapshot、SQLite savepoint、AFL 的 fork server 里,都是”不可逆操作边界上的同步锚点”。
7. CSR 与中断:P6a 的预留口
P3 还没有 CSR。但 P4 的框架在这里就要给 CSR 留位,否则 P6a 要推倒重来。
7.1 CSR 比对(P6a 之后才激活)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/difftest/difftest.c (片段)
#define CSR_CMP(field) do { \
if (csr.field != ref_csr.field) { \
fprintf(stderr, " csr %-8s : mine=0x%08" PRIx32 \
" ref=0x%08" PRIx32 "\n", \
#field, csr.field, ref_csr.field); \
mismatch = true; \
} \
} while (0)
CSR_CMP(mstatus);
CSR_CMP(mie);
CSR_CMP(mtvec);
CSR_CMP(mscratch);
CSR_CMP(mepc);
CSR_CMP(mcause);
/* mip 不比! 见下节 */
7.2 mip 的豁免
mip(machine interrupt pending)是硬件状态,反映外部中断挂起情况。TEMU 里 mip.MTIP(machine timer interrupt pending)由主循环每条指令末尾从 timer_mtime() >= timer_mtimecmp() 动态 poll 得来。
问题:主 CPU 和参考 CPU 不同时 poll——参考 CPU 根本不 poll。直接比对 mip 必然报分歧。
解法:把 mip 从 CSR 比对里踢出去:
1
2
3
4
5
6
7
/* mip 是墙钟衍生的硬件状态,不参与比对。
* 直接把主 CPU 的 mip 赋给 ref,下一步从共同起点继续。*/
ref_csr.mip = csr.mip;
CSR_CMP(mstatus);
/* ... 其它 6 个 CSR ... */
/* 注意没有 CSR_CMP(mip); */
代价是诚实承认:软件用 csrrw mip, ... 显式写 mip 的场景(罕见)下,bug 会被漏掉。收益是mip 从不报假阳。
7.3 中断 delivery 复用 flag
P6a 的异步中断 delivery(定时器打断主循环)也用 paddr_touched_mmio = true 这同一个 flag:
1
2
3
4
5
6
// src/cpu/cpu_exec.c (P6a 添加的代码)
if ((csr.mip & csr.mie) & MIP_MTIP) {
trap_take(CAUSE_INT_MTI, 0, cpu.pc);
trap_commit();
paddr_touched_mmio = true; /* 让 difftest snapshot 同步 */
}
中断是另一种”ref 没法重现的事件”——它由墙钟触发,参考 CPU 不会”碰巧”在同一条指令上决定中断。复用 snapshot 机制同步,避开 ref 独立 poll 时钟导致的漂移。
P4 的框架不用改——P2 挖的 flag、P4 读的快照、P6a 扩的用途——一个 flag 服务三章。
8. 现场演示:注入 bug 看 difftest 开火
纸上谈兵到此为止。动手:故意破坏一条 INSTPAT,跑 make test-diff,看 difftest 定位到那条指令。
8.1 选择目标
选 SLTIU——”无符号小于立即数”。spec 要求无符号比较,我们故意改成有符号:
1
2
3
4
5
// src/isa/riscv32/inst.c (故意改坏)
-INSTPAT("??????? ????? ????? 011 ????? 0010011", "sltiu", I,
- R(rd) = src1 < imm ? 1 : 0);
+INSTPAT("??????? ????? ????? 011 ????? 0010011", "sltiu", I,
+ R(rd) = (sword_t)src1 < (sword_t)imm ? 1 : 0);
重新 make,然后:
1
2
3
4
$ make test-diff
...
sltiu ne-max FAIL (got 0x00000000, expected 0x00000001)
isa tests: 63 passed, 1 failed
单元测试已经够了?这个例子恰好单元测试也抓到了——isa.c 的 test_slt 里有 SLTIU(A0, ZERO, -1) 这一条。
但单元测试的输出是什么?got 0x00000000, expected 0x00000001——知道 SLTIU 错了,要自己想哪儿错了。
8.2 开启 difftest
1
2
3
4
5
6
7
$ TEMU_DIFFTEST=1 make test-isa
...
gpr[10] (a0 ): mine=0x00000000 ref=0x00000001
difftest: CPU state diverged from reference
last instruction: 0x00303513 sltiu a0, zero, -1
HIT ABORT at pc=0x80000004
sltiu ne-max FAIL (HIT ABORT)
差异直接定位到指令级:
gpr[10]不对(a0 = 0vsa0 = 1)- 这一步反汇编是
sltiu a0, zero, -1 - 第一条出错的指令就是这条
换在真实调试场景里(改了 immB 某个 bit,跑 fib),make test-isa 可能会告诉你”fib 返回 42 而不是 55”——没用。make test-diff 告诉你”beq a0, zero, 0x55A 这一条分歧,分支该跳没跳”——立刻修。
8.3 恢复
1
2
3
4
5
$ git checkout src/isa/riscv32/inst.c
$ make test-diff
...
isa tests: 64 passed, 0 failed
program tests: 6 passed, 0 failed
全绿。Oracle 免费。
里程碑:P1 §7.7 承诺的”完全免费的 oracle”在 CPU 级别兑现。你刚刚用
cc编译出来的第二个 RV32I 实现就是你的 oracle——零成本、随叫随到、精确到指令级定位。
9. 理论视角
P1 §7.7 给过学术谱系(Weyuker → McKeeman → QuickCheck → CSmith)。本章不重复。本节深入三个P1 没讲的维度。
9.1 Oracle-hood 与 implementation independence
什么叫好的 oracle?三个指标:
| 指标 | 含义 | 例子 |
|---|---|---|
| 权威度 | 多少人信任它是对的 | Spike(RISC-V Foundation 官方参考)> QEMU(生产工具)> 自写实现 |
| 实现距离 | 和被测实现共享多少代码/思路/作者 | Sail 模型(形式化规范)> Spike(独立团队)> 自写 ref(同一个人) |
| 可用性 | 接入成本、运行开销 | 自写 ref(零依赖)> QEMU(C 库连接)> Spike(DPI 接入)> Sail(学习曲线陡峭) |
四个候选对比:
| Oracle | 权威度 | 实现距离 | 可用性 | 适合场景 |
|---|---|---|---|---|
| 手写第二实现(TEMU) | 低 | 近 | 极高 | 教学 / 原型 |
| Spike | 极高 | 远 | 中 | 工业实现、竞赛级验证 |
| QEMU TCG | 中 | 远 | 中 | 跨架构项目 |
| Sail 规范 | 极高 | 极远 | 低 | 学术 / 标准制订 |
TEMU 选手写第二实现的教学理由:读者亲自写过两遍,实打实感受 “spec 不变 / 代码形状变”。这种体验用 Spike 达不到——读者不会因为 “我用了 RISC-V 官方参考” 就理解 ISA 的本质,理解是从自己写两遍里长出来的。
工程场景反过来:Chip 设计 tape-out 前的验证几乎全用 Sail 或 Spike + 自家 RTL 对拍。手写第二实现在工业上不划算——成本高、作者共享型 bug 躲不过。
9.2 Lockstep 的三个精度等级
“按步对拍”听起来只是一件事,实际是三个量级的连续谱:
| 等级 | 对拍粒度 | 对拍对象 | 工具代表 | 成本量级 |
|---|---|---|---|---|
| Instruction-level(我们) | 每条 retired instruction 后 | GPR / PC / CSR / 内存 | TEMU、Spike 对拍 | 软件成本 |
| Cycle-accurate | 每个时钟周期 | 上面的 + 流水线各级、总线信号、cache line | Cadence Palladium、Synopsys ZeBu | 硬件加速盒,数千万 $ |
| Architectural equivalence | 每若干条 retired instruction 的最终效果 | 只看软件可见状态 | SPEC benchmark 回归 | 最便宜但最粗糙 |
TEMU 选第一种因为我们是功能正确性模拟器(P0 §3.1 定位)——cache miss / pipeline stall 在功能上不可观测,所以不需要 cycle-accurate。
类比:同一场足球比赛的三种裁判:
- 第 1 种:每个动作是否犯规(cycle-accurate)
- 第 2 种:每粒进球是否有效(instruction-level)
- 第 3 种:最终比分是否正确(architectural)
教学场景第 2 种够用,也最便宜。
9.3 Correlated failures 的四类谱系
P1 §7.7 末尾提过一句”共享假设 = 漏检”。这里深入分类 + 缓解:
| 类型 | 典型表现 | 缓解方法 |
|---|---|---|
| 规范共享型 | 两边都按同一份(错的)spec 版本 | 引入独立规范源(Sail 模型、另一 ISA 版本号)做三方 |
| 语言 UB 共享型 | 两边都是 C 写的,x >> 32、INT_MIN - 1 一样 UB | UBSan / Alive2 / 换语言(Rust、OCaml) |
| 测试输入共享型 | 两边都没被某类输入覆盖过 | Coverage-guided fuzzing(AFL、libFuzzer) |
| 作者共享型 | 同一个人写的两个实现 | 引入第三方独立实现(Spike) |
CSmith 2011 的真实数据:325 个发现的编译器 bug 中,约 15% 是 GCC 和 LLVM 共享的——纯差分测试(仅 GCC vs LLVM)漏掉这些。作者们不得不引入商业编译器 ICC 做第三方对拍,做三路差分才抓到。
对 TEMU 的自省:我们的两套实现都是我写的——作者共享型风险最高的配置。缓解路径是 Hard 练习 2:把 Spike 接入当第三方 oracle。
核心洞察:差分测试是一个 ‘概率放大器’,不是 ‘证明机’。它把 “我一个人能想到的 bug” 的检测概率从 50% 拉到 95%。想上 99%,就得加第三方或形式化方法。追求 100% 是停机问题。
10. 踩坑清单
difftest_step调用点错位:必须在cpu.pc = s.dnpc之后、中断 delivery 之后。放前面 = 永远差一步。difftest_init忘 memcpy CSR:前几步 CSR 比对”随机通过”(都是 0),一写 CSR 立刻爆。- 两边共享
BITS/SEXT宏:立即数 bug 两边同错,oracle 静默失效——“看起来在对拍但没在对拍”,最危险的配置。 - 参考 CPU 也
putchar:串口程序输出双倍字符。paddr_touched_mmiosnapshot 机制就是防这个。 paddr_touched_mmio只在主访存置 true,忘在中断 delivery 置:P6a 之后中断发生时 pc 分歧(主 CPU 跳 mtvec,ref 还在原地)。- x0 硬连线在两边写的位置不一致:一边在 ALU 前清零、一边在 ALU 后——
rd == 0时对拍看到”一边写入、一边未写”。TEMU 两边都在exec_once/ref_exec_one末尾清零,对齐。 mip没从 CSR 比对里剔除:每次墙钟 poll 都报假阳。- 误以为要手动同步 pmem:两边共享 pmem,
load_img只调一次,自动同步。 ref_exec_one内部 abort 但主 CPU 还在跑:沉默分歧——靠ref_abortedflag 在比较阶段显化(”ref aborted but main kept running”)。- 测试时漏开
TEMU_DIFFTEST=1:对拍框架默认关闭(节省 batch 性能),make test-diff里通过环境变量打开。开发新指令时记得顺手make test-diff一下。
11. 动手练习
Easy 1 · --diff-verbose:每步打印两边 pc
difftest_step 里加一个 flag,打开后每步都打 [diff] mine=0x... ref=0x...。跑 100 条指令感受”一模一样的两条轨道”。
学到:差分测试的”无声胜利”——90% 的时间两条轨道重合,你才能相信它抓过。
Easy 2 · 按 opcode 统计分歧
difftest_step 发现分歧时,按当前指令的 opcode 累加计数器,退出前打印 top 5。
学到:注入随机 bug 后看”哪类指令最容易被抓”——BRANCH 还是 ARITH?
Medium 1 · 注入 bug 比赛
从 INSTPAT 表里挑一条(不是 §8 演示的 SLTIU),故意改错一个字符。跑 make test-diff 观察分歧出现在第几条指令。再换一条,做 5 次不同的注入。
学到:每种 bug 的可达性不同——有的第 1 条指令就炸,有的要跑到 fib(6) 才炸。这种直觉靠讲讲不出来,靠手感。
Medium 2 · --diff-abort-on N
只在第 N 次分歧时才 abort,前 N-1 次只打 warning。用途:调试间歇性 bug。
学到:有些 bug 不是每次必中。真实工程场景里这种 flag 是救命工具。
Medium 3 · 随机指令序列 fuzzer
仿 P1 的 gen-expr,写个工具随机产出合法 RV32I 指令序列(带 EBREAK 结尾),跑进 TEMU 的 -d 模式。期望命中率 = 0——命中就是 TEMU 有 bug。
学到:CSmith 的微缩版。把”测试覆盖”从作者想象力解放出来,交给随机数。
Hard 1 · 接入 Spike
替换 ref_exec_one 为 Spike 的 step() 调用(需要 link librisv-sim 或通过 pipe 通信)。
学到:独立实现可以被真正的权威替换——P4 的框架没有绑死在手写 ref 上。你会在第一次跑起来的瞬间理解为什么工业界不手写 ref。
Hard 2 · 三方对拍
保留手写 ref,再加 Spike 当第二 oracle。分歧时先看”三方中哪两方一致、哪一方特立独行”——一致那两方是对的,独行那一方是 bug。
学到:CSmith 团队当年抓 GCC/LLVM 共享 bug 的办法。correlated failure 的工程级应对。
12. 本章小结
你应该能做到:
- 解释为什么单元测试的覆盖上限 = 作者想象力上限
- 用
difftest_init+difftest_step的三步搭起差分测试框架 - 读
src/difftest/difftest.c和src/isa/riscv32/inst.c两套风格迥异的 RV32I 实现 - 解释
paddr_touched_mmio为什么要从 P2 保留到 P4 才使用 - 注入一个 INSTPAT bug,用
make test-diff的输出定位到出错那条指令 - 解释为什么 ref 不能
putchar、不能读timer、不能写主 CPU
你应该能解释:
- Weyuker 1982 的 “pseudo-oracle” 和 McKeeman 1998 的 “differential testing” 的关系
- Oracle 的三个指标:权威度、实现距离、可用性——为什么 TEMU 选手写 ref、工业选 Spike
- instruction-level vs cycle-accurate vs architectural 三种 lockstep 的精度与成本对比
- Correlated failure 的四类来源和对应的缓解方法
- 为什么
mip必须从对拍里豁免 - 为什么共享 pmem 是 trade-off 不是 bug
13. 延伸阅读
- McKeeman · Differential Testing for Software(Digital SRC Technical Report 1998) — 差分测试正式命名的那篇
- Yang, Chen, Eide, Regehr · Finding and Understanding Bugs in C Compilers(PLDI 2011) — CSmith,差分测试工业级应用的代表作
- RISC-V Sail Specification — 形式化 ISA 规范,终极 oracle 的形态(github.com/riscv/sail-riscv)
- Spike RISC-V ISA Simulator — RISC-V Foundation 官方参考(github.com/riscv-software-src/riscv-isa-sim)
- AFL / libFuzzer 文档 — coverage-guided fuzzing 的两个代表实现——差分测试的随机输入侧
- Alive2 — 专门检测 C UB 共享型 correlated failures 的工具
- Lex Fridman 访谈 Chris Lattner(LLVM 作者)关于”编译器正确性测试”的段落——业内对差分测试的态度
与后续章节的连接
| 下一章做什么 | 本章埋下的伏笔 |
|---|---|
| P5 I/O 设备 | paddr_touched_mmio 被真正大量触发;snapshot 机制被压力测试;串口 / 定时器加入后差分测试继续绿意味着 device 层代码也对 |
| P6a CSR / Trap | CSR_CMP 从”纸面”变”生效”;trap 后的 mepc/mcause 比对立刻捕获 trap 实现 bug |
| P6a 中断 | paddr_touched_mmio 的第二个用途(中断 delivery 后置 true)激活——一个 flag 服务 3 章 |
| P6b 虚拟内存 | 地址翻译新问题:比物理地址状态还是虚拟地址状态?给答案 |
| 全书剩余 | P4 之后每一行新代码都走 lockstep 审计——差分测试是 TEMU 免费的回归测试平台 |
P4 是一个安全网。安装之后,你写新代码时会下意识地多敢——因为有 oracle 盯着。这种心理变化比任何代码更重要。
下一站:P5——给这台 CPU 装上第一个真实设备(串口),让它终于能 printf("hello world")。