构建五级流水线——lab1

本文最后更新于 2026年4月15日 12:29:24

我针对lab1代码进行了重构,想要更清晰地理清每个部分的功能。

一、信号定义

  1. enable和valid:

    • enable是控制寄存器是否更新,stall时表示不更新
    • valid控制指令是否有效,针对指令本身,对应气泡。这个需要一直传递
  2. pc和inst:

    • pc:当前命令对应的地址
    • inst:当前命令

    并不是所有模块都需要这两个信号,但可以在级间寄存器中保留,便于查看波形图

  3. clk和reset

    • clk:时钟信号,用于同步
    • reset:重置信号,用于告知是否要复位

    这两个信号都属于全局条件,整条流水线需要同时接收,而不是通过流水线一级级往下传

二、五级流水线

  1. if——取指

  2. id——译码

    这一步根据inst进行解码,同时从regfile那里得到寄存器的值。这是整条流水线的大脑,指导后续每个模块。这里解码出来的信号在到达所需阶段之前会一直被传递下去

  3. ex——运算

  4. mem——存取

  5. wb——写回

①如何设计一个职责清晰的流水线架构?

五级流水线通过每个阶段执行不同的指令,尽量让所有模块都在工作,从而达到提速的效果.因此,流水线被拆分成了取指,解码,计算,访存,写回这五个部分,每个部分各司其职.

然而这带来一个问题,也就是当前指令并不总是能够拿到最新的值(称为写后读问题).比如add运算在ex阶段算出来结果,却要在wb阶段才能真正写回.这时,需要专门处理,使得每个模块能够拿到最新的值.

因此,自然的思路就是每个模块各自处理自己的值,使得自己能够拿到最新的值.还有一种做法,就是在decode阶段就将转发逻辑处理好.这在lab1中只涉及转发至ex阶段的情形下还能够运行.然而,后续增加了更多指令,也会有各自需要处理的数据问题.如果全部都将这些处理交给id阶段,会使得它过于臃肿.并且,负责分解职责的只有id阶段这一个部分,将它看作整个流水线的大脑并不合适.

更加合理的做法是每个部分都引入一个小型的处理装置,哪个模块需要什么,这个模块就专门处理相关的数据逻辑.比如ex阶段需要最新的数据,就专门将转发交给ex来做.根据数据的归属阶段进行功能划分,会使得整体更加清晰.

具体实现在下面详述

三、基本模块

  1. pc

    首先是pc模块,单纯接受时钟信号用于同步,接受reset用于复位,enable表示指令是否更新,next_pc是将要跳到的pc,输出cur_pc表示当前进行到哪条指令。

  2. decoder

    接着是decoder模块。它只接受机器码,并完成解码工作。

    lab1中的指令有两类,第一类是R-Type (Register 类型)

    • 适用指令: add, sub, and, or, xor, addw, subw
    • 特点: 数据的来源是两个寄存器(rs1 和 rs2),计算结果存入目标寄存器(rd)。

    32 位机器码结构:(补充)

    • opcode:决定算数的type,这里就是Rtype
    • rd,rs1,rs2:分别对应一个具体的寄存器编号,由于位置固定,可以直接从inst中进行切分
    • funct3、funct7:决定具体的计算类型

    第二类:I-Type (Immediate 类型)

    • 适用指令: addi, xori, ori, andi, addiw
    • 特点: 数据的来源是一个寄存器(rs1)和一个立即数
    • 牺牲rs2和funct7,变为存储5+7=12位的立即数
    • 需要进行符号扩展以变成64位

    ① 首先是用于ex阶段的:

    从inst中的固定位置切分出对应代码,从而确定rd,rs的值。再分类成四种运算:i,r,iw,rw,并通过布尔运算确定i和w。接着分成四种运算,并根据具体的inst得到op,imm和we的值

    ② 还有wb阶段的:

    id阶段的decoder和regfile都是组合逻辑,因此decoder一旦分出寄存器编号,regfile立刻便可以将数据取出

    由于op写成数字不直观,引入额外的mypkg进行定义

  3. alu

    统一接受两个数进行计算,接受op来选择进行何种计算,接受w来判断是否要截断到32位,最后输出一个答案ans

  4. regfile

    同时负责id读取寄存器和wb阶段写回寄存器。这两个都是寄存器操作,因此并入一个模块。
    regfile作为直接操控寄存器的模块,需要直接暴露寄存器的值用于difftest。而写入寄存器为防止毛刺需要遵守clk,也就是第一拍接收到mem_wb的信号,在下一拍才真正写入寄存器
    而difftest会在mem_wb的valid信号传入的时候进行检查,来不及等regfile写入,因此需要实例化32条gprs导线将正确的值暴露出来

四、级间寄存器

这些时序寄存器起到承上启下的传递作用,让每个基本模块都能接收到它们需要的值;同时,它们全都接收clk和reset全局信号,从而可以控制整条流水线;它们还负责将valid信号传递下去,用于表示当前信号是否有效;传递pc,便于后续查错的时候检查当前指令走到哪一步了

因此,这些寄存器都默认接收clk,reset这两个信号,传递valid,pc,inst这三个信号,后续分析中不再赘述

这些寄存器还要将每个模块产生的新信号传递下去,用完的信号则不再传递

  1. if_id

    decoder需要接收它所需要的inst信号,用于解码。正好inst属于默认信号,最终这个寄存器只需要传递默认的三个信号

  2. id_ex

    这里将regfile取到的三个值进行传递
    同时将解码出来的运算op,是否32位和立即数传给alu。这些信号在alu处被消耗掉之后就不再继续传递
    将是否要写回,写回哪个寄存器传给wb,需要一直传递到wb阶段
    同时,需要额外传递ex阶段将要用到的两个源寄存器,用于交给转发模块进行写后读冲突的判断

  3. ex_mem

    需要将alu计算出来的结果传递下去
    继续传递wb的是否写回和写回哪个

  4. mem_wb

    继续传递alu的计算结果(虽然可能是写入内存而不是写入寄存器的)
    继续传递wb的是否写回和写回哪个

五、特殊模块

1. forward

转发逻辑的机制:

如何计算转发逻辑:可以数时钟上升沿的个数

ex阶段使用一个MUX,从id,EX/MEM,MEM/WB这三个来源中选取一个来源进行后续的计算.

  • alu包含五种基本运算:add,sub,and,or,xor

  • decoder只将所有指令区分为上面这五种中的一种,因为是否使用立即数或者32位与实际做出的计算无关

  • 需要处理三种转发:

    1. EX/MEM → EX

      1
      2
      add x5, x1, x2
      add x6, x5, x3

      上一条实际改动的寄存器x5,直接作为下一条指令的输入寄存器(rs1或者rs2)。这时,上一条指令的计算结果在EX/MEM寄存器中,并且已经经过时钟从输入走到了输出.此时,将ex/mem输出的数据转发至ex模块是正确的

    2. MEM/WB → EX

      1
      2
      3
      add x5, x1, x2
      xor x7, x8, x9
      sub x6, x3, x5

      sub指令需要用的x5在一条指令之前被使用过了,此时sub走到ex阶段,x5的新值走到了mem/wb的输出口上,将这个值转发至ex即可

    3. WB → ID / regfile 同周期旁路

      1
      2
      3
      4
      add x5, x1, x2
      add x10, x11, x12
      sub x13, x14, x15
      add x6, x5, x3

      add指令用到的x5在两条指令之前被使用,此时x5的值同样处在wb阶段.不同之处在于,此时wb使用regfile进行写回,而同时,id阶段通过regfile读取数据.然而,写数据是需要时间的.此时,我们可以在regfile中设计一种转发,使得id能够直接读取到最新的数据.
      这里的转发不再是针对ex阶段,而是由于写入数据需要时间,因此id阶段读取数据会有延迟导致的
      为什么不像前两个转发一样,把regfile也开放给ex,从而让ex再去读取regfile中最新的数据呢?这打破了ex单纯负责计算的职能.同时,经典的regfile只有两个读取端口,再增加更多的端口会带来更高的能耗,并降低主频

这三种转发正好按照从高到低的优先级顺序,即优先考虑最近的指令,从而保证指令一定是最新的

完成转发模块

ex阶段可能会涉及到两个寄存器的读取,因此要分别针对这两个寄存器进行转发判断,这里只分析其中一个。

创建两位二进制信号forward1,根据对应的写后读冲突为其赋值,并指示ex阶段需要选择其中哪个数据

转发模块只输出两个forward信号

2. ex

这个模块需要通过先通过MUX和forward信号选出正确的rs数据

同时,由于立即数计算的原因,alu的第二个操作数还需要根据“是否使用立即数”额外判断一次

最后交给内部的alu阶段进行计算

六、连接

1. include与import

  • include:直接将对应文件粘贴进来

    common.sv文件中定义了一系列简写,mypkg中,我们定义了一些enum枚举类型。对于用到这些自定义类型的文件需要再开头将它们include进来。额外地,对于ex部分还需要引入alu模块的include;对于core需要将所有模块include进来,因为它是顶层连线。

  • import:在module后面添加,将import的package引入当前作用域,从而可以直接使用里面的名字

2. 顶层模块——core

  • difftest

    它是否进行检查由commit_valid决定,也就是mem_wb的valid_out。当mem_wb将wb数据在第一拍传入regfile时,difftest直接根据这个valid信号进行检查。
    为什么不等regfile真的把数据写进去呢?因为下一拍就是下一个指令进入wb了。如果此时difftest再去检测上一条指令对应的寄存器状态,如果检测到错误,就会误报下一条指令出现错误,让查错变得很麻烦。

  • 实例化

    实例化部分设计大量的同名信号,将它们分级处理比较合适,即:

    • pc,if_id,ibus
    • decoder,regfile,id_ex
    • ex,alu,forward,ex_mem
    • mem,mem_wb
    • wb,regfile

对于所有级间寄存器,统一采用"{各自名字}_{各自接口名}_(in/out)"的形式进行命名;对于同级信号,只会在内部使用(后续通过级间寄存器进行传递才会造成改名)。这种情况造成的改名额外通过assign语句进行。最终得到分组如下:

  • pc / if_id / ibus

    • pc_enable
    • pc_next_pc
    • pc_cur_pc
    • if_id_valid_in/out
    • if_id_pc_in/out
    • if_id_inst_in/out
  • decoder / regfile / id_ex

    • decoder_rd_id
    • decoder_rs1_id
    • decoder_rs2_id
    • decoder_imm
    • decoder_op
    • decoder_use_imm
    • decoder_use_w
    • decoder_write_regs
    • regfile_rs1_num
    • regfile_rs2_num
    • regfile_gprs
    • id_ex_*_in/out
  • ex / alu / forward / ex_mem

    • forward_forward1
    • forward_forward2
    • ex_alu_result
    • ex_mem_*_in/out
  • mem / mem_wb

    • mem_alu_ans
    • mem_write_regs_in
    • mem_write_reg_id_in
    • mem_write_num_out
    • mem_write_regs_out
    • mem_write_reg_id_out
    • mem_wb_*_in/out
  • wb / regfile

    • wb_write_num_in/out
    • wb_write_regs_in/out
    • wb_write_reg_id_in/out

3. difftest

四个difftest分别起到不同作用:

  • DifftestInstrCommit指示当前指令的提交状态
  • DifftestArchIntRegState显示寄存器中的值
  • DifftestTrapEvent判断指令是否走到指定位置,并通过a0寄存器的值是否为0来判断是否hit good trap

构建五级流水线——lab1
https://travellingsheep.github.io/2026/04/14/构建五级流水线——lab1/
作者
trs62
发布于
2026年4月14日
许可协议