04 — Portability and ABI

The first C program you wrote runs on your laptop. The C programs you will maintain in a production job run on Linux x86_64, Linux ARM64 (AWS Graviton, Ampere), macOS ARM64, Windows x64, plus occasionally FreeBSD, plus embedded targets. The difference between “it works” and “it ships” is portability discipline: compiler abstractions, feature detection, and a hard understanding of the C ABI — because once your library has users, changing your ABI silently breaks their binaries and there is no CI matrix in the world that will catch it.

This file covers three things: (1) what the C ABI actually is and how it breaks, (2) how curl/SQLite/Valkey handle portability, and (3) symbol versioning so old binaries keep working when your library grows.

What “the C ABI” is (and isn’t)

The C API is what a header says: function signatures, struct layouts, enum values. The C ABI is what a compiled binary encodes: the exact byte layout of a struct in memory, the register/stack calling convention, the size of long, the alignment of double. Two libraries with identical APIs can have incompatible ABIs, and the linker will not warn you — you get a silent memory corruption at runtime.

A C ABI is defined by a combination of five things. Change any one of them without a version bump and you break your users.

Component

Determined by

Example break

Type sizes

Platform (SysV AMD64, Windows x64, AAPCS64)

long is 8 bytes on Linux x64, 4 on Windows x64

Struct layout

Compiler + #pragma pack + field order

Adding a field in the middle of a struct

Calling convention

Platform ABI

SysV vs. Microsoft x64 (different registers)

Name mangling

Language (C: none; C++: everything)

extern "C" for the seam

Exception model

Compiler (SEH vs. DWARF vs. SjLj)

Almost never crosses a C boundary

The SysV AMD64 ABI (Linux, macOS Intel, FreeBSD) passes the first six integer args in rdi, rsi, rdx, rcx, r8, r9. The Microsoft x64 ABI passes the first four in rcx, rdx, r8, r9. These are not interchangeable. This is why every serious C library ships different binaries per platform.

The five ways a C library breaks its ABI (and how to avoid each)

These are the actual mistakes maintainers make. Every one has burned a real library at least once.

  1. Adding a field in the middle of a public struct. Old callers indexing the struct read the wrong offset. Fix: append fields only; document struct sizes.

  2. Changing a #define constant’s value. Old binaries have the old value inlined. Fix: keep old constants forever; add new ones.

  3. Changing an enum’s numeric value. Same as above. Fix: never reorder enums; append.

  4. Changing a function’s signature. Old callers push wrong args. Fix: rename to foo_v2() and keep foo() as a wrapper.

  5. Removing a public function. Old binaries can’t link. Fix: never remove; deprecate loudly and keep for a decade.

One tell that a project takes ABI seriously: they publish an abi-check CI job using abi-compliance-checker or libabigail. Valkey, glibc, GTK, and libgit2 all do this.

Symbol versioning — how glibc keeps 25-year-old binaries running

When glibc changed memcpy in 2010, flash-player binaries compiled against the old memcpy still ran. This magic is symbol versioning — the same symbol name can exist multiple times in one .so, each tagged with a version. Old binaries link to <function_name_1>@GLIBC_2.2.5; new binaries link to <function_name_2>@GLIBC_2.14.

A minimal version script (libmylib.map):

LIBMYLIB_1.0 {
  global:
    mylib_init;
    mylib_call;
  local:
    *;
};

LIBMYLIB_2.0 {
  global:
    mylib_call;   # new signature
} LIBMYLIB_1.0;

Build with gcc -shared -Wl,--version-script=libmylib.map libmylib.c -o libmylib.so. Now callers built against v1 keep calling the old mylib_call, callers built against v2 get the new one, and both live in the same binary. This is how you evolve an API without abandoning users.

Reality check: most projects skip this entirely and bump the SONAME instead (libfoo.so.1libfoo.so.2). That is fine if your users tolerate rebuilds; it is not fine for glibc, OpenSSL, or anything shipped in a distro.

Semantic versioning for C libraries

SemVer (major.minor.patch) means something specific for C:

Bump

Means

Examples

Patch (1.2.3 → 1.2.4)

Bug fix, no header or ABI change

curl 8.7.1 → 8.7.2

Minor (1.2 → 1.3)

New public functions, no removals, no ABI break

Add mylib_new_call()

Major (1.x → 2.0)

ABI break: removed/changed exported symbol

glibc 2 → 3 (would be earthshaking)

Most mature C libraries have not bumped major in decades (curl went 7.x from 2000 to 2019; SQLite has been 3.x since 2004). That is not slowness — that is discipline.

Portability in practice — what curl does

curl compiles on 100+ OS/arch combos. The recipe:

  1. configure (autoconf) or cmake probes: does this compiler have stdatomic.h? Does this OS have pipe2()? Does this arch have __builtin_bswap32? Every answer becomes a #define HAVE_* in curl_config.h.

  2. Guarded includes. #ifdef HAVE_UNISTD_H #include <unistd.h> #endif. Never assume a header exists.

  3. Type abstraction. curl_off_t instead of off_t (which is 32 or 64 bits depending on _FILE_OFFSET_BITS). Every serious library rolls its own portable integer types.

  4. Fallback implementations. If the platform lacks strlcpy, curl ships Curl_strlcpy. Small cost, permanent portability win.

  5. CI matrix. curl runs 100+ CI jobs per PR across compilers and OSes.

Go read lib/curl_setup.h and lib/curl_config.h.cmake — an hour there teaches more portability than any book.

Feature detection > version detection

A common junior mistake is #if __GLIBC__ >= 2 && __GLIBC_MINOR__ >= 34. This is fragile. Better is #if HAVE_STRLCPY — let the build system probe. autoconf, CMake check_symbol_exists, and Meson’s has_function all do this. The rule: detect features, not versions.

What breaks when structs change (worked example)

Suppose your v1 header is:

struct point { int x; int y; };

User compiles against v1; sizeof(struct point) baked into their binary as 8 bytes. You ship v2:

struct point { int x; int y; int z; };

sizeof is now 12. The user’s binary calls mylib_area(struct point p) — the caller pushes 8 bytes, the callee reads 12, the extra 4 bytes are stack garbage. Silent memory corruption; no linker error. The fix is either:

  • Make struct point opaque: user only sees typedef struct point point_t; and never allocates it directly. Getters and setters everywhere. This is how SQLite handles sqlite3*, sqlite3_stmt*. Zero ABI risk.

  • Version the struct: struct point_v2 alongside struct point. New callers use v2; old callers keep the old one.

Opaque handles are the boring, correct answer. They are why SQLite has been ABI-stable since 2004.

Reading list for this file

  • Ulrich Drepper, “How to Write Shared Libraries” — the canonical paper (~80 pages).

  • “System V Application Binary Interface, AMD64 Architecture Processor Supplement” — the actual spec. Sections 3.2 (data types) and 3.5 (calling convention) are the essential reading.

  • https://curl.se/docs/CIPHERS.html and lib/curl_config.h.cmake — what a portable C project looks like.

  • abi-compliance-checker docs — how to run an ABI diff.

What most people get wrong about this

They think portability means “my code compiles with -Wall -Wextra clean.” It doesn’t. Portability means your binary keeps working when someone else’s binary compiled against you changes. That is a very different property. Test it by installing your library, compiling a caller, then upgrading your library and not rebuilding the caller. If the caller still runs, you are ABI-stable. If it segfaults, you weren’t. This test costs 20 minutes and catches the mistake that has burned every young C library in history.

The second thing they get wrong: they optimize for the new caller. Every design decision should optimize for the five-year-old caller, because that is who will find your regression.


Return to README.md · Next: 05_contributing_to_oss_c.md