很多年前我第一次拿到HDMI接口的时序图时,满屏都是TMDS、DE、CTL、差分对这些词,说实话云里雾里。后来自己用FPGA做过几次RGB转TMDS的显示输出,踩了无数坑,才算把这套编码真正吃透。回头再看,TMDS这个算法其实被严重低估了,它不光是HDMI和DVI的物理层基石,也是一种非常优雅的8b/10b变体编码,解决了高速传输里两个最要命的问题:跳变最少化和直流平衡。这篇文我就把自己做FPGA实现RGB转TMDS的完整思路、编码原理、实现细节和调试经验都整理出来,给正在做视频显示接口、或者刚接触HDMI/DVI的工程师一点参考。
TMDS全称Transition Minimized Differential Signaling,最小化差分传输信号。它并不是简单地把数字信号怼到差分线上去,而是先把8bit数据编码成10bit,再通过电流驱动差分对发送。编码的目的有两个:一是减少传输线上的电平跳变次数,降低电磁辐射和串扰;二是维持直流平衡,让接收端可以稳定恢复时钟和数据。下面我会从编码算法本身讲起,然后给出FPGA工程里的具体实现思路和踩坑经验。
1. 内容整体设计与思路拆解
做RGB转TMDS,最容易被忽视的是“编码不是在像素时钟下算出来就够了”,后面还有一长串问题:并行编码做完了,10bit数据怎么在1.5Gbps甚至更高的速率下送到引脚上?时钟关系怎么处理?接收端怎么对齐?这些必须在一开始就规划清楚。
1.1 TMDS整体系统结构
先看整体结构。HDMI/DVI物理层由四对差分信号组成:三对数据通道和一对接收端恢复用的时钟通道。三对数据通道分别传输R、G、B,每个像素时钟周期里每条通道都要送出一个10bit编码结果。换句话说,一个1080p@60Hz的RGB信号,像素时钟148.5MHz,每条TMDS通道的速率是1.485Gbps,三通道总带宽约4.455Gbps。
这个10bit编码结果不是纯RGB数据直接加出来的,而是分为三种状态来传输:视频数据期、控制期和数据岛期。在FPGA做纯HDMI/DVI输出时,最常用的是视频数据期和控制期,数据岛期主要用于传输音频和辅助数据,本文暂不展开。
- 视频数据期:DE为高,三通道分别编码R、G、B各8bit数据;
- 控制期:DE为低,三通道都发送2bit控制信号组成的固定编码,用于传递HSYNC、VSYNC等同步信息;
- 数据岛期:DE为低时,如果还要传音频等辅助数据,则插入数据岛编码,HDMI里会有专门的guard band,DVI一般不用。
从这个结构就能看出,RGB转TMDS不是简单做一轮编码,而是要在DE、CTL、DATA之间频繁切换编码模式,编码器必须清楚当前像素周期处于什么阶段。
1.2 FPGA实现方案选型
常见的FPGA实现RGB转TMDS方案有三种。
第一种,纯逻辑移位寄存器法。把编码后的10bit并行数据放到一个5倍像素时钟的移位寄存器里,逐位移出。优点是代码简单、不依赖厂商原语,很多教学项目、低成本开发板都用这个方式。缺点是移位寄存器在FPGA里布线延迟大,速率超过300Mbps时时序容易崩,适合做学习验证或者低分辨率输出。
第二种,厂商高速串行原语法。Xilinx 7系列用OSERDESE2,Intel用ALTIOBUF或DDIO,Lattice有专用DDR输出寄存器。这种方案用硬核完成10:1并串转换,时序好、速率高,是产品级的标准做法。缺点是需要了解原语配置,且位序容易搞错,需要花时间调。
第三种,把数据通道设计成DDR模式,先做5:1再拼成10:1。这种方案介于两者之间,利用FPGA的DDR寄存器输出两路不同相位的比特,可以减少一半的串行时钟。实际工程里很多开发板提供的RGB转HDMI例程就是这样做的。
我自己的建议是:如果只是验证算法,用方案一就够了;如果要做真正能长时间跑、能上高分辨率的产品,直接老老实实学OSERDESE2。后面我会把两种方式的差异和注意事项展开讲。
2. TMDS编码算法核心原理详解
TMDS编码算法本质上是两阶段编码:先做8b/9b跳变最小编码,再做9b/10b直流平衡编码。整个算法的精妙之处在于,两个阶段都是基于当前输入动态调整,接收端不需要知道发送端用了哪种编码方式,只要反向解码就能恢复数据。
2.1 第一阶段:8bit到9bit,跳变最小化
输入8bit数据记为d[7:0]。第一级编码的目标是把相邻输出位的跳变次数降到最低。做法是先统计d[7:0]中1的个数N1。
如果N1大于4,说明数据里1比较多,用XNOR编码可以抑制跳变;如果N1小于4,说明0比较多,用XOR编码;如果N1等于4,则看最低位d[0],d[0]为1时用XOR,d[0]为0时用XNOR。
编码过程是逐位迭代的:
q_m[0] = d[0]; for (i = 1; i <= 7; i = i + 1) begin if (使用XNOR模式) q_m[i] = ~(q_m[i-1] ^ d[i]); else q_m[i] = q_m[i-1] ^ d[i]; end q_m[8] = XNOR模式选择标志;稍微解释一下原理。XOR门的特点是:输入相同输出0,输入不同输出1。当输出逐位和输入变化相关时,如果前一位和当前位相同,XOR结果为0,输出保持不变;不同则输出翻转。XNOR刚好反过来。所以选择XOR还是XNOR,本质上是在选择“如何让相邻输出位尽量保持一致”。对于00000000这样的数据,N1=0小于4,用XOR编码后q_m每一位都为0,输出完全无跳变,结果就是9bit的100000000(最高位是模式标志1)。这样的编码结果非常适合高速传输,因为长时间不变的电平不会造成太多电磁干扰。
2.2 第二阶段:9bit到10bit,直流平衡
第一级编码虽然降低了跳变,但仍不能保证输出数据中0和1的数量平衡。如果长时间传输全0或者大量1的数据,差分线上的共模电平就会漂移,接收端的恢复电路会变得不稳定。所以第二阶段要把9bit变成10bit,使输出中的直流分量趋于零。
做法是维护一个计数器cnt,表示当前已经发送数据中1比0多多少。每发完一个10bit,根据当前q_m中1的数量和cnt当前值,决定是原样发送还是取反发送。同时更新cnt。
简化伪代码如下:
N1_q = q_m[8:0]中1的个数; if (cnt == 0 || N1_q == 4) begin q_out = {1'b1, q_m[8:0]}; // 不反转,最高位置1表示正极性 cnt = cnt + N1_q - 4; end else if ((cnt > 0 && N1_q > 4) || (cnt < 0 && N1_q < 4)) begin q_out = {1'b0, ~q_m[8:0]}; // 反转,最高位置0表示反极性 cnt = cnt + (4 - N1_q); end else begin q_out = {1'b1, q_m[8:0]}; cnt = cnt + N1_q - 4; end这里有几个细节需要重点理解。
第一,9bit里1的数量可能是0到9,理想情况是希望10bit输出里1和0各5个。如果q_m里1太多,就取反发送,让1的数量变少;如果1太少,就正常发送,补足1的数量。
第二,cnt不会无限增大。伪代码里cnt在-4到4之间摆动,这正是直流平衡的动态反馈。接收端通过判断最高位和后续解码规则,还原原始数据,不需要知道cnt的具体值。
第三,第二级还会做一次极性标记。q_out的最高位就是反转标记,接收端解码时先看这1bit,再决定后续是否取反。这也是为什么TMDS虽然是8b/10b变体,但它和平常说的8b/10b并不完全一样。
2.3 控制期编码和固定码
控制期里数据不传输,三个通道都发送固定的10bit控制码。控制码由CTL0和CTL1两个信号组成,编码表如下:
| CTL1 | CTL0 | 编码输出 |
|---|---|---|
| 0 | 0 | 1101010100 |
| 0 | 1 | 0010101011 |
| 1 | 0 | 0101010100 |
| 1 | 1 | 1010101011 |
不要小看这几个固定码。TMDS规范的核心就是通过控制码来区分当前是控制期还是数据期。接收端一旦识别到控制码,就跳转到控制状态;识别到非控制码,则进入数据解码。如果编码器把控制期的数据也当普通数据处理了,接收端就会认不出同步信号,显示器直接花屏或者无信号。
实际工程中,HSYNC和VSYNC通常是在后端时序模块里生成的。做RGB转TMDS时,常用的做法是将HSYNC和VSYNC分别映射到蓝色通道的CTL0和CTL1,其他两个通道的控制位在控制期固定为0。当然HDMI规范里也允许其他组合,关键是发送端和接收端约定好。
2.4 手动计算一遍加深理解
拿一个简单数据做手动编码,理解会更直观。
假设输入8bit数据d[7:0]=8’b00001111。统计1的个数,N1=4,d[0]=1。根据规则,N1等于4且d[0]为1,使用XOR模式。
q_m[0]=1,然后逐位XOR:
- d[1]=1,q_m[1]=1^1=0;
- d[2]=1,q_m[2]=0^1=1;
- d[3]=1,q_m[3]=1^1=0;
- d[4]=0,q_m[4]=0^0=0;
- d[5]=0,q_m[5]=0^0=0;
- d[6]=0,q_m[6]=0^0=0;
- d[7]=0,q_m[7]=0^0=0。
所以q_m[8:0] = 1_00000101_? 这里顺序容易混淆,我按从高位到低位写:q_m[8]=1(XOR模式标志),q_m[7:0]=00000101,所以9bit结果是100000101。
注意这里的q_m[7:0]是迭代结果,不等于原始数据0x0F,这很正常,因为TMDS编码本质是一种线路编码,不是简单的数据变换。
第二步9b到10b,需要数q_m中1的个数。q_m=100000101,1的个数是3,少于4个。cnt初始为0,根据伪代码,cnt==0时走第一分支:q_out = {1’b1, q_m},且cnt更新为3-4=-1。所以最终10bit输出为1100000101。这个结果1的个数为4,接近5个,直流偏移很小。
从这个例子能看出,TMDS编码的每一步都有明确的数学目标:第一阶段压低跳变,第二阶段平衡直流。两者结合起来,就是它在高速和EMC性能上表现优异的原因。
3. FPGA实现RGB转TMDS的完整实操过程
这一节直接进入工程实现。我会按模块拆解整个RGB转TMDS链路,并给出关键代码和操作步骤。
3.1 顶层模块划分
一个完整的RGB转TMDS工程,通常包含以下模块:
- 像素时钟产生模块:用MMCM/PLL产生像素时钟pixel_clk和5倍像素时钟bit_clk;
- RGB时序控制模块:生成行场同步信号、DE信号和有效像素数据;
- TMDS编码器模块:三个数据通道各一个,对R、G、B和同步信号分别编码;
- 并串转换模块:将10bit并行编码数据转成高速串行差分信号;
- 差分输出原语:将串行数据和时钟以差分形式送到FPGA引脚。
顶层连接的关键是,所有模块都要严格在同一个像素时钟域和位时钟域下工作。不同步的后果是编码数据错位、DE错位,最终表现为花屏或者无信号。
3.2 TMDS编码器的Verilog核心实现
下面给一个简化的TMDS编码器Verilog模块,重点展示状态机和算法逻辑。真正工程里建议用流水线,把两个阶段拆到不同时钟周期,降低组合逻辑延迟。
module tmds_encoder ( input wire clk, input wire rst_n, input wire [7:0] data_in, input wire c0_in, input wire c1_in, input wire de_in, output reg [9:0] tmds_out ); reg [3:0] cnt; // 运行差异计数器 reg [8:0] q_m; reg [4:0] n1_q_m; reg [4:0] n0_q_m; wire xnor_mode; // 第一阶段:8b->9b // 统计输入中1的个数 always @(*) begin n1_q_m = 0; for (int i = 0; i < 8; i = i + 1) n1_q_m = n1_q_m + data_in[i]; end assign xnor_mode = (n1_q_m > 4) || ((n1_q_m == 4) && (data_in[0] == 0)); always @(*) begin q_m[0] = data_in[0]; for (int i = 1; i < 8; i = i + 1) begin if (xnor_mode) q_m[i] = ~(q_m[i-1] ^ data_in[i]); else q_m[i] = q_m[i-1] ^ data_in[i]; end q_m[8] = xnor_mode; end // 第二阶段:9b->10b reg [9:0] q_out_tmp; always @(*) begin if (de_in) begin // 数据期编码:第二级平衡 n1_q_m = 0; for (int i = 0; i < 9; i = i + 1) n1_q_m = n1_q_m + q_m[i]; n0_q_m = 9 - n1_q_m; if (cnt == 0 || n1_q_m == 4) begin q_out_tmp = {1'b1, q_m}; // cnt更新在时序逻辑中 if (cnt == 0) cnt <= n1_q_m - 4; else cnt <= cnt + n1_q_m - 4; end else if ((cnt > 0 && n1_q_m > 4) || (cnt < 0 && n1_q_m < 4)) begin q_out_tmp = {1'b0, ~q_m}; cnt <= cnt + n0_q_m - n1_q_m; end else begin q_out_tmp = {1'b1, q_m}; cnt <= cnt + n1_q_m - 4; end end else begin // 控制期固定编码 case ({c1_in, c0_in}) 2'b00: q_out_tmp = 10'b1101010100; 2'b01: q_out_tmp = 10'b0010101011; 2'b10: q_out_tmp = 10'b0101010100; 2'b11: q_out_tmp = 10'b1010101011; default: q_out_tmp = 10'b1101010100; endcase end end always @(posedge clk or negedge rst_n) begin if (!rst_n) tmds_out <= 10'd0; else tmds_out <= q_out_tmp; end endmodule这段代码里我故意没有把cnt的更新写成纯组合逻辑,而是让它跟随时钟拍一拍,这是为了避免长组合逻辑链造成时序违例。实际工程里你还要再想想cnt初始化和复位值,标准做法是让它从0开始。
这里有个经验:第二阶段的编码条件网上有很多版本,关键要理解“cnt和N1相对关系”决定取不取反,而不是死记公式。我调试时遇到过一种情况,接收端画质非常微弱地闪烁,后来发现是cnt更新逻辑里在反转分支用了固定4而不是动态N0,导致直流平衡收敛慢。
3.3 并行转串行:OSERDESE2原语详细使用
编码器输出的是像素时钟域的10bit并行数据,要发到差分管脚上,必须转成高速串行。Xilinx 7系列里最靠谱的方式是OSERDESE2,配成DDR模式,10:1并行转串行。
OSERDESE2有几个常见参数需要理解:
- DATA_RATE_OQ:选DDR,表示DDR模式下每个CLK周期输出2bit;
- DATA_WIDTH:DDR模式下最大10,对应10:1;
- SERDES_MODE:MASTER或者SLAVE。10:1时必须用两个OSERDESE2级联,一个主一个从;
- T_DATA:三态控制,输出差分对时通常接地或置0。
下面是三通道中一个通道的核心例化思路,代码是示意性质的,完整位序还要对照UG471确定:
OSERDESE2 #( .DATA_RATE_OQ ("DDR"), .DATA_RATE_TQ ("DDR"), .DATA_WIDTH (10), .SERDES_MODE ("MASTER"), .TRISTATE_WIDTH (1) ) oserdes_master ( .OQ (tmds_data_serial), .CLK (bit_clk), .CLKDIV (pixel_clk), .D1 (tmds_data[0]), .D2 (tmds_data[1]), .D3 (tmds_data[2]), .D4 (tmds_data[3]), .D5 (tmds_data[4]), .D6 (tmds_data[5]), .D7 (tmds_data[6]), .D8 (tmds_data[7]), .OCE (1'b1), .RST (~rst_n), .T1 (1'b0), .T2 (1'b0), .T3 (1'b0), .T4 (1'b0), .TCE (1'b1) );使用OSERDESE2时有几个坑是新手必踩的。
第一个坑是位序。TMDS标准规定数据低位先发,但OSERDESE2的D1到D8在不同位宽下的bit映射并不直观,尤其在10:1级联模式下,需要对照原语手册仔细核对。我自己的做法是在调试时发一条固定测试图案,比如逐个像素递增的彩条,然后看显示器上颜色是否按预期渐变,如果有颜色位错,就反过来调整并行数据的位拼接顺序。
第二个坑是CLK和CLKDIV的相位关系。OSERDESE2要求CLK是CLKDIV的整数倍频,且两者要同源。如果CLK和CLKDIV来自不同的MMCM输出且相位没对齐,会有偶发错码,表现为画面随机噪点。解决办法是让bit_clk由pixel_clk的同一个MMCM倍频出来,并在约束里保证相位关系。
第三个坑是三态控制。很多HDMI输出调试时不接热插拔检测,直接把T1到T4置0,这样可以强制输出驱动,省掉一堆麻烦。但如果要做完整的HDMI兼容性,还是得把TCE和T信号接上,避免在切换分辨率时把总线搞成高阻态。
3.4 时钟生成与时序约束要点
RGB转TMDS的时钟关系是整个工程的命脉。像素时钟pixel_clk是基础,位时钟bit_clk必须是它的5倍,因为DDR模式下每个bit_clk周期输出2bit,两个周期输出4bit,10bit要5个bit_clk周期。以1080p@60Hz为例,pixel_clk=148.5MHz,bit_clk=742.5MHz。
MMCM配置时,建议先用一个PLL产生148.5MHz,再用它作为输入倍频产生742.5MHz,保证两个时钟同源。还可以额外产生一个像素时钟90度相移版本用于数据对齐,不过大多数情况下不需要。
时序约束方面,至少要有这几条:
create_clock -name pixel_clk -period 6.734 [get_pins mmcm/CLKOUT0] create_clock -name bit_clk -period 1.347 [get_pins mmcm/CLKOUT1] set_output_delay -clock [get_clocks bit_clk] -max 0.5 [get_ports tmds_data_p[*]] set_output_delay -clock [get_clocks bit_clk] -min -0.5 [get_ports tmds_data_p[*]]output delay的数值要根据接收端建立保持时间和PCB走线长度算,不能照抄。一般来说,TMDS输出接口需要满足HDMI接收端的Tsu/Th要求,这些数值在HDMI规范里有明确表格。
另一个约束重点是引脚位置和I/O标准。TMDS差分引脚通常分配在FPGA的高性能HP bank上,I/O标准选TMDS_33或者LVDS,具体看FPGA型号和板卡设计。很多开发板用TMDS_33,需要注意bank电压是否为3.3V。如果bank电压是2.5V,就不能直接用TMDS_33。
3.5 差分信号输出和PCB注意事项
最后一步是把OSERDESE2输出的单端串行信号转成差分信号。Xilinx 7系列里可以用OBUFDS原语,输出P和N两路。
OBUFDS #( .IOSTANDARD("TMDS_33") ) obufds_tmds ( .I (tmds_data_serial), .O (tmds_data_p), .OB (tmds_data_n) );时钟通道也需要同样处理,把像素时钟或位时钟经过ODDR转成串行时钟后,再用OBUFDS送出去。注意TMDS规范里时钟通道发送的是像素时钟频率的差分信号,不是5倍位时钟,这点不要搞错。
PCB方面,TMDS差分对要求100欧姆差分阻抗,对内等长误差尽量控制在5mil以内,三对数据线之间以及数据线对时钟线之间的等长误差控制在50mil以内。如果走线长度差太多,接收端时钟恢复后的采样点会偏移,症状是长线缆显示正常、短线缆反而闪屏。
做FPGA开发板时,最稳妥的做法是:数据线尽量走内层,参考地完整,不要跨分割,连接器和FPGA之间尽量少打过孔。我见过一个案例,板厂为了布线方便把TMDS线跨了一条电源分割线,结果画面始终有轻微水波纹,怎么调代码都没用,重新改板才解决。
4. 常见显示异常与排查经验
RGB转TMDS实现完成后,真正痛苦的往往是调试阶段。我把遇到的常见问题整理成一个速查表,每一条都是真金白银的踩坑记录。
4.1 无画面、显示器提示无信号
这种问题最让人抓狂,因为可能是硬件问题也可能是逻辑问题。
先检查时钟通道。用示波器量TMDS时钟差分对的频率,看是不是像素时钟频率。比如1080p@60Hz应该是148.5MHz。如果时钟频率不对或者完全没有,检查MMCM配置和差分输出是否正确。
再检查差分对极性。TMDS的P和N接反是最常见低级错误,但症状和没有信号很像。处理办法是用万用表确认板卡丝印,或者用FPGA逻辑把P和N对调再测。
还要检查DE信号极性。很多显示器要求DE高有效,如果你的DE在整个blanking期间都为低,接收端会一直认为处于控制期,当然不会显示画面。用逻辑分析仪或者ILA抓一下DE、HSYNC、VSYNC的时序,确认和标准VGA时序一致。
4.2 花屏、雪花点、画面跳变
这类问题多半和编码时序错位有关。DE和RGB数据必须严格对齐,如果DE比数据早一个像素时钟,编码器在数据期处理了还是控制期的数据,接收端解码就全乱了。解决办法是在编码器外面加一级寄存器,让DE、HSYNC、VSYNC和RGB数据经过相同数量的触发拍数再进入编码器。
另一个原因是并行数据和串行输出的bit顺序反了。比如OSERDESE2配置里把D1接成了最高位,导致接收端收到的bit流是反序的。判断方法很简单:显示彩条测试图,看颜色是否按红绿蓝顺序排列,如果颜色顺序混乱或者出现奇怪的颜色交换,基本就是bit序问题。
还有一种情况是MMA和时钟抖动。如果pixel_clk和bit_clk之间相位不对,OSERDESE2输出的高速数据会有毛刺,症状是画面随机出现横线。检查MMCM输出,确保bit_clk是pixel_clk的5倍频且相位对齐。
4.3 偏色或颜色整体错误
偏色问题通常是三个数据通道编码条件不一致导致的。比如红色通道的DE正确,绿色通道的DE延迟了一拍,画面就会整体偏紫或偏青。排查方法是用ILA同步抓三路编码器的输入DE,确认是否对齐。
还有一种偏色是编码器内部XOR/XNOR选择条件写错了。TMDS编码第一阶段的判定条件是N1>4或N1=4且d[0]=0,注意d[0]是原始数据的最低位,不是编码后数据的最低位。如果在Verilog里把data_in[0]和data_in[7]搞混,就会出现只在特定图案下偏色的奇怪故障。
4.4 闪烁、水波纹和EMI问题
闪烁通常不是纯逻辑问题,而是电源噪声或者地弹引起的。TMDS高速输出时,电流变化大,如果FPGA的VCCIO去耦电容放得不够,电源纹波会耦合到输出上,导致接收端时钟恢复不稳定。解决办法是在靠近差分引脚的地方加0.1uF和1uF电容,并保证数字地和模拟地单点连接。
水波纹可能是TMDS线缆过长或者阻抗不匹配。换一根短的高品质HDMI线测试,如果问题消失,大概率是PCB走线阻抗问题,而不是代码问题。
EMI超标往往是因为TMDS差分对没有做好屏蔽或者共模扼流圈设计不当。不要在PCB上把差分线走得太长,保持差分间距一致,参考地完整。必要时可以在TMDS差分线靠近连接器位置加共模电感。
4.5 排查工具和调试手段
调试TMDS这套高速信号,示波器是必须的。带宽至少1GHz以上,否则看不到信号的真实眼图。优先测时钟通道频率,再测单个数据通道的波形,确认码型变化是否正常。逻辑分析仪测像素时钟域的DE、HSYNC、VSYNC和编码器输出。
FPGA内部的ILA也很重要。把所有编码器的输入DE、输入数据、输出10bit和cnt信号都抓出来,对比正常时序,往往能非常快地定位是哪个模块出了问题。
我自己调试时的顺序是:先确认时钟频率→再确认差分极性→再确认DE时序→再确认编码器输出图形→最后才看眼图和EMI。千万不要一开始就去调OSERDESE2的位序,那样会把简单问题复杂化。
搞完这套RGB转TMDS,最大的体会是:TMDS编码本身并不难,算法就是那么两条规则,难点全在工程细节。编码器写错了可以看波形查出来,但时序约束、PCB布局、电源完整性这些问题,不经过一轮完整调试很难积累经验。
如果你刚开始做这个,建议先跑一个低分辨率,比如480p,把所有逻辑调到能出画面,再往上提分辨率。低分辨率下像素时钟只有27MHz,很多问题不会暴露,但至少能帮你把链路跑通。等你把分辨率提到1080p、1440p,再回来看时序约束和PCB设计,才能理解TMDS真正考验的是什么。最后分享一个小技巧:调通基本显示后,务必上一张全白和全黑的测试画面,观察屏幕亮度是否均匀,这能快速判断直流平衡是否真的收敛,比看彩条可靠得多。