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.
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:
a[i] and i[a] are the same thing and other details
that look like trivia until they compile.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:
printf trap documented in NOTES.md.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:
Functions and structs, and that is all. No classes, no modules, no namespaces, no generics, no interfaces.
Organisation is achieved by convention:
foo.h declares, foo.c defines. The header is the
interface; there is no language support for that claim.sqlite3_open, PyObject_New, curl_easy_setopt —
because there is exactly one global namespace for symbols.static as "private". At file scope, static means "not visible to the linker".
Almost every function in logagg.c is static for this reason, and it is the only
encapsulation the language offers.struct Table; in the header without the
body and callers cannot see the fields. This is how SQLite and libcurl present an API.struct full of function pointers — the kernel's
struct file_operations, SQLite's sqlite3_io_methods — is dynamic dispatch assembled
by hand: the caller stores the operations, the callee calls through them. Every C object
system (GObject, COM's C bindings, the Windows driver model) reduces to this pattern, and
it is how a kernel of thirty thousand source files gets by without classes.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:
struct file_operations
— the vtable idiom at production scale.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:
malloc returns NULL, fopen
returns NULL, strtol returns 0 — which is indistinguishable from a successful parse
of "0".errno and check it. Global, thread-local in practice, and only meaningful
immediately after the call.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:
setjmp(3) — the man page's
CAVEATS section is a compact list of everything the mechanism makes undefined or
unspecified.errno disciplines that real APIs follow.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:
pthreads(7) — what POSIX
threads actually share and keep private, including the per-thread errno that Q5 relies
on.<stdatomic.h> — the usable
reference for the C11 atomics and memory orderings.logagglogagg.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:
fgetc is not the bottleneck it looks like. read_line reads a byte at a time, but
stdio reads a block and the per-byte call only drains the buffer. The professional
alternatives are POSIX getline (unportable to MSVC) and fread-plus-memchr, or
mmap on systems that have it — all faster, none worth it at this size.split_fields destroys the line. It writes NUL bytes into the buffer and returns
pointers into it. Zero allocation, and the reason the table must copy its keys (Q3). This
in-place, borrow-everything style is completely normal C and completely alien to every
other track.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.
Ranked by how much professional time they cost, not by how often tutorials mention them.
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.realloc pattern above is one line away from a leak at all times.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.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.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.
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.
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:
logagg.c has an ownership
discipline — the table owns its keys, the line buffer owns its bytes, the reason strings
are static and owned by nobody. It just lives in comments. Rust's borrow checker is that
same discipline moved into the compiler; Java's GC is that discipline deleted by making
it the runtime's problem. You will read both differently having written the comment
version.dict is not just
shorter than 60 lines of hash table — it is 60 lines of hash table that the whole
ecosystem agrees on, so every library interoperates. C's ecosystem fragments precisely
where its standard library stops.goto is the ancestor of RAII, defer, try-with-resources, and
Drop. All four exist because the C pattern works and nobody can be trusted to write
it every time.ctypes and Rust writes
extern "C", they are both speaking the contract from Q2 — symbol names, a calling
convention, a struct layout. The first question to ask of any new language's practicality
is still "can it call C".