Post

P4 · 差分测试:让另一个 CPU 检查我们的 CPU

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)的定义只有三件事:

  1. 两套独立实现——对同一份 spec
  2. 相同输入——喂给两边
  3. 按步比较——每一步都对齐,不是只比最终状态

“按步”很关键。如果你让两个 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.
 */

作者把设计思考留在代码里——这是好系统软件的标志。我们从这段注释提炼三件事:

  1. 共享 pmem 的代价:两边对 store 的实现都错且错法一致时,参考 CPU 的 store 覆盖了主 CPU 的 store,两边 pmem 一样,对拍看不见。
  2. 共享 pmem 的收益:不翻倍 128 MB 内存;load/store 路径不需要维护两份相同内容
  3. 经验依据: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.ctest_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 = 0 vs a0 = 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 lineCadence 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 >> 32INT_MIN - 1 一样 UBUBSan / 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. 踩坑清单

  1. difftest_step 调用点错位:必须在 cpu.pc = s.dnpc 之后、中断 delivery 之后。放前面 = 永远差一步。
  2. difftest_init 忘 memcpy CSR:前几步 CSR 比对”随机通过”(都是 0),一写 CSR 立刻爆。
  3. 两边共享 BITS / SEXT:立即数 bug 两边同错,oracle 静默失效——“看起来在对拍但没在对拍”,最危险的配置。
  4. 参考 CPU 也 putchar:串口程序输出双倍字符。paddr_touched_mmio snapshot 机制就是防这个。
  5. paddr_touched_mmio 只在主访存置 true,忘在中断 delivery 置:P6a 之后中断发生时 pc 分歧(主 CPU 跳 mtvec,ref 还在原地)。
  6. x0 硬连线在两边写的位置不一致:一边在 ALU 前清零、一边在 ALU 后——rd == 0 时对拍看到”一边写入、一边未写”。TEMU 两边都在 exec_once / ref_exec_one 末尾清零,对齐。
  7. mip 没从 CSR 比对里剔除:每次墙钟 poll 都报假阳。
  8. 误以为要手动同步 pmem:两边共享 pmem,load_img 只调一次,自动同步。
  9. ref_exec_one 内部 abort 但主 CPU 还在跑:沉默分歧——靠 ref_aborted flag 在比较阶段显化(”ref aborted but main kept running”)。
  10. 测试时漏开 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.csrc/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 / TrapCSR_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")

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