Skip to content

[nanvix] E: Add cc-wrapper for -shared vs exe link mode#687

Open
esaurez wants to merge 1 commit into
nanvix/v3.12.3from
feat/cc-wrapper-shared-vs-exe
Open

[nanvix] E: Add cc-wrapper for -shared vs exe link mode#687
esaurez wants to merge 1 commit into
nanvix/v3.12.3from
feat/cc-wrapper-shared-vs-exe

Conversation

@esaurez
Copy link
Copy Markdown

@esaurez esaurez commented Jun 1, 2026

[nanvix] E: Add cc-wrapper for -shared vs exe link mode

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 <sysroot>/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 <script> (both -T <script> and -T<script> forms), -no-pie, -Wl,--no-dynamic-linker, -Wl,--export-dynamic, -Wl,-T,<script>, and bare *.ld argument files.
    • Adds -fPIC if not already present.
    • Uses a bash filtered=() array (not a string) so argv boundaries and quoting are preserved across the rewrite — exec "$real_bin" "${filtered[@]}" is argv-preserving for arguments containing spaces, empty strings, or glob metacharacters.
  • Dockerfile updated to install the wrapper. The script is copied to /opt/nanvix/bin/i686-nanvix-cc-wrapper.sh, made executable, and the real i686-nanvix-gcc / i686-nanvix-g++ binaries are renamed to <name>.real. Symlinks <name> -> i686-nanvix-cc-wrapper.sh are then installed. The wrapper picks the right .real binary based on argv[0].

The install pattern is defensive: if the upstream base image already has a wrapper symlinked (some local-build flavours of toolchain-python carry an earlier wrapper), the symlink is removed and replaced — but only after asserting that the matching <name>.real file already exists, so a missing .real aborts the install rather than stranding the toolchain. If the real binary has not yet been renamed, it is moved to <name>.real in the same step.

Validation

Built the image locally with the additions and smoke-tested all three wrapper modes against a trivial int main(){return 0;}:

  • Test 1 (exe link, no -shared): wrapper transparent; real gcc handles normally.
  • Test 2 (simple -shared): wrapper detects shared mode, adds -fPIC, link succeeds.
  • Test 3 (-shared plus the toxic exe-only flags -T <script> -no-pie -Wl,--no-dynamic-linker): wrapper strips the toxic flags and the link succeeds with no spurious "cannot create executables" error.

Build-side end-to-end smoke test with cpython: applied a Phase-0-style change to Modules/Setup.local adding *shared*\narray arraymodule.c. With the wrapper in place, array.cpython-312-i686-nanvix.so (~190 KB) is produced and installed at the canonical lib/python3.12/lib-dynload/ location; PyInit_array no longer appears in python.elf. Full ./z build succeeds.

What this unblocks

This wrapper is the foundational prerequisite for the broader .a -> .so migration documented in nanvix-todo/cpython-static-to-shared-migration.md. Every subsequent phase of that plan (peeling stdlib extension modules off python.elf and shipping them as .so files loaded via dlopen, exactly as upstream CPython works) depends on this wrapper being in place. Without it, every per-module conversion would need a per-call LDFLAGS hack.

Backward compatibility

