Tang Nano 20K — Milestone 0: first light¶
Proves the toolchain and console path before any CPU exists. The bitstream
echoes bytes over the USB serial link and blinks LED0. When this works, the P8X
core drops in where the echo logic sits in rtl/top.v.
Board: Sipeed Tang Nano 20K, Gowin GW2AR-LV18QN88C8/I7, 27 MHz clock.
Files¶
| File | What |
|---|---|
rtl/uart.v |
8N1 UART, TX + RX, DIV=234 (115200 @ 27 MHz) |
rtl/top.v |
echo + heartbeat, internal power-on reset |
tangnano20k.cst |
pin constraints (clk 4, uart_tx 69, uart_rx 70, led 15–20) |
Wiring¶
None. A single USB-C cable does both programming and the serial console, through the board's onboard BL616 USB↔JTAG/UART bridge.
Toolchain — open flow¶
Verified working 2026-08-12 on macOS 26 / Apple Silicon with oss-cad-suite 2026-08-12 (yosys 0.68, nextpnr 0.11.1, openFPGALoader 1.1.1).
Install YosysHQ oss-cad-suite (bundles yosys, nextpnr-himbaechel, apicula's
gowin_pack, and openFPGALoader): download the darwin-arm64 release tarball,
extract to ~/oss-cad-suite, then clear the Gatekeeper quarantine or every
binary fails to launch:
xattr -dr com.apple.quarantine ~/oss-cad-suite
No admin rights are needed — it is a self-contained tarball in your home directory, which is why it sidesteps the Homebrew permission problem entirely.
Then just:
./build.sh cpu # synthesize -> place & route -> pack
./build.sh cpu load # ... and load to SRAM (volatile)
./build.sh cpu flash # ... and write onboard flash (persistent)
./build.sh lcd load # the CPU plus the 480x272 graphics panel
./build.sh echo load # Milestone-0 UART echo, a known-good baseline
Give the target explicitly. The form is build.sh [echo|cpu|lcd]
[build|load|flash], and the first argument is the TARGET — it defaults to
echo, so a bare ./build.sh builds the Milestone-0 blinker, and
./build.sh load is rejected outright with "unknown target: load" because
load is an action in the second position. This block used to show exactly that
target-less form, left over from when there was only one design.
build.sh sources ~/oss-cad-suite/environment itself if the tools are not
already on PATH. The steps it runs, if you want them by hand:
# (the Milestone-0 echo design; the cpu/lcd targets pass more sources and -DLCD)
yosys -p "read_verilog rtl/top.v rtl/uart.v; synth_gowin -top top -json p8x.json"
nextpnr-himbaechel --json p8x.json --write pnr.json \
--device "GW2AR-LV18QN88C8/I7" \
--vopt family=GW2A-18C \
--vopt cst=tangnano20k.cst
gowin_pack -d GW2A-18C -o p8x.fs pnr.json
openFPGALoader -b tangnano20k p8x.fs
--vopt family=GW2A-18Cis required. Without it nextpnr stops with "For the GW2A series you need to specify --vopt family=GW2A-18 or --vopt family=GW2A-18C". The earlier version of this file omitted it.If the open-flow device strings fight you, the free vendor Gowin EDA IDE is the fallback: new project → device
GW2AR-LV18QN88C8/I7→ addrtl/top.v,rtl/uart.v,tangnano20k.cst→ Synthesize → Place & Route → Program.openFPGALoaderstill flashes it.
Resource use for that echo design is tiny — 219/20736 LUT4 (1%), 109/15552 DFF.
The real builds are much larger: cpu is 40/46 block RAMs, and lcd 42/46 with
the panel and the SD sector buffer. See The graphics panel below.
Connect a terminal¶
The BL616 bridge enumerates two serial devices. The higher-numbered one is the UART; the other is the JTAG side and returns garbage if you talk to it:
tools/term.py # exit: Ctrl-]
Convenient because it finds the console port itself (the bridge exposes two) and
quits with Ctrl-]. Nothing more than that: since 2026-08-13 CONOUT expands a
bare LF into CR LF in firmware, so P8X renders correctly on any terminal.
A flashed board keeps the ROM it was built with.
build.sh cpubakesemulator/eeprom.bininto the bitstream's BRAM, so a board flashed before that firmware change still emits bare LF. Rebuild and reflash to pick it up:./build.sh cpu flash.
A stock terminal works just as well:
ls /dev/cu.usbserial-*
# /dev/cu.usbserial-<N>0 <- JTAG (not this one)
# /dev/cu.usbserial-<N>1 <- console
screen /dev/cu.usbserial-<N>1 115200 # exit: Ctrl-A then k
# or: picocom -b 115200 /dev/cu.usbserial-<N>1
Use /dev/cu.*, not /dev/tty.* — the tty. node blocks on carrier detect.
Success looks like — confirmed on hardware 2026-08-12¶
Sending P8X Hello! to the console port returned P8X Hello! byte for byte.
- LED0 blinks ~once a second (heartbeat).
- Every character you type echoes back in the terminal (type at a human pace; this first-light echo drops a char if you paste a fast burst — that's fine, the real UART gets flow handling later).
That confirms clock, synthesis, bitstream, UART, and console — the whole substrate the P8X core will sit on.
Notes¶
- 115200 8N1. Baud is
clk / DIVin the UART; change bothDIVand the terminal to rescale. - Pins verified 2026-08 against Sipeed's pinout and a known-good project
.cst. - Reset button KEY2 is pin 87 — unused here;
top.vself-resets at power-on.
Simulation of the board build¶
sim/ holds benches for the board-level design — the things the co-sim in
../sim/ cannot cover, because they are about the substrate
(block RAM, a real UART, an actual SD card) rather than the CPU core.
| Bench | What it proves |
|---|---|
sim/tb_top.v |
Milestone-0 echo path at the real 115200-for-27MHz bit period |
sim/tb_p8x_top.v |
the whole board top: monitor banner out of the real UART, and P8X/OS booting off a modelled card |
sim/tb_video.v |
panel frame geometry: 480x272 active, 54.11 Hz |
sim/tb_scanout.v |
the framebuffer-to-panel mapping, pixel by pixel |
sim/sd_model.v |
a behavioural SPI microSD, serving a real disk image via $fseek |
sim/tb_sd_spi.v |
sd_spi.v's error paths, driven directly |
cd sim
iverilog -g2012 -o tb.vvp ../../rtl/p8x_cpu.v ../rtl/p8x_top.v ../rtl/uart.v \
../rtl/cf_sd.v ../rtl/sd_spi.v sd_model.v tb_p8x_top.v
vvp tb.vvp +sd=../../sim/work/disk.img # boots the OS in simulation
Making the card fail on purpose¶
A card model that always behaves only ever exercises the happy path, and that is
exactly where this controller had bugs. sd_model.v therefore takes +sdfail=N:
+sdfail= |
Injected fault |
|---|---|
0 (default) |
healthy card |
1 |
never initialises — ACMD41 reports busy forever |
2 |
never releases busy after a write |
iverilog -g2012 -o tb_sd_spi.vvp ../rtl/sd_spi.v sd_model.v tb_sd_spi.v
for m in 0 1 2; do vvp tb_sd_spi.vvp +sd=../../sim/work/disk.img +sdfail=$m; done
Both faults found a lockup that a healthy card never reveals:
sdfail=2—S_WBUSYwaited for the card to release busy with no bound. A card that dies mid-write left the controller busy forever: the CPU'sCFWAITgives up after ~4096 polls and reports an error, but the controller never returned to idle, so every later read and write was silently dropped. Now timed out.sdfail=1— a failed initialisation parked inS_ERRre-assertingdoneevery clock, which heldcf_sd's task file permanently reset, and there was no way out short of reloading the bitstream. A card inserted a second late stayed dead. Now backs off and retries the ladder.
The same work bounded ACMD41 by time rather than an arbitrary retry count: 4000 rounds is ~1.1 s at the init clock, which is the SD spec's own limit. The previous 20000 took 5.6 s, long enough that a missing card made the whole machine look hung.
The graphics panel (build.sh lcd)¶
build.sh cpu is unchanged and does not include the display. The panel is a
separate target:
./build.sh lcd load
The pinout and the panel timings are verified, taken from Sipeed's own
480x272 example for this board (TangNano-20K-example,
rgb_lcd/lcd_480_272/color_bar) rather than derived. Three things that example
settled, all three of which the earlier guesses had wrong:
- there is no HSYNC or VSYNC — their constraints file has pins for CLK, DEN and RGB only, so this is a DE-only panel;
- the real frame is 560x297, not 525x286 (H 480+50+30, V 272+20+5), which is 54.11 Hz, not 60;
- they clock it at 9 MHz via an rPLL with IDIV=2 — i.e. 27/3, exactly the divider the CPU already runs on, confirming no PLL is needed.
Pins: CLK 77, DEN 48, R 38–42, G 32–37, B 27–31, DRIVE=24 PULL_MODE=UP.
It fits with room to spare. Measured from the shipped build.sh lcd on the
sdram-framebuffer branch (now including the stage-8 MDU at $FF30 and the
stage-8b geometry engine at $FF40 — hardware muldiv, the 3D walker, and
page-flip double buffering; see sdram/STAGE8-DESIGN.md / STAGE8B-DESIGN.md):
lcd (with 3D engine) |
history | |
|---|---|---|
| BSRAM | 42 / 46 (91%) | 44 / 46 when the framebuffer was in block RAM |
| LUT4 | 10801 / 20736 (52%) | 7226 pre-3D; 15397 (74%) once would not place |
| DFF | 2815 / 15552 (18%) | 1581 pre-3D; 5657 in the FF-mapped-buffer era |
| Fmax | 47-60 MHz (PASS at 27) | 38.8 MHz |
Two changes account for the drop, and neither is a graphics change. The framebuffer moved out of block RAM into the in-package SDRAM, and the SD sector buffer stopped inferring 4,096 flip-flops (see below). At 34% LUT4 the placement cliff described in BACKLOG.md is no longer load-bearing — name mangling still perturbs the placer, it just no longer decides whether the build succeeds.
No PLL is needed: 480x272 at 60 Hz wants 9.009 MHz and 27/3 is 9.000, the same divide-by-three the CPU already runs on, so both rPLLs stay free for the Milestone-5 clock-up.
The 512-byte SD sector buffer is one BSRAM, and staying that way is a
constraint on how rtl/cf_sd.v is written. yosys maps an array onto a RAM
primitive only if it has ONE write port; the IDENTIFY command used to fill all
512 entries in a single cycle, which is 512 write ports, so the buffer fell back
to 4,096 discrete flops plus a 512-entry read mux — more than half the logic in
the design. The fill is now sequential and every writer muxes onto one port. If
you ever see using FF mapping for memory p8x_top.CF.buf_ in synth.log,
something has grown a second write port.
The section below describes the superseded block-RAM framebuffer (240x136, 2 bpp). The device is now single-mode 480x272 at 8 bpp with the framebuffer in SDRAM; see sdram/README.md.
The framebuffer cost 4 blocks (8160 bytes at 2 bits per pixel) and left two spare. The framebuffer shared ONE port between the drawing engine and the scanout. True dual port halves a Gowin block's usable depth, so 8160 bytes would have cost 8 blocks instead of 4 and the design would not have placed at 48/46. The scanout needed a byte only once per eight panel pixels, so the engine simply held for that cycle.
Two benches check this without hardware:
sim/tb_video.v— frame geometry: 480 active pixels a line, 272 lines, 498960 cycles a frame (54.11 Hz), scanout inside the framebuffer.sim/tb_scanout.v— the mapping: which framebuffer pixel reaches which panel pixel. Neither of the other tests covered that, which is how a shift-width bug that blanked half of every byte reached hardware.