Gonzalo Ferrero Martín

Personal project · Jul 2026 – present

HDMI / DVI transmitter

A DVI transmitter written from scratch in Verilog, verified in simulation before it ever touches a board.

One horizontal line of 640 by 480 video timing, drawn to scale: de is high for 640 clocks and low for 160; hsync is low from clock 656 to 751 inside the 800-clock line. de hsync active 640 16 sync 96 48
Fig. 1 — One line of 640×480 @ 25.175 MHz: 640 active, 16 front porch, 96 sync, 48 back porch; 800 clocks in all. Drawn to scale; hsync low from clock 656 to 751.
Role
Everything — personal project
Stack
Verilog · XSIM · Vivado
Board
PYNQ-Z2 (Zynq-7020)
Status
in progress — timing · pattern and encoder verified in simulation; serializer and board bring-up in progress

Four stages

The transmitter is a DVI video pipeline for the HDMI connector on a PYNQ-Z2, written from scratch in Verilog and started in July 2026 — plain DVI, with no audio and no data islands: three TMDS channels and a clock. It has four stages: a parameterized video timing generator, a pattern generator, a TMDS 8b/10b encoder, and a 10:1 serializer driving differential outputs. The first three exist and pass their testbenches; the serializer’s port list exists, and its body is still being written.

The timing generator is parameterized on the eight standard numbers and derives the rest as localparams. It produces 640×480 active pixels inside an 800×525 total frame — 16 front porch, 96 sync and 48 back porch clocks on every line; 10, 2 and 33 lines around every frame — at a 25.175 MHz pixel clock, and outputs hsync, vsync, de and 12-bit x/y counters. The pattern generator is deliberately simple for now: a horizontal red ramp, red = x mod 256, green and blue zero, black outside de.

The encoder

TMDS encoding is done in two stages, kept separate in the RTL so each can be reasoned about alone. The first counts the ones in the byte and chooses XOR or XNOR chaining to minimize transitions, flagging the choice in bit 8. The second is a DC-balance stage: it keeps a signed running disparity, cnt_r, and decides whether to invert the eight data bits to pull it back toward zero, flagging that in bit 9.

During blanking the encoder ignores the data, emits one of the four DVI control codewords selected by c0/c1 (Fig. 2), and resets the disparity to zero. The disparity arithmetic is where I expected mistakes, so that is where the testbench looks hardest.

The four DVI control codewords as stepped waveforms, drawn in transmission order with q[0] first: c1c0 = 00, 01, 10 and 11, each written beside its waveform as q[9:0]. transmitted q[0] → q[9] q[9:0] as written c1c0 = 00 1101010100 c1c0 = 01 0010101011 c1c0 = 10 0101010100 c1c0 = 11 1010101011
Fig. 2 — The four control codewords the encoder emits during blanking, drawn q[0] first as transmitted; written q[9:0] as in the DVI 1.0 spec. Checked against the spec by the testbench.

Try the encoder

Fig. 3 is the encoder itself, ported line for line from the RTL to JavaScript so it can be driven from the page. Pick a byte and the figure shows what each stage does with it: the ones count and the XOR or XNOR choice, the nine-bit q_m, the DC-balance decision with the reason the RTL used, and the ten bits that leave the encoder, drawn q[0] first as in Fig. 2. Set a prior disparity to reach the inversion branch, or feed the ramp to watch cnt_r accumulate over a video line the way it does for the red-ramp pattern, reset to zero in blanking. Under the figure is a self-check that runs in the reader’s browser every time this page loads: all 256 bytes through the encoder and back through a decoder written from the testbench’s, with the transition count and the disparity bound checked over one full line.

q_out = 0100111101 · cnt_r 0 → +2N1 = 4 ones in d, d[0] = 1 → XOR chain, q_m[8] = 1 q_m[7:0]: 5 ones, 3 zeros · cnt_r = 0 → q_m[8] decides → keep, q_out[9] = 0
The TMDS encoder on one byte: the input bits, the nine-bit q_m after the transition-minimizing stage, and the ten-bit q_out after DC balancing, each drawn as a stepped waveform bit 0 first and written beside it msb first, with the ones count, the chaining choice, the inversion decision and the running disparity before and after. Below, the running disparity as a stepped trace, plus or minus 8 full scale, against the input byte d, 0x00 to 0xFF. bit 0 first, as transmitted written msb first d = 0x47 01000111 N1 = 4 q_m 100111101 XOR · q_m[8] = 1 q_out 0100111101 cnt_r 0 → +2 N1 = 4 ones in d, d[0] = 1 → XOR chain, q_m[8] = 1 q_m[7:0]: 5 ones, 3 zeros · cnt_r = 0 → q_m[8] decides → keep, q_out[9] = 0 0x00 0x40 0x80 0xC0 0xFF d +8 0 −8

self-check: not run — this line needs JavaScript

Fig. 3 — The encoder, ported from the RTL: the input byte, q_m after the transition stage and q_out after DC balancing, drawn q[0] first as transmitted, written msb first. Below, the running disparity cnt_r at ±8 full scale against the input byte d: at rest, the first 256-symbol pass of the ramp from cnt_r = 0; after a slider moves, this one symbol before and after, drawn at its byte.

What the testbenches check

The encoder has a self-checking XSIM testbench that checks four properties on every symbol: round trip — decode(encode(d)) == d against an independently written decoder; at most five transitions per data symbol; an independently accumulated running disparity, kept from the ten transmitted bits, that matches the encoder’s own cnt_r every cycle; and a bounded |disparity|, with the bench’s limit at 12. Control periods are checked against the four spec codewords. One run covers 10,816 data symbols: every byte value ascending and descending, each value held for eight cycles to provoke disparity drift, long runs of four constants, and forty random bursts with blanking between them.

A second testbench drives the timing generator and the pattern generator together, waits for de at x = 0, y = 0, and dumps the full 640×480 frame from simulation to a PPM file so it can be checked numerically rather than by eye. Alongside it, it checks seven timing counts against constants: active pixels per frame, active lines, hsync pulses, total hsync and vsync cycles, and the length of every de run and every hsync pulse. Fig. 4 is that frame, placed inside the 800×525 timing envelope it was generated in.

Simulated 640 by 480 frame: three horizontal red ramps from black to red, repeating every 256 pixels.

blanking 160

blanking 45 lines (10 / 2 / 33)

Fig. 4 — The frame the timing/pattern testbench dumped from XSIM to PPM, placed inside the 800×525 frame it was generated in; red is x mod 256. Seven timing counts checked against constants.

Fig. 5 lays the pipeline out with its coverage. The frame testbench brackets the first two stages, the encoder testbench the third, and the serializer is drawn dashed because its body is not yet written.

Pipeline of four stages: timing generator, pattern generator, three TMDS encoders and a 10:1 serializer drawn dashed and marked in progress, feeding the TMDS differential outputs. Brackets mark the testbench coverage: the full-frame PPM dump with seven timing counts over the first two stages, and four checks per symbol over the encoder. timing gen pattern gen TMDS enc ×3 10:1 serializer TMDS ± 25.175 MHz in progress TB: full frame → PPM · 7 timing counts TB: 4 checks / symbol
Fig. 5 — Pipeline. Three stages verified in simulation; the 10:1 serializer is being written.

What is not done

Timing, pattern and encoder are verified in simulation. The 10:1 serializer — port list written, body not yet — and board bring-up are in progress. Nothing has been displayed on a monitor, and the page will say so until it has.

  • donetiming generator
  • donepattern generator
  • doneTMDS encoder + self-checking testbench
  • in progress10:1 serializer
  • in progressboard bring-up