本文结合
ysyx-workbench/nemu的实际代码,讲解"踪迹"这一调试基础设施,以及它在 NEMU 中的完整实现。
摘要
程序本质上是一个状态机:
S0 -> S1 -> S2 -> S3 -> ...
当程序复杂、状态数量变多时,仅靠观察最终状态很难定位 bug。
踪迹(Trace)的作用就是:
记录状态机运行过程中的关键事件
出问题后回溯最近发生了什么
NEMU 中常见的踪迹包括:
ITRACE 指令踪迹
MTRACE 内存访问踪迹
FTRACE 函数调用踪迹
ETRACE 异常/中断踪迹
IRINGBUF 最近指令环形缓冲
DIFFTEST 与参考模拟器对拍
SDB 交互式状态观察
本文会逐项对应到代码。
关键词
NEMU、ITRACE、MTRACE、FTRACE、ETRACE、IRINGBUF、DIFFTEST、SDB、riscv32、ysyx
一、踪迹的本质
踪迹不是简单"多打印一点日志"。
它的本质是:
记录状态机中的关键转移
例如:
执行了哪条指令
读写哪块内存
调用了哪个函数
发生了什么异常
最近 16 条指令是什么
当前 CPU 状态和参考模拟器是否一致
这些信息结合起来,就能回答三类问题:
- 程序从哪里跑到了哪里?
- 状态为什么变成现在这样?
- 出错前最后发生了什么?
二、NEMU 中与踪迹相关的模块
对应代码:
nemu/src/cpu/cpu-exec.c
nemu/src/utils/log.c
nemu/src/utils/disasm.c
nemu/src/utils/ftrace.c
nemu/src/memory/paddr.c
nemu/src/memory/vaddr.c
nemu/src/isa/riscv32/system/intr.c
nemu/src/cpu/difftest/
nemu/src/monitor/sdb/
对应配置:
nemu/Kconfig
nemu/.config
典型配置项:
CONFIG_TRACE
CONFIG_ITRACE
CONFIG_ITRACE_COND
CONFIG_MTRACE
CONFIG_FTRACE
CONFIG_IRINGTRACE
CONFIG_ETRACE
CONFIG_DIFFTEST
三、日志系统:所有踪迹的基础
文件:
nemu/src/utils/log.c
代码:
void init_log(const char *log_file) {
log_fp = stdout;
if (log_file != NULL) {
FILE *fp = fopen(log_file, "w");
Assert(fp, "Can not open '%s'", log_file);
log_fp = fp;
}
Log("Log is written to %s", log_file ? log_file : "stdout");
}
bool log_enable() {
return MUXDEF(CONFIG_TRACE,
(g_nr_guest_inst >= CONFIG_TRACE_START) &&
(g_nr_guest_inst <= CONFIG_TRACE_END),
false);
}
调用方式:
./riscv32-nemu-interpreter -l trace.log image.bin
这意味着:
-l 指定日志文件
CONFIG_TRACE 决定是否启用日志
CONFIG_TRACE_START / CONFIG_TRACE_END 决定 trace 区间
日志宏:
#define _Log(...) do { \
printf(__VA_ARGS__); \
log_write(__VA_ARGS__); \
} while (0)
所以:
Log(...) 既打印到终端,也写入日志文件
log_write(...) 只写日志文件
四、ITRACE:指令踪迹
1. 什么时候生成
文件:
nemu/src/cpu/cpu-exec.c
代码:
static void exec_once(Decode *s, vaddr_t pc) {
s->pc = pc;
s->snpc = pc;
isa_exec_once(s);
cpu.pc = s->dnpc;
#ifdef CONFIG_ITRACE
char *p = s->logbuf;
p += snprintf(p, sizeof(s->logbuf), "%08x:", s->pc);
int ilen = s->snpc - s->pc;
int i;
uint8_t *inst = (uint8_t *)&s->isa.inst;
for (i = ilen - 1; i >= 0; i --)
p += snprintf(p, 4, " %02x", inst[i]);
disassemble(...);
#endif
}
ITRACE 记录:
指令地址
指令机器码
反汇编结果
例如:
80000000: 00000297 auipc t0,0
80000004: 00028823 sb zero,16(t0)
2. 在哪里输出
static void trace_and_difftest(Decode *_this, vaddr_t dnpc) {
#ifdef CONFIG_ITRACE_COND
if (ITRACE_COND) { log_write("%s\n", _this->logbuf); }
#endif
if (g_print_step) {
#ifdef CONFIG_ITRACE
puts(_this->logbuf);
#endif
}
IFDEF(CONFIG_DIFFTEST, difftest_step(_this->pc, dnpc));
}
含义:
ITRACE_COND 为真 -> 写入日志文件
g_print_step 为真 -> 打印到终端
g_print_step 应该由 SDB 控制:
g_print_step = (n < MAX_INST_TO_PRINT);
这样:
si 1、si 10 -> 打印
c、-b -> 不逐条打印
3. 反汇编
文件:
nemu/src/utils/disasm.c
它使用 Capstone:
void disassemble(char *str, int size,
uint64_t pc, uint8_t *code, int nbyte);
没有反汇编时,ITRACE 只剩机器码,调试难度会明显增加。
五、IRINGBUF:最近指令环形缓冲
文件:
nemu/src/cpu/cpu-exec.c
定义:
#define IRING_BUF_SIZE 16
static char iringbuf[IRING_BUF_SIZE][128 + 4];
static size_t iringbuf_index = 0;
static bool iringbuf_initialized = false;
写入:
if (iringbuf_initialized) {
snprintf(iringbuf[iringbuf_index],
sizeof(iringbuf[0]),
"--> %s", s->logbuf);
iringbuf_index = (iringbuf_index + 1) % IRING_BUF_SIZE;
}
打印:
static void print_iringbuf() {
printf("Recent instructions (most recent last):\n");
...
}
IRINGBUF 的作用是:
正常运行时只保留最近 16 条指令
出问题时一次性打印出来
这比把全部 ITRACE 写到磁盘更适合定位崩溃前现场。
六、MTRACE:内存访问踪迹
文件:
nemu/src/memory/paddr.c
读取:
word_t paddr_read(paddr_t addr, int len) {
if (likely(in_pmem(addr))) {
word_t ret = pmem_read(addr, len);
#ifdef CONFIG_MTRACE
if (!is_instruction_fetch) {
printf("[MTRACE] Read %2d bytes from PMEM ...");
}
#endif
return ret;
}
IFDEF(CONFIG_DEVICE, {
word_t ret = mmio_read(addr, len);
#ifdef CONFIG_MTRACE
printf("[MTRACE] Read %2d bytes from MMIO ...");
#endif
return ret;
});
}
写入:
void paddr_write(paddr_t addr, int len, word_t data) {
#ifdef CONFIG_MTRACE
if (in_pmem(addr)) {
printf("[MTRACE] Write ... PMEM ...");
} else {
printf("[MTRACE] Write ... MMIO ...");
}
#endif
...
}
为什么要区分指令取指?
在:
nemu/src/memory/vaddr.c
中:
word_t vaddr_ifetch(vaddr_t addr, int len) {
is_instruction_fetch = true;
word_t ret = paddr_read(addr, len);
is_instruction_fetch = false;
return ret;
}
is_instruction_fetch 用来避免 MTRACE 把每条指令取指都打印出来,否则日志量会爆炸。
七、FTRACE:函数调用踪迹
文件:
nemu/src/utils/ftrace.c
FTRACE 的基本原理:
读取 ELF 符号表
建立 函数地址 -> 函数名 的映射
拦截 jal / jalr / ret
打印函数调用与返回
典型输出:
0x80000000: call [main]
0x80000020: call [foo]
0x80000040: call [bar]
0x80000060: ret [bar]
FTRACE 依赖 ELF 文件。
在你的代码中:
#ifdef CONFIG_FTRACE
char elf_path[256];
strncpy(elf_path, img_file, sizeof(elf_path)-1);
char *dot_bin = strstr(elf_path, ".bin");
if(dot_bin != NULL) {
*dot_bin = '\0';
}
strncat(elf_path, ".elf", ...);
parse_elf(elf_path);
#endif
它会把:
hello.bin
转换成:
hello.elf
然后解析符号。
使用前提:
编译时开启 CONFIG_FTRACE
镜像旁边存在同名 .elf
ELF 中包含符号表
八、ETRACE:异常和中断踪迹
文件:
nemu/src/isa/riscv32/system/intr.c
异常入口:
word_t isa_raise_intr(word_t NO, vaddr_t epc) {
csr_write(MEPC_ADDR, epc);
csr_write(MCAUSE_ADDR, NO);
if (NO == 3) {
nemu_state.state = NEMU_END;
nemu_state.halt_pc = epc;
nemu_state.halt_ret = 0;
return epc;
}
word_t mtvec = csr_read(MTVEC_ADDR);
return mtvec & ~0x3;
}
ETRACE 的理想输出:
Exception [11] triggered at PC = 0x80000100, entry = 0x80000080
Interrupt [7] triggered at PC = 0x80000200, entry = 0x80000080
你的代码中已经预留了类似逻辑:
#ifdef CONFIG_ETRACE
bool is_irq = (NO & 0x80000000u) != 0;
...
#endif
如果被注释掉,需要恢复或重新启用。
ETRACE 适合排查:
ecall后的异常跳转;mret返回错误;mcause/mepc/mtvec设置错误;- 中断没有触发。
九、DIFFTEST:差分测试
文件:
nemu/src/cpu/difftest/
nemu/src/isa/riscv32/difftest/dut.c
在 trace_and_difftest() 中:
IFDEF(CONFIG_DIFFTEST,
difftest_step(_this->pc, dnpc));
DIFFTEST 的思想是:
NEMU 执行一条指令
参考模拟器也执行一条指令
双方比较 PC、通用寄存器、部分 CSR
如果不一致,立即报错
典型错误信息:
DIFFTEST ERROR: Register ... mismatch
NPC=..., REF=... at PC=...
DIFFTEST 适合排查:
- 指令语义错误;
- PC 更新错误;
- 寄存器写回错误;
- 分支/跳转偏移计算错误;
- CSR 状态不同步。
十、SDB 与踪迹的配合
踪迹负责"自动记录",SDB 负责"交互观察"。
SDB 提供:
si 单步执行
info r 查看寄存器
x 查看内存
p 表达式求值
w 监视表达式变化
b 设置断点
例如:
p $pc
x 10 0x80000000
w $a0
两者结合:
ITRACE 告诉你"执行了哪些指令"
SDB 告诉你"当前状态是什么"
IRINGBUF 告诉你"崩溃前最后几条指令"
MTRACE 告诉你"谁改了内存"
FTRACE 告诉你"调用栈是什么"
ETRACE 告诉你"异常怎么触发"
DIFFTEST 告诉你"第一条不一致的指令"
十一、按问题选择踪迹
| 问题 | 推荐踪迹 |
|---|---|
| 不知道程序执行到哪里 | ITRACE / IRINGBUF |
| 内存被改坏 | MTRACE |
| 函数调用关系错误 | FTRACE |
| 异常/中断错误 | ETRACE |
| 指令实现和参考模拟器不一致 | DIFFTEST |
| 想交互式观察状态 | SDB si/info r/x/p |
| 想监视变量变化 | SDB watchpoint |
| 崩溃前最后指令 | IRINGBUF |
十二、典型调试流程
1. 打开配置
在 NEMU 目录:
make menuconfig
选择:
Testing and Debugging
-> Enable tracer
-> Enable instruction tracer
-> Enable memory access tracer
-> Enable ftrace
-> Enable iringtrace
-> Enable exception tracer
-> Enable differential testing
重新编译:
make clean
make
2. 运行并生成日志
./build/riscv32-nemu-interpreter \
-l trace.log \
-b image.bin
3. 查看日志
less trace.log
重点搜索:
ERROR
mismatch
exception
panic
invalid
4. 如果崩溃
检查:
IRINGBUF
PC
寄存器
异常号
最后访问的内存
十三、常见问题
1. ITRACE 不输出
检查:
CONFIG_TRACE
CONFIG_ITRACE
CONFIG_ITRACE_COND
g_print_step
如果 g_print_step 被强制设为 true,会导致所有 c 和 -b 都逐条打印,性能极差。
正确:
g_print_step = (n < MAX_INST_TO_PRINT);
2. MTRACE 太多日志
MTRACE 默认会打印所有内存访问,日志量很大。
建议:
- 只在调试时开启;
- 使用 trace start/end 限制范围;
- 通过
is_instruction_fetch过滤取指。
3. FTRACE 没有函数名
检查 ELF:
riscv64-linux-gnu-readelf -s image.elf | head
如果 ELF 没有符号表,FTRACE 无法输出函数名。
4. ETRACE 没有输出
检查:
CONFIG_ETRACE
intr.c 中对应代码是否被注释
mcause/mepc/mtvec 是否正确
5. DIFFTEST 不可用
检查:
CONFIG_DIFFTEST
参考动态库路径
端口
镜像是否一致
初始寄存器状态是否同步
十四、总结
踪迹系统的本质是:
记录状态机的关键事件
NEMU 中的踪迹分工:
ITRACE 指令流
MTRACE 内存读写
FTRACE 函数调用
ETRACE 异常中断
IRINGBUF 最近指令
DIFFTEST 与参考实现对比
SDB 交互式观察
对应代码:
| 踪迹 | 代码位置 |
|---|---|
| ITRACE | cpu-exec.c、disasm.c |
| IRINGBUF | cpu-exec.c |
| MTRACE | paddr.c、vaddr.c |
| FTRACE | utils/ftrace.c |
| ETRACE | isa/riscv32/system/intr.c |
| DIFFTEST | cpu/difftest/ |
| SDB | monitor/sdb/ |
最终:
不要盲目 printf
先用 ITRACE 看执行流
再用 MTRACE 看内存
用 FTRACE 看调用关系
用 ETRACE 看异常
用 IRINGBUF 看崩溃前现场
用 DIFFTEST 找第一条不一致
用 SDB 观察当前状态
这就是 NEMU 中"踪迹"作为 bug 诊断利器的完整使用方法。
