ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

FPGA图像处理必看:纯Verilog实现DDR3多通道读写控制器设计与实战

FPGA图像处理必看:纯Verilog实现DDR3多通道读写控制器设计与实战 做FPGA图像处理的朋友早晚都得面对DDR3这堵墙。流水线里做个缩放、sobel都还好说一旦视频源有两个、分辨率上到1080P60板上那几颗DDR3就成了绕不开的硬骨头。我这次做的是一个纯Verilog的DDR3多通道读写控制器不依赖厂家的MIG生成器专门拿来给多路图像数据做帧缓存和搬运。文章里会把我自己设计这个控制器时的架构思路、DDR3时序要点、仲裁策略、Verilog实现细节、仿真调试方法以及布线层面的坑都拉出来聊一遍如果你正在做FPGA图像采集、视频拼接、多窗口叠加这类项目这篇应该能给你省下不少查资料的时间。1. 这个DDR3控制器到底要解决什么问题1.1 多通道图像数据的真实压力先算一笔账。一路1080P60灰度图像数据量大概是1920108060约124MB/s。如果是RGB888乘个3就是373MB/s。两路720P60的RGB画面再加一路OSD叠加层轻松超过500MB/s。DDR3芯片的理论带宽看起来很吓人16bit位宽跑到800MT/s也有1.6GB/s但实际可用带宽要打折。因为DRAM要刷新、预充电、行切换命令与命令之间还有各种时序间隔。如果控制器写得粗糙频繁换行、频繁bank冲突实际带宽能掉到理论值的一半以下。这个时候多通道之间就开始互相抢时间表现就是画面撕裂、帧率上不去、写入丢数据。我这次做的控制器核心目标就是把这500MB/s级别甚至更高的多路图像数据流稳定地搬进DDR3里再按需读出来。控制器提供多个独立的读写通道每个通道接口统一上层图像模块不需要关心DDR3的bank、行、列、刷新这些事。1.2 为什么不用MIG非要自己“纯Verilog”造轮子Xilinx的MIG生成器很好用但碰上多通道自定义场景也会别扭。第一个问题是接口风格受限MIG的用户接口本质是“读写命令数据”的单通道流你要多路视频流还得自己在外面包一层仲裁和FIFO。第二个问题是时序封闭和引脚锁定由MIG说了算想移植到别的板卡或者换个DDR3颗粒经常要重新生成、重新跑。第三个问题是我自己的私心MIG内部被包得严严实实出了问题只能看波形猜很难真正理解DDR3协议。所以这次项目决定自己写控制器。注意我这里说的“纯Verilog”是指控制逻辑完全自己写命令状态机、仲裁调度、数据通路、FIFO管理这些全都用Verilog实现。物理层要上FPGA的专用IO资源ISERDES、OSERDES、IDELAY这些还是得调原语不然DDR3这种高速双沿接口很难在一个普通周期里稳定工作。这不丢人反而说明你清楚哪些事情该自己做哪些事情必须交给硬件原语。自研控制器最大的收益是接口完全可控。我可以在每个通道上定义自己想要的AXI-Lite风格请求应答也可以简化成“写请求数据”、“读请求返回数据”两套握手。对图像模块来说只需要管自己的像素时钟域把数据往FIFO里一塞然后发起读写剩下的事情控制器全包。1.3 模块划分和总体数据流整个控制器的模块划分按数据流方向分成四层。最顶层是ddr3_multi_ch_ctrl对外暴露多通道用户接口和DDR3物理引脚。第二层是通道适配层每个通道带异步FIFO解决图像模块自己的时钟域和控制器公共时钟域的跨时钟问题。第三层是核心的仲裁调度和命令生成模块负责把多个通道的读写请求转成DDR3命令序列。最底层是DDR3物理层包含命令/地址输出、数据读写、DQS处理、数据延迟对齐。图像通道0 ── 写FIFO ──┐ 图像通道1 ── 写FIFO ──┤ 通道0读取 ── 读FIFO ──┤── 写/读仲裁器 ── 命令状态机 ── DDR3 PHY ── DDR3颗粒 通道1读取 ── 读FIFO ──┘这样的结构有个很明显的好处软件上层不需要看到DDR3细节。你要送一帧图像进来就告诉控制器“目标地址在0x1000长度是512个突发数据来了”控制器自己判断最近哪个bank是可用的、要不要先预充电、要不要插一笔刷新。如果哪路OSD要读数据也走同样的通道接口互不干扰。实际码代码的时候我会把通道接口设计成跟AXI4比较接近的“ADDR通道写数据通道读数据通道”三通道分离方式只是砍掉了地址突发信号因为图像应用基本不需要任意长度突发固定长度一次发64字节到128字节就够用了。2. DDR3协议里那些绕不开的时序约束2.1 命令、bank状态和关键时序参数DDR3不像SRAM那样给地址就出数据。一次完整的读操作基本流程是激活ACT打开某个bank的某一行然后等tRCD再发读命令RDCAS延迟之后数据才出来。写类似发写命令后要等tWR和后续时序。整个流程里最核心的是维护好bank行状态表。举个实际例子控制器工作时钟假设是200MHzDDR3颗粒工作在1600MT/s内存时钟400MHz指令周期和用户时钟周期就是1.25ns和5ns的关系。颗粒的关键时序参数大约是这样参数含义典型值折算到200MHz时钟周期tRCD激活到读写命令间隔13.75ns3个周期tCLCAS潜伏期11个内存时钟换算到用户时钟需要额外处理tRP预充电时间13.75ns3个周期tRAS激活到预充电最短间隔35ns7个周期tRC同一bank两次激活最小间隔48.75ns10个周期tRFC刷新所需的阻塞时间160ns32个周期从表格能看出一个设计要点同一bank的操作间隔被tRC锁死了。比如连续读同一个bank的不同行必须先预充电再激活再读光是命令上的开销就要十几个周期。如果好几个通道的图像数据正好落到同一个bank的不同行控制器效率会非常难看。所以后来我在地址映射上动了心思不同通道的起始地址尽量分散到不同bank靠bank并行来隐藏tRAS和tRP。控制器内部需要给每个bank维护至少三个状态空闲、行已激活、预充电中。我用一个二维寄存器数组bank_row_open[8]保存当前打开的行号再用一个bank_state[8]表示状态机的运行状态。仲裁器在选择下一个命令时要根据目标bank状态做判断能直接发读写命令就发不能就插一笔预充电或者激活。2.2 刷新策略不能只想着定时DDR3要求64ms内完成8192次刷新折算下来刷新间隔约7.8us。在很多demo工程里大家做法就是起一个计数器到时间就插一笔刷新命令。这在普通读写压力下没问题但图像数据是高强度持续读写刷新命令一旦插入必须等tRFC走完命令通道和data总线全部暂停对带宽影响不小。我做的处理方式是“提前插空”加“紧急抢占”。平时仲裁器空闲时立刻把已到期的刷新补掉。如果刷新时间快到了仲裁器会优先把当前突发送完然后在bank刚好预充电完成的时候插入刷新避免刷新过程中还要额外等待tRP。实际代码里维护一个refresh_timer到阈值附近就提高刷新请求优先级这样既不会丢刷新窗口也不会让图像通道出现明显的帧停顿。还有个小细节DDR3的刷新分REFRESH和REFRESH_PB设计中可以结合bank状态尽量安排在bank空闲时刷新。自研控制器的好处就在这里你可以把刷新当成一个普通的仲裁请求让它跟读写命令一起排队灵活度比厂家IP高很多。2.3 用户接口与DDR3命令的映射关系用户在上层看到的是“逻辑地址”而DDR3物理地址由bank、row、column组成。芯片内部可以自动完成地址映射控制器则需要自己算。我通常采用这样的映射地址低位映射到column宽度由突发长度决定BL8一次访问8个内存字低3位需要保持为0中间位自然散列到bank保证连续图像数据在不同bank之间轮转高位映射到row代表这一行在bank里的行地址。这样设计以后连续写入一帧图像数据会均匀分布在8个bank里。控制器在写下一段时bank已经处于打开状态的概率就高能尽量减少预充电和激活次数。实测下来同样总吞吐量这个映射比“bank在低位不散列”的版本效率要提高将近15%。3. 多通道读写调度器设计3.1 先分清每路的实时性要求多通道图像应用里各通道对延迟和带宽的要求不一样。实时采集进来的图像写DDR3最怕丢数据比如CMOS传感器的数据一帧接一帧连续来FIFO深度又有限写请求不能被耽搁太久。显示输出从DDR3读数据最怕断流一旦读不到数据屏幕上就会出现闪烁或者花屏。而像OSD图层这类更新频率很低带宽要求不高但一旦发起请求也希望能尽快完成免得和主画面抢时间。所以仲裁器不能简单做成完全平等的轮询。我定义了三级优先级优先级通道类型说明高实时写入通道数据来自传感器几乎不可暂停高实时读通道显示输出需要稳定数据流中普通读写通道处理模块的中间结果低配置/后台通道寄存器读写、帧统计等高优先级通道之间用时间片轮转避免某一帧数据量特别大时饿死其他高优先级通道。中低优先级通道则在高优先级空闲时插入执行。为了不让低优先级通道等太久我加了一个starve_timer超过一定时间没被调度到就强制插队一次。这里的取舍很关键。如果你把所有通道当成同一优先级图像传输可能抖动如果你把某一通道优先级拉太高另外几路就会出问题。实际调试时我用ILA抓仲裁器输出发现有一路高分辨率视频写入时另一路读通道的r_valid长时间拉不低后来就是加上starve_timer解决的。3.2 轮询、优先级和时隙三种方案怎么选仲裁策略有几种常见方案。最简单的固定优先级代码好写但低优先级通道容易饿死不推荐用在多路视频流上。纯轮询Round Robin实现也不复杂每个通道平均分配带宽但实时性差的通道会拖慢别人。还有一种时间片仲裁把DDR3带宽按比例预分配比如通道0拿60%、通道1拿30%、通道2拿10%实现上稍微复杂但最贴近图像应用的带宽需求。我最终用的是“优先级轮询饥饿保护”的混合仲裁。每一轮仲裁开始时先检查有没有高优先级请求如果有就在高优先级请求集合里轮询。如果只有中低优先级请求依然轮询但记录等待周期数等待过久的请求会临时提升优先级。这样既保证了实时通道的低延迟也避免了某一非实时通道永远等不到的问题。代码上我用了一个很朴素的req_array和grant_id每一拍更新仲裁结果。仲裁器不直接下发DDR3命令只负责选出一个待执行请求然后交给命令状态机去处理。状态机如果判断目标bank需要预充电就先插入预充电需要激活就先插入激活。仲裁器和状态机分离好处是逻辑清晰后期想改调度策略不用动DDR3时序相关代码。3.3 刷新命令怎么插入调度上面说过刷新不能简单定时。我在仲裁器里把刷新也当成一个虚拟请求优先级设为最高但有一个小技巧当refresh_timer快到期时仲裁器会优先选一个bank状态为空闲的刷新请求如果所有bank都忙那就先让当前命令完成同时把预充电命令提前发出去确保在刷新前bank都已经回到idle状态。实际工程的坑在于tRFC长达160ns折合200MHz时钟就是32拍。如果刷新期间正好有图像数据涌进来FIFO就可能溢出。我的做法是给每个写通道的FIFO深度留够按最大写带宽乘刷新阻塞时间再加上余量动态计算深度。比如某通道突发吞吐率是200MB/s刷新阻塞约190ns再加上前后切换时间中间最多有约38字节数据积压FIFO至少留64字节才安全。你别小看这个计算很多丢数据问题根本不是带宽不够而是FIFO深度没cover住刷新周期。4. 核心Verilog实现细节4.1 顶层接口定义顶层接口设计时我刻意让它简单直白图像模块只认识“写请求/写数据/写应答”和“读请求/读返回”。每个通道带上独立的FIFO接口上层只管按自己的像素时钟读和写。module ddr3_multi_ch_ctrl #( parameter NUM_CH 4, parameter ADDR_W 27, parameter DATA_W 64, // 内部数据总线宽度 parameter STRB_W 8 )( input wire ctrl_clk, // 控制器公共时钟 input wire ctrl_rst_n, // 通道写地址/数据接口 output wire [NUM_CH-1:0] aw_ready, input wire [NUM_CH-1:0] aw_valid, input wire [NUM_CH*ADDR_W-1:0] aw_addr, input wire [NUM_CH*8-1:0] aw_len, output wire [NUM_CH-1:0] w_ready, input wire [NUM_CH-1:0] w_valid, input wire [NUM_CH*DATA_W-1:0] w_data, input wire [NUM_CH*STRB_W-1:0] w_strb, input wire [NUM_CH-1:0] w_last, // 通道读地址/数据接口 output wire [NUM_CH-1:0] ar_ready, input wire [NUM_CH-1:0] ar_valid, input wire [NUM_CH*ADDR_W-1:0] ar_addr, input wire [NUM_CH*8-1:0] ar_len, output wire [NUM_CH-1:0] r_valid, output wire [NUM_CH-1:0] r_last, output wire [NUM_CH*DATA_W-1:0] r_data, output wire [NUM_CH-1:0] r_error, // DDR3物理引脚 inout wire [15:0] ddr3_dq, output wire [13:0] ddr3_a, output wire [2:0] ddr3_ba, output wire [2:0] ddr3_bg, output wire [1:0] ddr3_cke, output wire [1:0] ddr3_cs_n, output wire [1:0] ddr3_odt, output wire [3:0] ddr3_dm, inout wire [3:0] ddr3_dqs_p, inout wire [3:0] ddr3_dqs_n, output wire ddr3_ck_p, output wire ddr3_ck_n, output wire ddr3_reset_n );这里内部数据总线取了64bit对应BL8一次读8个16bitDDR字。用户接口的地址我统一用“内部数据宽度对齐”就是说aw_addr的低3位必须为0一个aw_len单位代表64字节。图像模块如果只写32bit像素上层要做一次拼包把两个像素拼成64bit再发过来。别嫌麻烦拼包逻辑很便宜换来的却是内部数据通路的整洁。4.2 命令状态机的关键实现命令状态机是整个控制器的心脏。我在设计时把它分出几个主状态IDLE、ACT、RD、WR、PRE、REF、WAIT。仲裁器每次给状态机一个目标命令状态机结合当前bank状态决定走哪条路径。localparam S_IDLE 4d0, S_ACT 4d1, S_RD 4d2, S_WR 4d3, S_PRE 4d4, S_REF 4d5, S_WAIT 4d6; reg [3:0] state, next_state; always (posedge ctrl_clk or negedge ctrl_rst_n) begin if (!ctrl_rst_n) state S_IDLE; else state next_state; end always (*) begin next_state state; case (state) S_IDLE: begin if (refresh_req refresh_allowed) next_state S_REF; else if (arb_grant_valid) begin if (arb_is_write) next_state S_WR; else next_state S_RD; end end S_WR: begin if (write_cmd_done) next_state S_PRE; end ... endcase end写命令下发的时候必须确认写数据已经在数据总线上准备好。DDR3要求写数据与写命令之间有一定对齐关系控制器要向数据通路发出wr_data_launch信号。我这边数据通路的做法是写FIFO输出数据先在用户时钟域组装成64bit命令状态机在S_WR状态下同时拉高ddr3_cs_n、ddr3_we_n、ddr3_cas_n、ddr3_ras_n形成写命令并同步拉高数据有效信号驱动OSERDES把数据在内存时钟的上升沿和下降沿分别送出。读取命令则要特别注意CAS潜伏期。为了简化我没有做前置重排序而是固定采用一个“命令发出后N拍读数据返回”的模型。实际使用时这个N要通过训练确定。在FPGA上我用了一个rd_latency_cnt计数器读命令发出后开始计数计数到预置值时从ISERDES的输出寄存器里锁存数据。只要DQS和时钟的相位关系校准好这个固定延迟模型非常稳定。4.3 写数据通路与字节掩码处理图像数据进入DDR3前要过异步FIFO把像素时钟域搬到控制器时钟域。异步FIFO我建议自己实现或者例化厂商FIFO关键是写侧时钟和读侧时钟差别很大一个几十MHz一个200MHzFIFO深度必须留足。之前说过刷新阻塞的问题在这个环节体现得最明显。我一般把写FIFO深度设为512到1024按64bit宽度算每个FIFO能存4KB到8KB数据足够扛住两三次刷新窗口了。字节掩码在图像场景经常被忽略。比如把一块小图贴到已有背景上或者做画中画叠加往往只需要覆盖目标区域的一部分字节。DDR3写命令自带DQM信号可以在突发写时屏蔽不需要的字节通道。我在写数据通路里把每个通道的w_strb直接映射到DDR3的DQM引脚这样上层做像素混合时可以直接利用DDR3的写入屏蔽省掉先读后改的一次搬运。有个细节是端序。来自CMOS的RAW10数据经常是打包成20bit一个像素拼到64bit总线上时字节顺序和DDR3内部bank存储顺序容易搞反。我Debug时遇到过显示画面颜色通道错位的现象查半天发现是拼包时高低字节反了。这种问题纯看波形很难立刻定位建议在上层设计时就固定好字节序约定比如以大端方式把左边像素放高字节并写在接口文档里。4.4 读数据通路与DQS相位对齐DDR3读路径上最麻烦的是DQS数据选通信号的相位对齐。DDR3在读取时颗粒会把DQS和数据一起返回但DQS相对内存时钟的相位并不固定。FPGA内部需要用IDELAY把DQS调整到数据眼图中心再通过ISERDES在DQS上下沿采集数据。这个部分是整个设计里我最不想“手撸”的地方。如果你的FPGA平台支持我建议DQS延时用IDELAYCTRL和IDELAY原语然后写一个简单的查找表训练流程。启动后先读一段训练用pattern比如0xAAAA、0x5555或地址回环数据扫描IDELAY的tap值找到采样窗口最稳定的区间再把这个tap值固定下来。时序训练完成后读数据会有一个固定的延迟我把它折算进rd_latency_cnt。这个延迟值甚至可以通过读寄存器动态调整但并不需要在每次上电都做二次训练。普通场景下只要板卡电压和环境温度不是极端变化固定tap值完全够用。读数据返回还有一个对齐问题。第一次接DDR3时我发现读回来的数据总是整帧循环右移一个64bit。原因很隐蔽读数据经过ISERDES后在DDR接口时钟域里多了一拍我的读FIFO写使能没跟着多延一拍导致第一个数据被丢掉。后来用ILA抓到读数据总线上第一个有效拍和FIFO写使能的相对位置发现差了一拍修正后问题消失。这个坑很多新手会踩建议调试时先不要看DDR3内容直接观察读数据总线在r_valid拉高前后的几个周期把延迟建模搞准。5. 仿真验证和硬件调试5.1 搭建DDR3仿真模型验证仲裁和状态机写DDR3控制器一定不要拿到板子上才调试。DDR3颗粒都有丰富的仿真模型比如Micron的ddr3_model可以直接在ModelSim或者Vivado Simulator里跑。我会搭一个简单的测试bench例化控制器和DDR3模型然后写多个c测试任务分别模拟两路写、两路读以及读写并行。测试时重点看三件事第一写数据是否按地址正确落到DDR3第二读请求发出后返回数据是否与写入内容一致第三连续威胁性的读写压力下刷新命令能否正常插入且不丢数据。仿真阶段最值得做的是“随机地址随机长度”的压力测试。别只按顺序地址跑图像数据在实际场景里是分块存储的地址跳变很频繁。随机测试能暴露仲裁器对bank冲突处理的缺陷也能暴露命令状态机在临界时序下卡死的问题。我有一版仲裁逻辑就是在随机测试里发现会连续给同一个bank发命令导致tRC违例仿真直接报warning后来修改调度条件才通过。5.2 用ILA抓取控制器内部信号的技巧上板调试时ILA是神器。但别把ILA例化在顶层padding整个DDR3总线那样代价大、触发也不方便。我建议例化在仲裁器输出和命令状态机入口处观察grant_id、target_bank、state、refresh_timer这几个信号就够了。写过aw_valid、aw_ready这类握手信号时同样要留意。ILA里看控制器的响应是否及时能直接发现“某通道请求一直得不到grant”的问题。我调试多通道画面撕裂时就遇到过一路写通道占了grant很长时间另一路读通道被拖到断流。从ILA波形上看grant_id长时间停在通道0加上饥饿保护后才恢复轮询。如果设计里还有r_error信号在调试版本里可以把它接到一个GPIO或者LED。同步到用户逻辑后一旦DDR3读写出错就能立刻知道省得整块板子跑半天才从画面上猜问题。5.3 时序约束和DDR3布线规则这部分的最后一块拼图是硬件层面。FPGA工程的时序约束对DDR3接口极其重要。控制器内部可以跑200MHz引脚上却是400MHz双沿IO时序余量很紧张。需要在XDC里正确创建合成时钟把DDR3的ddr3_ck_p/ck_n约束为400MHz时钟输入输出延迟约束按DDR3规格和PCB走线长度估算。DDR3布线规则我这里划几个重点。DQS差分对内要做到严格等长一般配对长度差控制在5mil内。DQ每一组字节通道和对应DQS要尽量等长整组长度差控制在20mil内。地址、命令、控制信号最好与DQS组长度匹配至少做到同层参考地完整。末端匹配电阻、VREF电容、去耦电容和电源纹波这些细节直接决定你上板之后能不能一眼过时序训练。有条件的板卡还会给DDR3做电阻端接或者片上ODT设置。FPGA里ODT模式可以通过配置引脚或者写MR寄存器选择默认设置不懂的时候别乱改。我遇到过一块板子DDR3颗粒是x16的但SDRAM的ODT配置没对上导致读数据眼图很差后来翻芯片手册逐位确认才解决。6. 常见问题与排查技巧现象可能原因排查方法读回来的数据整体错位读返回延迟建模不对ISERDES输出少打一拍ILA抓读数据总线和FIFO写使能检查首拍对齐某通道写入丢数据、画面卡顿写FIFO深度不足刷新或bank切换期间溢出减少FIFO写使能频率观察FIFO几乎满信号写数据后读出来有一个字节错位拼包字节序不一致用固定pattern写入读回后和发送端比对多通道中某路长期得不到调度仲裁策略没有饥饿保护在仲裁器添加等待计数超时强制提升优先级上板后DDR3训练始终失败板级时序不满足ODT/端接配置不对检查DQS和DQ等长、VREF电压、IDELAY扫描范围刷新期间FIFO溢出刷新和仲裁配合不够默契让刷新请求提前仲裁把刷新放在bank空闲时插入顶层时序无法收敛跨时钟域处理不当或者命令状态机逻辑过长检查异步FIFO同步优化仲裁器组合逻辑流水你看很多问题其实在仿真阶段就能提前暴露剩下的才需要上板熬夜查。我个人的习惯是“仿真跑不通过绝不上板”虽然是笨办法但能省下大量调板时间。这里再分享一个很实用的自校验技巧。在工程里我加了一个测试寄存器能往DDR3的任意地址写入一串伪随机数然后读回来做CRC校验。这个自校验模块平时不参与工作需要时通过UART触发。这样调试的时候不需要依赖完整的图像通路只要控制器本身能稳定读写DDR3基本可以放心去查上层图像逻辑。做这个控制器的过程中我最大的体会是DDR3控制器的难点不在某个单一模块而在“所有模块必须同时正确”这件事上。仲裁策略稍有瑕疵可能只是偶发卡顿DDR3时序差一拍画面立刻花掉FIFO深度不够压力峰值一来就丢数据。每一个设计决定都会在最终的系统稳定性上体现出来。如果你也在做类似的东西建议先把模块接口定义清楚再一步一步从仿真到上板推进不要一上来就想把多通道、刷新、训练全部一次做对。分阶段验证踩的坑会少很多。
返回列表