跳转至

流水线 CPU 设计

2965 个字 180 行代码 预计阅读时间 12 分钟

Abstract

计算机系统 Ⅱ lab1 实验报告(2022.09.22 ~ 2022.10.13)

Warning

仅供学习参考,请勿抄袭

实验内容

  • lab 1-1:流水线加法机
    • 基于 lab 0 的单周期 CPU 搭建流水线加法机,支持 addi nop 指令
    • 进行仿真测试,检验 CPU 基本功能
    • 进行上板测试,检验 CPU 设计规范
    • 思考题
      1. 对于 part1 (2-14 ),请计算你的 CPU CPI,再用 lab0 的单周期 CPU 运行 part1,对比二者的 CPI
      2. 对于 part2 (24-39 ),请计算你的 CPU CPI(假设 nop 不计入指令条数),再用 lab0 的单周期 CPU 运行 part2,对比二者的 CPI。试解释为何需要添加 nop 指令
  • lab 1-2:指令扩展
    • 基于 lab 1-1,在流水线 CPU 中实现 lui、jal、jalr、beq、bne、lw、sw、addi、slti、xori、ori、andi、srli、srai、add、sub、sll、slt、sra、or、xor、and 指令
    • 搭建完整的流水线 CPU
    • 进行仿真测试和上板测试
    • 思考题
      1. 在你的设计中,本实验测试文件中的 nop 数量是否多于每条指令所需的延迟周期数
      2. 又是否存在出现冲突但是没有给足 nop 的情况
      3. 请计算每条指令间实际所需要的 nop 数量,并对你的 coe 文件进行修改,使之正确地运行出相应的结果

流水线加法机

数据通路设计

之前的单周期 CPU 数据通路设计(因为添加了 auipc 指令,所以和上课讲的有些许不一样):

nop 指令编译后相当于 addi x0, x0, 0,所以即只存在 addi 指令。在设计数据通路时可以先简化一下,不考虑 pc 的跳转变化,直接在 IF 阶段不断加四即可。所以在单周期 CPU 数据通路的基础上再加上四个阶段寄存器来分割即可(很多其它指令的细节也进行了保留,但在这部分中不会用到):

代码编写

由于本次实验不对数据通路进行封装,所以直接在 SCPU 中进行上述设计的实现。

IF

IF 段是由 PC IF/ID 两个寄存器(时序电路)分隔的。其中从 I-Mem 读取指令的部分在 SCPU 之外,也就是通过 pc_out 传出当前 pc,然后得到指令通过 inst 返回到 SCPU 中。因为 Core 中为 ROM 设置的 clk SCPU 正好错位,所以可以在 IF 所在的时钟周期中直接完成指令的读取。

体现在代码上即在上升沿时更新 pc pc_next(pc+4),同时更新 IF_ID_pc 为当前未更新的 pc(即传给下一阶段),也同时更新 IF_ID_inst 为当前读取出来的 inst

wire    [31:0]  pc_next;
reg     [31:0]  pc;

reg     [31:0]  IF_ID_pc;
reg     [31:0]  IF_ID_inst;

assign pc_out = pc;
assign pc_next = pc + 4; // 不考虑跳转

always @(posedge clk or posedge rst) begin 
    if (rst) begin
        pc <= 32'b0;
        IF_ID_pc <= 32'b0;
    end
    else begin
        pc <= pc_next;

        IF_ID_pc <= pc;
        IF_ID_inst <= inst;
    end
end

ID

ID 阶段需要进行寄存器组的访问、立即数生成以及指令译码,从数据通路图上可以看出,除了控制信号以外,输出给 ID/EX 寄存器的值有 pc、data1、data2、imm、write_addr。控制信号有 pc_src、mem_to_reg、reg_write、alu_src、branch、b_type、auipc、mem_write。所以需要根据这些来创建 ID/EX 寄存器,附带中间需要用到的 wire,代码为:

wire    [31:0]  read_data1, read_data2, imm;
wire    [3:0]   alu_op;
wire    [1:0]   pc_src, mem_to_reg;
wire            reg_write, alu_src, branch, b_type, auipc, mem_write_;
reg     [31:0]  ID_EX_data1, ID_EX_data2;
reg     [31:0]  ID_EX_pc, ID_EX_imm;
reg     [4:0]   ID_EX_write_addr;
reg     [3:0]   ID_EX_alu_op;
reg     [1:0]   ID_EX_pc_src, ID_EX_mem_to_reg;
reg             ID_EX_reg_write, ID_EX_alu_src, ID_EX_branch, ID_EX_b_type, ID_EX_auipc, ID_EX_mem_write;
以及涉及 ID/EX 寄存器的时序更新:
ID_EX_pc <= IF_ID_pc;
ID_EX_data1 <= read_data1;
ID_EX_data2 <= read_data2;
ID_EX_imm <= imm;
ID_EX_write_addr <= IF_ID_inst[11:7];
ID_EX_pc_src <= pc_src;
ID_EX_mem_to_reg <= mem_to_reg;
ID_EX_reg_write <= reg_write;
ID_EX_alu_src <= alu_src;
ID_EX_branch <= branch;
ID_EX_b_type <= b_type;
ID_EX_auipc <= auipc;
ID_EX_alu_op <= alu_op;
ID_EX_mem_write <= mem_write_;
剩下的是在这一阶段中连接三个模块:Regs、Control、ImmGen。对于 Regs,有一部分是在 WB 阶段用于写回的,这里先放下等到后面填。Control 和 ImmGen 和单周期 CPU 的写法基本一致,不过这里的 inst 要改成 IF_ID_inst 也就是 IF/ID 寄存器中存的指令,而不是当前的 inst(同一时刻会是后一条指令):
Regs regs (
    .clk(clk),
    .rst(rst),
    .we(____),              // 留给 WB 阶段
    .read_addr_1(IF_ID_inst[19:15]),
    .read_addr_2(IF_ID_inst[24:20]),
    .write_addr(____),      // 留给 WB 阶段
    .write_data(____),      // 留给 WB 阶段
    .read_data_1(read_data1),
    .read_data_2(read_data2)
);

Control control (
    .op_code(IF_ID_inst[6:0]),
    .funct3(IF_ID_inst[14:12]),
    .funct7_5(IF_ID_inst[30]),
    .alu_op(alu_op),
    .pc_src(pc_src),
    .mem_to_reg(mem_to_reg),
    .reg_write(reg_write),
    .alu_src_b(alu_src),
    .branch(branch),
    .b_type(b_type),
    .mem_write(mem_write_),
    .auipc(auipc)
);

ImmGen immgen (
    .inst(IF_ID_inst),
    .imm(imm)
);

EX

EX 阶段主要进行 ALU 运算,根据数据通路图可以看出,此阶段的输出(也就是 EX/MEM 寄存器中需要存的值)有 pc、alu_result、data2、imm、write_addr。控制信号在这一阶段中使用掉了 alu_opalu_src_b auipc,其余的还需要继续通过 EX/MEM 寄存器传下去。因此 EX/MEM 寄存器以及其它中间 wire 的定义:

wire    [31:0]  alu_data1, alu_data2, alu_result;
wire            alu_zero;
reg     [31:0]  EX_MEM_alu_result, EX_MEM_pc, EX_MEM_imm;
reg     [31:0]  EX_MEM_data2;
reg     [4:0]   EX_MEM_write_addr;
reg     [1:0]   EX_MEM_pc_src, EX_MEM_mem_to_reg;
reg             EX_MEM_reg_write, EX_MEM_branch, EX_MEM_b_type, EX_MEM_mem_write;
相关的时序逻辑更新:
EX_MEM_pc <= ID_EX_pc;
EX_MEM_imm <= ID_EX_imm;
EX_MEM_data2 <= ID_EX_data2;
EX_MEM_alu_result <= alu_result;
EX_MEM_write_addr <= ID_EX_write_addr;
EX_MEM_pc_src <= ID_EX_pc_src;
EX_MEM_mem_to_reg <= ID_EX_mem_to_reg;
EX_MEM_reg_write <= ID_EX_reg_write;
EX_MEM_branch <= ID_EX_branch;
EX_MEM_b_type <= ID_EX_b_type;
EX_MEM_mem_write <= ID_EX_mem_write;
在我的数据通路设计中,此阶段需要两个多路选择器(一个通过 auipc 在 data1 和 pc 中选择第一个输入,另一个通过 alu_src_b 在 data2 和 imm 中选择第二个输入)还有一个 ALU,因此这三个模块的定义:
Mux2x32 mux2x32_1 (
    .I0(ID_EX_data1),
    .I1(ID_EX_pc),
    .s(ID_EX_auipc),
    .o(alu_data1)
);

Mux2x32 mux2x32_2 (
    .I0(ID_EX_data2),
    .I1(ID_EX_imm),
    .s(ID_EX_alu_src),
    .o(alu_data2)
);

ALU alu (
    .a(alu_data1),
    .b(alu_data2),
    .alu_op(ID_EX_alu_op),
    .res(alu_result),
    .zero(alu_zero)     // 其实我没用
);

MEM

MEM 阶段需要进行 D-Mem 的访问,虽然 addi 指令不会涉及到 MEM 阶段,但是在此也进行了这一部分实现。D-Mem 也定义在 SCPU 之外,需要通过 SCPU 的接口来进行访问,即通过 addr_out 这一输出来输入给 RAM 指定操作的地址,data_out 输出来输入给 RAM 指定写入的数据,输出 mem_write 控制信号来指定进行写入还是读取,以及 RAM 输出 data_in SCPU 作为读取的数据。所以 SCPU 中只需要 assign 连线即可:

assign addr_out = EX_MEM_alu_result;
assign data_out = EX_MEM_data2;
assign mem_write = EX_MEM_mem_write;

下一步是进行 MEM/WB 寄存器的写入。从数据通路图中可以看出,需要写入的数据有 pc、alu_result、data_in、imm、write_addr。这一阶段的控制信号用掉了 mem_write,但是后续的跳转计算也计划在这里完成,将消耗 branch、b_type、pc_src 三个信号,最后 WB 阶段中会使用到的也只剩下 mem_to_reg reg_write 两个了。因此寄存器定义:

reg     [31:0]  MEM_WB_data_in, MEM_WB_alu_result, MEM_WB_pc, MEM_WB_imm;
reg     [4:0]   MEM_WB_write_addr;
reg     [1:0]   MEM_WB_mem_to_reg;
reg             MEM_WB_reg_write;
以及时序更新:
MEM_WB_data_in <= data_in;
MEM_WB_alu_result <= EX_MEM_alu_result;
MEM_WB_pc <= EX_MEM_pc;
MEM_WB_imm <= EX_MEM_imm;
MEM_WB_write_addr <= EX_MEM_write_addr;
MEM_WB_mem_to_reg <= EX_MEM_mem_to_reg;
MEM_WB_reg_write <= EX_MEM_reg_write;

WB

WB 段进行寄存器组的写回操作,需要复用 ID 阶段中定义连接的 Regs 模块,也就是先选择出需要写回的数据 write_data,然后写回在 write_addr 地址处的寄存器。因此需要一个 wire 变量 write_data。不需要再定义额外的寄存器。

对于 write_data 的选择,其来源有四个(完整情况下),分别是 alu_result、imm、pc+4、data_in,通过控制信号 mem_to_reg 进行四路选择即可:

Mux4x32 mux4x32 (
    .I0(MEM_WB_alu_result),
    .I1(MEM_WB_imm),
    .I2(MEM_WB_pc + 4),
    .I3(MEM_WB_data_in),
    .s(MEM_WB_mem_to_reg),
    .o(write_data)
);
最后再将 reg_write 信号、write_addr、write_data 连入 Regs 模块即可:
Regs regs (
    ...
    .we(MEM_WB_reg_write),
    ...
    .write_addr(MEM_WB_write_addr),
    .write_data(write_data),
    ...
);

仿真测试

按照之前的指导,将 lab1-1.coe 载入 ROM 中,然后以 Core_tb 为顶层模块进行仿真测试,仿真结果波形如下(包含 SCPU 中的 clk 信号、pc、inst 以及寄存器的变化):

对于这个波形的分析如下图(黑色、橙色方块为 addi 指令,红色方块为 nop 指令):

可以发现,指令确实叠在一起运行,一条指令运行五个周期,同一周期内运行五条指令,形成一个五阶流水线,且寄存器中结果变化均符合预期(在最后一个阶段 WB 写回产生变化,且值与汇编语句中描述相同)

思考题

  1. 对于 part1 (2-14 ),请计算你的 CPU CPI,再用 lab0 的单周期 CPU 运行 part1,对比二者的 CPI
对于目前的流水线 CPU,在 part1 部分,通过上面的波形图可以看出这 12 条指令一共运行了 16 个周期,其 CPI 为 16/12 = 1.33。而在 lab0 中,CPU 为单周期,一个周期会运行一条指令,所以其 CPI 为 1。可以看出,流水线 CPU 的 CPI 是会大于单周期 CPU 的,且当运行指令条数越多时,CPI 越接近单周期的 1。
  1. 对于 part2 (24-39 ),请计算你的 CPU CPI(假设 nop 不计入指令条数),再用 lab0 的单周期 CPU 运行 part2,对比二者的 CPI。试解释为何需要添加 nop 指令
从上波形图中可以看出,part2 的这些指令运行了 20 个周期,除去 nop 以外一共四条指令,CPI 为 20/4 = 5。而 lab0 中的 CPU 为单周期,运行 part2 的这些指令需要 16 个周期,有效 4 条,CPI 为 16/4 = 4(如果运行时除去 nop 指令,则 CPI 为 1)。发现此时流水线 CPU 的 CPI 是要大于单周期的。 添加 nop 指令的原因是此时会出现数据冒险,即在执行第一条指令时,第二条指令需要用到第一条指令的结果,但是第一条指令还没有执行完,所以需要等待第一条指令执行完,才能执行第二条指令。并且目前的流水线 CPU 中没有进行数据冒险的处理,所以需要靠添加 nop 指令来手动暂停避免冒险。

完整流水线 CPU

数据通路

在前面的简化数据通路基础上进行更改,需要修改的仅是为 pc 赋值的部分。采用了和 lab0 中相同的用于 pc 的多路选择器,其结构为:

input   [31:0]  I0,         // pc+4
input   [31:0]  I1,         // jalr 的地址
input   [31:0]  I2,         // jal 的地址
input   [31:0]  I3,         // branch 的地址,和 jal 相同
input   [1:0]   s,          // pc_src 控制信号
input           branch,     // branch 控制信号(是否是 branch 语句)
input           b_type,     // b_type 控制信号(0 表示 bne,反之 beq)
input   [31:0]  alu_res,    // alu 的结果(作用相当于 alu_zero)
output  [31:0]  o           // pc_next
在此处 jalr/jal/branch 的地址都需要到达 MEM 阶段才可以计算,几个控制信号也是在 MEM 阶段的,为了防止等待,pc+4 中的 pc 不应该是 MEM 阶段的 pc,而是当前时刻 pc 寄存器中的 pc,pc_next 也会直接在下一个上升沿赋值给 pc。因此需要保证除了跳转以外的其它时刻,s、branch 等信号都为 0(即选择 pc+4 作为 pc_next)。而一条包含跳转的指令运行到 MEM 时,会改变这些信号,从而影响 pc_next,实现跳转。基于此思路,数据通路为:

代码实现

首先需要删掉前面写的 assign pc_next = pc+4; 然后增加创建并连接 MuxPC 模块:

wire    [31:0]  jal_addr, jalr_addr;

//--------------------MEM--------------------//
assign addr_out = EX_MEM_alu_result;
assign data_out = EX_MEM_data2;
assign mem_write = EX_MEM_mem_write;

assign jal_addr = EX_MEM_pc + EX_MEM_imm;
assign jalr_addr = EX_MEM_alu_result;

MuxPC mux_pc (
    .I0(pc + 4),
    .I1(jalr_addr),
    .I2(jal_addr),
    .I3(jal_addr),
    .s(EX_MEM_pc_src),
    .branch(EX_MEM_branch),
    .b_type(EX_MEM_b_type),
    .alu_res(EX_MEM_alu_result),
    .o(pc_next)
);
这样整个流水线 CPU 就完成了。完整代码见附件。

仿真测试

因为在 lab0 中已经完成了 bonus 指令,包含了本实验中的所有指令,所以直接载入 coe 文件运行即可,仿真波形如下:

波形分析

第一部分,正常运行了一些计算指令,结果均正确。并且几个 bne 跳转未达到条件没有跳转。

第二部分,主要是跳转,slli 指令后面接三个 nop,然后是 jalr 无条件跳转指令,其中 MEM 阶段后更改了 pcWB 阶段后将该指令 pc 4 后存入了 x1 寄存器中。然后是一些 nop 指令防止副作用(此处只执行了三个),pc 跳转到了 244(即 addi 指令的位置),执行了 addi 指令后有三个 nop,一个未成功 bne,五个 nop,之后运行到了 j pass 指令,也就是反复跳到当前位置,在下图中也能清晰地看到有 pc 跳转接 3 nop 的循环:

通过以上分析,可见运行是正确的。

上板验证

需要修改一个地方用来 debug,即将 SCPU debug_reg_addr 输入到 Regs 中,然后输出 debug_reg,传入上级 Core 中,来查看某一寄存器的值。

Regs regs (
    ...
    .debug_reg_addr(debug_reg_addr),
    .debug_reg(debug_reg)
)

其它功能(看 pc、addr_out、inst,以及根据开关设置 debug_reg_addr)已经在 lab0 中实现,这里保留即可。

上板后逐周期调试运行,均和波形一致,结果正确。

思考题

  1. 在你的设计中,本实验测试文件中的 nop 数量是否多于每条指令所需的延迟周期数?
多于了,对于我的设计来说,在每个跳转指令后面只需要接 3 个 nop 指令就可以防止其后的指令在跳转前被执行(详见上面波形分析),但是在测试文件中,每个跳转指令后面都接了 5 个 nop 指令,这样就多了 2 个延迟周期。
  1. 又是否存在出现冲突但是没有给足 nop 的情况?
不存在
  1. 请计算每条指令间实际所需要的 nop 数量,并对你的 coe 文件进行修改,使之正确地运行出相应的结果
需要修改的只是各个跳转指令后面 nop 的个数,从 5 个修改到 3 个,其他指令不需要修改。仍可以正确运行。

最后更新: 2022年11月29日 15:52:39
创建日期: 2022年11月29日 15:52:39
回到页面顶部