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.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *