FPGA 驱动 HDMI 屏全讲解

/ 0评 / 1

其实一直也没怎么讲这个,网上各种教程都是串口收发等等,要不就是特别高级特别复杂的项目,什么以太网USB统统用上,其实我觉得驱动HDMI是一个比较简单,但是又比较需要整体考量的东西.

HDMI显示,本质上也是编码RGB显示屏数据,最后发送,不管是RGB时序生成,还是编码算法其实都非常简单,大家写个串口收发那种功力,就能把这个全部实现好,无非是东西比较多,由多个东西组合而成罢了.如果你使用像高云这样的平台,他们HDMI是封装成IP的,那就更加没东西可以讨论了,等于根本不用你自己写.

一开始我们要了解RGB驱动时序,毕竟笼统地说RGB驱动时序通过串并转换后就是HDMI时序嘛,我们不要太过复杂去说几种模式,就单纯说一下HDMI编码中用到的DE模式模式.

一帧时序.

一行时序.

我们需要定义计数器的起始位置,当然这都是我们开发时候可以自己定的,上面的VSPW和HSPW我成为SYNC信号,即记作VSYNC,HSYNC时间,这段时间他们是低电平的.之后经过一段时间的Back Porch,然后是有效像素,我称为VACTIVE,HACTIVE,最后再经历一段时间的Front Porch,一个HSYNC+HBP+HACTIVE+HFP周期刷完一行像素,很多个行像素周期构成一页.

第二个关键问题,VSYNC的时钟是基于HSYNC切换时切换的,看VSYNC图的虚线可见,在HSYNC的HFP的最后一刻,VSYNC时钟计数.

所以很自然就写出下面的逻辑.

// Horizontal Counter
always @ (posedge clk or negedge resetn)
begin
    if(!resetn)
        h_cnt <= 0;
    else if(h_cnt == H_TOTAL - 1)
        h_cnt <= 0;
    else
        h_cnt <= h_cnt + 1;
end

// Vertical Counter
always @ (posedge clk or negedge resetn)
begin
    if(!resetn)
        v_cnt <= 0;
    else if(h_cnt == H_FP - 1) // Horizontal Sync Time
        if(v_cnt == V_TOTAL - 1)
            v_cnt <= 0;
        else
            v_cnt <= v_cnt + 1;
    else
        v_cnt <= v_cnt;
end

第二个问题是计算DE时刻,DE都在H_ACTIVE且V_ACTIVE时候出现,根据条件,可以写出DE时机.

// Horizontal Active
assign h_active = ((h_cnt >= H_SYNC + H_BP) && (h_cnt < H_SYNC + H_BP + H_ACTIVE))?1'b1:1'b0;

// Vertical Active
assign v_active = ((v_cnt >= V_SYNC + V_BP) && (v_cnt < V_SYNC + V_BP + V_ACTIVE))?1'b1:1'b0;

assign de = h_active & v_active;

第三个问题就简单很多了,计算HSYNC,VSYNC这个信号什么时候变化,图上可以看到,除了H_SYNC和V_SYNC时间,其他时间都是高,所以也很容易写啦.

// HS Generate
assign hs = (h_cnt > H_SYNC)?1'b0:1'b1;

// VS Generate
assign vs = (v_cnt > V_SYNC)?1'b0:1'b1;

至此全部时序发生就写完了,至于具体屏幕坐标,已经知道H_ACTIVE第一点开始是X=0,V_ACTIVE第一个点开始是Y=0,但是后面再说怎么写,毕竟这里还是有点小技巧的,你现在可以仿真一下,时序目前是配上了.

接下来要看看如何把RGB时序转换成HDMI时序,HDMI时序其实是TMDS信号,三个通道,每个通道传输部分内容,而TMDS信号前面还要经历8b10b转换,所以这里还涉及两个时钟,一个是RGB时候的PCLK,即像素时钟,一个是编码后的时钟,即10倍的PCLK,即使我们串并转换后支持DDR模式,也需要5倍的PCLK.我们先看PCLK是多少,我这里找到一个常规显示信号的规格计算网站.

https://tomverbeure.github.io/video_timings_calculator

首先,明确我们使用的片子性能,比如我用的7Z010-1,PLL最大输出800MHz,选择2560*1440分辨率,总H_TOTAL是2640,总V_TOTAL是1471,刷一页就需要2640*1471=3883440,而800MHz/5=160MHz,160MHz/3883440≈41Hz,满足.如果分辨率再提高,下一个标准分辨率则30Hz都不能满足,因此选择这个就是最大能力了.当然,这时候也不用倍频到800MHz,降低EMI也是好事嘛,那就先做一个PLL出来.(我这里依然偷懒了,直接按最大性能拉满,但是警告也推荐不要这么干,想满足条件稳定的可以做1080p/30设计,而且时钟这么高,也不太好收敛.)

接下来进行比较难的HDMI编码部分,但是说比较难也并不算难,自己按着步骤写也很快的,毕竟也就没几步,看着是个流程图,像对FPGA这种并行来说不太友好,实际上只要打拍的时机合适,也是非常简单的,仅需三个时钟就能完成.

明白几个概念,就很简单了,DE就DE,C0,C1是控制信号输入,其中对于蓝色通道刚好对应HS/VS,其他我们暂时没用就暂时不考虑,D当然是RGB数据,分成三个8位,所以构成RGB888,至于Cnt(t-1)就是上一次执行时候Cnt的结果.既然是上一次结果,那没有上一次呢?哎,不可能的.因为你一开始就VS拉低了,之后Cnt就为0,这不就等于给了初值.

第一个判断条件,要看1的个数是否大于4,或者1的个数等于4但是第0位是0,统计个数,那当然也会占用一拍,CLK来了,统计一下,这样n1d就赋值了.这就是第一个时钟完成的事情.

//统计待编码输入数据中1的个数,最多8个1,所以位宽为4.
always@(posedge clk)
begin
    if(!resetn)
    begin
        n1d <= 4'd0;
    end
    else if(de)
    begin
        n1d <= din[0] + din[1] + din[2] + din[3] + din[4] + din[5] + din[6] + din[7];
    end
    else begin // DE为低时候传输的是控制字符,硬编码的,没有统计必要.
        n1d <= 4'd0;
    end
end

随后写上判断条件.

assign condition1 = ((n1d > 4'd4) || ((n1d == 4'd4) && (~din_r[0])));

记住,刚才为了计算n1d是过了1个CLK的.如果我们此时把输入数据也打一拍,也可以锁存起来.

// 前面统计数据时候打了一拍,这里也要打一拍来同步.
always@(posedge clk)begin
    din_r   <= din;
    de_r    <= {de_r[0],de};
    c0_r    <= {c0_r[0],c0};
    c1_r    <= {c1_r[0],c1};
    q_m_r   <= q_m;
end

由于他们都是经过1CLK,所以是同时发生的,至于为什么要锁存DE/C0/C1,以及等下要处理的q_m,则是因为数据还会随着PCLK进来而进来,不存起来不就覆盖了,这里计算又不止一个周期.

根据条件1进行计算,这里是连接到wire,因组合逻辑理论不占时间,所以不用考虑打拍.

//对输入的信号进行XOR/XNOR运算.
assign q_m[0] = din_r[0];
assign q_m[1] = condition1 ? ~((q_m[0] ^ din_r[1])) : (q_m[0] ^ din_r[1]);
assign q_m[2] = condition1 ? ~((q_m[1] ^ din_r[2])) : (q_m[1] ^ din_r[2]);
assign q_m[3] = condition1 ? ~((q_m[2] ^ din_r[3])) : (q_m[2] ^ din_r[3]);
assign q_m[4] = condition1 ? ~((q_m[3] ^ din_r[4])) : (q_m[3] ^ din_r[4]);
assign q_m[5] = condition1 ? ~((q_m[4] ^ din_r[5])) : (q_m[4] ^ din_r[5]);
assign q_m[6] = condition1 ? ~((q_m[5] ^ din_r[6])) : (q_m[5] ^ din_r[6]);
assign q_m[7] = condition1 ? ~((q_m[6] ^ din_r[7])) : (q_m[6] ^ din_r[7]);
assign q_m[8] = ~condition1;

之后看看DE状态,DE无效会直接跳到输出,但是我们为了所有输出都同步起来,现在暂时不输出任何东西,依然需要计算条件2和条件3,这里因为要统计n1q_m和n0q_m,所以这里也要一个时钟.为什么不能wire计算呢?因为这是依赖上一级的,是时序逻辑,前面没计算完这里自然没法继续,继续也没意义.

//判断条件2:一行已编码数据(上一次传输会更新Cnt)中1的个数等于0的个数或者本次编码数据中1的个数等于0的个数.
assign condition2 = ((cnt == 6'd0) || (n1q_m == n0q_m));

//判断条件3:已编码数据中1的多余0并且本次编码中间数据1的个数也多与0的个数或者已编码数据中0的个数较多并且此次编码中0的个数也比较多时拉高,其余时间拉低,为什么判断bit5,因为对于我们看,他就是符号位.
assign condition3 = (((~cnt[5]) && (n1q_m > n0q_m)) || (cnt[5] && (n1q_m < n0q_m)));

下一刻开始,可以输出结果了,因为条件2或者条件3之后也没什么判断了.至于q_m[8]用三元比较就可以,本质上也会综合成组合逻辑,不会打拍.

这下这段就很好理解了吧,最后一个时钟进行输出.

always@(posedge clk)
begin
    if(!resetn)
    begin
        cnt <= 6'd0;
        q_out <= 10'd0;
    end
    else if(de_r[1]) //又打了一拍之后,de_r[1]就是原来的de.
    begin
        q_out[8] <= q_m_r[8]; //第8位为编码方式位,直接输出即可.
        if(condition2)
        begin
            q_out[9] <= ~q_m_r[8];
            q_out[7:0] <= q_m_r[8] ? q_m_r[7:0] : ~q_m_r[7:0];
            // 按照规范更新Cnt.
            cnt <= q_m_r[8] ? (cnt + n1q_m - n0q_m) : (cnt + n0q_m - n1q_m);
        end
        else if(condition3)
        begin
            q_out[9] <= 1'b1;
            q_out[7:0] <= ~q_m_r[7:0];
            // 按照规范更新Cnt.
            cnt <= cnt + {q_m_r[8],1'b0} + n0q_m - n1q_m;
        end
        else 
        begin
            q_out[9] <= 1'b0;
            q_out[7:0] <= q_m_r[7:0];
            // 按照规范更新Cnt.
            cnt <= cnt - {~q_m_r[8],1'b0} + n1q_m - n0q_m;
        end
    end
    else 
    begin
        // DE = 0时,需要设置Cnt = 0.
        cnt <= 6'd0;
        case ({c1_r[1],c0_r[1]})
            2'b00   : q_out <= CTRLTOKEN0;
            2'b01   : q_out <= CTRLTOKEN1;
            2'b10   : q_out <= CTRLTOKEN2;
            2'b11   : q_out <= CTRLTOKEN3;
        endcase
    end
end

这样就把8b编码成10b了,这个编码能提供很好的直流平衡等等优势,反正高速传输很有用就是了.

还要例化三份这玩意,因为三个通道嘛,这里给出蓝色通道,如果C0,C1不使用,则设置为0.

tmds_encoder tmds_encoder_blue(
    .clk(clk_pclk),
    .resetn(reset0_n),
    .din(color[7:0]),
    .c0(hs),
    .c1(vs),
    .de(de),
    .q_out(tmds_blue)
);

接下来要使用并串转换,毕竟q_out是10b,而HDMI数据线上是串行的,所以要用OSERDESE2进行转换,还要用到级联模式.

wire shift1;
wire shift2;

OSERDESE2 #(
   .DATA_RATE_OQ("DDR"), // DDR, SDR
   .DATA_RATE_TQ("SDR"), // DDR, BUF, SDR
   .DATA_WIDTH(10), // Parallel data width (2-8,10,14)
   .SERDES_MODE("MASTER"), // MASTER, SLAVE
   .TBYTE_CTL("FALSE"), // Enable tristate byte operation (FALSE, TRUE)
   .TBYTE_SRC("FALSE"), // Tristate byte source (FALSE, TRUE)
   .TRISTATE_WIDTH(1)
)
OSERDESE2_M (      
   .CLK(clk_5x), // 1-bit input: High speed clock
   .CLKDIV(clk), // 1-bit input: Divided clock
   .OQ(out),

   // D1 - D8: 1-bit (each) input: Parallel data inputs (1-bit each)
   .D1(d[0]),
   .D2(d[1]),
   .D3(d[2]),
   .D4(d[3]),
   .D5(d[4]),
   .D6(d[5]),
   .D7(d[6]),
   .D8(d[7]),

   .RST(reset), // 1-bit input: Reset

   .SHIFTIN1(shift1),
   .SHIFTIN2(shift2),

   // T1 - T4: 1-bit (each) input: Parallel 3-state inputs
   .T1(0),
   .T2(0),
   .T3(0),
   .T4(0),
   .TBYTEIN(0), // 1-bit input: Byte group tristate
   .TCE(0) // 1-bit input: 3-state clock enable
);

OSERDESE2 #(
   .DATA_RATE_OQ("DDR"), // DDR, SDR
   .DATA_RATE_TQ("SDR"), // DDR, BUF, SDR
   .DATA_WIDTH(10), // Parallel data width (2-8,10,14)
   .SERDES_MODE("SLAVE"), // MASTER, SLAVE
   .TBYTE_CTL("FALSE"), // Enable tristate byte operation (FALSE, TRUE)
   .TBYTE_SRC("FALSE"), // Tristate byte source (FALSE, TRUE)
   .TRISTATE_WIDTH(1)
)
OSERDESE2_S (      
   .CLK(clk_5x), // 1-bit input: High speed clock
   .CLKDIV(clk), // 1-bit input: Divided clock

   // D1 - D8: 1-bit (each) input: Parallel data inputs (1-bit each)
   .D1(0),
   .D2(0),
   .D3(d[8]),
   .D4(d[9]),
   .D5(0),
   .D6(0),
   .D7(0),
   .D8(0),

   .RST(reset), // 1-bit input: Reset

   .SHIFTOUT1(shift1),
   .SHIFTOUT2(shift2),

   // T1 - T4: 1-bit (each) input: Parallel 3-state inputs
   .T1(0),
   .T2(0),
   .T3(0),
   .T4(0),
   .TBYTEIN(0), // 1-bit input: Byte group tristate
   .TCE(0) // 1-bit input: 3-state clock enable
);

最后再用OBUFDS单端转差分.,时钟则是硬编码的.

OBUFDS #(
  .IOSTANDARD("TMDS_33")
) tmds_diff_1 (
  .O(tmds_data_p[1]),     // Diff_p output (connect directly to top-level port)
  .OB(tmds_data_n[1]),   // Diff_n output (connect directly to top-level port)
  .I(tmds_data[1])      // Buffer input
);

看起来完成了,是不是还漏了什么,对的,color还没填充.

回到HS/VS生成的模块里,我们知道H_ACTIVE一旦开始,坐标就是0,如果我们同发送数据,则可以直接减去SYNC和Back Porch就是坐标,否则可能还需要FIFO/打拍或者其他手段,毕竟开发本就不可能完全一样.都应该根据实际选择.

always @ (posedge clk)
begin
    if(h_active)
        active_x <= h_cnt - H_SYNC - H_BP;
    else
        active_x <= 0;
end

always @ (posedge clk)
begin
    if(v_active)
        active_y <= v_cnt - V_SYNC - V_BP; 
    else
        active_y <= active_y;
end

至于testbench和下板测试,应该不用写了吧.

源码

https://gist.github.com/nickfox-taterli/f8d622b43cc97b7e37ae137f05e9a203

整体RTL.

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注