4. RISC-V单核处理器设计(下):执行单元(ALU)设计、访存单元设计、写回单元设计、流水线控制逻辑

好,咱们接着往下聊。上一节我们把取指、译码、寄存器堆这些基础模块搭好了,现在轮到真正干活的几个单元了。执行单元、访存单元、写回单元,再加上控制流水线的逻辑——这几个模块一接上,你的处理器就能真正跑指令了。

我个人习惯把这一节叫做「让处理器动起来」的关键一步。为什么?因为前面那些模块说白了只是准备阶段,从这节开始,数据开始流动,指令开始产生实际效果。嗯,咱们一个一个来。

4.1 执行单元(ALU)设计

ALU,算术逻辑单元,处理器的核心计算部件。RISC-V的ALU需要支持整数加减、移位、逻辑运算、比较等操作。我刚开始做的时候觉得这玩意儿简单,不就是一堆运算器拼一起嘛。结果第一次综合出来,时序一塌糊涂——关键路径全在ALU里。

先看一个基础的ALU结构:

module alu (
    input  [31:0] src_a,      // 操作数A
    input  [31:0] src_b,      // 操作数B
    input  [3:0]  alu_ctrl,   // 控制信号
    output reg [31:0] alu_result,
    output reg          zero       // 结果为0标志
);

    always @(*) begin
        case (alu_ctrl)
            4'b0000: alu_result = src_a + src_b;           // ADD
            4'b0001: alu_result = src_a - src_b;           // SUB
            4'b0010: alu_result = src_a & src_b;           // AND
            4'b0011: alu_result = src_a | src_b;           // OR
            4'b0100: alu_result = src_a ^ src_b;           // XOR
            4'b0101: alu_result = src_a << src_b[4:0];    // SLL
            4'b0110: alu_result = src_a >> src_b[4:0];    // SRL
            4'b0111: alu_result = $signed(src_a) >>> src_b[4:0]; // SRA
            4'b1000: alu_result = (src_a < src_b) ? 1 : 0; // SLT (有符号)
            4'b1001: alu_result = (src_a < src_b) ? 1 : 0; // SLTU (无符号)
            default: alu_result = 32'b0;
        endcase
        zero = (alu_result == 32'b0);
    end

endmodule

这段代码看起来挺规整,对吧?但实际项目中我踩过一个坑:加法器和比较器共用操作数路径时,进位链会打架。你想想看,加法器需要快速进位,比较器却要等所有位稳定——这两个混在一起,综合工具会给你一个很长的组合逻辑链。

我的建议:把加法器和比较器的数据路径分开。加法器用超前进位结构,比较器单独用逐位比较。虽然面积会大一点点,但时序好很多。

另外,RISC-V的移位指令有个细节:移位量只取低5位(RV32I)。这个我在调试时吃过亏——写了个移位指令,结果高位移位量没屏蔽,仿真结果全错。排查了半天才发现是这里的问题。

4.2 访存单元设计

访存单元负责处理load/store指令。说白了就是CPU和内存之间的数据搬运工。RISC-V的访存指令包括LB/LH/LW/LBU/LHU和SB/SH/SW,支持字节、半字、字的读写。

访存单元的核心逻辑包括三部分:

  • 地址计算:基址+偏移量,生成最终访存地址
  • 数据对齐与扩展:字节/半字/字的读写,以及符号扩展/零扩展
  • 访存控制:产生读写使能信号,处理字节掩码

看一个load指令的数据通路:

module load_unit (
    input  [31:0] mem_addr,      // 访存地址
    input  [31:0] mem_rdata,     // 从内存读回的数据
    input  [2:0]  load_type,     // 000:LB, 001:LH, 010:LW, 100:LBU, 101:LHU
    output reg [31:0] load_result
);

    wire [1:0] addr_offset = mem_addr[1:0];  // 字节偏移

    always @(*) begin
        case (load_type)
            3'b000: begin // LB: 取一个字节,符号扩展
                case (addr_offset)
                    2'b00: load_result = {{24{mem_rdata[7]}},  mem_rdata[7:0]};
                    2'b01: load_result = {{24{mem_rdata[15]}}, mem_rdata[15:8]};
                    2'b10: load_result = {{24{mem_rdata[23]}}, mem_rdata[23:16]};
                    2'b11: load_result = {{24{mem_rdata[31]}}, mem_rdata[31:24]};
                endcase
            end
            3'b001: begin // LH: 取半字,符号扩展
                case (addr_offset[1])
                    1'b0: load_result = {{16{mem_rdata[15]}}, mem_rdata[15:0]};
                    1'b1: load_result = {{16{mem_rdata[31]}}, mem_rdata[31:16]};
                endcase
            end
            3'b010: load_result = mem_rdata;  // LW: 取整字
            3'b100: begin // LBU: 取字节,零扩展
                case (addr_offset)
                    2'b00: load_result = {24'b0, mem_rdata[7:0]};
                    2'b01: load_result = {24'b0, mem_rdata[15:8]};
                    2'b10: load_result = {24'b0, mem_rdata[23:16]};
                    2'b11: load_result = {24'b0, mem_rdata[31:24]};
                endcase
            end
            3'b101: begin // LHU: 取半字,零扩展
                case (addr_offset[1])
                    1'b0: load_result = {16'b0, mem_rdata[15:0]};
                    1'b1: load_result = {16'b0, mem_rdata[31:16]};
                endcase
            end
            default: load_result = 32'b0;
        endcase
    end

