简介:面向PYNQ及Zynq嵌入式开发者的HDMI输入输出参考工程,以实测跑通的完整链路演示如何配合Vivado与Python配置视频接口,覆盖接口层、FPGA逻辑层与软件控制层,适合正在设计视频采集、显示回环或图像处理应用的FPGA工程师及进阶学习者。压缩包共380个文件、3.14MB,主体为v/vhd/vhdl硬件描述代码,xdc约束文件定义引脚与时钟,xci、bit与dcp保存Vivado工程配置及综合结果,runme.bat、sh、tcl脚本承担自动化编译与运行,配合Video.bd块设计与txt说明形成从逻辑到上板的完整闭环。目前已有2638人学习下载。借助这套已跑通的工程,使用者既可对照接收发送两侧的时钟同步、色度空间转换与AXI4-Stream数据通路实现,快速排查自身配置问题,也能直接复用其中Verilog/VHDL代码与Python控制思路,将视频接口逻辑迁移到同类Zynq项目中,大幅缩短二次开发周期。
1. PYNQ HDMI:从一条黑屏的线到一整套视频通路
一根HDMI线接上PYNQ板卡,屏幕却一片漆黑——这是很多人第一次碰PYNQ HDMI时遇到的场景。板子上明明标注了HDMI in和HDMI out两个接口,看起来就像一台微型电视,可真要让输入变成Python里的一帧图像、再让处理结果输出到显示器,中间隔着的是一整套视频通路,而不是一根线。这个标题要讲的,就是怎么把这条路打通:HDMI in如何变成VDMA里的帧缓冲,处理完的图像又如何从内存推到HDMI out。这套方案并不绑定某种特定板卡,PYNQ能跑,通用Zynq也行,区别只在于验证速度。适合手上有Zynq或PYNQ开发板、想搞视频采集与显示的人,也适合那些正在为视频通路选型拿不准的嵌入式视觉从业者。
2. 从物理接口到DDR地址:PYNQ HDMI的视频通路与选型
2.1 输入和输出:两条方向相反的TMDS链路
HDMI的物理层走的是TMDS差分对,但输入和输出完全不是一回事。输入端,HDMI RX要把高速串行数据里的像素时钟恢复出来,再解出RGB24、行场同步信号;输出端,HDMI TX要把像素数据和时钟编码成差分串行信号送出去。方向相反,链路结构也不同,所以别指望拿一个IP核复用它。输入端有一套时钟恢复逻辑,输出端则要自己产生像素时钟并编码,两边都要处理热插拔检测(HPD)和5V电源关系。很多人第一次看到PYNQ板上有两个HDMI口,误以为用跳线把in接到out就能在屏幕上看到输入源,实际拿到的只是一片黑或满屏雪花,原因就在物理层这第一步就没过。
我把这套路线拆开看,大概是这样一条数据流:HDMI物理接口、接收/发送IP核、AXI4-Stream流、VDMA、DDR内存,最后才到CPU。PYNQ上面这套链路通常是预置的,但你不知道它为什么能工作,出了问题就抓瞎,所以我还是建议从链路选型开始理解它。
注意:HDMI输入的时钟恢复不是说给个参考时钟就行,IP核内部会从TMDS数据里提取时钟。如果输入源不稳定或者线材太差,恢复出来的像素时钟会有抖动,后面整条通路都跟着出问题。所以别把锅都甩给VDMA,先看源端。
2.2 AXI4-Stream与VDMA:视频流怎么变成内存里的帧
视频解出来后,标准的做法是包成AXI4-Stream流,每个时钟周期传输一个像素,伴随行同步和帧同步信号。AXI4-Stream的好处是协议简单,没有地址概念,适合做流式处理。但CPU没法直接操作流,所以必须把它搬进DDR内存,这就轮到VDMA出场。VDMA有大名鼎鼎的三件事:S2MM通道把AXI4-Stream写入内存,MM2S通道把内存里的帧读出来变成AXI4-Stream,还支持行步长、帧缓冲循环和中断通知。
这里有个选型上的关键点:视频像素数据在DDR里不是简单的连续堆叠。一行1920像素乘以3字节,共5760字节,但DDR访问和对齐要求可能让每行之间有填充。VDMA有一组寄存器专门设置行步长(FRMDLY_STRIDE),目的就是让每一行的起始地址可以自由指定,处理这种行对齐问题。如果你自己写逻辑搬数据,行步长、跨时钟域、帧同步三件事会让你的RTL代码膨胀到不可维护。VDMA把这些都做成寄存器配置,这也是它在这个方案里不可替代的原因。
从PYNQ视角看,VDMA的寄存器会映射到PS端的某一段地址,Python里用MMIO就能读写。这套方案的本质,就是让FPGA负责像素级的搬运和编解码,让CPU只管处理帧,各干各的。
2.3 为什么选VDMA而不是自己写DMA
有人在论坛问过:ZXU的FPGA里能不能自己写个状态机去搬视频帧?答案是可以,但别这么干。自己写DMA,首先要把AXI4-Stream的时序吃透,是每像素一个beat还是每行一个beat,tlast和tuser怎么拉,光调试这个就够喝一壶的。其次,视频帧是周期性的,你要处理帧边界、行边界、缓冲切换的瞬间,任何时序没算对,图像就撕裂或者花屏。VDMA是经过实际验证的IP,它的循环模式可以自动在多个帧缓冲之间轮转,你不必在每一帧结束时介入。
另外一点是,VDMA的中断机制很成熟。SYNC帧完成、SOF、DMA错误都能触发中断,可以在PS里监听。对PYNQ来说,可以直接轮询寄存器读帧计数,不必上中断,这在调试阶段反而简单。选VDMA不是因为它名字好听,而是因为它的寄存器语义和驱动支持已经很稳定,你只需要配几个地址和尺寸就能把视频帧搬进内存,省出的时间可以用来处理真正的图像算法。
2.4 PYNQ和通用Zynq的落地差异
标题里说“zynq也可使用”是实话,但实际的落地路径差别不小。PYNQ这边,官方镜像已经预装好Python环境、pynq库和一套可用的overlay,打开Jupyter就能操作。通用Zynq那边,你得自己在Vivado里搭Block Design,做FSBL启动,写设备树,甚至要手写一个字符设备驱动暴露帧缓冲给用户态。本质上视频通路是一样的,但验证速度差一个量级。
我的经验是,如果手头有PYNQ,先用它的overlay把视频通路跑通,确认HDMI in能出帧、out能出画面,再开始按自己的需求裁剪。PYNQ里overlay其实就是一个比特流加一段Python封装,它的IP互联逻辑完全可以导出来在通用Zynq上重新生成。唯一要注意的是,PYNQ镜像里的overlay会默认初始化一部分外设,换到纯Zynq时这些初始化要自己在FSBL里补上,否则就会出现“同样的比特流,跑不起来”的尴尬。
3. HDMI in 出图:接收通路配置与第一帧的诞生
3.1 接收IP核的最小参数表
HDMI输入侧的IP核,不同版本叫法不同,但核心寄存器思路是相近的。我一般会先把数据宽度、像素格式、同步极性这三组参数定下来,这三样错了画面基本就是花屏和偏色。下面这份参数表是我在PYNQ和Zynq上常用的起点,按1080p60输入来设的。
| 参数项 | 推荐值 | 影响什么 |
|---|---|---|
| 像素数据宽度 | 24bit | RGB888,每像素3字节,最通用的格式 |
| 输出格式 | RGB888 | 如果输出YUV422,内存排布完全不同 |
| 帧缓冲数量 | 2 | 少于2会造成撕裂 |
| 行同步极性 | 按输入源配置 | 极性反了画面会偏移或抖动 |
| 场同步极性 | 按输入源配置 | 极性反了会帧同步错乱 |
| 像素时钟去抖 | 开启 | 处理信号质量不佳时的抖动 |
先澄清一个容易误解的点:HDMI RX解出来的数据不是直接接到VDMA就完事的,中间一般要过一个“Video In to AXI4-Stream”的桥接,把行场同步信号转成AXI4-Stream的tuser和tlast。如果你用的是某个现成的HDMI RX IP,它会封装好这些细节;但如果你是自己在Vivado里搭,一定记得在RX IP后端加这个转换,否则VDMA收到的流没有帧边界,帧计数永远不动。
3.2 用PYNQ配置VDMA并拿到第一帧
拿到第一帧的关键动作就是把VDMA的S2MM通道配好,然后轮询帧计数器。下面是简化版流程,适用于PYNQ镜像里已经加载好overlay、且overlay里包含AXI VDMA的情况。
import time import numpy as np from pynq import Overlay, MMIO, allocate # 载入overlay,ip_dict里能拿到VDMA的物理地址 ovl = Overlay('video.bit') vdma_base = ovl.ip_dict['axi_vdma_0']['phys_addr'] vdma = MMIO(vdma_base, length=0x1000) W, H = 1920, 1080 stride = W * 3 # RGB888每行字节数 # 分配物理连续内存,VDMA只能搬物理地址 buf = allocate(shape=(H, W, 3), dtype=np.uint8, cacheable=0) phy_addr = buf.physical_address # S2MM寄存器偏移 S2MM_CR = 0x30 S2MM_SRR = 0x34 S2MM_ADDR = 0x3C S2MM_STRD = 0x44 S2MM_HSIZE = 0x48 S2MM_VSIZE = 0x4C S2MM_FRMC = 0x5C # 复位S2MM通道,0x0A是复位命令 vdma.write(S2MM_SRR, 0x0A) time.sleep(0.01) # 控制寄存器写0x1003:循环模式 + 帧同步模式 + 启动DMA vdma.write(S2MM_CR, 0x1003) vdma.write(S2MM_ADDR, phy_addr) vdma.write(S2MM_STRD, stride) # 行步长 vdma.write(S2MM_HSIZE, stride) # 每行字节数 vdma.write(S2MM_VSIZE, H) # 行数 # 等待第一帧到来 last_cnt = vdma.read(S2MM_FRMC) timeout = 100 while vdma.read(S2MM_FRMC) == last_cnt: time.sleep(0.05) timeout -= 1 if timeout < 0: raise RuntimeError('HDMI in 无帧数据,先查RX链路') # 内存直接view成numpy数组,注意这是BGR还是RGB取决于IP配置 frame = buf.view(np.uint8).reshape(H, W, 3)这里要说清楚几个参数的含义。S2MM_CR里的bit0是DMA运行控制,置1启动;bit2是帧同步使能,团队里常叫它“genlock”,用来让VDMA跟着输入帧节奏走;bit3是循环模式,让它自动在不同帧缓冲之间轮转。0x1003从二进制角度就是这些位都置上,再加上bit12的值,具体bit位不用死记,手册里有表格,关键是理解循环模式不是可选项,单缓冲模式下CPU还没读完上一帧,下一帧就会覆盖,画面直接撕裂。HSIZE是每行字节数,VSIZE是行数,这两个值如果和RX IP的输出尺寸不匹配,VDMA搬出来的数据就是错位图像,不是花屏就是斜切。
提示:allocate里的cacheable参数,建议设为0。如果设成1,CPU缓存和DMA的一致性会让你看到“图像更新慢”或者“偶尔出现旧帧”的怪现象,这会让人误以为是视频源的问题,实际上是缓存没刷。
3.3 拿到帧后的三个检查:颜色、对齐、帧率
第一帧出来别急着欢呼,先做三个检查。第一个是颜色通道:HDMI链路传输的是RGB,但OpenCV的默认内存排列是BGR。如果你拿到的帧里红色物体变成了蓝色,不用改IP配置,在Python里转换一下就行,这是正常现象。第二个是图像是否对齐:如果画面是斜的,或者上下分块错位,基本可以断定FRMDLY_STRIDE填错了。第三个是帧率:用帧计数器差值除以时间,算出来是不是接近60fps或30fps。如果帧率只有个位数,优先怀疑DDR带宽和时钟频率,而不是FPGA逻辑跑不动。
还有个容易被忽略的点:HDMI源要持续输出,VDMA才有数据。如果你连的是相机或者某台电脑,先把源端的输出分辨率固定下来,不要让它自动协商。分辨率变化一次,RX IP的时钟恢复就要重新锁定,这个过程中VDMA可能拿到半帧或者花屏,这不是你的配置问题,是源端切换分辨率导致的正常现象。
4. HDMI out 显示:把帧缓冲搬到显示器上
4.1 为什么输出必须双缓冲
输入侧双缓冲是为了防止写入覆盖,输出侧的双缓冲则是为了不让显示器读到“正在被修改”的帧。显示器是个无情的消费者,它按照像素时钟一帧接一帧地读数据,如果内存里只有一块缓冲,CPU在写第100行的时候,显示器正好读到第80行,画面就会从第80行开始出现新旧内容劈开的撕裂线。
VDMA的MM2S通道在循环模式下会自动在设定的帧缓冲之间切换,指向当前正在输出的缓冲。应用端处理完一帧后,不是去改当前屏幕正在读的那块,而是写另一块,然后让VDMA切换指针。这块切换逻辑如果用寄存器直接操作,常见做法是通过帧计数器的变化判断显示器读到哪一帧了,再决定要不要切。PYNQ的Video库封装了这部分,但我更建议手动配一次,因为封装之后出了问题很难定位。
4.2 配置VDMA的MM2S通道并推一帧画面
输出通道的配置方向和输入几乎对称,只是寄存器前缀换成MM2S。我一般会在输入正常出帧之后,再配输出,避免两个通道同时调参时不知道是谁的问题。
MM2S_CR = 0x00 MM2S_SRR = 0x04 MM2S_ADDR = 0x0C MM2S_STRD = 0x14 MM2S_HSIZE = 0x18 MM2S_VSIZE = 0x1C def hdmi_out_start(vdma, addr, stride, width_bytes, height): vdma.write(MM2S_SRR, 0x0A) # 复位MM2S通道 time.sleep(0.01) vdma.write(MM2S_CR, 0x1003) # 循环模式 + 启动 vdma.write(MM2S_ADDR, addr) # 帧缓冲物理地址 vdma.write(MM2S_STRD, stride) # 行步长,一般等于width*3 vdma.write(MM2S_HSIZE, width_bytes) vdma.write(MM2S_VSIZE, height)配置好之后,往那块物理内存里写任何数据,显示器上都会实时显示。配合OpenCV,一套常见的处理循环长这样:
import cv2 # frame_in 是S2MM通道写入的内存地址,frame_out 是MM2S通道要读的内存 frame_in = buf_in.view(np.uint8).reshape(H, W, 3) frame_out = buf_out.view(np.uint8).reshape(H, W, 3) while True: # 轮询等待新帧 while vdma.read(S2MM_FRMC) == last_cnt: time.sleep(0.001) last_cnt = vdma.read(S2MM_FRMC) # 转灰度再转回RGB,做一个简单处理示例 gray = cv2.cvtColor(frame_in, cv2.COLOR_RGB2GRAY) frame_out[:, :, 0] = gray frame_out[:, :, 1] = gray frame_out[:, :, 2] = gray这段代码里有个约定:frame_in和frame_out是不同的物理内存缓冲。如果你把处理结果直接写回frame_in,而VDMA还没读完这一帧,轻则画面闪烁,重则读到半个旧帧半个新帧。所以输出通道一定要配独立的缓冲,别省这块内存。
4.3 时序参数:分辨率、像素时钟与H/V同步
输出侧看起来只是把像素数据编码成TMDS,但底层要保证像素时钟精确。1080p60的像素时钟是148.5MHz,720p60是74.25MHz,480p大约27MHz。这个时钟不是你随便一个PLL分频就能来的,需要根据板载参考时钟算好倍频和分频系数。
| 分辨率 | 像素时钟 | 行同步极性 | 场同步极性 | 典型带宽消耗 |
|---|---|---|---|---|
| 1920x1080@60 | 148.5MHz | 正极性 | 正极性 | 约373MB/s |
| 1280x720@60 | 74.25MHz | 正极性 | 正极性 | 约166MB/s |
| 640x480@60 | 27MHz | 负极性 | 负极性 | 约55MB/s |
带宽这块经常被忽略。以1080p60为例,RGB888一帧是192010803字节,约6.2MB,每秒60帧就是373MB/s。这只是写入方向,如果你同时跑S2MM和MM2S,DDR带宽占用直接翻倍,实际还要算上刷新和操作系统开销。Zynq-7020的内存控制器带宽并不是无限大的,跑到4K分辨率或者同时开多路视频流,就要做取舍了。我一般会在设计前先算好带宽预算,别等画面卡了才想起来查DDR。
还有一点容易翻车:FPGA逻辑时钟和像素时钟是异步的。如果HDMI TX的像素时钟域和VDMA输出侧的AXI时钟域没有做同步,偶尔会出现像素数据中间有毛刺,表现为画面偶尔有几行颜色偏掉。这个问题不好抓,因为它不是稳定复现。自查的方法是看IP核内部是否带了异步FIFO,没带的话就要在Block Design里显式加一个CDC同步器。
5. PYNQ HDMI避坑清单:五个让画面黑屏或花屏的真问题
5.1 插上HDMI源没有信号,VDMA帧计数不涨
现象:输入源明明在输出,HDMI线插在板卡上了,但是轮询S2MM的帧计数器,数值纹丝不动。很多人这时候开始怀疑VDMA配置,反复改寄存器,问题依然存在。
原因:大概率不是VDMA的事,是HDMI RX链路没锁定。输入源的TMDS时钟没有被正确恢复,或者HPD引脚没拉起来,IP核根本没有进入工作状态。还有一个常见原因是输入的5V电源线被某些转接头断开了,HDMI规范里源端要通过5V引脚给接收端供电检测,这个引脚断了,接收端认为没有设备插入,整个链路保持休眠。
解决:先查RX IP核的状态寄存器,看里面的pll_lock或者cdr_lock位有没有置1。没有锁定就换一根短一点的HDMI线,或者把输入源的分辨率强制设到720p60再试。接着用万用表确认连接器的5V引脚有电压。这两步能解决八成“没信号”的问题。
5.2 画面整体偏色,红蓝通道互换
现象:图像能出来,但颜色完全不对,红色物体显示成蓝色,肤色变成了紫色。取一帧图像存成文件,用工具看像素值,能明显看到R和B通道值互换了。
原因:这不是HDMI链路的bug,是内存数据排列和后续处理的颜色空间不一致。HDMI传输标准里像素排列是RGB,但OpenCV读取图像后默认当作BGR处理,于是一次不转换就是通道互换。另一种可能是RX IP核的数据宽度配成30bit,高位补0,导致每个像素多出一个字节,这时颜色看起来就像“洗过一样”,发白且偏色。
解决:先查IP配置是不是24bit RGB888,如果是,那就在Python里做一次通道转换cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)。注意不要在OpenCV处理之后再转,那会导致处理结果颜色又不对。这个坑的隐蔽性在于它不是完全花屏,而是“看着像那么回事但颜色都错了”。
5.3 图像斜切,画面像被“劈了一刀”
现象:画面内容能辨认出来,但整幅图像是斜的,比如一条垂直线变成了斜线,或者图像分成上下两半,中间有明显错位。
原因:VDMA的FRMDLY_STRIDE和实际的DDR行布局不匹配。如果你直接把行步长设成width*3,有时候会漏掉行尾的填充字节,导致下一行从偏移位置开始读。这种情况在分辨率不是DDR对齐值的时候尤其明显。
解决:显式计算行步长,把对齐因素考虑进去。做法是对每行字节数向上取整到64字节对齐,然后把这个对齐后的值同时填进S2MM_STRD和MM2S_STRD。代码里就是加一行:
stride = ((W * 3 + 63) // 64) * 64别小看这个64字节对齐,很多图像斜切的玄学问题最后都落在这上面。
5.4 输出画面闪烁、撕裂,屏幕上出现横向断层
现象:显示器上画面频繁出现横线,断层的位置上下移动,看起来像画面被水平切了几刀。
原因:输出通道的帧缓冲数量配置成了1,或者应用层在VDMA还在读旧帧的时候直接就改了旧帧的内容。单缓冲模式下,HDMI TX每时每刻都在读同一块内存,你处理完写回去的那一刻,显示端正好读到同一行,一次撕裂就发生了。
解决:把VDMA的帧缓冲数量配置到2或3,处理逻辑上一帧写缓冲A,下一帧写缓冲B,写完后再切换VDMA的起始地址。切换动作最好跟着帧计数器的变化走,不要CPU觉得自己写完了就立刻切。一条血泪经验:撕裂问题在静态桌面场景下不明显,一播视频或者跑实时算法就原形毕露,调试时别用静态图将就。
5.5 从PYNQ换到纯Zynq,同样的overlay起不来
现象:在PYNQ上跑得好好的HDMI进出方案,把同一个bitstream放到纯Zynq平台上,上电后没有任何输出,或者在Linux启动过程中报设备树节点错误。
原因:PYNQ的overlay不只是比特流,它还依赖PYNQ镜像里预置的设备树覆盖和启动初始化逻辑。HDMI IP核的复位信号、时钟使能,通常是PS端在启动过程中通过GPIO或AXI寄存器拉起来的。纯Zynq平台没有这层初始化,FPGA加载了但相关IP还停在复位状态,自然就没有输出。
解决:在纯Zynq上不要照搬PYNQ的overlay,而是把Block Design重建出来,把复位逻辑挂到PS的GPIO或者用一个AXI GPIO模块管理,并在FSBL里加上对应的初始化代码。另外一个简单办法是省掉PS初始化,把IP核的复位信号设计成上电自动释放,这样至少能先出画面,再考虑更细的控制。踩过一次这个坑之后,我每次做纯Zynq方案都会先列一个“上电后哪些IP必须被初始化”的清单,避免再次在看不见的地方翻车。
6. 从PYNQ到纯Zynq:移植裁剪与FPS验证
PYNQ的价值是快速验证,但产品落地往往要剥掉Python运行时,直接把方案放到更精简的Zynq平台上。移植时我会按这个顺序做:先在Vivado里把PYNQ那个overlay的BD源码拿出来,删掉和视频无关的IP;然后保留AXI VDMA、HDMI RX/TX IP、一个AXI GPIO用来做复位控制;最后把VDMA的地址写进设备树,启动后在应用层直接映射寄存器。
性能验证有个很实用的技巧,用帧计数器算FPS,不用上调试器。具体做法是读一次S2MM的帧计数值,睡一秒钟,再读一次,差值就是每秒帧数。这个方法比用示波器看时序快多了,也足够判断系统有没有掉帧。要注意的是,中间如果有分辨率切换,帧计数器不会清零,要用两次差值的绝对值做判断。
# 用户态读取帧计数值 devmem 0x4300005C sleep 1 devmem 0x4300005C地址0x4300005C只是示例,实际地址以你的Block Design地址映射为准。差值在60左右说明输入通路饱和,差值远小于60就要查时钟和DDR带宽。
我自己在这条路上栽过不少次跟头,印象最深的一次是把复位逻辑挂在了一个会被Linux禁用的GPIO上,导致每次系统启动到一半HDMI通路就复位了。从那以后,我习惯把任何不被用户态直接操作的复位信号都独立命名,并在FSBL里显式初始化一次。这个思路对于你未来调试PYNQ也好、纯Zynq也好,应该都能省下不少时间。希望帮到你。
本文还有配套的精品资源,点击获取