易灵思FPGA双线性插值缩放实战:DDR3帧缓存与Verilog实现
简介:这份资源面向具备Verilog基础与数字视频处理经验的中高级FPGA工程师及科研人员,提供一套基于易灵思TI60F225平台、纯Verilog实现的双线性插值视频缩放方案,支持HDMI输入输出任意比例缩放,可解决低成本平台上高效缩放与商业IP替代问题。压缩包共5个文件,约289KB,包含docx设计说明、pdf文档、html与md源码说明及txt补充资料,覆盖算法原理、定点数优化、DDR3帧缓存与环形行缓存等关键模块。已有277人学习。读者可从中掌握以定点运算替代浮点、直接操作DDR3物理层接口提升吞吐、双口RAM行缓存实现流畅数据流控制,以及参数化设计增强跨分辨率可移植性等技巧,并理解跨时钟域处理与资源优化策略,便于在其他FPGA平台移植扩展。
1. 易灵思 FPGA 上跑双线性插值缩放:为什么 DDR3 才是那个隐藏主角
手里有一块易灵思(Efinix)Trion 或 Titanium 系列的板子,摄像头进来的视频流分辨率跟显示屏对不上,想做个实时缩放,很多人第一反应是「双线性插值嘛,四个点加权平均,Verilog 写个状态机就完事」。真上手才发现,算法本身两小时能写完,卡你三天的是数据从哪来、往哪放、带宽够不够。易灵思 FPGA 的片上 BRAM 通常只有几百 Kbit 到几 Mbit 级别,一帧 1080P 的 RGB888 就是 1920×1080×3 字节,接近 6MB,片上根本放不下,必须外挂 DDR3 做帧缓存。标题里「DDR3」不是配角,它决定了你的缩放系统能不能跑满帧率、会不会撕裂、会不会因为读写冲突直接花屏。这篇面向的是已经会写 Verilog、手里有易灵思开发板、想把视频缩放这条链路真正跑通的工程师,从 DDR3 控制器选型一路讲到双线性插值的定点化参数和跨时钟域排坑。
2. 先想清楚数据怎么流:DDR3 帧缓存与双线性插值的耦合关系
2.1 为什么双线性插值对 DDR3 的访问模式特别挑剔
双线性插值的核心是:目标像素 (x, y) 映射回源图坐标 (sx, sy),取它周围四个整数像素 P00、P10、P01、P11,按小数部分做两次水平插值加一次垂直插值。问题在于,当缩放比例不是整数时,相邻两个目标像素映射到的源坐标可能跨行、跨列,甚至来回跳。如果每个目标像素都独立去 DDR3 里读四个点,一次读操作按 DDR3 的 burst 特性至少搬 8 个数据,实际利用率极低,带宽瞬间被打爆。
常见做法是行缓存(line buffer)架构:DDR3 里按行顺序读出源图,每读进一行就存进片上 BRAM 行缓存,同时保留上一行,这样计算当前输出行时,需要的两行源数据都在片上,只有列方向的四个点需要从行缓存里取。这样 DDR3 的读就变成了顺序读,写也是顺序写,带宽利用率能到 80% 以上。这个架构决定了你后面所有模块的接口:DDR3 侧是连续 burst 读写,插值侧是行缓存驱动的流水线。
2.2 易灵思平台上的 DDR3 控制器从哪来
易灵思的 Efinity 工具链里,DDR3 控制器一般通过两种方式获得:一是用 Efinity 自带的 DDR 内存控制器 IP(在 Interface Designer 里配置),二是自己写一个简化的控制器只跑特定 burst 长度。对于视频帧缓存这种顺序访问为主的场景,我一般直接用官方 IP,配置成 32 位或 64 位数据位宽,burst 长度 8,因为视频流天然适合长 burst。
配置时几个关键参数:数据位宽选 32 位还是 64 位取决于你的像素位宽和带宽需求。1080P@60fps、RGB888,像素时钟 148.5MHz,每像素 3 字节,理论带宽约 445MB/s。DDR3 在 400MHz 时钟、32 位位宽下理论带宽 1.6GB/s,看起来够,但读写各占一半,还要留刷新开销,实际能用的也就 60% 左右,所以 32 位够用但不宽裕,64 位更稳。地址映射上,把行地址放在高位、列地址放在低位,保证同一行的连续像素落在同一个 DDR3 page 里,减少行激活次数。
# Efinity Interface Designer 里 DDR3 控制器的典型配置项(示意,具体以你板子的引脚约束为准) # 数据位宽: 32 # Burst 长度: 8 # CAS 延迟: 6 # 时钟频率: 400MHz (DDR3-800) # 地址映射: {bank[2:0], row[14:0], col[9:0]}这里地址映射的顺序直接决定性能。如果把 bank 位放在最低,连续地址会在不同 bank 间跳,虽然 bank 并行能掩盖一部分延迟,但对视频这种大块顺序访问反而不如把 row 放高位、col 放低位来得稳,因为同一 row 内的连续访问不需要重新激活行。
2.3 帧缓存的乒乓结构与撕裂问题
单帧缓存一定会撕裂:你正在写第 N 帧的时候,显示端可能正在读第 N-1 帧的同一块地址。标准解法是乒乓(ping-pong)双帧缓存,写指针和读指针分别指向两块不同的 DDR3 区域,每帧结束交换。代价是 DDR3 容量要翻倍,1080P 两帧约 12MB,DDR3 随便都是 256MB 起步,容量不是问题,问题是地址切换时的时序。
// 乒乓帧缓存地址生成(简化示意) // frame_addr_width 根据 DDR3 容量和帧大小计算 localparam FRAME_SIZE = 1920 * 1080 * 3; // 一帧字节数 reg [1:0] wr_frame_sel; // 写帧选择 reg [1:0] rd_frame_sel; // 读帧选择 // 写地址 = 帧基址 + 帧内偏移 wire [31:0] wr_base_addr = (wr_frame_sel == 2'd0) ? 32'h0000_0000 : FRAME_SIZE; wire [31:0] wr_addr = wr_base_addr + wr_pixel_cnt * 3; // 读地址同理,但用 rd_frame_sel wire [31:0] rd_base_addr = (rd_frame_sel == 2'd0) ? 32'h0000_0000 : FRAME_SIZE;逻辑说明:wr_frame_sel 和 rd_frame_sel 必须始终不同,否则读写撞同一块区域。帧同步信号(比如摄像头的 vsync)到来时翻转 wr_frame_sel,显示端的 vsync 到来时翻转 rd_frame_sel。参数上,FRAME_SIZE 要按实际分辨率和像素格式算准,RGB888 是 3 字节,RGB565 是 2 字节,算错会导致帧边界错位,画面出现斜条纹。注意 DDR3 的地址是按字节还是按 burst 对齐,如果控制器是 32 位位宽,地址低两位通常被忽略,写地址要保证 4 字节对齐,否则会丢像素。
3. 双线性插值的 Verilog 实现:定点化、行缓存与流水线
3.1 定点化:把浮点权重变成整数移位
双线性插值的权重是小数,FPGA 里不能直接算浮点(除非你用浮点 IP,面积和延迟都不划算)。标准做法是定点化:把小数部分用 N 位表示,比如 8 位,那么权重范围是 0~255,插值公式变成:
out = (P00×(256-wx)×(256-wy) + P10×wx×(256-wy) + P01×(256-wx)×wy + P11×wx×wy) >> 16
wx、wy 是源坐标小数部分左移 8 位后的整数。这样全部用整数乘加,最后右移 16 位。精度上,8 位小数对大多数视频缩放够用,如果缩放比例特别小(比如 4K 缩到 480P),可以加到 10 位或 12 位,代价是乘法器位宽变大。
// 双线性插值核心计算(8 位定点小数) // p00, p10, p01, p11 是四个源像素,8 位灰度或单通道 // wx, wy 是小数部分,0~255 wire [15:0] w00 = (256 - wx) * (256 - wy); // 最大 256*256 = 65536 wire [15:0] w10 = wx * (256 - wy); wire [15:0] w01 = (256 - wx) * wy; wire [15:0] w11 = wx * wy; // 注意:w00+w10+w01+w11 = 65536,右移 16 位正好归一化 wire [31:0] sum = p00 * w00 + p10 * w10 + p01 * w01 + p11 * w11; wire [7:0] out = sum[23:16]; // 取高 8 位,等价于 >> 16逻辑说明:四个权重之和恒等于 65536,这是定点化的关键,保证输出不会整体偏亮或偏暗。sum 的位宽要算够:p 是 8 位,w 是 16 位,乘积 24 位,四个相加 26 位,取 [23:16] 是右移 16 位后的低 8 位。参数上,wx 和 wy 的位宽决定精度,8 位对应 1/256 像素精度,10 位对应 1/1024。如果发现缩放后画面有阶梯状条纹,多半是小数位宽不够,加大到 10 位试试。
3.2 行缓存:两行 BRAM 怎么组织
行缓存的作用是缓存源图的两行,供垂直插值使用。每行缓存深度等于源图宽度,位宽等于像素位宽。用两个 BRAM 交替:当前行写入 BRAM A 的同时,从 BRAM B 读上一行。当一行写完,交换读写角色。
// 双行缓存乒乓(简化) // line_buf_a, line_buf_b 是两块 BRAM,深度 = 源图宽度 reg [10:0] wr_ptr, rd_ptr; reg buf_sel; // 0: 写A读B, 1: 写B读A always @(posedge clk) begin if (src_valid) begin if (buf_sel == 0) line_buf_a[wr_ptr] <= src_pixel; else line_buf_b[wr_ptr] <= src_pixel; wr_ptr <= wr_ptr + 1; if (wr_ptr == SRC_WIDTH - 1) begin wr_ptr <= 0; buf_sel <= ~buf_sel; // 一行结束,交换 end end end逻辑说明:buf_sel 控制写哪块、读哪块。读的时候,rd_ptr 根据目标像素映射的源列坐标去取当前行和上一行的四个点。参数上,SRC_WIDTH 是源图宽度,BRAM 深度要 ≥ SRC_WIDTH,位宽按像素格式。注意读写指针的跨时钟域问题:如果摄像头像素时钟和 DDR3 控制器时钟不同源,wr_ptr 和 buf_sel 要同步到读时钟域,否则会读到半行数据,画面出现水平撕裂。
3.3 坐标映射:从目标像素反推源坐标
缩放的核心是坐标映射。假设源图宽 SW、高 SH,目标图宽 DW、高 DH,目标像素 (dx, dy) 对应的源坐标:
sx = dx × SW / DW sy = dy × SH / DH
FPGA 里不能直接除,通常预计算步进:step_x = SW × 256 / DW(定点 8 位),然后 sx 累加 step_x。这样每来一个目标像素,sx 加一次,整数部分取行缓存地址,小数部分做插值权重。
// 坐标累加器(定点 8 位小数) reg [31:0] sx_acc, sy_acc; wire [31:0] step_x = (SRC_WIDTH << 8) / DST_WIDTH; // 预计算,综合时是常数 wire [31:0] step_y = (SRC_HEIGHT << 8) / DST_HEIGHT; always @(posedge clk) begin if (dst_hsync) begin sx_acc <= 0; if (dst_vsync) sy_acc <= 0; else sy_acc <= sy_acc + step_y; end else if (dst_de) begin sx_acc <= sx_acc + step_x; end end wire [10:0] src_x = sx_acc[31:8]; // 整数部分 wire [7:0] wx = sx_acc[7:0]; // 小数部分逻辑说明:step_x 和 step_y 在综合时是常数,因为 SW、DW 都是参数。sx_acc 的高位是整数部分,低位是小数部分。注意当 sx_acc 整数部分达到 SW-1 时,需要钳位,否则会越界读到行缓存外面。参数上,小数位宽 8 位对应 step_x 左移 8 位,如果缩放比例导致 step_x 很小(放大),累加慢,没问题;如果 step_x 很大(缩小),累加快,要保证行缓存读地址不越界。
4. 避坑与排查:DDR3 读写冲突、跨时钟域和时序收敛
4.1 现象:画面出现随机闪烁的彩色噪点
原因:DDR3 读写冲突。当写帧缓存和读帧缓存同时访问 DDR3 时,如果控制器仲裁不当,读数据被写数据覆盖,或者读到了正在写的地址。易灵思的 DDR3 IP 一般有读写仲裁接口,但默认优先级不一定适合视频。
解决:在控制器配置里把读优先级设高,或者用独立的读写通道(如果 IP 支持)。更稳妥的做法是乒乓缓存加时间隔离:写第 N 帧时读第 N-1 帧,物理地址完全分开,仲裁压力小很多。如果还有噪点,检查 DDR3 的刷新周期是否被视频流打断,适当降低视频带宽或提高 DDR3 时钟。
4.2 现象:画面水平方向有规律地撕裂
原因:行缓存的读写指针跨时钟域没同步。摄像头像素时钟和 DDR3 控制器时钟不同频,wr_ptr 在写时钟域递增,rd_ptr 在读时钟域使用,如果直接跨域比较,会读到半行旧数据。
解决:用异步 FIFO 或者双口 BRAM 加格雷码指针同步。简单做法是行缓存用双口 BRAM,写口和读口各自独立时钟,地址不跨域,只同步 buf_sel 和行有效信号。buf_sel 是单 bit,用两级触发器同步即可。
4.3 现象:缩放后图像整体偏移或缩放比例不对
原因:坐标映射的 step_x/step_y 计算时整数除法截断,或者小数位宽不够。比如 SW=1920, DW=1280,step_x = 1920×256/1280 = 384,正好整除,没问题;但如果 SW=1920, DW=1000,step_x = 491.52,截断成 491,累积误差会导致右边部分像素映射偏移。
解决:step_x 计算时加四舍五入,或者把小数位宽加到 12 位。另外检查 sx_acc 的初值,应该是 0 还是半个像素(0.5 对应 128),取决于你的映射约定。如果图像整体偏移半个像素,把初值改成 128 试试。
4.4 现象:Efinity 综合后时序不收敛,DDR3 接口报 setup violation
原因:DDR3 控制器时钟频率高(400MHz),而插值逻辑的组合路径太长,尤其是四个乘法器加加法树,如果全挤在一个时钟周期,关键路径延迟大。
解决:把插值计算流水线化,乘法一级、加法一级、移位一级,用三到四级流水。代价是延迟增加几个时钟,但视频流不在乎这点延迟。另外检查 DDR3 引脚约束是否正确,易灵思的引脚约束文件里 DDR3 的 DQ/DQS 组要按 bank 分配,约束错了时序永远收敛不了。
4.5 现象:DDR3 读写带宽跑不满,帧率上不去
原因:burst 长度太短,或者地址不连续。视频流是天然连续的,但如果你的地址生成逻辑每读一个像素就换一次行,DDR3 会频繁激活新行,效率暴跌。
解决:保证一次 burst 至少读一行的一段连续像素,比如一次读 64 个像素。地址生成时按 burst 对齐,不要逐像素递增地址。另外检查 DDR3 控制器的 bank 交错配置,视频帧缓存这种大块顺序访问,bank 交错反而增加开销,可以关掉或改成 row 优先。
5. 进阶技巧:用仿真验证缩放链路,再上板
5.1 先跑行为仿真,别急着上板
DDR3 上板调试成本高,一旦花屏你分不清是插值算错了还是 DDR3 时序问题。我一般先在仿真里把整条链路跑通:用 Verilog 写一个假 DDR3 模型(行为级,读写延迟固定),摄像头输入用测试图案(比如渐变条或棋盘格),缩放输出存成文件,用 Python 或 MATLAB 对比预期结果。
// 假 DDR3 行为模型(简化,仅用于仿真) module fake_ddr3 #(parameter DEPTH = 1<<20) ( input clk, input [31:0] wr_addr, wr_data, input wr_en, input [31:0] rd_addr, output reg [31:0] rd_data, input rd_en ); reg [31:0] mem [0:DEPTH-1]; reg [3:0] rd_delay; always @(posedge clk) begin if (wr_en) mem[wr_addr[21:2]] <= wr_data; // 32 位对齐 if (rd_en) rd_delay <= 4'd4; // 模拟 4 周期读延迟 else if (rd_delay > 0) rd_delay <= rd_delay - 1; if (rd_delay == 1) rd_data <= mem[rd_addr[21:2]]; end endmodule逻辑说明:这个模型只模拟读写和固定延迟,不模拟 DDR3 的刷新、bank 冲突等真实行为,但足够验证插值逻辑和地址生成。参数上,DEPTH 按帧缓存大小设,rd_delay 按实际控制器延迟设。仿真时把摄像头输入和显示输出都接上,跑几帧,看输出文件里的像素值是否符合预期。
5.2 上板后的调试手段
上板后如果画面不对,先别改代码,用 SignalTap 或 Efinity 的 Debugger 抓几个关键信号:DDR3 读写地址、行缓存读写指针、插值输出的有效信号。重点看地址是否连续、读写是否撞同一区域、输出有效信号是否和显示时序对齐。如果 DDR3 读数据偶尔错,检查 DQS 训练是否完成,易灵思的 DDR3 IP 一般有校准完成信号,没校准完之前不要发起读操作。
5.3 一个具体技巧:用棋盘格测试图案定位问题
棋盘格图案(相邻像素黑白交替)对缩放算法是最苛刻的测试,因为插值权重在 0 和 255 之间跳变,任何定点化误差都会放大成可见的灰度偏差。如果棋盘格缩放后出现摩尔纹或灰度不均,说明小数位宽不够或权重计算有误。我一般先用 2×2 棋盘格跑仿真,确认输出灰度只有 0、85、170、255 四档(对应不同权重组合),如果出现其他值,就是计算逻辑有问题。
5.4 资源与带宽的平衡
易灵思 Trion T20 或 T120 系列,逻辑资源有限,双线性插值加 DDR3 控制器加行缓存,LUT 和 BRAM 占用要提前估算。如果资源紧张,可以把插值计算从四乘法器改成两乘法器分时复用,代价是时钟频率要跑更高或吞吐降一半。DDR3 带宽方面,如果 32 位位宽跑 1080P@60 吃力,降到 30fps 或者用 RGB565 代替 RGB888,带宽直接减半。这些取舍没有标准答案,取决于你的板子和应用场景。
我在这个方向上翻过的最大的车,是早期为了省 BRAM 把行缓存深度设成源图宽度的一半,结果缩放比例稍大就跨行越界,画面右边一条黑边,查了两天才发现是行缓存深度不够。后来养成习惯,行缓存深度永远设成源图宽度加几个像素的余量,宁可多占一点 BRAM,也不要在边界上赌。希望帮到你。
本文还有配套的精品资源,点击获取