C Part I · Module 13 · Writing Correct and Reliable C
Defensive Habits That Prevent C Bugs
Four defects have been outstanding since Chapter 13.1 measured them. Every warning flag this curriculum recommends is silent on them. The static analyser is silent. The sanitizers are silent. Even -Weverything, the complete set of diagnostics the compiler owns, says nothing. They are not hard bugs — each one is prevented completely by a habit that costs nothing and takes no runtime check. This chapter settles all four, adds the two habits that remove entire bug classes rather than single defects, and corrects the most widely taught defensive idiom in C by measuring it failing at the one job it is famous for.
Advanced22 min readCPart IFundamentalsCorrectness
Part I · Module 13 · Chapter 13.11 · Defensive Habits That Prevent C Bugs
1. Four Defects That Outlasted Every Tool
Chapter 13.1 compiled fifteen defective programs and counted how many the toolchain caught. The rest of Module 13 spent itself raising that count. Chapter 13.7 read the warnings properly and took it from one to four. Chapter 13.10 added the static analyser, which reached nine, and then the sanitizers, which reached eleven.
Eleven of fifteen. The remaining four were never caught by anything, and they are still outstanding:
- a structure whose members were reordered under a positional initialiser
constdefeated by a pointer typedef- a macro that expands to two statements, used after an
if - a defect inside a branch nobody compiles
It is worth being precise about how thoroughly the tools miss them. The following is not a selective test. Each of the three that can be compiled as a single program was put through the recommended flag set from 13.7, then the static analyser, then AddressSanitizer and UndefinedBehaviorSanitizer together at runtime — and then through -Weverything, which is not a flag anyone should ship but which turns on every diagnostic clang possesses, including the ones that are off by default because they are too noisy to live with:
$ cc -std=c17 -Weverything -O2 -c reordered_struct.c -o /dev/null
warning: include location '/usr/local/include' is unsafe for cross-compilation
[-Wpoison-system-directories]
1 warning generated.That warning is about the machine, not the code. The same single warning, with the same text, is the entire output for all three files. The compiler has nothing to say about any of them.
That is not a failure of the tools. It is a statement about what kind of defect they are. A warning flag recognises a suspicious construction. An analyser traces a path to a fault. A sanitizer observes a forbidden operation at runtime. All four of these defects are perfectly well-formed programs performing entirely legal operations. There is nothing suspicious to recognise, no fault to reach, and no rule being broken. Each one simply means something other than what its author intended, and no tool can know what you intended.
What can know it is the way you write the code in the first place. Each of the four is prevented — not detected, prevented — by a habit that takes no runtime check, costs nothing at execution, and can be adopted in an afternoon. This chapter is those habits.
2. Habit One: Initialise by Named Member
Chapter 10.2 introduced designated initialisers as a readability feature. They are considerably more than that.
Here is a structure and two initialisations of it that are, today, identical in meaning:
#include <stdio.h>
struct sensor { int id; int scale; int offset; };
int main(void) {
struct sensor pos = { 7, 100, -3 };
struct sensor nam = { .id = 7, .scale = 100, .offset = -3 };
printf("positional: id=%d scale=%d offset=%d\n", pos.id, pos.scale, pos.offset);
printf("named : id=%d scale=%d offset=%d\n", nam.id, nam.scale, nam.offset);
return 0;
}positional: id=7 scale=100 offset=-3
named : id=7 scale=100 offset=-3Now someone — possibly you, six months from now, possibly a colleague tidying a header — swaps two members. Perhaps to group them more logically, perhaps to improve the padding the way Chapter 10.5 showed. It is a one-line change in a different file from the one above, and it looks completely harmless:
struct sensor { int id; int offset; int scale; }; /* scale and offset swapped */Nothing else changes. The same program, recompiled with -Wall -Wextra -Wpedantic -O2:
positional: id=7 scale=-3 offset=100
named : id=7 scale=100 offset=-3The positional initialiser now puts the offset in the scale and the scale in the offset. It compiled without a single diagnostic, at any warning level, because it is a completely valid initialisation — it initialises the second member with 100 and the third with -3, exactly as it is written to do. The program's meaning changed when the structure was edited, and the braces had no way to object, because the braces never named anything.
The named initialiser is unaffected. It does not say "the second member", it says .scale, and .scale is still .scale wherever it has been moved to.
The habit: in an initialiser for any structure with more than one member, name every member. It is not a style preference. It converts a position-dependent construction, which silently follows a declaration you may not have seen, into a name-dependent one, which cannot.
There is a second, quieter benefit. Any member you omit from a designated initialiser is initialised as if it had static storage duration — zero for arithmetic members, null for pointers — which Chapter 9.12 established is the opposite of the indeterminate value an uninitialised automatic object carries. So { .id = 7 } is a fully defined object with a defined scale and offset, not a partly initialised one.
3. Habit Two: Never Hide a Pointer Inside a typedef
Chapter 10.9 established what typedef does: it introduces a name for a type. Not a macro, not a textual substitution — a genuine type name, subject to the ordinary rules of declaration syntax. That last part is what makes the following possible.
#include <stdio.h>
typedef char *string;
static void shout(const string s) { /* reads as "a const string" */
s[0] = 'X';
printf("%s\n", s);
}
int main(void) {
char buf[] = "hello";
shout(buf);
printf("%s\n", buf);
return 0;
}The parameter is declared const. The function writes through it. Compile with every flag in 13.7's set, including -Wcast-qual and -Wwrite-strings, which exist specifically to police qualifier discipline:
Xello
XelloNo diagnostic, and the caller's buffer has been modified. The function declared itself read-only and mutated its argument.
The reason is a rule from 10.9 that is easy to read past. When a qualifier is applied to a typedef name, it qualifies the type the name stands for, as a whole — it is not pasted into the middle of the declaration the way a macro would paste it. string is "pointer to char". Qualifying that whole type gives "const pointer to char" — char *const. The pointer is constant; the characters it points at are not. The const has protected the one thing nobody wanted to change.
You do not have to take that on trust. Force the compiler to name the type:
typedef char *string;
static void f(const string s) { char **p = &s; (void)p; }warning: initializing 'char **' with an expression of type 'const string *'
(aka 'char *const *') discards qualifiers
[-Wincompatible-pointer-types-discards-qualifiers]aka 'char *const *'. The compiler has known all along; it simply had no reason to mention it, because char *const is a perfectly ordinary type and the assignment to s[0] is a perfectly ordinary write through a non-const char *.
The habit: do not typedef a pointer type. Write char * and struct sensor * and const char * in full. Then const char *s means what it reads as, and the reader of any declaration can see the level of indirection without going to find the typedef.
4. Habit Three: Give a Multi-Statement Macro One Statement's Shape
Chapter 11.6 established that a function-like macro is a textual replacement performed before the compiler sees any syntax at all, and its §5 named this consequence the statement trap. What is added here is the measurement and the habit that closes it.
#include <stdio.h>
#define LOG_AND_COUNT(msg) printf("%s\n", msg); count++
#define LOG_AND_COUNT_OK(msg) do { printf("%s\n", msg); count++; } while (0)
static int count = 0;
int main(void) {
int failed = 0; /* nothing failed */
if (failed)
LOG_AND_COUNT("failure");
printf("count after guarded-looking macro: %d\n", count);
count = 0;
if (failed)
LOG_AND_COUNT_OK("failure");
printf("count after do/while(0) macro : %d\n", count);
return 0;
}count after guarded-looking macro: 1
count after do/while(0) macro : 0Nothing failed, and the failure counter reads one.
After the preprocessor has run, the first if is this:
if (failed)
printf("%s\n", "failure");
count++;The if governs one statement, because that is what an if without braces does. The printf is correctly suppressed. The count++ was never inside the if at all — it is the next statement in main, and it runs every time control reaches it. The indentation says otherwise and the indentation is a lie, because the preprocessor does not read indentation.
Compiled with -Wall -Wextra -Wpedantic the program is silent. There is no suspicious construction here: two statements in a row is the most ordinary thing in C, and the compiler has no record of the fact that the author believed they were one.
The habit: a function-like macro that expands to more than one statement is wrapped in do { ... } while (0).
The idiom looks like a loop and is not one: the body runs exactly once and while (0) is false, so there is no iteration. What it buys is syntax. do { ... } while (0) is a single statement, so it can be the entire body of an if with no braces — and because it is a do statement rather than a bare block, it requires the semicolon the call site already writes, so MACRO(x); is correct and MACRO(x) is a syntax error. A bare { ... } gets the first property and fails the second, which is why it breaks the else in if (c) MACRO(x); else ....
The measured difference is in the output above: one count versus zero, from the same conditional, with the same compiler and the same flags.
5. Habit Four: Build Every Configuration You Ship
Chapter 11.7 introduced conditional compilation and made the point that the preprocessor deletes code before the compiler sees it. Here is what that costs.
#include <stdio.h>
#ifndef ENABLE_RETRY
#define ENABLE_RETRY 0 /* default off; the build may define it as 1 */
#endif
static int attempt(void) { return 1; }
int main(void) {
int ok = attempt();
#if ENABLE_RETRY
for (int i = 0; i < 3 && !ok; i++)
if (ok = attempt() == 1) /* intended ==, wrote = */
break;
#endif
printf("ok = %d (retry %s)\n", ok, ENABLE_RETRY ? "on" : "off");
return 0;
}The default build, with the full recommended flag set:
$ cc -std=c17 -Wall -Wextra -Wpedantic -O2 retry.c -o retry
$ ./retry
ok = 1 (retry off)Clean. Now the configuration that ships to the other half of your users — the same file, the same compiler, the same flags, one -D added:
$ cc -std=c17 -Wall -Wextra -Wpedantic -DENABLE_RETRY=1 -O2 retry.c -o retry
retry.c:13:16: warning: using the result of an assignment as a condition
without parentheses [-Wparentheses]
13 | if (ok = attempt() == 1) /* intended ==, wrote = */
| ~~~^~~~~~~~~~~~~~~~
retry.c:13:16: note: use '==' to turn this assignment into an equality comparison
1 warning generated.The defect was always there. It was never compiled, so it was never diagnosed. Every warning flag in Module 13, every analyser pass, every sanitizer — none of them can instrument a token sequence the preprocessor discarded. Code inside a dead #if is not code as far as the toolchain is concerned; it is a comment that happens to be syntax-highlighted.
The habit: every configuration you ship is a configuration you build. If ENABLE_RETRY can be 0 or 1 in the field, both must be compiled, with warnings on, before release. If there are three such switches there are eight configurations, and that is an argument for having fewer switches, not for building fewer of them.
Where it can be arranged, a better habit still: prefer a runtime if to a compile-time #if. A branch the compiler can see is a branch it can check, and an optimiser will delete a branch on a compile-time constant just as thoroughly as the preprocessor would — the difference is that it type-checks and warns about the code first.
6. Two Habits That Remove Whole Classes
The four above each remove one specific defect. Two more remove entire categories, and they are the reason Modules 7 and 9 were as long as they were.
Carry a pointer and its length together. Chapter 7.7 established that an array decays to a pointer when passed to a function and that the length does not travel with it. A function given only a pointer cannot know how many elements it may read, so it must assume — and an assumption that is not checked is 13.6's entire bug class.
static int sum_bare(const int *a) {
int t = 0;
for (int i = 0; i < 8; i++) t += a[i]; /* 8 is an assumption */
return t;
}
static int sum_span(const int *a, size_t n) {
int t = 0;
for (size_t i = 0; i < n; i++) t += a[i];
return t;
}Called with a five-element array, sum_span is correct by construction and sum_bare reads three elements past the end. Under AddressSanitizer at -O1, the level 13.10 recommends:
ERROR: AddressSanitizer: stack-buffer-overflow
READ of size 4 at 0x00016f392674 thread T0
#0 in main sum.c:9Caught — at -O1. The identical program built at -O2 and run under the identical sanitizer:
sum_span: 15
sum_bare: 15Silence. This is not a reason to distrust sanitizers; it is a reason to use the build 13.10 specified rather than an arbitrary one. But it is a precise illustration of the difference the habit makes. sum_bare is a bug whose detection depends on which optimisation level you happened to choose. sum_span is not a bug at any optimisation level, under any tool, on any input, because the loop bound is the array's own length rather than a guess about it.
Give every allocation one documented owner. Chapter 9.8 stated the rule: for each allocation, exactly one piece of code is responsible for freeing it, and that responsibility is written down in the header next to the function that hands the pointer out. Leaks, double frees and use-after-free are three symptoms of the same missing sentence — nobody recorded who frees this. When the owner is named, the owner frees it once, and all three disappear together.
7. The Misconception: Defensive Means Adding Checks
Ask a room of C programmers for a defensive habit and someone will offer this one:
if (p) free(p);It is the best-known defensive idiom in the language. It is also worth taking apart, because it is wrong in two independent ways and measuring both is more useful than being told.
It guards against something that was never a problem. C17 §7.22.3.3 paragraph 2 says that if the argument to free is a null pointer, no action occurs. free(NULL) is a defined no-op — not undefined behaviour, not implementation-defined, simply nothing. The check cannot prevent a fault on the case it tests for, because that case has no fault.
It does not prevent the thing it is folklore for preventing. Here is the double free it is supposed to stop:
int *p = malloc(sizeof *p);
if (p == NULL) return 1;
*p = 1;
if (p) free(p);
printf("first free done, p is %s\n", p ? "non-NULL" : "NULL");
if (p) free(p); /* the same check passes again */first free done, p is non-NULLfree does not modify its argument — it cannot, since it receives the pointer by value. After the first call, p still holds the same address it always did. The guard tests whether p is null; p is not null; the second free runs. The idiom contributed nothing except the appearance of care.
The habit that works is the one from 9.8, and it is one line shorter than it looks:
free(p);
p = NULL;Now a second free(p) is free(NULL), which the standard defines as doing nothing. Verified under AddressSanitizer:
two frees, no fault: p is NULLThe general shape is what matters. The check-based version tests for a bad state that it cannot fix. The habit-based version makes the bad state unreachable: after the owner releases the object, the owner's pointer no longer denotes it. Defensive programming that works is almost always of the second kind. It removes the possibility; it does not inspect for it.
8. What Is Not on the List
A short list is only useful if things were left off it. These were considered and are not habits this chapter recommends.
Yoda conditions — writing if (0 == x) so that a mistyped = fails to compile. The defect it targets is real, but -Wparentheses is in -Wall and catches it, as §5's output shows, and it catches the cases Yoda ordering cannot — if (x = y) with two variables is unaffected by reordering. The habit costs readability at every comparison to duplicate a warning you already have switched on.
Hungarian notation — encoding a type in each identifier. It describes what the declaration already states, and when the declaration changes and the name does not, the name becomes a lie that reads like documentation.
A NULL check before every dereference. If a pointer can legitimately be null the check is not defensive, it is the logic. If it cannot, the check implies a possibility the reader must now rule out, and it hides the real failure — which is that the pointer arrived null, somewhere upstream — behind a silent early return. Chapter 13.8's assert is the tool for "this cannot be null and I want to be told if it is".
The pattern across all three: a habit earns its place by removing a defect that nothing else removes. Duplicating a warning flag, restating a declaration, or testing a condition that cannot occur all add text without removing anything. Every habit in §2 through §6 eliminates something this curriculum measured no tool catching, or an entire category at once.
9. What You Should Be Able to Say Now
- Why the four outstanding defects are outstanding. They are well-formed programs performing legal operations that mean something other than what the author intended. Warnings recognise suspicious constructions, analysers trace paths to faults, sanitizers observe forbidden operations — and none of those is what these are.
-Weverythinghad one warning for each, about the include path. - Name every member in an initialiser. Positional initialisers silently follow a declaration you may not have seen;
{ 7, 100, -3 }becamescale=-3 offset=100from a one-line edit to a structure in another file, with no diagnostic at any level. Omitted designated members are zero-initialised, not indeterminate. - Never typedef a pointer. A qualifier applied to a typedef name qualifies the whole type, so
const stringischar *const— a constant pointer to mutable characters. The compiler will tell you this if you force it:aka 'char *const *'. - Wrap a multi-statement macro in
do { ... } while (0). It makes the expansion a single statement that still requires the caller's semicolon. Without it,if (failed)guarded theprintfand not thecount++, and the counter read one when nothing had failed. Better still, write astaticfunction in a header unless you genuinely need the preprocessor. - Build every configuration you ship.
-DENABLE_RETRY=1surfaced a-Wparentheseswarning the default build could not, from the same file with the same flags. No tool can instrument tokens the preprocessor discarded. Guard a configurable default with#ifndef, or the build system cannot override it. - Carry a pointer with its length; give every allocation one documented owner. The bare-pointer overread was caught at
-O1and missed at-O2; the pointer-and-length version is not a bug at any level. Naming the owner removes leaks, double frees and use-after-free together. if (p) free(p)is not defensive.free(NULL)is already a defined no-op (C17 §7.22.3.3), andfreedoes not modify its argument, so the guard passes a second time and the double free proceeds.free(p); p = NULL;removes the possibility instead of testing for it.- A habit earns its place by removing something. Yoda conditions duplicate
-Wparentheses; Hungarian notation restates the declaration; a null check for a pointer that cannot be null hides the real failure. Prevention has no detection ceiling, because a bug that cannot be expressed does not need to be found.
10. Exercises
- Write a four-member structure and initialise it positionally. Swap two same-typed members in the declaration, recompile with
-Wall -Wextra -Wpedantic, and confirm the silence. Convert the initialiser to designated form and repeat the swap. - Take the
typedef char *string;example and write the version that does what the author intended — a function that genuinely cannot modify its argument. Then explain, without compiling, whatconst string *pmeans. - Write a macro that expands to two statements and call it as the body of an
ifwhose condition is false. Show the defect, then fix it withdo { } while (0). Then write thestaticfunction that makes the macro unnecessary and compare the disassembly at-O2. - Put a deliberate
-Wparenthesesdefect inside an#if FEATUREblock that defaults off. Confirm the default build is clean. Then write a shell loop that builds every combination of two such switches and fails if any build warns. - Reproduce §7's measurement. Build the double-free program at
-O0and-O1under AddressSanitizer in a single file and record which reports it. Then splitmalloc/freeinto a second translation unit and build at-O1. Explain the three results. - Take any function you have written in Module 12 that receives a pointer. Determine whether its length travels with it and whether its owner is documented. Fix whichever is missing.
- Pick one habit from this chapter that you did not already follow. Apply it to one file you have written in Part I. Record whether it found anything — a truthful "it found nothing" is a valid result and worth having.
11. Module 13 in One Page
Module 13 asked one question — how do you know a C program is correct? — and answered it by measurement rather than assertion. The same fifteen defective programs were carried through every chapter, and each chapter moved the number:
| Layer | Caught of 15 |
|---|---|
-Wall -Wextra -Wpedantic | 1 |
| + 13.7's recommended flags | 4 |
| + the static analyser | 9 |
| + AddressSanitizer and UndefinedBehaviorSanitizer | 11 |
| design and review only | the remaining 4 |
Around that spine the module established what the categories actually mean: undefined behaviour is a property of the program and not of the output you observed (13.2), and it is genuinely distinct from unspecified and implementation-defined behaviour (13.3); signed overflow is undefined while unsigned wraparound is defined (13.4); a signed value compared with an unsigned one is converted, not compared as written (13.5); an out-of-bounds write has no defined victim, and the neighbours it appeared to hit moved between -O0 and -O2 (13.6). Then the instruments: warnings are diagnoses to read rather than counts to total (13.7), assert records what you believe and is compiled out of the shipping build (13.8), a debugger answers what is true now where a printf answers only what did I think to ask (13.9), and sanitizers see what no static tool can but only on the inputs your tests actually reach (13.10).
And the four that nothing saw, which this chapter removed by making them unwritable.
The single most useful sentence in the module is 13.1's, and everything since has been evidence for it: the compiler accepting your program is a statement about its syntax, not about its meaning.
12. The End of Part I
This is the last chapter of Part I. One hundred and twenty-three chapters ago, Module 1 opened with what a program is and what a compiler does to one, and the question it set up has been the same one all the way through: what does this code actually do?
Part I answered it for the language. Values and their representation (M2), expressions and the conversions hiding inside them (M3), control flow (M4), functions and decomposition (M5), arrays (M6), pointers (M7), strings as the arrays they really are (M8), object lifetime and the heap (M9), the types you define yourself (M10), the multi-file program and what the linker does with it (M11), the standard library and the program's interface to the world (M12), and finally what correctness means and how it is established (M13).
What should have changed is the habit of mind. At the start, "it compiled and printed the right answer" was evidence. It is not, and you now have the measurements to say why: a program can be undefined and print the expected value, a sanitizer can be silent because the optimiser deleted the bug, a defect can sit in a configuration nobody built. Correct C is not code that happens to work on the run you tried. It is code you can give an account of.
You can also now read C you did not write — which, for most of a career, is the larger part of the job.
Part II changes the question. Part I asked what the language guarantees. Part II — C for SoC Verification, VLSI Engineers and DPI-C — asks what a value has to be when something outside your program has already decided its shape.
Module 14, C Through a Hardware Engineer's Eyes, makes that shift precisely. A value stops being an abstract quantity of a type you chose and becomes a bit pattern that a register, a bus or a reference model defined before your code existed. The width is no longer yours to pick, so Chapter 14.2 replaces "an int is fine" with the exact-width types; byte order stops being invisible the moment a value crosses a bus, a file or a language boundary (14.3); the alignment rules Chapter 10.5 taught as a layout curiosity acquire a cost and a hard edge, because converting a char * into a wider object pointer is implementation-defined rather than ordinary C (14.4); the structures of Module 10 are re-examined for when they may and may not be treated as a wire format (14.5); and the pointer model of Module 7 is laid over a real address map (14.6), which is the groundwork for the memory-mapped registers of Module 16 and the volatile of Module 17.
It closes on 14.7 — representing packet and transaction payloads as byte buffers with the length carried explicitly. That is §6 of this chapter, arriving as a requirement rather than a recommendation.
Nothing you have learned becomes wrong. The object model from Module 9, the representation rules from Module 2, the padding rules from Module 10, and Module 13's refusal to accept a working run as evidence are exactly the foundation Part II builds on, which is why Part II never repeats them. Reasoning about hardware without first being able to reason about the language is how people end up explaining real bugs with folklore — and §7 of this chapter is a small demonstration of what folklore costs.
Part I was the language. Part II is the language where the hardware has already decided.
Standards & specifications
- Governing standard
- ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)
Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C Programming curriculum.
