SoLo — Een .so-loader voor statische Linux-binaries

Statische binaries zijn een heerlijk eenvoudige manier om software op Linux te implementeren: één bestand, geen afhankelijkheden, niets dat kapot kan gaan. Wij bouwen onze binaries met IX, een source-first bouwsysteem voor het produceren van volledig statische Linux-binaries. De eenvoud eindigt echter zodra de applicatie de GPU nodig heeft: Vulkan- en OpenGL-drivers worden door de host geleverd als shared objects, meestal gebouwd tegen glibc. Een volledig statische musl-binary kan deze normaal gesproken niet via dlopen() laden.

SoLo overbrugt deze grens. Het biedt een dlfcn-stijl source API, ondersteund door een eigen ELF-loader (voor x86-64 en aarch64) en een glibc ABI-brug geïmplementeerd bovenop musl.

Het resultaat is nog steeds één gewoon statisch uitvoerbaar bestand, maar het kan gebruikmaken van de grafische driver die al op de machine is geïnstalleerd.

De repository bevat een end-to-end Vulkan-bewijs: een volledig statisch uitvoerbaar bestand dat de ongewijzigde Vulkan-driver van de host laadt, een compute shader uitvoert en het resultaat wegschrijft naar een PNG. Dit is getest op AMD radv, radeonsi, Intel en NVIDIA GPU's onder Linux, en op Apple M1 onder Asahi Linux.

De host behoudt de hardware-specifieke code; u verzendt al het andere.

Dit is niet slechts een demo: bij elke commit laadt de CI de shared libraries van de 1.000 meest geïnstalleerde Debian-pakketten — meer dan 2.100 host-objecten — via SoLo, op zowel x86-64 als aarch64.

Het in actie zien

U kunt de vooraf gebouwde binary downloaden — zonder clone of toolchain, op elke Linux-distributie met een geïnstalleerde Vulkan-driver (mesa-vulkan-drivers is voldoende):

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 dezelfde demo voor arm64-machines. Het commando detecteert de door de distributie geïnstalleerde Vulkan ICD op de gebruikelijke manier en produceert een 512×512 RGBA-afbeelding. Op deze manier bouwen we de Shitty release binaries (een razendsnelle terminalemulator).

Om een specifieke driver te forceren:

./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

De namen van ICD-manifesten variëren enigszins per distributie. Wanneer geen --driver wordt meegegeven, voert de ingebedde Khronos-loader de normale detectie uit.

U kunt verifiëren dat het uitvoerbare bestand zelf niet dynamisch is gekoppeld:

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

Of bouw dezelfde demo vanuit de broncode, met Python 3 en een C/C++ compiler in uw PATH:

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

Dit is geen simpele aanroep naar vkCreateInstance. De demo doet het volgende:

  • Treedt binnen in de statisch gekoppelde Khronos Vulkan-loader.
  • Laadt de Vulkan ICD van de host en de niet-glibc afhankelijkheden via SoLo.
  • Maakt een device, storage buffer, descriptor set en compute pipeline aan.
  • Voert een meegeleverde SPIR-V shader uit.
  • Mapt het resultaat en schrijft dit weg via de statisch gekoppelde libpng.

Het volledige voorbeeld bevindt zich in bin/vulkan, en het Vulkan-programma zelf staat in main.cpp.

Hoe het werkt

Structuur: applicatieingebedde Vulkan loaderSoLo dlopen/dlsymx86-64 ELF mapperglibc ABImusl → (mapt tijdens runtime) → systeem Mesa/Vulkan ICD.so + DSOs

elfloader.cpp mapt ELF-segmenten, doorloopt DTNEEDED, resolveert versie-gekoppelde symbolen, past x86-64 relocations toe, ondersteunt ELF TLS en TLSDESC, materialiseert IFUNCs, past RELRO toe en voert initialisaties uit. Afhankelijkheden die zelf ELF DSO's zijn, worden recursief geladen.

glibc wordt bewust niet geladen. Imports zoals malloc@GLIBC2.2.5 worden door glibcshim.cpp omgezet naar ABI-correcte adapters over de bestaande musl-runtime van het proces. Niet-ondersteunde glibc-functies hebben unieke, gegenereerde stubs die luidruchtig falen met het exacte symbool en de versie als ze worden aangeroepen, in plaats van het proces stilzwijgend te corrumperen.

Omdat musl zijn synchronisatie-objecten afstelt op de glibc ABI van elke architectuur, overschaduwt de brug deze niet: een pthreadmutext die een driver aanmaakt, wordt direct gebruikt. Een lock is dus één lock voor zowel de geladen DSO als het statische uitvoerbare bestand dat deze kan delen, en de statische recursieve en error-check initialisaties van glibc worden bij eerste gebruik overgenomen.

