[back] switch on a retro radio

Servant inside a FPGA inside a FPGA

A Gowin FPGA, modelled tile by tile, built into a Xilinx Artix-7 on a Digilent Arty A7-100T. After the Arty is programmed, the emulated Gowin chip is still blank. A Gowin bitstream, made with the open Gowin toolchain (yosys, nextpnr, apicula), is shifted into it over the Arty's USB-UART. That bitstream holds SERV, a bit-serial RISC-V, cut down to fit, which then prints

Hi, I'm Servant!

from inside the emulated Gowin chip, from inside the Artix-7.

Download: l8servant-dist.tar.gz: the Vivado project (vivado/top.v, vivado/gw_servtiny_arty.v, the constraints and the flow), the prebuilt bitstream bit/top.bit, the shipped Gowin configuration, the host tools, the toolchain script and its patches, and the sources.

tar xzf l8servant-dist.tar.gz && cd l8servant
make run              # program the Arty, shift the Gowin configuration in, print what SERV sends

Contents

  1. What runs where
  2. Running it
  3. The emulated Gowin: GW1NZ-1-trim10-s2
  4. The fabric as components
  5. Loading a bitstream: the shift chain
  6. The program, and how SERV was made small
  7. Your own designs: the patched toolchain
  8. What is implemented, what could be emulated
  9. The way to an ASIC
  10. Files

What runs where

Three layers. The Artix-7 is real silicon. The Gowin is a model, built out of Artix-7 LUTs and flip-flops. SERV is a Gowin bitstream: it exists only as the values of the Gowin's configuration bits.

 ┌───────────────────────────────────────────────────────────────────────────────┐
 │ Arty A7-100T: Xilinx XC7A100T (bit/top.bit)                                   │
 │                                                                               │
 │  USB-UART rx ──>┌─────────────────┐  52 120 bits  ┌─────────────────────────┐ │
 │  1 Mbaud        │ uart_loader     ├──────────────>│ GW1NZ-1-trim10-s2       │ │
 │                 │ shift chain +   │  2 x 512 rows │ the emulated Gowin      │ │
 │                 │ block RAM rows  ├──────────────>│ 9 x 10 tiles            │ │
 │                 └────────┬────────┘               │                         │ │
 │                          │ done: GSR off          │  ┌───────────────────┐  │ │
 │                          └───────────────────────>│  │ servtiny (SERV)   │  │ │
 │  100 MHz ──> 8 MHz ──> / 16 ──> pin 47 (clock) ──>│  │ a Gowin bitstream │  │ │
 │                                                   │  └───────────────────┘  │ │
 │  USB-UART tx <── uart_retime <── pin 6 (tx) ──────┤                         │ │
 │  115200 baud     (~1 792 baud)                    │                         │ │
 │  LED 0       <────────────────── pin 8 (led) ─────┤                         │ │
 │                                                   └─────────────────────────┘ │
 └───────────────────────────────────────────────────────────────────────────────┘

The model is never a netlist of the loaded design. Every routing multiplexer, every LUT, flip-flop, carry chain and block RAM of the Gowin tile is built, whether the bitstream uses it or not, and only the configuration decides what they do. Any bitstream for this chip that the toolchain can place runs, without rebuilding the Arty side.

