C1: 工具和基础设施
一、阶段目标
C1 阶段的核心目标是搭建完善的验证和调试基础设施,使后续的处理器开发可以高效定位和修复 bug。完成后你将:
- 理解并实现差分测试 (DiffTest) 框架——NPC 与 NEMU 逐指令对比
- 掌握多层 Trace 系统 (ITRACE/MTRACE/FTRACE/ETRACE) 的设计和使用
- 理解 DPI-C 接口在验证中的关键作用
- 掌握波形调试 (VCD + GTKWave) 的方法论
- 理解性能计数器 (Performance Counter) 的设计
二、差分测试 (DiffTest) 框架
2.1 核心思想
┌─────────────────┐ ┌─────────────────┐
│ NPC (DUT) │ │ NEMU (REF) │
│ 硬件RTL仿真 │ │ 软件参考模型 │
└────────┬────────┘ └────────┬────────┘
│ │
│ 同一份程序镜像 │
│ │
每执行1条指令后 ────┤ ├──── 同步执行1条指令
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ DUT 寄存器状态 │ ═══?═══ │ REF 寄存器状态 │
│ PC, x0-x31 │ │ PC, x0-x31 │
│ CSR │ │ CSR │
└─────────────────┘ └─────────────────┘
│ │
└───────────┬───────────────┘
│
┌────────▼────────┐
│ 一致? → 继续 │
│ 不一致? → 报错 │
│ + 打印两侧状态│
│ + 停止仿真 │
└─────────────────┘
2.2 NEMU 作为 REF 的编译
NEMU 可以编译为共享库 (.so),作为参考模型:
cd nemu
make menuconfig
# 选择: Build target → Shared object (used as REF for differential testing)
make
# 输出: build/riscv32-nemu-interpreter-so
共享库导出以下 API:
void difftest_init(); // 初始化参考模型
void difftest_memcpy(uint32_t addr, void *buf, size_t n, bool direction); // 内存同步
void difftest_regcpy(void *dut, bool direction); // 寄存器同步
void difftest_exec(uint32_t n); // 执行 n 条指令
void difftest_raise_intr(uint32_t NO); // 触发中断
2.3 NPC 端 DiffTest 初始化 (difftest.cpp)
void difftest_init(char *ref_so_file, long img_size) {
// 1. 动态加载 NEMU 共享库
void *handle = dlopen(ref_so_file, RTLD_NOW);
// 2. 获取 API 函数指针
ref_difftest_memcpy = dlsym(handle, "difftest_memcpy");
ref_difftest_regcpy = dlsym(handle, "difftest_regcpy");
ref_difftest_exec = dlsym(handle, "difftest_exec");
ref_difftest_raise_intr = dlsym(handle, "difftest_raise_intr");
// 3. 初始化参考模型
void (*ref_difftest_init)() = dlsym(handle, "difftest_init");
ref_difftest_init();
// 4. 将程序镜像同步到参考模型
ref_difftest_memcpy(INST_START, guest_to_host(INST_START), img_size, DIFFTEST_TO_REF);
// 5. 同步初始寄存器状态
regfile dut = pack_dut_regfile(dut_reg, INST_START, dut_csr);
ref_difftest_regcpy(&dut, DIFFTEST_TO_REF);
}
2.4 每条指令后的对比 (difftest_check)
bool difftest_check() {
// 如果上一条指令需要跳过检查 (如 MMIO 访问)
if (is_skip_ref_r) {
dut = pack_dut_regfile(dut_reg, dut_pc, dut_csr);
ref_difftest_regcpy(&dut, DIFFTEST_TO_REF); // 强制同步 DUT 状态到 REF
is_skip_ref_r = false;
return true;
}
// 获取 REF 寄存器状态
regfile ref;
ref_difftest_regcpy(&ref, DIFFTEST_TO_DUT);
// 获取 DUT 寄存器状态 (通过 DPI-C 读取 RTL 寄存器)
regfile dut = pack_dut_regfile(dut_reg, dut_pc, dut_csr);
// 逐一对比
bool ret = checkregs(&ref, &dut);
if (!ret) print_regs(&ref, &dut); // 不一致时打印双侧状态
return ret;
}
2.5 寄存器对比逻辑 (reg.cpp)
bool checkregs(regfile *ref, regfile *dut) {
// 检查 PC
if (ref->pc != dut->pc) {
printf("difftest error: next pc diff: ref=0x%x, dut=0x%x\n", ref->pc, dut->pc);
return false;
}
// 检查 32 个通用寄存器
for (int i = 0; i < 32; i++) {
if (ref->x[i] != dut->x[i]) {
printf("difftest error at pc=0x%x, reg %s diff: ref=0x%x, dut=0x%x\n",
dut->pc, regs[i], ref->x[i], dut->x[i]);
return false;
}
}
// 检查 4 个 CSR
for (int i = 0; i < 4; i++) {
if (ref->csr[i] != dut->csr[i]) {
printf("difftest error at pc=0x%x, csr %s diff: ref=0x%x, dut=0x%x\n",
dut->pc, csrs[i], ref->csr[i], dut->csr[i]);
return false;
}
}
return true;
}
2.6 DiffTest Skip 机制
某些指令无法对比(如访问 MMIO 设备——REF 和 DUT 的设备状态可能不同):
// dpic.cpp — 当 NPC 访问 UART 时
if (waddr == 0xa0000000 && wen) { // UART 地址
is_skip_ref = true; // 标记跳过
putc(ch, stderr);
return;
}
// difftest_step() — 处理 skip
void difftest_step() {
if (is_skip_ref) {
is_skip_ref_r = true; // 下一次 check 时同步而非对比
is_skip_ref = false;
return; // 不让 REF 执行
}
ref_difftest_exec(1); // 正常: REF 执行一条
}
Skip 的语义:当 DUT 执行一条不可对比的指令时:
- DUT 照常执行
- REF 跳过该指令不执行
- 下次 check 时将 DUT 的状态强制复制到 REF(保持两侧同步)
三、DPI-C 基础设施
3.1 从 Verilog 到 C++ 的信号传递
| DPI-C 函数 | 方向 | 用途 |
|---|---|---|
set_reg_ptr(rf) |
RTL→C++ | 暴露寄存器数组指针 |
set_csr_ptr(csr) |
RTL→C++ | 暴露 CSR 数组指针 |
diff_read_pc(pc) |
RTL→C++ | 传递当前 PC 值 |
diff_read_status(status) |
RTL→C++ | 传递指令提交状态 |
check_rst(rst) |
RTL→C++ | 传递复位状态 |
check_finish(instr) |
RTL→C++ | 检测 ebreak 指令 |
pmem_read(addr, data, ren) |
RTL→C++ | 内存读取回调 |
pmem_write(addr, data, mask, wen) |
RTL→C++ | 内存写入回调 |
3.2 DPI-C 内存读写 (dpic.cpp)
extern "C" void pmem_read(uint32_t raddr, uint32_t *rdata, svBit ren) {
raddr = raddr & ~0x3u; // 4 字节对齐
if (raddr == 0xa0002000 && ren) { // TIMER 设备
is_skip_ref = true; // DiffTest 跳过
us = get_time();
*rdata = (uint32_t)us;
}
else if (raddr == 0xa0002004 && ren) { // TIMER 高 32 位
is_skip_ref = true;
*rdata = (uint32_t)(us >> 32);
}
else if (ren && raddr >= PMEM_START && raddr <= PMEM_END) {
*rdata = _pmem_read(raddr, 4); // 普通内存读取
}
else {
*rdata = 0; // 避免 latch
}
}
3.3 性能计数器 DPI-C
// RTL 中通过 DPI-C 报告各种事件
extern "C" void axi4_handshake(svBit valid, svBit ready, svBit last, int pfc_type) {
if (valid && ready && last) {
switch (pfc_type) {
case 1: ifu_pfc_r++; break; // IFU 完成一次读取
case 4: lsu_pfc_w++; break; // LSU 完成一次写入
case 6: lsu_pfc_r++; break; // LSU 完成一次读取
}
}
}
extern "C" void idu_instr_type(svBit valid, int opcode) {
if (valid) {
switch (opcode) {
case 0x33: case 0x13: idu_cal_type++; break; // 计算类指令
case 0x23: case 0x03: idu_mem_type++; break; // 访存类指令
case 0x63: case 0x6f: idu_jump_type++; break; // 跳转类指令
case 0x73: idu_csr_type++; break; // CSR/系统指令
}
}
}
四、Trace 追踪系统
4.1 ITRACE — 指令追踪
记录每条指令的 PC、机器码、反汇编文本:
// 输出格式示例
0x80000000: 00000297 auipc t0, 0
0x80000004: 01028293 addi t0, t0, 16
0x80000008: 30029073 csrw mtvec, t0
在 NEMU 中 (cpu-exec.c):
#ifdef CONFIG_ITRACE
char *p = s->logbuf;
p += snprintf(p, sizeof(s->logbuf), FMT_WORD ":", s->pc);
// 打印机器码字节
for (i = ilen - 1; i >= 0; i--)
p += snprintf(p, 4, " %02x", inst[i]);
// 反汇编
disassemble(p, ..., s->pc, &s->isa.inst.val, ilen);
#endif
4.2 IRINGBUF — 指令环形缓冲
问题:程序崩溃时 ITRACE 可能已经输出了上百万行日志,根本无法查找。
解决方案:只保留最近 N 条指令,崩溃时一次性打印。
// 环形缓冲区数据结构
typedef struct {
uint8_t *buffer; // 缓冲区
uint32_t size; // 总大小
uint32_t in; // 写入位置
uint32_t out; // 读取位置
} RingBuffer;
// 每条指令执行后写入环形缓冲
if (RingBuffer_IsFull(rb)) {
RingBuffer_Out(rb, &tmp, 4); // 满了就丢弃最老的
}
RingBuffer_In(rb, &s->isa.inst.val, 4); // 写入最新指令
// 崩溃时打印
void assert_fail_msg() {
printf("[Last several instructions for debug.]\n");
while (!RingBuffer_IsEmpty(rb)) {
uint32_t dest;
RingBuffer_Out(rb, &dest, 4);
printf("instr: 0x%08x\n", dest);
}
}
4.3 MTRACE — 内存访问追踪
// nemu/src/memory/paddr.c
#ifdef CONFIG_MTRACE
if (addr >= CONFIG_MTRACE_START && addr <= CONFIG_MTRACE_START + CONFIG_MTRACE_SIZE) {
Log("MTRACE: Read Memory Address: 0x%x, len: %d, data: 0x%x", addr, len, ret);
}
#endif
在 NPC 中通过 DPI-C 实现:
// 编译时加 -DMTRACE_ON
IFDEF(MTRACE_ON, printf("raddr = 0x%08x, rdata = 0x%08x\n", raddr, *rdata));
4.4 各 Trace 的适用场景
| Trace | 适用场景 | 性能影响 |
|---|---|---|
| ITRACE | 定位哪条指令出错 | 中 (日志写入) |
| IRINGBUF | 崩溃后回溯 | 低 (内存中环形写入) |
| MTRACE | 定位内存相关 bug (越界、错误地址) | 高 (每次访存都打印) |
| FTRACE | 定位函数调用关系 | 中 (需要符号表解析) |
| ETRACE | 跟踪异常/中断触发 | 低 (异常较少) |
| DTRACE | 跟踪设备 I/O 访问 | 低 (设备访问较少) |
五、波形调试 (VCD + GTKWave)
5.1 波形生成
make run WAVE=1 # 编译时加 -DTRACE_ON,运行时打开 VCD 文件
// main.cpp
IFDEF(TRACE_ON,
contextp->traceEverOn(true);
top->trace(tfp, 0);
tfp->open("build/sim.vcd");
);
// 每个仿真步骤
IFDEF(TRACE_ON, tfp->dump(contextp->time()));
5.2 波形查看
make wave # 等效于 gtkwave build/sim.vcd &
5.3 波形调试方法论
定位 DiffTest 报错时刻:
- DiffTest 报错打印了 PC 值(如
pc=0x80000100) - 在波形中找到
dut_pc == 0x80000100的时刻 - 观察该时刻 IDU 输出的控制信号是否正确
- 检查 EXU/ALU 的输入输出
- 如果是访存指令,检查 LSU 的地址和数据
关键观察信号:
clock,reset— 时钟和复位pc— 当前 PCinstr— 当前指令exu_opt— ALU 操作类型exu_res— ALU 结果rd,wbu_rdwen— 写回信号ifu_valid,idu_ready— 流水线握手
六、错误定位完整流程
6.1 典型 Bug 定位流程图
程序执行异常 (HIT BAD TRAP / DiffTest 不一致)
│
▼
┌──────────────────────┐
│ 1. 确定出错的指令 PC │
│ (DiffTest 报告) │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ 2. objdump 查看 │
│ 该 PC 对应的指令 │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ 3. 判断 Bug 类型 │
├──────────┬───────────┤
│ │ │
▼ ▼ ▼
译码错误 执行错误 访存错误
│ │ │
▼ ▼ ▼
检查IDU 检查ALU 检查LSU/
控制信号 输入输出 地址计算
│ │ │
▼ ▼ ▼
┌──────────────────────┐
│ 4. 打开波形确认 │
│ 信号时序 │
└──────────────────────┘
6.2 常见 Bug 模式
| 现象 | 可能原因 | 检查方法 |
|---|---|---|
| PC 不一致 | 分支条件判断错误 | 检查 BRU 的 zero/brch 信号 |
| 某寄存器值错 | ALU 运算类型设错 | 检查 IDU 的 exu_opt 输出 |
| 访存数据错 | Load 符号扩展/零扩展错 | 检查 LSU 的 lsu_opt 信号 |
| 随机崩溃 | 复位不充分/未初始化 | 增加复位周期 |
| CSR 值不一致 | ecall/mret 处理错 | 检查 IRU 模块 |
七、条件编译基础设施
7.1 NEMU 中的 IFDEF/MUXDEF
// 在 NEMU 中通过 Kconfig 控制
IFDEF(CONFIG_ITRACE, puts(s->logbuf)); // 有 ITRACE 则打印
IFDEF(CONFIG_DIFFTEST, difftest_step(...)); // 有 DIFFTEST 则对比
IFDEF(CONFIG_DEVICE, device_update()); // 有 DEVICE 则更新设备
7.2 NPC 中的条件编译宏
// npc/csrc/include/macro.h
#define IFDEF(macro, ...) MUXDEF(macro, __VA_ARGS__, )
#define MUXDEF(macro, X, Y) MUX_MACRO_PROPERTY(__P_DEF_, macro, X, Y)
// 使用:
IFDEF(DIFFTEST_ON, difftest_step());
IFDEF(TRACE_ON, tfp->dump(contextp->time()));
IFDEF(MTRACE_ON, printf("mem access...\n"));
7.3 Makefile 控制
ifneq ($(DIFFTEST), )
CXXFLAGS += -DDIFFTEST_ON
NPC_ARGS += --diff $(NEMU_HOME)/build/riscv32-nemu-interpreter-so
endif
ifneq ($(WAVE), )
CXXFLAGS += -DTRACE_ON
endif
ifneq ($(MTRACE), )
CXXFLAGS += -DMTRACE_ON
endif
八、性能统计
8.1 仿真结束时的统计输出
void statistics() {
printf("Total cycles: %ld\n", contextp->time() / 2);
printf("Total insts: %ld\n", total_inst);
printf("CPU IPC: %f\n", total_inst / (contextp->time() / 2.0));
printf("CPU CPI: %f\n", (contextp->time() / 2.0) / total_inst);
printf("IFU Read count: %ld\n", ifu_pfc_r);
printf("LSU Read count: %ld\n", lsu_pfc_r);
printf("LSU Write count: %ld\n", lsu_pfc_w);
printf("ICACHE Hit Rate: %f\n", cache_hit_rate);
printf("ICACHE AMAT: %f\n", cache_acc_time + (1-cache_hit_rate)*cache_miss_penalty);
}
8.2 各统计指标含义
| 指标 | 公式 | 含义 |
|---|---|---|
| IPC | total_inst / cycles | 每周期执行指令数 (理想=1) |
| CPI | cycles / total_inst | 每条指令消耗周期数 |
| Cache Hit Rate | hits / accesses | 缓存命中比例 |
| AMAT | hit_time + miss_rate × miss_penalty | 平均内存访问时间 |
九、PA 讲义相关思考题
Q: DiffTest 为什么选择 NEMU 而不是 Spike 作为 NPC 的参考?
回答:
- 自己实现的 NEMU 可以深度定制:添加 MMIO skip、CSR 同步等功能
- API 简单:只需导出 4 个函数
- ISA 子集一致:你的 NEMU 和 NPC 实现相同的指令子集,避免不支持指令的差异
- Spike 更标准但更严格:Spike 实现完整的特权规范,可能在细节上与简化实现不一致
实际项目中两者可以配合使用:先用 NEMU 做快速验证,后期用 Spike 做合规性检查。
Q: 为什么 MMIO 访问要 skip DiffTest?
回答:因为设备访问是不确定性的:
- NPC 读定时器获得的是"NPC 仿真时的真实时间"
- NEMU 读定时器获得的是"NEMU 执行时的真实时间"
- 两者时间点不同,返回值必然不同
其他不确定性来源:键盘事件、随机数等。
解决方案:DUT 执行设备访问后,跳过 REF 的执行,将 DUT 的状态强制同步到 REF。
Q: dlopen/dlsym 是什么?为什么用它们?
回答:
dlopen(path, flags)— 运行时加载共享库 (.so 文件)dlsym(handle, symbol)— 从已加载的共享库中查找函数符号
为什么用:NPC 的 testbench (C++) 需要调用 NEMU 的函数,但编译时两者是独立项目。通过将 NEMU 编译为 .so 并在运行时动态加载,实现解耦。
Q: 波形文件 (VCD) 为什么不能一直开着?
回答:VCD 文件记录每个信号在每个时间步的值,数据量极大:
- 一个 100 信号、仿真 100 万周期的设计 → VCD 可能数百 MB
- 运行 CoreMark 等复杂程序可能产生 GB 级波形文件
最佳实践:
- 先不开波形跑 DiffTest 找到出错的大致位置
- 用
MAXCYCLE限制仿真周期 - 只在出错前后的小范围开波形
Q: 如何验证你的 DiffTest 框架本身是正确的?
回答:
- 先确保 NEMU 通过所有 cpu-tests(NEMU 自身是正确的)
- 让 NPC 运行一个已知正确的简单程序 (dummy),DiffTest 应该 PASS
- 故意引入 bug(如把
add改成sub),确认 DiffTest 能检测到 - 检查 skip 逻辑:MMIO 访问后状态应正确同步
十、学习建议
- DiffTest 是 NPC 开发的生命线 — 每增加一个功能就跑 DiffTest
- 先实现最简单的 DiffTest:只对比 PC 和通用寄存器,不对比 CSR
- 理解 Skip 机制:设备访问时必须 skip,否则 DiffTest 永远不一致
- 波形是最后手段:先用 DiffTest 定位到具体指令,再开波形看细节
- 性能计数器在优化阶段很有用:IPC < 0.5 说明有大量 stall
参考资料:
- PA2.4 "基础设施(2)" 讲义
- dlopen/dlsym man page
- Verilator Tracing 文档
- 项目源码: npc/csrc/difftest.cpp, npc/csrc/dpic.cpp, nemu/src/cpu/cpu-exec.c
十一、本阶段 Git 版本记录
| Commit | 说明 | 完成内容 |
|---|---|---|
323892b |
PA2.1 RTL | DiffTest 框架首次引入 (NPC vs NEMU .so) |
33c5971 |
PA2.2 w/o trace | ITRACE/IRINGBUF 实现 |
68bd7d0 |
PA2.2 RTL w/o trace | NPC 端 DPI-C 接口完善 |
DiffTest 和 Trace 系统贯穿整个项目开发,从 PA2.1 RTL 开始一直使用到最后。
查看对应版本代码:
git show 323892b:npc/csrc/difftest.cpp # DiffTest 初版
git show 33c5971:nemu/src/cpu/cpu-exec.c # ITRACE 实现

浙公网安备 33010602011771号