A memory mapped timer, generated with VeriSnip and the Open-Library snippet scripts.
timer is a prescaled up-counter behind an AXI-Lite subordinate, with the
same MMIO style as bitSANN: the register file is declared as a table in a
`include block comment and generated by Open-Library/scripts/MMIO.py.
hardware/
src/timer.sv timer core (AXI-Lite subordinate + MMIO)
src/fpga/timer_fpga.sv iCESugar-Pro wrapper + AXI-Lite control unit
testbench/timer_tb.sv self-checking core testbench
testbench/timer_fpga_tb.sv self-checking wrapper testbench
timer_IceSugar_pro.lpf board pin assignment
DATA_WIDTH is 32 bits, so the byte address of a register is its index << 2.
| Address | Register | Width | Access | Description |
|---|---|---|---|---|
0x00 |
Control |
8 | R/W | Control bits, see below |
0x04 |
Status |
8 | R | [0] Running, [1] Match |
0x08 |
Prescaler |
PRESCALER_WIDTH |
R/W | Counter advances every Prescaler + 1 cycles |
0x0C |
Compare |
COUNTER_WIDTH |
R/W | Value the counter is compared against |
0x10 |
Counter |
COUNTER_WIDTH |
R | Current counter value |
| Bit | Name | Description |
|---|---|---|
| 0 | EN |
Enable counting |
| 1 | CONT |
1 continuous (wrap on match), 0 one-shot (park on match) |
| 2 | CLR |
Clear the counter and the prescaler. Write-one-shot |
| 3 | IRQ_EN |
Route the sticky Match flag to irq_o |
| 4 | CLR_MATCH |
Clear the sticky Match flag. Write-one-shot |
CLR and CLR_MATCH are cleared again by the hardware one cycle after the
write, so they always read back as 0 and act as single-cycle pulses. The
other bits are persistent.
The counter increments once every Prescaler + 1 enabled clock cycles, so a
match period is
(Compare + 1) * (Prescaler + 1) clock cycles
On a match match_o pulses for one cycle and the sticky Status[1] Match flag
is set. In continuous mode the counter wraps to zero and keeps running; in
one-shot mode it parks on Compare and stops until the flag is acknowledged
(write EN | CLR | CLR_MATCH to restart a one-shot timer).
Status and Counter are registered copies of the data plane, and AXI-Lite
registers rdata on top of that, so a read reflects the state of two cycles
earlier. Leave a couple of idle cycles between a write and a read of the
registers it affects.
timer_fpga is the iCESugar-Pro (Lattice ECP5 LFE5U-25F, CABGA256) top
module. It owns the AXI-Lite port through a small control unit generated by
Open-Library/scripts/FSM.py:
WrCmd -> WrResp walk the 4 entry init sequence:
Prescaler = PRESCALER
Compare = TOGGLE_VALUE
Control = CLR
Control = EN | CONT
RdCnt -> RdCntD read Counter over AXI-Lite (value discarded)
RdSt -> RdStD read Status
-> ClrCmd -> ClrResp on Match: toggle the LED, write Control = EN|CONT|CLR_MATCH
TOGGLE_VALUE is the compile-time parameter: it is programmed into the timer's
Compare register, so the LED toggles every
(TOGGLE_VALUE + 1) * (PRESCALER + 1) clock cycles. It defaults to
CLK_FREQ_HZ - 1, which toggles the LED once per second on the 25 MHz board
oscillator (a 2 s on/off cycle).
| Parameter | Default | Description |
|---|---|---|
CLK_FREQ_HZ |
25_000_000 |
Board oscillator frequency |
PRESCALER |
0 |
Written to the Prescaler register |
TOGGLE_VALUE |
CLK_FREQ_HZ - 1 |
Written to the Compare register |
LED_COLOUR |
3'b001 |
RGB channel mask {red, green, blue}: blue |
LED_ACTIVE_LOW |
0 |
The board RGB LED lights on a high output |
PRESCALER is 0 because it does not need to be anything else here:
Compare is COUNTER_WIDTH (32) bits wide, so it holds a full second of
25 MHz cycles (25 000 000, a 25-bit number) with plenty of headroom, and
leaving the prescaler at 0 keeps TOGGLE_VALUE a plain cycle count. Raise it
only to stretch the period past what the counter can express, or to make the
Counter readback coarser. PRESCALER = 249 with
TOGGLE_VALUE = 100_000 - 1 is the same one second, for example.
| Port | Pin | Description |
|---|---|---|
clk_i |
P6 |
25 MHz oscillator |
arst_i |
L14 |
Active-high reset button, PULLMODE=DOWN so it idles de-asserted |
led_r_o |
B11 |
RGB LED red channel |
led_g_o |
A11 |
RGB LED green channel |
led_b_o |
A12 |
RGB LED blue channel |
The three LED pins are LVCMOS25, matching the 2.5 V bank they sit in; the
clock and reset are LVCMOS33. The pin sites and the reset polarity follow
Blink-Core/blink_IceSugar_pro.lpf.
LED_COLOUR is an RGB channel mask, {red, green, blue}. One signal gates
every channel, so the selected ones switch together and the rest are held dark:
the LED is only ever that single colour or nothing. The default 3'b001 gives
one second blue, one second off. 3'b111 gives white, 3'b100 red, and so
on.
The RGB LED on this board is common cathode: a channel lights on a high
output, hence LED_ACTIVE_LOW = 0.
The control unit still reads the Counter register on every poll round; the
value simply is not routed to an output, which is what keeps the LED from
mixing into another colour. timer_fpga_tb watches the AXI-Lite bus directly
to confirm those reads happen and that the returned values are in range, and
separately asserts that the unselected channels stay dark for the whole run
while the selected one really does light.
Programming the board is itself a reset: every register is loaded with its
reset value from the bitstream. The wrapper's power-on reset just makes that
observable, using the same idiom as Blink-Core/hardware/rst_gen.v -- a
VeriSnip register counting up from its reset value, with the top bit signalling
that the window has closed:
assign por_done = por_cnt_q[PorMsb];
assign por_reset = ~por_done;
assign por_cnt_n = por_cnt_q + 1'b1;
`include "reg_timer_fpga_por.vs" /*
por_cnt_q, PorWidth, 0, , por_reset, _n
*/
`include "synchronize_reset_timer_fpga.vs" // arst_i (active-high), ext_sync_reset (active-high)
assign sync_reset = ext_sync_reset | por_reset;That register is the one thing that cannot take sync_reset, because
sync_reset is derived from it. Its declaration carries the initialiser that
becomes the bitstream reset value, which also keeps simulation deterministic on
the power-on path where the button is never pressed.
There are three reset signals, and only the last one reaches the design:
| Signal | Kind |
|---|---|
arst_i |
Asynchronous, straight off the pin |
ext_sync_reset |
arst_i asserted async, de-asserted in sync |
por_reset |
Synchronous, from the power-on counter |
sync_reset |
Synchronous, ext_sync_reset | por_reset |
ext_sync_reset comes out of synchronize_reset.py, which asserts
asynchronously so a button press is never missed and de-asserts through two
flops so every register leaves reset on the same edge. Everything downstream of
sync_reset is plain synchronous logic.
arst_i is OR'ed in, so the board button can only ever extend the reset, never
prevent the design from starting. That matters because an active-high reset on
a pin explicitly pulled down is the only combination that cannot leave the
design stuck: an active-low reset on the same pin holds the whole wrapper in
reset forever if the button is absent or idles low.
If arst_i turns out to be driven high on your board, delete its two lines
from the LPF and the power-on reset will still start the design on its own.
The LED and button sites come from the iCESugar-Pro pinout; check them against your board revision before programming.
Nix with a nixpkgs channel is the only host
requirement. Clone this repository and enter the shell:
git clone <this repository>
cd Timer-Core
nix-shell --run "make test"Nothing else needs cloning. Timer-Core depends on two VeriSnip repositories, and shell.nix pins both by revision and fetches them on demand:
utilsDir ? builtins.fetchGit {
url = "https://github.com/VeriSnip/Utils-Tool.git";
rev = "587407ffc6168b6803cb8f976af2a989a070c762";
},
openLibraryDir ? builtins.fetchGit {
url = "https://github.com/VeriSnip/Open-Library.git";
rev = "f46c9643799f279c201d482b457da9554f91ea6b";
},| Dependency | Supplies |
|---|---|
Utils-Tool |
shell.nix and the make rules for simulation and boards |
Open-Library |
generator scripts: MMIO.py, AXI.py, FSM.py, reg.py |
Pinning the revisions is the point: the generated RTL depends on what those scripts emit, so an unpinned dependency means a project that builds today can break tomorrow without a single local change.
Entering the shell exports both paths:
$ nix-shell --run 'echo $Utils_DIR; echo $OpenLibrary_DIR'
/nix/store/0b0i84rgcs1p4vqmq6r8pmwcg6dwwahw-source
/nix/store/l9x5s55ynxbp74kmv2jgmm936svhrxh8-sourceThe Makefile reads both with ?=, so it uses those values without
being told:
Utils_DIR ?= $(PWD)/../Utils-Tool
OpenLibrary_DIR ?= $(PWD)/../Open-Libraryutils.mk reads Utils_DIR under the same name and uses it to locate
Hardware.mk, Simulation/ and Board/.
Because include $(Utils_DIR)/utils.mk is resolved when make parses, run
make from inside the shell (nix-shell --run "make ...", as above) rather
than calling make directly. The literals in the Makefile are only a fallback
for a checkout that does sit next to the two repositories.
The first nix-shell needs network access and takes a few minutes: it fetches
both repositories, realises the toolchain (iverilog, verilator, verible,
gtkwave, yosys, nextpnr, trellis, openFPGALoader), then creates
.venv here and pip installs VeriSnip and numpy into it. Later runs reuse
the Nix store and .venv and start immediately. .venv is git-ignored;
delete it to force a clean reinstall.
To build against a local checkout instead of a pinned revision, override either argument:
nix-shell --arg openLibraryDir /path/to/Open-Library --run "make test"
nix-shell --arg utilsDir /path/to/Utils-Tool --run "make test"Once a change lands upstream, bump the matching rev in shell.nix.
One gap remains: Utils-Tool's shellHook runs an unpinned
pip install verisnip numpy, so the vs_build version itself is not pinned by
any of the above. Closing that means pinning the version in Utils-Tool or
packaging VeriSnip as a Nix derivation.
nix-shell --run "make test"make test builds and simulates every testbench under hardware/testbench
and lints its DUT. It runs entirely inside the shell it was invoked from, so
--arg overrides reach it:
nix-shell --arg openLibraryDir /path/to/Open-Library --run "make test"
``` To work on one target only:
```bash
nix-shell --run "vs_build --clean timer --inc_dir=\$OpenLibrary_DIR && make sim-run"Add VCD=1 to make sim-run to dump waves, and DEBUG=1 for per-check
testbench output.
The generated sources land in generated/ (snippets) and build/
(RTL/, TestBench/, timer_fpga/). Note that make test rebuilds without
--Boards, so it leaves no build/timer_fpga/; run make vs_build before
synthesising.
nix-shell --run "make vs_build && make board-programming BOARD=IceSugar_pro"make vs_build is the step that matters: it passes --Boards "_fpga", which
is what produces build/timer_fpga/. The board makefile collects its sources
with $(wildcard ...) when make parses, so if that directory is missing the
wrapper is silently dropped from the file list and yosys reports
Module `timer_fpga' not found.
The yosys / nextpnr-ecp5 / ecppack flow closes timing comfortably, reporting well over 100 MHz against the 25 MHz constraint and using 130 flip-flops.