Skip to content
/ cod-scala Public

五级流水线 RISC-V 32 位处理器。清华大学计算机系计算机组成原理 (COD, 计组) 课程 2025 秋实验

Notifications You must be signed in to change notification settings

tfia/cod-scala

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

先造机,再吃饭

2025 秋季计算机组成原理大实验。

杜若和(计 36),陈禹默(计 31),张轶程(计 34)

实验目标与内容

我们使用 SpinalHDL 作为硬件描述语言,使用 Verilator 仿真,在 FPGA 上实现了一个五级流水线 RISC-V 32 位处理器。

  • 实现了 RV32I 指令集中除了 FENCE(不涉及多核)的全部指令、Zicsr、Zifencei 扩展指令集、MRET SRET SFENCE.VMA 指令、B 扩展中的 XNOR CTZ PCNT 指令
  • 支持中断异常、虚拟地址和页表、U/S/M 特权态、硬件计时器等扩展功能
  • 支持数据前传、分支预测、ICache、DCache 等性能优化
  • 支持读取 Flash、双缓冲 VGA 等外设控制
  • 能够正确运行监控程序版本 3

原本的设计目标是支持 uCore 的启动,但由于时间太紧,没能调试完成。目前已经支持 uCore 启动至 sh 所需的所有功能,但因为潜在的 bug 无法正常启动。

设计框图

功能模块

大部分可以被例化的主要功能模块都位于 src/components 目录下。流水线段寄存器和模块化的流水线段位于 src/pipeline

以下介绍大实验主要的功能模块。

ALU

通用 ALU 模块。支持配置数据位宽。

BranchPredictor

分支预测模块。实现了 Gshare 动态组合分支预测器,详见后续“功能实现”部分。内部使用 Area 隔离了查询使用的组合逻辑和更新使用的时序逻辑。

Cache

Cache 参数与接口定义。定义了 Global Config 供实例化的 Cache 模块使用,同时定义了 CacheLine 和 CacheSet 的结构,规定了 LRU Tree 的相关接线。

CsrFile

CSR 寄存器堆。定义了两套 IO 接口,同时支持软件 CSR 指令通过 CSR 地址访问 CSR,和硬件直接连线设置 CSR。

time timeh 硬连线到了硬件计时器 Timer 模块,不对应实际存在的寄存器。sstatus sip sie 是它们对应的 M 态寄存器的 mask,也不对应实际存在的寄存器。

DCache

例化了 DCache 模块。实现了 2-set, 4-way 的 PIPT Cache,支持读写请求,详见后续“功能实现”部分。

FlashController

Flash 控制器模块。支持 Wishbone 总线接口,内部实现状态机处理 Flash 时序,支持页模式优化读取。

ICache

例化了 ICache 模块。实现了 4-set, 4-way 的 PIPT Cache,支持读请求,详见后续“功能实现”部分。

MMU

实现了基于 SV32 的分页机制,支持两级页表结构和 TLB 缓存,详见后续“功能实现”部分。

RegFile

通用寄存器堆。支持配置数据位宽。支持写优先数据旁路。

Timer

硬件计时器。实现了 mtime mtimeh mtimecmp mtimecmph 4 个 CLINT 寄存器。

每个时钟周期,mtime mtimeh 对应的 64 位寄存器 +1。CPU 按照设计的主频运行,需要成百上千年 64 位寄存器才会产生溢出,因此没有考虑溢出问题。按照 RISC-V 规范,mtimemtimeh 进位时产生的数据错误由软件编写者处理,因此也没有考虑。

TrapHandler

负责处理异常和中断,详见后续“功能实现”部分。

VgaController

VGA 视频模块。使用双缓冲逻辑,维护两个显存页,通过与软件相结合的换页操作实现可控且稳定的帧率调节。外部输入系统时钟域,控制写入显存;内部定义像素时钟域,控制读出显存。详见功能实现部分。

性能测试

以下是添加了不同功能后,处理器性能的区别,使用监控程序中自带的测例进行测试。

性能测试(s) 1PTB 2DCT 3CCT 4MDCT CRYPTONIGHT
基础版本(30M) 67.110 33.555 78.295 54.806 3.164
分支预测(30M) 55.923 30.757 44.739 49.215 2.990
指令缓存(30M) 22.370 12.302 17.896 29.085 1.311
数据缓存(30M) 22.371 12.304 17.897 24.609 1.557
流水线去气泡(30M) 11.186 6.152 8.948 20.134 0.997
页表+TLB(30M) 22.370 12.303 17.896 29.081 1.451
最终版本(40M) 16.777 9.228 13.422 21.811 1.088

