Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Timer-Core

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

MMIO (Memory Mapped IO)

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

Control Register

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.

Behaviour

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.

FPGA wrapper

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

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.

Reset

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.

Setting up

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-source

The Makefile reads both with ?=, so it uses those values without being told:

Utils_DIR ?= $(PWD)/../Utils-Tool
OpenLibrary_DIR ?= $(PWD)/../Open-Library

utils.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.

Working on Open-Library or Utils-Tool

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.

Building

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.

FPGA

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages