
开始学FPGA那阵子最让我头疼的不是语法而是搞不懂那些藏在芯片里的存储资源到底该怎么用。寄存器堆随便写随便读可一旦数据量上来LUT和FF就像流水一样哗啦啦地消耗掉用分布式RAM做FIFO时序稍微复杂一点就开始出问题。后来老老实实把Xilinx BRAMBlock RAM从原理到实操过了一遍才发现这东西才是FPGA里最值得吃透的存储资源。这篇笔记就把我当初用Vivado搭工程、例化Block Memory Generator IP、再用PLProgrammable Logic可编程逻辑把BRAM里数据读出来的全过程整理出来给同样卡在起步阶段的同学一个能直接照做的参考。这篇内容适合刚接触Xilinx FPGA、想把BRAM用起来但不知道从哪下手的同学也适合已经会用BRAM但没搞明白读时序、想弄清楚PL侧到底怎么把数据取回来的工程师。我尽量把“为什么这么做”也讲清楚而不是只丢一堆操作截图。读完这篇你能自己搭一个最小系统向BRAM写入一组已知数据然后用PL侧的状态机把数据读出来通过仿真波形或者ILA在线逻辑分析仪验证结果整个流程走通之后再去看复杂的AXI BRAM Controller、DDR控制器这些东西心里就有底了。1. BRAM到底是什么和寄存器堆有什么区别1.1 FPGA里的“内存条”BRAM的物理结构BRAM的全称是Block RAM是Xilinx FPGA内部专门用来做数据存储的硬件块。在7系列和UltraScale系列里一个BRAM的基本单位是36Kb也就是4.5KB通常还可以拆成两个独立的18Kb块来用。这些BRAM以列column的形式均匀分布在FPGA的时钟区域里和CLB可配置逻辑块、DSP Slice一起构成芯片的主体资源。你可以把BRAM理解成FPGA内部的“内存条”而CLB里的寄存器就是“CPU缓存”。寄存器用LUT和FF搭出来速度快、延迟低但每bit都要消耗逻辑资源BRAM是现成的存储阵列容量大、不占CLB但访问有固定的时钟节拍和延迟。选哪个本质上是拿资源换性能的权衡题。我在Zynq-7010上做过一个粗略统计用寄存器堆搭一个32bit宽、1024深度的RAM大概要消耗几百个LUT和FF布局布线后时序还容易崩换成BRAM两个RAMB36就搞定了时序余量也大得多。数据量一上来BRAM几乎是唯一解。1.2 三种端口模式单口、简单双口、真双口BRAM的端口模式在Xilinx文档里分得很细实际工程里最常见的是这三种单口RAMSingle-Port RAM只有一组地址线和数据线读和写共用同一个端口同一时刻只能做一件事。适合一边写数据一边读数据的场景但读写不能同时发生。简单双口RAMSimple Dual-Port RAM一个端口只用来写另一个端口只用来读两个端口可以独立时钟。这是FIFO、异步数据跨时钟域最常用的结构也是AXI BRAM Controller默认支持的模式之一。真双口RAMTrue Dual-Port RAM两个端口都能读也能写端口A和端口B完全独立可以配成不同的位宽和时钟。适合需要同时读写的复杂场景比如视频缓存、双核通信。我自己的经验是先用单口把BRAM的读时序弄明白再上真双口不要一上来就折腾复杂配置。因为BRAM读操作有固定延迟通常1到2个时钟周期端口再多本质还是那套“给地址等拍数取数据”的逻辑。1.3 为什么要用“PL读取BRAM数据”这个场景很多Zynq的教程讲BRAM一上来就是PS往BRAM写、PL往BRAM读扯出AXI接口、中断、DMA一堆概念初学者很容易被绕晕。我刻意把场景简化成“纯PL操作”在PL里例化一个BRAM IP写一段简单的Verilog控制逻辑把预先存进去的数据读出来。这样做的好处有三个第一能把BRAM IP的配置和时序彻底搞明白不会被总线协议干扰第二PL直接读BRAM是后续所有高级玩法的基础比如用ILA抓波形、用串口把数据发出去、和PS做交互都是在“读数据”这步之上加壳第三排查问题范围小——数据不对不是IP配错就是时序没对齐不会出现“PS和PL谁背锅”的扯皮。2. 项目整体设计最小可验证的BRAM读写系统2.1 系统架构与数据流拆解我设计的目标很简单例化一个位宽32bit、深度64的BRAM先在仿真阶段用初始化文件.coe文件往里面预置64个已知递增数据然后PL侧用状态机按照地址从0到63逐个读出这些数据。读出来的数值一部分接到仿真波形里观察一部分用ILA在板子上抓取。数据流长这样BRAM IP内部存储阵列 → 读数据端口 → PL读控制状态机 → 输出寄存器组 → 仿真/ILA验证。为什么要专门加一级输出寄存器这在后面讲IP配置时会详细说简单讲就是为了控制时序延迟同时避免读数据端口直接驱动外部引脚导致扇出过高。工程环境我用的是Vivado 2021.2器件选的xc7z010clg400-1也就是Zynq-7010。这套设计完全不依赖PS端所以用Artix-7、Kintex-7甚至Versal都能跑只要把器件型号换掉就行。2.2 方案选型用IP核还是手写BRAM原语Xilinx FPGA里的BRAM可以通过两种方式使用一种是直接例化原语如RAMB36E1全部端口信号手动连接另一种是用Block Memory Generator IP核图形化配置自动生成时序约束和仿真模型。直接写原语听起来很“硬核”但实际工程里基本不这么做。原因是BRAM原语的端口信号非常底层比如写使能、字节使能、输出寄存器使能、级联信号等手动控制这些信号很容易漏掉某个细节而且不利于后期维护。IP核把这些细节封装好了你只需要关心位宽、深度、端口模式、输出寄存器这几项关键参数。所以我的建议很明确用Block Memory Generator IP核但要把IP核生成的“Native接口”信号理解透。也就是不选AXI4接口而是选择Native接口模式这样信号的时序关系最简单最适合教学和验证。等这一步跑通了再切换到AXI BRAM Controller IP去对接总线也不迟。2.3 读写时序的关键认知BRAM的读时序有个非常容易踩坑的点读数据不是“给地址马上出数据”而是有固定延迟的。在Native接口下读数据和地址之间的延迟取决于IP配置里“输出寄存器”的个数不添加输出寄存器读数据比地址晚1个时钟周期添加一级输出寄存器读数据比地址晚2个时钟周期很多第一次用BRAM的同学在仿真里看到“地址都变了数据还没更新”就以为IP坏了其实不是这是BRAM的本质特性。写操作相对简单地址、写使能、写数据在同一时钟上升沿满足建立时间即可写入。但读写冲突、同地址先读后写这些场景需要额外小心。2.4 为什么要用COE文件预置数据为了验证BRAM读出来的数据是“对”的最稳妥的办法是在BRAM里预置已知的数据序列。Block Memory Generator支持加载COE文件Coefficient File文件中可以逐行定义每个地址的初始值。我一般用一个小脚本生成递增序列格式如下memory_initialization_radix16; memory_initialization_vector 00000000, 00000001, 00000002, ... 0000003F;这样BRAM上电后每个地址都存好了数据PL只需要按地址读出来再比对就行。比“现场写入再读出”少了一层不确定性——如果写入逻辑本身有bug你再怎么读都是白搭。先把读通路验证通过再添加写入逻辑是排查问题的黄金顺序。3. Vivado实操从建工程到跑通仿真3.1 创建工程与器件选型打开Vivado选择Create Project工程名我这里就叫bram_read_test。器件选择这一步要注意不同型号的BRAM数量不同但IP配置流程基本一致。如果你和我一样用Zynq-7010选择xc7z010clg400-1如果用纯逻辑器件xc7a35ticsg324-1L也是一个性价比很高的选择。创建过程中Vivado会问你“Do you want to specify a design source now”这里可以先跳过等IP例化完再添加顶层文件。工程建好之后第一步建议先把器件型号截图留档后面做时序约束或者看资源报告都要用。3.2 Block Memory Generator IP核配置要点在IP Catalog里搜索“Block Memory Generator”双击打开配置界面。需要注意的选项有Component Name填bram_core后面例化时就用这个名字。Basic界面Memory Type选择“Simple Dual Port RAM”。为什么不用Single Port因为后续如果要扩展到PL写、PL读的场景简单双口的端口天然分离写端口和读端口互不干扰泛化性更强。Port A Options写端口位宽32深度64。写操作不需要“读使能”吗在简单双口模式下写端口只有地址、写使能、写数据没有读端口。读使能在Port B里设置。Port B Options读端口位宽32深度64Enable Port Type选择“Always Enabled”这样读端口始终有效不需要额外的EN信号控制逻辑能少一根线。Output Register Options勾选“Primitives Output Register”让输出路径加一级寄存器。默认是Primitive Output Register Core Output Register两级我建议只选Primitive这一级。为什么因为BRAM读数据本身有1拍延迟再加核心输出寄存器就变成2拍仿真和板上时序虽然都能工作但多一级寄存器会让ILA抓波形时多猜一排。用最少的寄存器把功能验证清楚再按需增加流水级。配置完成后点击OK在IP Sources里能看到生成的bram_core.xci文件右键选择“Open IP Example Design”可以生成官方示例这个示例很适合观摩学习但一般不用来直接做设计。3.3 AXI与Native接口应该选谁Block Memory Generator在“Port A/B Options”里有一个大类选项Native接口还是AXI4接口。如果你选AXI4接口会在BRAM外面再包一层AXI协议逻辑PS端通过AXI总线访问BRAMPL侧也能通过AXI Master发起读写。这在Zynq的SoC设计中非常常用。但在这篇入门笔记里我强烈建议选Native接口。原因是AXI协议有大量握手信号AWVALID、WVALID、BVALID、ARVALID、RVALID等如果总线协议和BRAM时序混在一起出了问题很难分清楚是总线没握手上还是BRAM时序没对齐。Native接口只有时钟、地址、写使能、写数据、读数据这些最直接的信号状态机写起来也简单适合把BRAM本身的特性摸透。等哪天你需要PS和PL共享数据再来学AXI BRAM Controller的配置那时候反而事半功倍。3.4 顶层Verilog设计与状态机代码配置好IP后我写了一个简单的顶层文件top_bram_read.v。核心是一个状态机负责给BRAM的读端口送地址并等待数据返回。关键代码如下module top_bram_read #( parameter ADDR_WIDTH 6, parameter DATA_WIDTH 32 )( input wire clk, input wire rst_n, output wire [DATA_WIDTH-1:0] rd_data, output wire rd_data_valid ); localparam IDLE 2d0; localparam RED 2d1; localparam DONE 2d2; reg [1:0] state; reg [ADDR_WIDTH-1:0] addr_reg; reg [DATA_WIDTH-1:0] data_reg; wire [DATA_WIDTH-1:0] bram_rd_data; bram_core u_bram_core ( .clka(clk), // 写端口时钟 .wea(1b0), // 写使能本实验不使用 .addra(6d0), // 写端口地址空闲置0 .dina(32d0), // 写端口数据空闲置0 .clkb(clk), // 读端口时钟 .addrb(addr_reg), // 读端口地址 .doutb(bram_rd_data) // 读端口数据 ); assign rd_data data_reg; assign rd_data_valid (state DONE); always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; addr_reg 6d0; data_reg 32d0; end else begin case (state) IDLE: begin addr_reg 6d0; state RED; end RED: begin addr_reg addr_reg 1b1; data_reg bram_rd_data; if (addr_reg 6d63) begin state DONE; end end DONE: begin state DONE; end endcase end end endmodule这段代码的逻辑是复位后从地址0开始连续递增读取每个时钟周期读一个地址数据经过BRAM输出寄存器后在下一个周期被采样到data_reg。当地址走到63时状态机进入DONE停止读取。有一个细节需要注意bram_rd_data和addr_reg之间隔了几个周期从上面IP配置看我们只勾了一级Primitive Output Register所以从地址变化到数据稳定输出需要2个时钟周期1拍BRAM内部读取 1拍输出寄存器。因此data_reg采到的数据实际是addr_reg前两个周期的地址所对应的数据。如果你在仿真波形里发现“对不上”请先检查自己的状态机是否考虑了这个延迟而不是怀疑IP坏了。3.5 编写Testbench并运行仿真仿真文件我习惯单独建一个tb_top_bram_read.v里面只需要生成时钟和复位然后例化顶层模块即可。时钟周期我设为10ns100MHz复位信号低有效保持20ns后拉高。module tb_top_bram_read(); reg clk; reg rst_n; wire [31:0] rd_data; wire rd_data_valid; top_bram_read u_top ( .clk(clk), .rst_n(rst_n), .rd_data(rd_data), .rd_data_valid(rd_data_valid) ); initial clk 0; always #5 clk ~clk; initial begin rst_n 0; #20; rst_n 1; #500; $stop; end endmodule在Vivado里添加仿真文件后点击Run Simulation → Run Behavioral Simulation。在弹出的波形窗口里把addr_reg、rd_data、bram_rd_data、state拖进波形窗口观察。正常情况下你会看到addr_reg从0递增到63rd_data紧随其后出现递增序列0、1、2、3……直到63。如果你的波形里rd_data是错乱的不用急大概率是时序没对齐把地址信号和数据信号放在同一时刻观察数一下延迟拍数就能定位。4. 深入PL读取BRAM数据三种实用场景4.1 场景一状态机连续读取上面的顶层设计就是“状态机连续读取”的典型实现。这种方式的优点是逻辑简单、响应快时序完全由自己控制适合做查表、做协议解析、做数据流处理。缺点是没有总线接口其他模块想读BRAM必须通过这个状态机灵活性受限。在实际工程里我会把这种状态机改造成一个“带请求和应答的读取器”外部模块拉高rd_req状态机从指定地址读数据读完后拉高rd_valid通知外部取数。这种方式比裸地址读取更规范也更方便接口复用。你可以在我的顶层代码基础上加两个信号就行改动量很小。4.2 场景二通过AXI BRAM Controller访问如果你的设计里PL侧有一套AXI互联网络比如MicroBlaze软核、AXI DMA、PS端的AXI_GP接口那就要用AXI BRAM Controller IP把BRAM挂到AXI总线上。这个IP内部会处理AXI读通道AR/R通道和写通道AW/W/B通道与BRAM Native接口之间的时序转换。使用AXI BRAM Controller之后“PL读取BRAM数据”这件事就变成了“AXI Master发起读事务总线返回带READY/VALID握手的数据”。从宏观上看BRAM像一块内存映射的设备任何AXI Master都能通过地址访问它。这种方式适合多主设备共享数据但代价是每次读写都要走总线协议延迟比纯逻辑读大不少。如果你先掌握了Native接口的BRAM读写再看AXI BRAM Controller的配置手册会发现它内部就是一堆状态机和地址译码逻辑原理上并不神秘。我建议基础弱的同学至少完成场景一的实验再尝试场景二。4.3 场景三ILA在线逻辑分析仪抓取读数据仿真通过不代表板上就能工作这是FPGA开发的铁律。为了在板子上验证BRAM读出来的数据强烈建议学习使用ILAIntegrated Logic AnalyzerIP核。ILA的使用流程很简单在Block Design里添加ILA把待观测的信号比如addr_reg、rd_data、rd_data_valid连接到ILA的探针上设置采样深度为1024触发条件设为rd_data_valid上升沿。综合、实现、生成比特流后下载到板卡在Hardware Manager里打开ILA就能实时观察BRAM读端口的数据流。有一点要提醒ILA的探针信号如果直接连BRAM的doutb可能会因为信号名有总线前缀而连不上或者综合后被优化掉。解决办法是给信号加(* keep true *)综合属性或者在ILA配置里选择“Don not insert ILA”的调试核列表避免Vivado自动插入的调试信息和你手动加的ILA冲突。我在这上面栽过两次跟头现在做实验前都会先查一下综合后的Netlist里信号还在不在。5. 常见问题与排查技巧实录5.1 仿真波形里读数据总晚两拍是不是IP配置错了不是。这是BRAM的正常行为。你要做的是“顺着延迟去读”——状态机里提前把地址送出去或者用波形窗口里把addr_reg右移两拍再和rd_data对比。最简单的方法是在状态机里按“读地址→等两拍→采样数据”的节奏写而不是假设数据是组合逻辑输出。我当初犯的错是送完地址后立刻在下一个周期采样doutb结果采到的全是上一个地址的数据。后来把采样时刻往后推了一个周期瞬间全对。记住这句口诀给地址等拍子再取数。5.2 COE文件加载了但上电后BRAM里全是0先检查COE文件的格式是不是被IP正确识别。在Block Memory Generator的“Other Options”页签里能看到“Load Init File”复选框和加载的文件路径。如果文件路径里有中文或空格Vivado有时会加载失败。另外COE文件的最后一行的分号一定要有数值个数不能小于BRAM深度否则Vivado只加载前面部分地址。如果配置没问题可以在IP核的“Instantiation Template”里找到初始化参数的示例直接对比你的COE格式和官方模板有什么差异。这个方法救了我很多次。5.3 端口宽度不一致导致的“溢出”问题简单双口RAM允许写端口和读端口位宽不同比如写端口8bit、读端口32bit。这是BRAM的一个高级特性但也带来了地址对齐问题读写端口的地址对应关系不是简单的数字对应而是按“字节地址”对齐的。新手如果不在这个特性上做足功课很容易出现读出数据和预期不一致。我的建议是初学者一律把写端口和读端口位宽设置成完全一样地址一一对应避免一切对齐带来的困扰。等后面做异步FIFO、数据位宽转换时再回过头研究这个特性。5.4 板上验证时ILA抓不到任何跳变ILA抓不到信号跳变九成是时钟问题。确认ILA的采样时钟是否和BRAM读端口时钟同源如果不小心用了不同频率的时钟采样窗口和信号变化对不齐波形看起来就好像“卡住了”。另外检查复位如果你的复位在板上一直被拉低状态机永远停在IDLEILA自然抓不到数据。可以用一个简单的计数器确认时钟和复位是否正常再连BRAM验证。5.5 资源报告里BRAM数量和我预期不一致Block Memory Generator所生成的BRAM块数量取决于位宽和深度。一个36Kb BRAM在简单双口下可以配置成最大32Kbit即1Kx32但如果位宽是40bit就可能要分两个BRAM拼起来。当你发现BRAM占用突然翻倍时先别慌去IP配置界面看Synthesis报告里的“Number of RAMB36”统计和你的预期做对比通常能找出原因。我当时做64x36bit的FIFO发现两个BRAM但按容量算一个BRAM就够了后来查资料才知道位宽超过32bit后Xilinx会拼接BRAM实现属于正常现象。5.6 IP版本升级导致的代码兼容问题Vivado版本不同Block Memory Generator IP的端口名称和内部实现可能有细微差异。比如早期版本的doutb在某些配置下可以配置为无输出寄存器新版本把输出寄存器分成Primitive和Core两级后时序特性会变化。如果从旧工程复制IP和使用代码到新版本Vivado建议重新生成IP并对比端口列表不要直接用旧代码。升级Vivado之后IP显示“Out of date”也是家常便饭右键IP核选择“Upgrade IP”再重新生成Output Products这个问题就能解决。6. 从BRAM到片内存储体系下一步往哪走6.1 异步FIFOBRAM最常见的“马甲”BRAM在工程里最常见的应用是一个IP核包装过的异步FIFO。Xilinx提供了FIFO Generator IP核底层实际就是用BRAM实现的。它的读端口和写端口分别独立时钟内部通过指针格雷码做跨时钟域处理满空信号由逻辑判断产生。从我踩过的坑来看FIFO的关键在于“读写指针比较”和“空满信号产生时机”的准确理解。满信号拉高后写入端要尽快停写否则数据覆盖空信号拉高后读端不能继续读否则读出无效数据。先用BRAM做一次简单的同步FIFO实验再扩展到异步FIFO你会对BRAM有更深的体感。6.2 与PS端联动用AXI接口共享数据Zynq的亮点在于PS处理系统和PL可以协同工作。想把PL侧的BRAM数据交给PS读取最简单的方式是用AXI BRAM Controller IP把BRAM挂到PS的GP口上。这样PS端通过内存映射访问一段逻辑地址就能读到PL侧的数据。这种模式下BRAM的写端口可以交给PL逻辑控制读端口交给AXI总线控制两边互不干扰。如果你实验板上有串口还可以让PS在Linux里写一个简单的应用程序读取这段地址并打印数据真真切切实现“PL采集数据、PS显示数据”的系统级流程。6.3 超越BRAMUltraRAM与分布式RAMXilinx的部分高性能器件还提供UltraRAM资源容量比BRAM大得多比如UltraScale里的UltraRAM单块288Kb延迟和功耗也相对较高适合做大容量缓存。而分布式RAMDistributed RAM就是用LUT搭出来的存储容量小但灵活适合做小规模寄存器文件和移位寄存器。设计选型时的一个参考原则是数据量小于几百bit用分布式RAM几百bit到几Mb用BRAM几Mb以上考虑UltraRAM再往上就得外挂DDR颗粒了。分清楚这个层次你就能根据项目需求快速确定存储方案而不是拿寄存器硬扛或者一上来就上DDR控制器。7. 最后分享一点我的实操体会把BRAM用熟是FPGA从“跑马灯选手”进阶到“能做正经设计”的一道分水岭。我见过很多刚入门的同学在BRAM上卡了很久核心原因不是硬件难懂而是被一堆术语和边界情况吓住了。其实只要你亲手把“写数据进BRAM → 用PL读出来 → 用波形验证”这条路走完一遍后面再遇到FIFO、AXI总线、PS和PL交互都会轻松很多。我在实际调试中最常用的小技巧是写一个“回环测试”——先向BRAM全部地址写入地址相同的值再全部读出来比对结果只要有任何一位不一致就能用二分法快速定位是写路径坏了还是读路径坏了。这个办法比盯着波形猜效率高得多你也可以在自己的项目里试试。几个核心提醒再重复一遍配置IP时输出寄存器不要盲目多加仿真和板级验证要分别做COE文件格式务必检查ILA采样时钟和BRAM读时钟必须同源。把这些记牢你的BRAM之路就不会跑偏。下一篇“系列学习二”我打算写写真正双口RAM在跨时钟域里的应用以及在AXI4接口下BRAM的配置细节到时候见。