备注:“流水线去气泡”指对 IF 段取指状态机进行优化,使得其连续不断地取指;其余版本实现较为保守,IF 段每取出一条指令,会在流水线中插入一个 1 时钟周期的气泡。

功能实现

数据前传实现

通过添加数据前传,我们消除了由于 EXE 段使用 MEM 和 WB 段的结果而导致的 data hazard 问题。

具体地,我们在 MEM 和 WB 段分别向 EXE 段前传被更新寄存器的更新结果。EXE 段会判断当前需要使用的寄存器是否在 MEM 或 WB 段中被更新过,如果是,则直接使用更新后的值,而不是从寄存器堆中读取。EXE 段关键代码如下:

val has_forward_mem = io.forwarding_mem.rf_we && io.forwarding_mem.rf_waddr =/= 0
val has_forward_wb = io.forwarding_wb.rf_we && io.forwarding_wb.rf_waddr =/= 0
when (has_forward_mem && io.forwarding_mem.rf_waddr === io.pre.rf_raddr_a) {
    rf_rdata_a := io.forwarding_mem.rf_wdata
} elsewhen (has_forward_wb && io.forwarding_wb.rf_waddr === io.pre.rf_raddr_a) {
    rf_rdata_a := io.forwarding_wb.rf_wdata
}

when (has_forward_mem && io.forwarding_mem.rf_waddr === io.pre.rf_raddr_b) {
    rf_rdata_b := io.forwarding_mem.rf_wdata
} elsewhen (has_forward_wb && io.forwarding_wb.rf_waddr === io.pre.rf_raddr_b) {
    rf_rdata_b := io.forwarding_wb.rf_wdata
}

这里需要注意,MEM 段的优先级更高,因为 MEM 段写入的数据可能覆盖 WB 段写入的数据。

此外,在寄存器堆中,我们也增加了数据旁路,实现了写优先,即如果同一周期内同时有读写操作且地址相同,则直接向读取端口返回写入新的数据。这样,流水线不必在 ID 段检测到后续阶段需要写当前 ID 段指令涉及的寄存器时发起 stall。

解决以上两种 data hazard 之后,只有访存会导致 data hazard 问题。

以下是一个 EXE 段使用 MEM 和 WB 段的结果导致 data hazard 的例子:

800000c8 <test_2>:
800000c8:	00200193          	li	gp,2
800000cc:	00000093          	li	ra,0
800000d0:	00000113          	li	sp,0
800000d4:	00208733          	add	a4,ra,sp
800000d8:	00000393          	li	t2,0
800000dc:	00770463          	beq	a4,t2,800000e4 <test_3>
800000e0:	7d80806f          	j	800088b8 <fail>

在我们最终版的 CPU 上运行这段代码,可以观察到如下波形:

800000d4 处的指令来到 EXE 段时,sp 寄存器还在 MEM 段被写入 (li sp, 0)。此时,可以观察到 has_forward_mem 信号被拉高,MEM 段回传了 sp 寄存器的地址 0x02、使能和写入值。当 li sp, 0 流动到 WB 段时,WB 段也向 EXE 段前传了 sp 寄存器的地址 0x02、使能和写入值。

分支预测实现

我们实现了一种动态组合分支预测器,使用了 Gshare 策略,使用全局跳转历史和分支地址共同对分支表进行寻址。

具体来说,我们使用一个全局分支历史寄存器(Global History Register, GHR),一个分支目标缓冲区(Branch Target Buffer, BTB)和一个模式历史表(Pattern History Table, PHT)来进行分支预测。

GHR 是一个 n 位寄存器,记录了最近 n 次分支指令的跳转结果(跳转为 1,不跳转为 0)。每当遇到分支指令时,GHR 会左移一位,并将当前分支的实际结果写入最低位。

BTB 采用 1-way direct-mapped 组织,每个表项如下:

+-------------------+
| Valid Bit (1 bit) |
+-------------------+
| Tag (n bits)      |
+-------------------+
| Target (m bits)   |
+-------------------+

在查找 BTB 表时,首先用 pc 的低位和 GHR 异或的结果得到 BTB 的索引,然后用 pc 的高位作为 tag 与表项中的 tag 进行比较。如果匹配且 valid 位为 1,则认为 BTB 访问命中。

PHT 是一张 2-bit 饱和计数器表,表项数目与 BTB 相同。BTB 命中后,使用相同的索引访问 PHT,根据 PHT 中对应的计数器值来决定分支的预测结果:

  • 00:强不跳转,预测分支不发生跳转
  • 01:弱不跳转,预测分支不发生跳转
  • 10:弱跳转,预测分支发生跳转
  • 11:强跳转,预测分支发生跳转

