资讯详情

FPGA图像缩放与移动实战:坐标变换、DDR3带宽与调试解析

📅 2026/10/13 17:30:42 | 华诺云谱 👁 阅读
FPGA图像缩放与移动实战:坐标变换、DDR3带宽与调试解析
上次做了一个基于 Quartus 的静态图像显示 Demo能把一张测试图从存储里读出来按标准时序打到屏幕上。当时评论区问得最多的就是能不能缩放能不能拖来拖去这次正好接着这个题目往下做把图像显示的第二篇补上重点聊缩放和移动在 FPGA 上到底怎么落地。先说结论缩放和移动本质上都是坐标变换不是像素搬运。理解了这句话后面所有逻辑都会顺很多。缩放要解决的是目标像素应该取源图像的哪个点移动要解决的是输出位置对应的源地址在哪儿。两者可以统一写进一个坐标变换模块嵌在像素流水线的最前端。这篇文章我会从坐标变换的基本盘讲起给出 Verilog 思路、DDR3 缓存的带宽计算、以及我在调试中遇到的花屏、撕裂、卡顿三类典型问题的完整排查过程适合已经能点亮屏幕、想进一步做交互显示的同学参考。1. 先把老框架理清楚显示通路的坐标到底在谁手里1.1 上一版显示的坐标流水线再快速过一遍老工程的坐标框架不然新代码加进去容易乱。完整的 VGA/HDMI 显示通路可以拆成三段时序生成、地址生成、数据读取。时序生成负责输出 hsync、vsync、de数据有效信号同时在内部维护两个计数器行计数器hcnt0~1919 有效加上消隐区会到 2199 左右和场计数器vcnt0~1079 有效加上消隐会到 1124 左右。这两个计数器就是整个画面的绝对坐标系所有图像操作都要以它们为基准。地址生成则把hcnt和vcnt映射成存储器的读地址。上一版没有缩放也没有移动地址映射就是最简单的线性关系addr vcnt * IMG_W hcnt;数据读取就是在有效期内每个时钟读出 RGB 数据送到接口。整个链路是纯流水的一个像素对应一个时钟周期没有回头路。1.2 新旧坐标体系的关系上一版的问题在于坐标变换是写死的线性映射想加缩放和移动就得改地址生成逻辑。但如果你直接在时序生成模块里改计数器——比如让hcnt每两个时钟跳一次以达到横向缩短的效果——那你很快会被消隐时序、同步信号、甚至图像撕裂一起找上门。正确的改法是让时序生成保持绝对坐标系不变所有缩放、移动的逻辑都放在坐标映射层里。时序生成告诉你现在屏幕上应该画第几个像素坐标映射模块告诉你这个位置应该去源图像哪里取数据数据读取模块根据映射后的坐标去取数。用软件的话说就是显示逻辑和渲染逻辑解耦。这样拆的好处有两个第一时序生成部分完全不用动之前验证过的显示稳定性继续有效第二缩放和移动的算法是纯组合逻辑加少量寄存器可以单独仿真、单独回放出问题容易定位。2. 缩放的本质是重采样最近邻与双线性插值的硬件账2.1 最近邻缩放用一个累加器翻译坐标缩放的第一种做法是最近邻。它的原理特别直白目标图像第dst_x列对应源图像的第src_x列映射关系是src_x dst_x × src_width / dst_width如果缩放比是整数倍比如从 640×480 缩到 320×240那每个目标像素对应源图像固定间隔的像素直接用计数器分频就行。但要支持任意比例缩放就必须做除法或者乘法运算。FPGA 里除法器很贵一个整数除法器动不动几十个 LE 还不一定能跑到高频率。更好的办法是把它变成累加器。// 横向缩放每次输出像素后src_x 按缩放步进累加 // scale_step src_width / dst_width用定点数表示 reg [31:0] x_acc; reg [11:0] src_x; always (posedge clk) begin if (hcnt 0) begin x_acc 0; src_x 0; end else begin x_acc x_acc scale_step; src_x x_acc[31:20]; // 取高12位做整数坐标 end endscale_step是定点化之后的缩放比比如把 1.0 表示成32d1_0000那么缩小一半就是32d5_000放大两倍就是32d2_0000。累加器的溢出自然完成了坐标的推进根本不需要除法器。这个思路在线性缩放里很常见用起来也顺手。2.2 双线性插值两根行缓存换画质最近邻缩放的最大问题是图像鋸齿感强尤其做缩小的时候细线条会断断续续。如果想画质更好一点就得做双线性插值。原理简单说目标坐标落在源图像四个像素之间取四个角的像素按距离加权平均。比如目标位置的源坐标为(x0dx, y0dy)其中dx、dy是小数部分那输出像素就是out (1-dx)(1-dy)×P00 dx(1-dy)×P10 (1-dx)dy×P01 dx×dy×P11其中P00是左上角像素P10是右上角P01是左下P11是右下。在 PC 上写这个公式毫无压力但在 FPGA 上第一个问题就来了计算一个插值时你需要同时拿到上下两行的像素。而在流水的显示场景里像素是一行一行串行读出来的上一行的数据读完就没了。想实现双线性插值必须把上一行和当前行并行摆出来这就引出行缓存line buffer。典型做法是用两块 RAM一块存上一行数据一块存当前行数据两行交替写入。读的时候同时从两块 RAM 各读两个点拼出四个像素再用定点乘法算加权和。资源账也很清楚每行 1920 像素 × 24bit一根行缓存约 5.6KB两根就是 11KB 左右的 BRAM。分辨率越高这笔账越要提前算好。需要注意的是双线性插值会引入乘法器消耗。横向插值需要两个乘法纵向插值再乘两个如果每个像素都要做 8 个乘法1080p60 下乘法资源会不够用。实际工程里会复用一个乘法器分时做横纵插值代价是流水线级数增加。所以双线性不是免费的它是画质和资源的取舍用不用取决于你的目标分辨率。2.3 坐标输出与时序的配合这里有一个特别容易踩的坑坐标映射模块算出来的src_x和src_y要等到数据有效信号de拉高时才开始有意义但在缩放场景下数据输出节奏和de并不总是对齐的。比如横向放大两倍每个源像素要重复输出两次那像素读取频率和输出频率就不同步了。在我自己的工程里解决方式是做一个小型 FIFO 作为速率匹配坐标映射模块按时钟节奏喂数据输出侧接口按自己的节拍取数。FIFO 深度不需要很大放大场景下取两倍深度缩小场景下取一行深度就够。若直接把坐标计算模块的输出接到 RGB 输出口大概率会在放大时丢数据、缩小时插空洞画面就是竖向的条纹。3. 移动不是挪像素是挪读地址平移、滚动与边界裁剪3.1 平移的本质坐标加偏移移动比缩放简单多了。所谓图像向左移动在坐标域里就是源坐标往右偏移src_x dst_x offset_x; src_y dst_y offset_y;当offset_x为正值画面看起来就是往左移的。移动操作不需要对像素内容做任何计算纯粹是地址变换。所以在 FPGA 上做移动的关键不在怎么移动而在移动之后源坐标越界怎么处理。3.2 边界处理三种策略与选择裁切模式源坐标超出图像范围就输出黑色或任意纯色。这是最简单、也是嵌入式 GUI 的默认做法。实现上只需对坐标做一个范围比较越界就置 RGB 为0或者背景色。拉伸模式把坐标钳制到边界上。视觉效果是图像边缘被拉伸适合做镜像效果但很多人会不习惯。循环滚动模式坐标对图像宽度取模图像从一侧滚出去、从另一侧滚回来。实现也简单前提是图像宽度为 2 的幂这样取模就可以用位与操作替代除法src_x (dst_x offset_x) (IMG_W - 1); // IMG_W 必须是 2^N我实际用的就是第三种滚动效果配合游戏场景的跑马灯展示非常自然。如果宽度不是 2 的幂取模就得选除代价立刻上去所以设计图像分辨率时我一律选 1024、800、640 这类 2 的幂宽度的整数倍。3.3 移动与缩放串联先移后缩还是先缩后移把缩放和移动放到同一个坐标变换模块时顺序是有讲究的。如果你希望图像先平移到某个位置、再整体缩放坐标公式是src_x (dst_x - offset_x) × scale;如果你希望先缩放、再平移公式就变成src_x dst_x × scale - offset_x;这两种在视觉效果上完全不同尤其是当缩放中心不在原点时。实际项目里我推荐统一采用先平移后缩放的形式并且把平移量offset也乘上缩放系数这样输出的移动手感在缩放前后一致。很多刚开始做的人改完缩放后发现移动速度也变了就是在这一步没做系数补偿。整个坐标变换模块的输入输出关系大概是这样// 输入hcnt、vcnt、de // 输出src_x、src_y、rd_en wire [11:0] trans_x hcnt offset_x; wire [11:0] trans_y vcnt offset_y; wire [15:0] src_x trans_x * scale_x; // 定点乘法 wire [15:0] src_y trans_y * scale_y;4. 带宽这座山DDR3 突发读与 FIFO 水位的配合4.1 1080p60 的数字先算算吓不吓人当画面内容变大ROM 放不下就得把图像数据放进 DDR SDRAM。一旦进了 DDR缩放的复杂性立刻从逻辑问题变成带宽问题。先算一笔账。1080p、60 帧、RGB888显示端所需要的读带宽是1920 × 1080 × 60 124,416,000 像素/秒 每像素 3 字节 → 373.2 MB/s这只是显示读数据。如果你还有图像写入、双缓冲切换、CPU 或摄像头往 DDR 里写入新图带宽需求会成倍增长。DDR3-1600、16bit 位宽的芯片理论带宽是 3.2GB/s。听起来绰绰有余但它不是每时每刻都能跑满的。DDR 有刷新开销、有行激活延迟、读写切换还有总线空闲周期实际效率能到 60%~70% 已经算调得不错。按 70% 算也就 2.24GB/s 的可用带宽分给显示读 373MB/s 后还剩很多支持双缓冲和摄像机写入绰绰有余。但如果你的 DDR 控制器是 AXI 总线仲裁策略又没调好突发被打断、效率直接掉到 30% 以下那 1080p 就会卡成幻灯片。4.2 突发读每像素读一次 DDR 等于自杀如果坐标映射模块每生成一个地址就去读一次 DDR效率低到不可收拾。DDR 的每一次读请求都有行激活、列读写、预充电的过程有时候这些额外开销比数据本身还长。效率高的做法是一次性读一整行或者一整块也就是突发读。具体操作是显示模块需要第 N 行数据时向 DDR 控制器发出一个请求读取从第 N 行起始地址开始的连续 512 字节存进一个行缓存 FIFO。坐标映射模块需要哪一列就从 FIFO 里顺序取。等这行数据快消耗完了再发下一次突发读。行缓存 FIFO 只需要做到能覆盖下一次突发读的响应延迟而不需要缓存整行。这个策略的本质是用面积换延迟——用行缓存吸收 DDR 的延迟抖动。DDR 读请求响应时间不是恒定的有时快有时慢突发之间穿插写请求、刷新请求都会让响应时间波动。如果没有行缓存缓冲画面就会在 DDR 响应慢的时候出现缺行。4.3 FIFO 水位与突发节奏的控制行缓存 FIFO 用起来有一个水位控制的问题。水位设太高FIFO 经常满DDR 读得早但数据在 FIFO 里堆着占空间水位设太低FIFO 空了才开始读容易出现显示流断供。我常用的控制逻辑是当 FIFO 剩余数据量低于 1/4 时向 DDR 控制器发出一次突发读请求读回来的数据持续写入 FIFO当 FIFO 满到 3/4 时不再发起新请求。这样设置的好处是FIFO 里始终维持一段缓冲数据DDR 响应延迟再大也不至于断流。FIFO 深度我一般取突发长度的 4 倍以上。突发长度是 128 个 16bit 字FIFO 就建 512 个字深。在 Quartus 里可以直接用 IP Catalog 里的 FIFO 生成器选同步读、同步写、普通模式即可不需要额外的跨时钟域逻辑——前提是 DDR 控制器和显示控制器在同一条时钟域下工作。5. 从实测波形到现象还原花屏、撕裂、卡顿的排查链路5.1 花屏先别怀疑 DDR先锁时序和坐标做完缩放和移动模组后我遇到过一个典型的花屏现象屏幕左上角约 1/4 区域出现斜向的彩色条纹其余区域正常。第一反应是 DDR 数据读错了于是在 SignalTap 里抓了读数据总线和地址总线结果数据完全正确当时非常困惑。后来我把读数据正确这个事实和条纹只出现在左上角结合到一起判断问题不在读取而在坐标映射。用 SignalTap 抓坐标变换模块的输出对比输入hcnt和输出src_x的波形发现一个规律每次hcnt到某个值时src_x会突然跳变到一个错误的大数值持续几百个周期后又恢复。查看代码后发现比较条件写成了hcnt IMG_W而IMG_W定义的是十进制 1920在坐标变换模块里却用了 12bit 的hcnt。当hcnt超过 2047 时有符号比较的符号位翻转整个比较结果就反了。这个问题在仿真里通常不会暴露因为仿真激励往往只覆盖hcnt 0~1919而真实 VGA 时序里hcnt会跑到 2199。所以花屏排查的第一个动作永远是加长hcnt的位宽保证整个消隐周期都能正确映射而不是一看花屏就怀疑 DDR 控制器。5.2 撕裂与移位读到了一帧里的新和旧移动画面时偶尔会看到屏幕从中间水平撕开上半个画面是旧帧下半个画面是新帧。这个现象最初让我很费解——DDR 里的数据写得好好的读出来怎么会新旧交替后来才搞明白撕裂和坐标模块无关是帧缓冲的读写没有同步。我在做的是一个单帧缓冲结构显示控制器边读边被新的图像数据覆盖。移动场景下画面更新频率较高场景变化大新一帧覆盖旧一帧的过程中恰好被显示控制器读走了一半旧数据、一半新数据屏幕就撕裂了。解决方案就是上双缓冲一个分区存当前正在显示的帧另一个分区存正在写入的新帧。垂直消隐期间做缓冲切换vsync上升沿把写指针切换到刚才写完的缓冲读指针指向另一个。这个方案把带宽需求翻了一倍但画面从此稳定值这个价。切换缓冲还有一个容易忽略的细节切换时机要精确放在vsync有效且全场消隐信号之间不能在de为高时切换。因为显示控制器正在从旧缓冲读数据你切了读指针画面就会错位半帧。我当时加了 1 拍延迟保证切换信号同步到像素时钟域问题就消失了。5.3 卡顿根源总线仲裁那点事图像不撕裂了但出现另一种问题高分辨率下移动路径比较长时画面偶尔卡一下好像掉了好几帧。用 SignalTap 看 FIFO 的空信号发现它在水平有效期内频繁拉高说明显示流在断供。断供的直接原因有两个一是突发读请求发得太晚FIFO 里提前蓄的水不够二是 DDR 控制器在响应读请求之前先去处理了一长串写请求把读响应时间拉长了。第一个原因好解决把水位阈值从 1/4 提到 1/2FIFO 里多蓄点水。第二个原因需要动 DDR 控制器的仲裁策略。我用的是 AXI 接口最初配置成写优先想着摄像头数据别丢。结果写请求一多读请求被饿死。改成读优先后画面卡顿立刻缓解而摄像头数据因为写缓冲的存在并没有丢帧。这个经验说明AXI 仲裁的优先级不是越高越好要根据实际带宽余量来调。如果在调整仲裁策略后仍然卡就得看一下 DDR 控制器有没有开自动预充电。不开自动预充电的话每次读不同行都需要手动预充电行切换延迟会大幅拉高。开了之后控制器的效率曲线会明显好于关闭状态。6. 资源账本与下一步扩展6.1 缩放方案资源实测对比同一套老工程基础上我在 Quartus 里分别实现了最近邻缩放和双线性插值缩放资源占用对比如下以 640×48060 输入1080p 输出为例指标最近邻双线性插值逻辑单元8121970寄存器6341550BRAM 块626DSP 乘法器06最大工作频率148MHz133MHz最近邻方案几乎不占额外资源适合低成本项目双线性插值的资源消耗大约是它的 2.5 倍换来的是缩小画面的抗锯齿效果。如果画面内容以静态文字和 UI 为主最近邻反而更合适因为文字需要的是清晰锐利的边缘强行插值反而显得模糊。6.2 下一步旋转也可以这样扩展缩放和移动统一成坐标变换后再往后做旋转就顺理成章了。旋转的公式也是坐标变换src_x (dst_x - cx) × cosθ - (dst_y - cy) × sinθ cx; src_y (dst_x - cx) × sinθ (dst_y - cy) × cosθ cy;难点在于sinθ和cosθ系数是浮点需要定点化且旋转后源坐标往往落在非整数位置必须配合双线性插值来取像素。这也解释了为什么很多 FPGA 显示项目做到旋转时就戛然而止——不是原理复杂而是把插值、坐标变换、带宽三者同时玩转工作量不小。我个人做这版缩放移动模块时最大的体会是FPGA 显示项目里最费时间的从来不是逻辑设计本身而是我以为逻辑是对的画面却不如预期的调试阶段。坐标变换这类纯数值计算模块建议从一开始就用 SignalTap 把输入输出都拉出来配合固定测试图案做回归验证等确认坐标完全正确后再接入真正的图像数据后面会少走很多弯路。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