实验4:寄存器堆和存储器
寄存器堆和存储器是处理器系统中重要的组成部分,本次实验我们将会使用Vivado的IP核生成器生成分布式存储器(Distributed Memory)和块式存储器(Block Memory),探究其时序特性,并设计先入先出队列(First In First Out, FIFO)。
1 寄存器堆
在计算系统概论课程上,大家已经在LC3处理器的CPU中看到了寄存器堆的存在。寄存器堆是处理器可以快速访问的存储单元,它的特点是:
- 读写速度快:读为异步读,当周期出结果;写为同步写,写入后下个周期即可访问写入数据。
- 读写端口多:一般有两个读端口和一个写端口,可以同时进行两次读操作和一次写操作。
- 容量小:一个寄存器堆中一般有32-64个寄存器,过多的寄存器会导致电路的面积和延迟增加。
- 读写并行可定制性强:虽然写数据需要一个周期,但写数据也可以通过前递的方式在当周期被读请求读出。
1.1 读优先的双端口寄存器堆
读优先、写优先是针对读写同时发生且地址相同的情况下读数据的来源而言的。
读优先是一种处理器读写方式,若当前周期的读写地址相同,且写使能为有效,则当周期读出的数据仍然为寄存器堆中读地址存储的数据,而不是写入的数据。这种方式可以保证在写入数据的同时,依旧读出寄存器堆中保存的数据。其verilog代码如下:
module reg_file # (
parameter ADDR_WIDTH = 5, //地址宽度
parameter DATA_WIDTH = 32 //数据宽度
)(
input clk, //时钟
input [ADDR_WIDTH -1:0] ra0, ra1, //读地址
output [DATA_WIDTH -1:0] rd0, rd1, //读数据
input [ADDR_WIDTH -1:0] wa, //写地址
input [DATA_WIDTH -1:0] wd, //写数据
input we //写使能
);
reg [DATA_WIDTH -1:0] rf [0:(1<<ADDR_WIDTH)-1]; //寄存器堆
//读操作:读优先,异步读
assign rd0 = rf[ra0];
assign rd1 = rf[ra1];
//写操作:同步写
always@ (posedge clk) begin
if (we) rf[wa] <= wd;
end
endmodule
对于读优先的理解,我们可以在这里举一个例子:当ra0 = 4, wa = 4, we = 1
时,我们可以看到,此时的读出数据应当为rf[4]
的值,而不是wd
的值。
1.2 写优先的双端口寄存器堆
写优先是一种处理器读写方式,若当前周期的读写地址相同,且写使能为有效,则当周期读出的数据为写入的数据,而不是寄存器堆中读地址存储的数据。这种方式可以保证在写入数据的同时,依旧读出写入的数据。其verilog代码如下:
module reg_file # (
parameter ADDR_WIDTH = 5, //地址宽度
parameter DATA_WIDTH = 32 //数据宽度
)(
input clk, //时钟
input [ADDR_WIDTH -1:0] ra0, ra1, //读地址
output [DATA_WIDTH -1:0] rd0, rd1, //读数据
input [ADDR_WIDTH -1:0] wa, //写地址
input [DATA_WIDTH -1:0] wd, //写数据
input we //写使能
);
reg [DATA_WIDTH -1:0] rf [0:(1<<ADDR_WIDTH)-1]; //寄存器堆
//读操作:写优先,异步读
assign rd0 = (wa == ra0 && we) ? wd : rf[ra0];
assign rd1 = (wa == ra1 && we) ? wd : rf[ra1];
//写操作:同步写
always@ (posedge clk) begin
if (we) rf[wa] <= wd;
end
特殊的寄存器要求
在某些指令集架构(当然,这肯定不包括LC3)中,规定:r0的值必须为0,且不可被修改。因此,我们在设计寄存器堆时,需要将rf[0]
的值固定为0,且不可被修改。这样的寄存器堆被称为特殊寄存器堆。这样的寄存器堆如果使用写优先,那么对于0号寄存器的处理应当如下:
module reg_file # (
parameter ADDR_WIDTH = 5, //地址宽度
parameter DATA_WIDTH = 32 //数据宽度
)(
input clk, //时钟
input [ADDR_WIDTH -1:0] ra0, ra1, //读地址
output [DATA_WIDTH -1:0] rd0, rd1, //读数据
input [ADDR_WIDTH -1:0] wa, //写地址
input [DATA_WIDTH -1:0] wd, //写数据
input we //写使能
);
reg [DATA_WIDTH -1:0] rf [0:(1<<ADDR_WIDTH)-1]; //寄存器堆
//读操作:写优先,异步读
assign rd0 = (wa == ra0 && we && wa != 0) ? wd : rf[ra0];
assign rd1 = (wa == ra1 && we && wa != 0) ? wd : rf[ra1];
//写操作:同步写
always@ (posedge clk) begin
if (we && wa != 0) rf[wa] <= wd;
end
2 存储器
存储器是用来存储大量数据的单元。在处理器系统中,存储器有多级结构,包括寄存器堆、高速缓存、内存等。寄存器堆能容纳的数据数量较少,我们只需要使用简单的verilog实现即可。但是,对于高速缓存和内存,我们需要使用IP核生成器来生成存储器,以保证其时序特性。
2.1 开发板上的存储器
在我们的实验中,我们主要接触的两种存储器是:
- Distributed RAM(分布式RAM):分布式随机存储器,是Vivado使用查找表和触发器实现的存储器,支持异步读、同步写,但其组合延迟较高,电路面积也较大。
- Block RAM(BRAM):块式随机存储器,是Vivado使用FPGA片内存储单元实现的存储器,支持同步读写,延迟较低,电路面积也较小,但不支持异步读(也就是说,读操作需要一个周期)。
2.2 IP核生成器
Vivado自带了IP核生成器,可以直接生成对应的IP核:
-
进入Vivado,点击左上角的IP Catalog,进入IP核生成器:
-
在IP核生成器中,选择Memory and Storage Elements:
其中,分布式RAM在
RAMs & ROMs
中,BRAM在RAMs & ROMs & BRAM
中。 -
选择对应的存储器(这里以分布式RAM为例)点击进入IP生成界面:
你需要修改存储器名称、深度(即存储器有多少个存储单元)、数据宽度(即每个存储单元有多少位)、读写端口数、读写时序等参数,各类存储器功能如下表:
存储器类型 中文名称 功能解读 ROM 只读存储器 只有读端口,没有写端口 Single Port RAM 单端口存储器 只有一个读写端口 True Dual Port RAM 真正双端口存储器 有两个读写端口,可以同时进行两次读操作和一次写操作 Simple Dual Port RAM 简单双端口存储器 有两个读写端口,可以同时进行一次读操作和一次写操作 你可以根据自己的需求选择不同的存储器类型。本次实验中我们使用Single Port RAM,深度32,数据宽度16,输入输出选项均选择无寄存器。
-
如果你需要给存储器赋予初值的话,那么需要在RST&Initialization中进行设置:
赋予初值有两种方式:
-
Load COE File:通过COE文件赋予初值。COE文件是一种文本文件,其格式如下:
coe ; radix对应数据宽度 memory_initialization_radix=16; memory_initialization_vector= ; 填入你需要初始化的数据 使用空格和逗号分隔,末尾改用分号结束,例如 02804084, 57e3cfff, 57ffe3ff, 00000000, 00000000, 1c00e000;
其中,
memory_initialization_radix
表示存储器的进制,memory_initialization_vector
表示存储器的初值。 -
-
Default Data:直接在IP核生成器的所有项中填入初值。
-
完成设置后,点击OK,即可生成对应的IP核。
BRAM的生成类似,参考配置如下:
注意在Port A Options中取消勾选Primitives Output Register,若勾选则会导致读出数据额外延迟一个周期。
2.3 生成一个自己可控的BRAM
对于分布式RAM,由于Vivado本来就会使用LUT和FF生成,因此其代码和寄存器堆是完全一致的。但是,对于BRAM,我们希望它使用板上的固定资源来生成,以获得更好的时序。如何不使用IP核,生成一个自己可控的BRAM呢?首先我们了解一下Vivado生成BRAM的原理:
Vivado调用板上固定资源的原理
Vivado会将你的代码与板上资源调用的模板代码进行比对,当相似率或功能较为接近时,Vivado会自动调用板上的资源。一旦代码中有些功能很难使用板上资源实现,那么Vivado就会使用LUT和FF来生成对应的电路。
板上固定资源的模板,我们可以通过vivado中Tools->language templates来查看。这里我们给出一个不支持COE初始化的读优先简单双端口的模板:
module xilinx_simple_dual_port_1_clock_ram_read_first #(
parameter RAM_WIDTH = 64, // Specify RAM data width
parameter RAM_DEPTH = 512 // Specify RAM depth (number of entries)
)(
input [$clog2(RAM_DEPTH)-1:0] addra, // Write address bus, width determined from RAM_DEPTH
input [$clog2(RAM_DEPTH)-1:0] addrb, // Read address bus, width determined from RAM_DEPTH
input [RAM_WIDTH-1:0] dina, // RAM input data
input clka, // Clock
input wea, // Write enable
output [RAM_WIDTH-1:0] doutb // RAM output data
);
(*ram_style="block"*)
reg [RAM_WIDTH-1:0] BRAM [RAM_DEPTH-1:0];
reg [$clog2(RAM_DEPTH)-1:0] addr_r;
generate
integer ram_index;
initial
for (ram_index = 0; ram_index < RAM_DEPTH; ram_index = ram_index + 1)
BRAM[ram_index] = {RAM_WIDTH{1'b0}};
endgenerate
always @(posedge clka) begin
addr_r <= addrb;
if (wea) BRAM[addra] <= dina;
end
assign doutb = BRAM[addr_r];
endmodule
always @(posedge clka) begin
addr_r <= addra == addrb ? addra : addrb;
if (wea) BRAM[addra] <= dina;
end
以下提供一个带有说明注释的读优先BRAM代码,以供理解如何使用readmemh/readmemb来初始化bram。
module block_ram #(
parameter DATA_WIDTH = 16, ADDR_WIDTH = 5,
// INIT_FILE0 = "C:/Users/86131/Desktop/Digital/LAB4_2024_RAM_FIFO/example.coe", 错误用法,coe文件不支持readmemh/readmemb,而是在IP核中使用
INIT_FILE = "C:/Users/86131/Desktop/Digital/LAB4_2024_RAM_FIFO/example1.hex", //文件格式可以是txt/hex等 可以是绝对路径(推荐)或者相对路径
INIT_FILE2= "C:/Users/86131/Desktop/Digital/LAB4_2024_RAM_FIFO/example2.bin" //文件格式可以是txt/bin等
// INIT_FILE3= "./example3.bin" //文件格式可以是txt/bin等 相对路径相对于仿真文件/RTL文件(仿真时文件要放在项目文件夹\lab_5.sim\sim_1\behav\xsim下) 某些操作会导致文件被清除,不推荐
)(
input clk, // clock
input [ADDR_WIDTH-1:0] addr, // address
input [DATA_WIDTH-1:0] din, // data input
input we, // write enable
output reg [DATA_WIDTH-1:0] dout // data output
);
reg [DATA_WIDTH-1:0] bram [0: (1 << ADDR_WIDTH) - 1]; //这里后面的索引中,0在前还是后不会影响初始化,初始化默认起始地址都是0
initial $readmemh(INIT_FILE, bram, 0, 15);// 初始化,readmemh读取16进制数据,填入bram数组,起始地址0,结束地址15
// 如果有地址的重合,后面的初始化会覆盖前面的初始化
initial $readmemb(INIT_FILE2, bram, 15);// 初始化,readmemb读取2进制数据,填入bram数组,起始地址16。文件中使用@指定的地址是相对数组0地址的16进制地址偏移 如0x14=20
// initial $readmemb(INIT_FILE3, bram);
always @(posedge clk) begin
dout <= bram[addr]; //读优先
if (we) bram[addr] <= din;
end
endmodule
example1.hex如下,使用空格和回车分隔,A-F大小写均可
example2.bin如下,可以指定地址填入数据,地址按16进制解析的
2.4 存储器的读优先和写优先
对于分布式RAM,由于其结构与寄存器堆是完全一致的,因此读优先和写优先的原理和实现和寄存器堆是一致的。你需要判断你生成的分布式RAM是读优先还是写优先的。
但是,对于BRAM,由于其结构与寄存器堆不同,因此读优先和写优先的原理和实现也不同。
- 读优先:对于读优先的BRAM,其读操作永远会在下一个时钟周期给出存储器中对应地址的数据
- 写优先:对于写优先的BRAM,如果当前周期读写地址相同且写使能有效,那么下个周期会给出当前周期的写入数据,否则会给出存储器中对应地址的数据
写优先举例
对于写优先存储器,如果当前周期的读地址为0x1c000000, 写地址为0x1c000000,写使能为有效,写入数据为0xdeadbeef,存储器中0x1c000000的数据为0x87533226,那么下歌周期的读出数据应当为0xdeadbeef。
2.5 存储器的例化
我们可以在代码中对生成的IP核存储器做出例化。例化的接口名就是存储器生成界面对应的接口名,你可以在生成界面中查看。以True Dual Port RAM为例,其接口名如下:
在调用的时候,我们可以用如下方式连线:
3 先入先出队列 FIFO
本次实验中,我们要实现FIFO,并使用能够使用串口调试单元SDU对其测试。
3.1 使用IP核例化FIFO
- IP核-Memory & Storage Elements-FIFOs-FIFO Generator
- 参数选择16×16,复位可以选择同步复位Synchronous Reset或异步复位Asynchronous Reset,注意IP核例化的FIFO的复位信号srst是高电平有效的。
- wr_en和rd_en都是同步生效的信号。在时钟上升沿若wr_en为1,且队列未满,则将din置入队尾;若rd_en为1,且队列不为空,则将队头元素出队,用它更新dout寄存器。
- 在没有元素出队的时候,dout寄存器会保持不变。
- IP核默认例化的FIFO,如果在empty的情况下读写信号同时有效,则dout不更新,din元素入队。
- IP核默认例化的FIFO,如果在full的情况下读写信号同时有效,则din元素不会入队。
- 仿真调试这个FIFO以理解FIFO的功能
3.2 实现自己的FIFO
-
使用循环队列实现16×16的FIFO,IOU内部维护指针front和rear。循环队列的满/空实现方式可自选浪费一个空间或使用额外计数器。
-
考虑到板上按按钮时间明显长于系统时钟,会导致按一次按键入队/出队多次。为了解决这个问题,你需要对btnl/btnr输入信号去抖动并取上升沿。取上升沿的方法如下:
-
处于SDU查看数据的需要,寄存器堆除了用于FIFO读写的端口以外,额外需要一个组读端口。
-
要求在队列满的状态下,若读写信号同时有效,会将队头元素更新到dout寄存器中,并将din元素加入到队尾,完成后队列仍为满。为了完成这个目标,你的寄存器堆可能需要是读优先的。
3.3 串行调试单元
-
在上次实验中,我们实现了串口的接收和发送的功能。本次实验中,我们将会使用已写好的串行调试单元SDU,调试所实现的FIFO。你可以在群文件中找到两个版本的SDU,本次实验使用简化的SDU_dm版本。
-
在SDU_top文件中例化FIFO和SDU模块。下面给出修改后适用于这次实验的SDU_top文件,由于助教手里没有开发板,没有实测过,可能有错误。收到反馈后助教会更新它。
4 对存储器的数据进行排序
4.1 概述
排序耗费时钟count可以采用数码管显示、led灯显示高位或维护特定寄存器使用sdu查看特定地址时不查看bram而是读出该寄存器的值。考虑到频率的不同和检查时难以证明代码和比特流一致,我们不会基于排序耗费周期数来影响实验得分,因此你不必考虑得过于复杂。但鼓励对优化性能的尝试,如果你在实验报告中解释你为降低总时间(周期*周期数)所做的努力和独特设计,将酌情提高报告分数。
至少要能够使用SDU查看数据,你可以不支持使用SDU来修改数据,而是直接用待排序数据来对BRAM初始化。
如果你想要使用SDU直接发送文件或写入特定地址,注意使用的bram需要读写双端口,并支持使用不同的时钟。我们需要支持排序模块的读写、SDU的读和SDU的写。sdu输出的clk_ld仅当输入LD指令时才会有脉冲信号,它用于sdu向bram写入数据,但无法支持SDU的读。SDU的读仍然应当依赖于系统时钟或者clk_cpu(后者是完整的SDU_cwyl才有的接口)。同时我们还需要排序模块原本的读写端口。你会发现这里端口不够用了,你也许可以通过额外的状态来处理这个问题。
本次实验需要更改sdu例化时信号的连接
sdu_dm sdu_dm_inst(
.clk(clk),
.rstn(rstn),
.rxd(rxd),
.txd(txd),
.addr(sdu_addr),//32位,我们这次使用其后10位(1024)
.dout({sdu_data}),//32位,用于sdu查看bram内容
.din(sdu_din), //用于将数据加载到存储器,你可以不使用或者接入bram的写入数据
.we(sdu_we), //用于将数据加载到存储器,你可以不使用或者接入bram的写使能
.clk_ld(clk_ld) //用于sdu写入数据的时钟,你可以不使用或者接入bram的用于sdu的端口时钟
);
4.2 状态机
用于冒泡排序模块SRT的状态机,你可以参考下图的基本思路,但不必完全按照它来实现。
"优化排序与额外加分
你可以尝试使用其他的排序方法或者优化冒泡排序来实现降序排列,如果你能说明你的排序方式相对一般的冒泡排序更优,将有酌情加分