Category: FPGA & LED Controllers

  • FPGA PWM vs. BCM for LED Dimming: Which Should You Use?

    Pulse-width modulation and binary-coded modulation can produce the same average LED brightness, but they organize time differently. That difference affects comparators, schedulers, memory access, refresh rate, camera behavior, and how easily a design scales from one LED to a matrix.

    The short answer

    Use counter-comparator PWM for independent outputs and simple channels. Use BCM when many pixels share a bitplane and row scheduler. Neither method creates extra brightness resolution for free: both must fit the same clock, refresh, settling, and blanking budget.

    Design questionConventional PWMBCM / bitplane PWM
    Core operationCompare each level with a counterDisplay each stored bit for its binary weight
    Natural unitOne independently timed channelA row or bank of pixels
    Per-channel logicComparator plus registerBit selection; one shared scheduler
    Memory accessRead complete level wordsRead one bitplane from many level words
    Full-scale conventionNeeds an explicit saturation choiceCode 255 naturally lights 255 of 255 slots
    Timing riskRunt pulses after mid-period updatesShort LSB planes and mixed-frame bitplanes

    Why the average brightness is equivalent

    An 8-bit PWM counter creates 256 count positions. Ignoring the special full-on convention, a code of 173 enables the output for 173 of those positions. An 8-bit BCM cycle contains weighted planes of 1, 2, 4, 8, 16, 32, 64, and 128 base slots. The binary value 173 is 10101101, so the active weights are 128 + 32 + 8 + 4 + 1 = 173. The ordering changes; the integral does not.

    Conventional PWM architecture

    The usual FPGA implementation shares one free-running counter among multiple channels and gives each channel a comparator. For three RGB outputs this is exceptionally small and clear. Each level word remains available continuously, and changing the carrier frequency is a matter of counter width or clock-enable rate.

    assign red_pwm   = (counter < red_level);
    assign green_pwm = (counter < green_level);
    assign blue_pwm  = (counter < blue_level);

    Scale that structure to thousands of physical outputs and the number of comparators and pins becomes the real limitation. Multiplexed panels already require a row transaction, so a per-output continuous comparator is no longer the natural abstraction.

    BCM architecture

    BCM selects one bit from every active pixel and holds the resulting plane for its binary weight. The framebuffer supplies data while a shared scheduler owns plane order, latch timing, row address, output enable, and dwell. This is why BCM appears frequently in FPGA matrix controllers: adding pixels expands memory and shifting work without duplicating a full PWM counter for every channel.

    The least-significant plane is also the hardest. At eight bits it receives only 1/255 of the available illuminated cycle; at twelve bits it receives 1/4095. After row multiplexing and blanking, that slot may become shorter than the panel or driver can settle. Effective color depth is limited by real timing, not merely by framebuffer width.

    Update boundaries matter in both designs

    Writing a PWM level in the middle of a period can create one unusually long or short pulse. Updating BCM memory while a frame is being scanned can combine low planes from one image with high planes from another. The robust solution is the same at two scales: separate requested state from active state, and transfer ownership at a known boundary.

    • Latch PWM brightness when the counter wraps.
    • Swap BCM framebuffers after the final plane and final row.
    • Cross clock domains with a handshake, toggle, or asynchronous FIFO rather than synchronizing a multi-bit pixel bus one bit at a time.

    Camera behavior and plane ordering

    Two waveforms with the same average can photograph differently. Conventional edge-aligned PWM clusters one on interval. Basic BCM clusters long light or dark intervals according to plane order, especially around the most-significant bit. Reordering or splitting large planes can distribute energy across the frame and reduce visible bands, but it cannot repair an insufficient refresh budget. Measure with the target exposure time and rolling-shutter camera.

    Decision checklist

    • One status LED or a few RGB channels: choose conventional PWM.
    • Many parallel LED outputs with enough comparators: shared-counter PWM remains simple.
    • HUB75 or another row-multiplexed display: choose a bitplane scheduler such as BCM.
    • External grayscale driver: send intensity words and let the device perform its specified PWM.
    • Camera-critical lighting: prototype both timing patterns and measure the actual optical output.

    Test the invariant, not the picture

    A self-checking PWM test counts high clocks over one period. A BCM test counts illuminated base slots over all bitplanes. Both should equal the requested code under the chosen full-scale convention. The downloadable examples implement both tests and make the architectural difference directly observable.

    Continue the series

    Build each implementation in the parameterized PWM tutorial and the 8-bit BCM tutorial. For matrix timing, continue with the HUB75 row-scanning guide.


    FPGA LED controller tutorial series

    This article is part of the FPGA LED Controller Tutorials learning path. Continue with PWM vs. BCM, the HUB75 timing calculator, or SystemVerilog verification.

  • HUB75 Timing Calculator: Refresh Rate, Bit Depth, and Pixel Clock

    A HUB75 controller has to shift every column, pulse the latch, settle the row address, blank the outputs, and display every BCM plane for every row group. Use this calculator to estimate whether the requested panel geometry, pixel clock, scan ratio, and color depth can meet the target refresh rate.

    estimated frame refresh
    pixel clocks per frame
    LSB dwell
    frame time spent shifting

    Estimate assumes shifting and display dwell do not overlap. Confirm the exact panel timing and measure the finished hardware.

    What the calculator models

    For each row group and bitplane, the controller shifts one column word per pixel-clock tick and pays a fixed overhead for blanking, latching, and address settling. Binary-coded modulation then adds weighted display time. The estimate is:

    frame clocks = row groups × [bitplanes × (columns + overhead) + base dwell × (2bits − 1)]

    The maximum full-frame refresh is the pixel clock divided by that total. This is a planning model, not a substitute for the panel’s electrical timing. Some architectures shift the next plane while displaying the current one; that overlap improves the result and should be modeled separately.

    How to enter the scan ratio

    A 64×32 panel marked 1/16 scan has 16 row addresses. HUB75 normally shifts two RGB pixels in parallel, one in the upper half and one in the lower half, so the number of row groups is 16—not 32. A 1/32-scan panel uses 32 row addresses. Confirm the exact panel because identical connectors do not guarantee identical internal mapping.

    Worked example

    Consider a 64-column, 1/16-scan panel at a 20 MHz pixel clock with eight BCM bits, two overhead clocks per plane, and a base dwell of two clocks. The controller must pay the 64-column shift cost eight times for each row group, plus 255 weighted dwell units. Increasing the base dwell improves settling margin but lowers refresh. Increasing color depth adds another shift transaction and nearly doubles the weighted dwell range.

    Interpret the result conservatively

    • Below 100 Hz: visible flicker is likely and cameras will usually show severe bands.
    • 100–200 Hz: may look steady to the eye but remains camera-sensitive.
    • 200–400 Hz: a practical starting range for many installations.
    • Above 400 Hz: offers more camera margin, but optical behavior still depends on plane order and exposure.

    These are engineering heuristics, not universal thresholds. Validate the final hardware with an oscilloscope or photodiode, the intended brightness, and the actual camera exposure conditions.

    If the budget fails

    • Reduce color depth or use temporal dithering for the lowest bits.
    • Increase the shift clock within the panel and level-shifter limits.
    • Overlap shifting with the current display dwell using a separate output latch.
    • Split or reorder the most-significant planes without changing total weight.
    • Drive several panel chains in parallel instead of extending one serial chain.
    • Use a driver that generates grayscale locally when architecture permits.

    Continue the design

    Read HUB75 signals and row scanning for the safe transaction sequence, then the deeper refresh-rate derivation. The complete FPGA LED controller series connects those topics to BCM, framebuffers, and verification.


    FPGA LED controller tutorial series

    This article is part of the FPGA LED Controller Tutorials learning path. Continue with PWM vs. BCM, the HUB75 timing calculator, or SystemVerilog verification.

  • FPGA LED Controller Verification With SystemVerilog Assertions

    LED controllers are ideal candidates for self-checking simulation because their visible behavior reduces to countable timing invariants. Instead of approving a screenshot by eye, make the testbench fail when PWM duty cycle, BCM weight, HUB75 blanking, or frame-boundary ownership is wrong.

    Four properties worth automating

    1. A PWM code produces the expected number of high clocks in one complete period.
    2. A BCM code produces the same number of illuminated base slots as its numeric value.
    3. HUB75 output enable remains inactive during shifting, latching, and row-address changes.
    4. Requested framebuffer or brightness data becomes active only at the defined boundary.

    PWM: count the duty cycle

    For an 8-bit counter, sample exactly 256 clock positions after the new brightness becomes active. The saturation convention matters: this design maps 255 to continuously on, so its expected high count is 256 rather than 255.

    task automatic check_level(input logic [7:0] requested);
        int high_count = 0;
        int expected;
    
        level = requested;
        while (dut.active_level !== requested)
            @(negedge clk);
    
        repeat (256) begin
            @(negedge clk);
            high_count += pwm_out;
        end
    
        expected = (requested == 8'hff) ? 256 : requested;
        assert (high_count == expected)
            else $fatal("expected %0d, got %0d", expected, high_count);
    endtask

    Run boundary values before random values: 0, 1, midpoint, maximum minus one, and maximum. Off-by-one errors hide most effectively at rollover and full scale.

    BCM: sum illuminated slots

    An eight-bit BCM cycle contains 255 base slots. If every plane receives the correct binary weight, the total number of illuminated slots must equal the unsigned input value.

    on_slots = 0;
    repeat (255) begin
        on_slots += led_out;
        @(negedge clk);
    end
    
    assert (on_slots == requested)
        else $fatal("BCM %0d produced %0d on-slots",
                    requested, on_slots);

    This one invariant catches incorrect plane dwell, skipped planes, double-counted boundaries, and many update-timing errors. Add a second monitor that records per-plane durations when debugging which weight failed.

    HUB75: assert blanking around dangerous edges

    The most important safety property is simple: do not pulse the output latch while LEDs are enabled. A concurrent assertion can express it directly:

    property latch_only_while_blanked;
        @(posedge clk) disable iff (rst)
            lat |-> oe_n;
    endproperty
    
    assert property (latch_only_while_blanked);

    Also count shift-clock rising edges between latch pulses. For a 64-column chain, each completed transaction should contain exactly 64 edges unless the interface intentionally sends several chained panels. Assert that the row address changes only while output enable is inactive.

    Check atomic updates

    A multi-bit value cannot be treated like one asynchronous control bit. Synchronizing each bit separately allows the destination to observe a combination that never existed in the source domain. Use a handshake, asynchronous FIFO, or dual-port memory plus an ownership toggle. Then assert that active data remains stable throughout one PWM period, BCM cycle, or displayed frame.

    property active_level_stable_inside_period;
        @(posedge clk) disable iff (rst)
            !period_start |=> $stable(active_level);
    endproperty
    
    assert property (active_level_stable_inside_period);

    Avoid sampling races

    Testbench code that samples on the same edge as nonblocking assignments can accidentally observe the previous state. A simple educational testbench can drive and sample at the opposite edge. A larger verification environment should use clocking blocks or clearly defined sampling regions. Whatever convention you choose, document it and keep it consistent.

    Run it in continuous integration

    The downloadable project includes a Makefile and runs with Icarus Verilog. The same test intent can be adapted to Verilator or a vendor simulator. A CI job only needs to install the simulator, run make test, and retain waveforms when a test fails.

    unzip fpga-led-controller-examples.zip
    cd fpga-led-controller-examples
    make test

    What simulation cannot prove

    RTL simulation does not prove I/O voltage compatibility, signal integrity, power distribution, optical linearity, or that a panel meets its undocumented settling time. After the digital invariants pass, measure shift clock, latch, row address, output enable, and optical output on the real system. Verification narrows the hardware search space; it does not replace electrical validation.

    Continue with the PWM implementation, BCM engine, and HUB75 row scanner, or browse the complete tutorial series.


    FPGA LED controller tutorial series

    This article is part of the FPGA LED Controller Tutorials learning path. Continue with PWM vs. BCM, the HUB75 timing calculator, or SystemVerilog verification.

  • FPGA PWM LED Dimmer in SystemVerilog: A Parameterized Tutorial

    Pulse-width modulation is the simplest useful LED project on an FPGA—and one of the best ways to learn counters, clock enables, parameterized RTL, and self-checking simulation. This tutorial builds a reusable SystemVerilog PWM core, derives its frequency and resolution, and verifies that the output duty cycle matches the requested brightness.

    What we are building

    The design accepts an unsigned brightness value and produces one digital output. During each PWM period, the output is high for a number of clock cycles proportional to that value. An 8-bit input gives 256 possible codes; a 12-bit input gives 4,096.

    The circuit needs only a free-running counter and a comparator. The counter is the time axis. The brightness word is the threshold.

    Choose resolution from the clock budget

    For a B-bit counter clocked at Fclk, the PWM repetition rate is:

    FPWM = Fclk / 2B

    At 100 MHz, 8-bit PWM repeats at about 390.6 kHz, 12-bit PWM at 24.4 kHz, and 16-bit PWM at 1.53 kHz. More bits improve low-level control but reduce the carrier frequency. Pick the smallest resolution that meets the visual and camera requirements of the product.

    A parameterized PWM module

    module led_pwm #(
        parameter int BITS = 8
    ) (
        input  logic              clk,
        input  logic              rst,
        input  logic [BITS-1:0]   level,
        output logic              pwm_out
    );
    
        logic [BITS-1:0] counter;
    
        always_ff @(posedge clk) begin
            if (rst)
                counter <= '0;
            else
                counter <= counter + 1'b1;
        end
    
        always_comb begin
            // Saturate the top code so 8'hFF can mean fully on.
            if (&level)
                pwm_out = 1'b1;
            else
                pwm_out = (counter < level);
        end
    
    endmodule

    Without the saturation branch, code 255 in an 8-bit design produces 255 high clocks out of 256, or 99.61%. That behavior is mathematically clean, but many user interfaces expect the maximum code to mean continuously on. Make the choice explicit rather than discovering it during system testing.

    Avoid creating a new clock

    Do not divide the board clock in fabric and use the divided signal as a new clock for this block. Keep the logic in one clock domain. If the PWM rate must be lower, generate a one-cycle clock-enable pulse and update the counter only when that enable is asserted. This keeps the timing tools aware of one real clock tree and avoids a common source of skew and routing surprises.

    Update brightness without a runt pulse

    If software can change level at any time, an update in the middle of a period can lengthen or shorten one pulse. Human eyes may never notice, but a camera or measurement circuit can. Latch the requested level when the counter wraps:

    logic [BITS-1:0] active_level;
    
    always_ff @(posedge clk) begin
        if (rst)
            active_level <= '0;
        else if (counter == '0)
            active_level <= level;
    end

    Use active_level in the comparator. Every PWM period then uses exactly one brightness value.

    Self-check the duty cycle

    A waveform is useful, but an assertion is repeatable. The following test counts high samples over one complete 8-bit period.

    module tb_led_pwm;
        timeunit 1ns; timeprecision 1ps;
    
        logic clk = 0;
        logic rst = 1;
        logic [7:0] level;
        logic pwm_out;
        int high_count;
    
        always #5ns clk = ~clk;
    
        led_pwm #(.BITS(8)) dut (.*);
    
        initial begin
            level = 8'd64;
            repeat (2) @(posedge clk);
            rst = 0;
    
            // Align to the beginning of a PWM period.
            @(posedge clk iff dut.counter == 0);
            high_count = 0;
    
            repeat (256) begin
                @(posedge clk);
                high_count += pwm_out;
            end
    
            assert (high_count == 64)
                else $fatal("Expected 64 high clocks, got %0d", high_count);
    
            $finish;
        end
    endmodule

    Connect it to real hardware

    • Check whether the board LED is active-high or active-low. Invert pwm_out for an active-low connection.
    • Assign the correct package pin and I/O standard in the board constraint file.
    • Start at a mid-level code such as 64 or 128 so an oscilloscope shows both edges.
    • Probe the physical pin, not just the RTL simulation, and confirm the measured period equals the calculated value.
    • For external high-current LEDs, use a suitable transistor or constant-current driver. An FPGA pin is a logic signal, not a power supply.

    Where to go next

    This core is ideal for a status LED or one channel. An RGB fixture needs three instances. A large matrix needs a scheduler that shares time across rows and bitplanes; that is where binary-coded modulation becomes useful. For a comparison between externally scheduled bitplanes and driver-managed grayscale, see External PWM Bitframes vs. 16-Bit Grayscale.

    Official references

    Measured behavior in simulation

    Eight-bit FPGA PWM waveform showing level 64 high for 64 of 256 clock ticks
    At level 64, the output is high for exactly one quarter of the 8-bit period. Brightness changes are captured only at the boundary.

    The testbench checks codes 1, 64, 128, 254, and 255. The first four produce the same number of high clocks as the code. The explicit full-scale saturation rule maps 255 to all 256 clocks. Keeping that convention in the assertion prevents a future cleanup from silently changing what “fully on” means.

    Download the complete module and testbench

    The reference project includes synthesizable RTL, a self-checking SystemVerilog testbench, the BCM and HUB75 companion designs, and a Makefile tested with Icarus Verilog.

    unzip fpga-led-controller-examples.zip
    cd fpga-led-controller-examples
    make pwm

    Synthesis and timing checklist

    • Constrain the real input clock; do not rely on a simulation period alone.
    • Use a clock enable when slowing the counter rather than routing a fabric-generated clock.
    • Register the output when it must meet a strict external setup or hold relationship.
    • Confirm the LED polarity in the board schematic and constraints.
    • Measure the physical output with a scope at minimum, midpoint, and maximum codes.

    FPGA LED controller tutorial series

    This article is part of the FPGA LED Controller Tutorials learning path. Continue with PWM vs. BCM, the HUB75 timing calculator, or SystemVerilog verification.

  • Binary-Coded Modulation on FPGA: Build an 8-Bit BCM LED Engine

    Binary-coded modulation (BCM) produces LED brightness with weighted bitplanes instead of one comparator per channel. It is a natural fit for FPGAs driving many LEDs because the same scheduler can service an entire row, bank, or panel. This tutorial derives the timing, implements an 8-bit engine in SystemVerilog, and proves its most important property: an input value of N produces exactly N illuminated base-time slots per cycle.

    BCM in one equation

    An unsigned B-bit value is the sum of its set bits. Bit 0 is worth 1, bit 1 is worth 2, bit 2 is worth 4, and so on. BCM displays each bitplane for that same weight. The total cycle contains:

    1 + 2 + 4 + … + 2B-1 = 2B − 1 base slots

    For 8-bit BCM, the complete cycle is 255 base slots. A pixel value of 173 is binary 10101101, so it is on during planes worth 128, 32, 8, 4, and 1 slots. Those weights add back to 173.

    The scheduler

    The controller needs a plane index and a dwell counter. Plane 0 lasts one tick, plane 1 lasts two, and plane 7 lasts 128. When the dwell counter reaches the weight of the current plane, advance to the next plane.

    module bcm_led #(
        parameter int BITS = 8
    ) (
        input  logic            clk,
        input  logic            rst,
        input  logic [BITS-1:0] level,
        output logic            led_out,
        output logic            cycle_start
    );
    
        localparam int PW = $clog2(BITS);
    
        logic [PW-1:0]   plane;
        logic [BITS-1:0] dwell;
        logic [BITS-1:0] active_level;
        logic [BITS-1:0] plane_ticks;
    
        always_comb begin
            plane_ticks = {{(BITS-1){1'b0}}, 1'b1} << plane;
            led_out      = active_level[plane];
            cycle_start  = (plane == 0) && (dwell == 0);
        end
    
        always_ff @(posedge clk) begin
            if (rst) begin
                plane        <= '0;
                dwell        <= '0;
                active_level <= '0;
            end else if (dwell == plane_ticks - 1'b1) begin
                dwell <= '0;
                if (plane == BITS-1) begin
                    plane        <= '0;
                    active_level <= level;
                end else begin
                    plane <= plane + 1'b1;
                end
            end else begin
                dwell <= dwell + 1'b1;
            end
        end
    endmodule

    The requested level is copied only at the end of a complete cycle. That prevents one cycle from containing lower planes from an old value and upper planes from a new one.

    Choose the base tick

    If the base-tick frequency is Fbase, the BCM cycle rate is Fbase divided by 255 for 8 bits. A 1 MHz base tick produces about 3.92 kHz. In a multiplexed display, divide again by the number of row groups and include shifting, latching, and blanking overhead.

    Do not assume the FPGA clock itself can be the base tick. A 10 ns least-significant plane may be shorter than the external driver, level shifter, wiring, or LED output can settle. Generate a clock-enable pulse that establishes a practical minimum slot.

    A self-checking BCM test

    int on_slots;
    
    task automatic check_level(input logic [7:0] requested);
        level = requested;
    
        // Wait for the new value to be captured, then for its cycle to begin.
        @(posedge clk iff cycle_start);
        @(posedge clk iff cycle_start);
    
        on_slots = 0;
        repeat (255) begin
            @(posedge clk);
            on_slots += led_out;
        end
    
        assert (on_slots == requested)
            else $fatal("BCM %0d produced %0d on-slots",
                        requested, on_slots);
    endtask

    Run the task for boundary values 0, 1, 127, 128, 254, and 255, plus randomized values. The equality between the code and total illuminated slots is a compact specification of correct BCM behavior.

    Plane order is a design choice

    Least-significant-bit first is easy to understand, but it places the longest dark or light interval at the end. You can reorder planes, split the most-significant plane into several smaller visits, or interleave rows to distribute energy more evenly. The sum of dwell times must remain unchanged. Reordering helps camera behavior and peak-current distribution, but it cannot rescue a refresh budget that is fundamentally too small.

    Scaling from one LED to a matrix

    • Store one brightness word per color channel in a framebuffer.
    • For the active plane, transmit the corresponding bit from every pixel in the row.
    • Blank outputs, latch the shifted bits, select the row, and enable the plane for its weighted dwell.
    • Swap framebuffers only when the full frame completes.
    • Verify the refresh budget before increasing color depth.

    BCM and conventional PWM deliver the same average duty cycle; they organize time differently. For the architectural tradeoff between external bitplanes and drivers that generate grayscale internally, read External PWM Bitframes vs. 16-Bit Grayscale.

    Official references

    The complete 8-bit timeline

    Eight-bit BCM timeline with planes weighted 1, 2, 4, 8, 16, 32, 64, and 128 slots
    Every 8-bit BCM cycle contains 255 base slots. A pixel is enabled only in the planes corresponding to set bits.

    The diagram makes the main engineering constraint visible: the most-significant plane occupies roughly half of the cycle while the least-significant plane gets one base slot. The minimum usable slot therefore limits effective depth. If shifting, blanking, or output settling cannot fit around that slot, the framebuffer may say eight bits while the optics deliver fewer.

    Download and prove the scheduler

    The downloadable test drives 1, 64, 127, 128, 254, and 255. It counts exactly 255 samples for each active value and fails unless illuminated slots equal the requested unsigned code.

    unzip fpga-led-controller-examples.zip
    cd fpga-led-controller-examples
    make bcm

    Implementation checklist

    • Define whether a base slot includes or excludes blanking and latch overhead.
    • Capture a new level only at a full BCM-cycle boundary.
    • Verify every plane duration independently as well as the total on-slot sum.
    • Calculate refresh after multiplying by the row-group count.
    • Measure the optical waveform when plane order matters to cameras.

    FPGA LED controller tutorial series

    This article is part of the FPGA LED Controller Tutorials learning path. Continue with PWM vs. BCM, the HUB75 timing calculator, or SystemVerilog verification.

  • Gamma-Corrected LED Dimming on FPGA With a Lookup Table

    A mathematically linear PWM duty cycle does not look like a linear brightness ramp. The lower codes appear crowded together and the upper codes consume much of the electrical range. Gamma correction fixes the control curve before values reach the PWM or BCM engine. This tutorial generates a lookup table, infers it as ROM, and explains how to validate the result.

    Why the ramp looks wrong

    PWM controls average optical power approximately in proportion to duty cycle, but perceived brightness is nonlinear. A practical starting curve is output = inputγ, with γ around 2.0 to 2.4. It is a design starting point, not a universal constant: LED efficiency, optics, ambient light, and the desired creative response all matter.

    Generate the ROM contents

    # gamma_lut.py
    gamma = 2.2
    input_bits = 8
    output_bits = 12
    top = (1 << output_bits) - 1
    
    with open("gamma_8_to_12.mem", "w") as f:
        for code in range(1 << input_bits):
            x = code / ((1 << input_bits) - 1)
            corrected = round((x ** gamma) * top)
            f.write(f"{corrected:03x}
    ")

    The generated file has 256 hexadecimal words. Code 0 maps to 0 and code 255 maps to 4095. Keeping 12 output bits preserves small changes near black even when the external interface is only 8 bits.

    Infer the table in SystemVerilog

    module gamma_lut (
        input  logic         clk,
        input  logic [7:0]   linear_level,
        output logic [11:0]  pwm_level
    );
        logic [11:0] rom [0:255];
    
        initial $readmemh("gamma_8_to_12.mem", rom);
    
        always_ff @(posedge clk)
            pwm_level <= rom[linear_level];
    endmodule

    The registered read gives the table a one-clock latency and usually makes block-memory inference easier. Pipeline the associated pixel address and color-channel metadata by the same amount.

    Validate more than the endpoints

    • Assert that the table is monotonic.
    • Assert exact endpoints at zero and full scale.
    • Plot input code against corrected output before synthesis.
    • Display a slow ramp and look for plateaus, jumps, or low-level flicker.
    • Measure each RGB channel separately; identical digital curves do not guarantee a neutral white.

    Calibration and dithering

    Gamma correction shapes the response; dot correction fixes channel-to-channel differences; white-balance gains align red, green, and blue. Apply those stages deliberately and keep enough intermediate precision to avoid banding. If the hardware has fewer PWM bits than the corrected value, temporal dithering can alternate adjacent codes across frames, but only when the refresh rate is high and the frame sequence is deterministic.

    Official references


    FPGA LED controller tutorial series

    This article is part of the FPGA LED Controller Tutorials learning path. Continue with PWM vs. BCM, the HUB75 timing calculator, or SystemVerilog verification.

  • Ghost-Free Seven-Segment Display Controller on FPGA

    A multiplexed seven-segment display looks simple until dim neighboring segments glow, digits smear during updates, or brightness changes with the number being shown. The fix is a disciplined scan sequence: blank, change the segment pattern, select the next digit, then enable light. This tutorial implements that sequence and adds per-display PWM brightness.

    Understand the electrical polarity

    The Basys 3 uses a four-digit common-anode display. Its digit enables and segment cathodes are active-low: drive one anode low to select a digit, and drive a segment line low to illuminate that segment. Other boards may be common-cathode, so put polarity conversion at the top level instead of burying it in the scan engine.

    Choose a scan rate

    If the controller visits four digits at a 4 kHz scan-event rate, each digit refreshes at 1 kHz. That leaves comfortable margin for human vision and many cameras. The segment duty cycle is about one quarter before global brightness PWM is applied.

    Decode hexadecimal digits

    function automatic logic [6:0] hex_segments(input logic [3:0] x);
        case (x)
            4'h0: hex_segments = 7'b1000000;
            4'h1: hex_segments = 7'b1111001;
            4'h2: hex_segments = 7'b0100100;
            4'h3: hex_segments = 7'b0110000;
            4'h4: hex_segments = 7'b0011001;
            4'h5: hex_segments = 7'b0010010;
            4'h6: hex_segments = 7'b0000010;
            4'h7: hex_segments = 7'b1111000;
            4'h8: hex_segments = 7'b0000000;
            4'h9: hex_segments = 7'b0010000;
            4'hA: hex_segments = 7'b0001000;
            4'hB: hex_segments = 7'b0000011;
            4'hC: hex_segments = 7'b1000110;
            4'hD: hex_segments = 7'b0100001;
            4'hE: hex_segments = 7'b0000110;
            default: hex_segments = 7'b0001110;
        endcase
    endfunction

    Blank before switching

    always_ff @(posedge clk) begin
        if (scan_tick) begin
            if (!phase) begin
                an_n  <= 4'b1111;       // all digits off
                digit <= digit + 1'b1;
                seg_n <= hex_segments(value[digit*4 +: 4]);
                phase <= 1'b1;
            end else begin
                an_n  <= ~(4'b0001 << digit);
                phase <= 1'b0;
            end
        end
    end

    The explicit blank interval prevents the old segment pattern from appearing on the new digit. Because nonblocking assignments use old right-hand-side values, calculate the next digit in a temporary signal or update the index and pattern in separate states in production RTL.

    Add brightness without breaking the scan

    Keep scanning at a fixed rate and gate the selected anode with a higher-frequency PWM enable. Do not slow the scan to dim the display; that creates flicker. Latch brightness at a PWM boundary so one pulse cannot be truncated by a mid-period write.

    Hardware checklist

    • Confirm common-anode versus common-cathode polarity.
    • Verify the A-through-G bit order against the schematic.
    • Blank all digits during reset.
    • Probe an enable and one segment together to confirm non-overlap.
    • Use the board manufacturer’s pin constraints and I/O voltage.

    Official reference

    Digilent Basys 3 FPGA Board Reference Manual documents the common-anode display, active polarities, and package pins.


    FPGA LED controller tutorial series

    This article is part of the FPGA LED Controller Tutorials learning path. Continue with PWM vs. BCM, the HUB75 timing calculator, or SystemVerilog verification.

  • HUB75 RGB LED Matrix Controller in FPGA: Signals and Row Scanning

    HUB75-style RGB panels have no framebuffer of their own. The controller must continuously shift color bits, latch them, select a row address, and control output enable. An FPGA is a strong match because those deadlines can be expressed as a deterministic state machine. This tutorial explains the interface and builds the safe row-scan sequence.

    The signal groups

    • R0/G0/B0 and R1/G1/B1: two RGB pixels shifted in parallel, usually for an upper and lower row.
    • CLK: shifts one column of color data.
    • LAT or STB: transfers the shift register into the output latch.
    • OE: output enable, commonly active-low.
    • A–E: binary row address; the count depends on the panel scan ratio.

    Panel naming is not a complete electrical specification. Verify the connector, logic voltage, scan ratio, channel order, and timing on the exact unit. Some panels swap green and blue or use a nonstandard address arrangement.

    One safe row transaction

    1. Drive OE inactive to blank the outputs.
    2. Shift one bit per color channel for every column.
    3. Pulse LAT while outputs remain blanked.
    4. Set the row address and allow it to settle.
    5. Drive OE active for the desired dwell time.
    6. Blank again before the next latch or row transition.

    Blanking around latch and address changes is the main defense against ghosting. The interval can be short, but it must be explicit and repeatable.

    A row-scan state machine

    typedef enum logic [2:0] {
        BLANK, SHIFT_LOW, SHIFT_HIGH, LATCH, SET_ROW, DISPLAY
    } state_t;
    
    always_ff @(posedge clk) begin
        case (state)
            BLANK: begin
                oe_n  <= 1'b1;
                col   <= '0;
                state <= SHIFT_LOW;
            end
            SHIFT_LOW: begin
                hub_clk <= 1'b0;
                {r1,g1,b1,r0,g0,b0} <= pixel_bits;
                state <= SHIFT_HIGH;
            end
            SHIFT_HIGH: begin
                hub_clk <= 1'b1;
                if (col == COLUMNS-1) state <= LATCH;
                else begin col <= col + 1'b1; state <= SHIFT_LOW; end
            end
            LATCH: begin
                hub_clk <= 1'b0;
                lat     <= 1'b1;
                state   <= SET_ROW;
            end
            SET_ROW: begin
                lat      <= 1'b0;
                row_addr <= row;
                state    <= DISPLAY;
            end
            DISPLAY: begin
                oe_n <= 1'b0;
                if (dwell_done) begin oe_n <= 1'b1; state <= BLANK; end
            end
        endcase
    end

    Production logic should register memory output ahead of the shift edge and constrain the external clock and data relationship. The conceptual FSM above makes signal ownership and blanking easy to review.

    First-light test pattern

    Start with one row and solid primary colors. Then display vertical stripes that change every column, followed by a single moving pixel. These patterns reveal channel swaps, column direction, row-address errors, and off-by-one clock counts much faster than a photograph.

    Power and logic levels

    A panel can draw several amperes at 5 V. Power it through its intended connector with appropriate wiring and ground return; do not route LED current through an FPGA board header. Many controller boards use level shifting between 3.3 V FPGA I/O and 5 V panel logic. Confirm the exact panel requirements before connection.

    Panel references

    Timing diagram: blank before changing state

    HUB75 timing diagram showing outputs blanked during shift, latch, and row address changes
    OE_N stays high while data shifts, LAT pulses, and the row address settles. Only the display dwell enables the LEDs.

    A controller that follows this ownership sequence makes ghosting much easier to reason about. The output latch never changes while LEDs are visible, and the address bus receives a defined settling interval before output enable becomes active.

    Self-check the transaction

    The reference testbench asserts that LAT never pulses while outputs are enabled, counts exactly one shift-clock rising edge per column before each latch, and observes the row address wrapping after the configured number of groups.

    unzip fpga-led-controller-examples.zip
    cd fpga-led-controller-examples
    make hub75

    Calculate the refresh budget before adding color

    The row transaction is repeated for every bitplane and every row group. Use the interactive HUB75 timing calculator to see how panel width, scan ratio, pixel clock, overhead, and BCM depth change the maximum full-frame refresh rate.


    FPGA LED controller tutorial series

    This article is part of the FPGA LED Controller Tutorials learning path. Continue with PWM vs. BCM, the HUB75 timing calculator, or SystemVerilog verification.

  • HUB75 Refresh-Rate Math: BCM Color Depth Without Flicker

    A HUB75 controller can be logically correct and still produce dim output, visible flicker, missing low bits, or camera banding. The cause is often an impossible timing budget. This tutorial turns panel geometry, scan ratio, bit depth, shift time, blanking, and BCM dwell into one refresh-rate calculation you can run before writing the RTL.

    Define the costs

    • C: columns shifted for each row pair.
    • R: row addresses per complete frame, such as 16 for a 1:16-scan panel.
    • B: BCM bitplanes.
    • S: system clocks per shifted column.
    • O: blank, latch, and address-settling clocks per plane.
    • T: system clocks in one least-significant dwell unit.

    The conservative frame equation

    If shifting and display time do not overlap, one row address costs:

    row clocks = B(CS + O) + T(2B − 1)

    The full refresh rate is:

    Frefresh = Fclk / [R × row clocks]

    Worked 64×32 example

    Assume a 100 MHz FPGA clock, 64 columns, 1:16 scan, 12 bitplanes, two clocks per shifted column, 8 clocks of overhead per plane, and a 10-clock base dwell. Shifting and overhead cost 12 × (128 + 8) = 1,632 clocks. Weighted display time costs 10 × 4,095 = 40,950 clocks. One row address therefore costs 42,582 clocks and a frame costs 681,312 clocks. The resulting full refresh is about 147 Hz.

    That may look acceptable to an eye while still showing bands on a rolling-shutter camera. It also leaves little room for longer blanking, memory stalls, or a slower external shift clock.

    Ways to recover timing margin

    • Reduce bit depth. Ten bits has 1,023 dwell units instead of 4,095.
    • Shift the next plane while the current plane is visible, if the panel latches allow it.
    • Use both clock edges only when the interface and timing constraints genuinely support it.
    • Split a wide display across parallel data paths.
    • Use temporal dithering to recover apparent depth across frames.
    • Keep blanking long enough for clean output; do not “optimize” by reintroducing ghosting.

    Effective bits matter more than nominal bits

    If the least-significant plane is shorter than driver propagation and settling time, it is not a real brightness bit. Measure the smallest repeatable pulse at the panel output and choose T from that result. A stable 10-bit system usually looks better than a nominal 12-bit system whose lower planes vanish or jitter.

    Verify the scheduler

    • Count exactly C shift clocks before every latch.
    • Assert that OE is inactive during latch and row changes.
    • Measure each plane dwell and verify powers-of-two weighting.
    • Count row visits per frame and frames per second in simulation.
    • Compare simulated timing with a logic-analyzer capture on hardware.

    Do the arithmetic first, then choose the architecture. It is much cheaper to change B, T, or the number of parallel lanes in a spreadsheet than after the serializer and framebuffer are complete.


    FPGA LED controller tutorial series

    This article is part of the FPGA LED Controller Tutorials learning path. Continue with PWM vs. BCM, the HUB75 timing calculator, or SystemVerilog verification.

  • Center-Aligned vs. Edge-Aligned PWM on an FPGA

    Two PWM engines can have the same frequency and duty cycle yet behave differently at their switching edges. Edge-aligned PWM places every rising edge at the period boundary. Center-aligned PWM moves both edges symmetrically around the center of the pulse. This tutorial implements both forms, explains the frequency math, and shows how to update duty cycle without producing runt pulses.

    Edge-aligned PWM

    An up-counter starts at zero and the output is active while the counter is below the duty value. The carrier period is 2B clocks for a B-bit counter.

    always_ff @(posedge clk) begin
        counter <= counter + 1'b1;
        if (counter == '0)
            active_duty <= requested_duty;
    end
    
    assign pwm_edge = (counter < active_duty);

    Every active pulse begins at counter zero. That deterministic edge is convenient for triggering an ADC or logic analyzer, but simultaneous channels can switch together and create a larger instantaneous current step.

    Center-aligned PWM

    A triangular carrier counts up and then down. The compare threshold produces a pulse centered in the carrier cycle.

    logic [BITS-1:0] carrier;
    logic            counting_up;
    
    always_ff @(posedge clk) begin
        if (rst) begin
            carrier    <= '0;
            counting_up <= 1'b1;
        end else if (counting_up) begin
            if (carrier == {BITS{1'b1}}) begin
                counting_up <= 1'b0;
                carrier <= carrier - 1'b1;
            end else carrier <= carrier + 1'b1;
        end else begin
            if (carrier == '0) begin
                counting_up <= 1'b1;
                active_duty <= requested_duty;
                carrier <= carrier + 1'b1;
            end else carrier <= carrier - 1'b1;
        end
    end
    
    assign pwm_center = (carrier < active_duty);

    The triangular period is approximately twice the edge-aligned period for the same counter width, so compare frequencies only after accounting for the up-and-down traversal. Endpoint handling determines whether the exact count is 2(2B−1) or another nearby value; specify it in the testbench.

    When center alignment helps

    • Symmetric edges are useful in motor-control and power-conversion timing.
    • Multiple channels can be phase shifted to distribute switching current.
    • Spectral energy differs from edge-aligned modulation, which may ease a particular EMI problem.
    • For a simple status LED, edge-aligned PWM is usually smaller and easier to verify.

    Verification checklist

    • Measure the exact carrier period in clocks.
    • Test duty values 0, 1, midscale, maximum−1, and maximum.
    • Change the requested duty in the middle of a pulse and prove the active value changes only at the boundary.
    • Assert complementary outputs never overlap if dead time is added.

    Do not infer a gated fabric clock to implement either version. Use one real clock plus clock enables, and let the timing tools analyze the complete datapath.

    Intel’s FPGA design recommendations provide broader guidance on clocking and RTL structure.


    FPGA LED controller tutorial series

    This article is part of the FPGA LED Controller Tutorials learning path. Continue with PWM vs. BCM, the HUB75 timing calculator, or SystemVerilog verification.