沧州市网站建设_网站建设公司_字体设计_seo优化
2026/1/11 7:18:05 网站建设 项目流程

从零构建一个RISC单周期处理器:我的FPGA实战手记

最近在带学生做数字系统课程设计时,我又一次亲手复现了那个经典的“玩具”——RISC单周期处理器。虽然它看起来像个教学模型,远不如现代流水线CPU那样炫酷,但正是这个看似简单的结构,让我第一次真正理解了“一条指令是如何在硬件上跑起来的”。

今天,我想以一个工程师而非教科书作者的身份,带你完整走一遍这个项目的设计全过程。不堆术语,不说空话,只讲我们实际踩过的坑、调过的信号、看过的波形。


为什么是RISC?为什么是单周期?

先说结论:如果你想搞懂CPU是怎么工作的,那就从RISC单周期开始。

不是ARM,也不是x86,更不是直接上RISC-V核。而是自己搭——从PC到ALU,从寄存器堆到控制逻辑,一行行代码写出来。

为什么选RISC?因为它够“干净”。不像CISC那样有上百种复杂寻址模式和变长指令,RISC用的是固定32位指令、统一的操作流程,数据通路清晰得像一张电路图。你可以一眼看出addlw之间的差异只是几个控制信号的不同。

而单周期呢?它的“笨”反而成了优点——所有操作都在一个时钟周期内完成。没有流水线冒险,没有竞争条件,没有复杂的时序约束(好吧,其实还是有的)。你写完仿真一跑,看到PC+4、寄存器更新、内存读写全部同步发生,那种“我掌控一切”的感觉,对初学者来说太重要了。


我们到底要造什么?

我们的目标很明确:实现一个能运行简单汇编程序的32位RISC处理器,支持以下几类指令:

  • R-typeadd,sub,and,or
  • I-typeaddi,lw,sw
  • J-typej

运行平台是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_opfunct字段联合译码。

符号扩展单元也很简单,但必须处理好立即数左移(比如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=1mem_to_reg=1

这种映射关系可以用一张表来总结:

指令RegWriteMemReadMemWriteALUSrcMemtoRegALUOp
add1000010
lw1101100
sw0011x00
beq0000x01

✅ 小技巧:把这些控制信号做成参数化定义,未来升级流水线时可以直接复用。


实战验证:让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得到期望值 —— 成功!

但中间也遇到不少问题:

调试实录:那些让人头秃的夜晚

  1. sw写不进去?
    - 查了好久才发现data_memory的写使能信号反了……原来是mem_write没取非。
    - 改成.we(~mem_write)就好了(某些RAM IP要求低电平有效)。

  2. beq死循环?
    - 原来是alu_zero没连上!ALU计算完减法后忘了输出零标志。
    - 加上assign zero = (result == 32'd0);后恢复正常。

  3. 时序违例?
    - 综合报告显示关键路径延迟达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,才算真正踏入了硬件世界的大门。

如果你在实现过程中遇到了其他挑战,欢迎在评论区分享讨论。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询