A 50 MHz chip needs a 1 ms interrupt. Set the prescaler to 49 and the period to 999, and the timer steps once every 50 cycles and fires once every 1000 steps: 50,000 cycles, exactly 1 ms. If the prescaler divides by 49 instead of 50, the interrupt comes every 0.98 ms and a clock built on it gains about 29 minutes a day. Every boundary in a timer is an off-by-one waiting to happen, so this project writes them all down first.
You will build the core of a timer peripheral: a prescaler, a 16-bit counter with load, one-shot and auto-reload modes, and a compare match that raises a sticky interrupt flag. It reuses this topic's counter, prescaler and watchdog lessons.
Read it left to right along the top, then down.
Prescaler: a tick enable, not a divided clock. The whole timer runs on clk.
Counter: steps only on a tick. At top it wraps to 0 (auto-reload) or stops and raises done (one-shot).
Compare and flag: a match is a step that lands on cmp. The flag remembers it until software writes 1 to clear it.
Port | Direction | Width | Meaning |
|---|---|---|---|
| input | 1, 1 | Clock; asynchronous active-low reset |
| input | 1 | 1 runs the timer, 0 pauses it |
| input | 1 | 1 auto-reload, 0 one-shot |
| input | 8 | One step every |
| input | 16 | Last count of a period |
| input | 16 | Compare value |
| input | 1, 16 | Pulse: load the count |
| input | 1 | Write-1-to-clear for |
| output | 16 | The count |
| output | 1 | High in the cycle a step is due |
| output | 1 | Sticky compare-match flag |
| output | 1 | One-shot has finished |
Reset clears the count, the prescaler, irq and done.
Prescaler: while en is 1 it counts 0 to psc, and tick is high in the last cycle, so a step is due every psc + 1 enabled cycles. en = 0 freezes the prescaler and the count; resuming keeps the phase.
Step: on a tick, unless done or load is high, the count adds 1. At top, auto-reload goes to 0; one-shot holds the count and sets done. A period is (psc + 1) x (top + 1) cycles.
Load beats counting: count gets load_val, the prescaler and done clear. A load never sets irq.
Match: irq sets on the edge where a step moves the count to cmp, so it rises in the cycle the count first shows cmp.
Flag: irq stays 1 until irq_clr. If a match and irq_clr arrive in the same cycle, set wins.
Robust limits: pcnt >= psc and count >= top end the prescale or period, so a smaller value written mid-count takes effect at once instead of running round all 2¹⁶ values.
Counting as controlled state evolution: terminal count and why >= recovers.
Up/down counter with reload: the priority chain between reset, load and enable.
A prescaler feeding a timer tick: the tick enable this design uses.
Watchdog timer example: a sticky flag that only one thing clears.
Check | How | Done when |
|---|---|---|
Clean compile |
| No output at all |
Clean lint |
| No warnings |
Bench passes |
| The PASS line, after the coverage line |
Bench can fail | Swap the flag's set and clear (see Common mistakes) | MISMATCH lines and a FAIL |
No latch, flops counted | Yosys | 26 flops (8 prescaler, 16 count, |
The synthesis check, trimmed:
$ yosys -p "read_verilog -sv timer_core.sv; synth -flatten -top timer_core; stat"
...
270 cells
9 $_ANDNOT_
85 $_AND_
26 $_DFFE_PN0P_
16 $_MUX_
39 $_NAND_
1 $_NOR_
6 $_NOT_
44 $_ORNOT_
11 $_OR_
13 $_XNOR_
20 $_XOR_Count the state in the spec first: 8 prescaler bits, 16 count bits, done and irq make 26. Yosys reports 26 $_DFFE_PN0P_ cells, flops with an enable and an asynchronous reset to 0, and no latch. A 27th flop would be a question about your code.
The full listing is under The test. Read it in three parts.
// snippet: part 1, the decisions, all combinational
assign tick = en && (pcnt >= psc);
assign step = tick && !done && !load;
assign at_top = (count >= top);
assign count_next = at_top ? 16'd0 : count + 16'd1;
assign match = step && !(at_top && !reload_mode) && (count_next == cmp);Every rule in the spec is one line here. step is the only event that moves the count. count_next is where a step goes. match compares that destination with cmp, which is why irq rises in the same cycle the count shows cmp. The one-shot stop is excluded: it moves nowhere, so it cannot match. Comparing count == cmp alone would stay true for every cycle the count waits on cmp, so a clear would be undone at once.
// snippet: part 2, the counter and prescaler registers
end else if (load) begin // load beats counting
pcnt <= '0;
count <= load_val;
done <= 1'b0;
end else if (en) begin
pcnt <= tick ? 8'd0 : pcnt + 8'd1;
if (step) begin
if (at_top && !reload_mode) done <= 1'b1; // one-shot: stop at top
else count <= count_next;
end
endThe if order is the priority from the spec: reset, then load, then enabled counting. With en low nothing is assigned, so every register holds; that is the pause.
// snippet: part 3, the sticky flag in its own block
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) irq <= 1'b0;
else if (match) irq <= 1'b1;
else if (irq_clr) irq <= 1'b0;The set is tested before the clear, so a match in the clearing cycle wins. The flag has its own block so that load does not touch it.
Each slot is one clock cycle; a step happens at the edge that closes a slot where tick is high.
Slots 1, 3, 5, 7: tick is high every second cycle (psc = 1), so the count reads 0, 1, 2 and 3 from slots 0, 2, 4 and 6.
Slot 4: the step lands on cmp = 2 and irq rises in the same slot the count shows 2.
Slot 7: the count is at top = 3 with tick high, so the next edge reloads it: slot 8 reads 0.
Slot 8: irq_clr is high with no match on that edge, so irq falls in slot 9.
The complete listing below contains every file of this project, each starting at a // ---- file: <name> ---- line. Save each part under its name in one folder, then run the script.
File | What it holds |
|---|---|
| The design: prescaler, counter, compare, reload and sticky interrupt |
| The bench: directed cases and seeded random writes against a cycle model |
run.sh
#!/bin/sh
set -e
iverilog -g2012 -Wall -o sim.out timer_core.sv tb_timer_core.sv
vvp sim.out
yosys -q -p "read_verilog -sv timer_core.sv; synth -flatten -top timer_core; stat" # optionalExpected output: the line a correct design makes the simulation print at the end of its run.
PASS: timer_core matches the cycle model (directed load, pause, reload, one-shot, set beats clear; seeded random writes)A reference model in plain integers runs beside the design and predicts count, irq, done and tick after every rising edge; any difference is a MISMATCH. On top of that, directed checks name the behaviour they expect:
Load and count: load 1, then with psc = 2 two steps in six cycles reach cmp = 3 and set irq.
Stop and start: five paused cycles leave the count alone; the next step comes on time after resuming.
Reload: nine more steps run to top = 9, wrap to 0 and match cmp again.
Set beats clear: with irq already 1, irq_clr is driven in exactly the cycle that steps 6 to 7 = cmp. irq must stay 1, then a clear alone must drop it.
One-shot: from 0 to top = 4, done rises on the fifth step, the count holds for 20 more cycles, and a load restarts it.
Seeded random register writes: 20000 cycles with random en, load, load_val, irq_clr, and occasional new psc, top, cmp and mode, so values change mid-period.
$ iverilog -g2012 -Wall -o sim.out timer_core.sv tb_timer_core.sv
$ vvp sim.out
checked 20076 cycles: 234 matches, 34 set-and-clear collisions, 759 reloads, 188 one-shot ends
PASS: timer_core matches the cycle model (directed load, pause, reload, one-shot, set beats clear; seeded random writes)
tb_timer_core.sv:110: $finish called at 200770 (1s)The coverage line matters as much as the PASS. Random writes produced 34 set-and-clear collisions, so the rule was tested far beyond the one directed case.
// ---- file: timer_core.sv ----
module timer_core (
input logic clk, rst_n,
input logic en, // 1: run, 0: pause (prescaler and count hold)
input logic reload_mode, // 1: auto-reload at top, 0: one-shot
input logic [7:0] psc, // one count step every psc + 1 enabled cycles
input logic [15:0] top, // last count of a period
input logic [15:0] cmp, // compare value
input logic load, // pulse: count <= load_val, prescaler and done cleared
input logic [15:0] load_val,
input logic irq_clr, // write-1-to-clear for irq
output logic [15:0] count,
output logic tick, // the cycle in which a step is due
output logic irq, // sticky compare-match flag
output logic done // one-shot finished; cleared by load
);
// Prescaler: count enabled cycles 0..psc; tick in the last one.
logic [7:0] pcnt;
assign tick = en && (pcnt >= psc); // >=: a smaller psc written mid-count still ticks
// One step of the counter, and the value it moves to.
logic step, at_top, match;
logic [15:0] count_next;
assign step = tick && !done && !load;
assign at_top = (count >= top); // >=: a smaller top written mid-period still ends it
assign count_next = at_top ? 16'd0 : count + 16'd1;
assign match = step && !(at_top && !reload_mode) && (count_next == cmp);
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) begin
pcnt <= '0;
count <= '0;
done <= 1'b0;
end else if (load) begin // load beats counting
pcnt <= '0;
count <= load_val;
done <= 1'b0;
end else if (en) begin
pcnt <= tick ? 8'd0 : pcnt + 8'd1;
if (step) begin
if (at_top && !reload_mode) done <= 1'b1; // one-shot: stop at top
else count <= count_next;
end
end
// Sticky flag: set beats clear, so a match in the clearing cycle is not lost.
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) irq <= 1'b0;
else if (match) irq <= 1'b1;
else if (irq_clr) irq <= 1'b0;
endmodule
Clear-first is the natural way to write the flag, because clearing feels like the command and setting like the background:
// bad-example: clear tested first
else if (irq_clr) irq <= 1'b0;
else if (match) irq <= 1'b1;MISMATCH cycle 47: count=7 want 7, irq=0 want 1, done=0 want 0, tick=1
FAIL: set beats clear in the same cycle (count=7 irq=0 done=0)
...
FATAL: tb_timer_core.sv:106: FAIL: 228 errors in 20076 cyclesCycle 47 is the directed collision: the count steps to 7, the match fires, and the clear erases it. The model says irq is 1; the design says 0.
psc and top pair, and say why psc = 50 is wrong.A period is (psc + 1) x (top + 1) cycles, and 1 ms is 50,000 cycles. psc = 49 and top = 999 gives 50 x 1000. psc = 50 divides by 51, so the period is 51,000 cycles: 1.02 ms.
psc = 0, top = 4, load_val = 0. How many rising edges after the load edge does done rise, and what does count read then?Five. With psc = 0 every enabled cycle is a step: four steps take the count from 0 to 4, and the fifth finds it at top, sets done and leaves the count at 4. That is top + 1 steps, the same period length as auto-reload.
Add a capture input. On a rising edge of an external input, copy count into a capture register and set a second sticky flag. Synchronise the input first; see The two-flop synchroniser.
Put it behind the CSR block. Map psc, top, cmp and a control word as read-write fields, count as read-only and irq as write-1-to-clear, then drive the bench through register writes. See Field access types and Interrupt register pattern.
Add a watchdog mode. In this mode a kick input reloads the count, and reaching top sets a sticky reset request that only reset clears. See Watchdog timer example.
Tools: Icarus Verilog 13.0, Verilator 5.048, Yosys 0.69+post (git sha1 143eb14f9); run on 2026-10-17.
Commands: iverilog -g2012 -Wall -o sim.out timer_core.sv tb_timer_core.sv && vvp sim.out; verilator --lint-only -Wall timer_core.sv; yosys -p "read_verilog -sv timer_core.sv; synth -flatten -top timer_core; stat"; node scripts/tools/gen-waves.mjs sequential:counters-timers for the waveform (wave-src/counters-timers/timer-reload.sv, the same core with psc = 1, top = 3, cmp = 2).
Result: the compile prints nothing, lint is clean, the bench prints the coverage and PASS lines shown (20076 cycles, 34 set-and-clear collisions), and Yosys reports 270 cells with 26 $_DFFE_PN0P_ flops and no latch cell.
Variant: timer_core_bad.sv (irq_clr tested before match) run on the same bench prints the MISMATCH at cycle 47 shown and fails with 228 errors.
Scope and limits: functional, zero-delay, single clock, seed 11 for the random phase; the reference model is a second implementation, so a misunderstanding shared by spec and model would not be caught. No bus interface or CDC is modelled.