跳转至

Lab6 非可缓存访问与MMIO

事不凝滞,理贵变通。————《宋史·赵普列传》

在上一个实验中,我们构建了通用的 AXI4 总线来对内存进行访问。为了解决 AXI4 总线的低速性,我们还引入了高速缓存来加速访存过程。但是,高速缓存并不是万能的,它只能加速一部分访存操作,对于一些特殊的访存操作,我们仍然需要直接访问主存。本实验将会介绍这些特殊的访存操作。

本次实验的主要任务有:

  • 改造 DCache,使其能够支持非可缓存访问;
  • 为裸机环境中添加 MMIO 操作支持。
请获取 Lab6 所需的代码框架

请将工作目录切换到 Lab6,运行以下命令:

shell
$ ./zinit.sh

1 处理器的非可缓存访问

1.1 非可缓存访问的概念

非可缓存(Uncached)访问,是一类重要的访存方式。在内存的地址空间中,我们经常会预留出一段空间作为外设的内存地址映射空间。对于这一部分空间,我们不能对其进行缓存,其原因是:

  • 外设会自主修改自己的数据,如果对这个数据使用缓存,那么处理器无法及时读取到外设更新的内容;
  • 在写外设(如串口)地址时,我们希望这个写操作能够立即生效,而不是等待高速缓存的写回操作。

由于 RISC-V 没有专用的非可缓存 load/store 指令,而只是通过地址来区分是否是非可缓存访问,因此我们需要在 DCache 中对非可缓存访问进行特殊处理。

真正的非可缓存访问的区分

在我们的实验中,由于我们不涉及虚实地址转换,因此我们只是通过 load/store 的地址来区分可缓存和非可缓存访问。在真正的计算系统中,处理器可以通过页表项中的 MAT 字段来判断该地址是否可缓存,这样就可以实时地根据操作系统的不同需求来修改某个地址的可缓存性。

1.2 高速缓存的非可缓存访问原理

在介绍非可缓存访问之前,我们先来回顾一下《计算机组成原理》课程中介绍的高速缓存的工作原理:

  • 对于每一个存储地址,高速缓存会将其分为三个部分:标记 Tag、组号 Index 和块内偏移 Offset;
  • 高速缓存使用 Index 来读取 Tag 表中对应的数据,并与本次地址的 Tag 进行比较,如果相同切当前行有效,则说明本次访问命中,否则说明本次访问不命中;
  • 对于命中的情况,高速缓存会送出其内部存储器对应的数据,否则需要向主存发起访问。
数据高速缓存通路

在阅读下面的内容之前,请先了解本课程实验框架中的数据高速缓存通路

在上一个实验中,我们通过实现 AXI4 总线的转接-仲裁,已经能完整地实现上述所有逻辑了。而对于非可缓存访问,我们可以对高速缓存做如下修改:

  • 高速缓存“必定缺失”,即忽略掉标签比较的结果;
  • 写地址不再是块对齐的地址,而是直接将请求的地址字对齐后写入 maddr_buf
  • d_rlen, d_wlen 固定为 0,以控制只读一个数据;
  • 读的宽度 d_rsize 不再是行宽度,而是等于本次读取请求的数据宽度;
  • 写数据不再通过 write_manage 进行对齐,而是直接将流水线的写数据送往 write_buf
  • 写的宽度 d_wsize 依然为一个字的宽度,但写掩码也不再是 0b1111,而是将流水线的写掩码直接送出。
Task 1.1

本任务不需要大家编写任何代码,请大家根据高速缓存数据通路中的描述,仔细梳理框架中高速缓存的各个元件和缺失处理的逻辑。梳理完毕后,请结合上述提到的内容,定位到需要代码中修改的位置。在阅读后续文档详细的介绍后,再来完成这些内容的修改。

1.3 高速缓存的非可缓存访问实现

在这一部分,我们将实现高速缓存的非可缓存读写。下面我们将介绍这一部分的实现细节。

1.3.1 非可缓存的判断

在我们的实验中,所有非可缓存的外设地址最高 4 位都是 0xA,因此我们可以通过组合逻辑判断 req_buf 中锁存的 addr_pipe 最高四位是否为 0xA,来判断是否是非可缓存访问。

Task 1.2

请在 DCache 中定义一个一位宽的 uncached 信号,并在 DCache 的第 1 部分 Request Buffer 中实现上述的非可缓存访问判断逻辑。

1.3.2 非可缓存的读写地址

对于读地址,我们需要在非可缓存访问时将 d_raddr 改为 addr_pipe,即直接将请求的地址送往主存。而对于写地址,由于 d_waddrmaddr_buf 的值是一致的,所以我们需要修改 maddr_buf 的写入逻辑,使其在非可缓存访问时直接将 addr_pipe 四字节向下对齐的值写入。

Task 1.3

请修改 DCache 的第 12 部分 Miss Address Buffer 和第 14 部分 Memory Settings,实现上述的读写地址修改逻辑。

1.3.3 非可缓存的读写配置

对于非可缓存读访问,我们需要将 d_rlen 恒置为 0,以保持只读一个数据。同时需要将 d_rsize 改为 req_buf 中锁存的读数据宽度。

对于非可缓存写访问,我们需要将 d_wlen恒置为 0,以保持只写一个数据。d_wsize 不需要修改,因为我们控制写的数据是通过 d_wstrb 来控制的,因此我们还需要将 d_wstrb 改为 req_buf 中锁存的写掩码。

Task 1.4

请修改 DCache 的第 14 部分 Memory Settings,实现上述的读写配置修改逻辑。

1.3.4 非可缓存的读写数据

对于非可缓存读访问,主存返回的数据依然会保存在 ret_buf 中,但由于 ret_buf 只会移位写入一次,因此这个数据在 ret_buf 的最高 32 位中。为此,你需要修改第 8 部分中 rdata_ret 的生成逻辑,使其能在非可缓存读访问时将 ret_buf 中的高 32 位数据直接送出。

对于非可缓存写访问,我们需要将流水线的写数据直接送往 write_buf,而不是通过 write_manage 进行对齐。为此,你需要修改第 11 部分中 write_buf 的写入逻辑,使其能在非可缓存写访问时将流水线的写数据直接送入 write_buf 的最低32位。

Task 1.5

请修改 DCache 的第 8 部分 Read Data 和第 11 部分 Write Data,实现上述的读写数据修改逻辑。

1.3.5 非可缓存的控制逻辑

在主状态机中,我们需要复用普通缺失处理的数据通路。先来看一下状态转换的修改方式:

  • 在 IDLE 状态且有访存访问时,如果当前是非可缓存访问,我们需要根据当前是否为读访问,将状态机转移到 MISS 或 WAIT_WRITE 状态;
  • 在 MISS 状态且数据全部返回时,如果当前是非可缓存访问,我们需要让状态机跳过 REFILL 状态,直接进入 WAIT_WRITE 状态。

对于信号输出的修改如下:

  • 在 IDLE 状态且有访存访问时,如果当前是非可缓存访问,那么由于我们强制缺失,所以信号生成和原本的非命中情况一致。

在写回状态机中,我们需要对状态转换做如下修改:

  • 在 INIT 状态下,如果当前写回状态机被激活且为非可缓存读访问,则不需要发起写回操作,直接进入 FINISH 状态。

而在写回状态机中,还有一个非常重要的信号:write_num,这个信号控制了写回的数据个数。在非可缓存访问中,我们需要将 write_num 恒置为 0,以保证只写回一个数据。

Task 1.6

请修改 DCache 的第 15, 16 部分,实现上述的状态转换和信号输出修改逻辑。

在完成以上所有任务后,你就可以修改 simulator/script/build.mk 中的 APP 变量,来运行 testcase/app 中的 hello, keyboard, rtc, video 四个测试了。

与 nemu 进行同步

在 simulator/sim/sim.cpp 的 cpu_exec 函数中,我们定义了一个 npc_cpu_uncache_pre 的布尔型变量。这个变量是为了处理非可缓存读的情况下,nemu 中时钟等外设可能和我们的处理器不一致的情况。一旦前一条指令是一条非可缓存读指令,我们就将这个信号置为 1。在读指令执行完成后,将处理器的状态与 nemu 进行一次同步。这样一来,我们就可以跳过非可缓存读的对比了。

2 MMIO 的实现

在实现了 DCache 的非可缓存通路后,我们再次回到 base-port 裸机环境中。我们现在还不能编译出支持特定 MMIO 地址的代码。为此,我们需要在裸机环境中添加 MMIO 操作的支持。

复杂硬件的调试方法

从本节开始,我们将会运行一些包含很多指令的程序。如果你的 CPU 实现不够健壮,那么程序很有可能在中途出现错误。这时,从头开始生成波形是不现实的。我们提供一种高效的 debug 方式:

  • 先关闭波形导出,注意出错时 simulator 输出的总执行指令的数量;
  • 在 sim.cpp 中,我们用一个变量来记录当前执行的有效指令数目。你可以使用这个变量,在出错点之前的一小段时间内再开始生成波形。

2.1 MMIO的概念

MMIO,即 Memory Mapped I/O,是一种常见的外设访问方式。在 MMIO 中,外设的寄存器被映射到内存的某个地址空间中,通过访问这个地址空间,我们就可以对外设进行读写操作。在 RISC-V 中,我们可以通过 load 和 store 指令来对MMIO进行访问。

在我们的实验中,我们主要支持以下几种外设:

  • 串口:通过串口,我们可以在屏幕上输出一些信息。
  • 时钟:通过时钟,我们可以获取当前的时间。
  • 键盘:通过键盘,我们可以从键盘上读取一些信息。
  • VGA:通过 VGA,我们可以在屏幕上显示一些图像。
  • 磁盘:通过磁盘,我们可以读取和写入一些文件。

我们的裸机环境只是编译出了读写这些特定地址的代码,而真正实现这些外设的是仿真环境中的外设。在 simulator 中,这些外设都是通过 C 语言的库函数来进行实现的。

我们需要大家实现的外设包括:

  • 串口:base/src/trm.c
  • 时钟:base/src/rtc.c

2.2 串口的MMIO访问

串口(Serial Port)是最为简单的外设,也是很方便的调试工具。在我们的实验中,我们只用到串口的输出功能。

在我们的实验中,串口的 MMIO 起始地址(UART_ADDR)为 0xA00003F8,空间总长度为 1 个字节。向这个地址写入一个字节,就可以让这个字节显示到终端上。由于串口的功能过于基础也过于简单,我们并没有将其统一管理到外设中,而是直接在 base/src/trm.c 中提供了一个 putch 函数。这个函数的功能就是向串口的 MMIO 地址写入一个字节。

为了方便大家生成写入特定地址的代码,我们在 base/include/device-mmio.h 中提供了一些函数,这里需要关注的是:

C++
static inline void outb(uintptr_t addr, uint8_t data) { *(volatile uint8_t *)addr = data; }

这里定义了一个 outb 函数,这个函数可以向 addr 地址写入一个字节,内容为 data。我们可以通过这个宏来向串口写入一个字节。

Task 2.1

请你使用 outb 函数,完成 base/src/trm.c 中 putch 函数。这个函数会向串口写入一个字节。

完成这个 MMIO 之后,你就可以运行 device-test 中的 hello 测试了。这个程序会输出一些信息到终端上。特别注意,由于你修改了 base 库文件,因此你需要执行 make clean-all 之后才能重新构建 base 库并正确构建程序。

2.3 时钟的MMIO访问

时钟(Real Time Clock)是一个非常重要的外设,它可以提供给我们当前的时间。

在我们的实验中,时钟的 MMIO 起始地址(RTC_ADDR)为 0xA0000040,空间总长度为 32 个字节。这个地址空间内的偏移量定义如下(所有偏移量均为字偏移):

偏移量 说明
0 距离系统启动时间微秒数的低 32 位
4 距离系统启动时间微秒数的高 32 位
8 真实时间的秒数
12 真实时间的分钟数
16 真实时间的小时数
20 真实时间的天数
24 真实时间的月份
28 真实时间的年份

我们同样需要关注 base/include/device-mmio.h:

C++
static inline uint32_t inl(uintptr_t addr) { return *(volatile uint32_t *)addr; }

这一行定义了一个 inl 函数,这个函数可以读取 addr 地址的一个字,并返回这个字的值。我们可以通过这个宏来读取时钟的某个寄存器的值。

Task 2.2

请你使用 inl 函数,完成 base/src/rtc.c 中的 __timer_uptime__timer_rtc,这两个函数分别返回了距离系统启动时间的微秒数和真实时间。

在完成这个函数之前,我们建议先阅读 DEV_TIMER_UPTIME_TDEV_TIMER_RTC_T 的定义,以便你能够正确地返回这两个结构体。

完成这个 MMIO 之后,你就可以运行 device-test 中的 rtc 测试了。这个程序会每隔一秒输出当前时间和距离系统启动时间的秒数。

我们已经帮助大家实现了键盘和 VGA 的外设控制,大家可以运行 keyboard 和 video 测试来查看这两个外设的功能。特别注意,键盘输入需要点击一下 VGA 窗口才能有响应。

Advanced Task

在 VGA 的外设控制中,我们采用了将 pixel 中的内容逐字节复制到 VGA 的显存中的方式来实现显示。由于这一部分内存全部都为非可缓存,所以会产生大量需要停顿的写操作,这是我们不愿意看到的。

很自然地,我们会想到一个更高效的办法:能不能只把 pixel 的起始地址和长度告诉仿真环境,让仿真环境中的 VGA 自己去内存中读取数据呢?这当然是个好主意!我们在仿真环境中提供了两个寄存器: ffb_memffb_draw,前者会记录 pixel 起始地址、写入的长度和写入的偏移量,后者根据 ffb_mem 的内容,将 pixel 地址中的内容直接写入 VGA 的显存 vmem 中。

请你阅读 simulator/src/vga.c 中的 ffb_memffb_draw 的实现,找到它们的映射地址和使用方法,并将这种方法替换掉 base/src/gpu.c 中 __gpu_fbdraw 的实现。这样一来,我们就可以大大减少对 VGA 的写操作时间了。

但是,这样真的能输出正确的结果吗?尝试运行 video 测试,你得到了想要的结果吗?

请你思考一下,为什么会出现这种情况(提示:思考 DCache 和内存不一致的问题)?请了解 RISC-V 指令集中的 fence.i 指令,查找它的定义,并思考它的实现方法,并尝试在合适的地方插入这条指令,使得 video 测试能正确运行。

3 实验报告

本次实验不需要提交实验报告。