ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

FPGA存储设计实战:手写RAM的坑与Block Memory Generator配置指南

FPGA存储设计实战:手写RAM的坑与Block Memory Generator配置指南 以我个人的FPGA项目经历开头吧。去年我做一块网络报文处理板卡中间需要一段8K深度的数据缓存最开始觉得这不就是双端口RAM嘛直接手写一段Verilog信号控制就完事了。结果综合之后时序报告里出现好几条WNS为负的路径布线上来之后资源占用也比预估高很多。后来在同事提醒下换成Xilinx自带的Block Memory Generator v8.4配置界面点几下仿真、上板一次通过后续维护也省心很多。这篇文章就把我从手写RAM转投BMG v8.4的全过程记录下来包括为什么不要自己造轮子、配置界面每个选项到底该怎么选、接口时序到底是怎么走的以及我实际踩过的几个坑和完整的排查思路。无论你是刚接触FPGA还是准备在Vivado工程里规范地管理存储资源这篇内容应该都能直接参考。1. 手写RAM踩坑之后为什么生产级项目要改用BMG很多初学者包括当年的我都觉得RAM太简单了一个时钟、一个写使能、一组地址、一组写数据、一组读数据十几行代码就写完。但真正放到工程里问题并没有这么简单。1.1 手写RAM的三个隐患手工写RAM的代码逻辑本身没问题但综合工具并不会自动把它映射到最优的存储原语上。你喜欢怎么写是一回事布局布线工具认不认是另一回事。多数情况下综合器能把你的代码推断成块RAM可一旦代码风格稍微复杂一点比如带了异步复位、读写使能逻辑不规整、地址位宽和BRAM端口宽度对不齐它就会退化成LUT阵列或者消耗超出预期的BRAM资源。这在资源紧张的板卡上是很难接受的。第二个隐患是时序不可控。BRAM在Xilinx 7系列里对应的是RAMB36E1、RAMB18E1这类专用原语它们的输入端和输出端都有寄存器级锁存读写路径经过专用布线资源时延非常固定。但手写RAM强依赖综合器的推断能力综合器选用的原语、插入的寄存器级数和你设想的往往不一样。结果就是同样一段逻辑别人实现能跑200MHz你实现只能跑到150MHz时序报告里一堆关键路径最后只能一遍遍改代码重来。第三个隐患是仿真模型不够准确。手写RAM的行为级仿真只能验证功能到了时序仿真阶段BRAM的配置延迟、输出寄存器的建立保持时间、字节使能、复位优先级这些细节行为模型基本没法覆盖。一旦上板出现偶发数据错误你根本不知道是逻辑问题还是RAM时序问题。1.2 BMG v8.4帮你做了什么Block Memory Generator简称BMG是Xilinx官方提供的存储类IP核。它本质上就是一个图形化的配置工具底层根据你选的参数自动例化并连接对应的BRAM原语或分布式RAMLUT RAM同时生成仿真模型、综合约束文件和例化模板。v8.4这个版本我在Vivado 2020.2上用得比较多覆盖了单端口RAM、简单双端口RAM、真双端口RAM、单端口ROM、双端口ROM五大类存储结构还支持ECC校验、字节写使能、输出寄存器、独立时钟等高级选项。用BMG最大的收益是稳定。你选的每一个选项IP核都会生成对应的原语级网表综合工具不会再做过多的推断干预时序是可控可预测的。而且官方IP核的仿真模型非常严谨功能仿真、时序仿真行为一致问题暴露得也早。我之前那个缓存模块换成BMG之后时钟直接从150MHz拉到了200MHz关键是再也不用花时间抠那几行RAM代码的时序了。2. 动手配置前先理解存储结构五种模式到底怎么选打开BMG配置界面后第一页就是让你选Memory Type五个选项摆在那里很多人凭名字猜了个大概就选了后面出问题才后悔。这里我把每种类型的适用场景拆开讲清楚。2.1 五种存储类型对应什么场景我用一个表格把这五种类型的端口特性和典型应用整理了一下配置之前先对着这个表想清楚需求存储类型端口特性时钟要求典型使用场景Single Port RAM单端口读写共用一组地址/数据线可单时钟也可端口内读写独立时钟单核CPU的数据存储器、寄存器堆扩展Simple Dual Port RAM一个写端口、一个读端口各自独立支持两个独立时钟域跨时钟域数据缓存、异步数据搬移True Dual Port RAM两个端口均可读写支持两个独立时钟域双核交互、端口A写端口B读的复杂存储应用Single Port ROM单端口只读启动时加载初始化文件单时钟查找表、系数存储、启动引导代码Dual Port ROM双端口只读各自独立地址可独立时钟需要同时查多张表的算法比如FFT旋转因子实际项目里最常见的是Simple Dual Port RAM因为大部分应用都是“一边写入、一边读出”读写数据宽度还不一样。比如ADC采样数据写入DSP算法模块再读出来这时读写位宽独立配置就很重要。BMG允许Write Width和Read Width不同比如写端32位、读端8位内部会自动做位宽转换省去你自己做并串转换的逻辑。2.2 块RAM和分布式RAM怎么取舍同一种存储类型下你还会面临一个选择用Block RAMBRAM还是Distributed RAM分布式RAM。这个选项在很多版本里会自动推荐但理解它的逻辑能帮你避免资源浪费。Block RAM使用的是芯片里的专用存储阵列容量大、速度快但每个BRAM都是固定大小7系列是36Kb你用不满也会占满一个物理块。Distributed RAM则是用LUT搭出来的存储可以按bit精确裁剪深度浅的时候比如32深、64深比BRAM划算得多但不适合大容量存储而且会占用逻辑资源。我的判断标准很简单存储深度大于128、宽度大于16位优先用块RAM深度不太深、位宽要求灵活、逻辑资源有富余的用分布式RAM。如果拿不准可以先让工具自动选再根据综合报告里的资源占用微调。要注意的是分布式RAM在异步读取场景下的时序比BRAM更难收敛最好只在同步逻辑里使用。2.3 读写模式的区别不能只看名字配置端口时你会看到Operating Mode有三个选项Read First、Write First、No Change。这是BRAM原语里一个非常关键的硬件行为影响到同一个端口在写操作发生时输出数据线上到底显示什么。Read First表示写操作发生时输出端先给出当前地址对应的旧数据Write First表示写操作发生时输出端直接给出正在写入的新数据No Change表示写操作发生时输出端保持上一次读出的值不变。这个行为不是软件逻辑而是硬件的真实体现。举个例子你想实现一个“先读后写”的乒乓操作如果配置成Write First你在写一个地址的同时把读使能拉高读到的是新写入的数据那“先读旧值再覆盖”的需求就实现不了了。我在做寄存器配置模块时需要软件通过总线写状态寄存器同时硬件还要读回旧状态做判断这种情况就必须选Read First。而如果只是纯粹的缓冲写入不希望写操作影响输出就选No Change能有效降低输出端的毛刺概率。3. 配置界面逐项拆解以256x32单端口RAM为例讲完理论下面拿一个实际配置案例走一遍。我要在工程里例化一个256深度、32位宽的单端口RAM用作帧头解析的缓存区。打开Vivado的IP Catalog搜索Block Memory Generator双击打开v8.4配置界面。3.1 Basic选项卡先定大框架Component Name建议起一个明确的模块名比如frame_buffer_ram这个名称会体现在生成的网表和例化模板里后面要留意IP版本升级时这个名称最好保持不变否则替换IP时关联的约束文件也要一起改。Memory Type这里选Single Port RAM。Common Clock还是Independent Clock单端口RAM在v8.4里同样可以选两种时钟结构我的缓存模块只有一种全局时钟所以选Common Clock。如果你的系统里写操作来自A时钟域读操作来自B时钟域那就需要Independent Clock但要注意这等同于做了跨时钟域处理读写冲突问题要额外考虑。3.2 Port A Options端口参数和输出寄存器Port A Options里重点看几项。Write Width填32Read Width默认也是32。Write Depth填256Read Depth会自动翻转计算成256。需要注意的是当读写宽度不一致时深度会按位宽换算比如写32位读8位写深度256时读深度自动变1024务必确认换算后的地址位宽是否满足你的地址规划。Operating Mode我按上文说的选Read First因为我的寄存器更新逻辑需要先读旧值。Enable Port Type我选Use Enable Pin这样可以把RAM的使能和周边模块的valid信号联动避免无谓的翻转功耗。Output Register选项我建议保持勾选。这里的输出寄存器会在BRAM输出端再打一拍读延迟从1拍变成2拍但能显著改善时序。如果你的读通路时序余量不够这个选项是第一个要开的。下面还有一个重要配置是Byte Write Enable。勾选之后写使能信号会变成向量32位数据对应WEA[3:0]每一位控制一个字节。这对于小端字节流写入、部分字节更新非常有用。不勾选的话只能整字写入做图像或协议处理时非常痛苦。3.3 Other Options初始化文件和复位优先级Other Options里最核心的是Software Optimization Priority通常保持默认的Area就行。往下是Initialize Memory Cell这里可以勾选Load Init File然后添加一个.coe或.mif文件。对ROM类应用这是必选项对RAM类应用则代表上电时RAM会先被写入这些初值——注意我说的是上电初始值不是复位值后面我会详细讲这个坑。复位配置涉及Reset Priority、Reset Type和Reset Value。一般同步复位Synchronous和Sync Override选项保持默认就行。关键是下面的Output Reset Value它决定复位信号有效时输出数据线变成什么一般填0。但你要再次确认它复位的是输出寄存器和数据线不是RAM存储单元本身。3.4 一个可以直接抄的配置清单我用一张表把我这个项目的最终配置整理出来方便你对着核对自己的工程配置项我的选择说明Component Nameframe_buffer_ram全局唯一命名Memory TypeSingle Port RAM单端口读写Common/Independent ClockCommon Clock单一时钟域Write/Read Width32/32读写等宽Write/Read Depth256/256满足帧头缓存深度Operating ModeRead First寄存器更新需要旧值Enable Port TypeUse Enable Pin与valid联动Output Register勾选改善时序读延迟2拍Byte Write Enable勾选支持字节更新Load Init File不勾选RAM上电不需要初值配置完成后点击OKVivado会弹出生成窗口选择Output Products建议把Simulation和Synthesis选项都勾上这样功能和时序仿真都能用。生成完毕后在Sources窗口能看到IP的例化模板里面有端口名和参数定义直接复制到顶层模块里接线即可。4. 接口时序与读写控制纯波形视角的完整说明配置完成只是第一步把接口信号接对、时序理解到位才算真正会用BMG。这里我把读、写、字节使能三个关键行为拆开讲。4.1 读操作输出寄存器带来的流水线延迟先看不勾选Output Register的BRAM读时序。当读地址信号和读使能通常就是Enable Pin在时钟上升沿被采样后BRAM内部会把地址锁存到地址寄存器经过一小段组合延迟后输出数据线上会出现该地址对应的数据。从外部观测读数据比读地址晚了整整一个时钟周期。如果勾选了Output Register数据还要经过输出端的额外寄存器打一拍整个读延迟变成两个时钟周期。这就意味着你在第N个时钟沿拉高地址和使能要等到第N2个时钟沿才能采到数据。如果算法里对读数据有严格的对齐要求比如用状态机边读边判断那这个流水线延迟就要在设计时扣掉不能默认读出数据和当前地址是同一拍的。有个经验之谈BMG的输出寄存器数量和你的读操作状态机深度一定要匹配。我之前在写FIFO状态机时默认读延迟是1拍结果开了输出寄存器之后读出来的是下下个地址的数据整个解析逻辑全乱了排查了好一阵子。4.2 写操作三种写模式的波形行为写操作的地址、数据、写使能信号同样是在时钟上升沿被采样。区别在于写操作发生时输出端口的动作因Operating Mode而异。Read First模式下当时钟沿来临时如果检测到写使能有效BRAM会先把当前地址的旧数据送到输出端口然后再将新数据写入存储单元。这个“旧数据可见”的过程大约消耗一个时钟周期所以你在下一拍能看到旧值出现在Q端口。Write First模式下写数据会直接被同时送到存储单元和输出端口也就是说下一拍你能看到新写入的数据出现在Q端口。这种模式适合写后即读的操作能省掉一次额外的读访问。No Change模式最简单粗暴写操作发生时Q端口保持之前读出的数据不变写操作的功耗最低也不会产生输出端口的毛刺。如果你的存储需求只是缓冲写入不关心写发生时的输出值选No Change是功耗表现最好的。仿真时你可能会发现一个现象RAM未初始化区域读出来是X未知态。如果在功能仿真里看到X通常意味着对应地址还没被写入过这是正常现象不代表IP有问题。但如果上板后读到随机值并且影响逻辑判断就需要在设计中保证所有地址在读取前都经过初始化写入流程或者在复位流程里对RAM做全地址清零。4.3 Byte Write Enable字节粒度控制写操作勾选Byte Write Enable后写使能会变成位向量。32位数据对应WEA[3:0]WEA[0]控制data[7:0]WEA[1]控制data[15:8]依此类推。时序上WEA向量、地址、写数据依然在同一时钟沿采样只是只有WEA位为1的那些字节才真正被写入存储单元。这个特性的典型应用是DDR写数据拼接。我做DDR3控制器的时候经常需要32位数据里只更新其中1个字节没有字节使能时就要先把整字读出来修改某字节后再写回去既耗时又逻辑复杂。有了WEA之后直接置对应位为1其余为0一个时钟周期就完成了。字节使能的宽度计算公式是WEA宽度 写数据位宽 / 8如果位宽不是8的整数倍BMG会自动向上取整。实际使用时要注意读端不会有字节使能信号字节使能只作用于写端口这一点在Simple Dual Port RAM和True Dual Port RAM上也是一样的。5. 五个最容易踩的坑与完整排查过程BMG虽然好用但绝对不是零坑。以下五个问题是我自己在项目中实际遇到过的每个都给出排查链路你可以照着自己的工程逐步对照。5.1 .coe初始化文件格式错误ROM读出来全是0现象很直接功能仿真里ROM数据阵列全是0怎么读都读不出预期值。排查第一步先回到BMG配置界面确认Other Options里的Initialize Memory Cell选项已经勾选并且Load Init File的路径正确。路径里不要有中文和空格否则综合工具可能找不到文件。第二步用文本编辑器直接打开.coe文件检查格式。一个合法的.coe文件长这样memory_initialization_radix16; memory_initialization_vector 00000000, 12345678, 89ABCDEF, FFFFFFFF;有几个细节非常容易被忽略第一行以分号结尾第二行开头的memory_initialization_vector后面是等号不能省数据之间用逗号分隔但最后一个数据后面跟的是分号不是逗号radix可以选2、10、16但数据必须和radix对应选16写2进制数一定会报错。第三步确认十六进制数据的位宽和配置的Read Width一致。如果Read Width是32每个数据就要写8位十六进制数只写了4位十六进制数BMG会按高位补零处理看起来数据“变短了”实际上不是报错是值变了。我那次问题就出在最后一个数据后面多打了一个逗号工具静默忽略了这个文件ROM初始加载没有生效。这种错误综合时不报错最坑。5.2 复位不等于清空RAM复位后数据居然还在我做帧缓存时有个很自然的想法系统复位后缓存区应当清零。于是我在状态机里拉高RST信号等了几拍之后再去读RAM发现读出来的居然还是复位前留下的旧数据一下子愣住了。排查之后确认BMG的复位信号只对输出寄存器和数据输出管脚起作用它会清空输出寄存器让Q端口回到复位值但存储单元的内容完全不受影响。BRAM本身没有全局清0硬件存储阵列的初始状态只由上电配置决定或者由你写代码逐地址覆盖。这也意味着如果你的算法要求“复位后整块RAM都清0”你必须在复位流程里通过状态机对每个地址执行写0操作。对于深度较大的RAM这会消耗不少启动时间设计复位时序时要预留。如果只是让读输出回到默认值用BMG自带的RST就够了。5.3 Simple Dual Port跨时钟域读写冲突偶发数据错乱跨时钟域场景下用Simple Dual Port RAM做异步缓存很常见。一开始我认为读写端口完全独立两边时钟不相干就没有问题实际测试中在某个数据率下出现了偶发的错帧而且不是每次都复现。排查链路是这样走的先看功能仿真读写时钟分别模拟发现功能仿真完全正常但上板有问题说明问题藏在物理行为里。再查时序报告果然在读写地址交叉区域出现了亚稳态。Simple Dual Port RAM的A端口和B端口虽然物理独立但在内部对同一存储单元进行访问时如果写端口正在更新某地址读端口同时读取同地址BRAM内部会出现竞争读出数据可能既不是旧值也不是新值。最简单的规避方法是做地址互斥读写两侧约定某些地址段只能由写端访问另一些只能由读端访问如果确实需要同址读写那就必须引入握手机制确保两侧不会在同一时刻触碰同一个地址。如果这个约束在你的应用里做不到更稳妥的方案是改用异步FIFO Generator它内部已经做好了跨时钟域指针同步和格雷码处理出错概率要小得多。5.4 资源占用比预期翻倍一个BRAM变成了两个有次综合完之后看到资源报告明明只例化了一个256x32的RAM怎么BRAM占用是2块第一反应是IP配置错了。排查后发现问题出在读写的位宽不一致。BMG生成BRAM阵列时会按照物理BRAM的端口宽度来切分。7系列一个RAMB36E1的端口宽度最大是72位ECC模式是64位如果你的读位宽和写位宽差距大比如写32位、读72位内部就会自动拼出两块BRAM。还有一个原因是读端口配置了异步复位或输出寄存器而写端口没有导致BMG只能分别用不同的物理块来实现读写通路。解决办法有两个方向一是重新审视读写位宽尽量把位宽对齐减少物理BRAM的拼接二是如果位宽确实不可调那就接受这个资源开销但要在成本评估时提前算进去。不要等到布线资源紧张了才回来改IP代价更大。5.5 功能仿真正常上板异常初始化文件和时序约束的隐形问题最后一个坑最隐蔽。功能仿真时ROM数据都正确下板测试却发现输出和仿真相差很大。这种情况先别怀疑仿真模型先检查两个地方。第一是检查综合和实现流程是否正确加载了初始化文件。Vivado在综合时会把.coe文件的内容嵌入到生成的网表里如果你改过.coe文件但没有重新生成IP的Output Products综合工具用的还是旧文件。正确做法是改完.coe后在IP Sources里右键该IP选择Reset Output Products重新生成再重新综合。第二是确认你的约束文件里有没有对应的时序例外。BRAM端口如果连接了来自不同时钟域的组合逻辑工具可能没法自动约束导致时序仿真里看到的实际延迟和功能仿真相差很大。此时在XDC里加一条set_false_path或set_max_delay限定到具体路径重新实现后就能看到差异消失。6. 进阶扩展把BMG放进自定义IP和其他IP协同BMG的用途远不止单独例化一块RAM实际工程里更多是嵌入到自定义IP或者和别的IP配合使用。6.1 BMG和FIFO Generator的分工很多人问BMG和FIFO Generator都是基于BRAM到底区别在哪我的理解是按地址访问就用BMG按顺序访问且需要状态信号就用FIFO。FIFO Generator内部其实就是一个带读指针、写指针和状态判断的BRAM它额外为你生成了空、满、almost_empty、almost_full这些信号并且处理了跨时钟域指针同步。如果你做数据搬移只需要不断写入、不断读出不需要指定地址直接用FIFO IP最省事。如果你需要随机访问某个地址或者要同时维护多个独立的存储区域那就必须用BMG自己搭。6.2 在自定义IP里例化BMG的流程Vivado里把BMG封装进自定义IP的流程并不复杂。先用BMG生成好存储模块然后通过Tools - Create and Package New IP把含有BMG例化的模块打包成自定义IP。这样在Block Design里就能像调用普通IP一样使用。需要注意的一点是BMG本身是加密IP打包后的自定义IP在分发给其他工程时目标环境里仍然需要存在对应版本的BMG IP否则综合阶段会报找不到原语。我在一个Aurora 8B/10B接口的工程里就是把BMG封装进了自定义的报文缓存IP对外只暴露读写端口和状态信号内部细节全部被IP封装起来上层工程师拿到手直接填地址、读写数据就行配合ILA调试也干净很多。6.3 和Cordic、FFT、Clocking Wizard这类IP共用的工程经验FPGA工程但凡复杂一点基本都是在拼装各种官方IPClocking Wizard负责产生多路时钟Cordic IP做三角函数或者反正切FFT IP做频谱BMG做数据中转。这些IP虽然功能各异使用范式高度一致——都是图形化配置、生成网表和仿真模型、按照模板例化。我的一个心得是工程里多个IP的时钟必须统一规划。BMG如果配成独立时钟模式就一定要用Clocking Wizard输出的BUFG网络连接并把时钟约束写清楚。之前有个工程BMG的Port A用了PLL输出时钟Port B用了MMCM输出时钟两个时钟源相位关系没对齐导致异步读写偶尔出问题。后来我统一成同一个MMCM的输出问题就消失了。存储资源和计算资源是互相挤占的配置IP前打开Device资源视图看看BRAM、DSP、FFT资源分别用了多少再做权衡。BMG配宽位宽、高深度时非常吃BRAM如果资源已经紧张考虑用UltraScale的URAM或者改存储方案。6.4 版本升级时的一个提醒BMG IP在不同Vivado版本间会有细节差异比如配置界面字段、默认的复位优先级、字节使能向量命名。从v8.3升到v8.4时我自己就碰到过端口名称变化导致旧代码例化失败的问题。升级版本后务必重新生成Output Products并用IP的例化模板核对端口名。如果是成熟项目尽量锁定在某个已经验证过的Vivado版本不要为了新功能随便升级BMG版本。最后再分享一点实用经验。拿到任何一个Xilinx存储类IP第一步不是打开配置界面乱选而是先读一下对应的产品指南BMG对应的是PG058。文档里对每种模式的时序波形、复位行为、资源用量都有明确说明花二十分钟翻一遍比你在配置界面里来回试要省太多时间。另外每个IP生成后把它的example design单独跑一遍仿真见过官方例子的行为之后再接到自己的逻辑里你会少踩很多没必要的坑。
返回列表