Skip to content

libs/libc/elf: Keep a module registry per task group. - #20492

Open
casaroli wants to merge 3 commits into
apache:masterfrom
casaroli:libelf-per-task-registry
Open

casaroli wants to merge 3 commits into
apache:masterfrom
casaroli:libelf-per-task-registry

Conversation

@casaroli

@casaroli casaroli commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Each task group now has its own registry of the ELF objects that dlopen() loads, as suggested in #20132. The registry is in struct task_info_s and not in struct tls_info_s, because POSIX makes a dlopen() handle valid in the whole process, not only in the thread that opened it.

Kernel modules from insmod() stay on one registry for the system, and /proc/modules shows only them.

A library that a module names in DT_NEEDED goes on the registry of that module. In a flat build, exec() gives the libraries of the program to the new task group, so dlopen() in the program finds the same copy.

When a task group exits, the libraries that it did not close are closed. A flat build does this in group_release(), after the last thread. A protected build does it in exit(), because the kernel cannot reach the user registry.

The first commit fixes dlclose() of a library that other modules still depend on: it gave up the last reference although it failed, so a later dlclose() of the open handle failed with ENOENT. It now fails with EBUSY and keeps the reference.

Impact

Two processes that use one library now each have their own copy of its data, and its constructors and destructors run once per process. An FDPIC library executed in place from xipfs still shares its text.

A library that is not executed in place is now loaded once per process, so its text uses RAM in each process.

A program loaded by exec() no longer resolves symbols from libraries that other processes loaded, or from insmod() modules.

dlopen() handles that a process does not close are now freed when it exits. Before, they stayed loaded.

The xipfs C++ test in apps checks the old shared behaviour. apache/nuttx-apps#3813 changes it.

Testing

The table below is from the first version of this PR. The reworked version (one list per registry, recursive registry lock, libelf_removeall()) was built for qemu-armv8a:knsh on the current master, and a 4-worker exec() stress test on qemu-armv8a:knsh with 4 CPUs completes 100 rounds with no memory growth (page memory back to 16 MB after the run).

Arm GNU Toolchain 13.2.rel1, QEMU mps2-an500.

test master this PR
xipfs_test fdpic (FDPIC, mps2-an500:xipfs) not run on the current master 34 passed, 0 failed, with the apps PR
fdpicxip solib library pinned once, totals 9 and 9 pinned twice, totals 3 and 6, 0 pins after exit
fdpicxip cxx one library destructor one destructor per process
sotest (plain ELF) same output same output
sotest that exits with its libraries open the library destructor never runs it runs at exit
mps2-an500:knsh (protected) with ELF and dlfcn: ostest exits 0 exits 0
qemu-armv7a:knsh (kernel) with dlfcn builds builds
esp32s3-devkit:kernel_n8r2 (kernel, CONFIG_LIBC_ELF), kernel and user programs builds builds
esp32s3-devkit:kernel_oct (kernel, CONFIG_LIBC_ELF) on an ESP32-S3-WROOM-2-N32R8V: ostest and sandbox not run ostest exits 0, fork() passes; sandbox reports every check passed

tools/checkpatch.sh -c -u -m -g passes.

@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

MemBrowse Memory Report

s698pm-dkit

@casaroli
casaroli force-pushed the libelf-per-task-registry branch from 69e3fe6 to 52e93c8 Compare October 7, 2026 19:26
@casaroli

casaroli commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

@xiaoxiang781216 i think this is breaking change. Should I mark it and bring a vote to the list?

@xiaoxiang781216

Copy link
Copy Markdown
Contributor

@xiaoxiang781216 i think this is breaking change. Should I mark it and bring a vote to the list?

the old behavior doesn't compliant to POSIX, this patch fix the problem. So, I think it's a bug fix.:)

@casaroli
casaroli force-pushed the libelf-per-task-registry branch from 52e93c8 to a04d4c0 Compare October 8, 2026 06:24
@casaroli
casaroli marked this pull request as ready for review October 8, 2026 06:50
@casaroli

casaroli commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

ok, i agree

acassis
acassis previously approved these changes Oct 8, 2026
@github-actions

Copy link
Copy Markdown

❌ Cross-repo dependency could not be applied

The Build report says the declared dependency PR(s) could not be applied, so CI did not run against the combined code:

Reason: cherry-pick failed (if your PR has merge commits, rebase instead)

CI run: https://github.com/apache/nuttx/actions/runs/38088049757

@casaroli casaroli closed this Oct 11, 2026
@casaroli casaroli reopened this Oct 11, 2026
@casaroli casaroli closed this Oct 11, 2026
@casaroli casaroli reopened this Oct 11, 2026
@casaroli casaroli closed this Oct 12, 2026
@casaroli casaroli reopened this Oct 12, 2026
its last handle in the task group is closed. They do not run per
``dlopen()``/``dlclose()`` pair.
* The libraries that a program loaded by ``exec()`` names in ``DT_NEEDED``
belong to its task group in the FLAT build, so ``dlopen()`` of one of them

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does DT_NEEDED work in protected/kernel build now?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the PROTECTED build, yes: the kernel loads the libraries of a program and keeps them on a list in its struct binary_s.