endmodule
注意:RISC-V要求访存地址必须对齐。LW要求地址低2位为0,LH要求地址低1位为0。如果不对齐,会产生异常。我曾经在项目中为了省事没做对齐检查,结果跑Linux内核时频繁崩溃——内核里有些未对齐的访存操作。

store单元的逻辑类似,但需要生成字节掩码(byte enable)来控制写入哪些字节。这个掩码信号要传给数据存储器,告诉它哪些字节需要更新。

4.3 写回单元设计

写回单元负责把计算结果写回寄存器堆。听起来简单,但这里有个关键问题:写回的数据来源有多个——ALU结果、访存数据、PC+4(用于JAL指令)。

写回单元本质上就是一个多路选择器:

module writeback_mux (
    input  [31:0] alu_result,
    input  [31:0] mem_rdata,
    input  [31:0] pc_plus4,
    input  [1:0]  wb_sel,      // 00: ALU, 01: MEM, 10: PC+4
    output reg [31:0] wb_data
);

    always @(*) begin
        case (wb_sel)
            2'b00: wb_data = alu_result;
            2'b01: wb_data = mem_rdata;
            2'b10: wb_data = pc_plus4;
            default: wb_data = 32'b0;
        endcase
    end

endmodule

这里有个小细节:写回使能信号(reg_write)需要和写回数据同步。我见过有人把使能信号提前一个周期拉高,结果寄存器被写入了错误的数据。嗯,这种bug特别难查,因为波形上看使能和数据都是对的,但时序上差了一个周期。

4.4 流水线控制逻辑

终于到流水线控制逻辑了。这部分是整个处理器的「大脑」,负责协调各个流水级的工作。五级流水线(IF、ID、EX、MEM、WB)之间的数据依赖、控制冒险、结构冒险,全得靠它来处理。

先画个流水线结构图,让大家有个直观认识:

五级流水线处理器结构 IF 取指 ID 译码/读寄存器 EX 执行(ALU) MEM 访存 WB 写回 IF/ID ID/EX EX/MEM MEM/WB 前向转发路径 流水线控制逻辑 PC更新 寄存器读 ALU控制 访存控制 写回控制 冒险检测单元 | 前向转发单元 | 流水线暂停/冲刷控制 数据冒险检测 | 控制冒险处理 | 异常处理 前向转发路径 控制信号

流水线控制逻辑主要处理三类问题:

4.4.1 数据冒险处理

数据冒险是最常见的。比如:

add x1, x2, x3   // 写x1
add x4, x1, x5   // 读x1,但x1还没写回

