English · Українська
A self-contained RISC-V Linux machine built from a Sipeed Tang Primer 20K + Dock — a computer whose CPU is synthesized into the FPGA, with its console on the dock's little LCD and a USB keyboard as its only input.
No hard CPU, no vendor SoC — the processor is a VexRiscv-SMP soft core (rv32ima + MMU) generated by LiteX into a Gowin GW2A-18 fabric. It runs mainline 32-bit RISC-V Linux out of the SOM's 128 MB DDR3, paints a framebuffer console on the 480×272 panel, and takes keystrokes from a USB keyboard plugged into the dock's USB-C OTG port. The last part is the interesting one: the dock's USB3317 PHY is wired for 6-pin ULPI serial mode, so the USB host is driven by a hand-written serial-mode shim in front of a LiteX OHCI core — as far as I can tell, the first working USB host through this dock's PHY.
root@buildroot:~# help
Built-in commands:
. : [ [[ alias bg break cd chdir command continue echo eval exec
exit export false fg getopts hash help history jobs kill let ...
root@buildroot:~# uname -m
riscv32
| Booted, logged in, running a shell — typed on the USB keyboard, shown on the LCD |
|---|
![]() |
The panel is a real Linux virtual terminal: fbcon over a simple-framebuffer, a getty
login, and the Tux boot logo. The keyboard enumerates as a normal USB HID device through the
custom host and feeds the console:
Most of the effort was in two places: getting DDR3 + the framebuffer to coexist without starving the CPU, and getting the panel to actually show Linux rather than raw memory. During bring-up I drove the debug loop with a webcam pointed at the LCD:
| Before — gateware scanning raw DRAM (wrong/absent DTB) | After — Linux framebuffer console |
|---|---|
![]() |
![]() |
The "before" is the scanout faithfully displaying uninitialized DRAM: the display path was fine,
but Linux hadn't been told about the framebuffer (the SD held a DTB from a USB-only build with no
simple-framebuffer node). Pairing the bitstream with its matching device tree fixed it.
- CPU / SoC — VexRiscv-SMP
rv32ima+MMU (single core, coherent DMA), assembled by LiteX. - DRAM — the SOM's 128 MB DDR3 via LiteDRAM +
GW2DDRPHY. This is why the build uses the Gowin toolchain: the open apicula flow has no DQS/DLL primitives, so it cannot build the DDR3 PHY. - Display — a custom scanout (
gateware/lcd.py) reads RGB565 from DRAM over a Wishbone DMA master and drives the panel through aVideoLCDPHY(DCLK inverted so the panel latches mid-eye, per the siblingtang_monitor). Linux sees it as asimple-framebuffer;fbcongives it a console. - USB host — a LiteX/SpinalHDL OHCI core at
0xc0000000(IRQ 16, DMA master), driven in Linux by the stockohci-platformdriver. Between OHCI and the USB3317 PHY sits the novel part (gateware/usb_host.py): an init FSM that walks the PHY into 6-pin serial mode by writing its ULPI Function/OTG/Interface-Control registers, plus a combinational shim that maps OHCI'sdp/dmdrive/receive onto the PHY's serial pins. No custom kernel driver — Linux thinks it's an ordinary OHCI controller. - Boot — LiteX BIOS →
sdcardbootreadsboot.jsonfrom the FAT card → OpenSBI → Linux → Buildroot initramfs (root=/dev/ram0). Console on both the LCD (console=tty0) and the BL702 UART.
See docs/pinmap.md for the audited LCD + ULPI ball map (the ULPI map was
recovered from the dock schematic and cross-checked against Sipeed's own USB example), and
docs/usb3317.md for the serial-mode register sequence.
The individual pieces are open source — LiteX, VexRiscv, a LiteX OHCI core, the USB3317 PHY. What this project puts together, and what (as far as I can find) hasn't been shown before, is:
- A USB host through the Tang Primer 20K dock's USB3317 PHY. The dock wires the PHY for the
6-pin ULPI FS/LS serial mode (only the serial data/SE0 lines are routed, not the full 8-bit
ULPI bus), and Sipeed's own examples use it only as a USB device. Here it runs as a host: a
small init FSM walks the PHY into serial mode over ULPI, and a combinational shim maps a stock
LiteX OHCI core's
dp/dmonto the PHY's serial pins. Linux drives it with the in-treeohci-platformdriver — no out-of-tree kernel code. - 6-pin serial mode, between the two usual approaches. FPGA USB hosts normally either speak the full 8-bit ULPI protocol or bit-bang raw D+/D−. This sits in between: the PHY does the analog FS/LS signaling and the fabric only has to bridge OHCI to six pins.
- The whole machine from open tooling. A CPU you synthesize yourself, running mainline 32-bit RISC-V Linux, with its console on the dock's LCD and a USB keyboard as input — no hard CPU, no vendor SoC. (The one closed piece is DDR3: the open apicula flow has no DQS/DLL primitives, so the DDR3 PHY is built with Gowin EDA.)
- A reproducible bring-up trail. Every milestone was verified on real hardware and logged — including the bug that made it work: the serial-mode ULPI register write was missing its terminating STP, found by synthesizing a LiteScope logic analyzer directly onto the ULPI wire.
Each step was verified on hardware; the gory details and dead-ends are in
docs/bringup_log.md.
| # | Milestone | The hard part |
|---|---|---|
| M0 | Toolchain + DDR3 memtest | apicula can't build GW2DDRPHY → Gowin EDA runs headless in Docker under Rosetta. |
| M1 | Custom SoC boots the LiteX BIOS | Reuse the board's CRG; add LCD + USB clock domains. |
| M2 | Linux to serial console from SD | DRAM aliased at +128 MiB (1 Gbit chip); OpenSBI hardcodes the UART at 0xf0001000. |
| M3 | LCD framebuffer console | Native scanout DMA deadlocked the CPU on the DRAM crossbar → switched to a Wishbone fetch at a lower pixel clock. |
| M4 | ULPI register access to the USB3317 | Confirmed PHY vendor ID 0x0424 and the serial-mode register bits over the BIOS console. |
| M5 | USB keyboard types into Linux | The serial-mode register write was missing its terminating STP — found by synthesizing a LiteScope logic analyzer onto the ULPI wire. A free-running RESETB pulse recovers the PHY from a stuck SE1 state. |
The turning point at M5 was LiteScope: register polling could never have shown that the PHY
never committed the serial-mode switch because the ULPI write never asserted STP. With that fixed,
a Keychron K3 Pro enumerated and typed hello into /dev/input/event0.
- Dock switch SW2 → host position (straps the USB3317 ID pin; host mode is otherwise pure gateware).
- Keyboard on a USB-C(male) → USB-A(female) OTG adapter into the dock's OTG port. VBUS is hardwired to +5 V on the dock, so no power bodge is needed.
- Dock DIP switch #1 DOWN for SRAM (JTAG) configuration.
- microSD in the SOM slot.
. ./env.sh # PATH: oss-cad-suite, openFPGALoader, LiteX venv, riscv gcc
make doctor # sanity-check tools + board
# 1. Gateware — the full SoC (VexRiscv-SMP + framebuffer + USB host). Uses the Gowin
# toolchain (DDR3); runs in Docker/Rosetta via scripts/gw_sh.
make soc-full # -> build/soc-full/gateware/sipeed_tang_primer_20k.fs
# 2. Linux — kernel + OpenSBI + Buildroot rootfs, built in a container.
linux/build_linux.sh # -> linux/out/{Image,opensbi.bin,rootfs.cpio}
# 3. Device tree — adds console=tty0 + loglevel=3, then compiles.
make dtb # soc/make_dtb.sh build/soc-full -> images/rv32.dtb
# 4. SD image, then write it to a card (dd the image to your reader).
make sd # -> linux/out/sdcard.img
# 5. Configure the FPGA over JTAG and open the console.
make load-full # SRAM-load the bitstream (DIP #1 DOWN)
make term # serial consoleUpdating just the DTB / kernel on a card that's still in the SOM: scripts/push_to_sd.sh <file>
(ZMODEM over the console) or scripts/push_to_sd.py <file> rv32.dtb (base64, no host deps).
gateware/ LiteX SoC + the custom gateware
target.py SoC top / build target (--with-video-fb, --with-usb-host, ...)
lcd.py LCD scanout (Wishbone DMA) + VideoLCDPHY (inverted DCLK)
usb_host.py USB3317 ULPI 6-pin serial-mode shim + OHCI integration ← the novel part
ulpi.py ULPI register-access core (M4 PoC + init FSM)
tc_ios.py audited LCD + ULPI pin map
soc/make_dtb.sh generate + patch (console=tty0, loglevel=3) + compile the DTB
linux/ Buildroot defconfig, rootfs overlay (panel getty), build driver, Dockerfiles
scripts/ board.py, lxterm, make_sd_image.sh, push_to_sd.*, panel_photo.sh, ulpi_probe.py
diagnostics/ one-off serial/VT bring-up scripts (reference only)
docs/ pinmap.md, usb3317.md, bringup_log.md, images/
Makefile env.sh
- USB typing reliability under load. Typing is clean on a fresh boot, but heavy framebuffer DMA competes with the OHCI controller for DRAM bandwidth (boot is noticeably slower with the panel active than in the USB-only build). Giving the OHCI DMA higher crossbar priority — or adding a line buffer so the framebuffer bursts less often — is the next gateware step. A powered USB hub also helps in the meantime.
- The bitstream is volatile. It's SRAM-loaded over JTAG (
make load-full), so a cold power cycle loses it. Writing it to the dock's SPI config flash (openFPGALoader -f, boot-mode DIP in the flash position) would make the machine boot to Linux on power-up with no laptop. - Boot is slow (a few minutes) — a direct consequence of the framebuffer sharing DRAM with the CPU.
Built on LiteX, VexRiscv, linux-on-litex-vexriscv, and the open FPGA toolchain (yosys, nextpnr, apicula). The audited LCD pin map and DCLK-inversion trick come from the sibling project tang_monitor. DDR3 gateware is built with Gowin EDA (Education).
MIT.




