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 PASSEDThe 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
PASSline 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 whiletick_enis 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 endmodulePASS 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 PASSEDThe 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.
IDLEis the power-on and post-reset state with the counter stopped at zero. A start press moves it toRUN, where every tick advances the time. A pause press movesRUNtoPAUSE, which holds the current value without clearing it; pressing start again resumes fromPAUSEback toRUN. A synchronous reset returns toIDLEfrom 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 intocc1.cc1(hundredths tens) wraps 9→0 at 99 and carries one second.ss0wraps 9→0;ss1wraps at 5 (giving 59→00) and carries one minute.mm0/mm1behave 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_enpulse everyf_clk/100clocks.// 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; endKeeping 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,lapandrstis 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 countstick_enpulses, 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 holdcheck 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 LabRun it in the LabOpen 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 Hi! Ask me about courses, fees, batches or discounts. ×ChipXpert AssistantAsk about courses, fees, batches and centres