Skip to content

[dlfcn] E: Add dlfcn-weak-c tests for STB_WEAK loader semantics#1

Closed
esaurez wants to merge 1 commit into
mainfrom
feat/dlfcn-weak-c-tests
Closed

[dlfcn] E: Add dlfcn-weak-c tests for STB_WEAK loader semantics#1
esaurez wants to merge 1 commit into
mainfrom
feat/dlfcn-weak-c-tests

Conversation

@esaurez

@esaurez esaurez commented May 28, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a new src/dlfcn-weak-c/ regression suite covering STB_WEAK
undefined-symbol semantics through the public POSIX dlfcn interface
(dlopen/dlsym). The suite is the canonical end-to-end test for the
in-process Nanvix loader change that adds STB_WEAK handling to
get_symbol_value() — once that change lands upstream, this suite
becomes the test that pins the behaviour in place.

Why this matters

The System V gABI (chapter "Symbol Table") specifies that an unresolved
STB_WEAK undefined symbol takes value zero at dynamic link time, with
the program responsible for null-checking before use. Every mainstream
ELF loader (glibc elf/dl-lookup.c, musl ldso/dynlink.c, FreeBSD
rtld-elf/rtld.c, Android Bionic linker/linker_relocate.cpp)
implements this rule. The Nanvix loader work mirrors that contract.

We need a hermetic C-level test driven through the public POSIX dlfcn
API — independent of any specific consumer (cpython, Rust startup
crates, libposix) — so that future loader refactors cannot silently
re-break the contract.

Test matrix

7 cases, all in main.c. Magic string "ok" is written on full
success; assert() aborts on failure (matches the sibling dlfcn-c,
dlfcn-pie-c, dlfcn-global-c, dlfcn-needed-c convention).

# Fixture Relocation Symbol Expected behaviour
5 libstrong-missing.so R_386_JUMP_SLOT strong_missing dlopen(RTLD_NOW) fails
1 libweak-func-resolved.so R_386_GLOB_DAT main_callback resolves to main exe; try_callback()==70
2 libweak-func-missing.so R_386_GLOB_DAT missing_callback &fn == NULL; try_callback() == -1
3 libweak-data-resolved.so R_386_GLOB_DAT weak_data resolves to main exe; read_weak_data()==99
4 libweak-data-missing.so R_386_GLOB_DAT missing_weak_data &data == NULL; read_weak_data() == -1
6 libweak-plt-resolved.so R_386_JUMP_SLOT main_callback resolves to main exe; PLT call returns 70
7 libweak-plt-missing.so R_386_JUMP_SLOT missing_plt_callback dlopen(RTLD_NOW) succeeds

Case 5 runs first so that a regression in the strong-undefined
path is reported independently of the weak cases. It is also the only
case that passes against a loader without STB_WEAK support — see the
validation section below.

Case 7 deliberately does not call the function pointer it resolves.
The JMP_SLOT was zeroed by the loader (weak undefined missing → 0);
invoking it would jump to NULL. The case only validates that
dlopen(..., RTLD_NOW) accepts the .so despite the weak undefined
PLT entry.

Relocation-class coverage

The weak NULL-guarded pattern if (&fn) fn(); (in libs/weak_func.c
and libs/weak_data.c) forces a GOT-indirect call/load and only
exercises R_386_GLOB_DAT. To also exercise weak references via the
PLT, this PR adds libs/weak_func_plt.c (an unguarded call) which
emits R_386_JUMP_SLOT. Together, the two helpers cover both
relocation classes the loader must handle for weak undefined symbols
in i686 ELF.

The strong-undefined regression guard (libs/strong_missing.c) is
unguarded by construction and emits R_386_JUMP_SLOT.

Build-time fixture verification

The Makefile asserts via readelf -rW and readelf -WsD that each
produced .so contains both:

  1. The intended relocation type AND symbol name (proves the fixture is
    exercising the symbol we think it is, not some accidental CRT-injected
    weak symbol).
  2. The intended dynamic-symbol binding (WEAK / GLOBAL) and that the
    symbol is UND.

Helper .so files are compiled with the suite-wide CFLAGS, keeping
the test representative of the project's normal -O2 + warning
environment.

Linker flags

