FPGA-based SPI flash emulator for the Sipeed Tang Nano 20K board using the GW2AR-18's internal 64Mbit (8MB) SDRAM.
This is a port of the ULX3S SPI flash emulator to the Tang Nano 20K, built entirely with the open-source toolchain (Yosys + nextpnr-himbaechel + Apycula).
| Chip | FLASH_CHIP | Size | Address Mode | Notes |
|---|---|---|---|---|
| Winbond W25Q64FV | 0 (default) | 8MB | 3-byte only | Fits in available SDRAM |
| Micron N25Q256A | 1 | 32MB | 3/4-byte | Exceeds SDRAM (only 8MB usable) |
The Winbond W25Q64FV is the default and recommended chip to emulate, as its 8MB capacity matches the available SDRAM. The Micron N25Q256A emulation is retained for compatibility but only the first 8MB is actually stored.
- Emulates Winbond W25Q64FV (8MB) or Micron N25Q256A (32MB) SPI flash
- 3-byte addressing (4-byte mode available for Micron chip only)
- Handles SPI clock speeds up to 48 MHz
- 3 Mbaud UART interface for loading flash contents
- Uses internal 64Mbit SDRAM (no external memory needed)
- Supports READ, WRITE, ERASE, READ ID, and READ STATUS commands
- Sipeed Tang Nano 20K (GW2AR-LV18QN88C8/I7)
- USB-C cable for programming and UART communication
Connect these pins to your SPI master device:
| Signal | FPGA Pin | Board Location | Description |
|---|---|---|---|
| CS | 73 | Edge connector | Chip Select (active low) |
| CLK | 74 | Edge connector | SPI Clock |
| MOSI | 75 | Edge connector | Master Out, Slave In |
| MISO | 76 | Edge connector | Master In, Slave Out |
| POWER | 77 | Edge connector | Power detection (active high) |
| DEBUG | 27 | Edge connector | Debug output |
The POWER pin should be connected to the SPI master's power supply (via voltage divider if needed) to detect when the master is powered on. This enables proper reset sequencing.
The UART is exposed via the onboard BL616 USB debugger:
| Signal | FPGA Pin | Description |
|---|---|---|
| TX | 69 | FPGA transmit (to host) |
| RX | 70 | FPGA receive (from host) |
Settings: 3,000,000 baud, 8N1 (8 data bits, no parity, 1 stop bit)
| Signal | FPGA Pin | Description |
|---|---|---|
| LED[0:5] | 15-20 | Status LEDs (active low) |
| BTN S1 | 88 | User button 1 |
| BTN S2 | 87 | User button 2 |
Install the open-source FPGA toolchain:
- Yosys - Verilog synthesis
- nextpnr-himbaechel - Place and route with Gowin support
- Apycula - Gowin bitstream tools (gowin_pack)
- openFPGALoader - FPGA programming
Or use the provided Nix flake:
nix developmake # Build bitstream (Winbond W25Q64FV, default)
make FLASH_CHIP=1 # Build for Micron N25Q256A
make prog # Program FPGA (volatile - lost on power cycle)
make flash # Program to flash (persistent)
make tool # Build spi-flash-tool
make clean # Clean build artifactsThe emulated flash chip is selected at build time via the FLASH_CHIP variable:
# Winbond W25Q64FV (8MB, 3-byte address) - default
make FLASH_CHIP=0
# Micron N25Q256A (32MB, 3/4-byte address)
make FLASH_CHIP=1Run make clean before switching between chip configurations.
Use the serial interface to load data into the emulated flash before connecting to the SPI master.
The spi-flash-tool provides a convenient CLI for interacting with the flash emulator:
# Build the tool
make tool
# List available serial ports
./tool/target/release/spi-flash-tool ports
# Check connection and protocol version
./tool/target/release/spi-flash-tool -p /dev/ttyUSB0 version
# Load a firmware image
./tool/target/release/spi-flash-tool -p /dev/ttyUSB0 load firmware.bin
# Load with verification
./tool/target/release/spi-flash-tool -p /dev/ttyUSB0 load -v firmware.bin
# Load to a specific address
./tool/target/release/spi-flash-tool -p /dev/ttyUSB0 load -a 0x10000 firmware.bin
# Read and display memory (hex dump)
./tool/target/release/spi-flash-tool -p /dev/ttyUSB0 read 0x0 256
# Read memory to file
./tool/target/release/spi-flash-tool -p /dev/ttyUSB0 read 0x0 0x100000 -o dump.bin
# Dump memory to file
./tool/target/release/spi-flash-tool -p /dev/ttyUSB0 dump -a 0x0 -l 0x100000 dump.bin
# Write hex data directly
./tool/target/release/spi-flash-tool -p /dev/ttyUSB0 write 0x0 "deadbeefcafebabe"| Command | Description |
|---|---|
version |
Get protocol version from device |
load <file> |
Load a file into flash memory |
read <addr> <len> |
Read memory and display/save |
write <addr> <hex> |
Write hex data to memory |
dump <file> |
Dump memory region to file |
ports |
List available serial ports |
| Option | Description |
|---|---|
-p, --port <PORT> |
Serial port (default: /dev/ttyUSB0) |
-a, --address <ADDR> |
Start address (hex or decimal) |
-v, --verify |
Verify after writing (for load) |
-o, --output <FILE> |
Output file (for read) |
-l, --length <LEN> |
Length in bytes (for dump) |
The UART runs at 3 Mbaud (8N1). The following commands are available:
| Command | Description |
|---|---|
0x30 |
Get protocol version (returns 0x01) |
0x31 |
Read data from SDRAM |
0x32 |
Write data to SDRAM |
Both read and write commands are followed by 4 bytes:
- Bytes 0-2: Address (MSB first, in 8-byte units)
- Byte 3: Length (in 8-byte units, 0 = 8 bytes, 255 = 2048 bytes)
Read: Returns the requested data immediately after the command.
Write: Send the data after the command bytes. A 0x01 byte is returned when the write completes.
import serial
ser = serial.Serial('/dev/ttyUSB0', 3000000, timeout=1)
def write_block(addr, data):
"""Write up to 2048 bytes at addr (must be 8-byte aligned)"""
addr_units = addr // 8
len_units = (len(data) // 8) - 1
cmd = bytes([0x32,
(addr_units >> 16) & 0xFF,
(addr_units >> 8) & 0xFF,
addr_units & 0xFF,
len_units])
ser.write(cmd)
ser.write(data)
ser.read(1) # Wait for completion byte
# Load firmware.bin
with open('firmware.bin', 'rb') as f:
data = f.read()
for offset in range(0, len(data), 2048):
block = data[offset:offset+2048]
if len(block) % 8:
block += b'\xFF' * (8 - len(block) % 8)
write_block(offset, block)
print(f"Wrote {offset + len(block)} / {len(data)} bytes")
ser.close()| Command | Code | Description | Chip |
|---|---|---|---|
| READ | 0x03 | Read data (3-byte address) | Both |
| READ4 | 0x13 | Read data (4-byte address) | Micron only |
| FAST_READ | 0x0B | Fast read with dummy byte | Both |
| FAST_READ4 | 0x0C | Fast read (4-byte address) | Micron only |
| READ_ID | 0x9E/0x9F | Read JEDEC ID | Both |
| READ_STATUS | 0x05 | Read status register | Both |
| WRITE_ENABLE | 0x06 | Enable writes | Both |
| WRITE_DISABLE | 0x04 | Disable writes | Both |
| PAGE_PROGRAM | 0x02 | Program page (256 bytes) | Both |
| SECTOR_ERASE | 0x20 | Erase 4KB sector | Both |
| BLOCK_ERASE_32K | 0x52 | Erase 32KB block | Both |
| BLOCK_ERASE_64K | 0xD8 | Erase 64KB block | Both |
| CHIP_ERASE | 0x60/0xC7 | Erase entire chip | Both |
| EN4B | 0xB7 | Enter 4-byte address mode | Micron only |
| EX4B | 0xE9 | Exit 4-byte address mode | Micron only |
- Page program overwrites entire page: The current implementation writes all 256 bytes of a page, even if fewer bytes were sent. Partial page programming is not supported.
- No flash semantics for page program: Real flash can only change 1s to 0s during page program (bits must be erased to 1 first). This emulator overwrites bytes unconditionally.
TODO:
- Support partial page program (only write bytes that were actually received)
- Emulate flash bit semantics (only allow 1→0 transitions during page program, require erase for 0→1)
- Clock: 27 MHz input, PLL generates ~132 MHz for SDRAM
- SDRAM: Internal 64Mbit (8MB) with 32-bit data bus
- Storage: Data is interleaved in SDRAM for optimized SPI read timing
- Timing: Main clock 167 MHz max, SPI clock 198 MHz max (both pass timing)
| File | Description |
|---|---|
top.v |
Top-level module with I/O and interconnects |
spi_trx.v |
SPI transceiver and command decoder |
sdram.v |
SDRAM controller (adapted for 32-bit internal SDRAM) |
glue.v |
Serial protocol handler and write buffer |
uart.v |
UART transmitter/receiver |
fifo.v |
FIFO buffer for UART |
pll.v |
PLL configuration (27 MHz -> 132 MHz) |
util.v |
Utility modules |
- Load before connecting: Load flash contents via UART before powering on the SPI master
- Power sequencing: The POWER pin detects when the SPI master is powered, triggering proper initialization
- Serial conflicts: Do not use the serial interface while the SPI bus is active
- Voltage levels: All I/O is 3.3V LVCMOS
- Uses internal 32-bit SDRAM instead of external 16-bit SDRAM
- PLL adapted for 27 MHz input (was 25 MHz)
- Removed ECP5-specific IO primitives for portability
- Pin assignments for Tang Nano 20K edge connector
See original project for license information.
Based on the ULX3S SPI flash emulator, which was originally inspired by spispy.