# 扫雷(RISC-V / cpu21)— 纯 C + MMIO 显示 + 中断读鼠标 给 `pipelined-cpu` 里那颗 CPU(`cpu21_riscv_redirect_int_bpb`)写的扫雷。 **游戏逻辑和三个中断入口都由纯 C 写成**,用 RISC-V 工具链编译成机器码后固化进片内 ROM 运行。 ``` saolei/ ├─ sw/ │ ├─ mmio.h 全部 MMIO 地址与格子编码(换外设地址只改这一个文件) │ ├─ saolei.c 纯 C 扫雷:背景界面(main) + 关卡(level_main) + 棋盘与绘制 │ ├─ isr.c **中断读鼠标的代码**:三个固定地址入口 + 纯 C 服务主体 isr_body │ ├─ start.c C 启动(设 sp、清 .bss、进 main)——只有这一处必需的内联汇编 │ └─ linker.ld ROM/RAM 布局:复位 0x0、中断入口 0x100/0x200/0x300、栈顶 0x0FFC └─ tools/ ├─ build.ps1 一键构建:C → .o → ELF → .hex + cpu21_saolei_rom.vh ├─ check_isa.py 逐条反汇编检查:有没有用到这颗核没实现的指令 └─ elf2hex.py 裸二进制 → $readmemh hex + ROM 初始化头文件 ``` 配套硬件在 Vivado 工程里(不在本目录):`srcs/rtl/cpu21_mmio.v`(外设中枢)、 `cpu21_vga.v`(显示)、`cpu21_seg7.v`(数码管)、`cpu21_pointer.v`(按键当鼠标)、 `cpu21_ps2_mouse.v` / `cpu21_ps2_kbd.v`(可选真鼠标/真键盘)、 `srcs/board/cpu21_board_top.v`(上板顶层)。 --- ## 1. 程序结构:背景"伪操作系统" + 关卡 + 鼠标,正好占三个中断 CPU 复位后从 `0x0` 开始跑 `main()`(**背景程序**,从头到尾一直运行),它画开始界面 并等鼠标。整套交互是这样串起来的: ```mermaid sequenceDiagram participant BG as 背景 main()(0x0 起,一直跑) participant LV as 关卡 level_main()(IRQ1 = 0x100) participant MS as 鼠标 ISR(IRQ2 = 0x200) BG->>BG: 画开始界面(标题 + 绿色"开始"按钮 + 光标框) BG->>BG: 点中"开始"按钮 → 写 LEVEL 寄存器 = 1 Note over BG: 硬件拉起 irq_i[1],CPU 跳到 0x100 BG->>LV: isr1_entry:保存现场 + 打开 MIE → level_main() LV->>LV: 初始化关卡(布雷、画盘面) MS-->>LV: 鼠标事件中断(IRQ2 优先级更高,嵌套进来) LV->>LV: 轮询事件队列:移动光标 / 揭开 / 插旗 LV->>BG: 点左上角黄格 = 返回 → 清 LEVEL + uret Note over BG: 回到被打断的那条指令,继续等下一次"开始" ``` | 入口 | 地址 | 作用 | 对应 RTL 参数 | | ---- | ------- | ----------------------------------- | ------------- | | 复位 | `0x000` | `_start`:设栈、进 `main` | `RESET_PC` | | IRQ1 | `0x100` | **关卡(游戏)程序** `level_main()` | `IRQ1_VECTOR` | | IRQ2 | `0x200` | **鼠标读取** `isr_body()` | `IRQ2_VECTOR` | | IRQ3 | `0x300` | 备用(只 `uret`) | `IRQ3_VECTOR` | **地址是链接脚本定死的**(`linker.ld` 里 `.text.isr1 0x100 : {...}`),所以先定地址、 再设 RTL 参数,两边成对;不存在"编完才知道地址"的问题。优先级 IRQ3 > IRQ2 > IRQ1, 所以"关卡跑在 IRQ1、鼠标跑在 IRQ2"天然允许鼠标嵌套打断关卡。 中断硬件行为(见 `cpu21_riscv_irq_ctrl.v`):`irq_i` 上升沿置挂起位;响应时硬件自动清 挂起位、清 `ustatus[0]`(MIE)、把返回地址压进内部 EPC 栈;返回用 **`uret`**;**硬件不保存 任何通用寄存器**。因此: - `isr1_entry`(游戏体量大会调函数)保存**全部调用者保存寄存器**,并用 `csrrsi x0,0x004,1` 打开 MIE 让鼠标中断能嵌套; - `isr2_entry` 只存 `ra`,C 主体 `isr_body` 用 `-ffixed-x1,x5..x7,x10..x17,x28..x31` 编译,逼 GCC 只用 `s0-s11`(callee-saved,函数自己会存/恢复)和 `sp` —— 这样纯 C 主体不会破坏被打断的代码; - 事件流:鼠标/按键 → RTL 事件 FIFO(深 16)→ 中断 → `isr_body` 搬进软件环形队列 → 主循环 `handle_event()` 处理。FIFO 读空后中断线自动回 0,下一个事件重新产生上升沿, 所以不丢事件;主循环只能轮询(这颗核没有 `wfi`)。 ## 2. 针对这颗 CPU 的硬约束(改 C 代码时别踩) `cpu21_riscv_decoder.v` 实现的是 RV32I 的一个子集: | 类别 | 有 | **没有** | | ------ | -------------------------------------------- | ---------------------------------- | | R 型 | add sub sll slt sltu xor srl sra or and | — | | M 扩展 | mul mulh mulhsu mulhu **divu remu** | **div / rem(有符号)** | | I 型 | addi slti sltiu xori ori andi slli srli srai | — | | 访存 | **lw / sw / sb** | **lb / lh / lbu / lhu / sh** | | 高位 | lui | **auipc** | | 控制流 | 6 条分支 + jal + jalr | — | | 系统 | ecall uret csrrw csrrsi csrrci | mret / sret / fence / ebreak / wfi | 对应做法:不按字节*读*内存(**写**显存用 `sb` 可以)、不做有符号除法、索引运算全用 2 的幂 + 移位/掩码、**不能有 `.data`/`.rodata`**(数据空间读不到 ROM,所以没有带初值的 全局变量、没有字符串/常量表);字模放在 RTL 里,不在软件里。 ## 3. 构建(已实测通过) 工具链:xPack `riscv-none-elf-gcc`(本机装在 `C:\Users\29653\tools\riscv\xpack-riscv-none-elf-gcc-15.2.0-1\bin`,已加入用户 PATH)。 ```powershell cd <仓库>\saolei powershell -ExecutionPolicy Bypass -File tools\build.ps1 # 若没配 PATH:-Prefix 'C:\Users\29653\tools\riscv\xpack-riscv-none-elf-gcc-15.2.0-1\bin\riscv-none-elf-' ``` | 步骤 | 说明 | | ------------ | -------------------------------------------------------------------------------------------- | | 编译 | `start.c`/`saolei.c` 正常编;`isr.c` 额外加 `-ffixed-x...` | | 链接 | `-T sw/linker.ld`;脚本里的 `ASSERT` 会拦住 `.data`/`.rodata` | | **反汇编** | `build/saolei.lst` ← 这就是"编译出来的汇编",可以直接读 | | **ISA 检查** | `tools/check_isa.py` 逐条比对指令白名单,出现 `auipc`/`lb`/`lbu`/`div`… 直接报错并给修复提示 | | 转格式 | `build/saolei.hex`(`$readmemh`)+ `pipelined-cpu/srcs/board/cpu21_saolei_rom.vh` | 当前实测结果:`text=3368 B, data=0, bss=1436 B`,**842 条指令全部通过 ISA 检查**, 入口地址 `_start=0x0`、`isr1_entry=0x100`、`isr2_entry=0x200`、`isr3_entry=0x300`。 编译参数里有三处是这颗核/工具链的坑,已经写在脚本注释里: 1. `-march=rv32im_zicsr` —— GCC 15 起 `rv32im` 不含 Zicsr,`csrrsi` 会报 `unrecognized opcode ... extension 'zicsr' required`; 2. **不要禁链接器松弛**(别加 `-mno-relax`/`-Wl,--no-relax`)—— gcc 的函数调用默认是 `auipc ra,%pcrel_hi` + `jalr`,要靠链接器松弛压成一条 `jal`;禁掉后 35 条 `auipc` 会留在代码里(这颗核没有)。用 `-msmall-data-limit=0` 保证松弛不会引入 gp 相对寻址; 3. 中断入口地址必须写在**段声明**上(`.text.isr1 0x100 : {...} > ROM`)—— 只写 `. = 0x100;` 会被 `ld` 忽略(实测 isr1 掉到了 0x14)。 ## 4. MMIO 地址表(与 `cpu21_mmio.v` 的译码一致) | 地址 | 外设 | | -------------------- | ------------------------------------------------------------------------------- | | `0x0000_1000` | LED 数据寄存器(低 16 位点亮板载 LED) | | `0x0000_1004` | 七段数码管:8 位十六进制数(低 4 位 = 最右) | | `0x0000_1100+` | 鼠标:STATUS / DATA / ACK / CTRL / INPUT / TIME / FRAME / EVCNT / LEVEL / MAGIC | | `0x0000_1140+` | 显示控制器:CTRL(光标可见、背景色)/ CURSOR(列、行)/ MAGIC | | `0x0000_2000-0x2FFF` | 显存:40 列 × 30 行、每格 2 字节(共 2400 字节) | `LEVEL` 是**软件中断**寄存器:写 1 拉起 `irq_i[1]`(关卡入口),关卡返回前写 0 清掉, 否则下次点"开始"没有新的上升沿。`MAGIC`(`0xCAFE0001/2`)可以读来自检 MMIO 是否活着。 格子编码为两个字节:第一个字节低 6 位是字形编号,第二个字节低 3 位是颜色;字形 0 空白、 1..8 数字、9 未揭开、10 旗、11 雷、12 爆炸、13 问号、14 横线、15 实心、16..41 为 A-Z; 颜色是 3 位调色板。 ## 5. 怎么跑起来 **仿真**(用你现成的测试平台): ```powershell copy build\saolei.hex <仓库>\pipelined-cpu\srcs\rom\saolei.hex # 把 srcs/tb/tb_no_intr.v 顶部的 ROM_FILE 改成 "saolei.hex" ``` **上板**:在 Vivado 里给综合/仿真**定义宏 `SAOLEI_ROM`**(Settings → Verilog Macros), `cpu21_board_top.v` 就会加载 `cpu21_saolei_rom.vh`;不定义仍是原来的跑马灯程序。 接 VGA 显示器,按 `CPU_RESETN` 复位 → 出现开始界面(标题 + 绿色按钮 + 闪动光标框)。 **操作**(板载 5 向按键一直有效): | 操作 | 板载按键 | PS/2 设备(SW15 选) | | ------------------------------------ | ------------------------------------- | ------------------------------------- | | 移动光标 | `BTNU` / `BTND` / `BTNL` / `BTNR` | 键盘 `↑↓←→`(按住 = 连续)/ 鼠标移动 | | 左键(揭开;开始界面 = 选难度/开始) | `BTNC` | 键盘 `Enter`/空格 / 鼠标左键 | | 右键(插旗 / 取消旗) | `SW3`(或 `SW0`)拨上 + `BTNC` | 键盘左 `Shift`/`F`/`C` / 鼠标右键 | | 返回上一层(关卡内) | 光标移到盘面**左上角黄格**再按左键 | 同上 | **PS/2 键鼠**(板载 USB HOST 口,也可把 Pmod PS/2 插在 JA):顶层参数 `USE_PS2_MOUSE` 默认为 1,鼠标解码器和键盘解码器都挂在同一根 PS/2 线上,**用 SW15 选哪个生效**(0 = 键盘,1 = 鼠标)。键盘解的是扫描码集 2(`E0` 扩展前缀、`F0` 松开 前缀,只对按下产生事件),映射成与鼠标**完全一样**的事件格式,所以 C 代码一行不用改。 接收器只做**接收**(不实现主机发 `0xF4` 使能上报):多数键鼠上电即上报。上板时用 LED 诊断看“当前设备收到的字节数”(见根目录 README 的“PS/2 键鼠”一节)。 USB 鼠标在这个核上不现实(协议栈放不下)。 **踩过的坑(值得写进报告)**:`cpu21_ps2_mouse.v` 早期把 11 位帧**整体右移**、又在 停止位还没移进来时就判 `frame_q[10]`,并且把 PS/2 的**奇**校验当成**偶**校验判,结果 真设备发来的帧**全部被丢掉** —— 上板表现就是“插上鼠标毫无反应、收到的字节数恒为 0”, 很容易误判成硬件/跳线问题。现在帧按**位的下标**存(`frame_q[i]` = 第 i 位)、停止位 单独判(它就在当前拍的 `ps2_data` 上)、校验按奇校验判,并且 `srcs/tb/tb_ps2_mouse.v` / `tb_ps2_kbd.v` 两个自测里都带“故意发错校验 → 必须丢帧” 的用例。 ## 6. 验证状态(说实话) | 部分 | 状态 | | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | | C 工具链链路 | 已实测通过:`text=4608 B, data=0, bss=2508 B`,**1152 条指令全部通过 ISA 检查** | | 游戏逻辑(布雷、`count_adj`、三档难度、`draw_tile`) | PC 侧独立测试通过(3 难度 × 40 局重算比对 + 端到端显示测试) | | 仿真显示(`tb_saolei` / `tb_saolei_play` + `tools/fb2png.py`) | 通过:整屏 dump 能还原成 PNG,hard 盘面(30×16)整屏都能画出来 | | `cpu21_mmio.v` / `cpu21_vga.v` / `cpu21_seg7.v` / `cpu21_pointer.v` / 板级顶层 / XDC | `xvlog` 编译通过;已综合、已上板 | | 上板实测 | VGA 画面、开始界面、三档难度、开格/插旗、板载按键均已实测可用 | | PS/2 键鼠 | 接收器逻辑有自测(`tb_ps2_mouse` / `tb_ps2_kbd`);板载 USB HOST 口待复测 | 建议验证顺序:① 跑 `pipelined-cpu/tools/run_regress.ps1` 确认跑马灯/中断/指令测试仍全 PASS;② 定义 `SAOLEI_ROM` 跑行为仿真(`tb_saolei` 会自动打完一局并 dump 显存); ③ 上板看 VGA;④ 接键鼠时先用 `srcs/tb/tb_ps2_*` 确认解码无误,再看 LED 诊断确认板载 桥接芯片到底有没有转发你插的那个设备。 ## 7. 已知可改进项 - **难度**:已按微软扫雷做成三档 —— `EASY` 9×9/10 雷、`NORMAL` 16×16/40 雷、 `HARD` 30×16/99 雷。盘面尺寸是**运行期变量**(`g_bw/g_bh/g_mines/g_ncells`), 开始界面上点那一行就即时切换、重新画盘面;所有索引走 `IDX_R/IDX_C` 宏。 再想更大就要动 `bfs_q` 的打包方式(数据 RAM 只有 4 KB)。 - **输入**:板载 5 向按键、PS/2 鼠标、PS/2 键盘三种事件源在顶层合并成同一种格式 (±8 = 一格),键鼠用 SW15 切换(见上文“操作”)。 - **文字显示**:显存每格 2 字节(字形[5:0] + 颜色[2:0]),RTL 内置 A-Z 和数字/游戏 符号字模,开始界面显示 `MINES`、`EASY`、`NORMAL`、`HARD`、`START`。注意**字符颜色 不能取黑色**(黑字画在黑背景上等于看不见)。 - 回归覆盖:`run_regress.ps1` 里还没有扫雷的自动测试项(PS/2 接收器有单独的 `tb_ps2_mouse` / `tb_ps2_kbd`);也还没有“自动玩一局 + 显存 dump + PNG 比对”的 一键回归。