Why I wrote this
I bought a ROCK 4D to run as a small “remote router” appliance — a box that connects home over WireGuard and rebroadcasts my home Wi-Fi networks on the far end. OpenWrt’s official rockchip/armv8 target already lists radxa_rock-4d as a supported device, so I expected this to be a fifteen-minute job: download the snapshot image, flash it to an SD card, done.
It was not a fifteen-minute job. The stock radxa_rock-4d target in OpenWrt 25.12 doesn’t boot a custom-built kernel at all (it hangs during the FIT image load, sometimes with silent corruption), and even once it boots, the onboard Wi-Fi chip never shows up as a network interface. Both problems have real, fixable causes, and I want to document them properly instead of just posting “here’s my image, download it” — because the second someone builds a slightly different kernel config, the “just take my binary” approach stops working, and because whoever hits this next deserves to understand why, not just get a patch dropped on them.
I worked through this together with Claude (Anthropic’s Claude Code), which did the git archaeology, disassembled the boot log line by line, and iterated on the driver source until the Wi-Fi chip actually associated to an access point. I’m writing this account in first person because I was the one at the console flashing SD cards and power-cycling the board, but a lot of the root-cause digging below is Claude’s work, and I want to be upfront about that rather than pretend I independently reverse-engineered a vendor Wi-Fi driver’s cfg80211 API usage.
This guide covers two independent problems:
- The board doesn’t boot a custom kernel image at all — a wrong
KERNEL_LOADADDRin OpenWrt’s device definition. - Wi-Fi never appears — a broken devicetree node plus a vendor driver that needs a small, out-of-tree package and a couple of patches to compile and run correctly against OpenWrt’s current wireless stack.
If you only care about the fix and not the story, skip to the “Summary of changes” section near the end — everything is there in one place, ready to copy.
Hardware and build environment
- Target board: Radxa ROCK 4D (RK3576 SoC), booting OpenWrt from a plain SD card (no eMMC/NVMe involved).
- Onboard Wi-Fi: AIC8800D80 (marketed by some vendors as “Quectel FCU760K”), attached over USB, not SDIO — this matters, because a lot of the AIC8800 driver packages floating around target the SDIO variant used on other boards, and that’s the wrong one here.
- OpenWrt branch:
openwrt-25.12(the 25.12-SNAPSHOT branch), built from the officialopenwrt/openwrtgit repository. - Build host: any reasonably capable Linux x86_64 or aarch64 machine works. I built on an aarch64 SBC with 8 GB RAM; if you’re on something similarly memory-constrained, cap the parallel build jobs (
-j4, not-j$(nproc)) — I had the build host itself crash three times undermake -j8before dialing it back, on a board that turned out to be running close to its power/thermal limits under sustained full-core compilation. If you’re building on a normal desktop/laptop/server, this almost certainly won’t affect you, but it cost me real time so I’m mentioning it.
Part 1 — Getting the board to boot a custom kernel at all
The symptom
Flashing the stock openwrt-*-rockchip-armv8-radxa_rock-4d-ext4-sysupgrade.img.gz snapshot image works fine — that one boots. The problem starts the moment you rebuild the image yourself with anything that changes the kernel’s size even slightly (a different .config, an added driver, debug symbols, whatever). The board’s serial console shows the FIT image loading, and then either:
- A silent hang right after
Loading Kernel Image to 3000000with no further output (“Synchronous Abort”-style dead stop), or ERROR: new format image overwritten - must RESET the board to recover
Both are U-Boot refusing to (or failing to) hand off to a kernel that isn’t laid out in memory the way U-Boot expects. Interestingly, the board doesn’t brick — it falls back to a secondary boot source (I believe SPI-NOR based on mtd0: spi5.0 in the fallback shell) and boots the last-known-good firmware, so every failed attempt was recoverable, just confusing until I understood what was happening.
Root cause
OpenWrt’s target/linux/rockchip/image/armv8.mk defines the RK3576 SoC’s default kernel load address like this:
define Device/rk3576
SOC := rk3576
KERNEL_LOADADDR := 0x43000000
...
endef
That 0x43000000 is correct for RK3576 (confirmed by a look at U-Boot’s own memory layout in include/configs/rk3576_common.h, where kernel_addr_r=0x42000000 and DRAM starts at 0x40200000). But the radxa_rock-4d device definition overrides this with its own, wrong value:
define Device/radxa_rock-4d
$(Device/rk3576)
DEVICE_VENDOR := Radxa
DEVICE_MODEL := ROCK 4D
KERNEL_LOADADDR := 0x03000000 # <-- this is the bug
KERNEL := kernel-bin | fit none $(KDIR)/image-$(firstword $(DEVICE_DTS)).dtb
endef
0x03000000 is below the start of DRAM (0x40200000) on this SoC — it’s simply not a valid address to copy a kernel to. I found the history of this line with git log -p -S'KERNEL_LOADADDR := 0x03000000' -- target/linux/rockchip/image/armv8.mk, which led me to the commit that introduced per-SoC load addresses for the rockchip target in the first place. That commit’s own message explicitly states RK3576 needs an address in the 0x42000000/0x43000000 family — so this override in the ROCK 4D block looks like a leftover from before per-SoC defaults existed, never cleaned up when the shared Device/rk3576 default was introduced. Small kernels (the ones the OpenWrt snapshot image ships with) apparently never grew large enough to actually touch and corrupt anything at 0x03000000, so the bug stayed latent until someone built a bigger kernel — like anyone adding an out-of-tree Wi-Fi driver does.
Fix, attempt 1: just delete the bad override line so the device inherits 0x43000000 from Device/rk3576. This got further — the boot log now showed the correct load address — but produced a new error: ERROR: new format image overwritten - must RESET the board to recover.
Root cause of the second error: U-Boot’s own memory layout (include/configs/rk3576_common.h) has:
"kernel_addr_r=0x42000000\0" /* FIT image gets loaded here first */
"kernel_comp_addr_r=0x4a000000\0" /* 128 MiB reserved for decompression */
The FIT image (containing the compressed kernel plus the devicetree blob) is read into memory starting at kernel_addr_r = 0x42000000. U-Boot then copies the uncompressed kernel out of that FIT image to KERNEL_LOADADDR. With KERNEL_LOADADDR = 0x43000000, that’s only a 16 MiB gap above the FIT’s own load address. My kernel, once I’d added the Wi-Fi driver, was about 17 MiB — bigger than the gap — so copying the kernel out started overwriting the tail end of the FIT image it was still being copied from, mid-copy. U-Boot detects this exact situation and aborts loudly rather than boot a corrupted kernel, which is exactly the error message it gave.
Fix, final: give it a much bigger gap. I set:
KERNEL_LOADADDR := 0x48000000
That’s a 96 MiB gap above kernel_addr_r, comfortably larger than any kernel this device is realistically going to produce (mine ended up around 17.7 MiB with the Wi-Fi driver built in; even generously oversized future kernels are extremely unlikely to blow past ~90 MiB), and it still lands safely below kernel_comp_addr_r = 0x4a000000 (the 128 MiB scratch space U-Boot uses for decompression), so there’s no risk of the load address and the decompression scratch space colliding either.
With that change, the board boots a custom-built kernel reliably — this fix alone is enough to get vanilla OpenWrt running from a self-built image; you don’t need the Wi-Fi driver work below just to have a working router.
Part 2 — Getting the onboard Wi-Fi chip working
With the board booting, dmesg showed no trace of the Wi-Fi chip at all — not even a “device not found” message, just nothing. That told me the problem was at the hardware-enable level, before any driver even had a chance to see the chip.
Problem 2a — the chip is never powered on
The ROCK 4D’s devicetree (arch/arm64/boot/dts/rockchip/rk3576-rock-4d.dts, as shipped in the mainline/OpenWrt kernel source) wires the Wi-Fi chip’s enable pin through an rfkill-gpio node:
rfkill {
compatible = "rfkill-gpio";
pinctrl-names = "default";
pinctrl-0 = <&wifi_en_h>;
radio-type = "wlan";
shutdown-gpios = <&gpio2 RK_PD1 GPIO_ACTIVE_HIGH>;
};
This is a real Linux driver interface (drivers/net/rfkill-gpio.c), but it needs CONFIG_RFKILL_GPIO built into (or as a module loaded in) the kernel, and it needs userspace to actively “unblock” the radio via rfkill before the GPIO is ever driven to its active state — by design, an rfkill GPIO defaults to the “blocked” (radio off) state until something tells it otherwise. OpenWrt’s default kernel config doesn’t build the rfkill-gpio driver at all, and nothing in a stock image would think to unblock a radio it doesn’t know exists. Net effect: the chip-enable GPIO simply never gets driven, the AIC8800D80 sits in reset forever, and it never enumerates on its internal USB bus.
I checked how Radxa’s own vendor kernel (the linux-6.1-stan-rkr5.1 branch they ship for Debian/Android images) wires this same physical pin, and they don’t use rfkill at all — they use a plain always-on GPIO regulator, the same mechanism already used elsewhere on this board for the 3.3 V Wi-Fi supply rail. That’s a much simpler, always-known-working driver (reg-fixed-voltage), so I replaced the rfkill node with an equivalent regulator node:
--- a/arch/arm64/boot/dts/rockchip/rk3576-rock-4d.dts
+++ b/arch/arm64/boot/dts/rockchip/rk3576-rock-4d.dts
@@ -37,12 +37,18 @@
};
};
- rfkill {
- compatible = "rfkill-gpio";
+ wifi_chip_en: regulator-wifi-chip-en {
+ compatible = "regulator-fixed";
+ regulator-name = "wifi_chip_en";
+ regulator-min-microvolt = <3300000>;
+ regulator-max-microvolt = <3300000>;
+ regulator-boot-on;
+ regulator-always-on;
+ enable-active-high;
+ gpios = <&gpio2 RK_PD1 GPIO_ACTIVE_HIGH>;
+ startup-delay-us = <5000>;
pinctrl-names = "default";
pinctrl-0 = <&wifi_en_h>;
- radio-type = "wlan";
- shutdown-gpios = <&gpio2 RK_PD1 GPIO_ACTIVE_HIGH>;
};
leds: leds {
This is a plain kernel patch dropped into target/linux/rockchip/patches-6.12/, applied automatically by OpenWrt’s build system against the vendored kernel source — no manual devicetree compilation needed, make picks it up.
With this patch, the chip finally powers on and enumerates on USB (visible in dmesg as vid:0xA69C pid:0x8D80).
Problem 2b — there is no in-tree driver for this chip
OpenWrt doesn’t ship a kernel driver for the AIC8800 family at all. I found two candidate driver sources on GitHub — one packaging the SDIO interface variant, one packaging the USB interface variant (from radxa-pkg/aic8800, based on Aicsemi’s own SDK v5.0). Since lsusb-equivalent output on this board clearly showed the chip attached over USB, only the USB variant was ever going to work here — worth checking explicitly before spending time on a driver package, since the two aren’t interchangeable and the error you get from loading the wrong one (nothing happens, silently) doesn’t obviously tell you why.
I wrote an OpenWrt kernel-module package (package/kernel/aic8800-usb/) around the USB driver source. Building an out-of-tree kernel module for OpenWrt means either (a) building the entire toolchain and matching your own local kernel build exactly, or (b) using OpenWrt’s official prebuilt SDK for your exact target/release, which guarantees the kernel module ABI hash matches without you having to rebuild anything else. I’d recommend (b) if you just want a working driver, but if you’re doing kernel development anyway (as I was, since I needed the KERNEL_LOADADDR fix from Part 1), building the module as part of your full tree works fine too — that’s what this guide does, since we’re building the whole image from source anyway.
Getting this driver to actually compile and run correctly against current OpenWrt took three separate fixes, each with its own “why did this happen” story:
Fix 1 — wrong firmware file paths (two of them, not one)
The driver’s Bluetooth-coexistence firmware loader (aicbluetooth.c) hardcodes its firmware search path, but it does so twice, gated by a compile-time flag:
#if defined(CONFIG_PLATFORM_UBUNTU)
static const char* aic_default_fw_path = "/lib/firmware/aic8800_fw/USB";
#else
static const char* aic_default_fw_path = "/vendor/etc/firmware";
#endif
Since we’re building for OpenWrt, CONFIG_PLATFORM_UBUNTU is (correctly) not defined, which means the driver compiles in the /vendor/etc/firmware branch — a path that makes sense on an Android-derived vendor image and doesn’t exist at all on OpenWrt. The first time I patched this file, I only replaced the string "/lib/firmware" with the OpenWrt-appropriate path — which silently did nothing, because that string never appears in the branch that’s actually compiled in. I had to add a second substitution targeting /vendor/etc/firmware specifically. The same double-definition (and the same fix) is needed in the companion aic_btusb.c file for the Bluetooth-only USB interface.
Lesson here, in case it saves someone else the same false start: when a vendor driver has version/platform-gated #if/#else blocks, check which branch is actually compiled before assuming a single string substitution covers it — grep for the string across both branches, not just the one you expect to be active.
Fix 2 — firmware installed to the wrong directory
Even with the path fixed, firmware loading still failed — the driver logs showed it looking for /lib/firmware/aic8800_fw/USB/fw_patch_table_8800d80_u02.bin (flat, directly in that directory), while my OpenWrt package’s Package/aic8800-usb-firmware/install step was installing the firmware files into a .../USB/aic8800D80/ subdirectory. Tracing the driver’s path-construction logic (aicbluetooth.c) showed why: the subdirectory logic only exists in the CONFIG_PLATFORM_UBUNTU branch (which, again, we don’t compile), keyed off a runtime chip-ID check. In the #else branch we actually use, the firmware path is always flat, unconditionally, regardless of chip ID. Once I knew that, the fix was just installing the firmware files directly into the parent directory instead of a chip-specific subdirectory.
Fix 3 — the real showstopper: driver built against the wrong cfg80211 headers
With firmware loading fixed, the driver’s USB probe ran all the way through firmware upload and into registering the wireless device with the kernel’s cfg80211 subsystem — and then crashed. The kernel log showed a WARN_ON() inside wiphy_register(), followed by (null): Could not register wiphy device, followed by an unrelated-looking kobject/refcount warning as the driver’s own (buggy) error-cleanup path tried to free a wiphy that never finished registering. iw dev showed nothing; no wlan0.
I initially went down a wrong path here, spending a while trying to figure out exactly which interface-combination check inside cfg80211 was failing (the driver declares two ieee80211_iface_combination structs — a normal one and a DFS/radar one — and there’s a real, documented cfg80211 sanity check that rejects a combination advertising both radar detection and more than one concurrent channel at once). Every value I could find in the driver source looked individually correct against that specific check, though — which was the tell that I was looking at the wrong layer of the problem entirely.
The actual root cause: out-of-tree wireless drivers on OpenWrt are supposed to build against OpenWrt’s own mac80211/cfg80211 backport headers (from the wireless-backports project), not against the target kernel’s own copy of <net/cfg80211.h>. My initial package Makefile just pointed the module build at KERNELDIR and let it pick up whatever <net/cfg80211.h> the kernel source tree itself provided. On a modern kernel that header does define a real, usable struct wiphy — but OpenWrt’s actual, running cfg80211.ko is built from a separate, independently-versioned backports package (wireless-backports, version 6.18.26 in the release I was building against) with its own struct layouts and API surface, decoupled from the running kernel’s own version. Compiling my driver against one copy of these headers while the kernel loads a cfg80211.ko built from a different copy is a recipe for exactly the kind of memory-layout mismatch that produces bizarre, hard-to-place crashes like the one I was seeing — the WARN wasn’t lying about where it fired, it just wasn’t firing for the reason I initially assumed, because the struct being validated wasn’t laid out the way I thought it was.
Other OpenWrt wireless driver packages (I checked mt76’s Makefile as a working reference) solve this by explicitly pointing their build at the installed backport headers ahead of the kernel’s own include path:
STAMP_CONFIGURED_DEPENDS := $(STAGING_DIR)/usr/include/mac80211-backport/backport/autoconf.h
NOSTDINC_FLAGS := \
$(KERNEL_NOSTDINC_FLAGS) \
-I$(STAGING_DIR)/usr/include/mac80211-backport/uapi \
-I$(STAGING_DIR)/usr/include/mac80211-backport \
-I$(STAGING_DIR)/usr/include/mac80211/uapi \
-I$(STAGING_DIR)/usr/include/mac80211 \
-include backport/autoconf.h \
-include backport/backport.h
Adding the same NOSTDINC_FLAGS (passed through to the kernel module build via NOSTDINC_FLAGS="$(NOSTDINC_FLAGS)" in the make modules invocation) immediately surfaced the real problem clearly, as a compile error instead of a runtime crash: the driver’s calls into several cfg80211 API functions no longer matched the current function signatures — for example cfg80211_rx_unexpected_4addr_frame() now takes an additional link_id parameter that didn’t exist when this vendor driver was written. The driver source actually already anticipates this, with its own version-gated code:
#if LINUX_VERSION_CODE >= KERNEL_VERSION(6, 17, 0)
cfg80211_rx_unexpected_4addr_frame(rwnx_vif->ndev, sta->mac_addr, -1, GFP_ATOMIC);
#else
cfg80211_rx_unexpected_4addr_frame(rwnx_vif->ndev, sta->mac_addr, GFP_ATOMIC);
#endif
— but that check tests the running kernel’s version (6.12.94 in my case), not the API surface actually exposed by the backports package (which tracks a much newer, 6.18-era API regardless of what real kernel it’s compiled against). Every one of these version gates in the driver — and there turned out to be over two dozen of them, scattered across several source files, at various thresholds between kernel 6.13 and 6.17 — needed to evaluate as if the kernel were newer than it actually is, to select the branch that matches what the backports headers actually declare.
Rather than hand-patch two dozen scattered #if blocks (some with inconsistent whitespace even within the same file, e.g. KERNEL_VERSION (6, 14, 0) with a stray space in one spot and KERNEL_VERSION(6, 17, 0) without one a few lines later — which by itself broke my first, narrower regex-based fix attempt), I took the blunt but reliable approach of substituting the LINUX_VERSION_CODE macro itself, globally, with a fixed large numeric value corresponding to the backports version actually in use:
find $(PKG_BUILD_DIR)/src/USB/driver_fw/drivers/aic8800/aic8800_fdrv \
$(PKG_BUILD_DIR)/src/USB/driver_fw/drivers/aic8800/aic_load_fw \
-name '*.c' -o -name '*.h' | xargs $(SED) \
's/\bLINUX_VERSION_CODE\b/397850/g'
(397850 is KERNEL_VERSION(6, 18, 26) — matching the wireless-backports release actually providing cfg80211 in this OpenWrt build. Since the driver’s real running kernel, 6.12.94, is already newer than every lower threshold this source checks against, forcing the value higher only changes the outcome of the checks I actually needed changed — nothing that depended on running on an older kernel than 6.12 was ever true to begin with, so nothing regresses.)
With that change, the module finally compiled cleanly, loaded without any cfg80211 warnings, and — on the next boot — ip a showed a wlan0 interface, and iw dev wlan0 scan returned real access points from the surrounding area.
Summary of changes
If you just want the fix, here’s everything in one place.
1. target/linux/rockchip/image/armv8.mk — change the ROCK 4D device block’s load address:
define Device/radxa_rock-4d
$(Device/rk3576)
DEVICE_VENDOR := Radxa
DEVICE_MODEL := ROCK 4D
KERNEL_LOADADDR := 0x48000000
KERNEL := kernel-bin | fit none $(KDIR)/image-$(firstword $(DEVICE_DTS)).dtb
endef
2. New kernel patch — target/linux/rockchip/patches-6.12/051-11-arm64-dts-rockchip-fix-wifi-chip-enable-on-ROCK-4D.patch (full diff above, replaces the broken rfkill-gpio node with a regulator-fixed node for the Wi-Fi chip-enable GPIO).
3. New package — package/kernel/aic8800-usb/Makefile, building the USB variant of the AIC8800 driver from radxa-pkg/aic8800 (git commit bd11969265809a0fc948f1107c8256bbb2c1aa60), with:
NOSTDINC_FLAGSpointed at themac80211-backportstaging headers (not the kernel’s owncfg80211.h)- firmware-path
sedfixes covering both theCONFIG_PLATFORM_UBUNTUand non-UBUNTUcode branches - a global
LINUX_VERSION_CODE→397850substitution so the driver’s own version-gated code picks the branches matching the installedwireless-backportsAPI level - firmware installed flat under
/lib/firmware/aic8800_fw/USB/(no chip-specific subdirectory)
4. .config — select CONFIG_TARGET_rockchip_armv8_DEVICE_radxa_rock-4d=y, CONFIG_PACKAGE_kmod-aic8800-usb=y, CONFIG_PACKAGE_aic8800-usb-firmware=y, and bump CONFIG_TARGET_KERNEL_PARTSIZE from the default 16 MiB to at least 32 MiB (a kernel with this driver built in comes out around 17–18 MiB, which no longer fits the default boot partition size).
Building it yourself, from zero
These are the exact steps, assuming a Linux build host (native or a VM) with the usual OpenWrt build dependencies already installed.
# 1. Clone the OpenWrt tree and switch to the 25.12 release branch
git clone https://github.com/openwrt/openwrt.git
cd openwrt
git checkout openwrt-25.12
# 2. Update and install the package feeds (needed for kmod-cfg80211,
# kmod-usb-core, and the mac80211/wireless-backports package this
# driver depends on at build time)
./scripts/feeds update -a
./scripts/feeds install -a
# 3. Apply the KERNEL_LOADADDR fix
# (edit target/linux/rockchip/image/armv8.mk by hand, or apply as a patch —
# see the diff in "Summary of changes" above)
# 4. Add the devicetree fix as a kernel patch
mkdir -p target/linux/rockchip/patches-6.12
# save the diff from "Problem 2a" above as:
# target/linux/rockchip/patches-6.12/051-11-arm64-dts-rockchip-fix-wifi-chip-enable-on-ROCK-4D.patch
# 5. Add the aic8800-usb package
mkdir -p package/kernel/aic8800-usb
# save the Makefile from "Summary of changes" (point 3) as:
# package/kernel/aic8800-usb/Makefile
# 6. Configure the target
make menuconfig
# Target System: Rockchip
# Subtarget: ARMv8
# Target Profile: Radxa ROCK 4D
# Target Images: ext4 (or squashfs, your preference)
# Kernel modules -> Wireless Drivers -> kmod-aic8800-usb: <M> or built-in
# Firmware -> aic8800-usb-firmware: <M>
#
# Also, under "Global build settings":
# Target Images -> increase the boot/kernel partition size to 32 MiB
# (CONFIG_TARGET_KERNEL_PARTSIZE=32) — the default 16 MiB is too small
# once this driver is built in.
# 7. Build. Keep an eye on your build host's actual capability — don't
# just default to -j$(nproc) if you're on a small/thermally-limited
# board yourself.
make -j$(nproc) V=s 2>&1 | tee build.log
# (I used -j4 on an 8 GB aarch64 build host; scale to your own machine.)
# 8. Flash and deploy
# First flash: write the *-ext4-sysupgrade.img.gz (or the matching
# non-sysupgrade "factory"-style image, if this is your very first
# OpenWrt install on this board) to an SD card the normal way
# (balenaEtcher, dd, Raspberry Pi Imager, whatever you're used to).
#
# For subsequent rebuilds, once OpenWrt is already running, prefer
# `sysupgrade` over re-flashing the SD card by hand — it's the
# well-tested upgrade path and it preserves your configuration:
scp bin/targets/rockchip/armv8/openwrt-rockchip-armv8-radxa_rock-4d-ext4-sysupgrade.img.gz root@<router-ip>:/tmp/
ssh root@<router-ip> 'sysupgrade -v /tmp/openwrt-rockchip-armv8-radxa_rock-4d-ext4-sysupgrade.img.gz'
After the board reboots, ip a should show a wlan0 interface, and iw dev wlan0 scan (after ip link set wlan0 up) should return nearby access points.
One operational gotcha worth knowing about ahead of time
The stock OpenWrt image’s default lan network zone auto-generates a random IPv6 ULA prefix and serves it via Router Advertisement / DHCPv6 on whatever’s bridged into br-lan. If you plug this board’s Ethernet port straight into an existing home network to manage it (rather than into an isolated bench network), every device on that network will pick up this router’s ULA prefix as if it were a legitimate upstream network. It’s harmless in the sense that it doesn’t break anything, but it’s confusing to debug the first time a random device on your LAN suddenly shows a DNS server address you don’t recognize. If you don’t want this, disable it explicitly:
uci set dhcp.lan.ra='disabled'
uci set dhcp.lan.dhcpv4='disabled'
uci set dhcp.lan.dhcpv6='disabled'
uci commit dhcp
/etc/init.d/odhcpd restart
/etc/init.d/dnsmasq restart
This survives sysupgrade (which preserves /etc/config/), but not a full re-flash from an SD card image, so you’ll need to reapply it if you ever flash from scratch again.
Closing thoughts
Everything above traces back to two categories of problem that I suspect show up on a lot of newly-added OpenWrt device targets, not just this one: a device definition copy-pasted from an era before per-SoC defaults existed and never revisited, and a vendor Wi-Fi driver written against an older cfg80211 API surface than what’s currently shipping, with version-detection logic that (reasonably, from the driver author’s point of view) assumes it’s being compiled directly against a kernel tree rather than against a separately-versioned backports package. Neither is unique to the ROCK 4D — I’d expect the NOSTDINC_FLAGS/backport-headers issue in particular to bite anyone packaging an unmaintained out-of-tree Wi-Fi driver for a recent OpenWrt release.
If you’re picking up this board today and just want it to work: the KERNEL_LOADADDR fix should probably go upstream regardless of what you do about Wi-Fi — it’s a plain bug with no tradeoffs. I’d be happy to submit that one as a proper PR if there’s interest; the Wi-Fi package is messier (it patches a fairly hacky vendor driver rather than a clean upstream one) and I’m less sure it belongs in the tree as-is versus staying as a documented out-of-tree package like this.
Happy to answer questions or share the exact patch files if anyone wants to reproduce this.