在 EXE 段,当分支指令的实际结果确定后,GHR、BTB 和 PHT 都会被更新。PHT 更新时,若分支实际跳转,则将对应的计数器加 1(饱和到 11);若不跳转,则将计数器减 1(饱和到 00)。BTB 更新时,若分支跳转,则将该分支的 pc 和目标地址写入 BTB(覆盖更新),并将 valid 位设为 1。

如果发现分支预测错误,EXE 会向 IF 段发送 flush 信号和正确的 PC 值,并向 ID 段发送 flush 信号,以清除错误的指令并重新取指。

将全局分支跳转历史信息纳入考虑,可以更好的识别不同分支指令之间的关联,从而提高预测准确率。我们使用一个简单的方式计算了分支预测的命中率。在 TestMispredictRate.scala 中,通过如下代码估算分支预测的错误率:

dut.clockDomain.onSamplings {
    // When a branch instruction is in EXE stage
    if (dut.exe_u.io.pre.branch_type != BranchType.NONE) {
        branch_count += 1
    }
    // When a misprediction occurs, a flush request is sent from EXE stage
    if (dut.exe_u.mispredict.toBoolean && dut.exe_u.io.pre.branch_type != BranchType.NONE) {
        mispredict_count += 1
    }
}

此代码在仿真中的每个时钟周期采样一次,当 EXE 段有分支指令时,branch_count 增加 1;当发生分支预测错误时,mispredict_count 增加 1。最终通过计算 mispredict_count / branch_count 得到分支预测的错误率。若分支指令被 stall,这些信号可能会持续多个周期,导致统计到的分支数量和预测错误的分支数量都偏高,因此此代码仅作为一个近似估算。

我们用上述代码测试监控程序版本 2 的启动过程,总共记录了 86816 次分支。在使用 Gshare 策略的情况下,分支预测错误率约为 $7.73%$;在关闭 Gshare,仅使用两位计数器动态分支预测的情况下,错误率约为 $8.24%$。可见,Gshare 策略在一定程度上提升了分支预测的准确率。

以下是几个分支预测的例子:

这张波形图展示了成功预测 beq t1, zero, -8 时的波形,可以观察到 IF 段并没有被 flush,传给下一个流水段的 pc (io_pre_pc) 直接变成了上一个 pc-8,说明分支预测成功,省去了一次流水线冲刷。

这张波形图展示了错误预测 beq zero, zero, -12 时的波形。预测器认为此时不发生跳转,但 EXE 段发现预测错误,于是拉高了 flush IF 段的信号,冲刷流水线纠正错误。

这张波形图展示了成功预测 beq zero, zero, 0 时的波形。可以看到 pc 维持不变。

中断异常检测与处理实现

中断 (Interrupt) 和异常 (Exception) 统称 Trap。我们将 Trap 信息附加在流水线信号中跟随流水线流动(称为 TrapPayload),并统一在 WB 阶段提交到 TrapHandler 模块中进行处理。

TrapPayload 的定义如下:

case class TrapPayload() extends Bundle with IMasterSlave {
    val valid = Bool()
    val epc = UInt(32 bits)
    val cause = Bits(32 bits)
    val tval = Bits(32 bits)

    override def asMaster(): Unit = {
        out(valid, epc, cause, tval)
    }
}

valid 指示当前 TrapPayload 流水线寄存器中的值是否有效,若有效则忽略这条引发异常的指令在本流水段应当进行的动作,直接将 TrapPayload 信息透传到下一个流水段。其余字段与同名 CSR 寄存器含义相同。

目前,以下流水线阶段可能产生异常:

  • IF。取指时可能出现 Page Fault
  • ID。译码出 ecall 等指令
  • MEM。访存时可能出现 Page Fault

mretsret 指令不是异常,但同样会影响控制流、特权态和 mstatus (sstatus) 寄存器,在实现上需要单独处理。为了简单起见,我们将这两条指令当作特殊的异常处理,分别分配了异常号 0x24 0x25(都是 RISC-V 规范中特定于实现的异常号)。在 TrapHandler 中,如果遇到这两条指令,则不设置 xepc xcause xtval 等寄存器,但需要更新特权态和 xstatusxms)。

中断是异步到来的,我们无法预知。在我们的设计中,中断检测位于 ID 段。ID 段直连 mstatus mip mie 等寄存器,当一个中断发生时,ID 段将当前正在被译码的指令作为“替罪羊”,设置 TrapPayload,并将 epc 设置为这条恰好在中断发生时被译码的指令的地址。这条指令本身的内容将不会被继续执行,仅仅带着中断的 TrapPayload 向后流动。TrapHandler 收到该中断并处理之后,中断处理例程交还控制流,将 pc 恢复为 epc,IF 取出“替罪羊”指令重新执行,实现了对软件透明的中断处理。

