上一篇文章介绍了移位寄存器的实现和两种常用的移位寄存器,在实现串口通信的时候会很有用,对后面的高速接口设计可能又会有一定启发。在数字设计中,不仅仅有寄存器可以保存电路的状态信号,内存(Memory,或叫作存储器,本文都用内存称呼)也是可以保存状态信息的,在处理器设计中内存还用于程序代码和数据的存放,因此十足重要。这一篇文章我们就一起来学习Chisel中的内存实现尤其是同步内存的实现和初始化。
SyncReadMem
内存可以用一组寄存器来构造,比如Chisel中一个Vec
的Reg
。然而在硬件上用寄存器实现内存代价太大了,所以大一点的内存结构都构建为SRAM(Static Random Access Memory,静态随机访问存储器)。对于ASIC是使用内存编译器来构建内存的。而FPGA上面包含片上存储器存单元(On-chip Memory Block),也叫做block RAM,即块随机存储器。这些片上内存单元可以组合成大的内存。FPGA上的内存一般有一个读端口和一个写端口,或者两个可以在运行时切换读写的端口。
FPGA和ASIC通常都支持同步内存,同步内存在它们的输入端有寄存器,用于存放读写端口、写数据、写使能信号等。这意味着在设置了地址后的一个时钟周期后才能读取到数据。下图就是个同步内存的示意图:
这是个双端口内存,有一个读端口,一个写端口。读端口有一个输入rdAddr
,即读地址,和一个输出rdData
,即读取到的数据。写端口有三个输入,写地址(wrAddr
)、要写入的数据(wrData
)和写使能信号(wrEna
)。需要特别注意的是,对于所有的输入,内存中都有个寄存器用于实现同步行为。
前面说到我们不想用Chisel中的寄存器来实现内存,所以Chisel中为了支持片上内存,提供了一个专门的内存构造器SyncReadMem
。下面的代码就实现了一个1KiB的内存Memory
,且输入输出为字节宽度且有写使能信号:
class Memory() extends Module {
val io = IO(new Bundle {
val rdAddr = Input(UInt(10.W))
val rdData = Output(UInt(8.W))
val wrEnable = Input(Bool())
val wrAddr = Input(UInt(10.W))
val wrData = Input(UInt(8.W))
})
val mem = SyncReadMem(1024, UInt(8.W))
io.rdData := mem.read(io.rdAddr)
when(io.wrEnable) {
mem.write(io.wrAddr, io.wrData)
}
}
大概可以看得出来,构造器SyncReadMem
生成的内存就是带那几个寄存器的同步内存,所以我们直接给模块定义相关的输入输出端口就行了。SyncReadMem
的两个参数分别是内存的大小和内存单元的数据类型,例子中就是定义了1024个字节的内存,即1KiB的内存。另外,mem.read(addr)
可以读取内存中地址为addr
的数据,同理mem.write(addr, data)
可以在内存中地址为addr
处写入数据data
。由于对1024个内存单元进行索引需要10位的地址,左移读写地址都是10位宽度。
上面的代码看起来没啥问题,但是存在一个有趣的点,那就是如果在同一个时钟周期对内存的同一个地址进行读写的话,我们读到的值是旧的值还是新的将要写入的值呢?这个内存的写时读(Read-During-Write)行为值得关注,读到的值具体有三种可能性:
FPGA上具体是那种可能性取决于FPGA的类型,有时候是可以指定的。不过在Chisel的文档中明确了,这个读数据是未定义的。
因此,如果我们想要读取到新写入的值的话,我们可以构建一个转发电路,这个电路可以检测读写的地址是否一致,如果一致的话,就把写入的数据转发到读数据端口。下图就是带读写转发的同步内存的示意图:
可以看到示意图中,读写地址会进行比较,然后结果和写使能信号相与,用于选择读出的数据到底是写数据的值还是内存中读到的值。同样,因为是同步内存,所以选择信号也需要写入一个寄存器里面,读数据会在下一个时钟周期输出。
下面是带读写转发的同步内存的Chisel实现:
class ForwardingMemory() extends Module {
val io = IO(new Bundle {
val rdAddr = Input(UInt(10.W))
val rdData = Output(UInt(8.W))
val wrEnable = Input(Bool())
val wrAddr = Input(UInt(10.W))
val wrData = Input(UInt(8.W))
})
val mem = SyncReadMem(1024, UInt(8.W))
val wrDataReg = RegNext(io.wrData)
val doForwardReg = RegNext(io.wrAddr === io.rdAddr && io.wrEnabale)
val memData = mem.read(io.rdAddr)
when(io.wrEnable) {
mem.write(io.wrAddr, io.wrData)
}
io.rdData := Mux(doForwardReg, wrDataReg, memData)
}
代码依然难度不大,我们需要把写入的数据放到一个寄存器wrDataReg
里面,这样下个时钟周期才可用,一方面是适配同步内存,另一方面是为下一周期的读取提供值。同时代码还比较了两个输入地址,即读地址rdAddr
和写地址wrAddr
,检查了写使能信号wrEnable
,用于判断是否满足转发的条件。这个转发条件也是应该延迟一个时钟周期,因此需要整个寄存器doForwardReg
来存放转发条件是否成立。最后用一个Mux来选择读取到的数据是转发数据还是内存读到的数据。
Mem
Chisel中还提供了另一个构造器Mem
,这个构造器构造的内存是同步写、异步读的。但这种类型在FPGA上是不能直接使用的,综合工具会把它用触发器构建出来,而不是用block RAM。因此,我们还是应该使用SyncReadMem
。如果确实需要实现异步读的行为,而且你用的FPGA里面有对应的资源(比如类似Xilinx FPGA里面的LUTRAM的结构),那你可以手动实现并封装为一个BlackBox
。一般来说供应商会给出代码模板,可以直接拿来用。
FPGA上的内存可以用二进制或十六进制初始化文件进行初始化,不过注意这些文件是简单的ASCII文本文件而不是二进制文件,文件的行数应该和相应的内存的条目数是一样的。文件中的每一个字符代表一位或四位的值,其中二进制文件用.bin
作为文件后缀,每个字符表示一位二进制,十六进制文件用.hex
作为文件后缀,每个字符表示四位二进制数。
在Chisel中可以使用loadMemoryFromFile
函数对内存进行初始化,用法如下:
// 需要使用这两个包
import chisel3.util.experimental.loadMemoryFromFile
import firrtl.annotations.MemoryLoadFileType
// 加载十六进制文本文件到内存
loadMemoryFromFile(mem, "mem.hex", MemoryLoadFileType.Hex)
// 加载二进制文本文件到内存
loadMemoryFromFile(mem, "mem.bin", MemoryLoadFileType.Binary)
这个loadMemoryFromFile
会在数据文本文件所在文件夹下生成一个单独的Verilog文件,文件内容类似下面的代码:
module BindsTo_0_Memory(
input clock,
input reset,
input [9:0] io_rdAddr,
output [7:0] io_rdData,
input io_wrEnable,
input [9:0] io_wrAddr,
input [7:0] io_wrData
);
initial begin
$readmemh("mem.hex", Memory.mem);
end
endmodule
bind Memory BindsTo_0_Memory BindsTo_0_Memory_Inst(.*);
这个文件会在ChiselTest
中工作,初始化也是基于对Verilog中的readmemb
或readmemh
的调用来实现的。
这一篇文章我们学习了Chisel时序电路中最后的内容,包括了同步内存的实现和使用,还谈及了内存初始化这个实验性的特性。当然了,关于内存的内容并不只有这些,还有一些更加高阶的内容,会在本系列的最后讲到,内容基于Chisel官方文档的Explainations,会涵盖本系列教程基础部分未涉及的高阶内容。下一个部分,我们会学习Chisel中的一些中阶内容,分别面向信号处理、有限状态机和通信状态机,期间会涉及到Chisel的一些中阶特性,在数字设计中很有用。