Packaging and Distributing C Libraries
A library that only you can build is a directory of source files. Making it installable — and keeping it installable across versions without breaking programs already compiled against it — is a distinct engineering problem, and ABI compatibility is where most of the difficulty lives.
- Design a public header that does not constrain your implementation.
- Distinguish API from ABI compatibility and classify a change correctly.
- Set a
sonameand control which symbols are exported. - Install to the conventional layout and supply a
pkg-configfile. - Deprecate and remove functionality without breaking existing users.
1The public header
Everything in an installed header is a promise. The smaller the header, the more freedom you retain.
#ifndef INTARRAY_H
#define INTARRAY_H
#include <stddef.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" { /* week 46: C++ callers */
#endif
#define INTARRAY_VERSION_MAJOR 2
#define INTARRAY_VERSION_MINOR 3
#define INTARRAY_VERSION_PATCH 0
typedef struct IntArray IntArray; /* opaque: no layout promised */
IntArray *intarray_create(size_t initial_capacity);
void intarray_destroy(IntArray *a);
bool intarray_push(IntArray *a, int value);
size_t intarray_count(const IntArray *a);
const char *intarray_version_string(void);
#ifdef __cplusplus
}
#endif
#endif /* INTARRAY_H */| Rule | Why |
|---|---|
| Prefix every public name | C has no namespaces; intarray_ is the substitute |
| Opaque types wherever possible | The layout stays yours — week 28 |
| Include only what the header itself needs | Pulling in <stdio.h> imposes it on every user |
No static or inline definitions | They become part of the ABI once a caller compiles them in |
| Version macros and a runtime accessor | The macro says what you compiled against; the function says what is loaded |
extern "C" guard | Costs two lines; makes the library usable from C++ |
That last pair is worth understanding. INTARRAY_VERSION_MINOR is baked into the caller at compile time; intarray_version_string() reports the shared library actually loaded. When they disagree, you have a deployment problem — and without both you cannot detect it.
2API versus ABI
| API | ABI | |
|---|---|---|
| Compatible means | Existing source still compiles | Existing binaries still run |
| Broken by | Removing a function, changing a signature | Anything that changes layout, size, or calling convention |
| Detected | At compile time, loudly | At run time, as corruption |
ABI is the harder discipline because the breakages are invisible in the header's text.
| Change | API | ABI |
|---|---|---|
| Add a new function | safe | safe |
| Add a member to an opaque struct | safe | safe |
| Add a member to a struct in the header | safe | BREAKS — size changes |
| Reorder members of a visible struct | safe | BREAKS — offsets change |
| Insert an enumerator in the middle | safe | BREAKS — values shift |
Change a parameter from int to long | usually safe | BREAKS |
| Add a parameter | BREAKS | BREAKS |
Change int to size_t in a returned struct | safe | BREAKS |
| Rename a function | BREAKS | BREAKS |
Make a function static | BREAKS | BREAKS |
Rows two and three are the whole argument for opaque types. If the caller never sees the definition, it never allocates the struct and never computes an offset — so you may add, remove, and reorder members forever.
An ABI break with no API break is the dangerous one. The user's code still compiles, so nothing warns. Their existing binary loads the new library, allocates the old size, and writes past the end of every object. The symptom is memory corruption in code that was not recompiled and did not change.
3soname and symbol visibility
gcc -shared -Wl,-soname,libintarray.so.2 \
-o libintarray.so.2.3.0 intarray.o
ln -sf libintarray.so.2.3.0 libintarray.so.2 # runtime link
ln -sf libintarray.so.2 libintarray.so # build-time linkThree names, three jobs:
| Name | Used by |
|---|---|
libintarray.so | The linker at build time, via -lintarray |
libintarray.so.2 | The dynamic loader at run time — this is the soname |
libintarray.so.2.3.0 | The actual file |
The soname is recorded inside every program linked against the library, and it contains only the major version. So installing 2.4.0 alongside 2.3.0 lets existing programs pick up the new one automatically — while 3.0.0 has soname libintarray.so.3 and coexists rather than replacing it. That is how a system can hold two incompatible versions at once.
Exporting only what you mean to
By default every non-static symbol is exported, which makes internal helpers part of your ABI whether you intended it or not.
gcc -fvisibility=hidden -fPIC -c intarray.c # hide by default#define INTARRAY_API __attribute__((visibility("default")))
INTARRAY_API IntArray *intarray_create(size_t capacity);Or list them explicitly, which works across compilers:
/* intarray.map */
INTARRAY_2 {
global:
intarray_create;
intarray_destroy;
intarray_push;
intarray_count;
intarray_version_string;
local:
*;
};gcc -shared -Wl,--version-script=intarray.map -o libintarray.so.2.3.0 …Verify the result rather than assuming it:
nm -D --defined-only libintarray.so.2.3.0 | grep ' T 'Anything in that list is a promise. Week 29's static keyword and this flag are the two mechanisms that keep it short.
4Installing
| Goes to | What |
|---|---|
$(prefix)/include/ | Public headers |
$(prefix)/lib/ | .so, .a, and the symlinks |
$(prefix)/lib/pkgconfig/ | The .pc file |
$(prefix)/share/doc/ | README, licence, changelog |
prefix defaults to /usr/local; distributions override it to /usr. Two conventions that packagers require: honour DESTDIR, which stages the install into a temporary root, and never write outside $(DESTDIR)$(prefix).
pkg-config
prefix=/usr/local
exec_prefix=${prefix}
libdir=${exec_prefix}/lib
includedir=${prefix}/include
Name: intarray
Description: A growable integer array
Version: 2.3.0
URL: https://example.org/intarray
Libs: -L${libdir} -lintarray
Cflags: -I${includedir}With that file installed, a consumer never hard-codes a path:
gcc app.c $(pkg-config --cflags --libs intarray) -o app
pkg-config --modversion intarray
pkg-config --atleast-version=2.1 intarray && echo "new enough"Two fields matter beyond the obvious. Requires: lists other .pc packages you depend on, so their flags propagate automatically. Libs.private: lists libraries needed only for static linking — omitting it is why some static links fail with undefined references to -lm or -lpthread.
5Deprecating
Removing a function immediately breaks every user. The sequence that does not:
- Add the replacement. Both work; nothing breaks.
- Mark the old one deprecated. Users get a compiler warning naming the replacement.
- Document it in the changelog with the version in which it will be removed.
- Wait at least one major cycle.
- Remove it in a major release, and say so prominently.
#if defined(__GNUC__) || defined(__clang__)
# define INTARRAY_DEPRECATED(msg) __attribute__((deprecated(msg)))
#elif defined(_MSC_VER)
# define INTARRAY_DEPRECATED(msg) __declspec(deprecated(msg))
#else
# define INTARRAY_DEPRECATED(msg)
#endif
INTARRAY_DEPRECATED("use intarray_create_ex() instead")
IntArray *intarray_create(size_t capacity);C23 standardizes this as [[deprecated("message")]].
When you must change behavior rather than a name, add a new entry point instead of altering the old one — intarray_create_ex alongside intarray_create. It is inelegant, and it is why *_ex and *2 suffixes are so common in long-lived C libraries. The alternative is breaking programs you cannot recompile.
6Worked example: shipping the week 29 library
The Makefile
NAME := intarray
MAJOR := 2
MINOR := 3
PATCH := 0
VERSION := $(MAJOR).$(MINOR).$(PATCH)
prefix ?= /usr/local
libdir ?= $(prefix)/lib
includedir ?= $(prefix)/include
pcdir ?= $(libdir)/pkgconfig
CC := gcc
CFLAGS := -std=c17 -Wall -Wextra -O2 -fPIC -fvisibility=hidden \
-DINTARRAY_BUILD
LDFLAGS :=
SONAME := lib$(NAME).so.$(MAJOR)
SOFILE := lib$(NAME).so.$(VERSION)
ARFILE := lib$(NAME).a
.PHONY: all clean install uninstall check abi-check
all: $(SOFILE) $(ARFILE) $(NAME).pc
$(NAME).o: $(NAME).c $(NAME).h
$(CC) $(CFLAGS) -c $< -o $@
$(SOFILE): $(NAME).o $(NAME).map
$(CC) -shared -Wl,-soname,$(SONAME) \
-Wl,--version-script=$(NAME).map \
-o $@ $< $(LDFLAGS)
ln -sf $(SOFILE) $(SONAME)
ln -sf $(SONAME) lib$(NAME).so
$(ARFILE): $(NAME).o
ar rcs $@ $<
$(NAME).pc: $(NAME).pc.in
sed -e 's|@prefix@|$(prefix)|g' \
-e 's|@libdir@|$(libdir)|g' \
-e 's|@includedir@|$(includedir)|g' \
-e 's|@VERSION@|$(VERSION)|g' $< > $@
install: all
install -d $(DESTDIR)$(includedir) $(DESTDIR)$(libdir) $(DESTDIR)$(pcdir)
install -m 644 $(NAME).h $(DESTDIR)$(includedir)/
install -m 755 $(SOFILE) $(DESTDIR)$(libdir)/
install -m 644 $(ARFILE) $(DESTDIR)$(libdir)/
ln -sf $(SOFILE) $(DESTDIR)$(libdir)/$(SONAME)
ln -sf $(SONAME) $(DESTDIR)$(libdir)/lib$(NAME).so
install -m 644 $(NAME).pc $(DESTDIR)$(pcdir)/
uninstall:
rm -f $(DESTDIR)$(includedir)/$(NAME).h
rm -f $(DESTDIR)$(libdir)/lib$(NAME).*
rm -f $(DESTDIR)$(pcdir)/$(NAME).pc
check: $(SOFILE)
@echo "-- exported symbols --"
@nm -D --defined-only $(SOFILE) | grep ' T ' || true
@echo "-- recorded soname --"
@objdump -p $(SOFILE) | grep SONAME
clean:
rm -f *.o *.a *.so *.so.* $(NAME).pcBuild and inspect
make
make check-- exported symbols --
0000000000001180 T intarray_count
0000000000001120 T intarray_create
0000000000001160 T intarray_destroy
0000000000001140 T intarray_push
00000000000011a0 T intarray_version_string
-- recorded soname --
SONAME libintarray.so.2Exactly five symbols. Before adding -fvisibility=hidden and the version script, run the same check: every internal helper appears, and each one you did not intend is a symbol someone can come to depend on.
Install into a staging root
make install DESTDIR=/tmp/stage prefix=/usr
find /tmp/stage -type f -o -type l | sort/tmp/stage/usr/include/intarray.h
/tmp/stage/usr/lib/libintarray.a
/tmp/stage/usr/lib/libintarray.so
/tmp/stage/usr/lib/libintarray.so.2
/tmp/stage/usr/lib/libintarray.so.2.3.0
/tmp/stage/usr/lib/pkgconfig/intarray.pcNothing outside the staging root, and both symlinks present. That is what a distribution packager needs; getting it wrong is the most common reason a library is hard to package.
Consume it from a separate project
mkdir -p /tmp/consumer && cd /tmp/consumer
cat > app.c <<'EOF'
#include <intarray.h>
#include <stdio.h>
int main(void)
{
printf("compiled against %d.%d.%d, running %s\n",
INTARRAY_VERSION_MAJOR, INTARRAY_VERSION_MINOR,
INTARRAY_VERSION_PATCH, intarray_version_string());
IntArray *a = intarray_create(4);
if (a == NULL) return 1;
for (int i = 0; i < 10; i++) intarray_push(a, i * i);
printf("count %zu\n", intarray_count(a));
intarray_destroy(a);
return 0;
}
EOF
export PKG_CONFIG_PATH=/tmp/stage/usr/lib/pkgconfig
pkg-config --cflags --libs intarray
gcc app.c $(pkg-config --cflags --libs intarray) -o app
LD_LIBRARY_PATH=/tmp/stage/usr/lib ./app
ldd app | grep intarrayThe consumer's compile line names no paths. It sees only intarray.h and the five functions; the structure definition is not available to it, which is exactly the freedom the opaque type bought.
Break the ABI on purpose
This is the experiment worth doing, because the failure mode is otherwise hard to believe.
Temporarily expose the struct in the header:
/* intarray.h — do NOT do this in a real library */
typedef struct IntArray {
int *data;
size_t count;
size_t capacity;
} IntArray;Rebuild, reinstall, and rebuild the consumer so it compiles against that layout. Now add a field in the middle, bump only PATCH, rebuild the library only, and run the old consumer binary:
typedef struct IntArray {
int *data;
size_t generation; /* new member, inserted */
size_t count;
size_t capacity;
} IntArray;make && make install DESTDIR=/tmp/stage prefix=/usr
cd /tmp/consumer && LD_LIBRARY_PATH=/tmp/stage/usr/lib ./appThe consumer reads count from the offset where generation now lives, and prints nonsense — or crashes. Nothing was recompiled, no warning appeared, and the version number claimed a patch release. Restore the opaque form and the same experiment changes nothing at all, because the consumer never knew the layout.
Check for breaks automatically
abidiff old/libintarray.so.2.3.0 new/libintarray.so.2.4.0abidiff, from libabigail, compares the debug information of two builds and reports every ABI-relevant difference. Running it in CI against the previous release is how projects with real compatibility promises keep them — it turns "we think this is safe" into a check.
7Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
| Exposing a struct in a public header | Layout frozen; every field change is a major version | Opaque handle plus accessors. |
No soname | Incompatible versions cannot coexist | -Wl,-soname,lib…so.MAJOR. |
Exporting every non-static symbol | Internals become part of the ABI | -fvisibility=hidden plus a version script. |
Ignoring DESTDIR | Cannot be packaged | Prefix every install path with it. |
Hard-coding /usr/local | Distributions cannot relocate it | Honour prefix. |
No pkg-config file | Users hard-code paths | Ship a .pc. |
Omitting Libs.private | Static linking fails with undefined references | List the private dependencies. |
| Removing a function without deprecating | Every user breaks at once | Deprecate, wait a cycle, remove in a major release. |
| Bumping PATCH for an ABI change | Installed programs corrupt memory | An ABI break is a MAJOR bump. |
8Check yourself
What is the difference between API and ABI compatibility?
API compatibility means existing source still compiles against the new version. ABI compatibility means an already-compiled binary still links and runs correctly against it. A change can preserve the first and break the second — adding a struct member visible in the header, for instance — and that combination is the dangerous one, because nothing warns.
Why does an opaque type let you add struct members freely?
Because the caller never sees the definition, so it cannot allocate the struct, cannot compute a member offset, and cannot embed the size in its compiled code. Everything goes through your functions, which are recompiled with the new layout. Expose the definition and all three of those become promises.
Why does the soname contain only the major version?
So that compatible updates are picked up automatically and incompatible ones are not. Every program linked against the library records the soname, so installing 2.4.0 over 2.3.0 serves existing binaries, while 3.0.0 has a different soname and installs alongside rather than replacing. That is how two incompatible versions coexist on one system.
Why hide symbols by default?
Because every exported symbol is something a user can come to depend on, and removing it later is an ABI break. With default visibility, internal helpers are exported whether you meant them to be or not. Hiding by default and marking the public interface explicitly keeps the promise set equal to the documented one.
Why must an install target honour DESTDIR?
Because packagers build into a temporary staging root and then archive it, rather than installing onto the build machine. An install that writes to the real prefix regardless cannot be packaged, and may overwrite files on a system it was never meant to touch.
9Where this leads
Week 46 calls this library from other languages. The extern "C" guard in the header is the first step; the rest is the platform ABI itself — calling conventions, how structures are passed, and who owns memory that crosses a language boundary.