Skip to content

examples/fdpicxip, testing/fs/xipfs: FDPIC modules executed in place from XIPFS - #3682

Open
casaroli wants to merge 2 commits into
apache:masterfrom
casaroli:xipfs-fdpic
Open

examples/fdpicxip, testing/fs/xipfs: FDPIC modules executed in place from XIPFS#3682
casaroli wants to merge 2 commits into
apache:masterfrom
casaroli:xipfs-fdpic

Conversation

@casaroli

@casaroli casaroli commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

The apps half of the FDPIC ELF loader: apache/nuttx#19584, which should merge first. Nothing here can be enabled until CONFIG_FDPIC exists, so on its own this PR is inert rather than broken: the demo's Kconfig depends on that symbol and the new test sections are inside #ifdef CONFIG_FDPIC. Two commits, both new directories, both guarded by CONFIG_FDPIC.

examples/fdpicxip demonstrates FDPIC modules executed in place out of XIPFS. It writes modules into the volume at run time, the way a download would, then runs them. Four subcommands, one loader property each: qsort is one self-contained module as two concurrent instances, sharing a single copy of its text in flash while each instance gets its own data; solib adds a shared library, so two objects are mapped and each instance gets its own copy of both objects' data; cxx is the same in C++, which additionally requires each object's global constructors to have run in dependency order before main; and jmprel is a module whose imports are all in DT_JMPREL rather than DT_REL. It is the FDPIC counterpart of examples/nxflatxip, kept separate because one program demonstrating both module formats would demonstrate neither clearly.

testing/fs/xipfs grows two sections, fdpic and reject, so a loader regression fails a test rather than waiting to be noticed on someone's board. They belong in the xipfs suite because what they exercise is modules loaded out of the filesystem — the pin, the in-place mapping and the loader are one path. fdpic covers the properties that otherwise fail silently: constructors in dependency order, per-instance data, the manufactured R_ARM_FUNCDESC descriptors, both relocation tables, the GOT fallback for a leaf library with no DT_PLTGOT, and every firmware entry point that has to resolve a module callback, including mq_notify and timer_create with SIGEV_THREAD. Each module reports its own invariants through its exit status, because a callback that never runs is the failure being tested for. reject mutates a known-good module and asserts the loader refuses it rather than loading something broken.

The modules are embedded as C headers rather than built as part of either app, because linking one needs arm-uclinuxfdpiceabi binutils, which building NuttX does not. Their sources are in examples/fdpicxip/modules with a makefile that regenerates every header on an explicit make regen NUTTX_DIR=..., never as part of the application build. That one makefile writes the headers both apps embed, so the two cannot drift apart, and it drives the tooling in nuttx/tools/fdpic. CPU is cortex-m3 there deliberately: a v7-M module also runs on the v8-M targets, so one set of blobs serves both the RP2350 and mps2-an500, whereas a cortex-m33 build produces blobs a Cortex-M7 cannot execute at all.

Impact

New feature, off by default. Both CONFIG_EXAMPLES_FDPICXIP and the two new test sections require CONFIG_FDPIC, which defaults to n, so a tree without it is unaffected. The new sections in testing/fs/xipfs are #ifdef CONFIG_FDPIC, so an existing xipfs test configuration behaves exactly as before.

Both apps build with a plain arm-none-eabi toolchain, because the module blobs are committed. Nothing invokes modules/; regeneration is opt-in.

No change to any existing app.

Testing

Host: macOS 26.5.1 on arm64. Arm GNU Toolchain 15.2.Rel1 (arm-none-eabi-gcc 15.2.1), arm-uclinuxfdpiceabi-ld from GNU Binutils 2.43, QEMU 11.0.3.

QEMU, mps2-an500 (Cortex-M7, ARMv7E-M), xipfs on a rammtd device that answers BIOC_XIPBASE. Full xipfs_test suite, which is the 90 filesystem checks plus the 40 this PR adds:

==== 130 passed, 0 failed ====

The two new sections on their own:

nsh> xipfs_test fdpic
==== 33 passed, 0 failed ====

nsh> xipfs_test reject
-- FDPIC loader rejections --
  PASS  a non-32-bit ELF class is refused
  PASS  a non-ET_DYN object is refused
  PASS  a module for the wrong machine is refused
  PASS  a module with RELA PLT relocations is refused
  PASS  a module whose needed library is absent is refused
  PASS  a module importing an unexported symbol is refused
  PASS  a module with too many DT_NEEDED entries is refused
==== 7 passed, 0 failed ====

The demo, all four subcommands. solib:

nsh> fdpicxip solib
=== FDPIC shared library, executed in place ===

staged libcounter.so (2052 bytes) and user (2732 bytes)
  libcounter.so text at 0x6000cd20
  user          text at 0x6000dd20

spawning two instances, each bumping by its own seed...

[while running] pins on the library's shared text = 2

[user 1] library total = 3 (expected 3) -- PASS
[user 2] library total = 6 (expected 6) -- PASS

[after exit]    pins on the library's shared text = 0

cxx, showing each object's constructors ran, the library's before the module's, and the destructors on unload:

nsh> fdpicxip cxx
  [libshape] Registry() ran
  [libshape] Registry() ran
[user 1] own ctor ran: yes, library ctor ran: yes, library first: yes
[user 2] own ctor ran: yes, library ctor ran: yes, library first: yes
[while running] pins on the library's shared text = 2

[user 1] library total = 3 (expected 3) -- PASS
  [libshape] ~Registry() ran after 3 adds
[user 2] library total = 6 (expected 6) -- PASS
  [libshape] ~Registry() ran after 3 adds

[after exit]    pins on the library's shared text = 0

qsort runs two instances with no library behind them, and jmprel confirms a module bound through DT_JMPREL reaches the firmware and returns.

Hardware, Pimoroni Pico Plus 2 (RP2350, Cortex-M33, ARMv8-M) against real QSPI flash, pimoroni-pico-2-plus:xipfs-fdpic: full suite 130/130, fdpic 33/33, reject 7/7, and all four demo subcommands. The same blobs run on both cores.

Simulator, sim:xipfs on the host, confirming the new sections are correctly guarded — the loader is ARM-only, so only the 90 filesystem checks run and nothing regressed:

==== 90 passed, 0 failed ====

Blob provenance is reproducible. make -C examples/fdpicxip/modules regen NUTTX_DIR=... rebuilds all 15 headers from the committed sources and reproduces the committed bytes exactly, so the blobs can be audited against their sources.

Assertions verified against deliberate breakage. Each check in the fdpic section was confirmed to fail when the loader property it names is broken. On Cortex-M that manifests as a HardFault taking the system down mid-test rather than a FAIL line, so a run that ends without its summary line is a failure, not a hang.

tools/checkpatch.sh -f passes on every changed C, C++ and header file, including the generated *_bin.h.

🤖 Generated with Claude Code

https://claude.ai/code/session_01X77FVXXW4bvvBtfFmz1JPz

casaroli added 2 commits July 30, 2026 14:05
The FDPIC counterpart of examples/nxflatxip, kept separate from it: that
example is about NXFLAT, and mixing the two module formats into one program
would leave neither demonstrating anything clearly.

Four subcommands, each showing one property of the loader.  'qsort' is the
simplest case it has -- one self-contained module, two concurrent instances,
one shared copy of the text in flash and a private copy of the data each,
with the pin count showing the extent held in place while they run and
released after.  'solib' adds a shared library, so two objects are mapped
and each instance still gets its own copy of both objects' data.  'cxx' is
the same in C++, which additionally requires global constructors to have run
in dependency order before main.  'jmprel' is a module whose imports are all
in DT_JMPREL rather than DT_REL.

The modules are embedded as headers rather than built as part of the app:
linking one needs arm-uclinuxfdpiceabi binutils, which the tree does not
require, so the headers are committed and both this example and
testing/fs/xipfs build with a plain toolchain.

Their sources are in modules/, with a makefile that rebuilds every header
from them on an explicit 'make regen NUTTX_DIR=...' and is never invoked by
the application build.  It drives nuttx/tools/fdpic and writes the headers
this example needs alongside the ones testing/fs/xipfs needs, so one source
tree serves both and the two cannot drift apart.  CPU is cortex-m3 there
deliberately: a v7-M module runs on the v8-M targets too, so one set of blobs
serves both the RP2350 and mps2-an500, while a cortex-m33 build produces
blobs the Cortex-M7 cannot execute at all.

This demonstrates; it does not assert.  The assertions are in the fdpic and
reject sections of apps/testing/fs/xipfs.

Verified on a Pimoroni Pico Plus 2: all four subcommands, and nxflatxip
unaffected alongside them.  Also verified on mps2-an500 under QEMU, which is
a Cortex-M7 -- a different core generation from the RP2350's Cortex-M33.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
Adds two sections to the xipfs suite, guarded by CONFIG_FDPIC so a build
without the loader is unaffected.  They belong here rather than in their own
test because what they exercise is modules loaded *out of the filesystem* --
the pin, the in-place mapping and the loader are one path.

'fdpic' asserts on the loader properties that otherwise fail *quietly*: a
loader that skips DT_INIT_ARRAY runs a C++ module happily with every global
left zero, one that skips DT_JMPREL loads a module that hard-faults only
once it calls out, and one that mis-sizes the descriptor pool corrupts the
heap.  None of those announce themselves.  The module exit status is the
channel -- each module checks its own invariants and reports a bitmask.  It
also covers shared libraries across concurrent instances, per-instance data,
the leaf-library GOT fallback, the R_ARM_FUNCDESC descriptor pool, and every
firmware entry point that has to resolve a module callback, including
mq_notify and timer_create with SIGEV_THREAD.

'reject' mutates a known-good module byte by byte and asserts the loader
refuses it rather than loading something broken: a missing import, and more
DT_NEEDED entries than the walk will follow.

The modules are embedded as headers, for the same reason as in
examples/fdpicxip, and built for cortex-m3 for the same reason: one set of
blobs then runs on both the v7-M and v8-M targets.  Their sources and the
makefile that regenerates these headers live in
apps/examples/fdpicxip/modules, so the copies the two apps carry are built
from the same sources in the same way.

Verified on a Pimoroni Pico Plus 2: fdpic 33/33, reject 7/7, and the whole
suite 130/130 with them included.  The same three numbers on mps2-an500 under
QEMU, which is a Cortex-M7 rather than the RP2350's Cortex-M33.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants