curayious Language Lab

C — The Substrate

Why first. Everything later in the series is, in some sense, a reaction to C. Rust's ownership rules are answers to C's bugs. Java's GC is an answer to C's free. Go's austerity is an answer to C++'s answer to C. Read those languages before C and they look arbitrary; read them after and every design decision has a reason with a name.

Q1 · Origin and niche

Dennis Ritchie, Bell Labs, 1972, to write Unix in. The lineage matters: C descends from BCPL and from Ken Thompson's B, both typeless languages built for machines measured in kilowords. Ritchie's own account — The Development of the C Language, presented at HOPL-II in 1993 — is the primary source and a short, readable one.

C was designed as a portable assembler — thin enough that a competent programmer could predict the generated code, portable enough that Unix could leave the PDP-11. It succeeded so completely that the C ABI is now the interoperability layer of computing: Python, Java, Go, and Rust all call into C libraries, and none of them can call into each other without going through C.

Two consequences are worth noting. First, "C" is whichever standard you pin: K&R (1978) documented the language; ANSI X3J11 produced C89 — function prototypes, the syntax everyone now writes, are a standardization artifact — then ISO C90, C99, C11, C17, and C23. Much of what the language is in practice is decided by which of those a -std= flag selects, which is why Q2 pins one. Second, the standards process explains the language's conservatism: features arrive when existing compilers already agree on them, not before.

