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++, которая позволяет одному полностью статическому исполняемому файлу Linux, слинкованному с musl, загружать в рантайме уже имеющиеся у хоста разделяемые объекты, слинкованные с glibc, например драйверы GPU, вместо упаковки контейнера, AppImage или второй libc.
Это утилита командной строки или то, что я линкую в свою программу?Это библиотека: вы подключаете lib/dlfcn.h и линкуете архив libdlfcn.a (собирается make-целью по умолчанию) в свой musl-статический бинарник, после чего обычные вызовы dlopen()/dlsym() идут через собственный ELF-загрузчик SoLo. В репозитории есть и запускаемое готовое демо Vulkan, чтобы увидеть работу, ничего не собирая.
Как увидеть её в работе, ничего не собирая?Возьмите готовый бинарник vulkan (или vulkan-aarch64 для arm64) и запустите на любой Linux-машине с установленным драйвером Vulkan — достаточно mesa-vulkan-drivers; демо записывает PNG 512x512 RGBA с помощью собственного драйвера Vulkan хоста.
Работает ли это на любой архитектуре CPU, и что происходит, если glibc-функция не поддерживается?ELF-загрузчик нацелен только на x86-64 и aarch64. Для glibc-функций, не реализованных в SoLo, вызовы попадают в явные функции-заглушки, которые громко падают, называя недостающий символ, вместо того чтобы молча портить процесс.
Чем это отличается от Flatpak, AppImage или контейнера?Эти подходы прячут маленький Linux-дистрибутив внутри или вокруг вашей программы, порождая большую глыбу дублирующихся библиотек, монтирований и namespace, что ухудшает отладку. SoLo поставляет один обычный, доступный для осмотра статический исполняемый файл и заимствует у хоста только драйвер железа.
Что на деле доказывает включённое демо Vulkan?Оно показывает, как полностью статический musl-исполняемый файл входит в статически слинкованный Vulkan-загрузчик Khronos, загружает через SoLo немодифицированный Vulkan ICD хоста и его glibc-зависимости, запускает настоящий compute-шейдер и записывает результат в PNG. Проверено на GPU AMD, Intel и NVIDIA под Linux, на Apple M1 под Asahi, на Android через Termux и на драйвере Mesa dzn в WSL.
Как он обрабатывает исключения C++, пересекающие границу musl/glibc?Исключения чисто проходят в обе стороны: throw в статическом musl-мире раскручивается через кадры, скомпилированные с glibc, к обработчику catch в glibc и обратно, с корректно срабатывающими деструкторами на обеих сторонах, поскольку unwind-импорты загруженного гостя привязаны к единственному unwinder, уже находящемуся в исполняемом файле.
Почему бы просто не использовать что-то вроде gcompat?gcompat перезапускает программу через динамический линковщик musl с предзагруженной libgcompat.so, поэтому всё ещё нужны дистрибутивный пакет совместимости и динамический исполняемый файл. SoLo вместо этого встраивает и ELF-загрузчик, и мост к ABI glibc прямо в полностью статический бинарник, вообще без участия системного загрузчика.
Creator
pg83
Size
11.9 MB
Stars
641
Age
2mo
Commits · 90 Days