Compatible vs Incompatible Library Changes

 

Chapter 41 – Part 2 of 3
Compatible vs Incompatible Library Changes
When can you bump the minor version? When must you create a new major version? — The ABI rules explained
3
Code Examples
10
Interview Q&A
~25
min read

Why Library Versioning Rules Matter

Shared libraries are used by many programs simultaneously. When you update a library, you must decide: does this change break existing programs or not? Getting this wrong leads to DLL Hell (on Windows) or broken systems on Linux.

Linux solves this with a strict versioning convention backed by the soname mechanism. The key question every library maintainer must answer: is my change ABI-compatible or ABI-incompatible?

ABI (Application Binary Interface) is the low-level contract between a shared library and the programs that use it — function signatures, data structures, calling conventions, symbol names.

Key Terms in This Tutorial
ABI API compatible change incompatible change major version minor version function signature struct layout padding fields symbol removal

1. Understanding Version Numbers

A shared library real name follows this format:

lib demo .so. 1 . 0 . 1
Library name Major
version
Minor
version
Patch
level
Major Version (1)

Changes when the library has ABI-breaking changes. Old programs compiled against major version 1 will NOT work with major version 2. They must be recompiled.

Minor Version (0)

Changes for backward-compatible updates — bug fixes, performance improvements, new functions. Old programs still work without recompiling.

Patch Level (1)

Minor bug fixes within the same minor version. Not all libraries use this third component.

2. The Three Conditions for Compatibility

A library change is compatible (minor version bump only) if and only if all three of these conditions are met:

1
Function & Variable Semantics Unchanged

Every public function must keep the same argument types, same return type, and same behavior. Performance improvements and bug fixes that don’t change the observable contract are fine. The function must still do what its documentation says.

2
No Public Symbols Removed

You must not remove any function or variable from the public API. It is, however, compatible to add new public functions and variables. Adding does not break existing programs since they don’t know about (and don’t call) the new additions.

3
Public Data Structures Unchanged

Structures allocated by or returned from library functions must not change layout. If a struct changes size or field offsets, programs compiled against the old struct will read/write wrong memory. Exception: adding fields at the end of a struct can sometimes be safe, but is risky if callers allocate arrays of the struct.

If ANY of the three conditions is violated → new major version required. You must create a new soname (e.g., libdemo.so.2) so that old programs continue to find and use the old version, while new programs can be written to use the new version.

3. Compatible vs Incompatible — Quick Reference

✔ Compatible Changes (Minor Bump)
  • Fix a bug (same contract, better behavior)
  • Improve performance of an existing function
  • Add a new function to the public API
  • Add a new global variable
  • Add padding fields at end of a struct (with care)
  • Change internal (private) implementation
  • Add internal helper functions not in header
✘ Incompatible Changes (New Major Version)
  • Change function argument types or count
  • Change function return type
  • Remove a public function or variable
  • Change the size of a public struct
  • Change the order of fields in a struct
  • Change semantics of an existing function
  • Change calling convention

Code Example 1 — A Compatible Change (Minor Version Bump)

In version 1.0.1 of libdemo, add_numbers takes two ints. In 1.0.2 we fix a bug and add a new function multiply_numbers. This is compatible — no existing caller breaks.

/* ===== libdemo version 1.0.1 ===== */
/* mod1.c */
int add_numbers(int a, int b) {
    return a + b;   /* original */
}

void hello_from_mod1(void) {
    printf("libdemo v1.0.1\n");
}
/* ===== libdemo version 1.0.2 — COMPATIBLE CHANGE ===== */
/* mod1.c */
#include <stdio.h>

/* Same function, same signature — bug fix inside */
int add_numbers(int a, int b) {
    /* Fixed: previously had off-by-one in some edge case */
    return a + b;
}

/* NEW function added — does NOT break existing callers */
int multiply_numbers(int a, int b) {
    return a * b;
}

void hello_from_mod1(void) {
    printf("libdemo v1.0.2 (bug fixed)\n");
}
/* demo.h — updated public header for v1.0.2 */
#ifndef DEMO_H
#define DEMO_H

