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
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)之间的数据依赖、控制冒险、结构冒险,全得靠它来处理。
先画个流水线结构图,让大家有个直观认识:
流水线控制逻辑主要处理三类问题:
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
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