在FPGA开发里,RAM资源的调用几乎绕不开。不管是做数据缓存、跨时钟域缓冲、FIFO底层存储,还是图像行缓存、网络包暂存,你最终都要面对Vivado里那个Block Memory Generator的配置界面。而每次配到"Memory Type"那一栏,三个选项就摆在那里:Single-port RAM、Simple Dual-port RAM、True Dual-port RAM。很多人第一次选的时候凭感觉点一个能跑通就完事了,直到某天发现读写冲突、数据被覆盖、时序收不敛,才回头来想这三个到底差在哪。我自己在几个项目里分别用过这三种模式,也踩过因为端口模式选错导致数据错乱的坑,这篇文章就把这三种RAM的底层结构、端口行为、资源开销和选型逻辑一次讲透,让你下次配IP的时候不再犹豫。
1. 先搞清楚三种RAM到底在硬件层面长什么样
1.1 单口RAM:一个端口,读写分时复用
单口RAM是最朴素的结构。它只有一组地址线、一组数据线和一个写使能信号。同一时刻,这个端口要么在执行写操作,要么在执行读操作,不能同时干两件事。你可以把它想象成一间只有一个窗口的银行柜台,同一时刻只能服务一个人——要么存钱要么取钱,不能一边存一边取。
在Xilinx的Block Memory Generator里,单口RAM的端口信号大致是这样的:
// 单口RAM端口示意 RAM_Single u_ram ( .clka (clk), // 时钟 .ena (ram_en), // 端口使能 .wea (ram_we), // 写使能,1为写,0为读 .addra (ram_addr), // 地址 .dina (ram_din), // 写数据输入 .douta (ram_dout) // 读数据输出 );注意这里只有一个地址端口addra,读写共用。当wea为高时,dina上的数据被写入addra指向的位置;当wea为低时,douta输出addra指向位置的数据。读写操作在时钟沿上分时进行,硬件上只需要一套地址译码器和一套存储阵列的访问通路。
这种结构的好处是资源占用最少。一个36Kb的BRAM(Block RAM)在单口模式下可以完整配置为32K×1或4K×8等位宽,没有额外的端口逻辑开销。缺点是吞吐率受限——如果你需要在一个时钟周期内同时完成一次读和一次写,单口RAM做不到。
1.2 伪双口RAM:两个端口,但一个只能写一个只能读
伪双口RAM(Simple Dual-port RAM)有两个端口,但分工是固定的:A端口专门用于写,B端口专门用于读。你不能用A端口读,也不能用B端口写。这就像银行开了两个窗口,一个只办存款,一个只办取款,各司其职。
端口结构大概是这样:
// 伪双口RAM端口示意 RAM_SimpleDual u_ram ( .clka (wr_clk), // 写时钟 .ena (wr_en), // 写端口使能 .wea (wr_we), // 写使能 .addra (wr_addr), // 写地址 .dina (wr_data), // 写数据 .clkb (rd_clk), // 读时钟 .enb (rd_en), // 读端口使能 .addrb (rd_addr), // 读地址 .doutb (rd_data) // 读数据 );关键点在于:A端口和B端口可以独立时钟。写时钟和读时钟可以不同,这天然适合跨时钟域的数据缓冲场景。比如你从100MHz的采集逻辑往RAM里写数据,然后在150MHz的处理逻辑里读出来,伪双口RAM直接支持这种操作,不需要额外的握手电路。
但要注意,伪双口RAM的两个端口虽然独立,但它们访问的是同一块存储阵列。如果写地址和读地址在同一时刻指向同一个位置,读出来的数据可能是旧的也可能是新的,这取决于BRAM的读写时序模式(WRITE_FIRST、READ_FIRST还是NO_CHANGE)。这个后面会详细讲。
1.3 真双口RAM:两个端口,每个都能读能写
真双口RAM(True Dual-port RAM)是两个完全对等的端口,每个端口都具备独立的地址线、数据输入、数据输出和写使能。A端口可以读也可以写,B端口同样可以读也可以写。这是最灵活但也最耗资源的结构。
// 真双口RAM端口示意 RAM_TrueDual u_ram ( .clka (clk_a), .ena (en_a), .wea (we_a), .addra (addr_a), .dina (din_a), .douta (dout_a), .clkb (clk_b), .enb (en_b), .web (we_b), .addrb (addr_b), .dinb (din_b), .doutb (dout_b) );真双口RAM的典型应用场景是两个处理器共享一块内存,或者一个系统里两个主设备需要同时读写同一块存储区。比如在一个SoC原型里,CPU和DMA控制器都需要访问共享缓冲区,真双口RAM就能让它们各自独立操作,不需要仲裁逻辑。
但真双口RAM有一个必须注意的问题:当两个端口同时写同一个地址时,写入的数据是不确定的。Xilinx的文档里明确说了,这种情况下存储的内容是未知的。同样,一个端口写、另一个端口读同一地址时,读出的数据取决于时序模式。这些冲突场景在实际使用中必须通过地址分区或仲裁逻辑来避免。
2. 端口模式背后的BRAM资源消耗差异
2.1 三种模式对BRAM原语的占用对比
Xilinx 7系列FPGA里,每个BRAM是36Kb,可以配置为36Kb或18Kb模式。不同端口模式对BRAM的占用方式不同,这直接影响你能用多少块RAM。
| 模式 | BRAM占用 | 地址端口数 | 数据端口数 | 典型位宽配置 |
|---|---|---|---|---|
| 单口RAM | 1个36Kb或2个18Kb | 1 | 1读1写(复用) | 32K×1, 4K×8 |
| 伪双口RAM | 1个36Kb或2个18Kb | 2 | 1写1读 | 32K×1, 4K×8 |
| 真双口RAM | 1个36Kb或2个18Kb | 2 | 2读2写 | 16K×2, 2K×16 |
从表里能看出来,三种模式在BRAM原语数量上其实是一样的——都是占一个36Kb的BRAM。区别在于内部端口的复用方式。单口RAM把读写端口合并了,伪双口RAM把读写端口分开但固定了方向,真双口RAM则给每个端口都配了完整的读写通路。
但这里有个容易忽略的点:真双口RAM在配置较宽位宽时,可能会消耗更多的BRAM。比如你要做一个32位宽、深度1024的真双口RAM,总容量是32Kb,理论上一个36Kb的BRAM够了。但因为真双口模式下每个BRAM的端口资源有限,Vivado可能会用两个BRAM来拼位宽。而同样容量的伪双口RAM可能只需要一个BRAM。这个差异在资源紧张的项目里很关键。
2.2 级联与位宽扩展时的资源变化
当你需要的RAM深度超过单个BRAM的容量时,Vivado会自动级联多个BRAM。这时候三种模式的资源消耗差异会更明显。
举个例子:做一个深度8192、位宽8位的RAM,总容量64Kb,需要两个36Kb的BRAM级联。
- 单口模式:两个BRAM级联,地址高位做片选,读写共用一套地址译码。资源占用2个BRAM。
- 伪双口模式:同样两个BRAM级联,但写端口和读端口各自需要一套地址译码和片选逻辑。资源占用2个BRAM,但布线资源略多。
- 真双口模式:两个BRAM级联,每个BRAM的两个端口都需要独立的地址译码和片选。如果位宽较宽,可能还需要额外的BRAM来满足端口位宽要求。资源占用可能变成3到4个BRAM。
所以在深度较大的应用中,真双口RAM的资源开销可能是单口RAM的两倍。这不是因为存储容量不够,而是因为端口逻辑的复制。
2.3 一个实际项目中的资源对比数据
我之前做过一个视频行缓存的项目,需要缓存1920个像素、每个像素16位,总容量约30Kb。分别用三种模式实现,在XC7A200T上综合后的资源占用如下:
| 模式 | BRAM36占用 | LUT占用 | 最大时钟频率 |
|---|---|---|---|
| 单口RAM | 1 | 12 | 285MHz |
| 伪双口RAM | 1 | 18 | 270MHz |
| 真双口RAM | 2 | 31 | 245MHz |
这个数据很直观:单口RAM最省资源也最快,伪双口RAM略差一点,真双口RAM因为端口逻辑复杂,资源翻倍且频率下降。当然这个数据会随具体配置变化,但趋势是一致的。
注意:这里的频率数据是在速度等级-2的器件上、默认综合策略下得到的。如果你用了性能优化策略或者手动加了时序约束,结果会不同。但相对关系不会变。
3. 读写冲突与时序模式:选型时最容易翻车的地方
3.1 三种写模式的行为差异
Xilinx BRAM有一个很容易被忽视的配置项:写模式(Write Mode)。它决定了当你在同一个时钟沿对同一地址进行写操作时,读端口输出什么。这个配置在单口RAM和伪双口RAM里都存在,真双口RAM的每个端口也各自有写模式。
三种写模式的行为如下:
- WRITE_FIRST(写优先):写操作发生时,读端口输出的是刚写入的新数据。相当于"写穿透"。
- READ_FIRST(读优先):写操作发生时,读端口输出的是该地址原来的旧数据。写入的新数据要下一个周期才能读到。
- NO_CHANGE(无变化):写操作发生时,读端口输出保持不变,不反映任何读写结果。
用生活化的例子解释:假设你有一个笔记本,WRITE_FIRST就像你一边写一边念,旁边的人立刻听到你写的内容;READ_FIRST就像你先看一眼原来写的是什么,然后再写新的;NO_CHANGE就像你写字的时候旁边的人什么都听不到,直到你写完翻到下一页才看到。
在Verilog仿真里,这三种模式的行为差异会直接导致功能正确性问题。我见过一个项目,写模式配成了NO_CHANGE,结果读端口在写操作期间一直保持旧值,导致下游状态机误判,调试了两天才找到原因。
3.2 伪双口RAM的读写地址冲突场景
伪双口RAM最典型的冲突场景是:写端口正在写地址A,读端口同时读地址A。这时候读出来的是什么?
如果写模式是WRITE_FIRST,读出来的是新写入的数据。如果写模式是READ_FIRST,读出来的是旧数据。如果写模式是NO_CHANGE,读出来的是上一次读操作的结果,跟当前地址无关。
这个行为在跨时钟域场景下更复杂。因为写时钟和读时钟不同,你无法保证读写操作在同一个时钟沿发生。可能写操作先到,也可能读操作先到。这时候就需要额外的逻辑来保证数据一致性。
常见的做法是:用格雷码地址做跨时钟域同步,或者用FIFO来隔离读写时钟域。伪双口RAM本身不解决跨时钟域的数据一致性问题,它只是提供了独立的读写时钟端口。
3.3 真双口RAM的端口冲突与仲裁
真双口RAM的两个端口都可以读写,冲突场景更多:
- 两个端口同时写同一地址:写入数据不确定,必须避免。
- 端口A写、端口B读同一地址:读出数据取决于写模式。
- 两个端口同时读同一地址:没问题,两个端口各自输出该地址的数据。
- 端口A写地址X、端口B写地址Y(X≠Y):没问题,各自写入各自地址。
在实际设计中,第1种情况必须通过地址分区来避免。比如把RAM分成两个区域,A端口只写区域1,B端口只写区域2。或者加一个仲裁器,保证同一时刻只有一个端口在写。
第2种情况需要根据系统需求选择写模式。如果读端口需要立刻看到新数据,用WRITE_FIRST;如果读端口需要旧数据,用READ_FIRST。
提示:真双口RAM的两个端口如果时钟不同,跨时钟域的读写冲突会更难处理。建议在真双口RAM的两个端口之间加握手信号或使用独立的FIFO做缓冲。
4. 从应用场景反推该选哪种RAM
4.1 单口RAM的适用场景
单口RAM适合以下场景:
- 单时钟域的数据缓存:比如一个状态机需要暂存中间结果,读写都在同一个时钟域,且读写不会同时发生。
- ROM替代:如果你只需要初始化后读取,不需要运行时写入,单口RAM配置成ROM模式最省资源。
- 深度大、位宽小的存储:比如做一个查找表,深度很大但每次只读一个值,单口RAM的地址复用特性正好合适。
- 资源极度紧张的项目:当BRAM不够用时,单口RAM是最省资源的选择。
我有个项目做的是简单的数据采集缓存,采集频率不高,读写不会冲突,直接用单口RAM,一个BRAM搞定,逻辑简单,时序也好收敛。
4.2 伪双口RAM的适用场景
伪双口RAM是实际项目里用得最多的模式,适合:
- 跨时钟域数据缓冲:写时钟和读时钟不同,伪双口RAM天然支持。
- FIFO的底层存储:Xilinx的FIFO IP核底层就是用伪双口RAM实现的。如果你自己手写FIFO,伪双口RAM是最佳选择。
- 流水线中的数据暂存:写端口在前级时钟域写入,读端口在后级时钟域读出,中间用伪双口RAM做隔离。
- 图像处理中的行缓存:写端口写入当前行像素,读端口读出上一行像素做卷积运算。
伪双口RAM的核心优势是读写端口独立且方向固定,这简化了控制逻辑。你不需要仲裁,不需要担心两个端口同时写的问题,只需要处理好读写地址的同步。
4.3 真双口RAM的适用场景
真双口RAM适合以下场景:
- 双主设备共享存储:两个处理器或两个DMA通道需要访问同一块内存。
- 多端口数据交换:比如一个交换矩阵,多个输入端口需要写入,多个输出端口需要读取。
- 乒乓操作中的双缓冲:两个端口分别读写两个缓冲区,实现无缝切换。
- 需要同时读写不同地址的场景:比如一个端口在写新数据,另一个端口在读旧数据,两者地址不同,互不干扰。
但真双口RAM的资源开销和时序复杂度都更高,如果不是真的需要两个端口都能读写,优先考虑伪双口RAM。
4.4 选型决策表
| 需求特征 | 推荐模式 | 理由 |
|---|---|---|
| 读写同一时钟域,不会同时发生 | 单口RAM | 资源最省,逻辑最简单 |
| 读写不同时钟域,写固定读固定 | 伪双口RAM | 天然支持跨时钟域,控制简单 |
| 两个主设备共享存储 | 真双口RAM | 两个端口对等,各自独立访问 |
| 需要同时读写不同地址 | 真双口RAM | 两个端口可同时操作 |
| 资源紧张,BRAM不够用 | 单口RAM | 端口复用,BRAM占用最少 |
| 需要最高时钟频率 | 单口RAM | 端口逻辑最少,时序最好 |
| 手写FIFO | 伪双口RAM | 读写端口分离,方向固定 |
| 图像行缓存 | 伪双口RAM | 写当前行,读上一行 |
5. Vivado中配置RAM IP核的实操细节
5.1 Block Memory Generator的配置流程
在Vivado里配置RAM IP核,路径是IP Catalog → Memory & Storage Elements → Block Memory Generator。双击打开配置界面,几个关键页面需要仔细设置。
第一页是Basic,选择Memory Type。这里有三个选项:Single-port RAM、Simple Dual-port RAM、True Dual-port RAM。选完之后Interface Type一般保持Native,除非你要用AXI接口。
第二页是Port A Options,设置端口A的位宽、深度、使能信号、写模式等。如果选了伪双口或真双口,还会有Port B Options页面。
第三页是Other Options,这里可以设置初始化文件、ECC、流水线寄存器等。流水线寄存器建议打开,可以改善时序,但会增加一个周期的读延迟。
5.2 位宽和深度的计算
BRAM的配置需要满足Xilinx的位宽-深度组合规则。一个36Kb的BRAM支持以下位宽:1、2、4、8、9、16、18、32、36、64、72。深度则根据位宽自动计算。
比如你要做一个位宽12位、深度2048的RAM,总容量24576b,不到36Kb。但12位不是BRAM支持的标准位宽,Vivado会自动扩展到16位,实际占用16×2048=32768b,接近36Kb。如果你把位宽改成8位,深度2048,总容量16384b,Vivado可能用两个18Kb的BRAM或者一个36Kb的BRAM配置成18Kb模式。
这里有个经验:尽量让位宽和深度匹配BRAM的原生配置,避免浪费。比如需要12位宽的时候,考虑是否能用16位宽代替,或者拆成8位+4位两个RAM。
5.3 读写延迟与流水线配置
BRAM的读操作有一个固有的时钟延迟。在默认配置下,从地址输入到数据输出需要1个时钟周期。如果你在Other Options里勾选了"Pipeline Stages",可以把这个延迟增加到2个周期,但能提高最大时钟频率。
这个延迟在写控制逻辑时必须考虑。比如你发出读地址后,要等1个或2个周期才能拿到数据。如果你的状态机没有考虑这个延迟,读出来的数据会对不上。
// 读延迟处理示例 reg [1:0] rd_cnt; always @(posedge clk) begin if (rd_start) begin rd_addr <= target_addr; rd_cnt <= 2'd2; // 假设流水线延迟2周期 end else if (rd_cnt > 0) begin rd_cnt <= rd_cnt - 1; end end // rd_cnt减到0时,dout有效 wire rd_valid = (rd_cnt == 2'd1);5.4 初始化文件的格式与加载
BRAM支持通过COE文件初始化。COE文件的格式是:
memory_initialization_radix=16; memory_initialization_vector= 00, 01, 02, 03, 04, 05, 06, 07, 08, 09, 0A, 0B, 0C, 0D, 0E, 0F;radix可以是2、10或16。vector里的数据按地址从0开始依次排列。注意数据个数必须和RAM深度一致,否则Vivado会报错。
在仿真时,COE文件会在配置阶段加载到BRAM里。在实际硬件上,初始化数据会在FPGA配置时写入BRAM。如果你用的是SPI Flash配置模式,COE数据会包含在bitstream里。
注意:COE文件里的数据位宽必须和RAM位宽匹配。如果RAM是8位宽,COE里每个数据不能超过0xFF。如果RAM是16位宽,COE里每个数据用4个十六进制字符表示。
6. 常见踩坑与调试经验
6.1 写模式配错导致的数据错乱
这是我踩过的最典型的坑。在一个伪双口RAM的项目里,写模式默认是WRITE_FIRST,但我需要的是READ_FIRST行为。结果读端口在写操作期间读出了新数据,而下游逻辑期望的是旧数据,导致状态机跳转错误。
排查过程:先用ILA抓了读写地址和数据,发现读出的数据比预期"新"了一个周期。然后查Xilinx文档,确认是写模式的问题。改成READ_FIRST后问题解决。
这个坑的教训是:不要用默认配置,要根据系统需求明确选择写模式。WRITE_FIRST适合需要立刻看到新数据的场景,READ_FIRST适合需要旧数据的场景,NO_CHANGE适合读端口在写期间不关心输出的场景。
6.2 跨时钟域时的地址同步问题
伪双口RAM的读写时钟不同,如果读地址来自写时钟域,或者写地址来自读时钟域,就需要做跨时钟域同步。我见过一个项目,读地址直接用写时钟域的计数器,结果在时钟沿附近出现亚稳态,读出的数据偶尔出错。
正确的做法是用格雷码做地址同步,或者用双触发器同步器。格雷码的好处是每次只变一位,同步时不会出现多位同时变化导致的错误。
// 格雷码地址同步示例 reg [ADDR_WIDTH:0] wr_addr_gray; reg [ADDR_WIDTH:0] rd_addr_gray_sync1, rd_addr_gray_sync2; // 写地址转格雷码 always @(posedge wr_clk) begin wr_addr_gray <= (wr_addr >> 1) ^ wr_addr; end // 读时钟域同步 always @(posedge rd_clk) begin rd_addr_gray_sync1 <= wr_addr_gray; rd_addr_gray_sync2 <= rd_addr_gray_sync1; end6.3 真双口RAM的端口冲突调试
真双口RAM的端口冲突很难在仿真里复现,因为仿真时两个端口的操作顺序是确定的,而实际硬件上两个端口的时钟相位关系可能变化。我遇到过一次,两个端口同时写同一地址,仿真时A端口先写,硬件上B端口先写,导致数据不一致。
解决办法是在设计阶段就避免冲突。具体做法:把RAM地址空间分成两个区域,A端口只访问区域1,B端口只访问区域2。如果确实需要共享区域,加一个仲裁器,用请求-授权机制保证同一时刻只有一个端口在写。
6.4 读延迟导致的时序问题
BRAM的读延迟是固定的,但如果你在读数据路径上加了组合逻辑,可能会让时序变差。比如读出的数据直接参与地址计算,形成组合环路,时序很难收敛。
建议在读数据路径上加一级寄存器,把BRAM输出打一拍再使用。这样虽然增加了一个周期的延迟,但时序会好很多。如果系统对延迟敏感,可以用BRAM的输出寄存器(在IP配置里勾选"Output Register"),这个寄存器在BRAM内部,不占用额外的逻辑资源。
6.5 资源估算的保守策略
在项目初期做资源估算时,建议对真双口RAM按两倍BRAM数量来估算。比如你需要一个深度4096、位宽16的真双口RAM,总容量64Kb,理论上两个36Kb的BRAM够了。但实际综合后可能用3个甚至4个。按两倍估算可以避免后期BRAM不够用导致的重新设计。
伪双口RAM按1.2倍估算,单口RAM按1倍估算。这个系数不是绝对的,但能给你一个安全边界。
7. 从BRAM到URAM:更高速场景下的选择
7.1 UltraScale+中的URAM资源
如果你用的是UltraScale+系列的FPGA,除了BRAM还有URAM(Ultra RAM)可用。URAM的容量更大,每个288Kb,但端口模式更受限。URAM只支持伪双口模式,不支持真双口。而且URAM的位宽固定为72位,深度固定为4096。
URAM适合大容量、低频率的存储场景,比如深度学习里的权重缓存。如果你需要真双口RAM,URAM用不了,只能用BRAM。
7.2 分布式RAM与BRAM的取舍
除了BRAM,Xilinx FPGA里还有分布式RAM(Distributed RAM),用LUT实现。分布式RAM的容量小,但延迟低、灵活度高。适合小容量、浅深度的存储,比如几个寄存器的缓存。
选型原则:容量小于64位用分布式RAM,容量大于64位用BRAM。如果对延迟极度敏感,分布式RAM可以做到组合输出,但BRAM至少有一个时钟周期的延迟。
7.3 什么时候该用FIFO而不是RAM
如果你的需求是数据缓冲,且读写速率不匹配,优先考虑FIFO而不是自己用RAM搭。Xilinx的FIFO IP核底层就是伪双口RAM,但它帮你处理了读写指针、空满标志、跨时钟域同步等细节。自己用RAM搭FIFO容易在空满判断上出错,特别是跨时钟域的场景。
只有当你需要随机访问,或者需要同时读写多个地址时,才用RAM而不是FIFO。
8. 一个完整的选型实例:图像行缓存
8.1 需求分析
假设你要做一个3×3卷积的图像处理模块,输入是1920×1080的灰度图,像素时钟100MHz。3×3卷积需要同时访问三行像素,所以需要缓存两行。每行1920个像素,每个像素8位,一行需要1920×8=15360b,两行需要30720b,不到一个36Kb的BRAM。
8.2 三种模式的实现对比
单口RAM方案:用两个单口RAM,每个存一行。写操作在当前行写入,读操作在上一行读出。但单口RAM同一时刻只能读或写,而卷积需要同时读三个像素(左、中、右),单口RAM做不到。所以单口RAM不适合这个场景。
伪双口RAM方案:用两个伪双口RAM,每个存一行。写端口在当前行写入新像素,读端口在上一行读出三个像素。但读端口一次只能读一个地址,要读三个像素需要三个时钟周期。如果像素时钟是100MHz,三个周期就是30ns,可能跟不上输入速率。
真双口RAM方案:用两个真双口RAM,每个存一行。A端口写当前行,B端口读上一行。但同样面临一次只能读一个地址的问题。
实际上,这个场景更常用的做法是用两个伪双口RAM做乒乓,或者用三个行缓存加移位寄存器。真双口RAM在这里的优势是可以同时读写不同地址,但读三个像素还是需要多个周期。
8.3 最终选型与理由
我最终选了伪双口RAM,每个RAM存一行,写端口写当前行,读端口读上一行。为了在一个周期内读出三个像素,我在读端口后面加了移位寄存器,把连续读出的三个像素对齐。这样虽然读端口需要三个周期,但通过流水线设计,整体吞吐率还是能跟上100MHz的像素时钟。
这个例子的关键是:真双口RAM虽然灵活,但不一定是最优解。伪双口RAM配合适当的外部逻辑,往往能达到同样的效果,而且资源更省。
8.4 实际综合结果
在XC7A200T上综合后,伪双口RAM方案用了2个BRAM36,LUT占用约200个,最大时钟频率280MHz。真双口RAM方案用了4个BRAM36,LUT占用约350个,最大时钟频率250MHz。伪双口方案在资源和频率上都更优。
这个结果再次验证了选型原则:能用伪双口就不用真双口,能用单口就不用伪双口。只有在真正需要两个端口同时读写不同地址时,才上真双口。
9. 写在最后的一些个人体会
三种RAM模式的选择,本质上是在资源、灵活性和时序之间做权衡。单口RAM最省资源但灵活性最差,真双口RAM最灵活但资源开销最大,伪双口RAM是中间的平衡点。
我的经验是:先问自己三个问题。第一,读写会不会同时发生?如果不会,单口RAM就够了。第二,读写是不是固定方向?如果是,伪双口RAM最合适。第三,是不是真的需要两个端口都能读写?如果不是,别用真双口。
还有一个容易被忽视的点:写模式的选择比端口模式的选择更容易出错。WRITE_FIRST、READ_FIRST、NO_CHANGE这三种模式的行为差异很大,配错了仿真可能过但硬件会出问题。建议在项目初期就用仿真验证写模式的行为,别等到上板才发现。
最后,BRAM的读延迟一定要在控制逻辑里考虑。我见过太多项目因为忘了读延迟导致数据错位。一个简单的做法是在读地址发出后打几拍,等数据有效再采样。这个几拍就是BRAM的读延迟,可以在IP配置里查到。