上回我們實作了 UART 的輸入輸出,這回就來挑戰板子上有附的另一個介面:HDMI,這個實作出來我們就有影像輸出可以用了呢。
不過因為 HDMI 的難度比起來上升了一個層次,這次我是直接用 icesugar-pro 的範例程式碼改寫, TMDS 的部分則有參考網路上的 encoder

HDMI protocol

High Definition Multimedia Interface,HDMI (高畫質多媒體介面)是應用相當廣泛的視訊介面,板子使用的是 HDMI pin A 17 支針腳, 不過如果近拍的話,可以看到它只連接了 8 支腳跟接地,分別是:

  • 1,3 Data 2+/- 藍色
  • 4,6 Data 1+/- 綠色
  • 7,9 Data 0+/- 紅色
  • 10,12 Data Clock +/-

hdmi_pin

HDMI 的信號走的是差動傳輸,比起 UART 的單端傳輸能做到更高的速率,相關的文章可以看這篇, 因為沒接所以我們沒辦法玩 HDMI 後面針腳提供 I2C,反正那個應該是用來走音訊用的,不玩沒差(欸。
另外,不知道為什麼,但在它的 schematic 跟圖片找不到 HDMI 的接腳是走哪支腳,只能照抄範例的 lpf 檔:

LOCATE COMP "hdmi_dp[0]" SITE "G1"; # Blue +
LOCATE COMP "hdmi_dn[0]" SITE "F1"; # Blue -
LOCATE COMP "hdmi_dp[1]" SITE "J1"; # Green +
LOCATE COMP "hdmi_dn[1]" SITE "H2"; # Green -
LOCATE COMP "hdmi_dp[2]" SITE "L1"; # Red +
LOCATE COMP "hdmi_dn[2]" SITE "K2"; # Red -
LOCATE COMP "hdmi_dp[3]" SITE "E2"; # Clock +
LOCATE COMP "hdmi_dn[3]" SITE "D3"; # Clock -

IOBUF PORT "hdmi_dp[0]" IO_TYPE=LVCMOS33 DRIVE=4;
IOBUF PORT "hdmi_dn[0]" IO_TYPE=LVCMOS33 DRIVE=4;
IOBUF PORT "hdmi_dp[1]" IO_TYPE=LVCMOS33 DRIVE=4;
IOBUF PORT "hdmi_dn[1]" IO_TYPE=LVCMOS33 DRIVE=4;
IOBUF PORT "hdmi_dp[2]" IO_TYPE=LVCMOS33 DRIVE=4;
IOBUF PORT "hdmi_dn[2]" IO_TYPE=LVCMOS33 DRIVE=4;
IOBUF PORT "hdmi_dp[3]" IO_TYPE=LVCMOS33 DRIVE=4;
IOBUF PORT "hdmi_dn[3]" IO_TYPE=LVCMOS33 DRIVE=4;

PLL

由於 HDMI 的運作原理是這樣子的,在螢幕上每個像素用的是 24 位元的高彩,也就是 RGB 各佔一個 byte,在傳輸的時候,它會使用 8b/10b 編碼, 把一個 byte 的資料加上兩個 bit ,透過 TMDS 最小化傳輸差動信號 填充成 10 個 bits,然後由上述的差動信號傳輸出去。

FPGA 運作在 25MHz,serial port 上行走的資料速度就要 250 MHz,要在 FPGA 上拿到這麼快的 clock,就一定要用上 PLL 不可。
幸好,icesugar-pro 用的 FPGA LFE5U-25F-6BG256C 還真的有提供一個 PLL 可用, 搭配型號與 datasheet 去 google 可以看到 Lattice 提供的 datasheet; 另外可以用 ECP5 and ECP5-5G sysCLOCK PLL/DLL Design and Usage Guide 查到另一份專門講解 PLL 的 datasheet, 因為都是 pdf 的連結我這裡就不附上了。

如果有使用 Lattice 的整合開發環境 Lattice Diamond , 它們裡面就會提供圖形化介面的 PLL 設定工具,應該是會直接幫你生出可用的 verilog code,但我們沒 license 就只能手爆了。

手爆步驟如下,首先是查看 PLL datasheet,在第 18 章開始介紹完整的 PLL 架構: hdmi_pll

PLL 的運作是這個樣子的,首先你會有個 input clock,然後提供一個 feedback 的時脈,兩個一齊進到中間的 VCO, VCO 會調整輸出時脈的頻率,直到 input 跟 feedback 的 phase 鎖定為止,至於怎麼實現就是一門專門的學問了, 可能要去修 Abyss 的鎖相迴路課程。

表 18.1 提供了 verilog module 所有的 input/output 列表,另外有一些是要由參數來設定的,在表 18.6 有參數的列表。
我們的目標是從 25 MHz 的 input 生成 250 MHz 的 output,選用 primary output CLKOP 生成 250 MHz; CLKOS 生成 25 MHz 的 clk 作為 feedback clk,其他的 clock 就全部關掉。
這裡不知道為什麼 PLL 的文件沒寫,反而寫在 LFE5U-25F-6BG256C 的文件裡,VCO 推薦的使用範圍是 400-800 MHz , 因此我們讓 VCO 頻率為 500 MHz,CLKOP 跟 CLKOS 分頻 2 跟 20。

以下是我寫出來的 clock.sv 檔:

module clock
(
  input clkin_25MHz,
  output clk_25MHz,
  output clk_250MHz,
  output locked
);

(* ICP_CURRENT="9" *) (* LPF_RESISTOR="8" *) (* MFG_ENABLE_FILTEROPAMP="1" *) (* MFG_GMCREF_SEL="2" *)
EHXPLLL
#(
  .CLKOS_FPHASE(0),
  .CLKOP_FPHASE(0),
  .CLKOS_CPHASE(2),
  .CLKOP_CPHASE(20),
  .CLKOS_ENABLE("ENABLED"),
  .CLKOP_ENABLE("ENABLED"),
  .CLKI_DIV(1),
  .CLKOP_DIV(2),
  .CLKOS_DIV(20),
  .CLKFB_DIV(1),
  .FEEDBK_PATH("CLKOS")
)
pll_i
(
  .CLKI(clkin_25MHz),
  .CLKFB(clk_25MHz),
  .CLKOP(clk_250MHz),
  .CLKOS(clk_25MHz),
  .CLKOS2(),
  .CLKOS3(),
  .RST(1'b0),
  .STDBY(1'b0),
  .PHASESEL0(1'b0),
  .PHASESEL1(1'b0),
  .PHASEDIR(1'b0),
  .PHASESTEP(1'b0),
  .PLLWAKESYNC(1'b0),
  .ENCLKOP(1'b0),
  .ENCLKOS(1'b0),
  .ENCLKOS2(),
  .ENCLKOS3(),
  .LOCK(locked),
  .INTLOCK()
);
endmodule

最上面那串 ICP_CURRENT 我不知道是幹嘛,查了 FAQ 是一些 LPF 參數的設定,會由工具自動產生,我留不留都不影響最終結果。
可以看到我只留下 CLKOP 跟 CLKOS 的設定,CLKOP 除 2 變 250 MHz,CLKOS 除 20 變 25 MHz 灌回去 CLKFB; 搭配 FPHASE 跟 CPHASE 可以調整輸出信號的相位,不過我們這邊都不設定相位。

HDMI

module hdmi(
  input clk_tmds,
  input clk_pixel,
  input rst,
  input [7:0] i_red, i_green, i_blue,
  output logic o_enable,
  output logic o_newline,
  output logic o_newframe,
  output logic o_red,
  output logic o_green,
  output logic o_blue);

HDMI 模組的介面如下:

  • clk_tmds/clk_pixel 250 MHz 和 25 MHz 的時脈
  • i_red/green/blue RGB 各一個 byte 的資料
  • o_red/o_green/o_blue 送到串列上的 bit
  • o_enable, o_newline, o_newline 告知資料源現在顯示到哪的線,不會真的送到螢幕上。
parameter WIDTH = 640;
parameter HEIGHT = 480;
parameter VWIDTH = 800;
parameter VHEIGHT = 525;

logic [9:0] CounterX, CounterY;

// update counterX and counterY
always_ff @(posedge clk_pixel or negedge rst) begin
  if (!rst) begin
    CounterX <= 0;
  end
  else begin
    if (CounterX == VWIDTH-1) begin
      CounterY <= (CounterY == VHEIGHT-1) ? 0 : CounterY+1;
    end
  end
end

always_ff @(posedge clk_pixel or negedge rst) begin
  if (!rst) begin
    CounterY <= 0;
  end
  else begin
    if (CounterX == VWIDTH-1) begin
      CounterY <= (CounterY == VHEIGHT-1) ? 0 : CounterY+1;
    end
  end
end

透過 clk_pixel 計算 counterX, counterY 我們可以知道現在正在處理哪個像素。

logic hSync, vSync, DrawArea;

// Signal end of line, end of frame
assign o_newline  = (CounterX == WIDTH-1);
assign o_newframe = (CounterX == WIDTH-1) && (CounterY == HEIGHT-1);
assign DrawArea   = (CounterX < WIDTH) && (CounterY < HEIGHT);
assign o_enable   = rst & DrawArea;
assign hSync = (CounterX >= 656) && (CounterX < 752);
assign vSync = (CounterY >= 490) && (CounterY < 492);

這邊就只是一些順著 CounterX, Y 變化的線,hSync 跟 vSync 的時序似乎是 HDMI 標準有制定, 才會出現 X=656~752 間和 Y=490~492 間必須進到 hSync 跟 vSync 設定。
因為未來其他 project 的緣故,我真的很好奇這個 hSync, vSync 的時序是不是能隨意調整?

logic [9:0] tmds_red, tmds_green, tmds_blue;
logic [9:0] tmds_red_next, tmds_green_next, tmds_blue_next;
TMDS_encoder encode_R(.clk(clk_pixel), .rst(rst), .data(i_red), .control(2'b00),
  .enable(DrawArea), .tmds(tmds_red_next));
TMDS_encoder encode_G(.clk(clk_pixel), .rst(rst), .data(i_green), .control(2'b00),
  .enable(DrawArea), .tmds(tmds_green_next));
TMDS_encoder encode_B(.clk(clk_pixel), .rst(rst), .data(i_blue), .control({vSync,hSync}),
  .enable(DrawArea), .tmds(tmds_blue_next));

把 red/green/blue byte 塞進 TMDS_encoder 編碼成 10 bits 的信號 tmds_*_next。

logic [3:0] tmds_counter=0;
always @(posedge clk_tmds) begin
  if (!rst) begin
    tmds_counter <= 0;
    tmds_red   <= 0;
    tmds_green <= 0;
    tmds_blue  <= 0;
  end else begin
    tmds_counter <= (tmds_counter==4'd9) ? 4'd0 : tmds_counter+4'd1;
    tmds_red   <= (tmds_counter == 4'd9)? tmds_red_next: tmds_red >> 1;
    tmds_green <= (tmds_counter == 4'd9)? tmds_green_next: tmds_green >> 1;
    tmds_blue  <= (tmds_counter == 4'd9)? tmds_blue_next: tmds_blue >> 1;
  end
end

assign o_red   = tmds_red[0];
assign o_green = tmds_green[0];
assign o_blue  = tmds_blue[0];

每經過 10 個 tmds clock,我們就把下一輪的 data 更新到 tmds_RGB 裡面; 反之就是原本的 data 右移,o_red/o_green/o_blue 把該資料的 LSB 送出去就可以了。

TMDS

TMDS 應該是這次最麻煩的部分,模組的宣告:

module TMDS_encoder(
  input clk, // 250 MHz
  input rst,
  input [7:0] data,  // video data (red, green or blue)
  input [1:0] control,  // control data
  input enable,  // enable == 1 ? data : control
  output logic [9:0] tmds
);

typedef enum logic [9:0] {
  CTRL_00 = 10'b1101010100,
  CTRL_01 = 10'b0010101011,
  CTRL_10 = 10'b0101010100,
  CTRL_11 = 10'b1010101011
} control_t;

我們要把 8 bits data 編碼成 10 bits tmds,control 如 hdmi 模組的呼叫,在藍色的通道上會傳送控制的信號 h_sync, v_sync, 這兩個信號會對應到上面寫的四個 TMDS control signal。

再來可以把 TMDS 流程可以分成下面兩步:

1. XOR/XNOR

第一步會先把 data 進行 rolling xor 或 rolling xnor,要選哪個則是根據哪一個出來的結果的 0-1 transition 的數量比較少。 實作上會直接去算 8 bits 中 1 的數量,比 4 多就會是 xnor,原因如下:

input operator output
00 XOR 00
00 XNOR 01
01 XOR 01
01 XNOR 00
10 XOR 11
10 XNOR 10
11 XOR 10
11 XNOR 11

可以看到,只要後一個 bit 是 0,XOR 的結果就不會有 transition,反之則是 XNOR,因此 1 愈多 XNOR 的 transition 就愈少; 如果序列中 0 跟 1 剛好對半,那麼就要看第一個 bit,如果是 1 表示剩下的 7 個 bits 1 會比較少,就要選 XOR。

logic [3:0] ones_d;
bit use_xor;
logic [7:0] qm;

function automatic logic [3:0] count_ones(input logic [7:0] bits);
  count_ones = 0;
  int i;
  for (i = 0; i < 8; i = i+1) begin
    count_ones += $bits(count_ones)'(bits[i]);
  end
endfunction

function automatic logic [7:0] rolling_xor(input logic [7:0] bits);
  rolling_xor[0] = bits[0];
  int i;
  for (i = 1; i < 8; i = i+1) begin
    rolling_xor[i] = rolling_xor[i-1] ^ bits[i];
  end
endfunction

function automatic logic [7:0] rolling_xnor(input logic [7:0] bits);
  rolling_xnor[0] = bits[0];
  int i;
  for (i = 1; i < 8; i = i+1) begin
    rolling_xnor[i] = rolling_xnor[i-1] ^~ bits[i];
  end
endfunction

// stage 1: rolling_xor or rolling_xnor the data
assign ones_d = count_ones(data);
assign use_xor = ones_d < 4 || (ones_d == 4 && data[0] == 1'b1);
assign qm = (use_xor)? rolling_xor(data) : rolling_xnor(data);

這裡用 systemverilog 的 function,還有用 for loop 展開程式碼; 雖然說 yosys 的 for loop 就像 C89 一樣,沒辦法把宣告寫在 for loop 裡面,死廢物耶

JJL:不廢一點你覺得什麼 S 公司 C 公司靠什麼賺錢

2. Invert

第二步 TMDS 會看 XOR/XNOR 出來的結果,其中 0 跟 1 數量的差距,必要時反轉全部的 bits 讓 0 1 的數量平衡;

// stage 2: invert bits to compensate diff in 1s or 0s
assign ones_qm = count_ones(qm);
assign diff_qm = (signed'(5'(ones_qm) << 1)) - 5'd8;

always_comb begin
  if (disparity == 0 && ones_qm == 4) begin
    // balanced, set invert_qm to compensate xor bit
    invert_qm = ~use_xor;
  end
  else begin
    invert_qm = (disparity > 0 && ones_qm > 4) || (disparity < 0 && ones_qm < 4);
  end
end

使用的是 XOR 1 / XNOR 0 保存在第 9 個 bit,invert 1 / non-invert 0 保存在第 10 個 bit。
最後我們把信號放進 output tmds 中並更新 disparity,如果不在畫面裡就送 control signal。

always_ff @(posedge clk) begin
  if (enable) begin
    tmds <= {invert_qm, use_xor, invert_qm ? ~qm : qm};
    disparity <= disparity +
      (invert_qm ? -($bits(disparity)'(diff_qm)) : $bits(disparity)'(diff_qm)) +
      (invert_qm ? $bits(disparity)'('sd1) : -($bits(disparity)'('sd1)));
  end
  else begin
    disparity <= 0;
    case (control)
      2'b00: tmds <= CTRL_00;
      2'b01: tmds <= CTRL_01;
      2'b10: tmds <= CTRL_10;
      2'b11: tmds <= CTRL_11;
    endcase
  end
end

Test Pattern

跟範例 code 一樣,使用 vgatestsrc 來產生測試的畫面, 就能看到顯示的 Test Pattern 了。

hdmi_test_pattern

看著這畫面實在有點古早的感覺。

結語

無論是上一篇的 UART 還是這篇的 HDMI,要理解一些通訊介面的運作原理,最好的方式就是用 FPGA 去實作它; 正如要理解作業系統的原理,最好的方法就是自幹一個作業系統,雖然我現在幹到一半還沒幹完

有了 UART 跟 HDMI,現在我們已經有了文字介面的輸入輸出,以及畫面的輸出,下一步應該會來測試一下上面附的 SDRAM,看要如何存取, 這部分我記得強者我同學施博神有自幹過 DRAM controller,是不是該跟他要個 code(欸