ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Xilinx FPGA BRAM从选型到读写验证:PL数据缓存实战指南

Xilinx FPGA BRAM从选型到读写验证:PL数据缓存实战指南 第一次用Xilinx做数据采集时我遇到一个特别尴尬的情况采样率不高但每次采回来的一帧数据有1024个点每个点16bit。起初我用寄存器数组缓存综合完一看资源LUT被吃掉一大片换成FIFO又因为后续要做随机读取而束手束脚。同事过来瞄了一眼说这种情况你用BRAM不就行了这句话点醒了我也成了我系统学习Xilinx存储资源的开始。BRAM就是FPGA片上的Block RAM硬核存储块在7系列和Zynq的PL侧是一等公民。容量大、读写可靠、不占逻辑资源最关键的是它支持通过IP核快速生成PL逻辑直接按端口时序读写即可不需要像DDR那样去折腾控制器和复杂带宽。这篇就把我从BRAM选型、Block Memory Generator参数配置到PL读写代码、ILA实测验证的完整过程记录下来。我会把每个参数背后的逻辑、读延迟的计算方式都讲清楚适合刚接触Xilinx、想在PL里用BRAM缓存数据的工程师。1. BRAM和分布式RAM怎么选FPGA片上存储的底层逻辑1.1 FPGA里到底有哪些“存储器”很多人一听到FPGA里做存储第一反应就是数组reg [7:0] mem [0:255]或者直接上手FIFO IP核。其实FPGA内部能用的存储资源分三类三者定位完全不同。第一类是触发器加LUT拼出来的寄存器堆。这种存储器用逻辑资源搭成理论上地址任意、读写特别灵活但容量稍微一大就非常吃资源。我最早试过的1024x16bit数组综合完资源报表里的LUT和FF几乎全被占满时序还特别难看。这类存储只适合做几十bit级别的配置寄存器、状态寄存器或者少量深度很小的缓存。第二类是分布式RAM。它的本质是拿LUT当存储单元用Xilinx的LUT本身可以被配置成小容量的RAM。深度一般在64到512bit左右做成小FIFO或者小缓存放少量数据够用再往上就不太现实了。它最大的问题是容量有限而且存储单元分布在逻辑阵列里时序收敛不如BRAM稳定。第三类就是BRAM。这是FPGA里真正意义上的“大块头”专用存储。7系列每个BRAM是36Kb也可以拆成两个独立的18Kb使用所以在Zynq-7020这类中等规模的芯片上140块BRAM加起来就有接近4.9Mb的片上存储这是逻辑资源再堆也堆不出来的量级。BRAM是硬核电路不消耗LUT和FF读写路径经过厂商优化时序稳定还支持独立的双端口访问。1.2 判断标准三种片上存储该怎么挑我现在的选型习惯基本可以浓缩成三个问题存多少、怎么访问、几个时钟域。讲清楚存多少很简单。容量在几百bit以内用分布式RAM性价比最高容量到了Kb级别以上直接上BRAM。因为BRAM是按块分配的你哪怕只用了1Kb也会占掉至少一块36Kb的BRAM所以小容量场景用BRAM有点浪费分布式RAM反而更合适。怎么访问决定用RAM还是FIFO。如果数据完全是先入先出只用顺序读写那BRAM当然能做但直接用Xilinx的FIFO IP核更省事毕竟FIFO帮你把空满标志、读写指针都算好了。如果后续需要随机访问比如按某个下标读历史数据、做查表、做帧缓存那就必须用BRAM。几个时钟域则决定了用单口、简单双口还是真双口。写侧时钟和读侧时钟如果不同步你的存储就必须是双时钟的BRAM否则老老实实用同一套时钟驱动读写逻辑。1.3 存储资源对比速查存储形式本质典型容量范围适合场景注意点寄存器堆FF LUT几十bit配置寄存器、少量状态缓存容量一大资源和时序都崩分布式RAMLUT配置成RAM几十bit到几Kbit小深度FIFO、小查表容量有限时序易受布局影响BRAM硬核Block RAM36Kb起可按块拼接帧缓存、大深度FIFO、随机访问缓冲按块分配小容量会浪费DDR外部存储颗粒数百Mb到数Gb大容量图像帧存、嵌入式Linux内存延迟高、需要控制器、带宽受限每次拿到需求先按这张表把存储方案定下来后面写代码就顺很多。接下来正式进入BRAM的实务操作。2. 创建BRAM IP核时这些参数到底该怎么填2.1 进入Block Memory Generator后的第一步Vivado里创建BRAM入口很固定左侧IP Catalog里搜索Block Memory Generator双击打开。这个IP核是Xilinx全家桶里最基础、也最稳定的存储IP所有FPGA工程几乎都会碰到它值得把每个配置项吃透。打开后的第一个页面是Basic页最重要的就是Memory Type下拉框Single Port RAM只有一个端口既能写也能读读写共用一套地址线和数据线。适合读写操作严格分时进行的场景。Simple Dual Port RAM一个端口专写另一个端口专读。写侧有clka、addra、dina、wea读侧有clkb、addrb、doutb。做数据缓存和跨时钟桥接最常用。True Dual Port RAM两个端口各自都能读写灵活性最高。适合需要两个模块同时访问存储内容的场景比如CPU和FPGA逻辑共用一个数据区。还有Single Port ROM和Dual Port ROM只读不写用于查表、系数存储这类需求。2.2 位宽、深度和操作模式一句话讲清每个参数Basic页里填完端口模式下一件事就是定端口位宽和读写深度。这里有个很容易忽略的点BRAM物理块是36KbVivado会帮你自动拼接多个BRAM来满足你的位宽和深度要求但你最好在动手前自己估算一下心里有数。举例来说如果你需要16bit位宽、1024深度那容量是16Kb一块36Kb BRAM就能装下。如果你需要32bit位宽、4096深度容量128KbVivado会自动拼4块甚至更多。规划资源时按位宽乘以深度换算出bit数再除以36Kb就能大致知道要吃多少块BRAM。端口宽度还要注意写使能位宽的问题。Xilinx BRAM的写使能不是随意的单bit而是按字节对齐的。数据宽度8bit时WE是1bit数据宽度16bit时WE变成2bit32bit时WE变成4bit。写入整个字时把所有使能位都拉高就行。这个细节做字节写入时尤其重要很多人第一次看到[3:0]的wea信号会愣一下其实是字节使能。Port A Options标签页里还有一个Operation Mode只对单端口RAM和真双口RAM的同端口读写有意义。Write First写入时读出口先输出正在写入的内容适合写后立即读的场合。Read First写入时读出口先输出该地址的旧数据适合先备份旧值再更新的场合。No Change写入时读出口保持不变隔离性最好也最节能。用生活类比来说这就像你更新一张表格Write First是边写边把新内容亮出来Read First是把旧内容先给你看完再覆盖No Change则是写的时候隔壁窗口完全不显示内容。选哪个取决于你的下游逻辑是否需要看到屏蔽期间的数据。Simple Dual Port模式下读端口独立不涉及这个设置但你需要知道它存在。2.3 输出寄存器、初始化文件和IP核例化模板Port A Options往下翻会看到Primitive Output Register这一个复选框它是整个BRAM IP配置里最容易被点错、也最影响时序的一项。这个选项的意思是在BRAM输出路径上加一级寄存器。默认情况下BRAM读地址经过内部寻址后数据在下一拍出现在dout上勾选之后内部再打一拍读延迟会从1个时钟周期变成2个时钟周期。换来的好处是输出路径被寄存切断时序更干净尤其时钟频率到200MHz以上时不开这个寄存器BRAM输出路径很可能成为关键路径瓶颈。除非你对延迟极其苛刻否则建议勾上。旁边还有Load Init File选项可以用.coe文件给BRAM预设初值。调试PL读取BRAM有个很好用的技巧先在.coe文件里按地址顺序写一段递增数据比如16进制00、01、02一直到0xFF再让PL读回波形一对比就知道逻辑有没有写对。没有初始化文件的话BRAM上电后的值是未定义的这一点在调试时经常误导人。IP配置完成后点击OKVivado会生成一个IP核模块。在Sources窗口里展开这个IP核打开后缀为.veo的文件里面是标准的例化模板端口名、位宽、方向全部现成直接复制到自己的顶层模块里最省事。3. PL读写BRAM的接口时序和Verilog例化模板3.1 三种端口模式接口差异到底在哪BRAM的端口信号名称看着多其实规律非常强。所有端口都围绕时钟、使能、地址、写数据、写使能、读数据这几个要素。Single Port RAM的接口最精简clka、ena、wea、addra、dina、douta。读写共用addrawea为高时写入wea为低时读。Simple Dual Port RAM把读写通道拆开了写端口有clka、ena、wea、addra、dina读端口有clkb、enb、addrb、doutb。这两个时钟可以接同一个时钟也可以接不同时钟频率和相位都不用一致。做跨时钟域数据缓冲时这个模式是首选。True Dual Port RAM则是两套完整端口每套都有读写能力适合两个独立模块都需随机访问存储内容的场景。接口复杂度比前两者高出一截但在实际项目里它的出现频率不低比如PS和PL同时访问一个共享数据缓冲区。3.2 简单双口RAM的完整例化代码下面给一个我实际调试用过的例子往BRAM地址0到255写入数据0到255写完后再把整个BRAM内容从地址0开始连续读出来。模块接口和逻辑都保持简单方便你直接套用和上板验。module bram_rw_test( input wire clk, input wire rst_n, // 写侧 output wire [7:0] wr_addr, output wire [7:0] wr_data, output wire wr_en, // 读侧 output wire [7:0] rd_addr, input wire [7:0] rd_data ); reg [7:0] wr_cnt; reg [7:0] rd_cnt; reg wea_r; always (posedge clk) begin if (!rst_n) begin wr_cnt 8d0; wea_r 1b0; end else if (wr_cnt 8d255) begin wr_cnt wr_cnt 1b1; wea_r 1b1; end else begin wea_r 1b0; end end assign wr_addr wr_cnt; assign wr_data wr_cnt; assign wr_en wea_r; always (posedge clk) begin if (!rst_n) rd_cnt 8d0; else rd_cnt rd_cnt 1b1; end assign rd_addr rd_cnt; blk_mem_gen_0 u_bram( .clka (clk), .ena (1b1), .wea (wr_en), .addra (wr_addr), .dina (wr_data), .clkb (clk), .enb (1b1), .addrb (rd_addr), .doutb (rd_data) ); endmodule这段代码里写侧在0到254的周期里把数据写入写地址和数据都是同一个计数器值读侧则从第0个周期开始不停地递增读地址两个操作同时进行正好能体现BRAM的并行读写能力。例化时要注意IP核名称要和你实际生成的一致blk_mem_gen_0是我这边的名字。如果你改叫bram_256x8那就同步替换。ena和enb我在这里直接拉高因为读写始终需要使能BRAM。如果想让BRAM在不使用时降低功耗可以动态控制这两个信号。3.3 读延迟是1拍还是2拍这个细节别搞错BRAM时序里最容易出问题的就是读延迟。在前面配置IP核时如果我勾选了Primitive Output Register那BRAM的读时序就是一个固定的两拍流水第一个上升沿addrb地址被锁存BRAM内部开始寻址。第二个上升沿内部读出的数据经过输出寄存器出现在doutb上。所以你在波形上看到的规律是doutb相对于addrb滞后两个时钟周期。如果没勾输出寄存器则滞后一个周期。这个延迟细节在做校验时尤其关键。如果你在代码里写完rd_addr的下一拍就立刻去比较rd_data那拿到的一定是不对的。正确的做法是写一个地址延迟链把读地址打两拍再和doutb做对齐。reg [7:0] rd_addr_d1; reg [7:0] rd_addr_d2; always (posedge clk) begin rd_addr_d1 rd_addr; rd_addr_d2 rd_addr_d1; end always (posedge clk) begin if (rd_data rd_addr_d2) // 数据校验通过 else // 数据校验失败 end用这种方式对齐延迟后BRAM的读验证基本不会出问题。写侧则不需要考虑延迟wea为高的那个时钟沿dina上的数据已经写入addra对应地址了。4. 上板前双保险先仿真对齐时序再上ILA实测波形4.1 写一个能自解释的testbench写HDL逻辑不上testbench直接上板是对自己时间的不尊重。BRAM时序稍微复杂点仿真这一步尤其不能省。一个最简单的testbench只需要做三件事生成时钟、给复位、例化被测模块。我更推荐的做法是让testbench自动做断言不要光靠人眼盯波形。比如你往地址0写到255读侧最终应该按两拍延迟输出同样的值那就在testbench里加一个计数器跟踪读地址延迟每次时钟上升沿判断rd_data是否等于延迟后的地址。不等就报错全等就打印一行PASS。我用这种方式调BRAM几乎没踩过逻辑错位的坑因为出错的第一时间就有明确报错而不是等着波形慢慢翻页找。你可以把BRAM实例换成IP核的仿真模型这个模型会在behavioral仿真阶段被自动使用不需要额外配置。4.2 ILA探针配置怎么把内部信号抓出来看仿真通过后下一步是上板实测。但要看到FPGA内部信号就得用到ILA也就是Integrated Logic AnalyzerXilinx内置的逻辑分析仪IP核。ILA的使用方式我推荐最直接的一种在代码里把想观察的信号打上mark_debug属性然后让Vivado自动连接ILA。(* mark_debug true *) reg [7:0] dbg_rd_addr; (* mark_debug true *) reg [7:0] dbg_rd_data; (* mark_debug true *) reg dbg_wr_en;综合后在Vivado的Netlist界面里找到这些打了标记的信号右键选择Set Up DebugVivado会引导你完成ILA的创建和连接。这种方式的好处是不需要手工例化ILA IP核探针宽度和时钟域由工具帮你整理适合入门。如果你希望完全掌控ILA参数也可以像例化BRAM一样手动例化ILA IP核。控制信号主要有clk、probe0、probe1这些probe信号位宽按需填就行。采样的深度一般设1024就够了调试更深的数据流再加到2048或4096。有个容易忽略的坑ILA采集数据本身也是用BRAM存储的。你本来就用BRAM做数据缓存再开一个深度4096、几十个探针位的ILA又会吃掉好几块BRAM。在小容量芯片上这可能导致综合或布局布线失败。所以探针数量能少则少采样深度够看波形就行不要把每个中间信号都拉进去。4.3 抓不到波形、数据错位时的排查顺序ILA接好后上板运行常见的诡异问题无非几种。第一种是ILA整个没有波形一抓全空。优先查ILA的时钟是否连到了真实存在的、跑起来的时钟。ILA触发条件是否配置合理如果设置了触发事件而事件从没发生那也抓不到数据。最常见的解决方法是把触发条件设为无条件触发也就是always先保证波形能抓下来。第二种是doutb一直为0。先检查BRAM的ena、enb是否一直为高再检查写侧wea有没有真正拉起来过。前面代码里我特意设计了一轮写完就停止写使能的逻辑你可以在波形里确认wea确实只在预期窗口内为高。如果发现wea只在复位后很短时间有效那就检查rst_n是不是在外部被意外拉低了。第三种是数据读出来和预期错位。这个基本可以断定是读延迟对齐问题。勾了输出寄存器却按一拍延迟去处理或者没勾输出寄存器却按两拍延迟处理都会出现这种错位。我在调试时习惯把rd_addr的延迟链和rd_data并排放在一起看延迟链到了第几拍数据应当恰好对齐。第四种也是最隐蔽的复位释放时机不对。如果BRAM写侧逻辑用了异步复位而且复位释放时正好处于时钟沿附近会导致计数器或使能信号出现亚稳态表现就是偶尔多写一笔或者漏写一笔。这种问题在仿真里极难复现只有上板才出现。我的建议是PL逻辑里的复位都统一做一次同步化处理再分发到各个模块不要在可写数据的关键路径上直接用异步复位。5. BRAM工程里真正会踩的坑与性能边界5.1 跨时钟域操作BRAM不是万能的同步保险箱很多人看Simple Dual Port RAM有两个时钟就以为把一个模块的异步信号直接接到写端口另一个模块再从读端口拿数据跨时钟域就搞定了。这个想法只对了一半。BRAM存储单元本身是同步RAM读写两侧确实有各自独立的时钟存储器不会因为你写时钟和读时钟频率不一样就出错。它保证的是一个字节在某个时钟域被可靠写入之后在另一个时钟域可以可靠读出。但它不负责告诉你数据什么时候写好了、读的时候数据是否已经更新完毕。这些握手和同步逻辑必须你自己处理。如果你只是单拍传递一个数据直接用两级寄存器同步就够如果是批量数据从慢时钟域搬到快时钟域或者反过来正确的做法是让数据先写成BRAM再通过独立的写侧计数器和读侧计数器判断“新数据准备好了”。更省心的替代方案是直接用Xilinx的FIFO IP核选择独立时钟模式空满标志和同步逻辑都由IP帮你处理底层其实就是BRAM。我的经验是批量跨时钟域数据优先用异步FIFO少量控制信号用两级同步器只有需要随机访问的数据缓冲才选BRAM加自研握手逻辑。5.2 乒乓缓冲两个BRAM怎么轮流干活BRAM在实际采集系统里最常见的应用之一是乒乓缓冲。简单来说就是用两块BRAM交替工作A块写入时B块读出B块写入时A块读出。数据采集端的采样率因此可以和数据处理端的处理速度错开两边各跑各的互不相等的带宽也能衔接上。控制逻辑并不复杂。准备一个buffer_sel寄存器写侧根据当前选择把数据写到A或B读侧始终读另一块。一帧数据写完后切buffer_sel同时给读侧一个帧有效信号。这里的关键是切换时机要错开不能写读同时切换同一块BRAM。我用这个方法在低速ADC采集和后续FIR滤波之间做过渡效果非常稳定。侧向补充一句如果只有一块BRAM读写操作可以分时复用但吞吐量和并行度都会下降遇到实时性要求高的场景容易卡顿所以硬件资源允许时乒乓是更稳的方案。5.3 资源边界与后续扩展方向BRAM不是无限的规划工程时建议一开始就估算清楚。假设你有64块36Kb BRAM每块能配置成512x72bit或者1024x36bit最高位宽是72bit。深度超过这个范围工具自动拼接但在IP核配置界面上你可以直观看到估算结果那里会直接显示当前配置消耗多少块BRAM。另外BRAM不支持异步复位清空内部内容上电后必须通过写操作或者初始化文件来获得确定值。很多新手期望一复位BRAM里的数据自动清零结果读出来是随机值白白浪费半天排查时间。理解这一点很多奇怪的波形也就有了解释。如果你后面需要让PS端通过AXI总线访问PL里的BRAMXilinx有现成的AXI BRAM Controller IP核它会把AXI读写的突发转换成BRAM端口时序省得你自己写AXI从机逻辑。不过它默认挂载的是Simple Dual Port模式你需要留出一个端口给BRAM Controller另一个端口留给PL逻辑两边可以同时访问同一块BRAM。到时候你会发现BRAM作为PL和PS之间的数据交换枢纽真的是FPGA工程里的万金油。最后分享一个调试BRAM的习惯无论改动多小先跑一遍behavioral simulation再上板。这个步骤让我避开了绝大多数写地址越界、读延迟错位和初始化数据不对的问题。另有一个小建议如果想在板上静态查看BRAM内容可以专门留一个读端口加一组拨码地址把读数据接到LED上拨动拨码就能按地址查看存储内容调试效率会高很多。
返回列表