PWM Verilog Code: Counter-Compare Generator + Testbench
Pulse-width modulation (PWM) is how a digital circuit fakes an analog level: it switches a signal on and off quickly and varies the fraction of each period the signal stays high. That fraction, the duty cycle, is what sets LED brightness, motor speed, heater power, or a cheap single-pin DAC. This tutorial builds a clean, synthesizable block of PWM Verilog code from scratch, explains every line, then drives it with a testbench that sweeps the duty cycle so you can watch the output track the request.
The design uses the classic counter-compare architecture. It is tiny, needs no multiplier, and maps directly onto any FPGA or ASIC flow. Everything below is portable Verilog-2005 and runs as-is under Icarus Verilog with iverilog -g2005 -o sim pwm_basic.v && vvp sim.
The counter-compare design
A counter-compare PWM needs only two pieces of state: a free-running counter that defines the period, and a comparison against a duty threshold that defines the pulse width. If the counter is WIDTH bits wide it counts from 0 to 2**WIDTH – 1 and then wraps, so one PWM period is exactly 2**WIDTH clock cycles. The output is driven high whenever counter < duty, which means the pulse stays high for duty cycles out of every 2**WIDTH. Making WIDTH a parameter lets the same module serve a coarse 4-bit LED dimmer or a fine 12-bit motor driver.
// pwm_basic.v -- parameterized counter-compare PWM generator
module pwm_generator #(
parameter WIDTH = 8
)(
input wire clk,
input wire rst_n,
input wire [WIDTH-1:0] duty,
output reg pwm_out
);
reg [WIDTH-1:0] counter;
// free-running counter, wraps naturally at 2**WIDTH
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
counter <= {WIDTH{1'b0}};
else
counter <= counter + 1'b1;
end
// registered compare: output is high while counter is below the threshold
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
pwm_out <= 1'b0;
else
pwm_out <= (counter < duty);
end
endmodule
// ---------------- testbench ----------------
module tb_pwm_basic;
localparam WIDTH = 4; // 16-step period: short for simulation
localparam PERIOD = (1 << WIDTH);
reg clk;
reg rst_n;
reg [WIDTH-1:0] duty;
wire pwm_out;
integer i;
integer high_count;
pwm_generator #(.WIDTH(WIDTH)) dut (
.clk(clk), .rst_n(rst_n), .duty(duty), .pwm_out(pwm_out)
);
initial clk = 1'b0;
always #5 clk = ~clk; // 10-time-unit clock
initial begin
duty = 4'd4; // request 4 / 16 = 25%
rst_n = 1'b0;
@(posedge clk); @(posedge clk);
rst_n = 1'b1; // release reset
high_count = 0;
for (i = 0; i < PERIOD; i = i + 1) begin
@(posedge clk);
if (pwm_out) high_count = high_count + 1;
$display("t=%0t cycle=%0d pwm_out=%b", $time, i, pwm_out);
end
$display("requested duty = %0d/%0d, measured high cycles = %0d/%0d",
duty, PERIOD, high_count, PERIOD);
$finish;
end
endmodule
t=25 cycle=0 pwm_out=1 t=35 cycle=1 pwm_out=1 t=45 cycle=2 pwm_out=1 t=55 cycle=3 pwm_out=1 t=65 cycle=4 pwm_out=0 t=75 cycle=5 pwm_out=0 t=85 cycle=6 pwm_out=0 t=95 cycle=7 pwm_out=0 t=105 cycle=8 pwm_out=0 t=115 cycle=9 pwm_out=0 t=125 cycle=10 pwm_out=0 t=135 cycle=11 pwm_out=0 t=145 cycle=12 pwm_out=0 t=155 cycle=13 pwm_out=0 t=165 cycle=14 pwm_out=0 t=175 cycle=15 pwm_out=0 requested duty = 4/16, measured high cycles = 4/16 /var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmp4_9whb7f/pwm_basic.v:63: $finish called at 175 (1s)
How the code works
The module is deliberately small, so every element earns its place:
- The counter increments every clock and overflows back to zero on its own because it is exactly
WIDTHbits. No explicit wrap logic is needed; that is the whole point of sizing the counter to the period. - The compare
counter < dutyis the heart of the circuit. During the firstdutycounts of each period the expression is true and the output is high; for the rest it is low. - Registered output. Instead of wiring the comparator straight to a pin, the result is clocked into
pwm_out. This removes comparator glitches and gives a clean, timing-friendly edge aligned to the clock. It costs one cycle of latency, which is irrelevant for PWM. - Active-low reset forces both the counter and the output to a known state, which keeps synthesis from inferring latches and makes power-up deterministic.
Resolution is set entirely by WIDTH. The finest duty step you can request is one part in 2**WIDTH:
| WIDTH | Steps per period | Finest duty step |
|---|---|---|
| 4 | 16 | 6.25% |
| 8 | 256 | 0.39% |
| 10 | 1024 | 0.098% |
| 12 | 4096 | 0.024% |
There is a trade-off: a wider counter gives finer control but a lower PWM frequency for a fixed clock, because the period is 2**WIDTH clocks. The simulation above uses WIDTH = 4 only to keep the run short; it reports 4 high cycles out of 16, exactly the 25% requested.
Sweeping duty cycles in the testbench
A single duty value proves little. The real test is to drive a range of duty settings and confirm the measured on-time matches each request. The testbench below loads five values (0, 4, 8, 12, 15), and for each one counts how many clocks the output is high across one full 16-cycle period. Because a free-running mod-16 counter visits every value 0 to 15 exactly once per period, exactly duty of those cycles satisfy counter < duty, so the measured count should equal the input regardless of phase.
// pwm_sweep.v -- parameterized PWM generator with a duty-cycle sweep testbench
module pwm_generator #(
parameter WIDTH = 8
)(
input wire clk,
input wire rst_n,
input wire [WIDTH-1:0] duty,
output reg pwm_out
);
reg [WIDTH-1:0] counter;
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
counter <= {WIDTH{1'b0}};
else
counter <= counter + 1'b1;
end
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
pwm_out <= 1'b0;
else
pwm_out <= (counter < duty);
end
endmodule
// ---------------- testbench ----------------
module tb_pwm_sweep;
localparam WIDTH = 4;
localparam PERIOD = (1 << WIDTH); // 16 clocks per PWM period
reg clk;
reg rst_n;
reg [WIDTH-1:0] duty;
wire pwm_out;
integer i, k;
integer high_count;
reg [WIDTH-1:0] test_duty [0:4];
pwm_generator #(.WIDTH(WIDTH)) dut (
.clk(clk), .rst_n(rst_n), .duty(duty), .pwm_out(pwm_out)
);
initial clk = 1'b0;
always #5 clk = ~clk;
initial begin
test_duty[0] = 4'd0;
test_duty[1] = 4'd4;
test_duty[2] = 4'd8;
test_duty[3] = 4'd12;
test_duty[4] = 4'd15;
duty = 4'd0;
rst_n = 1'b0;
@(posedge clk); @(posedge clk);
rst_n = 1'b1;
$display("duty_in | high_cycles | measured_duty_%%");
for (k = 0; k < 5; k = k + 1) begin
duty = test_duty[k];
@(posedge clk); // let the new duty value settle
high_count = 0;
for (i = 0; i < PERIOD; i = i + 1) begin
@(posedge clk);
if (pwm_out) high_count = high_count + 1;
end
$display(" %2d | %2d | %3d",
duty, high_count, (high_count * 100) / PERIOD);
end
$finish;
end
endmodule
duty_in | high_cycles | measured_duty_% 0 | 0 | 0 4 | 4 | 25 8 | 8 | 50 12 | 12 | 75 15 | 15 | 93 /var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmpmwmffcdk/pwm_sweep.v:71: $finish called at 865 (1s)
The log shows a straight-line relationship: 0, 4, 8, 12, and 15 map to 0%, 25%, 50%, 75%, and 93%. Notice that the top value, 15 out of 16, is 93% rather than 100%, which leads directly to the most common counter-compare surprise discussed below.
Updating the duty without glitches
In the simple module, changing duty partway through a period can shorten or lengthen the pulse that is already in progress, producing a runt pulse. For driving motors or audio that distortion matters. The fix is to double-buffer: keep a live input duty and an internal duty_latched that is only sampled when the counter is about to wrap. Every output period is then built from a single, stable value.
// pwm_sync.v -- PWM generator that latches a new duty only at the period boundary
module pwm_sync #(
parameter WIDTH = 8
)(
input wire clk,
input wire rst_n,
input wire [WIDTH-1:0] duty,
output reg pwm_out
);
reg [WIDTH-1:0] counter;
reg [WIDTH-1:0] duty_latched;
wire period_end = (counter == {WIDTH{1'b1}});
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
counter <= {WIDTH{1'b0}};
else
counter <= counter + 1'b1;
end
// double-buffer: sample the live duty only when the counter is about to wrap
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
duty_latched <= {WIDTH{1'b0}};
else if (period_end)
duty_latched <= duty;
end
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
pwm_out <= 1'b0;
else
pwm_out <= (counter < duty_latched);
end
endmodule
// ---------------- testbench ----------------
module tb_pwm_sync;
localparam WIDTH = 4;
reg clk;
reg rst_n;
reg [WIDTH-1:0] duty;
wire pwm_out;
integer periods;
pwm_sync #(.WIDTH(WIDTH)) dut (
.clk(clk), .rst_n(rst_n), .duty(duty), .pwm_out(pwm_out)
);
initial clk = 1'b0;
always #5 clk = ~clk;
initial begin
rst_n = 1'b0;
duty = 4'd4; // start by requesting 25%
periods = 0;
@(posedge clk); @(posedge clk);
rst_n = 1'b1;
$display("t cnt duty_in duty_latched pwm");
repeat (50) begin
@(posedge clk);
#1; // settle after non-blocking updates
$display("%4t %2d %2d %2d %b",
$time, dut.counter, duty, dut.duty_latched, pwm_out);
if (dut.counter == 4'd15)
periods = periods + 1;
// request 75% midway through the second period:
// it must NOT take effect until the next wrap
if (periods == 1 && dut.counter == 4'd6)
duty = 4'd12;
end
$finish;
end
endmodule
t cnt duty_in duty_latched pwm 26 2 4 0 0 36 3 4 0 0 46 4 4 0 0 56 5 4 0 0 66 6 4 0 0 76 7 4 0 0 86 8 4 0 0 96 9 4 0 0 106 10 4 0 0 116 11 4 0 0 126 12 4 0 0 136 13 4 0 0 146 14 4 0 0 156 15 4 0 0 166 0 4 4 0 176 1 4 4 1 186 2 4 4 1 196 3 4 4 1 206 4 4 4 1 216 5 4 4 0 226 6 4 4 0 236 7 12 4 0 246 8 12 4 0 256 9 12 4 0 266 10 12 4 0 276 11 12 4 0 286 12 12 4 0 296 13 12 4 0 306 14 12 4 0 316 15 12 4 0 326 0 12 12 0 336 1 12 12 1 346 2 12 12 1 356 3 12 12 1 366 4 12 12 1 376 5 12 12 1 386 6 12 12 1 396 7 12 12 1 406 8 12 12 1 416 9 12 12 1 426 10 12 12 1 436 11 12 12 1 446 12 12 12 1 456 13 12 12 0 466 14 12 12 0 476 15 12 12 0 486 0 12 12 0 496 1 12 12 1 506 2 12 12 1 516 3 12 12 1 /var/folders/l0/gs5hnkm56g54bmxg32r6nnwr0000gn/T/tmp9rge_8x2/pwm_sync.v:74: $finish called at 516 (1s)
Follow duty_latched in the log. The input jumps from 4 to 12 while cnt is 6, but duty_latched holds its old value until cnt reaches 15 and wraps. Only then does the new 12 take effect, so the pulse currently on the air is never truncated.
Common mistakes
- Expecting 100% duty. With a
WIDTH-bit counter and a strictcounter < duty, the maximum is 2**WIDTH – 1 high cycles out of 2**WIDTH, e.g. 255/256. For a true 0-100% range, add one extra counter bit, or special-case the maximum duty value to force the output high for the whole period. - Changing duty asynchronously. Writing
dutymid-period in the simple module creates runt pulses. Use the latched design when the duty source is not already period-aligned. - Blocking assignments in clocked blocks. Use non-blocking (
<=) for every register driven on a clock edge; mixing in blocking (=) invites simulation-versus-synthesis mismatches. - Mismatched bus widths. The
dutyport must be the same width as the counter. A narrower input silently zero-extends and limits your maximum duty; a wider one wastes bits and can warn during synthesis. - No reset on the output register. Leaving
pwm_outunreset can infer a latch or produce an unknown level at power-up; always give it a defined reset value. - Confusing PWM frequency with resolution. Both are tied to
WIDTHfor a fixed clock. If you need more steps without dropping the frequency, raise the clock as well.
Quick check
With WIDTH = 8 and duty = 64, what duty cycle does the output produce?
64 of 256 cycles are high, so 64/256 = 25%.
Why can an 8-bit counter-compare PWM never reach exactly 100%?
The counter takes 256 distinct values per period and the compare is strictly less-than, so the largest duty (255) is high for 255 of 256 cycles, about 99.6%. True 100% needs an extra bit or a special case that forces the output high at the maximum duty value.
What problem does latching duty only at the period boundary solve?
It prevents runt or stretched pulses when the duty value changes partway through a period, because every output period is built from a single stable value sampled at the wrap.
In the sweep testbench, why does measuring over exactly 2**WIDTH clocks give the exact requested duty regardless of start phase?
A free-running mod-2**WIDTH counter visits each value 0 to 2**WIDTH-1 exactly once per period, so in any window of that length precisely duty of the cycles satisfy counter < duty.
Share your question in comments or talk to our mentor team for batch guidance.
Open this design in the free in-browser simulator — edit, compile and see the waveform/log instantly.
Learn Verilog with mentors, labs and placement support
This free series is maintained by the ChipXpert training team in Hyderabad & Bengaluru. Go deeper with instructor-led courses on real EDA tools.
Explore VLSI Courses