Device tree, AIC8800DC WiFi, and build-time image configuration
2026-09-30
This deck walks the actual port of scpcom/sophgo-sg200x-debian to the Pine64 Oz64 — an SG2000 board the upstream repo had never targeted.
Part I — The Repo and the Board
Part II — The Device Tree (the heart of the port)
Part III — WiFi Bring-Up (the hard part)
Part IV — Turning It Into an Appliance Image
preconfigure)Part V — Build, Flash, Debug
scpcom/sophgo-sg200x-debian produces Debian images for Sophgo cv181x / sg200x boards (Duo S, Duo 256, LicheeRV Nano, NanoKVM, …).
The build is a container pipeline:
Dockerfile bakes a builder image (toolchains, mmdebstrap, genimage, Debian build tools).scripts/Makefile (+ configs/chip/<chip>/chip.mk) drives:| Target | Produces |
|---|---|
linux |
linux-image-*-<board>.deb (vendor 5.10 kernel + DTB) |
fsbl |
cvitek-fsbl-<board>.deb (FSBL + OpenSBI + U-Boot → fip.bin) |
osdrv / middleware |
vendor kernel modules + ISP/media userspace |
image |
the flashable *_sd.img.lz4 |
Everything is selected by one make variable: make BOARD=oz64 image. The BOARD name selects a configs/<board>/ directory and nothing else.
A board is a directory. For configs/oz64/ it looks like:
| Path | Role |
|---|---|
settings.mk |
chip, arch, DDR config, packages, IMAGE_ADDITIONS |
dts/cv181x_milkv_duos_sd.dts |
the board device tree source |
linux/defconfig |
kernel .config (5.10 vendor tree) |
u-boot/defconfig, cvitek.h, cvi_board_init.c |
U-Boot config |
memmap.py |
DRAM map: ION/heap/FreeRTOS carve-outs |
partition_sd.xml |
on-disk partition table |
logo.jpeg |
boot splash |
Makefile copies the DTS into the kernel tree, builds it, and the board’s settings.mk decides which addons (scripts/addons/*) get baked into the rootfs — WiFi firmware, init scripts, meta-packages, …
The Oz64 is an SG2000 board, same SoC family as the Milk-V Duo S. So configs/oz64/ starts as a copy of configs/duos/ with small deltas:
| Setting | Value | Why |
|---|---|---|
CHIP |
cv181x |
same SoC family |
ARCH / BOOT_CPU |
riscv |
Oz64 boots the C906 |
DDR_CFG |
ddr3_1866_x16 |
same SiP DRAM |
UBOOT_BOARD |
milkv_duos_$(STORAGE_TYPE) |
reuse the Duo S U-Boot/fip |
ION_SIZE |
0 (arduino) / 74 (camera) |
second-CPU policy |
The bolded line is the trick: the Oz64 has no upstream U-Boot port, so it boots the Duo S bootloader, and the “board identity” lives entirely in the kernel device tree.
Compared with configs/duos/, the Oz64 config changes exactly these things:
Example of “actively wrong”: the Duo S usb-switch helper drives GPIO 510, which on the Oz64 is the WiFi CHIP_EN. Enabling USB host mode would switch the radio off. Removed from configs/oz64/settings.mk.
Also dropped: ethernet-leds (Duo S LED sysfs names) and hciattach-uart (Oz64 BT is SDIO, and disabled).
Embedded SoCs have non-discoverable memory-mapped hardware — no PCIe-style enumeration. The kernel cannot find devices by probing; it must be told.
The DTB (compiled from .dts/.dtsi) describes CPUs, memory, and every peripheral’s register range, interrupts, clocks and GPIOs. It travels: BL2 → OpenSBI → U-Boot → Linux (a1).
So for a board with no dedicated bootloader port, “porting the board” reduces to: give the kernel the right device tree, and reuse the rest.
configs/oz64/dts/cv181x_milkv_duos_sd.dts is the Duo S tree with a tiny diff. The whole WiFi-relevant change is:
That’s it: renamed model, and moved the WiFi power hog from PA15 to PA30. Everything else (MMC, I2C, SPI, CSI/DSI, LEDs, UARTs) is inherited.
What is gpio-hog? A DT node that tells the GPIO controller driver to claim a pin and drive it automatically at probe time, before any driver asks for it.
On the Oz64 schematic, the AIC8800DC’s CHIP_EN / WL_REG_ON is wired to the SG2000 pad AUX0 = XGPIOA[30]. The Duo S instead powers its WiFi from PA15 — which on the Oz64 is SPK_EN. So the inherited Duo S hog was driving a speaker enable, and the radio stayed off.
An SDIO card is enumerated by the MMC core at probe, before userspace exists. There is no “WiFi driver” yet to assert a power pin.
Ordering on this SoC:
CHIP_EN high.wifisd, mmc1) probes → sees the SDIO card.S25wifimod later insmods aic8800_bsp / aic8800_fdrv, which binds to the already-enumerated card.If step 1 is missing (or wrong pin), step 2 sees no card, and no amount of driver loading helps. This is why the bug presented as “the AIC BSP fails to set power state” rather than as a missing driver.
Even though the board is “Oz64”, the DTS is named cv181x_milkv_duos_sd.dts — and the built DTB is cv181x_milkv_duos_sd.dtb.
Why? Because the reused Duo S U-Boot selects its loaded DTB from fdtfile=cvitek/cv181x_milkv_duos_sd.dtb. If we renamed it, U-Boot would look for a DTB that does not exist. Keeping the filename means the Oz64 model and its wifi_chip_en hog ride along under the Duo S name.
The model string inside the DTB is what actually changes:
$ strings .../cv181x_milkv_duos_sd.dtb | grep -i oz64
Pine64 Oz64
$ strings .../cv181x_milkv_duos_sd.dtb | grep -i wifi_chip_en
wifi_chip_en
The rest of the tree is inherited and matters for a usable board:
| Node | Setting | Why it matters |
|---|---|---|
&wifisd |
status="okay", sd-uhs-sdr104, max-freq=187500000 |
SDIO WiFi bus (mmc1) |
&sd |
no-1-8-v |
microSD signalling |
leds |
porta 29, porte 6/8 |
activity / netdev / link |
&usb |
vbus-gpio = <&portb 6 0> |
USB host power |
&mipi_rx / &vo / &mipi_tx |
CSI / DSI | camera + panel path |
&uart1..4 |
okay | BT UART, console, headers |
The kernel defconfig is byte-identical to the Duo S. The Oz64 port needed no kernel config change — only the DTB and the out-of-tree WiFi driver.
The Oz64’s WiFi/BT is an AIC8800DC (2.4 GHz WiFi 6 + BT 5.2) behind the SDIO mmc1 host (wifisd@4320000).
Bring-up had two independent failures; fixing one without the other still left “no WiFi”:
CHIP_EN) was never asserted → no card on the SDIO bus.Symptom chain on the stock Duo S tree:
aicbsp: aicbsp_set_subsys, subsys: AIC_WIFI, state to: 1
aicbsp: aicbsp_platform_power_on
aicbsp: aicbsp_set_subsys, fail to set AIC_WIFI power state to 1
AICWFDBG(LOGERROR) rwnx_mod_init, set power on fail!
and ls /sys/bus/mmc/devices had no mmc1 — no SDIO card at all.
Root cause: the driver’s “power on” ultimately depends on the SDIO card being present; with CHIP_EN low, the card never enumerated, so BSP init failed at the first step.
Fix: the wifi_chip_en gpio-hog on XGPIOA[30] from Part II. With the pin driven high, mmc1 enumerates the card and the BSP’s power-on succeeds.
With the card powered, wlan0 was created — then the machine hung right after btsdio_init():
TDLS_SDIO_BT_SEND_CFM # lmac_msg id 3089
aicwf_sdio_hal_irqhandler ... intstatus=ff
The SDIO reads return -110 (ETIMEDOUT) forever; SSH dies (serial console survives).
The vendor driver was built with CONFIG_SDIO_BT=y — the AIC8800 Bluetooth-over-SDIO send path. On this DC part/firmware that path wedges the shared SDIO bus.
Fix: build the driver with CONFIG_SDIO_BT=n (and CONFIG_COEX=n), as an OSDRV patch applied at image build: configs/chip/sg200x/patches/osdrv/0001-aic8800-disable-sdio-bt.patch.
The driver lives in sophgo-osdrv (a separate repo the image build clones). We do not fork it; we ship a patch under configs/chip/sg200x/patches/osdrv/ that the Makefile applies during osdrv-prepare-patch:
--- a/extdrv/wireless/aic8800/aic8800_bsp/Makefile
+++ b/extdrv/wireless/aic8800/aic8800_bsp/Makefile
-CONFIG_SDIO_BT = y
+CONFIG_SDIO_BT = n
--- a/extdrv/wireless/aic8800/aic8800_fdrv/Makefile
+++ b/extdrv/wireless/aic8800/aic8800_fdrv/Makefile
-CONFIG_SDIO_BT=y
+CONFIG_SDIO_BT=n
-CONFIG_COEX = y
+CONFIG_COEX = nPattern to remember: the image repo is a patching layer. Board quirks become patches in configs/{common,chip/<chip>,<board>}/patches/<component>/.
Even with power and a good driver, the chip needs its firmware. The AIC BSP hard-codes a single directory:
and requests names such as fmacfw_8800dc_u02.bin, fw_patch_8800dc_u02.bin, aic_userconfig_8800dc.txt.
The stock image packaged aic8800/ and aic8800_and_aic8800D80/ but not the aic8800DC/ blobs the Oz64 actually needs — an upstream oversight.
Two init scripts do the userspace work (both in the load-systemko addon):
S00kmod — sets up /mnt/system/ko, then insmods the base and (if ion_size != 0) the ISP/vision/vcodec modules.S25wifimod — the WiFi stack:S00kmod gates the camera modules on the ION heap size — which ties directly into the second-CPU policy (SECOND_CPU=camera vs arduino) in Part IV.
After power + driver + firmware, the link came up but was unusable: associated, got DHCP, but RSSI ≈ -83 dBm, linkspeed 1 Mbps, and ping/ARP failed.
The Oz64 has no onboard WiFi antenna. The schematic routes the module’s RF_ANT through matching (L11, R51 0R) to a tiny external connector, part ZX-RF-3-Z1.30.85X = u.FL / IPEX MHF1 (Gen 1), ~3.0 mm across.
Attaching a 2.4 GHz u.FL antenna (stolen from a VisionFive 2) jumped the link to -53 dBm, 0% loss. Rule of thumb: a nearby 4-antenna AP should give ~-30…-45 dBm; ~40 dB low means no antenna, not “a small antenna”.
From a serial console / SSH:
ip -br link # wlan0 present?
lsmod | grep -E 'aic|cfg80211' # bsp + fdrv loaded
ls /sys/bus/mmc/devices/ # mmc1:xxxx (SDIO card)
dmesg | grep -iE 'aicbsp|mmc1|sdio' # power + enumeration
cat /proc/device-tree/model; echo # Pine64 Oz64
cat /sys/kernel/debug/gpio | grep wifi # gpio-510 (wifi_chip_en) out hiRather than post-editing a flashed card, the image is personalized at build time by a small addon, scripts/addons/preconfigure/addon.mk, driven by make variables:
| Variable | Writes | Effect |
|---|---|---|
IMAGE_HOSTNAME |
/boot/hostname |
exact hostname |
IMAGE_HOSTNAME_PREFIX |
/boot/hostname.prefix |
<prefix>-<hash> name |
WIFI_MODE |
/boot/wifi.sta / .ap |
station / AP / none |
WIFI_SSID / WIFI_PASS |
/boot/wifi.ssid / .pass |
credentials (empty ⇒ open) |
WIFI_WPA_CONF |
/boot/wpa_supplicant.conf |
full raw supplicant config |
SECOND_CPU |
/boot/arduino + ION_SIZE |
C906L policy |
On the board, /etc/hostname is not authoritative: S10uuid rewrites it every boot from a device-key hash (so you can’t just edit it).
So the persistent knobs are:
/boot/hostname → exact name (wins over the hash)/boot/hostname.prefix → just the prefixThe preconfigure addon writes /boot/hostname from IMAGE_HOSTNAME, so the build-time name sticks across reboots. The same script also sets the ethernet MAC (overridable via /boot/eth.mac).
S30wifi (from the wifi-builtin addon) reads the /boot/wifi.* files and generates the interface config:
/boot/wifi.ssid + /boot/wifi.pass → wpa-essid / wpa-psk in /etc/network/interfaces.d/wlan0wpa-key-mgmt NONE (open network)/boot/wpa_supplicant.conf → wpa-conf /etc/wpa_supplicant.conf/boot/wifi.ap → hostapd + udhcpd (access point)Two fixes made here:
echo -e bug — /bin/sh is dash, whose echo does not understand -e, so the generated file got literal -e prefixes and never associated. Replaced with printf.wpa-key-mgmt NONE instead of an empty wpa-psk (which wpa_passphrase turns into a bogus passphrase).After flashing, WiFi still failed to auto-connect. The config on disk was correct, but ifup@wlan0 had already run before S30wifi wrote it.
The cause was a typo in the systemd unit:
wifi0 is not a unit, so nothing ordered S30wifi ahead of ifup@wlan0. Both fired in the same second; ifup@wlan0 read the default iface wlan0 inet manual and never re-ran.
Evidence: ifup@wlan0 at 00:20:41, S30wifi wrote the ssid at 00:20:42; /run/network/ifstate already contained wlan0=wlan0.
A subtler usability bug: both NICs sent the same DHCP hostname, so the router’s DNS conflated them into one name with several (stale) records — resolvectl returned dead fd86::… ULAs, and ping <name> failed while ping -4 <name> worked.
preconfigure now edits /etc/dhcpcd.conf (each interface runs its own dhcpcd):
Result: oz64-nat.lan (eth) and oz64-nat-wifi.lan (WiFi), each with its own stable A/AAAA; <name>.local (mDNS) still resolves both.
The SG2000 has one small C906L core. It can run the vendor ISP or a user FreeRTOS/Arduino image, not both.
SECOND_CPU |
ION_SIZE |
IMAGE_ADDITIONS |
C906L |
|---|---|---|---|
camera |
74 |
sensor-config, tpusdk |
runs ISP |
arduino |
0 |
(none of the above) | free for remoteproc |
With ION_SIZE=0, chip.mk zeroes the ISP/H26X heap and S00kmod skips every ISP/vcodec insmod; the kernel still exports CONFIG_CVITEK_REMOTEPROC + mailbox, so Arduino firmware loads via /sys/class/remoteproc/.
Because the memory map changes, the two personalities are separate full image builds — not a runtime toggle.
Everything runs inside the builder container (paths like /configs, /builder, /rootfs are container paths):
Output: image/oz64-e_sd.img.lz4 (plus the .deb packages). Flash:
Note: a build that mounts scripts/ writable lets the toolchain step rewrite scripts/replace-all-*.sh; commit/stash or rebuild the builder image instead. (A local build-demo1.sh wraps docker build + make.)
When a ported board “doesn’t work”, peel the layers in order:
| Layer | Check | Tool |
|---|---|---|
| DTB applied? | model string | cat /proc/device-tree/model |
| Pin powered? | hog state | cat /sys/kernel/debug/gpio |
| Bus enumerates? | SDIO card | ls /sys/bus/mmc/devices |
| Driver loads? | modules | lsmod, dmesg \| grep aic |
| Interface up? | wlan0 |
ip -br link |
| Join + data? | RSSI / neighbor | wpa_cli signal_poll, ip neigh |
| Config applied? | ordering | systemctl show -p ExecMainStartTimestamp |
The failures we hit mapped cleanly onto this list: pin (DT), driver (CONFIG_SDIO_BT), firmware (missing blobs), physical (antenna), and ordering (systemd typo).
With both Ethernet and WiFi up on the same subnet, the board is multi-homed and default Linux behavior bites:
arp_ignore=0 → the board answers ARP for wlan0’s IP on end0, so the peer learns the wrong MAC.ping to the WiFi IP dies.Cleanest fixes, in order:
arp_ignore=1, arp_announce=2, and link metrics / policy routing.configs/<board>/ and picking addons.fdtfile resolves.XGPIOA[30] (GPIO 510).CONFIG_SDIO_BT=n driver patch, the AIC8800DC firmware blobs, and a physical antenna.Discussion
gpio set later needs an MMC rescan?fail to set AIC_WIFI power state) really a bus error here?CONFIG_SDIO_BT=n) better than forking it?Lab
dtc -I fs /proc/device-tree and find wifi_chip_en.configs/duos vs configs/oz64 and explain every line.Before=ifup@wlan0.service.CS 4250 · Porting a Debian image to the Pine64 Oz64