第1章:FPGA上的RISC-V实现:核心模块与流水线设计
各位同学,咱们今天直接切入正题。FPGA上跑RISC-V处理器,说白了就是把一个CPU的微架构用硬件描述语言在可编程逻辑上搭出来。我这些年做过的RISC-V项目不下十几个,从单周期到五级流水线,踩过的坑能写本书。今天这章,咱们就聊聊最核心的几个模块怎么实现。
1.1 核心模块的顶层架构
先看整体结构。一个基本的RISC-V处理器,至少包含这几个部分:取指单元、译码单元、执行单元、访存单元、写回单元。嗯,听起来像教科书对吧?但实际做起来,每个模块都有讲究。
核心模块清单:
- 取指单元(IFU):从指令存储器读取指令,更新PC
- 译码单元(IDU):解析指令,生成控制信号和立即数
- 执行单元(EXU):包含ALU、乘除法器、分支判断
- 访存单元(LSU):处理load/store操作,与数据存储器交互
- 写回单元(WBU):将结果写回寄存器堆
我个人习惯把寄存器堆单独拎出来,因为它既是数据通路的核心,也是流水线冒险的重灾区。你想想看,所有指令都要读写寄存器,这地方一旦出问题,整个处理器就乱套了。
1.2 流水线寄存器设计
流水线寄存器,说白了就是每两级流水线之间的“缓冲带”。它们负责暂存上一级的结果,同时为下一级提供稳定的输入。我刚开始做流水线设计时,总觉得这玩意儿就是几个触发器,后来发现——没那么简单。
我的经验:流水线寄存器不仅要存数据,还要存控制信号。比如写使能、寄存器地址这些,必须跟着数据一起走。我曾经犯过一个低级错误:控制信号没打拍,结果写回阶段写错了寄存器地址,调试了整整两天。
来看一个典型的流水线寄存器实现:
module pipeline_reg #(
parameter DATA_WIDTH = 32
)(
input wire clk,
input wire rst_n,
input wire stall,
input wire flush,
input wire [DATA_WIDTH-1:0] data_in,
output reg [DATA_WIDTH-1:0] data_out
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
data_out <= {DATA_WIDTH{1'b0}};
else if (flush)
data_out <= {DATA_WIDTH{1'b0}};
else if (!stall)
data_out <= data_in;
end
endmodule
这里有个关键点:stall和flush的优先级。stall是暂停流水线,flush是清空流水线。我建议把flush的优先级设得比stall高,因为分支预测错误时,我们需要立即清空错误路径的指令,而不是等它慢慢停。
1.3 ALU与乘除法器
ALU是执行单元的心脏。RISC-V的ALU需要支持加减法、逻辑运算、移位操作。这些都不难,但要注意一点:标志位的处理。RISC-V不像x86那样有专门的标志寄存器,它的比较结果直接通过数据通路传递。
乘除法器就有点意思了。RISC-V的乘法指令(M扩展)要求在一个周期内完成32位乘法,这在FPGA上其实挺吃资源的。我一般用Xilinx的DSP48E2硬核来实现,又快又省资源。
避坑指南:千万不要在FPGA上用纯组合逻辑实现32位乘法器!我曾经试过,结果时序跑不到100MHz,最后不得不改成流水线乘法器。如果你非要自己做,至少拆成两级流水线。
除法器更麻烦。RISC-V的除法指令(div/rem)需要多个周期才能完成。我常用的方案是:
- 恢复余数法:面积小,但需要32个周期
- 非恢复余数法:速度稍快,但控制逻辑复杂
- 查表法:速度快,但ROM资源消耗大
我个人推荐非恢复余数法,面积和速度的平衡点比较好。下面是一个简化版的除法器状态机:
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
state <= IDLE;
done <= 1'b0;
end else begin
case (state)
IDLE: begin
if (start) begin
state <= COMPUTE;
cnt <= 5'd0;
done <= 1'b0;
end
end
COMPUTE: begin
if (cnt == 5'd31) begin
state <= DONE;
done <= 1'b1;
end else begin
cnt <= cnt + 1'b1;
end
end
DONE: begin
if (!start) begin
state <= IDLE;
done <= 1'b0;
end
end
endcase
end
end
1.4 加载存储单元设计
加载存储单元(LSU)负责处理load和store指令。这玩意儿比看起来复杂得多,因为它要处理字节、半字、字的对齐问题,还要处理未对齐访问。
RISC-V的load指令支持lb、lh、lw、lbu、lhu五种类型。store指令支持sb、sh、sw三种类型。每种指令的地址对齐要求不同:
| 指令类型 | 对齐要求 | 数据宽度 |
|---|---|---|
| lb / lbu | 无要求 | 8位 |
| lh / lhu | 2字节对齐 | 16位 |
| lw | 4字节对齐 | 32位 |
| sb | 无要求 | 8位 |
| sh | 2字节对齐 | 16位 |
| sw | 4字节对齐 | 32位 |
遇到未对齐访问怎么办?RISC-V规范说可以触发异常,也可以硬件处理。我建议在FPGA实现中直接触发异常,因为硬件处理未对齐访问太复杂了,而且性能损失很大。
我的做法:在LSU中加一个地址对齐检查模块。如果地址不对齐,就生成一个异常信号,让处理器跳转到异常处理程序。这样既简单又符合规范。
最后,咱们用一张图来总结本章的核心逻辑:
这张图展示了五级流水线的数据流向。每个箭头代表数据在流水线寄存器之间的传递。你注意看,寄存器堆和译码单元之间有个双向箭头——这代表读操作,而写回单元到寄存器堆是单向的。嗯,这里其实隐藏了一个冒险问题:如果写回还没完成,下一条指令就要读同一个寄存器,怎么办?这就是我们下一章要讲的流水线冒险处理。
好了,本章的内容就到这里。核心模块的Verilog实现、流水线寄存器的设计要点、ALU和乘除法器的取舍、LSU的对齐处理——这些都是在FPGA上实现RISC-V必须啃下的硬骨头。我个人建议你先把这些模块一个个在仿真里跑通,再考虑把它们连起来。别急着上板,仿真能发现90%的问题。