A HUB75 controller can look correct in a screenshot and still be wrong at the panel connector. A missing blanking cycle, an off-by-one column counter, or a latch pulse one clock too early produces flicker and ghosting that may disappear when you slow the design down. A small self-checking VHDL testbench catches those ordering errors before hardware bring-up. This tutorial builds one with GHDL and the row scanner from the preceding timing tutorial.
The goal is not to model a particular panel’s internal driver IC. The goal is to assert the contract owned by the FPGA: all shifting and latching happen while the output is blank, one latch follows exactly one row’s worth of columns, the address settles before display enable, and the row counter wraps cleanly.
The four invariants worth testing first
| Invariant | Failure symptom | Testbench observation |
|---|---|---|
| LAT is never high while OE is enabled. | Old and new row data bleed together; visible ghosting. | Assert not (lat = '1' and oe_n = '0') every clock. |
| CLK is never high while OE is enabled. | Shift-register activity appears on the display. | Assert not (hub_clk = '1' and oe_n = '0'). |
One transaction shifts exactly COLUMNS clock phases. | Columns wrap early, shift off the end, or latch stale data. | Count the clock-high states before LAT. |
| Row address changes only while blank and wraps. | Wrong row lights or the panel stops after the last row. | Sample row_addr at display enable and after dwell_done. |
Use a tiny panel in simulation
The testbench overrides the scanner generics to COLUMNS = 4 and ROW_GROUPS = 2. That makes a complete transaction easy to read in a waveform while preserving the same state transitions as a 64-column, 16-row panel. A smaller simulation is not a different design; it is the same parameterized design with fewer repetitions.
The stimulus drives a known six-bit RGB word, releases reset, counts the four high clock phases, waits for LAT, and then checks that the panel remains blank for the latch and address-settling cycles. It enables row zero, pulses dwell_done, and checks that the scanner blanks and advances to row one.
A self-checking VHDL-2008 testbench
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use std.env.all;
entity tb_hub75_row_scanner is
end entity;
architecture sim of tb_hub75_row_scanner is
constant COLUMNS : positive := 4;
constant ROW_GROUPS : positive := 2;
signal clk : std_logic := '0';
signal rst : std_logic := '1';
signal dwell_done : std_logic := '0';
signal pixel_bits : std_logic_vector(5 downto 0) := "101011";
signal hub_clk : std_logic;
signal lat : std_logic;
signal oe_n : std_logic;
signal row_addr : natural range 0 to ROW_GROUPS - 1;
signal rgb : std_logic_vector(5 downto 0);
begin
clk <= not clk after 5 ns;
dut: entity work.hub75_row_scanner
generic map (COLUMNS => COLUMNS, ROW_GROUPS => ROW_GROUPS)
port map (
clk => clk, rst => rst, dwell_done => dwell_done,
pixel_bits => pixel_bits, hub_clk => hub_clk, lat => lat,
oe_n => oe_n, row_addr => row_addr, rgb => rgb
);
stimulus: process
variable shift_cycles : natural := 0;
begin
wait for 25 ns;
rst <= '0';
while true loop
wait until rising_edge(clk);
wait for 1 ns;
assert not (oe_n = '0' and lat = '1')
report "LAT asserted while the panel output was enabled"
severity failure;
assert not (oe_n = '0' and hub_clk = '1')
report "HUB75 clock toggled while the panel output was enabled"
severity failure;
if hub_clk = '1' then
shift_cycles := shift_cycles + 1;
assert rgb = "000000"
report "RGB data was not blanked during the high clock phase"
severity failure;
end if;
exit when lat = '1';
end loop;
assert shift_cycles = COLUMNS
report "The scanner latched after the wrong number of column clocks"
severity failure;
assert oe_n = '1'
report "OE was enabled during the latch phase"
severity failure;
wait until rising_edge(clk);
wait for 1 ns;
assert lat = '0' and oe_n = '1'
report "The scanner did not keep the panel blank after LAT"
severity failure;
wait until rising_edge(clk);
wait for 1 ns;
assert oe_n = '0' and row_addr = 0
report "The scanner did not enable row zero after address settling"
severity failure;
dwell_done <= '1';
wait until rising_edge(clk);
wait for 1 ns;
dwell_done <= '0';
assert oe_n = '1' and row_addr = 1
report "The scanner did not blank and advance to the next row"
severity failure;
report "HUB75 row scanner timing checks passed" severity note;
finish;
end process;
end architecture;
Run it with GHDL
Run from the example directory. The --std=08 flag matters because the testbench uses VHDL-2008’s std.env.finish to end a successful simulation without an artificial stop time.
ghdl -a --std=08 rtl/hub75_row_scanner.vhd tb/tb_hub75_row_scanner.vhd
ghdl -e --std=08 tb_hub75_row_scanner
ghdl -r --std=08 tb_hub75_row_scanner
A passing run ends with HUB75 row scanner timing checks passed. To prove that the assertions are doing useful work, temporarily change oe_n to enable during the latch state or change the column terminal condition from COLUMNS - 1 to COLUMNS - 2. GHDL should stop at the corresponding failure rather than returning a misleading green result.
Turn the smoke test into a real verification layer
This first test checks control ordering, not image correctness. The next useful extensions are:
- Drive changing pixels. Replace the constant
pixel_bitswith a column counter and assert that the expected value is present during everyshift_lowstate. - Check every row. Hold
dwell_donehigh for one display interval repeatedly and assert the sequence0, 1, ..., ROW_GROUPS - 1, 0. - Add a bitplane scheduler. Give each plane a different dwell length and assert that the total visible slots match the numeric brightness code.
- Test reset at awkward times. Assert reset during shifting, latching, and display. The safe result is always blank output and a known row/column restart.
- Test the frame boundary. Connect a double-buffered framebuffer and assert that a bank swap is accepted only while the scan engine is at its defined frame boundary.
For larger designs, move the same ideas into reusable assertions around the DUT. The companion FPGA LED controller verification guide covers the invariant style for PWM, BCM, and HUB75 controllers; this VHDL testbench is the smallest place to start because every failure maps to one visible control signal.
A waveform-reading shortcut
When the test fails, inspect the signals in this order: OE first, then LAT, then row address, then CLK/RGB. If OE is low during a control transition, the panel can visibly show it even if the pixel data is perfect. If OE stays high but the latch count is wrong, the problem is in the column counter or state transition. If the latch count passes but the row is wrong, inspect the address update and wrap condition. Only after those boundaries are correct should you debug bit packing, channel order, gamma, or framebuffer reads.
That order keeps the test useful as the design grows. A testbench that only compares a final RGB value can miss the exact timing error that makes a physical panel flicker. A few cycle-level assertions give the simulator a definition of “ghost-free” that does not depend on a camera, a particular panel, or a lucky clock rate.
Continue the FPGA LED controller path
Read the HUB75 row-scanner timing tutorial, then use the FPGA LED controller hub to connect the verified scan transaction to PWM/BCM brightness, a frame buffer, and a tear-free clock-domain crossing.
Leave a Reply