目前,实现了以下三种中断:

  • Machine Timer Interrupt,中断号 7
  • Supervisor Timer Interrupt,中断号 5
  • Other Interrupt,中断号 16

精确异常要求我们在处理异常时不提前执行引发异常的指令之后的指令,一种简单的想法是当异常在 WB 段提交时冲刷前面的所有流水段。然而,由于 Wishbone 协议不可打断,所以 MEM 段无法实现被冲刷。为了实现精确异常,我们设计了组合逻辑信号,当 TrapPayload 流动到 ID,EXE 或 MEM 段时,立刻(组合逻辑)冲刷该段之前的所有流水段,在引发异常的指令之后制造持续 3 个流水段的气泡,确保没有任何访存指令跟在其后面。这种设计基本等价于最后冲刷整个流水线,但由于使用了许多跨流水段的组合逻辑信号,我们的设计时序变紧了许多,主频很难跑到 50M 以上。

TrapHandler 模块负责在 Trap 到来时处理上述冲刷流程,设置 CSR 寄存器,并修改控制流(向 IF 回传新 pc)。

Cache 实现

Cache 结构

我们实现了 ICache (指令缓存),采用了 4 路组相联方式缓存,4 个 cacheset ,每个 cacheline 有 128 bits 的缓存。以下给出 cacheline 和 cacheset 的结构关系:

CacheLine
+-------------------+
| Valid Bit (1 bit) |
+-------------------+
| Tag (26 bits)     |
+-------------------+
| Data (32*4 bits)  |
+-------------------+

CacheSet
+-------------------+
| CacheLine 0       |
+-------------------+
| CacheLine 1       |
+-------------------+
| CacheLine 2       |
+-------------------+
| CacheLine 3       |
+-------------------+
| LRU Tree (3 bits) |
+-------------------+

我们也实现了 DCache (数据缓存),与 ICache 在规模上缩水一半,仅 2 个 CacheSet,除此之外并无差别。

Cache 均使用 PIPT(Physically Indexed, Physically Tagged)结构,即使用物理地址进行索引和标记。

替换策略

替换策略上,我们选用了 Pseudo LRU 策略,即在每个 cacheset 内维护一个 3 bits 的二叉树,用来记录上一次访问该 set 选择的索引,并在 cache miss 时反向索引以近似找到最古老的 cacheline 加以替换。

LRUTree

上图是一个 LRU Tree 的模型,如果上一次访问的是 Line 2 ,那么 bit[2:0] 应该修改为 0x1x 代表延续上一次的结果(不修改)。又如 cache miss 时,发现存储的树是 110 ,那么就应当反向索引(使用 ~110=001 索引),得到 Line 2 是估计出的最古老的 cacheline 记录,应当被替换。

查询方法

ICache 应当接入 IF 段模块上,代替原有的 Wishbone 接口。ICache 模块则负责读取请求,按照下图拆分指令,索引缓存:

+-------------------------+-------------+--------------+----+
|       tag [31, 6]       | index[5, 4] | offset[3, 2] | 00 |
+-------------------------+-------------+--------------+----+

其中, tag 代表 CacheLine 的 Tag , index 代表 cacheset 的索引, offset 代表这是每个缓存 4 字中的第几个字(1 字= 4 Byte)

因而可以通过组合逻辑立刻得知缓存是否命中:

  • 命中:则立刻返回 ack 信号,并返回正确指令
  • 未命中:则转移进入读取状态,连续读取 4 条指令进入根据 LRU 策略选出的 CacheLine 中,更新 Tag 和 Valid 位,最后返回指令

DCache 则接入 MEM 段模块上,代替原有的 Wishbone 接口。DCache 模块负责读取和写入请求,索引缓存:

+---------------------------+----------+--------------+----+
|        tag [31, 5]        | index[4] | offset[3, 2] | 00 |
+---------------------------+----------+--------------+----+

对于一般读取请求,与 ICache 读取类似,命中即组合逻辑返回,否则进入读取状态,连续读取 4 条数据进入 CacheLine 中,更新 Tag 和 Valid 位,最后返回数据。

对于不可缓存段(如 UART 串口),直接转移进入特殊状态读取返回。

DCache 写入方法

策略上,我们实现了 write-back 和 write-through 两种策略,但 write-through 有调试上的便利,因此选择了 write-through 策略。

对于一般写入请求,DCache 直接写穿内存,同时根据是否命中缓存选择是否要替换缓存行。

对于不可缓存段(如 UART 串口),直接转移进入特殊状态写入返回。

MMU 实现

页表结构

我们实现了基于 SV32 的分页机制,采用两级页表结构。每个 PTE 大小为 4 字节。虚拟地址和物理地址均为 32 位。

