FDPIC Modules
Overview
An FDPIC module is an ELF shared object whose read-only and writable
segments are placed independently of one another. NuttX uses the
read-only segment where it already lies on the media and never copies
it; only the writable segment is copied to RAM, once per running
instance. A module’s code and .rodata therefore cost no RAM at all,
and several instances of one module share them.
FDPIC is not a separate binary format and has no loader of its own. An
object announces itself in its OS/ABI byte,
e_ident[EI_OSABI] == ELFOSABI_ARM_FDPIC (65), which readelf -h
reports as OS/ABI: ARM FDPIC, and the ELF loader takes it from there.
Everything else – exec(), posix_spawn(), dlopen(), the
symbol table – is the ordinary ELF path.
What FDPIC adds over the position independent ELF support already in the tree is a function pointer that carries its own data base. That is what lets a module be called back on a thread it did not create, and what lets a module and the libraries it uses hold distinct data bases at the same time.
Function descriptors
Code reaches its own data through a base register – r9 on ARM – holding the address of that object’s GOT. Because code and data are placed independently, a bare code address is not enough to call a function: the callee needs its data base too. FDPIC therefore represents a function pointer as a two word descriptor:
Word |
Contents |
|---|---|
|
Code address, including its Thumb bit |
|
Data base to install in the PIC base register before branching |
Building those descriptors is most of what relocation does. Because each one names its own base, a pointer handed to the base firmware carries everything needed to call back into the module later, from any thread.
A module links against nothing. libc and everything else are undefined
imports, resolved at load time against the globally registered symbols
first, then any shared libraries the module names, then the symbol table
exec() supplied.
Placement
The loader asks the filesystem where the file lies on its media. Two mechanisms exist and they are not interchangeable:
XIPFSIOC_PINis for a filesystem that can move a file’s blocks. It returns an address together with a pin that holds the extent still, and the pin is given back withXIPFSIOC_UNPINwhen the module is unloaded. XIPFS is the one in tree.FIOC_XIPBASEis for a filesystem whose layout never changes, which has nothing to hold and answers with a bare address. ROMFS and TMPFS are those.
The pin is asked for first, because a filesystem that needs one is not safe without it. The loader asks for a pin only if it can hold one, which is the flat build, or the pin would stay for ever.
A filesystem that answers neither is still usable. The loader then copies the text to RAM, as it does for any other module. The module loses the shared text and the flash saving, but it runs.
The writable segment is allocated and copied per instance, and a pool of
function descriptors is reserved behind it for the relocations that ask
the loader to manufacture one. When the task starts,
up_initial_state() installs the object’s data base – DT_PLTGOT,
or the GOT immediately after PT_DYNAMIC in an object with no
imports – into the PIC base register.
Comparison with NXFLAT and PIC ELF
All three run position independent code from flash on a target with no
MMU, and all three give several instances of one module a shared
.text with private .data. They differ in what a pointer can
express and in what the toolchain has to provide.
Property |
NXFLAT |
PIC ELF |
FDPIC |
|---|---|---|---|
Format |
NuttX only |
ELF |
ELF |
Extra build tools |
yes |
none |
linker |
Data base per |
task |
task |
object |
Shared libraries |
no |
no |
yes |
Foreign-thread callback |
no |
no |
yes |
Instruction set |
ARM, Thumb-2 |
unrestricted |
Thumb-2 only |
NXFLAT is a NuttX-specific format. A module imports
symbols from the base firmware but cannot export any, so shared
libraries are not possible, and the build needs mknxflat to generate
a thunk, ldnxflat to link, and one of the binfmt/libnxflat linker
scripts to place the sections.
PIC ELF needs no extra tools. With CONFIG_PIC the ELF loader
allocates the writable sections separately and, when the filesystem
answers FIOC_XIPBASE, leaves the read-only ones on the media. Two
limits follow from having one base register per task: a shared object is
loaded as a single allocation, because the distance between its text and
its data is compiled into it, and the data base is installed once per
task, so every object in a task shares one.
FDPIC pays for its descriptors with an arm-uclinuxfdpiceabi
linker, and gets back the two things a single register
cannot express. A task or pthread that a module starts inherits the
module’s D-Space, so a register would be enough there; a work queue
worker was created at boot and carries no module base, and a descriptor
supplies one, which is how SIGEV_THREAD notifications reach module
code.
Requirements
An ARM Thumb-2 core. The boundary is the instruction set, not the core profile: GCC rejects FDPIC in Thumb-1 mode.
Core |
Architecture |
FDPIC |
|---|---|---|
Cortex-M3 / M4 / M7 |
ARMv7-M / ARMv7E-M |
yes |
Cortex-M33 |
ARMv8-M Mainline |
yes |
Cortex-M0 / M0+ / M23 |
ARMv6-M / ARMv8-M Baseline |
no |
RISC-V has no FDPIC ABI – the psABI addendum is an unmerged proposal and
no EI_OSABI value is assigned – so a RISC-V target cannot use this.
Flash that is memory mapped and executable, exposed by a filesystem
that answers XIPFSIOC_PIN or FIOC_XIPBASE. This is what gives
execute in place. Without it the module still loads, but from RAM.
An FDPIC linker. A stock arm-none-eabi GCC compiles correct
FDPIC code for both C and C++. Its assembler accepts the relocations that
code produces in FDPIC mode only: GCC 14 and later select that mode for
-mfdpic, and for an older GCC the build passes -Wa,--fdpic. Only
arm-uclinuxfdpiceabi binutils carry the armelf_linux_fdpiceabi
emulation that the link needs, and arm-none-eabi-ld rejects it.
No distribution packages that target, so build binutils for it – which takes about a minute and needs nothing else:
configure --target=arm-uclinuxfdpiceabi --prefix=$HOME/fdpic \
--disable-nls --disable-werror
make && make install
export PATH=$HOME/fdpic/bin:$PATH
An FDPIC GCC is not needed.
The base firmware must reserve r9. It is not enough for the module
to be well behaved: a firmware routine calling back into module code
arrives with the module’s data base in r9 only if the compiler was never
free to allocate that register elsewhere. CONFIG_FDPIC selects
CONFIG_PIC, under which arch/arm/src/common/Toolchain.defs adds
--fixed-r9; see NXFLAT for why it goes into ARCHCFLAGS
rather than CFLAGS and how to check that it arrived.
Configuration
CONFIG_FDPIC lives under CONFIG_ELF. A working configuration
also needs a symbol table for modules to import from and a filesystem
that can expose its media:
CONFIG_ELF=y
CONFIG_FDPIC=y
CONFIG_LIBC_EXECFUNCS=y
CONFIG_EXECFUNCS_HAVE_SYMTAB=y
CONFIG_EXECFUNCS_SYSTEM_SYMTAB=y
CONFIG_FS_XIPFS=y
crt0 runs the constructors of a module only with
CONFIG_HAVE_CXXINITIALIZE.
Shared libraries need three more. A library resolves its own imports
against the table of CONFIG_LIBC_ELF_HAVE_SYMTAB, and the last two let
a library be named rather than spelled out as an absolute path:
CONFIG_LIBC_ELF_HAVE_SYMTAB=y
CONFIG_LIBC_ENVPATH=y
CONFIG_LDPATH_INITIAL="/mnt/xipfs"
dlopen() needs CONFIG_LIBC_DLFCN too.
Only the make build generates the table of
CONFIG_EXECFUNCS_SYSTEM_SYMTAB. A CMake configuration needs a symbol
table from elsewhere.
CONFIG_ELF_STACKSIZE gives the stack a module runs with. A module
that needs a different one can export an nx_stacksize symbol, which
the loader prefers when present.
Building a module
Select CONFIG_FDPIC and a module is built by the ordinary in-tree ELF
build: the same MODULE = m in the same application Makefile as any
other, and the same crt0. Nothing else is needed.
The flags come from arch/arm/src/common/Toolchain.defs. A board whose
scripts/Make.defs sets LDELFFLAGS after it includes that file
replaces them, and its modules do not link as FDPIC. Remove that setting
from the board.
What the build does differently is add compiler flags and use a different linker:
arm-none-eabi-gcc -mcpu=cortex-m33 -mthumb -mfdpic -fPIC -Wa,--fdpic \
-fno-optimize-sibling-calls -Os -fno-builtin -D__NuttX__ \
-I$NUTTX/include -c mod.c -o mod.o
arm-uclinuxfdpiceabi-ld -m armelf_linux_fdpiceabi -shared -z now \
-e _start -T $NUTTX/libs/libc/elf/gnu-elf.ld \
-o mod $NUTTX/arch/arm/src/crt0.o mod.o
Only the link needs the FDPIC toolchain. The stock compiler emits correct
FDPIC objects for both C and C++, its assembler included once it is in
FDPIC mode. That linker is in the NuttX CI image;
tools/ci/docker/linux/Dockerfile shows how it is built.
FDPIC_CROSSDEV names a different prefix, and the build says so if it
is missing.
Seven flags carry weight:
-mfdpicis stated rather than assumed, so a mis-set toolchain fails loudly instead of producing a plain ELF the loader will not recognize.-fPICis not implied by-mfdpicon a bare-metal target, and without it the link emitsTEXTREL. Text relocations cannot work against text executed from read-only flash.-Wa,--fdpicputs the assembler in FDPIC mode, which GCC before 14 does not do for-mfdpic. Without it the assembler stops with “Relocation supported only in FDPIC mode”.-fno-optimize-sibling-callskeeps GCC from making tail calls. With-mlong-calls, GCC turns a call in tail position into a branch to the function descriptor rather than through it, and the module faults. Only an optimized build makes tail calls, so a build without optimization hides the problem.-sharedpreserves theR_ARM_FUNCDESC_VALUErelocations for imported symbols. A PIE link with--unresolved-symbols=ignore-allappears to work but degrades every import toR_ARM_NONE, and the module branches to zero on its first call into the firmware.-m armelf_linux_fdpiceabiis required: this linker supports several emulations and will not guess.-e _startnames the entry point.crt0.cis the module’s own start-up file, the one every other module uses: it walks.init_arrayand then callsmain. A shared library is never entered, so it is linked withoutcrt0.
A module links with -shared, so importing something the firmware does
not export links cleanly and fails only on the target. Checking the
undefined symbols of the module against the generated
libs/libc/exec_symtab.c catches that at build time.
When the toolchain cannot make an FDPIC module, the build stops with one of these:
CONFIG_FDPIC needs arm-uclinuxfdpiceabi-ld, which is not on PATHThe FDPIC linker is missing. The CMake build stops with the same message when it configures.
unrecognised emulation mode: armelf_linux_fdpiceabiFDPIC_CROSSDEVnames a linker without the FDPIC emulation, such asarm-none-eabi-.Relocation supported only in FDPIC modeThe assembler is not in FDPIC mode. A compile that does not use the module flags has lost
-Wa,--fdpic.unknown argument: '-mfdpic'The compiler cannot make FDPIC code. Clang is one.
Calling back into a module
A module’s function pointer is the address of a descriptor in its
writable segment. Firmware that stores one and later branches to it
would jump into RAM data, so an entry point that accepts a callback from
a module has to resolve the descriptor first. CONFIG_FDPIC makes
these do so:
qsort, bsearch, pthread_create, signal/sigaction,
task_create/task_create_with_stack, task_spawn,
pthread_once, scandir, and mq_notify/timer_create with
SIGEV_THREAD.
Whether a pointer is a descriptor is decided by reading the PIC base register: a module’s task runs with its data base there, a firmware task with zero, so a kernel caller is unaffected.
A new entry point that takes a module callback must resolve it too, under three rules:
Resolve once, in the innermost common routine. Resolving twice treats a code address as a descriptor.
qsort()recurses, so its public entry resolves and the recursive body does not;signal()does not resolve becausenxsig_action()does it for both paths;scandir()resolves its filter but not the comparison function it hands toqsort().Exclude sentinel values by hand.
fdpic_callback()declines to dereference NULL and nothing else.sigaction()excludesSIG_IGN,SIG_DFL,SIG_HOLDandSIG_ERR– the integers 0, 1, 2 and -1.A callback on a shared thread needs its base installed. A
SIGEV_THREADnotification runs on a work queue worker that carries no module base, so resolving the entry is not enough. Capture the base at registration withfdpic_base(), in the module’s own context, and install it around the call withfdpic_invoke().
Everywhere else the callback runs in a task that inherited the module’s D-Space, so only the code address needs resolving.
Examples and tests
apps/examples/fdpicxip writes modules into XIPFS at run time and runs
them. fdpicxip qsort runs one module twice, with one copy of its
text; solib adds a shared library; cxx does the same in C++ and
checks that the constructors ran; jmprel runs a module whose imports
are bound through DT_JMPREL.
apps/testing/fs/xipfs asserts what the demo shows. xipfs_test
fdpic loads modules and checks the loader, and xipfs_test reject
checks that a malformed or unloadable module is refused. Both apps build
their modules from apps/examples/fdpicxip/modules with the module
flags of the tree.
pimoroni-pico-2-plus:xipfs-fdpic is a board configuration with all of
this. Without hardware, mps2-an500:xipfs runs it under QEMU with
these added:
CONFIG_ELF=y
CONFIG_FDPIC=y
CONFIG_EXAMPLES_FDPICXIP=y
CONFIG_LIBC_EXECFUNCS=y
CONFIG_EXECFUNCS_HAVE_SYMTAB=y
CONFIG_EXECFUNCS_SYSTEM_SYMTAB=y
CONFIG_LIBC_DLFCN=y
CONFIG_LIBC_ELF_HAVE_SYMTAB=y
CONFIG_LIBC_ENVPATH=y
CONFIG_LDPATH_INITIAL="/mnt/xipfs"
CONFIG_SIG_EVTHREAD=y
CONFIG_SCHED_HPWORK=y
CONFIG_INIT_STACKSIZE=16384
and with CONFIG_DISABLE_POSIX_TIMERS, CONFIG_PROFILE_ALL,
CONFIG_PROFILE_MINI and CONFIG_SYSTEM_GPROF off.
Constructors and destructors
A module that exec() runs is entered at its own crt0, which walks
.init_array on the task that runs the module. A library is
constructed by dlopen() through libelf_insert(), on the task that
loads it, as for any shared library. It is entered through
fdpic_invoke(), so that a global object reaches the library’s own
storage.
A library named in DT_NEEDED is constructed before the module that
needs it, because the module’s own relocation is what opens it, and
destroyed after, at the last dlclose(). Since the library is one
instance, its constructors run once however many modules name it.
Destructors are walked from DT_FINI_ARRAY at unload, in
libelf_uninit(), and not from crt0, so they run once whichever way
the object was loaded.
Limitations
Tested in the flat build only. In a protected build, a module that
exec() started faulted at its entry point when last tested, because
the kernel side of the loader places the module in the kernel heap. A
kernel build needs an MMU, which the FDPIC cores do not have, and an
address environment refuses DT_NEEDED.
Reference
Object layout
libs/libc/elf/gnu-elf.ld gives a module the layout that execute in
place needs:
LOAD vaddr 0x00000000 R E .text .rodata .dynsym .dynstr .hash
.rofixup .rel.dyn .rel.plt
LOAD vaddr 0x00001000 RW .data .dynamic .got .bss
DYNAMIC DT_PLTGOT -> .got
The writable segment starts on the next 4 KiB boundary. .rel.plt is
there only when the module calls through a PLT. In .got,
.got.plt comes first, as in the linker’s own script.
.rodata lands in the read-only segment on its own, reached PC
relative or GOT indirect. That matters: in the writable segment it would
be copied to RAM with .data, and most of the saving would evaporate
silently, with everything still working.
The FDPIC marker is the OS/ABI byte alone. e_flags holds the
ordinary EABI version and float ABI, such as 0x5000000, Version5 EABI.
Relocations
The static link resolves R_ARM_GOT_BREL and R_ARM_GOTFUNCDESC
into the GOT already, so only four types carry work into a linked
module.
R_ARM_RELATIVEAn address needing its segment’s base added.
R_ARM_FUNCDESC_VALUEA descriptor the linker has laid out, for the loader to fill in. This is what a call to an imported function produces. When the symbol resolves to a function in another FDPIC object, both words are copied from that object’s own descriptor, so the callee runs with its own data base; otherwise the entry is the resolved address and the base is this object’s.
R_ARM_FUNCDESCA pointer to a descriptor that does not exist yet, which the loader manufactures from the pool behind the writable segment. This is what taking the address of a function produces – a different thing from calling one, and both can appear for the same symbol.
R_ARM_GLOB_DATThe address of a symbol, stored in a GOT entry: an imported data object, or a symbol of the module itself, such as the bounds of
.init_arraythatcrt0reads.
Constants, from binutils include/elf/arm.h and mirrored in
arch/arm/include/elf.h: R_ARM_GOTFUNCDESC 161,
R_ARM_GOTOFFFUNCDESC 162, R_ARM_FUNCDESC 163,
R_ARM_FUNCDESC_VALUE 164.
Which table an imported function’s descriptor lands in is the linker’s
decision. The module flags have -mlong-calls and -z now, so a
call goes through the GOT and its descriptor is in DT_REL. A module
built without -mlong-calls and linked with -z lazy calls through a
PLT, and the descriptors are in DT_JMPREL. Both are bound eagerly,
so either link works, but the two are not walked identically. In
DT_REL the word being overwritten is the addend and is added to the
resolved value; in DT_JMPREL it is the lazy binding bootstrap and the
descriptor is overwritten outright.
.rofixup is skipped. It is the self-relocation list a static
executable’s crt0 walks to find its own GOT. A module’s crt0 does
not: the loader supplies the data base, in the PIC base register, before
the module is entered.
Exported functions
A function exported by a module or library is published to dlsym()
as a descriptor rather than a code address, taken from the same pool, so
that an FDPIC caller can branch through what it gets back.