A synthesisable, parameterisable design pair written in Verilog-2001, verified in simulation and brought up on real hardware (ALINX PIAX301V2):
- SPI master controller with FIFO buffering, programmable timing, multi-CS support and a memory-mapped register interface, suitable for direct connection to soft CPU cores (Nios II, PicoRV32, etc.) through a thin Avalon-MM / AXI4-Lite / Wishbone adapter.
- LCD controller for the ALINX AN430 / TM043NBH02 parallel-RGB 480x272 panel mounted on the ALINX PIAX301V2 board. Provides background patterns and 7-segment hex value overlays, controlled through the same bus protocol as the SPI master.
Both subsystems share a single 50 MHz clock domain. The XPT2046 touch controller on the AN430 board is served by the SPI master via CS[3], and the on-board LCD acts as a live dashboard for SPI bring-up.
A full long-form write-up of the whole journey — from spec to signals on a logic analyser — lives in
docs/(31 chapters + appendices A-E, in Russian).
- Silicon cost on EP4CE6 (the smallest device of the family), full
spi_lcdbuild: 1,743 / 6,272 LE (28 %), 854 dedicated registers, 0 memory bits / 0 PLLs (FIFOs in LE, single clock by design). - Timing met: worst setup slack +1.032 ns on the 50 MHz clock.
- 29 self-checking tests, all green: 18 SPI cases (38 checks), 7 LCD cases
(13 checks), 4 integration cases. Full regression runs in seconds via
make, no license required (Icarus Verilog). - Hardware-proven: live touch coordinates on the LCD, "SPI: FOUND/NONE" detection that flips both ways when the touch cable is unplugged.
- All four SPI modes (0, 1, 2, 3); MSB-first and LSB-first ordering.
- Word lengths of 8, 16, 24 or 32 bits, selectable at run time (any other
value at START raises the
ERR_WORD_LENsticky flag). - Independent TX/RX FIFOs (parameterised depth, defaults to 8 entries).
- Multi-slave support with parameterised number of CS lines (default 4).
- Programmable CS-setup, CS-hold and inter-word delays (in clk cycles).
- Continuous-burst behaviour: while the TX FIFO is not empty, transfers chain under the same active CS, separated by the inter-word delay.
- Sticky error flags (TX/RX overflow, start-with-empty, bad word length).
- Maskable interrupt output (done / RX valid / error).
- Synchronous single-clock-domain implementation. Asynchronous external
reset is brought in through
spi_reset_sync(async assert, sync deassert). - MISO is brought into the FPGA clock domain through a parameterisable multi-flop synchroniser.
- Bit-indexed access to the active TX/RX word (no shift-register wrap-around): identical correctness for MSB-first and LSB-first; immune to LSB-side mis-alignment bugs typical of shifting implementations.
.
├── rtl/
│ ├── spi_defs.vh SPI register map + bit definitions
│ ├── spi_reset_sync.v async-assert / sync-deassert reset cleaner
│ ├── spi_fifo.v single-clock FIFO with flush
│ ├── spi_reg_if.v SPI memory-mapped register interface
│ ├── spi_engine.v SPI FSM, SCLK / MOSI / CS generation
│ ├── spi_master_top.v SPI integration (FIFOs + reg IF + engine)
│ ├── spi_master_fpga_top.v SPI-only FPGA top (debug bring-up board)
│ ├── demo_init.v Boot-time init sequencer (ROM-driven master)
│ ├── spi_probe.v XPT2046 presence probe (live detector)
│ ├── spi_lcd_fpga_top.v Combined SPI + LCD FPGA top (PIAX301V2)
│ └── lcd/
│ ├── lcd_defs.vh LCD register map
│ ├── lcd_timing.v Generic RGB timing generator
│ ├── lcd_pattern.v Background patterns (solid/bars/gradient/checker)
│ ├── lcd_seg7.v Single 7-segment digit glyph
│ ├── lcd_hex_display.v N-digit hex overlay
│ ├── lcd_font5x7.v Compact 5x7 bitmap font (16 glyphs)
│ ├── lcd_status_label.v "SPI: FOUND / SPI: NONE" status overlay
│ ├── lcd_reg_if.v LCD memory-mapped register interface
│ └── lcd_top.v LCD integration (single clk-en pipeline)
├── sim/
│ ├── tb_spi_slave_ref.v independent reference SPI slave model
│ ├── tb_spi_master.v SPI self-checking testbench
│ ├── tb_lcd_top.v LCD self-checking testbench
│ └── tb_spi_lcd_top.v Integration test (init -> probe -> detect)
├── quartus/
│ ├── spi_master.qpf/.qsf/.sdc SPI-only project (debug board)
│ ├── spi_lcd.qpf/.qsf/.sdc Combined SPI + LCD project (PIAX301V2)
│ ├── build.sh Command-line compile wrapper
│ └── output_files/ Generated .sof / .pof / .rpt land here
├── datasheets/ Board schematics (PIAX301V2, AN430)
├── docs/ Long-form article (part0..part7 + appendices)
├── Makefile Icarus Verilog simulation driver
├── README.md This file (English)
└── README-RU.md Russian version
| Addr | Name | R/W | Description |
|---|---|---|---|
| 0x0 | CONTROL | R/W | Bit 0 EN, 1 START (W), 2 SOFT_RST (W), 3 CPOL, 4 CPHA, 5 LSB_FIRST, 6 IRQ_EN |
| 0x1 | STATUS | R | See below |
| 0x2 | CLK_DIV | R/W | SCLK half-period = (CLK_DIV + 1) clk cycles; CLK_DIV >= 1 |
| 0x3 | CS_SELECT | R/W | Index of active CS (0..NUM_CS-1) |
| 0x4 | WORD_LEN | R/W | 8, 16, 24 or 32 |
| 0x5 | TX_DATA | W | Push one word to the TX FIFO (right-justified) |
| 0x6 | RX_DATA | R | Pop one word from the RX FIFO |
| 0x7 | DELAY_CFG | R/W | [7:0] CS_SETUP, [15:8] CS_HOLD, [23:16] INTER (clk cycles) |
| 0x8 | IRQ_STATUS | R | Sticky IRQ source bits (DONE, RX_VALID, ERROR) |
| 0x9 | IRQ_MASK | R/W | Per-source enable mask |
| 0xA | ERROR_CLR | W | Writing W1C to this register clears sticky errors / IRQs and flushes both FIFOs |
| Bit | Name | Description |
|---|---|---|
| 0 | BUSY | Engine is in the middle of a transfer |
| 1 | DONE | Sticky: set when a transfer completes |
| 2 | RX_VALID | RX FIFO contains at least one word |
| 3 | TX_READY | TX FIFO is not full |
| 4 | ENABLED | CONTROL.EN |
| 5 | TX_EMPTY | TX FIFO is empty |
| 6 | RX_FULL | RX FIFO is full |
| 8 | ERR_TX_OVF | Sticky: host wrote TX_DATA while FIFO full |
| 9 | ERR_RX_OVF | Sticky: RX FIFO overflowed |
| 10 | ERR_START | Sticky: START while TX FIFO was empty |
| 11 | ERR_WORD_LEN | Sticky: bad WORD_LEN at START |
write CLK_DIV = 4 // SCLK = clk / (2*(4+1)) = 5 MHz @ 50 MHz
write CS_SELECT = 0
write WORD_LEN = 8
write DELAY_CFG = 0x00_02_02_02 // 2 cycles of setup / hold / inter
write TX_DATA = 0xA5
write CONTROL = EN | START | (CPOL<<3) | (CPHA<<4)
poll STATUS until (DONE && !BUSY) or wait for IRQ
read RX_DATA
write ERROR_CLR = (1 << IRQ_DONE) // ack
| Addr | Name | R/W | Description |
|---|---|---|---|
| 0x0 | CONTROL | R/W | Bit 0 EN, 1 BL_EN, [3:2] PATTERN, 4 HEX0_EN, 5 HEX1_EN |
| 0x1 | STATUS | R | 0 VSYNC_ACT, 1 HSYNC_ACT, 2 DE, 3 FRAME_TOGGLE |
| 0x2 | BG_COLOR | R/W | 24-bit {R, G, B}, zero-extended |
| 0x3 | FG_COLOR | R/W | 24-bit {R, G, B}, zero-extended |
| 0x4 | HEX0_POS | R/W | {y[15:0], x[15:0]} -- top-left of hex display 0 |
| 0x5 | HEX0_VALUE | R/W | 32-bit value to render (8 hex digits, MSB-first) |
| 0x6 | HEX1_POS | R/W | top-left of hex display 1 |
| 0x7 | HEX1_VALUE | R/W | hex display 1 value |
| 0x8 | FRAME_CNT | R | Frame counter (wraps), increments at each VSYNC start |
| Value | Mode |
|---|---|
| 0 | SOLID -- fills the screen with BG_COLOR |
| 1 | COLOR_BARS -- 8 vertical bars (white, yellow, cyan, green, |
| magenta, red, blue, black) | |
| 2 | GRADIENT -- analytic R = x, G = y, B = (x + y)/2 |
| 3 | CHECKER -- 32x32 squares alternating FG_COLOR / BG_COLOR |
Hex displays are rendered on top of the background. Each hex display shows 8 hex digits of a 32-bit register value, with the most-significant nibble drawn on the left. Width / height / segment thickness can be adjusted at module instantiation time (defaults: 24x40 cells, 4 px thick segments).
The top exposes a single 6-bit-address parallel bus that addresses both peripherals:
| addr[5:4] | Peripheral | addr[3:0] address space |
|---|---|---|
00 |
SPI master | 0x00..0x0F |
01 |
LCD controller | 0x10..0x1F |
Both modules return zero for unselected addresses, so the read mux is a simple OR.
Requires Icarus Verilog and (optionally) GTKWave.
make sim_spi # SPI master testbench (18 cases, 38 checks)
make sim_lcd # LCD controller testbench ( 7 cases, 13 checks)
make sim_int # Top-level integration test ( 4 cases, init/probe/detect)
make sim # run all three
make wave_spi # open SPI VCD in GTKWave
make wave_lcd # open LCD VCD in GTKWave
make wave_int # open integration VCD in GTKWave
make clean
The SPI testbench instantiates an independent reference SPI slave
(sim/tb_spi_slave_ref.v) and covers reset state, modes 0..3 MSB-first
with 8/16/24/32-bit words, LSB-first 8/16-bit, multi-word bursts under a
single CS, multi-CS routing, sticky error flags, the full IRQ sequence
and SOFT_RST recovery.
The integration testbench instantiates spi_lcd_fpga_top plus a tiny
behavioural XPT2046 model: it verifies that demo_init reaches
init_done, that spi_probe correctly reports detected = 0 with a
floating MISO, and that it flips to detected = 1 (with the right
ADC value) once the model starts responding.
Two projects are provided side-by-side in quartus/:
| Revision | Top entity | Use case |
|---|---|---|
| spi_lcd | spi_lcd_fpga_top |
Production target, ALINX PIAX301V2 (SPI + LCD). |
| spi_master | spi_master_fpga_top |
SPI core only, bring-up board with debug bus pins. |
The spi_lcd top is fully self-contained. After reset:
demo_init.vwalks a ROM table of register writes that bring up the LCD (COLOR_BARS background + two hex overlays + status label) and configure the SPI master (CLK_DIV=24 -> SCLK 1 MHz, WORD_LEN, CS_SELECT=3, EN).spi_probe.vthen takes over the bus and, every scan, performs two 24-bit XPT2046 conversions: control 0xD0 (X) and 0x90 (Y). The results drive:- the on-screen "SPI: FOUND" (green) / "SPI: NONE" (red) label;
- LCD HEX0 = X coordinate (0x00000XXX), HEX1 = Y coordinate
(0x00000YYY), updated live each scan so they change while touching.
PENIRQ (active-low) is sampled and exposed as
pen_down.
| Parameter | Default | Purpose |
|---|---|---|
SPI_PROBE_CONTINUOUS |
1 | 1: back-to-back touch scans (bring-up). 0: periodic polling. |
SPI_PROBE_PERIOD_BITS |
22 | When not continuous, poll period ~ 2^N clk cycles (~84 ms). |
DIAG_PINWALK |
0 | 1: route free-running counter bits straight to SCLK/MOSI/CS — a binary "physics vs logic" probe for hardware bring-up (see docs ch. 27). |
Pin map for the AN430 daughter board on PIAX301V2 (touch IC = XPT2046):
| Signal | FPGA pin | Direction | Note |
|---|---|---|---|
| clk_50m | E1 | in | 50 MHz crystal |
| reset_n | N13 | in | Push button, active-low; 2FF-synced |
| SPI DCLK | F8 | out | spi_sclk (1 MHz at CLK_DIV=24) |
| SPI CS# | E7 | out | spi_cs_n (single-line, drives CS[3]) |
| SPI DIN | D8 | out | spi_mosi (master -> XPT2046) |
| SPI DOUT | E9 | in | spi_miso (XPT2046 -> master, pull-up) |
| BUSY | C8 | in | spi_busy |
| PENIRQ | E8 | in (OD) | weak pull-up, pen-down indicator |
| spi_irq | R12 | out | composite core IRQ (observation) |
The LCD pin assignments come unchanged from the official ALINX
17_2_sd_sdram_an430_lcd demo. The full pinout (clock, reset, touch SPI and
the RGB888 bus) is tabulated in docs/appendices.md,
appendix B.
- Launch Quartus Prime (Standard or Lite, 13.1 / 17.x / 25.x are all OK).
- File -> Open Project...
- Navigate to the
quartus/folder of this repo. - Select
spi_lcd.qpf(orspi_master.qpffor the SPI-only build). - On first open Quartus generates
spi_lcd_assignment_defaults.qdfand thedb/cache directories. That is expected; they are listed inquartus/.gitignore. - Processing -> Start Compilation (or press Ctrl+L) to run the full flow (Analysis & Synthesis -> Fitter -> Assembler -> TimeQuest).
- The programming file is produced at
quartus/output_files/spi_lcd.sof. Load it via Tools -> Programmer.
If you prefer the shell:
cd quartus/
./build.sh spi_lcd # full compile of the SPI + LCD revision
./build.sh spi_master # full compile of the SPI-only revision
build.sh is a thin wrapper around quartus_sh --flow compile <revision>;
the equivalent invocation is quartus_sh --flow compile spi_lcd.
Each project ships an SDC file that declares the primary clock and the
generated clocks for spi_sclk and lcd_dclk, plus conservative output
delays for the LCD bus (against the TM043NBH02 datasheet) and the SPI bus
(loose, suitable for slow slaves like the XPT2046). Refine the input /
output delays once your operating point is fixed.
- System clock: 25..100 MHz.
CLK_DIV >= 1(controller refuses divisor of zero).- With the default 2-stage MISO synchroniser,
CLK_DIV >= 2is recommended so that the half-SCLK period is at least 3 clk cycles - long enough to absorb the 2-cycle synchroniser latency. SetMISO_SYNC_STAGES = 1(or 0, source-synchronously) and tighten the SDC constraints for faster operation.
- Single clock domain only; no slave-mode support (master only).
- FIFOs use LE-based storage for portability. For deeper buffers convert
spi_fifo.vto use M9K block RAM with a separate-read-pointer scheme. - No DMA: every word passes through the register interface (fine at 1-5 MHz, a bottleneck at 25+ MHz with large blocks).
- Touch readout is raw ADC (no calibration / filtering); polling is timer-driven rather than gated by PENIRQ.
spi_master_fpga_topexposes a simple parallel debug bus. For a real product, wrapspi_master_topin an Avalon-MM / AXI4-Lite / Wishbone adapter; the protocol is already single-cycle andready-friendly.- The 2-FF synchroniser on MISO is the only async input in the design;
all other I/Os are synchronous to
clk.
See docs/part7_results.md for the full,
annotated roadmap (Avalon/AXI bridge, M9K FIFO + DMA, touch calibration,
slave mode + QSPI, formal verification).