Procedural Programming with C · Professional · Week 42

Reading Excellent C

You have written C for forty-one weeks. Professionals read far more than they write, and the best available teacher is code that has survived twenty years of production use. This week is about reading it — and about why the language has the shape it does.

By the end of this week you can
  • Explain C's oddities historically rather than memorizing them as rules.
  • Say why undefined behavior exists and what the standard was trading away.
  • Orient yourself in an unfamiliar codebase of a hundred thousand lines.
  • Reconstruct a module's ownership and error conventions from the code alone.
  • Judge which idioms are worth borrowing and which are historical accidents.

1Why C looks the way it does

Week 2 placed C on a PDP-11 in 1972: a machine with 64 KB of address space, whose compiler had to fit in a fraction of it. Nearly every feature that seems strange is explicable from that context, and knowing the reason converts a rule you memorize into one you can reason about.

OddityReason
Arrays decay to pointersCopying an array on every call was unaffordable. Passing an address was one word.
Strings end in '\0'A length prefix costs a byte per string and caps the length. The PDP-11 had a string instruction that stopped on zero.
No bounds checkingA check per access was a measurable fraction of runtime on a machine executing a few hundred thousand instructions per second.
Declaration mimics useint *p means "*p is an int". Elegant in principle; the reason week 16 needed a rule.
The preprocessor is textualIt was a separate program bolted on, not part of the language.
int has no fixed sizeC had to compile for 16-bit, 18-bit, and 36-bit machines.
Integer promotionThe hardware had no sub-word arithmetic.

These were not oversights. They were the correct engineering decisions for the constraints of 1972, and the reason a language designed for a minicomputer still runs every operating system kernel in production is that most of them turned out to age well.

2Why undefined behavior exists

Week 36 treated undefined behavior as a hazard. It is also a deliberate design decision, and understanding the trade explains why it will never be removed.

Consider signed overflow. The committee could have required wrapping. Two costs:

  • Machines that trapped on overflow, or used sign-magnitude, would need extra instructions on every arithmetic operation to produce the mandated behavior.
  • The optimizer could no longer assume i + 1 > i, which is what allows it to promote a loop counter to a register and vectorize.

Leaving it undefined pushes the cost onto the one program in a thousand that overflows, instead of onto every program. That is the bargain C makes throughout: maximum performance on the correct program, no guarantees for the incorrect one.

Whether that bargain was right is a live argument — Rust exists largely to answer "no". But it is a coherent position, not an accident, and reading C written by people who understood it is the fastest way to internalize where the edges are.

3Where to read

ProjectSizeRead it for
musl libc~80k linesThe standard library implemented cleanly. Start here: each function is small, self-contained, and something you already know the specification of.
SQLite~150k linesExtreme portability and testing discipline. Its test suite is many times the size of the code.
Lua~25k linesA complete language implementation small enough to read entirely. Exceptional clarity.
Redis~150k linesData structures and an event loop under real load. sds.c and dict.c are directly comparable to weeks 19 and 33.
git~250k linesA large codebase that grew organically — including the parts that show it.
Linux kernelmillionsNot for reading whole. Pick one driver, or lib/list.h, which is week 32's sentinel list.

Start with musl. Read src/string/strlen.c, then src/stdlib/qsort.c. You know exactly what these must do, so all your attention goes to how — which is the point.

4How to read a codebase

Reading top to bottom does not work past a few thousand lines. Work outside in.

  1. Build it first. A codebase you cannot compile is one you cannot experiment with. Read the README and the build files before any source.
  2. Find the entry point. main, or the exported functions in the public header. The header is the intended summary of what the code does.
  3. Map the directories. Names usually reveal the architecture. Skim it before diving.
  4. Pick one path and follow it. One command, one request, one function call, end to end. Breadth-first reading of a large codebase produces nothing.
  5. Run it under a debugger. A breakpoint and a backtrace answer in seconds what an hour of reading may not.
grep -rn "function_name" --include='*.c' --include='*.h' .
ctags -R . && vim -t function_name        # jump to a definition
cloc .                                     # size and language breakdown
git log --oneline -- path/to/file.c        # why this file looks like this
git log -S "some_symbol" --oneline         # when a symbol appeared
gdb ./prog  → break f  → run  → bt         # who calls this, actually

A language server — clangd with a compile_commands.json — gives go-to-definition and find-all-references in an editor, and is worth the setup on anything you will read more than once. Generate the file with cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON or bear -- make.

5Reconstructing the conventions

Every C project invents answers to the same questions, because the language supplies none. Before changing anything, find the answers — they are rarely documented and always consistent.

QuestionWhere to look
How are errors reported?The return type of any function that can fail. An int code, a bool with an output parameter, or NULL?
Who frees what?Function names: a _create/_destroy pair, or a caller-supplied buffer. Check three call sites.
What are the naming rules?Prefixes (sqlite3_, redis_), snake_case, _t suffixes for types.
Is cleanup centralized?Search for goto cleanup or goto fail. Its presence tells you week 27's idiom is the house style.
How is memory allocated?Many projects wrap mallocsqlite3_malloc, zmalloc — for accounting or failure injection.
What is public?The installed headers. Everything else is internal, whatever its visibility.

Answer these five questions and you can make a change that looks like it belongs. Ignore them and your patch will be rejected on style before anyone reads the logic.

6Idioms worth borrowing, and not

BorrowWhy
goto cleanupOne exit path; adding a resource is one line. Week 27.
Opaque handlesThe implementation stays changeable. Weeks 28, 33.
A prefix on every public nameC has no namespaces; the prefix is the substitute.
A wrapped allocatorEnables accounting, limits, and failure injection in tests.
Intrusive listsThe kernel's list_head: no allocation per link, no special cases.
static on everything not exportedSmaller interface, better optimization. Week 28.
Do not borrowWhy not
Clever macros that build control flowUndebuggable; the compiler sees only the expansion.
Hungarian notationEncodes the type in the name, where the compiler already knows it.
Single-letter names outside loopsMade sense when identifiers were expensive. They are not.
Deep #ifdef nestingOnly one configuration is ever compile-checked. Week 28.
void main(), K&R parameter listsPre-standard; kept only for compatibility.
Manual loop unrollingThe compiler does it better, and the source becomes unreadable.

Age is not quality. A twenty-year-old codebase contains decisions that were right in 2005, workarounds for compilers nobody uses, and genuine mistakes nobody dared touch. Read critically.

7Worked example: reading musl's strlen

Take one function you already understand completely and see what twenty years of care look like.

git clone --depth 1 git://git.musl-libc.org/musl
cd musl
wc -l src/string/strlen.c
cat src/string/strlen.c

The implementation is roughly this:

#include <string.h>
#include <stdint.h>
#include <limits.h>

#define ALIGN (sizeof(size_t))
#define ONES ((size_t)-1/UCHAR_MAX)                  /* 0x0101...01 */
#define HIGHS (ONES * (UCHAR_MAX/2+1))               /* 0x8080...80 */
#define HASZERO(x) ((x)-ONES & ~(x) & HIGHS)

size_t strlen(const char *s)
{
    const char *a = s;
    const size_t *w;

    for (; (uintptr_t)s % ALIGN; s++) {
        if (!*s) return s-a;                         /* byte-at-a-time
                                                        until aligned */
    }
    for (w = (const void *)s; !HASZERO(*w); w++);    /* word at a time */

    for (s = (const void *)w; *s; s++);              /* find the exact byte */
    return s-a;
}

What it is doing, and why

Your week 14 version walked one byte at a time. This one walks eight, using a bit trick to test all eight for a zero simultaneously.

HASZERO is the heart of it. For a word x, x - ONES borrows out of any byte that is zero; ~x has the top bit set in any byte whose top bit was clear; the AND with HIGHS keeps only the top bit of each byte. The result is non-zero exactly when some byte of x is zero. One subtraction, two ANDs, one NOT — for eight bytes.

The first loop exists because a word-sized load at an unaligned address is undefined or slow, exactly as week 37 established. It advances byte by byte only until alignment is reached — at most seven iterations.

The third loop is needed because the second stops at the word containing the terminator, not the byte.

What to take from it

The constants are computed, not written. ONES is derived from sizeof(size_t) and UCHAR_MAX, so the same source is correct on a 32-bit and a 64-bit machine. Week 37's portability discipline, applied to a bit trick.

The alignment loop is not optional. It is the difference between fast and undefined.

It is unreadable without the explanation — and that is acceptable here. strlen is called billions of times a day, its specification will never change, and it is thirteen lines. The same density in your application code would be indefensible.

Measure it against yours

/* bench.c */
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <time.h>

static size_t naive_strlen(const char *s)
{
    const char *p = s;
    while (*p) p++;
    return (size_t)(p - s);
}

