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 WIDTH bits. No explicit wrap logic is needed; that is the whole point of sizing the counter to the period.
  • The compare counter < duty is the heart of the circuit. During the first duty counts 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:

WIDTHSteps per periodFinest duty step
4166.25%
82560.39%
1010240.098%
1240960.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 strict counter < 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 duty mid-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 duty port 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_out unreset 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 WIDTH for 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.

Have questions about this topic?
Share your question in comments or talk to our mentor team for batch guidance.
▶ Try it in the ChipXpert Lab

Open this design in the free in-browser simulator — edit, compile and see the waveform/log instantly.

Run it in the Lab

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
Next batch starts 18th November 2026
Start your VLSI career with ChipXpert

Live online and classroom batches in Hyderabad & Bengaluru. Fill in your details and a counsellor will call you back.

  • Real EDA tools in your browser: industry-standard EDAReal EDA tools in your browser
  • Recorded sessions on the elearn portalRecorded sessions
  • Placement assistance: resume, mock interviewsPlacement assistance
  • Merit scholarship up to 60% · EMI optionsScholarship up to 60%
Hi! Ask me about courses, fees, batches or discounts. ×
BENGALURU
HYDERABAD