第一次在FPGA上调HDMI输出,我对着黑屏显示器坐了三个小时。逻辑分析仪显示RGB数据和同步信号全对,仿真波形也看不出问题,直到我把TMDS编码器的最终输出抓下来,才意识到自己对“数据编码”的理解有多粗糙——我直接把8位像素按原样送了进去,根本没有做8b到10b的变换。TMDS(Transition Minimized Differential Signaling,过渡最小化差分信号)这套数据编码算法,是HDMI和DVI物理层的底层基础,RGB转TMDS则是FPGA上做视频输出的必经之路。这篇文章把我踩过的坑和整套算法的细节一次说清楚,适合正在调FPGA视频接口、或者想彻底搞明白HDMI底层原理的开发者。
1. 为什么视频传输偏偏要用TMDS这套编码
1.1 从DVI到HDMI:TMDS的身世
TMDS来自Silicon Image公司,1999年随DVI 1.0标准发布,2002年HDMI 1.0直接继承了这套物理层编码。直到HDMI 2.0时代,TMDS都还是绝对主力——1080p@60Hz下单通道数据率1.485Gbps,4K@60Hz需要跑到6Gbps,底层编码算法本质上没变过。只有到了HDMI 2.1才全面换用FRL(Fixed Rate Link),TMDS逐步退居兼容模式。
所以现在市面上能买到的绝大多数HDMI设备,里面跑的仍然是TMDS。你在FPGA上做一个RGB转TMDS的发送器,就相当于手工实现了一个简化版HDMI发送端的核心。
1.2 三个绕不开的工程问题
任何高速串行传输都要面对三个基础问题,TMDS的编码设计完全是围绕它们展开的。
第一个是直流平衡。视频数据不是均匀的随机信号,一张纯白画面的像素全是0xFF,一张纯黑画面全是0x00。如果直接把这些原始数据串行发出去,线路上的平均电平会长时间偏向高或低,接收端没法做交流耦合,也无法稳定恢复时钟。TMDS解决方式是引入“运行差异”计数器,保证任意一段码流中1和0的数量长期趋于相等。
第二个是电磁干扰和信号完整性问题。如果数据流中0→1和1→0的翻转非常频繁且随机,高速差分线上的辐射会变大,串扰也会变严重。TMDS的阶段一编码专门做了“过渡最小化”,让输出码流中相邻位的翻转次数尽量少。
第三个是时钟恢复。HDMI链路上除了专门传像素时钟的通道之外,三路数据通道本身不带独立时钟,接收端需要从数据边沿恢复出位时钟。这就要求数据流中必须有足够多的跳变沿,不能出现长时间不变的电平。TMDS的编码规则保证了即使数据全0或全1,输出码流也始终有节奏地翻转。
1.3 一条TMDS链路长什么样
标准TMDS链路有4对差分线:3对数据通道加1对时钟通道。RGB三个颜色分量各占一条数据通道,每个像素时钟周期传输一个10位的TMDS符号。时钟通道传输的就是像素时钟本身,接收端以它为参考重建整个时序。
数据通道里传输的内容分成两类:DE有效时传视频像素,DE无效时传控制信号(比如HSYNC和VSYNC)。视频像素经过完整的两阶段8b/10b编码,控制信号则直接映射成固定10位模式。DE信号本身不会单独占用一根线,它是通过码流中标志位的不同来区分的——这一点在FPGA实现时尤其容易混淆。
2. 编码算法逐拍拆解:8位数据如何变成10位符号
2.1 阶段一:过渡最小化(Transition Minimization)
对每个8位像素数据D[7:0],先计算一组“前缀异或”序列N[7:0]:
N[0] = D[0] N[i] = N[i-1] ^ D[i] (i = 1..7)然后统计N[0]到N[6]这7位中1的个数。如果1的个数大于4,或者等于4且D[0]为0,就判定为“需要反转”,否则不反转。这个决策条件看起来有点绕,实际上是统计数据中的“位分布倾向”,决定q_m采用正常序还是反相序,目的是让后续输出的相邻位翻转尽量少。
根据决策结果生成q_m[7:0]:
if (decision == 0) q_m[i] = q_m[i-1] ^ N[i] else q_m[i] = q_m[i-1] ^ ~N[i]注意这里q_m[0]始终等于D[0],逐位递推而不是整体取反。这个细节非常关键,网上很多简化代码直接写q_m = ~N或q_m = N,看起来能用,实际上生成的码字不符合标准推导,接收端按标准反解时可能还原出错位。
我拿一个真实数据走一遍。假设D = 0xB1,即二进制10110001(bit7到bit0依次为1、0、1、1、0、0、0、1):
N[0]=1 N[1]=1^0=1 N[2]=1^0=1 N[3]=1^0=1 N[4]=1^1=0 N[5]=0^1=1 N[6]=1^0=1 N[7]=1^1=0N[7:0] = 01110111(0x77),统计N[0]到N[6]得6个1,大于4,决策为1。然后递推:
q_m[0]=1 q_m[1]=1 ^ ~1 = 0 q_m[2]=0 ^ ~1 = 0 q_m[3]=0 ^ ~1 = 0 q_m[4]=0 ^ ~0 = 1 q_m[5]=1 ^ ~1 = 0 q_m[6]=0 ^ ~1 = 0 q_m[7]=0 ^ ~0 = 1得到q_m = 10000111(0x87)。整个过程必须按位递推,不能用简单取反替代,这是我对这个算法最深的体会之一。
2.2 阶段二:直流平衡(DC Balancing)
q_m只有8位,要变成10位,还差2位。这2位不只是补位,而是承载了直流平衡的核心逻辑。
设q_m中1的个数为k,则k的范围是0到8。编码器内部维护一个运行差异计数器cnt,记录“我已经累计发送了多少个多余的1”。每次编码后,cnt会更新,目标是让cnt尽量回到0附近。
规则分三种情况:
- 如果cnt为0,或者k等于4(本次本身平衡),输出q_m或~q_m,具体看cnt的符号(cnt为正说明之前1多,本次选~q_m来抵消;cnt为负则选q_m),然后cnt按本次实际差异更新,如果k为4则cnt清零。
- 如果cnt为正且k大于4,或者cnt为负且k小于4,说明本次的方向和累计偏差方向相同,需要反转q_m输出来对冲,cnt减去本次正差异。
- 其余情况,直接输出q_m,cnt加上本次差异。
这里的“差异”等于2k-8(1的个数减去0的个数),每次更新后cnt理论上会向0回归,长期看线路上的直流电平稳定。标准中cnt一般限制在±4范围内,实际实现时建议用5位有符号数避免极端情况下溢出。
接收端拿到10位符号后,先看bit8判断是不是数据符号,再看bit9决定是否把后8位取反,恢复出q_m,再反推原始数据。整个链路是双向可逆的,这正是TMDS编码设计严谨的地方。
2.3 控制字符:DE为0时发什么
DE为0时,不传像素,传控制信号。HDMI的每条数据通道在控制期有2位控制输入,映射成固定的10位编码。常用映射如下:
| 控制输入ctrl[1:0] | 10位输出 |
|---|---|
| 00 | 1101010100 |
| 01 | 0010101011 |
| 10 | 0101010100 |
| 11 | 1010101011 |
这些控制码本身大多接近DC平衡,接收端看到这类模式就知道当前处于消隐区,同步信号的边沿就通过这些码字的切换来传递。实际工程中,HSYNC和VSYNC通常映射到蓝色通道的ctrl[1:0]上,RGB三通道分配哪一路由设计者决定,不同HDMI发送芯片的映射可能有差异。
提示:控制字符的具体位序和极性在不同资料里存在差异。我做项目时习惯直接以HDMI规范中的CTLx编码表为准,HDL代码里用常量表形式给出,调试时优先比对示波器上的波形而不是凭记忆核对码值。
2.4 解码端视角:怎样才能完整还原
把编码算法理解透,最好的检验方式是站在接收端想一遍。接收端收到10位符号后:
- 检查bit8。如果为1,表示这是视频数据;如果为0,说明是控制符号,查表还原出2位控制信号。
- 对于视频符号,根据bit9判断信号极性,恢复出8位q_m。
- 从q_m反推原始数据D:q_m[0]就是D[0],后面每一位根据阶段一的递推关系进行逆运算。
接收端的反推不需要知道发送端当时decision的值,因为q_m本身就携带了全部信息。这也是为什么发送端必须严格按标准递推生成q_m,任何简化取反都会让接收端还原得牛头不对马嘴。
3. FPGA落地:RGB转TMDS的完整实现路径
3.1 顶层架构
FPGA上做RGB转TMDS发送器,整体分四块:像素源、TMDS编码器、并行转串行、时钟生成。
像素源输出24位RGB、HSYNC、VSYNC、DE和像素时钟。三个颜色通道各接一个TMDS编码器,每个编码器输出10位符号。然后三个10位符号同步进入串行化模块,变成3对高速差分数据。时钟通道单独处理,把像素时钟直接通过DDR输出原语变成一对差分时钟。
我画过不少版本,最核心的教训是:编码器工作在像素时钟域,串行化模块工作在5倍像素时钟域,这两个时钟域之间的数据交接必须安排得明明白白。很多黑屏问题不是编码错,而是时序跨域没处理好。
3.2 核心编码器的Verilog实现
下面是一个可直接综合的TMDS编码器模块。这个模块我至少用了三年,从720p到1080p都跑过,逻辑本身没问题:
module tmds_encoder ( input wire clk, input wire [7:0] data_in, input wire [1:0] ctrl_in, input wire de_in, output reg [9:0] tmds_out ); reg [7:0] n; // 前缀异或序列 reg [7:0] q_m; // 过渡最小化后的8位 reg [3:0] ones_n; // N[0]..N[6]中1的个数 reg decision; integer i; // 阶段一:计算N序列 always @(*) begin n[0] = data_in[0]; for (i = 1; i < 8; i = i + 1) n[i] = n[i-1] ^ data_in[i]; end // 统计N[0]..N[6]中1的个数,注意是7位 always @(*) begin ones_n = 4'd0; for (i = 0; i < 7; i = i + 1) ones_n = ones_n + n[i]; end // 决策:1的个数>4,或==4且D[0]==0时反转 always @(*) begin decision = (ones_n > 4'd4) || ((ones_n == 4'd4) && ~data_in[0]); end // 递推生成q_m always @(*) begin q_m[0] = data_in[0]; for (i = 1; i < 8; i = i + 1) q_m[i] = q_m[i-1] ^ (decision ? ~n[i] : n[i]); end // 阶段二:直流平衡 reg signed [4:0] cnt; // 运行差异计数器 reg [3:0] ones_q_m; // q_m中1的个数 wire [4:0] diff_qm; // 本次q_m对应差异 2k-8 always @(*) begin ones_q_m = 4'd0; for (i = 0; i < 8; i = i + 1) ones_q_m = ones_q_m + q_m[i]; end assign diff_qm = {1'b0, ones_q_m, 1'b0} - 5'd8; // 等价于2*k-8 always @(posedge clk) begin if (de_in) begin if (cnt == 0 || ones_q_m == 4) begin // cnt非0且本次平衡时,按cnt符号选择极性 if (cnt <= 0) begin tmds_out[9] <= 1'b1; tmds_out[8] <= 1'b1; tmds_out[7:0] <= q_m; end else begin tmds_out[9] <= 1'b0; tmds_out[8] <= 1'b1; tmds_out[7:0] <= ~q_m; end if (cnt == 0) cnt <= diff_qm; else cnt <= 0; end else if ((cnt > 0 && ones_q_m > 4) || (cnt < 0 && ones_q_m < 4)) begin tmds_out[9] <= 1'b0; tmds_out[8] <= 1'b1; tmds_out[7:0] <= ~q_m; cnt <= cnt - diff_qm; end else begin tmds_out[9] <= 1'b1; tmds_out[8] <= 1'b1; tmds_out[7:0] <= q_m; cnt <= cnt + diff_qm; end end else begin case (ctrl_in) 2'b00: tmds_out <= 10'b1101010100; 2'b01: tmds_out <= 10'b0010101011; 2'b10: tmds_out <= 10'b0101010100; 2'b11: tmds_out <= 10'b1010101011; default: tmds_out <= 10'b1101010100; endcase cnt <= 0; end end endmodule代码里有几个点必须单独说。diff_qm的计算我用移位减法的写法是为了避免综合器把它优化出问题,实际等价于2*k - 8。统计ones_n时标准只统计7位,不是8位,这个细节曾经让我花了一晚上对比标准文档。控制字符的case里,我把默认分支也写上了,防止综合后产生锁存器。
3.3 并行转串行:DDR与OSERDES的取舍
10位并行数据要变成1对高速差分信号,关键是串行化怎么做。我整理过三种常见方案:
| 方案 | 时钟需求 | 资源占用 | 适用场景 |
|---|---|---|---|
| OSERDESE2 10:1 SDR | 10倍像素时钟 | 少,需级联主从 | 高分辨率,速率要求高 |
| OSERDESE2 5:1 DDR | 5倍像素时钟 | 少 | 1080p附近,最常用 |
| 移位寄存器+ODDR | 5倍像素时钟 | 中等,逻辑实现 | 低端FPGA,教学验证 |
以1080p@60Hz(像素时钟148.5MHz)为例,数据率1.485Gbps。7系列FPGA的OSERDESE2在DDR模式下,用742.5MHz时钟即可驱动,这个频率在器件规格内。如果用10倍时钟做SDR,1.485GHz的全局时钟布线压力会非常大,一般不推荐。
DDR模式的实现逻辑很直观:10位符号拆成高5位和低5位,每个5倍时钟周期内,上升沿输出一位、下降沿输出一位,5个时钟周期刚好把10位发完。OSERDESE2原语本身支持这种用法,Xilinx的SelectIO IP也封装好了。
Altera平台对应的是ALTDDIO_OUT,Lattice平台用DDR输出寄存器加逻辑移位。不管什么平台,核心思想一致:5倍时钟做DDR传输,数据通道和时钟通道之间做相位对齐。
3.4 时钟方案:5倍像素时钟与相位对齐
时钟是整个发送链路的心脏。我的经验是先确定像素时钟,再用MMCM/PLL生成5倍频时钟:
- 720p@60Hz:像素时钟74.25MHz,5倍时钟371.25MHz
- 1080p@60Hz:像素时钟148.5MHz,5倍时钟742.5MHz
- 1080p@50Hz:像素时钟148.5MHz(实际是148.5/1.001,做开发板功能验证时不纠结)
时钟通道输出的像素时钟本身,通过ODDR原语固定输出模式实现差分对:一个DDR寄存器在上升沿输出1、下降沿输出0,这样每两个TMDS位周期产生一个完整时钟周期,正好一个像素时钟周期内有10个位周期。
数据通道和时钟通道之间的相位关系是重点。我遇到过画面能点亮但偶尔闪一下的情况,最后定位到是数据通道的串行边界和时钟通道边沿偏离了理想位置。解决方法是利用MMCM的fine phase shift微调串行时钟相位,或者在数据通道上插入可调的IDELAY。具体调整值要靠扫相位实测,没有一次到位的捷径。
3.5 硬件连接:电平标准与PCB注意事项
FPGA的IO标准直接决定信号能不能被显示器正确识别。TMDS是电流型差分信号,规范要求共模电平约3.3V、差分摆幅约±10mA电流在50欧端接上产生约500mV摆幅。
老一点的FPGA方案里,直接支持TMDS_33 IO标准的器件不少。Vivado里给差分端口分配TMDS_33即可,这时驱动能力最接近规范。如果器件只支持LVDS_25,也可以点起来,但驱动幅度偏低,短距离(几十厘米)没问题,线长了就掉链子。
我在实际开发板上经常碰到的情况是:FPGA差分IO直接连HDMI座子,IO标准配LVDS_25。这种做法做验证完全够用,但不适合做产品——要么加TMDS电平转换芯片(比如TFP410、SIL171),要么选用原生支持TMDS电平的FPGA bank。
PCB布局走线要注意三件事:四对差分线尽量等长,对内P/N偏差控制在50密耳以内;差分阻抗控制在100欧;电源去耦要做好,高速切换时IO电源纹波会影响信号质量。我在一块手工焊接的板上踩过坑,VCCIO波纹稍微大一点,屏幕就开始出横纹。
4. 调试实录:从黑屏到点亮的排查链路
4.1 先仿真:编码器输出对不对
上板之前一定先仿真,这是我能给出的最诚恳的建议。仿真用例不要用随机数据,直接用彩条测试图样,这样可以直观判断输出码流是否合理。
仿真重点看三件事。第一,DE为0时,输出是否是固定的控制编码表里的值,且每两个像素时钟周期切换一次。第二,DE为1时,10位输出的bit8必须是1。第三,跑几百个像素周期后看cnt是否保持在合理范围,如果cnt一直往一个方向跑偏,说明直流平衡逻辑有bug。
我习惯在testbench里写一个简单的解码模块,把TMDS输出解回RGB,再和原始像素比对。只要解码比对通过,编码器的问题基本就排除了。
4.2 硬件层面:从黑屏到点亮的排查链条
上板后黑屏,不要慌着改代码。按顺序查,一个问题一个问题排除:
第一查PLL锁定。用ILA抓MMCM的locked信号,没锁定说明时钟输入有问题,或者配置参数不对。
第二查时钟通道。用示波器看差分时钟对是否有输出,频率是否符合像素时钟。没有时钟输出,后面一切免谈。
第三查数据通道波形。示波器看三对差分数据是否有对应频率的跳变,如果有一对完全没波形,检查对应的编码器输出是否卡在固定值。
第四查DE和同步时序。DE的极性、HSYNC/VSYNC的极性如果和显示器要求不一致,也会黑屏。有的显示器对同步信号极性和DE的时序容忍度很低。
第五查串行顺序。10位并行转串行时,发送顺序是从bit0开始还是从bit9开始,不同规范和不同IP的约定可能不同。这个坑最隐蔽,症状是画面能出来但颜色和位置错乱。
4.3 常见故障对照表
| 现象 | 可能原因 | 检查方向 |
|---|---|---|
| 完全黑屏 | 时钟通道没输出、PLL未锁定 | 查MMCM配置、时钟输入 |
| 黑屏但时钟通道正常 | 编码器卡在控制字符、DE没识别 | 查DE极性、编码器状态 |
| 花屏、雪花 | 数据通道时序对齐差、串行时钟相位偏 | 扫相位、加IDELAY |
| 颜色错乱 | 通道映射错误、位序不对 | 查串行顺序、RGB通道连接 |
| 颜色偏色 | 某通道直流平衡异常 | 查cnt复位、编码器输出 |
| 间歇性黑屏/闪屏 | 电源纹波、差分走线不等长 | 查供电、调整I/O强度 |
4.4 我踩过的一个坑:10位符号的发送顺序
有一次画面能显示,但是整体颜色像是RGB通道互换后又错位了一位,我排查了很久才发现是串行化模块的位序问题。TMDS规范中符号的发送顺序是固定的,但FPGA的OSERDESE2原语和移位寄存器实现方式不同,对“哪一位先出去”的约定可能不一样。当时我把10位符号的最终输出顺序反过来,一切就正常了。
这个坑几乎每个人都会踩一次。建议在串行化模块的接口处加一个位序反转的配置参数,调试时可以先试默认顺序,不行就翻过来,能省大量时间。
5. 扩展思考:从8b/10b家族看TMDS的定位
5.1 TMDS与8b/10b编码的异同
TMDS看起来像是8b/10b编码的变体,但实际上有本质区别。两者都是8位数据扩成10位、效率都是80%、都带直流平衡,但设计侧重点不同:
| 维度 | TMDS | 8b/10b |
|---|---|---|
| 编码目标 | 最小化翻转、DC平衡 | 保证翻转密度、DC平衡 |
| 控制字符 | 固定编码,2位控制 | K码,5位控制 |
| 解码方式 | 递推逆运算 | 查表 |
| 典型场景 | HDMI/DVI | PCIe、USB3.0、千兆以太网 |
8b/10b的著名劣势之一是实现复杂、需要查表;TMDS把控制信息压缩成更少的组合,电路规模更小,这也是当年DVI选择它的原因之一。
5.2 Deep Color、HDR对编码算法的挑战
像素位宽超过24位后,TMDS编码算法本身不用改,变的是数据的吞吐方式。30位或36位深色模式下,要么用更高的TMDS时钟,要么分时复用多路通道。HDMI 1.4的深色模式就是用提高TMDS时钟频率实现的——像素时钟变成原来的1.25倍、1.5倍或2倍。
对FPGA实现的影响是:编码器逻辑不变,但串行化部分的速率压力变大。30位深色1080p需要1.85Gbps单通道速率,7系列FPGA的普通IO已经逼近极限,这时就得考虑更高级的GTX/GTH收发器或者换用HDMI 2.0的FRL方案。
HDR本身不改变TMDS编码,它只是改变了像素数据的格式(比如宽色域、高位深)。如果你在FPGA上做HDR视频输出,编码器部分完全不用动,处理好像素格式转换就行。
5.3 什么情况下可以绕开TMDS
做产品时没必要把所有场景都硬往TMDS上套。板内短距离RGB并口传输,根本不需要编码;传感器接口用MIPI或LVDS更合适;工业相机走Camera Link或CoaXPress。只有需要接HDMI/DVI显示器的场景,TMDS才是必须品。
我自己的体会是:把TMDS编码算法彻底搞懂,最大的收获不是背下了那套递推公式,而是理解了“为什么高速接口需要编码层”这个通用问题。之后再看PCIe、以太网、USB,它们那些复杂的加扰、编码机制,底层逻辑都是一脉相通的。