The .a file is a relic: Why static archives were a bad idea all along
78 comments
·July 19, 2025TuxSH
flohofwoe
There's also the other 'old-school' method to compile each function into its own object file, I guess that's why MUSL has each function in its own source file:
https://github.com/kraj/musl/tree/kraj/master/src/stdio
...but these days -flto is simply the better option to get rid of unused code and data - and enable more optimizations on top. LTO is also exactly why static linking is strictly better than dynamic linking, unless dynamic linking is absolutely required (for instance at the operating system boundary).
astrobe_
Yes, these are really esoteric options, and IIRC GCC's docs say they can be counter-productive.
TuxSH
These options can easily be found by a Google Search or via LLM, whichever one prefers
> they can be counter-productive
Rarely[1]. The only side effect this can have is the constant pools (for ldr rX, [pc, #off] kind of stuff) not being merged, but the negative impact is absolutely minimal (different functions usually use different constants after all!)
([1] assuming elf file format or elf+objcopy output)
There are many other upsides too: you can combine these options with -Wl,-wrap to e.g. prune exception symbols from already-compiled libraries and make the resulting binaries even smaller (depending on platform)
The question is, why are function-sections and data-sections not the default?
It is quite annoying to have to deal with static libs (including standard libraries themselves) that were compiled with neither these flags nor LTO.
readmodifywrite
One engineer's esoteric is another's daily driver. All 3 of those options are borderline mandatory in embedded firmware development.
jeffbee
-ffunction-sections has 750k hits on github. It is among the default flags for opt mode builds in Bazel. There are probably people who consider them defaults, in practice.
astrobe_
Well, C and C++ together have around 7M repos, so about 10%. Actually not entirely esoteric, but Github is only a fraction of the world's codebase and users of these repos probably never looked in the makefile, so I'd say 10% of C/C++ developers knowing about this is a very optimistic estimate.
null
greenavocado
> Do people really not know about $OBSCURE_GCC_FLAG?
Do you know what you sound like?
null
pjmlp
How can they be expected to learn this, when it is now fashionable to treat C and C++ as if they are scripting languages, shipping header only files?
We already had scripting engines for those languages in the 1990's, and the fact they are hardly available nowadays kind of tells of their commercial success, with exception of ROOT.
TuxSH
> How can they be expected to learn this
It's the first thing Google and LLMs 'tell' you when you ask about reducing binary size with static libraries. Also LTO does most of the same.
pjmlp
To learn, first one needs to want to learn, which was my whole point.
asveikau
It makes more sense for c++ due to templates, but the header only C library trend is indeed very strange. It's not surprising that people are coming up now who are writing articles about being confused by static linking behavior.
TuxSH
Header-only is simpler to integrate, so it makes sense for simple stuff, or stuff that is going to be used by only one TU there.
However, the semantics of inline are different between C and C++. To put it simply, C is restricted to static inline and, for variables, static const, whereas C++ has no such limitations (making them a superset); and static inline/const can sometimes lead to binary size bloat
pjmlp
Even with C++ templates, if you want faster builds, header files aren't the place to store external templates, which are instantiations for common type parameters.
flohofwoe
An STB-style header-only library is actually quite perfect for eliminating dead code if the implementation and all code using that library is in the same compilation unit (since the compiler will not include static functions into the build that are not called).
...or build -flto for the 'modern' catch-all feature to eliminate any dead code.
...apart from that, none of the problems outlined in the blog post apply to header only libraries anyway since they are not distributed as precompiled binaries.
amiga386
> Yet, what if the logger’s ctor function is implemented in a different object file?
This is a contrived example akin to "what if I only know the name of the function at runtime and have to dlsym()"?
Have a macro that "enables use of" the logger that the API user must place in global scope, so it can write "extern ctor_name;". Or have library specific additions for LDFLAGS to add --undefined=ctor_name
There are workarounds for this niche case, and it doesn't add up to ".a files were a bad idea", that's just clickbait. You'll appreciate static linkage more on the day after your program survives a dynamic linker exploit
> Every non-static function in the SDK is suddenly a possible cause of naming conflict
Has this person never written a C library before? Step 1: make all globals/functions static unless they're for export. Step 2: give all exported symbols and public header definitions a prefix, like "mylibname_", because linkage has a global namespace. C++ namespaces are just a formalisation of this
Joker_vD
> This is a contrived example akin to "what if I only know the name of the function at runtime and have to dlsym()"?
Well, you just do what the standard Linux loader does: iterate through the .so's in your library path, loading them one by one and doing dlsym() until it succeeds :)
Okay, the dynamic loader actually only tries the .so's whose names are explicitly mentioned as DT_NEEDED in the .dynamic section but it still is an interesting design choice that the functions being imported are not actually bound to the libraries; you just have a list of shared objects, and a list of functions that those shared objects, in totality, should provide you with.
lokar
Also, don’t use automatic module init, make the user call an init function at startup.
And prefix everything in your library with a unique string.
kazinator
.a archives can speed up linking of very large software. This is because of assumptions as to the dependencies and the way the traditional Unix-style linker deals with .a files (by default).
When a bunch of .o files are presented to the linker, it has to consider references in every direction. The last .o file could have references to the first one, and the reverse could be true.
This is not so for .a files. Every successive .a archive presented on the linker command line in left-to-right order is assumed to satisfy references only in material to the left of it. There cannot be circular dependencies among .a files and they have to be presented in topologically sorted order. If libfoo.a depends on libbar.a then libfoo.a must be first, then libbar.a.
(The GNU Linker has options to override this: you can demarcate a sequence of archives as a group in which mutual references are considered.)
This property of archives (or of the way they are treated by linking) is useful enough that at some point when the Linux kernel reached a certain size and complexity, its build was broken into archive files. This reduced the memory and time needed for linking it.
Before that, Linux was linked as a list of .o files, same as most programs.
rixed
Do people who write this kind of pieces with such peremptory titles really believe that they finally came about to understand everything better after decades of ignorance?
Chesterton’s Fence yada yada?
cap11235
Well, it is on medium.com, so probably yes?
EE84M3i
Something I've never quite understood is why can't you statically link against an so file? What specific information was lost during the linking phase to create the shared object that presents that machine code from being placed into a PIE executable?
sherincall
wcc can do that for you: https://github.com/endrazine/wcc
EE84M3i
Woah this is really awesome! Thanks for sharing, this made my day.
accelbred
so files require PIC code, which brings along symbol interpolation.
LtWorf
You can, but why?
EE84M3i
At a fundamental level I don't understand why we have two separate file types for static and dynamic libraries. It seems primarily for historical reasons?
The author proposes introducing a new kind of file that solves some of the problems with .a filed - but we already have a perfectly good compiled library format for shared libraries! So why can't we make gcc sufficiently smart to allow linking against those statically and drop this distinction?
Joker_vD
Oh, it's even better on the Windows side of things, at least how the MSVC toolchain does it. You can only link a statically-linked .lib library, period. So if you want to statically link against a dynamic library (what a phrase!), you need to have a special version of that .lib library that essentially is just a collection of thunks (in MSVC-specific format) that basically say "oh, you actually want to add symbol Bar@8 from LIBFOO.DLL to your import section" [0]. So yeah, you'd see three binaries distributed as a result of building a library: libfoo_static.lib (statically-linked library), libfoo.dll (dynamic library), libfoo.lib (the shim library to link against when you want to link to libfoo.dll).
Amusingly, other (even MSVC-compatible) toolchains never had such problem; e.g. Delphi could straight up link against a DLL you tell it to use.
[0] https://learn.microsoft.com/en-us/cpp/build/reference/using-...
alexvitkov
Because with the current compilation model shared libraries (.so/.dll) are the output of the linker, but static libraries are input for the linker. It is historical baggage, but as it currently stands they're fairly different beasts.
convolvatron
you could say historical reasons, in that dynamic libraries are generated using relocatable position independent code (-pic), which incurs some performance penalty vs code where the linker fills in all the relocations. my guess is thats somewhere around 10%? historical in the sense that that used to be enough to matter? idk that it still is
personally I think leaving the binding of libraries to runtime opens up alot of room for problems, and maybe the savings of having a single copy of a library loaded into memory vs N specialized copies isn't important anymore either.
alexvitkov
Because I want my program to run on other people's computers.
tempay
I think the question isn’t why statically link but rather why bother with .a files and instead use the shared libraries all the time (even if only to build a statically linked executable).
cryptonector
It's not that .a files and static linking are a relic, but that static linking never evolved like dynamic linking did. Static linking is stuck with 1978 semantics, while dynamic linking has grown features that prevent the mess that static linking made. There are legit reasons for wanting static linking in 2025, so we really ought to evolve static linking like we did dynamic linking.
Namely we should:
- make -l and -rpath options in
.a generation do something:
record that metadata in the .a
- make link-edits use that meta-
data recorded in .a files in
the previous item
I.e., start recording dependency metadata in .a files and / so we can stop flattening dependency trees onto the final link-edit.This will allow static linking to have the same symbol conflict resolution behaviors as dynamic linking.
flohofwoe
Library files are not the problem, deploying an SDK as precompiled binary blobs is ;)
(I bet that .a/.lib files were originally never really meant for software distribution, but only as intermediate file format between a compiler and linker, both running as part of the same build process)
tux3
I actually wrote a tool a to fix exactly this asymmetry between dynamic libraries (a single object file) and static libraries (actually a bag of loose objects)
I never really advertised it, but what it does is take all the objects inside your static library, and tells the linker to make a static library that contains a single merged object.
https://github.com/tux3/armerge
The huge advantage is that with a single object, everything works just like it would for a dynamic library. You can keep a set of public symbols and hide your private symbols, so you don't have pollution issues.
Objects that aren't needed by any public symbol (recursively) are discarded properly, so unlike --whole-archive you still get the size benefits of static linking.
And all your users don't need to handle anything new or to know about a new format, at the end of the day you still just ship a regular .a static library. It just happens to contain a single object.
I think the article's suggestion of a new ET_STAT is a good idea, actually. But in the meantime the closest to that is probably to use ET_REL, a single relocatable object in a traditional ar archive.
amluto
Is there any actual functional difference between the author’s proposed ET_STAT and an appropriately prepared ET_RET file?
For that matter, I’ve occasionally wondered if there’s any real reason you can’t statically link an ET_DYN (.so) file other than lack of linker support.
tux3
I think everything that you would want to do with an ET_STAT file is possible today, but it is a little off the beaten path, and the toolchain command line options today aren't as simple as for dynamic libraries (e.g. figuring out how to hide symbols in a relocatable object is completely different on the GNU toolchain, LLVM on Linux, or Apple-LLVM which also supports relocatable objects, but has a whole different object file format).
I would also be very happy to have one less use of the legacy ar archive format. A little known fact is that this format is actually not standard at all, there's several variants floating around that are sometimes incompatible (Debian ar, BSD ar, GNU ar, ...)
stabbles
It sounds interesting, but I think it's better if a linker could resolve dependencies of static libraries like it's done with shared libraries. Then you can update individual files without having to worry about outdated symbols in these merged files.
tux3
If you mean updating some dependency without recompiling the final binary, that's not possible with static linking.
However the ELF format does support complex symbol resolution, even for static objects. You can have weak and optional symbols, ELF interposition to override a symbol, and so forth.
But I feel like for most libraries it's best to keep it simple, unless you really need the complexity.
dzaima
How possible would it be to have a utility that merges multiple .o files (or equivalently a .a file) into one .o file, via changing all hidden symbols to local ones (i.e. alike C's "static")? Would solve the private symbols leaking out, and give a single object file that's guaranteed to link as a whole. Or would that break too many assumptions made by other things?
Joker_vD
Like, a linker, with "objcopy --strip-symbols" run as the post-step? I believe you can do this even today.
dzaima
--localize-hidden seems to be more what I was thinking of. So this works:
ld --relocatable --whole-archive crappy-regular-static-archive.a -o merged.o
objcopy --localize-hidden merged.o merged.o
This should (?) then solve most issues in the article, except that including the same library twice still results in an error.reactordev
I did this with my dependencies for my game engine. Built them all as libs and used linker to merge them all together. Makes building my codebase as easy as -llibutils
benreesman
I routinely tear apart badly laid-out .a files and re-ar them into something useful. It's a few lines of bash.
tux3
This works, but scripting with the ar tool is annoying because it doesn't handle all the edge cases of the .a format.
For instance if two libraries have a source file foo.c with the same name, you can end up with two foo.o, and when you extract they override each other. So you might think to rename them, but actually this nonsense can happen with two foo.o objects in the same archive.
The errors you get when running into these are not fun to debug.
benreesman
This is the nastiest one in my `libmodern-cpp` suite: https://gist.github.com/b7r6/31a055e890eaaa9e09b260358da897b....
It took a few minutes, probably has a few edge cases I haven't banged out yet, and now I get to `-l` and I can deploy with `rsync` instead of fucking Docker or something.
I take that deal.
benreesman
`boost` is a little sticky too: https://gist.github.com/b7r6/e9d56c0f6d55bc0620b2ce190e15d44...
but for your trouble: https://gist.github.com/b7r6/0cc4248e24288551bcc06281c831148...
If there's interest in this I can make a priority out of trying to get it open-sourced.
layer8
> Something like a “Static Bundle Object” (.sbo) file, that will be closer to a Shared Object (.so) file, than to the existing Static Archive (.a) file.
Is there something missing from .so files that wouldn’t allow them to be used as a basis for static linking? Ideally, you’d only distribute one version of the library that third parties can decide to either link statically or dynamically.
dale_glass
Oh, static linking can be lots of "fun". I ran into this interesting issue once.
1. We have libshared. It's got logging and other general stuff. libshared has static "Foo foo;" somewhere.
2. We link libshared into libfoo and libbar.
3. libfoo and libbar then go into application.
If you do this statically, what happens is that the Foo constructor gets invoked twice, once from libfoo and once from libbar. And also gets destroyed twice.
stabbles
Much of the dynamic section of shared libraries could just be translated to a metadata file as part of a static library. It's not breaking: the linker skips files in archives that are not object files.
binutils implemented this with `libdep`, it's just that it's done poorly. You can put a few flags like `-L /foo -lbar` in a file `__.LIBDEP` as part of your static library, and the linker will use this to resolve dependencies of static archives when linking (i.e. extend the link line). This is much like DT_RPATH and DT_NEEDED in shared libraries.
It's just that it feels a bit half-baked. With dynamic linking, symbols are resolved and dependencies recorded as you create the shared object. That's not the case when creating static libraries.
But even if tooling for static libraries with the equivalent of DT_RPATH and DT_NEEDED was improved, there are still the limitations of static archives mentioned in the article, in particular related to symbol visibility.
> This design decision at the source level, means that in our linked binary we might not have the logic for the 3DES building block, but we would still have unused decryption functions for AES256.
Do people really not know about `-ffunction-sections -fdata-sections` & `-Wl,--gc-sections` (doesn't require LTO)? Why is it used so little when doing statically-linked builds?
> Let’s say someone in our library designed the following logging module: (...)
Relying on static initialization order, and on runtime static initialization at all, is never a good idea IMHO