solo README

The project's README from github.com/pg83/solo. As one markdown file: README.md.

SoLo — a .so loader for static Linux binaries

CI codecov release license linux libcs in the process host DSOs loaded in CI

Ship one musl-linked executable. At runtime, load the user's existing glibc-linked GPU driver. No container, no AppImage, and no second libc in the process.

Static binaries are a wonderfully boring way to deploy software on Linux: one file, no dependencies, nothing to break. We build ours with IX, a source-first build system for producing fully static Linux binaries. The boredom ends the moment the application needs the GPU: Vulkan and OpenGL drivers are supplied by the host as shared objects, usually built against glibc, and a fully static musl binary cannot normally dlopen() them.

SoLo crosses that boundary. It provides a dlfcn-style source API backed by its own ELF loader (x86-64 and aarch64) and a glibc ABI bridge implemented on top of musl. The result is still one ordinary static executable, but it can use the graphics driver already installed on the machine.

The repository includes an end-to-end Vulkan proof: a fully static executable loads the host's unmodified Vulkan driver, runs a compute shader, and writes the result to a PNG. Tested on AMD radv, radeonsi, Intel, and NVIDIA GPUs under Linux, on Apple M1 under Asahi Linux, on Android under Termux, and on WSL with Mesa's dzn driver over Direct3D 12.

The host keeps the hardware-specific code. You ship everything else.

And not on a demo's word alone: on every commit, CI loads the shared libraries of the 1,000 most-installed Debian packages — over 2,100 host objects — through SoLo, on both x86-64 and aarch64.

See it work

Grab the prebuilt binary — no clone, no toolchain, any Linux with a Vulkan driver installed (mesa-vulkan-drivers is enough):

curl -LO https://github.com/pg83/solo/releases/latest/download/vulkan-x86_64
chmod +x vulkan-x86_64
./vulkan-x86_64 hello.png

vulkan-aarch64 is the same demo for arm64 machines. The command discovers the distro-installed Vulkan ICD in the usual way and produces a 512×512 RGBA image. This is how we build the Shitty release binaries—a blazingly fast terminal emulator, BTW! To force a particular driver:

./vulkan-x86_64 --driver /usr/share/vulkan/icd.d/radeon_icd.x86_64.json radeon.png
./vulkan-x86_64 --driver /usr/share/vulkan/icd.d/lvp_icd.json lavapipe.png

ICD manifest names vary slightly between distributions. Passing no --driver lets the embedded Khronos loader perform its normal discovery.

You can verify that the executable itself is not dynamically linked:

readelf -lW ./vulkan-x86_64 | grep INTERP    # no output
readelf -dW ./vulkan-x86_64                  # "There is no dynamic section"

Or build the same demo from source, with Python 3 and a C/C++ compiler in PATH:

git clone https://github.com/pg83/solo.git
cd solo
./build vulkan
./vulkan hello.png

This is not a toy call to vkCreateInstance. The demo:

  1. enters the statically linked Khronos Vulkan loader;
  2. loads the host's Vulkan ICD and its non-glibc dependencies through SoLo;
  3. creates a device, storage buffer, descriptor set, and compute pipeline;
  4. dispatches a checked-in SPIR-V shader;
  5. maps the result and writes it through statically linked libpng.

The complete example is in bin/vulkan, and the Vulkan program itself is in main.cpp.

How it works

┌──────────────────── fully static executable ────────────────────┐
│                                                                 │
│  application → embedded Vulkan loader → SoLo dlopen/dlsym       │
│                                           ├─ x86-64 ELF mapper  │
│                                           └─ glibc ABI → musl   │
│                                           │                     │
└───────────────────────────────────────────┬─────────────────────┘
                                            │ maps at runtime
                                            ▼
                              system Mesa/Vulkan ICD.so + DSOs

elf_loader.cpp maps ELF segments, walks DT_NEEDED, resolves versioned symbols, applies x86-64 relocations, supports ELF TLS and TLSDESC, materializes IFUNCs, applies RELRO, and runs initializers. Dependencies that are themselves ELF DSOs are loaded recursively.

glibc is deliberately not loaded. Imports such as malloc@GLIBC_2.2.5 are resolved by glibc_shim.cpp to ABI-correct adapters over the process's existing musl runtime. Unsupported glibc functions have unique generated stubs that fail loudly with the exact symbol and version if they are ever called, instead of silently corrupting the process.

Because musl sizes its synchronization objects to the glibc ABI of each architecture, the bridge does not shadow them: a pthread_mutex_t a driver creates is used in place. A lock is therefore one lock for both the loaded DSO and the static executable that may share it, and glibc's static recursive and error-check initializers are adopted on first use.

Before loading a DSO from disk, SoLo checks its static provider registry. This lets an application satisfy a dependency—Wayland, for example—with functions already linked into the executable. LD_LIBRARY_PATH is honored for libraries outside the standard system directories, except in secure-execution mode (AT_SECURE), where the environment is ignored the way ld.so ignores it. LD_TRACE_LOADED_OBJECTS=1 prints every object in ldd's format as it loads — including the names served without a mapping, by the ABI bridges or by providers linked into the executable.

The interesting pieces are small enough to read:

Use it as a library

The default target builds the standalone archive:

./build

The published ./dlfcn symlink points to the resulting libdlfcn.a. Include lib/dlfcn.h, link the archive into a musl-static application, and ordinary dlopen()/dlsym() calls are redirected to SoLo. The source tree is intentionally self-contained and suitable for copying into another static build graph.

There is no startup call. The static TLS that guests demand — including initial-exec — comes from a thread_local pad the library links into the application: musl sizes every thread's TLS with it from process birth, and the loader hands guests pieces of it as they load.

Reproduce the experiment

./build test          # load an Arch glibc DSO closure in the smoke test
./build vulkan_test   # build the static demo and verify a native Lavapipe PNG

CI performs the native build and test on Alpine/musl with GCC, Fedora with GCC, and Ubuntu with Clang. The Vulkan test installs each distribution's own Lavapipe package; it does not run the driver from an Arch sysroot.

Every third-party build input is vendored under ext/. build.py compiles those sources directly: upstream CMake, Meson, configure, and Make build systems are not invoked.

Vendored versions
  • musl 1.2.5 (0784374d561435f7c787a555aeab8ede699ed298)
  • LLVM runtimes 15.0.7: libc++, libc++abi, libunwind, and compiler-rt builtins (8dfdcc7b7bf66834a761bd8de445840ef68e4d1a)
  • Vulkan Headers 1.4.357 (e3b1eec08173d6b825cd3ac88c885a63b621504a)
  • Vulkan Loader 1.4.357 (5f157b62e333c63260d05d81bf66faa216ab0fb8)
  • zlib 1.3.2 (da607da739fa6047df13e66a2af6b8bec7c2a498)
  • libpng 1.6.50 (2b978915d82377df13fcbb1fb56660195ded868a)

License files are retained beside the corresponding sources. shader.inc is the checked-in SPIR-V form of shader.comp, so no shader compiler is required.

How this differs from prior work

In the general case, only SoLo lets a static application tell the dynamic loader: "for this system DSO's libwayland dependency, use the symbols already linked into my executable." This lets the application embed the newest libwayland instead of targeting the oldest version available on every supported system.

And the boundary between the two worlds is not a thin dlsym shim — it carries the parts that make foreign code actually behave:

Every one of these is exercised by a conformance battery compiled against real glibc headers at -O2, and by loading every shared object of the thousand most-installed Debian library packages in CI, on x86-64 and aarch64.

Scope

The goal is to turn the hard wall between “fully static” and “uses the system GPU” into a finite, testable compatibility layer. The Vulkan PNG is the first proof that the wall has a door.

solo (pg83/solo)

Searching Database…

641 ·GitHub
What is this?SoLo is a C/C++ library that lets a single, fully static musl-linked Linux executable load the host's existing glibc-linked shared objects, such as GPU drivers, at runtime, instead of bundling a container, AppImage, or a second libc.
Is this a command-line tool, or something I link into my own program?It is a library: you include lib/dlfcn.h and link the libdlfcn.a archive (built via the default make target) into your musl-static binary, after which ordinary dlopen()/dlsym() calls are routed through SoLo's own ELF loader. The repo also ships a runnable prebuilt Vulkan demo so you can see it work without building anything.
How do I see it working without building anything?Grab the prebuilt vulkan (or vulkan-aarch64 for arm64) binary and run it on any Linux box with a Vulkan driver installed — mesa-vulkan-drivers is enough; the demo writes a 512x512 RGBA PNG using the host's own Vulkan driver.
Does it work on every CPU architecture, and what happens if a glibc function isn't supported?The ELF loader only targets x86-64 and aarch64. For glibc functions SoLo hasn't implemented, calls hit explicit stub functions that fail loudly naming the missing symbol, instead of silently corrupting the process.
How is this different from using Flatpak, AppImage, or a container?Those approaches hide a small Linux distribution inside or around your program, producing a large blob of duplicated libraries, mounts, and namespaces that makes debugging worse. SoLo ships one ordinary, inspectable static executable and borrows only the host's hardware driver.
What does the included Vulkan demo actually prove?It shows a fully static musl executable entering the statically linked Khronos Vulkan loader, loading the host's unmodified Vulkan ICD and its glibc dependencies through SoLo, running a real compute shader, and writing the result to a PNG. It's been verified on AMD, Intel, and NVIDIA GPUs on Linux, on Apple M1 under Asahi, on Android via Termux, and on WSL's Mesa dzn driver.
How does it handle C++ exceptions crossing the musl/glibc boundary?Exceptions cross cleanly in both directions: a throw in the static musl world unwinds through glibc-compiled frames into a glibc catch and back again, with destructors running correctly on both sides, because the loaded guest's unwind imports are bound to the one unwinder already in the executable.
Why not just use something like gcompat instead?gcompat re-executes a program through musl's dynamic linker with libgcompat.so preloaded, so it still needs a distro compatibility package and a dynamic executable. SoLo instead embeds both the ELF loader and the glibc ABI bridge directly in a fully static binary, with no system loader involved at all.
Creator
pg83
Size
11.9 MB
Stars
641
Age
2mo
Commits · 90 Days