☰
FPGA实战:用PL读取BRAM数据的完整配置与调试指南
2026/10/7 9:30:01 网站建设 项目流程

1. 项目背景与整体设计思路

1.1 BRAM在FPGA开发中的地位

接触Xilinx FPGA开发的第一站,BRAM永远绕不开。我在做FPGA项目之前,一直觉得存储就是DDR、SRAM这类外挂颗粒,直到被一个图像缓存项目逼着看了两天的Block Memory Generator文档,才意识到FPGA内部这片“小而美”的存储资源有多关键。BRAM(Block 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和分布式RAM(Distributed 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,按整帧存储计算就是1024x768x8=6291456bit,也就是约6.3Mbit,如果用单块36Kb的BRAM,需要约176块,这显然会把资源占满。这种场景一般不会整帧存BRAM,而是用行缓存的方式。我平时遇到更多的情况是存储256个32bit的配置参数,那就是256x32=8192bit,一块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里,就必须通过COE(Coefficient)文件或.mem文件加载初始化数据。COE文件的格式很简单,我贴一个自己常用的写法:

memory_initialization_radix=16; 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 Type:Native
  • Memory Type:Simple Dual Port RAM(A端写、B端读,更贴近“写入后读取”的模型)
  • Port A:写端口,位宽32bit,深度256,使能方式Always Enabled
  • Port 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 <= 8'd0; wr_en <= 1'b0; end else if (wr_addr < 8'd255) begin wr_addr <= wr_addr + 1'b1; wr_en <= 1'b1; end else begin wr_en <= 1'b0; end end assign wr_data = {wr_addr[7:0], 8'hA5, 8'h5A, 8'hFF}; // 读地址与控制 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rd_addr <= 8'd0; rd_en <= 1'b0; end else if (wr_addr == 8'd255 && wr_en) begin rd_en <= 1'b1; rd_addr <= 8'd0; end else if (rd_en && rd_addr < 8'd255) begin rd_addr <= rd_addr + 1'b1; end else begin rd_en <= 1'b0; end end // 生成读有效标志(与读数据流水线对齐) reg rd_en_d1; always @(posedge clk or negedge rst_n) begin if (!rst_n) rd_en_d1 <= 1'b0; 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) 8'd0: rd_data <= 32'h0000_0001; 8'd1: rd_data <= 32'h0000_0002; 8'd2: rd_data <= 32'h0000_0004; default: rd_data <= 32'h0000_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加载的数据在最初几拍读出来是否正确。

上板调试我推荐用ILA(Integrated 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写数据到BRAM,PL侧用Vivado里的AXI BRAM Controller IP或直接访问BRAM的B端口。这里必须注意跨时钟域。如果PS侧写时钟和PL侧读时钟频率不同,或者相位不同,异步FIFO是最稳妥的方案。虽然Xilinx的BRAM支持独立时钟的两个端口(True Dual Port RAM),但异步读写时若不加同步处理,读端口采到半个字、或者拿到旧数据,是常见问题。

我在一个数据采集模块里遇到过,PS通过PCIe接口持续写入大块数据,PL侧以系统时钟去读BRAM。写入端时钟是125MHz,PL读时钟是100MHz,虽然读写频率差不大,但不做跨时钟域处理时,读出的数据会周期性地出现偶发错位。原因是写地址的格雷码和读地址的同步延迟在边界处产生了短暂不一致。最终解决方案,是在PL侧先用异步FIFO跨接,保证写入和读出序列不乱,再存储到BRAM中。

注意:BRAM IP本身不具备跨时钟域同步能力,它只是提供了两个物理上独立的时钟端口。如果你的读写时钟完全不同源,务必在逻辑层面对读写指针或握手信号做同步,否则数据完整性无法保证。

5.2 读写时序约束与关键路径优化

BRAM使用时序约束并不复杂,但忽略时序分析的后果很严重。BRAM IP生成后,Vivado会自动约束IP内部的内部路径,你只需要保证与BRAM相连的地址、数据、使能信号满足建立和保持时间即可。关键是检查Timing Summary,看WNS(Worst 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把数据从外部存储器搬入BRAM,PL再按数据包边界取走处理。这里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等资源时,会发现很多概念都是相通的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询