YSYX 项目 NPC 开发阶段问题复盘与解决方案汇总(1)

在基于 YSYX 项目开发 RISC-V NPC(Verilator 仿真处理器)的过程中,围绕 AM(AbstractMachine)适配、外设 MMIO 实现、仿真调试遇到了一系列典型问题,在此做完整复盘总结,所有代码与方案均可直接复用。


一、程序执行陷入原地死循环,DiffTest 全程比对通过

问题现象

运行 dummy 测试程序时,控制台无限刷屏0x8000002c: 0x0000006f j 0,每一条指令的 DiffTest 都显示passed state comparison,程序永远不会正常结束。

根因分析

  1. 反汇编确认0x8000002c位于_trm_init函数末尾,对应 AM 框架中halt()函数的默认实现while(1);,编译后生成原地跳转指令。
  2. NPC 仅通过ebreak指令的 DPI-C 回调触发停机,AM 的 NPC 平台未对接停机接口,main 函数返回后进入死循环等待外部终止。
  3. 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 完全失效。

根因分析

  1. AM 的printf最终会调用平台相关的putch()函数输出单个字符。
  2. 原 NPC 平台trm.cputch()为空实现,没有对接 MMIO 串口地址。
  3. 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。

根因分析

  1. timer.c__am_timer_uptime__am_timer_rtc均为空实现,直接返回 0。
  2. 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结束日志,内容基本一致。

根因分析

两处代码独立添加了停机日志:

  1. DPI 回调层tb_rv32.cppebreak()函数中,设置完停机状态后直接打印了一次日志。
  2. 调试状态机层cpu.cppcpu_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 设计的正确性,问题全部集中在软件抽象层与仿真环境的适配对接上。