前阵子做了一台视频采集盒,客户要求在输出画面上叠加“REC”字样和一行静态日期信息。需求听起来非常简单,无非是在HDMI或者SDI的输出层上盖几个白色字符。真正动手之后才发现,从像素坐标到字模ROM,从行场同步到时序收敛,几乎每一步都有坑。这篇文章就把我从设计到仿真、最后上板调试的完整过程记录下来,重点不是贴完整工程,而是说清楚每个坑的成因、现象和解决方法,希望能给正在做类似FPGA图像处理项目的朋友省几天时间。
1. 先搞清楚叠加的位置:像素、行场时序和“画布”坐标
1.1 叠加的本质是逐像素二选一
视频叠加静态字幕,说白了就是一个像素选择器:在每一行有效像素的扫描过程中,判断当前像素坐标是否落在字幕区域;如果落在区域内,就输出字幕的颜色,否则原样输出视频像素。
这句话说起来轻巧,但很多新手会把它理解成“往帧存里写字”。如果是带着操作系统的SoC方案,那确实是往显存里刷一片区域,但纯FPGA裸逻辑处理实时视频流时,根本没有一帧完整图像可以被随意读写。视频数据是按像素时钟一个点一个点从接口进来的,我们必须在这个持续流动的流水线上做替换。
我犯过的第一个错误是试图用“整帧存储+CPU修改”的思路实现,结果发现纯逻辑需要外部DDR读写控制器、帧同步缓存、总线仲裁,整个工程复杂度翻好几倍。后来老老实实改成逐像素实时判断,才意识到静态字幕场景下根本不需要帧缓存,只需要一块很小的字符ROM和一套精准的坐标计数器。
1.2 用像素计数器和行计数器定位字幕区域
做逐像素叠加,第一步是把输入视频的行场同步信号转换成坐标。这里说的坐标不是内存地址,而是当前像素在画面中的二维位置:横向位置 x_pos 从行有效区的第一个像素开始递增,纵向位置 y_pos 从场有效区的第一行开始递增。
TPG(Test Pattern Generator)类IP通常会在数据握手信号之外额外输出 x 和 y 坐标,但真正接入HDMI、SDI时,我们手里往往只有 data_valid、hsync、vsync 三个信号。我当时的做法是写一个时序解码模块:
always @(posedge clk) begin if (!vsync) begin y_cnt <= 0; x_cnt <= 0; end else if (de) begin if (hsync) x_cnt <= 0; else x_cnt <= x_cnt + 1'b1; if (hsync_pulse) y_cnt <= y_cnt + 1'b1; end end这里的“de”是数据有效标志,hsync 在有些协议里是脉宽极低的同步信号,有些则是每行的有效指示。务必先确认接口手册里 hsync 的真实极性,我在这上面因为不区分“同步脉冲”和“行有效”而吃过亏,后面章节会细说。
有了 x_cnt 和 y_cnt,判断是否落在某个字符区域就非常直白:
wire char_visible = (x_cnt >= CHAR_X_START) && (x_cnt < CHAR_X_END) && (y_cnt >= CHAR_Y_START) && (y_cnt < CHAR_Y_END);这里的 CHAR_X_START 等参数就是字幕框在屏幕上的位置,我通常把它们做成模块参数或者寄存器,方便上板后微调。
1.3 不同分辨率下的时序参数对照
不同视频分辨率的行场参数差异很大,直接影响去抖和计数逻辑。这里列几个实际调试时用过的参数,注意 blanking 部分是决定坐标计数器是否稳定的关键。
| 分辨率 | 像素时钟 | 行总数 | 有效行 | 列总数 | 有效列 | Sync脉冲位置 |
|---|---|---|---|---|---|---|
| 1920x1080@60 | 148.5 MHz | 1125 | 1080 | 2200 | 1920 | H: 88-128, V: 0-4 |
| 1280x720@60 | 74.25 MHz | 750 | 720 | 1650 | 1280 | H: 88-128, V: 0-5 |
| 640x480@60 | 25.175 MHz | 525 | 480 | 800 | 640 | H: 96, V: 2 |
同一块字幕叠加模块要跑多种分辨率时,这些参数不能写死,最好做成只读寄存器由软件配置。早期我在 1080p 上验证没问题,切到 720p 之后字幕整体向右下偏移,后来才发现是计数起点没有跟随 blanking 变化,导致 x_cnt 没有从有效区起点开始计数。
2. 字幕数据从哪来:取模、字库ROM和像素时钟的对账
2.1 从BMP到COE:字模的生成与排布
静态字幕第一件麻烦事是“字模怎么来”。我试过用Python PIL库直接提取BMP像素,然后把每个字符截成8x16的位图,逐行转成二进制。对于只显示“REC”这类简单字符,也可以直接用字库工具生成。
我推荐的做法是:先用本机字体渲染出字符位图,统一归一化成8x16或16x32等尺寸,然后按行列扫描输出16进制宽度的数据流。比如一个8x16字符,每行一个字节:
"R" 的示例字模(8列 x 16行) 0x00, // 第0行全透明 0x7E, // 0111 1110 0x81, // 1000 0001 0x81, // 1000 0001 ... 0x00,这里最关键的是“位序”和“颜色极性”。取值模工具时,有的工具按MSB在左,有的按LSB在左,如果和ROM读取时移位方向不一致,字符会呈现左右镜像甚至乱码。我踩过最狠的一次是取了MSB在左的字模,但在RTL里按LSB读取,导致字母“R”变成了一团完全无法识别的符号。
解决方式很简单:在testbench里用 $display 打印字模数据,人工对照位图检查一遍,不要省这一步。
2.2 双端口BRAM和字符行缓存
字符ROM在FPGA里通常放在Block RAM里。一个8x16字符用16个字节的存储,按128个字符算只有一个2KB量级,完全没必要用DDR。但要注意,如果整屏字幕包含几十个字符,而每个字符都要在像素时钟上连续读取,ROM端口会不够用。
我最终采用的是双端口BRAM:端口A在空闲时由微控制器或逻辑写字模数据;端口B在视频像素时钟读取。这样字模更新和字符显示互不干扰。读取地址由“当前显示字符索引”和“当前行内像素序号”组成:
// 像素时钟下读取ROM assign rom_addr = {char_index, y_in_char[3:0], x_in_char[2:0]};一个字符像素扫描需要16个时钟(8列x16行? 实际是每行8像素,共16行),但实际视频像素时钟永不停歇,一行里如果连续显示N个字符,就要在N*8个像素位置连续提供数据。BRAM读端口在时序上有一个延迟周期,所以字幕像素流要比视频像素流晚一个周期,我在流水线里人工对齐了两路数据。
2.3 带宽估算:为什么静态字幕不应该碰DDR
很多人一听到“视频叠加”就想到DDR帧缓存。实际上,对静态字幕而言,每帧需要输出的字幕像素数据量非常小。
以1920x1080@60为例,像素时钟148.5MHz,如果字幕区域占200x30像素,单色1bit,则每帧字幕数据量只有 200x30/8=750字节,即使加上半透明混合逻辑也远在BRAM带宽之内。真正需要的只是跟上视频像素流,每个有效点做一个判断。
反过来,如果坚持用DDR缓存,你需要处理帧同步、行缓存、写地址跨越等问题。我见过很多项目因为过度设计,把简单叠加干成了复杂的存储系统工程,最后调试周期至少多两周。所以我的原则是:先问自己“字幕内容多久变一次”,如果只变一次或者变化频率极低,就别碰DDR。
3. RTL实现里真正值得花时间的地方
3.1 黑白叠加和Alpha混合:先做1bit还是8bit
有段时间我纠结要不要给字幕加半透明效果,就是黑色竖条背景上透出底层视频那种。显然1bit单色只能做“全替换”,不能做透明度渐变。
静态字幕最常见的需求是白字黑底或者描边字,这两种都能用1bit字模+少量邻域判断实现。我第一版用1bit字模,字符像素为1时输出白色,为0时在原视频像素基础上压暗一点点,视觉效果接近黑底。
如果想做真正的alpha混合,则字模需要带8bit灰度或每个字符独立透明参数,输出逻辑变成:
always @(posedge clk) begin if (char_visible) rgb_out <= (char_rgb * alpha) + (video_rgb * (8'd255 - alpha)); else rgb_out <= video_rgb; end这里立刻引出两个坑:一是乘法器数量,8bit alpha加三通道就是3个8x8乘法器,在像素时钟150MHz下很容易成为时序瓶颈;二是alpha运算后的最终像素位宽是否还是8bit,需要做截断或饱和。我建议除非UI明确要求透明背景,否则第一版只用全替换,简单、稳,出错容易查。
3.2 坐标比较的时序路径:一场关于打拍的战争
前面说用 x_cnt >= CHAR_X_START 这种方式做判断,听起来毫无难度,但实际在148.5MHz下,字幕区域判断逻辑是组合逻辑,加上ROM地址、字模位提取、像素替换逻辑,很容易形成一条很长的组合路径。综合一次后时序报告显示 slack 为负数时,我是有点懵的:一个“equal”判断怎么可能时序违例?
原因在于 x_cnt 和 y_cnt 是计数器,CHAR_X_START 若又是寄存器的实时值,那么比较器输入距离触发器输出非常近,后面 ROM 地址和位选又加了不少逻辑,整体路径就超了。
解决办法有两个:
- 把坐标比较结果作为中间寄存器打一拍,ROM读出再打一拍,允许字模像素比视频像素晚一两个周期,后用 FIFO 或延迟寄存器对齐。
- 把字幕区域起始坐标做成固定参数,用常量比较,路径会短很多。如果不要求运行时动态调整字幕位置,强烈建议用参数而不是寄存器。
后来我干脆做了一个流水线,把像素通道上所有处理环节统一延迟对齐:
// 第一拍:比较 reg char_visible_r; always @(posedge clk) char_visible_r <= char_visible; // 第二拍:读ROM reg [7:0] pixel_code_r; always @(posedge clk) pixel_code_r <= pixel_code; // 第三拍:替换输出 reg [23:0] video_rgb_r; always @(posedge clk) video_rgb_r <= video_rgb; always @(posedge clk) rgb_out <= char_visible_r ? char_color : video_rgb_r;这样每一级路径都被截短,时序问题直接消失。
3.3 复位、消隐区和H/V Blank的边界处理
这是仿真里很难暴露、上板却让你找半天的问题。
HDMI/SDI这类接口的输入数据流中,de有效区域内才是可见图像,blank附近的数据可能是黑电平或者边带数据。如果 RTL 里的 x_cnt 和 y_cnt 没有在消隐期严格复位或清零,叠加模块会把字幕图像错误地画到 blank 区域里,结果在屏幕边缘出现流动的噪点。
我的做法是:在每行 hsync 脉冲出现后的第一个 de 有效像素处 x_cnt 清零,在每场 vsync 脉冲后 y_cnt 清零,同时计数只在 de 有效时递增。如果你接的信号源 hsync 极性是反的,必须先在顶层取反。
另外复位信号尽量不要直接异步清零整个像素计数器,除非寄存器完全处于静止状态。我习惯用异步复位、同步释放的复位模块,避免多路计数器复位不同拍导致图像位置偏移。
4. Testbench不是随便写写:仿真阶段踩出的四个坑
4.1 造一个符合协议的视频源Task
很多同学仿真时用一个很短的memory初始化来模拟视频流,比如 8x8 像素的退化图像。这种环境对模块握手验证够用,但对字幕叠加这种“坐标敏感型”模块远远不够。至少得按真实分辨率产生有完整 blanking 的行场时序,否则你根本验证不了 x/y 计数的正确性。
我写了一个testbench任务,按1280x720@60的参数循环生成像素流:
task send_line(input [23:0] pixel, input [31:0] line_pixels); repeat (H_FRONT + H_SYNC + H_BACK) begin de <= 0; hsync <= (i >= H_FRONT) && (i < H_FRONT + H_SYNC); @(posedge clk); end repeat (line_pixels) begin de <= 1; data_video <= pixel; @(posedge clk); end repeat (H_FP) begin de <= 0; hsync <= 0; @(posedge clk); end endtask这里“line_pixels”就是有效列数。每次调用前先判断当前是同步头还是普通行,并把行号传给叠加模块。造时序的时候最忌讳拍脑袋用短分辨率,因为计数器边界和溢出逻辑只有在真实分辨率下才会触发。
4.2 用Assertion让字幕错误当场现形
肉眼盯波形是最容易漏问题的。我吃过一次大亏:字幕区域边缘的像素在交替两帧之间闪烁,波形上只有几个信号在高频翻转,人眼根本看不过来。
后来我改成在testbench里加断言,直接用系统函数检查输出像素是否符合预期:
property p_subtitle; @(posedge clk) disable iff (!rst) (char_visible_r) |-> (rgb_out == CHAR_COLOR); endproperty assert property (p_subtitle);这个断言保证“当字幕像素可见那一拍,RGB输出必须等于字幕颜色”。凡是字幕区域有漏像素,仿真自动报错,能迅速定位是哪一级流水线没有对齐。断言别等到出问题时再加,写RTL的时候顺手放进去,可以省去大量肉眼排查时间。
4.3 波形里的X态到底是谁的锅
Modelsim/Vivado仿真里看到红色波浪线或者X态时,不要急着怀疑综合出问题,绝大多数是testbench自己造成的。
我在验证时遇到过一个经典案例:BRAM没有初始化,所有字模数据都是X,于是字幕区域输出变成了X态,仿真报错。解决办法是在testbench里用 $readmemh 把字模文件读入,并在复位释放之前完成初始化。如果BRAM里的数据可能被使用者修改,还需要专门做初始化复位逻辑。
另外,两级寄存器之间如果有一个异步信号没有同步器,仿真波形也可能出现X态。凡是跨时钟域信号,testbench里必须模拟真实同步器结构,否则验证结果不具备参考价值。
4.4 别小看后仿真:延迟模型带来的新问题
前仿真只验证功能逻辑,时序路径延迟是看不到的。综合后或布局布线后仿真里,寄存器输出会带有 clk-to-Q 延迟、组合逻辑延迟、BRAM读延迟等,很多“功能正确但时序不对”的问题会在这里暴露。
我在后仿真时发现过ROM读数据比预期晚了一个周期,导致字幕和背景错位。原因是BRAM IP核有可选的输出寄存器,前仿真隐藏了这个延迟,后仿真才真实体现。解决办法是统一用寄存器输出并重新对齐流水线。所以建议做后仿时,先查每个存储IP的延迟配置,别想当然指望和前仿一致。
5. 上板以后,仿真和现实的“温差”在哪
5.1 跨时钟域FIFO深度不够导致字幕抖动
静态字幕虽然内容固定,但视频源如果有两个时钟域,比如HDMI接收端输出的像素时钟和显示端像素时钟不是同一个PLL锁出来的,就需要异步FIFO把视频数据搬过去。字幕叠加模块如果放在显示端,字模ROM数据却由接收端寄存器配置,就可能出现一个跨时钟域的点。
第一版我把字模ROM挂在接收端时钟域,显示端直接读,异步FIFO给视频数据预留了深度512x24bit。实测发现字幕偶尔出现整行错位抖动,查了很久才意识到,虽然字模ROM内容只在开机时更新,但显示端读ROM的地址总线上存在亚稳态可能。更稳妥的做法是在字模ROM后加一个“地址同步到显示时钟域”的处理步骤,或者干脆把ROM整个放进显示端时钟域,更新时通过同步器写入。
如果像视频流FIFO一样,写入端时钟和读出端时钟长期不完全一致,FIFO深度至少要能容纳一行有效像素的抖动,而不是只看FIFO平均带宽。我后来把深度改成2048,问题消失。
5.2 ILA抓不到信号?先检查触发条件
上板调试时我习惯用ILA观察行场计数器和字模ROM输出。第一次触发时波形总是空的,设置成更宽触发行号才抓到。原因很简单:叠加模块坐标计数器在未锁定时(比如HDMI未接入有效信号)一直复位,在待机状态根本不会命中触发条件,所以看起来像是逻辑不工作。
正确的调试顺序是:
- 先看配置寄存器读写是否OK,ILA中把配置状态加进去。
- 再抓 vsync 和 hsync 的极性,确认计数器确实在跳变。
- 最后才抓字幕区域内部的叠加信号。
5.3 屏幕上“花斑”可能来自未复位的BRAM
我还遇到过一种现象:字幕区域里偶尔出现随机亮斑,位置和内容完全不是预期的字符。排查了很久,最后发现是BRAM初始化的问题——字模ROM使用的是“默认初始化”而不是“显式初始化文件”,部分BRAM里的数据在上电后是未知的,字幕区域就把这些混乱数据当成了像素。
这类问题仿真往往看不出来,因为testbench里 $readmemh 已经把数据写好了。解决方法是把字模ROM配置成初始化COE文件,并在复位逻辑里对关键存储模块做显式复位写入,不要依赖FPGA上电默认值。
调试到这一步,字幕已经稳定出现在画面左上角,和视频背景完全同步。回想整个过程,真正麻烦的地方不是“叠加”本身,而是像素坐标、字模位序、跨时钟域和流水线对齐这些细节。
6. 模块化改造:从REC字样到可换内容字幕
6.1 预留字库索引和坐标参数
如果产品后续要从“REC”扩展到日期、时间或其他文字,字符ROM里肯定不能只放一两个字符。我建议从一开始就保留字符索引参数,比如用8bit ASCII码作为字模ROM高地址,显示逻辑根据ASCII索引查找。
这样软件侧只要往一个寄存器文件里写入ASCII码和显示坐标,字幕模块就能动态切换内容。寄存器接口用简单的AXI-Lite或者SPI都行,关键是字模ROM要足够大,至少要覆盖常用ASCII字符和数字。我当时预留了128个字符,8x16字模,只占两个BRAM块,资源压力很小。
6.2 把行缓存参数化
如果未来还想做两行以上的字幕,显示逻辑要能同时处理多个字符行。处理方式是在每行有效像素时循环访问多个字符行索引,或者每个字符行对应一组坐标比较器。无论哪种,顶层参数最好直接以字符行数、每行字符数、字号为参数,方便综合时复用:
module subtitle_overlay #( parameter N_CHAR_LINES = 2, parameter CHARS_PER_LINE = 40, parameter CHAR_W = 8, parameter CHAR_H = 16 )( ... );6.3 后续扩展思路
这套模块的滑动滚动字幕改造也很顺:只要把显示区域内的字符索引每隔N帧移位一次,就能实现简单滚动。更复杂一点的做法是加一个定时器触发异步FIFO读地址变化,让字幕内容随时间更新。
但无论怎么扩展,设计时守住的底线是:视频通路上的流水线节奏不能被字幕逻辑打断。所有坐标比较、ROM读取和像素替换都在同一像素时钟下推进,绝不在视频路径上插入等待周期,否则要么图像撕裂,要么字幕错位。这是整个项目里最值得记住的经验。
我个人的体会是,FPGA图像处理项目里的大多数“难啃的骨头”不是算法有多高深,而是每个模块之间的时序约定和字节序约定。字幕叠加看起来是个小功能,却把像素坐标、存储读时序、跨时钟域和数据对齐全串了一遍。如果你也在做类似需求,我建议第一天就把字模位序和行场计数的极性确认清楚,至少能避开一半的坑。