Main executable: -pie -rdynamic -Wl,--no-dynamic-linker

  • -pie + -rdynamic: export main_callback and weak_data in the
    main exe's .dynsym so the resolved cases can look them up
    (matches the existing dlfcn-pie-c pattern).
  • --no-dynamic-linker: Nanvix uses an in-process loader via syscalls,
    not a separate ld.so.

Validation

  • End-to-end with the STB_WEAK loader change applied: all 7 cases
    pass against a Nanvix microvm guest. The full test runs in standalone
    mode via ./z test.
  • Case 5 alone against a loader WITHOUT the STB_WEAK change: also
    passes (sanity-validated by temporarily commenting out cases 1-4 and
    6-7 and rebuilding). This confirms the regression-guard semantics
    (dlopen(RTLD_NOW) must continue to fail on missing strong UND
    symbols regardless of the weak-symbol code path).

Merge order

This PR can land any time. Cases 1-4 and 6-7 will only pass once the
Nanvix loader STB_WEAK change is merged. If this PR lands first, the
posix-tests CI run on a Nanvix build without the loader change would
fail cases 1-4/6-7 — that's the intended signal once the matching
nanvix-side PR opens. The cleanest sequencing is to merge this PR
roughly the same release cycle as the loader change.

New `src/dlfcn-weak-c/` regression suite covering the System V ABI rule
that an unresolved STB_WEAK undefined symbol takes value zero at dynamic
link time (gABI, chapter "Symbol Table"; also implemented by glibc, musl,
FreeBSD rtld, and Bionic).  Companion to the in-process nanvix loader
work that adds STB_WEAK handling to `get_symbol_value()`.

Test matrix (7 cases driven through public POSIX `dlfcn`):

  1. weak UND function (GOT, R_386_GLOB_DAT), resolved via main exe
  2. weak UND function (GOT, R_386_GLOB_DAT), missing -> &fn == NULL
  3. weak UND data     (GOT, R_386_GLOB_DAT), resolved via main exe
  4. weak UND data     (GOT, R_386_GLOB_DAT), missing -> &data == NULL
  5. strong UND function (PLT, R_386_JUMP_SLOT), missing -> dlopen fails
  6. weak UND function (PLT, R_386_JUMP_SLOT), resolved via main exe
  7. weak UND function (PLT, R_386_JUMP_SLOT), missing -> dlopen succeeds

Case 5 runs first because it is the only one that passes against a
loader without STB_WEAK support, so a regression in the strong-undefined
path is reported independently of the weak cases.

Case 7 dlopens the missing-PLT variant but deliberately does not call
the resolved function pointer: the JMP_SLOT was zeroed by the loader
and invoking it would jump to NULL.  The case only validates that
`dlopen(..., RTLD_NOW)` accepts the .so despite the weak undefined
symbol.

The Makefile asserts via `readelf -rW` / `-WsD` that each .so contains
both the intended relocation type AND the intended symbol name (and
binding), so fixtures cannot silently drift to exercising the wrong
symbol.  Helper .so files are compiled with the suite-wide CFLAGS to
keep the test representative of the project's normal optimization
environment.

Main executable links with `-pie -rdynamic -Wl,--no-dynamic-linker`
so `main_callback` and `weak_data` are exported in the main exe
.dynsym (matching the existing `dlfcn-pie-c` pattern).

Validated end-to-end on the Nanvix microvm against a loader build
with the STB_WEAK change: all 7 cases pass.  Case 5 alone was also
validated against a loader build without the change (the historical
strong-undefined path), confirming the regression-guard semantics.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ppenna pushed a commit to nanvix/nanvix that referenced this pull request Jun 4, 2026
Per the System V ABI generic ABI (gABI, chapter "Symbol Table"), an
undefined symbol whose binding is `STB_WEAK` and which cannot be
resolved at dynamic-link time is silently taken to have the value zero
(or `NULL` for function symbols).  Every mainstream ELF dynamic
loader -- glibc `elf/dl-lookup.c`, musl `ldso/dynlink.c`, FreeBSD
`libexec/rtld-elf/rtld.c`, Android Bionic
`linker/linker_relocate.cpp` -- implements this contract; the program
is responsible for null-checking the symbol before use.

The Nanvix in-process loader's `get_symbol_value()` previously
returned `Err(BadFile, "symbol not found")` unconditionally when a
referenced symbol could not be resolved, regardless of binding.  As a
result, any `.so` containing a weak undefined reference -- which is
extremely common: libstdc++ keeps weak refs to `pthread_*` and
`__gmon_start__`, glibc-compatible compilers emit weak refs to
`__cxa_thread_atexit_impl` and TLS descriptors, and Rust crates often
expose optional integration hooks as weak symbols -- failed to
`dlopen()`, even though the program would have run correctly with the
spec-defined zero substitution.

This patch fills that gap:

  - `src/libs/elf/src/elf32.rs` adds the `STB_LOCAL` / `STB_GLOBAL` /
    `STB_WEAK` constants, the `ST_BIND_SHIFT` constant, and an
    `Elf32Sym::st_bind()` accessor that extracts the binding nibble.

  - `src/libs/elf/src/relocation.rs` lifts the raw `STB_*` values into
    a typed `SymbolBinding` enum (`Local` / `Global` / `Weak` /
    `Other`), wires `Symbol::binding()` through the goblin
    `st_bind()` helper, and adds an `is_weak()` predicate.  Unknown
    or reserved binding values fall through to `SymbolBinding::Other`
    so they are never silently treated as weak.  Five unit tests
    cover decoding, the `Other` fallback, the independence of the
    binding nibble from the type nibble, and that `is_weak()` /
    `is_undefined()` are orthogonal axes.

  - `src/libs/syscall/src/dlfcn/syscall/dynlib.rs::get_symbol_value()`
    intercepts the existing `lookup() == None` arm: if the referring
    symbol is both `SHN_UNDEF` and `STB_WEAK`, return `Ok(0)` and log
    at `debug!`.  Strong undefined references continue to return the
    pre-existing `BadFile` error, so `dlopen(RTLD_NOW)` still fails
    on a genuine missing strong dependency.

  - `src/libs/syscall/src/dlfcn/syscall/dynlib.rs::query()` skips
    undefined entries before consulting `get_symbol_value()`.  Without
    this, `dladdr()` would report a ghost symbol at address `0` for
    every weak undefined entry in the dynsym; that is correct
    behaviour for relocation but is not what `dladdr` is meant to
    surface.

Substituting zero is safe across the relocation types the loader
currently implements (`R_386_32`, `R_386_PC32`, `R_386_JMP_SLOT`,
`R_386_GLOB_DAT`): the resulting GOT / PLT entry or in-place 32-bit
slot is null, so any code path that actually dereferences the symbol
traps deterministically -- matching the contract the spec puts on the
program (it must null-check before use).

Validated end-to-end on the Nanvix microvm:

  - The new `dlfcn-weak-c` regression suite (proposed in
    nanvix/posix-tests, see esaurez/posix-tests#1) exercises all four
    weak-undefined relocation classes (GOT/PLT × function/data) in
    both resolved-via-main-exe and missing variants, plus a strong-
    undefined regression guard.  All 7 cases pass against this
    branch.  The strong-undefined case still fails `dlopen(RTLD_NOW)`,
    preserving the existing contract.

  - CPython 3.12 successfully links against libstdc++ with weak refs
    to `pthread_*` etc. left unresolved at .so-load time, runs
    `hello.py`, then `dlopen()`s and exercises numpy 1.26.4 to
    produce `NUMPY_TEST_OK` on the guest.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@esaurez

esaurez commented Jun 9, 2026

Copy link
Copy Markdown
Owner Author

Superseded by upstream PR nanvix/posix-tests#176, which carries the same test contribution -- rebased onto upstream main, scope-trimmed to only the test code (the fork PR also carried unrelated local-env drift: a CI workflow downgrade from v2.2.0 to v2.0.2, a nanvix-version downgrade from 0.16.17 to 0.15.26, z.ps1/z.sh changes, .gitignore tweaks, and z.py imports cleanup -- all dropped), deep-reviewed (one SHOULD-FIX applied: removed fork-specific BINARIES_DIR/LIBRARIES_DIR/READELF ?= fallbacks and Windows REMOVE macro from the suite Makefile to match siblings; three NITs applied: stripped non-ASCII em-dashes and right-arrow, dropped 'static' qualifier on test helpers to match dlfcn-c convention, added per-case R_386_GLOB_DAT / R_386_JUMP_SLOT relocation comments). Full posix-tests integration suite passes against the 0.16.17 release nanvix. Closing this fork PR; tracking continues upstream.

@esaurez esaurez closed this Jun 9, 2026
tangruize pushed a commit to nanvix/nanvix that referenced this pull request Jun 15, 2026
Per the System V ABI generic ABI (gABI, chapter "Symbol Table"), an
undefined symbol whose binding is `STB_WEAK` and which cannot be
resolved at dynamic-link time is silently taken to have the value zero
(or `NULL` for function symbols).  Every mainstream ELF dynamic
loader -- glibc `elf/dl-lookup.c`, musl `ldso/dynlink.c`, FreeBSD
`libexec/rtld-elf/rtld.c`, Android Bionic
`linker/linker_relocate.cpp` -- implements this contract; the program
is responsible for null-checking the symbol before use.

The Nanvix in-process loader's `get_symbol_value()` previously
returned `Err(BadFile, "symbol not found")` unconditionally when a
referenced symbol could not be resolved, regardless of binding.  As a
result, any `.so` containing a weak undefined reference -- which is
extremely common: libstdc++ keeps weak refs to `pthread_*` and
`__gmon_start__`, glibc-compatible compilers emit weak refs to
`__cxa_thread_atexit_impl` and TLS descriptors, and Rust crates often
expose optional integration hooks as weak symbols -- failed to
`dlopen()`, even though the program would have run correctly with the
spec-defined zero substitution.

This patch fills that gap:

  - `src/libs/elf/src/elf32.rs` adds the `STB_LOCAL` / `STB_GLOBAL` /
    `STB_WEAK` constants, the `ST_BIND_SHIFT` constant, and an
    `Elf32Sym::st_bind()` accessor that extracts the binding nibble.

  - `src/libs/elf/src/relocation.rs` lifts the raw `STB_*` values into
    a typed `SymbolBinding` enum (`Local` / `Global` / `Weak` /
    `Other`), wires `Symbol::binding()` through the goblin
    `st_bind()` helper, and adds an `is_weak()` predicate.  Unknown
    or reserved binding values fall through to `SymbolBinding::Other`
    so they are never silently treated as weak.  Five unit tests
    cover decoding, the `Other` fallback, the independence of the
    binding nibble from the type nibble, and that `is_weak()` /
    `is_undefined()` are orthogonal axes.

  - `src/libs/syscall/src/dlfcn/syscall/dynlib.rs::get_symbol_value()`
    intercepts the existing `lookup() == None` arm: if the referring
    symbol is both `SHN_UNDEF` and `STB_WEAK`, return `Ok(0)` and log
    at `debug!`.  Strong undefined references continue to return the
    pre-existing `BadFile` error, so `dlopen(RTLD_NOW)` still fails
    on a genuine missing strong dependency.

  - `src/libs/syscall/src/dlfcn/syscall/dynlib.rs::query()` skips
    undefined entries before consulting `get_symbol_value()`.  Without
    this, `dladdr()` would report a ghost symbol at address `0` for
    every weak undefined entry in the dynsym; that is correct
    behaviour for relocation but is not what `dladdr` is meant to
    surface.

Substituting zero is safe across the relocation types the loader
currently implements (`R_386_32`, `R_386_PC32`, `R_386_JMP_SLOT`,
`R_386_GLOB_DAT`): the resulting GOT / PLT entry or in-place 32-bit
slot is null, so any code path that actually dereferences the symbol
traps deterministically -- matching the contract the spec puts on the
program (it must null-check before use).

Validated end-to-end on the Nanvix microvm:

  - The new `dlfcn-weak-c` regression suite (proposed in
    nanvix/posix-tests, see esaurez/posix-tests#1) exercises all four
    weak-undefined relocation classes (GOT/PLT × function/data) in
    both resolved-via-main-exe and missing variants, plus a strong-
    undefined regression guard.  All 7 cases pass against this
    branch.  The strong-undefined case still fails `dlopen(RTLD_NOW)`,
    preserving the existing contract.

  - CPython 3.12 successfully links against libstdc++ with weak refs
    to `pthread_*` etc. left unresolved at .so-load time, runs
    `hello.py`, then `dlopen()`s and exercises numpy 1.26.4 to
    produce `NUMPY_TEST_OK` on the guest.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant