HDMI

TL;DR ULX3S에서 640x480으로 동작하며 다른 파일 없이 단독으로 동작하는 쉬운 예제는 여기에 있습니다. 여러 해상도를 지원하는 범용 버전은 여기에 있습니다. FemtoRV32용 그래픽 보드는 여기에 있습니다.

RAM의 일부를 HDMI로 내보내는 프레임버퍼 장치를 추가해 봅시다. 저는 HDMI에 대해 아는 것이 전혀 없으며, ULX3S에서 잘 동작하는 단순하고 단독으로 동작하는 설계를 찾기가 매우 어렵습니다. 여기에는 여러 이유가 있는데, 그중 하나는 FPGA마다 다를 수 있는 특수한 블록을 사용해야 한다는 점입니다.

제가 처음 찾은 것은 fpga4fun 웹사이트입니다. 이 사이트에는 훌륭한 설명과 예제 프로그램이 있습니다(다만 예제 프로그램은 수정해서 써야 합니다). 여기서 HDMI 커넥터에는 핀이 19개 있지만 그중 8개에 신호를 생성해야 하며, 이 8개가 이른바 TMDS differential pairs(TMDS 차동 쌍) 4개를 이룬다는 것을 알 수 있습니다. 제가 이해하기로는, 차동 쌍은 디지털 신호를 높은 주파수로 전선을 통해 보내는 방법입니다. 빨강, 초록, 파랑, 그리고 클록에 각각 하나씩 할당됩니다.

다음으로, VGA와 마찬가지로 화면 밖 영역(off-screen area)이 있다는 것을 알 수 있습니다. 다시 말해, 제가 이해한 바로는 HDMI(또는 DVI)는 VGA와 똑같이 동작하며, 차이점은 모든 신호가 아날로그가 아니라 디지털(그리고 직렬화된) 신호라는 것뿐입니다. 그다음으로 640x480 RGB 24bpp 60Hz의 타이밍을 살펴봅니다. 25MHz의 픽셀 클록이 필요하다는 것을 알 수 있습니다. ULX3S에는 25MHz 클록이 있으므로 이를 그대로 사용할 수 있습니다. 좋습니다! 그리고 화면 밖 영역까지 포함하면 전체 영역은 800x525가 됩니다. 또한 VGA와 마찬가지로 hSync 신호와 vSync 신호도 생성합니다.

이제 빨강, 초록, 파랑 신호를 직렬화해야 하는데, 몇 가지 복잡한 점이 있습니다. 비트를 특정한 방식으로 뒤섞어야 하고, 신호마다 비트가 2개씩 추가된다는 것입니다(궁금하시다면 말씀드리자면, 이는 0과 1 사이의 전환을 최소화하고 0과 1의 개수를 균형 있게 맞추기 위한 것입니다). 이를 위해 TMDS_encoder 모듈이 있습니다. 이 모듈은 픽셀 클록, 8비트 채널 데이터, 제어 비트 2개, 그리고 VDE(Video Display Enable) 신호(화면 밖 영역에서는 0이 되는 신호)를 입력으로 받습니다. 제어 비트 2개는 빨강과 초록 신호에서는 00으로, 파랑 신호에서는 vSync,hSync로 설정합니다. 각 TMDS_encoder는 10비트 데이터를 출력합니다.

이제 10비트 폭의 빨강, 초록, 파랑 TMDS 신호를 직렬화해야 합니다. 이를 위해서는 10*25MHz = 250MHz로 동작하는 클록이 필요합니다. 이를 위한 특수 블록이 있습니다. 또한 10비트 시프트 레지스터 3개와, 10비트 TMDS 신호를 시프트 레지스터로 복사하기 위해 250MHz 클록의 10틱마다 high가 되는 신호가 필요합니다. 처음의 25MHz 신호를 그대로 사용할 수는 없는 것 같습니다. 아마 그 신호가 생성되는 방식 때문에 동기화가 잘 맞지 않거나 하는 문제가 있을 것입니다.

