Back to Bear

Linker selection for the preload library

docs/rationale/preload-linker-selection.md

4.2.04.5 KB
Original Source

Linker selection for the preload library

Context

The preload library must export exactly the interposer symbols that src/c/shim.c defines (the exec family, posix_spawn*, popen, pclose, system) plus LIBEXEC_VERSION, and hide everything else. On ELF platforms this is done with a linker version script (exports.map, generated by build.rs).

rustc injects its own version script into every cdylib link (an anonymous tag exporting the crate's #[no_mangle] symbols, ending in local: *). Two consequences, both verified experimentally (2026-07):

  • Bear's script cannot be dropped: rustc's script hides every symbol that comes from a linked native static archive, so without exports.map the interposers vanish from the dynamic table and interception silently stops. This holds even when the archive is linked with rustc-native -l static:+whole-archive=....
  • The link line always carries two version scripts, and GNU ld refuses that combination outright: "anonymous version tag cannot be combined with other version tags" (binutils 2.46). A named tag in Bear's script does not help; rustc's script is the anonymous one. This is the failure users hit when lld is absent (issue #662, Ubuntu 24.04 aarch64).

lld and mold both accept the combination and export the union of both scripts' globals. The union is also why the rust_<name> implementation symbols currently appear in the dynamic symbol table despite local: * in exports.map: rustc's script lists them as global. This over-export is cosmetic (the names are prefixed, and calls resolve within the library), but it is a consequence to know about.

On x86_64-unknown-linux-gnu, current stable rustc links cdylibs with its bundled rust-lld by default (it passes -B <sysroot>/lib/rustlib/<host>/bin/gcc-ld and -fuse-ld=lld itself). Targets without that default, such as aarch64, fall back to the system cc default linker, GNU ld -- which is why issue #662 appeared on ARM64 only.

Decision

build.rs selects the linker for the ELF cdylib link with a fallback chain instead of hard-coding -fuse-ld=lld:

  1. the Rust toolchain's bundled rust-lld, when <sysroot>/lib/rustlib/<host>/bin/gcc-ld/ld.lld exists (pass -B for that directory plus -fuse-ld=lld);
  2. a system ld.lld (-fuse-ld=lld);
  3. a system mold (-fuse-ld=mold);
  4. otherwise, emit a build warning that names the expected GNU ld failure and the remedy, and add no linker flag, in case the toolchain's default linker copes on its own.

Consequences

  • rustup-installed toolchains build the library on every architecture with no extra linker package: the bundled rust-lld covers them.
  • Builds against a Rust toolchain without bundled rust-lld (some distro packages) need lld or mold installed; both are widely packaged. The failure without them is an early, explicit warning followed by the linker error, instead of only the cryptic error.
  • The system probes scan PATH only. A compiler-internal ld.lld that cc would find through its own exec-prefix (but which is not on PATH) falls through to the warning branch; probing cc -print-prog-name=ld.lld could close that gap if it ever matters in practice.
  • The rust_<name> union-export remains. Removing it would need a function-pointer indirection between the shim and the Rust implementation (so the Rust side has no #[no_mangle] symbols), or a rustc-side mechanism to exclude them. Revisit if a strict export surface becomes a requirement.

Rejected alternatives:

  • Drive the final link with cc from a staticlib. Linker-clean (a single version script links fine under GNU ld, verified), but stable cargo cannot orchestrate it: a build script cannot depend on a sibling crate's staticlib artifact (the gap that RFC 3028 "artifact dependencies" exists to fill), a crate's own build script runs before the crate compiles, and moving the link outside cargo would break the single-command cargo build workflow and add a packaging step. Revisit when cargo's artifact dependencies stabilize.
  • A named version tag in exports.map. GNU ld rejects any combination that includes an anonymous tag, and rustc's tag is anonymous; verified.
  • Dropping exports.map in favor of rustc's own export handling. Native-archive symbols are never in rustc's export list; verified.

References