Who pays for it today: operating system kernels (Linux, Windows, the BSDs), embedded and automotive (where MISRA C is the norm rather than a suggestion), database engines (SQLite, PostgreSQL, Redis), language runtimes (CPython, the JVM's native layer), networking stacks, and every device with a driver. It is an infrastructure language, and its niche is not shrinking so much as being contested at the edges by Rust.

Further reading:

Q2 · Hello, build

There is no dependency manager. Dependencies are the platform's problem (apt, vcpkg, pacman) or they are vendored into your tree; no mainstream language shipped since has repeated the arrangement. Understanding why requires seeing what a build is:

The unit of compilation is the translation unit: one .c file plus everything its #includes pull in. Each translation unit compiles in isolation; calls into other files are just extern declarations that the linker later satisfies by name. That weakness is also the stability. Because the contract is only names, a calling convention, and a struct-layout agreement, the C ABI became the lingua franca every FFI speaks — Python's ctypes, JNI, Rust's extern "C" all target it. A language that cannot call C barely exists industrially.

On this machine the compiler comes from MSYS2's UCRT64 environment — the same toolchain the COBOL track uses for GnuCOBOL, which is why it was already installed:

gcc -std=c99 -Wall -Wextra -O2 -o build/logagg.exe logagg.c
.\build.ps1

Notes on the flags, because C's defaults are from 1989 and will not protect you:

Flag Why
-std=c99 Pins the dialect. Without it you get the compiler's default, which varies.
-Wall -Wextra Not optional. Neither is on by default, and between them they catch most of what a modern compiler would simply reject.
-O2 Also changes semantics in practice: optimisers exploit undefined behaviour, so bugs that hide at -O0 appear here. Test at both.

Worth adding once you are past the first build: -fsanitize=address,undefined (catches use-after-free and UB at runtime; see Q8) and -Werror in CI. Past a handful of files the convention is a real build system — make, CMake, or Meson — with pkg-config carrying the -I/-L/-l flags between libraries. Shipping is a single static binary with no runtime, which is why C is still the answer for anything that has to run inside a container image measured in megabytes.

Further reading:

Q3 · Type and memory model

A variable is a name for a region of memory. Not a reference to an object, not a binding in a namespace — an address and a size. Every confusing thing about C follows from taking that literally.

The address space the name refers into, with logagg's own objects placed where they live:

Typing is static but weak. The compiler knows every type, and will let you discard that knowledge with a cast and say nothing:

char *p = (char *)42;    /* compiles clean; dereferencing it is a crash */

Memory is manual. Three allocation regions, and knowing which one a pointer is in is your job, tracked in your head and your comments:

Region Lifetime How it bites
Static Whole program Not reentrant; global mutable state
Stack (automatic) The enclosing block Returning a pointer to a local — the classic beginner bug
Heap (malloc) Until you free it Leak if you never free, crash if you free twice

The third region is where logagg does all its real work, and the two places it matters are worth reading in the source:

char *p = realloc(l->buf, cap);
if (!p) return 0;          /* realloc returning NULL leaves the ORIGINAL block valid */
l->buf = p;                /* assigning straight into l->buf would have leaked it */

and

n->service = malloc(strlen(service) + 1);   /* the table OWNS its keys */
strcpy(n->service, service);

The second one is the subtle one. The service pointer handed to table_get points into the line buffer, which is overwritten on the next read. The whole ownership picture in one drawing:

line.buf — one block; split_fields writes NULs into it, read_line rewrites it

  "2026-09-12T08:15:22Z│api-gateway│GET│/v1/accounts│200│12.480"     (│ = the written NULs)
    ▲                   ▲           ▲    ▲            ▲     ▲
    fields[0]           fields[1]   …                 fields[4], [5]
    borrowed pointers — all dead after the next read_line()

table.buckets[fnv1a(service) & 63] ─► Node ─► service ─► "api-gateway"
                                      a separate malloc — owned, outlives the line

Store the borrowed pointer and every key in the hash table silently becomes the last line of the file — a program that produces a plausible-looking report with entirely wrong numbers. Nothing in the type system distinguishes a pointer you own from a pointer you are borrowing; the distinction lives in comments and convention. Rust's ownership model is the argument that it belongs in the compiler instead, and that argument reads differently once you have maintained the comment version.

One more distinction the standard makes and tutorials blur: there are three tiers of "not specified", and they mean different things.

Term What the standard guarantees Example
Implementation-defined The implementation must choose and document it Whether plain char is signed; sizeof(int)
Unspecified A small set of choices; no documentation owed Order of evaluation of f(a(), b())
Undefined Nothing. The program has no meaning Signed overflow, use-after-free, strict-aliasing violation

The last tier is the one with teeth, and it is why a bug can disappear at -O0 and reappear at -O2 (Q8). Annex J of the standard lists roughly two hundred kinds of undefined behaviour; the list exists to be consulted, not memorised.

Further reading:

Q4 · The unit of abstraction

Functions and structs, and that is all. No classes, no modules, no namespaces, no generics, no interfaces.

Organisation is achieved by convention:

logagg.c builds one real abstraction — the hash table — out of a struct and four functions, and the shape (table_get, table_free, a Node with a next) is what every C data structure looks like.

Further reading:

Q5 · The error model

Return codes, and a discipline. The language provides nothing: no exceptions, no Result, no stack unwinding. There are four prevailing mechanisms and a codebase usually commits to a subset:

  1. Return the value, signal error by a sentinel. malloc returns NULL, fopen returns NULL, strtol returns 0 — which is indistinguishable from a successful parse of "0".
  2. Return a status code, write the result through a pointer. What most modern C APIs do.
  3. Set errno and check it. Global, thread-local in practice, and only meaningful immediately after the call.
  4. Jump out. setjmp/longjmp — a non-local return that unwinds to a saved stack frame. It is C's actual escape hatch (libpng routes every error through it), and it composes badly with everything above: a longjmp taken over live allocations leaks them. It survives as a library-level mechanism rather than a general idiom.

logagg uses the first for allocation and the second for classify(), which returns NULL for "fine" and a string literal for the reason:

static const char *classify(int nfields, char *f[MAXFIELDS])
{
    if (nfields != 6) return "FIELDS";
    ...
    return NULL;
}

Returning a string literal is a deliberate small trick: it has static storage duration, so there is no ownership to track and no allocation that could itself fail on the error path. Error paths that allocate are how error handling becomes a second source of bugs.

The cost of all this is visible in main: every allocation is followed by a check and a goto done. That goto is not bad style — in C it is the idiom for a single cleanup path, and it is what the Linux kernel does everywhere. Without it you either leak on the error path or duplicate the cleanup at every return.

Compare directly with the other tracks:

Track Line 31 has status 20x
C classify returns "STATUS"; caller checks and continues
Java 8 classify returns a String or null; a try/catch would be the alternative
Python Same, but int("20x") would have raised ValueError had the regex not guarded it

Further reading:

Q6 · The concurrency model

C99 has none. C11 added <threads.h> and <stdatomic.h>, but implementors were allowed to decline (__STDC_NO_THREADS__, __STDC_NO_ATOMICS__) and many did — Microsoft shipped the threading half only in 2023. In practice C concurrency is POSIX threads (pthread_create, pthread_mutex_t) or the Win32 equivalents, plus atomics for lock-free work.

What that means for --shard N (SPEC 6): give each thread its own Table, run with no shared mutable state at all, and merge after pthread_join:

                        access.log
                            │ split into N chunks
            ┌───────────────┼───────────────┐
            ▼               ▼               ▼
        worker 0        worker 1        worker N-1     pthread_create
        Table 0         Table 1         Table N-1      no shared state
            └───────────────┼───────────────┘
                            ▼
                  pthread_join, merge the N tables
                            ▼
                  qsort → stdout            unchanged from the serial run

The alternative — one shared table behind a mutex — is simpler to write, slower, and the version where a missing pthread_mutex_lock produces a program that is correct 999 runs out of 1000.

The thing C makes easy to get wrong is not locking. It is that a data race is undefined behaviour, not merely a wrong answer. The standard is explicit: if two threads access the same object and at least one writes, without ordering, the entire execution is undefined. The optimiser may then assume races never happen, so a racy program can misbehave in ways that correspond to no interleaving you could draw on a whiteboard. Hans Boehm's Threads Cannot Be Implemented as a Library is the paper that established why — that a threads package bolted onto a language with no memory model cannot guarantee anything, which is the argument the C11 memory model exists to answer.

Further reading:

Q7 · The idiom test — logagg

logagg.c · annotated walkthrough in NOTES.md

PASS  c

The C version is ~330 lines against Python's ~190 and Java's ~210, and the difference is three things the other two got from their standard library:

What C lines Python
Growable line buffer ~45 (Line, line_push, read_line) for line in fh
Hash map ~60 (Table, fnv1a, table_get, table_free) dict
Split on whitespace ~15 (split_fields) line.split()

That is a fair measure of what a standard library is worth. It is also why the C version is the only track where a subtle bug is plausible — the other two cannot get the hash table wrong, because they did not write one.

The table, for reference:

table.buckets[64]                  bucket b = fnv1a(service) & 63

  [0]     [1]     …      [b]     …     [63]
   ·       │             │
           ▼             ▼
        ┌──────┐      ┌──────┐         ┌──────┐
        │ Node │      │ Node │─next───►│ Node │─next───► NULL
        │ svc* │      │ svc* │         │ svc* │
        │count │      │count │         │count │
        │next ─┼──►…  │next ─┼──► NULL │ …    │
        └──────┘      └──────┘         └──────┘

  svc* ──► a separately malloc'd copy of the service name (owned by the Node)

Three implementation notes worth more than the diagram:

And the one absence: no regex. The standard library has none. POSIX <regex.h> exists but is not portable to MSVC, so ^[0-9]+\.[0-9]{3}$ is hand-written with strcspn and a digit check. For a pattern this small that is arguably clearer than the escape soup — and for a pattern twice this size it would be a liability.

Q8 · How it fails

Ranked by how much professional time they cost, not by how often tutorials mention them.

  1. Buffer overrun. strcpy, sprintf, gets — no bounds, ever. Both Microsoft and the Chromium project put memory-safety bugs at about 70% of their serious vulnerabilities (Chromium's analysis of 912 high/critical bugs: ~70% memory unsafety, half of those use-after-free; Microsoft's same figure is what pushed the industry toward Rust). Heartbleed — CVE-2014-0160 — was the rarer direction: an over-read, a memcpy length taken from the attacker. logagg uses snprintf with an explicit size everywhere, and %-20.20s with the precision, because %-20s pads but does not truncate.
  2. Use-after-free and double-free. Nothing in the type system tracks lifetime. The realloc pattern above is one line away from a leak at all times.
  3. Undefined behaviour is not "unspecified". Signed overflow, reading uninitialised memory, and strict-aliasing violations do not produce a wrong answer — they give the compiler permission to assume the code is unreachable. x + 1 > x can be folded to true for signed int; a bug can vanish at -O0 and reappear at -O2; and "it works on my machine" is not evidence of anything.
  4. Integer promotion. char compared against EOF, isspace(*p) on a plain char with the high bit set (UB — which is why the source casts to unsigned char every time), and size_t being unsigned so i >= 0 is always true.
  5. The null terminator is a convention, not a fact. Every string operation is O(n) and every one of them trusts a byte you may not have written.

The discipline that closes the gap is a toolchain, not a habit:

Layer Tool What it catches
Compile time -Wall -Wextra, -Werror, gcc -fanalyzer, clang-tidy Warnings and shallow static checks
Runtime, instrumented -fsanitize=address,undefined (ASan/UBSan) Overruns, UAF, UB as it happens
Runtime, uninstrumented valgrind (Linux/WSL) What the sanitizers miss, ~10× slower
Adversarial input afl++, libFuzzer The inputs you did not think of — a stdin-driven program like logagg is a natural fuzz target
Process CERT C, MISRA C The rules a team can actually enforce

Professional C is the language plus this apparatus. The compiler alone is not enough, and the market for "safe subsets" — MISRA in automotive, CERT in security work — exists precisely because the defaults are not survivable at scale.

Q9 · Read real code

Recommended: SQLite's allocator layer — src/mem1.c on the GitHub mirror, or the same file in the canonical Fossil tree. It is the low-level driver behind sqlite3_malloc: every allocation in SQLite goes through one auditable chokepoint, out-of-memory is tested by an instrumented allocator that fails the Nth allocation, and the project behind it ships about 590 times as much test code as library code. Read sqlite3MemMalloc and note how every allocation is counted and checked — the discipline that C requires and does not enforce.

Alternative, shorter: Redis's sds.c — the simple dynamic string library. It is logagg.c's Line struct, done properly, by someone who needed it to be fast. Read it directly against the Line code here; the differences are the lesson.

Q10 · What transfers

Every abstraction in every later language is a named solution to a C problem, and knowing the problem is what makes the solution legible.

Concretely, carry these forward: