上一篇:阶段
下一篇:寄存器重命名
内存分为SRAM和DRAM两大类,均为阵列式,由于Vivado已经提供了Block Memory Generator,这里就不对内存做过多介绍了。Vivado的BMG的读写操作都要延迟到第3个周期才完成。
我们知道,内存读取数据的速度是比较慢的(因为大容量的DRAM内部使用电容存储数据),而CPU执行指令的速度是比较快的(触发器的反应速度比较快),因此为了平衡两者之间的速度差,我们在CPU内引入一级缓存,用于存储我们要使用到的数据,这样我们要访问内存的时候(无论是读还是写),只要访问一级缓存就可以了。
一级缓存有3种结构:直接映射、全相联映射和组相联映射三种。
我们将内存区域划分为一个一个的数据块,并为每个数据块依次编号。我们将连续的数个数据块(通常是2的幂次个数据块,比如16个)划分为一组。这样我们就有很多组数据块。另一边,缓存设立16个数据块,每个数据块直接使用寄存器实现,并为每个数据块给一个标记。我们建立缓存到内存的映射关系。如果我们希望访问内存中的某个数据块,那么我们先得到这个数据块在某个数据组中的是第几块,比如如果是第8块,那么这个块就映射到缓存中的第8块。因为缓存中的数据块个数和内存中的数据组内的数据块的个数一样,所以这个映射是满射的。
如下图,由于我们假定数据块大小是2的幂,数据块的编号也是2的幂,因此我们可以从内存地址中直接截出相应的数据,这里addr[3:0]
就是字节在数据块内的编号,addr[8:4]
就是数据块在数据组内的编号,而addr[31:9]
就是数据组的编号。右下角的矩阵就是我们的缓存池,一共32个数据块,每行为一个数据块,块内有32个字节的数据。同时每个数据
块还需要一个标记tag,表示当前我们存储到的这个数据块所在的数据组编号是多少。因为我们已经预先规定了缓存中的数据块所在某个组内的编号,所以我们就可以通过tag知道这个块是哪个块。如果我们希望访问内存中的某个数据,比如tag=0x50,index=0x01
的数据块,我们就先检查缓存内index=0x01
的数据块的tag是否是0x50,如果是,表明我们
之前已经建立了这个数据块在主存和缓存之间的映射关系,我们可以直接调用缓存中的数据;否则缓存未命中,我们需要先将缓存内的数据写回到内存中(如果缓存中的数据被修改过),然后再从内存中拉取tag=0x50
的这个数据块,覆盖缓存中index=0x01
的数据块空间,这样CPU就可以从缓存中调取需要的数据了。
所以现在我们将数据的内存地址划分为了数据组、数据块和块内偏移量三个部分。缓存映射数据块的编号并检查数据组编号是否一致,一致直接操作,否则就要从内存中读出另外的数据。也就是说,一旦我们频繁读写数据块编号一致,但数据组号不一致的数据时将会导致缓存未命中的频率很高,导致大量的内存读写降低缓存的效率。这个是直接映射法的缺点。
为了解决频繁读写数据块编号一致而数据组编号不一致的数据的问题,我们可以不分数据组,只分数据块,而缓存内的16个数据块的标记位存储数据块的编号,而我们查找一个数据是否在缓存中,只需要依次检查每个数据块,如果标记位和我们要访问的数据的标记位一致,就在缓存中。否则不在缓存中,我们需要在缓存中分配一个数据块给这个未命中的数据块。这样做的好处是缓存的空间被充分地利用了,但每次判断数据是否在缓存中的速度比较慢。
那么我们可以考虑结合两者的优点,应用组相联映射的方法。也就是说,在直接映射的基础上,我们为缓存内每个数据块编号内采用全相联映射法。也就是说,假如我们有16种数据块编号,那么缓存内则可以有32个数据块空间,每两个数据块空间被分为一组,对应上数据块的编号。这样我们先通过要访问的数据的数据块在数据组内的编号找到特定的2个数据块,并判断哪个数据块中保存的数据是我们要的(也就是标记位一致)。如果发生失配,我们再从2个数据块中分配一个存储我们要访问的数据所在的数据块。
综上,我们定位数据组的方法是直接映射,而定位块的方法是全相联映射。由于如果一种数据块编号内如果只有2个数据块,那么选择器的实现就会简单很多。电路示意图如下:
图中每行为一种数据块编号的存储单元,左侧的存储单元为第一个块,右侧的存储单元为第二个块,我们将数据组编号传递给左右两侧的存储单元,可以得到左侧是否匹配到,以及右侧是否匹配到,哪侧匹配成功,则选择哪侧的数据输出,并得到缓存的命中标志信号。
我们需要一种机制,使得我们能尽量挑选一个好的覆盖方案,使得一旦需要覆盖缓存时,分配一个数据块使得我们的缓存的命中率更高(或者说分配/覆盖缓存的次数更少)。显然替换掉使用最频繁的之类的数据块的策略肯定是不优的,毕竟当前使用很频繁,可能之后还是很频繁,如果其被替换掉了,我们之后又需要将其替换回来下面列举一些可能的覆盖方案:
这种机制很简单,随便取一个块替换掉,如果内存的访问比较平均,倒不失为一种有效的方法。
这种机制专门覆盖最近使用过的那个块,其实现比较简单,只需要记录最新使用过的块的编号即可,但是显然不是最有效的。
这种机制专门覆盖最久没被访问过的那个块,认为最久没被访问过的块再次访问的可能性不大,替换掉是可以的。这种方法不失为一种可行的方案,我们可以这么实现LRU:
首先我们为每个数据块分配一个标记位表示这个块在所有块中是第几个访问过的,比如最近一次访问过的块的时间顺序为0,最久没被访问过的块的时间顺序为3(如果一个一个数据块编号内有4个数据块采用全相联映射法)。那么如果我们访问了时间顺序为2的数据块,那么这个数据块的时间顺序变为0,而时间顺序为0和1的时间顺序加1变成1和2,就可以了。然后我们找需要覆盖的块就是找时间顺序为3的块就可以了。
这种机制需要我们统计在一定数量的操作内每个块被访问次数并找到访问次数最少的那个覆盖,实现比较复杂。
这种机制希望结合两者的优点,在多久没被访问过或者用的次数太少时都可能被覆盖。实现比较复杂。
我们较为常用的两种方法是直接映射法和组相联映射法。一级缓存将使用直接映射法,而二级缓存将采用组相联映射法。这是因为一级缓存从二级缓存中调取数据的速度要比从内存中调取数据的速度快,我们可以在短时间内完成失配重读的操作,而一级缓存是与CPU直接交互的设备,调取数据的速度要求很高,也比较有规律,一级缓存的命中率很高,因此我们使用组相联映射法花费在判断是哪个组以及分配一个组的时间显得没有必要。但二级缓存需要组相联映射法,因为读取内存的速度比较慢,我们希望能尽可能多地缓存数据,减少访问内存的频率。一般不会用到全相联映射法。
我们在了解了一级缓存的实现方法后,实现一级指令缓存也就很简单了。由于指令的特性,
我们一般不会修改在内存中的存储的指令,因此一级指令缓存只需要有从内存读的操作,而
不需要写操作。那么一级指令缓存的端口表如下:
端口种类 | 端口名 | 端口意义 |
---|---|---|
input | clk | 时钟信号 |
input | rst_n | 复位信号 |
input | addr | 要读取的指令的内存地址 |
output | ready | 指令缓存是否可读(指令缓存是否在刷新数据块) |
output | data | 读取的数据 |
output | data_valid | 数据是否可读 |
一些与内存交互的端口 |
实现一级的指令缓存也很简单,我们使用有限状态机实现。状态表如下:
STATE_READY
,表示当前指令缓存处于可读状态,一旦检查到未命中的情况,转移到STATE_MISS
STATE_MISS
,表示当前指令缓存发生了未命中,我们需要从内存读出相应的数据块的数据,读取完成后转移到STATE_READY
实现一级的数据缓存和指令缓存类似,只是多了写操作。那么状态集合就要改变了:
STATE_READY
,表示当前数据缓存处于可读可写状态,一旦检查到未命中的情况,如果内存块被修改过,我们转移到STATE_WB
,否则跳转到STATE_POPULATE
。STATE_WB
,表示当前数据缓存发生了未命中,我们需要先将被修改过的数据块写回内存,一旦完成写操作,我们跳转到STATE_POPULATE从内存读出相应的数据块的数据。STATE_POPULATE
,表示当前数据缓存需要从内存中把相应的内存块数据读取出来,读取完成后跳转到STATE_READY
。从上述描述来看,我们需要给每个数据块另外开设一个标志位,表示该数据块是否被修改过,也就是说如果写操作命中了,那么那个数据块的dirty位就要被标记,导致整个块的数据写回内存。
注意,和指令缓存每次总是4字节对齐地取32位数据出来使用,数据缓存可能操作一个字节,也可能操作一个半字,也可能操作一个字,因此端口稍有不同,除了多出来的写操作的端口,我们还需要标记当前操作的数据宽度以及是符号扩展还是零扩展。归功于MIPS架构强制要求内存的数据访问4字节对齐,我们的数据缓存的实现也要简单很多。
CPU可支持的外置设备可能有闪存,也可能有网卡之类的设备,我将在UART章节介绍外置设备。
我们介绍了内存以及3中需要访问内存的设备,接下来我们需要设计一个专用的数据选择器以便选择要访问内存的设备将端口连接在一起。
我们要设计的内存控制器模块memory_controller.v
的需要以下的端口:
端口名 | 注释 |
---|---|
clk | 时钟信号 |
rst_n | 复位信号 |
指令缓存控制内存的端口 | |
数据缓存控制内存的端口 | |
外置设备控制内存的端口 | |
内存实际的端口 |
我们通过有限状态机实现这个内存控制器。我们定义状态列表如下:
STATE_READY
, 目前没有访问内存的请求,一旦有请求,转换到对应的状态。STATE_INST_MEM
, 一旦指令缓存没有命中相应的内存块,将会发起内存读的请求,使内存控制器转换到本状态,将端口与内存相连,允许指令缓存访问内存。STATE_DATA_MEM
, 一旦数据缓存没有命中相应的内存块,将会发起访问内存的请求,使内存控制器转换到本状态,将端口与内存相连,允许数据缓存访问内存。STATE_EXTERNAL
, 一旦外部设备需要访问内存,使内存控制器转换到本状态,将端口与内存相连,允许外部设备访问内存。那么实现内存控制器就很简单了,一个时钟驱动的always语句负责状态的转换。另一个无条件触发的always语句负责将内存的端口连到当前需要使用内存的器件提供的端口就可以了。