마지막으로, 생성된 빨강, 초록, 파랑 직렬 신호와 픽셀 클록(250 MHz 클록의 10틱마다 high가 되는 신호가 아니라 ??)을 TMDS 쌍에 대응하는 특수 블록을 통해 보내야 합니다.

좋습니다, 이제 꽤 명확해졌습니다. 정리하면 다음과 같습니다.

  1. 이식 가능한 Verilog로 작성한 VGA 신호 생성기 비슷한 것: 여기 (FPGA4fun의 것을 사용)
  2. 마찬가지로 이식 가능한 Verilog로 작성된 TMDS 인코더는 빨강, 초록, 파랑 신호를 받아 10비트로 인코딩하며, 여기에 있습니다(FPGA4fun의 것을 사용했습니다).
  3. 특수 프리미티브를 사용하는 250 MHz 클록은 여기에 있습니다(ECP5 전용이며, Lawrie의 것을 사용했습니다).
  4. 이식 가능한 Verilog로 작성된 시프트 레지스터는 여기에 있습니다(FPGA4fun의 것을 사용했습니다).
  5. 마지막으로, 직렬화된 빨강, 초록, 파랑 신호와 픽셀 클록으로부터 TMDS 쌍을 생성하는 전용 블록입니다. 이것 역시 ECP5 전용입니다. HDMI 핀에는 LVCMOS33D 모드를 사용하며, 이는 여기에 있는 .lpf 파일에 지정되어 있습니다. 이 모드는 제가 필요로 하는 일, 즉 양극 핀으로부터 음극 핀을 생성하는 일을 정확히 해 줍니다. 이 모드가 활성화되어 있을 때에는 음극 핀을 구동해서는 안 됩니다!

LVCMOS33D는 어떻게 알아냈을까요? 저는 운이 아주 좋았습니다. Emard의 저장소 여기에서 ulx3s.lpf를 무작정 복사한 다음, 인버터를 사용한 단순한 설계를 시도해 보았는데(참고로 이 설계는 어차피 동작하지 않았을 것입니다), NEXPNR에서 어서션 실패가 발생했습니다: Assertion failure: pio == "PIOA". 이 메시지를 Google에 붙여 넣었더니 여기로 안내되었는데, 이곳에 그 사연이 설명되어 있습니다(LVCMOS33D는 쌍의 양쪽을 모두 구동하는 의사 차동(pseudo-differential) 드라이버를 의미하므로, gpdi_dp만 구동하고 gpdi_dn은 제거해야 합니다.

더 높은 해상도를 위해서는 클록당 두 비트를 시프트하여 보낼 수 있는 ECP5 전용 프리미티브(ODDRX1F)를 사용할 수 있습니다(이 경우 250MHz 대신 125MHz 클록을 사용합니다). Lawrie의 코드는 여기를 참고하십시오. 이것 없이도 제 설계는 800x600까지는 동작할 수 있다고 생각합니다(다만 이를 위해서는 주파수, 해상도 등을 변경해야 합니다...). 더 높은 해상도를 위해서는 물론 클록과 해상도를 변경하는 것과 더불어, 시프터 부분을 다음과 같이 변경해야 합니다:

   // Counter now counts modulo 5 instead of modulo 10
   reg [4:0] TMDS_mod5=1;
   wire TMDS_shift_load = TMDS_mod5[4];
   always @(posedge clk_TMDS) TMDS_mod5 <= {TMDS_mod5[3:0],TMDS_mod5[4]};
   
   // Shifter now shifts two bits at each clock
   reg [9:0] TMDS_shift_red=0, TMDS_shift_green=0, TMDS_shift_blue=0;
   always @(posedge clk_TMDS) begin
      TMDS_shift_red   <= TMDS_shift_load ? TMDS_red   : TMDS_shift_red  [9:2];
      TMDS_shift_green <= TMDS_shift_load ? TMDS_green : TMDS_shift_green[9:2];
      TMDS_shift_blue  <= TMDS_shift_load ? TMDS_blue  : TMDS_shift_blue [9:2];
   end
   
   // DDR serializers: they send D0 at the rising edge and D1 at the falling edge
   ODDRX1F ddr_red  (.D0[TMDS_shift_red[0]),   .D1[TMDS_shift_red[1]),   .Q(TMDS_rgb_p[2]), .SCLK(), .RST(1'b0));
   ODDRX1F ddr_green(.D0[TMDS_shift_green[0]), .D1[TMDS_shift_green[1]), .Q(TMDS_rgb_p[1]), .SCLK(), .RST(1'b0));
   ODDRX1F ddr_blue (.D0[TMDS_shift_blue[0]),  .D1[TMDS_shift_blue[1]),  .Q(TMDS_rgb_p[0]), .SCLK(), .RST(1'b0));   

이제 임의의 모드를 구현할 준비가 되었습니다. 타이밍은 여기에서 찾을 수 있습니다.

각 모드에 대해 다음 사항을 알아야 합니다:

이 매개변수들을 바탕으로 스캐너를 다음과 같이 구현할 수 있습니다:

localparam GFX_line_width = GFX_width  + GFX_h_front_porch + GFX_h_sync_width + GFX_h_back_porch;
localparam GFX_lines      = GFX_height + GFX_v_front_porch + GFX_v_sync_width + GFX_v_back_porch;

reg [10:0] GFX_X, GFX_Y;
reg hSync, vSync, DrawArea;

always @(posedge pixclk) DrawArea <= (GFX_X<GFX_width) && (GFX_Y<GFX_height);

always @(posedge pixclk) GFX_X <= (GFX_X==GFX_line_width-1) ? 0 : GFX_X+1;
always @(posedge pixclk) if(GFX_X==GFX_line_width-1) GFX_Y <= (GFX_Y==GFX_lines-1) ? 0 : GFX_Y+1;

always @(posedge pixclk) hSync <= 
   (GFX_X>=GFX_width+GFX_h_front_porch) && (GFX_X<GFX_width+GFX_h_front_porch+GFX_h_sync_width);
   
always @(posedge pixclk) vSync <= 
   (GFX_Y>=GFX_height+GFX_v_front_porch) && (GFX_Y<GFX_height+GFX_v_front_porch+GFX_v_sync_width);

다음으로, ULX3S의 25 MHz 클록으로부터 다음을 생성해야 합니다:

이는 PLL을 사용하여 구현할 수 있습니다. ULX3S에 탑재된 ECP5에서는 이 전용 블록을 EHXPLLL이라고 부릅니다. 올바른 매개변수를 대신 설정해 주는 ecppll 명령줄 도구가 있습니다. 사용법은 다음과 같습니다:

$ ecppll -i 25 -o <tmds_clock_freq> -f tmp.v

여기서 <tmds_clock_freq>는 픽셀 클록의 5배에 해당합니다. 그런 다음 tmp.v를 편집할 수 있습니다. 또한 생성된 PLL을 수정하여 CLKOS 핀으로 픽셀 클록도 생성하도록 할 수 있습니다. 이를 위해 다음과 같이 설정합니다:

 #(
  ...
  .CLKOS_ENABLE("ENABLED"),
  .CLKOS_DIV(5*CLKOP_DIV),
  .CLKOS_CPHASE(CLKOP_CPHASE),
  .CLKOS_FPHASE(CLKOP_FPHASE),
  ...
 ) pll_i(
  ...
  .CLKOS(pixclk),  
  ...
 );

분주값이 CLKOP의 5배(따라서 5배 느림)라는 점을 제외하면 CLKOP와 같은 파라미터를 사용합니다.

이제 이 파일에 나온 것처럼 여러 가지 모드를 구현할 준비가 되었습니다.

참고 자료