12. Low-Level and Advanced Topics
This chapter is about the places where C stops being a portable abstraction and starts being a description of a machine: individual bits, the exact rules the standard leaves undefined, the binary interface your functions actually obey, and the hardware registers an embedded program writes to directly. It is also where the most dangerous C lives, because the optimizer assumes you never break the rules and rewrites your program accordingly. Understanding what the standard guarantees — and what it deliberately does not — is what separates code that survives a compiler upgrade from code that does not.
<a id="121-bit-manipulation"></a>
12.1 Bit Manipulation
Below the level of integers and structs, memory is just bits, and a large amount of real C exists to set, clear, count, and rearrange them. This section covers the standard idioms, the traps that come from C's integer promotion rules, and how to move structured data in and out of a byte stream.
Masking, Setting, and Clearing Bits
Theory
Sometimes a variable is not a number but a collection of independent yes/no facts. A file's permissions, a CPU's status register, a set of enabled features, a hardware peripheral's configuration — each is a group of single-bit answers packed into one integer. Manipulating them means operating on individual bits rather than on the value as a whole.
C gives six operators for this, and each has one job:
| Operator | Name | Effect on each bit pair |
|---|---|---|
& | AND | 1 only if both are 1 |
| | OR | 1 if either is 1 |
^ | XOR | 1 if they differ |
~ | NOT | flips every bit |
<< | left shift | moves bits toward the high end, filling with 0 |
>> | right shift | moves bits toward the low end |
From these, four idioms cover almost everything. The key tool is a mask: a value with 1s exactly where you want to act. 1u << n builds a mask for bit n, since shifting a single 1 left by n places puts it in position n.
| Goal | Idiom | Why it works |
|---|---|---|
Set bit n | x |= (1u << n) | OR with 1 forces a bit to 1; OR with 0 leaves it alone |
Clear bit n | x &= ~(1u << n) | ~mask is 0 at n and 1 elsewhere; AND with 0 forces 0 |
Toggle bit n | x ^= (1u << n) | XOR with 1 flips; XOR with 0 preserves |
Test bit n | (x >> n) & 1u | shift the bit down to position 0, then isolate it |
Notice that each idiom leaves every other bit untouched — that is the entire point of a mask.
Three hazards deserve real attention, because they produce bugs that look like magic.
Shifting by too much is undefined behavior. Shifting an int by 32 or more is not "zero"; it is UB, and on x86 the hardware actually shifts by count % 32, so x << 32 returns x unchanged. There is no diagnostic.
Prefer unsigned types. Right-shifting a negative signed value is implementation-defined: it may shift in zeros or copies of the sign bit. And left-shifting into the sign bit is undefined. Bit manipulation belongs on unsigned types, which have fully defined wrapping and shifting behavior.
Integer promotion silently widens your operands. Any type narrower than int is promoted to int before an operation. So for uint8_t x, the expression ~x produces an int with 24 extra 1 bits at the top. Assigning it back to a uint8_t truncates and hides the problem — but comparing it, or shifting it, does not. When working with narrow types, cast back explicitly.
Operator precedence is a further trap: &, |, and ^ bind looser than ==, so if (flags & MASK == 0) parses as flags & (MASK == 0), which is almost never what you meant. Parenthesize bitwise expressions.
Examples
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h>
/* Print the bits of a value, most significant first. */
static void print_bits(const char *label, uint32_t v, int width)
{
printf("%-14s", label);
for (int i = width - 1; i >= 0; i--) {
putchar((v >> i) & 1u ? '1' : '0');
if (i % 4 == 0 && i) putchar(' '); /* group into nibbles */
}
printf(" (0x%0*X)\n", width / 4, v);
}
/* The four core idioms, as named helpers. */
static uint32_t bit_set (uint32_t x, int n) { return x | (1u << n); }
static uint32_t bit_clear (uint32_t x, int n) { return x & ~(1u << n); }
static uint32_t bit_toggle(uint32_t x, int n) { return x ^ (1u << n); }
static int bit_test (uint32_t x, int n) { return (x >> n) & 1u; }
int main(void)
{
uint32_t x = 0;
print_bits("start:", x, 16);
x = bit_set(x, 3); print_bits("set bit 3:", x, 16);
x = bit_set(x, 7); print_bits("set bit 7:", x, 16);
x = bit_toggle(x, 3); print_bits("toggle bit 3:", x, 16);
x = bit_set(x, 12); print_bits("set bit 12:", x, 16);
x = bit_clear(x, 7); print_bits("clear bit 7:", x, 16);
printf("bit 12 is %s, bit 7 is %s\n",
bit_test(x, 12) ? "set" : "clear",
bit_test(x, 7) ? "set" : "clear");
/* --- multi-bit masks: extracting a FIELD ------------------------- */
uint32_t packed = 0xABCD1234u;
/* Extract bits 8..15: shift the field down, then mask off the rest.
0xFF is eight 1s, so it keeps exactly eight bits. */
uint32_t byte1 = (packed >> 8) & 0xFFu;
printf("\nbyte 1 of 0x%08X = 0x%02X\n", packed, byte1);
/* Replace that field without disturbing anything else:
clear it first, then OR the new value in. */
uint32_t new_field = 0x99u;
packed = (packed & ~(0xFFu << 8)) | (new_field << 8);
printf("after replacing: 0x%08X\n", packed);
/* --- the promotion trap ------------------------------------------- */
uint8_t small = 0x0Fu;
/* ~small promotes to int FIRST, giving 0xFFFFFFF0, not 0xF0. */
printf("\n~small as int: 0x%08X\n", (unsigned)~small);
printf("~small cast back: 0x%02X\n", (uint8_t)~small);
/* This is why a comparison can surprise you: */
printf("(~small == 0xF0) is %s -- because ~small is 0xFFFFFFF0\n",
(~small == 0xF0) ? "true" : "FALSE");
/* --- precedence: bitwise binds LOOSER than comparison ------------- */
uint32_t flags = 0x04u, MASK = 0x04u;
/* if (flags & MASK == 0) would parse as flags & (MASK == 0)
which is flags & 0 == 0 -- always false. Parenthesize. */
if ((flags & MASK) != 0) printf("\nmask bit is set (correctly parenthesized)\n");
printf("flags & MASK == 0 evaluates to %u <- the wrong parse\n",
flags & (MASK == 0));
/* --- shift limits: UB, not zero ----------------------------------- */
printf("\nshifting a 32-bit value by >= 32 is UNDEFINED BEHAVIOR.\n");
printf("On x86 the hardware masks the count, so x << 32 == x.\n");
return 0;
}
HOW EACH IDIOM WORKS ON ONE BYTE
start 0 0 1 0 1 1 0 0 0x2C
SET bit 4: mask = 1u << 4 = 0001 0000
0010 1100
OR 0001 0000
---------
0011 1100 OR with 1 forces 1
CLEAR bit 3: mask = ~(1u << 3) = 1111 0111
0011 1100
AND 1111 0111
---------
0011 0100 AND with 0 forces 0
TOGGLE bit 2: mask = 1u << 2 = 0000 0100
0011 0100
XOR 0000 0100
---------
0011 0000 XOR with 1 flips
TEST bit 5: (x >> 5) & 1 -> shift it to position 0, isolate it
Key Takeaways
- Build a mask with
1u << n, then set with|=, clear with&= ~, toggle with^=, and test with(x >> n) & 1— each leaves every other bit untouched. - Do bit manipulation on
unsignedtypes: signed right shift is implementation-defined and shifting into the sign bit is undefined. - Shifting by the width of the type or more is undefined behavior, not zero — on x86 the count is silently masked, hiding the bug.
- Types narrower than
intare promoted before the operation, so~xon auint8_tyields anintwith high bits set; cast back explicitly. - Bitwise operators bind looser than comparisons, so
flags & MASK == 0parses wrongly — always parenthesize.
🧪 Practice
- Write
set_bits(uint32_t x, int lo, int hi)that sets every bit in the inclusive range and leaves the rest alone.- Implement
extract_field(uint32_t x, int offset, int width)andinsert_field(uint32_t x, int offset, int width, uint32_t value), and verify they round-trip.- Swap two integers using only XOR, then explain why the trick breaks when both arguments alias the same variable.
- Interview-style: "What does
x & (x - 1)compute, and what is it used for?" Hint: write out the binary of a value and its predecessor, and look at the lowest set bit.
Bit Counting and Scanning Idioms
Theory
Beyond setting individual bits, a family of classic operations asks questions about a word's bits: how many are set, which is the lowest set bit, is this a power of two, what is the next power of two. These appear constantly in allocators, hash tables, schedulers, compression, and cryptography, and each has a well-known idiom worth recognizing on sight.
The foundational trick is x & (x - 1) clears the lowest set bit. Subtracting 1 flips the lowest set bit to 0 and turns every zero below it into 1; ANDing with the original keeps only the bits above:
x = 0101 1000
x - 1 = 0101 0111 lowest set bit flipped, zeros below became 1s
x & (x-1) = 0101 0000 the lowest set bit is gone
From this single identity, several results follow immediately:
| Idiom | Computes |
|---|---|
x & (x - 1) | x with its lowest set bit cleared |
x & -x | only the lowest set bit (everything else cleared) |
x & (x - 1)) == 0 | x is zero or a power of two |
loop x &= x - 1 | population count — iterations equal the number of set bits |
The population-count loop (Kernighan's algorithm) runs once per set bit rather than once per bit, so counting the bits of a sparse word is fast.
x & -x deserves a note. On two's-complement machines, -x is ~x + 1, which makes every bit above the lowest set bit the complement of x's — so ANDing isolates exactly that bit. It relies on unsigned arithmetic or two's complement, which C23 now mandates and every real machine has always used.
Checking for a power of two is x && !(x & (x - 1)) — the x && part matters, because zero has no set bits and would otherwise pass. Powers of two matter constantly: hash table sizes, alignment, ring buffer capacities, and page sizes are all powers of two so that % n becomes & (n - 1).
Modern practice is to use the compiler's builtins rather than hand-rolling these. GCC and Clang provide:
| Builtin | Returns | Note |
|---|---|---|
__builtin_popcount(x) | number of set bits | one instruction on modern CPUs |
__builtin_clz(x) | count of leading zeros | undefined for x == 0 |
__builtin_ctz(x) | count of trailing zeros | undefined for x == 0 |
__builtin_ffs(x) | index of lowest set bit, 1-based | 0 when x is 0 |
They compile to single instructions (POPCNT, LZCNT, TZCNT) where the hardware supports it. The clz/ctz undefined-at-zero caveat is real and catches people — always guard the zero case.
__builtin_clz also gives floor(log2(x)) as 31 - __builtin_clz(x), which is how you compute an integer logarithm or round up to the next power of two without a loop.
C23 finally standardizes all of this in <stdbit.h> (stdc_count_ones, stdc_leading_zeros, and so on), which will eventually make the builtins unnecessary.
Examples
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>
/* --- Kernighan's population count: one iteration per SET bit ---------- */
static int popcount_loop(uint32_t x)
{
int count = 0;
while (x) {
x &= x - 1; /* clear the lowest set bit */
count++; /* so the loop runs exactly popcount times */
}
return count;
}
/* --- the naive version, for contrast: always 32 iterations ------------ */
static int popcount_naive(uint32_t x)
{
int count = 0;
for (int i = 0; i < 32; i++) count += (x >> i) & 1u;
return count;
}
/* --- power of two: zero must be excluded explicitly ------------------- */
static bool is_power_of_two(uint32_t x)
{
return x != 0 && (x & (x - 1)) == 0; /* x != 0 matters: 0 & -1 == 0 */
}
/* --- round up to the next power of two (bit-smearing) ----------------- */
static uint32_t next_power_of_two(uint32_t x)
{
if (x == 0) return 1;
x--; /* so an exact power of two stays itself */
x |= x >> 1; /* smear the highest set bit downward... */
x |= x >> 2;
x |= x >> 4;
x |= x >> 8;
x |= x >> 16; /* ...until every bit below it is 1 */
return x + 1; /* one more gives the next power of two */
}
/* --- integer log2, via leading zeros ---------------------------------- */
static int floor_log2(uint32_t x)
{
if (x == 0) return -1; /* clz is UNDEFINED at zero */
return 31 - __builtin_clz(x);
}
int main(void)
{
uint32_t values[] = { 0, 1, 7, 8, 255, 256, 0xF0F0F0F0u, 0x80000000u };
printf("%-12s %-6s %-6s %-8s %-6s %-6s\n",
"value", "pop", "pow2", "next2", "clz", "ctz");
for (size_t i = 0; i < sizeof values / sizeof *values; i++) {
uint32_t v = values[i];
printf("0x%08X %-6d %-6s %-8u ",
v, popcount_loop(v),
is_power_of_two(v) ? "yes" : "no",
next_power_of_two(v));
/* clz and ctz are UNDEFINED for zero -- guard every call. */
if (v == 0) printf("%-6s %-6s\n", "UB", "UB");
else printf("%-6d %-6d\n", __builtin_clz(v), __builtin_ctz(v));
}
/* Cross-check the hand-written version against the builtin. */
printf("\nverifying popcount against __builtin_popcount:\n");
bool ok = true;
for (uint32_t v = 0; v < 100000u; v++)
if (popcount_loop(v) != __builtin_popcount(v) ||
popcount_naive(v) != __builtin_popcount(v)) { ok = false; break; }
printf(" %s\n", ok ? "all agree" : "MISMATCH");
/* --- isolating the lowest set bit --------------------------------- */
uint32_t x = 0b01011000u;
printf("\nx = 0x%02X\n", x);
printf("x & (x - 1) = 0x%02X (lowest set bit CLEARED)\n", x & (x - 1));
printf("x & -x = 0x%02X (ONLY the lowest set bit)\n", x & (uint32_t)(-x));
printf("ffs(x) = %d (1-based index, 0 if x is 0)\n",
__builtin_ffs((int)x));
/* --- why powers of two matter: modulo becomes masking -------------- */
uint32_t cap = 16; /* a power of two */
printf("\n37 %% 16 = %u\n", 37u % cap);
printf("37 & (16-1) = %u (identical, and far cheaper)\n", 37u & (cap - 1));
printf("floor_log2(1024) = %d\n", floor_log2(1024));
return 0;
}
WHY x & (x - 1) CLEARS THE LOWEST SET BIT
x = 0 1 0 1 1 0 0 0
^ lowest set bit
x - 1 = 0 1 0 1 0 1 1 1
^ it flipped to 0, and every 0 below became 1
AND = 0 1 0 1 0 0 0 0
^ gone; every higher bit survived
COUNTING WITH IT
0101 1000 -> 0101 0000 -> 0100 0000 -> 0000 0000
(1) (2) (3) loop ends
three iterations = three set bits
WHY x & -x ISOLATES IT (two's complement)
x = 0 1 0 1 1 0 0 0
-x = 1 0 1 0 1 0 0 0 (~x + 1)
AND = 0 0 0 0 1 0 0 0 only the lowest set bit survives
Key Takeaways
x & (x - 1)clears the lowest set bit andx & -xisolates it; nearly every counting and scanning idiom is built from these two.- Kernighan's loop
while (x) { x &= x - 1; count++; }counts set bits in as many iterations as there are set bits, not the full word width. - Test for a power of two with
x != 0 && (x & (x - 1)) == 0— omitting the zero check wrongly accepts zero. - Prefer
__builtin_popcount,__builtin_clz, and__builtin_ctz, which compile to single instructions, but rememberclzandctzare undefined for zero. - Powers of two turn
% ninto& (n - 1), which is why hash tables, ring buffers, and allocators size themselves that way.
🧪 Practice
- Implement
popcountthree ways — naive loop, Kernighan, and the builtin — and benchmark them on ten million random values.- Write
reverse_bits(uint32_t x)that reverses the bit order, and verify that applying it twice restores the original.- Implement
next_power_of_twofor 64-bit values and confirm the behavior at 0, 1, and exact powers of two.- Interview-style: "How would you check whether two integers have opposite signs without using comparison operators?" Hint: consider what XOR does to the sign bit.
Flags and Bit Sets
Theory
A single bit stores a boolean, so an unsigned int can store 32 of them. That is the idea behind two closely related techniques: flag words, where each bit is a named option, and bit sets, where each bit records membership of an element in a set.
Flags are how C APIs express "any combination of these options" in one argument. open(path, O_RDONLY | O_CREAT | O_CLOEXEC) passes three independent choices in a single int. The convention is fixed and worth following exactly:
- Each flag is a distinct power of two, so no two overlap:
1, 2, 4, 8, ..., usually written1u << 0,1u << 1, and so on. - Combine with
|, test with&, remove with&= ~. - Reserve
0for "no flags", so a plain0argument is meaningful.
Writing the values as shifts rather than as literals matters — 1u << 5 is obviously bit 5, while 32 requires the reader to count. A common mistake is defining a flag as a non-power-of-two (say 3), which silently overlaps two others.
Compared with a struct of bool fields, a flag word is far more compact (32 options in 4 bytes rather than 32), can be passed and returned by value trivially, and supports set operations — union, intersection, difference — as single instructions. What it gives up is type safety and readability: nothing stops you passing a file-mode flag where a color flag is expected, and a debugger shows 0x2C rather than named fields.
Bit sets apply the same idea to arbitrary-sized sets. To track membership of n elements, allocate n / bits_per_word words and let bit i mean "element i is present". Element i lives in word i / 64 at bit position i % 64 — and since 64 is a power of two, those become i >> 6 and i & 63.
The payoff is dramatic: a set of one million elements costs 125 KB as a bit set versus 1 MB as an array of bool (which is one byte each) or 8 MB as an array of pointers. And because operations work a whole word at a time, intersecting two million-element sets is about 15,000 AND instructions rather than a million comparisons. This is why bit sets underpin sieve algorithms, database index intersection, and garbage collector mark phases.
The cost is that a bit set only works for elements that map to small dense integers. A set of arbitrary strings needs a hash table.
Examples
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
/* ===================== flag words ===================== */
/* Each flag is a DISTINCT power of two. Writing them as shifts makes the
bit position obvious and makes overlaps impossible to introduce. */
typedef enum {
PERM_NONE = 0, /* 0 means "no flags" -- always reserve it */
PERM_READ = 1u << 0, /* 0x01 */
PERM_WRITE = 1u << 1, /* 0x02 */
PERM_EXEC = 1u << 2, /* 0x04 */
PERM_DELETE = 1u << 3, /* 0x08 */
PERM_ADMIN = 1u << 4, /* 0x10 */
/* Composites are fine, as long as the PRIMITIVES stay powers of two. */
PERM_RW = PERM_READ | PERM_WRITE,
PERM_ALL = PERM_READ | PERM_WRITE | PERM_EXEC | PERM_DELETE | PERM_ADMIN
} Permission;
static void describe(const char *label, unsigned flags)
{
printf("%-12s 0x%02X ", label, flags);
if (flags == PERM_NONE) { printf("(none)\n"); return; }
if (flags & PERM_READ) printf("read ");
if (flags & PERM_WRITE) printf("write ");
if (flags & PERM_EXEC) printf("exec ");
if (flags & PERM_DELETE) printf("delete ");
if (flags & PERM_ADMIN) printf("admin ");
putchar('\n');
}
/* ===================== bit sets ===================== */
#define BITS_PER_WORD 64
typedef struct {
uint64_t *words;
size_t nbits;
size_t nwords;
} BitSet;
static bool bitset_init(BitSet *s, size_t nbits)
{
/* Round UP so a partial final word is still allocated. */
s->nwords = (nbits + BITS_PER_WORD - 1) / BITS_PER_WORD;
s->words = calloc(s->nwords, sizeof *s->words); /* calloc: all clear */
s->nbits = nbits;
return s->words != NULL;
}
static void bitset_free(BitSet *s) { free(s->words); s->words = NULL; }
/* Element i lives in word i/64 at bit i%64. Both are powers of two, so
the compiler turns them into a shift and a mask. */
static void bitset_add(BitSet *s, size_t i)
{
if (i < s->nbits) s->words[i / BITS_PER_WORD] |= 1ULL << (i % BITS_PER_WORD);
}
static void bitset_remove(BitSet *s, size_t i)
{
if (i < s->nbits) s->words[i / BITS_PER_WORD] &= ~(1ULL << (i % BITS_PER_WORD));
}
static bool bitset_contains(const BitSet *s, size_t i)
{
return i < s->nbits &&
(s->words[i / BITS_PER_WORD] >> (i % BITS_PER_WORD)) & 1ULL;
}
/* Set operations act on a whole WORD at a time -- 64 elements per instruction. */
static void bitset_intersect(BitSet *dst, const BitSet *other)
{
size_t n = dst->nwords < other->nwords ? dst->nwords : other->nwords;
for (size_t i = 0; i < n; i++) dst->words[i] &= other->words[i];
for (size_t i = n; i < dst->nwords; i++) dst->words[i] = 0;
}
static void bitset_union(BitSet *dst, const BitSet *other)
{
size_t n = dst->nwords < other->nwords ? dst->nwords : other->nwords;
for (size_t i = 0; i < n; i++) dst->words[i] |= other->words[i];
}
static size_t bitset_count(const BitSet *s)
{
size_t total = 0;
for (size_t i = 0; i < s->nwords; i++)
total += (size_t)__builtin_popcountll(s->words[i]); /* per word */
return total;
}
int main(void)
{
/* ---------------- flags ---------------- */
unsigned perms = PERM_READ | PERM_WRITE; /* combine with OR */
describe("initial:", perms);
perms |= PERM_EXEC; /* add a flag */
describe("+exec:", perms);
perms &= ~PERM_WRITE; /* remove a flag */
describe("-write:", perms);
perms ^= PERM_ADMIN; /* toggle a flag */
describe("^admin:", perms);
/* Testing several at once: & gives the intersection. */
printf("has read? %s\n", (perms & PERM_READ) ? "yes" : "no");
printf("has ALL of RW? %s\n",
(perms & PERM_RW) == PERM_RW ? "yes" : "no"); /* note: == mask */
printf("has ANY of RW? %s\n", (perms & PERM_RW) ? "yes" : "no");
/* ---------------- bit set: the Sieve of Eratosthenes ------------- */
const size_t N = 1000;
BitSet composite;
if (!bitset_init(&composite, N + 1)) return 1;
for (size_t i = 2; i * i <= N; i++)
if (!bitset_contains(&composite, i))
for (size_t j = i * i; j <= N; j += i)
bitset_add(&composite, j); /* mark multiples */
size_t primes = 0;
for (size_t i = 2; i <= N; i++) if (!bitset_contains(&composite, i)) primes++;
printf("\nprimes below %zu: %zu\n", N, primes); /* 168 */
printf("memory: %zu bytes as a bit set vs %zu as bool[]\n",
composite.nwords * sizeof(uint64_t), N + 1);
/* ---------------- set algebra ---------------- */
BitSet a, b;
bitset_init(&a, 256);
bitset_init(&b, 256);
for (size_t i = 0; i < 256; i += 2) bitset_add(&a, i); /* evens */
for (size_t i = 0; i < 256; i += 3) bitset_add(&b, i); /* multiples of 3 */
printf("\n|A| = %zu, |B| = %zu\n", bitset_count(&a), bitset_count(&b));
BitSet both;
bitset_init(&both, 256);
memcpy(both.words, a.words, a.nwords * sizeof *a.words);
bitset_intersect(&both, &b); /* multiples of 6 */
printf("|A and B| = %zu (multiples of 6)\n", bitset_count(&both));
bitset_union(&a, &b);
printf("|A or B| = %zu\n", bitset_count(&a));
bitset_free(&a); bitset_free(&b); bitset_free(&both);
bitset_free(&composite);
return 0;
}
| Flag word | Struct of bool | Bit set | |
|---|---|---|---|
| Storage for 32 options | 4 bytes | 32 bytes | 4 bytes |
| Type safety | none | full | none |
| Debugger readability | a hex number | named fields | a hex dump |
| Combine / test | | and & | field access | | and & |
| Set algebra | one instruction | loop | one instruction per 64 elements |
| Suits | API options | configuration structs | dense integer sets |
Key Takeaways
- Define each flag as a distinct power of two written as
1u << n; combine with|, test with&, and clear with&= ~, reserving0for "none". - Testing for all of several flags is
(x & MASK) == MASK, while testing for any is(x & MASK) != 0— the two are easy to confuse. - A bit set stores element
iin wordi / 64at biti % 64, using 1 bit per element instead of a byte or a pointer. - Set operations on bit sets process 64 elements per instruction, which is why sieves, index intersection, and GC mark phases use them.
- Flag words trade type safety and debuggability for compactness and cheap set algebra; a struct of
boolis clearer when neither matters.
🧪 Practice
- Define a flag enum for a text editor (bold, italic, underline, strikethrough), and write functions to add, remove, and describe a combination.
- Add
bitset_differenceandbitset_is_subsetto theBitSetimplementation and test them on known sets.- Implement the Sieve of Eratosthenes for 10 million with both a
boolarray and a bit set, comparing memory use and runtime.- Interview-style: "When would you choose a bit set over a hash set?" Hint: think about what a bit set requires of the element type, and what it gets in return.
Endianness
Theory
A byte has one unambiguous value. A four-byte integer does not — it depends on which order the four bytes are stored in, and machines disagree.
Big-endian stores the most significant byte at the lowest address, the way we write numbers. Little-endian stores the least significant byte first. Neither is more correct; x86 and most ARM are little-endian, while network protocols and some older architectures are big-endian.
The 32-bit value 0x12345678 stored at address 100
BIG-ENDIAN LITTLE-ENDIAN
addr: 100 101 102 103 addr: 100 101 102 103
byte: 12 34 56 78 byte: 78 56 34 12
^ most significant first ^ least significant first
Within one program on one machine, this is completely invisible: write 0x12345678, read it back, get 0x12345678. Endianness only becomes observable when you look at the bytes rather than the value — which happens in exactly four situations:
- Network transmission (covered in the previous chapter — network byte order is big-endian).
- Binary file formats read or written on different machines.
- Casting a pointer to a different type to inspect the representation.
- Shared memory or device registers between components of differing endianness.
The practical rule that keeps you safe is simple: arithmetic is always portable, byte access never is. x >> 8 extracts the same bits on every machine, because shifting is defined on the value. Reading ((unsigned char *)&x)[1] gives different results depending on endianness, because it reads the representation.
That distinction gives the correct way to write portable serialization: use shifts and masks, not memcpy of the whole value. Building a byte array with buf[0] = (x >> 24) & 0xFF; produces the same bytes on every machine, because it never touches the in-memory layout.
Little-endian has one genuinely useful property: the address of a multi-byte value is also the address of its least significant byte, so a pointer to a uint32_t can be read as a uint8_t to get the low byte. Big-endian has the property that a byte-wise memory dump reads in the natural order, which makes hex dumps easier to interpret.
Detecting endianness at runtime is a three-line function: store a known value, look at its first byte. C23 finally standardizes compile-time detection with __STDC_ENDIAN_NATIVE__ in <stdbit.h>; before that, compilers offer __BYTE_ORDER__ and __ORDER_LITTLE_ENDIAN__.
There is a third form, mixed or middle-endian, which some historical architectures used for 32-bit values. It is extinct in practice but explains why careful code checks rather than assumes.
Examples
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdbool.h>
/* --- runtime detection: store a known value, inspect its first byte --- */
static bool is_little_endian(void)
{
uint32_t probe = 0x01020304u;
unsigned char first = *(const unsigned char *)&probe; /* LOWEST address */
return first == 0x04; /* LSB first */
}
/* --- compile-time detection, where the compiler offers it ------------- */
static const char *compile_time_order(void)
{
#if defined(__BYTE_ORDER__) && defined(__ORDER_LITTLE_ENDIAN__)
# if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
return "little-endian (known at compile time)";
# elif __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__
return "big-endian (known at compile time)";
# else
return "unusual byte order";
# endif
#else
return "unknown at compile time";
#endif
}
static void dump(const char *label, const void *p, size_t n)
{
const unsigned char *b = p;
printf("%-26s", label);
for (size_t i = 0; i < n; i++) printf("%02X ", b[i]);
putchar('\n');
}
/* --- PORTABLE serialization: shifts operate on the VALUE, not the bytes -- */
static void store_be32(unsigned char *out, uint32_t v)
{
out[0] = (unsigned char)(v >> 24); /* most significant first */
out[1] = (unsigned char)(v >> 16);
out[2] = (unsigned char)(v >> 8);
out[3] = (unsigned char)(v );
/* Identical output on every machine: no in-memory layout was read. */
}
static uint32_t load_be32(const unsigned char *in)
{
return ((uint32_t)in[0] << 24) | ((uint32_t)in[1] << 16)
| ((uint32_t)in[2] << 8) | (uint32_t)in[3];
}
static void store_le32(unsigned char *out, uint32_t v)
{
out[0] = (unsigned char)(v ); /* least significant first */
out[1] = (unsigned char)(v >> 8);
out[2] = (unsigned char)(v >> 16);
out[3] = (unsigned char)(v >> 24);
}
static uint32_t load_le32(const unsigned char *in)
{
return (uint32_t)in[0] | ((uint32_t)in[1] << 8)
| ((uint32_t)in[2] << 16) | ((uint32_t)in[3] << 24);
}
/* --- byte swapping, when you must convert in place -------------------- */
static uint32_t bswap32(uint32_t v)
{
return ((v & 0x000000FFu) << 24) | ((v & 0x0000FF00u) << 8)
| ((v & 0x00FF0000u) >> 8) | ((v & 0xFF000000u) >> 24);
}
int main(void)
{
printf("runtime: %s\n", is_little_endian() ? "little-endian" : "big-endian");
printf("compile time: %s\n\n", compile_time_order());
uint32_t value = 0x12345678u;
/* The VALUE is the same everywhere; the REPRESENTATION is not. */
printf("value = 0x%08X\n", value);
dump("in-memory bytes:", &value, sizeof value);
/* Arithmetic is portable: these produce the same result on any machine. */
printf("\nPORTABLE (arithmetic on the value):\n");
printf(" (value >> 24) & 0xFF = 0x%02X <- always the MSB\n",
(value >> 24) & 0xFFu);
printf(" value & 0xFF = 0x%02X <- always the LSB\n",
value & 0xFFu);
/* Byte access is NOT portable: this depends on the machine. */
printf("\nNON-PORTABLE (reading the representation):\n");
printf(" ((unsigned char*)&value)[0] = 0x%02X <- differs by endianness\n",
((const unsigned char *)&value)[0]);
/* --- serialization round trip ------------------------------------- */
unsigned char be[4], le[4];
store_be32(be, value);
store_le32(le, value);
printf("\n");
dump("serialized big-endian:", be, 4);
dump("serialized little-endian:", le, 4);
printf("load_be32 -> 0x%08X load_le32 -> 0x%08X (both exact)\n",
load_be32(be), load_le32(le));
/* --- byte swapping ------------------------------------------------ */
printf("\nbswap32(0x%08X) = 0x%08X\n", value, bswap32(value));
printf("__builtin_bswap32 = 0x%08X (one instruction)\n",
__builtin_bswap32(value));
/* --- the classic union trick: legal in C, unlike in C++ ------------ */
union { uint32_t u; unsigned char b[4]; } probe = { .u = 0x0A0B0C0Du };
printf("\nunion view of 0x0A0B0C0D: ");
for (int i = 0; i < 4; i++) printf("%02X ", probe.b[i]);
printf("\n(reading a different union member is defined in C -- "
"it is type punning, and C explicitly allows it)\n");
return 0;
}
| Operation | Portable? | Why |
|---|---|---|
x >> 8, x & 0xFF | yes | defined on the value |
x * 2, x + 1 | yes | arithmetic |
((unsigned char *)&x)[0] | no | reads the representation |
memcpy(buf, &x, 4) then transmit | no | copies the layout |
buf[0] = x >> 24; ... | yes | builds bytes from the value |
fwrite(&struct, sizeof, 1, f) | no | layout plus padding |
Key Takeaways
- Endianness is the order of bytes within a multi-byte value; it is invisible until you inspect the representation rather than the value.
- Arithmetic and shifts are portable because they operate on the value; casting to
unsigned char *ormemcpying the object reads the layout and is not. - Serialize with explicit shifts and masks so the byte order is chosen by your code, not by the machine.
- Detect endianness at runtime by inspecting the first byte of a known value, or at compile time with
__BYTE_ORDER__; C23 adds<stdbit.h>. - Use
__builtin_bswap32/64for in-place conversion, and remember that reading a different union member is defined type punning in C.
🧪 Practice
- Write a hex-dump function and use it to display the bytes of a
uint16_t,uint32_t, anddoubleon your machine.- Implement
store_be64/load_be64and verify a round trip for0x0123456789ABCDEF.- Write a binary file with an explicit little-endian header, then read it back with code that works regardless of the host's endianness.
- Interview-style: "Why does
x >> 8work identically on every machine while((char*)&x)[1]does not?" Hint: one operates on the number, the other on how the number is stored.
Packing and Unpacking Binary Data
Theory
Sooner or later a C program must read or write a binary format: a network packet, a file header, a device protocol, a saved game. The format specifies exactly which bytes mean what, and your job is to convert between that byte layout and your program's structures.
The tempting approach is to define a matching struct and fwrite it. This is wrong, and it is wrong for three independent reasons, any one of which is fatal:
- Padding. The compiler inserts unnamed bytes between members so each is properly aligned.
struct { char c; int i; }is typically 8 bytes, not 5, and the 3 padding bytes hold garbage. Your file now contains uninitialized memory — a correctness bug and an information leak. - Endianness. The multi-byte members are written in the host's byte order, so the file is unreadable on a machine with the opposite order.
- Type sizes.
longis 8 bytes on Linux and 4 on Windows;intcould in principle be 2. The layout changes with the platform.
Add that #pragma pack — which removes the padding — is non-standard, may generate slower code, and on some architectures makes unaligned access crash. It solves only the first of the three problems.
The correct approach is explicit serialization: write and read one field at a time, with a fixed-width type and a chosen byte order for each. It is more code, and it is code that behaves identically on every platform, forever.
The discipline has a few components:
- Use fixed-width types from
<stdint.h>—uint16_t,int32_t— neverintorlong, in any structure describing a wire or file format. - Choose a byte order and apply it explicitly with shifts, exactly as in the previous topic.
- Track an offset as you write and read, so the two directions cannot drift apart.
- Validate everything on input. A length field from an untrusted source can claim four gigabytes; a magic number can be wrong; a version can be one you do not support. Checking these is the difference between a parser and a vulnerability.
Bit fields are worth mentioning and mostly avoiding. C lets you declare unsigned flags : 3; inside a struct, which looks perfect for packed formats. But the standard leaves the allocation order, the straddling of storage units, and the signedness of plain int bit fields all implementation-defined. They are fine for internal memory savings and unusable for a defined external format — use explicit shifts and masks instead.
For reading, the safest pattern is a small set of load_be16/load_be32 helpers plus bounds checks, and for writing the mirror set. Once those exist, a parser for even a complex format is short and obviously correct.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <stdbool.h>
#include <stddef.h> /* offsetof */
/* =====================================================================
Why writing a struct directly does not work.
===================================================================== */
struct Naive {
uint8_t type; /* offset 0 */
/* 3 bytes of PADDING here */
uint32_t length; /* offset 4 (must be 4-aligned) */
uint16_t flags; /* offset 8 */
/* 2 more bytes of padding */
}; /* sizeof == 12, not 7 */
/* =====================================================================
Explicit serialization helpers. Written once, used everywhere.
===================================================================== */
static void put_u8 (unsigned char *b, size_t *off, uint8_t v)
{
b[(*off)++] = v;
}
static void put_be16(unsigned char *b, size_t *off, uint16_t v)
{
b[(*off)++] = (unsigned char)(v >> 8); /* shifts: portable */
b[(*off)++] = (unsigned char)(v );
}
static void put_be32(unsigned char *b, size_t *off, uint32_t v)
{
b[(*off)++] = (unsigned char)(v >> 24);
b[(*off)++] = (unsigned char)(v >> 16);
b[(*off)++] = (unsigned char)(v >> 8);
b[(*off)++] = (unsigned char)(v );
}
/* Every read is bounds-checked against the buffer size. */
static bool get_u8(const unsigned char *b, size_t len, size_t *off, uint8_t *out)
{
if (*off + 1 > len) return false;
*out = b[(*off)++];
return true;
}
static bool get_be16(const unsigned char *b, size_t len, size_t *off, uint16_t *out)
{
if (*off + 2 > len) return false;
*out = (uint16_t)((uint32_t)b[*off] << 8 | b[*off + 1]);
*off += 2;
return true;
}
static bool get_be32(const unsigned char *b, size_t len, size_t *off, uint32_t *out)
{
if (*off + 4 > len) return false;
*out = (uint32_t)b[*off] << 24 | (uint32_t)b[*off + 1] << 16
| (uint32_t)b[*off + 2] << 8 | (uint32_t)b[*off + 3];
*off += 4;
return true;
}
/* =====================================================================
A concrete format:
magic 4 bytes "PKT1"
version 1 byte
type 1 byte
flags 2 bytes big-endian
length 4 bytes big-endian, payload size
payload 'length' bytes
Total header: 12 bytes, with NO padding and NO host dependence.
===================================================================== */
#define HEADER_SIZE 12
#define MAGIC "PKT1"
#define VERSION 1
#define MAX_PAYLOAD 4096
typedef struct { /* the IN-MEMORY form; layout is irrelevant */
uint8_t version, type;
uint16_t flags;
uint32_t length;
unsigned char payload[MAX_PAYLOAD];
} Packet;
static size_t pack(const Packet *p, unsigned char *out, size_t cap)
{
if (p->length > MAX_PAYLOAD) return 0;
if (cap < HEADER_SIZE + p->length) return 0;
size_t off = 0;
memcpy(out + off, MAGIC, 4); off += 4; /* bytes: no conversion */
put_u8 (out, &off, p->version);
put_u8 (out, &off, p->type);
put_be16(out, &off, p->flags);
put_be32(out, &off, p->length);
memcpy(out + off, p->payload, p->length);
off += p->length;
return off; /* total bytes written */
}
static bool unpack(const unsigned char *in, size_t len, Packet *p)
{
size_t off = 0;
/* 1. Magic: refuse anything that is not our format. */
if (len < 4 || memcmp(in, MAGIC, 4) != 0) {
fprintf(stderr, "bad magic\n"); return false;
}
off = 4;
/* 2. Every field is bounds-checked as it is read. */
if (!get_u8 (in, len, &off, &p->version) ||
!get_u8 (in, len, &off, &p->type) ||
!get_be16(in, len, &off, &p->flags) ||
!get_be32(in, len, &off, &p->length)) {
fprintf(stderr, "truncated header\n"); return false;
}
/* 3. Validate the version BEFORE trusting anything that follows. */
if (p->version != VERSION) {
fprintf(stderr, "unsupported version %u\n", p->version); return false;
}
/* 4. NEVER trust a length from the wire. This single check is the
difference between a parser and a remote buffer overflow. */
if (p->length > MAX_PAYLOAD) {
fprintf(stderr, "declared length %u exceeds maximum\n", p->length);
return false;
}
if (off + p->length > len) {
fprintf(stderr, "declared length %u exceeds the %zu bytes available\n",
p->length, len - off);
return false;
}
memcpy(p->payload, in + off, p->length);
return true;
}
static void dump(const char *label, const unsigned char *b, size_t n)
{
printf("%s (%zu bytes)\n ", label, n);
for (size_t i = 0; i < n; i++) {
printf("%02X ", b[i]);
if ((i + 1) % 12 == 0 && i + 1 < n) printf("\n ");
}
putchar('\n');
}
int main(void)
{
/* Show why the naive approach fails. */
printf("struct Naive: 7 bytes of data, sizeof == %zu\n", sizeof(struct Naive));
printf(" offsets: type=0 length=%zu flags=%zu <- gaps are PADDING\n\n",
offsetof(struct Naive, length), offsetof(struct Naive, flags));
/* Build and serialize a packet. */
Packet out = { .version = VERSION, .type = 7, .flags = 0x00FF };
const char *body = "hello, binary world";
out.length = (uint32_t)strlen(body);
memcpy(out.payload, body, out.length);
unsigned char wire[512];
size_t n = pack(&out, wire, sizeof wire);
if (n == 0) { fprintf(stderr, "pack failed\n"); return 1; }
dump("serialized packet", wire, n);
printf(" header is exactly %d bytes on EVERY platform\n\n", HEADER_SIZE);
/* Round trip. */
Packet in;
if (unpack(wire, n, &in)) {
printf("unpacked: version=%u type=%u flags=0x%04X length=%u\n",
in.version, in.type, in.flags, in.length);
printf("payload: \"%.*s\"\n\n", (int)in.length, in.payload);
}
/* --- hostile input: each check earns its place ------------------- */
printf("rejecting malformed input:\n");
unsigned char bad[HEADER_SIZE];
memcpy(bad, wire, HEADER_SIZE);
bad[4] = 99; /* wrong version */
printf(" version 99: "); unpack(bad, sizeof bad, &in);
memcpy(bad, wire, HEADER_SIZE);
bad[8] = 0xFF; bad[9] = 0xFF; /* enormous length */
bad[10] = 0xFF; bad[11] = 0xFF;
printf(" length 4 GB: "); unpack(bad, sizeof bad, &in);
printf(" truncated input: "); unpack(wire, 6, &in);
memcpy(bad, wire, HEADER_SIZE); bad[0] = 'X';
printf(" wrong magic: "); unpack(bad, sizeof bad, &in);
return 0;
}
WHY fwrite(&struct, ...) IS NOT A FILE FORMAT
struct { uint8_t type; uint32_t length; uint16_t flags; };
IN MEMORY (x86-64) WHAT THE FORMAT SPECIFIES
+----+----+----+----+ +----+
|type| ?? | ?? | ?? | <- padding |type|
+----+----+----+----+ +----+----+----+----+
| length (LE) | <- host order | length (big-end)|
+----+----+----+----+ +----+----+----+----+
| flags | ?? | ?? | <- padding | flags |
+----+----+----+----+ +----+----+
12 bytes, 5 of them garbage, 7 bytes, fully specified,
byte order depends on the CPU identical everywhere
THE THREE INDEPENDENT PROBLEMS
padding -> uninitialized bytes in your file (and an info leak)
endianness -> unreadable on the other kind of machine
type sizes -> 'long' is 8 bytes here, 4 bytes there
Key Takeaways
- Never
fwritea struct as a binary format: padding writes uninitialized bytes, the byte order follows the host, and type sizes vary by platform. - Serialize field by field using fixed-width
<stdint.h>types and explicit shifts, tracking an offset so writing and reading stay in step. #pragma packis non-standard, can slow access or fault on strict-alignment CPUs, and fixes only the padding problem.- Bit fields have implementation-defined ordering and straddling, so they are unusable for an externally defined format.
- Validate every field from an untrusted source — magic, version, and especially any length — before allocating or copying based on it.
🧪 Practice
sizeofandoffsetoffor a struct mixingchar,int, anddouble, and account for every padding byte.- Extend the packet format with a 64-bit timestamp field, updating
pack,unpack, and the header size consistently.- Write a parser for the first 20 bytes of a PNG file (8-byte signature, then a big-endian length and chunk type) and print the values.
- Interview-style: "A colleague writes structs to a file with
fwriteand reads them back withfread. It works on their machine. What will break?" Hint: name three independent problems, not just one.
<a id="122-undefined-and-unspecified-behavior"></a>
12.2 Undefined and Unspecified Behavior
C's speed comes from a bargain: the standard declines to define what happens in certain situations, and the compiler is free to assume those situations never arise. This section covers the categories of unspecified behavior, the constructs that trigger them, and — most importantly — how modern optimizers turn a small violation into code that behaves nothing like what you wrote.
Categories of Non-Portable Behavior
Theory
Not every question the C standard leaves open is equally dangerous. The standard defines four distinct categories, and confusing them leads people to either panic about harmless things or shrug at genuinely fatal ones.
Implementation-defined behavior. The standard requires the implementation to choose a behavior and document it. The result varies between compilers but is knowable and stable for yours.
Examples: the size of int, whether plain char is signed, the result of right-shifting a negative number, how a value is converted when it does not fit the target signed type.
This is safe to rely on if you accept the portability limit and document it. Code that assumes int is 32 bits is fine for a project that only targets 32- and 64-bit platforms — as long as someone wrote that down.
Unspecified behavior. The standard offers two or more possibilities and requires no choice, no documentation, and no consistency. The implementation may do something different each time.
Examples: the order in which function arguments are evaluated, the order of evaluation of subexpressions, whether identical string literals share storage, the amount of padding in a struct.
This is more dangerous than implementation-defined, because testing cannot reveal it — your compiler may evaluate arguments left to right today and right to left at -O2 tomorrow.
Undefined behavior (UB). The standard imposes no requirements whatsoever. The program may crash, produce wrong results, appear to work, or — crucially — cause the compiler to generate code that makes no sense at all, including in parts of the program far from the violation.
Examples: signed integer overflow, dereferencing NULL, reading uninitialized memory, out-of-bounds array access, use after free, violating strict aliasing, shifting by more than the type's width, modifying an object twice without an intervening sequence point.
The critical insight is that UB is not "an unpredictable result" — it is a licence the compiler uses for optimization. Since the standard says a correct program never has UB, the compiler assumes UB does not occur and optimizes on that basis. A null check after a dereference can be deleted, because the dereference proves the pointer was not null. This is covered in depth later in this section.
Locale-specific behavior. Behavior that depends on the current locale, such as which characters isalpha accepts or the decimal separator printf produces.
| Category | Standard requires | Documented | Consistent | Risk |
|---|---|---|---|---|
| Implementation-defined | a documented choice | yes | yes | portability only |
| Unspecified | one of several options | no | no | silent behavior change |
| Undefined | nothing at all | no | no | anything, anywhere |
| Locale-specific | depends on locale | partly | per locale | surprising input handling |
The practical response differs by category. Implementation-defined behavior calls for a documented assumption and ideally a static_assert. Unspecified behavior calls for rewriting the expression so the order stops mattering. Undefined behavior calls for eliminating it entirely — there is no safe way to rely on it.
Examples
#include <stdio.h>
#include <limits.h>
#include <float.h>
#include <string.h>
#include <assert.h>
static int trace(const char *name, int value)
{
printf(" evaluating %s\n", name);
return value;
}
int main(void)
{
/* ================= IMPLEMENTATION-DEFINED ==================
Documented by your compiler; varies between platforms. */
printf("IMPLEMENTATION-DEFINED (documented, stable for this compiler)\n");
printf(" sizeof(int) = %zu bytes\n", sizeof(int));
printf(" sizeof(long) = %zu bytes (8 on Linux, 4 on Windows)\n", sizeof(long));
printf(" plain char is %s\n", CHAR_MIN < 0 ? "SIGNED" : "unsigned");
/* Right-shifting a negative value: arithmetic (sign-extending) or
logical? The standard permits either; every mainstream compiler
chooses arithmetic, but the standard does not require it. */
int negative = -16;
printf(" -16 >> 2 = %d (arithmetic shift here)\n", negative >> 2);
/* Guard the assumptions you rely on, so a new platform fails to BUILD
rather than failing at run time. */
static_assert(sizeof(int) >= 4, "this code assumes a 32-bit int");
static_assert(CHAR_BIT == 8, "this code assumes 8-bit bytes");
/* ==================== UNSPECIFIED ==========================
Several options allowed, no documentation, no consistency. */
printf("\nUNSPECIFIED (may differ between compilers or optimization levels)\n");
/* Argument evaluation order. GCC on x86-64 typically evaluates
right-to-left; other compilers go left-to-right. Both conform. */
printf(" argument evaluation order:\n");
int sum = trace("first", 1) + trace("second", 2);
printf(" sum = %d (the VALUE is well defined; the ORDER is not)\n", sum);
/* Whether identical string literals share storage. */
const char *a = "shared?";
const char *b = "shared?";
printf(" identical literals share storage: %s (unspecified)\n",
a == b ? "yes" : "no");
/* Struct padding amount and placement. */
struct S { char c; int i; };
printf(" sizeof(struct{char;int;}) = %zu (padding amount is unspecified)\n",
sizeof(struct S));
/* ==================== UNDEFINED ============================
No requirements at all. These are shown as DESCRIPTIONS,
deliberately not executed. */
printf("\nUNDEFINED (the compiler assumes these never happen)\n");
printf(" signed overflow: INT_MAX + 1\n");
printf(" null dereference: *(int *)0\n");
printf(" out of bounds: arr[10] on int arr[10]\n");
printf(" use after free: *p after free(p)\n");
printf(" uninitialized read: int x; return x;\n");
printf(" oversized shift: 1 << 32 on a 32-bit int\n");
printf(" double modification: i = i++;\n");
/* Demonstrating the boundary safely: check BEFORE, never after. */
int x = INT_MAX;
if (x > INT_MAX - 1) /* the correct guard */
printf("\n x + 1 would overflow -- detected without causing UB\n");
/* ================= LOCALE-SPECIFIC ========================= */
printf("\nLOCALE-SPECIFIC\n");
printf(" which characters isalpha() accepts depends on LC_CTYPE\n");
printf(" the decimal separator printf uses depends on LC_NUMERIC\n");
return 0;
}
# UndefinedBehaviorSanitizer catches most UB at run time, with a clear
# message naming the file, line, and rule violated. Use it in every
# debug build and in CI.
$ gcc -fsanitize=undefined -fno-sanitize-recover=all -g prog.c -o prog
# Combine with AddressSanitizer for memory errors:
$ gcc -fsanitize=address,undefined -g prog.c -o prog
# -Wall -Wextra catches some cases statically:
$ gcc -Wall -Wextra -Wpedantic prog.c
prog.c:12:14: warning: operation on 'i' may be undefined [-Wsequence-point]
Key Takeaways
- Implementation-defined behavior is documented and stable for your compiler — safe to rely on if you record the assumption, ideally with a
static_assert. - Unspecified behavior offers several legal options with no documentation and no consistency, so it can change between optimization levels; rewrite the code so the choice stops mattering.
- Undefined behavior imposes no requirements at all and licenses the compiler to assume it never happens, which can corrupt code far from the violation.
- The four categories carry very different risk: only UB can make unrelated parts of your program misbehave.
- Build with
-fsanitize=undefined,addressand-Wall -Wextraso violations surface during development rather than after a compiler upgrade.
🧪 Practice
- Write a program printing
sizeoffor every fundamental type, the signedness ofchar, and the result of-1 >> 1. Compare againstgcc -m32.- Write an expression whose result depends on argument evaluation order, compile at
-O0and-O2, and see whether the output changes.- Classify each of these:
INT_MAX + 1,sizeof(long), the order off() + g(), andarr[-1]. Justify each.- Interview-style: "What is the difference between unspecified and undefined behavior?" Hint: one bounds the possible outcomes, the other bounds nothing.
Common Sources of UB
Theory
Undefined behavior is not exotic. A handful of ordinary-looking constructs cause the large majority of real cases, and recognizing them on sight is the practical skill.
Signed integer overflow. INT_MAX + 1 is undefined. Unsigned overflow, by contrast, is fully defined and wraps modulo 2^N. This asymmetry surprises people constantly and is the reason for (int i = 0; i <= n; i++) with n == INT_MAX is an infinite loop the compiler may optimize into something stranger. Always check before the operation: if (a > INT_MAX - b).
Out-of-bounds access. Reading or writing outside an array is undefined even if the memory happens to be mapped. Note that computing arr + n (one past the end) is legal and dereferencing it is not, while computing arr - 1 is undefined even without dereferencing.
Use after free and dangling pointers. Using memory after free, or returning a pointer to a local variable, is undefined. It very often appears to work, because the memory is still mapped and briefly unchanged — which is exactly what makes it dangerous.
Reading uninitialized variables. An uninitialized automatic variable has an indeterminate value, and reading it is undefined. Modern compilers exploit this: since the value is undefined, the compiler may assume any branch on it goes whichever way is convenient.
Null pointer dereference. Undefined, including a "harmless" read. It usually faults, but the more damaging effect is that the compiler infers non-nullness and deletes your later checks.
Invalid shifts. Shifting by negative amounts, or by the width of the type or more, is undefined. So is left-shifting a signed value into the sign bit.
Modifying an object twice between sequence points. i = i++ and arr[i] = i++ are undefined — covered in detail shortly.
Strict aliasing violations. Accessing an object through a pointer of an incompatible type — the next topic.
Division by zero. Undefined for integers, including INT_MIN / -1, which overflows because the result is not representable.
Library misuse. Passing NULL to strlen, overlapping regions to memcpy, or a non-null-terminated array to a str* function is all undefined even though nothing looks unusual.
The unifying lesson is that "it worked when I tested it" is not evidence of correctness in C. UB frequently produces the expected behavior at -O0 and something else at -O2, or on a different compiler, or after an unrelated edit changes register allocation. The sanitizers exist because human review reliably misses these.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>
#include <stdbool.h>
/* ===================== 1. SIGNED OVERFLOW ===================== */
/* WRONG: the check itself relies on the overflow having happened, which
is UB -- so the compiler may delete the check entirely. */
static bool add_broken(int a, int b, int *out)
{
int sum = a + b; /* UB when it overflows */
if (sum < a) return false; /* the compiler may prove this is
unreachable and remove it */
*out = sum;
return true;
}
/* RIGHT: test the OPERANDS before performing the operation. */
static bool add_safe(int a, int b, int *out)
{
if (b > 0 && a > INT_MAX - b) return false;
if (b < 0 && a < INT_MIN - b) return false;
*out = a + b;
return true;
}
/* ===================== 2. BOUNDS ===================== */
static void bounds_rules(void)
{
int arr[10];
int *end = arr + 10; /* LEGAL: one past the end may be COMPUTED */
/* int bad = *end; UNDEFINED: it may not be dereferenced */
/* int *before = arr - 1; UNDEFINED even without dereferencing */
printf(" arr+10 computed legally (%p); dereferencing it is not\n",
(void *)end);
for (int *p = arr; p != end; p++) *p = 0; /* the correct idiom */
printf(" the p != end loop stays inside the object\n");
}
/* ===================== 3. DANGLING POINTERS ===================== */
/* WRONG: the array lives on this function's stack, which is gone on return. */
static const char *dangling(void)
{
char local[] = "on the stack";
return local; /* UB for the caller to read */
}
/* RIGHT: return storage that outlives the call. */
static char *owned(void)
{
char *heap = malloc(32);
if (heap) snprintf(heap, 32, "on the heap");
return heap; /* the caller frees it */
}
/* ===================== 4. INVALID SHIFTS ===================== */
static void shift_rules(void)
{
unsigned x = 1;
printf(" 1u << 31 = 0x%08X (legal: 31 < 32)\n", x << 31);
printf(" 1u << 32 is UNDEFINED. On x86 the CPU masks the count to\n");
printf(" 32 %% 32 == 0, so it silently returns the original value.\n");
/* Guard when the count is not a constant: */
unsigned count = 40;
if (count < sizeof(unsigned) * CHAR_BIT)
printf(" shifted\n");
else
printf(" count %u rejected before shifting\n", count);
}
/* ===================== 5. LIBRARY MISUSE ===================== */
static void library_rules(void)
{
char buf[8];
/* strncpy does NOT terminate when the source fills the buffer, so a
following strlen runs off the end -- undefined. */
strncpy(buf, "abcdefghij", sizeof buf);
buf[sizeof buf - 1] = '\0'; /* terminate it ourselves */
printf(" after strncpy + manual terminator: \"%s\"\n", buf);
/* memcpy with overlapping regions is UB even though it often works. */
char over[] = "ABCDEFGH";
memmove(over + 1, over, 7); /* memmove handles overlap; memcpy does not */
printf(" memmove for overlap: \"%s\"\n", over);
}
int main(void)
{
int result;
printf("SIGNED OVERFLOW\n");
printf(" add_safe(INT_MAX, 1) -> %s\n",
add_safe(INT_MAX, 1, &result) ? "ok" : "refused (correct)");
/* Call FIRST, then print. Passing 'result' as an argument to the same
printf that also calls add_safe would read it unsequenced with the
write -- exactly the evaluation-order trap described earlier. */
bool ok = add_safe(2, 3, &result);
printf(" add_safe(2, 3) -> %s (%d)\n", ok ? "ok" : "refused", result);
printf(" add_broken relies on UB and may be optimized away\n");
(void)add_broken;
/* Unsigned overflow is DEFINED: it wraps. This is not UB. */
unsigned u = UINT_MAX;
printf(" UINT_MAX + 1 = %u (unsigned wrapping is DEFINED)\n", u + 1u);
printf("\nBOUNDS\n"); bounds_rules();
printf("\nDANGLING POINTERS\n");
(void)dangling; /* calling it and reading the result is UB */
printf(" returning a pointer to a local is UB for the caller\n");
char *h = owned();
if (h) { printf(" heap version works: \"%s\"\n", h); free(h); }
printf("\nSHIFTS\n"); shift_rules();
printf("\nLIBRARY MISUSE\n");library_rules();
printf("\nDIVISION\n");
int d = 0;
if (d != 0) printf(" %d\n", 10 / d);
else printf(" guarded: 10 / 0 is undefined, as is INT_MIN / -1\n");
printf("\nUNINITIALIZED\n");
int initialized = 0; /* always initialize */
printf(" reading an uninitialized variable is UB; this one is %d\n",
initialized);
return 0;
}
| UB source | Looks like | Correct form |
|---|---|---|
| Signed overflow | if (a + b < a) | check a > INT_MAX - b first |
| Out of bounds | arr[n] with n == len | n < len before indexing |
| Dangling pointer | returning &local | return heap or caller-supplied storage |
| Use after free | *p after free(p) | set p = NULL after freeing |
| Uninitialized read | int x; use(x); | initialize at declaration |
| Oversized shift | x << n with n >= width | check n < width * CHAR_BIT |
| Overlapping copy | memcpy on overlap | memmove |
| Unterminated string | strncpy then strlen | snprintf, or terminate manually |
INT_MIN / -1 | ordinary division | guard both operands |
Key Takeaways
- Signed overflow is undefined while unsigned overflow wraps predictably — never detect signed overflow by inspecting the result afterwards.
- Computing a pointer one past the end of an array is legal, but dereferencing it, or computing one before the start, is not.
- Use after free and returning a pointer to a local frequently appear to work, which is precisely what makes them dangerous.
- Shifting by the type's width or more is undefined and silently masked on x86; guard any non-constant shift count.
- Ordinary library calls carry UB too —
memcpyon overlapping regions,strlenon an unterminated buffer,INT_MIN / -1— so "it ran fine" proves nothing.
🧪 Practice
- Write
safe_mul(int a, int b, int *out)that detects overflow before multiplying, and test it atINT_MAX,INT_MIN,0, and-1.- Compile a program containing signed overflow at
-O0and-O2under-fsanitize=undefined, and compare the diagnostics with the unsanitized output.- Write a function returning a pointer to a local, call it, and observe the behavior at both optimization levels. Then fix it two different ways.
- Interview-style: "Why is unsigned overflow well defined while signed overflow is not?" Hint: consider how many representations of signed integers the standard historically had to accommodate.
Strict Aliasing Rule
Theory
To generate fast code, a compiler must know when two pointers can refer to the same memory. If int *a and float *b might alias, then after writing through b the compiler must reload *a from memory, because it could have changed. If it knows they cannot alias, it can keep *a in a register across the write.
The strict aliasing rule is what gives the compiler that knowledge. In essence:
An object may only be accessed through an lvalue of a type compatible with the object's own type.
The permitted exceptions are important and few:
- The object's own type, or a signed/unsigned or
const/volatile-qualified version of it. - A struct or union type containing the object as a member.
- A character type —
char,signed char, orunsigned char. This exception is what makesmemcpyand byte-wise inspection legal.
The consequence is that the classic "reinterpret these bytes" cast is undefined:
float f = 1.0f;
uint32_t bits = *(uint32_t *)&f; /* UB: reading a float as a uint32_t */
This looks harmless and often works — until the optimizer reorders a store and a load it believes cannot interact, and the result is silently wrong.
C offers three legal ways to reinterpret an object's bytes:
memcpy. Copying through unsigned char is explicitly permitted, and every modern compiler recognizes a fixed-size memcpy and compiles it to the same single instruction the illegal cast would have produced. There is no performance cost.
A union. Unlike C++, C explicitly permits reading a union member other than the one last written — this is defined type punning, and the standard says the bytes are reinterpreted. It is idiomatic and safe in C.
A character-type pointer. Accessing any object through unsigned char * is always allowed, which is how you write a hex dumper or a hash of an arbitrary object.
Two related notes. -fno-strict-aliasing disables the optimization and makes the punning cast work — the Linux kernel builds with it — but relying on a compiler flag means the code is not C, and a different compiler will break it. And restrict is the opposite promise: it tells the compiler two pointers definitely do not alias, enabling optimizations it could not otherwise make. Lying with restrict is undefined behavior.
The rule is easy to follow once known: to reinterpret bytes, use memcpy or a union — never a pointer cast between unrelated types.
Examples
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <inttypes.h>
/* ===================== the illegal form ===================== */
/* UB: 'f' is a float object, accessed through a uint32_t lvalue.
Often works; may be miscompiled at -O2 with -Wstrict-aliasing. */
static uint32_t bits_illegal(float f)
{
return *(uint32_t *)&f; /* strict aliasing violation */
}
/* ===================== legal form 1: memcpy ===================== */
/* Copying through character types is explicitly permitted. Compilers
recognize a fixed-size memcpy and emit the SAME single instruction
the illegal cast would have -- zero cost, fully defined. */
static uint32_t bits_memcpy(float f)
{
uint32_t out;
memcpy(&out, &f, sizeof out);
return out;
}
static float from_bits_memcpy(uint32_t bits)
{
float out;
memcpy(&out, &bits, sizeof out);
return out;
}
/* ===================== legal form 2: a union ===================== */
/* C (unlike C++) explicitly allows reading a member other than the one
last written: the bytes are reinterpreted. This is DEFINED punning. */
union FloatBits { float f; uint32_t u; };
static uint32_t bits_union(float f)
{
union FloatBits fb;
fb.f = f;
return fb.u; /* defined in C */
}
/* ===================== legal form 3: character pointers ============ */
/* Any object may be inspected through unsigned char* -- this is the
exception that makes hex dumps and generic hashing possible. */
static void dump_object(const char *label, const void *p, size_t n)
{
const unsigned char *b = p; /* always allowed */
printf("%-22s", label);
for (size_t i = 0; i < n; i++) printf("%02X ", b[i]);
putchar('\n');
}
static uint64_t hash_object(const void *p, size_t n)
{
const unsigned char *b = p;
uint64_t h = 1469598103934665603ULL;
for (size_t i = 0; i < n; i++) { h ^= b[i]; h *= 1099511628211ULL; }
return h;
}
/* ===================== why the compiler cares ====================== */
/* The compiler knows an int and a float cannot alias, so it may keep
*a in a register across the write to *b. If they DO alias, the
result is wrong -- which is why the aliasing cast is undefined. */
static int assumes_no_alias(int *a, float *b)
{
*a = 1;
*b = 2.0f; /* cannot touch *a, as far as the compiler knows */
return *a; /* may be folded to a constant 1, without reloading */
}
int main(void)
{
float f = 1.0f;
printf("float 1.0f bit patterns:\n");
printf(" illegal cast: 0x%08" PRIX32 " <- UB, may be miscompiled\n",
bits_illegal(f));
printf(" memcpy: 0x%08" PRIX32 " <- legal, same instruction\n",
bits_memcpy(f));
printf(" union: 0x%08" PRIX32 " <- legal in C\n", bits_union(f));
/* 1.0f is 0x3F800000: sign 0, exponent 127, mantissa 0. */
uint32_t bits = bits_memcpy(f);
printf("\ndecoding 0x%08" PRIX32 ":\n", bits);
printf(" sign = %" PRIu32 "\n", bits >> 31);
printf(" exponent = %" PRIu32 " (biased; unbiased %" PRId32 ")\n",
(bits >> 23) & 0xFFu, (int32_t)((bits >> 23) & 0xFFu) - 127);
printf(" mantissa = 0x%06" PRIX32 "\n", bits & 0x7FFFFFu);
printf("\nround trip: %f\n", (double)from_bits_memcpy(bits));
/* Character-pointer access: always legal, for any object. */
printf("\n");
dump_object("float 1.0f bytes:", &f, sizeof f);
struct Point { int x, y; double z; } pt = { 3, 4, 5.5 };
dump_object("struct Point bytes:", &pt, sizeof pt);
printf("hash of the struct: 0x%016" PRIX64 "\n", hash_object(&pt, sizeof pt));
printf("(note: this hashes PADDING too -- see the struct topics)\n");
int i = 0;
float g = 0.0f;
printf("\nassumes_no_alias returns %d (the compiler may fold this to 1)\n",
assumes_no_alias(&i, &g));
return 0;
}
# The warning that catches most violations:
$ gcc -O2 -Wall -Wstrict-aliasing=2 prog.c
prog.c:14:12: warning: dereferencing type-punned pointer will break
strict-aliasing rules [-Wstrict-aliasing]
# The escape hatch. It works, and it means your code is no longer
# portable C -- another compiler with no such flag will miscompile it.
$ gcc -O2 -fno-strict-aliasing prog.c
# Verify that memcpy costs nothing: both forms emit one instruction.
$ gcc -O2 -S -o - prog.c | grep -A3 bits_memcpy
WHY THE RULE EXISTS
int *a
float *b can these point at the same bytes?
IF THE COMPILER MUST ASSUME YES IF IT MAY ASSUME NO
*a = 1; *a = 1;
*b = 2.0f; *b = 2.0f;
return *a; -> must RELOAD *a return *a; -> folds to 1
Strict aliasing licenses the second. That is the optimization the rule
buys, and the reason violating it silently produces wrong answers.
LEGAL WAYS TO REINTERPRET BYTES
memcpy(&dst, &src, n) always; compiles to one instruction
union { float f; uint32_t u; } defined in C (not in C++)
const unsigned char *p = obj character types may alias anything
Key Takeaways
- Strict aliasing lets the compiler assume pointers of incompatible types never refer to the same object, which is what allows values to stay in registers across stores.
- Casting a
float *to auint32_t *and dereferencing it is undefined behavior, even though it usually appears to work. memcpyis the portable way to reinterpret bytes and costs nothing — compilers turn a fixed-size copy into the same single instruction.- C explicitly permits reading a union member other than the one last written, so union type punning is defined in C (unlike C++).
- Character types may alias any object, which is what makes byte-wise inspection legal;
-fno-strict-aliasingworks but makes your code compiler-specific.
🧪 Practice
- Implement
float_to_bitsandbits_to_floatwithmemcpy, and verify the round trip for0.0f,1.0f,-1.5f, and infinity.- Compile the illegal cast version at
-O2 -Wstrict-aliasing=2and record the warning, then compare the generated assembly with thememcpyversion.- Write a generic
hexdump(const void *, size_t)usingunsigned char *and use it on anint, adouble, and a struct.- Interview-style: "Why is
*(int*)&my_floatundefined behavior when it clearly reads the right bytes?" Hint: the question is not what it reads, but what the compiler is allowed to assume about the surrounding code.
Sequence Points and Evaluation Order
Theory
C gives the compiler wide freedom to reorder evaluation, which is part of why it optimizes well. The limits of that freedom are defined by sequence points (C89/C99 terminology) or the sequenced-before relation (C11 onward, which is more precise but describes the same idea).
A sequence point is a moment at which all side effects of preceding evaluations are complete and none of the following have started. The main ones:
- The end of a full expression — usually the
;. - The
&&,||,?:, and comma operators, between their left and right operands. - Immediately before a function is called, after its arguments are evaluated.
Between two sequence points, the compiler may evaluate subexpressions in any order it likes, and may interleave them.
Two rules follow, and violating either is undefined behavior:
- An object's stored value may be modified at most once between sequence points.
- A prior value may only be read to determine the value to be stored.
This makes the classic puzzles undefined rather than merely surprising:
| Expression | Why it is undefined |
|---|---|
i = i++ | i modified twice (by = and by ++) |
i = ++i + i++ | modified twice, read for other purposes |
arr[i] = i++ | i read to index, and modified, unsequenced |
f(i++, i++) | two unsequenced modifications of i |
printf("%d %d", i++, i++) | same |
These are not "compiler-dependent results" — they are UB, and the compiler may do anything, including producing a value neither ordering would give.
Separately, and importantly, function argument evaluation order is unspecified even when there is no UB. f(g(), h()) may call h first, and GCC on x86-64 typically does. This is legal, silent, and changes with the platform. Any argument expression with a side effect that the other argument depends on is a latent bug.
The short-circuit operators are the exception worth remembering clearly. &&, ||, ?:, and the comma operator do sequence their operands left to right, and &&/|| do not evaluate the right operand at all when the left settles the answer. That guarantee is what makes the idiom if (p != NULL && p->field) safe, and it is the one place ordering can be relied upon.
C11 replaced "sequence point" with sequenced before, unsequenced, and indeterminately sequenced. The last is worth knowing: two function calls in one expression are indeterminately sequenced, meaning one completes entirely before the other starts — they do not interleave — but which goes first is unspecified.
The practical rule is simple: one side effect per statement. If an expression modifies something and also reads it for another purpose, split it into two statements. The clarity costs nothing and the compiler generates identical code.
Examples
#include <stdio.h>
static int calls = 0;
static int side_effect(const char *name, int value)
{
printf(" call %d: %s\n", ++calls, name);
return value;
}
int main(void)
{
/* ============ GUARANTEED ORDERING: && || ?: and comma ============ */
printf("SEQUENCED left to right (safe to depend on):\n");
int *p = NULL;
/* && evaluates the left operand FIRST and skips the right entirely
when the left is false. This is what makes the null check safe. */
if (p != NULL && *p > 0) /* *p is never evaluated */
printf(" unreachable\n");
printf(" p != NULL && *p did not dereference NULL\n");
/* || short-circuits the other way. */
int denom = 0;
if (denom == 0 || 100 / denom > 1) /* the division is skipped */
printf(" denom == 0 || 100/denom did not divide by zero\n");
/* ?: evaluates the condition, then exactly one branch. */
int n = 5;
printf(" ?: chose the %s branch\n", n > 0 ? "positive" : "negative");
/* The comma operator sequences left, discards it, yields the right. */
int comma = (side_effect("comma-left", 1), side_effect("comma-right", 2));
printf(" comma yielded %d (left evaluated first, then discarded)\n", comma);
/* ============ UNSPECIFIED: function argument order ============ */
printf("\nUNSPECIFIED order (legal, but do not depend on it):\n");
calls = 0;
int sum = side_effect("first-written", 10) + side_effect("second-written", 20);
printf(" sum = %d -- the VALUE is defined, the ORDER is not\n", sum);
calls = 0;
printf(" two arguments to one printf:\n");
printf(" got %d and %d\n",
side_effect("arg-a", 1), side_effect("arg-b", 2));
/* ============ UNDEFINED: multiple unsequenced modifications ====== */
printf("\nUNDEFINED (shown as descriptions, not executed):\n");
printf(" i = i++; i modified twice\n");
printf(" i = ++i + i++; modified twice, also read otherwise\n");
printf(" arr[i] = i++; i indexes AND is modified, unsequenced\n");
printf(" f(i++, i++); two unsequenced modifications\n");
printf(" printf(\"%%d %%d\", i++, i++); same problem\n");
/* THE FIX: one side effect per statement. Identical machine code,
and the meaning is now unambiguous. */
printf("\nThe fix -- split into separate statements:\n");
int i = 5;
int arr[10] = {0};
arr[i] = i; /* was: arr[i] = i++; */
i++; /* the intent is now explicit */
printf(" arr[5] = %d, i = %d\n", arr[5], i);
int a = i; /* was: a = i++ + i++; */
i++;
a += i;
i++;
printf(" a = %d, i = %d\n", a, i);
/* A loop increment is fine: the ; is a sequence point. */
int total = 0;
for (int k = 0; k < 3; k++) total += k; /* well defined */
printf(" for-loop total = %d (k++ is sequenced by the loop)\n", total);
return 0;
}
# GCC and Clang detect many of these statically:
$ gcc -Wall -Wsequence-point prog.c
prog.c:12:7: warning: operation on 'i' may be undefined [-Wsequence-point]
# UBSan catches the rest at run time:
$ clang -fsanitize=undefined -g prog.c -o prog && ./prog
prog.c:12:8: runtime error: unsequenced modification and access to 'i'
WHERE THE SEQUENCE POINTS ARE
a = f() + g() ;
^ full expression ends: all side effects complete
^ f and g are INDETERMINATELY SEQUENCED: one finishes before the
other starts, but which one goes first is UNSPECIFIED
p != NULL && p->x
^ SEQUENCE POINT: the left operand is fully evaluated,
and the right is skipped entirely if the left is false
i = i++
^ TWO modifications of i with nothing between them
-> UNDEFINED BEHAVIOR, not "some order"
THE RULE THAT AVOIDS ALL OF IT
one side effect per statement
| Construct | Ordering | Safe to depend on |
|---|---|---|
a && b | left, then right (right skipped if left false) | yes |
a || b | left, then right (right skipped if left true) | yes |
c ? a : b | condition, then exactly one branch | yes |
a , b | left, then right | yes |
f(a, b) | unspecified | no |
a + b | unspecified | no |
i = i++ | undefined behavior | no — it is a bug |
Key Takeaways
- Between sequence points the compiler may evaluate subexpressions in any order, so modifying an object twice, or reading it for an unrelated purpose while modifying it, is undefined.
i = i++,arr[i] = i++, andf(i++, i++)are undefined behavior, not merely implementation-dependent orderings.- Function argument evaluation order is unspecified even without UB, and GCC on x86-64 commonly evaluates right to left.
&&,||,?:, and the comma operator do guarantee left-to-right sequencing and short-circuiting — this is what makesp && p->fieldsafe.- Write one side effect per statement; the generated code is identical and the meaning becomes unambiguous.
🧪 Practice
- Compile
int i = 5; i = i++;with-Wall -Wsequence-pointat-O0and-O2, and compare the warnings and the results.- Write
f(g(), h())where bothgandhprint, and determine your compiler's evaluation order. Then explain why relying on it is wrong.- Rewrite
arr[i] = i++;anda = b++ + b++;as unambiguous multi-statement sequences, and confirm the assembly is unchanged.- Interview-style: "Why is
printf(\"%d %d\", i++, i++)undefined rather than just printing in some order?" Hint: count the modifications ofiand the sequence points between them.
How Optimizers Exploit UB
Theory
This is the topic that changes how people write C, because it explains why undefined behavior is not a local problem.
The compiler reasons as follows: the standard says a conforming program never exhibits undefined behavior. Therefore, when analyzing your code, it may assume UB does not occur and use that assumption to deduce facts. Those deductions then drive optimizations that can delete code you wrote, in places that look entirely unrelated to the violation.
The canonical example is the null check deleted after a dereference:
void f(int *p) {
int x = *p; /* if p were NULL this would be UB */
if (p == NULL) /* therefore p is not NULL here */
return; /* therefore this branch is dead */
use(x, p);
}
The dereference on line 2 is undefined if p is null. The compiler concludes p is non-null, proves the if is always false, and deletes it. This is exactly the reasoning that produced CVE-2009-1897 in the Linux kernel, where a removed null check became an exploitable vulnerability.
Signed overflow enables similar deductions. Because i + 1 > i cannot be false without overflow, and overflow is UB, the compiler may simplify it to true — turning a bounds check into an infinite loop. Similarly, x * 2 / 2 folds to x, and for (int i = 0; i <= n; i++) may be assumed to terminate because signed i cannot wrap.
Two further consequences are worth internalizing:
UB can affect code that runs before it. The compiler may reorder or hoist based on the assumption, so a violation late in a function can change behavior earlier. There is no "the program was fine until it hit the bad line".
UB can make an entire path unreachable. If every route into a block contains UB, the compiler may conclude the block is never entered and remove it — or, in extreme cases, remove the check that would have prevented entry.
This is why the phrase "it works on my machine" carries so little weight in C. The same source, compiled at a different optimization level or with a newer compiler, may behave completely differently — not because the compiler broke, but because your program never had a defined meaning.
The defensive posture is straightforward and effective:
- Sanitizers in every debug build and in CI. UBSan and ASan catch the large majority of real violations at the point they occur, with the file and line.
- Warnings as errors.
-Wall -Wextracatches many cases statically. - Never "fix" UB by lowering the optimization level. If a bug disappears at
-O0, the bug is still there. - Check preconditions before the operation, not after — the check-after pattern is precisely what gets deleted.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <limits.h>
#include <stdbool.h>
/* =====================================================================
1. A null check deleted because an earlier dereference implied it
cannot be null. This is the shape of CVE-2009-1897.
===================================================================== */
static int deref_then_check(int *p)
{
int value = *p; /* UB if p is NULL -> the compiler assumes it is not */
if (p == NULL) /* provably false given the line above */
return -1; /* -O2 may DELETE this branch entirely */
return value;
}
/* The fix: check FIRST. Now the compiler can deduce nothing improper. */
static int check_then_deref(int *p)
{
if (p == NULL) return -1; /* the check happens before any use */
return *p;
}
/* =====================================================================
2. Signed overflow makes a comparison provably true.
===================================================================== */
static bool always_true_to_the_compiler(int x)
{
/* x + 1 > x is false ONLY on overflow, which is UB, so the compiler
may fold this whole expression to 'true'. */
return x + 1 > x;
}
/* Unsigned wrapping is DEFINED, so no such deduction is permitted. */
static bool honest_unsigned(unsigned x)
{
return x + 1u > x; /* genuinely false when x == UINT_MAX */
}
/* =====================================================================
3. A loop the compiler may assume terminates.
===================================================================== */
static long count_up_signed(int limit)
{
long iterations = 0;
/* With a signed counter the compiler may assume i never wraps, and
therefore that the loop terminates -- even when limit == INT_MAX,
where it actually would not. */
for (int i = 0; i < limit; i++) iterations++;
return iterations;
}
/* =====================================================================
4. The correct patterns.
===================================================================== */
static bool add_checked(int a, int b, int *out)
{
if (b > 0 && a > INT_MAX - b) return false; /* test the OPERANDS */
if (b < 0 && a < INT_MIN - b) return false;
*out = a + b;
return true;
}
static bool add_builtin(int a, int b, int *out)
{
/* GCC/Clang: one instruction plus a flag test, and no UB anywhere. */
return !__builtin_add_overflow(a, b, out);
}
int main(void)
{
int value = 42;
printf("check_then_deref(&value) = %d\n", check_then_deref(&value));
printf("check_then_deref(NULL) = %d <- the check survives\n",
check_then_deref(NULL));
printf("deref_then_check is compiled with its NULL branch removed at -O2\n");
(void)deref_then_check;
printf("\nsigned: x + 1 > x may be folded to TRUE (overflow is UB)\n");
printf(" at x = INT_MAX it returns %s\n",
always_true_to_the_compiler(INT_MAX) ? "true" : "false");
printf("unsigned: x + 1 > x must be evaluated honestly (wrapping is defined)\n");
printf(" at x = UINT_MAX it returns %s\n",
honest_unsigned(UINT_MAX) ? "true" : "false");
printf("\nloop with a small limit: %ld iterations\n", count_up_signed(1000));
int result;
printf("\nsafe addition:\n");
printf(" add_checked(INT_MAX, 1) -> %s\n",
add_checked(INT_MAX, 1, &result) ? "ok" : "refused");
printf(" add_builtin(INT_MAX, 1) -> %s\n",
add_builtin(INT_MAX, 1, &result) ? "ok" : "refused");
/* Sequence the call before the read: 'result' must not be an argument
to the same call that writes it. */
bool ok = add_builtin(20, 22, &result);
printf(" add_builtin(20, 22) -> %s (%d)\n", ok ? "ok" : "refused", result);
return 0;
}
# See the deletion for yourself: compare the two optimization levels.
$ gcc -O0 -S -o - prog.c | grep -A20 deref_then_check # the check is present
$ gcc -O2 -S -o - prog.c | grep -A20 deref_then_check # it is gone
# The warning that names this specific transformation:
$ gcc -O2 -Wall -Wnull-dereference prog.c
prog.c:12:9: warning: comparison of a pointer that is already
dereferenced will always evaluate to false
# The defensive build every project should use in CI:
$ gcc -O1 -g -Wall -Wextra -Werror \
-fsanitize=address,undefined -fno-sanitize-recover=all prog.c
HOW ONE VIOLATION PROPAGATES
YOU WROTE WHAT THE COMPILER DEDUCES
int x = *p; "a conforming program never
if (p == NULL) return -1; dereferences NULL, so p != NULL"
use(x, p); |
v
"then (p == NULL) is always false"
|
v
"then the return is unreachable"
|
v
WHAT ACTUALLY RUNS the branch is DELETED
int x = *p;
use(x, p); a NULL p now reaches use()
THE POINT: the deleted code is not the code that had UB.
A violation on one line removed a safety check on another.
THE FIX IS ALWAYS THE SAME: make the check happen BEFORE the
operation that would be undefined, never after it.
Key Takeaways
- The compiler assumes undefined behavior never occurs and derives facts from that assumption, so a violation can delete code elsewhere in the function.
- A null check placed after a dereference is provably dead and may be removed — the pattern behind real kernel vulnerabilities.
- Signed overflow being undefined lets the compiler fold
x + 1 > xto true and assume signed loop counters never wrap; unsigned arithmetic gets no such treatment. - UB can affect code that executes before the offending line, because the assumption drives reordering and hoisting.
- Check preconditions before the risky operation, build with
-fsanitize=address,undefinedand-Wall -Wextra, and never treat "it works at-O0" as a fix.
🧪 Practice
- Compile
deref_then_checkat-O0and-O2, compare the assembly withgcc -S, and identify the removed instructions.- Write a loop with a signed counter that relies on wrapping, run it at
-O0and-O2, and explain the difference.- Add
-fsanitize=undefined -fno-sanitize-recover=allto a small project and fix every violation it reports.- Interview-style: "A bug disappears when you compile with
-O0. Is it fixed?" Hint: ask what changed — the program's meaning, or only the compiler's use of an assumption.
<a id="123-interfacing-and-portability"></a>
12.3 Interfacing and Portability
C is the lingua franca of systems software: almost every language can call it, and almost every platform provides it. This section covers the binary contract that makes that possible, how to drop to assembly when you must, how to package code as a library, and what actually breaks when you move C between platforms.
Calling Conventions and the ABI
Theory
A function prototype tells the compiler how to type-check a call. It says nothing about how the call happens at the machine level: which register holds the first argument, who cleans up the stack, where the return value appears. Those decisions are the calling convention, and they are part of the larger ABI (Application Binary Interface).
The distinction between API and ABI is worth stating clearly:
| API | ABI | |
|---|---|---|
| Level | source code | machine code |
| Governs | function names, types, semantics | registers, stack layout, struct offsets, name mangling |
| Compatible means | the code compiles | the binaries link and run |
| Broken by | renaming a function, changing a signature | reordering struct members, changing a type's size |
The second row of "broken by" is the subtle one. Adding a field to a struct in a shared library keeps the API identical — everything still compiles — while silently breaking the ABI, because existing binaries were compiled with the old size and offsets. This is why mature libraries hide struct definitions behind opaque handles.
On x86-64 System V (Linux, macOS, BSD), the essentials are:
- Integer and pointer arguments go in
RDI, RSI, RDX, RCX, R8, R9, in that order; further arguments go on the stack. - Floating-point arguments go in
XMM0–XMM7. - The return value comes back in
RAX(orXMM0for floating point). RBX, RBP, R12–R15are callee-saved: a function must preserve them. Everything else is caller-saved.- The stack must be 16-byte aligned at the point of a
call. - There is a 128-byte red zone below
RSPthat leaf functions may use without adjusting the stack pointer.
Windows x64 differs — four register arguments (RCX, RDX, R8, R9), a 32-byte shadow space, and a different callee-saved set — which is exactly why an object file from one platform cannot link against the other.
Three practical consequences:
Variadic functions need the argument count communicated somehow, because the callee cannot know how many registers were used. That is what the format string does for printf, and on x86-64 AL additionally carries the number of vector registers used.
Struct passing depends on size. Small structs are passed in registers, decomposed field by field; larger ones are passed by hidden pointer. The threshold is ABI-specific, which is one reason passing large structs by value is discouraged.
extern "C" exists because C++ mangles names. A C++ compiler encodes the parameter types into the symbol to support overloading, so int add(int, int) becomes something like _Z3addii. A C compiler emits plain add. Linking the two requires telling C++ to use C linkage.
You rarely write to the ABI directly, but you must respect it whenever binaries meet: linking libraries, writing inline assembly, or calling C from another language.
Examples
#include <stdio.h>
#include <stdint.h>
/* Six integer arguments fit in registers on x86-64 System V:
RDI, RSI, RDX, RCX, R8, R9 -- in declaration order. */
long six_in_registers(long a, long b, long c, long d, long e, long f)
{
return a + b + c + d + e + f;
}
/* The seventh and beyond are passed on the STACK. */
long seven_arguments(long a, long b, long c, long d, long e, long f, long g)
{
return a + b + c + d + e + f + g; /* g comes from the stack */
}
/* Floating point uses a separate register file: XMM0-XMM7. */
double mixed_arguments(int i, double d, int j, double e)
{
/* i -> RDI, j -> RSI (integer registers, in order)
d -> XMM0, e -> XMM1 (vector registers, independently in order) */
return i + d + j + e;
}
/* A small struct is decomposed and passed in registers... */
typedef struct { int x, y; } Small; /* 8 bytes: fits in one register */
int sum_small(Small s) { return s.x + s.y; }
/* ...while a large one is passed by a hidden pointer to a copy. */
typedef struct { double v[8]; } Large; /* 64 bytes: too big for registers */
double sum_large(Large l)
{
double total = 0;
for (int i = 0; i < 8; i++) total += l.v[i];
return total;
}
int main(void)
{
printf("six in registers: %ld\n", six_in_registers(1, 2, 3, 4, 5, 6));
printf("seven (one on the stack): %ld\n",
seven_arguments(1, 2, 3, 4, 5, 6, 7));
printf("mixed int/double: %.1f\n", mixed_arguments(1, 2.5, 3, 4.5));
Small s = { 10, 20 };
printf("small struct (in registers): %d\n", sum_small(s));
Large l = { { 1, 2, 3, 4, 5, 6, 7, 8 } };
printf("large struct (by hidden pointer): %.1f\n", sum_large(l));
printf("\nsizeof(Small) = %zu (register-passed)\n", sizeof(Small));
printf("sizeof(Large) = %zu (memory-passed)\n", sizeof(Large));
return 0;
}
Making a C header usable from C++ — the extern "C" idiom:
/* ============================ mathlib.h ============================ */
#ifndef MATHLIB_H
#define MATHLIB_H
/* A C++ compiler mangles symbol names to encode parameter types, which
supports overloading but produces a symbol a C object file does not
contain. extern "C" suppresses the mangling for these declarations.
__cplusplus is defined only by C++ compilers, so this header stays
valid C -- the guard is invisible when compiled as C. */
#ifdef __cplusplus
extern "C" {
#endif
int ml_add(int a, int b);
double ml_average(const double *values, unsigned long count);
/* An OPAQUE handle: callers cannot see the fields, so the layout may
change without breaking ABI compatibility with existing binaries. */
typedef struct MlContext MlContext;
MlContext *ml_create(void);
void ml_destroy(MlContext *ctx);
int ml_accumulate(MlContext *ctx, int value);
int ml_total(const MlContext *ctx);
#ifdef __cplusplus
}
#endif
#endif /* MATHLIB_H */
/* ============================ mathlib.c ============================ */
#include "mathlib.h"
#include <stdlib.h>
/* The definition lives here, invisible to callers. Adding a field to
this struct does NOT break binaries compiled against the header. */
struct MlContext { int total; int count; };
int ml_add(int a, int b) { return a + b; }
double ml_average(const double *values, unsigned long count)
{
if (!values || count == 0) return 0.0;
double sum = 0.0;
for (unsigned long i = 0; i < count; i++) sum += values[i];
return sum / (double)count;
}
MlContext *ml_create(void) { return calloc(1, sizeof(MlContext)); }
void ml_destroy(MlContext *c) { free(c); }
int ml_accumulate(MlContext *c, int v)
{
if (!c) return -1;
c->total += v;
c->count++;
return c->total;
}
int ml_total(const MlContext *c) { return c ? c->total : 0; }
# Inspect the actual symbols an object file exports.
$ gcc -c mathlib.c -o mathlib.o && nm mathlib.o | grep ' T '
0000000000000000 T ml_accumulate
0000000000000030 T ml_add
0000000000000040 T ml_average
0000000000000000 T ml_create
0000000000000010 T ml_destroy
0000000000000020 T ml_total
# Plain, unmangled names -- exactly what a C++ 'extern "C"' block expects.
# The same source compiled as C++ WITHOUT extern "C":
$ g++ -c -x c++ mathlib.c -o cpp.o && nm cpp.o | grep add
0000000000000000 T _Z6ml_addii <- mangled: 'ii' encodes (int, int)
# See the calling convention directly:
$ gcc -O2 -S -o - abi.c | grep -A6 six_in_registers
# leaq (%rdi,%rsi), %rax <- first two arguments in RDI and RSI
x86-64 SYSTEM V CALLING CONVENTION
f(a, b, c, d, e, f, g)
| | | | | | |
v v v v v v +--> pushed on the STACK
RDI RSI RDX RCX R8 R9
doubles use a SEPARATE sequence: XMM0 XMM1 ... XMM7
return value: RAX (integers/pointers) XMM0 (floating point)
CALLEE-SAVED (the called function must restore these)
RBX RBP R12 R13 R14 R15
CALLER-SAVED (assume they are destroyed by any call)
RAX RCX RDX RSI RDI R8-R11 and all XMM
API vs ABI
add a function to a header API unchanged for callers, ABI fine
reorder two struct fields API IDENTICAL, ABI BROKEN
change 'int' field to 'long' API identical, ABI BROKEN
rename a function API broken, ABI broken
This is why public libraries hide their structs behind opaque handles.
Key Takeaways
- The calling convention specifies which registers carry arguments and return values, who preserves what, and how the stack is aligned; it is part of the platform ABI, not the C language.
- API compatibility means the source still compiles; ABI compatibility means existing binaries still work — reordering a struct's fields breaks the second while leaving the first intact.
- On x86-64 System V, the first six integer arguments go in
RDI, RSI, RDX, RCX, R8, R9, floats go inXMM0–XMM7, and results return inRAXorXMM0. - Small structs are passed in registers and large ones by hidden pointer, which is why passing big structs by value is discouraged.
- C++ mangles symbol names to support overloading, so headers meant for both languages must wrap declarations in
extern "C", guarded by#ifdef __cplusplus.
🧪 Practice
- Write functions taking 3, 6, and 9 integer arguments, compile with
gcc -O2 -S, and identify where the seventh argument comes from.- Compile the same source as C and as C++ and compare
nmoutput, then addextern "C"and observe the change.- Write a struct-returning function and inspect the assembly to determine whether it returns in registers or through a hidden pointer.
- Interview-style: "You add a field to a struct in a shared library. Existing programs crash. Why, when nothing failed to compile?" Hint: consider what the old binaries believe about
sizeofand member offsets.
Inline Assembly
Theory
Occasionally C cannot express what you need: a privileged instruction, a CPU feature with no intrinsic, an atomic operation the compiler will not emit, or a cycle-exact sequence. Inline assembly lets you embed machine instructions in a C function while still letting the compiler manage registers around them.
GCC and Clang use extended asm, whose syntax is dense but systematic:
asm volatile ( "instructions"
: output operands
: input operands
: clobbered registers );
The four sections must be understood together, because the constraint letters are what connect assembly to C variables:
| Constraint | Meaning |
|---|---|
"r" | any general-purpose register |
"m" | a memory location |
"i" | an immediate constant |
"a", "b", "c", "d" | specifically RAX, RBX, RCX, RDX |
"=r" | write-only output |
"+r" | read-write operand |
"0" | the same location as operand 0 |
Operands are referenced as %0, %1, and so on, numbered from the first output. Named operands (%[result]) are far more readable and are what modern code uses.
Three details cause most inline-asm bugs:
The clobber list must be complete. If your assembly modifies a register you did not declare, the compiler still believes its old value is live, and the resulting corruption is essentially undebuggable. "memory" in the clobber list says "this may read or write arbitrary memory", forcing the compiler to flush and reload; "cc" says the condition flags changed.
volatile prevents removal and reordering. Without it, the compiler may delete asm whose outputs are unused, or hoist it out of a loop. Any asm with a side effect the operands do not express — I/O, a barrier, a system call — needs volatile.
GCC uses AT&T syntax by default: mov src, dst — the reverse of Intel order — with % before registers and $ before immediates. Since % is also the operand marker, registers written literally need %%.
The honest guidance is that inline assembly is almost always the wrong tool. It is unportable across architectures and across compilers, it blocks optimization, it cannot be inlined into by the compiler's own passes, and modern compilers generate better code than hand-written assembly for nearly everything. Before reaching for it, consider:
- Compiler builtins —
__builtin_popcount,__builtin_expect,__builtin_add_overflow. - Intrinsics —
<immintrin.h>gives SSE and AVX as ordinary functions the optimizer understands. - Standard atomics —
<stdatomic.h>covers what most hand-written asm barriers were for.
Legitimate remaining uses are narrow: privileged instructions (cpuid, rdtsc, hlt), system call stubs, memory barriers on exotic platforms, and cryptographic code that must run in constant time.
Examples
#include <stdio.h>
#include <stdint.h>
/* This file is x86-64 specific. Guard architecture-dependent code so it
fails to build clearly rather than mysteriously. */
#if !defined(__x86_64__)
int main(void) { printf("this example requires x86-64\n"); return 0; }
#else
/* ---- 1. the minimal form: one input, one output ---- */
static int add_asm(int a, int b)
{
int result;
asm ("addl %[rhs], %[out]" /* AT&T: add rhs INTO out */
: [out] "=r" (result) /* output: any register, write-only */
: [rhs] "r" (b), "0" (a)); /* inputs: b in a register,
a in the SAME place as operand 0 */
return result;
}
/* ---- 2. cpuid: a genuinely good use -- no C equivalent exists ---- */
static void cpu_vendor(char out[13])
{
uint32_t eax, ebx, ecx, edx;
/* cpuid hard-wires its operands to specific registers, so we name
them with the a/b/c/d constraints rather than letting GCC choose. */
asm volatile ("cpuid"
: "=a" (eax), "=b" (ebx), "=c" (ecx), "=d" (edx)
: "a" (0) /* leaf 0: vendor string */
: );
/* The 12-character vendor string arrives in EBX, EDX, ECX -- in that
order, which is not the numeric order of the registers. */
((uint32_t *)out)[0] = ebx;
((uint32_t *)out)[1] = edx;
((uint32_t *)out)[2] = ecx;
out[12] = '\0';
}
/* ---- 3. rdtsc: read the CPU timestamp counter ---- */
static uint64_t read_tsc(void)
{
uint32_t lo, hi;
/* volatile is ESSENTIAL: without it the compiler may hoist this out
of a loop or delete it, since its inputs never change. */
asm volatile ("rdtsc" : "=a" (lo), "=d" (hi));
return ((uint64_t)hi << 32) | lo;
}
/* ---- 4. a compiler barrier: no instructions at all ---- */
static inline void compiler_barrier(void)
{
/* Empty assembly, but the "memory" clobber tells the compiler that
arbitrary memory may have changed -- so it must not move loads or
stores across this point. Emits ZERO instructions. */
asm volatile ("" ::: "memory");
}
/* ---- 5. byte swap, to compare against the builtin ---- */
static uint32_t bswap_asm(uint32_t x)
{
asm ("bswapl %0" : "+r" (x)); /* "+r": read AND written */
return x;
}
int main(void)
{
printf("add_asm(20, 22) = %d\n", add_asm(20, 22));
char vendor[13];
cpu_vendor(vendor);
printf("CPU vendor = %s\n", vendor);
uint64_t t0 = read_tsc();
volatile long spin = 0;
for (long i = 0; i < 1000000; i++) spin += i;
uint64_t t1 = read_tsc();
printf("elapsed cycles = %llu\n", (unsigned long long)(t1 - t0));
compiler_barrier();
uint32_t v = 0x12345678u;
printf("bswap_asm(0x%08X) = 0x%08X\n", v, bswap_asm(v));
printf("__builtin_bswap32 = 0x%08X <- prefer this: portable and\n",
__builtin_bswap32(v));
printf(" the optimizer understands it\n");
return 0;
}
#endif
The alternatives that almost always beat inline assembly:
#include <stdio.h>
#include <stdint.h>
#include <stdatomic.h>
#include <stdbool.h>
int main(void)
{
uint32_t x = 0xF0F0F0F0u;
/* 1. BUILTINS: portable across architectures, one instruction each,
and fully visible to the optimizer. */
printf("popcount = %d\n", __builtin_popcount(x));
printf("leading zeros= %d\n", __builtin_clz(x));
printf("byte swap = 0x%08X\n", __builtin_bswap32(x));
int sum;
printf("checked add = %s\n",
__builtin_add_overflow(2147483647, 1, &sum) ? "overflow" : "ok");
/* Branch hints, where profiling shows they help. */
int rare = 0;
if (__builtin_expect(rare, 0)) printf("unlikely\n");
/* 2. STANDARD ATOMICS: what most hand-written barriers were for. */
_Atomic int counter = 0;
atomic_fetch_add(&counter, 1);
atomic_thread_fence(memory_order_seq_cst); /* a portable barrier */
printf("atomic count = %d\n", atomic_load(&counter));
/* 3. INTRINSICS (<immintrin.h>) expose SSE/AVX as ordinary functions
that the register allocator and scheduler still understand,
unlike an opaque asm block. */
printf("\nprefer, in order: builtins > intrinsics > atomics > inline asm\n");
return 0;
}
| Approach | Portable | Optimizer-friendly | Use when |
|---|---|---|---|
| Plain C | yes | fully | almost always |
| Compiler builtin | across architectures | fully | popcount, overflow checks, hints |
| Intrinsics | within an ISA family | mostly | SIMD work |
<stdatomic.h> | fully | yes | synchronization, barriers |
| Inline assembly | no | blocks it | privileged instructions, syscall stubs |
Key Takeaways
- Extended asm connects assembly to C through constraint letters and named operands, with separate sections for outputs, inputs, and clobbers.
- An incomplete clobber list corrupts registers the compiler still believes are live;
"memory"forces a flush and reload, and"cc"declares that flags changed. volatileprevents the compiler from deleting or reordering asm whose effects are not visible in its operands.- GCC defaults to AT&T syntax with reversed operand order, and literal register names need
%%because%marks operands. - Reach for builtins, intrinsics, and
<stdatomic.h>first — inline assembly is unportable, blocks optimization, and is rarely faster than what the compiler produces.
🧪 Practice
- Write an inline-asm function that multiplies two integers, then compare its generated code with the plain C version at
-O2.- Use
cpuidleaf 1 to detect whether the CPU supports SSE4.2, checking the relevant bit ofECX.- Write a byte-swap in inline assembly and benchmark it against
__builtin_bswap32over ten million values.- Interview-style: "What can go wrong if you omit a register from the clobber list?" Hint: the compiler still believes it holds something valuable.
Linking Against C from Other Languages
Theory
C's ABI has become the universal interface between languages. Python, Rust, Go, Java, C#, Ruby, and JavaScript runtimes can all call C, and almost none can call each other directly. The reason is that C's binary contract is simple, stable, and documented: plain symbol names, a well-defined calling convention, and data types that map onto machine primitives.
This is why "writing bindings" almost always means "expose a C interface", even for a library written in C++ or Rust.
Making a C library comfortably callable from elsewhere is a design task, and a few rules cover most of it:
Keep the interface in the C subset that maps cleanly. Scalars, pointers, and opaque handles cross language boundaries easily. Function-like macros, inline functions, complex struct-by-value passing, and variadic functions do not — a macro has no symbol to call, and every foreign-function interface struggles with varargs.
Use fixed-width types. int32_t is unambiguous on both sides; long is 8 bytes on Linux and 4 on Windows, and the mismatch corrupts arguments silently.
Prefer opaque handles to exposed structs. The caller holds a pointer and never inspects it, which means your layout is free to change and the other language does not need to replicate your struct definition.
Be explicit about ownership. Which side allocates, which side frees, and with what function? Foreign runtimes have their own allocators, so memory allocated by your library must be freed by your library — always export a matching free function rather than letting the caller call free directly.
Return errors as values. Do not exit, do not abort, and do not let a C++ exception escape across the boundary — an exception unwinding into a foreign runtime is undefined behavior.
Keep strings simple. NUL-terminated UTF-8 (const char *) is what every language can convert. Returning a heap-allocated string requires a paired free function.
Mechanically, the caller side varies but the shape is the same. Python's ctypes loads a shared library and declares signatures at runtime. Rust uses extern "C" blocks with unsafe. Go uses cgo. All of them ultimately do dlopen and a call through the C ABI.
The one thing to keep straight is that callbacks must not violate the callee's expectations: a function pointer handed to C must have C linkage, must not throw, and must be careful about the foreign runtime's threading rules — calling into a Python callback from a C thread requires acquiring the GIL first.
Examples
A C library designed for foreign consumption:
/* ============================ counter.h ============================ */
#ifndef COUNTER_H
#define COUNTER_H
#include <stdint.h> /* fixed-width types: unambiguous in every language */
#ifdef __cplusplus
extern "C" {
#endif
/* An OPAQUE handle. Foreign callers hold the pointer and never look
inside, so they need no copy of the struct definition and the layout
is free to change. */
typedef struct Counter Counter;
/* Errors are RETURN VALUES. Never exit(), abort(), or let an exception
escape -- a foreign runtime cannot recover from any of those. */
typedef enum {
COUNTER_OK = 0,
COUNTER_ERR_NULL = -1,
COUNTER_ERR_NOMEM = -2,
COUNTER_ERR_OVERFLOW= -3
} CounterStatus;
/* Lifecycle: the LIBRARY allocates and the LIBRARY frees. A foreign
runtime's allocator is not ours, so never ask the caller to free(). */
Counter *counter_create(int64_t initial);
void counter_destroy(Counter *c); /* accepts NULL */
CounterStatus counter_add(Counter *c, int64_t delta);
CounterStatus counter_value(const Counter *c, int64_t *out);
const char *counter_strerror(CounterStatus status); /* static: do not free */
/* A callback taking a context pointer, so the foreign side can carry
its own state without needing a global. */
typedef void (*CounterObserver)(int64_t new_value, void *user_data);
CounterStatus counter_set_observer(Counter *c, CounterObserver fn, void *user_data);
#ifdef __cplusplus
}
#endif
#endif /* COUNTER_H */
/* ============================ counter.c ============================ */
#include "counter.h"
#include <stdlib.h>
#include <limits.h>
#include <stdint.h>
struct Counter { /* invisible to callers */
int64_t value;
CounterObserver observer;
void *user_data;
};
Counter *counter_create(int64_t initial)
{
Counter *c = calloc(1, sizeof *c);
if (c) c->value = initial;
return c; /* NULL on failure: the universal signal */
}
void counter_destroy(Counter *c) { free(c); } /* free(NULL) is a no-op */
CounterStatus counter_add(Counter *c, int64_t delta)
{
if (!c) return COUNTER_ERR_NULL;
/* Check before the operation: signed overflow is UB. */
if (delta > 0 && c->value > INT64_MAX - delta) return COUNTER_ERR_OVERFLOW;
if (delta < 0 && c->value < INT64_MIN - delta) return COUNTER_ERR_OVERFLOW;
c->value += delta;
if (c->observer) c->observer(c->value, c->user_data);
return COUNTER_OK;
}
CounterStatus counter_value(const Counter *c, int64_t *out)
{
if (!c || !out) return COUNTER_ERR_NULL;
*out = c->value;
return COUNTER_OK;
}
const char *counter_strerror(CounterStatus status)
{
switch (status) {
case COUNTER_OK: return "success";
case COUNTER_ERR_NULL: return "null argument";
case COUNTER_ERR_NOMEM: return "out of memory";
case COUNTER_ERR_OVERFLOW: return "value would overflow";
}
return "unknown error";
}
CounterStatus counter_set_observer(Counter *c, CounterObserver fn, void *user_data)
{
if (!c) return COUNTER_ERR_NULL;
c->observer = fn;
c->user_data = user_data;
return COUNTER_OK;
}
Calling it from Python, Rust, and C++:
# ============================ caller.py ============================
import ctypes
lib = ctypes.CDLL("./libcounter.so")
# ctypes cannot read the header, so every signature must be declared.
# Getting these wrong corrupts arguments silently -- there is no check.
lib.counter_create.argtypes = [ctypes.c_int64]
lib.counter_create.restype = ctypes.c_void_p # the opaque handle
lib.counter_destroy.argtypes = [ctypes.c_void_p]
lib.counter_destroy.restype = None
lib.counter_add.argtypes = [ctypes.c_void_p, ctypes.c_int64]
lib.counter_add.restype = ctypes.c_int
lib.counter_value.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int64)]
lib.counter_value.restype = ctypes.c_int
lib.counter_strerror.argtypes = [ctypes.c_int]
lib.counter_strerror.restype = ctypes.c_char_p # borrowed: do not free
# A Python function exposed as a C function pointer.
OBSERVER = ctypes.CFUNCTYPE(None, ctypes.c_int64, ctypes.c_void_p)
def on_change(new_value, _user_data):
print(f" observer saw {new_value}")
callback = OBSERVER(on_change) # MUST be kept alive: if Python garbage
# collects it, C calls a dangling pointer
lib.counter_set_observer.argtypes = [ctypes.c_void_p, OBSERVER, ctypes.c_void_p]
handle = lib.counter_create(100)
lib.counter_set_observer(handle, callback, None)
lib.counter_add(handle, 50)
out = ctypes.c_int64()
lib.counter_value(handle, ctypes.byref(out))
print(f"value = {out.value}")
status = lib.counter_add(handle, 2**63 - 1) # forces an overflow
print("overflow ->", lib.counter_strerror(status).decode())
lib.counter_destroy(handle) # the LIBRARY frees what it allocated
// ============================ caller.rs ============================
use std::os::raw::{c_char, c_void};
use std::ffi::CStr;
// The opaque handle: an empty enum can never be constructed, which
// enforces at compile time that Rust never dereferences it.
#[repr(C)] pub struct Counter { _private: [u8; 0] }
// extern "C" selects the C ABI; the symbol names are unmangled.
#[link(name = "counter")]
extern "C" {
fn counter_create(initial: i64) -> *mut Counter;
fn counter_destroy(c: *mut Counter);
fn counter_add(c: *mut Counter, delta: i64) -> i32;
fn counter_value(c: *const Counter, out: *mut i64) -> i32;
fn counter_strerror(status: i32) -> *const c_char;
}
fn main() {
unsafe { // FFI is inherently unsafe
let handle = counter_create(100);
if handle.is_null() { return; }
counter_add(handle, 50);
let mut value: i64 = 0;
counter_value(handle, &mut value);
println!("value = {}", value);
let status = counter_add(handle, i64::MAX);
let msg = CStr::from_ptr(counter_strerror(status)); // borrowed
println!("overflow -> {}", msg.to_string_lossy());
counter_destroy(handle); // paired with counter_create
}
let _ = std::ptr::null::<c_void>();
}
# Build the shared library the other languages will load.
$ gcc -fPIC -shared -O2 counter.c -o libcounter.so
# Confirm the exported symbols are plain and unmangled.
$ nm -D libcounter.so | grep ' T '
counter_add counter_create counter_destroy counter_set_observer
counter_strerror counter_value
$ python3 caller.py
observer saw 150
value = 150
overflow -> value would overflow
| Feature | Crosses the boundary well | Why |
|---|---|---|
int32_t, double, pointers | yes | direct machine types |
Opaque handle (void *) | yes | no layout knowledge needed |
| NUL-terminated UTF-8 string | yes | every language converts it |
Function pointer with a void *ctx | yes | context avoids globals |
long, size_t | risky | size differs by platform |
| Struct by value | risky | ABI-specific decomposition |
| Variadic functions | no | most FFIs cannot express them |
Macros, inline functions | no | no symbol exists to call |
| C++ exceptions | no | undefined across the boundary |
Key Takeaways
- C's ABI is the universal interface between languages because its symbols are unmangled and its calling convention is simple and documented.
- Expose fixed-width types, opaque handles, and NUL-terminated strings; avoid macros, inline functions, varargs, and struct-by-value at the boundary.
- The library must free what the library allocated — foreign runtimes have their own allocators, so always export a paired destroy function.
- Return errors as values and never let
exit,abort, or an exception cross the boundary. - Callbacks need a
void *context parameter, must have C linkage, and must be kept alive by the foreign side for as long as C holds the pointer.
🧪 Practice
- Build
libcounter.soand call it from Python withctypes, deliberately declaring a wrongargtypesto see how silently it fails.- Add a function returning a heap-allocated string plus a paired free function, and use both correctly from Python.
- Write a C function taking a callback and invoke it from Python, confirming what happens if the callback object is garbage collected.
- Interview-style: "Why must a C library expose its own free function instead of letting callers use
free?" Hint: consider which allocator each side of the boundary actually uses.
Static vs Shared Libraries
Theory
A library is a bundle of compiled object files packaged for reuse. There are two ways to package one, and the choice affects program size, startup time, deployment, and how security patches reach your users.
A static library (.a on Unix, .lib on Windows) is an archive of .o files. At link time, the linker copies the object files your program actually references directly into the executable. The library is then irrelevant — the binary is self-contained.
A shared library (.so on Linux, .dylib on macOS, .dll on Windows) is loaded at run time. The executable records only a reference to it; the dynamic linker finds and maps it at startup, then resolves the symbols.
The trade-offs are genuinely balanced:
| Static | Shared | |
|---|---|---|
| Binary size | larger (code is copied in) | smaller |
| Memory with many processes | one copy each | one copy total, shared |
| Startup time | faster (nothing to resolve) | slower (load and relocate) |
| Deployment | one self-contained file | library must be present and found |
| Security updates | relink and redistribute | replace the library once |
| Version conflicts | impossible | "dependency hell" |
| Cross-TU optimization | possible with LTO | not across the boundary |
| Call overhead | direct call | indirect through the PLT |
The two decisive rows are usually memory sharing and updates. Fifty processes using a shared libc map the same physical pages of code, and a fix to a vulnerability in it reaches every one of them by replacing a single file. With static linking, each binary carries its own copy and each must be rebuilt.
The counter-argument, which containers strengthened considerably, is that a statically linked binary has no runtime dependencies at all — it runs on any kernel-compatible system with no version negotiation. That predictability is why Go defaults to static linking.
Two mechanics are worth knowing.
Shared libraries need position-independent code (-fPIC), because the library may be loaded at a different address in each process. PIC accesses globals through the Global Offset Table rather than at fixed addresses, which is a small cost that makes sharing possible.
Link order matters for static libraries. The linker processes its inputs left to right, and pulls an object from an archive only to satisfy a symbol that is already unresolved. So libraries must come after the files that use them: gcc main.o -lmylib works, gcc -lmylib main.o typically does not.
Shared libraries carry a soname (libfoo.so.1) recording the ABI version. The convention is a chain of symlinks: libfoo.so for the linker at build time, libfoo.so.1 as the soname burned into binaries, and libfoo.so.1.2.3 as the actual file. Bumping the soname signals an incompatible ABI change, letting old and new versions coexist.
ldd shows what an executable needs, and LD_LIBRARY_PATH (or better, -rpath) controls where the loader searches.
Examples
/* ============================ shapes.h ============================ */
#ifndef SHAPES_H
#define SHAPES_H
double circle_area(double radius);
double rect_area(double w, double h);
const char *shapes_version(void);
#endif
/* ============================ shapes.c ============================ */
#include "shapes.h"
#define PI 3.14159265358979323846
double circle_area(double radius) { return PI * radius * radius; }
double rect_area(double w, double h) { return w * h; }
const char *shapes_version(void) { return "shapes 1.2.3"; }
/* ============================ app.c ============================ */
#include <stdio.h>
#include "shapes.h"
int main(void)
{
printf("using %s\n", shapes_version());
printf("circle r=2.0 -> %.4f\n", circle_area(2.0));
printf("rect 3x4 -> %.1f\n", rect_area(3.0, 4.0));
return 0;
}
# ===================== STATIC LIBRARY =====================
# 1. Compile to an object file (no -fPIC needed).
$ gcc -c -O2 shapes.c -o shapes.o
# 2. Archive it. 'r' = insert, 'c' = create, 's' = write an index.
$ ar rcs libshapes.a shapes.o
$ ar t libshapes.a # list the members
shapes.o
# 3. Link. The LIBRARY MUST COME AFTER the files that use it: the linker
# reads left to right and only pulls members that resolve a symbol
# that is already unresolved.
$ gcc app.c -L. -lshapes -o app_static # works
$ gcc -L. -lshapes app.c -o app_static # typically FAILS: undefined reference
$ ./app_static
using shapes 1.2.3
circle r=2.0 -> 12.5664
$ ldd app_static | grep shapes || echo "no shapes dependency -- code was copied in"
no shapes dependency -- code was copied in
# ===================== SHARED LIBRARY =====================
# 1. -fPIC is REQUIRED: the library may load at a different address in
# each process, so it cannot use absolute addresses for its globals.
$ gcc -c -fPIC -O2 shapes.c -o shapes_pic.o
# 2. Link as a shared object, recording a SONAME (the ABI version that
# gets burned into every binary linked against it).
$ gcc -shared -Wl,-soname,libshapes.so.1 shapes_pic.o -o libshapes.so.1.2.3
# 3. The conventional symlink chain.
$ ln -sf libshapes.so.1.2.3 libshapes.so.1 # what the loader looks for
$ ln -sf libshapes.so.1 libshapes.so # what the linker looks for
# 4. Link the application against it.
$ gcc app.c -L. -lshapes -o app_shared
$ ldd app_shared
libshapes.so.1 => not found # the loader cannot find it yet
# 5. Tell the loader where to look. Three options, worst to best:
$ LD_LIBRARY_PATH=. ./app_shared # per-run, for testing
$ gcc app.c -L. -lshapes -Wl,-rpath,'$ORIGIN' -o app_shared # baked in
$ sudo cp libshapes.so.1.2.3 /usr/local/lib && sudo ldconfig # installed
# ===================== COMPARING THEM =====================
$ ls -l app_static app_shared
-rwxr-xr-x 16104 app_static # carries the library's code
-rwxr-xr-x 15952 app_shared # carries only a reference
$ nm -D libshapes.so.1.2.3 | grep ' T '
0000000000001100 T circle_area
0000000000001120 T rect_area
0000000000001130 T shapes_version
# THE UPDATE ARGUMENT: fix a bug in shapes.c, rebuild ONLY the library,
# and every program using it is fixed with no relinking.
$ gcc -shared -fPIC -O2 shapes.c -Wl,-soname,libshapes.so.1 -o libshapes.so.1.2.4
$ ln -sf libshapes.so.1.2.4 libshapes.so.1
$ LD_LIBRARY_PATH=. ./app_shared # picks up the fix immediately
# app_static still contains the OLD code and must be rebuilt.
WHAT LINKING ACTUALLY DOES
STATIC SHARED
app.o + libshapes.a app.o + libshapes.so
| | | |
+----------+ | (stays on disk)
| |
v v
+-----------------+ +-----------------+
| app_static | | app_shared |
| - main | | - main |
| - circle_area | <- COPIED | - "needs |
| - rect_area | | libshapes.so.1"
+-----------------+ +-----------------+
self-contained |
at startup, ld.so maps the library
and resolves the symbols
MEMORY WITH 50 PROCESSES
static: 50 copies of the library's code in RAM
shared: ONE copy, mapped into all 50 address spaces
LINK ORDER (static archives only)
gcc main.o -lfoo works -- foo's symbols are unresolved when
the linker reaches the archive
gcc -lfoo main.o fails -- nothing was unresolved yet, so
no members were pulled in
Key Takeaways
- A static library is copied into the executable at link time; a shared library is referenced and loaded at run time by the dynamic linker.
- Shared libraries let many processes share one physical copy of the code and let a security fix reach every user by replacing one file — the two strongest arguments for them.
- Static linking produces a self-contained binary with no runtime dependencies and faster startup, which is why containers and Go favor it.
- Shared libraries require
-fPICso they can load at any address, and carry a soname that records the ABI version. - Static archive link order matters: the library must appear after the objects that reference it, because the linker resolves left to right.
🧪 Practice
- Build the same source as both a static and a shared library, link both, and compare binary sizes and
lddoutput.- Demonstrate the link-order rule by putting
-lshapesbefore and afterapp.c, and read the error.- Change the library's implementation, rebuild only the shared library, and confirm the existing binary picks up the change while the static one does not.
- Interview-style: "A security patch is released for a library you use. Which linking strategy gets it to your users faster?" Hint: consider what must be rebuilt and redistributed in each case.
Dynamic Loading with dlopen
Theory
Linking against a shared library at build time still requires the library to exist when the program starts — the loader resolves everything up front, and a missing library means the program will not run at all.
Dynamic loading removes that requirement. With dlopen the program decides at run time which library to load, by name, and looks up symbols by string. Nothing is recorded in the executable, and the library's absence is a condition your code can handle rather than a startup failure.
The API is four functions:
| Function | Purpose |
|---|---|
dlopen(path, flags) | load a library, returning a handle or NULL |
dlsym(handle, "name") | look up a symbol, returning its address or NULL |
dlclose(handle) | release it |
dlerror() | describe the last failure — and clear it |
The two important flags are RTLD_LAZY (resolve symbols on first use, faster) and RTLD_NOW (resolve everything immediately, so errors surface at load time). RTLD_GLOBAL makes the library's symbols available to subsequently loaded libraries.
This is the mechanism behind plugin architectures: a program defines an interface, and third parties ship .so files implementing it. The host scans a directory, loads each one, looks up a known entry point, and calls it. Editors, browsers, audio workstations, and database engines all work this way.
Three details cause the bugs.
Casting a void * to a function pointer is not strictly conforming C. The standard does not guarantee that object and function pointers are interconvertible; POSIX requires it, and the idiomatic workaround is a cast through a union or *(void **)&fn = dlsym(...). In practice a direct cast works everywhere POSIX does, but compilers warn under -pedantic.
Always check dlerror correctly. dlsym legitimately returns NULL for a symbol whose value is null, so the correct test is to call dlerror() first to clear any stale error, then dlsym, then dlerror() again to see whether it failed.
Unloading invalidates everything. After dlclose, every function pointer and every pointer to data in that library dangles. A plugin that registered a callback, allocated memory the host still holds, or stored a pointer to its own static data will crash the host on next use. Plugin systems therefore either never unload, or define a strict teardown protocol.
A stable plugin ABI is the real design work: define a version number the host checks, keep the interface struct's layout fixed, use opaque handles, and have the plugin's memory freed by the plugin.
Compile the host with -ldl and, if the plugin needs to call back into the host's symbols, -rdynamic.
Examples
The plugin interface, shared by host and plugins:
/* ============================ plugin.h ============================ */
#ifndef PLUGIN_H
#define PLUGIN_H
#include <stdint.h>
#include <stddef.h> /* size_t -- headers must be self-contained */
/* Bump this whenever the struct below changes incompatibly. The host
refuses to load a plugin whose version it does not understand --
this single check prevents a whole class of crashes. */
#define PLUGIN_ABI_VERSION 1
typedef struct {
uint32_t abi_version; /* MUST be first and MUST never move */
const char *name;
const char *description;
int (*initialize)(void); /* 0 on success */
int (*process)(const char *input, char *out, size_t out_cap);
void (*shutdown)(void);
} PluginInterface;
/* Every plugin exports exactly this symbol. The host looks it up by name. */
typedef const PluginInterface *(*PluginEntryFn)(void);
#define PLUGIN_ENTRY_SYMBOL "plugin_entry"
#endif
/* ======================== plugin_upper.c ========================
Build: gcc -fPIC -shared plugin_upper.c -o plugin_upper.so */
#include "plugin.h"
#include <string.h>
#include <ctype.h>
#include <stdio.h>
static int upper_init(void) { return 0; }
static void upper_shutdown(void) { }
static int upper_process(const char *in, char *out, size_t cap)
{
size_t n = strlen(in);
if (n + 1 > cap) return -1; /* never overflow the caller */
for (size_t i = 0; i < n; i++)
out[i] = (char)toupper((unsigned char)in[i]); /* cast: ctype rule */
out[n] = '\0';
return 0;
}
static const PluginInterface INTERFACE = {
.abi_version = PLUGIN_ABI_VERSION,
.name = "upper",
.description = "converts text to upper case",
.initialize = upper_init,
.process = upper_process,
.shutdown = upper_shutdown,
};
/* The one exported symbol the host looks for. */
const PluginInterface *plugin_entry(void) { return &INTERFACE; }
The host that loads plugins at run time:
/* ============================ host.c ============================
Build: gcc host.c -ldl -o host */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dlfcn.h> /* dlopen, dlsym, dlclose, dlerror */
#include "plugin.h"
typedef struct {
void *handle;
const PluginInterface *iface;
} LoadedPlugin;
static int load_plugin(const char *path, LoadedPlugin *out)
{
/* RTLD_NOW resolves every symbol immediately, so a missing dependency
fails HERE rather than at some later call. RTLD_LOCAL keeps the
plugin's symbols from leaking into later loads. */
void *handle = dlopen(path, RTLD_NOW | RTLD_LOCAL);
if (!handle) {
fprintf(stderr, "dlopen(%s): %s\n", path, dlerror());
return -1;
}
/* dlsym can legitimately return NULL for a symbol whose value is
null, so the reliable protocol is: clear, call, then check. */
dlerror(); /* clear any stale error */
PluginEntryFn entry;
/* Casting void* to a function pointer is not strictly conforming C.
POSIX guarantees it works; this form avoids the -pedantic warning. */
*(void **)&entry = dlsym(handle, PLUGIN_ENTRY_SYMBOL);
const char *err = dlerror();
if (err) {
fprintf(stderr, "dlsym(%s): %s\n", PLUGIN_ENTRY_SYMBOL, err);
dlclose(handle);
return -1;
}
const PluginInterface *iface = entry();
/* Validate the ABI BEFORE touching any other field -- a mismatched
plugin may have a completely different struct layout. */
if (!iface || iface->abi_version != PLUGIN_ABI_VERSION) {
fprintf(stderr, "%s: ABI version %u, host expects %d\n", path,
iface ? iface->abi_version : 0, PLUGIN_ABI_VERSION);
dlclose(handle);
return -1;
}
if (iface->initialize && iface->initialize() != 0) {
fprintf(stderr, "%s: initialize failed\n", path);
dlclose(handle);
return -1;
}
out->handle = handle;
out->iface = iface;
return 0;
}
static void unload_plugin(LoadedPlugin *p)
{
if (!p->handle) return;
if (p->iface && p->iface->shutdown) p->iface->shutdown();
dlclose(p->handle);
/* After dlclose, EVERY pointer into that library dangles -- including
p->iface and every function pointer inside it. Clear them. */
p->handle = NULL;
p->iface = NULL;
}
int main(int argc, char **argv)
{
if (argc < 2) {
fprintf(stderr, "usage: %s <plugin.so>...\n", argv[0]);
return EXIT_FAILURE;
}
for (int i = 1; i < argc; i++) {
LoadedPlugin p = {0};
/* A missing or broken plugin is a HANDLED CONDITION here, not a
startup failure -- which is the whole point of dlopen. */
if (load_plugin(argv[i], &p) != 0) {
fprintf(stderr, "skipping %s\n\n", argv[i]);
continue;
}
printf("loaded: %s -- %s\n", p.iface->name, p.iface->description);
char output[256];
if (p.iface->process("Hello, Plugins!", output, sizeof output) == 0)
printf(" process() -> \"%s\"\n\n", output);
unload_plugin(&p);
}
return EXIT_SUCCESS;
}
# Build the plugin and the host.
$ gcc -fPIC -shared plugin_upper.c -o plugin_upper.so
$ gcc host.c -ldl -o host
# -rdynamic is needed only if plugins call back into the HOST's symbols:
$ gcc -rdynamic host.c -ldl -o host
$ ./host ./plugin_upper.so
loaded: upper -- converts text to upper case
process() -> "HELLO, PLUGINS!"
# A missing plugin is handled, not fatal:
$ ./host ./nonexistent.so
dlopen(./nonexistent.so): ./nonexistent.so: cannot open shared object file...
skipping ./nonexistent.so
# Inspect what a shared object exports:
$ nm -D plugin_upper.so | grep ' T '
0000000000001139 T plugin_entry
Link-time (-lfoo) | Runtime (dlopen) | |
|---|---|---|
| Library needed at build | yes | no |
| Library needed at startup | yes — or the program will not run | no |
| Missing library | fatal, before main | a handled error |
| Symbols resolved | by the loader, automatically | by you, by name |
| Type checking | full, by the compiler | none — you cast |
| Choose which library at run time | no | yes |
| Used for | ordinary dependencies | plugins, optional features |
Key Takeaways
dlopenloads a library by name at run time anddlsymlooks up symbols by string, so a missing library becomes a handled condition rather than a startup failure.- Use
RTLD_NOWso unresolved symbols fail at load time, and cleardlerror()beforedlsymbecauseNULLcan be a legitimate symbol value. - Casting
void *to a function pointer is a POSIX guarantee rather than a C one; the*(void **)&fnform avoids the pedantic warning. - After
dlclose, every pointer into that library — function pointers, static data, the interface struct — dangles. - A plugin ABI needs a version field checked before anything else, a fixed interface layout, and memory freed by whichever side allocated it.
🧪 Practice
- Write a second plugin that reverses its input, and confirm the host loads and runs both without recompilation.
- Change
PLUGIN_ABI_VERSIONin the host only, and verify the version check rejects the now-stale plugin.- Write a host that scans a directory with
readdirand loads every.soit finds, reporting failures individually.- Interview-style: "What happens to a function pointer obtained from
dlsymafter you calldlclose?" Hint: ask whether the code it points to is still mapped.
Cross-Platform Portability Concerns
Theory
C compiles nearly everywhere, which is often mistaken for "C code runs everywhere". The language is portable; a given program is portable only if it was written to be. The differences that break code fall into a few well-understood groups.
Type sizes. The standard specifies minimum ranges, not exact sizes. int is at least 16 bits, long at least 32. In practice, LP64 (Linux, macOS: long is 8 bytes) and LLP64 (Windows: long is 4 bytes) differ on exactly the type most people reach for. Use <stdint.h> fixed-width types where the size matters, size_t for sizes, and intptr_t when a pointer must be stored in an integer.
Character signedness. Plain char is signed on x86 and unsigned on ARM. Code that stores a byte in a char and compares it against a value above 127, or passes it to <ctype.h> without a cast, behaves differently on the two.
Endianness and alignment. Covered earlier; the additional platform note is that x86 tolerates unaligned access while some ARM and most embedded targets fault or silently produce wrong results.
Paths and filesystems. Separators (/ vs \), case sensitivity, maximum lengths, and reserved names all differ. Windows also distinguishes text and binary file modes, so the b in fopen matters there and nowhere else.
Line endings. \n on Unix, \r\n on Windows. Text-mode I/O translates them on Windows and nowhere else, which is why a file written in text mode on one platform and read in binary mode on another has stray \r bytes.
APIs. POSIX functions do not exist on Windows; Windows APIs do not exist elsewhere. Even shared names differ subtly — strcasecmp versus _stricmp.
The techniques that actually work:
- Isolate platform code behind your own interface. Write
platform_mkdir, implement it twice, and let the rest of the program stay portable. This is far better than scattering#ifdefthrough logic. - Use feature tests rather than platform guesses where possible — check for the capability, not the operating system.
- Rely on the standard library first.
<stdio.h>,<string.h>, and<time.h>work everywhere; reach for POSIX only when you must. - Test on more than one platform. CI on Linux, macOS, and Windows catches these immediately; a single-platform project accumulates assumptions silently.
- Compile with
-Wall -Wextra -Wpedanticand, for maximum strictness,-std=c11rather thangnu11, so GNU extensions do not slip in unnoticed.
The predefined macros (_WIN32, __linux__, __APPLE__) identify the platform, and __STDC_VERSION__ identifies the language level. Note that _WIN32 is defined on 64-bit Windows too — _WIN64 is the 64-bit-specific one.
Examples
A platform abstraction layer — the pattern that keeps #ifdef out of your logic:
/* ============================ platform.h ============================ */
#ifndef PLATFORM_H
#define PLATFORM_H
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
/* ---- identify the platform ONCE, here, and nowhere else ---- */
#if defined(_WIN32) /* defined on 32- AND 64-bit Windows */
# define PLATFORM_WINDOWS 1
# define PATH_SEPARATOR '\\'
#elif defined(__APPLE__)
# define PLATFORM_MACOS 1
# define PATH_SEPARATOR '/'
#elif defined(__linux__)
# define PLATFORM_LINUX 1
# define PATH_SEPARATOR '/'
#else
# error "unsupported platform -- add a case here and in platform.c"
#endif
/* A portable INTERFACE. Callers never see an #ifdef. */
bool plat_mkdir(const char *path);
bool plat_file_exists(const char *path);
const char *plat_name(void);
void plat_sleep_ms(unsigned ms);
int plat_strcasecmp(const char *a, const char *b);
#endif
/* ============================ platform.c ============================ */
#include "platform.h"
#include <string.h>
/* Every #ifdef lives in this ONE file. The rest of the program is
ordinary portable C. */
#ifdef PLATFORM_WINDOWS
# include <windows.h>
# include <direct.h>
# include <io.h>
#else
# include <sys/stat.h>
# include <unistd.h>
# include <strings.h>
#endif
bool plat_mkdir(const char *path)
{
#ifdef PLATFORM_WINDOWS
return _mkdir(path) == 0; /* no mode argument on Windows */
#else
return mkdir(path, 0755) == 0; /* POSIX takes a permission mode */
#endif
}
bool plat_file_exists(const char *path)
{
#ifdef PLATFORM_WINDOWS
return _access(path, 0) == 0;
#else
return access(path, F_OK) == 0;
#endif
}
void plat_sleep_ms(unsigned ms)
{
#ifdef PLATFORM_WINDOWS
Sleep(ms); /* milliseconds */
#else
usleep((useconds_t)ms * 1000); /* microseconds */
#endif
}
int plat_strcasecmp(const char *a, const char *b)
{
#ifdef PLATFORM_WINDOWS
return _stricmp(a, b); /* same job, different name */
#else
return strcasecmp(a, b);
#endif
}
const char *plat_name(void)
{
#if defined(PLATFORM_WINDOWS)
return "Windows";
#elif defined(PLATFORM_MACOS)
return "macOS";
#else
return "Linux";
#endif
}
Inspecting the assumptions a program is making:
#include <stdio.h>
#include <stdint.h>
#include <limits.h>
#include <stddef.h>
#include <assert.h>
int main(void)
{
printf("=== language level ===\n");
#ifdef __STDC_VERSION__
printf(" __STDC_VERSION__ = %ldL\n", __STDC_VERSION__);
#else
printf(" pre-C99 compiler\n");
#endif
printf("\n=== type sizes (the LP64 / LLP64 divide) ===\n");
printf(" char %2zu short %2zu\n", sizeof(char), sizeof(short));
printf(" int %2zu long %2zu <- 8 on Linux, 4 on Windows\n",
sizeof(int), sizeof(long));
printf(" long long %2zu void * %2zu\n",
sizeof(long long), sizeof(void *));
printf(" size_t %2zu ptrdiff_t %2zu\n",
sizeof(size_t), sizeof(ptrdiff_t));
/* The fixed-width types exist precisely so size is never in doubt. */
printf("\n=== fixed-width types: identical everywhere ===\n");
printf(" int32_t %zu int64_t %zu intptr_t %zu\n",
sizeof(int32_t), sizeof(int64_t), sizeof(intptr_t));
printf("\n=== character signedness ===\n");
printf(" plain char is %s (signed on x86, unsigned on ARM)\n",
CHAR_MIN < 0 ? "SIGNED" : "unsigned");
printf(" this is why <ctype.h> calls need (unsigned char) casts\n");
printf("\n=== byte order ===\n");
uint32_t probe = 0x01020304u;
printf(" %s-endian\n",
*(const unsigned char *)&probe == 0x04 ? "little" : "big");
printf("\n=== documented assumptions, enforced at COMPILE time ===\n");
static_assert(CHAR_BIT == 8, "assumes 8-bit bytes");
static_assert(sizeof(int) >= 4, "assumes a 32-bit int");
static_assert(sizeof(void *) >= 4, "assumes 32-bit or wider pointers");
printf(" all static_assert checks passed on this platform\n");
/* Portable printing: the PRI* macros expand to the right specifier. */
printf("\n=== portable format specifiers ===\n");
printf(" size_t needs %%zu, int64_t needs PRId64, pointers need %%p\n");
return 0;
}
| Concern | Varies how | Portable approach |
|---|---|---|
int, long size | 16/32/64 bits | <stdint.h> fixed-width types |
Plain char signedness | signed on x86, unsigned on ARM | use unsigned char for bytes |
| Endianness | little on x86/ARM, big elsewhere | serialize with shifts |
| Unaligned access | fine on x86, faults on some ARM | memcpy into an aligned object |
| Path separator | / vs \ | a platform layer, or / (Windows accepts it) |
| Line endings | \n vs \r\n | binary mode plus explicit handling |
| Text/binary file mode | matters only on Windows | always pass b for binary |
| POSIX APIs | absent on Windows | wrap behind your own interface |
printf for size_t | %zu (C99+) | %zu, and PRI* for fixed-width |
Key Takeaways
- The standard fixes minimum ranges, not sizes:
longis 8 bytes on Linux and 4 on Windows, so use<stdint.h>types wherever the width matters. - Plain
charis signed on x86 and unsigned on ARM, which is why byte values belong inunsigned charand<ctype.h>calls need a cast. - Isolate every platform difference behind your own interface in one file, rather than scattering
#ifdefthrough application logic. _WIN32is defined on 64-bit Windows as well, and an#errorin the final#elseturns an unsupported platform into a clear build failure.- Document platform assumptions with
static_assertso a new target fails to build instead of failing at run time, and test on more than one platform in CI.
🧪 Practice
- Write a program printing every fundamental type's size and the signedness of
char, then compare its output undergccandgcc -m32.- Build a small platform layer providing
plat_mkdirandplat_file_exists, and write an application that uses it with no#ifdefof its own.- Add
static_assertchecks for 8-bit bytes, a 32-bitint, and IEEE-754 doubles, then deliberately break one to see the build fail.- Interview-style: "Your code works on Linux and crashes on an embedded ARM target. What would you check first?" Hint: consider
charsignedness, alignment, and endianness before anything else.
<a id="124-embedded-and-freestanding-c"></a>
12.4 Embedded and Freestanding C
On a microcontroller there is no operating system, often no heap, and a few kilobytes of RAM — but C runs there essentially unchanged, which is why it has dominated embedded work for decades. This section covers talking to hardware directly, the keyword that makes it possible, the constrained world of interrupt handlers, and how a program is laid out in memory when nothing else is there to do it for you.
Memory-Mapped Registers
Theory
A microcontroller has peripherals: timers, serial ports, GPIO pins, analog converters. Your program must configure and control them, and on almost every modern architecture it does so through memory-mapped I/O.
The idea is that the hardware designers wire certain peripheral registers to respond to specific addresses. Writing to address 0x40020014 does not store a value in RAM — it drives voltage onto physical pins. Reading 0x40020010 does not load from memory; it samples the pins' current state. From C's perspective these look exactly like memory, which means no special instructions are needed: a pointer dereference is the entire interface.
A MICROCONTROLLER'S ADDRESS SPACE
0xFFFFFFFF +---------------------------+
| system / debug |
0x40020000 +---------------------------+
| GPIO port A registers | <- writing here drives PINS
0x40000000 +---------------------------+
| other peripherals |
0x20005000 +---------------------------+
| SRAM (variables, stack) | <- ordinary memory
0x20000000 +---------------------------+
| FLASH (code, constants) | <- your program
0x08000000 +---------------------------+
Same pointer syntax, completely different behavior.
Registers have properties ordinary memory does not, and each one breaks an assumption the compiler normally makes:
- Reading can have side effects. Reading a UART's data register removes the byte from the receive FIFO. Reading a status register may clear the flags it reports. So a redundant read is not redundant.
- The value changes without your code writing it. A timer counter increments on its own; an input pin changes when something external happens.
- Write-only and read-only registers exist, and the value read back after a write may differ from what was written.
- Access width and order matter. Some registers must be written as a 32-bit word; some require a specific sequence to unlock.
All of this means the compiler must not cache, eliminate, or reorder these accesses — which is exactly what volatile is for, covered in the next topic.
The idiomatic way to describe a peripheral is a volatile struct whose members match the register layout, placed at the peripheral's base address:
typedef struct {
volatile uint32_t MODER; /* offset 0x00 */
volatile uint32_t OTYPER; /* offset 0x04 */
...
} GPIO_TypeDef;
#define GPIOA ((GPIO_TypeDef *)0x40020000u)
This is what vendor headers (CMSIS for ARM Cortex-M) provide, and it works because the struct members land at exactly the offsets the hardware documents — provided the types are exact-width and no padding is introduced, which is why every member is a uint32_t.
Read-modify-write is the standard operation for configuring a field: read the register, clear the bits you own, OR in the new value, write it back. Doing this without the clear step leaves stale bits set; doing it non-atomically when an interrupt also touches the register is a genuine race.
Examples
#include <stdio.h>
#include <stdint.h>
#include <stddef.h> /* offsetof */
/* =====================================================================
A simplified GPIO peripheral, in the style of ARM Cortex-M vendor
headers. Every member is exact-width so the offsets match the
hardware documentation with no padding.
===================================================================== */
typedef struct {
volatile uint32_t MODER; /* 0x00 mode: 2 bits per pin */
volatile uint32_t OTYPER; /* 0x04 output type: 1 bit per pin */
volatile uint32_t OSPEEDR; /* 0x08 speed: 2 bits per pin */
volatile uint32_t PUPDR; /* 0x0C pull-up/down: 2 bits per pin */
volatile const uint32_t IDR;/* 0x10 input data -- READ ONLY */
volatile uint32_t ODR; /* 0x14 output data */
volatile uint32_t BSRR; /* 0x18 atomic set/reset -- WRITE ONLY */
} GPIO_TypeDef;
/* On real hardware this is the peripheral's fixed address:
#define GPIOA ((GPIO_TypeDef *)0x40020000u)
Here we point at a simulated block so the example runs on a PC. */
static GPIO_TypeDef simulated_port;
#define GPIOA (&simulated_port)
/* --- field definitions, as shifts and masks ------------------------- */
#define MODER_MASK(pin) (0x3u << ((pin) * 2)) /* 2 bits per pin */
#define MODER_INPUT 0x0u
#define MODER_OUTPUT 0x1u
#define MODER_ALTERNATE 0x2u
#define MODER_ANALOG 0x3u
/* BSRR: writing bit n SETS pin n; writing bit n+16 CLEARS pin n.
This exists so a pin can be changed with ONE write -- no read, no
modify, so an interrupt cannot corrupt it halfway through. */
#define BSRR_SET(pin) (1u << (pin))
#define BSRR_RESET(pin) (1u << ((pin) + 16))
/* --- configure one pin's mode: the read-modify-write idiom ---------- */
static void gpio_set_mode(GPIO_TypeDef *port, unsigned pin, uint32_t mode)
{
uint32_t reg = port->MODER; /* 1. READ the whole register */
reg &= ~MODER_MASK(pin); /* 2. CLEAR only our field */
reg |= (mode << (pin * 2)); /* 3. OR in the new value */
port->MODER = reg; /* 4. WRITE it back */
/* Skipping step 2 would leave stale bits set. Doing steps 1-4
non-atomically while an ISR also writes MODER is a real race --
which is exactly why BSRR exists for the data register. */
}
/* --- driving a pin: two ways, one of them atomic ------------------- */
static void gpio_write_odr(GPIO_TypeDef *port, unsigned pin, int high)
{
/* Read-modify-write on ODR: NOT atomic. An interrupt landing between
the read and the write can lose an update to a different pin. */
if (high) port->ODR |= (1u << pin);
else port->ODR &= ~(1u << pin);
}
static void gpio_write_bsrr(GPIO_TypeDef *port, unsigned pin, int high)
{
/* A SINGLE write. The hardware performs the set or clear atomically,
and other pins are untouched. Always prefer this. */
port->BSRR = high ? BSRR_SET(pin) : BSRR_RESET(pin);
}
static int gpio_read(const GPIO_TypeDef *port, unsigned pin)
{
return (port->IDR >> pin) & 1u; /* IDR is read-only */
}
/* Simulate what the hardware does with a BSRR write, so the demo runs. */
static void simulate_bsrr(GPIO_TypeDef *port)
{
uint32_t bsrr = port->BSRR;
port->ODR |= (bsrr & 0xFFFFu); /* low half sets */
port->ODR &= ~((bsrr >> 16) & 0xFFFFu); /* high half clears */
port->BSRR = 0;
}
int main(void)
{
/* Verify the struct layout matches the hardware's documented offsets.
On a real target a mismatch would write to the WRONG register. */
printf("register offsets:\n");
printf(" MODER 0x%02zX OTYPER 0x%02zX OSPEEDR 0x%02zX\n",
offsetof(GPIO_TypeDef, MODER), offsetof(GPIO_TypeDef, OTYPER),
offsetof(GPIO_TypeDef, OSPEEDR));
printf(" PUPDR 0x%02zX IDR 0x%02zX ODR 0x%02zX BSRR 0x%02zX\n",
offsetof(GPIO_TypeDef, PUPDR), offsetof(GPIO_TypeDef, IDR),
offsetof(GPIO_TypeDef, ODR), offsetof(GPIO_TypeDef, BSRR));
printf(" sizeof = %zu bytes (no padding: every member is uint32_t)\n\n",
sizeof(GPIO_TypeDef));
/* Configure pin 5 as an output, pin 0 as an input. */
gpio_set_mode(GPIOA, 5, MODER_OUTPUT);
gpio_set_mode(GPIOA, 0, MODER_INPUT);
printf("MODER after configuring pins 5 and 0: 0x%08X\n", GPIOA->MODER);
printf(" bits 10-11 = %u (output), bits 0-1 = %u (input)\n\n",
(GPIOA->MODER >> 10) & 3u, GPIOA->MODER & 3u);
/* Drive the pin high, then low, using the atomic register. */
gpio_write_bsrr(GPIOA, 5, 1); simulate_bsrr(GPIOA);
printf("after BSRR set: ODR = 0x%08X (pin 5 %s)\n",
GPIOA->ODR, (GPIOA->ODR >> 5) & 1u ? "high" : "low");
gpio_write_bsrr(GPIOA, 5, 0); simulate_bsrr(GPIOA);
printf("after BSRR reset: ODR = 0x%08X (pin 5 %s)\n",
GPIOA->ODR, (GPIOA->ODR >> 5) & 1u ? "high" : "low");
gpio_write_odr(GPIOA, 5, 1);
printf("after ODR |= : ODR = 0x%08X (same result, NOT atomic)\n\n",
GPIOA->ODR);
/* Reading an input pin. */
simulated_port.MODER = simulated_port.MODER; /* keep the simulation honest */
*(uint32_t *)&simulated_port.IDR = 0x0001u; /* pretend pin 0 went high */
printf("pin 0 reads %d\n", gpio_read(GPIOA, 0));
return 0;
}
THE READ-MODIFY-WRITE RACE
MODER controls several pins. Two writers, no atomicity:
main thread interrupt handler
------------------------------- -------------------------------
reg = MODER (0x00000000)
reg2 = MODER (0x00000000)
reg2 |= pin3 bits
MODER = reg2 (0x000000C0)
reg |= pin5 bits
MODER = reg (0x00000400)
^ pin 3's configuration is LOST
THE HARDWARE'S ANSWER: a set/reset register
BSRR = (1 << pin) one write, atomic, other pins untouched
No read, so nothing can be lost between the read and the write.
Key Takeaways
- Memory-mapped I/O wires peripheral registers to fixed addresses, so a pointer dereference is the entire hardware interface — no special instructions required.
- Register reads can have side effects and register values change without your code writing them, which is why
volatileis mandatory here. - Describe a peripheral as a
volatilestruct of exact-width members at a fixed base address, and verify the offsets match the documentation. - Configuring a field is read, clear the field, OR in the new value, write back — omitting the clear leaves stale bits.
- Read-modify-write is not atomic; prefer a hardware set/reset register such as BSRR, which changes a pin with one write that cannot be interrupted halfway.
🧪 Practice
- Define a struct for a UART with status, data, and control registers, and confirm with
offsetofthat the members land at the documented offsets.- Write
set_field(volatile uint32_t *reg, unsigned shift, unsigned width, uint32_t value)implementing a correct read-modify-write for any field.- Explain why
port->ODR |= (1 << 5)can lose an update from an interrupt handler, and rewrite it using a set/reset register.- Interview-style: "Why can reading a hardware register twice give different results with no code in between?" Hint: consider who else is allowed to change that address.
volatile in Hardware Contexts
Theory
The compiler's optimizer assumes that the only way memory changes is through code it can see. Given that assumption, it does entirely reasonable things: keep a variable in a register instead of reloading it, delete a store whose value is never read, hoist an invariant load out of a loop, or collapse repeated reads into one.
Every one of those is wrong for a hardware register.
volatile tells the compiler that an object may change outside its knowledge, and that every access has an observable effect. In consequence it must:
- Read from memory every time the source reads, never reusing a cached value.
- Write to memory every time the source writes, never eliminating a "dead" store.
- Not reorder volatile accesses relative to each other.
- Not merge or split accesses.
Three situations genuinely require it, and they are the only ones:
- Memory-mapped hardware registers, which change independently and whose reads may have side effects.
- Variables shared with an interrupt handler, since the ISR runs between arbitrary instructions.
- Variables modified across
setjmp/longjmp, as covered earlier.
The most important thing to state clearly is what volatile is not. It is not a synchronization primitive. It gives no atomicity — volatile int x; x++ is still a non-atomic read-modify-write. It gives no ordering with respect to non-volatile accesses, and no memory barriers, so it does not make data visible to another CPU core. Using volatile for multithreaded synchronization is a widespread and genuine bug; use <stdatomic.h> for that.
The distinction between the two use cases is worth holding onto:
| Need | Tool |
|---|---|
| Hardware register, changes outside the program | volatile |
| Flag set by an interrupt handler, read by main | volatile sig_atomic_t |
| Shared between threads or CPU cores | _Atomic |
| Both hardware and cross-core | volatile _Atomic |
Placement is the other trap, and it follows the same right-to-left reading as const:
volatile uint32_t *p; /* pointer to volatile data -- the usual case */
uint32_t *volatile p; /* volatile pointer to normal data */
volatile uint32_t *volatile p; /* both */
For a hardware register you want the first: the data is volatile, and the pointer itself is an ordinary variable.
The cost is real — every access becomes a memory operation, so a volatile loop counter can be several times slower. Mark only what needs it: a common and effective pattern is to copy a volatile value into a local once and work with the local.
Examples
#include <stdio.h>
#include <stdint.h>
#include <signal.h>
#include <stdatomic.h>
/* Simulated hardware registers. On real hardware these would be at
fixed addresses; the volatile qualifier is what matters here. */
static volatile uint32_t status_register = 0;
static volatile uint32_t data_register = 0;
static uint32_t plain_variable = 0;
#define STATUS_READY (1u << 0)
/* =====================================================================
1. Polling a hardware flag: the canonical use of volatile.
===================================================================== */
/* WITHOUT volatile the compiler sees a loop whose condition never changes
inside the loop, hoists the load out, and generates an infinite loop:
if (!(reg & READY)) for (;;) ;
This is not a hypothetical -- it is what -O2 actually produces. */
static void wait_for_ready_broken(void)
{
/* while (!(plain_variable & STATUS_READY)) { } <- would hang forever */
printf(" (a non-volatile poll on plain_variable=%u would never exit)\n",
plain_variable);
}
/* WITH volatile the register is re-read on every iteration, so the loop
observes the hardware setting the bit. */
static int wait_for_ready(unsigned max_polls)
{
unsigned polls = 0;
while (!(status_register & STATUS_READY)) { /* re-read EVERY time */
if (++polls >= max_polls) return -1; /* a real driver times out */
}
return 0;
}
/* =====================================================================
2. A read with a SIDE EFFECT: the compiler must not eliminate it.
===================================================================== */
static void drain_fifo(void)
{
/* On real hardware, reading the data register REMOVES a byte from the
receive FIFO. The value is discarded, so without volatile the
compiler would delete the read entirely -- and the FIFO would never
drain. The cast to void says "discarding this on purpose". */
for (int i = 0; i < 4; i++)
(void)data_register;
}
/* =====================================================================
3. Shared with an interrupt handler.
===================================================================== */
/* volatile sig_atomic_t: volatile so the main loop re-reads it, and
sig_atomic_t so the read and write are indivisible. */
static volatile sig_atomic_t interrupt_flag = 0;
static void handler(int sig) { (void)sig; interrupt_flag = 1; }
/* =====================================================================
4. What volatile does NOT do.
===================================================================== */
static volatile int volatile_counter = 0; /* NOT thread-safe */
static _Atomic int atomic_counter = 0; /* thread-safe */
/* =====================================================================
5. Placement: read the declaration right to left.
===================================================================== */
static void placement_rules(void)
{
static volatile uint32_t reg_storage = 0;
static uint32_t plain_storage = 0;
volatile uint32_t *p_to_volatile = ®_storage; /* the usual case:
the DATA is volatile */
uint32_t *volatile volatile_p = &plain_storage; /* the POINTER is volatile */
*p_to_volatile = 1; /* this WRITE is volatile */
*volatile_p = 2; /* this write is ordinary; the pointer is volatile */
printf(" reg_storage = %u (via a pointer to volatile)\n", reg_storage);
printf(" plain_storage = %u (via a volatile pointer)\n", plain_storage);
}
int main(void)
{
printf("POLLING A HARDWARE FLAG\n");
wait_for_ready_broken();
status_register = STATUS_READY; /* pretend hardware set it */
printf(" wait_for_ready -> %s\n", wait_for_ready(1000) == 0 ? "ready" : "timeout");
status_register = 0;
printf(" with the flag clear -> %s (correctly times out)\n",
wait_for_ready(1000) == 0 ? "ready" : "timeout");
printf("\nREADS WITH SIDE EFFECTS\n");
drain_fifo();
printf(" four volatile reads issued; none was optimized away\n");
printf("\nINTERRUPT FLAG\n");
signal(SIGUSR1, handler);
raise(SIGUSR1);
printf(" interrupt_flag = %d (set by the handler, seen by main)\n",
(int)interrupt_flag);
printf("\nWHAT volatile DOES NOT DO\n");
volatile_counter++; /* still THREE operations: load, add, store */
atomic_fetch_add(&atomic_counter, 1); /* genuinely indivisible */
printf(" volatile_counter++ is load-add-store: NOT atomic\n");
printf(" volatile gives no atomicity, no ordering, no barriers\n");
printf(" use _Atomic for threads; volatile is for HARDWARE\n");
printf("\nPLACEMENT\n");
placement_rules();
return 0;
}
# See the optimizer's behavior directly. Without volatile the load is
# hoisted out of the loop and the loop becomes unconditional:
$ gcc -O2 -S -o - poll.c | grep -A6 wait_loop
.L2:
jmp .L2 # infinite loop: the load was hoisted out
# With volatile, the load stays inside:
.L3:
movl status(%rip), %eax # re-read on EVERY iteration
testb $1, %al
je .L3
volatile | _Atomic | Both | |
|---|---|---|---|
| Forces re-read from memory | yes | yes | yes |
| Prevents eliminating stores | yes | yes | yes |
| Indivisible read-modify-write | no | yes | yes |
| Orders non-volatile accesses | no | yes | yes |
| Inserts memory barriers | no | yes | yes |
| Correct for hardware registers | yes | not sufficient alone | yes |
| Correct for thread synchronization | no | yes | yes |
Key Takeaways
volatiletells the compiler an object may change outside its knowledge, forcing a real memory access for every read and write and forbidding reordering among volatile accesses.- It is required for memory-mapped registers, variables shared with an interrupt handler, and variables modified across
longjmp— and for nothing else. - Without it, a polling loop's load is hoisted out and becomes an infinite loop, and a read whose value is discarded is deleted entirely.
volatileprovides no atomicity, no ordering with non-volatile accesses, and no barriers, so it is not a thread-synchronization primitive — use_Atomic.- Read the declaration right to left:
volatile uint32_t *pis a pointer to volatile data, which is what a hardware register needs.
🧪 Practice
- Write a polling loop on a non-volatile global, compile at
-O0and-O2, and compare the generated assembly.- Write a function that reads a
volatilevariable three times and discards the results, and confirm all three loads appear in the assembly.- Increment a
volatile intfrom two threads and show the count is wrong, then fix it with_Atomic.- Interview-style: "Is
volatileenough to share a variable between two threads?" Hint: separate "the compiler must re-read it" from "no other core can interleave".
Interrupt Service Routines
Theory
An interrupt is the hardware's way of demanding attention. When a timer expires, a byte arrives on a serial port, or a pin changes state, the peripheral raises a signal; the CPU finishes its current instruction, saves enough state to resume, and jumps to an interrupt service routine (ISR).
The essential property is that this happens between any two instructions of your main code, with no cooperation from it. An ISR is not called by your program — it interrupts it. That single fact generates every rule about writing them.
Rule one: be fast. While an ISR runs, interrupts of the same or lower priority are typically blocked. A slow ISR delays every other event and can cause data loss — a UART's receive buffer overruns while you were busy. The discipline is to do the minimum in the ISR (read the byte, store it, set a flag) and let the main loop do the work.
Rule two: never block. No waiting for another interrupt, no polling a flag that only an ISR can set, no acquiring a mutex that main-loop code might hold. Each of those deadlocks the system permanently.
Rule three: almost nothing from the standard library is safe. printf is slow and often not reentrant. malloc manipulates global structures and can corrupt the heap if it interrupts itself. Floating point may require saving extra registers the ISR does not save. Treat the library as unavailable.
Rule four: shared variables must be volatile, or the main loop will cache them in a register and never observe the ISR's updates. And because volatile gives no atomicity, any shared object larger than a single atomic-width word needs the access protected — typically by briefly disabling interrupts.
That last point deserves emphasis. A 32-bit counter on an 8-bit micro takes four instructions to read; an interrupt landing in the middle yields a torn read mixing old and new halves. The fix is a critical section: disable interrupts, copy the value, restore the previous interrupt state. Note restore, not enable — blindly enabling interrupts at the end of a nested critical section re-enables them too early.
Rule five: acknowledge the interrupt. Most peripherals latch a flag that must be explicitly cleared, or the ISR is re-entered immediately in an infinite loop. This is the single most common embedded bug.
The classic architecture that follows from all this is the flag-and-defer pattern: ISRs are tiny and only enqueue work, and a main loop drains the queue. It is the same shape as the signal handler pattern from the previous chapter, for the same reason.
Examples
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
/* =====================================================================
On a real target these are intrinsics or inline assembly, e.g.
__disable_irq() / __enable_irq() (ARM CMSIS)
cli() / sei() (AVR)
Here they are simulated so the example runs on a PC.
===================================================================== */
static volatile bool interrupts_enabled = true;
static uint32_t irq_save_disable(void)
{
uint32_t previous = interrupts_enabled;
interrupts_enabled = false;
return previous; /* return the PREVIOUS state */
}
static void irq_restore(uint32_t previous)
{
interrupts_enabled = previous; /* RESTORE, do not blindly enable:
nested critical sections would
otherwise re-enable too early */
}
/* =====================================================================
Shared state. Every object touched by both an ISR and the main loop
must be volatile, or the main loop caches it and never sees updates.
===================================================================== */
#define RX_CAPACITY 64
static volatile uint8_t rx_buffer[RX_CAPACITY];
static volatile uint16_t rx_head = 0; /* written ONLY by the ISR */
static volatile uint16_t rx_tail = 0; /* written ONLY by the main loop */
static volatile uint32_t rx_dropped = 0;
static volatile uint32_t tick_count = 0;
static volatile bool tick_flag = false;
/* Simulated hardware registers. */
static volatile uint32_t uart_status = 0;
static volatile uint8_t uart_data = 0;
#define UART_RX_NOT_EMPTY (1u << 0)
/* =====================================================================
ISR 1: UART receive. Short, non-blocking, no library calls.
===================================================================== */
static void uart_isr(void)
{
/* Read the byte -- on real hardware this also CLEARS the interrupt
flag, which is why acknowledging is often implicit for a UART. */
uint8_t byte = uart_data;
/* A single-producer ring buffer. Because only the ISR writes head
and only the main loop writes tail, no lock is needed. */
uint16_t next = (uint16_t)((rx_head + 1u) % RX_CAPACITY);
if (next == rx_tail) {
rx_dropped++; /* full: drop and COUNT it */
} else {
rx_buffer[rx_head] = byte;
rx_head = next; /* publish AFTER the data */
}
/* NOT here: printf, malloc, blocking waits, long loops, floating point. */
uart_status &= ~UART_RX_NOT_EMPTY;
}
/* =====================================================================
ISR 2: timer tick. Sets a flag; the main loop does the real work.
===================================================================== */
static void timer_isr(void)
{
tick_count++;
tick_flag = true; /* flag and defer */
/* On real hardware you MUST clear the peripheral's pending flag here,
or the ISR is re-entered immediately, forever. This is the single
most common embedded bug:
TIM2->SR &= ~TIM_SR_UIF; */
}
/* =====================================================================
Main-loop side: reading shared state safely.
===================================================================== */
/* A single byte fits in one atomic access, so no critical section is
needed to consume from the ring buffer. */
static bool rx_read(uint8_t *out)
{
if (rx_tail == rx_head) return false; /* empty */
*out = rx_buffer[rx_tail];
rx_tail = (uint16_t)((rx_tail + 1u) % RX_CAPACITY);
return true;
}
/* A 32-bit counter may take several instructions to read on a small MCU.
An interrupt landing mid-read yields a TORN value mixing old and new
halves, so the read must be inside a critical section. */
static uint32_t read_tick_count(void)
{
uint32_t saved = irq_save_disable(); /* enter the critical section */
uint32_t value = tick_count; /* now indivisible */
irq_restore(saved); /* restore the PREVIOUS state */
return value;
}
/* Simulate the hardware delivering bytes and timer events. */
static void simulate_uart_rx(const char *text)
{
for (const char *p = text; *p; p++) {
uart_data = (uint8_t)*p;
uart_status |= UART_RX_NOT_EMPTY;
uart_isr(); /* the hardware would call this */
}
}
int main(void)
{
printf("ISR-driven UART receive with a ring buffer\n\n");
simulate_uart_rx("Hello from the ISR");
for (int i = 0; i < 5; i++) timer_isr();
/* The MAIN LOOP does everything the ISR could not: printing,
allocating, and any work that takes real time. */
printf("main loop draining the buffer: \"");
uint8_t byte;
while (rx_read(&byte)) putchar(byte);
printf("\"\n");
if (tick_flag) {
tick_flag = false; /* clear it before handling */
printf("ticks observed: %u (read inside a critical section)\n",
read_tick_count());
}
/* Overflow accounting: drop and count, never block inside the ISR. */
printf("\nfilling the buffer past capacity...\n");
char flood[RX_CAPACITY + 20];
memset(flood, 'x', sizeof flood - 1);
flood[sizeof flood - 1] = '\0';
simulate_uart_rx(flood);
printf("bytes dropped: %u (the ISR counted them and moved on)\n", rx_dropped);
return 0;
}
WHAT AN INTERRUPT ACTUALLY DOES
main loop
| instruction
| instruction <-- IRQ asserted here
| |
| +--> CPU: finish this instruction, push context,
| jump to the vector table entry
| |
| v
| +-----------+
| | ISR | runs with same/lower priority
| | (short!) | interrupts BLOCKED
| +-----------+
| |
| +<--------------+ restore context, resume
v instruction
The main loop had NO IDEA this happened. That is why:
- shared variables must be volatile (main would cache them)
- multi-word reads need a critical section (they can TEAR)
- the ISR must not block (nothing can preempt it)
THE TORN READ
tick_count is 0x0000FFFF, ISR increments to 0x00010000
main: read low half -> 0xFFFF
<-- ISR runs, counter becomes 0x00010000
main: read high half -> 0x0001
combined -> 0x0001FFFF a value that NEVER EXISTED
| Allowed in an ISR | Forbidden in an ISR |
|---|---|
Read/write volatile shared state | printf and most of stdio |
| Set a flag for the main loop | malloc / free |
| Copy a byte into a buffer | blocking or waiting on anything |
| Clear the peripheral's pending flag | long loops or heavy computation |
| Increment a counter | acquiring a lock main-loop code holds |
| Enqueue work | floating point (on many targets) |
Key Takeaways
- An ISR interrupts the main program between arbitrary instructions, so every shared variable must be
volatileor the main loop will never observe the updates. - Keep ISRs short and non-blocking: do the minimum, set a flag, and defer the real work to the main loop.
- Objects too large for a single atomic access can tear when read concurrently — protect them by disabling interrupts, and restore the previous state rather than blindly enabling.
- Almost nothing in the standard library is safe in an ISR, especially
printfandmalloc. - Clear the peripheral's pending flag before returning, or the ISR will be re-entered immediately and forever.
🧪 Practice
- Extend the ring buffer with a
rx_available()function and confirm it stays correct while the ISR is producing.- Write a 64-bit millisecond counter incremented by a timer ISR, and a main-loop reader that avoids torn reads.
- Explain why the single-producer/single-consumer ring buffer needs no critical section, and what changes if a second producer is added.
- Interview-style: "Your ISR fires once and then the system hangs. What is the most likely cause?" Hint: consider what the peripheral is still asserting.
Avoiding Dynamic Allocation
Theory
Most embedded systems ban malloc outright. That sounds extreme until you look at what dynamic allocation actually promises, and what an embedded system actually needs.
The objections are concrete:
It can fail, and there is nowhere to go. On a desktop, an allocation failure can be reported to a user. On a flight controller there is no user, and no meaningful recovery — the correct response to "out of memory" mid-flight does not exist. A system that cannot fail must not perform operations that can.
Fragmentation is unbounded. Repeated allocation and freeing of mixed sizes leaves the heap perforated: plenty of total free memory, no single block large enough. A desktop process restarts; a device that must run for years cannot. Fragmentation is the failure that appears after six weeks of uptime.
Timing is non-deterministic. malloc may take a few nanoseconds or may walk a long free list. A real-time system must bound its worst case, and "usually fast" is not a bound.
Memory use becomes unprovable. With static allocation the linker reports exactly how much RAM the program uses, and you know at build time whether it fits. With a heap, the answer depends on the runtime path taken.
The alternatives, in rough order of preference:
Static allocation. Declare everything at file scope or as static, sized for the worst case. Memory use is known at link time, allocation cannot fail, and access time is constant. The cost is that the worst case is always occupied.
Pools (fixed-block allocators). Pre-allocate an array of N identical objects plus a free list. Allocation and release are O(1) pointer operations, and because every block is the same size, fragmentation is impossible. This is the standard answer when you need dynamic lifetime without dynamic sizing.
Arena / bump allocators. Carve a large static buffer with a moving pointer. Allocation is one addition; there is no individual free, only a reset of the whole arena. Ideal for per-frame or per-request data with a common lifetime.
Caller-provided buffers. Library functions take a buffer and a size rather than allocating. This pushes the decision to the caller, who knows the lifetime, and keeps the library allocation-free.
Note that the stack is not automatically safe either: it is small and rarely checked, so large local arrays, deep recursion, and variable-length arrays are all hazards. Many embedded coding standards ban recursion and VLAs for exactly this reason.
Examples
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
#include <stddef.h>
/* =====================================================================
1. STATIC ALLOCATION: sized for the worst case, known at link time.
===================================================================== */
#define MAX_SENSORS 8
#define NAME_LEN 16
typedef struct {
char name[NAME_LEN]; /* a fixed array, not a char* */
int32_t reading;
bool active;
} Sensor;
static Sensor sensors[MAX_SENSORS]; /* all the storage there will be */
static size_t sensor_count = 0;
static Sensor *sensor_add(const char *name)
{
if (sensor_count >= MAX_SENSORS) return NULL; /* a bounded, known limit */
Sensor *s = &sensors[sensor_count++];
snprintf(s->name, sizeof s->name, "%s", name); /* bounded copy */
s->reading = 0;
s->active = true;
return s;
}
/* =====================================================================
2. POOL ALLOCATOR: dynamic lifetime, fixed size, O(1), no fragmentation.
===================================================================== */
#define POOL_CAPACITY 16
typedef struct Message {
uint8_t payload[32];
uint16_t length;
struct Message *next_free; /* reused as the free-list link */
} Message;
typedef struct {
Message blocks[POOL_CAPACITY];
Message *free_list;
size_t in_use, high_water;
} MessagePool;
static MessagePool pool;
static void pool_init(MessagePool *p)
{
p->free_list = NULL;
p->in_use = p->high_water = 0;
/* Thread every block onto the free list, back to front so that the
first allocation returns block 0. */
for (size_t i = POOL_CAPACITY; i-- > 0; ) {
p->blocks[i].next_free = p->free_list;
p->free_list = &p->blocks[i];
}
}
/* O(1), always. No searching, no coalescing, no worst case to reason about. */
static Message *pool_alloc(MessagePool *p)
{
Message *m = p->free_list;
if (!m) return NULL; /* exhausted: a KNOWN limit */
p->free_list = m->next_free;
m->next_free = NULL;
p->in_use++;
if (p->in_use > p->high_water) p->high_water = p->in_use; /* track the peak */
return m;
}
static void pool_free(MessagePool *p, Message *m)
{
if (!m) return;
m->next_free = p->free_list; /* push back onto the free list */
p->free_list = m;
p->in_use--;
/* Every block is the SAME SIZE, so a freed block always satisfies any
future request. Fragmentation is structurally impossible. */
}
/* =====================================================================
3. ARENA / BUMP ALLOCATOR: one pointer, no individual free.
===================================================================== */
#define ARENA_SIZE 1024
typedef struct {
unsigned char buffer[ARENA_SIZE];
size_t offset;
} Arena;
static Arena arena;
static void *arena_alloc(Arena *a, size_t size, size_t align)
{
/* Round the current offset up to the required alignment. */
size_t aligned = (a->offset + align - 1) & ~(align - 1);
if (aligned + size > ARENA_SIZE) return NULL; /* bounded by design */
void *p = a->buffer + aligned;
a->offset = aligned + size; /* allocation is ONE addition */
return p;
}
static void arena_reset(Arena *a) { a->offset = 0; } /* frees EVERYTHING */
/* =====================================================================
4. CALLER-PROVIDED BUFFERS: the library never allocates.
===================================================================== */
static int format_reading(char *out, size_t cap, const Sensor *s)
{
/* The caller owns the storage and decides its lifetime. */
return snprintf(out, cap, "%s=%d", s->name, s->reading);
}
int main(void)
{
/* ---- static ---- */
printf("STATIC ALLOCATION\n");
sensor_add("temp"); sensor_add("humidity"); sensor_add("pressure");
sensors[0].reading = 23;
printf(" %zu of %d sensors used, %zu bytes reserved at link time\n",
sensor_count, MAX_SENSORS, sizeof sensors);
char line[32];
format_reading(line, sizeof line, &sensors[0]);
printf(" formatted into a caller buffer: \"%s\"\n", line);
/* ---- pool ---- */
printf("\nPOOL ALLOCATOR\n");
pool_init(&pool);
Message *held[4];
for (int i = 0; i < 4; i++) {
held[i] = pool_alloc(&pool);
if (held[i]) {
held[i]->length = (uint16_t)snprintf((char *)held[i]->payload,
sizeof held[i]->payload,
"message %d", i);
}
}
printf(" allocated 4: in_use=%zu\n", pool.in_use);
pool_free(&pool, held[1]);
pool_free(&pool, held[2]);
printf(" freed 2: in_use=%zu\n", pool.in_use);
/* Exhaustion is a bounded, testable condition -- not a surprise. */
Message *all[POOL_CAPACITY];
size_t got = 0;
while (got < POOL_CAPACITY && (all[got] = pool_alloc(&pool)) != NULL) got++;
printf(" exhausted after %zu more; next alloc returns %s\n",
got, pool_alloc(&pool) == NULL ? "NULL" : "a block");
printf(" high water mark: %zu of %d blocks\n", pool.high_water, POOL_CAPACITY);
/* ---- arena ---- */
printf("\nARENA ALLOCATOR\n");
arena_reset(&arena);
int32_t *numbers = arena_alloc(&arena, 10 * sizeof(int32_t), _Alignof(int32_t));
char *text = arena_alloc(&arena, 64, 1);
if (numbers && text) {
for (int i = 0; i < 10; i++) numbers[i] = i * i;
snprintf(text, 64, "arena offset now %zu", arena.offset);
printf(" numbers[9]=%d, text=\"%s\"\n", numbers[9], text);
}
printf(" used %zu of %d bytes\n", arena.offset, ARENA_SIZE);
arena_reset(&arena); /* frees everything at once, in O(1) */
printf(" after reset: %zu bytes used\n", arena.offset);
return 0;
}
| Strategy | Allocation cost | Fragmentation | Individual free | Fails when |
|---|---|---|---|---|
malloc/free | variable, unbounded | yes | yes | unpredictably |
| Static | none | none | n/a | never (fixed at link) |
| Pool | O(1) | impossible | yes | pool exhausted (known bound) |
| Arena | O(1) | none | no — reset all | arena full (known bound) |
| Caller-provided | caller's problem | caller's problem | caller's | caller decides |
Key Takeaways
- Embedded systems avoid
mallocbecause it can fail with no recovery available, fragments unboundedly over long uptimes, and has non-deterministic timing. - Static allocation makes memory use known at link time and allocation incapable of failing, at the cost of always occupying the worst case.
- A pool allocator gives dynamic lifetime with fixed-size blocks, making allocation O(1) and fragmentation structurally impossible.
- An arena allocates by bumping a pointer and frees everything at once, which suits per-frame or per-request data sharing a lifetime.
- The stack is not automatically safe: deep recursion, large locals, and VLAs overflow it silently, which is why many embedded standards ban them.
🧪 Practice
- Add
pool_available()and a guard inpool_freethat rejects a pointer outside the pool's block array.- Extend the arena with save/restore markers so a scope can release everything it allocated without resetting the whole arena.
- Rewrite a small program that uses
mallocto use a pool instead, and compare peak memory use.- Interview-style: "Why is
mallocbanned in most safety-critical embedded code?" Hint: name three distinct problems, and say which one appears only after weeks of uptime.
Linker Scripts and Sections
Theory
On a hosted system the linker uses a default script and you never think about it. On bare metal there is no operating system to load your program, so you must tell the linker exactly where everything goes: which addresses are flash, which are RAM, where the stack starts, and what the CPU should execute first.
A compiled object file is divided into sections, each with a distinct purpose:
| Section | Contains | Typically lives in |
|---|---|---|
.text | executable code | flash / ROM |
.rodata | const data, string literals | flash / ROM |
.data | initialized globals | RAM, initialized from flash |
.bss | zero-initialized globals | RAM, zeroed at startup |
.stack / .heap | runtime storage | RAM |
The .data row is the interesting one. A variable like int counter = 42; must live in RAM (it is writable) but its initial value must survive power-off (so it must be in flash). The linker therefore places the variable in RAM but stores its initial value in flash, and startup code copies it across before main runs. Similarly, .bss holds variables initialized to zero — storing thousands of zeros in flash would be wasteful, so only the address range is recorded and startup code zeroes it.
That copy-and-zero step is part of the C runtime startup, and on bare metal you often write it yourself. Until it has run, global variables do not have their initial values — which is why code that runs before it must not depend on them.
A linker script describes two things:
MEMORY declares the physical regions — name, permissions, origin address, and length.
SECTIONS maps input sections into those regions, and defines symbols marking the boundaries. Those symbols (_sdata, _edata, _sbss, _ebss) are what the startup code uses to know what to copy and what to zero.
A subtlety worth knowing: >FLASH AT>FLASH versus >RAM AT>FLASH. The first address is the VMA (where the section will be at run time) and the second is the LMA (where it is stored in the image). For .data they differ, and that difference is exactly what the startup copy resolves.
On ARM Cortex-M, the first thing at the flash base is the vector table: an array whose first entry is the initial stack pointer and whose second is the reset handler's address. The CPU loads both directly from those addresses on reset, before any code has run.
You control section placement from C with __attribute__((section(".name"))), which is how you put an interrupt vector table at a fixed address, place a lookup table in flash, or reserve a RAM region that survives a reset.
arm-none-eabi-size and objdump report exactly how much flash and RAM the result uses, which is the build-time proof that the program fits.
Examples
A linker script for a Cortex-M microcontroller:
/* ======================== device.ld ======================== */
/* Where execution begins -- the linker keeps everything reachable
from this symbol and can warn about unused sections. */
ENTRY(Reset_Handler)
/* The physical memory regions of this specific chip. */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K /* read + execute */
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K /* read + write */
}
_stack_size = 0x2000; /* 8 KB of stack */
SECTIONS
{
/* ---- vector table: MUST be first, at the flash base address.
On reset the CPU loads the initial stack pointer from
offset 0 and the reset handler address from offset 4. ---- */
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector)) /* KEEP: never garbage-collect this,
even though no C code references it */
. = ALIGN(4);
} >FLASH
/* ---- code and read-only data: execute in place from flash ---- */
.text :
{
. = ALIGN(4);
*(.text) /* all code */
*(.text*) /* and per-function sections */
*(.rodata) /* const data, string literals */
*(.rodata*)
. = ALIGN(4);
_etext = .; /* end of text: where .data's
initial values are stored */
} >FLASH
/* ---- initialized globals: they LIVE in RAM (VMA) but are
STORED in flash (LMA). Startup copies flash -> RAM. ---- */
.data : AT(_etext) /* AT() sets the load address */
{
. = ALIGN(4);
_sdata = .; /* start, used by the startup copy */
*(.data)
*(.data*)
. = ALIGN(4);
_edata = .; /* end */
} >RAM
/* ---- zero-initialized globals: no bytes in the image at all,
just an address range the startup code clears. ---- */
.bss :
{
. = ALIGN(4);
_sbss = .;
*(.bss)
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} >RAM
/* ---- reserve the stack and fail the BUILD if it does not fit ---- */
.stack :
{
. = ALIGN(8);
. = . + _stack_size;
. = ALIGN(8);
_estack = .; /* the stack grows DOWN from here */
} >RAM
}
The startup code that runs before main:
/* ======================== startup.c ========================
Runs on reset, before the C environment exists. Until the copy and
zero loops below have finished, GLOBAL VARIABLES DO NOT HAVE THEIR
INITIAL VALUES -- so nothing here may depend on them. */
#include <stdint.h>
/* These symbols are defined by the LINKER SCRIPT, not by any C file.
Only their ADDRESSES are meaningful, which is why they are declared
as arrays and used with & or as bare addresses. */
extern uint32_t _etext, _sdata, _edata, _sbss, _ebss, _estack;
extern int main(void);
void Reset_Handler(void)
{
/* 1. Copy .data from its load address in FLASH to its run address
in RAM. This is what gives int x = 42; its value. */
uint32_t *src = &_etext; /* stored in flash after .text */
uint32_t *dst = &_sdata; /* destination in RAM */
while (dst < &_edata) *dst++ = *src++;
/* 2. Zero the .bss region. C guarantees uninitialized globals are
zero, and this loop is what actually makes that true. */
for (uint32_t *p = &_sbss; p < &_ebss; p++) *p = 0;
/* 3. Now the C environment is valid: globals have their values. */
main();
/* main must never return on bare metal -- there is nothing to
return to. Trap here rather than executing whatever follows. */
for (;;) { }
}
static void Default_Handler(void) { for (;;) { } }
/* Weak aliases: a real driver can define NMI_Handler and override this. */
void NMI_Handler(void) __attribute__((weak, alias("Default_Handler")));
void HardFault_Handler(void) __attribute__((weak, alias("Default_Handler")));
void SysTick_Handler(void) __attribute__((weak, alias("Default_Handler")));
/* The vector table, forced into the section the linker script places
at the flash base. 'used' stops the compiler discarding it, since no
C code ever references it. */
__attribute__((section(".isr_vector"), used))
const void *const vector_table[] = {
(const void *)&_estack, /* 0x00: the CPU loads SP from here */
(const void *)Reset_Handler,/* 0x04: and PC from here */
(const void *)NMI_Handler,
(const void *)HardFault_Handler,
/* ... the rest of the device's interrupt vectors ... */
};
Placing data in specific sections from C:
#include <stdio.h>
#include <stdint.h>
/* Each of these lands in a different section, which the linker script
then maps to flash or RAM. */
int initialized_global = 42; /* .data -- RAM, copied from flash */
int zero_global; /* .bss -- RAM, zeroed at startup */
const int constant_global = 100; /* .rodata-- stays in flash */
static int file_static = 7; /* .data, but not exported */
void some_function(void) { } /* .text -- flash */
/* Explicit placement: a lookup table kept in flash to save RAM. */
__attribute__((section(".rodata")))
static const uint8_t sine_table[8] = { 128, 218, 255, 218, 128, 37, 0, 37 };
/* A region that survives a warm reset, if the linker script excludes it
from the .bss zeroing loop. */
__attribute__((section(".noinit")))
static uint32_t reset_counter;
int main(void)
{
printf("SECTION PLACEMENT\n");
printf(" initialized_global = %d (.data: RAM, copied from flash)\n",
initialized_global);
printf(" zero_global = %d (.bss: RAM, zeroed at startup)\n",
zero_global);
printf(" constant_global = %d (.rodata: stays in flash)\n",
constant_global);
printf(" file_static = %d (.data, internal linkage)\n",
file_static);
printf(" sine_table[2] = %u (.rodata via an attribute)\n",
sine_table[2]);
printf("\nWHY .data AND .bss DIFFER\n");
printf(" .data costs flash (the initial values) AND RAM\n");
printf(" .bss costs ONLY RAM -- storing thousands of zeros in flash\n");
printf(" would be pure waste, so only the range is recorded\n");
(void)reset_counter;
return 0;
}
# What the image actually costs. This is the build-time proof that the
# program fits the device.
$ arm-none-eabi-size firmware.elf
text data bss dec hex filename
12456 108 2048 14612 3914 firmware.elf
# ^ ^ ^
# | | +-- RAM only, zeroed at startup
# | +---------- RAM at run time, AND 108 bytes of flash for the values
# +------------------ flash only
#
# FLASH used = text + data = 12564 bytes
# RAM used = data + bss = 2156 bytes (plus stack and heap)
# List the sections and both addresses:
$ arm-none-eabi-objdump -h firmware.elf
Idx Name Size VMA LMA Algn
0 .isr_vector 000001b0 08000000 08000000 2**2
1 .text 000030a8 080001b0 080001b0 2**2
2 .data 0000006c 20000000 08003258 2**2 <- VMA != LMA
# ^^^^^^^^ stored in flash, lives in RAM
$ arm-none-eabi-nm firmware.elf | grep -E '_sdata|_edata|_sbss|_ebss'
20000000 D _sdata
2000006c D _edata
2000006c B _sbss
2000086c B _ebss
# Build with a custom script:
$ arm-none-eabi-gcc -T device.ld -nostdlib startup.c main.c -o firmware.elf
WHY .data NEEDS A COPY AT STARTUP
int counter = 42; must be WRITABLE (so: RAM)
must SURVIVE POWER-OFF (so: flash)
FLASH (persistent) RAM (writable, volatile)
+---------------------+ +---------------------+
| .isr_vector | | |
| .text (code) | | |
| .rodata (constants) | | |
+---------------------+ +---------------------+
| initial values | COPY | .data counter = 42 | <- VMA
| for .data (LMA)| ------> | |
+---------------------+ +---------------------+
| .bss (zeroed) | <- ZERO loop
+---------------------+
| heap (often unused) |
| | |
| v |
| ^ |
| | |
| stack (grows DOWN) | <- _estack
+---------------------+
Reset_Handler does the COPY and the ZERO, then calls main().
Before those loops run, globals hold whatever was in RAM at power-on.
Key Takeaways
- Object files are split into sections:
.textand.rodatalive in flash,.dataand.bsslive in RAM, and the linker script maps each to a physical region. .datahas a different load address (flash) from its run address (RAM), and startup code copies it across — which is what gives initialized globals their values..bsscosts no space in the image because only its address range is recorded; startup zeroes it, which is what makes C's zero-initialization guarantee true.- The linker script defines boundary symbols such as
_sdataand_ebssthat the startup code uses; those symbols have meaningful addresses, not values. - Place data explicitly with
__attribute__((section(...))), useKEEPfor tables no code references, and read total flash and RAM use fromsizeoutput.
🧪 Practice
- Compile a program with an initialized global, an uninitialized global, and a
constglobal, then useobjdump -handnmto find which section each landed in.- Add a large
constarray and a large zeroed array, and observe how each changes thetext,data, andbssfigures fromsize.- Write the
.datacopy and.bsszero loops from scratch, using linker-defined symbols, and explain why they must run beforemain.- Interview-style: "Why does an initialized global need both flash and RAM, while a zero-initialized one needs only RAM?" Hint: ask where each one's initial value has to come from after a power cycle.