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.
- 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.
| Oddity | Reason |
|---|---|
| Arrays decay to pointers | Copying 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 checking | A check per access was a measurable fraction of runtime on a machine executing a few hundred thousand instructions per second. |
| Declaration mimics use | int *p means "*p is an int". Elegant in principle; the reason week 16 needed a rule. |
| The preprocessor is textual | It was a separate program bolted on, not part of the language. |
int has no fixed size | C had to compile for 16-bit, 18-bit, and 36-bit machines. |
| Integer promotion | The 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
| Project | Size | Read it for |
|---|---|---|
| musl libc | ~80k lines | The standard library implemented cleanly. Start here: each function is small, self-contained, and something you already know the specification of. |
| SQLite | ~150k lines | Extreme portability and testing discipline. Its test suite is many times the size of the code. |
| Lua | ~25k lines | A complete language implementation small enough to read entirely. Exceptional clarity. |
| Redis | ~150k lines | Data structures and an event loop under real load. sds.c and dict.c are directly comparable to weeks 19 and 33. |
| git | ~250k lines | A large codebase that grew organically — including the parts that show it. |
| Linux kernel | millions | Not 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.
- Build it first. A codebase you cannot compile is one you cannot experiment with. Read the README and the build files before any source.
- Find the entry point.
main, or the exported functions in the public header. The header is the intended summary of what the code does. - Map the directories. Names usually reveal the architecture. Skim it before diving.
- 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.
- 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, actuallyA 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.
| Question | Where 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 malloc — sqlite3_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
| Borrow | Why |
|---|---|
goto cleanup | One exit path; adding a resource is one line. Week 27. |
| Opaque handles | The implementation stays changeable. Weeks 28, 33. |
| A prefix on every public name | C has no namespaces; the prefix is the substitute. |
| A wrapped allocator | Enables accounting, limits, and failure injection in tests. |
| Intrusive lists | The kernel's list_head: no allocation per link, no special cases. |
static on everything not exported | Smaller interface, better optimization. Week 28. |
| Do not borrow | Why not |
|---|---|
| Clever macros that build control flow | Undebuggable; the compiler sees only the expansion. |
| Hungarian notation | Encodes the type in the name, where the compiler already knows it. |
| Single-letter names outside loops | Made sense when identifiers were expensive. They are not. |
Deep #ifdef nesting | Only one configuration is ever compile-checked. Week 28. |
void main(), K&R parameter lists | Pre-standard; kept only for compatibility. |
| Manual loop unrolling | The 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.cThe 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 && ./benchThe 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 tablesds 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 | headThen 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
| Mistake | What happens | Fix |
|---|---|---|
| Reading a large codebase linearly | Hours spent, nothing retained | Follow one execution path end to end. |
| Reading without building | Cannot experiment or verify | Get it compiling first. |
| Assuming old code is good code | Copying a 2005 workaround | Check the history; ask why. |
| Copying a clever idiom without the context | Unreadable application code | Density is justified in a hot library function, rarely elsewhere. |
| Ignoring the house conventions | Patch rejected on style | Reconstruct them before writing. |
| Starting with the kernel | Overwhelming; nothing learned | Start with musl. |
| Reading only the code | Missing the intent | git 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.