Contents

流水线MIPS CPU

写在前面

本篇是对CO-P5~7的总结。由于通关P7后便进入期末周,故本篇是在考完试放假后写的,有一些具体的细节可能已经记不太清了,就权当总结贴吧。全部代码已在GitHub开源:点此进入

P5:流水线CPU的改造

什么是流水线CPU

流水线CPU,是指将CPU划分成不同的部分,每一个周期指令向前进入下一部分的架构。尽管每一条指令的执行周期从原来的一个周期变成了多个周期,但是在同一时刻可以有多条指令同时执行,大大提高了CPI和吞吐量。在我们的五级流水线CPU中,我们将CPU划分为F(Fetch)、D(Decode)、E(Execute)、M(Memory)、W(Writeback)五个级次,分别负责取指、解码、计算、读写内存、写回寄存器。

流水线寄存器

实现流水线的核心方法即为流水线寄存器。流水线寄存器,即在每两级之间加入一个寄存器堆,这样既可以将后面的级次需要用到的数据传下去,也实现了再每一个时钟周期只前进一个级次的效果。其实现也十分简单,只需要将需要流水的数据在时钟上升沿复制给下一级输出,在复位时将输出清空。以D/E流水线寄存器为例:

module DE(
    input clk,
    input reset,
    input DE_en,
    input DE_reset,
    input [31:0] D_Instr,
    input [31:0] D_PC,
    input [31:0] D_PCplus8,
    input [31:0] D_RD1,
    input [31:0] D_RD2,
    input [4:0] D_A3,
    input [31:0] D_imm32,
    output reg [31:0] E_Instr,
    output reg [31:0] E_PC,
    output reg [31:0] E_PCplus8,
    output reg [31:0] E_RD1,
    output reg [31:0] E_RD2,
    output reg [4:0] E_A3,
    output reg [31:0] E_imm32
    );

    always @(posedge clk) begin
        if(reset | DE_reset) begin
            E_Instr <= 32'b0;
            E_PC <= 32'b0;
            E_PCplus8 <= 32'b0;
            E_RD1 <= 32'b0;
            E_RD2 <= 32'b0;
            E_A3 <= 32'b0;
            E_imm32 <= 32'b0;
        end
        else begin
            if(DE_en) begin
                E_Instr <= D_Instr;
                E_PC <= D_PC;
                E_PCplus8 <= D_PCplus8;
                E_RD1 <= D_RD1;
                E_RD2 <= D_RD2;
                E_A3 <= D_A3;
                E_imm32 <= D_imm32;
            end
        end
    end
endmodule

冒险

可以想见,我们在同一时刻最多有五条指令进入流水线,但是我们后一条指令可能会用到前一条指令的结果,甚至后一条指令是否执行都是未定的。这便是冒险。冒险有三种情况:数据冒险结构冒险控制冒险

  1. 数据冒险:后一条指令需要用到前一条指令的计算结果,但是这一结果还未存入寄存器。
  2. 结构冒险:当指令和数据存储器共享同一个内存时,如果取指和读写内存同时发生,便会产生冲突。
  3. 控制冒险:涉及到分支跳转指令时,下一条指令进入时尚且无法判断这条指令是否会被执行。

冒险的解决

  1. 数据冒险:使用转发阻塞来解决。转发,即在产生可能会被后面的指令用到的数据后,通过导线向前面的级次传递,新的指令进入后判断是否使用(即使用多路选择器)。当然,有些时候下一条指令需要用到的数据,上一条指令还没有计算完成,这个时候我们就需要阻塞(即将中间的所有流水线寄存器清空,让中间的指令变成nop空泡,等到可以进行转发或者已经写回后再让后面的指令流动)。判断是否可以转发、是否使用转发的值的方法,即AT&T法,实际上就是对每一种指令何时可以转发、何时需要用到某个寄存器进行打表,然后再进行判断。具体而言:

