learn-tech/专栏/计算机基础实战课/09手写CPU(四):如何实现CPU流水线的访存阶段?.md
2024-10-16 10:18:29 +08:00

320 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
09 手写CPU如何实现CPU流水线的访存阶段
你好我是LMOS。
先简单回顾一下上一节课我们设计了MiniCPU流水线的执行相关模块。其中包括执行控制模块、通用寄存器模块以及可以进行加减法运算、大小比较、移位操作的ALU模块。
指令执行之后就到了流水线的下一级——访存。这节课我们就重点聊聊怎么设计实现访存的相关模块。在你的设想里,访存模块必要的组成部分有哪些呢?
如果你的第一反应是访存控制模块,我只能说你只答对了一部分。访存控制模块虽然是流水线的主线,但你可能忽略了流水线中的数据相关性问题。因此,今天我们先想办法解决流水线的数据冒险问题,然后再完成流水线访存阶段相关模块的设计。
这节课的代码你可以从这里获取。
流水线数据冒险
在开始设计访存模块之前,我们得先解决一个问题,即流水线的数据冒险。
在CPU流水线里执行不同的指令时会发生这样的情况一条指令B它依赖于前面还在流水线中的指令A的执行结果。当指令B到达执行阶段时因为指令A还在访存阶段所以这时候就无法提供指令B执行所需要的数据。这就导致指令B无法在预期的时钟周期内执行。
当指令在流水线中重叠执行时,后面的指令需要用到前面的指令的执行结果,而前面的指令结果尚未写回,由此导致的冲突就叫数据冒险。
我再举个更具体些的例子。比如,有一条减法指令,它需要用到前面一条加法指令的运算结果作为被减数:
add x2,x0,x1
sub x6,x2,x3
结合下面的示意图我们可以看到在不做任何干预的情况下sub依赖于add的执行结果这导致sub指令要等到add指令走到流水线的第五个阶段把结果写回之后才能执行这就浪费了三个时钟周期。
这种数据冒险将会严重地阻碍CPU的流水线设置流水线目的就是为了提升效率让某个时间点上有多条指令可以同时执行这种让指令“干等”的状态显然不是最佳选项。那我们怎么解决这样的问题呢方法其实不止一种让我带你分析分析。
结合前的例子,我们最直接的处理办法就是通过编译器调整一些指令顺序。不过指令存在依赖关系的情况经常发生,用编译器调整的方式会导致延迟太长,处理的结果无法让我们满意。
别灰心我们再另寻出路。把前面的加法指令add放到流水线中通过观察整个运算过程我们不难发现其实add加法运算的结果经过流水线的执行阶段也就是第三个模块EX之后就已经出来了只是还没把结果写回到x2寄存器而已。
所以,另一种解决办法也就有了头绪,能不能通过向内部资源添加额外的硬件,来尽快找到缺少的运算项呢?
这当然可以。对于上述的指令序列一旦ALU计算出加法指令的结果就可以将其作为减法指令执行的数据输入不需要等待指令完成就可以解决数据冒险的问题。
如上图所示将add指令执行阶段运算的结果x2中的值直接传递给sub指令作为执行阶段的输入替换sub指令在译码阶段读出的寄存器x2的值。这种硬件上解决数据冒险的方法称为前递forwarding
好,思路既然理清楚了,我们就把它落地到实际设计里。
数据前递模块的设计
通过上节课执行模块的设计我们知道了ALU的逻辑运算需要两个操作数一个来自于通用寄存器另一个来自于通用寄存器或者指令自带的立即数。
但是当需要读取的通用寄存器的值依赖于前面第一条或者第二条指令的运算结果时就出现了刚才我们提到的数据冒险问题。为了解决这个问题就需要我们专门设计一个数据前递模块forwarding它在流水线中的位置如下图所示
所谓前递,顾名思义,就是把流水线中后面阶段产生的数据向前传递的过程。
正如上图中的forwarding模块可以看到它的数据来自于流水线中的执行模块EX、访存模块MEM、写回模块WB的输出经过forwarding模块处理后把数据传递到执行模块的输入。
然后,流水线根据当前指令的译码信号,选择读取通用寄存器的数据作为执行模块的操作数,或者选择来自前递模块的数据作为执行模块的操作数。
那么具体是如何选择前递数据作为执行模块的操作数呢让我们结合下面forwarding模块的代码来寻找答案
module forwarding (
input [4:0] rs1,
input [4:0] rs2,
input [4:0] exMemRd,
input exMemRw,
input [4:0] memWBRd,
input memWBRw,
input mem_wb_ctrl_data_toReg,
input [31:0] mem_wb_readData,
input [31:0] mem_wb_data_result,
input [31:0] id_ex_data_regRData1,
input [31:0] id_ex_data_regRData2,
input [31:0] ex_mem_data_result,
output [31:0] forward_rs1_data,
output [31:0] forward_rs2_data
);
//检查是否发生数据冒险
wire [1:0] forward_rs1_sel = (exMemRw & (rs1 == exMemRd) & (exMemRd != 5'b0)) ? 2'b01
:(memWBRw & (rs1 == memWBRd) & (memWBRd != 5'b0)) ? 2'b10
: 2'b00;
wire [1:0] forward_rs2_sel = (exMemRw & (rs2 == exMemRd) & (exMemRd != 5'b0)) ? 2'b01
:(memWBRw & (rs2 == memWBRd) & (memWBRd != 5'b0)) ? 2'b10
: 2'b00;
wire [31:0] regWData = mem_wb_ctrl_data_toReg ? mem_wb_readData : mem_wb_data_result;
//根据数据冒险的类型选择前递的数据
assign forward_rs1_data = (forward_rs1_sel == 2'b00) ? id_ex_data_regRData1 :
(forward_rs1_sel == 2'b01) ? ex_mem_data_result :
(forward_rs1_sel == 2'b10) ? regWData : 32'h0;
assign forward_rs2_data = (forward_rs2_sel == 2'b00) ? id_ex_data_regRData2 :
(forward_rs2_sel == 2'b01) ? ex_mem_data_result :
(forward_rs2_sel == 2'b10) ? regWData : 32'h0;
endmodule
我们分别看看代码中的各种信号。前递模块输入的端口信号rs1和rs2来自于指令译码后得到的两个通用寄存器索引。exMemRd信号是来自访存模块的对通用寄存器的访问地址。exMemRw是流水线访存阶段对通用寄存器的写使能控制信号。memWBRd 和 memWBRw分别是写回模块对通用寄存器的地址和写使能控制信号。
利用这些信号就可以判断是否发生数据冒险,我们结合下面这段代码继续分析分析:
//检查是否发生数据冒险
wire [1:0] forward_rs1_sel = (exMemRw & (rs1 == exMemRd) & (exMemRd != 5'b0)) ? 2'b01
:(memWBRw & (rs1 == memWBRd) & (memWBRd != 5'b0)) ? 2'b10
: 2'b00;
wire [1:0] forward_rs2_sel = (exMemRw & (rs2 == exMemRd) & (exMemRd != 5'b0)) ? 2'b01
:(memWBRw & (rs2 == memWBRd) & (memWBRd != 5'b0)) ? 2'b10
: 2'b00;
当需要读取的通用寄存器的地址等于访存或者写回阶段要访问通用寄存器地址时也就是rs1 == exMemRd和rs1 == memWBRd就判断为将要发生数据冒险。
当然由于通用寄存器中的零寄存器的值永远为“0”所以不会发生数据冒险需要排除掉这种特殊情况也就是exMemRd != 5b0 和 memWBRd != 5b0。根据这样的判断结果就会产生前递数据的两个选择信号forward_rs1_sel和forward_rs2_sel。
发生数据冒险的情况就是这样,那不发生数据冒险又是什么情况呢?下面是选择前递的数据对应的代码段,我们结合这段代码继续分析。
//根据数据冒险的类型选择前递的数据
assign forward_rs1_data = (forward_rs1_sel == 2'b00) ? id_ex_data_regRData1 :
(forward_rs1_sel == 2'b01) ? ex_mem_data_result :
(forward_rs1_sel == 2'b10) ? regWData : 32'h0;
assign forward_rs2_data = (forward_rs2_sel == 2'b00) ? id_ex_data_regRData2 :
(forward_rs2_sel == 2'b01) ? ex_mem_data_result :
(forward_rs2_sel == 2'b10) ? regWData : 32'h0;
我们先把目光聚焦到id_ex_data_regRData1和id_ex_data_regRData2这两个信号上。它们来自于指令译码之后读出通用寄存器的两个操作数这是流水线不发生数据冒险时流水线正常选择的数据通路。
而ex_mem_data_result 信号是访存阶段需要写到通用寄存器的数据regWData是回写阶段需要更新到通用寄存器的数据。这样通过判断将要发生数据冒险的位置前递模块选择性地把处于流水线中的数据前递就可以巧妙地解决流水线中的数据冒险问题了。
访存控制模块设计
好了,解决了流水线的数据冒险问题,让我们回到流水线设计的主线来,继续完成流水线的第四级——访存相关模块的设计。
在[第六节课]讲CPU流水线的时候我们提到过流水线中一条指令的生命周期分为五个阶段。流水线的访存阶段就是指将数据从存储器中读出或写入存储器的过程。这个阶段会出现由 LOAD / STORE 指令产生的内存访问。
因为访存阶段的功能就是对存储器读写所以访存控制信号中最重要的两个信号就是存储器读控制信号memRead 和写控制信号memWrite。当然访存的控制信号通路也会受流水线冲刷等流水线管理信号的控制具体的代码如下
module ex_mem_ctrl(
input clk,
input reset,
input in_mem_ctrl_memRead, //memory读控制信号
input in_mem_ctrl_memWrite, //memory写控制信号
input [1:0] in_mem_ctrl_maskMode, //mask模式选择
input in_mem_ctrl_sext, //符合扩展
input in_wb_ctrl_toReg, //写回寄存器的数据选择“1”时为mem读取的数据
input in_wb_ctrl_regWrite, //寄存器写控制信号
input flush, //流水线数据冲刷信号
output out_mem_ctrl_memRead,
output out_mem_ctrl_memWrite,
output [1:0] out_mem_ctrl_maskMode,
output out_mem_ctrl_sext,
output out_wb_ctrl_toReg,
output out_wb_ctrl_regWrite
);
reg reg_mem_ctrl_memRead;
reg reg_mem_ctrl_memWrite;
reg [1:0] reg_mem_ctrl_maskMode;
reg reg_mem_ctrl_sext;
reg reg_wb_ctrl_toReg;
reg reg_wb_ctrl_regWrite;
assign out_mem_ctrl_memRead = reg_mem_ctrl_memRead;
assign out_mem_ctrl_memWrite = reg_mem_ctrl_memWrite;
assign out_mem_ctrl_maskMode = reg_mem_ctrl_maskMode;
assign out_mem_ctrl_sext = reg_mem_ctrl_sext;
assign out_wb_ctrl_toReg = reg_wb_ctrl_toReg;
assign out_wb_ctrl_regWrite = reg_wb_ctrl_regWrite;
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_mem_ctrl_memRead <= 1'h0;
end else if (flush) begin
reg_mem_ctrl_memRead <= 1'h0;
end else begin
reg_mem_ctrl_memRead <= in_mem_ctrl_memRead;
end
end
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_mem_ctrl_memWrite <= 1'h0;
end else if (flush) begin
reg_mem_ctrl_memWrite <= 1'h0;
end else begin
reg_mem_ctrl_memWrite <= in_mem_ctrl_memWrite;
end
end
………… //由于代码较长结构相似这里省略了一部分完整代码你可以从Gitee上获取
endmodule
虽然代码有几十行但过程还是很容易理解的。首先要根据流水线的冲刷控制信号flush判断访存阶段的控制信号是否需要清零。
如果flush等于“0”就把上一阶段送过来的控制信号比如存储器读控制信号memRead、存储器写控制信号memWrite……等通过寄存器保存下来然后发送给存储器读写控制模块dmem_rw.v或者流水线的下一级使用。
访存数据通路模块设计
接下来,我们继续完成访存数据通路模块的设计。访存数据通路就是把访存阶段读取到的存储器数据,或者是指令执行产生的结果发送流水线的下一级处理。
由于下一级也就是流水线的最后一级——写回所以访存的数据通路主要包括要写回的通用寄存器地址regWAddr、访问存储器读取的数据regRData2、指令运算的结果result等。
访存的数据通路也会受流水线冲刷等流水线管理信号的控制,具体代码如下:
module ex_mem(
input clk,
input reset,
input [4:0] in_regWAddr, //写回寄存器的地址
input [31:0] in_regRData2, //读存储器的数据
input [1:0] ex_result_sel, //执行结果选择
input [31:0] id_ex_data_imm, //指令立即数
input [31:0] alu_result, //ALU运算结果
input [31:0] in_pc, //当前PC值
input flush, //流水线数据冲刷控制信号
output [4:0] data_regWAddr,
output [31:0] data_regRData2,
output [31:0] data_result,
output [31:0] data_pc
);
reg [4:0] reg_regWAddr;
reg [31:0] reg_regRData2;
reg [31:0] reg_result;
reg [31:0] reg_pc;
wire [31:0] resulet_w = (ex_result_sel == 2'h0) ? alu_result :
(ex_result_sel == 2'h1) ? id_ex_data_imm :
(ex_result_sel == 2'h2) ? (in_pc +32'h4) : 32'h0;
assign data_regWAddr = reg_regWAddr;
assign data_regRData2 = reg_regRData2;
assign data_result = reg_result;
assign data_pc = reg_pc;
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_regWAddr <= 5'h0;
end else if (flush) begin
reg_regWAddr <= 5'h0;
end else begin
reg_regWAddr <= in_regWAddr;
end
end
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_regRData2 <= 32'h0;
end else if (flush) begin
reg_regRData2 <= 32'h0;
end else begin
reg_regRData2 <= in_regRData2;
end
end
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_result <= 32'h0;
end else if (flush) begin
reg_result <= 32'h0;
end else begin
reg_result <= resulet_w;
end
end
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_pc <= 32'h0;
end else if (flush) begin
reg_pc <= 32'h0;
end else begin
reg_pc <= in_pc;
end
end
endmodule
和上面的访存控制模块类似访存数据通路模块也是根据流水线的冲刷控制信号flush判断访存阶段的数据是否需要清零。如果不需要清零就把上一阶段送过来的数据通过寄存器保存下来。
对于代码的第21到第23行代码我为你单独解释一下。
ex_result_sel就是对流水线执行阶段的结果进行选择。当ex_result_sel == 2h0就选择ALU的运算结果ex_result_sel == 2h1就会选择指令解码得到的立即数其实就是对应LUI指令ex_result_sel == 2h2选择PC加4的值也就是下一个PC的值。
wire [31:0] resulet_w = (ex_result_sel == 2'h0) ? alu_result :
(ex_result_sel == 2'h1) ? id_ex_data_imm :
(ex_result_sel == 2'h2) ? (in_pc +32'h4) : 32'h0;
重点回顾
这节课的内容到这里就告一段落了,我给你做个总结吧。
今天我们在设计访存模块之前先探讨了流水线中的数据冒险问题。在执行指令时如果发生了数据冒险就可能使流水线停顿等待前面的指令执行完成后才能继续执行后续的指令严重影响了指令在CPU流水线中并行执行。因此我们设计了数据前递模块来解决数据冒险的问题。
但是添加前递模块并不能避免所有的流水线停顿。比如当一条读存储器指令LOAD之后紧跟一条需要使用其结果的R型指令时就算使用前递也需要流水线停顿。因为读存储器的数据必须要在访存之后才能用但load指令正在访存时后一条指令已经在执行。所以在这种情况下流水线必须停顿通常的说法是在两条指令之间插入气泡。
最后,我们根据流水线的控制信号,完成了访存控制信号通路和访存数据通路的模块设计。这节课的要点你可以参考下面的导图。
通过课程的讲解CPU流水线中访存阶段的设计实现的思路相信你已经心中有数了别忘了课后结合配套代码再找找“手感”。下节课我们将会介绍流水线的最后一级——写回模块的设计敬请期待。
思考题
除了数据冒险我们的CPU流水线是否还存在其它的冲突问题你想到解决方法了么
欢迎你在留言区和我交流踊跃提问或者记录笔记对我们加深理解有很大的帮助。如果你觉得这节课还不错别忘了分享给更多朋友和他一起手写CPU。