multiapp: one firmware that runs the others¶
Status: all six apps (capture, rfprobe, wpan, stream, osmosdr, link) run as
llext extensions from PSRAM, built from their unchanged sources, one app per boot, on the image of
apps/multiapp/ (“the host”). The host loads each app from its <name>.llext file on a FAT
volume in flash, which the PC sees as a USB drive in boots without an app; built-in copies are
the fallback. It needs Zephyr changes that are on the fork west.yml pins (“Zephyr changes”).
Usage: apps/multiapp/README.rst.
Constraints¶
Every app still builds and runs standalone, with its own
prj.conf, overlay andmain(), unchanged in behavior. multiapp compiles the same sources; no app source moves.Internal RAM is the binding constraint. IRAM and DRAM share one pool, and the image must end below the dump banks the configured apps reserve (hardware-quirks.md, “Memory”). Extensions therefore run from PSRAM.
Kernel configuration cannot differ per app. USB classes, network buffers, SMP and heap sizes are fixed when the host is built; the host carries the union of what its apps need.
Constructs that register through linker iterable sections (
SHELL_CMD_REGISTER,USBD_DEFINE_CLASS,SYS_INIT,LOG_MODULE_REGISTER,K_THREAD_STACK_DEFINE, static memory slabs,NET_BUF_POOL_DEFINE) do not work in an extension as written: its sections do not join the host’s.Exit is a reboot. The Wi-Fi PHY, ring and DAC state cannot be torn down cleanly.
app runwith an app loaded leaves the name in RTC memory and resets.
How it works¶
Code in PSRAM. The S3’s cache MMU maps the data bus window (0x3c000000) and the instruction bus window (0x42000000) through one table, so a PSRAM page written at data address D executes at D + 0x06000000 after a data cache write-back and an instruction cache invalidate. llext keeps one address per region, so a local Zephyr change (
CONFIG_LLEXT_EXEC_ALIAS,llext_heap_init_harvard_exec()) copies and patches through the writable address and uses the alias for relocations, the symbol table and the init/fini check. The ESP cache driver has no instruction cache operations: the host syncs the range itself afterllext_load()(sync_text()insrc/apps.c). Heaps:CONFIG_MULTIAPP_INSTR_HEAP_KB(256) andCONFIG_MULTIAPP_DATA_HEAP_KB(512), both in PSRAM.A prelude (
include/multiapp/ext.h, put in front of every C source with-include) turns what an extension cannot do into what it can:SHELL_CMD_REGISTER()andSYS_INIT()become constructors,LOG_MODULE_REGISTER()a declaration, slabs are initialized in a constructor,NET_BUF_POOL_DEFINEleaves a descriptor in.multiapp_hostfrom which the host defines the pool (net_buf finds pools by their index in a host section)..noinitand the PSRAM section attributes join.bss, because llext loads only one NOBITS section. The app’sapp_usb_init()becomes a call that does nothing (see “Bugs found”).Generated host glue (
exports.py): the host exports exactly the symbols the extensions import, in its own export group (Zephyr’s default groups, every syscall kept in IRAM, are off), and registers their log modules. A symbol the host lacks fails the host’s link. WithCONFIG_LLEXT_IMPORT_ALL_GLOBALSthe host finds an app’s hooks by name, such asapp_usb_dfu_prepare().The app’s own Kconfig values. An extension compiles against the host’s
autoconf.h;appconfig.pywrites the app’sCONFIG_APP_*values into a header included after it.Proxy roots (
src/apps.c): the host registers the apps’ root commands (probe,wpan,sdr,link,ofdm,hello) as dynamic commands that forward to the loaded app, so the shell and the tools that drive it look as standalone. The app’smain()runs in a host thread (CONFIG_MULTIAPP_STACK_SIZE, stack in PSRAM).USB profiles. All class code lives in the host. The S3’s OTG controller has 4 IN endpoints for classes (
GHWCFG4.INEps), and the union of the apps’ classes needs 6, so the host registers a subset per boot:app_usb_classes()(a weak hook inapps/commonbetween registering the classes andusbd_init()) unregisters what the boot’s app does not name in itsUSBmask.Class
Used by
IN endpoints
CDC-ACM (shell)
every boot
2
CDC-NCM
stream
2
vendor bulk (
usb_sdr.c)osmosdr
1
MSC (the volume as a drive)
boots without an app
1
DFU
every boot
0
osmosdr’s vendor class is built in the host; its calls into the app go through forwarders in
src/osmosdr.cthat look the app’s functions up by name, so the boot’s app is loaded before USB starts.NCM’s DMA buffers in bank 0 (
multiapp_bank0.ld): USB DMA needs internal RAM, andstreamnever runs the ring, so bank 0 is free in its boots.An internal RAM area, one user per boot. Buffers an app must keep in internal RAM (
ESP_SDR_HIGH_BUF_DEFINE(),ESP_SDR_HIGH_STACK_DEFINE()) become host objects inmultiapp_area, 24 KB of the high region (AREAinmultiapp_extension()). osmosdr takes 23 KB; the 802.15.4 driver gets it in the other boots (CONFIG_ESP_SDR_IEEE802154_HIGH_RAM_APP).app runreboots when the profile or the area owner changes.The apps as files (
src/drive.c). A FAT volume in the upper 4 MB of the flash, which the board’s 4 MB partition table leaves out (partitionfatat 0x400000, flash diskFLASH), holds<name>.llext. The host mounts it read-only at boot, before USB starts and before an app runs, compares every file with the built-in copy (app list), and reads the boot’s app whole into a PSRAM buffer (CONFIG_MULTIAPP_FILE_MAX_KB); llext then loads it from there. The FATFS object, the flash disk cache and the drive’s SCSI buffer are in bank 0, which the ring overwrites once an app runs: nothing touches the volume after that. Reads go in 256-byte chunks through the FATFS window, so the flash driver never gets a PSRAM buffer.A trailer on every file (
apptrail.py): ELF size, CRC-32, the stack the app’smain()needs and a 16-byte build id (buildid.py: a hash ofautoconf.h, the devicetree, the extensions’ imports, the headers both sides compile against and the compiler). The host checks it before llext reads the file: a truncated, damaged or foreign file (built for another host) is refused and the built-in copy runs (CONFIG_MULTIAPP_EMBEDDED, default y; without it the image is 165 KB smaller and such a boot comes up without an app).app infosays where the app came from.The drive. A boot without an app (no request, no autostart, or an app that failed to start) registers USB mass storage on the volume next to the shell and DFU, for copying app files from the PC; boots with an app unregister it, and
app runfrom a drive boot reboots into the app. The PC must eject the drive first: the host cannot see what the PC still caches.west dfu --storagewrites the build’s volume image (mkfat.py: deterministic FAT12, 4 KiB clusters on the erase page, long names) to the DFU targetfat, andwest dfu --esptoolwrites MCUboot, the image and the volume over the USB-Serial-JTAG, which the running app hands over with the shell commandusj.
Memory budget¶
The host’s image ends 3840 bytes below bank 0 (0x3fcb0000), with the volume (1.1 KB, mostly the
DFU write buffer) and the drive (2.2 KB). What got it there: the Wi-Fi driver’s
fewest static receive buffers (2, no A-MPDU receive; system heap peak 12216 bytes, heap 13312),
CONFIG_ESP32_WIFI_NET_ALLOC_SPIRAM, the export group above, CONFIG_NUM_MBOX_ASYNC_MSGS=0, the
app’s main() stack in PSRAM. The high region has 1.2 of its 37.75 KB free (DAC, ring, area), the
area 0.5 KB. Log queue 256 messages, link’s shell stack 3072 bytes. Internal RAM stacks are
sized so their peak with every app exercised is at most 80 % (kernel thread stacks; main’s in
app info, the USB thread’s in the last DFU download in app crash): main 1120 of 1536, USB
thread 1008 of 1280, drive thread 880 of 1152 while the PC copies 2 MB.
Measured on the XIAO¶
wpan against the C6 (
interop.py, 100 frames of 100 bytes): 100 of 100 each way and 50 of 50 ACKs on channels 11, 15, 20 and 25; DFU while the ring runs.stream: 947 kB/s of VITA 49.2 over NCM (standalone 968 kB/s);vrt_tx.pypasses as standalone.osmosdr (
esdr_usb):test-in1154 kB/s, receive at 250 kS/s (8 and 16 bit) without overflow or gaps, 1.25 M samples transmitted with none dropped, ring at 13.3 of 15 cycles per pair.link: QAM build and decode within 1 to 4 % of standalone, FFT and RS the same, OFDM test frame 5583 us (standalone 5589).
link bench’s float loop and power sum are 1.4 and 2.1 times slower (the shell runs them on CPU 0 here, CPU 1 standalone; not isolated). PSRAM reads miss the cache once, then match internal RAM. Not tested: a link between two boards.48 app switches, each into the requested app; all six apps run from their files with the same results.
The volume:
west dfu --storagereads back byte for byte (DFU upload), fsck.vfat is clean; copying through the drive runs at about 63 kB/s (every 4 KiB page erased and rewritten), files read back identical after a remount and run after ejecting.west dfu --esptool --storagewrites MCUboot, image and volume in 27 s. A host built with one Kconfig value changed refuses the old files. The desk checksample.esp_sdr.multiapp.hil(testing.md) passes.
Bugs found¶
Atomics in PSRAM are not atomic (
hello atomic); local Zephyr fix (hardware-quirks.md, “Memory”).No flash access from a PSRAM stack. The app’s
main()raced the host’sapp_usb_init()and ran MCUboot’s image check on its PSRAM stack: double fault, board hung after some app switches (hardware-quirks.md, “Memory”).Extensions built at
-O0: Zephyr drops-Osfrom Xtensa extensions; a 1472-byte frame (32 at-Os) overflowed a 2048-byte stack. multiapp keeps the host’s-Os.llext relocations:
R_XTENSA_GLOB_DATdropped its addend; alignment padding made.bssclaim.exported_sym’s relocations; a shared object’s.bsssat 12 bytes off its 16-byte alignment, which the S3’s 128-bit loads round down silently. Local Zephyr fixes.Stale extensions: Zephyr’s llext CMake rule never rebuilt
<target>_debug.elfafter an edit. Local Zephyr fix.Unregistering NCM before init crashed (its shutdown removed a descriptor it never added). Local Zephyr fix.
link decodeandlink benchoverflowed the shell stack (a 3.6 KB decode chain): they run on the paused MAC thread now (link_mac_run_paused()). osmosdr’s receive thread has 2560 bytes.llext frees an extension’s symbol table after loading; the shell recognizes dynamic entries by host section address.
flash_imgwrites an MCUboot image, from the start the swap-using-offset layout gives the update slot: the volume landed 4 KiB into its partition. Its DFU target usesstream_flash.
Zephyr changes¶
Eight commits on the fork’s esp-sdr-multiapp branch, which west.yml pins, on top of the
SMP port: exec alias, llext repackage dependency, NCM/ECM unregister before init, Xtensa
GLOB_DAT addend, alignment padding, PSRAM atomics (two commits), shared object region alignment.
They are candidates for upstream Zephyr.
The standalone apps (2026-10-09)¶
Each app’s S3 build, read from zephyr.elf (the gap is from the end of the DRAM image to the
first dump bank the app reserves):
App |
IRAM |
DRAM |
Gap to first bank |
High region |
Flash code |
Flash rodata |
PSRAM |
|---|---|---|---|---|---|---|---|
capture |
61 KB |
170 KB |
125 KB |
0 |
341 KB |
447 KB |
0 |
rfprobe |
67 KB |
189 KB |
106 KB |
0 |
408 KB |
528 KB |
0 |
wpan |
89 KB |
164 KB |
0.2 KB |
37 KB |
430 KB |
532 KB |
2.6 MB |
osmosdr |
87 KB |
163 KB |
0.4 KB |
37 KB |
413 KB |
530 KB |
2.6 MB |
stream |
84 KB |
228 KB |
1.7 KB |
20 KB |
491 KB |
628 KB |
2.6 MB |
link |
92 KB |
228 KB |
1.0 KB |
39 KB |
460 KB |
599 KB |
1.7 MB |
The part every app has (kernel, drivers, Wi-Fi and PHY blobs) is 60 to 75 KB of IRAM and 60 to 90 KB of DRAM; the apps’ own DRAM is mostly buffers. Imports per app from the host: capture 17, rfprobe 56, wpan 60, osmosdr 68, stream 108, link 92; 197 symbols in the union.
Design choices¶
No new partition table. The volume takes the upper 4 MB the board’s 4 MB table leaves unused, so MCUboot, the slots and DFU stay compatible with the standalone apps.
No
fs_loader. It reads the symbol table and every relocation entry by entry (about four times the file size in flash reads) and would overflow main’s stack; the host reads the file whole into PSRAM and keeps the buffer loader.A build id, not an ABI hash with a manual bump. llext binds by name only, so a file is accepted only from a host built from the same inputs.
Built-in copies stay on (
CONFIG_MULTIAPP_EMBEDDED) until the volume has proven itself on more boards; off, the image shrinks by 165 KB.
Risks¶
Extension code cannot run while flash is written (cache off): interrupt handlers and code that runs then stay in the host.
No clean unload: exit is a reboot.
The local Zephyr commits above, until upstream.
link throughput between two boards is untested.
The PC’s write cache: files copied onto the drive reach the flash only when the PC writes them out, which ejecting guarantees;
app runreminds of it in a drive boot.