处理方式有两种:

  • 插入气泡(stall):暂停流水线,等数据写回后再继续。简单但性能损失大。
  • 前向转发(forwarding):把EX或MEM级的结果直接送给ALU输入。这是主流做法。

前向转发的核心逻辑:

// 前向转发控制
always @(*) begin
    // 默认使用ID级读出的寄存器值
    forward_a = 2'b00;
    forward_b = 2'b00;
    
    // EX冒险:上一条指令的ALU结果
    if (ex_reg_write && (ex_rd != 0) && (ex_rd == id_rs1))
        forward_a = 2'b10;  // 转发EX结果
    if (ex_reg_write && (ex_rd != 0) && (ex_rd == id_rs2))
        forward_b = 2'b10;
    
    // MEM冒险:上上条指令的访存结果
    if (mem_reg_write && (mem_rd != 0) && (mem_rd == id_rs1))
        forward_a = 2'b01;  // 转发MEM结果
    if (mem_reg_write && (mem_rd != 0) && (mem_rd == id_rs2))
        forward_b = 2'b01;
end
关键点:前向转发不能解决所有数据冒险。比如load指令后紧跟使用该数据的指令——load的数据在MEM级才拿到,而ALU在EX级就需要。这种情况必须插入一个气泡。

4.4.2 控制冒险处理

分支指令会导致控制冒险。RISC-V的分支指令(BEQ、BNE等)在EX级才计算出结果,但下一条指令已经在IF级被取进来了。

处理方式:

  • 预测不跳转:默认继续取顺序下一条指令,如果分支跳转则冲刷流水线
  • 分支预测:更复杂,但性能更好。简单实现可以用静态预测(向后跳转预测为真)

我早期做的一个版本用了最简单的「预测不跳转」,结果跑Dhrystone基准测试时性能惨不忍睹——分支指令占了将近20%,每次预测错误都要浪费2个周期。后来加了个2位饱和计数器,性能提升了15%。

4.4.3 结构冒险处理

结构冒险是指多个流水级同时访问同一个硬件资源。比如IF级取指令和MEM级访存同时访问存储器。解决办法:

  • 使用哈佛结构:指令存储和数据存储分开
  • 或者使用双端口存储器
  • 最差情况:插入气泡等待

我个人推荐用哈佛结构,简单可靠。FPGA的Block RAM可以配置成双端口,一个端口给IF,一个端口给MEM,完美解决结构冒险。

4.5 流水线控制状态机

最后,把控制逻辑整合成一个状态机。这个状态机管理流水线的运行、暂停和冲刷:

typedef enum logic [1:0] {
    RUN,      // 正常运行
    STALL,    // 暂停(插入气泡)
    FLUSH     // 冲刷(清空流水线)
} pipe_state_t;

pipe_state_t pipe_state, next_pipe_state;

always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        pipe_state <= RUN;
    else
        pipe_state <= next_pipe_state;
end

always @(*) begin
    next_pipe_state = pipe_state;
    case (pipe_state)
        RUN: begin
            if (load_use_hazard)      // load-use数据冒险
                next_pipe_state = STALL;
            else if (branch_taken)    // 分支跳转
                next_pipe_state = FLUSH;
        end
        STALL: begin
            // 等待一个周期后恢复
            next_pipe_state = RUN;
        end
        FLUSH: begin
            // 冲刷后立即恢复
            next_pipe_state = RUN;
        end
    endcase
end

这个状态机虽然简单,但能处理大部分流水线控制场景。实际项目中,你可能还需要处理异常、中断等更复杂的情况——那些我们后面章节再聊。

调试小技巧:在仿真时,把流水线各级的指令和寄存器值打印出来。我习惯在每个流水级寄存器里加一个「指令序号」字段,这样波形上一眼就能看出哪条指令在哪个级,排查数据冒险特别方便。

好了,这一节的内容就到这儿。执行单元、访存单元、写回单元加上流水线控制逻辑,这四个模块组合起来,你的RISC-V处理器就能真正跑指令了。下一节我们会把这些模块全部连起来,做一个完整的单核处理器验证。


公众号:蓝海资料掘金营,微信deep3321