Stopwatch in Verilog (FSM + BCD Counters) with Code

A digital stopwatch counts elapsed time in minutes, seconds and hundredths of a second (MM:SS:CC) under the control of start, stop/pause and reset buttons. In hardware it is built from two cooperating pieces: a small finite state machine (FSM) that decides whether to count, and a chain of cascaded BCD (binary-coded decimal) counters that produce the display digits. This stopwatch Verilog code page gives you two complete, simulator-verified designs you can drop into a testbench or an FPGA project.

Every example here is self-checking and was compiled and run under Icarus Verilog in strict Verilog-2005 mode. Example 1 is the clean MM:SS:CC core with a tick enable; Example 2 adds a lap/hold feature where the display freezes while the internal counter keeps running.

The core keeps six BCD digit registers (two each for hundredths, seconds and minutes) and advances them only when the FSM is in RUN and a tick_en pulse arrives. Using a tick enable instead of a real 1/100 s prescaler lets the testbench advance time in a few clocks while keeping the exact same counting logic you would synthesize.

// Stopwatch core: FSM (IDLE/RUN/PAUSE) driving cascaded BCD counters MM:SS:CC
module stopwatch(
    input  wire       clk,
    input  wire       rst,     // synchronous reset
    input  wire       start,   // go / resume
    input  wire       pause,   // hold
    input  wire       tick_en, // 1/100 s tick enable (prescaler output)
    output reg  [3:0] cc0, cc1, // hundredths ones/tens  (00-99)
    output reg  [3:0] ss0, ss1, // seconds   ones/tens  (00-59)
    output reg  [3:0] mm0, mm1, // minutes   ones/tens  (00-59)
    output reg  [1:0] state
);
    localparam IDLE = 2'd0, RUN = 2'd1, PAUSE = 2'd2;

    // FSM
    always @(posedge clk) begin
        if (rst)
            state <= IDLE;
        else case (state)
            IDLE:  if (start) state <= RUN;
            RUN:   if (pause) state <= PAUSE;
            PAUSE: if (start) state <= RUN;
            default: state <= IDLE;
        endcase
    end

    // cascaded BCD counters
    reg c_cc1, c_ss0, c_ss1, c_mm0, c_mm1; // carry signals
    always @(posedge clk) begin
        if (rst) begin
            cc0<=0; cc1<=0; ss0<=0; ss1<=0; mm0<=0; mm1<=0;
        end else if (state==RUN && tick_en) begin
            // default: hold carries low
            c_cc1=0; c_ss0=0; c_ss1=0; c_mm0=0; c_mm1=0;
            // hundredths ones
            if (cc0==9) begin cc0<=0; c_cc1=1; end else cc0<=cc0+1;
            // hundredths tens
            if (c_cc1) begin
                if (cc1==9) begin cc1<=0; c_ss0=1; end else cc1<=cc1+1;
            end
            // seconds ones
            if (c_ss0) begin
                if (ss0==9) begin ss0<=0; c_ss1=1; end else ss0<=ss0+1;
            end
            // seconds tens
            if (c_ss1) begin
                if (ss1==5) begin ss1<=0; c_mm0=1; end else ss1<=ss1+1;
            end
            // minutes ones
            if (c_mm0) begin
                if (mm0==9) begin mm0<=0; c_mm1=1; end else mm0<=mm0+1;
            end
            // minutes tens (wraps at 59:xx)
            if (c_mm1) begin
                if (mm1==5) mm1<=0; else mm1<=mm1+1;
            end
        end
    end
endmodule

