YatCPU

Introduction

  • 单周期 CPU:本实验的目的是从零开始编写一个可以运行 RV32I 指令集程序的单周期 CPU,这个 CPU 将会是后续所有实验的基础。完成本实验之后,可以通过仿真的方式,加载运行 RV32I 程序,验证正确性。
  • 中断机制:本实验在单周期 CPU 的基础上,添加了中断控制器以及中断控制流的内容,使单周期 CPU 可以处理来自外部设备的中断,响应 IO。完成本实验之后,CPU 将具备响应 UART 数据中断、时钟中断的功能,你将可以使用 UART 端口来操作 CPU 运行中的程序。
  • 流水线 CPU:本实验在以上两个实验基础上,对 CPU 进行性能优化,从单周期的 CPU 修改为多周期以及流水线的 CPU。本实验将通过几个性能评测,来验证 CPU 性能优化的效果。
  • 总线处理:本实验在流水线 CPU 的基础上,给 CPU 添加总线功能,实现外设与 CPU 的解耦。在完成总线实验后,添加外设无需修改 CPU 本身,只需要实现总线协议并与总线对接即可。本实验目的是进一步完善 CPU 实验的总体框架,提供更贴近真实计算机系统的结构实践。

Lab0—get ready for experiment

chisel 3基本学习

chisel3基本语法和功能

  • 区别举例:想要在chisel3中使用一个常量,要写when(value===12.U),而不是if(value==12)

基本类型

  • 无符号整数**UInt与有符号整数 SInt** ,使用.W来指定整数位宽(Uint(8.W)),.U来将scala中的整数转换为chisel3的硬件整数(见我们最开始举的例子)
  • 布尔值Bool;使用.B来转换成硬件布尔值(如**true.B**)
  • 模块:声明一个模块需要继承 Module,并通过 io 成员声明输入输出端口。
1
2
3
4
5
6
7
8
import chisel3._

class MyModule **extends Module** {
val io = IO(new Bundle {
val in = Input(UInt(8.W))
val out = Output(UInt(8.W))
})
}
  • 组合逻辑

val wire = Wire(UInt(8.W)) val wireinit = WireInit(0.U(8.W))

  • 时序电路
  • 寄存器

val reg = Reg(UInt(8.W)) val reginit = RegInit(0.U(8.W))

Chisel3 项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- labx
- coremark # CPU 性能测试
- csrc # 存放汇编语言和 C 语言源代码
- project # sbt 的插件以及配置文件
- src
- main
- scala # Chisel 3 源代码 <--
- resources # 资源文件
- test
- scala # Chisel 3 测试代码
- target # sbt 生成的文件
- test_run_dir # 运行测试时生成的文件
- verilog # Verilog 代码
- vivado # tcl 脚本以及约束文件(用于自动化烧板)
- build.sbt # sbt 配置文件

  • 反汇编:llvm-objdump
    • 目标文件反汇编:objdump -s -d main.o > main.o.txt //将main.o 反汇编并将结果输出到txt文件中。
    • 可执行文件反汇编: objdump -s -d main> main.txt

objdump反汇编常用参数

  • objdump -d <file(s)>: 将代码段反汇编;
  • objdump -S <file(s)>: 将代码段反汇编的同时,将反汇编代码与源代码交替显示,编译时需要使用 g参数,即需要调试信息;
  • objdump -C <file(s)>: 将C++符号名逆向解析
  • objdump -l <file(s)>: 反汇编代码中插入文件名和行号
  • objdump -j section <file(s)>: 仅反汇编指定的section

Lab1:单周期CPU

本实验的目的:理解 CPU 的基本结构以及 CPU 是如何执行指令的。

  • 基本概念,然后会按照指令执行的步骤逐步构造数据通路和控制单元(期间会留有填写代码的任务,请记得完成),最终构造成一个简单的单周期 RISC-V 处理器。

基本概念:

  • 数据通路:显示数据从一个组件流向另一个组件的所有方式。
  • 控制信号:让各个数据通路部件知道自己要干什么。CPU 原理图中的 Decoder、ALUControl、JumpJudge 三个元件都可以看作控制单元。他们接收指令并输出控制信号。
  • 组合单元与状态单元:组合逻辑电路构成的单元叫组合单元,时序逻辑电路构成的单元叫做状态单元。
    • 本实验中只有寄存器属于状态单元(内存不属于 CPU 内核的范畴),其余的均为组合单元。
    • 组合单元:输出只取决于当前的输入,并且不需要时钟作为触发条件,输入会立即(不考虑延时)反映到输出
    • 状态单元:存储了状态,并且以时钟作为触发条件,时钟的上升沿到来时输入才会反映到输出

实现方式:

我们设计的 RISC-V CPU 能执行 RISC-V 指令的一个核心子集(RV32I):

  • 算术逻辑指令:addsubslt 等
  • 存储器访问指令:lblwsb 等
  • 分支指令:beqjar 等

我们将执行指令分为五个不同的阶段

  • 取指:从内存中获取指令数据
  • 译码:弄清楚这条指令的意义,并读取寄存器数据
  • 执行:用 ALU 计算结果
  • 访存(load/store 指令):读写内存
  • 回写(除了 store 指令外所有指令):将结果写回寄存器

下面我们先按照上述步骤逐步构建数据通路部件然后在 CPU 顶层模块将这些数据通路部件实例化并且连接起来。(下面涉及的代码都位于 lab1/src/main/scala/riscv 目录下)

Chisel教程——02.Chisel环境配置和第一个Chisel模块的实现与测试-CSDN博客

取指:

image.png

重点:

  • 理解各个变量都是什么。,我们需要实现的是when指令有效时,先取出pc的当前指令,然后再判断是否需要jump—也就是判断jump_flag_id是否有效,如果有效就要把pc的值换成需要jump到的地址,否则就是顺序执行pc+4;
  • 主要pc+4这个点,chisel语言写的比较不同4.U表示无符号的整数。

进行测试:sbt "testOnly riscv.singlecycle.InstructionFetchTest"

译码

一些知识:

  • 多路选择器:Mux

Mux类似于传统的三元运算符,参数依次为 (条件, 为真时的值, 为假时的值),建议用 true.Bfalse.B来创建Chisel中的布尔值。

image.png

重点:

  • 对于ALUop2,我有个小疑惑,提供的图里是0的时候连接reg,而1连接immediate,但是实际写的时候反过来了。不太理解。已经理解了,看前面提供的代码发现,所提供的Aluop2source结构体里面,reg对应的值是0,immediate对应的是1,故要对应上。

  • 注意不要漏,所有i指令都要添加上,他甚至有一个单独的lui没有被添加,要额外加上。主要就是看那个指令有没有i标识。

  • 对于使能指令比较简单,一开始傻傻的还去使用Mux,其实完全没有必要,因为值仅仅是真或假,直接判断是否符合就行了。Mux一般用于给条件后,读取不同值(非0,1)

    • 只有load指令可以访问内存
    • 只有store指令可以写入到内存
    • 故自然而然得到结果
  • 稍微困难一点点的是写回输入来源,由于有四个来源,不能使用两个通路的Mux了,要使用MuxLookup。使用指南:

    • 三个参数:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    import chisel3._
    import chisel3.util._

    val key = Wire(UInt(3.W))
    val default = 0.U
    val lookupTable = Seq(
    0.U -> "b000".U,
    1.U -> "b001".U,
    2.U -> "b010".U,
    3.U -> "b011".U
    )

    val result = MuxLookup(key, default, lookupTable)
    • 两个参数:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    import chisel3._
    import chisel3.util._

    val key = Wire(UInt(3.W))
    val default = 0.U
    val lookupTable = Seq(
    0.U -> "b000".U,
    1.U -> "b001".U,
    2.U -> "b010".U,
    3.U -> "b011".U
    )

    val result = MuxLookup(key, default)(lookupTable)

    seq表示有序集合/序列。然后箭头左右表示的是一种映射关系。

执行

image.png

  • 目的:给ALU的输入端口赋值。
  • 我们要看到ALU.scala实现文件:

image.png

可以看到我们需要传入的是alu执行什么功能,op1和op2,然后他会计算出结果result。至于func的来源就是ALU_control,里面有一个output是会根据指令内容来输出alu_funct。

image.png

值得注意的是,我们在得到op1和op2时,op1_source和op2_source均是指示变量(类似于bool)表示输入来源,而没有运用我们上一个模块写的 如下:(或许是还没有到组合成一个完整单周期CPU的时候,尚且在一个周期一个周期实现,且各种数据来源比较割裂)

image.png

访存

  • 只有load和store才有访存阶段。数据从内存读到寄存器或者反过来。
  • 判断读还是写看 memory_read_enable使能,为1则read,反之write。

写回

多路选择器,决定从哪里得到写回的数据。

组成CPU

  • CPUBundle 是 CPU 和内存等外设进行数据交换的通道。

烧板

  1. 生成verilator文件(运行Top.scala)

image.png

  1. 生成 Vivado 项目

image.png

  1. 生成比特流文件

image.png

image.png

  1. 烧录:由于是在wsl中进行的,会导致没有“驱动”的问题,然后发现了win上之前安装的vivado是可以连接到debian里面的,所以就直接在里面打开,根据program_device.tcl里面的指令(结合vivado中的烧录键),实际上只需要自己多输入最后那步 close_hw_target ,这样子能够保证vitis正常工作。

image.png

烧录结果:(后打开clock之后,会一闪一闪亮红灯)

be3cee1cacb047c90c08ad06a6b68ee.jpg

vitis显示如下:

企业微信截图_17338008355682.png

实验报告:

合规性测试部分:

1
2
3
4
export TARGET_SIM=~/YatCPU/yatcpu23_new/lab1/verilog/verilator/obj_dir/VTop
export TARGETDIR=~/YatCPU/yatcpu23_new/lab1/riscv-target
export RISCV_TARGET=yatcpu
make

lab2:

1
2
3
4
# 测试 lab2 的 CPU
export TARGET_SIM=~/YatCPU/yatcpu23_new/lab2/verilog/verilator/obj_dir/VTop
export TARGETDIR=~/YatCPU/yatcpu23_new/lab2/riscv-target
export RISCV_TARGET=yatcpu

lab3:

1
2
3
4
# 测试 lab3 的 CPU
export TARGET_SIM=~/YatCPU/yatcpu23_new/lab3/verilog/verilator/obj_dir/VTop
export TARGETDIR=~/YatCPU/yatcpu23_new/lab3/riscv-target
export RISCV_TARGET=yatcpu

lab1的测试结果:

32d668896c5b7ecbacf247af766530d.png

32d668896c5b7ecbacf247af766530d.png

Lab2中断

实验目的:

  • 学习CSR寄存器及其操作命令
  • 中断控制器的原理和设计
  • 编写一个简单的定时中断发生器

CSR寄存器的操作命令:

回顾:中断和异常

image.png

image.png

CSR:用来控制和保存CPU的其他功能的状态。例如终端使能状态,特权等级等。

  • mstatus寄存器:记录机器模式下的状态(status),如中断是否启用等。
  • mepc寄存器:保存了终端返回后需要执行的指令地址,当 CPU 执行中断时,mepc 寄存器被自动设置为当前指令的地址,如果 EX 阶段正在执行跳转,则设置为跳转的目标地址。
  • mcause寄存器:保存了中断的原因
  • mtvec寄存器:保存了中断处理程序的地址。发生执行中断时会传给pc寄存器
  • 中断发生时,CPU需要清空并阻塞流水线,并在CSR寄存器写入中断相关的信息。由于CSR寄存器堆实现只有一个读写端口,故需要多个周期才能写入CSR寄存器。写完后,发出控制信号,开始处理。

image.png

image.png

中断处理程序:实现更加复杂的功能。

image.png

  • 我们可以将CSR指令分解为:CSR寄存器组,ID译码单元,EX执行单元,WB写回单元。
    • CSR寄存器组需要根据ID模块译码后给出的控制信号CSR寄存器地址来对内部寄存器进行寻址,获取其内容并修改。
    • ID译码单元需要识别CSR指令。(看手册)
    • EX执行单元:CSR 指令都是原子读写的,即一条指令的执行结果中,既要把目标 CSR 寄存器原来的内容写入到目标通用寄存器中,还要按指令语义把从目标 CSR 寄存器读出来的内容修改之后再写回给该CSR 寄存器。此时 EX 里面的 ALU 单元是空闲的,要得到写入 CSR 寄存器的值,可以复用 ALU,也可以不复用。
    • WB写回单元:支持 CSR 相关操作指令后,写回到目标通用寄存器的数据来源就多了一个从目标 CSR 寄存器读出来的修改前的值

中断控制器CLINT

检测外部中断,并在中断到来并且中断使能时,中断CPU目前执行流,设置好相关的CSR信息后跳转到中断处理程序。

  • 保存到CSR寄存器的信息:CPU执行完当前指令的下一个状态。
  • 让当前指令执行完后再跳转到中断处理程序

响应(硬件)中断

https://blog.csdn.net/zyhse/article/details/136390088

  • 获取CPU下一个状态信息,一个周期内写入到相应的寄存器。

写入的内容包括:mepc,mcause,mstatus。

  • mepc:保存的是中断或者异常处理完成后,CPU返回并开始执行的地址。所以对于异常和中断,mepc 的保存内容需要注意。

  • mcause:保存的是导致中断或者异常的原因,具体内容请查阅特权级手册里的相关内容。

  • mstatus:在响应中断时,需要将 mstatus 寄存器中的 MPIE 标志位设置为 0,禁用中断。

    • 注意注意注意:这里不是自己手动地将MPIE位设置为0,是将MIE位设置为0,然后硬件会自动的把MPIE位修改然后从 mtvec 获取中断处理程序的地址,跳转到该地址执行进一步的中断处理

    image.png

    image.png

该部分的代码截图:

image.png