int main(void)
{
    size_t n = 1000000;
    char *big = malloc(n + 1);
    memset(big, 'x', n);
    big[n] = '\0';

    clock_t t0 = clock();
    volatile size_t a = 0;
    for (int i = 0; i < 200; i++) a += naive_strlen(big);
    double naive = (double)(clock() - t0) / CLOCKS_PER_SEC;

    t0 = clock();
    volatile size_t b = 0;
    for (int i = 0; i < 200; i++) b += strlen(big);
    double library = (double)(clock() - t0) / CLOCKS_PER_SEC;

    printf("naive   %.3f s\nlibrary %.3f s\nratio   %.1fx\n",
           naive, library, naive / library);
    free(big);
    return 0;
}
gcc -O2 -o bench bench.c && ./bench

The library version is typically several times faster on a long string, and glibc's is faster still because it uses SIMD instructions. The lesson is not to write your own — it is that the standard library is not a convenience wrapper, and the gap between "correct" and "correct and fast" is real.

Then read something with a design in it

git clone --depth 1 https://github.com/redis/redis
sed -n '1,120p' redis/src/sds.h        # a length-prefixed string type
sed -n '1,80p'  redis/src/dict.h       # an incrementally rehashing hash table

sds is Redis's answer to week 14's null-terminated strings: it stores the length in a header immediately before the characters, so strlen is O(1) and the pointer can still be passed to any C function expecting char *. Read the header and work out how that dual nature is achieved before reading the implementation.

dict.c is week 33's hash table with one addition: rehashing happens incrementally, a few buckets per operation, so no single insertion ever pauses for a full rehash. That matters for a server with latency guarantees — and it is a design consideration your version had no reason to have.

Reconstruct the conventions

For whichever project you choose, answer section 5's five questions from the code alone, writing the answers down:

grep -rn "goto cleanup\|goto fail\|goto err" src/ | head
grep -rn "_create\|_free\|_destroy\|_new\|_release" src/*.h | head
grep -rn "zmalloc\|sqlite3_malloc\|xmalloc" src/ | head -5
ls include/ 2>/dev/null || grep -l "^[a-z].*(" src/*.h | head

Then check your conclusions against the project's contributing guide, if it has one. Where the code and the document disagree, the code is what reviewers will enforce.

8Common mistakes

MistakeWhat happensFix
Reading a large codebase linearlyHours spent, nothing retainedFollow one execution path end to end.
Reading without buildingCannot experiment or verifyGet it compiling first.
Assuming old code is good codeCopying a 2005 workaroundCheck the history; ask why.
Copying a clever idiom without the contextUnreadable application codeDensity is justified in a hot library function, rarely elsewhere.
Ignoring the house conventionsPatch rejected on styleReconstruct them before writing.
Starting with the kernelOverwhelming; nothing learnedStart with musl.
Reading only the codeMissing the intentgit log and git blame explain the why.

9Check yourself

Why do arrays decay to pointers when passed to a function?

Because copying an array on every call was unaffordable on a machine with 64 KB of memory — passing the address costs one word regardless of size. The consequence is everything week 13 covered: sizeof changes inside the function, the length must be passed separately, and a function can modify the caller's data despite pass-by-value.

What is C trading away by leaving signed overflow undefined?

Diagnosability, in exchange for performance and portability. Defining it would require extra instructions on machines that trap or use a different representation, and would prevent the optimizer from assuming i + 1 > i — which is what allows loop counters in registers and vectorization. The cost falls on the incorrect program rather than on every program.

Why start with musl rather than the Linux kernel?

Because you already know precisely what each musl function must do — you have the specification in your head from forty weeks of using them. That frees all your attention for how it is implemented. The kernel requires learning the domain and the code simultaneously, which is why people bounce off it.

What five things should you determine before changing unfamiliar C?

How errors are reported, who owns and frees memory, the naming conventions, whether cleanup is centralized with goto, and which headers constitute the public interface. C supplies none of these, so every project invents its own — consistently, and usually without documenting it.

Why is musl's dense strlen good code while the same density in your program would not be?

Because the trade-off differs. strlen is thirteen lines, its specification will never change, and it runs billions of times a day, so unreadability buys a real and permanent gain. Application code changes constantly and is read far more than executed, so the same density costs maintainability for a speedup nobody measured.

10Where this leads

Week 43 makes this practical: not reading a codebase to learn from it, but navigating one in order to change it — locating a regression with git bisect, understanding enough context to be confident, and making the smallest change that fixes the problem. Week 44 then puts that change through review.