module tb;
    reg clk=0, rst, start, pause, tick_en;
    wire [3:0] cc0,cc1,ss0,ss1,mm0,mm1;
    wire [1:0] state;
    integer errors=0;

    stopwatch dut(.clk(clk),.rst(rst),.start(start),.pause(pause),.tick_en(tick_en),
        .cc0(cc0),.cc1(cc1),.ss0(ss0),.ss1(ss1),.mm0(mm0),.mm1(mm1),.state(state));

    always #5 clk=~clk;

    task tickn(input integer n);
        integer i;
        begin
            for (i=0;i<n;i=i+1) begin
                @(negedge clk); tick_en=1;
                @(negedge clk); tick_en=0;
            end
        end
    endtask

    // golden check against elapsed hundredths
    task chk(input integer elapsed, input [8*16:1] label);
        integer cc, ss, mm;
        integer gcc0,gcc1,gss0,gss1,gmm0,gmm1;
        begin
            cc = elapsed % 100;
            ss = (elapsed / 100) % 60;
            mm = (elapsed / 6000) % 60;
            gcc0=cc%10; gcc1=cc/10; gss0=ss%10; gss1=ss/10; gmm0=mm%10; gmm1=mm/10;
            if (cc0==gcc0 && cc1==gcc1 && ss0==gss0 && ss1==gss1 && mm0==gmm0 && mm1==gmm1)
                $display("PASS %0s: %0d%0d:%0d%0d:%0d%0d (elapsed=%0d cs)",label,mm1,mm0,ss1,ss0,cc1,cc0,elapsed);
            else begin
                $display("FAIL %0s: got %0d%0d:%0d%0d:%0d%0d expected %0d%0d:%0d%0d:%0d%0d",
                    label,mm1,mm0,ss1,ss0,cc1,cc0,gmm1,gmm0,gss1,gss0,gcc1,gcc0);
                errors=errors+1;
            end
        end
    endtask

    initial begin
        rst=1; start=0; pause=0; tick_en=0;
        @(negedge clk); @(negedge clk); rst=0;

        start=1; @(negedge clk); start=0;   // enter RUN
        tickn(150);                          // 1.50 s
        chk(150,"after 150cs");

        pause=1; @(negedge clk); pause=0;    // PAUSE
        tickn(50);                           // ignored while paused
        chk(150,"paused hold");

        start=1; @(negedge clk); start=0;    // resume
        tickn(50);                           // total 200cs
        chk(200,"after resume");

        rst=1; @(negedge clk); rst=0;        // reset to zero
        chk(0,"after reset");

        start=1; @(negedge clk); start=0;
        tickn(6050);                         // 1 min 0.50 s -> minute carry
        chk(6050,"minute carry");

        if (errors==0) $display("ALL TESTS PASSED");
        else $display("%0d TEST(S) FAILED", errors);
        $finish;
    end
endmodule
PASS after 150cs: 00:01:50 (elapsed=150 cs)
PASS paused hold: 00:01:50 (elapsed=150 cs)
PASS after resume: 00:02:00 (elapsed=200 cs)
PASS after reset: 00:00:00 (elapsed=0 cs)
PASS minute carry: 01:00:50 (elapsed=6050 cs)
ALL TESTS PASSED

The simulation log drives the FSM through start, pause, resume and reset, pulsing the tick enable a known number of times at each step. The testbench recomputes the expected MM:SS:CC digits from the elapsed hundredths with its own integer golden model, so each PASS line proves the BCD cascade carried correctly — including the 99→00 hundredths rollover and the seconds-to-minutes carry at 60.50 s. The paused step proves the counter holds while tick_en is pulsed.

This variant adds a lap/hold button. Pressing lap latches a snapshot of the current time into separate display registers and keeps the real counter running underneath; pressing it again returns the display to the live value. The displayed digits are a simple mux between the snapshot and the running count.

// Stopwatch with lap/hold: counter keeps running while the display is latched.
module stopwatch_lap(
    input  wire       clk,
    input  wire       rst,
    input  wire       start,
    input  wire       pause,
    input  wire       lap,     // one-shot: freeze/unfreeze displayed time
    input  wire       tick_en,
    output wire [3:0] d_cc0,d_cc1,d_ss0,d_ss1,d_mm0,d_mm1, // DISPLAYED time
    output reg  [3:0] r_cc0,r_cc1,r_ss0,r_ss1,r_mm0,r_mm1, // live RUNNING time
    output reg        lap_active,
    output reg  [1:0] state
);
    localparam IDLE=2'd0, RUN=2'd1, PAUSE=2'd2;

    // latched (lap) snapshot
    reg [3:0] l_cc0,l_cc1,l_ss0,l_ss1,l_mm0,l_mm1;

    // displayed time is the snapshot when a lap is held, else the live count
    assign d_cc0 = lap_active ? l_cc0 : r_cc0;
    assign d_cc1 = lap_active ? l_cc1 : r_cc1;
    assign d_ss0 = lap_active ? l_ss0 : r_ss0;
    assign d_ss1 = lap_active ? l_ss1 : r_ss1;
    assign d_mm0 = lap_active ? l_mm0 : r_mm0;
    assign d_mm1 = lap_active ? l_mm1 : r_mm1;

    always @(posedge clk) begin
        if (rst) state <= IDLE;
        else case (state)
            IDLE:  if (start) state <= RUN;
            RUN:   if (pause) state <= PAUSE;
            PAUSE: if (start) state <= RUN;
            default: state <= IDLE;
        endcase
    end

    // lap control: pressing lap captures a snapshot and toggles the hold
    always @(posedge clk) begin
        if (rst) begin
            lap_active <= 0;
            l_cc0<=0; l_cc1<=0; l_ss0<=0; l_ss1<=0; l_mm0<=0; l_mm1<=0;
        end else if (lap) begin
            if (!lap_active) begin
                l_cc0<=r_cc0; l_cc1<=r_cc1;
                l_ss0<=r_ss0; l_ss1<=r_ss1;
                l_mm0<=r_mm0; l_mm1<=r_mm1;
                lap_active <= 1;
            end else begin
                lap_active <= 0; // release -> show live again
            end
        end
    end

    // running BCD cascade (always advances in RUN, regardless of lap)
    reg c1,c2,c3,c4,c5;
    always @(posedge clk) begin
        if (rst) begin
            r_cc0<=0; r_cc1<=0; r_ss0<=0; r_ss1<=0; r_mm0<=0; r_mm1<=0;
        end else if (state==RUN && tick_en) begin
            c1=0; c2=0; c3=0; c4=0; c5=0;
            if (r_cc0==9) begin r_cc0<=0; c1=1; end else r_cc0<=r_cc0+1;
            if (c1) begin if (r_cc1==9) begin r_cc1<=0; c2=1; end else r_cc1<=r_cc1+1; end
            if (c2) begin if (r_ss0==9) begin r_ss0<=0; c3=1; end else r_ss0<=r_ss0+1; end
            if (c3) begin if (r_ss1==5) begin r_ss1<=0; c4=1; end else r_ss1<=r_ss1+1; end
            if (c4) begin if (r_mm0==9) begin r_mm0<=0; c5=1; end else r_mm0<=r_mm0+1; end
            if (c5) begin if (r_mm1==5) r_mm1<=0; else r_mm1<=r_mm1+1; end
        end
    end
endmodule

module tb;
    reg clk=0, rst, start, pause, lap, tick_en;
    wire [3:0] d_cc0,d_cc1,d_ss0,d_ss1,d_mm0,d_mm1;
    wire [3:0] r_cc0,r_cc1,r_ss0,r_ss1,r_mm0,r_mm1;
    wire lap_active; wire [1:0] state;
    integer errors=0;

    stopwatch_lap dut(.clk(clk),.rst(rst),.start(start),.pause(pause),.lap(lap),.tick_en(tick_en),
        .d_cc0(d_cc0),.d_cc1(d_cc1),.d_ss0(d_ss0),.d_ss1(d_ss1),.d_mm0(d_mm0),.d_mm1(d_mm1),
        .r_cc0(r_cc0),.r_cc1(r_cc1),.r_ss0(r_ss0),.r_ss1(r_ss1),.r_mm0(r_mm0),.r_mm1(r_mm1),
        .lap_active(lap_active),.state(state));

    always #5 clk=~clk;

    task tickn(input integer n);
        integer i;
        begin
            for (i=0;i<n;i=i+1) begin
                @(negedge clk); tick_en=1;
                @(negedge clk); tick_en=0;
            end
        end
    endtask

    // check a {displayed or running} time against an elapsed-hundredths golden value
    task chk(input integer elapsed, input use_disp, input [8*20:1] label);
        integer cc,ss,mm, g0,g1,s0,s1,m0,m1;
        integer a0,a1,b0,b1,x0,x1;
        begin
            cc=elapsed%100; ss=(elapsed/100)%60; mm=(elapsed/6000)%60;
            g0=cc%10; g1=cc/10; s0=ss%10; s1=ss/10; m0=mm%10; m1=mm/10;
            if (use_disp) begin a0=d_cc0;a1=d_cc1;b0=d_ss0;b1=d_ss1;x0=d_mm0;x1=d_mm1; end
            else          begin a0=r_cc0;a1=r_cc1;b0=r_ss0;b1=r_ss1;x0=r_mm0;x1=r_mm1; end
            if (a0==g0&&a1==g1&&b0==s0&&b1==s1&&x0==m0&&x1==m1)
                $display("PASS %0s: %0d%0d:%0d%0d:%0d%0d",label,x1,x0,b1,b0,a1,a0);
            else begin
                $display("FAIL %0s: got %0d%0d:%0d%0d:%0d%0d expected %0d%0d:%0d%0d:%0d%0d",
                    label,x1,x0,b1,b0,a1,a0,m1,m0,s1,s0,g1,g0);
                errors=errors+1;
            end
        end
    endtask

    initial begin
        rst=1; start=0; pause=0; lap=0; tick_en=0;
        @(negedge clk); @(negedge clk); rst=0;

        start=1; @(negedge clk); start=0;
        tickn(320);                           // live 03.20
        chk(320,1,"live before lap");

        lap=1; @(negedge clk); lap=0;         // freeze display at 320
        tickn(180);                           // live advances to 500
        chk(320,1,"display frozen");          // displayed stays 03.20
        chk(500,0,"running advanced");        // running counter is at 05.00
        if (lap_active) $display("PASS lap_active high while held");
        else begin $display("FAIL lap_active not set"); errors=errors+1; end

        lap=1; @(negedge clk); lap=0;         // release -> show live
        chk(500,1,"display live again");
        if (!lap_active) $display("PASS lap released");
        else begin $display("FAIL lap still active"); errors=errors+1; end

        if (errors==0) $display("ALL TESTS PASSED");
        else $display("%0d TEST(S) FAILED", errors);
        $finish;
    end
