From ff1e77c66e76ebfa5faecedfc9811a49c8de439a Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Thu, 28 May 2026 18:24:47 -0700 Subject: [PATCH 1/5] [nanvix] E: Embed C/C++ runtime in python.elf for .so dlopen support Updates `Makefile.nanvix` so that `python.elf` correctly serves as the "main module" against which extension `.so`s (numpy, ssl, lxml, future pip-installed wheels, ...) resolve their C and C++ runtime symbols at dlopen() time. This is the consumer-side companion to the Nanvix loader's STB_WEAK support (esaurez/nanvix#22) and is gated on the new libposix `pathconf` / `fpathconf` stubs (esaurez/nanvix#23) for the configure conftest to even produce an executable. Three coordinated link-flag changes to the `CONFIGURE_ENV` block: 1. `LIBS` segment 1 -- new `--whole-archive ... --no-whole-archive` block ahead of the existing `--start-group`. Forces every object from libposix, libc, libm, libstdc++, and libgcc into python.elf so the runtime symbols extension `.so`s depend on are embedded (and re-exported via `-Wl,--export-dynamic`, already present). Without this, the static linker drops unreferenced objects (e.g. `fscanf`, `longjmp`, `strtold_l` for numpy; `operator new/delete[]`, `__cxa_*`, `_Unwind_*`, `std::type_info` vtables for any C++ extension) and subsequent dlopen() of those `.so`s fails with "symbol not found". 2. `LIBS` segment 2 -- the existing `--start-group` is trimmed to just the external add-on libraries (sqlite3, ssl, crypto, z, bz2, lzma, ffi). It no longer re-lists libposix / libc / libm: those archives are already fully included by segment 1, so the external libs can resolve their references against the already-embedded objects. 3. Two new top-level Makefile vars `LIBSTDCXX := -lstdc++` and `LIBGCC := -lgcc`. The GCC driver resolves them against its built- in search paths (libgcc lives under a versioned `lib/gcc/i686- nanvix//` directory, which would be fragile to hardcode). Defined once at top level because the `-l` form is identical between the docker and host build paths. `LDFLAGS` is unchanged. The existing `-Wl,--allow-multiple-definition` flag is kept and the surrounding comment is expanded to honestly enumerate the duplicate-symbol categories the flag is masking (newlib long-double math helpers, libposix/libc env+isatty overlaps, libc/libm math helper overlaps, libgcc internal `__x86.get_pc_thunk.*` duplicates, etc.) -- the set is large and toolchain-build-version- dependent, and is the only practical workaround until the contributing upstreams are fixed. `.nanvix/config.py::configure_env()` -- an unused helper that mirrors `Makefile.nanvix`'s `CONFIGURE_ENV` -- is kept in sync (same `--whole-archive` LIBS, same LDFLAGS) and gains a docstring calling out the dead-code status. A separate small cleanup PR can delete the helper entirely. Validated end-to-end on the Nanvix microvm: CPython 3.12 + numpy 1.26.4 runs `import numpy`, `np.arange`, `np.dot`, `reshape`, `flatten`, broadcasting, all producing `NUMPY_TEST_OK`. Hello.py and the existing single-process / multi-process / standalone modes are unaffected by the change because the linker flags are not mode-conditional. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .nanvix/config.py | 14 ++++++++++++-- Makefile.nanvix | 48 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/.nanvix/config.py b/.nanvix/config.py index 36b62c5df199511..f0e65e2347b8273 100644 --- a/.nanvix/config.py +++ b/.nanvix/config.py @@ -76,7 +76,15 @@ def toolchain_paths( def configure_env(toolchain: str | Path, sysroot: str | Path) -> dict[str, str]: - """Return the environment dict for ./configure.""" + """Return the environment dict for ./configure. + + NOTE: This helper is currently unused; the actual cpython build invokes + ``make -f Makefile.nanvix`` which has its own inline CONFIGURE_ENV. This + function is kept in sync so a future caller does not pick up stale link + flags. See ``Makefile.nanvix`` for the authoritative comment block + explaining the ``--whole-archive`` / ``--export-dynamic`` / + ``--allow-multiple-definition`` rationale. + """ tp = toolchain_paths(toolchain, sysroot) sr = Path(sysroot) return { @@ -96,7 +104,9 @@ def configure_env(toolchain: str | Path, sysroot: str | Path) -> dict[str, str]: f"-Wl,--export-dynamic -Wl,--no-dynamic-linker" ), "LIBS": ( - f"-Wl,--start-group {tp['libposix']} {tp['libc']} {tp['libm']} " + f"-Wl,--whole-archive {tp['libposix']} {tp['libc']} {tp['libm']} " + f"-lstdc++ -lgcc -Wl,--no-whole-archive " + f"-Wl,--start-group " f"-lsqlite3 -lssl -lcrypto -lz -lbz2 -llzma -lffi -Wl,--end-group" ), "LIBSQLITE3_LIBS": f"-L{sr}/lib -lsqlite3", diff --git a/Makefile.nanvix b/Makefile.nanvix index 4a11e1a19b3b028..a2e5d7da42b8e65 100644 --- a/Makefile.nanvix +++ b/Makefile.nanvix @@ -104,6 +104,15 @@ ifdef CONFIG_NANVIX LIBCRYPTO := $(abspath $(NANVIX_HOME))/lib/libcrypto.a BUILD_PYTHON := $(NANVIX_TOOLCHAIN)/bin/python3 endif + + # libstdc++ / libgcc are referenced via `-l` rather than absolute paths so + # the GCC driver resolves them: libgcc lives under a versioned dir + # (`lib/gcc/i686-nanvix//libgcc.a`) and hardcoding a version + # would be fragile across toolchain upgrades. Defined once at top-level + # because the `-l` form is identical between the docker and host build + # paths above. + LIBSTDCXX := -lstdc++ + LIBGCC := -lgcc else ifneq ($(MAKECMDGOALS),clean) ifneq ($(MAKECMDGOALS),distclean) @@ -114,6 +123,43 @@ else endif # Configure environment variables +# +# Linker flag rationale: +# +# `-Wl,--export-dynamic`: put every globally-defined symbol from python.elf +# into its `.dynsym`. Extension `.so`s (numpy's `_multiarray_umath.so`, ssl, +# etc.) leave C/C++ runtime symbols UND and resolve them against python.elf +# at dlopen() time. Without this, those `.so`s fail to load. +# +# `-Wl,--allow-multiple-definition`: tolerates duplicate symbol definitions +# that arise from forcing every libposix / libc / libm / libstdc++ / libgcc +# object into python.elf via `--whole-archive` below. The biggest set is +# newlib's long-double math helpers (`frexpl`, `llrintl`, `lrintl`, `rintl` +# are defined in three different newlib directories simultaneously — a +# newlib build-system bug). Other known overlaps include libposix vs. libc +# (`_start`, `copysign[f]`, `getenv`, `setenv`, `unsetenv`, `environ`, +# `isatty`), libc vs. libm (`frexp`, `ldexp`, `modf`, `isnan`, `isinf`, +# `scalbn`, …), libm vs. libstdc++ (`hypotf`), libgcc internal duplicates +# (`__x86.get_pc_thunk.*`), and a libc / libgcc `__eprintf`. The set is +# large and toolchain-build-version-dependent; treating the link as +# multiple-definition-tolerant is the only practical workaround until each +# upstream is fixed. Remove this flag once the contributing duplicates are +# resolved upstream. +# +# `LIBS` segment 1 (`--whole-archive ... --no-whole-archive`): force every +# object from libposix, libc, libm, libstdc++, and libgcc into python.elf +# so the runtime symbols extension `.so`s depend on are embedded and +# re-exported via `--export-dynamic`. Without `--whole-archive`, the static +# linker drops unreferenced objects (e.g. `fscanf`, `longjmp`, `strtold_l` +# for numpy; `operator new/delete[]`, `__cxa_*`, `_Unwind_*`, +# `std::type_info` vtables for any C++ extension) and subsequent dlopen() +# of those `.so`s fails with "symbol not found". +# +# `LIBS` segment 2 (`--start-group ... --end-group`): the external add-on +# libraries (sqlite3, ssl, crypto, z, bz2, lzma, ffi). The group is needed +# only for their inter-archive circular dependencies; they resolve symbols +# from libposix/libc/libm/libstdc++ against the already-embedded objects +# from segment 1. CONFIGURE_ENV = \ CC="$(TOOLCHAIN_PREFIX)/bin/i686-nanvix-gcc" \ CXX="$(TOOLCHAIN_PREFIX)/bin/i686-nanvix-g++" \ @@ -123,7 +169,7 @@ CONFIGURE_ENV = \ CFLAGS="-O3 -fomit-frame-pointer -fno-unwind-tables -fno-asynchronous-unwind-tables -I$(SYSROOT_PATH)/include" \ CFLAGS_NODIST="-fno-semantic-interposition" \ LDFLAGS="-L$(SYSROOT_PATH)/lib -T$(SYSROOT_PATH)/lib/user.ld -Wl,--allow-multiple-definition -no-pie -Wl,--export-dynamic -Wl,--no-dynamic-linker" \ - LIBS="-Wl,--start-group $(LIBPOSIX) $(LIBC) $(LIBM) -lsqlite3 -lssl -lcrypto -lz -lbz2 -llzma -lffi -Wl,--end-group" \ + LIBS="-Wl,--whole-archive $(LIBPOSIX) $(LIBC) $(LIBM) $(LIBSTDCXX) $(LIBGCC) -Wl,--no-whole-archive -Wl,--start-group -lsqlite3 -lssl -lcrypto -lz -lbz2 -llzma -lffi -Wl,--end-group" \ LIBSQLITE3_LIBS="-L$(SYSROOT_PATH)/lib -lsqlite3" \ LIBSQLITE3_CFLAGS="-I$(SYSROOT_PATH)/include" \ ZLIB_LIBS="-L$(SYSROOT_PATH)/lib -lz" \ From efb87c6d73315d09fc73dae2b45b339568ad47b4 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 29 May 2026 09:49:38 -0700 Subject: [PATCH 2/5] [nanvix] E: Link libnvx_crt0.a into python.elf Stacked on top of the prior `[nanvix] E: Embed C/C++ runtime in python.elf for .so dlopen support` PR. Adds `libnvx_crt0.a` -- the executable-startup archive introduced by the Nanvix `nvx-crt0` crate split -- to python.elf's `--whole-archive` LIBS segment. This makes python.elf forward-compatible with the planned Nanvix upstream change that removes `_start` / `_do_start` / `c_trampoline` from `libposix.a`: once libposix drops the duplicate, `libnvx_crt0.a` becomes the sole provider of those symbols. What changed in `Makefile.nanvix`: - New `LIBNVX_CRT0` variable (defined in both the docker and host-build branches): absolute path to the sysroot copy of `libnvx_crt0.a`. - Existence check: parse-time error if `$(LIBNVX_CRT0)` is missing from the sysroot, with a clear "update your sysroot" hint. Gated on `MAKECMDGOALS` so `clean` and `distclean` still work against older sysroots. - `LIBS` line: prepend `$(LIBNVX_CRT0)` to the `--whole-archive` group. Listed FIRST so that under the existing `-Wl,--allow-multiple-definition` LDFLAG, the linker picks `libnvx_crt0`'s `_start` over the duplicate copy `libposix.a` currently still ships. This is an intentional behaviour change: python.elf today and python.elf against a future stripped-down libposix.a both use the same `_start` source (the standalone crt0 crate), avoiding subtle differences in startup behaviour across the migration window. - Comment block extended to document the ordering rationale and the expected future state where libposix no longer carries startup symbols. `.nanvix/config.py::configure_env()` (the dead-code helper kept in sync as documented in PR-10) is updated to mirror the new LIBS line. Validated end-to-end: - `./z build` succeeds; python.elf grows by ~500 bytes (the crt0 objects added under `--whole-archive`). - `nm python.elf` shows `T _start` and `T _do_start` resolved. - numpy 1.26.4 import + arithmetic + broadcasting test produces `NUMPY_TEST_OK` on the Nanvix microvm. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .nanvix/config.py | 4 +++- Makefile.nanvix | 42 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/.nanvix/config.py b/.nanvix/config.py index f0e65e2347b8273..fb4d1eaf02ed41e 100644 --- a/.nanvix/config.py +++ b/.nanvix/config.py @@ -72,6 +72,7 @@ def toolchain_paths( "libssl": sr / "lib" / "libssl.a", "libcrypto": sr / "lib" / "libcrypto.a", "liblzma": sr / "lib" / "liblzma.a", + "libnvx_crt0": sr / "lib" / "libnvx_crt0.a", } @@ -104,7 +105,8 @@ def configure_env(toolchain: str | Path, sysroot: str | Path) -> dict[str, str]: f"-Wl,--export-dynamic -Wl,--no-dynamic-linker" ), "LIBS": ( - f"-Wl,--whole-archive {tp['libposix']} {tp['libc']} {tp['libm']} " + f"-Wl,--whole-archive {tp['libnvx_crt0']} {tp['libposix']} " + f"{tp['libc']} {tp['libm']} " f"-lstdc++ -lgcc -Wl,--no-whole-archive " f"-Wl,--start-group " f"-lsqlite3 -lssl -lcrypto -lz -lbz2 -llzma -lffi -Wl,--end-group" diff --git a/Makefile.nanvix b/Makefile.nanvix index a2e5d7da42b8e65..55636235b10d825 100644 --- a/Makefile.nanvix +++ b/Makefile.nanvix @@ -91,6 +91,10 @@ ifdef CONFIG_NANVIX LIBSQLITE3 := $(DOCKER_SYSROOT_PATH)/lib/libsqlite3.a LIBSSL := $(DOCKER_SYSROOT_PATH)/lib/libssl.a LIBCRYPTO := $(DOCKER_SYSROOT_PATH)/lib/libcrypto.a + # libnvx_crt0 ships the executable startup symbols (`_do_start`, `_start`, + # `c_trampoline`). It must be present in the Nanvix sysroot ahead of this + # cpython build; the existence check below fails loudly when it is not. + LIBNVX_CRT0 := $(DOCKER_SYSROOT_PATH)/lib/libnvx_crt0.a BUILD_PYTHON := $(DOCKER_TOOLCHAIN_PATH)/bin/python3 else TOOLCHAIN_PREFIX := $(NANVIX_TOOLCHAIN) @@ -102,6 +106,7 @@ ifdef CONFIG_NANVIX LIBSQLITE3 := $(abspath $(NANVIX_HOME))/lib/libsqlite3.a LIBSSL := $(abspath $(NANVIX_HOME))/lib/libssl.a LIBCRYPTO := $(abspath $(NANVIX_HOME))/lib/libcrypto.a + LIBNVX_CRT0 := $(abspath $(NANVIX_HOME))/lib/libnvx_crt0.a BUILD_PYTHON := $(NANVIX_TOOLCHAIN)/bin/python3 endif @@ -122,6 +127,21 @@ else EXE=.elf endif +# Existence check for libnvx_crt0.a. +# +# libnvx_crt0.a is the executable startup archive (`_do_start` / `_start` / +# `c_trampoline`) introduced by the Nanvix `nvx-crt0` crate split. cpython +# requires a Nanvix sysroot that ships it. If the user is building against +# an older sysroot snapshot, fail at make-parse time with an actionable +# message rather than producing a python.elf with no entry point. +ifdef CONFIG_NANVIX +ifneq ($(filter clean distclean,$(MAKECMDGOALS)),$(MAKECMDGOALS)) +ifeq ($(wildcard $(LIBNVX_CRT0)),) +$(error libnvx_crt0.a not found at $(LIBNVX_CRT0). Update the Nanvix sysroot to one that ships libnvx_crt0.a (the nvx-crt0 crate must be present and built into the sysroot lib/ directory).) +endif +endif +endif + # Configure environment variables # # Linker flag rationale: @@ -147,14 +167,24 @@ endif # resolved upstream. # # `LIBS` segment 1 (`--whole-archive ... --no-whole-archive`): force every -# object from libposix, libc, libm, libstdc++, and libgcc into python.elf -# so the runtime symbols extension `.so`s depend on are embedded and -# re-exported via `--export-dynamic`. Without `--whole-archive`, the static -# linker drops unreferenced objects (e.g. `fscanf`, `longjmp`, `strtold_l` -# for numpy; `operator new/delete[]`, `__cxa_*`, `_Unwind_*`, +# object from libnvx_crt0, libposix, libc, libm, libstdc++, and libgcc into +# python.elf so the runtime symbols extension `.so`s depend on are embedded +# and re-exported via `--export-dynamic`. Without `--whole-archive`, the +# static linker drops unreferenced objects (e.g. `fscanf`, `longjmp`, +# `strtold_l` for numpy; `operator new/delete[]`, `__cxa_*`, `_Unwind_*`, # `std::type_info` vtables for any C++ extension) and subsequent dlopen() # of those `.so`s fails with "symbol not found". # +# `libnvx_crt0` is listed first inside `--whole-archive`. Today, libposix.a +# still ships its own copy of the startup symbols (`_start`, `_do_start`, +# `c_trampoline`) because `nvx` builds them in under the `staticlib` +# feature. With `--allow-multiple-definition` (above) the linker takes the +# first definition, so listing libnvx_crt0 first selects the standalone +# crt0 copy of `_start` and friends. This is an intentional behaviour +# change: future Nanvix versions remove the duplicate from libposix, after +# which libnvx_crt0 is the sole provider. Listing it first today keeps +# python.elf using a consistent `_start` source across both states. +# # `LIBS` segment 2 (`--start-group ... --end-group`): the external add-on # libraries (sqlite3, ssl, crypto, z, bz2, lzma, ffi). The group is needed # only for their inter-archive circular dependencies; they resolve symbols @@ -169,7 +199,7 @@ CONFIGURE_ENV = \ CFLAGS="-O3 -fomit-frame-pointer -fno-unwind-tables -fno-asynchronous-unwind-tables -I$(SYSROOT_PATH)/include" \ CFLAGS_NODIST="-fno-semantic-interposition" \ LDFLAGS="-L$(SYSROOT_PATH)/lib -T$(SYSROOT_PATH)/lib/user.ld -Wl,--allow-multiple-definition -no-pie -Wl,--export-dynamic -Wl,--no-dynamic-linker" \ - LIBS="-Wl,--whole-archive $(LIBPOSIX) $(LIBC) $(LIBM) $(LIBSTDCXX) $(LIBGCC) -Wl,--no-whole-archive -Wl,--start-group -lsqlite3 -lssl -lcrypto -lz -lbz2 -llzma -lffi -Wl,--end-group" \ + LIBS="-Wl,--whole-archive $(LIBNVX_CRT0) $(LIBPOSIX) $(LIBC) $(LIBM) $(LIBSTDCXX) $(LIBGCC) -Wl,--no-whole-archive -Wl,--start-group -lsqlite3 -lssl -lcrypto -lz -lbz2 -llzma -lffi -Wl,--end-group" \ LIBSQLITE3_LIBS="-L$(SYSROOT_PATH)/lib -lsqlite3" \ LIBSQLITE3_CFLAGS="-I$(SYSROOT_PATH)/include" \ ZLIB_LIBS="-L$(SYSROOT_PATH)/lib -lz" \ From 7c56b1455be5e41d769a90333f1ba4d643fb152d Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 29 May 2026 17:49:57 -0700 Subject: [PATCH 3/5] [nanvix] E: Bake ninja and Cython into toolchain image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `ninja-build`, `python3-pip`, and `Cython<3` to the toolchain-python docker image so that meson- and Cython-based Python extension cross-builds (numpy, scipy, ...) work out-of-the-box, without an apt/pip preamble on every `docker run` invocation. What changed in `.nanvix/docker/Dockerfile`: - Added `python3-pip` and `ninja-build` to the apt install list. - Added `pip3 install --break-system-packages 'Cython<3'` (pinned for numpy 1.26.x compatibility; lift the pin when bumping numpy). - Added `rm -rf /usr/include/python3.12` after the install. The `python3-pip`/`ninja-build` apt packages transitively pull in `libpython3.12-dev`, whose headers under `/usr/include/python3.12` would otherwise be picked up by meson's regen step ahead of the Nanvix cross sysroot headers and silently corrupt the cross-build. - Comment block explaining the rationale for each addition and the `/usr/include/python3.12` purge. Why this matters: The numpy `.so` cross-build (validated end-to-end on 2026-05-27 with the STB_WEAK loader fix landed) requires two tools that were not present in the image as shipped: - `ninja` — meson's default backend; missing it makes every meson-based Python extension build fail immediately. - `Cython` — used by `numpy/_build_utils/tempita.py` to template `.pyx.in` files; without it the `numpy.random` codegen step fails. Before this change, the workaround was to inject: ```bash apt-get update -qq apt-get install -qq -y --no-install-recommends ninja-build python3-pip pip3 install --quiet --break-system-packages 'Cython<3' rm -rf /usr/include/python3.12 ``` into every numpy build invocation, which (a) was fragile, (b) required the docker container to have outbound network access on every build (non-hermetic), and (c) re-paid the apt install cost in CI every run. Validated locally: - `docker build -f .nanvix/docker/Dockerfile -t toolchain-python:pr13 .nanvix/docker/` succeeds. - `docker run --rm bash -c 'ninja --version'` → `1.11.1`. - `docker run --rm bash -c 'python3 -c "import Cython; print(Cython.__version__)"'` → `0.29.37`. - `docker run --rm bash -c 'ls /usr/include/python3.12'` → exits non-zero / "No such file or directory". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .nanvix/docker/Dockerfile | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.nanvix/docker/Dockerfile b/.nanvix/docker/Dockerfile index 8edab3fd54399fa..5d3dab2a4dd565b 100644 --- a/.nanvix/docker/Dockerfile +++ b/.nanvix/docker/Dockerfile @@ -6,9 +6,28 @@ FROM ghcr.io/nanvix/toolchain-gcc:sha-34a3641 +# Install the host Python plus the build helpers required by extension +# modules that ship a meson or Cython build step (numpy, scipy, pandas, +# ...): +# +# - ninja — meson's default backend; missing it makes every meson-based +# extension build fail immediately. +# - Cython — required by numpy 1.26.4's `numpy/_build_utils/tempita.py` +# .pyx.in code generation. Pinned `<3` for numpy 1.26.x +# compatibility; lift the pin when bumping numpy. +# +# We deliberately purge `/usr/include/python3.12` after the install. The +# `python3-pip` / `ninja-build` apt packages transitively pull in +# `libpython3.12-dev`, whose headers under `/usr/include/python3.12` would +# otherwise be picked up by meson's regen step ahead of the Nanvix cross +# sysroot headers and silently corrupt the cross-build. RUN apt-get update \ && apt-get install -y --no-install-recommends \ python3 \ python3-dev \ + python3-pip \ + ninja-build \ + && pip3 install --break-system-packages --no-cache-dir 'Cython<3' \ + && rm -rf /usr/include/python3.12 \ && rm -rf /var/lib/apt/lists/* \ && ln -sf /usr/bin/python3 /opt/nanvix/bin/python3 From b44d32b79287a4f10f2935875b72cdd6dd6d4410 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Mon, 1 Jun 2026 14:40:31 -0700 Subject: [PATCH 4/5] [nanvix] E: Add cc-wrapper for -shared vs exe link mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a small bash wrapper that sits in front of the real `i686-nanvix-gcc` and `i686-nanvix-g++` driver binaries in the toolchain-python docker image. The wrapper detects whether the invocation is producing an executable or a shared library and applies the correct linker flags for each case. This mirrors the well-established `emcc` pattern used by emscripten and Pyodide, where the compiler-driver wrapper is what knows the difference between main-module and side-module builds, so consumers (cpython's Makefile.nanvix, future numpy / scipy / lxml meson builds, etc.) can use a single `LDFLAGS` env var without needing to encode the exe-vs-shared distinction themselves. ## Why this exists `Makefile.nanvix` sets a single `LDFLAGS` on `./configure` that contains executable-specific linker flags: - `-T /lib/user.ld` — executable layout script. - `-no-pie` — disable PIE for executables. - `-Wl,--no-dynamic-linker` — executable-only marker. - `-Wl,--export-dynamic` — populate the main executable's `.dynsym`. cpython propagates that same `LDFLAGS` to BOTH the main `python.elf` link AND every extension-module `.so` link via `PY_LDFLAGS`. For `.so` outputs these exe-only flags are wrong: - `-T user.ld` tells `ld` to use an executable layout. When applied to a `-shared` link, `ld` treats the output as an exe and rejects undefined symbols, even those that should resolve at `dlopen()` time against the main exe's `.dynsym` (the Python C API symbols every extension references). - `-no-pie` is incompatible with `-shared` (shared libraries must be PIC). - `-Wl,--no-dynamic-linker` is meaningless for `.so`. - `-Wl,--export-dynamic` is exe-only. There is no clean place in cpython 3.12's `Modules/Setup` system to express "use this LDFLAGS for the exe link and that LDFLAGS for shared modules". cpython expects a single `LDFLAGS` shared between both. The cleanest fix is to install a compiler-driver wrapper that does the split transparently: forwards exe builds unchanged, and strips exe-only flags + adds `-fPIC` for shared builds. That way `LDFLAGS` stays the same for everyone and the wrapper does the right thing per-invocation. ## What changed in `.nanvix/docker/` - **New file `cc-wrapper.sh`** (~110 lines bash). Detects compile-only mode (`-c` / `-S` / `-E`), exe-link mode (no `-shared`), or shared-link mode (`-shared` present). Compile-only and exe modes forward to the real binary unchanged via `exec "$real_bin" "$@"`. Shared mode iterates the args and: - Strips `-T