页表项(PTE)格式如下:

+-------------------+-----------------+--------------+-+-+-+-+-+-+-+-+
|31     PPN 1     20|19    PPN 0    10|9  Reserved  8|D|A|G|U|X|W|R|V|
+-------------------+-----------------+--------------+-+-+-+-+-+-+-+-+

虚拟地址(VA)格式如下:

+---------------+---------------+--------------------+
|31   VPN 1   22|21   VPN 0   12|11   Page Offset   0|
+---------------+---------------+--------------------+

物理地址(PA)格式如下:

+-------------+-------------------------------+--------------------+
| 舍弃最高两位 |31            PPN            12|11   Page Offset   0|
+-------------+-------------------------------+--------------------+

地址转换过程

通过阅读 RiscV-Privileged 手册,我们实现了 SV32 地址转换过程。

当发现特权态与 SATP 寄存器值符合开启页表条件时,按照下述步骤转换地址:

  1. 计算 $a=\text{satp.PPN} \times 4096$ ,作为页表的根地址;并记当前级数 $i=1$
  2. 访问页表项:计算页表项地址 $\text{pteaddr}=a+\text{VPN}[i] \times 4$ ,读取该地址的 PTE 。
  3. 检查 PTE 的是否合法:
    • 如果 PTE 的 V 位为 0 ,则触发页错误异常。
    • 如果 PTE 的 R 位为 0 且 W 位为 1 ,则触发页错误异常。
  4. 检查 PTE 的类型:
    • 如果 PTE 的 R 或 X 位为 1 ,则表示这是一个叶页表项,前往 5. 。
    • 否则,表示这是一个非叶页表项,令 $i \leftarrow i-1, a \leftarrow \text{pte.PPN} \times 4096$ 前往 2. 。
  5. 检查是否真的是叶页表项:
    • 如果 $i \neq 0$$\text{pte.PPN}[0] = 0$ ,则这是一个非法页表项,触发页错误异常。
    • 否则,前往 6. 。
  6. 根据 mstatus 寄存器的 SUM 和 MXR 位,当前的特权态,以及 PTE 的 U 位,检查访问权限:
    • 如果不满足访问权限要求,则触发页错误异常。
  7. 根据 PTE 的 R W X 位决定本次访存是否合法,如果不合法则触发页错误异常。
  8. 计算物理地址:令 $\text{PA}=\text{pte.PPN} \times 4096 + \text{VA.PPO}$

TLB 实现

我们实现了一个直接映射的 32项 TLB ,用于缓存最近的页表项。

每个 TLB 项目结构如下:

+-------------------+
| Valid Bit (1 bit) |
+-------------------+
| VPN (20 bits)     |
+-------------------+
| PTE (32 bits)     |
+-------------------+

每次成功访问页表,都直接根据虚拟页号(VPN)将对应的 PTE 写入 TLB 。

每次进行地址转换时,先查询 TLB ,如果命中且权限检查正确,则直接使用缓存的 PTE ,否则访问页表。

Flash 实现

为了在启动时加载监控程序以及存储大容量的视频资源,我们设计并实现了一个基于 Wishbone 总线的 Flash 控制器。

跨页访问优化

Flash 存储器的读取具有“页模式”访问的特性:同一页内的连续读取速度很快,而跨页访问则需要较长的建立时间。如果简单地每次读取都重置地址并等待完整的建立周期,带宽将极低。

由此特性,在控制器内部维护一个 prev_page 寄存器,记录上一次访问的页地址,并据此设计状态机:

  1. 当 Wishbone 发起读请求时,控制器首先比较请求地址与 prev_page
  2. 如果命中(Same Page),则直接跳转到快速读取状态 INNER_WAIT_LO/HI,仅需等待极短的 INNER_CYC 周期。
  3. 如果未命中(Page Miss),则跳转到 PAGE_BUFFER_WAIT 状态,等待较长的 BUFFER_CYC 周期以满足跨页建立时间。

16 位转 32 位接口

根据 JS28F320J3F75 的说明书,实验平台所用的 Flash 芯片数据接口为 16 位,而我们的 CPU 和 Wishbone 总线是 32 位架构。

为了适配这一差异,控制器内部执行了两次连续的 16 位读取操作:

  1. 低 16 位读取:先读取 Offset 0 的数据,暂存到内部寄存器 data[15:0]
  2. 高 16 位读取:自动改变最低位地址 (addr_offset),读取 Offset 1 的数据到 data[31:16]
  3. 拼接返回:两次读取完成后,拼接成完整的 32 位字,并向 Wishbone 总线返回 ACK 信号。

VGA 实现

为了在 FPGA 上实现流畅的视频播放,我们需要解决显存带宽受限、画面撕裂以及帧率控制不稳等问题。我们设计了一个支持双缓冲和硬件定时交换的 VGA 控制器,并通过定义控制位和状态位实现软硬件之间的协同工作,达到更高的解耦度和灵活性。

分辨率压缩

标准 SVGA (800x600) 分辨率下,如果使用 32 位真彩色,一帧图像需要约 1.8MB 显存,对带宽和存储容量都是巨大的挑战。为了在有限 的资源下播放长视频,我们设计了一种硬件放大的“低分辨率模式”。

在此模式下,我们在显存中仅存储 100x75 分辨率的图像数据,并采用 RGB332 格式(8-bit 色深)。硬件扫描电路在读取显存时,会自动将每个像素在水平和垂直方向上各放大 8 倍,填充 800x600 的屏幕区域。这使得一帧图像的大小降低至 7.5KB,极大减轻了 CPU 搬运视频流的负担,也降低了对总线带宽的占用。

双缓冲机制

如果 CPU 直接向当前正在被扫描输出的显存区域写入下一帧数据,屏幕上极易出现“画面撕裂”现象(即屏幕上半部分是旧帧,下半部分是新帧)。

为此,我们引入了 Double Buffering 机制。控制器内部维护两个显存页(Page 0 和 Page 1)。硬件逻辑维护一个 Display Page 指针用于屏幕扫描读取,和一个 Write Page 指针用于 CPU 写入。CPU 总是向“后台”页面写入数据,只有在完整的一帧图像写入完成后,才请求交换页面。

硬件帧率控制

在软件层面通过循环延时来控制视频帧率往往是不准确的,容易受到 CPU 流水线状态、Cache 命中率等因素的干扰。为了实现稳定的帧率,我们将定时逻辑下沉到了硬件中。

我们在控制器内集成了一个硬件定时器。软件在初始化时根据系统频率和目标 FPS 计算出两帧之间的时钟周期数,写入 Swap Interval 寄存器。在每一帧数据写入完成后,软件只需向控制寄存器写入 ARM 信号。

硬件会接管后续的同步工作:它会等待,直到同时满足以下两个条件:

  1. 定时器计数达到设定的时间间隔;
  2. 当前 VGA 扫描处于垂直同步(VSYNC)期间(避免在扫描中途切换页面)。

当条件满足时,硬件自动翻转 Display PageWrite Page,并置位 DONE 标志通知软件。软件轮询到该标志后,即可开始下一帧的写入。

以下展示了从视频源文件到屏幕显示的完整工作流程:

sequenceDiagram
    participant PC as Python
    participant Flash as Flash Memory
    participant CPU as Software (Driver)
    participant VGA as Hardware (VGA Ctrl)
    participant Screen as Monitor

    Note over PC, Flash: 1. 预处理阶段
    PC->>PC: mp4_to_vga_bin.py: 缩放(100x75) -> RGB332 -> 拼接
    PC->>Flash: 烧录生成的二进制视频流

    Note over CPU, Screen: 2. 运行时阶段
    CPU->>VGA: 读取系统频率 (SysFreq)
    CPU->>CPU: 计算 SwapInterval = SysFreq / TargetFPS
    CPU->>VGA: 写入 SwapInterval

    loop 每一帧
        CPU->>VGA: 读取状态 (Get WritePage Address)
        VGA-->>CPU: 返回 WritePage_0 基地址

        loop 轮询
            CPU->>Flash: 读取下一帧数据
            Flash-->>CPU: 返回数据
            CPU->>VGA: 写入数据到显存 (WritePage_0)
        end

        loop 轮询
            VGA->>Screen: 扫描 DisplayPage_0 (输出旧帧)
        end
        CPU->>VGA: 写入 ARM 信号 (启用定时器)
        
        par 硬件定时与扫描
            VGA->>VGA: 定时器倒计时 & 等待 VSYNC
        and CPU 等待
            loop 轮询
                CPU->>VGA: 读取 DONE 标志
                VGA-->>CPU: 返回状态
            end
        end

        VGA->>VGA: 条件满足: 交换,Page0 <-> Page1
        loop 轮询
            VGA->>Screen: 扫描 DisplayPage_1 (输出新帧)
        end
        VGA-->>CPU: 置位 DONE 标志

        CPU->>VGA: 清除 DONE 标志
    end
Loading

思考题

流水线 CPU 设计与多周期 CPU 设计的异同?插入等待周期(气泡)和数据旁路在处理数据冲突的性能上有什么差异。

两者设计的相同点是都需要把一条指令拆成多个阶段,使用多个周期分别处理。不同点是,前者需要支持这多个阶段同时运行,后者每个周期内只处理一个阶段。插入等待周期(气泡)会使得整个流水线暂停流动,冲突指令之后的指令无法继续执行,从而降低指令吞吐率;而通过数据旁路解决数据冲突的同时,流水线可以继续流动,性能更好。

如何使用 Flash 作为外存,如果要求 CPU 在启动时,能够将存放在 Flash 上固定位置的监控程序读入内存,CPU 应当做什么样的改动?

给 Flash 设计一个控制器,并且赋予其一个内存映射地址空间,接入到当前的 Wishbone 总线上,这样 CPU 读写对应的地址时就能访问 Flash 了。在 CPU 启动时,修改初始 PC,使其指向 Flash 上存放监控程序的固定位置即可。

如何将 DVI 作为系统的输出设备,从而在屏幕上显示文字?

首先给 DVI 分配一块显存。之后设计一个 DVI 控制器,从显存中读取图像数据,并按照 DVI 协议的时序进行输出。写显存的任务是由 OS 完成的,与我们设计的 CPU 无关,只需将这块显存映射到一个地址空间即可。

(分支预测)对于性能测试中的 3CCT 测例,计算一下你设计的分支预测在理论上的准确率和性能提升效果,和实际测试结果对比一下是否相符。

测例代码:

80001064 <UTEST_3CCT>:
80001064:	040002b7          	lui	t0,0x4000
80001068:	00029463          	bnez	t0,80001070 <UTEST_3CCT+0xc>
8000106c:	00008067          	ret
80001070:	0040006f          	j	80001074 <UTEST_3CCT+0x10>
80001074:	fff28293          	addi	t0,t0,-1 # 3ffffff <INITLOCATE-0x7c000001>
80001078:	ff1ff06f          	j	80001068 <UTEST_3CCT+0x4>
8000107c:	fff28293          	addi	t0,t0,-1

理论上,我们的 CPU 在几乎每次遇到分支指令时都能正确预测跳转与否,因此单次循环只需要 8 个周期。未实现分支预测时,每次循环需要 (2+2)*3+2=14 个周期(流水线没有去气泡)。性能理论提升 $\frac{14-8}{14} \approx 42.86%$

实际测试结果:

性能测试(s) 1PTB 2DCT 3CCT 4MDCT CRYPTONIGHT
基础版本(30M) 67.110 33.555 78.295 54.806 3.164
分支预测(30M) 55.923 30.757 44.739 49.215 2.990

性能提升了 $42.86%$ ,十分符合预期。

(缓存)对于性能测试中的 4MDCT 测例,计算一下你设计的缓存在理论上的命中率和性能提升效果,和实际测试结果对比一下是否相符。

测试代码:

80001080 <UTEST_4MDCT>:
80001080:	020002b7          	lui	t0,0x2000
80001084:	ffc10113          	addi	sp,sp,-4
80001088:	00512023          	sw	t0,0(sp)
8000108c:	00012303          	lw	t1,0(sp)
80001090:	fff30313          	addi	t1,t1,-1
80001094:	00612023          	sw	t1,0(sp)
80001098:	00012283          	lw	t0,0(sp)
8000109c:	fe0296e3          	bnez	t0,80001088 <UTEST_4MDCT+0x8>
800010a0:	00410113          	addi	sp,sp,4
800010a4:	00008067          	ret

理论上,DCache 在每次循环中会命中 2 次,未命中 2 次。单次循环需要 $5+3+2+5+3+2=20$ 周期。不使用缓存,每次循环需要 $5+5+2+5+5+2=24$ 周期。性能理论提升 $\frac{24-20}{24} \approx 16.67%$

实际测试结果:

性能测试(s) 1PTB 2DCT 3CCT 4MDCT CRYPTONIGHT
指令缓存(30M) 22.370 12.302 17.896 29.085 1.311
数据缓存(30M) 22.371 12.304 17.897 24.609 1.557

性能提升了 $15.39%$ ,符合预期。

(虚拟内存)考虑支持虚拟内存的监控程序。如果要初始化完成后用 G 命令运行起始物理地址 0x80100000 处的用户程序,可以输入哪些地址?分别描述一下输入这些地址时的地址翻译流程。

可以输入 0x801000000x00000000 ,它们都是虚拟地址。

  • 0x80100000 是给定页表中的调试用的映射,页表项中 PTE 的 DAGUXRV 位均为 1 ,因此可以成功访问,最终被翻译为物理地址 0x80100000

  • 0x00000000 是给定页表中的正常用户程序映射,页表项中 U 位为 1 ,因此可以成功访问,最终被翻译为物理地址 0x80100000

翻译流程:参照页表部分的描述。

(异常与中断)假设第 a 个周期在 ID 阶段发生了 Illegal Instruction 异常,你的 CPU 会在周期 b 从中断处理函数的入口开始取指令执行,在你的设计中,b - a 的值为?

值为 4 。

分工情况

我们小组的大实验在杜若和的五级流水线小实验基础上继续开发。以下是具体功能的分工表:

成员 分工
杜若和 流水线最初设计、数据前传、分支预测、中断异常、特权态
陈禹默 ICache、DCache、页表、TLB
张轶程 双缓冲 VGA、Flash

心得体会

  • 陈禹默是我的男神。Verilator 救我狗命。——杜若和

  • 杜若和是我的小教员。究竟什么是 Timing Loop ?——陈禹默

  • 男神是我的小教员。默默萌萌的。 ——张轶程


接口约定

MMU Mapping

Module Address Range
SRAM 0x80000000 - 0x803FFFFF (base), 0x80400000 - 0x807FFFFF (ext)
UART 0x10000000 - 0x10000007
VGA 0x81000000 - 0x81003FFF
Flash 0x80800000 - 0x80FFFFFF
MTIME 0x0200BFF8 (lo), 0x0200BFFC (hi)
MTIMECMP 0x02004000 (lo), 0x02004004 (hi)

仿真相关

如果仿真遇到问题,请使用 Verilator 4.228 版本。参考 这里 以安装一个特定的 Verilator 版本。

VGA 测试流程

End-to-End

  1. 修改 scripts/runVga.sh 中的环境设置,确保本机环境正确

  2. 视频放在 assets/vga_input.mp4,视频编码工具放在 tools/mp4_to_vga_bin.py

  3. 执行脚本:

    # 只复制第一帧
    VGA_MODE=single scripts/runVga.sh
    # 循环播放所有帧
    VGA_MODE=full scripts/runVga.sh
    # 测试仿真,我的 mill cmd line tools 貌似有问题,我反正是直接用 metal extension 的运行工具运行的,不保证命令行的正确性
    # VGA_MODE=test scripts/runVga.sh
    # 清理编译文件
    # VGA_MODE=clean scripts/runVga.sh

Step-by-Step

  1. 准备显存原始数据 安装 opencv-python,然后执行

    • 仅抽取单帧:

      python3 tools/mp4_to_vga_bin.py assets/vga_input.mp4 \
          --frame-index 0 \
          --output simWorkspace/vga_demo/extram_frame_single.bin \
          --preview simWorkspace/vga_demo/frame0_single.png
    • 生成整段视频(全部帧):

      python3 tools/mp4_to_vga_bin.py assets/vga_input.mp4 \
          --all-frames \
          --output simWorkspace/vga_demo/extram_frame_full.bin \
          --preview simWorkspace/vga_demo/frame0_full.png

    每帧会被缩放到 100×75、量化为 RGB332;单帧约 7 500 字节,多帧文件会按顺序拼接。

  2. 编译拷贝程序

    cd software/vga_demo
    make MODE=single RISCV_PREFIX=riscv64-unknown-elf-   # 只复制第一帧
    make MODE=full   RISCV_PREFIX=riscv64-unknown-elf-   # 循环播放所有帧

    分别生成 baseram_vga_demo.bin(单帧)与 baseram_vga_demo_multi.bin(多帧),程序会把 ExtRAM 的数据写入 0x8100_0000 并设置 VGA 模式寄存器。

  3. 运行仿真并比对结果

    mill -i cod25.runMain cod25.sim.VgaCopySim       # 单帧校验
    mill -i cod25.runMain cod25.sim.VgaCopySimFull   # 全帧校验

    仿真会把 MyCPUTop 置于 sim=true,加载上述初始化文件。VgaCopySim 会生成 simWorkspace/vga_demo/frame_dump.txt 方便人工检查;VgaCopySimFull 会逐帧等待 CPU 复制完成并打印每帧的匹配结果。

开发规范

  • main 分支已经保护,非 owner 的所有修改必须(在自己的 fork 下)拉新的分支,再发起 PR,等待 owner 合并。合并后,原分支将被删除
  • Commit message 需要有清晰的语义,说明这次 commit 做了什么,建议使用 feat fix refactor 等关键字
  • 尽量保持每次 commit 的改动粒度较小,不要把很多不太相关的改动放在同一个 commit 里
  • PR 需要有清晰的描述,说明这次 PR 做了什么
  • 不要把二进制文件和 __MACOSX 这种东西提交到仓库里,可以自行修改 .gitignore

About

五级流水线 RISC-V 32 位处理器。清华大学计算机系计算机组成原理 (COD, 计组) 课程 2025 秋实验

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 3

  •  
  •  
  •