/* Existing functions — unchanged */
int  add_numbers(int a, int b);
void hello_from_mod1(void);
void hello_from_mod2(void);

/* New function — safely added */
int  multiply_numbers(int a, int b);

#endif
# Build version 1.0.2 — SAME soname (libdemo.so.1) because compatible
gcc -g -c -fPIC -Wall mod1.c mod2.c

gcc -g -shared -Wl,-soname,libdemo.so.1 \
    -o libdemo.so.1.0.2 \
    mod1.o mod2.o

# Install and run ldconfig
sudo mv libdemo.so.1.0.2 /usr/local/lib/
sudo ldconfig -v | grep libdemo
# libdemo.so.1 -> libdemo.so.1.0.2   (changed — soname now points to 1.0.2)

# Old programs still work — they load libdemo.so.1 -> libdemo.so.1.0.2
# They call add_numbers(), which still exists with same signature → OK
# They never call multiply_numbers() — they don't know it exists → no problem

Code Example 2 — An Incompatible Change (New Major Version)

In version 2.0.0, we change add_numbers to take a third parameter and remove hello_from_mod2. Old programs that call these functions will break — so we need a new major version.

/* ===== libdemo version 2.0.0 — INCOMPATIBLE CHANGE ===== */
/* mod1.c */
#include <stdio.h>

/* CHANGED: third parameter added — breaks all old callers! */
int add_numbers(int a, int b, int c) {
    return a + b + c;
}

/* CHANGED: return type changed from void to int — breaks old callers! */
int hello_from_mod1(void) {
    printf("libdemo v2.0.0\n");
    return 0;
}

/* hello_from_mod2 REMOVED — old programs calling it will crash at link time */
/* demo.h — v2 header: INCOMPATIBLE with v1 */
#ifndef DEMO_H
#define DEMO_H

/* Changed signature */
int add_numbers(int a, int b, int c);

/* Changed return type */
int hello_from_mod1(void);

/* hello_from_mod2 removed */

#endif
# Build version 2.0.0 — NEW soname (libdemo.so.2) — DIFFERENT from v1!
gcc -g -c -fPIC -Wall mod1.c

# NOTE: soname is now libdemo.so.2
gcc -g -shared -Wl,-soname,libdemo.so.2 \
    -o libdemo.so.2.0.0 \
    mod1.o

# Install
sudo mv libdemo.so.2.0.0 /usr/local/lib/
sudo ldconfig -v | grep libdemo
# libdemo.so.1 -> libdemo.so.1.0.2   (v1 still present, untouched)
# libdemo.so.2 -> libdemo.so.2.0.0   (new v2 soname created)

# Create linker name pointing to v2
sudo ln -sf libdemo.so.2 /usr/local/lib/libdemo.so

# -------------------------------------------------------
# Old program compiled against v1:
ldd old_program
# libdemo.so.1 => /usr/local/lib/libdemo.so.1.0.2  ← still gets v1! WORKS.

# New program compiled against v2:
gcc -o new_program new_main.c -ldemo -L/usr/local/lib
ldd new_program
# libdemo.so.2 => /usr/local/lib/libdemo.so.2.0.0  ← gets v2. WORKS.

Code Example 3 — The Struct Padding Technique

Library designers sometimes reserve space in exported structs for future use, allowing fields to be added without breaking the ABI. This is a common technique for stable library ABIs.

/* ===== demo.h — v1.0.x with reserved padding ===== */
#ifndef DEMO_H
#define DEMO_H

/* 
 * The struct has 'reserved' fields to allow future additions
 * WITHOUT changing the struct's total size.
 * This is the "padding for future use" technique.
 */
typedef struct {
    int  id;            /* offset 0 */
    int  value;         /* offset 4 */
    char name[64];      /* offset 8 */
    int  flags;         /* offset 72 */

    /* Reserved fields — currently unused, set to 0 */
    int  _reserved1;   /* offset 76 — for future use */
    int  _reserved2;   /* offset 80 — for future use */
    void *_reserved3;  /* offset 84 — for future use */
} DemoRecord;

DemoRecord *demo_create(int id, int value, const char *name);
void        demo_destroy(DemoRecord *rec);
void        demo_print(const DemoRecord *rec);

#endif
/* mod_record.c — implementation */
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include "demo.h"

DemoRecord *demo_create(int id, int value, const char *name) {
    DemoRecord *rec = calloc(1, sizeof(DemoRecord)); /* calloc zeros all reserved fields */
    if (!rec) return NULL;
    rec->id    = id;
    rec->value = value;
    strncpy(rec->name, name, sizeof(rec->name) - 1);
    return rec;
}

void demo_destroy(DemoRecord *rec) {
    free(rec);
}

void demo_print(const DemoRecord *rec) {
    printf("id=%d value=%d name=%s flags=%d\n",
           rec->id, rec->value, rec->name, rec->flags);
}
/* ===== Later: v1.1.0 — USE one reserved field, NO ABI break ===== */
/* New demo.h: _reserved1 is now 'priority', struct SIZE is same */

typedef struct {
    int  id;
    int  value;
    char name[64];
    int  flags;

    /* _reserved1 is now officially 'priority' */
    int  priority;     /* offset 76 — newly defined field */
    int  _reserved2;
    void *_reserved3;
} DemoRecord;

/* Add new function that uses priority */
void demo_set_priority(DemoRecord *rec, int pri);
/* 
 * Why is this still compatible?
 *
 * Old programs allocate DemoRecord — size is still the same (reserved space holds it).
 * Old programs never set 'priority' — it stays 0 (calloc initializes to 0).
 * Old programs call demo_create/demo_destroy/demo_print — all still exist, same signature.
 * Only NEW programs can use demo_set_priority — old programs simply don't call it.
 *
 * Result: minor version bump to 1.1.0, same soname libdemo.so.1 — COMPATIBLE.
 */

# Build v1.1.0 — still soname libdemo.so.1
gcc -g -shared -Wl,-soname,libdemo.so.1 \
    -o libdemo.so.1.1.0 \
    mod_record.o

sudo mv libdemo.so.1.1.0 /usr/local/lib/
sudo ldconfig -v | grep libdemo
# libdemo.so.1 -> libdemo.so.1.1.0   (changed, still v1 soname)
Real-world example: The Linux stat struct has historically used padding fields. glibc’s struct timespec reserved space for nanosecond timestamps long before they were officially supported, allowing a smooth transition without breaking ABI.

4. Version Decision Guide

Use this decision table when making a library change:

Change You Are Making Compatible? Version Action
Fix a bug without changing function signature ✔ Yes Bump minor (1.0.1 → 1.0.2)
Add a new exported function ✔ Yes Bump minor (1.0.1 → 1.1.0)
Change a function’s parameter types ✘ No New major (libdemo.so.2)
Remove a public function ✘ No New major (libdemo.so.2)
Add fields to middle of a public struct ✘ No New major (libdemo.so.2)
Add padding fields at end of struct (carefully) ⚠ Maybe Minor bump if callers don’t allocate arrays of struct
Improve performance, no behavior change ✔ Yes Bump minor
Change function return type ✘ No New major (libdemo.so.2)

Interview Questions & Answers

Q1. What is ABI compatibility in the context of shared libraries?
ABI (Application Binary Interface) compatibility means that a new version of a library can be used by programs compiled against an older version without recompiling those programs. The binary contract includes function signatures (argument types, return types, calling convention), data structure layouts (field offsets, struct sizes), and the set of exported symbols. ABI compatibility is stronger than API compatibility — the code can compile against the old header (API), but must also run correctly against the new binary (ABI).
Q2. A library has version 1.2.3. What does each number mean?
1 is the major version — changes when the ABI breaks and old programs can no longer use the library without recompiling. 2 is the minor version — changes for backward-compatible additions (new functions, bug fixes). 3 is the patch level — for micro bug-fixes within the same minor release. The soname embeds only the major version (e.g., libXYZ.so.1), so programs compiled against any 1.x.y version find and use the latest 1.x.y without recompiling.
Q3. Can you add a new function to a library without breaking ABI? Why?
Yes. Adding a new function is always ABI-compatible. Existing programs compiled against the old library don’t call the new function — they don’t even know it exists. At run time, the dynamic linker resolves only the symbols the program actually uses. The new symbol simply sits in the library’s symbol table, ignored by old programs. New programs that know about the new function can be compiled against the updated header and will call it normally.
Q4. Why is changing a struct’s field layout an ABI-breaking change?
When a program is compiled, the compiler generates code that accesses struct fields by their byte offset. For example, rec->value becomes “read 4 bytes from address (rec + 4)”. If the library changes the struct layout in a new version (shifting fields), the offsets change. Old programs still use the old offsets and will read from the wrong memory location. This causes silent data corruption or crashes — without any compile-time error. This is why struct layout changes always require a new major version.
Q5. What is the “padding for future use” technique and when is it useful?
Library designers sometimes add extra fields (named _reserved1, _reserved2, etc.) to exported structs beyond what is immediately needed. These fields are always initialized to 0. In a future version, the library can “use” a reserved field by officially naming and documenting it, without changing the struct’s total size or field offsets. This allows adding new struct fields without bumping the major version. However, this only works if callers do not allocate arrays of the struct — if they do, and the struct size ever changes despite the reservation, it would still break.
Q6. A library function has a bug and we fix it, but the fix slightly changes the output in some edge cases. Is this a compatible change?
Technically yes, if the original behavior was a bug (i.e., the old behavior was never part of the documented contract). A bug fix that brings the implementation closer to the specification is considered a compatible change — only the minor version is bumped. However, if programs were written to depend on the buggy behavior, they may break. This is a judgment call: strictly speaking, fixing a documented behavior IS incompatible even if unintentional. The safest approach is to document the fix clearly in the changelog and bump the minor version.
Q7. What happens at runtime if a program requires libdemo.so.1 but only libdemo.so.2 is installed?
The program will fail to start with a dynamic linking error: error while loading shared libraries: libdemo.so.1: cannot open shared object file: No such file or directory. The dynamic linker looks for the soname libdemo.so.1 in the cache and library paths. Since it doesn’t exist (only libdemo.so.2 is installed), the load fails. The program cannot use a different major version — this is exactly the protection the major version mechanism provides.
Q8. What is the difference between API and ABI?
API (Application Programming Interface) is the source-level contract — the function prototypes, struct definitions, and macros in header files. If the API is compatible, code will compile without changes. ABI (Application Binary Interface) is the binary-level contract — the actual machine-code calling convention, symbol names, struct field offsets, and type sizes. ABI compatibility means programs compiled against an old version work correctly with a new binary version. ABI compatibility is stricter: a change can be API-compatible (compiles fine) but ABI-incompatible (crashes at runtime due to struct size change).
Q9. Can removing a deprecated function from a library be a compatible change?
No. Removing any public function, even a deprecated one, is an ABI-breaking change and requires a new major version. The reason: the deprecation is only a documentation/API-level notice. Programs that were compiled against the old version still have the symbol name recorded in their ELF dynamic section as a required import. When the dynamic linker loads the program, it must find and resolve that symbol. If the symbol doesn’t exist in the new library, the load fails with an undefined symbol error.
Q10. How does Linux handle two major versions of the same library installed simultaneously?
Linux handles this gracefully through the soname mechanism. Both libdemo.so.1.x.y and libdemo.so.2.x.y can coexist in the same directory. ldconfig creates two separate soname symlinks: libdemo.so.1 and libdemo.so.2. Programs compiled against v1 have libdemo.so.1 recorded in their ELF — they always load from the v1 chain. Programs compiled against v2 have libdemo.so.2 recorded — they load from the v2 chain. The two versions are completely independent at runtime, and both can be active in memory simultaneously (in different processes, or even the same process if it deliberately links both).

Continue to Part 3

Learn how to perform live upgrades of shared libraries while programs are running.

Part 3: Upgrading Shared Libraries → ← Part 1: ldconfig

Leave a Reply

Your email address will not be published. Required fields are marked *