第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中加一个地址对齐检查模块。如果地址不对齐,就生成一个异常信号,让处理器跳转到异常处理程序。这样既简单又符合规范。

最后,咱们用一张图来总结本章的核心逻辑:

RISC-V处理器核心模块数据流 取指单元 (IFU) 译码单元 (IDU) 执行单元 (EXU) 访存单元 (LSU) 写回单元 (WBU) 寄存器堆 (RegFile) IF/ID ID/EX EX/MEM MEM/WB 图例: 取指 译码 执行 访存 写回 寄存器堆 流水线寄存器

这张图展示了五级流水线的数据流向。每个箭头代表数据在流水线寄存器之间的传递。你注意看,寄存器堆和译码单元之间有个双向箭头——这代表读操作,而写回单元到寄存器堆是单向的。嗯,这里其实隐藏了一个冒险问题:如果写回还没完成,下一条指令就要读同一个寄存器,怎么办?这就是我们下一章要讲的流水线冒险处理。

好了,本章的内容就到这里。核心模块的Verilog实现、流水线寄存器的设计要点、ALU和乘除法器的取舍、LSU的对齐处理——这些都是在FPGA上实现RISC-V必须啃下的硬骨头。我个人建议你先把这些模块一个个在仿真里跑通,再考虑把它们连起来。别急着上板,仿真能发现90%的问题。

专注资料整理