从零构建一个RISC单周期处理器:我的FPGA实战手记
最近在带学生做数字系统课程设计时,我又一次亲手复现了那个经典的“玩具”——RISC单周期处理器。虽然它看起来像个教学模型,远不如现代流水线CPU那样炫酷,但正是这个看似简单的结构,让我第一次真正理解了“一条指令是如何在硬件上跑起来的”。
今天,我想以一个工程师而非教科书作者的身份,带你完整走一遍这个项目的设计全过程。不堆术语,不说空话,只讲我们实际踩过的坑、调过的信号、看过的波形。
为什么是RISC?为什么是单周期?
先说结论:如果你想搞懂CPU是怎么工作的,那就从RISC单周期开始。
不是ARM,也不是x86,更不是直接上RISC-V核。而是自己搭——从PC到ALU,从寄存器堆到控制逻辑,一行行代码写出来。
为什么选RISC?因为它够“干净”。不像CISC那样有上百种复杂寻址模式和变长指令,RISC用的是固定32位指令、统一的操作流程,数据通路清晰得像一张电路图。你可以一眼看出add和lw之间的差异只是几个控制信号的不同。
而单周期呢?它的“笨”反而成了优点——所有操作都在一个时钟周期内完成。没有流水线冒险,没有竞争条件,没有复杂的时序约束(好吧,其实还是有的)。你写完仿真一跑,看到PC+4、寄存器更新、内存读写全部同步发生,那种“我掌控一切”的感觉,对初学者来说太重要了。
我们到底要造什么?
我们的目标很明确:实现一个能运行简单汇编程序的32位RISC处理器,支持以下几类指令:
- R-type:
add,sub,and,or - I-type:
addi,lw,sw - J-type:
j
运行平台是Xilinx Artix-7 FPGA,使用Verilog HDL编码,通过Vivado综合与仿真。
整个系统架构如下图所示:
+------------------+ | Instruction | | Memory | | (ROM) | +--------+---------+ | v +-------------------+-------------------+ | | v v +-------+--------+ +--------+-------+ | Control Unit |<------------------+ Program Counter| +-------+--------+ 控制信号 +--------+-------+ | ^ | | v | +-------+--------+ +------------+ +------+------+ | Register File |<-->| ALU |<-->| Sign Extend | +-------+--------+ +------------+ +-------------+ | | | v | +-------+--------+ +---------->| Data Memory | | (RAM) | +----------------+别看这张图现在规整,当初连PC怎么跳转都纠结了半天。
数据通路:让数据流动起来
核心模块拆解
1. 程序计数器(PC)
最基础但也最容易出错的地方。我们用了一个边沿触发的寄存器来保存当前指令地址:
always @(posedge clk or posedge reset) begin if (reset) pc <= 32'h0; else pc <= pc_next; end关键在于pc_next的计算。一开始我们只做了pc + 4,结果发现跳转指令完全失效。后来才加上多路选择逻辑:
assign pc_next = branch && alu_zero ? {pc_plus_4[31:28], target_addr} : // beq成立时跳转 jump ? {pc[31:28], jump_addr} : // j指令 pc_plus_4; // 默认顺序执行⚠️ 坑点提醒:跳转地址拼接时一定要注意高位保留!否则跨区域跳转会出问题。
2. 指令存储器 & 寄存器堆
我们用Block RAM模拟ROM作为指令存储器,初始化加载由MIPS风格汇编生成的二进制码。
寄存器堆用了双端口读、单端口写的结构,其中$0强制为0,符合RISC惯例:
reg [31:0] reg_array [0:31]; always @(posedge clk) begin if (we3 && (wa3 != 5'd0)) reg_array[wa3] <= wd3; end assign rd1 = reg_array[ra1]; assign rd2 = reg_array[ra2];💡 秘籍:调试时可以在顶层加一个
output [31:0] debug_reg[0:31],把整个寄存器堆引出来观察状态。
3. ALU与符号扩展
ALU本身不难,就是一个多路选择器:
case(alu_ctrl) 3'b000: result = a + b; 3'b001: result = a - b; 3'b010: result = a & b; 3'b011: result = a | b; ... endcase但要注意两点:
- 减法时要输出zero标志,用于beq判断;
-alu_ctrl来自控制单元的alu_op和funct字段联合译码。
符号扩展单元也很简单,但必须处理好立即数左移(比如lw中的偏移量不需要左移,而跳转地址需要)。
控制单元:处理器的“大脑”
这是我最喜欢的部分——把每条指令翻译成一组开关信号。
我们采用硬连线控制(hardwired control),不用微码。好处是速度快、资源少,适合单周期结构。
来看一段真实的控制逻辑:
always @(*) begin case(op_code) 6'b000000: begin // R-type reg_write = 1; mem_read = 0; mem_write = 0; branch = 0; alu_src = 0; // 第二个操作数来自rt寄存器 mem_to_reg = 0; // 写回数据来自ALU alu_op = 2'b10; // 表示需进一步查看funct end 6'b100011: begin // lw reg_write = 1; mem_read = 1; mem_write = 0; branch = 0; alu_src = 1; // 使用立即数 mem_to_reg = 1; // 写回数据来自内存 alu_op = 2'b00; end 6'b101011: begin // sw reg_write = 0; // 不写回寄存器 mem_read = 0; mem_write = 1; alu_src = 1; alu_op = 2'b00; end ... endcase end你会发现,每条指令的本质就是一组控制信号的组合。lw之所以能访问内存,不是因为它“特殊”,而是因为mem_read=1且mem_to_reg=1。
这种映射关系可以用一张表来总结:
| 指令 | RegWrite | MemRead | MemWrite | ALUSrc | MemtoReg | ALUOp |
|---|---|---|---|---|---|---|
| add | 1 | 0 | 0 | 0 | 0 | 10 |
| lw | 1 | 1 | 0 | 1 | 1 | 00 |
| sw | 0 | 0 | 1 | 1 | x | 00 |
| beq | 0 | 0 | 0 | 0 | x | 01 |
✅ 小技巧:把这些控制信号做成参数化定义,未来升级流水线时可以直接复用。
实战验证:让CPU真正跑起来
我们写了一段测试程序,功能是将数组A[0..3]求和并存入$t0:
lui $s0, 0x4000 # A基地址高16位 ori $s0, $s0, 0x0000 # 完整地址 0x4000_0000 lw $t1, 0($s0) # A[0] lw $t2, 4($s0) # A[1] add $t1, $t1, $t2 lw $t2, 8($s0) # A[2] add $t1, $t1, $t2 lw $t2, 12($s0) # A[3] add $t0, $t1, $t2 # 结果存入$t0烧录进FPGA后,通过ILA抓取内部信号,看到PC一步步递增,每次lw都能正确从BRAM读出预置数据,最后$t0得到期望值 —— 成功!
但中间也遇到不少问题:
调试实录:那些让人头秃的夜晚
sw写不进去?
- 查了好久才发现data_memory的写使能信号反了……原来是mem_write没取非。
- 改成.we(~mem_write)就好了(某些RAM IP要求低电平有效)。beq死循环?
- 原来是alu_zero没连上!ALU计算完减法后忘了输出零标志。
- 加上assign zero = (result == 32'd0);后恢复正常。时序违例?
- 综合报告显示关键路径延迟达8ns,最高只能跑~125MHz。
- 分析发现瓶颈在“PC → IMEM → 控制单元 → ALU → DMEM → 写回”这条链。
- 解决方案:插入寄存器打拍?不行,这是单周期!最终靠优化布局布线勉强达标。
教学之外的价值:不只是“玩具”
很多人觉得单周期处理器没实用价值。但我认为恰恰相反。
它是最扎实的入门路径
当你亲手实现过一次lw指令的数据流,你会明白为什么后来的处理器要做缓存;当你为beq的跳转延迟头疼过,你就理解了为什么要有分支预测。
这就像学开车前先拆一遍发动机。
可扩展性强
我们在项目末期尝试加入了两个自定义指令:
-max $rd, $rs, $rt:返回两数较大者
-not $rd, $rs:按位取反
只需修改三处:
1. 指令编码分配新opcode/funct;
2. 控制单元增加译码条目;
3. ALU添加对应操作。
几天就搞定,换成商业IP核根本做不到这么灵活。
为后续学习铺路
这个设计本身就是五级流水线的“展开形式”。下一步自然可以问:
- 能不能把五个阶段拆开?
- 如何解决数据冒险?
- 怎么处理控制冒险?
答案就在眼前。
写在最后
这个项目花了我们三周时间,写了近800行Verilog代码,改了无数遍testbench,看了几十小时的波形图。
但它值得。
现在每当我在文档里看到“CPU执行一条load指令需要经过取指、译码、执行、访存、写回”这句话时,我不再觉得抽象。我知道那背后是一根根连线、一个个触发器、一组组控制信号在协同工作。
如果你也在学习计算机组成原理或准备进入FPGA开发领域,我强烈建议你动手实现一次自己的单周期处理器。
不要怕错,不要嫌慢。
只有当你亲手点亮第一个PC+4,才算真正踏入了硬件世界的大门。
如果你在实现过程中遇到了其他挑战,欢迎在评论区分享讨论。