This change is purely additive at the image level — it adds the wrapper script and rewrites the gcc/g++ entry points to point at it. Existing build paths that don't pass -shared see no behavioural change (the wrapper falls through to the real binary unchanged). Existing .so build paths that DID work before (e.g., when LDFLAGS happened not to include -T user.ld) continue to work because the wrapper's strip-then-add-fPIC produces a strict superset of what bare gcc -shared would have done.

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

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 <sysroot>/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 <script>` (both `-T <script>` and `-T<script>` forms), `-no-pie`, `-Wl,--no-dynamic-linker`, `-Wl,--export-dynamic`, `-Wl,-T,<script>`, and bare `*.ld` argument files.
  - Adds `-fPIC` if not already present.
  - Uses a bash `filtered=()` array (not a string) so argv boundaries and quoting are preserved across the rewrite — `exec "$real_bin" "${filtered[@]}"` is argv-preserving for arguments containing spaces, empty strings, or glob metacharacters.
- **Dockerfile updated** to install the wrapper. The script is copied to `/opt/nanvix/bin/i686-nanvix-cc-wrapper.sh`, made executable, and the real `i686-nanvix-gcc` / `i686-nanvix-g++` binaries are renamed to `<name>.real`. Symlinks `<name>` -> `i686-nanvix-cc-wrapper.sh` are then installed. The wrapper picks the right `.real` binary based on `argv[0]`.

The install pattern is defensive: if the upstream base image already has a wrapper symlinked (some local-build flavours of `toolchain-python` carry an earlier wrapper), the symlink is removed and replaced — but only after asserting that the matching `<name>.real` file already exists, so a missing `.real` aborts the install rather than stranding the toolchain. If the real binary has not yet been renamed, it is moved to `<name>.real` in the same step.

## Validation

Built the image locally with the additions and smoke-tested all three wrapper modes against a trivial `int main(){return 0;}`:

- **Test 1 (exe link, no `-shared`):** wrapper transparent; real gcc handles normally.
- **Test 2 (simple `-shared`):** wrapper detects shared mode, adds `-fPIC`, link succeeds.
- **Test 3 (`-shared` plus the toxic exe-only flags `-T <script>` `-no-pie` `-Wl,--no-dynamic-linker`):** wrapper strips the toxic flags and the link succeeds with no spurious "cannot create executables" error.

Build-side end-to-end smoke test with cpython: applied a Phase-0-style change to `Modules/Setup.local` adding `*shared*\narray arraymodule.c`. With the wrapper in place, `array.cpython-312-i686-nanvix.so` (~190 KB) is produced and installed at the canonical `lib/python3.12/lib-dynload/` location; `PyInit_array` no longer appears in `python.elf`. Full `./z build` succeeds.

## What this unblocks

This wrapper is the foundational prerequisite for the broader `.a` -> `.so` migration documented in `nanvix-todo/cpython-static-to-shared-migration.md`. Every subsequent phase of that plan (peeling stdlib extension modules off `python.elf` and shipping them as `.so` files loaded via dlopen, exactly as upstream CPython works) depends on this wrapper being in place. Without it, every per-module conversion would need a per-call LDFLAGS hack.

## Backward compatibility

This change is purely additive at the image level — it adds the wrapper script and rewrites the gcc/g++ entry points to point at it. Existing build paths that don't pass `-shared` see no behavioural change (the wrapper falls through to the real binary unchanged). Existing `.so` build paths that DID work before (e.g., when LDFLAGS happened not to include `-T user.ld`) continue to work because the wrapper's strip-then-add-fPIC produces a strict superset of what bare `gcc -shared` would have done.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 1, 2026 21:46
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a compiler-driver wrapper to the Nanvix toolchain-python Docker image so the same LDFLAGS can be used for both executable links and -shared extension-module links, while automatically stripping executable-only flags for .so builds.

Changes:

  • Add a bash cc-wrapper.sh that detects -shared links and rewrites argv to remove exe-only linker flags and inject -fPIC.
  • Update the Nanvix Docker image to install the wrapper and interpose it in front of i686-nanvix-gcc / i686-nanvix-g++ by renaming the real drivers to *.real and symlinking the tool names to the wrapper.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
.nanvix/docker/Dockerfile Installs the wrapper into the image and rewires gcc/g++ entrypoints to go through it.
.nanvix/docker/cc-wrapper.sh Implements -shared vs exe/compile-only mode detection and argument filtering for shared-library links.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +54 to +57
# Find the real binary by appending `.real` to argv[0]'s basename.
self_dir="$(dirname "$0")"
self_name="$(basename "$0")"
real_bin="${self_dir}/${self_name}.real"
Comment thread .nanvix/docker/Dockerfile
Comment on lines +16 to +18
# Install the cc-wrapper. This wrapper sits in front of the real
# `i686-nanvix-gcc` / `i686-nanvix-g++` driver binaries and detects
# whether the invocation is producing an executable or a shared library.
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.

2 participants