令:级次为:D-0 E-1 M-2 W-3,一条指令需要用到 rs/rt 寄存器的级次为 $t_{rs}$ 和 $t_{rt}$,产生写入寄存器值的级次为 $t$。
前一条指令到 D 的距离为 $t_1$,后一条指令到 D 的距离为 $t_2$。

记:

$$t_{\text{rsuse}} = t_{rs} - t_2$$

$$t_{\text{rtuse}} = t_{rt} - t_2$$

$$t_{\text{new}} = t - t_1$$

则转发的触发条件为:

$$t_{\text{rsuse}} \ge t_{\text{new}}$$

$$t_{\text{rtuse}} \ge t_{\text{new}}$$

另外,若 $t_{\text{use}}$ 或 $t_{\text{new}} < 0$,则记为 0。

  1. 结构冒险:我们一直使用的结构是哈佛结构(即IM与DM分离),故不存在此冒险
  2. 控制冒险:解决控制冒险的方法有很多,如分支预测、延迟槽等。在mips中,我们使用延迟槽来解决。所谓延迟槽,即跳转指令的下一条指令一定被执行。这样,我们便无需关心跳转到哪里的问题。同时,为了保证第三条指令是正确的,我们将分支判断提前到D级(即我们的zero模块),这样我们就能及时判断第三条指令的PC值。当然,在实际编写mips程序时,我们往往并不想要在跳转指令后执行任何指令,而是直接跳转,这时我们可以加入nop指令——这就是这个空泡指令的作用。

P6:新指令与新模块

P6相较于P5,并没有很大的迈步,核心的工作即为添加乘除法模块、增加许多简单的新指令的支持、存储器外置。

乘除法模块

为了实现简单,课程组不要求真正实现乘除法电路,而是可以直接用verilog的乘除运算符。同时,除了HI LO两个寄存器外,由于我们需要模拟实际乘除法的阻塞,我们需要一个计数器来存储当前指令运行的周期数。代码如下:

module MDU(
    input clk,
    input reset,
    input [31:0] in1,
    input [31:0] in2,
    input [5:0] type,
    input start,
    output [31:0] out,
    output busy
);

    reg [31:0] HI;
    reg [31:0] LO;
    reg [3:0] cnt;

    parameter MULT  = 6'b010101,
              MULTU = 6'b010110,
              DIV   = 6'b010111,
              DIVU  = 6'b011000,
              MFHI  = 6'b011001,
              MFLO  = 6'b011010,
              MTHI  = 6'b011011,
              MTLO  = 6'b011100;

    always @(posedge clk) begin
        if (reset) begin
            HI <= 32'h00000000;
            LO <= 32'h00000000;
            cnt <= 4'h0;
        end
        else begin
            if (type == MULT) begin
                {HI, LO} <= $signed(in1) * $signed(in2);
            end
            else if (type == MULTU) begin
                {HI, LO} <= in1 * in2;
            end
            else if (type == DIV) begin
                if (in2 != 32'h00000000) begin
                    HI <= $signed(in1) % $signed(in2);
                    LO <= $signed(in1) / $signed(in2);
                end
            end
            else if (type == DIVU) begin
                if (in2 != 32'h00000000) begin
                    HI <= in1 % in2;
                    LO <= in1 / in2;
                end
            end
            else if (type == MTHI) begin
                HI <= in1;
            end
            else if (type == MTLO) begin
                LO <= in1;
            end

            if (start == 1'b1) begin
                if (type == MULT | type == MULTU) begin
                    cnt <= 4'd5;
                end
                else if (type == DIV | type == DIVU) begin
                    cnt <= 4'd10;
                end
            end

            if (cnt != 4'b0) begin
                cnt <= cnt - 4'b0001;
            end
        end
    end

    assign out = (type == MFHI) ? HI : (type == MFLO) ? LO : 32'b0;
    assign busy = (cnt != 4'b0) ? 1'b1 : 1'b0;
endmodule

新指令添加

这里绝大多数指令都是走流程即可,唯一值得一提的是内存存取中的lb lh sb sh这四个指令。由于这四个指令不再是整字读取,所以我们采取的方案是将每个字划分成四个字节,每一个字节具有单独的WE,我们最后再对读写的数据进行拼接。

存储模块外置

实际的CPU中,GRF、DM等是不封装在CPU中的。为了模拟与外部存储设备的交互,课程组在testbench中提供了存储器,而我们在CPU中只需要将原来存储器模块删除,并将输入输出与CPU顶层的输入输出相连。由于DM模块我们刚刚进行了改动,所以这里展示出用于读写内存的模块代码:

module DMLoad(
    input [31:0] addr,
    input [31:0] data,
    input [5:0] type,
    output [31:0] fixed_data
    );

    parameter LW = 6'b000110,
              LH = 6'b010001,
              LB = 6'b010010;

    wire [15:0] half_word;
    wire [7:0] byte;

    assign half_word = (type == LH && addr[1] == 1'b0) ? data[15:0] :
                       (type == LH && addr[1] == 1'b1) ? data[31:16] :
                       16'b0;

    assign byte = (type == LB && addr[1:0] == 2'b00) ? data[7:0] :
                  (type == LB && addr[1:0] == 2'b01) ? data[15:8] :
                  (type == LB && addr[1:0] == 2'b10) ? data[23:16] :
                  (type == LB && addr[1:0] == 2'b11) ? data[31:24] :
                  8'b0;

    assign fixed_data = (type == LW) ? data :
                        (type == LH) ? {{16{half_word[15]}}, half_word} :
                        (type == LB) ? {{24{byte[7]}}, byte} :
                        data;
endmodule
module DMSave(
    input [31:0] addr,
    input [31:0] data,
    input [5:0] type,
    output [3:0] WE,
    output [31:0] fixed_data
    );

    parameter SW = 6'b000111,
              SH = 6'b010011,
              SB = 6'b010100;

    assign WE = (type == SH && addr[1] == 1'b0) ? 4'b0011 :
                (type == SH && addr[1] == 1'b1) ? 4'b1100 :
                (type == SB && addr[1:0] == 2'b00) ? 4'b0001 :
                (type == SB && addr[1:0] == 2'b01) ? 4'b0010 :
                (type == SB && addr[1:0] == 2'b10) ? 4'b0100 :
                (type == SB && addr[1:0] == 2'b11) ? 4'b1000 :
                (type == SW) ? 4'b1111 : 4'b0000;

    assign fixed_data = (type == SH && addr[1] == 1'b0) ? {16'b0, data[15:0]} :
                        (type == SH && addr[1] == 1'b1) ? {data[15:0], 16'b0} :
                        (type == SB && addr[1:0] == 2'b00) ? {24'b0, data[7:0]} :
                        (type == SB && addr[1:0] == 2'b01) ? {16'b0, data[7:0], 8'b0} :
                        (type == SB && addr[1:0] == 2'b10) ? {8'b0, data[7:0], 16'b0} :
                        (type == SB && addr[1:0] == 2'b11) ? {data[7:0], 24'b0} :
                        (type == SW) ? data : data;
endmodule

P7:中断异常的处理

终于来到了最终关!在这里,我们需要做的,是增加中断异常的处理,并将我们的mips.v变成CPU.v,而真正的顶层模块mips.v是封装了CPU、Timer、系统桥三个部分的完整CPU!(Timer模块由课程组给出) 同样地,这里依然不准备展示完整细节,而是用简单的语言介绍一下大体思路。

中断异常

不要被陷入内核、返回现场等等概念绕蒙了!我们实际要做的十分简单,就是在发生异常时,CP0模块向各个模块发送中断信号,并记录此时的地址,然后跳转到PC值为0x4180的地方执行那里的指令,直到遇到eret指令,返回刚刚记录的位置。所以,中断异常的处理,我认为,可以理解为一种特殊的跳转指令。

外部设备模拟

这里我们要做的,就是为我们的五级流水线CPU添加一个宏观PC,让他在外部看起来是一个单周期CPU,然后实现一个系统桥,负责CPU、Timer、外部输入输出之间的沟通。最后,将CPU、Timer0、Timer1、Bridge和外部输入输出的数据线在mips.v中进行连接即可。

设计文稿

下面附上P7部分的设计文稿,方便各位理解。

设计文稿

CP0

功能
判断是否需要进行中断异常,向其他模块发送中断信号,并记录陷入内核时的地址
设计

Reg: SR (12), Cause (13), EPC (14), PRId (15)
SR: 判断某种异常是否被允许发生 
- EXL(SR[1]) 总开关 1'b0表示允许 
- IE(SR[0]) 外部中断开关 1'b1表示允许 
- IM(SR[15:10]) 各种中断允许位 1'b1表示允许  
Cause: 存储陷入内核原因 
- BD(Cause[31]) 1'b1表示在延迟槽中发生 
- IP(Cause[15:10]) 各种中断请求位 1'b1表示请求 
- ExcCode(Cause[6:2]) 异常代码  
EPC: 异常发生时的返回地址  
PRId: 处理器标识

判断是否发生异常:

assign Req = ((`SR_IE && (`SR_IM & HWInt)) || ExcCode) && (~`SR_EXL);

输入输出信号:

ExcCode:异常代码,在各级判断汇总后传入  
VPC:陷入内核的PC地址  
HWInt:外部中断信号  
BD:是否是延迟槽中的指令  
EXLclr:根据eret指令信号进行恢复现场  
Req:判断是否发生异常  
EPCOut:输出返回现场的PC值  
WE:mtc0写使能  
WD:mtc0写入数据  
addr:根据指令中Rd的值判断写入的寄存器  
data:mfc0读出数据

将CP0模块放在M级,可解决在M级可能出现的新异常,对于乘除法操作在检测到异常信号时已经将本周期的内容写入寄存器的问题,解决方案为在MDU模块内部设置两个寄存器储存上一周期的内容,一旦检测到中断信号将HI、LO寄存器赋值为上一周期寄存器,实现撤回写入的功能。

宏观PC

用于将流水线CPU封装成单周期CPU。为保证中断异常发生时,宏观PC的顺序与实际顺序相同,宏观PC的监测点设置于CP0同级的M级。

异常检测

F级:PC地址未对齐/越界
D级:指令的合法性/syscall指令
E级:算术溢出
M级:内存地址未对齐/越界
逐级流水并按优先级整合后,得到最终的ExcCode传入CP0

陷入内核

nPC需跳转到0x4180,清空流水线寄存器(PC变成0x4180),并阻止MDU写入,一切均由Req信号驱动。原因见上下文。

返回现场

在D级检测到eret指令后,直接将F级PC设置为EPC,跳过延迟槽。

外部中断

外部中断由顶层模块输入传入,并直接传入CP0。
为防止外部中断时,刚好是空泡指令流水,需在流水线寄存器复位时,选择性地复位PC信号。具体如下:

if (reser | Req | clear) begin
D_PC <= reset ? 32'b0 : Req ? 32'h00004180 : F_PC;
E_PC <= reset ? 32'b0 : Req ? 32'h00004180 : D_PC;
M_PC <= reset ? 32'b0 : Req ? 32'h00004180 : E_PC;
W_PC <= reset ? 32'b0 : Req ? 32'h00004180 : M_PC;
end

BD信号同理。

外部信号模拟

将现有模块改为cpu.v,在顶层模块mips.v中,添加cpu,timer0,timer1,bridge四个模块。其中,bridge系统桥负责系统各个模块、以及各个模块与外部输入输出之间的通信。

最后

十分感谢伟大的Kamonto学长的《一本书教你通关计组实验》!