Voordat een DSO vanaf schijf wordt geladen, controleert SoLo het statische provider-register. Hiermee kan een applicatie een afhankelijkheid — bijvoorbeeld Wayland — vervullen met functies die al in het uitvoerbare bestand zijn gekoppeld. LDLIBRARYPATH en DLELFLIBRARY_PATH worden gerespecteerd voor bibliotheken buiten de standaard systeemdirectories.

De belangrijkste onderdelen zijn klein genoeg om te lezen:

  • lib/dlfcn.cppdlopen, dlsym, fouten en statische providers.
  • lib/elf_loader.cpp — ELF-mapping, symbolen, relocaties en TLS.
  • lib/glibc_shim.cpp — geïmplementeerde glibc ABI-adapters.
  • lib/glibc_stubs.cpp — expliciete fallbacks voor de rest van de ABI.

Gebruik als bibliotheek

Het standaarddoel bouwt het standalone archief:

./build

De gepubliceerde ./dlfcn symlink wijst naar de resulterende libdlfcn.a. Inclusie van lib/dlfcn.h en het koppelen van het archief aan een musl-static applicatie zorgt ervoor dat gewone dlopen()/dlsym() aanroepen worden omgeleid naar SoLo. De bronboom is bewust zelfvoorzienend en geschikt om te kopiëren naar een andere statische build-graph.

Het experiment reproduceren

./build test          # laadt een Arch glibc DSO closure in de smoke test
./build vulkan_test   # bouwt de statische demo en verifieert een native Lavapipe PNG

De CI voert de native build en test uit op Alpine/musl met GCC, Fedora met GCC, en Ubuntu met Clang. De Vulkan-test installeert het Lavapipe-pakket van elke distributie; het draait de driver niet vanuit een Arch sysroot.

Elke build-input voor het standalone Vulkan-uitvoerbare bestand is opgenomen onder bin/vulkan. build.py compileert deze bronnen direct; upstream CMake, Meson, configure en Make bouwsystemen worden niet aangeroepen.

Ingebouwde versies:

  • musl 1.2.5
  • LLVM runtimes 15.0.7: libc++, libc++abi, libunwind, en compiler-rt builtins
  • Vulkan Headers 1.4.357
  • Vulkan Loader 1.4.357
  • zlib 1.3.2
  • libpng 1.6.50

Licentiebestanden zijn bewaard naast de corresponderende bronnen. shader.inc is de SPIR-V vorm van shader.comp, waardoor geen shader-compiler nodig is.

Verschillen met eerder werk

In het algemene geval is SoLo de enige die een statische applicatie in staat stelt de dynamische loader te vertellen: "gebruik voor de libwayland-afhankelijkheid van deze systeem-DSO de symbolen die al in mijn uitvoerbare bestand zijn gekoppeld." Hierdoor kan de applicatie de nieuwste libwayland insluiten in plaats van te mikken op de oudste versie die op elk ondersteund systeem beschikbaar is.

De grens tussen de twee werelden is bovendien geen dunne dlsym shim, maar bevat de onderdelen die ervoor zorgen dat vreemde code daadwerkelijk correct functioneert:

  • C++ exceptions: Deze overbruggen de grens in beide richtingen. Een throw in de statische wereld ontwindt via glibc-gecompileerde frames naar een glibc catch, en vice versa, waarbij destructoren aan beide zijden worden uitgevoerd. De Unwind* imports van de gasten zijn gebonden aan de ene unwinder in het uitvoerbare bestand, zodat er één exception-mechanisme in het proces is in plaats van twee die met elkaar vechten.
  • TLS-modellen: Alle vier de TLS-modellen worden ondersteund zonder wrappers of code-patching. General- en local-dynamic via __tlsgetaddr, TLSDESC via zijn custom-ABI resolver, en initial-exec — waarvan de GOT-slots eenvoudige thread-pointer-relatieve offsets zijn die geen loader kan onderscheppen — worden bediend vanuit een surplus arena in de statische TLS van het uitvoerbare bestand. Zo is één procesbreed offset geldig in elke thread en regelt de ongewijzigde musl de per-thread layout.
  • Binding-semantiek van ld.so: Geen benadering, maar exacte implementatie. Global-scope interposition, RTLDDEEPBIND, DTSYMBOLIC, symbol-versioning met de unversioned-provider compatibiliteitsregel, lazy PLT binding waarbij argumentregisters behouden blijven via de resolver, GNU en SysV hash lookups, ifunc resolvers met hun hwcaps, en /etc/ld.so.cache.
  • Cross-world introspectie: backtrace() doorloopt zowel statische als glibc frames en benoemt beide via één dladdr; dliteratephdr, dladdr1 en de link_map facade laten unwinders en profilers elk image zien; de file-backed mappings behouden echte paden in /proc/self/maps voor debuggers.
  • Stateful hoeken van glibc: getcontext / makecontext / swapcontext in assembly tegen glibc's mcontext layouts op beide architecturen, de pre-2.34 pthread ABI's, GNU obstacks, de fortified chk familie, en de inline-stdio ABI — musl's FILE is bewust zo ingericht dat glibc's inlined putcunlocked ertegen compileert — tot aan IO21stdout_ dat resolveert naar musl's eigen stream.

Elk van deze punten wordt getest door een conformiteitsbatterij, gecompileerd tegen echte glibc-headers op -O2, en door in de CI elke shared object van de duizend meest geïnstalleerde Debian-bibliotheekpakketten te laden op x86-64 en aarch64.

Vergelijking met alternatieven

gcompat is een glibc API-shim op distributieniveau om vooraf gebouwde glibc-binaries op musl te draaien. De loader-stub voert het programma opnieuw uit via de dynamische linker van musl met libgcompat.so preloaded. Het geeft een volledig statisch musl-proces geen dynamische loader. Het zelfvoorzienende model van SoLo is sterker: het uitvoerbare bestand bevat zowel de ELF-loader als de ABI-brug, laadt ongewijzigde host-DSO's zonder systeem-compatibiliteitspakket, behoudt de versies van hun glibc-imports en laat ongebruikte, niet-ondersteunde functies achterwege via symbool-specifieke, "fail-loud" stubs.

Detour bootstrapt de ld-linux van het systeem en staat toe dat meerdere C-runtimes naast elkaar bestaan. SoLo kiest de tegenovergestelde route: het mapt de benodigde DSO's zelf en vertaalt hun glibc-imports naar musl, zodat een tweede libc en diens TLS-status het proces nooit betreden.

Cosmopolitan Libc's cosmo_dlopen() volgt hetzelfde split-runtime schema als Detour: het bootstrapt de ELF-interpreter en libc van de host, en delegeert het laden van de doel-DSO aan de dlopen() van de host.

ClickHouse's experimentele userspace dynamic loader mapt momenteel ELF-objecten zelf, maar stopt vlak voor het laden van glibc. De voorgestelde weg naar echte systeembibliotheken zoals CUDA is Detour-achtig: de ld.so van het systeem bootstrappen, een tweede libc-runtime behouden en de musl/glibc thread pointer bij elke grens wisselen. SoLo implementeert in plaats daarvan de glibc ABI over de musl-runtime van de host en kan DSO-afhankelijkheden vervullen vanuit providers die al in het statische uitvoerbare bestand zijn gekoppeld.

graphics.gd's musl + dlopen experiment volgt hetzelfde split-runtime model als Detour: een ingebouwde helper brengt de glibc-loader van de host binnen, en assembly trampolines schakelen tussen musl- en glibc TLS rondom vreemde aanroepen. Dit laat twee onafhankelijke TLS-werelden achter. SoLo behoudt in plaats daarvan één enkele musl TLS-wereld.

Flatpak, AppImage en containers lossen het probleem op door een kleine Linux-distributie te verbergen in of rond uw programma. Het resultaat is een enorme blob vol gedupliceerde bibliotheken, mounts, namespaces en runtime-indirectie, wat profiling, debugging en basale introspectie verslechtert. Het verzenden van een distributie omdat u één systeem .so nodig heeft, is geen portabiliteit. SoLo verzendt één normaal, inspecteerbaar uitvoerbaar bestand en leent alleen het component dat werkelijk bij de host hoort: de hardware-driver.

Reikwijdte

  • Linux only, op x86-64 en aarch64. De loader, de TLSDESC en lazy-PLT resolvers en de initial-exec arena dekken beide; de glibc symbool-inventarissen zijn per architectuur gegenereerd.
  • Focus op echte Mesa/Vulkan ICD-afhankelijkheden, aangedreven door de top 1000 Debian-bibliotheekpakketten: de 885 daarvan die glibc-gekoppelde shared objects leveren — ongeveer 2100 objecten — worden allemaal in de CI via SoLo geladen op beide architecturen.
  • Een load-once runtime (dlclose slaagt maar ontlaadt een image niet).
  • Ondersteuning voor alle vier de TLS-modellen. Initial-exec variabelen worden geplaatst in een surplus arena van 16 KiB in de statische TLS van het uitvoerbare bestand. Beperking: threads die zijn aangemaakt vóór een dlopen zien nul-geïnitialiseerde TLS voor de modules die daarna zijn geladen. Laad daarom initial-exec bibliotheken voordat u de threads start die ze gebruiken. Een initial-exec module die niet in de arena past, faalt bij het laden met een foutmelding over het image en het aantal bytes.
  • Expliciet over ontbrekende ABI-dekking: een niet-geïmplementeerde glibc-aanroep breekt af en noemt zichzelf.

Het doel is om de harde muur tussen "volledig statisch" en "gebruikt de systeem-GPU" te veranderen in een eindige, testbare compatibiliteitslaag. De Vulkan PNG is het eerste bewijs dat er een deur in die muur zit.