简介:面向PYNQ与Zynq平台的HDMI输入输出工程,已在实际板卡上跑通,适合嵌入式开发者、FPGA学习者和多媒体应用设计人员。资源共380个文件,压缩包大小仅3.14MB,以Vivado工程文件为主,包含Verilog/VHDL源码、XDC引脚约束、Block Design框图、IP核配置、综合后网表以及Tcl/Jou工程脚本;另有Shell、JavaScript、批处理等辅助脚本和说明文档,并保留运行日志与综合报告,便于复现与排错。从文件结构可以清晰看出工程覆盖了HDMI接收与发送的完整链路,包括像素时钟与同步信号管理、色度空间转换、像素格式适配,以及AXI4-Stream接口的数据搬运。对想在PYNQ上快速实现视频输入、处理与输出的人来说,这是一份可直接参考的完整样例;目前已有2638人学习,具备实际验证价值。通过已提供的比特流和硬件描述信息,可在开发板上直接加载运行,观察HDMI输入解码与输出编码的效果,并在此基础上扩展图像滤波、视频叠加或实时渲染等功能。
1. PYNQ 上的 HDMI 输入输出,到底能干什么
如果你接触过 PYNQ 或者 Zynq 平台,多半早晚会撞上“hdmi in / hdmi out 同时接通”的需求:左边进来一个摄像头信号或电脑显卡信号,右边接一台显示器,中间在可编程逻辑里做一次实时处理,再把结果直接送出去。最典型的场景是边缘检测、画面叠加、视频格式转换,偶尔也会有人在两路 HDMI 之间做纯直通透传。你可能会以为这类功能从厂商示例工程里随手就能复现,真到自己布线、配 VDMA、接 EDID 时才发现,黑屏、花屏、掉帧几乎轮着来。这篇文章不讲某种源码包的内部实现,而是把我基于 PYNQ / Zynq 做 hdmi 输入输出时反复验证过的一条路径讲清楚:先弄懂视频时序,再搭好 AXI 数据流,最后靠一套避坑清单把系统稳定下来。新手能照着跑,熟手也能在参数和边界上做对比。
2. HDMI 输入输出背后的视频时序与 TMDS 编码:先读懂像素是怎么被搬上线的
很多人一上来就写寄存器、调 VDMA,遇到图像偏移就怀疑是驱动 bug,其实问题往往出在没搞明白视频时序的结构。HDMI 本质上是一根高速串行线,它把每一帧图像拆成“行”和“帧”,再按时钟周期串出去。你要配置的像素时钟、行消隐、场消隐、有效区,其实都藏在 CEA-861 或者 VESA 的时序表里。
2.1 查一帧 1080p@60 的时序参数:像素时钟、行消隐、场消隐怎么算
先看一张我经常用的参数表,这两组是最容易作为调试基准的分辨率。
| 分辨率 | 刷新率 | 像素时钟 | 行总像素 | 帧总行数 | 有效宽度 | 有效高度 |
|---|---|---|---|---|---|---|
| 1920x1080 | 60Hz | 148.5 MHz | 2200 | 1125 | 1920 | 1080 |
| 1280x720 | 60Hz | 74.25 MHz | 1980 | 750 | 1280 | 720 |
注意,像素时钟不是用有效像素直接乘帧率,而是用总像素算。以 1080p@60 为例,2200 × 1125 × 60 ≈ 148.5 MHz。很多人在计算 DDR 带宽或者 VDMA 吞吐时只算 1920×1080×60,结果带宽差了一截,就是这个原因。
行消隐就是一行总像素减去有效宽度,1080p 下是 2200 - 1920 = 280 个像素周期;场消隐类似,1125 - 1080 = 45 行。这些消隐期并不是空白的,HDMI 在消隐期会传控制字符和音频数据包,但在 FPGA 内部做视频处理时,通常只关心有效像素,所以绝大多数 IP 会单独把行同步、场同步、数据有效信号拆出来。
实际做工程时,我一般不会手算这些参数,而是去查 HDMI 官方规范和 VESA 的 CVT 标准。真正需要你记住的是另一个点:分频系数来自“总像素”而不是“有效像素”。如果你在 Block Design 里给视频时序生成 IP 填了 1920×1080,又给它配了一个 148.5 MHz 的像素时钟,它内部会自动产生 H/V 计数;但如果你的像素时钟配错成 1920×1080×60 = 124.4 MHz,时序生成器就永远跑不出标准时序,输出端轻则分辨率识别错,重则直接无信号。
对输入侧而言,时序解析 IP 一般能自动从 hdmi 接收端芯片恢复出来的像素时钟里取得 H/V 信号,不需要你去算相位。但你要关注的是:输入分辨率会不会在运行中变化。很多信号源会根据内容的宽高比自动切换输出格式,从 1920×1080 切到 1280×720,甚至切到 640×480。如果你的 VDMA 缓冲区和行跨度是按 1080p 分配的,切换后必然花屏。所以设计初期就要想清楚:是只支持单分辨率,还是需要动态检测并重新配置。
2.2 EDID 握手与 TMDS 编码:为什么输入方向和输出方向不能照搬
HDMI 的物理链接是四对差分线:三对数据线分别传 RGB(或 YCbCr)颜色通道,一对时钟线。数据先进行 TMDS 编码,把每 8 位像素展成 10 位直流平衡码,再串行发送。所以你在 FPGA 里看到的像素是 24 位并行 RGB,但在线上是 3×10 位串行 TMDS 流。接收端芯片负责把串行差分信号恢复成并行数据,并且恢复出与像素同步的像素时钟;输出端则正好相反,需要把并行像素编码成 TMDS 串行流驱动出去。
这带来一个重要结论:hdmi in 和 hdmi out 不是“反过来接”的同一个模块。输入端通常要例化接收芯片的接口逻辑,用于时钟数据恢复;输出端则用 FPGA 自身的 OSERDES 或发送芯片把并行数据串行化。两者的时钟来源也一样:输入的像素时钟是被动锁定到信号源的,输出的像素时钟则是由本地 PLL 主动产生的。如果你想把输入数据和输出直接相连,两边的像素时钟不同步是必然的,中间要么过异步 FIFO,要么过 DDR 帧缓冲。
真正容易被忽略的是 EDID 交互,这也是输入通道能不能出图的关键。hdmi in 场景下,你的 FPGA 板卡扮演的是“显示器”角色,需要向信号源提供一份合法的 EDID 数据,告诉它“我能支持 1080p@60、24 位 RGB、HDMI 1.4 特性”。这份 EDID 通过 DDC 通道(本质是一条 I2C 总线)提供给源端读取。
hdmi out 场景则相反,你的板卡是“源端”,需要主动去读显示器侧 EDID。也就是说,同样是 I2C 物理接口,输入侧要做 I2C 从机,输出侧要做 I2C 主机,状态机逻辑完全不同。我见过不少新手把输入输出子板接到同一个 I2C 控制器上,结果输入侧没应答、输出侧读不全显示器参数,两边都不工作。
另外,TMDS 编码里的“直流平衡”经常被忽略。10 位编码有一项规则叫最少跳变差分信号,它保证长时间传输时高低电平的累积一致。如果你在自定义输出逻辑里把 8 位像素直接扩展成 10 位而不做编码,物理层确实能收到波形,但接收端显示器会判定为无信号或黑屏。这也是为什么我在做输出时更倾向使用成熟的发送芯片或厂商视频 IP,而不是自己从零写编码器。
3. 用 PYNQ / Zynq 搭 HDMI 通路:从板卡选型到 AXI 数据流设计
原理看懂了,接下来是系统架构。这里说的架构不是某个工程里的高阶设计,而是把 hdmi 输入输出跑通所必需的通用骨架。我的建议是:先定子板,再画数据流,最后写软件。顺序反了,后面多半要返工。
3.1 常见 HDMI 子板方案与引脚分配:先确认你的板子能接几路
PYNQ 板卡和许多 Zynq 评估板一样,对外提供排针接口,有人专门做 HDMI 子板:一块负责输入,一块负责输出。每块子板引出的关键信号大致有:一对差分时钟输入或输出、两对或三对差分数据线、DDC 的 SCL/SDA、HPD(热插拔检测)引脚、5V 电源、地。有些子板还引出了 CEC 控制线,但大多数工程用不到。
拿到一块陌生板卡,我第一件事不是看 IP,而是看原理图里 HPD 是谁驱动的。HPD 是“显示器已连接”的信号,输出端由源端拉高,输入端由 FPGA 或接收芯片提供。子板设计如果是开漏结构,FPGA 引脚就要配置成带上拉;如果是推挽结构,就不能额外再启用上拉,否则电平可能到不了 L 或者 H 的有效门限。
引脚分配上要特别留意差分对的位置。HDMI 数据线频率很高,普通 IO 上的伪差分结构往往跑不动 1080p。我踩过一个大坑:某块板上把 hdmi_out 的数据引脚放在普通单端 IO 上,短时间小分辨率没问题,一上 1080p 就花屏,最后只能是换子板或降低分辨率。所以不要只看接口排针能不能插入,还要确认引脚是否具备高性能差分输出能力。
还有一路是 DDC。HDMI 的 DDC 物理上就是 I2C,速度通常是 100 kHz 或 400 kHz。对于输入侧,FPGA 要作为从机应答 0x50 地址;对于输出侧,FPGA 要作为主机去读显示器的 EDID。很多参考工程为了省事,直接把 EDID 数据放到 BRAM 里,用一个简单的 I2C 从机状态机来响应。这种做法可行,但要注意源端可能连续读块,你的状态机必须支持地址自动递增,不能每次都从头开始。
3.2 系统链路设计:HDMI 输入、DMA 搬运、内存缓冲、HDMI 输出的完整数据流
我常用的链路结构是“接收解析 → 时序转 AXI Stream → VDMA 写入 DDR → 处理环节 → VDMA 从 DDR 读出 → 时序生成 → 发送”。其中最关键的角色是 VDMA(视频直接内存访问)和视频时序控制器。VDMA 负责把连续的 AXI Stream 视频数据写成帧格式放入 DDR,也负责把内存里的帧读出来变成 AXI Stream。没有它,你就要自己在 RTL 里写缓存和突发逻辑,工作量大且容易出错。
| 链路阶段 | 主要组件 | 时钟域 | 数据格式 |
|---|---|---|---|
| 物理接收 | HDMI 接收子板、接收芯片 IP | 外部像素时钟 | 并行 RGB / YCbCr |
| 时序解析 | 视频时序控制器 | 像素时钟 | AXI4-Stream |
| 帧存储 | VDMA S2MM | AXI 时钟 | AXI4 内存映射 |
| 处理 | HLS 算子或处理器代码 | 处理器时钟 | DDR 缓冲 |
| 帧读取 | VDMA MM2S | AXI 时钟 | AXI4-Stream |
| 输出 | 视频时序生成 + TMDS 发送 | 本地像素时钟 | 并行像素 |
这张表不仅是结构图,也是排查时的分工边界。比如画面花屏,先判断是输入解析错、DMA 写错,还是输出时序错;方法就是分两端跑测试卡,这在后面章节会细说。
带宽估算是绕不开的。1080p@60、24 位 RGB 的原始数据率是 1920×1080×60×3 字节,约 373 MB/s。如果你的处理流程是“输入写入一帧缓冲,处理器读出,处理后再写回输出缓冲,VDMA 再读输出帧”,DDR 上至少要承担输入写 373MB/s、处理器读 373MB/s、处理器写 373MB/s、输出读 373MB/s,合计接近 1.5GB/s。Zynq 的 DDR 控制器理论带宽远大于这个数,但实际 AXI 效率、刷新开销、多端口仲裁都会打折,所以别在写代码之前就低估它。
3.3 在 PYNQ 上用 Python 配置 HDMI IP:一段最少工作的配置脚本
PYNQ 的优势在于可以用 Python 操作内存映射和硬件句柄。下面这段代码是我在一个模拟项目 X 里用的最简启动流程,不依赖某个具体厂商封装,而是自己打开 /dev/mem 映射寄存器并配置 VDMA。这里重点展示操作顺序,具体寄存器基地址需要按你的 Block Design 里 IP 的地址分配替换。
import mmap import struct import time # 假设以下地址来自当前工程的地址映射 AXI_VDMA_BASE = 0x43000000 # 输入 VDMA 基地址 AXI_VDMA_OUT_BASE = 0x43001000 # 输出 VDMA 基地址 FRAMEBUF_IN = 0x1E000000 # 输入缓存区物理地址 FRAMEBUF_OUT = 0x1F000000 # 输出缓存区物理地址 WIDTH = 1920 HEIGHT = 1080 STRIDE = 1920 * 3 # 24位RGB 行跨度 # 映射一个 4KB 页面,覆盖 VDMA 寄存器组 with open("/dev/mem", "rb") as f: mm = mmap.mmap(f.fileno(), 4096, mmap.MAP_SHARED, mmap.PROT_READ | mmap.PROT_WRITE, offset=AXI_VDMA_BASE & ~(4096 - 1)) def write_reg(offset, value): mm.seek(offset % 4096) mm.write(struct.pack("<I", value)) # 停止 S2MM 通道,防止残留状态 write_reg(0x030, 0) time.sleep(0.1) # 配置输入 VDMA:写帧缓冲地址并启动 write_reg(0x030, 1) # 复位 S2MM write_reg(0x038, FRAMEBUF_IN) # 第一帧地址 write_reg(0x040, FRAMEBUF_IN) # 第二帧地址(乒乓) write_reg(0x048, FRAMEBUF_IN) # 第三帧地址(循环) write_reg(0x0AC, 1) # 启动循环帧模式逻辑说明:这段代码做了三件事,先把 S2MM 通道复位到已知状态,再写入帧缓冲起始地址,最后启动循环采集。S2MM 是 VDMA 的“写内存方向”,即把外部视频流写入 DDR。很多新手只看 data sheet 里说写地址,但忘了不同作废版本会区分 0x030、0x038、0x040 的含义,这里我按常见寄存器排布给出,但更重要的习惯是:每次改动代码前先把通道复位,等待至少几个时钟周期,再写新的帧地址。
参数说明:FRAMEBUF_IN 必须与 VDMA 驱动的内存地址一致,且建议铺在 DDR 中连续区域。WIDTH 与 HEIGHT 是像素数,STRIDE 是每行跳过的字节数,它和 WIDTH×3 不一定相等,因为 VDMA 按 64 字节对齐会多占用一些空间。如果你后续用 NumPy 读回图像,reshape 时要按 STRIDE 而不是 WIDTH×3,否则行尾错位。
配置输出 VDMA 的 MM2S 通道逻辑类似,只是寄存器方向对应“从内存读出来”。真正跑在 PYNQ 上时,还要额外打开时钟使能以及中断控制。我现在习惯把这段代码封装成函数 vdma_config(base, buf_addr, width, height, stride),这样输入输出可以复用。
4. 把 hdmi_in / hdmi_out 从裸工程跑到能实时出图:迭代过程与细节
系统的架构定下来后,就需要在 Vivado 里把 Block Design 搭出来,再回到 Python 侧写处理逻辑。这一章按我实际操作时的顺序展开,每个环节都给到能直接抄的参数和命令。
4.1 硬件工程部分:重建 Block Design 的最小步骤
先说明,Vivado 版本不同,IP 名称和配置对话框布局会有差异,但核心步骤不变。我不堆代码,而是按 GUI 操作难懂处补 Tcl 命令,下面是重建最小工程的 Tcl 风格片段,适用于 batch 模式。
# 创建工程,part 以实际板卡为准 create_project hdmi_inout ./hdmi_inout -part xc7z020clg400-1 # 添加 Zynq 处理器系统 create_bd_cell -type ip -name processing_system7_0 xilinx.com:ip:processing_system7:5.5 apply_bd_automation -rule xilinx.com:bd_rule:processing_system7 \ -config {make_external "FIXED_IO, DDR" Master "Disable" Slave "Disable"} # 添加视频时序控制器 create_bd_cell -type ip -name vtc_in xilinx.com:ip:video_timing_controller:2.0 set_property -dict [list \ CONFIG.Enable_Detector {true} \ CONFIG.Enable_Generator {false}] \ [get_bd_cells vtc_in] # 添加带有四个帧缓冲功能的 VDMA create_bd_cell -type ip -name vdma_in xilinx.com:ip:axi_vdma:6.3 set_property -dict [list \ CONFIG.c_s2mm_enable {true} \ CONFIG.c_mm2s_enable {false}] \ [get_bd_cells vdma_in] validate_bd_design save_bd_design逻辑说明:这个脚本创建了一个最简单的“视频写入”骨架。processing_system7 提供 PS 侧的 DDR 内存和 AXI 主端口,video timing controller 被配置为检测模式,用来解析外部进入的行场同步信号和有效窗口。axi_vdma 配置只打开 S2MM,也就是只负责把 AXI4-Stream 视频数据写入内存。如果你是第一次做,先不要急着加输出通道,把一个方向调通再复刻另一个方向,踩坑成本会低很多。
参数说明:c_s2mm_enable 是 VDMA 写内存通道使能,c_mm2s_enable 是读内存通道使能。很多教程会默认把两个通道都勾上,但分步验证时最好一次只开一个。CONFIG.Enable_Detector 为 true 表示 VTC 作为输入检测器,它会根据外部送来的 H/V 信号自动生成帧同步。如果之后要改成输出生成模式,就反过来把 Generator 打开、Detector 关闭。
在 GUI 里你还必须连接 AXI Interconnect、复位模块和时钟模块。像素时钟的源头在输入侧是子板接收芯片输出,在输出侧是 MMCM/PLL 生成。我会在 Block Design 里单独建一个 clocking wizard,输出像素时钟作为生成器主时钟,同时把输入像素时钟直接连到 VTC 检测器,两个时钟域互不干扰。这一步完成后就能综合出 bit 流,虽然还没有任何软件逻辑,但可以先抓上板后的寄存器状态来验证硬件树是否正常。
4.2 软件部分:从内存读回视频帧,做一次 Sobel 边缘检测再送 hdmi_out
硬件就绪后,PyNQ 侧的工作是把帧缓存区当作一个大数组操作。下面的 Python 代码演示的是:从输入帧缓冲中取一帧,用一个简化 Sobel 算子提取边缘,再写入输出帧缓冲。
import numpy as np # 将物理地址映射为 numpy 数组 def buffer_as_array(phys_addr, num_bytes): with open("/dev/mem", "rb") as f: mm = mmap.mmap(f.fileno(), num_bytes, mmap.MAP_SHARED, mmap.PROT_READ | mmap.PROT_WRITE, offset=phys_addr & ~(4096 - 1)) return np.frombuffer(mm, dtype=np.uint8, count=num_bytes) in_arr = buffer_as_array(FRAMEBUF_IN, WIDTH * HEIGHT * 3) out_arr = buffer_as_array(FRAMEBUF_OUT, WIDTH * HEIGHT * 3) # 重塑为 H x W x 3,这里假设行之间没有额外对齐 in_img = in_arr.reshape(HEIGHT, WIDTH, 3) # 简单灰度化:取蓝色通道作为亮度近似 gray = in_img[:, :, 0].astype(np.float32) # 3x3 Sobel 水平梯度 gx = np.zeros_like(gray) gx[1:-1, 1:-1] = ( -1 * gray[:-2, :-2] + 1 * gray[:-2, 2:] + -2 * gray[1:-1, :-2] + 2 * gray[1:-1, 2:] + -1 * gray[2:, :-2] + 1 * gray[2:, 2:] ) edge = np.clip(np.abs(gx), 0, 255).astype(np.uint8) # 把边缘写回蓝色通道,保持红绿为 0 out_arr[..., 0] = edge out_arr[..., 1] = 0 out_arr[..., 2] = 0逻辑说明:这段代码是最直观的“从输入帧读到输出帧写回”软件环。in_arr 和 out_arr 是对物理内存的直接映射,中间没有拷贝,所以操作 numpy 数组就等于操作硬件帧缓冲。Sobel 卷积用向量化切片实现,步长是 1 像素。真实项目里,纯 Python 处理一帧 1080p 肯定跑不到 60fps,所以这个实现更适合验证链路;生产环境一般把卷积做成 HLS IP 放进 PL。
参数说明:reshape 时假设 STRIDE 等于 WIDTH×3。如果你的 Block Design 里 VDMA 行跨度有对齐,这个假设会错,行尾会偏移,画面出现斜向割裂。我一般会在软件里先打印 in_arr.shape,再手动确认一行结束后的内存偏移。另一个关键点是帧缓冲双缓冲地址应该不同,否则输入 VDMA 写这一帧的同时,输出 VDMA 在读同一帧,撕裂就发生了。本例为了简化只写了一个地址,实际工程要准备两个地址轮流使用。
4.3 hdmi_in 与 hdmi_out 同时接通时,如何保持时钟域同步
双 HDMI 同时工作的核心问题是:输入像素时钟来自信号源,输出像素时钟来自本地 PLL,二者相位无关。如果在 RTL 里把输入 stream 直接接到输出 timing controller 上,VTC 会产生错误的行缓存长度,画面要么左右撕裂,要么滚动。正确做法是用帧缓冲作为两端的缓冲池,把输入侧写入 DDR,输出侧按自己的时间轴从 DDR 读出来。
为了让画面不撕裂,我在双缓冲基础上会再加一个简单握手协议:输入侧每写完一帧,软件或 PL 侧切换下一个可写缓冲区;输出侧在垂直消隐期切换当前读缓冲。只要输出始终比输入慢半拍到一拍,人眼基本察觉不到延迟。反过来,如果你硬要追求零延迟透传,输入输出必须共用同一个像素时钟域,这时只能把源端时钟作为系统时钟,输出显示器的时序也完全由输入源决定,做法比较受限。
用什么来控制切换?最省事的是 VDMA 的 frame counter 中断。VDMA 每完成一帧会置一个中断位,软件处理器轮询该位,然后更新输出 VDMA 的读地址。轮询间隔不宜太短,否则频繁中断会占用 CPU。我在模拟项目 X 上用的是每 4 帧切换一次,延迟增加约 67ms,但画面稳定性好很多。
# 中断轮询伪代码:等待 VDMA S2MM 帧完成,再切换输出读地址 last_frame = 0 while True: irq_status = read_reg(AXI_VDMA_BASE, 0x040) if irq_status & (1 << 24): # 第 24 位表示帧完成 last_frame = 1 - last_frame write_reg(AXI_VDMA_OUT_BASE, 0x038, FRAMEBUF_OUT + last_frame * FRAME_BYTES) # 清除中断标志 write_reg(AXI_VDMA_BASE, 0x040, irq_status) time.sleep(0.001)逻辑说明:这段代码处理输入 VDMA 的帧完成中断,然后把输出读缓冲地址切换到另一块帧缓存。第 24 位是否代表帧完成,取决于 VDMA 版本,你需要查当前芯片厂商的寄存器摘要。我写这段的用意在于演示“谁触发切换”这件事由中断决定,而不是在软件里盲目 sleep 固定时间。固定 sleep 在输入分辨率变化时会彻底失效。
参数说明:FRAMEBUF_OUT 必须以帧大小对齐,切换地址时用 last_frame * FRAME_BYTES 作为偏移,要保证 FRAME_BYTES 也是 64 字节对齐。如果输出分辨率与输入分辨率不同,例如输入 720p、输出 1080p,那么不能直接切换缓冲区,还必须接入缩放逻辑,这种事不要指望 VDMA 能替你完成。
5. HDMI 落地避坑手册:五条最常遇到的翻车现场与排查路径
这一章是我从实际调试里沉淀下来的经验,每一条都对应过具体案例。照着排查思路走,能省下不少折腾的时间。
5.1 输入黑屏:EDID 没起来,或者 DDC 链路不通
现象:hdmi_in 接上电脑后,源端显示“已连接”,但你的 FPGA 硬件帧计数始终为零,抓不到有效行同步。
原因:输入侧没向信号源提供可用的 EDID。源端在连接后第一步就是通过 DDC 读取显示器信息,读不到或读到非法 EDID,就会拒绝输出画面。很多用户买了 HDMI 输入子板,但漏了处理 I2C 从机逻辑,或者把 EDID 数据放在外部 EEPROM 里却没焊。
解决:先在子板上确认有没有 EEPROM 通道。如果有,把这个通道挂到 DDC 总线上就行;如果没有,就得在 FPGA 里写一个 I2C 从机状态机。我做过的最简实现是:把 256 字节 EDID 放到 BRAM,从机只响应地址 0x50,每次读操作按偏移返回。一定要支持连续读,否则部分显卡读完第一个块就认为协议异常。调试时在 PS 侧加一个 DDC 错误计数寄存器,每发生一次 NACK 就加一,能快速判断是状态机问题还是物理链路问题。
5.2 输出花屏:像素时钟与 AXI 频率没理顺
现象:hdmi_out 能出画面,但出现斜向撕裂、颜色横向错位,重插显示器后偶尔恢复正常。
原因:输出 VDMA 的行跨度设置与实际帧布局不符。1920×1080×3 是 5760 字节,这个数按 64 字节对齐后是 5824 字节,而 VDMA 的 line stride 寄存器要求填实际分配的行宽,不是像素宽度乘通道数。若软件侧按 5760 去 reshape,硬件按 5824 搬运,画面就会错位。另一个次要原因是输出像素时钟没加时序约束,工具无法优化像素域路径。
解决:固定所有帧缓冲行大小对齐到 64 字节,软件侧 reshape 也用对齐后的 stride。VDMA 配置里通常有 width 和 stride 两个概念,width 是有效像素字节数,stride 是实际跳过的总字节数,这两个值必须分别填写。验证时用一块“彩条数据”先喂给输出端,如果彩条显示正常但视频花屏,问题就在 VDMA stride;如果彩条也乱,问题更可能在时钟约束或者 TMDS 发送链路。
5.3 HPD 误判:Cable disconnected 永远报真
现象:HDMI 线明明插着,但驱动状态机一直检测到 Cable disconnected,或者显示画面间歇黑屏。
原因:HPD 信号电平不对。HPD 在高电平时表示已连接,但不少子板为了兼容多种电压域,会把 HPD 做成开漏驱动,FPGA 端如果没启用内部上拉,线上的电压就浮空,检测到低电平。
解决:先在 Vivado 约束里给 HPD 引脚加PULLUP TRUE,再检查子板原理图确认 HPD 驱动方式。假如子板已经加了上拉到 3.3V,FPGA 端就不能再启用内部上拉,否则两个上拉并联会削弱驱动能力。这条线低频率不高,但对电平阈值敏感,不要想当然。如果 FPGA 有内嵌接收芯片 IP,有的寄存器可以直接读取 HPD 状态,省去再用 GPIO 检测的过程。
5.4 DMA 带宽不足导致掉帧、撕裂
现象:画面流畅度时好时坏,处理器跑起来后掉帧明显,有时静置也间歇撕裂。
原因:DDR 带宽被占满。要注意处理器和两个 VDMA 同时访问内存时的竞争,尤其是 Python 里的 NumPy 操作会触发大量连续内存访问,优先级与 VDMA 并不相同。部分低端 Zynq 的 DDR 端口数有限,多个 AXI 主设备同时发长突发会互相阻塞。
解决:把输入输出 VDMA 设为同步模式,让它的读写首尾相接,减少跨越内存长的总线持锁。处理器侧不要一帧一读,最好用双缓冲:输入 VDMA 写帧 A 的同时处理器去读写帧 B。如果带宽仍然不够,就把像素处理搬到 PL 侧,让 VDMA 的 AXI Stream 直接接入处理 IP,不经过 DDR。这个判断标准很简单:用硬件计数器测量每一帧从提到完成的耗时,超过 16.6ms 就说明链路需要优化。
5.5 嵌入 ILA 后时序违例,甚至影响出图
现象:加了 ILA 调试核后,整体时序报告变红,原本能出图的板卡开始闪屏或完全黑屏。
原因:ILA 的采样时钟选错,或者把触发条件设置得太频繁。若 ILA 采样视频像素时钟,而驱动时钟来自输入源,一旦源端切分辨率,采样时钟频率突变,调试逻辑反而先崩。
解决:ILA 采样时钟尽量选固定频率的系统时钟,把被测的 AXI Stream 信号做跨时钟域转换后再接进去。触发条件用 tvalid && tready 的组合,不要开默认的全信号全时钟沿采样,那样会把大量无关跳变也灌进环形缓冲。另外,不要把 ILA 放在像素最密集的链路中间,而是放到 VDMA 后级的 AXI 侧,数据已经结构化成帧,触发条件干净,资源占用也要少一个量级。
6. 进阶验证:用测试卡和寄存器面板验证整条像素链路
当系统能在真实信号源下出图后,我会再做一轮主动验证,而不是等出了问题才查。这一轮的核心是“隔离变量”:先把输出链路证明可靠,再把输入链路证明可靠,最后才让它们合流。
# 直接向输出 VDMA 缓冲写入一张蓝灰条纹测试卡 out_arr = buffer_as_array(FRAMEBUF_OUT, WIDTH * HEIGHT * 3) frame = np.zeros((HEIGHT, WIDTH, 3), dtype=np.uint8) frame[:, 0::4] = (255, 0, 0) # 每4个像素画一条蓝色竖带 out_arr.reshape(HEIGHT, WIDTH, 3)[...] = frame逻辑说明:测试卡由纯软件生成,不依赖输入源。如果显示器上出现等距蓝色竖条纹,就证明输出 VDMA、HDMI 发送芯片、显示器 EDID 协商这三段都是通的,接下来再排查输入问题就只关注输入端和 DDR 缓冲这一侧。如果条纹缺失或错位,问题一定在输出侧,此时可以逐段检查 VDMA 读通道和时序生成器。
参数说明:0::4 这种跳步写法要求 WIDTH 能被 4 整除,1080p 满足。实际测试时我还会换全红、全绿、全蓝三种纯色场,用于检查颜色通道是否接反。LCD 显示器常见的 RGB 顺序错误在这里一验便知。
输入链路验证我常用“一行偏移”手法:把输入帧的每一行向右平移一个像素写到输出帧。
out_arr.reshape(HEIGHT, WIDTH, 3)[1:, :, :] = in_arr.reshape(HEIGHT, WIDTH, 3)[:-1, :, :]如果画面整体下移一行,说明输入采集与输出显示的行序一致;如果出现一行黑线在某个位置跳动,就说明 VDMA 的 stride 和 width 配置不一致。这是非常灵敏的验证手段,比盯着满屏噪点猜测有效得多。
调试到最后,我会把 hdmi_in 的 EDID 状态、HPD 电平、帧计数、DDC 错误计数,以及 hdmi_out 的帧计数全部映射到一组只读 AXI 寄存器,写一个循环脚本每秒采集一次。曾经我为了验收一块新板子,连续按这个流程跑了上千次,结果抓出过一次子板 HPD 上拉电阻虚焊导致的间歇性丢帧。这个惨痛经历让我养成了“能测量的东西绝不靠猜”的习惯。后来每次接手新板卡,我都先跑三件套:彩条测试、输入偏移、寄存器统计,整套流程跑完才敢说链路是稳的。希望这套验证习惯也能帮到你少走弯路。
本文还有配套的精品资源,点击获取