C3: 调试技巧
一、阶段目标
C3 阶段的核心目标是掌握系统性的硬件调试方法论,能够高效地定位和修复 RTL 设计中的 bug。完成后你将:
- 建立"分层定位"的调试思维——从宏观到微观逐步收窄范围
- 熟练使用波形 (GTKWave) 分析时序行为
- 掌握 DiffTest 报错信息的快速解读
- 理解常见 bug 模式及其排查路径
- 能够独立定位和修复处理器设计中的各类错误
二、调试方法论:分层定位
2.1 核心原则
不要猜测,要观察。不要随机修改代码,要系统排查。
调试流程遵循"从高层到底层"的原则:
第1层: DiffTest 报错 → 哪条指令出错?哪个寄存器不一致?
│
▼
第2层: 反汇编确认 → 这条指令应该做什么?
│
▼
第3层: 信号追踪 → IDU 输出的控制信号正确吗?
│
▼
第4层: 波形验证 → 数据通路上哪一级的数据出了问题?
│
▼
第5层: 根因修复 → Verilog 代码具体哪一行写错了?
2.2 调试工具层级
| 层级 | 工具 | 适用场景 | 效率 |
|---|---|---|---|
| 高层 | DiffTest | 发现"哪条指令出错" | ★★★★★ |
| 中层 | ITRACE/IRINGBUF | 查看出错前后的指令流 | ★★★★ |
| 中层 | MTRACE | 内存访问地址/数据错误 | ★★★ |
| 底层 | 波形 (GTKWave) | 分析信号时序关系 | ★★ |
| 底层 | printf/DPI-C 打印 | 观察特定信号值 | ★★ |
效率越高的工具越先使用——DiffTest 能在几秒内定位到出错指令,而波形分析可能需要几十分钟。
三、DiffTest 报错解读
3.1 典型报错信息
difftest error at nextpc = 0x800000a4, reg a0 is diff: ref = 0x00000005, dut = 0x00000003
解读:
nextpc = 0x800000a4→ 出错指令的下一条地址,即出错指令在0x800000a0reg a0 is diff→ a0 寄存器值不一致ref = 5, dut = 3→ 参考模型算出 5,你的硬件算出 3
3.2 从报错到定位
# Step 1: 找到出错的指令
riscv64-linux-gnu-objdump -d build/xxx.elf | grep "800000a0"
# 输出: 800000a0: 00b50533 add a0, a0, a1
# Step 2: 分析预期行为
# add a0, a0, a1 → a0 = a0 + a1
# ref 说结果是 5 → 说明 a0=2, a1=3 (2+3=5)
# dut 说结果是 3 → 可能把 a1 的值直接放到了 a0?或者 add 做成了 mov?
# Step 3: 检查 IDU 和 EXU
# 看 IDU 是否正确输出: exu_opt=ADD, exu_src_sel=REG
# 看 EXU 的两个输入 src1 和 src2 是否正确
3.3 常见报错模式与含义
| 报错模式 | 可能原因 |
|---|---|
| PC diff | 分支条件判断错误 / 跳转地址计算错 |
| rd 差一个固定值 | 立即数符号扩展错误 |
| rd 值完全无关 | exu_opt 或 exu_src_sel 错误 |
| rd 值和 rs2 相同 | R 型被当成了 mov (可能 IDU 没设 exu_opt) |
| Load 值错位 | 地址不对齐 / 字节序处理错误 |
| CSR diff | ecall/mret 处理逻辑有误 |
四、反汇编辅助调试
4.1 生成反汇编文件
每次编译 AM 程序时会自动生成:
# 位于:
build/xxx-riscv32e-npc.txt # objdump -d 的输出
4.2 快速定位指令
# 在反汇编文件中搜索出错地址
grep "800000a0" build/add-riscv32e-npc.txt
# 或者查看某个函数
grep -A 20 "<add>:" build/add-riscv32e-npc.txt
4.3 手动解码指令
如果你看到一个 32-bit 机器码想知道它是什么指令:
机器码: 0x00b50533
二进制: 0000000 01011 01010 000 01010 0110011
─────── ───── ───── ─── ───── ───────
funct7 rs2 rs1 f3 rd opcode
opcode = 0110011 → R 型
funct3 = 000 → ADD/SUB
funct7 = 0000000 → ADD
rs1 = 01010 = 10 = a0
rs2 = 01011 = 11 = a1
rd = 01010 = 10 = a0
结论: add a0, a0, a1
五、波形调试技巧
5.1 准备波形
# 限制仿真周期,避免波形文件过大
make run WAVE=1 MAXCYCLE=10000 DIFFTEST=1
5.2 GTKWave 使用要点
常用操作:
- 添加信号:在左侧信号列表中双击或拖拽到波形区域
- 放大/缩小:Ctrl+滚轮,或使用工具栏的 + / - 按钮
- 搜索时刻:Edit → Find → Search for value/time
- 标记:在时间轴上点击放置光标,便于来回跳转
- 分组:将相关信号放入同一组(如 IDU 输出、EXU 输入输出)
推荐观察的信号组:
Group 1: 总控
clock, reset, pc, instr
Group 2: IDU 输出
exu_opt, exu_src_sel, lsu_opt
rs1id, rs2id, rdid, rdwen
imm, brch, jal, jalr
Group 3: 数据通路
rs1, rs2 (寄存器读出值)
exu_res (ALU 结果)
lsu_res (内存读出值)
rd (写回数据)
Group 4: 流控 (流水线阶段)
ifu_valid, idu_ready
idu_valid, exu_ready
exu_valid, lsu_ready
lsu_valid, wbu_ready
5.3 波形分析步骤
- 定位出错时刻:搜索
pc == 0x800000a0 - 确认指令正确取回:检查
instr信号值是否等于 objdump 显示的机器码 - 检查 IDU 输出:
exu_opt、exu_src_sel是否正确 - 检查 EXU 输入:
src1、src2的值是否等于预期的寄存器值 - 检查 EXU 输出:
exu_res是否等于预期结果 - 检查写回:
rdwen是否为 1,写回地址和数据是否正确
六、常见 Bug 模式详解
6.1 符号扩展错误
症状:涉及负数的运算结果不对
示例:addi a0, zero, -1 应得 0xFFFFFFFF,却得 0x00000FFF
原因:I 型立即数提取时没有符号扩展
// 错误
o_imm = {20'b0, i_instr[31:20]}; // 零扩展!
// 正确
o_imm = {{20{i_instr[31]}}, i_instr[31:20]}; // 符号扩展
6.2 移位量截断错误
症状:移位结果不对(移了过多位)
原因:RISC-V 规定移位量只取 rs2/imm 的低 5 位
// 错误
o_alu_res = i_src1 << i_src2; // 用完整的 32 位做移位!
// 正确
o_alu_res = i_src1 << i_src2[4:0]; // 只取低 5 位
6.3 算术右移 vs 逻辑右移
症状:负数右移后高位为 0 而非全 1
原因:Verilog 的 >> 总是逻辑右移
// 错误
o_alu_res = i_src1 >> i_src2[4:0]; // 逻辑右移,高位补 0
// 正确 (符号扩展后逻辑右移)
wire [63:0] tmp = {{32{i_src1[31]}}, i_src1} >> i_src2[5:0];
o_alu_res = tmp[31:0];
6.4 Store 误写回寄存器
症状:Store 指令执行后某个寄存器值被覆盖
原因:IDU 中 Store 指令的 rdwen 未设为 0
// S 型指令不写寄存器
`TYPE_S: begin
o_rdwen = 1'b0; // ← 关键! Store 不写 rd
// ...
end
6.5 分支偏移地址错误
症状:分支跳转到奇怪的地址
原因:B 型立即数拼接顺序错误
// B 型立即数正确拼接:
// {sign, [7], [30:25], [11:8], 0}
o_imm = {{20{i_instr[31]}}, i_instr[7], i_instr[30:25], i_instr[11:8], 1'b0};
// ─────符号扩展──── ─bit7─ ────bit30:25──── ────bit11:8──── ─末位0─
6.6 JAL/JALR 返回地址错误
症状:函数返回后跑飞
原因:rd = PC + 4 的 PC 值取错(取了下一条PC而非当前PC)
// 正确: 保存的是当前指令的 PC + 4
case (`TYPE_J): exu_src_sel = EXU_SEL_PC4; // result = current_pc + 4
七、调试心态与方法论
7.1 "Bug 总是确定性的"
硬件设计中的 bug 不像多线程程序那样不可复现。给定相同的输入,RTL 仿真必定产生相同的输出。所以:
- 不要说"我重新编译一下看看会不会好"
- bug 不会自己消失——除非你理解并修复了它
7.2 "二分法定位"
如果不确定 bug 在数据通路的哪一级:
- 先看 EXU 输出是否正确
- 正确 → bug 在 LSU/WBU
- 不正确 → 再看 EXU 输入是否正确
- 正确 → bug 在 EXU/ALU 本身
- 不正确 → 再看 IDU 输出
- 如此二分,快速收窄
7.3 "最小复现"
如果一个复杂程序出错:
- 用 DiffTest 找到出错的指令
- 写一个只包含该指令(及最小前置指令)的小程序
- 在小程序上调试——信号少、波形短、容易分析
7.4 "回归测试"
修复一个 bug 后:
make run DIFFTEST=1 # 跑全部 cpu-tests
确保修复没有引入新 bug(回归)。
八、调试工具速查
8.1 命令行速查
# 编译 + 运行 + DiffTest
make run DIFFTEST=1
# 编译 + 运行 + 波形 + 限周期
make run WAVE=1 MAXCYCLE=5000
# 编译 + 运行 + DiffTest + 波形
make run DIFFTEST=1 WAVE=1
# 编译 + 运行 + 内存追踪
make run MTRACE=1
# 打开波形文件
make wave
# 反汇编 ELF 文件
riscv64-linux-gnu-objdump -d build/xxx.elf | less
# 查看 ELF 段信息
riscv64-linux-gnu-readelf -S build/xxx.elf
8.2 GTKWave 快捷键
| 快捷键 | 功能 |
|---|---|
| Ctrl++ | 放大 |
| Ctrl+- | 缩小 |
| Ctrl+F | 搜索信号值 |
| Home | 跳转到起始 |
| End | 跳转到末尾 |
| 左/右箭头 | 在时间轴上移动 |
九、PA 讲义相关思考题
Q: 为什么说"机器永远是对的"?
回答:PA 讲义的核心哲学。如果程序运行结果不对:
- 不是编译器的 bug(99.99% 不是)
- 不是 Verilator 的 bug(99.99% 不是)
- 不是电脑的问题
- 是你的代码的问题
接受这一点,才能停止无谓的怀疑,专注于排查自己的实现。
Q: RTFM (Read The Fantastic Manual) 在调试中的作用?
回答:当一条指令行为与预期不符时,唯一权威的参考是 RISC-V ISA 手册。不要凭记忆实现指令——每条指令都应该对照手册中的精确描述:
- 立即数的位域排列
- 符号扩展的精确规则
- 溢出行为(RISC-V 大多数运算忽略溢出)
- 除零行为(RISC-V 规定除零结果为全 1,不触发异常)
Q: 什么时候应该放弃调试,重新设计?
回答:如果一个 bug:
- 修了一处,另一处又坏了(说明架构设计有根本问题)
- 无法隔离到单个模块(信号依赖关系太复杂)
- 涉及时序错误(组合逻辑环路、setup violation)
这时应该退一步思考是否设计本身需要重构,而不是打补丁。
十、学习建议
- 养成写完就跑 DiffTest 的习惯 — 不要攒一堆修改再测试
- 波形文件要小 — 用 MAXCYCLE 限制,否则 GTKWave 打开都很慢
- 建立信号 checklist — 每次看波形按固定顺序检查信号
- 记录 debug 过程 — 写下"现象→假设→验证→结论",避免反复踩同一个坑
- 学会用
$display— 在 RTL 中临时加调试打印,确认某个内部信号的值
// 临时调试代码 (调试完成后删除)
always @(posedge clock) begin
if (rst_n_sync && ifu_valid)
$display("PC=%h INSTR=%h EXU_OPT=%d", pc, instr, exu_opt);
end
参考资料:
- PA 讲义中关于调试的建议
- GTKWave User's Guide
- RISC-V ISA Specification (用于确认指令行为)
- 项目源码: npc/vsrc/, npc/csrc/difftest.cpp

浙公网安备 33010602011771号