In the KERNEL build, no: with CONFIG_ARCH_ADDRENV, libelf_bind() fails a module that has DT_NEEDED (ENOSYS), because a library that libelf_insert() loads cannot reach the address space of the program.

Comment thread include/nuttx/lib/elf.h Outdated
@@ -175,6 +175,10 @@ struct fdpic_desc_s;
struct module_s
{
FAR struct module_s *flink; /* Supports a singly linked list */
FAR struct module_s **registry; /* The registry this module is on */
FAR struct module_s *libraries; /* exec(): its own registry, for

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why need add libraries/registry field? we just need a link list and hold the head in tls

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: struct module_s has only a list node now, and the head is ta_modules in the task info, with its lock ta_modlock.

One field stays in mod_loadinfo_s: the registry that libelf_bind() puts the DT_NEEDED libraries on. A program that exec() loads has no task group yet, so its libraries go first on a list in struct binary_s, and exec_module() moves them to the new task group.

Comment thread libs/libc/elf/elf_registry.c Outdated
* Name: libelf_registry_kernel
****************************************************************************/

FAR struct module_s **libelf_registry_kernel(void)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move to module

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread libs/libc/elf/elf_registry.c Outdated
* Name: libelf_registry
****************************************************************************/

FAR struct module_s **libelf_registry(void)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move to dlfnc

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread libs/libc/elf/elf_bind.c Outdated
@@ -1040,7 +984,8 @@ static int libelf_relocatedyn(FAR struct module_s *modp,
return ret;
}

handle = libelf_insertneeded((FAR const char *)loadinfo->iobuffer);
handle = libelf_insertneeded(modp->registry,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not insert to ta_modules

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread libs/libc/elf/elf_remove.c Outdated
{
FAR struct module_s *modp;

libelf_registry_lock();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should use per task lock from tls

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment thread libs/libc/elf/elf_remove.c Outdated
*
****************************************************************************/

void libelf_closeall(FAR struct module_s **registry)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

libelf_removeall

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

libelf_remove() set the reference count of a module to zero before
libelf_uninit() refused to unload it because other modules depend on it.
The failed dlclose() thus gave up the last reference anyway.  When the
last dependent went, libelf_undepend() unloaded the module, and the
next dlclose() of the still open handle failed with ENOENT.
examples/sotest shows it: "ERROR: dlclose(handle1) failed: -1".

Refuse with EBUSY before the reference is given up, and keep the
reference if the uninitializer of the module fails.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
One global registry held every ELF object that dlopen(), insmod() and
the DT_NEEDED loader loaded.  Thus a library was one instance for the
whole system, and all task groups shared its data.

A registry is now a list head, and a module is on it through a list
node, so a module can leave its list without knowing the head.  The
libelf functions get the registry from the caller and do not lock it:
the caller holds the lock of that registry.

- dlopen(), dlclose() and dlsym() use the registry of the calling task
  group, ta_modules in struct task_info_s.  POSIX makes a dlopen() handle
  valid in the whole process, so the registry is per task group and not
  per thread.  Its lock, ta_modlock, is recursive because the
  constructors of a library run with it held and can call dlopen().
- insmod(), rmmod(), modsym(), modhandle() and /proc/modules use the
  registry of the kernel modules, which is now in sched/module.
- The global symbol table has a lock of its own.

A DT_NEEDED library goes on the registry of the module that needs it,
which libelf_bind() gets in struct mod_loadinfo_s.  A program that exec()
loads has no task group yet, so its libraries go on a list in struct
binary_s.  In a flat build, exec_module() moves them to the registry of
the new task group, so dlopen() in the program finds the same copy.  In
the protected build the program is loaded by the kernel libc, which has
another heap, so its libraries stay on the list of the program.  If the
bind of a program fails, its libraries are now released.

libelf_removeall() closes the libraries that a task group did not close.
In a flat build, group_release() does it after the last thread, and then
gives what the program still uses back to the program, before the
registry goes with the task information.  In the protected build the
kernel cannot reach the user registry, so exit() does it.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
Describe the per task group registry: each task group loads its own copy
of a library, constructors and destructors run once per task group, and
a task group closes what it left open when it exits.  Kernel modules stay
on one list for the system.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
@casaroli
casaroli force-pushed the libelf-per-task-registry branch from a04d4c0 to 1516192 Compare October 13, 2026 14:19
@casaroli
casaroli requested a review from michallenc as a code owner October 13, 2026 14:19
@github-actions github-actions Bot added Size: L The size of the change in this PR is large and removed Size: M The size of the change in this PR is medium labels Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area: BINFMT Size: L The size of the change in this PR is large

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants