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
这是什么?SoLo 是一个 C/C++ 库,让单个完全静态、以 musl 链接的 Linux 可执行文件在运行时加载宿主机上现成的 glibc 链接共享对象(比如 GPU 驱动),而不必捆绑容器、AppImage 或第二套 libc。
这是个命令行工具,还是要链接进我自己程序里的东西?它是一个库:在你的 musl 静态二进制里包含 lib/dlfcn.h 并链接 libdlfcn.a 归档(用默认 make 目标构建),之后普通的 dlopen()/dlsym() 调用就会走 SoLo 自己的 ELF 加载器。仓库还提供一个能直接运行的预构建 Vulkan 演示,你什么都不用构建就能看到效果。
不构建任何东西,怎么看它跑起来的效果?拿预构建的 vulkan 二进制(arm64 用 vulkan-aarch64),在任意一台装了 Vulkan 驱动的 Linux 机器上运行——有 mesa-vulkan-drivers 就够了;演示会用宿主机自己的 Vulkan 驱动写出一个 512x512 的 RGBA PNG。
它在所有 CPU 架构上都能用吗?如果某个 glibc 函数不支持会怎样?ELF 加载器只支持 x86-64 和 aarch64。对 SoLo 没实现的 glibc 函数,调用会命中显式的桩函数,大声报错并指出缺失的符号,而不是悄悄把进程搞坏。
这和用 Flatpak、AppImage 或容器有什么不一样?那些做法会在你的程序里面或周围塞进一个小型 Linux 发行版,产生一大坨重复的库、挂载和命名空间,让调试更难受。SoLo 交付的是一个普通、可检查的静态可执行文件,只借用宿主机的硬件驱动。
自带的 Vulkan 演示到底证明了什么?它展示了一个完全静态的 musl 可执行文件进入静态链接的 Khronos Vulkan 加载器,通过 SoLo 加载宿主机未修改的 Vulkan ICD 及其 glibc 依赖,跑了一个真正的计算着色器,并把结果写进 PNG。它已在 Linux 上的 AMD、Intel、NVIDIA GPU、Asahi 下的 Apple M1、经 Termux 的 Android,以及 WSL 的 Mesa dzn 驱动上验证过。
它怎么处理跨越 musl/glibc 边界的 C++ 异常?异常可以干净地双向穿越:静态 musl 世界里抛出的异常会穿过 glibc 编译的栈帧进入 glibc 的 catch 再原路返回,两边的析构函数都正确执行,因为被加载方的展开(unwind)导入绑定到了可执行文件里已有的那一个展开器上。
为什么不直接用 gcompat 之类的方案?gcompat 是通过预加载 libgcompat.so、用 musl 的动态链接器重新执行程序,所以还是需要发行版的兼容包和一个动态可执行文件。SoLo 则把 ELF 加载器和 glibc ABI 桥直接嵌进完全静态的二进制里,完全不经过系统加载器。
Creator
pg83
Size
11.9 MB
Stars
641
Age
2mo
Commits · 90 Days