ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

FPGA实战:用PL读取BRAM数据的完整配置与调试指南

2026/10/7 9:30:02 拓冰建站 浏览量
FPGA实战:用PL读取BRAM数据的完整配置与调试指南 1. 项目背景与整体设计思路1.1 BRAM在FPGA开发中的地位接触Xilinx FPGA开发的第一站BRAM永远绕不开。我在做FPGA项目之前一直觉得存储就是DDR、SRAM这类外挂颗粒直到被一个图像缓存项目逼着看了两天的Block Memory Generator文档才意识到FPGA内部这片“小而美”的存储资源有多关键。BRAMBlock RAM是Xilinx FPGA内部硬件化的专用存储块位置靠近可编程逻辑不占用户逻辑资源读写带宽高延迟可以做到固定周期非常适合需要高吞吐、低延迟的数据通路。以我自己的使用经验来说BRAM最常见的用途包括缓存一帧图像的行数据、做跨时钟域的异步FIFO、保存查表用的正弦波系数、存储CPU下发的小型参数集合。说白了它就像FPGA内部的一组小仓库数据量不大但访问速度极快用好了能让整个设计的时序和资源占用都舒服很多。至于标题里的“用PL读取BRAM数据”这是另一个常被问到的点。PL就是可编程逻辑Programmable Logic很多人刚开始会在PL和PS之间纠结特别是Zynq平台上。简单说PS是ARM处理器PL是FPGA逻辑两者都能访问BRAM但访问路径完全不同。使用PL读写BRAM数据路径最短不需要经过AXI总线也不需要操作系统参与适合追求确定性时序和实时性的场景。这篇文章我就以PL侧为切入点把BRAM从配置到读写再到调试的完整链路讲清楚。1.2 为什么先学BRAM再学DDR很多初学者上来就想直接干DDR3/DDR4觉得那才叫存储。但我的建议是先把BRAM玩透原因很简单BRAM是FPGA内部资源配置和读写都围绕IP核和端口信号展开不牵扯物理层时序训练、Bank管理、刷新这些烦琐细节是最容易建立“片上存储读写”心智模型的路径。从学习曲线看BRAM相当于把“存储”这件大事的最小闭环画出来了地址、数据、读写使能、时钟。把这几根信号的关系搞明白后面看DDR控制器、AXI Interconnect、HP端口这些思路会顺畅很多。我甚至建议有条件的同学把BRAM的仿真波形和ILA抓到的真实波形放在一起对比亲眼观察数据在哪个时钟沿被采样这会比死记代码更有帮助。2. BRAM的基础认知与资源选型要点2.1 BRAM、分布式RAM、外部存储怎么选Xilinx FPGA里的存储方案不止BRAM一种选型之前需要先分清几种存储的差异。先看BRAM和分布式RAMDistributed RAM的区别。分布式RAM是用LUT搭出来的存储资源小但灵活性高适合深度浅比如32x64位以内的配置表的场景。BRAM是独立的存储块Xilinx 7系列/UltraScale系列单块BRAM容量为36Kb可配置为两个独立的18Kb块容量大且不占LUT资源。我的经验是存储深度超过64或者位宽超过16时直接用BRAM更划算因为分布式RAM到后期布局布线会吃紧时序收敛会让人头疼。再看BRAM和外部DDR的取舍。DDR容量大从几十MB到几GB但是要用DDR控制器IP涉及物理层、命令调度、跨时钟域资源开销和复杂度上了不止一个量级。FPGA内部BRAM虽然只有几MB但数据带宽极高一个时钟周期可以完成一次读写延迟也是固定的。所以我的选型原则很直接数据量在几百KB以内、需要随机高频访问的果断用BRAM数据量大到需要GB级别再考虑DDR两者之间可以用FIFO或者移位寄存器去过渡。2.2 BRAM容量估算与级联原则实际设计里第一步就是算容量我见过不少新手直接拍脑袋定深度结果BRAM不够用又从头改位宽的。BRAM的数量有一个简单计算公式单个BRAM可用容量按36Kb计算乘以块数必须大于等于你需要存储的位宽乘以深度。注意配置相关逻辑比如ECC会占用少量额外存储所以不要卡着极限用留出10%~20%的余量更稳妥。举个例子我要存一帧1024x768的灰度图像每像素8bit按整帧存储计算就是1024x768x86291456bit也就是约6.3Mbit如果用单块36Kb的BRAM需要约176块这显然会把资源占满。这种场景一般不会整帧存BRAM而是用行缓存的方式。我平时遇到更多的情况是存储256个32bit的配置参数那就是256x328192bit一块BRAM绰绰有余连顶层都省了。超过单块BRAM容量时IP核会自动级联不需要手动拼。但需要知道一个原则位宽超过72bit时IP核会把多块BRAM按位宽方向拼接深度太深时会在地址高位添加译码逻辑。级联后读写延迟会有细微变化一定要以IP核生成的datasheet为准别想当然按单块BRAM的时序去约束设计。3. Block Memory Generator IP核配置全解析3.1 创建IP并选择接口类型Xilinx Vivado里创建BRAM的入口是Block Memory Generator在IP Catalog里搜索“Block Memory Generator”就能看到。这个IP支持Native接口、AXI4接口和AXI4-Lite接口。标题里的“用PL读取BRAM数据”最常用的是Native接口直接操作读写信号简单直接适合PL逻辑自行控制时序如果后续数据要经过PS的AXI总线访问才推荐使用AXI4接口。我第一次用这个IP时被界面里一大堆选项搞昏了头后来总结出几个关键点。首先是Memory Type下拉菜单里有Single Port RAM、Simple Dual Port RAM、True Dual Port RAM、Single Port ROM等。单端口RAM适合“同一时刻只能读或写”的场景简单双端口RAM是一端只写、另一端只读异步FIFO就是这么用出来的真双端口RAM则两端都可以读写适合多时钟域交互和复杂存储场景。这篇文章的核心场景“PL读取BRAM”只读操作的话建议直接用Single Port ROM简单省资源如果后续还要动态更新数据再用True Dual Port RAM。接口类型选好以后还有Write Enable写使能和Output Register输出寄存器这些选项。输出寄存器默认是开启的它的作用是打一拍改善时序但代价是多一个周期的读延迟。高频率设计里强烈建议开启低频小设计可以关掉。这里有个常见误区很多教程为了让仿真结果“立刻看到数据”会把输出寄存器关掉结果上板之后发现时序收敛不了这就是给自己挖坑。提示Native接口下BRAM的写出数据在无输出寄存器时是读地址有效后的第二个时钟上升沿稳定开启输出寄存器后还要再多一拍。做时序分析时千万不要把这两类延迟搞混。3.2 位宽、深度与写入模式的选型细节配置Port A Width位宽和Port A Depth深度时Vivado会实时显示需要消耗的BRAM数量。这个数字值得仔细看有时候你只需要小容量但位宽选了128bit结果Vivado建议用4块BRAM远超出预期。这是由BRAM物理结构决定的单块BRAM标准数据位宽是36bit内部可选择配置超过后必须拼接。所以设计初期就要有意识控制“位宽深度比”访问频繁的宽数据可以考虑拆成多个窄位宽的BRAM并行访问在面积和带宽之间取平衡。写入模式有三个选项Write First、Read First、No Change。Write First表示写入时数据总线同时输出新写入的数据Read First表示写操作时读数据总线先输出旧数据No Change表示写操作时读数据总线保持不变。这套规则在仿真时会直接影响波形。我调试时踩过一个坑写模式下没有注意No Change设置测试读取旧值时数据一直保持为0当时以为是地址错了查了半天才发现是No Change的特性导致读端口在写时序期间根本不更新。所以调试时遇到“数据读不出来”第一件事先确认写入模式和读使能的组合是不是自洽的。另外还有Enable Pin选项。默认是Always Enabled也就是BRAM每个时钟周期都处于使能状态。如果设计中存在“某些周期不访问BRAM”的需求比如为了省电或者避免地址变化时的不必要翻转推荐勾选Use EN Pin通过EN信号来控制。需要提醒的是Enable Pin关闭期间读写端口都是无效的即使地址在变化也不会触发任何操作这种特性在做低功耗设计时很有用。3.3 初始化BRAM用COE文件写入初值BRAM上电后默认内容是全0如果只想让它当存储用不初始化也没关系。但如果打算把查找表、正弦波系数、固件参数固化在BRAM里就必须通过COECoefficient文件或.mem文件加载初始化数据。COE文件的格式很简单我贴一个自己常用的写法memory_initialization_radix16; memory_initialization_vector 0001020304050607, 08090A0B0C0D0E0F, DEADBEEFCAFEBABE, ;注意每行数据要符合你配置的位宽。如果位宽是8bit每行写2个十六进制数位宽是16bit每行写4个十六进制数位宽是32bit每行写8个十六进制数。位数不够或者超过IP核生成时会直接报错或者截断这个要特别小心。在Block Memory Generator的Other Options标签页里可以看到Load Init File的选项勾选后浏览到你写好的COE文件即可。很多新手在仿真里发现BRAM读出来全是0怀疑IP坏了其实大概率是COE文件没加载或者加载后没有重新生成IP。加载COE之后一定要重新Generate Output Products并重新生成Bitstream仿真时还要确认IP核的仿真模型重新编译过。4. 实操PL读取BRAM数据的完整实现流程4.1 工程创建与引脚规划我以Vivado 2021.2为例新建一个RTL工程。芯片型号随意只要资源够就行我习惯选xc7z010clg400-1便宜且常见。新建工程后第一步先Create Block Design创建一个块设计也可以直接写顶层Verilog例化IP两种方式我都用简单的、单时钟的场景直接写Verilog更清爽。在Block Design里添加Block Memory Generator IP双击打开配置界面。这里我把端口配置成Interface TypeNativeMemory TypeSimple Dual Port RAMA端写、B端读更贴近“写入后读取”的模型Port A写端口位宽32bit深度256使能方式Always EnabledPort B读端口位宽32bit深度256使能方式Always Enabled输出寄存器开启之所以选Simple Dual Port而不是Single Port RAM是因为它的读写端口独立后面扩展时不用改结构比如想用BRAM做帧缓存一个端口写视频流另一个端口读出来送显示这种模型刚好贴合。4.2 PL侧读写逻辑的实现写BRAM比较简单把地址、数据、写使能往端口上一拍就行。读BRAM需要明确时序关系。一个带输出寄存器的Simple Dual Port RAM当读地址有效后需要等待两个时钟周期第一个周期寻址第二个周期输出寄存器采样数据才会出现在读数据总线上。我写了一个简单的读写控制逻辑地址从0递增到255把8个固定32bit值写入然后循环读出用仿真验证module bram_rw_test #( parameter ADDR_WIDTH 8, parameter DATA_WIDTH 32 )( input wire clk, input wire rst_n, output wire [DATA_WIDTH-1:0] rd_data, output wire rd_valid ); reg [ADDR_WIDTH-1:0] wr_addr; reg [ADDR_WIDTH-1:0] rd_addr; reg wr_en; reg [DATA_WIDTH-1:0] wr_data; reg rd_en; reg [DATA_WIDTH-1:0] data_mem [0:255]; // 写地址与控制 always (posedge clk or negedge rst_n) begin if (!rst_n) begin wr_addr 8d0; wr_en 1b0; end else if (wr_addr 8d255) begin wr_addr wr_addr 1b1; wr_en 1b1; end else begin wr_en 1b0; end end assign wr_data {wr_addr[7:0], 8hA5, 8h5A, 8hFF}; // 读地址与控制 always (posedge clk or negedge rst_n) begin if (!rst_n) begin rd_addr 8d0; rd_en 1b0; end else if (wr_addr 8d255 wr_en) begin rd_en 1b1; rd_addr 8d0; end else if (rd_en rd_addr 8d255) begin rd_addr rd_addr 1b1; end else begin rd_en 1b0; end end // 生成读有效标志与读数据流水线对齐 reg rd_en_d1; always (posedge clk or negedge rst_n) begin if (!rst_n) rd_en_d1 1b0; else rd_en_d1 rd_en; end assign rd_valid rd_en_d1; // 例化Block Memory Generator blk_mem_gen_0 u_bram ( .clka (clk), .wea (wr_en), .addra (wr_addr), .dina (wr_data), .clkb (clk), .addrb (rd_addr), .doutb (rd_data) ); endmodule这段代码逻辑很简单但有一个知识点值得展开rd_valid信号为什么要打一拍因为读数据经过两级寄存器输出地址有效后数据有固定延迟。如果你在rd_en拉高的同一拍去采样rd_data采到的还是上一地址的数据。这个“流水线错位”是初学BRAM最容易懵的地方我在工程里都是把valid信号按同样的延迟拍数打齐保证下游逻辑拿到数据时标志同步有效。4.3 使用initialize和直接赋值对比对于仅读取的ROM场景除了COE文件初始化外下面这种Verilog内嵌赋值的方式也可以用来“预置”数据适合数据量小的快速原型always (posedge clk) begin if (rd_en) begin case (rd_addr) 8d0: rd_data 32h0000_0001; 8d1: rd_data 32h0000_0002; 8d2: rd_data 32h0000_0004; default: rd_data 32h0000_0000; endcase end end这种方式写起来直观但只适合临时调试真正的工程还得靠COE或者AXI接口在运行时动态更新数据。因为case语句会被综合成LUT或MUX数据量一大会消耗大量查找表资源和BRAM的初衷背道而驰。所以记住常量查询表用COE动态数据用BRAM端口写入。4.4 仿真验证与ILA在线调试写完了代码一定要先仿真再上板。Testbench里我给时钟、复位、读使能等激励信号跑出波形后重点检查三个东西一是写数据是否成功写入BRAM内部。这个在行为仿真里可以通过右键信号树把u_bram内部memory的数组加进波形窗口观察Vivado支持直接查看IP例化内部存储值。如果没有这个习惯可以试试专门给某个地址写一个特征值比如0xDEADBEEF然后读出来比对用数据特征判断读写通路。二是读延迟是否符合预期。开启输出寄存器后从读地址有效到数据有效应为两个时钟周期。如果你发现只延迟了一拍很可能是配置界面漏开了Output Register。三是检查COE加载的数据在最初几拍读出来是否正确。上板调试我推荐用ILAIntegrated Logic Analyzer这是Xilinx FPGA调试的法宝。在Vivado里把ILA IP核挂到BRAM的读数据总线和读地址总线上设置触发条件为读地址等于某个特定值板子跑起来后就能在硬件管理器里抓到真实的读写时序。实测中很多仿真里看不出来的问题比如跨时钟域的数据抖动、上电后BRAM内容被意外改写、复位时序不对导致地址跳变都是在ILA波形里暴露出来的。5. 从PL到BRAM的数据回读与时序分析5.1 回读场景与设计思路“PL读取BRAM数据”这个概念除了自己写逻辑主动访问BRAM之外还有一种重要场景是处理器或外部接口通知PL去读取BRAM中已存好的数据。换句话说BRAM里可能先被PS、DMA或其他模块写入了数据PL再从另一个端口主动读取。Zynq平台下典型结构如图PS通过AXI BRAM Controller写数据到BRAMPL侧用Vivado里的AXI BRAM Controller IP或直接访问BRAM的B端口。这里必须注意跨时钟域。如果PS侧写时钟和PL侧读时钟频率不同或者相位不同异步FIFO是最稳妥的方案。虽然Xilinx的BRAM支持独立时钟的两个端口True Dual Port RAM但异步读写时若不加同步处理读端口采到半个字、或者拿到旧数据是常见问题。我在一个数据采集模块里遇到过PS通过PCIe接口持续写入大块数据PL侧以系统时钟去读BRAM。写入端时钟是125MHzPL读时钟是100MHz虽然读写频率差不大但不做跨时钟域处理时读出的数据会周期性地出现偶发错位。原因是写地址的格雷码和读地址的同步延迟在边界处产生了短暂不一致。最终解决方案是在PL侧先用异步FIFO跨接保证写入和读出序列不乱再存储到BRAM中。注意BRAM IP本身不具备跨时钟域同步能力它只是提供了两个物理上独立的时钟端口。如果你的读写时钟完全不同源务必在逻辑层面对读写指针或握手信号做同步否则数据完整性无法保证。5.2 读写时序约束与关键路径优化BRAM使用时序约束并不复杂但忽略时序分析的后果很严重。BRAM IP生成后Vivado会自动约束IP内部的内部路径你只需要保证与BRAM相连的地址、数据、使能信号满足建立和保持时间即可。关键是检查Timing Summary看WNSWorst Negative Slack是否为正值。如果出现负时序裕量优先考虑在BRAM输出端增加输出寄存器、调整流水线级数其次才是修改逻辑。针对PL读取BRAM的场景常见的时序优化手段有三个。一是在读数据输出端加两级甚至三级寄存均衡组合逻辑延迟二是避免将地址信号直接经过复杂组合逻辑再送到BRAM先寄存再输出到BRAM三是如果BRAM位宽较大高速时钟下做到全位宽读写很吃力可以拆成多个小位宽BRAM交替访问。这三种手段我都在工程里实际验证过效果立竿见影尤其第一招几乎零成本地解决过多次WNS为负的问题。关于输出寄存器和延迟需要再强调一次。很多人看到BRAM读数据延迟增加会本能地想去掉输出寄存器“提速”。但实际表明BRAM的物理读写时间本就固定输出寄存器的存在只是把关键路径上的延迟从BRAM输出挪到了FF触发器上反而方便了布局布线工具收敛时序。所以在时钟频率较高时不要盲目关闭Output Register。5.3 BRAM与DMA协同工作的一种实现思路很多高性能场景里PL读取BRAM往往不止是“CPU读一下数据”这么简单而是和DMA配合让大量数据流式地搬运。简单说DMA把数据从外部存储器搬入BRAMPL再按数据包边界取走处理。这里BRAM相当于一级缓存。在这种模式下BRAM容量实际上限制了DMA单次搬运的块大小。工程上常用的策略是“双缓冲”把BRAM划分为两个区域DMA往A区写入时PL读B区DMA写满A区并切换指针后PL也切到A区读取B区则被DMA继续覆盖。这种交替使用的方式能有效隐藏DMA写等待时间让PL侧始终有数据可读。实现双缓冲并不复杂核心就是维护一组状态位。我自己的实现是用一个寄存器记录当前可读区域编号DMA完成写入后更新该寄存器。PL侧读取时先判断当前可读区生成相应的高位地址偏移。要注意的是缓冲区切换时PL可能正在读出上一块数据这时不能让读地址瞬间跳到新区域否则中间数据会断流。解决办法通常是在切换前等待PL侧当前数据包处理完毕或者用额外的缓存把切换边界补上。这个细节是实际项目中比BRAM配置本身更难调试的地方。6. 常见问题与排查技巧实录6.1 问题速查表我把这段时间积累的BRAM排查经验整理成了一张表方便大家直接对照现象可能原因排查方法读数据全部为0COE文件未加载初始化输出寄存器未开启但采样时机错误确认Other Options中Load Init File检查读延迟用仿真查看BRAM内部存储值读数据延迟不对Output Register开启/关闭状态与预期不符回到IP配置界面确认寄存器选项以生成的datasheet中读写时序图为准写入后数据不更新Write First/Read First/No Change模式选错了观察写使能期间读数据端口的变化No Change模式下写期间读端口不更新部分地址数据错乱BRAM读写端口竞争地址未对齐确认是否有两个端口同时操作同一地址检查跨时钟域同步逻辑时序违例WNS为负读数据输出路径组合逻辑太大增加输出寄存器/打拍数优化地址源逻辑考虑拆位宽并行访问上板数据与仿真不一致复位时序或BRAM上电初始化未完成检查复位信号释放时间在上电过程中留足初始化时间再使能读操作6.2 调试技巧先写后读特征数据验证我在调试BRAM时最常用的方法是“特征数据验证法”。先往BRAM里写入一组规律明显的特征数据比如地址值本身再加一个固定偏移然后读出来比对。如果读到某个地址时的数据等于地址加偏移就说明读写通路和寻址逻辑都正常如果不一致通过比对结果能快速判断是地址错位、数据位宽错误还是延迟拍数错位。6.3 资源占用异常的排查方向资源占用异常也是BRAM使用中常遇到的问题。比如256x32bit的存储需求怎么看都应该只用一块BRAM综合后却报出三四块。遇到这种情况第一个检查项是Block Memory Generator是否自动推断成了LUTRAM原因是存储数据没有单独放到IP里而是通过数组声明让综合工具自行推断。Vivado的综合策略确实会自动推断BRAM但有时推断不全会落到分布式RAM上。检查方式是打开综合后的Utilization Report看RAM_LUT和BRAM的比例若看到RAM_LUT占得较多就要手动给数组加(* ram_style block *)属性(* ram_style block *) reg [DATA_WIDTH-1:0] data_mem [0:255];还有一个坑是IP核配置里选择了“Common Clock”或“Independent Clock”实际端口连接与配置不符时IP核核内的缓冲逻辑会额外消耗资源。务必在配置界面里就确认使用的是Single Clock同一时钟还是独立的时钟A/B后者的资源会比前者稍高。对于简单场景不相关的时钟用同一时钟即可少一堆异步逻辑。6.4 经验总结BRAM调试的几个习惯最后分享几个自己踩过坑之后形成的习惯一是做BRAM相关设计先把读写时序图亲手画一遍标清地址有效、数据有效、读使能、读延迟的关系再写代码。画图这个过程能省掉大量无效仿真时间。二是仿真时除了看顶层信号别忘把IP内部存储数组加进来观察。数据写到哪儿、读出来是什么一眼就能定位问题不需要从头到尾猜。三是改BRAM配置后必须重新Generate Output Products并查看综合报告里BRAM数量、延迟配置是否和预期一致。有时候改了配置没生效继续拿旧仿真结果排查白费功夫。四是对BRAM的地址、数据、使能信号尽可能在顶层就打拍寄存不要从很远处组合逻辑直接连过来。这种习惯对时序收敛极有帮助。我最初学BRAM的时候被那个“读延迟两拍”折腾得不轻波形上看半天没搞懂为什么数据总比预期晚出现。后来把输出寄存器的原理弄明白再回头分析整个读写链路就通畅多了。建议你也从这一步开始把基础打扎实后面用DDR、AXI等资源时会发现很多概念都是相通的。