在基于 YSYX 项目开发 RISC-V NPC(Verilator 仿真处理器)的过程中,围绕 AM(AbstractMachine)适配、外设 MMIO 实现、仿真调试遇到了一系列典型问题,在此做完整复盘总结,所有代码与方案均可直接复用。
一、程序执行陷入原地死循环,DiffTest 全程比对通过
问题现象
运行 dummy 测试程序时,控制台无限刷屏0x8000002c: 0x0000006f j 0,每一条指令的 DiffTest 都显示passed state comparison,程序永远不会正常结束。
根因分析
- 反汇编确认
0x8000002c位于_trm_init函数末尾,对应 AM 框架中halt()函数的默认实现while(1);,编译后生成原地跳转指令。 - NPC 仅通过
ebreak指令的 DPI-C 回调触发停机,AM 的 NPC 平台未对接停机接口,main 函数返回后进入死循环等待外部终止。 - DiffTest 比对通过说明 CPU 硬件逻辑无错误,参考模型 Spike 执行到此处也会进入相同死循环,属于软件适配缺失。
解决方案
修改abstract-machine/am/src/riscv/npc/trm.c中的halt()函数,通过内联汇编执行ebreak指令触发 NPC 停机,同时通过a0寄存器传递退出码:
c
运行
void halt(int code) {
asm volatile (
"mv a0, %0\n"
"ebreak\n"
:
: "r"(code)
: "a0"
);
__builtin_unreachable();
}
二、串口无字符输出,printf 无任何打印
问题现象
运行 hello 测试程序时,程序可以正常停机,但控制台没有任何字符输出,printf 完全失效。
根因分析
- AM 的
printf最终会调用平台相关的putch()函数输出单个字符。 - 原 NPC 平台
trm.c中putch()为空实现,没有对接 MMIO 串口地址。 - RISC-V 架构通过 MMIO(内存映射 IO)访问外设,需要软件向指定地址写数据,仿真侧捕获地址后调用系统调用打印。
解决方案
在trm.c中实现putch(),向串口数据寄存器地址0xa00003f8写入单字节数据,与 NPC 仿真侧的 MMIO 地址对齐:
c
运行
#define SERIAL_DATA_PORT 0xa00003f8
void putch(char ch) {
outb(SERIAL_DATA_PORT, ch);
}
同时需确认仿真侧paddr.cpp中存在对应地址判断,执行putchar(c)并调用fflush(stdout)刷新缓冲区。
三、时钟测试读数全为 0,RTC 功能失效
问题现象
运行 am-tests 的 RTC 测试项时,年、月、日、时、分、秒全部输出 0,运行时长也始终为 0。
根因分析
- 原
timer.c中__am_timer_uptime和__am_timer_rtc均为空实现,直接返回 0。 - AM 通过 IOE 抽象层访问时钟,需要软件通过 MMIO 读取外设寄存器,仿真侧返回主机系统时间。
解决方案
修改abstract-machine/am/src/riscv/npc/timer.c,通过 MMIO 地址读取仿真侧提供的时钟寄存器:
c
运行
#define RTC_UPTIME_LO 0xa0000048
#define RTC_UPTIME_HI 0xa000004c
#define RTC_YEAR 0xa0000100
#define RTC_MONTH 0xa0000104
#define RTC_DAY 0xa0000108
#define RTC_HOUR 0xa000010c
#define RTC_MINUTE 0xa0000110
#define RTC_SECOND 0xa0000114
void __am_timer_uptime(AM_TIMER_UPTIME_T *uptime) {
uint32_t hi1, lo, hi2;
do {
hi1 = inl(RTC_UPTIME_HI);
lo = inl(RTC_UPTIME_LO);
hi2 = inl(RTC_UPTIME_HI);
} while (hi1 != hi2);
uptime->us = ((uint64_t)hi1 << 32) | lo;
}
void __am_timer_rtc(AM_TIMER_RTC_T *rtc) {
rtc->year = inl(RTC_YEAR);
rtc->month = inl(RTC_MONTH);
rtc->day = inl(RTC_DAY);
rtc->hour = inl(RTC_HOUR);
rtc->minute = inl(RTC_MINUTE);
rtc->second = inl(RTC_SECOND);
}
其中两次读取高 32 位的操作用于避免计数器进位导致的数值跳变,保证 64 位时间戳读取的原子性。
四、make run 输出 TODO 占位符,无法一键运行程序
问题现象
在 am-kernels 目录执行make ARCH=riscv32e-npc run时,编译正常完成,但运行阶段只输出TODO: add command here to run simulation,不会自动启动 NPC 仿真。
根因分析
AM 框架的平台脚本scripts/platform/npc.mk中,run目标仅保留了 TODO 占位提示,没有配置实际的仿真器启动命令。
解决方案
修改abstract-machine/scripts/platform/npc.mk,补充 NPC 仿真器路径和运行命令,添加-b批处理参数实现自动执行:
makefile
# NPC 仿真器可执行文件绝对路径
NPC_EXEC := /home/xuhaitao/ysyx-workbench/npc/obj_dir/Vrv32
run: insert-arg
@echo "### Running on NPC RISC-V Simulator ###"
@$(NPC_EXEC) -b $(IMAGE).bin
保留insert-arg依赖,确保运行前自动将mainargs参数写入镜像二进制。
五、程序停机时日志重复打印两次
问题现象
程序正常触发 ebreak 结束时,控制台会连续打印两次HIT GOOD TRAP结束日志,内容基本一致。
根因分析
两处代码独立添加了停机日志:
- DPI 回调层 :
tb_rv32.cpp的ebreak()函数中,设置完停机状态后直接打印了一次日志。 - 调试状态机层 :
cpu.cpp的cpu_exec()函数中,统一处理所有停机场景时,又打印了一次标准日志。
执行流程上,ebreak 指令先触发 DPI 回调打印第一次,随后指令执行流回到状态机,检测到停机状态后打印第二次。
解决方案
删除 DPI 回调层的冗余日志,只保留cpu_exec作为统一的日志出口。该位置是所有停机场景(ebreak、异常、断点、监视点)的公共出口,逻辑更完整,还会自动配合指令环形缓冲区等调试信息输出。
注释或删除ebreak()函数内的打印语句即可:
cpp
运行
// printf("DEBUG: ebreak triggered! PC=0x%08x, ret_code=%d\n", top->pc, ret_code);
// fflush(stdout);
整体总结
上述问题均属于 YSYX 项目 NPC 阶段的典型适配问题,核心矛盾在于AM 抽象层与 NPC 仿真平台的接口不对齐:
- 停机机制:需要从软件侧主动触发 ebreak,对接仿真侧的 DPI 停机回调
- 外设访问:通过 MMIO 地址空间映射,将软件访存请求重定向到主机系统调用
- 工程脚本:补充平台运行脚本,实现编译 - 运行全流程自动化
所有问题均不涉及 CPU 核心硬件逻辑错误,DiffTest 全程比对通过验证了 RTL 设计的正确性,问题全部集中在软件抽象层与仿真环境的适配对接上。