Files
risc-v-cpu/README.md
T
Alpha 1e5cb54c00 docs(readme): 记录上板实测出来的 PS/2 方向约定(鼠标 dy 取负)
上板已经确认:不定负号的表现就是“左右正常、上下反向”,不是解码错;
另外把“键鼠静止时一个字节都不发”写成显式提醒,避免再被误判成硬件坏。
2026-09-23 22:50:29 +08:00

793 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `cpu21-riscv-4.circ` 的 Verilog 实现
处理器顶层文件:`pipelined-cpu/srcs/rtl/cpu21_riscv_redirect_int_bpb.v`
该设计对应原电路中的“重定向流水线+中断+分支预测”部分,顶层模块为
`cpu21_riscv_redirect_int_bpb`。为便于单独仿真与调试,各功能单元已拆分为
独立文件,顶层的 ALU、BPB 也已移出,具体见文末“文件与模块划分”。
## 目录结构
```text
risc-v/
├─ README.md 本文档
├─ pipelined-cpu/ Vivado 工程目录(也是源文件所在地)
│ ├─ create_project.tcl 一键重建工程的 Tcl 脚本
│ ├─ srcs/rtl/*.v 处理器与外设 RTL(18 个文件,cpu21_riscv_redirect_int_bpb.v 为处理器顶层)
│ │ 外设:cpu21_mmio.v(MMIO 中枢)、cpu21_vga.v(显示)、
│ │ cpu21_seg7.v(数码管)、cpu21_pointer.v(按键当鼠标)、
│ │ cpu21_ps2_mouse.v / cpu21_ps2_kbd.v(PS/2 鼠标、键盘接收)
│ ├─ srcs/board/*.v|*.vh 板级顶层(时钟分频 + 片内 ROM/RAM + 引脚);四份 ROM 初始化:
│ │ cpu21_board_rom.vh / _fast.vh(跑马灯)、risc-v-intrupt-ccab.vh
│ │ (课程中断综合测试程序)、cpu21_saolei_rom.vh(扫雷)
│ ├─ srcs/constrs/*.xdc 引脚与分频时钟约束(+ Nexys4DDR 母版)
│ ├─ srcs/tb/*.v 七个测试平台(含 PS/2 鼠标/键盘的单独自测 tb_ps2_mouse.v / tb_ps2_kbd.v)
│ ├─ srcs/rom/*.hex 测试平台加载的 ROM 镜像
│ ├─ tools/ rv_asm.py(迷你汇编器)、hex2vh.py(hex → ROM 初始化)、
│ │ adapt_intrupt_ccab.py(课程中断程序 → 上板 ROM)、
│ │ run_regress.ps1(一键回归)、check_rtl_text.ps1(RTL 文本体检)、
│ │ rars_f0c874c.jar(课程提供的 RARS 汇编器)、play_saolei.tcl(命令行玩扫雷)
│ └─ programs/ 汇编源程序与期望值:跑马灯 demo、指令集/MMIO 测试、课程原始
│ 资产(risc-v-benchmark_ccab、真值表),以及中断综合测试程序
│ risc-v-intrupt-ccab.asm / .hex
└─ saolei/ 纯 C 扫雷(MMIO 显示 + 中断读鼠标),见下文
├─ sw/ C 源码 + 链接脚本(三个固定中断入口)
└─ tools/ build.ps1(C → ROM)、check_isa.py(指令白名单校验)
```
**仓库里不放任何 Vivado 生成物**(`.xpr`、`.runs`、`.sim`、`.cache`、`.hw`、
`.ip_user_files`、`.Xil`、`vivado.jou/log` 本地已清理干净):需要工程时用
`source create_project.tcl` 一键重建,这样仓库始终是最清爽的源文件状态。
约定:git 只跟踪 `pipelined-cpu/` 下的源文件(`srcs/`、`tools/`、`programs/`、
`create_project.tcl`),Vivado 生成物(`*.xpr`、`*.runs`、`*.sim`、`*.cache`…)由
`.gitignore` 排除。`srcs/rtl/` 只放可综合的处理器源码;板级顶层与约束在 `srcs/board/`、
`srcs/constrs/`;测试平台与 ROM 镜像在 `srcs/tb/`、`srcs/rom/`(镜像是按 MMIO 写 LED
修改过的版本,课程原始程序与 RARS 输出保留在 `programs/`)。
## 已转换的功能
- 五级流水:IF、ID、EX、MEM、WB。
- EX/MEM 与 MEM/WB 前递,以及 load-use 一拍停顿。
- 条件分支 `beq`、`bne`、`blt`/`bge`(有符号)、`bltu`/`bgeu`(无符号)在 EX 段判定,错误预测时清空 IF/ID、ID/EX 并重定向 PC。真值表中 BLT 的 `ALU_OP` 为 SLT,与实现一致。
- `jal`、`jalr` 在 EX 段重定向,返回地址写回 `rd`。
- 8 项全相联 BPB:标签 `PC[11:2]`,每项包含 valid、目标 PC、2 位饱和计数器和 3 位年龄字段。BPB 只由条件分支更新,避免 `jal`、`jalr` 占用不会被查询的表项;未命中时优先使用无效项,否则替换年龄最大的项。
- 三路中断输入 `irq_i[2:0]`:两级同步、上升沿挂起、IRQ3 > IRQ2 > IRQ1 优先级,仅在 `ustatus[0]`(MIE)允许时响应。
- 支持嵌套中断:保存 EPC、`ustatus` 和当前优先级;`URET` 使用当前 `uepc` 返回,并从硬件栈恢复外层上下文。三个入口按照原电路的 `INTpc` 选择:IRQ1=`0x30ac`、IRQ2=`0x31e4`、IRQ3=`0x3310`。`0x22` 是 `ecall` 的服务号 34,不是中断向量。
- CSRRSI、CSRRCI,以及 CSRRW:支持 CSR 地址 `0x004`(`ustatus`)和 `0x041`(`uepc`)。立即数为 `IR[19:15]` 的零扩展值,并按 RISC-V 规则把旧 CSR 值写回 `rd`;这覆盖任务书中的 `csrrsi s6,0x41,0` 和 `csrrw zero,0x41,s6` 用法。
- `ecall`:停机和调试用。`a7 == 34` 是历史遗留的“显示”调用,现已保留为无操作;其余取值(例如程序结尾惯用的 `a7 = 10`)置 `halted_o`,直到 `go_i` 为高。LED 显示改由 MMIO 存储完成(见下文“MMIO 与外设”)。
- `sw` 和 `sb`:输出 `data_wstrb_o`,支持按字节写入。
- `lui`:加载高位立即数,`rd = imm[31:12] << 12`(按需求补充的扩展指令,标准 opcode `0x37`)。
- `sltiu`:I 型 `funct3=011`,立即数符号扩展后按无符号与 rs1 比较。
- M 扩展乘法(`funct7=0000001`):`mul`(乘积低 32 位)、`mulh`(有符号×有符号高 32 位)、`mulhsu`(有符号×无符号高 32 位)、`mulhu`(无符号×无符号高 32 位)。
- M 扩展除法:课程真值表里的 `divu`(`funct3=101`,无符号商)与 `remu`(`funct3=111`,无符号余数)。
以上六条乘法/除法指令都由 **多周期单元** `pipelined-cpu/srcs/rtl/cpu21_riscv_muldiv.v` 完成(见“时序和使用说明”第 7 条),
而不是组合逻辑。
## 指令编码
表中的 `opcode` 是控制器使用的五位字段 `IR[6:2]`,不是包含最低两位的 7 位原始 opcode。
因此它与标准 RISC-V 指令的低 7 位 opcode 相差右移两位。
| 指令类别 | opcode | 说明 |
| ----------------------------------------------------------- | -------: | ----------------------------------------------------------------------------------------- |
| R 型 | `0x0c` | add/sub/and/or/xor/slt/sltu/sll/srl/sra |
| M 扩展(R 型) | `0x0c` | `funct7=0000001`:mul/mulh/mulhsu/mulhu、divu(`funct3=101`)、remu(`funct3=111`) |
| I 型 ALU | `0x04` | addi/andi/ori/xori/slti/sltiu/slli/srli/srai |
| `lui`(扩展) | `0x0d` | 加载高位立即数,`rd = imm[31:12] << 12` |
| `lw` | `0x00` | `funct3=010` |
| `sw` / `sb` | `0x08` | `funct3=010` / `000` |
| `beq` / `bne` / `blt` / `bge` / `bltu` / `bgeu` | `0x18` | `funct3=000` / `001` / `100` / `101` / `110` / `111` |
| `jal` | `0x1b` | J 型立即数 |
| `jalr` | `0x19` | `funct3=000` |
| 系统类 | `0x1c` | `ecall`、`URET`、CSRRSI、CSRRCI |
`ecall` 和 `URET` 都是 `opcode=0x1c、funct3=0`,由 `IR[21]` 区分:
`IR[21]=0` 是 `ecall`,`IR[21]=1` 是 `URET`。这一点来自原控制器中的 `IR21` 分支逻辑。
## MMIO 与外设
外设寄存器映射在数据存储器地址空间,由 `pipelined-cpu/srcs/rtl/cpu21_mmio.v` 在 MEM 级译码。
这些地址都在 4 KB 数据 RAM(`0x0000`–`0x0FFC`)之外,因此不会与 RAM 冲突。
每行的字节偏移都按**字**对齐译码(同一 32 位寄存器内任意字节偏移都命中)。
| 地址 | 外设 | 写 | 读 |
| --------------------------- | -------------- | ------------------------------------------------------------------------------------ | ---------------------------- |
| `0x0000_1000` | LED 数据寄存器 | `sw` 写全字;`sb` 按 `data_wstrb_o` 只改对应字节通道,`led_valid_o` 拉高一拍 | `lw` 返回 LED 寄存器当前值 |
| `0x0000_1004` | 七段数码管 | `sw`/`sb` 写 8 位十六进制数(低 4 位 = 最右边那个数码管) | `lw` 读回 |
| `0x0000_1100`+ | 鼠标寄存器组 | STATUS / DATA / ACK / CTRL / INPUT / TIME / FRAME / EVCNT / LEVEL / MAGIC | 同左(DATA 读无副作用) |
| `0x0000_1140`+ | 显示控制器 | CTRL(光标可见、背景色)/ CURSOR(列、行) | 同左(另含 MAGIC 自检值) |
| `0x0000_2000`–`0x2FFF` | 显存 | 40 列 × 30 行、每格 2 字节(低字节 = 字形[5:0]、高字节 = 颜色[2:0],共 2400 字节),写端口接到板级顶层的双口 RAM | `lw` 组合读回 |
其中 `LEVEL`(`0x1120`)是一个**软件中断**寄存器:写 1 会拉起 `irq_i[1]`,用来让程序
"跳到关卡入口";`MAGIC`(`0xCAFE0001`/`0xCAFE0002`)可以读来自检 MMIO 是否活着。
鼠标事件 FIFO(深度 16)配合 `mouse_irq_o` 提供"事件到达即中断",`isr_body` 读空 FIFO 后
中断线自动回 0,因此不会丢事件。
地址译码按**字**对齐:同一 32 位寄存器内的任意字节偏移(如 `0x1001`)都算命中,
具体修改哪几个字节由 `data_wstrb_o` 决定。`data_addr_o` 在命中 MMIO 时不会
访问外部 RAM;读数据由 `cpu21_mmio` 的输出在顶层与 `data_rdata_i` 做选择。
典型用法(LED 写 `0x12345678`,可用 `pipelined-cpu/tools/rv_asm.py` 汇编):
```asm
lui t0, 0x1 # t0 = LED_ADDR = 0x00001000
lui t1, 0x12345
addi t1, t1, 0x678 # t1 = 0x12345678
sw t1, 0(t0) # MMIO 写 LED -> LED 输出 12345678
lw a0, 0(t0) # MMIO 读回
```
`pipelined-cpu/programs/mmio_test.asm`(期望值见 `mmio_test.expected`)覆盖了
全字写、读回、字节写三种情况。加入新外设(拨码开关、UART 等)时只需在
`pipelined-cpu/srcs/rtl/cpu21_mmio.v` 里扩展地址译码,处理器流水线无需改动。
## 顶层端口约定
- `instr_i`:当前 `instr_addr_o` 对应的 32 位指令。原 Logisim 中由 ROM 提供。
- `data_rdata_i`:当前 `data_addr_o` 的读数据。原 Logisim 中由 MIPS RAM 提供。
- `data_addr_o`、`data_wdata_o`、`data_wstrb_o`、`data_we_o`:数据存储器接口。地址按字节地址输出;若使用原 32 位 RAM,可用 `data_addr_o[11:2]` 作为字地址。
- `irq_i[0]`、`irq_i[1]`、`irq_i[2]`:1、2、3 号中断源;输入应为脉冲或边沿信号,模块内部会同步并锁存挂起状态。
- `go_i`:对应原电路的“继续运行”按键。
- `if_pc_o`、`id_pc_o`、`ex_pc_o`、`mem_pc_o`、`wb_pc_o`:五级流水当前保存的 PC,气泡时输出 0,便于替代原电路的调试探针。
- `rdin_o`、`mdin_o`、`reg_write_o`、`mem_write_o`:对应原电路的寄存器写回和存储器写调试输出。
- `cycle_count_o`、`stall_count_o`、`bubble_count_o`、`conditional_taken_count_o`、`unconditional_branch_count_o`、`prediction_success_count_o`、`prediction_failure_count_o`:对应原电路和课程要求的周期、停顿/气泡、分支和动态预测统计量。
## 时序和使用说明
1. `reset` 为高时异步清空流水线、寄存器文件、BPB、挂起中断和中断栈,PC 置为 `RESET_PC`;`ustatus` 复位为参数 `USTATUS_INIT`,默认 `0x0000_0001`(MIE 上电即使能)。这样做的原因是课程的中断测试程序只在中断处理程序的“保护现场”之后写 MIE,主流程不会开中断;若不默认使能,第一次中断只会挂在 `irq_pending_o` 上而永不响应。参数可覆盖,例如把 `USTATUS_INIT` 设为 `32'h0` 即可恢复“上电关中断”的行为。
2. 指令存储器按组合读处理;数据存储器读数据在一个 MEM 周期内返回。若 RAM 为同步读,需要在外部增加一级数据寄存或相应调整 MEM/WB。
3. 响应中断时允许当前 EX 指令完成,并保存下一条尚未执行指令的 PC;若 EX 是已正确预测的跳转分支,则保存实际目标地址。EX 为空时优先保存 IF/ID 中尚未执行的 PC。这样 `URET` 不会重复执行已经提交的存储或寄存器写操作。
4. 默认 BPB 新表项采用弱不跳转/弱跳转状态:首次更新为不跳转时计数器为 `01`,首次更新为跳转时为 `10`;命中后按 2 位饱和规则增减。
5. `bpb_predict_hit_o`、`bpb_predict_taken_o`、`bpb_mispredict_o` 和 `irq_*_o` 是观测端口,不参与外部控制,可以直接接逻辑分析仪或测试平台。
6. `cycle_count_o` 在 `halted_o` 为高时暂停;load-use 每次计入一个气泡,重定向或中断清空 IF/ID 与当前 IF 时计入两个气泡。
7. 乘除法是多周期指令:结果算完之前 `ex_md_stall` 有效,ID/EX 保持(指令继续占用 EX)、EX/MEM 插气泡、取指/译码冻结,同时屏蔽中断(避免结果未定型就写回)。一次乘/除共占用 34 拍(1 拍装载 + 32 拍迭代 + 1 拍定型),这些等待周期也计入 `stall_count_o` / `bubble_count_o`。乘除法迭代期间不会把错误值写进 rd,也不会因为中断被打断。
原 `.circ` 文件中的 ROM、MIPS RAM、按钮、LED 和调试显示器属于 Logisim 外围,不直接搬入 RTL;其功能分别由指令接口、数据接口、`go_i`、MMIO(`led_data_o`/`led_valid_o`)和调试端口替代。
## 文件与模块划分
| 文件 | 模块 | 职责 |
| ----------------------------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------- |
| `pipelined-cpu/srcs/rtl/cpu21_riscv_redirect_int_bpb.v` | `cpu21_riscv_redirect_int_bpb` | 顶层:五级流水寄存器、子模块例化、PC/IF-ID/ID-EX 控制、对外输出 |
| `pipelined-cpu/srcs/rtl/cpu21_riscv_alu.v` | `cpu21_riscv_alu` | 组合 ALU(加减/移位/逻辑/比较,不做乘除) |
| `pipelined-cpu/srcs/rtl/cpu21_riscv_muldiv.v` | `cpu21_riscv_muldiv` | 多周期乘除法单元:32 拍移位迭代,mul/mulh/mulhsu/mulhu/divu/remu |
| `pipelined-cpu/srcs/rtl/cpu21_bpb_8.v` | `cpu21_bpb_8` | 8 项全相联分支目标缓冲(2 位饱和计数器 + 近似 LRU) |
| `pipelined-cpu/srcs/rtl/cpu21_riscv_decoder.v` | `cpu21_riscv_decoder` | ID 级组合译码器(操作码`IR[6:2]`) |
| `pipelined-cpu/srcs/rtl/cpu21_riscv_regfile.v` | `cpu21_riscv_regfile` | 寄存器堆,`x0` 恒为 0,同周期 WB 写优先旁路 |
| `pipelined-cpu/srcs/rtl/cpu21_riscv_forward_unit.v` | `cpu21_riscv_forward_unit` | EX 级前递网络(EX/MEM 优先于 MEM/WB) |
| `pipelined-cpu/srcs/rtl/cpu21_riscv_store_unit.v` | `cpu21_riscv_store_unit` | EX 级存储数据对齐:`sw`/`sb` 的写数据与字节写掩码 |
| `pipelined-cpu/srcs/rtl/cpu21_riscv_branch_unit.v` | `cpu21_riscv_branch_unit` | EX 级控制流裁决:分支方向/目标、JAL/JALR 重定向 |
| `pipelined-cpu/srcs/rtl/cpu21_riscv_hazard_unit.v` | `cpu21_riscv_hazard_unit` | load-use 冒险、停顿(stall)与冲刷(flush)判定 |
| `pipelined-cpu/srcs/rtl/cpu21_riscv_irq_ctrl.v` | `cpu21_riscv_irq_ctrl` | 中断采样与优先级、`ustatus`/`uepc` CSR、嵌套返回栈 |
| `pipelined-cpu/srcs/rtl/cpu21_riscv_perf_counters.v` | `cpu21_riscv_perf_counters` | 周期/停顿/气泡/分支/预测成功率等统计计数器 |
| `pipelined-cpu/srcs/rtl/cpu21_mmio.v` | `cpu21_mmio` | MMIO 中枢:LED、七段数码管、鼠标寄存器组 + 事件 FIFO + 中断、显示控制器、显存窗口 |
| `pipelined-cpu/srcs/rtl/cpu21_vga.v` | `cpu21_vga` | 640x480@60 显示控制器:按"格子"扫描,8x8 字模放大 2 倍填满 16x16 的格子 |
| `pipelined-cpu/srcs/rtl/cpu21_seg7.v` | `cpu21_seg7` | 板载 8 位数码管动态扫描驱动(共阳极,位选/段选低有效) |
| `pipelined-cpu/srcs/rtl/cpu21_pointer.v` | `cpu21_pointer` | 把 5 向按键变成"鼠标"事件(去抖 + 事件排队,单位 1/8 格) |
| `pipelined-cpu/srcs/rtl/cpu21_ps2_mouse.v` | `cpu21_ps2_mouse` | 可选的真鼠标:PS/2 接收(11 位帧按位下标对齐 + **奇校验** + 3 字节包解析),只实现接收 |
| `pipelined-cpu/srcs/rtl/cpu21_ps2_kbd.v` | `cpu21_ps2_kbd` | 可选的真键盘:扫描码集 2 解码(`F0` 断码 / `E0` 扩展前缀),把按键映射成与鼠标同格式的事件 |
| `pipelined-cpu/srcs/board/cpu21_board_top.v` | `cpu21_board_top` | 板级顶层:时钟分频、复位同步、片内 ROM/RAM、显存双口 RAM、VGA/数码管、PS/2 键鼠选择(SW15)、中断连线 |
| `pipelined-cpu/srcs/board/cpu21_saolei_rom.vh` | — | 扫雷程序的 ROM 初始化(由`saolei/tools/build.ps1` 生成) |
| `pipelined-cpu/srcs/constrs/cpu21_board_top.xdc` | — | Nexys4DDR 引脚约束 + 分频时钟(`create_generated_clock`) |
| `pipelined-cpu/srcs/board/cpu21_board_rom.vh` | — | 片内 ROM 内容(生成文件,`rom[i] = 32'h…;`) |
| `pipelined-cpu/srcs/tb/tb_board_top.v` | `tb_board_top` | 板级顶层的 RTL 仿真(观察 LED 跑马灯与写入指示) |
| `pipelined-cpu/srcs/tb/tb_no_intr.v` | `tb_no_intr` | 无中断测试平台(指令集/MMIO 测试,默认加载 benchmark 镜像) |
| `pipelined-cpu/srcs/tb/tb_cpu21_riscv_redirect_int_bpb.v` | `tb_cpu21_riscv_redirect_int_bpb` | 中断与分支预测测试平台(默认加载中断测试镜像) |
| `pipelined-cpu/srcs/tb/tb_saolei.v` | `tb_saolei` | 扫雷自动打完一局并 dump 出显存(配 `saolei/tools/fb2png.py` 渲染成 PNG) |
| `pipelined-cpu/srcs/tb/tb_saolei_play.v` | `tb_saolei_play` | 命令行“边玩边截图”的测试平台(配 `tools/play_saolei.tcl`) |
| `pipelined-cpu/srcs/tb/tb_ps2_mouse.v` | `tb_ps2_mouse` | PS/2 鼠标接收器自测:奇校验、3 字节包、按键位(几秒出结果) |
| `pipelined-cpu/srcs/tb/tb_ps2_kbd.v` | `tb_ps2_kbd` | PS/2 键盘解码自测:方向键 / Enter / 左 Shift / 松开码不产生事件 |
| `pipelined-cpu/tools/rv_asm.py` | — | 迷你汇编器:`.asm` → 机器码 hex(`.lst` 带反汇编清单) |
| `pipelined-cpu/tools/hex2vh.py` | — | hex →`pipelined-cpu/srcs/board/cpu21_board_rom.vh`(`rom[i] = 32'h…;` 初始化语句) |
拆分只调整了模块边界与文件组织,端口、时序与行为完全等价:使用同一 ROM 程序
在 xsim 下对拆分前后逐周期对比,波形输出与最终统计(周期、停顿、气泡、分支
跳转、预测成败)完全一致。
## 各模块的实现方式
下面按“处理器流水线 → 外设 → 板级”的顺序说明每个模块的实现要点(对应上面
“文件与模块划分”表)。全部是可综合的纯 RTL,没有例化 Xilinx 原语,
因此 `xvlog`/`xelab` 可以直接仿真。
### 流水线主体
- **`cpu21_riscv_redirect_int_bpb`(顶层)**:五级寄存器 IF/ID、ID/EX、EX/MEM、
MEM/WB 各自带 valid 位;下一个 PC 由一个多路选择器决定(复位 `RESET_PC`、
顺序 PC+4、分支/跳转重定向目标、`URET` 返回地址、中断入口)。停顿与冲刷分别
由 hazard unit 的 `pipeline_stall_o`、`flush_younger_o` 控制:停顿时冻结
PC/IF-ID 并给 ID/EX 插气泡;冲刷时只把年轻指令的 valid 清零,所以已经提交的
存储/寄存器写不会被执行两次。
- **`cpu21_riscv_alu`**:纯组合,4 位 `alu_op` 直接选择加减/逻辑/移位/比较;
减法用“B 取反加 1”实现,比较分有符号/无符号两条路径。乘除法不在 ALU 里
(历史上的组合 `a*b`/`a%b` 是关键路径,已拆到多周期单元)。
- **`cpu21_riscv_muldiv`**:32 拍移位迭代(乘法 shift-add、除法 restoring),
加装载与定型共占用 EX **34 拍**;期间 `ex_md_stall` 有效 → 保持 ID/EX、给
EX/MEM 插气泡、冻结取指、屏蔽中断(见“时序和使用说明”第 7 条)。接口沿用
ALU 约定:`result_o` 是低位/商、`result2_o` 是高位/余数;除零时商全 1、
余数取被除数。
- **`cpu21_riscv_decoder`**:ID 级组合译码,操作码取 `IR[6:2]`,`funct3`/
`funct7`/`IR[21]` 区分 R 型细分、M 扩展、CSR 与 `ecall`/`URET`;输出一组
控制位(`alu_op`、`alu_src`、立即数、`reg_write`、访存使能、`wb_sel`、
分支/跳转、CSR 操作、`md_op`)。
- **`cpu21_riscv_regfile`**:32×32 寄存器堆,`x0` 恒 0;两个组合读口 + 一个写口,
同周期 WB 的数据通过“写优先旁路”直送到读口,省掉一拍。
- **`cpu21_riscv_forward_unit`**:EX 级前递,把 EX/MEM 与 MEM/WB 的结果和 ID/EX
的 `rs1/rs2` 比较,EX/MEM 优先;EX/MEM 是 load 时不参与前递(load 结果要
到 MEM 才有效,改由 hazard unit 停顿一拍);store 的数据同样可以前递。
- **`cpu21_riscv_store_unit`**:EX 级数据对齐,`sw` 产生四个字节通道、`sb` 按
`addr[1:0]` 只打开一个通道,并把数据搬到对应字节位置,输出 `data_wstrb_o`。
- **`cpu21_riscv_branch_unit`**:EX 级裁决分支方向与目标:6 条条件分支各用有
符号/无符号比较,`jal`/`jalr` 总是重定向,并把 `PC+4` 写回 `rd`(链接地址
在 EX 级预先算好放进 EX/MEM,避免在写回/前递路径上再加一个加法器)。
- **`cpu21_riscv_hazard_unit`**:load-use 冒险(ID/EX 是 load 且和 ID 级要用的
寄存器相同)插一个气泡;`flush_younger` 的优先级高于停顿:中断、`URET`、
`ecall` 停机、分支/跳转重定向都会丢弃年轻指令,此时不再插气泡。
- **`cpu21_riscv_irq_ctrl`**:`irq_i` 先打两拍同步,再与“上一拍”比较取出
**上升沿**(挂起位锁存),优先级 IRQ3 > IRQ2 > IRQ1;响应条件是“有挂起 +
`ustatus[0]`(MIE) + 嵌套深度 < 4 + 未停机 + EX 级没有重定向/停机”。
`ustatus`(0x004) 与 `uepc`(0x041) 用组合逻辑实现 CSRRS/CSRRC/CSRRW 三种读写
语义(旧值回写 `rd`);嵌套栈保存 EPC / `ustatus` / 上一层优先级,`URET` 时
逐层恢复。响应时保存“下一条尚未执行的指令的 PC”,EX 是已正确预测的跳转
分支则保存实际目标,避免 `URET` 重复执行已提交的操作。
- **`cpu21_riscv_perf_counters`**:周期、停顿、气泡、条件分支、无条件跳转、
预测成功/失败六个计数器;`halted_o` 期间不计周期;load-use 记 1 个气泡,
重定向/中断清空记 2 个气泡。
### 外设
- **`cpu21_mmio`**:MEM 级译码;先用**字对齐**地址比较命中哪个寄存器(这样
`sb` 写到 `0x1001` 也能命中 LED 寄存器),再用 `data_wstrb_o` 决定改哪几个
字节;命中时给出 `rdata_hit_o`,顶层据此在 RAM 与 MMIO 之间选择读数据。
内部包含 LED/数码管寄存器、鼠标寄存器组(含 16 深事件 FIFO 与 `mouse_irq_o`)、
显示控制器、显存窗口,以及用于自检的 `MAGIC` 常量。
- **`cpu21_vga`**:工作在 CPU 时钟下,用 `tick`(每 2 拍一个使能)产生 25 MHz
像素节拍(**不引入第二个时钟域**);按 640×480@60 的行场计数产生同步信号,
并按“格子”寻址显存:一个显存字对应 4 个像素,8×8 字模放大 2 倍填满 16×16
的格子(40×30 格),帧末产生 `frame_tick_o`。
- **`cpu21_seg7`**:8 位动态扫描。计数到 `REFRESH_DIV` 换一位(默认 50 MHz 下
50000 → 每步 1 ms,每位数码管 125 Hz 刷新);十六进制→七段码用查表;共阳极
所以位选/段选都取反输出,`an_en_i` 可以把某几位熄灭。
- **`cpu21_pointer`**:把 5 向按键变成鼠标事件。用约 1 ms 的采样节拍对两级同步
后的按键采样,只在“按下”边沿产生事件;一次采样可能同时按下多个键,用待发
队列 + 优先级选择器逐个上报(每拍最多一个事件,不丢键)。事件单位是 1/8 格,
软件端只用整数加减就能换算成格(不需要除法)。
- **`cpu21_ps2_mouse`**:可选的真鼠标,只实现接收:`ps2_clk` 下降沿把 11 位帧
**按位的下标**存进 `frame_q`(`frame_q[i]` = 第 i 位,所以字节 = `frame_q[8:1]`、
校验 = `frame_q[9]`、停止 = `frame_q[10]`),停下来后判“停止位 = 1 + 奇校验”,
再按 3 字节包解析出 dx/dy/按键,产生 `ev_valid_o` 脉冲。这里两个细节曾经写错过,
导致真设备的数据**全被丢掉**(上板表现为 `rx_count` 恒定 0):
① 原来用整体右移 `{data, frame_q[10:1]}`,判 `frame_q[10]` 时停止位还没移进来,
判到的是校验位,字节也整体错了一位;② 原来按**偶**校验判,而 PS/2 是**奇**校验。
- **`cpu21_ps2_kbd`**:可选的真键盘,扫描码集 2:`0xE0` = 扩展键前缀、`0xF0` = 松开
前缀,只有**按下**才产生事件(松开码丢弃),所以按住方向键时键盘自己的重复码正好
变成“连续移动”。产出的事件格式与鼠标**完全一致**(↑/↓/←/→ = dy/dx ±8,
一格;Enter/空格 = 左键;左 Shift/F/C = 右键),因此 `saolei.c`/`isr.c` 一行都不用改。
### 板级
- **`cpu21_board_top`**:把这些接起来,只引出板上真正需要的引脚。内含参数化分频器
(`CLK_DIV` / `CLK_DIV_INTRUPT`)、复位同步器(异步置位 + 同步释放)、片内
ROM(组合读、`nop` 填充、`ifdef` 链选程序)与 RAM(字节写使能 + 组合读、
只响应 4 KB 以内地址,避免和 MMIO 别名)、显存双口 RAM(CPU 写口 + VGA 读口
同处一个时钟域,用像素使能而不是第二时钟)、数码管、VGA、PS/2 键鼠(同一根
PS/2 线上同时挂鼠标与键盘两个解码器,用 `hid_sel`(SW15) 选哪个生效,板载 5 向
按键始终有效)、中断连线(SW0/1/2 分别与鼠标事件/关卡软件中断相或)、
锁存式中断指示 LED(请求上升沿置位、对应级别的 ISR 执行 `uret` 后熄灭)和
`led_we` 写入指示;另有一个“PS/2 诊断”模式把收到的字节数/时钟活动/线电平
摆到 LED 高位(`DEBUG_PS2_RX_LED`,见下文“PS/2 键鼠”)。
## 上板(Nexys4DDR + Vivado)
处理器顶层 `cpu21_riscv_redirect_int_bpb` 有 775 位调试端口(各级 PC/IR、计数器、
BPB/中断观测值)。如果直接把它设为实现顶层,这些端口会全部变成引脚,导致
`[Place 30-415] IO Placement failed due to overutilization`(占用 775 个 IO,
而 `xc7a100tcsg324-1` 只有 210 个)。因此上板时用 `cpu21_board_top` 作为顶层,
只引出板上真正需要的那些引脚(LED、按键、拨码、VGA、8 位数码管、可选的 PS/2),
其余调试端口不连接。定义宏 `SAOLEI_ROM` 时还会把扫雷程序固化进 ROM;当前
`create_project.tcl` 默认定义的是 `RISC_V_INTRUPT_CCAB_ROM`(跑课程的
中断综合测试程序),宏的含义、优先级和改法见下文“内置程序与宏的用法”。
### 引脚分配(`cpu21_board_top.xdc`)
| 端口 | 引脚 | 板上元件 | 说明 |
| --------------- | ----------- | ------------------------- | ----------------------------------------------------------------------- |
| `clk100` | E3 | 100 MHz 晶振 | 经`CPU_DIV` 分频后给 CPU(默认 50 MHz) |
| `rst_n` | C12 | 按键 CPU_RESETN(低有效) | 异步置位、与 CPU 时钟同步释放 |
| `go` | N17 | 按键 BTNC | 对应原电路“继续运行”(`ecall` 停机后恢复执行) |
| `irq_sw[2:0]` | J15/L16/M13 | 拨码开关 SW0–SW2 | 三路中断源,拨动即触发(内部同步并锁存) |
| `hid_sel` | V10 | 拨码开关 SW15 | 选 PS/2 设备:0 = 键盘(默认),1 = 鼠标(见下文“PS/2 键鼠”) |
| `sw_flag` | R15 | 拨码开关 SW3 | 扫雷的插旗模式(软件读 INPUT 寄存器;自动鼠标右键同样能插旗) |
| `led[15:0]` | H17…V11 | LED0–LED15 | `LED[2:0]` = 三路中断指示(锁存,ISR 返回后熄灭);`LED[15:3]` 平时熄灭,`DEBUG_PS2_RX_LED=1` 时是 PS/2 诊断(见下文) |
| `led_we` | R12 | LED16_B | MMIO 写入指示,每写一次拉长约 0.34 s @50 MHz |
### PS/2 键鼠(板载 USB HID 口,也可改接 Pmod JA)
Nexys4DDR 右上角那个 **USB HOST** 口不是给 FPGA 直连的 USB:板上有一颗 USB↔PS/2
桥接芯片,它把自己当 USB 主机,把键盘/鼠标当 PS/2 设备,输出的是 PS/2 的两根线
(官方母版标明 `ps2_clk = F4`、`ps2_data = B2`)。所以 FPGA 侧只需要一个 PS/2
接收器,**不用写 USB 协议栈**。
| 接线方式 | 能不能用 | 说明 |
| ------------------------------------- | -------- | ---------------------------------------------------------------- |
| 键鼠用**有线模式**插 USB HOST | 最推荐 | Type-C/PS2-USB 转换都用它的有线模式 |
| 2.4G USB 接收器 | 可以试 | 部分复合设备桥接芯片不转发 |
| 蓝牙 | 不行 | 桥接芯片不支持蓝牙,键盘的蓝牙链路它根本看不见 |
| Pmod PS/2(JA)+ 真 PS/2 键鼠 | 保底 | 直接进 FPGA,只需把 XDC 改回 `ps2_clk = C17`、`ps2_data = D18` |
**键盘/鼠标不动时一个字节都不发**,所以插上不动时诊断 LED 不跳是正常的:要一边按
键/移鼠标一边看。
**选设备:SW15 拨下(0)= PS/2 键盘,拨上(1)= PS/2 鼠标**(默认键盘)。两个
解码器挂在同一根线上,但只有选中的那一路事件会送给 CPU —— 不能让两路同时生效:
键盘的扫描码会凑巧组成“合法的鼠标包”,会让光标乱跳。
诊断 LED(`DEBUG_PS2_RX_LED = 1`,默认开):
| LED | 含义 |
| ------------------ | ---------------------------------------------------------- |
| `LED[15:6]` | 当前选中设备**收到的字节数**低 10 位(按键时数字跳 = 通了) |
| `LED[5]` | `ps2_clk` 有边沿活动(时钟在动就快速翻转) |
| `LED[4]` / `LED[3]` | `ps2_data` / `ps2_clk` 当前电平(静态,判断线有没有被拉死) |
| `LED[2:0]` | 三路中断指示(和其它模式一样) |
排查口诀:**LED[5] 在跳但字节数不动** = 桥接芯片在发数据、我们的解码没认(帧格式/
校验不匹配,先怀疑奇偶校验);**LED[5] 也不跳** = 桥接芯片根本没转发这个设备
(换有线模式,或改走 Pmod JA)。
| 操作 | 键盘 | 鼠标 |
| ---------------- | --------------------------------- | ---------- |
| 移动光标 | `↑` `↓` `←` `→` | 移动 |
| 左键(开始/开格) | `Enter`、空格 | 左键 |
| 右键(插旗) | 左键 `Shift`、`F`、`C` | 右键 |
按住方向键 = 连续移动(用的是键盘自己的重复码)。**C 程序一行都不用改**:键盘产出的
事件与 `cpu21_pointer`、`cpu21_ps2_mouse` 格式完全相同(±8 = 一格),一样从 IRQ2
进 `saolei/sw/isr.c`。不想要 PS/2 就把顶层参数 `USE_PS2_MOUSE` 设为 0(只剩板载
5 向按键);PS/2 接收器的帧格式/校验有专门的几秒钟自测(见下文“回归测试”)。
两条**上板实测**出来的方向约定(都值得写进报告):
- **鼠标的 dy 要取负**:PS/2 的原生约定是「正 dx = 右、**正 dy = 鼠标上移**」,
而软件里「正 dx = 列 +1、**正 dy = 行 +1(向下)**」(板载按键 BTND 就是 +8),
所以 `cpu21_ps2_mouse.v` 在拼包时把 dy 取负、dx 不动。不定这个负号的表现就是
**左右正常、上下反向** —— 不是解码错。
- **键鼠静止时一个字节都不发**,所以“插上不动没反应”是正常的,必须一边按键/
移鼠标一边看 LED。
### 时钟:分频比怎么选(`CLK_DIV` / `CLK_DIV_INTRUPT`)
乘除法原本是 ALU 里的组合运算(`a*b`、`a%b`),实测关键路径约 94 ns(325 级逻辑、
286 个 CARRY4),在 100 MHz 下必然不收敛,所以之前只能把时钟 16 分频到 6.25 MHz。
现在乘除法改成了 `pipelined-cpu/srcs/rtl/cpu21_riscv_muldiv.v` 里的 **32 拍迭代多周期单元**,关键路径上
不再有组合乘/除,也不需要再分频了;但把所有加法器/比较器都换成迭代后,在 100 MHz
下仍有约 1.1 ns 的违例——那是 **EX 级“分支判决 / 中断保存 → BPB 更新”这条与乘除法
无关的组合路径**(逻辑只占约 3 ns,剩下 7~8 ns 全是布线)。
因此在 `cpu21_board_top` 里保留一个参数化的分频器 `CLK_DIV`,默认取 **2(50 MHz,
周期 20 ns)**,既比原来的 16 分频快 8 倍,又留出充足余量:
| 配置 | CPU 时钟 | 实测 WNS(xc7a100tcsg324-1) | 说明 |
| -------------- | -------- | ---------------------------- | ------------------------------------- |
| `CLK_DIV=1` | 100 MHz | -1.1 ns ✗ | 违例路径与乘除法无关(分支/中断/BPB) |
| `CLK_DIV=2` | 50 MHz | **+5.1 ns** ✓ | 默认,余量约 25% |
| `CLK_DIV=16` | 6.25 MHz | +6.1 ns ✓ | 改造前的设置,现在没必要了 |
分频后的时钟在综合/实现时由 Vivado 自动插入 BUFG 走全局时钟网络(RTL 里不例化
`BUFG` 原语,这样在不认识 Xilinx 原语的仿真器里也能直接仿真)。XDC 中必须用
`create_generated_clock` 声明这个分频时钟,否则时序分析会按 10 ns 去要求 CPU
内部路径而误报大量违例:
```tcl
create_generated_clock -name cpu_clk -source [get_ports { clk100 }] \
-divide_by 2 [get_pins { clk_div_reg/Q }]
```
改分频比时:**变慢**(分频比变大)不用动 XDC;**变快**(分频比小于 2)就要把 XDC 的
`-divide_by` 一起改小,否则约束会比实际时钟松、可能掩盖违例。
### 中断测试程序为什么还要再慢一档
标着 `RISC_V_INTRUPT_CCAB_ROM` 的课程中断综合测试程序里,两次显示之间的延时常量
是按 50 MHz 写的:50 MHz 下每个数值只停留约 10 ms,动态扫描的数码管上根本读不
出来。所以顶层在跑这个程序时改用**另一个分频比参数 `CLK_DIV_INTRUPT`**:停留时间
与分频比成正比:取 8 → 12.5 MHz(约 40 ms/值)、取 32 → 3.125 MHz(约 160 ms/值)——
当前默认 32,在 `cpu21_board_top.v` 里改。这个分频比只在定义了
`RISC_V_INTRUPT_CCAB_ROM` 时生效,其它程序仍用 `CLK_DIV`。
### 分频与刷新率一览(想改速度看这里)
| 想改什么 | 改哪里 | 当前值 / 换算 |
| -------------------------- | --------------------------------------------------- | ------------------------------------------------------------ |
| CPU 主频(程序快慢) | 参数`CLK_DIV` | 2 → 50 MHz(`100 MHz / CLK_DIV`) |
| 中断程序的 CPU 主频 | 参数`CLK_DIV_INTRUPT`(只在该 ROM 宏下生效) | 32 → 3.125 MHz;每个数值约 10 ms × (`CLK_DIV_INTRUPT`/2) |
| 数码管扫描刷新率 | 顶层`SEG_REFRESH_DIV`(自动跟随 `CPU_DIV`) | `100000 / CPU_DIV` → 每步 1 ms、每位 125 Hz |
| LED16_B 写入指示点亮时长 | `led_we_cnt` 的装载值 `24'hFF_FFFF` | 约 0.34 s @50 MHz(`0xFFFFFF / f_cpu`) |
| 中断程序每个数值的停留时间 | `tools/adapt_intrupt_ccab.py` 的 `DELAY_NEG_HI` | `0xFFFC0` → 约 10 ms @50 MHz |
- **数码管**:`SEG_REFRESH_DIV = 100000 / CPU_DIV` 保证“每步 1 ms、每位数码管
125 Hz”与主频无关(不这样缩放的话,主频降 4 倍时全屏刷新只剩 31 Hz,能看到
闪烁)。想更亮可以把它改小(扫描更快),但每位刷新率要远高于 50 Hz。
`cpu21_seg7` 的分频计数是 20 位,最大约 100 万,所以 100 MHz(不分频)也放得下。
- **慢下来的副作用**:`cpu21_pointer` 的按键采样节拍(`SAMPLE_DIV = 50000`)也会
跟着变慢(3.125 MHz 下约 16 ms 采样一次,仍在可接受范围);`cpu21_vga` 的
25 MHz 像素节拍是从 CPU 时钟分频出来的,主频一降 VGA 行场时序就超出规范
(中断测试程序不用 VGA,无影响)。
- **XDC 不用跟着改(只要不是变快)**:`cpu_clk` 固定按最坏情况 `-divide_by 2`
(50 MHz)约束,实际时钟更慢属于“约束偏严”,不会掩盖违例;只有把分频比改到
比 2 更小(更快)时,才需要同步改小 XDC 的 `-divide_by`。
### 片内 ROM / RAM
- ROM:`ROM_WORDS = 8192` 字(32 KB),组合读,取指地址 `instr_addr[ROM_AW+1:2]`;
内容由 `` `include `` 一个 ROM 初始化文件给出(与 `cpu21_board_top.v` 同目录,
文件里是 `rom[i] = 32'h…;` 语句,放在 `initial` 里,等价于 `$readmemh`,
且不依赖仿真工作目录)。具体包含哪份由宏决定(见下文“内置程序与宏的用法”)。
超出镜像长度的地址填 `nop`(`32'h0000_0013`),所以跳飞时不会执行到随机指令。
- RAM:`RAM_WORDS = 1024` 字(4 KB),字节写使能 + 组合读,只响应
`data_addr < 4 KB`,因此 MMIO 地址(默认 `0x1000`)不会落进 RAM。
- 两个 `initial` 块都用 NOP(`32'h0000_0013`)或 0 预填充,ROM 未使用区域是 NOP,
这样即使程序跑飞也不会执行到随机指令而乱写寄存器。
### 内置程序与宏的用法
板级顶层用一条 `` `ifdef `` 链决定 ROM 里固化哪个程序(连带决定中断入口地址),
`create_project.tcl` 里的两个开关就是给这条链用的:
| 宏(`sources_1` 的 Verilog Define) | ROM 文件 | 中断入口`IRQ1/2/3` | 说明 |
| ------------------------------------- | -------------------------------------- | --------------------------------- | -------------------------------------------- |
| `SAOLEI_ROM`(优先级最高) | `srcs/board/cpu21_saolei_rom.vh` | `0x100` / `0x200` / `0x300` | 纯 C 扫雷(`saolei/tools/build.ps1` 生成) |
| `RISC_V_INTRUPT_CCAB_ROM` | `srcs/board/risc-v-intrupt-ccab.vh` | `0x0AC` / `0x1E4` / `0x308` | 课程中断综合测试程序(当前默认) |
| `BOARD_ROM_FAST` | `srcs/board/cpu21_board_rom_fast.vh` | `0x100` / `0x200` / `0x300` | 仿真专用:跑马灯延时改小(综合不用) |
| 都不定义 | `srcs/board/cpu21_board_rom.vh` | `0x100` / `0x200` / `0x300` | 上板跑马灯 demo(默认) |
同一时间只应定义一个(`ifdef` 链按上表顺序取第一个命中的)。**中断入口必须和
ROM 里 ISR 的起点一致**,所以换程序时宏与入口是一起换的:`cpu21_board_top.v`
里 `.IRQ1/2/3_VECTOR` 也跟着 `RISC_V_INTRUPT_CCAB_ROM` 分支走。
#### 怎么定义宏
1. **Vivado GUI**:`Project` → `Settings` → `Synthesis` → `Verilog Options` →
`Verilog Macros` 里填 `RISC_V_INTRUPT_CCAB_ROM`(只对 `sources_1` 有效),
确定后 `Reset Synthesis Run` 再综合。也可以直接在 Tcl Console 里写:
```tcl
set_property verilog_define {RISC_V_INTRUPT_CCAB_ROM} [get_filesets sources_1]
reset_run synth_1
launch_runs synth_1
wait_on_run synth_1
```
换回跑马灯就是 `set_property verilog_define {} [get_filesets sources_1]`。
2. **一键重建**:改 `create_project.tcl` 里的 `use_intrupt_ccab` / `use_saolei`
两个开关(同一时间只开一个),重新 `source create_project.tcl`。
3. **命令行仿真**:`xvlog` 与 `xelab` 都要带同一个 `-d`,并用 `-i` 指向头文件目录:
```powershell
xvlog -d RISC_V_INTRUPT_CCAB_ROM -i srcs/board `
srcs/rtl/*.v srcs/board/cpu21_board_top.v 你的tb.v
xelab -top 你的tb -snapshot s -d RISC_V_INTRUPT_CCAB_ROM
```
4. **注意**:这些宏只影响“综合用”的 `sources_1`。跑 `tb_no_intr` /
`tb_cpu21_riscv_redirect_int_bpb` 时,程序来自测试平台自己探测的
`srcs/rom/*.hex`,与宏无关。
#### 中断综合测试程序的适配(`tools/adapt_intrupt_ccab.py`)
课程给的 `programs/risc-v-intrupt-ccab.hex` 不能直接上板,脚本把它适配成
`srcs/board/risc-v-intrupt-ccab.vh`,且**不移动任何指令地址**(三个中断入口
仍是 `0x0AC` / `0x1E4` / `0x308`):
```powershell
python tools/adapt_intrupt_ccab.py programs/risc-v-intrupt-ccab.hex `
srcs/board/risc-v-intrupt-ccab.vh _tmp/fast.hex _tmp/plain.hex
```
六处适配(脚本内每步都有断言,输入不对会直接报错):
1. 三个 ISR 里的 `addi a7,zero,10` + `ecall` → 两条 `nop`(`a7 != 34` 的
`ecall` 会让这颗 CPU 停机等 `go_i`);
2. 11 个显示点 `lui a7,1` + `sw a0,0(a7)` → `jal` 调用追加的显示例程;
顺带纠正早期手改留下的 `sb` 误码(只写低字节会显示错值);
3. 追加主程序用的 `DISP_DLY`(0x438):写 LED 寄存器(数码管显示它)+ 约 10 ms
延时;返回地址存在 `sp+256`,位于 ISR 压栈区(最多 192 B)之上,所以中断
随时打断延时也不会破坏它;
4. 修正 ISR2/ISR3 出栈顺序写乱的 12 条 `lw`(否则 ISR2 的循环计数会变成
-3823,看起来像“二级中断返回之后卡住”);
5. ISR3 的 SB 测试改用 ISR3 自己保存的 `s4/s5/s6`(原来用未保存的 `t1/t3/s2`,
抢占 ISR1 时会毁掉 ISR1 的 DIVU 循环计数,造成死循环);
6. 追加 ISR 专用的 `DISP_DLY_ISR`(0x458):**链接寄存器改用 `a0`、显示值走
`a7`**(这两个寄存器三个 ISR 都保存/恢复),于是 ISR 完全不碰 `ra`。否则
中断若恰好落在主程序 `DISP_DLY` 末尾的 `jalr` 上,返回会跳进 ISR 代码、
最后执行到 ISR 结尾的 `csrrci` 把 MIE 清零(“跑一阵子之后拨开关不再响应”);
第 3 个参数生成“延时极小”的仿真版,便于在 xsim 里跑完整流程;第 4 个参数生成
只去掉停机 `ecall`、显示仍是 `lui`+`sw` 的“原样”版本,用于对比。
适配后的 ROM 在仿真里验证控制流很简单:用 `-d RISC_V_INTRUPT_CCAB_ROM` 编译
`srcs/rtl/*.v` + `srcs/board/cpu21_board_top.v` 加一个自建的 tb(或直接用
`srcs/tb/tb_board_top.v` 看 LED),观察 `irq_current_level_o`(0→1→2→3→…→0)、
`uepc_o`、`halted_o` 是否依次回到 0,以及 `LED[2:0]` 是否随嵌套逐层点亮/熄灭。
建议用第 3 个参数生成的快版 ROM(否则一次中断就是几十毫秒仿真时间)。
### 演示程序的生成与仿真
```powershell
# 在工程目录 pipelined-cpu/ 下执行:
# 1) 汇编 + 生成 ROM 初始化文件
python tools/rv_asm.py programs/board_demo.asm _tmp/board_demo.hex
python tools/hex2vh.py _tmp/board_demo.hex srcs/board/cpu21_board_rom.vh 1024
# 2) 上板顶层的 RTL 仿真(默认程序延时约 20 ms,只需确认第一次写入 LED=0001)
xvlog srcs/rtl/*.v srcs/board/cpu21_board_top.v srcs/tb/tb_board_top.v
xelab -top tb_board_top -snapshot board_sim
xsim board_sim -tclbatch run_quit.tcl
```
`board_demo.asm` 是一段 16 步跑马灯(`0001 → 0002 → … → 8000 → 0001`),
每步之间用软件循环延时约 20 ms(50 MHz 下)。仓库里同时提供了两份 ROM 初始化
文件,由 `BOARD_ROM_FAST` 宏选择(综合流程不会定义该宏):
| 文件 | 用途 | 每步耗时 |
| ---------------------------------------------------- | ------------------------------ | -------- |
| `pipelined-cpu/srcs/board/cpu21_board_rom.vh` | 综合/上板(默认) | 约 20 ms |
| `pipelined-cpu/srcs/board/cpu21_board_rom_fast.vh` | 仅仿真:一次跑完整个跑马灯序列 | 2.6 µs |
```powershell
# 快速版仿真:编译时加上 -d BOARD_ROM_FAST(xvlog 与 xelab 都要带)
xvlog -d BOARD_ROM_FAST srcs/rtl/*.v srcs/board/cpu21_board_top.v srcs/tb/tb_board_top.v
xelab -top tb_board_top -snapshot board_sim_fast -d BOARD_ROM_FAST
```
两份 ROM 初始化文件由 `programs/board_demo.asm`(默认,≈ 20 ms/步)与
`programs/board_demo_fast.asm`(快速版,≈ 2.6 µs/步,只差延时循环的初值)生成:
```powershell
python tools/rv_asm.py programs/board_demo_fast.asm _tmp/board_demo_fast.hex
python tools/hex2vh.py _tmp/board_demo_fast.hex srcs/board/cpu21_board_rom_fast.vh 1024
```
### Vivado 工程设置与实现结果
Vivado 工程 `pipelined-cpu/pipelined-cpu.xpr` 被 `.gitignore` 排除,需要手动设置:
1. `sources_1` 顶层设为 `cpu21_board_top`(纯 RTL 仿真时也可切回
`cpu21_riscv_redirect_int_bpb`);
2. 加入 `pipelined-cpu/srcs/board/cpu21_board_top.v`、`pipelined-cpu/srcs/constrs/cpu21_board_top.xdc`,
四份 ROM 初始化文件(`cpu21_board_rom.vh`、`cpu21_board_rom_fast.vh`、
`risc-v-intrupt-ccab.vh`、`cpu21_saolei_rom.vh`)的文件类型都设成 `Verilog Header`
(不能当普通源码,否则会被当成顶层候选);
3. 三个 testbench 放在**仿真 fileset**(`sim_1` 顶层 `tb_cpu21_riscv_redirect_int_bpb`、
`sim_2` 顶层 `tb_no_intr`),不参与综合,因此不会再出现“testbench 被当成顶层”
导致的 `[Place 30-494] The design is empty`。
`xc7a100tcsg324-1` 上的实现结果(乘除法改为多周期单元、`CLK_DIV=2` = 50 MHz):
| 项目 | 结果 |
| -------- | -------------------------------------------------------------------------------------------------- |
| 综合 | `synth_design Complete!` |
| 实现 | `route_design Complete!`、`write_bitstream Complete!`,`WNS = +5.109 ns`(无 Failed Timing) |
| 资源 | LUT 3420 (5.39%)、FF 2507、**DSP 0**(组合乘法器已消失)、IOB 23 (10.95%) |
| bit 文件 | `pipelined-cpu/pipelined-cpu.runs/impl_1/cpu21_board_top.bit` |
对比改造前(组合乘除法 + `CLK_DIV=16`):LUT 5400 → 3420、DSP 12 → 0、
CPU 时钟 6.25 MHz → 50 MHz(快 8 倍),并且乘除法不再位于关键路径上。
上板操作:用 Vivado 打开工程 → `Open Hardware Manager` → `Program Device`,选上述
`.bit`。复位后先跑 `sources_1` 里宏选中的那个程序。以跑马灯为例:复位后立即从
`0001` 开始逐位左移(演示程序不执行 `ecall`,所以不用按 `go`;`go` 只对执行了
`ecall` 停机、等待继续运行的程序有意义),LED16_B 每次写入时点亮。
#### 跑中断综合测试程序时的现象
(`create_project.tcl` 默认就是它,无需额外设置)
- 复位后主程序先跑自己的指令测试,数码管上显示程序写进 `LED_ADDR` 的 32 位十六进制值
(低 4 位在最右边那个数码管);
- 拨动 **SW0 / SW1 / SW2**(J15/L16/M13)就对应 1/2/3 号中断:数码管随即切成该 ISR 的
显示内容(SB 读写测试、DIVU 测试、多层循环等),每个数值停留约 160 ms(3.125 MHz 下);
- `LED[2:0]` 是**锁存**的中断指示:从该路中断请求出现开始亮,一直到它的 ISR 执行完
`uret` 返回才熄灭。抢占(例如 ISR1 还没跑完就拨 SW2)不会把外层的灯灭掉,一级
一级返回时才依次熄灭,正好能看出嵌套层次;
- LED16_B 每次 MMIO 写入内一下;程序里若有 `ecall a7 != 34` 的停机点,会停在原地等
按 **BTNC**(`go`)继续;
- 想重新开始随时按 **CPU_RESETN**(`rst_n`);想让它更快/更慢就改 `CLK_DIV_INTRUPT`
(见上文“分频与刷新率一览”)。
## 扫雷程序(`saolei/`)
`saolei/` 是一个**纯 C 写的扫雷**,用 RISC-V 工具链编译成机器码后固化进片内 ROM 运行,
反过来验证这套 MMIO/中断/显示/鼠标外设。完整说明见 `saolei/README.md`,要点:
- **三段中断入口**(地址由 `saolei/sw/linker.ld` 定死,与顶层 `IRQ1/2/3_VECTOR` 成对):
IRQ1=`0x100` 关卡(游戏)程序、IRQ2=`0x200` 鼠标读取、IRQ3=`0x300` 备用;
- **两层结构**:背景 `main()` 画开始界面并一直运行 → 点"开始"写 `LEVEL` 寄存器拉起软件中断
→ CPU 跳进 `0x100` 的关卡程序跑完整局 → 点左上角黄格 `uret` 回到背景被打断的那条指令;
- **纯 C**,只有三处必要的内联汇编:设 `sp`、三个中断入口的现场保护、`uret`;
- **指令子集适配**:没有 `auipc`/`lb`/`lbu`/有符号 `div`,所以程序只按字节写显存、
索引全用移位、且不含 `.data`/`.rodata`(链接脚本用 `ASSERT` 兜住);
- **构建**:`saolei/tools/build.ps1`(C → ELF → `.hex` + `cpu21_saolei_rom.vh`),
并用 `check_isa.py` 逐条反汇编检查有没有用到未实现的指令。实测 842 条指令全部合法;
- **上板切换**:Vivado 里定义宏 `SAOLEI_ROM` 就加载扫雷程序,不定义仍是下面的跑马灯。
### 扫雷:怎么烧到板上
```powershell
# 1) 生成扫雷的 ROM 镜像(改了 saolei/sw/*.c 之后必须重跑这一步)
cd saolei
powershell -ExecutionPolicy Bypass -File tools\build.ps1 `
-Prefix 'C:\Users\29653\tools\riscv\xpack-riscv-none-elf-gcc-15.2.0-1\bin\riscv-none-elf-'
# 2) 一键建"扫雷专用"工程(先清掉旧的同名工程再重建;sources_1 和 sim_1 都固定定义 SAOLEI_ROM)
cd ..\pipelined-cpu
& 'C:\Xilinx\Vivado\2020.2\bin\vivado.bat' -mode batch -source create_project_saolei.tcl -log create_saolei.log
# 产出 pipelined-cpu-saolei.xpr(和通用工程 pipelined-cpu 互不影响)
```
然后在 Vivado GUI 里依次点:`Run Synthesis` → `Run Implementation` → `Generate Bitstream`
→ `Open Hardware Manager` → `Open Target / Auto Connect` → `Program Device`。
板上操作:
| 板子上 | 作用 |
| ------------------------- | ---------------------------------------------------------- |
| BTNU / BTND / BTNL / BTNR | 光标 上 / 下 / 左 / 右 |
| BTNC | 左键:开始界面确认 / 关卡里开格 |
| SW3 拨上 + BTNC | 插旗 / 取消旗 |
| SW15 = 1 | 改用 PS/2 鼠标(默认 SW15 = 0 = PS/2 键盘,见上文“PS/2 键鼠”) |
| 键盘 `↑↓←→` / `Enter`·空格 / 左`Shift`·`F`·`C` | 移动 / 左键 / 右键(插旗)—— 需 SW15 = 0 |
| 鼠标 移动 / 左键 / 右键 | 移动 / 左键 / 右键(插旗)—— 需 SW15 = 1 |
| SW0 / SW1 / SW2 | **别动**,它们会 OR 进外部中断线,一拨就可能触发中断 |
开始界面的光标默认停在 START 按钮上,按 BTNC 进关卡;关卡里把光标移到盘面**左上角那个黄格**
再按 BTNC 就退回开始界面(分出胜负后随便点一下也能退)。
### 扫雷:没有 VGA 显示器怎么玩(命令行仿真 + 每步截图)
显存是内存映射的(`0x2000` 起,40×30 格、每格 2 字节:低字节 = 字形[5:0]、
高字节 = 颜色[2:0],共 2400 字节 = 600 字),
所以仿真里可以把整屏 dump 出来,再按 `cpu21_vga.v` 的字库/调色板还原成 PNG —— 等价于给 VGA 截图。
`tools/play_saolei.tcl` 更进一步:用 `cmd.txt` 往仿真里"喂"按键,每按一步自动存一张 PNG。
```powershell
# ① 建仿真快照(只在第一次,或改了 RTL 之后需要重跑)
cd D:\Desktop\risc-v-cpu
New-Item -ItemType Directory -Force -Path _tmp\saolei_sim | Out-Null # 用哪个空目录都行
cd _tmp\saolei_sim
$p = 'D:\Desktop\risc-v-cpu\pipelined-cpu'
$rtl = (Get-ChildItem "$p\srcs\rtl\*.v").FullName
& 'C:\Xilinx\Vivado\2020.2\bin\xvlog.bat' -d SAOLEI_ROM -i "$p\srcs\board" -i "$p\srcs\rtl" $rtl "$p\srcs\board\cpu21_board_top.v" "$p\srcs\tb\tb_saolei_play.v"
& 'C:\Xilinx\Vivado\2020.2\bin\xelab.bat' -d SAOLEI_ROM -top tb_saolei_play -snapshot saolei_play
# ② 开始玩(之后是交互式的,命令直接敲在 xsim 提示符下)
& 'C:\Xilinx\Vivado\2020.2\bin\xsim.bat' saolei_play
```
到 `xsim%` 提示符后(**`source` 要带 `-encoding utf-8`**,否则 xsim 按 GBK 读脚本会乱码):
```text
source -encoding utf-8 D:/Desktop/risc-v-cpu/pipelined-cpu/tools/play_saolei.tcl
k c 左键点击(进关卡用这个)
k u/d/l/r 光标 上/下/左/右(也认 w/s/a/d)
k f 插旗模式点击 k p 只切换插旗开关
k z 复位 k uucc 连写多个动作(上、上、点、点)
shot 重画 + 存图 view 用看图器打开最新那张图
help / quit
```
- **每按一个动作都会自动存一张 PNG**:`shots/001_c.png`、`shots/002_u.png`… 可以直接贴进报告;
同时会覆盖 `shots/latest.png` —— 在 VS Code 里打开这一张,每操作一步它自动刷新,等于边玩边看屏幕;
- 仿真速度约 1 ms 仿真时间 = 0.5 s 墙钟,所以每一步等 1~2 秒;
- 只想"看一局"不想手动操作:用 `srcs/tb/tb_saolei.v`(`-top tb_saolei`)会自动打完一整局
(首击展开 → 全盘扫描 → 216/216 通关),并 dump 出 `fb_00_start.txt` … `fb_13_after_sweep.txt`,
再用 `py saolei\tools\fb2png.py <dump.txt> <out.png>` 渲染成 640×480 PNG;
- 光标格在画面里是**黄色 2 像素常亮方框**,VGA 上和截图里都能一眼看到指针在哪。
## 回归测试
工程里带了一个 xsim 回归脚本,在 `pipelined-cpu/` 目录下运行:
```powershell
powershell -ExecutionPolicy Bypass -File tools\run_regress.ps1
```
它会按顺序做四件事:
1. 编译 `srcs/rtl/` 下的处理器源码;
2. 跑 4 个指令测试(`muldiv_test`、`isa_test`、`lui_test`、`mmio_test`),把测试平台
打印的 LED 序列与 `programs/*.expected` 逐值比对;
3. 跑两个流水基线(`tb_no_intr`、`tb_cpu21_riscv_redirect_int_bpb`),打印 FINAL 统计行;
4. 跑板级顶层(`-d BOARD_ROM_FAST`),检查跑马灯 LED 序列。
最后汇总 PASS/FAIL,并以失败数作为退出码。编译与仿真的中间文件都写在
`%TEMP%\cpu21_regress` 下,不会污染工程目录;需要 `xvlog`/`xelab`/`xsim` 在 PATH 上。
期望结果(2026-09,多周期乘除法 + `CLK_DIV=2`):
| 测试 | 期望 |
| -------------------------------------------------- | ------------------------------------------------------------------------------ |
| muldiv_test / isa_test / lui_test / mmio_test | 13 / 24 / 4 / 5 个 LED 值全部匹配 |
| tb_no_intr(benchmark,5560 周期) | `stalls=1528 bubbles=1830 cond_taken=639 uncond=71 pred_ok=687 pred_fail=80` |
| tb_cpu21_riscv_redirect_int_bpb(中断,6003 周期) | `stalls=963 bubbles=1205 cond_taken=547 uncond=28 pred_ok=557 pred_fail=87` |
| 板级(快速 ROM) | 跑马灯`0001 → … → 8000` 循环,每步约 2.6 µs |
PS/2 接收器不在上面这个脚本里(它跟板载芯片/真设备有关,不属于纯 RTL 回归),
但解码逻辑(帧对齐、奇校验、包解析、键位映射)值得单独盯,两个自测各几秒钟:
```powershell
cd pipelined-cpu
$p = 'D:\Desktop\risc-v-cpu\pipelined-cpu'
# 鼠标接收器
& 'C:\Xilinx\Vivado\2020.2\bin\xvlog.bat' "$p\srcs\rtl\cpu21_ps2_mouse.v" "$p\srcs\tb\tb_ps2_mouse.v"
& 'C:\Xilinx\Vivado\2020.2\bin\xelab.bat' -top tb_ps2_mouse -snapshot ps2m
& 'C:\Xilinx\Vivado\2020.2\bin\xsim.bat' ps2m -R # 期望 ALL PASS
# 键盘解码(把 mouse 换成 kbd)
& 'C:\Xilinx\Vivado\2020.2\bin\xvlog.bat' "$p\srcs\rtl\cpu21_ps2_kbd.v" "$p\srcs\tb\tb_ps2_kbd.v"
& 'C:\Xilinx\Vivado\2020.2\bin\xelab.bat' -top tb_ps2_kbd -snapshot ps2k
& 'C:\Xilinx\Vivado\2020.2\bin\xsim.bat' ps2k -R # 期望 ALL PASS
```
两个自测都会:发几个帧 → 检查事件内容是否正确;**故意发一个校验位错的帧** →
必须整帧丢掉(不计数、不产生事件);键盘那个还会检查“松开码(F0 xx)不产生事件”。
## 工程分享(Tcl 一键重建)
`pipelined-cpu/` 已整理成“自包含 + 可直接分享”的工程目录,设计文件都在里面,
别人拿到整个文件夹后**不需要改任何路径**:
```text
pipelined-cpu/
├─ create_project.tcl 一键重建脚本(用 [info script] 定位自身目录)
├─ srcs/rtl/*.v 处理器与外设 RTL(17 个文件)
├─ srcs/board/*.v|*.vh 板级顶层、四份 ROM 初始化(跑马灯/中断测试/扫雷/仿真快版)
├─ srcs/constrs/*.xdc 引脚约束 + 板级母版
├─ srcs/tb/*.v 三个测试平台
├─ srcs/rom/*.hex 测试平台加载的 ROM 镜像
├─ tools/、programs/ 迷你汇编器与测试程序(重生成 ROM 用)
├─ cpu21_riscv_redirect_int_bpb说明.md
└─ pipelined-cpu.xpr(+ *.runs/…) 本机工程,分享时可删
```
用法(在 Vivado Tcl Shell 里,或命令行 `vivado -mode batch -source create_project.tcl`):
```tcl
cd <pipelined-cpu 所在目录>
source create_project.tcl
```
脚本会就地创建 `pipelined-cpu.xpr`,并配好:
- `sources_1` 顶层 `cpu21_board_top`(处理器 RTL + 板级顶层 + 片内 ROM 初始化,
`cpu21_board_rom.vh` 标记为 `Verilog Header`);
- `constrs_1`:`cpu21_board_top.xdc`(引脚 + `CLK_DIV` 分频时钟)与板级母版;
- `sim_1`(顶层 `tb_cpu21_riscv_redirect_int_bpb`)与 `sim_2`(顶层 `tb_no_intr`);
测试平台会依次探测 ROM 镜像,其中 `../../../../../srcs/rom/` 对应 GUI 仿真的
工作目录(`<proj>.sim/sim_1/behav/xsim`),所以创建完就能直接 `Run Simulation`;
- 两个仿真 fileset 的 `xsim.simulate.runtime` 设为 `all`,避免 GUI 仿真只跑 1000 ns
就停下(看起来像卡死)。
分享前建议删掉生成目录:`*.xpr`、`*.runs`、`*.sim`、`*.cache`、`*.hw`、`*.gen`、
`*.ip_user_files`、`.Xil`。实测只带 `create_project.tcl` + `srcs/` + `pipelined-cpu/tools/` +
`programs/` + 说明文档约 **0.3 MB**,在任意路径重建工程并综合成功。
> 源文件只有一份,就在 `pipelined-cpu/srcs/`(`tools/`、`programs/` 同理),
> 直接在工程目录里改即可,没有需要同步的副本。
也可以用 Vivado 自动生成:
```tcl
write_project_tcl -force -paths_relative_to <工程目录> <工程目录>/create_project.tcl
```
注意自动生成的版本会把源路径写成**生成时的绝对路径**、并且要求 `origin_dir` 指向
工程目录,所以要手动改两行才能给别人用:
```tcl
set origin_dir [file dirname [file normalize [info script]]]
create_project ${_xil_proj_name_} $origin_dir -part xc7a100tcsg324-1
```