解释:disable_interrupt是用来修改MIE位的,并且让后续禁用中断(因为我们要保证不会发生中断嵌套)

然后看到数据中的信号连接:

  • 着重提醒mcause的原因,我们在同一个文件的最上方看到了Status的结构体定义,了解到就这么几种,并没有把所有的原因都列举出来,所以暂时只需要这样子写。(更多的mcause原因可以查看后面的截图或者直接看特权手册第二卷)

mstatus的指令结构:

image.png

image.png

image.png

(硬件)中断返回

需要写入的寄存器:mstatus

从mepc中获取跳转目标地址(原本正常执行的下一条地址)

MIE 位置为 MPIE 位,那么 MPIE 为 1 的话 mret 就会恢复中断,如果 MPIE 为 0 的话,mret 则不改变 mstatus 的值,这也导致了我们不支持中断嵌套。

CLINT的实现:for简单—采用纯组合逻辑实现。

CLINT 需要一个周期把多个寄存器的内容修改的功能,而正常的 CSR 指令只能对一个寄存器读-修改-写(Read-Modify-Write, RMW)。所以 CLINT 和 CSR 之间有独立的优先级更高的通路,用来快速更新 CSR 寄存器的值。

一个好的解释:

image.png

简单的定时中断发生器

MMIO的定时中断发生器—Timer

MMIO 简单来说就是:该外设用来和 CPU 交互的寄存器是与内存一起编址的,所以 CPU 可以通过访存指令(load/store)来修改这些寄存器的值,从而达到 CPU 和外设交互的目的。

CPU发出的逻辑地址要发送到哪个设备,就由逻辑地址的高位作为外围设备的位选信号即可,低位则用于设备内部的寻址。

实验任务:

  1. EX 执行单元在处理 CSR 指令时能够正确地得到写入 CSR 寄存器的数据。(done
  2. CSR 寄存器组可以正确支持CLINT和来自CSR指令的读写操作。(done
  3. 定时中断发生器可以正确产生中断信号,并且实现 Timer 寄存器的 MMIO
  4. CLINT 能够正确的响应中断并且在中断结束后回到原来的执行流。(done

如果能够正确完成本次实验,那么你的 CPU 就可以运行更加复杂的程序了,可以运行一下俄罗斯方块程序试试,如果想要上手玩的话,也许需要一个串口转接板,这样就可以通过电脑的键盘通过 UART 串口给程序输入字符了。

任务1:EX 执行单元在处理 CSR 指令时能够正确地得到写入 CSR 寄存器的数据

image.png

image.png

0a3f20ee132b54699d4134c1320040d.png

这个地方要注意,csr寄存器的立即数和之前译码阶段的立即数是不太一样的。译码阶段取立即数主要是针对立即数长度or位置不同于一般指令的进行获取。(如图)

image.png

从上方的指令划分的图中可以看到,crs寄存器的立即数和R指令寄存器指令取寄存器数是一样的(15-19)→ 在上图也是可以看到目标寄存器的值和立即数来源都是rs1,故后面只用在此处取值就行了。唯一的区别是第二个寄存器加上原本的func7组合在一起合成了crs寄存器的func7指示。(附上译码阶段的取指令的图:其中rs1对应的数据存储到reg1_data,rs2对应的数据存储到reg2_data—但此处是应该被忽略的,因为rs2部分的数据被合并到了func7中) mips的rs,rt的位置是确定的。

image.png

image.png

image.png

要注意的是,uimm是15-19位的原码,而source是rs1里面的值

任务一结果:

image.png

任务2:CSR 寄存器组可以正确支持CLINT和来自CSR指令的读写操作

image.png

image.png

  • CSRRegister.CycleL 和 CSRRegister.CycleH 这两个参数通常是用于访问或设置 CSR(Control and Status Registers)寄存器中的低位和高位的值。

image.png

首先我们看到CSR中需要我们实现的是可以正确读取CSR寄存器组的信息,并且可以正确与CLINT交互—将信息传到CLINT)

然后我们看到提示写了:如果数据线与CLINT冲突了,我们需要优先进行数据更新,这样子保证了CLINT读到的数是最新的。

我们可以借鉴已经写好的代码中判断条件的方法:

image.png

可以看到如果需要进行数据更新,要判断reg_write_enable_id是否为1,然后判断对应的地址是哪一个—>对应了需要更改的那一个寄存器的值。使用Mux来进行数据选择,如果条件满足,那么输入到CLINT中的值是reg_write_data_ex,若不满足则输入旧的值(也就是前面读取的寄存器组原本的值)

image.png

功能3:定时中断发生器可以正确产生中断信号,并且实现 Timer 寄存器的 MMIO

实现一个MMIO的定时中断发生器—timer

MMIO:该外设用来和CPU交互的寄存器一起编址,这样子CPU就可以通过访存指令来修改这些寄存器的值,从而实现CPU与外设交互。即内存映射。

没有总线时可以使用多路选择器(即现在阶段用多路选择器实现)

内部逻辑:两个控制寄存器 enable 寄存器和 limit 寄存器。

  • enable 寄存器:控制定时中断发生器的使能,为false则不产生中断,映射到地址空间的逻辑地址为0x80000008.
  • limit 寄存器:用来控制定时器的中断发生间隔。映射到地址空间逻辑地址:0x80000004。内部有个加一计数器,达到limit为标准的界限时,定时器会发生一次中断信号(enable使能)。注:产生中断信号的时长没有太大关系,但是至少应该大于一个 CPU 时钟周期,确保 CPU 能够正确捕捉到该信号即可。

image.png

任务4:CLINT 能够正确的响应中断并且在中断结束后回到原来的执行流(更多内容在前面中断概念处)

CLINT的一些理解概念:

  1. CLINT 具有固定的优先级方案,但不支持给定特权级别内的嵌套中断(抢占)。 然而,较高的特权级别可能会抢占较低的特权级别。 CLINT 提供两种操作模式,直接模式向量模式
    • 直接模式下,所有中断和异常都会捕获到 mtvec.BASE
    • 向量模式下,异常trap到 mtvec.BASE,但中断将直接跳转到它们的向量表索引。

image.png

  1. 一些代码解释:

    image.png

InterruptStatus对象:

  • 定义了中断状态的常量值
  • None表示没有中断
  • Timer0表示计时器0中断
  • Ret表示返回状态

InterruptEntry对象:

  • 定义了中断入口地址的常量值
  • Timer0表示计时器0中断入口地址

InterruptState对象:

  • 定义了中断状态机的不同状态
  • Idle:空闲状态
  • SyncAssert:同步断言状态
  • AsyncAssert:异步断言状态
  • MRET:表示从中断返回的状态

CSRState对象:

  • 定义了CSR状态机的不同状态。
  • Idle: 空闲状态,值为 0x0
  • Traping: 陷入状态,值为 0x1
  • Mret: 从中断返回状态,值为 0x2
  1. mstatus寄存器和mcause寄存器

mstatus指令结构:(我们使用到的是32位的

image.png

mcause指令相关:

可能出现的机器级异常代码

image.png

image.png

image.png

mtvec

image.png

Lab3流水线CPU

内容简介:

  • 竞争冒险的处理是流水线 CPU 设计的难点和关键所在。
  • 设计一个简单的三级流水线 CPU(IF、ID 和 EX 三级),它只涉及分支和跳转指令带来的控制冒险,然后,我们再将三级流水线 CPU 的 EX 级继续切分为 EX、MEM 和 WB,形成经典的五级流水线,这样做带来的数据冒险需要使用阻塞和转发技术进行处理;最后,我们将分支和跳转提前到 ID 阶段,进一步缩短分支延迟。
  • 参考资料:计组黑书4.5-4.8节
  • 学习:
    • 使用流水线设计缩短关键路径
    • 正确处理流水线阻塞与清空
    • 使用转发逻辑减少流水线阻塞

流水线寄存器:

缓存作用,切分组合逻辑,缩短关键路径。(存储该阶段产生的各种信息和数据。

基本功能:

  • 在每一个时钟周期,根据复位(流水线清空)或阻塞(流水线暂停)的状态,将寄存器内容情况、保持或设置为新的值。输出则为其中保存的值。
  • 为了方便复用,我们可以定义一个带参数的 PipelineRegister 模块,用来实现不同数据位宽的流水线寄存器。

task0:补充完成PipelineRegister.scala

  • stall 和 flush 分别为流水线寄存器的阻塞和清空信号,in 和 out 分别为要写入寄存器的值和寄存器的当前值。

image.png

  • 解释:我们要存储结果,并且下一个阶段有可能还要用到前面那个阶段的状态信息,所以要用寄存器来传递、存储。

image.png

image.png

对比总结

特性 Scala 变量 (var) Chisel 寄存器 (Reg)
功能 软件变量,临时存储值 硬件寄存器,存储状态
硬件生成 不生成硬件 生成硬件寄存器
值的更新 随程序执行更新 时钟边沿更新
硬件复位 不支持复位行为 可设置复位值 (RegInit)
用途 软件逻辑辅助计算 描述硬件逻辑和状态存储

三级流水线

image.png

  • 两组流水线寄存器:IF2IDID2EX 划分出三个阶段。(已写好)
    • 取指(Instruction Fetch,IF):根据 PC 中的指令地址从内存中取出指令码;
    • 译码(Instruction Decode,ID):将指令码解码为控制信号并从寄存器组中读取操作数;
    • 执行(Execute,EX):包括 ALU 运算、访问内存和结果写回。

处理竞争冒险

解决控制冒险—清空

  • 由于所有数据处理都在EX阶段,不会出现数据冲突,无需考虑。我们只需处理程序跳转(beq指令等)带来的控制冒险。
    • EX执行到跳转指令
    • EX端执行到分支指令且分支成立
    • 发生中断,接收到CLINT发来的中断信号 InterruptAssert 。相当于在EX指令至上叠加了跳转指令,要丢弃之前的—IF和ID
  • 无论哪种情况,都是由 EX 段向 IF 段发送跳转信号 jump_flag 和跳转的目标地址 jump_address,但在 jump_address 写入 PC 并从该处取出指令前,流水线的 IF 和 ID 段已经各有两条不需要执行的指令,好在这两条指令的结果还没有写回,我们只需要清空对应的流水线寄存器,把它们变成两条空指令即可
  • 我们用一个控制单元来检测控制冒险并清空流水线,模块定义在 src/main/scala/riscv/core/threestage/Control.scala,为了避免此题过于简单(呵呵,我们没有提供模块接口,请根据以上分析确定模块的输入输出,在 // Lab3(Flush) 处将代码补充完整,并在 src/main/scala/riscv/core/threestage/CPU.scala 的 // Lab3(Flush) 处补充相关连线,使其能够通过 ThreeStageCPUTest 测试。

control:

image.png

如果传入了要跳转的信号,那我们控制器需要输出信号,确保能把IF和ID两个阶段清空

CPU:与control连线。

首先要保证输入信息传入;注意的是Interrupt_flag 信号的来源

image.png

五级流水线

five_stage_pipelined_CPU_structure.png

注意,上面的 CPU 结构图是我们完成所有实验之后的结果,在完成“缩短分支延迟”实验之前,我们 CPU 的结构将与上图稍有不同。例如,我们紧接着讨论的五级流水线 CPU 在 EX 阶段判断程序是否发生跳转,而不是 ID 阶段。

  • 使用阻塞的方式解决数据冒险(由于数据的处理不止在执行阶段了,所以会出现数据冒险),使用**旁路和将分支跳转提前到ID阶段(这两步选做)**进一步提升效率。

解决数据冒险:阻塞

  • 当处于 ID 阶段的指令要读取的寄存器依赖于 EX 或 MEM 阶段的指令时,发生数据冒险。可以保持这IF和ID阶段状态不变,直到相关数据被放出来。
  • 位于 ID 阶段的指令和位于 WB 阶段的指令之间不会发生数据冒险,这是因为我们的寄存器组模拟实现了 Double Pumping 功能,即 WB 阶段在前半个时钟周期向寄存器组写入数据,ID 阶段在后半个时钟周期从寄存器组读出数据(读写分离)。
  • 值得注意的是,我们在阻塞 PC 和 IF2ID 寄存器以保持 IF 和 ID 阶段不变的同时,需要清空 ID2EX 寄存器以在 EX 阶段插入空指令(“气泡”),否则 ID 阶段的指令还是会进入 EX 阶段,这样就不是“阻塞”,而变成“重复”了。
  • jalr 是跳转指令,虽然它后面两条指令依赖于它写入的寄存器,但是它们本就不应该紧接着被执行,而是应该被清空,所以在第 10 个时钟周期应该清空 IF2ID 和 ID2EX 寄存器,而不是阻塞。
情况 PC阻塞 IF2ID阻塞 ID2EX清空 说明
数据冒险(ID依赖EX或MEM) 保持当前指令状态,防止错误执行
数据冒险(ID依赖WB) WB 阶段支持双抽泵,不需要阻塞
控制冒险(跳转指令如jalr) 跳转指令需要清空后续流水段,移除错误指令
依赖寄存器x0 x0结果总是0,无需阻塞

核心逻辑:

  1. 数据冒险检测(依赖判断)
    • EX 阶段的寄存器写入:如果 ID 阶段的指令需要读取的寄存器(rs1_idrs2_id)依赖 EX 阶段的目标寄存器rd_ex),需要阻塞。
    • MEM 阶段的寄存器写入:如果 ID 阶段的指令依赖 MEM 阶段的目标寄存器rd_mem),需要阻塞。
  2. 清空信号
    • 控制冒险(跳转指令):遇到跳转信号(jump_flag),需要清空 IF 和 ID 阶段的指令。
  3. 阻塞信号
    • PC 阻塞与 IF 阶段阻塞:在数据冒险发生时,需要阻塞 PC 和 IF 阶段,保持当前指令不变。

image.png

拓展:使用旁路(转发)减少阻塞

  • 用一个控制单元来处理流水线的阻塞和清空
  • 用一个旁路单元来检测数据冒险并发出旁路控制信号
  • 在执行单元中根据旁路单元的控制信号使用对应的旁路数据

Control:

image.png

Forwarding:检测数据冒险(看是否存在冲突),发出转发信号。看是与那个寄存器冲突。

image.png

Execute:看CPU流程图,可以得知数据的来源有两类,直接得到(已经写回了的数据,直接从寄存器中得到),发生数据冒险的数据(看写到了哪个寄存器中,如果是ex2mem,则从mem转发,如果是mem2wb,就从wb转发

image.png

拓展:缩短分支延迟

  • 往cpu中加入少量硬件,将分支/跳转指令的执行从EX段提前到ID段,进而把程序跳转的损失减少到一个时钟周期

Steps:

  • 跳转的判断从EX段移到ID段
  • 跳转的目标地址原本是在Ex段的ALU进行计算的,故我们要在ID段添加一个加法器来计算目标地址
  • 添加额外的旁路逻辑,将前面指令的执行结果旁路到ID段给分支or跳转指令使用,如果所依赖的结果还没有产生,还需要进行阻塞

1. 在 ID 段实现跳转判断

  • 核心目标:将跳转判断逻辑(如条件跳转的比较)从 EX 段移到 ID 段。

  • 具体实现

    1. InstructionDecode.scala
      • 定义额外的逻辑单元,用于在 ID 段进行分支条件的比较判断。
      • 根据分支指令的操作码(opcode)和功能码(funct3),确定需要执行的比较操作(如 ==, <, >= 等)。
      • 添加对寄存器值(rs1rs2)的读取和比较逻辑。
    2. 目标地址计算
      • 添加一个加法器,计算跳转目标地址。跳转目标地址为:PC + offset,其中 offset 是指令中解析出的立即数(imm)。
      • 在 ID 段,将 PC 和立即数相加生成跳转目标地址。
  • 示例代码(在 InstructionDecode.scala 中):

    1
    2
    3
    4
    5
    6
    scala
    复制代码
    when (isBranch) {
    branchTarget := currentPC + imm
    branchTaken := (rs1Value === rs2Value) // Example: BEQ condition
    }

如果信号的值需要通过逻辑运算生成或在后续逻辑中被赋值,则需要使用 Wire


2. 添加旁路逻辑

  • 核心目标:解决跳转判断所依赖的数据可能尚未生成的问题。

  • 具体实现

    1. Forwarding.scala 中,扩展旁路逻辑,使得 ALU 或存储单元的输出能够在 ID 段被立即使用。
    2. 判断数据依赖的来源:
      • 如果依赖的数据在 EX 段,需要旁路 EX 段的 ALU 输出。
      • 如果依赖的数据在 MEM 段,需要旁路 MEM 段的存储输出。
    3. 阻塞处理:
      • 如果数据依赖尚未解决(例如还在加载数据),则插入气泡(阻塞流水线)。
  • 示例代码(在 Forwarding.scala 中):

    1
    2
    3
    4
    5
    6
    7
    8
    scala
    复制代码
    when (rs1Valid && rs1Source === EX_STAGE) {
    rs1Value := exAluOutput
    }.elsewhen (rs1Valid && rs1Source === MEM_STAGE) {
    rs1Value := memDataOutput
    }


3. 修改控制逻辑

  • 核心目标:更新 Control.scala 中的控制信号生成逻辑,以支持分支跳转在 ID 段的实现。

  • 具体实现

    1. 根据指令类型(分支/跳转),生成额外的控制信号:
      • branchTaken:判断是否跳转。
      • branchTarget:跳转的目标地址。
    2. 在 ID 段判断后,如果确定分支成功,则向流水线发出控制信号清空后续指令(如插入气泡)。
  • 示例代码(在 Control.scala 中):

    1
    2
    3
    4
    5
    6
    7
    scala
    复制代码
    when (branchTaken) {
    pcSrc := BRANCH_TARGET
    pipelineFlush := true
    }


4. 测试竞争冒险

  • 核心目标:模拟和解决所有可能的竞争冒险情况。
  • 指导原则
    • 测试分支跳转依赖前面指令的结果。例如:
      1. BEQ R1, R2, offset
      2. ADD R1, R3, R4
    • 验证是否正确解决数据依赖问题。
  • 测试数据冒险的关键
    • 如果冒险可通过旁路解决,则确保旁路逻辑正常工作。
    • 如果旁路无法解决,则检查阻塞是否正常。

lab4:总线

  • CPU除了通过内存控制器访问内存以外,还可以通过总线访问外部设备。使用总线可以减少电路布线数量以及电路设计复杂度。避免CPU与外部直接连接。(具体的硬件操作则进一步抽象为读写硬件设备上的寄存器)

在本实验中,你将学习到:

  • AXI4-Lite 总线协议原理
  • 使用状态机实现总线协议

总线前置知识:

AXI4-Lite总线通信协议

axi.png

写地址通道:

image.png

写数据通道:

image.png

写响应通道

image.png

读地址通道:

image.png

读数据通道:

image.png

状态机:

axi-fsm.png

  • 总线仲裁:所有设备在通信之前都要检测总线是都占用。**每一个设备则需要增加总线请求线以及总线授权线,连接到总线仲裁器。**在设备需要通过总线传输数据前,需要先通过总线请求线请求总线的访问权限。总线仲裁器则通过总线授权线来授予访问权限,从而避免设备之间的冲突。
  • 总线交换机:类似于网络交换机的方式连接。不同对的设备之间可以通过交换机同时进行通信。

读操作:

  1. IF发出取指信号,包括:读请求(valid)和读地址(pc),如果对应的主机处于空闲状态,则对本次读取做出响应(空闲→读请求状态),产生并发送读请求(ARVALID),读地址(ARADDR)
  2. 当 从机(slave)接收到读请求且处于空闲状态,则返回主机(master)读准备(ARREADY)—表示可以读取。(完成一次读地址的握手
  3. 从机(slave)开始准备需返回的数据(RDATA),读返回请求(RVALID),主机跳变到下一个读数据状态。
  4. 当主机的RVALID和RREADY完成握手,主机得到目标数据,则产生读完成的信号(RREADY),把数据返还给取值模块,就完成了一次读操作,此时主机跳转回空闲状态。

image.png

写操作:

相比读多了一个接收写回复(如图)

  1. 有总线后的MMIO(已给)
  2. 把总线加到流水线上

实验任务:

  1. 完成主从设备内部的状态机(按照上面给的状态图)

主从设备的状态机切换图在预备知识里面给出了,不需要自己去总结,实现所需的寄存器和模块输入输出接口已经给出,你只需要实现状态切换以及相应的握手信号

资料:

https://blog.csdn.net/weixin_45937291/article/details/129771811

实验指导:

实现 AXI4Lite 从设备模块

  1. 实现状态机
    • 在每个状态中,根据相应的输入信号更新状态和输出信号。
    • 例如,在 ReadAddr 状态中,等待 ARVALID 信号变高,并将 ARREADY 信号置高。
    • 一旦地址被接受,将状态切换到 ReadData 并准备输出数据。
  2. 实现读写逻辑
    • 在 ReadData 状态中,等待 RREADY 信号,输出 RDATA 和 RRESP
    • 在 WriteAddr 状态中,等待 AWVALID 信号,并将 AWREADY 信号置高。
    • 在 WriteData 状态中,等待 WVALID 信号,并将写数据和写掩码存储到寄存器中。
  3. 实现响应逻辑
    • 在 WriteResp 状态中,输出写响应信号 BVALID 和 BRESP

image.png

实现 AXI4Lite 主设备模块

  1. 实现状态机
    • 在每个状态中,根据相应的输出信号更新状态和输入信号。
    • 例如,在 ReadAddr 状态中,输出 ARVALID 信号,并等待 ARREADY 信号变高。
    • 一旦地址被接受,将状态切换到 ReadData 并准备接收数据。
  2. 实现读写逻辑
    • 在 ReadData 状态中,等待 RVALID 信号,接收 RDATA 和 RRESP
    • 在 WriteAddr 状态中,输出 AWVALID 信号,并等待 AWREADY 信号变高。
    • 在 WriteData 状态中,输出写数据和写掩码信号 WSTRB
  3. 实现响应逻辑
    • 在 WriteResp 状态中,等待 BVALID 信号,接收写响应信号 BRESP

image.png

ps:一些chisel的知识:

  • Bundle:用于将不同类型的信号划分为一组
  • Vec用于表示一个可索引的、相同类型的信号的集合
  • <> 运算符:用于连接两个信号,类似于 Verilog 中的 assign 语句。

实验报告

  1. 简要概括不同测试用例的功能,描述它们分别从什么层面测试 CPU,以及使用了什么方法加载测试程序指令,以及测试用例的执行结果。
  2. 对于填空涉及到的信号,使用测试框架输出波形图,描述在执行不同指令时候对应的部件的关键信号的变化情况。
  3. 使用实验板上的 LED 或者数码管等外设,体现你的 CPU 可以响应定时器中断或者其他外部中断。
  4. 在完成实验的过程中,遇到的关于实验指导不明确或者其他问题,或者改进的建议。

image.png


YatCPU
https://pqcu77.github.io/2025/02/28/YatCPU/
作者
linqt
发布于
2025年2月28日
许可协议