Actually, scratch that, because Windows and macOS have historically struggled with ABI compatibility as well (macOS less so, due to not caring about backward compatibility in the first place).
How did we get to the point where people feel they need to go to the length of embedding an ELF loader in their binary (!!) rather than just linking with glibc?
> 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.
Why? Have people managed to break the ancient concept of shared libraries, and this is a fix for that?
Yacks
valid point. I am usually okay with LLM generated code since even if it might not be architecturally sound It is usually well commented and has tests and documentation for helping another agent/human debug any issues.
But, just the painful experience of debugging any dlopen related crashes and/or intermittent bugs; and the sheer amount of tokens burnt by an LLM chasing tangents when shown a stack trace; I wouldn’t touch this at least as a packager/consumer of certain apps for personal usage on older distros. So far, AnyLinux-Appimages seem to be a mature solution with great support from the developers, in case anyone lands here for packaging applications to run on older distros.
Shared libraries have always been broken in Linux. Unfortunately many things like GPU drivers, graphics libraries and NSS need shared libraries to dynamically load certain runtimes (because you don't want to load all possible GPU drivers in existence to your RAM). So an ecosystem has been developed on top of terrible ABI and architecture GNU/glibc provided.
What you can't do is build something statically with musl and then reliably dlopen shared libraries built with glibc.
I think the disconnect is mostly people that can't decide if they want a stable distro or a rolling release distro. Most everyone uses a stable distro because it's stable, but then the want some up-to-date software that isn't ore-built for their (crusty old) stable distro and they get annoyed. My solution was to finally give in and embrace a rolling release distro (I use arch, btw). If there isn't a package for something I want, it's not hard to build something myself because all my build tools, kernel, and libs are up to date.
Other reasons to want static linking is to distribute proprietary software with no source code available. Linux certainly does not cater to that scenario and I suppose some might call that a complete failure ¯ \ _ ( ツ ) _ / ¯
[1] https://www.linker-aliens.org/blogs/rie/entry/direct_binding...
[2] https://web.archive.org/web/20011004090044/http://developer....
Rude.
It only gets better from there - https://github.com/pg83/solo/blob/main/CONTRIBUTING.md!
That's currently the real core of the problem.
The loader (and libdl) need to be decoupled from the glibc itself under Linux.
Without that, any attempt to ship static binaries (or any binary with a different Libc) will be a source of perpetual pain.
nss plugins and its associated pain (sssd and avahi) are an other examples of that.
Not really, though. glibc uses symbol versions that are forward but not backward compatible. If you got an error that said "this program was built for a newer version of <distro>" would you say the same thing?
Note this is the same (if not worse) on MacOS, and on windows you used to distribute the CRT with your application just to deal with the same problem.
C++ abi should not be included in this. It is independent from the other pieces and historically a source of incompatibility on its own.
Saying "C/C++ abi" as if they are the same is looney tunes, the former is very simple and stable and the latter is very complex.
This is possible because Zig allows for targeting most glibc versions out of the box with its cross-compilation support.
1) Why should I limit myself to the available APIs?
2) Not just glibc. For example, if I build against the latest libstdc++, it will automatically support the more recent glibc. And pinning the old libstdc++ -well, that's just not a good idea.
https://docs.appimage.org/reference/best-practices.html
I hear you about WINE though.
Purely academically, this is a very cool piece of code! Just hoping that it gets a thorough vetting before used by privileged/security-critical software :)
Telling me, a coder you never met or interacted with before, that I would introduce more bugs than The Product(tm) is pretty rude and prejudiced.
> The project author believes that, with capable human direction, modern LLMs write code faster than people and introduce fewer bugs.
Yes glibc has some backwards compat but you cannot load a binary compiled with a newer version of glibc using an older ld-linux.so. That's because the interdependency. Nor you can load binaries that depend on different libc.so files with glibc systems
I cannot comment on macOS, I have never used it. However this is not a problem with Windows. You can ship a newer CRT or you can install it as a system component using Microsoft's MSI. The dependency is one way on Windows. CRT purely depends on Win32. Moreover the loader is completely independent and DLLs are loaded into their own unique scoped namespace unlike Linux that loads them in global symbol namespace. That's why you can mix and match DLLs compiled for different CRT versions.
If we look at the issue at its core, we're still a statically linked program in a hostile environment, forced to dynamically load device drivers from the system.
It's similar to Golang; on MacOS, it has to use libSystem, even though otherwise, these are the statically linked Go binaries we're used to and love.
Let me add a little more detail: if I use vdso with gettimeofday in a statically linked program on Linux, am I still a statically linked program, or not? :)
How libstdc++ initializes global variables absolutely depends on glibc and ld-linux.so. That is part of C++ ABI.
C++ global/static variable initialization depends on the specific version of glibc (they don't usually break compat, but they can and they did in the past) which also provides ld-linux.so that loads those global variable placeholders in the correct manner such that glibc and libstdc++ can initialize them correctly.
This is just one example. Thread local variables and behavior of things like pthreads with signal, fork etc all depend on glibc.
There are also much less well-known "little things" that regularly pop up here and there.
> If your CI uses an older glibc you should be fine AFAIK.
In any case, my binaries work not only under glibc, but also under Alpine, and (work in progress) under android/bionic.
In any case, we take testing very seriously—every glibc shim we've written is covered with tests, and we run our loader against 1000 of the most popular Debian packages. The project has 100% code coverage. Perhaps, if I have the time, I'll also do some fuzzing on this thing.
Obviously, this is an offense to me.
> Telling me, a coder you never met or interacted with before, that I would introduce more bugs than The Product(tm) is pretty rude and prejudiced.
Don't exaggerate. I didn't say that you personally introduce more bugs (as you rightly pointed out, I don't know you and don't know how often you introduce bugs), I said that the average developer introduces more bugs than an SOTA LLM.
Until you define a thread local variable (C11) or use atomics (also C11) or define a global with an initial value. Then it happily generates code that depends on "whatever my target glibc + ld-linux.so needs".
- Linus Torvalds
That bug report was a good read.
I could choose to believe in a social fiction and you can't stop me.
Not sure how the comments about alpine or bionic relate either to my claim that cross distro glibc is fine.
On Windows you don't need to ship a new binary loader. I can just ship Windows 10 UCRT DLL (which is the new libc of Windows) to Vista and my binaries will work. The binary loader isn't interlinked with the libc.
Overall, both of our points of view on compatibility were discussed well in that thread; we probably shouldn't repeat ourselves. :)
The ABI is strongly dependent on explicit libc implementation in current Linux systems. There is no libc independent ABI on Linux.
.so loader for static Linux binariesShip 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, and on Apple M1 under Asahi Linux.
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.
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:
The complete example is in bin/vulkan, and the Vulkan program
itself is in main.cpp.
┌──────────────────── 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 and
DL_ELF_LIBRARY_PATH are honored for libraries outside the standard system
directories.
The interesting pieces are small enough to read:
lib/dlfcn.cpp — dlopen, dlsym, errors, and static providerslib/elf_loader.cpp — ELF mapping, symbols, relocations, and TLSlib/glibc_shim.cpp — implemented glibc ABI adapterslib/glibc_stubs.cpp — explicit fallbacks for the rest of the ABIThe 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.
./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 build input for the standalone Vulkan executable is vendored under
bin/vulkan. build.py compiles those sources directly: upstream
CMake, Meson, configure, and Make build systems are not invoked.
0784374d561435f7c787a555aeab8ede699ed298)8dfdcc7b7bf66834a761bd8de445840ef68e4d1a)e3b1eec08173d6b825cd3ac88c885a63b621504a)5f157b62e333c63260d05d81bf66faa216ab0fb8)da607da739fa6047df13e66a2af6b8bec7c2a498)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.
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:
catch, and the other
way around, destructors running on both sides: the guests' _Unwind_*
imports are bound to the one unwinder in the executable, so there is a
single exception machinery in the process instead of two fighting ones.__tls_get_addr, TLSDESC through its custom-ABI
resolver, and initial-exec — whose GOT slots are plain
thread-pointer-relative offsets no loader can intercept — served from a
surplus arena that rides in the executable's own static TLS, so one
process-wide offset is valid in every thread and unmodified musl does the
per-thread layout.ld.so's binding semantics, not an approximation. Global-scope
interposition, RTLD_DEEPBIND, DT_SYMBOLIC, symbol versioning with the
unversioned-provider compatibility rule, lazy PLT binding with the
argument registers preserved through the resolver, GNU and SysV hash
lookups, ifunc resolvers handed their hwcaps, /etc/ld.so.cache.backtrace() walks static and glibc frames
alike and names both through one dladdr; dl_iterate_phdr, dladdr1,
and the link_map facade let unwinders and profilers see every image; the
file-backed mappings keep real paths in /proc/self/maps for debuggers.getcontext /
makecontext / swapcontext in assembly against glibc's mcontext
layouts on both architectures, the pre-2.34 pthread ABIs, GNU obstacks,
the fortified _chk family, and the inline-stdio ABI — musl's FILE is
deliberately laid out so glibc's inlined putc_unlocked compiles against
it — down to _IO_2_1_stdout_ resolving to musl's own stream.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.
libgcompat.so
preloaded; using it from a musl program requires linking that shared library
or adding it to the loaded DSO's DT_NEEDED. It does not give a fully static
musl process a dynamic loader. SoLo's self-contained model is stronger: the
executable embeds both the ELF loader and ABI bridge, loads unchanged host
DSOs without a system compatibility package, preserves the versions of their
glibc imports, and lets unused unsupported functions remain behind
symbol-specific, fail-loud stubs instead of blocking the entire DSO.ld-linux and allows multiple C runtimes to coexist. SoLo takes the opposite
route: it maps the required DSOs itself and translates their glibc imports
onto musl, so a second libc and its TLS state never enter the process.cosmo_dlopen()
follows the same split-runtime scheme as Detour, with all of its advantages
and drawbacks: it bootstraps the host's ELF interpreter and libc, then
delegates loading the target DSO to the host's dlopen().ld.so, keep a second libc runtime, and swap the
musl/glibc thread pointer at every boundary. SoLo instead implements the
glibc ABI over the host's musl runtime and can satisfy DSO dependencies from
providers already linked into the static executable.musl + dlopen experiment
follows the same split-runtime model as Detour: an embedded helper brings in
the host's glibc loader, and assembly trampolines switch between musl and
glibc TLS around foreign calls. This leaves two independent TLS worlds: every
boundary crossing needs a trampoline, and a callback implemented in musl
cannot be passed safely to glibc code because glibc invokes it while its own
TLS is active. SoLo keeps a single musl TLS world instead..so is
not portability. SoLo ships one normal, inspectable executable and borrows
the only component that genuinely belongs to the host: its hardware driver.printf@GLIBC_2.2.5 on one
is printf@GLIBC_2.17 on the other without a single translation rule in
the code;dlclose succeeds but does not unload an image);dlopen see zero-initialized TLS
for the modules it loaded, so load initial-exec libraries before spawning
the threads that use them. An initial-exec module that does not fit the
arena fails to load with an error naming the image and the byte counts;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.
It may not be clear to a non-english first language person, but when I read Claudes documentation, my brain immediately picks up claude-speak. Therefore, I expect the code to be generally correct, maybe, depending on how specific I was during my prompting. In no way shape or form do I really trust it, at least until i dig into the code and validate my mental model. And query the review for edge cases etc...
By writing your documentation with Claude, my brain immediately associates the quality of the project with the quality of unreviewed Claude output.
To a degree this is unfair, as it is like judging the quality of someone's work based on their accent.
But Claudes accent, has a high correlation with Claude, so unlike with people, where an accent has no bearing on technical ability, Claude being Claude does.
I would much rather read a typo ridden sentence than claudism, even just as a forward, explaining what you did vs the ai, and telling the users how much we should trust it.
Also, If you really insist on using AI to write, have another model rewrite docs/comments into regular English (opus 4.6 for example is much better than 4.7, 4.8, or 5.
It is open source so you can do whatever you like, but people (especially native English speakers) will discount your work, because Claude, especially opus 5 writes very very badly.
The people who say they would rather see a typo infested mess of a readme are serious. Or even just write in your native language and then have Claude translate.
Both of those are better indications of proof of effort than a Claude readme.
When you compile libc, you also get a binary loader ld-linux.so with it. They are not two independent components of a system.
Basically all .so files compiled with glibc require the ld-linux.so that's also generated by that glibc (or a later version, if they didn't break the binary compatibility).
There are a lot of stuff that's executed by ld-linux.so and glibc that are not explicitly documented but they are absolutely necessary for your program to start and correctly initialize things like global variables or signal handling or loading other dynamic libraries. Some of that functionality sits in ld-linux.so and some of that in glibc. They have circular dependencies to each other. glibc expects ld-linux.so to put things in certain order but ld-linux.so also must load glibc first to have access to certain APIs. They are not part of System V ABI. They are not documented.
Musl maybe can implement this but it is simply reverse engineering what glibc did and then playing a game of cat and mouse. There is no independent ABI standard.
That's exactly what I did.
One of the ways Windows manages to support multiple libc's is by being careful not to mix allocators; if a system API you call allocates on your behalf, your libc can't free it, the system API will offer a function to free it.
Windows loader is certainly available to user programs though; LoadLibrary has been around longer than many developers.
MS haven't made this work arbitrarily far back, I believe they deprecated targeting Windows XP in one of the more recent toolchains, but that was purely a "not worth supporting" situation.
EDIT: mixed up talking about older & newer
As for older Windows systems not being able to load new DLLs, they can; the format hasn't changed in a very long time. I've had experience with installing some DLLs on Windows NT 3.51 and running a modern Firefox, which is about 20 years behind the times.