endmodule
PASS live before lap: 00:03:20
PASS display frozen: 00:03:20
PASS running advanced: 00:05:00
PASS lap_active high while held
PASS display live again: 00:05:00
PASS lap released
ALL TESTS PASSED

The log captures a lap at 03.20, then pulses 180 more ticks. The checks prove two things at once: the displayed time stays frozen at 03.20 while the running counter independently reaches 05.00, and releasing the lap snaps the display back to the live 05.00. Each value is compared against an elapsed-hundredths golden model, so a wrong latch or a stalled counter would print FAIL.

FSM states and transitions

The controller is a three-state Moore machine. IDLE is the power-on and post-reset state with the counter stopped at zero. A start press moves it to RUN, where every tick advances the time. A pause press moves RUN to PAUSE, which holds the current value without clearing it; pressing start again resumes from PAUSE back to RUN. A synchronous reset returns to IDLE from any state and clears all digits.

  • IDLE → RUN on start
  • RUN → PAUSE on pause (value retained)
  • PAUSE → RUN on start (resume)
  • any → IDLE on reset (value cleared)

Because counting is gated by state==RUN && tick_en, pause and reset have fundamentally different meanings: pause stops the enable, reset clears the data.

BCD cascade, carry and rollover

Rather than keep a single binary count and convert it for display, the design stores each decimal digit in its own 4-bit register. Hundredths run 00–99, seconds and minutes run 00–59. Each digit generates a carry into the next only on its own rollover:

  • cc0 (hundredths ones) wraps 9→0 and carries into cc1.
  • cc1 (hundredths tens) wraps 9→0 at 99 and carries one second.
  • ss0 wraps 9→0; ss1 wraps at 5 (giving 59→00) and carries one minute.
  • mm0/mm1 behave like seconds and wrap the whole display at 59:59.99.

This ripple-carry structure is the hardware analogue of a mechanical odometer and makes the display digits directly drivable onto seven-segment decoders with no binary-to-BCD conversion step.

The tick-enable trick: simulation vs a real 1/100 s prescaler

On a real board the clock is typically tens of megahertz, so a hundredth of a second is hundreds of thousands of clock cycles away. You generate the 1/100 s industry-standard EDA tools with a prescaler: a free-running counter that asserts a one-cycle tick_en pulse every f_clk/100 clocks.

// e.g. 50 MHz clock -> tick every 500000 cycles
if (pre == 500000-1) begin pre <= 0; tick_en <= 1; end
else                  begin pre <= pre + 1; tick_en <= 0; end

Keeping the prescaler outside the counting logic is deliberate. The counter advances once per tick_en, so in simulation the testbench can supply the pulses directly and sweep thousands of hundredths in microseconds — exactly what the verified logs above do — while synthesis simply wires the real prescaler to the same port. The counting behaviour is identical in both.

Lap vs reset semantics

Beginners often conflate the two display-affecting buttons, but they are unrelated. Lap (Example 2) never touches the counter: it only latches a snapshot into display registers so a reader can note a split time while the stopwatch keeps timing. Reset zeroes the counter itself. A clean design therefore keeps three independent concerns apart: the running count (data), the FSM run/pause decision (control), and the display source (a mux selecting live or latched values).

A consequence worth testing, and proven in the log, is that after a lap the running registers and the displayed registers hold different values simultaneously — the whole point of a split timer.

Debouncing the physical buttons (note)

The RTL above assumes each of start, pause, lap and rst is a clean one-cycle event. Real pushbuttons bounce for several milliseconds, which at FPGA clock rates would register as dozens of start/pause toggles per press. In a physical build you insert a debouncer and a rising-edge detector in front of these inputs so one human press produces exactly one pulse. This is a front-end concern only; it does not change the FSM or the counters, which is why the testbench drives idealized single-cycle pulses.

FAQ

Why BCD counters instead of a binary count?

BCD digits map one-to-one onto seven-segment displays, so you avoid a binary-to-decimal conversion that would otherwise need division or a double-dabble circuit. The trade-off is slightly more register logic, which is negligible on any modern FPGA.

How do I change the clock frequency?

Only the prescaler's terminal count changes — set it to f_clk/100 - 1. The stopwatch core is frequency-independent because it counts tick_en pulses, not clock edges.

Does pause lose the time?

No. Pause merely stops the tick enable from reaching the counter; all digit registers retain their values and resume exactly where they left off, as the paused hold check demonstrates.

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