简介:面向FPGA视频接口开发与学习的BT656 Verilog工程包,聚焦720×576(576p)标清逐行扫描输出,帮助读者理解RGB888到YCbCr色彩空间转换、行场同步、串行时序控制、字节打包及同步字插入等关键环节,适合具备一定Verilog基础、希望打通视频采集与显示链路的开发者参考。压缩包共130个文件,约4.14MB,以Quartus工程编译产生的cdb、hdb、ddb等数据库文件及rpt报告为主,同时包含qsf引脚与工程设置、sof下载文件、Verilog源码和readme说明,便于对照综合布线结果、时序与资源占用排查设计问题。已有5148人学习下载。工程保留较完整的编译中间产物与报告,可用于观察720×576 BT656数据流的模块划分、时序约束与引脚分配思路,并借助映射、适配和汇编报告定位拥塞、时序违例或同步异常。对需要快速验证RGB888转BT656输出、复盘FPGA视频接口实现流程的读者具有较强实践参考价值。
1. 先看清这项目:一条 8bit 数据线怎么变成 720×576 的画面
手里那块摄像头模组只给出 8 根数据线和一个 27MHz 时钟,上电就有波形,可抓下来全是 FF 00 00 打头的字节流,像素在哪、行从哪开始、一帧有多长,全凭猜——这就是 BT656 接口的日常。这套 720×576 的 Verilog 代码干的事情很具体:把 8bit 的 BT656 裸流拆成能看的画面,解析 SAV/EAV 时序基准码,切出 720×576(576p 隔行,25fps)的有效像素区,把 4:2:2 的 YUV 拼成完整像素,再转成 RGB 送去显示。它面向的是做 FPGA 视频采集入门、需要把手里的 PAL 模组或 CVBS 解码芯片接上屏的开发者,纯 Verilog HDL 实现,不依赖厂商 VIP,仿真、综合、上板三处都能走一遍。代码量不大,但协议细节密集,属于那种“看懂了觉得简单,没看懂能把人按在地上摩擦”的类型。
2. 协议与数据通路:一行 1728 字节里的门道
BT656 最反直觉的地方在于:它没有独立的行场同步线。行从哪开始、场从哪结束,全部藏在数据流的特殊字节里。所以任何一份 BT656 解码代码,第一件事都不是处理像素,而是“找边界”。把一行拆开来看,720 个像素的 YUV 4:2:2 数据是 1440 字节,加上前导的同步码和中间的消隐填充,一行总共 1728 字节。625 行 × 25 帧 × 1728 字节 = 27,000,000,正好对上 27MHz 的像素时钟——这个整除关系不是巧合,是标准定死的结果,也是后面写状态机时最重要的一个算术校验点。
2.1 EAV/SAV 时序基准码与 XY 状态字
每一行的开头是 EAV(End of Active Video),有效数据开始前是 SAV(Start of Active Video),两者都是 4 个字节:FF 00 00 XY。前三个字节固定,第四个字节 XY 才是信息载体。它拆成两半用:高 4 位是标志位,低 4 位是保护位。bit7 恒为 1,bit6 是场标志 F,bit5 是场消隐 V,bit4 是行消隐 H,bit3~bit0 依次是 P3、P2、P1、P0,其中 P3=V、P2=H、P1=F、P0=V⊕H⊕F。这个保护位的设计很实在:万一数据里恰好出现了FF 00 00,靠 P0 的奇偶校验和 bit7 的强制 1,基本可以排除误判。
八种组合的 XY 值可以直接背下来,调试时扫一眼波形就知道当前在哪:
| F(场) | V(场消隐) | H(行消隐) | XY 值 | 含义 |
|---|---|---|---|---|
| 0 | 0 | 0 | 0x80 | 第一场,有效行,SAV |
| 0 | 0 | 1 | 0x9D | 第一场,有效行,EAV |
| 0 | 1 | 0 | 0xAB | 第一场,场消隐,SAV |
| 0 | 1 | 1 | 0xB6 | 第一场,场消隐,EAV |
| 1 | 0 | 0 | 0xC7 | 第二场,有效行,SAV |
| 1 | 0 | 1 | 0xDA | 第二场,有效行,EAV |
| 1 | 1 | 0 | 0xEC | 第二场,场消隐,SAV |
| 1 | 1 | 1 | 0xF1 | 第二场,场消隐,EAV |
凭这张表就能判断:H=0 说明后面紧跟着的是有效像素,H=1 说明这一行到此为止;V=1 说明整行都是消隐,一个像素都不用采。实际写代码时不需要穷举八个常量,把 F、V、H 三个 bit 提出来做条件判断更省事,也更容易在换制式时复用。
2.2 4:2:2 字节序与 720 像素的两拍对齐
BT656 的 8bit 模式是字节复用,一个时钟周期只走一个字节,像素数据按Cb Y Cr Y的顺序排列。这意味着一个像素的亮度 Y 每拍都有,但色度 U(也就是 Cb)和 V(Cr)是每两个像素共享一组。720 个像素对应 1440 个字节,第 0 字节是 Cb、第 1 字节是 Y0、第 2 字节是 Cr、第 3 字节是 Y1,以此类推。
这里最容易翻车的是顺序:很多人下意识按Y Cb Y Cr去解,结果画面能出来,但整体偏绿或者偏紫,肤色像中毒。原因就在于标准写的是 Cb 在前、Cr 在后,而很多图形库、图像格式文档里习惯把 Y 放第一位。血泪经验是:拿到任何一份 BT656 源码,先看它取色度的两个字节谁先谁后,别信注释,看代码里第一个色度字节存进了 U 还是 V。
再说消隐区。一行的 1728 字节减去 1440 字节有效数据,剩 288 字节,构成是:4 字节 EAV + 280 字节填充 + 4 字节 SAV。填充字节通常是 0x80 或 0x10,具体值不重要,重要的是解码逻辑必须在 SAV 之后才开始计数、在 EAV 处清零。场结构上,625 行里真正带像素的只有 576 行(两场各 288 行),其余 49 行是场消隐,V 位全程为 1。所以一个完整的帧同步逻辑需要两个计数器:一个是行内字节计数(0~1727),一个是帧内行计数(0~624),两者都靠 SAV/EAV 脉冲复位,而不是靠外部 hsync/vsync。
3. 核心模块拆解:从 8bit 裸流到 RGB 像素
整份工程按数据流切成四级流水:同步码检测 → 有效数据门控 → 4:2:2 转 4:4:4 → YUV 转 RGB。每一级之间用 valid 信号串起来,不用全局使能,这样即使某一级因为 FIFO 满而反压,前面的逻辑也不会把数据吞掉。下面按模块顺序拆,代码都可以直接抠出来单独仿真。
3.1 顶层端口与时钟域划分
顶层只做两件事:例化子模块、定义时钟域。视频输入侧是 27MHz,由模组或解码芯片提供,这个时钟必须用来采数据,不能换成板上的晶振,否则采到的字节会整体偏移。输出侧是像素时钟,如果直接驱动 VGA 或 RGB 屏,通常就是 27MHz;如果转 HDMI,则是 74.25MHz 之类,两者之间必须过异步 FIFO。
module bt656_top #( parameter H_ACTIVE = 720, // 一行有效像素数 parameter V_ACTIVE = 576, // 一帧有效行数(两场合计) parameter H_TOTAL = 1728 // 一行总字节数,含消隐 )( input wire rst_n, // ---- 视频输入侧:27MHz 域 ---- input wire vid_clk, // 必须由 BT656 源提供 input wire [7:0] vid_data, // 8bit 复用数据流 input wire vid_de, // 无行场线的模组可恒接 1'b1 // ---- 像素输出侧:像素时钟域 ---- input wire pix_clk, output reg vs_out, output reg hs_out, output reg de_out, output reg [7:0] r_out, output reg [7:0] g_out, output reg [7:0] b_out ); // 27MHz 域内部信号 wire sav_pulse, eav_pulse; wire f_field, v_blank; wire [7:0] y_pix, u_pix, v_pix; wire pix_valid; // 同步检测:找出每行的起止 bt656_sync_detect u_sync ( .clk (vid_clk), .rst_n (rst_n), .din (vid_data), .din_en (vid_de), .sav_pulse (sav_pulse), .eav_pulse (eav_pulse), .f_field (f_field), .v_blank (v_blank) ); // 4:2:2 -> 4:4:4,输出逐像素的 Y/U/V yuv422_to_444 u_422 ( .clk (vid_clk), .rst_n (rst_n), .din (vid_data), .din_en (vid_de & ~v_blank & ~sav_pulse & ~eav_pulse), .sav_pulse (sav_pulse), .y_out (y_pix), .u_out (u_pix), .v_out (v_pix), .pix_valid (pix_valid) ); // 后面的 YUV->RGB 与 FIFO 跨时钟域见 3.3、3.4 endmodule端口设计上有两个取舍值得说。vid_de这个信号很多模组根本不输出,但保留它可以让同一份代码兼容“带 DE 的并行接口”和“纯 BT656 裸流”,接法上区别只是恒接 1 还是接真实 DE。f_field单独引出来,是因为隔行视频的两场必须分开处理,后面做去隔行时它就是关键的场选择信号,如果在这里把它丢掉,二次开发时就得回头改顶层。
3.2 同步码检测:四字节滑窗加保护位校验
这个模块是整个工程的地基。思路是拿一个 3 级移位寄存器缓存历史字节,当前字节din加上d0/d1/d2正好构成 4 字节窗口,当窗口内容为FF 00 00 XY且 XY 的保护位校验通过时,判定命中。
module bt656_sync_detect ( input wire clk, input wire rst_n, input wire [7:0] din, input wire din_en, output reg sav_pulse, // SAV 命中,单拍脉冲 output reg eav_pulse, // EAV 命中,单拍脉冲 output reg f_field, // 0: 第一场 1: 第二场 output reg v_blank // 1: 当前处于场消隐 ); reg [7:0] d0, d1, d2; // d0 最早,din 最新 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin d0 <= 8'h00; d1 <= 8'h00; d2 <= 8'h00; end else if (din_en) begin d0 <= din; d1 <= d0; d2 <= d1; end end // 前三个字节必须是 FF 00 00,且 XY 的 bit7 恒为 1 wire hdr_hit = din_en && (d2 == 8'hFF) && (d1 == 8'h00) && (d0 == 8'h00) && din[7]; // 保护位校验:P3=V P2=H P1=F P0=V^H^F wire chk_ok = (din[3:0] == {din[5], din[4], din[6], din[5]^din[4]^din[6]}); wire is_sav = hdr_hit && chk_ok && (din[4] == 1'b0); wire is_eav = hdr_hit && chk_ok && (din[4] == 1'b1); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sav_pulse <= 1'b0; eav_pulse <= 1'b0; f_field <= 1'b0; v_blank <= 1'b0; end else begin sav_pulse <= is_sav; eav_pulse <= is_eav; if (is_sav || is_eav) begin f_field <= din[6]; // F 位 v_blank <= din[5]; // V 位 end end end endmodule参数和逻辑说明几点。din_en参与移位寄存器的使能,保证在数据间隙不会把旧字节反复移位造出假同步码;如果模组始终有数据输出,这里恒接 1 也不会出错,只是多消耗一点动态功耗。chk_ok用拼接的方式现场算 P3~P0,而不是写八个常量比较,好处是换 NTSC 或者做 10bit 变体时不用改表。
f_field和v_blank只在同步码命中的那一拍更新,之后一直保持,正好可以当作“当前行、当前场属性”来用。这两个信号是后面所有门控逻辑的依据:v_blank为 1 时整行都不该有像素输出,f_field变化时表示新的一场开始,帧计数器要复位。
实际调试时,这个模块最值得用在线逻辑分析仪抓来看的,就是sav_pulse和eav_pulse的间隔。正常情况下两者之间应该正好是 1440 个din_en有效的时钟周期。如果抓出来是 1438 或者 1442,说明数据源的行长度和标准不一致,后面的像素计数器会慢慢漂移,画面表现为从某一行开始整体左移。
3.3 行缓存与 4:2:2 转 4:4:4
检测到 SAV 之后,接下来的字节按 Cb、Y0、Cr、Y1 循环排列。转 4:4:4 的核心是在 Y 字节到达的那一拍,把对应的 U、V 凑齐输出。这里用一个 2bit 的字节序号做状态,配合两个保持寄存器。
module yuv422_to_444 ( input wire clk, input wire rst_n, input wire [7:0] din, input wire din_en, input wire sav_pulse, output reg [7:0] y_out, output reg [7:0] u_out, output reg [7:0] v_out, output reg pix_valid ); reg [1:0] byte_idx; // 0=Cb 1=Y0 2=Cr 3=Y1 reg [7:0] u_hold, v_hold; reg [7:0] v_prev; // 上一像素对的 Cr always @(posedge clk or negedge rst_n) begin if (!rst_n) begin byte_idx <= 2'd0; pix_valid <= 1'b0; u_hold <= 8'h80; v_hold <= 8'h80; v_prev <= 8'h80; end else if (sav_pulse) begin byte_idx <= 2'd0; // 每行 SAV 处清零,防止漂移累积 pix_valid <= 1'b0; end else if (din_en) begin pix_valid <= 1'b0; // 默认无效,只有 Y 到达才拉高 case (byte_idx) 2'd0: begin // Cb u_hold <= din; byte_idx <= 2'd1; end 2'd1: begin // Y0 y_out <= din; u_out <= u_hold; v_out <= v_prev; // 用上一对的 Cr 保证对齐 pix_valid <= 1'b1; byte_idx <= 2'd2; end 2'd2: begin // Cr v_hold <= din; byte_idx <= 2'd3; end default: begin // Y1 y_out <= din; u_out <= u_hold; v_out <= v_hold; pix_valid <= 1'b1; v_prev <= v_hold; byte_idx <= 2'd0; end endcase end end endmodule这里的写法把pix_valid只在 Y 字节处拉高一拍,U 和 V 在那一拍已经稳定,下游可以直接用pix_valid当时钟使能。sav_pulse复位byte_idx这一步不能省:一帧 625 行、每行 1440 字节,理论上一定能被 4 整除,但如果某次上电时时钟抖动让模组多发了一个字节,靠 SAV 复位能保证下一行重新对齐,不至于一路错到底。
关于v_prev,这是一个有意的折中。严格来说 Y0 应该配本像素对的 Cr(也就是下一个字节),但那需要提前一到两拍拿到数据,得再加一级缓存。用上一对的 Cr 带来的效果是色度在水平方向滞后一个像素,对 720×576 的 PAL 画面来说肉眼完全看不出,换来的是逻辑简单、时序宽松。如果做文字或者细线条画面要求严格对齐,可以改成用双口 RAM 预取两个字节,代价是多一个读延时。
3.4 YUV 转 RGB 的定点乘法
BT656 送出来的是 YUV,最终上屏或者写显存多半要 RGB。转换公式里带小数系数,FPGA 里必须转成定点乘法,否则综合工具会直接给你例化一堆浮点 IP。常用的做法是乘 256 再右移 8 位,Q8 精度对这一级来说足够了。
module yuv2rgb ( input wire clk, input wire rst_n, input wire [7:0] y_in, u_in, v_in, input wire de_in, output reg [7:0] r_out, g_out, b_out, output reg de_out ); // Q8 定点系数:真实值 × 256 localparam signed [19:0] C_RV = 20'sd359; // 1.402 localparam signed [19:0] C_GU = 20'sd88; // 0.344 localparam signed [19:0] C_GV = 20'sd183; // 0.714 localparam signed [19:0] C_BU = 20'sd454; // 1.772 reg signed [19:0] r_acc, g_acc, b_acc; reg signed [19:0] u_off, v_off; reg [7:0] y_d; reg de_d1, de_d2, de_d3; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin r_acc <= 20'sd0; g_acc <= 20'sd0; b_acc <= 20'sd0; u_off <= 20'sd0; v_off <= 20'sd0; y_d <= 8'd0; de_d1 <= 1'b0; de_d2 <= 1'b0; de_d3 <= 1'b0; end else begin // U/V 去偏置,Y 左移 8 位变成同一量纲 u_off <= $signed({2'b00, u_in}) - 20'sd128; v_off <= $signed({2'b00, v_in}) - 20'sd128; y_d <= y_in; r_acc <= $signed({3'b000, y_in, 8'd0}) + v_off * C_RV; g_acc <= $signed({3'b000, y_in, 8'd0}) - u_off * C_GU - v_off * C_GV; b_acc <= $signed({3'b000, y_in, 8'd0}) + u_off * C_BU; // de_out 与三级乘法流水对齐 de_d1 <= de_in; de_d2 <= de_d1; de_d3 <= de_d2; de_out <= de_d3; end end // 右移 8 位并做 8bit 饱和截断 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin r_out <= 8'd0; g_out <= 8'd0; b_out <= 8'd0; end else begin r_out <= (r_acc >>> 8) < 0 ? 8'd0 : (r_acc >>> 8) > 20'sd255 ? 8'd255 : r_acc[15:8]; g_out <= (g_acc >>> 8) < 0 ? 8'd0 : (g_acc >>> 8) > 20'sd255 ? 8'd255 : g_acc[15:8]; b_out <= (b_acc >>> 8) < 0 ? 8'd0 : (b_acc >>> 8) > 20'sd255 ? 8'd255 : b_acc[15:8]; end end endmodule几个参数得解释清楚。y_off那部分直接写成{3'b000, y_in, 8'd0},等价于y_in × 256,符号位补 3 个 0 是因为 Y 只有 8bit 无符号,扩到 20bit 才不会在减法里溢出。u_off和v_off的$signed强制转换必须加,Verilog 里混合有符号和无符号运算,综合工具的行为有时候跟仿真对不上,这是经典玄学问题,写清楚比事后查波形便宜得多。
饱和截断不能省。理论上 YUV 转到 RGB 出来的值应该在 0~255 之间,但 BT656 常用的 Y 范围是 16~235、色度是 16~240(所谓 studio range),超出范围的值如果不截断,直接取低 8 位会看到高光处突然变黑或者暗部变白,那种“鬼影”现象就是这样来的。如果确实要按 studio range 处理,还得再做一次(Y-16) × 255/219的拉伸,这个放在二次开发里讲。
4. 仿真与上板:让这套代码真跑出画面
代码写完只是第一步,BT656 这种协议密集型的模块,仿真跑通和上板出图是两个完全不同的难度等级。很多人卡在仿真全绿、上板黑屏,问题往往出在激励不够真实或者观测手段不足,而不是逻辑真的错了。这一章按“搭工程 → 造激励 → 上板抓”的顺序走一遍。
4.1 工程结构与搭建顺序
目录建议按功能分层,不要把所有文件堆在一个平铺目录里。BT656 相关的东西复用性强,分开放以后换项目直接拷子目录就行。
bt656_720x576/ ├── rtl/ │ ├── bt656_top.v # 顶层,只做例化和连线 │ ├── bt656_sync_detect.v # EAV/SAV 检测 │ ├── yuv422_to_444.v # 字节解复用 │ ├── yuv2rgb.v # 颜色空间转换 │ ├── timing_gen.v # 输出行场时序(VGA/HDMI) │ └── async_fifo.v # 27MHz 跨到像素时钟 ├── sim/ │ ├── tb_bt656_top.v # 顶层 testbench │ └── frame_720x576.hex # 一帧裸流,脚本生成 ├── script/ │ └── gen_bt656.py # 生成 hex 激励 └── constr/ └── bt656_pin.xdc # 引脚与时钟约束搭建顺序上有讲究:先把bt656_sync_detect单独仿真,只看sav_pulse、eav_pulse和v_blank对不对;再级联yuv422_to_444,检查pix_valid每行是不是正好 720 个;最后接上yuv2rgb和输出时序。反过来做的话,一旦画面不对,你没法判断是同步没对齐、像素少了、还是颜色算错了。
约束文件里最关键的一条是输入时钟。vid_clk必须约束成 27MHz 并且指定为输入端口,综合工具默认不会给它建时钟树,不约束的话时序分析直接跳过这一路,上板后偶发花屏完全没法查。
4.2 用脚本造一帧 720×576 的 BT656 激励
手写 testbench 生成 625 行 × 1728 字节不现实,正确做法是用脚本生成 hex 文件,仿真时$readmemh读进去。下面这个 Python 脚本生成一帧带彩条图案的 PAL 制 BT656 裸流,色度用简单的水平渐变,方便在波形里分辨行边界。
# gen_bt656.py —— 生成一帧 720x576 的 BT656 裸流(hex 格式) W_ACTIVE = 720 H_TOTAL = 1728 H_BLANK = H_TOTAL - W_ACTIVE * 2 # 288 字节 V_TOTAL = 625 V_BLANK_TOP = 23 # 场消隐行数(近似值,够仿真用) def xy_byte(f, v, h): """按 F/V/H 生成 XY 状态字,含保护位""" p3, p2, p1 = v, h, f p0 = p3 ^ p2 ^ p1 return 0x80 | (f << 6) | (v << 5) | (h << 4) | (p3 << 3) | (p2 << 2) | (p1 << 1) | p0 def yuv422_line(y_val, base_u, base_v): """生成一行 1440 字节的 Cb Y Cr Y 数据""" out = [] for x in range(W_ACTIVE): # 每两个像素共享一组色度,这里用轻微渐变模拟彩条 if x % 2 == 0: out.append((base_u + x // 8) & 0xFF) # Cb <p> <a href="https://download.csdn.net/download/u013867758/10814466" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>