While battling with linking and versioning issues with dynamically loaded shared libraries, it occurred to me that the function coloring metaphor is quite appropriate for shared libraries as well. So, here’s an LLM-generated post in the same style as the original function coloring piece, complete with the prompt that generated it. I found it pretty amusing and even somewhat useful.
In the style of https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/, write an article titled “what color is your library”. It explores the difficulties that shared libraries pose compared to static libraries, touches on how shared libraries are “red” libraries and are contagious, prone to make all other libraries in the project also become shared libraries. If appropriate, also explore alternative solutions in this space like the function article.
You’ve seen this before.
You start with a clean, fast, blue world: one statically linked executable, everything compiled together, LTO humming, symbols private, nothing surprising at runtime.
Then someone says, “We need a plugin.” Or “We’ll ship a shared core so other teams can embed us.” You flip one target to a .so, add -fPIC a couple places, and—whoops—the color bleeds. Your build turns red.
Shared libraries are red libraries. They’re contagious. Once one shows up, it wants your whole graph.
Blue vs. red (static vs. shared)
- Blue (static): link-time composition, one artifact, whole-program optimization, reproducible startup, no loader puzzles. ABI doesn’t leak across component boundaries because there aren’t any.
- Red (shared): runtime composition, the dynamic loader sits between you and your code, ABI freezes at the boundary, search paths matter, symbol interposition can bite, and position independence becomes a property you carry everywhere.
Both have their place. But red brings rules that blue code never had to obey—and those rules spread.
Why red spreads
1) PIC tax is transitive
On ELF platforms, if a shared object depends on static archives, those archives must have been compiled with PIC (e.g., -fPIC or -fpic). If they weren’t, you hit errors like:
relocation R_X86_64_32 against `.text’ can not be used when making a shared object; recompile with -fPIC
So your brand-new libcore.so can’t consume yesterday’s comfortable libalgo.a unless you rebuild libalgo with PIC. And if libalgo depends on libutil.a, that needs PIC too. One red target forces a red rebuild of its entire static closure.
People try to dodge by “just link the .a into the final binary and dlopen the .so separately.” That only works if the shared object doesn’t pull on those .as. The moment it does, you’re back to repainting.
2) ABIs harden at boundaries
Red turns implementation details into promises:
- Inline & templates: in blue, you can inline/instantiate freely. In red, headers that bleed inlines across the boundary pin client and server to identical compilers/libstdc++ ABIs. Breaks appear as ODR potholes and “worked in dev, crashed in prod.”
- Exceptions/RTTI: crossing a .so boundary with C++ exceptions or polymorphic types couples you to allocator, unwinder, and RTTI layout details. Upgrading one side silently changes behavior on the other.
- Interposition: any non-hidden symbol might be replaced by a different definition from another .so. You thought you called your logger; you actually called someone else’s.
The fix is discipline—visibility hygiene, C-shaped APIs, versioned symbols—but that’s work. And once one boundary exists, you need that discipline everywhere that boundary touches.
3) The loader is now part of your system
The dynamic loader resolves search paths and symbol bindings based on DT_NEEDED, RPATH/RUNPATH, LD_LIBRARY_PATH, and dlopen flags (RTLD_LOCAL/RTLD_GLOBAL). The order you load things can change which functions get called. $ORIGIN becomes a design decision, not an afterthought.
If your program was blue yesterday, nobody on the team internalized these rules. Introduce one .so, and every deploy script, container, and CI job now has to care.
4) Performance shifts
Modern x86-64 PIC overhead is usually modest, but it’s not zero—extra indirections through GOT/PLT, fewer cross-module inlines, more conservative devirtualization. Startup relocations and symbol lookups show up in tail latency. If your world is ultra-low-latency, these details matter. Turning one central library red pushes PIC and PLT decisions across everything that touches it.
Why people choose red anyway
- Plugins & scripting: load at runtime, optional features, third-party extensions.
- Independent upgrades: patch a component without relinking the world.
- Footprint: multiple processes can share code pages from a single .so.
- Licensing & packaging: some ecosystems expect shared objects.
All valid. Just don’t pretend the rest of the code can stay innocent.
A field guide to red contagion
Symptoms you’ll see after introducing one .so:
- -fPIC shows up in “unrelated” libraries.
- Build system sprinkles twin targets: mylib and mylib.pic.
- Someone proposes -Wl,–whole-archive to cram old .as into a .so.
- You start arguing about RPATH vs RUNPATH and $ORIGIN.
- A segfault disappears when you export fewer symbols (visibility).
- A perf regression vanishes when you turn off interposition (-Bsymbolic-functions).
- You add a “stable C SDK” doc because C++ across the boundary hurt.
Congrats: your project is red now.
Surviving in a red world
If you must be red, be intentionally red.
- Draw narrow, explicit, C-shaped boundaries
- Expose a C ABI (extern “C”) with POD structs and lengths. No STL types, no exceptions across the boundary.
- Use pimpl on the C++ side so you can evolve internals without ABI breakage. - Put stable headers in a sdk/ directory. Freeze them like an API, not like a header convenience.
- Hide everything else
-
Compile with -fvisibility=hidden and export only what’s public (linker version script or attribute((visibility(“default”)))).
-
Consider -Wl,-Bsymbolic-functions to reduce interposition surprises inside a .so. 3. Version and police your ABI
-
Give the library a SONAME and use symbol versioning if you truly need multiple ABI generations.
-
Add CI that fails if new exports appear: nm -D, objdump -T, compare against a baseline.
-
Force closure: -Wl,-z,defs so missing dependencies trip the link, not runtime.
- Tame the loader
- Prefer RUNPATH with $ORIGIN/… over global LD_LIBRARY_PATH.
- Use dlopen(…, RTLD_LOCAL) unless you want intentional symbol sharing.
- Control performance
- Build everything that participates with PIC; measure with and without PLT optimizations (-fno-plt) and LTO-enabled shared libs if your toolchain supports it.
- Keep hot call paths inside a single module when possible.
Alternatives (choose your palette up front)
A) Stay purely blue (static all the way)
- One binary, fully static (or at least statically link your stack except for the C library).
- Great for hermetic, reproducible deployments and low latency.
- Downsides: larger artifacts; security updates require rebuild; fully static glibc can be painful—musl helps if you can switch.
When to pick: servers you own end-to-end, no third-party plugins, strong perf/repro needs.
B) Red islands on a blue sea
Keep the application and internal libs static. Introduce one red boundary for plugins:
- Define a small C ABI (create_plugin, destroy_plugin, process()).
- Vendor or statically link a plugin’s internal dependencies into the plugin to avoid global symbol collisions.
- Expose only the plugin API; hide the rest with visibility controls.
- Load plugins with RTLD_LOCAL and pass opaque handles, not STL, across the boundary.
This keeps most of the graph blue and confines red rules to a small, well-documented island.
C) Separate process instead of .so
Use IPC (Unix domain sockets, shared memory ring buffers, memfd) to compose. You pay a crossing cost, but you get:
- No ABI coupling, no loader games, crash isolation, language freedom.
- With careful shared-memory design, the overhead can be microseconds. For UI tools and many services, it’s a no-brainer. For in-the-loop trading paths, maybe not.
D) “Ship shared, build blue”
Internally build statically to keep sanity and performance. At publish time, produce a red distribution by carving out a few shared components with crystal-clear ABIs. Treat that split as a release-engineering step, not how developers work day-to-day.
E) Header-only or static-preferred dependencies
For third-party libs under your control, prefer header-only or static builds. If you must depend on a vendor .so, put a thin adapter around it and keep the rest blue.
Practical build hygiene (CMake/Bazel-ish)
- Produce both PIC and non-PIC variants where needed:
- CMake: set POSITION_INDEPENDENT_CODE on targets that may end up in .sos; keep others off.
- Bazel: split cc_library into :mylib (non-PIC static) and :mylib.pic (PIC) and depend on the right one per context.
- Uniform toolchain switches: exceptions, RTTI, allocators. Mismatches become heisenbugs across red boundaries.
- Export maps / version scripts checked into sdk/. CI diff them on every change.
- Audit artifacts: readelf -d, ldd, nm -D, and objdump -x in a release job. Fail on unexpected NEEDED, stray RPATH, or unapproved exports.
The moral
Color is a property that propagates.
If you choose blue, protect it: say no to casual plugins and enforce a static-first toolchain. If you choose red, embrace it: draw tight C ABIs, hide your guts, police exports, and script the loader.
What doesn’t work is pretending a red decision can live quietly in a blue world. It won’t. One shared library invites the loader, ABI stability, PIC, and interposition to your party—and they don’t mingle politely.
Pick your color early. Make the boundary small. And whatever you do, don’t let accidental red repaint the whole house.