/\
/ \
/ /\ \ ━━━━━━━━━
─────────────── / / \ \ ━━━━ ━━━━━━━━━
/ / [] \ \ ━━━━━━━━━
/ / PRISM\ \ ━━━━━━━━━
/_/________\_\ ━━━━━━━━━
A weekend of hacking on PRISM — my programmable FSM peripheral riding shotgun on TinyQV, now in real silicon on the TinyTapeout Sky 25a shuttle.
PRISM — the Programmable Reconfigurable Indexed State Machine — is a peripheral I taped out alongside Michael Bell's TinyQV RISC-V core. The concept: instead of one fixed-function peripheral, ship a tiny runtime-loadable Mealy FSM (eight states, each packed into a 44-bit STate Execution Word "STEW") surrounded by a useful datapath — a 24-bit counter/shift register, an 8-bit counter with compare (or it can be an 8-bit shift register), a small FIFO, a comm register. Load a different bitstream (I call them chromas) and the same silicon becomes a different peripheral.
▣ media/prism_periph.png — the PRISM peripheral: 8-state FSM core with its counter / shifter / FIFO / comm datapath (click for full size)
This weekend the question was: now that it's real silicon, what can it actually do? The answer escalated from register pokes to ZZ Top, and it did not stop there.
First order of business was a proper driver layer. The interesting quirk of
PRISM's configuration space is that the State Information Table (the program/chroma memory) is latch-based
and not randomly addressable — you shift whole STEWs through it like a
bucket brigade, and the oldest word recirculates to a readback register. So
prism_load_chroma() doubles as a verification pass: as the new
chroma shifts in, the previous one shifts out, and you can check every word of
it on the way. Area-frugal silicon caused by fierce, inviolable 2-tile
competition rules forces honest software!
Before anything clever, there was an evening of typing at the
prism> console: load a chroma, set breakpoints on state
indices with bp/bc, halt, single-step, poke
count1 preloads and count2 compares by hand and watch
the input/output vectors move. Unglamorous, but this is where you learn your
silicon matches your simulation — and PRISM has real hardware debug (halt,
step, two breakpoints, force-state), which paid for itself many times over
before the weekend was out.
Chroma number one: turn PRISM into a 1-bit delta-sigma DAC pushing about a
megabit per second out one pin, into the audio PMOD's 200 kHz low-pass filter.
The companion tool, mp3_to_dsm.py, decodes audio and modulates it
into the bitstream. It grew up in three painful steps:
uint32_t so the playback loop does single 32-bit loads.The payoff — ZZ Top's Sharp Dressed Man, played by an 8-state FSM through one pin! (this was before identifying the incorrect shift direction):
▶ media/ZZT.mov — 1-bit DSM playback via the PRISM DSM chroma
The WS2812 chroma was written and simulated before tapeout, so this was the moment of truth: it drove an AdaFruit 7-NeoPixel Jewel with zero FSM changes. The application on top was ported from a previous life where it ran on an ECP3 FPGA behind PCIe — now it's the same LED show, driven by a peripheral the size of a rounding error.
▶ media/ws2812b.mov — NeoPixel Jewel on the WS2812 chroma
Next chroma: an 80's-style synth. PRISM generates a 1 MHz PWM carrier whose
duty cycle is the volume (the comm register), while the 24-bit counter runs the
note oscillator — the CPU just retunes preloads and walks envelopes. Presets,
a little arpeggiator, then a demo mode with song tables, and
suddenly the debug console had a set list.
Somewhere in here I started nudging the system clock: 90 MHz runs clean on my board; 91 does not. A 40% overclock on TinyTapeout silicon is a nice margin to know about when you're counting cycles. But for now, we are sticking with 64MHz.
DSM audio is a megabit per second — flash fills fast. Like 5MB for 30s of audio! So: write a small LZ4-class decompressor ("L4Z"), stream-decompress from flash to RAM in the background of an interrupt-driven player, save huge space. The decompressor worked beautifully! The compression ratio did NOT.
The streaming machinery survived the pivot, though — it became the backbone of everything in section 0x09.
Reflashing through a browser gets old by the third iteration, so the weekend
got a command-line tool: tqv.py flash programs the QSPI flash
through the RP2350 and drops you on the design's console;
load pushes a song into the spare 8 MB PSRAM bank over SPI at
roughly 100× UART speed; console reattaches anytime. The
underrated consequence: the whole board became scriptable. Regression
tests type commands and assert on output — and an AI assistant spent most of
the weekend flashing builds, driving the console, and measuring results with a
logic analyzer while I supplied taste and bug reports.
Staring at the synth chroma, it clicked: the fixed-function plumbing around the FSM is accidentally a perfect sample-playback engine. Three details conspire:
HOST_TOGGLE is a one-byte write that clears the interrupt,
toggles the host_in[0] input state notifying new data is ready
and loads count2's compare register at the same instant — an entire
"ack IRQ + deliver next sample" handshake in a single store (yes, this was deliberate during design).>=, not == — a level
compare, so a slightly late sample write can never miss its window. No
race, ever.That's a race-free 8-bit PWM DAC with single-byte sample delivery. I'd like to claim I planned it. I co-designed the register map while writing early drivers, and this is where that investment quietly paid out.
With the PCM chroma in hand, the codec question answered itself: IMA ADPCM
in independent 1024-sample blocks — 8.3× smaller than
DSM — decoded on the fly by the CPU with a fused lookup table. (A
"clever" branchless rewrite of the decoder was slower; on a core that
fetches over QSPI flash, instruction count is king and short branches are
cheap. Measure, don't guess.) The toolchain grew a --format adpcm
mode, and songs became .bin blobs you can park in PSRAM
(playr) or in the upper 8 MB of flash (playf), where
they survive power cycles. Three minutes of audio in 2.7 MB, zero underruns,
played by a peripheral that is nominally a state machine.
The endgame needed C++, so the SDK learned it: an .init_array
section in the linker script, a constructor walk in the runtime, the embedded
subset flags (no exceptions, no RTTI), and eventually a real heap carved out of
RAM A with operator new over newlib malloc. Along the way, two
finds worth admitting to:
uart_is_char_available() had been returning
inverted since forever — nothing in the pure-C code had ever
actually called it, so it sat there wrong and harmless like a trap waiting
for the first real customer._write() gained a stdout hook, so printf output
can be redirected wholesale — which is exactly what a full-screen UI needs to
capture legacy command output without rewriting every command.The finale. Years ago I wrote a curses-based debugger TUI for an earlier,
bigger PRISM living under NuttX — my own CTui framework, my
termcurses terminal driver I wrote and contributed to NuttX
years ago, pdcurses 3.4 in between. This weekend
that whole stack came to bare-metal TinyQV: threads stubbed out (the design's
poll loop never actually needed them), five POSIX calls shimmed onto the UART,
and the terminal itself answers the ESC[6n size query so the UI comes up at
your real window size. That's 8 years worth of code from different project
all wrapped into one tidy little package (yes, the TUI is from 2018 and termcurses
from 2021).
The old layering held up almost embarrassingly well —
the effort went into lifetime bugs the NuttX heap had been silently forgiving:
an uninitialized member that only mattered on a reused heap block, an
endwin() that stored through a freed-and-nulled pointer straight
into the flash address window and wedged the bus.
On top sits a new CPrism backend wired to the real SDK:
▶ media/theTUI.mov — Full TUI demo in browser playing synth(s) and audio
Every console command with tab completion and history, a live watch window
on the FSM state, chroma .lst listings in tabs with the halted
state highlighted as you single-step, the Verilog sources with syntax
highlighting for reference — and a Notes tab where the synth streams its note
progression in real time, melody notes in bright yellow. The same hardware
debug registers from section 0x02, wearing a much better suit.
Still open: a selftest resume quirk in the config/debug corner that doesn't reproduce interactively, and the STEW mapping rules deserve real documentation before the next chroma author (probably me) rediscovers them the hard way. But right now there's an eight-state FSM on my desk running ZZ Top through a low-pass filter, and a full-screen debugger watching it happen over one UART. Good weekend.