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_outfor 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
- Intel Quartus Prime Pro Edition User Guide: Design Recommendations
- AMD Vivado Design Suite User Guide: Synthesis (UG901)
Measured behavior in simulation

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.
Leave a Reply