SERV bit-bangs its UART, so its baud rate is the fabric clock divided by its loop (~1 792 baud here). The Arty side re-times it (uart_retime: an 8N1 receiver at the design's bit time, a one-byte buffer, a transmitter at 115200), so the host hears a standard rate with any terminal.

Running it

What you need: an Arty A7-100T on USB, Linux, python3 with pyserial, and permission to use the serial port (the dialout group). The archive carries its own openFPGALoader; if it does not run on your host, it is built from the source in tools/src/ (or install openfpgaloader from your distribution).

There are two FPGAs to program, one inside the other:

 your PC ── USB (JTAG) ──> step 1: the Arty's FPGA (Xilinx Artix-7)    <- bit/top.bit:  the emulated Gowin FPGA
         ── USB (UART) ──> step 2: the Gowin FPGA emulated inside it   <- cfg/<design>: a Gowin bitstream

Step 1, make prog-arty (= make prog): openFPGALoader writes bit/top.bit into the Artix-7 over JTAG. The Arty now holds an emulated Gowin GW1NZ-1-trim10-s2, still blank (LED 1 off). This is needed once after power-up; the Artix-7 forgets it when the power goes.

Step 2, make prog-gowin: tools/send_fs.py sends a Gowin bitstream over the Arty's USB-UART at 1 Mbaud into the emulated Gowin's configuration port: the SYNC word, the 52 120 configuration bits, the block RAM rows (11 635 bytes, 0.12 s). The Gowin design starts at once (LED 1 on). Step 2 can be repeated as often as you like, with the same or another design: a new SYNC resets the emulated Gowin and loads the new bitstream, no step 1 needed.

make run                          # the menu: step 1, then choose a Gowin bitstream for step 2 (and another, ...)
make prog-arty                    # step 1 only
make prog-gowin                   # step 2: "Hi, I'm Servant!" (DESIGN=servtiny, the default), then listen
make prog-gowin DESIGN=blinky     # step 2: the LED blinker (LD4 blinks about once a second)
make prog-gowin FS=top.fs PNR=pnr.json   # step 2: your own Gowin design (see "Your own designs")
make reset-gowin                  # reset the emulated Gowin to a blank chip (an empty configuration)
make listen                       # only listen at 115200
make bit                          # rebuild bit/top.bit with Vivado from vivado/ (about 40 minutes)

SERIAL=<FTDI serial> picks one board of several, TTY=/dev/ttyUSBn the port, SECS= the listening time.

On the board:

LED 0 the Gowin design's LED pin (pin 8)
LED 1 the fabric is configured
LED 2 the fabric clock divided by 2^16: blinks while the Gowin runs
LED 3 block RAM rows have arrived
BTN 0 held: SERV's reset (pin 5 low)
BTN 1 the loader starts over (the same as a new SYNC from make prog-gowin), the fabric back to its global set/reset

The loader holds SERV in reset for about half a second after the last byte, so the host has time to switch its port from 1 Mbaud to 115200 before the first character. BTN 0 restarts SERV: it prints again. Any terminal at 115200 8N1 works for listening (picocom -b 115200 /dev/ttyUSB1).

On the Arty A7-100T (2026-10-27): make run programs the board, shifts the 11 635 bytes of configuration in (0.12 s at 1 Mbaud) and listens at 115200:

cfg/servtiny/servtiny.hex: 52120 configuration bits (6515 bytes) + 1024 block RAM rows = 11635 bytes
sent in 0.12 s
Hi, I'm Servant!

[17 bytes received at 115200 baud]

BTN 0 resets SERV: it prints again.

The emulated Gowin: GW1NZ-1-trim10-s2

The base is the Gowin GW1NZ-1 (1 152 LUT4, 864 flip-flops, 4 block RAMs, 11 x 20 tiles), the chip of the Sipeed Tang Nano 1K. It does not fit an Artix-7 100T as a whole: built tile by tile it needs about 176 000 LUT6. So the emulated chip is a virtual Gowin, made from GW1NZ-1 by chipdb/variant.py, which rewrites apicula's chip database:

        col 0   col 1   col 2   col 3   col 4   col 5   col 6   col 7   col 8   col 9
       ┌───────┬───────┬───────┬───────┬───────┬───────┬───────┬───────┬───────┬───────┐
 row 0 │ corner│       │       │       │       │       │ IO AB │ IO AB │ IO AB │ IO AB │
       ├───────┼───────┼───────┼───────┼───────┼───────┼───────┼───────┼───────┼───────┤
 row 1 │  left │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │
 row 2 │  left │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │
 row 3 │  left │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │
       ├───────┼───────┴───────┴───────┼───────┴───────┴───────┼───────┼───────┼───────┤
 row 4 │  left │ BSRAM 0 (3 tiles)     │ BSRAM 1 (3 tiles)     │   -   │   -   │   -   │
       ├───────┼───────┬───────┬───────┼───────┬───────┬───────┼───────┼───────┼───────┤
 row 5 │  left │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │
 row 6 │  left │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │
 row 7 │  left │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │  CFU  │
       ├───────┼───────┼───────┼───────┼───────┼───────┼───────┼───────┼───────┼───────┤
 row 8 │ corner│ bottom│ bottom│ bottom│ bottom│ bottom│ bottom│ bottom│ bottom│ corner│
       └───────┴───────┴───────┴───────┴───────┴───────┴───────┴───────┴───────┴───────┘
GW1NZ-1 GW1NZ-1-trim10-s2
tiles 11 x 20 = 220 9 x 10 = 90, 21 tile types
CFU (logic tiles) 144 54
LUT4 / flip-flops 1 152 / 864 432 / 324
block RAMs (18 Kbit) 4 2
package pins QFN48 8 of its QFN48 pins (IO pairs at the top)
routing mux sources up to 28 up to 10
nets 28 868 14 688
configuration bits in the model ~582 000 fuses 52 120 cells
IDCODE 0x0100681B 0x0100F81B (own)

The model keeps the Gowin's own structure: the same tiles, the same wire names, the same fuses. Its bitstreams are ordinary Gowin .fs files, written by gowin_pack for the virtual device.

The Artix-7 side (vivado/, 8 MHz host clock): 54 228 of 63 400 (85.53 %) LUTs, 48 497 of 126 800 (38.25 %) flip-flops, no block RAM tiles (the Gowin block RAMs are LUT-RAM columns); worst setup slack 0.446 ns (the fabric's own paths are false paths: the emulation samples them once a fabric half period, 16 host clocks).

The fabric as components

The fabric is a GENERATOR, not one hand-written chip. chipdb/extract.py reads the routing graph from nextpnr's own Gowin generator and the fuses from apicula; chipdb/gen_l8.py writes one component per tile type and the grid that wires them. The tiles are built from reusable components (src/model/components/):

component what it is
cfgbit a configuration word: a flip-flop per bit here; a latch or an SRAM cell in a real chip
luts/lut4 LUT4: 16 configuration bits, a 4-input mux
alu/alu the Gowin ALU mode of a LUT pair: LUT4 + LUT2 + hard carry, the ten vendor modes
ff/dff DFF / DFFE / DFFS / DFFR / DFFP / DFFC / DFFN...: one component, the mode in fuses
widemux/mux2_lut MUX2_LUT5..8, the wide multiplexers between LUTs
router/rmux a routing multiplexer as the silicon has it: each source selected by its own fuse pattern
router/smux the compact form used here: the fuse pattern stored as an index, one indexed mux
bsram/bsram the 18 Kbit block RAM: SP / SDP / DP, widths 1..36, read modes, byte enables, a load port
iob/iob an IO pair: input, output, output enable

A CFU tile = 8 LUT4 (4 slices, ALU-capable, wide muxes) + 6 flip-flops + its routing multiplexers (about 127 of them, each with its own fuses). The emulated flip-flops sample their ROUTED clock with the host clock: the fabric clock is a net like any other, it arrives through the Gowin's clock spines, and its edge is detected. That is why the fabric clock (500 kHz) is a fraction of the host clock (8 MHz): a routed clock must reach every flip-flop within one host clock, through up to dozens of emulated routing multiplexers.

Loading a bitstream: the shift chain

A Gowin .fs is turned into what the model stores by chipdb/fs_to_fuses.py: one bit per configuration cell (52 120, in the order of gen/.../fuses.txt), and the block RAMs' initial contents as 512 rows of 36 bits each (bsram_init.py is the inverse of apicula's init map). The routing fuses of each multiplexer are decoded to the index of the pattern they select (exact match, as the silicon does).

On the Arty, uart_loader is the configuration port, like a real FPGA's, in small:

 host (tools/send_fs.py)              Arty: uart_loader                      fabric
 ┌───────────────────────┐   1 Mbaud  ┌────────────────────────────┐
 │ 6 515 bytes:          ├───────────>│ 8N1 receiver               │
 │ the configuration     │            │ phase 0: each byte shifts  │ cells ┌───────────┐
 │ bits, LSB first       │            │ the chain by 8 cells       ├──────>│ tiles     │
 │                       │            │ phase 1: 5 bytes = a row,  │ rows  │ block RAM │
 │ 1 024 rows x 5 bytes  │            │ written by the load port   ├──────>│ load port │
 └───────────────────────┘            │ phase 2: GSR released      │       └───────────┘
                                      └────────────────────────────┘

11 635 bytes in all, about 0.12 s at 1 Mbaud. The host then switches its port to 115200 and listens. BTN 1 starts over: a new bitstream can be shifted in without reprogramming the Arty.

Default connections and the loops they close

A Gowin routing multiplexer has a default connection: with none of its fuses set, a LUT's D input, for example, is wired to the F output of a neighbouring LUT in the same CLS. nextpnr leaves unused LUT inputs alone, so in servtiny's bitstream hundreds of them carry such a default. Through a carry chain a few of them close a ring (F2 → D1 → COUT1 → CIN2 → F2): harmless in silicon, where the LUT ignores a don't-care input. In the emulation the truth table is a multiplexer tree over configuration flip-flops, so a toggling don't-care input can make it glitch, and the ring can oscillate. That was the last bug on the way to the first "Hi": the fabric simulated correctly, but on the Artix-7 the UART pin sat at 0.

chipdb/loops.py finds such rings in a configuration (strongly connected components of the switched-on routing and the LUT inputs the truth tables depend on). chipdb/fs_to_fuses.py --pnr pnr.json opens the default connections into LUT data inputs that nextpnr did not route (1 826 in servtiny), which leaves 0 loops. Clock enables and set/resets keep their defaults: nextpnr relies on them.

The program, and how SERV was made small

The program is SERV's own hello_uart.S, built from source with clang/lld for rv32i: a bit-banged UART on one GPIO bit (start bit, 8 data bits, stop bit, two nops a bit), 216 bytes.

#define GPIO_ADDR 0x40000000
#define HALT_ADDR 0x90000000

    /*
    a0 = GPIO address
    a1 = String address
    t0 = Character to write
    */

.globl _start
_start:
    /* Load gpio address to a0 */
    li  a0, GPIO_ADDR

    /* Set GPIO high initially */
    addi    t0, zero, 1
    sb  t0, 0(a0)

    /* Load string address to a1 */
    la  a1, str

next_char:
    /* Read char from string */
    lbu t0, 0(a1)

    /* If zero, we reached end of string and will exit the simulation */
    beqz    t0, halt

    /* Bitbanged UART loop */
    ori t0, t0, 0x100
    slli    t0, t0, 1
1:  sb  t0, 0(a0)
    srli    t0, t0, 1

    /*
     * Adding delay nops to achieve an approximate
     * baud rate of 57600 at 16MHz
    */
    nop
    nop
    bnez    t0, 1b

    /* Increase address to next char in string */
    addi    a1, a1, 1

    j   next_char

    /* Writing anything to HALT_ADDR will exit simulation */
halt:   li  t0, HALT_ADDR
    sw  zero, 0(t0)
    /*
     * Loop to prevent PC from keep reading memory in case
     * we run on real HW and not in simulation
    */
    j   halt
str:
    .section .data
    .string "Hi, I'm Servant!\n"

One bit is 279 fabric clocks (SERV takes 32+ clocks per instruction): at the fabric's 500 kHz about 1 792 baud, which the Arty side re-times to 115200. The same bitstream on the same emulated fabric, simulated with Verilator (every tile, every routing mux, no netlist of the design), sends H and i within the first 6 000 fabric clocks.

SERV's reference SoC, servant, does not fit 432 LUT4. The steps, each placed and routed by nextpnr:

step what changed LUT4 ALU DFF BSRAM
servant upstream SoC: SERV + CSRs, 4 KB RAM, timer, GPIO, register file inferred as DP x2 513 98 260 3
servant lean no CSRs (with_csr 0), 1 KB RAM; register file moved to ONE SDPB (serv_rf_ram_sdpb.v: GW1NZ-1 has no dual port at width 2) 361 64 209 2
servmin servile (SERV + its bus arbiter) + RAM + a 1-bit GPIO; no timer, no peripheral mux 347 30 176 2
servtiny serv_top with our own glue: SERV never fetches and loads at once, so instruction and data share ONE RAM port (address muxed, acked a clock later); GPIO on address bit 30; the GPIO bit stored inverted, so the UART line idles high without a set-type flip-flop 298 30 175 2

servtiny on GW1NZ-1-trim10-s2: 298 of 432 LUT4 (68 %), 175 of 324 DFF, both block RAMs, routed at 52.3 MHz (nextpnr seed 2; the 10-source muxes route it only with some seeds). Its RTL prints the same text with the same UART edges as servant under iverilog (designs/servant/sim).

Your own designs: the patched toolchain

Any design that fits 432 LUT4 / 324 DFF / 2 block RAMs and uses the 8 pins can be loaded. The toolchain is built from source, with two small patches that teach it the virtual chips:

toolchain/build.sh does all of it: it clones yosys, nextpnr and iverilog at the commits used here, fetches the apycula 0.35 source and Boost 1.86 / Eigen 3.4 sources, builds numpy, msgspec and fastcrc from source in a venv, applies the patches, writes the virtual chips and builds nextpnr for them. Nothing prebuilt is executed.

make toolchain                    # = sh toolchain/build.sh ~/git/gowin-tools ~/opt/gowin-tools  (~30 min)
make servtiny                     # SERV cloned at its pinned commit, servtiny.fs built again (same bytes)
make load-fs FS=out/servtiny/servant_gw1nz.fs PNR=out/servtiny/pnr.json

For a design of your own:

P=~/opt/gowin-tools
$P/bin/yosys -p "read_verilog top.v; synth_gowin -nolutram -top top -json top.json"
LD_LIBRARY_PATH=$P/lib $P/bin/nextpnr-himbaechel --json top.json --write pnr.json \
    --device GW1NZ-LV1QN48C6/I5 --vopt family=GW1NZ-1-trim10-s2 --vopt cst=top.cst
$P/venv/bin/gowin_pack -d GW1NZ-1-trim10-s2 -o top.fs pnr.json
make load-fs FS=top.fs PNR=pnr.json   # fs_to_fuses.py --pnr -> cfg/top/, then shifted in over the USB-UART

The pins that exist (designs/servant/servant_crop.cst): QFN48 pin 47 is the clock (500 kHz from the Arty), pin 5 the reset button (BTN 0, active low), pin 6 goes to the USB-UART's tx, pin 8 to LED 0. Use -nolutram: the LUT-RAM mode (RAM16SDP4) is not modelled yet.

What is implemented, what could be emulated

Implemented, and exercised by servtiny's bitstream:

Not modelled yet (they read 0 or are ignored): the LUT-RAM mode (RAM16SDP4), the PLL and the internal oscillator (OSCZ), DCS / DQCE clock selection and the clock dividers, the IO logic (IOLOGIC: DDR, serdes), IO standards and drive strengths, the user flash, JTAG and the vendor configuration commands.

What could be emulated with the same generator:

The way to an ASIC

The fabric is written as plain synthesizable RTL for a REAL chip, not only for emulation: the same components, emitted to Verilog, lint clean with Verilator and synthesize with yosys' generic flow. The steps from here to silicon:

  1. configuration cells: cfgbit is a flip-flop here. In an ASIC it becomes a latch or a 6T SRAM cell, written by a frame register and row enables (the Gowin's own SRAM configuration: frames of 480 or 608 bits), not a shift chain. 52 120 cells x 6T is small next to the logic.
  2. routing muxes: smux (indexed) is for the emulation; the silicon form is rmux, one pass-gate or transmission-gate per fused source, sized for the wire it drives. A custom cell per tile type, laid out once, abutted (the tiles are regular: 21 types for 90 tiles).
  3. the clock: the emulation samples the routed clock with a host clock. In silicon the clock spines are real clock trees driven from the global buffers, and the flip-flops are clocked by them; the model's clock path already follows the spines, taps and branches.
  4. block RAM: bsram becomes an SRAM macro (18 Kbit, two ports) from the PDK's memory compiler, with the model's mode logic around it.
  5. IO: the pads from the PDK's IO library, the IO standards and drive strengths added to iob.
  6. flow: an open PDK (SkyWater 130 or IHP SG13G2) with OpenROAD / OpenLane: the generator writes one tile type as a hard macro, the grid as a placement of macros. The open toolchain (yosys, nextpnr, apicula) keeps working unchanged: the chip's bitstream format is the Gowin's, by construction.
  7. verification: the model is the golden. The same .fs that runs here runs on the netlist of the ASIC in simulation, and on the real GW1NZ-1 for the designs that fit both.

The result would be an FPGA whose bitstream format and toolchain already exist: a Gowin-compatible open FPGA.

Files

path what
bit/top.bit the Arty bitstream: the emulated GW1NZ-1-trim10-s2 + the configuration port
cfg/servtiny/ the shipped configuration: servtiny.hex (52 120 bits), bsram_init.hex (1 024 rows)
vivado/ the Vivado project: top.v, gw_servtiny_arty.v (the fabric, emitted), top.xdc, top.tcl
tools/send_fs.py the host side of the configuration port, then the listener at 115200
tools/openFPGALoader the programmer (+ its source in tools/src/)
chipdb/ fs_to_fuses.py (.fs to configuration), loops.py (rings a configuration closes), bsram_*.py, variant.py (the virtual chips), patches/
toolchain/build.sh the open Gowin toolchain from source, patched
gen/gw1nz_1_trim10_s2_c/ the device's tables: fuse order, mux patterns, pads
designs/servant/ servtiny: the top, the register file on one SDPB, the pins, hello_uart.S, the RTL golden
src/model/ the model's sources: components, tiles, the generated device, the loader, the re-timer, the board top

SERV is by Olof Kindgren (ISC licence, designs/servant/SERV.LICENSE). apicula, yosys and nextpnr are their authors' (MIT / ISC); the patches here are small and under the same licences.

[back]