Procedural Programming with C · Professional · Week 45

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.

By the end of this week you can
  • Design a public header that does not constrain your implementation.
  • Distinguish API from ABI compatibility and classify a change correctly.
  • Set a soname and control which symbols are exported.
  • Install to the conventional layout and supply a pkg-config file.
  • 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 */
RuleWhy
Prefix every public nameC has no namespaces; intarray_ is the substitute
Opaque types wherever possibleThe layout stays yours — week 28
Include only what the header itself needsPulling in <stdio.h> imposes it on every user
No static or inline definitionsThey become part of the ABI once a caller compiles them in
Version macros and a runtime accessorThe macro says what you compiled against; the function says what is loaded
extern "C" guardCosts 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

APIABI
Compatible meansExisting source still compilesExisting binaries still run
Broken byRemoving a function, changing a signatureAnything that changes layout, size, or calling convention
DetectedAt compile time, loudlyAt run time, as corruption

ABI is the harder discipline because the breakages are invisible in the header's text.

ChangeAPIABI
Add a new functionsafesafe
Add a member to an opaque structsafesafe
Add a member to a struct in the headersafeBREAKS — size changes
Reorder members of a visible structsafeBREAKS — offsets change
Insert an enumerator in the middlesafeBREAKS — values shift
Change a parameter from int to longusually safeBREAKS
Add a parameterBREAKSBREAKS
Change int to size_t in a returned structsafeBREAKS
Rename a functionBREAKSBREAKS
Make a function staticBREAKSBREAKS

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 link

Three names, three jobs:

NameUsed by
libintarray.soThe linker at build time, via -lintarray
libintarray.so.2The dynamic loader at run time — this is the soname
libintarray.so.2.3.0The 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 toWhat
$(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:

  1. Add the replacement. Both work; nothing breaks.
  2. Mark the old one deprecated. Users get a compiler warning naming the replacement.
  3. Document it in the changelog with the version in which it will be removed.
  4. Wait at least one major cycle.
  5. 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).pc

Build 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.2

Exactly 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.pc

Nothing 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 intarray

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

The 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.0

abidiff, 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

MistakeWhat happensFix
Exposing a struct in a public headerLayout frozen; every field change is a major versionOpaque handle plus accessors.
No sonameIncompatible versions cannot coexist-Wl,-soname,lib…so.MAJOR.
Exporting every non-static symbolInternals become part of the ABI-fvisibility=hidden plus a version script.
Ignoring DESTDIRCannot be packagedPrefix every install path with it.
Hard-coding /usr/localDistributions cannot relocate itHonour prefix.
No pkg-config fileUsers hard-code pathsShip a .pc.
Omitting Libs.privateStatic linking fails with undefined referencesList the private dependencies.
Removing a function without deprecatingEvery user breaks at onceDeprecate, wait a cycle, remove in a major release.
Bumping PATCH for an ABI changeInstalled programs corrupt memoryAn 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.