用DDR3乒乓操作根治FPGA视频帧撕裂问题

发布时间:2026/9/29 19:58:51
用DDR3乒乓操作根治FPGA视频帧撕裂问题 做FPGA视频处理最烦的就是画面撕裂。尤其是在摄像头采集、DDR3缓存、HDMI显示这条经典链路里你明明看到读写都在跑MIG也没报错但屏幕上就是有一条横贯的断层上半帧和下半帧根本不是同一个时间点的事。要根治这个问题绕不开两个词DDR3和乒乓操作。这篇我把自己用Verilog在FPGA上做帧级乒乓缓存、解决视频帧撕裂的完整思路写下来覆盖方案选型、乒乓状态机、地址映射、DDR3带宽估算以及我在调板时踩过的几个典型坑。适合已经调通MIG或UniPHY、正准备把图像采集显示链路彻底做稳的开发者也适合刚接触FPGA视频方向、想拿真实项目练手的朋友。1. 为什么视频帧撕裂会找上你1.1 撕裂的本质写端和读端在抢同一个地址先说人话。显示器是按行扫描的一帧画面从左到右、从上到下逐行显示。如果你的“帧缓存”只有一块摄像头/上位机正在往第500行写新数据而显示器刚好扫到第500行那屏幕上第500行后面的内容就变成了新帧前面还是旧帧视觉上就是一条水平断层这就是帧撕裂tearing。这个问题的根源不是DDR3慢也不是FPGA逻辑不够快而是读写两端在使用同一个FIFO或者同一片SRAM时没有做隔离。它本质上跟软件里两个线程同时读写同一个文件是一个道理有人一边写有人一边读读到的内容自然就是“新一半旧一半”的混合体。很多初学者以为是DDR3控制器的问题把MIG参数翻来覆去调甚至怀疑是时钟域没处理好。其实只要抓一下ChipScope/ILA里的读写地址就能看到问题非常规律读地址在某一行突然跳变因为那一行对应的缓存已经被写端覆盖了。1.2 为什么BRAM根本装不下完整的视频帧你可能想能不能不用DDR3直接用FPGA内部的BRAM做帧缓存算一笔账就明白了。拿1080P、RGB565格式来说一帧的数据量是1920×1080×2字节约4.14MB。Xilinx 7系列一片中等规模的芯片内部BRAM总量也就在几百KB到几MB级别而且你不可能把所有BRAM都拿去存帧逻辑、行缓存、FIFO都还要分走一部分。就算你能勉强塞下一帧双缓冲需要两份直接翻倍这就彻底没戏了。所以外部存储是必须的。DDR3容量大、带宽高、成本低是这个场景下最自然的选择。DDR4或DDR5当然也行但很多项目还在用DDR3而且从逻辑上看带宽余量完全够没必要为了“追新”去换更复杂的控制器。2. 方案选型DDR3加乒乓操作到底值不值2.1 乒乓操作是怎么把读写干掉的乒乓操作不是一个复杂概念它就是准备两块缓存A和B。某一时刻写入端只写A读取端只读B等A写满一整帧等显示端进入场消隐再把读写角色对调写端切到B读端切到A。如此循环。这样做的核心价值是显示端在整个扫描一帧的时间里永远读的是那块已经写完整、不会再被碰的数据。既然读端的数据不会被写端“搅和”撕裂自然就消失了。这里面有一个容易忽略的点乒乓切换不是随便切必须满足两个条件同时成立第一写端已经确认一整帧完整落盘到DDR3第二读端此刻在场消隐或者至少不处于有效像素输出阶段。如果这两个条件没满足切换时读端可能正好读到新旧地址交界的瞬间或者写端正在写一半就把bank换了反而会出现更奇怪的画面问题。2.2 双缓冲、三缓冲到底怎么选乒乓操作具体到“帧”这个粒度就是双缓冲。双缓冲会有一个固有特性写端在写下一帧时读端只能读上一帧所以显示延迟至少是一帧。对于摄像头监控这类场景一帧延迟几乎无感对于交互性很强的场景比如FPGA实现的低延迟画面控制就需要考虑三缓冲。三缓冲是准备三块帧缓存写入端永远写“最新空闲”的那块读取端永远读“最新完整”的那块中间还会留一块作为缓冲池。它的优势是写入端不一定要等读端读完整帧才能切换所以响应更快。代价是DDR3占用空间和带宽都增加而且控制逻辑更复杂需要在“三个bank选一个写、选一个读”之间做裁决。做项目时我的建议是普通显示、视频采集、工业相机预览双缓冲就够别给自己找麻烦只有当显示延迟直接影响操作手感时才考虑三缓冲。2.3 需要的DDR3带宽先算清楚再动手很多人一上来就担心DDR3带宽不够其实先冷静算一下。以1080P60、RGB565为例一帧是1920×1080×2字节约4.14MB60帧/秒写入带宽约248MB/s显示输出还要读一份同样大小的数据合计约496MB/s。DDR3-1600、16bit位宽的理论峰值带宽是1600MT/s×2字节也就是3.2GB/s。即使考虑MIG/UniPHY的协议开销、bank刷新、读写切换损失实际能干到50%效率也有1.6GB/s余量相当足。真正让你觉得“带宽不够”的通常不是DDR3本身不够快而是你的读写请求太碎。比如每次只写一个16bit像素DDR3会有严重的命令/数据开销效率可能连10%都不到。解决办法是攒够一整个burst再发请求尽量让读写成块、连续地发生。这一点在后面代码设计里非常重要。3. Verilog实现的关键环节3.1 乒乓状态机的设计别把切换条件写拧了整个方案的“大脑”是一个简单的状态机只需要三个状态空闲、等待切换、切换完成。下面给出一份精简但逻辑完整的Verilog实现重点注释了每个条件为什么必须存在。module pingpong_sm #( parameter H_ACTIVE 640, parameter V_ACTIVE 480 )( input wire clk, input wire rst_n, // 写端标志当前帧已经完整写入DDR3 input wire wr_frame_done, // 读端标志显示端当前处于场消隐 input wire rd_in_vblank, // 输出0表示BANK01表示BANK1 output reg wr_bank, output reg rd_bank, // 切换完成脉冲用于清空读写地址计数 output reg switch_pulse, // 至少两帧写完后才允许读防止开机读垃圾数据 output reg startup_ready ); localparam IDLE 2d0; localparam WAIT_CONFIRM 2d1; localparam SWITCH_DONE 2d2; reg [2:0] state; reg [1:0] frame_done_cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; wr_bank 1b0; // 上电先写BANK0 rd_bank 1b1; // 上电先读BANK1保证读写不撞车 switch_pulse 1b0; startup_ready 1b0; frame_done_cnt 2d0; end else begin switch_pulse 1b0; // 统计完整写完的帧数至少两帧后才允许读 if (wr_frame_done frame_done_cnt ! 2d2) frame_done_cnt frame_done_cnt 2d1; if (frame_done_cnt 2d2) startup_ready 1b1; case (state) IDLE: begin if (wr_frame_done rd_in_vblank startup_ready) begin // 双条件满足才切换写端已落盘读端在消隐 wr_bank ~wr_bank; rd_bank ~rd_bank; switch_pulse 1b1; state WAIT_CONFIRM; end end WAIT_CONFIRM: begin // 多等一拍让DDR3写管道上的残存请求排空 state SWITCH_DONE; end SWITCH_DONE: begin state IDLE; end default: state IDLE; endcase end end endmodule这份代码里有几个细节值得展开讲。第一上电瞬间写端和读端的bank不能相同。如果初始wr_bank0、rd_bank0读端会直接去读正在被写入的BANK0开机第一帧一定花。我习惯把读端初始化为BANK1即使BANK1是空的配合startup_ready信号也不会在数据有效前启动读取所以视觉上最多只是黑屏到正常显示不会出现花屏。第二切换条件必须是wr_frame_done和rd_in_vblank的“与”不是“或”。如果只等写端完成就切读端可能正在扫描有效行中间的某个位置切换后同一帧画面里会读到新旧两个bank的数据等于又制造了一次撕裂。如果只等场消隐就切写端可能还没来得及把最后一行的数据写完切过去之后读到的是半帧残缺数据。第三state多等一拍WAIT_CONFIRM表面上看是“多余”实际是在等DDR3写入管道上最后几个请求真正排空。DDR3的写数据通道有延迟wr_frame_done只是“所有像素已经进入写入FIFO”不代表DDR3物理颗粒已经写完。3.2 帧地址映射行、列、字节别算错乒乓状态机决定写哪一块bank还需要一个地址计算模块把“第几行第几列”换算成DDR3的字节地址。最容易出错的地方就在这里。我们按字节地址来算假设一帧是640×480、RGB565格式每像素2字节一帧字节数 640×480×2 614400字节换成十六进制是0x96000BANK0基地址定义为0x0000000BANK1基地址定义为0x096000帧内字节偏移 (行号 × 每行像素数 列号) × 每像素字节数对应到代码里可以这样实现parameter [27:0] FRAME_BASE0 28h0_000000; parameter [27:0] FRAME_BASE1 28h0_096000; reg [15:0] x_cnt; // 当前列号 reg [15:0] y_cnt; // 当前行号 reg [27:0] pix_byte_addr; always (*) begin // 乘法在低速时钟下没问题时序紧张时改成累加器 pix_byte_addr (cur_wr_bank ? FRAME_BASE1 : FRAME_BASE0) ((y_cnt * H_ACTIVE x_cnt) 1); end这里唯一要提醒的就是对齐。DDR3的读写请求是按突发访问的每个请求实际上最小是一个burst换算成字节与位宽强相关。比如32bit位宽的DDR3一个burst通常是8拍一次性处理32字节。所以你的frame基地址必须是32字节对齐。上面例子里614400除以32等于19200正好整除所以BANK1基地址没有对齐问题。实操中如果遇到不能整除的分辨率或格式比如某个自定义分辨率就要在帧尾补padding让每帧占用空间向上取整到对齐值否则地址会错乱画面就是隔三差五出现彩色横条。另一个常见陷阱是“行地址”的起始位置。显示输出时每一行都从第0列开始读写入时如果输入源自带行有效信号行计数也要严格从0开始。很多花屏现象查到最后其实就是某一次行计数器没有复位导致整帧首行地址偏移了一截。3.3 读写通路的流水线直接单像素读写DDR3是大忌乒乓状态机把bank分开了但如果读写DDR3的请求还是“一次一个像素”DDR3效率会低到让你怀疑人生。我见过有人写640×480的输出DDR3利用率只有10%画面一卡一卡的。正确做法是在DDR3外面包两层写侧FIFO和读侧行缓冲。写侧流程是这样的输入像素首先进入一个小FIFO攒够DDR3一个burst的数据量比如32bit位宽、8拍burst对应16个RGB565像素再一次性发起写请求。这样DDR3每次操作都是一整块效率立刻就上来了。读侧流程更讲究。显示控制器是逐像素输出的但DDR3一次读回来的是一大块数据所以必须中间加一个行缓存。典型做法是“双行缓冲”显示端从当前行缓冲逐像素输出下一行数据在后台从DDR3预取到另一个行缓冲。当一行显示结束两个行缓冲的角色互换。这样显示时钟和DDR3时钟即使不同步也不会出现显示输出断流。代码层面读侧地址生成通常做成“行预取”模式// 行预取读引擎的简化示意 reg [15:0] line_cnt; reg [15:0] pix_cnt; wire [27:0] rd_base cur_rd_bank ? FRAME_BASE1 : FRAME_BASE0; wire [27:0] rd_target rd_base ((line_cnt * H_ACTIVE pix_cnt) 1); always (posedge clk or negedge rst_n) begin if (!rst_n) begin line_cnt 16d0; pix_cnt 16d0; end else if (prefetch_en) begin pix_cnt (pix_cnt H_ACTIVE - 1) ? 16d0 : pix_cnt 16d1; if (pix_cnt H_ACTIVE - 1) line_cnt (line_cnt V_ACTIVE - 1) ? 16d0 : line_cnt 16d1; end end这个地址生成逻辑看起来简单但真正工程里你还要加一个“预取请求标志”只有当行缓存里剩余数据量低于阈值时才发起下一行预取。如果每行都固定提前一整行预取也行但在DDR3同时还要被写端占用的情况下最好根据FIFO的水位动态调整否则容易出现读请求排队、行缓存已经空了、显示端还没拿到数据的瞬间。4. 调试实录我遇到过的坑与排查方法4.1 开机第一帧花屏或画面错乱这是新手最容易碰上的问题。现象是上电后显示出来的不是干净的第一帧而是一堆乱码或以前残留的数据。原因一般有两个。第一DDR3上电初始化还没完成MIG的calib_done还没拉高你就开始发读写请求读回来的数据自然毫无意义。解决方法是所有读端、写端逻辑都要等calib_done拉高后再启动。第二乒乓状态机缺少“预热帧”机制。上电时BANK1是空的可是读端可能已经开始读。我们前面的状态机里专门加了两帧预热计数frame_done_cnt达到2之后才允许读就是为了避免读到空数据。如果没有这个保护显示出来就会是开机时的那一片闪花。4.2 切换帧的瞬间出现一条斜纹如果平时画面正常但每次帧切换那条线附近会闪一条斜纹大概率是切换时机有问题。最典型的是你只等了wr_frame_done没有严格等rd_in_vblank。你可以用示波器或者ILA抓三个信号wr_frame_done、rd_in_vblank、switch_pulse。把触发放到switch_pulse上看它拉高时rd_in_vblank是不是确实是高。如果发现switch_pulse经常在有效像素输出中段拉高那就要回头检查读端的场消隐信号是不是极性取反了或者你的“场消隐空闲”信号逻辑写反了。还有一种情况是DDR3读侧的行缓冲在切换bank之后没有立即清零。切换前读缓冲里残留了旧bank的数据切换后如果不清空显示端会先吐出一段旧帧的残影看起来就像切换位置有一条斜向拖影。4.3 DDR3带宽莫名其妙不够用画面整体卡顿、像素流断断续续多半是DDR3实际的写/读效率太低了。我处理过一个案子单看每一个读请求都正常但一帧数据显示需要的时间比理论值多了5倍最后发现问题是读请求太碎。显示控制器每需要一行数据就发一个小的突发请求而且中间夹杂着大量写请求DDR3控制器在读写之间频繁换向每次换向都有额外延迟。解决思路就是看ILA里的app_rdy、app_en和app_cmd。统计一下一秒钟内DDR3实际有效读写的周期数再和理论带宽对比。如果端到端效率低于50%优先做两件事第一在DDR3控制器外面加读调度器和写调度器让读请求和写请求分别排队尽量按“读多拍、写多拍”成组切换第二加大行缓存的深度让每行数据能通过一次更大的DDR3突发读回来而不是分好几次小突发。4.4 DDR3布线引发的“灵异问题”这不是逻辑层面能完全解决的但做FPGA视频项目躲不开。DDR3跑在800MHz甚至更高频率时PCB布线直接影响稳定性。常见表现是常温下测试全通过机箱温度一上来就随机花屏或者同一套逻辑在这个板子上正常换一块板子就崩。DDR3布线要特别注意数据线按byte lane分组DQS和对应的DQ保持等长地址线和控制线做“树形”或“菊花链”端接VREF要就近可靠滤波原理图里每一颗VREF退耦电容的位置、GND回流路径都要仔细核对。这些在芯片手册和几家存储颗粒厂商的应用笔记里都写得很明确做FPGA开发的人如果同时负责PCB一定不要在这上面省时间如果只做逻辑至少要和硬件工程师确认DDR3的等长约束和阻抗控制有没有做扎实否则后面逻辑再对也会被硬件坑一票。5. 把乒乓缓存做成可复用的模块5.1 封装成AXI4接口省掉重复劳动第一版代码你可能直接写在顶层能跑通但项目一多就很麻烦。我通常会把乒乓控制、写地址生成、读行缓冲这三块单独封装对外暴露一个精简的AXI4接口。比如把写端封装成AXI4 Full Master读端封装成AXI4 Full Master中间每个模块只关心自己的地址、数据、握手信号。这样不管是接MIG、接DDR3还是后续想换DDR4控制器顶层几乎不用动。封装时有个原则不要把乒乓状态机埋在很深的层次里。它应该处在一个“能看到整帧调度”的位置因为帧完成标志需要综合行计数场消隐标志需要综合读端时序这两类信息分散在两个时钟域里集中在一个模块里处理会更清晰。5.2 拓展方向从双缓冲到多缓冲再到多路视频这套结构稳定以后还能往几个方向扩展。一个是三缓冲解决延迟敏感场景下的卡顿感。实现上需要把2个bank变成3个bank写端永远选择“当前没有在读、且数据已陈旧”的那个bank读端永远选择“最新完整”的bank。控制逻辑比双缓冲复杂但原理相通。另一个是多路视频同时输入输出。摄像头越来越多如果一路视频处理用一对乒乓两路就要两对。这时候建议你把DDR3空间划分成多个区域然后做一个统一的写仲裁器和读仲裁器每一路视频对应一组bank描述符。硬生生复制两份乒乓逻辑也能跑但资源浪费大也不可能扩展。最后就是把分辨率参数做成寄存器可配。比如H_ACTIVE、V_ACTIVE、每像素字节数全部变成配置寄存器这样同一个工程可以兼容720P、1080P只要DDR3空间够还能平滑切换。但要注意每帧大小必须对DDR3对齐否则切换分辨率后地址错位那种bug相当隐蔽。我个人在实际项目里还有一个习惯任何视频缓存类顶层都会在仿真阶段专门构造一个“慢写入”场景把输入像素率故意压到DDR3带宽的一半以下再在切换点附近打上断点看关键信号。很多问题在连续播放时很难被肉眼发现但在慢速仿真里一次就能看出状态机在哪一拍出了问题。这个方法帮我省过不少上板时间你也可以试试。