C Part I · Module 13 · Writing Correct and Reliable C
Sanitizers and Memory Tools
These tools have been doing decisive work since Module 9 and have never been explained. They named the line a program said nothing about, pointed at the function that caused a corruption rather than the one that displayed it, and took Chapter 13.1's detection rate from nine defects to eleven where the compiler found one. This chapter is the account they are owed: what they actually do to a program, what each kind can and cannot see, what they cost in time and memory, which of them cannot be run on the machine these pages were measured on — and the limitation that matters more than all their capabilities put together, which is that a tool watching a running program reports only the faults your inputs happen to reach.
Advanced20 min readCPart IFundamentalsCorrectness
Part I · Module 13 · Chapter 13.10 · Sanitizers and Memory Tools
1. What a Sanitizer Is
A sanitizer is a compiler option that rewrites your program to check itself. The compiler inserts extra instructions around memory accesses and arithmetic, and links in a runtime library that maintains the bookkeeping those checks need.
That phrasing matters, and Chapter 9.10 §6 made the point once already in a different context: a sanitizer does not observe your program, it changes it. The binary you test is not the binary you ship. What it reports is "this line performed an operation the standard does not define", which is a fact about your code — but the crash you see is the sanitizer's, deliberately produced so there is something to see.
This is exactly why they find what the compiler cannot. Chapter 13.1 measured the ceiling: static tooling reached 9 of 15 defects, and the remaining two it could reach needed a running program, because whether a pointer is still valid or whether two regions overlap depends on values that only exist at run time.
$ cc -std=c17 -g -fsanitize=address prog.c -o prog
$ ./progTwo things to note about that command. -g gives file and line in the report rather than addresses. And the sanitizer is selected at compile time, not run time — you build a separate binary for it.
2. What Each One Sees
$ cc -std=c17 -fsanitize=memory t.c
clang: error: unsupported option '-fsanitize=memory' for target 'arm64-apple-darwin'
$ cc -std=c17 -fsanitize=leak t.c
clang: error: unsupported option '-fsanitize=leak' for target 'arm64-apple-darwin'
$ ASAN_OPTIONS=detect_leaks=1 ./prog
AddressSanitizer: detect_leaks is not supported on this platform.Leak detection is also absent, in both forms. Chapter 9.9 met this and used the platform's own leaks tool instead, which measured 4000 leaks for 327,680,000 bytes on a deliberately leaky program and 0 leaks on the corrected one. On Linux, -fsanitize=address includes leak detection by default and valgrind is a further option; neither is available here, and this chapter does not describe tools it could not run.
3. AddressSanitizer
What it detects: memory accessed outside its bounds, or after its lifetime ended. Chapter 9's entire subject.
==63349==ERROR: AddressSanitizer: stack-buffer-overflow
WRITE of size 19 at 0x00016bd8268c thread T0
Address 0x00016bd8268c is located in stack of thread T0 at offset 44 in frame
SUMMARY: AddressSanitizer: stack-buffer-overflow d2.c:8 in set_nameFour things in that report, and the fourth is why it is worth the cost:
The kind of error — stack-buffer-overflow, as distinct from heap, global, or use-after-free.
The access — a write, of 19 bytes.
The storage — which object, and where it came from.
And the location of the cause. Chapter 13.9 §4 measured this: the report named set_name, the function that wrote out of bounds, not use_limit, where the corrupted value would have surfaced. That is the Chapter 9.11 §4 distance between cause and symptom, closed.
For a freed block it adds the two facts that matter most:
==1244==ERROR: AddressSanitizer: heap-use-after-free
READ of size 4 at 0x6020000000d0
freed by thread T0 here: ...
previously allocated by thread T0 here: ...Where it was freed and where it was allocated — Chapter 9.10 §6's measurement, and precisely the provenance a bare crash cannot give you.
How it works, briefly, because it explains the costs in §5: ASan keeps a shadow map recording which bytes are currently valid, surrounds every object with poisoned redzones, and holds freed memory in quarantine so a use-after-free hits poison rather than reused storage. Chapter 9.10 §3 measured that an ordinary allocator hands the same address straight back out; quarantine is the deliberate removal of that coincidence.
4. UndefinedBehaviorSanitizer
Where ASan is about memory, UBSan is about operations: arithmetic and conversions that the standard leaves undefined.
d1.c:15:14: runtime error: division by zero
runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'Both measured in earlier chapters — Chapter 13.9 §4 for the first, Chapter 13.4 §8 for the second — and both from programs that gave no other sign. The division-by-zero program printed a plausible number and exited with status 0.
It is a family of checks rather than one, and they can be selected individually:
| Check | Catches | Measured in |
|---|---|---|
signed-integer-overflow | a + b leaving int's range | 13.4, 12.9 |
shift | shifting by the width or more, or by a negative | 13.1's census |
null | dereferencing a null pointer | 7.4 |
bounds | a constant-bounded array index out of range | 13.6 |
alignment | a misaligned access through a pointer | 14.4's subject |
integer-divide-by-zero | exactly that | 13.9 |
-fsanitize=undefined enables a broad default set. Two options change how it behaves and both are worth knowing:
-fsanitize=undefined -fno-sanitize-recover=all /* stop at the first finding */
-fsanitize=undefined -fsanitize-trap=all /* trap instead of reporting */By default UBSan reports and continues, which is why Chapter 13.9 §4's program printed its result after the diagnostic. For a test run that is usually wrong — you want the first finding to fail the test — and -fno-sanitize-recover=all is how you get that.
5. What They Cost
Measured on a pointer-chasing workload over a 16 MB array, best of five runs:
plain CPU 0.298 s peak RSS 17,399,808 bytes
asan CPU 0.348 s peak RSS 34,242,560 bytes 1.17x time, 1.97x memory
ubsan CPU 0.321 s peak RSS 27,934,720 bytes 1.08x time, 1.61x memoryThe memory figures are the reliable ones and they are the ones that bite: ASan roughly doubles peak memory, because the shadow map and redzones are real storage. A program that already uses most of a machine's memory may not run under ASan at all.
The time figures here are at the low end and should not be taken as general. This workload is dominated by memory latency, so the added instructions hide in the stalls. ASan's own documentation describes a typical slowdown around 2×, and a workload with many small allocations or tight arithmetic loops will be closer to that than to the 1.17× measured here.
6. The Limitation That Outranks the Capabilities
A sanitizer reports only the faults the execution actually reaches. It is a detector, not a prover.
This is more important than anything in §§3–5, and it has three consequences.
No finding is not a clean bill of health. The program ran one path with one input. A defect on any other path is untouched, and Chapter 13.1 measured four defects that no tool found on any path.
Coverage is the real variable. A sanitizer's value is bounded by how much of your program your tests actually execute. Running a sanitizer on a test suite that covers a third of the code checks a third of the code. This is why sanitizers are usually paired with fuzzing — a technique for generating inputs that reach paths your tests do not — and why "we run ASan in CI" means little without knowing what CI runs.
And detectors have false negatives of their own. Chapter 9.9 §8 measured one directly: a leak detector reported 1 of 2 real leaks, deterministically, because a dead stack slot still held the address of the second block and the scan treats anything that looks like a pointer as a reference. Both runs were correct scans; one was an incomplete answer.
So the honest statement of what a clean sanitizer run means:
| It means | It does not mean |
|---|---|
| no detected fault on these inputs | no fault exists |
| the paths you exercised were clean | the paths you did not are clean |
| this class of bug was not seen | other classes were checked |
That is still extremely valuable — it is the difference between Chapter 13.1's nine detections and eleven — and it is not a proof.
7. Using Them
Combine ASan and UBSan. They check different things and can be enabled together:
cc -std=c17 -g -O1 -fsanitize=address,undefined -fno-sanitize-recover=all-O1 rather than -O0. Optimisation keeps the instrumented build fast enough to be usable, and -g keeps the line numbers. -O0 works and is slower for no benefit.
-fno-omit-frame-pointer improves the stack traces in the reports, at negligible cost.
TSan must be used alone. ThreadSanitizer is incompatible with ASan — they both want the same parts of the address space.
Run them on your tests, not on a demo. §6 is the reason: the value is proportional to coverage.
Make a finding fail the build. -fno-sanitize-recover=all turns a UBSan report into a non-zero exit status, which is what makes CI notice.
And do not ship a sanitized binary. The cost is real (§5), and the sanitizer's own failure behaviour — aborting on a finding — is not how a production program should fail.
A practical arrangement that works for most projects:
| Build | Flags | Purpose |
|---|---|---|
| development | -g -O1 -Wall -Wextra ... plus 13.7's set | fast edit-compile-run |
| test / CI | the same, plus -fsanitize=address,undefined -fno-sanitize-recover=all | catch what the compiler cannot |
| release | -O2 -g and the same warnings | what you ship |
Three builds, the same warnings in all of them, and the sanitizers in the one place where their cost buys something.
8. Where They Fit
Chapter 13.1's ladder, now complete:
| Layer | Caught, of 15 | Cost |
|---|---|---|
-Wall -Wextra -Wpedantic | 1 | nothing |
| plus the recommended flags (13.7) | 4 | nothing |
| plus a static analyser | 9 | a separate run, no execution needed |
| plus sanitizers | 11 | ~2× memory, and only on paths you run |
| design and review | the remaining 4 | the only thing that reaches them |
Read the last two rows together. Sanitizers add two defects over everything static, at the cost of a slower build that must actually execute the faulty path — and four defects remain that no tool on this list touches. Chapter 13.11 is about those.
9. What You Should Be Able to Say Now
- A sanitizer rewrites your program to check itself — the binary you test is not the binary you ship.
- They find what static tools cannot because validity depends on run-time values — 13.1's ceiling was 9 of 15 statically.
- Measured availability on arm64 macOS: address, undefined and thread yes; memory and leak NO.
- So the only detector for Chapter 9.12's uninitialised reads could not be run here, and leak detection is absent in both forms — 9.9 used the platform
leakstool instead. - ASan reports the kind, the access, the storage, and — measured — the function that caused it rather than the one that displayed it; plus where a block was freed and allocated.
- UBSan reports operations: overflow, shifts, null, bounds, alignment, division by zero — measured on programs that gave no other sign.
-fno-sanitize-recover=allmakes the first finding stop the program; by default UBSan reports and continues.- Measured cost: ASan 1.17× time, 1.97× memory; UBSan 1.08× / 1.61×. The memory figures are the reliable ones; the time figures are at the low end for this workload.
- Two of my three cost measurements were worthless — one benchmark the optimiser deleted, one swamped by noise. An implausible number is a signal.
- A sanitizer is a detector, not a prover — it reports only faults your inputs reach, and 9.9 measured a detector finding 1 of 2 real leaks.
- Combine ASan and UBSan at
-O1 -g; TSan alone; run them on tests; never ship them. - The complete ladder: 1 → 4 → 9 → 11, and four defects that only design and review reach.
10. Exercises
Exercise 1 — choose the tool. For each symptom, name the sanitizer and what you expect it to report.
- a crash inside
freewith no obvious cause - totals that are occasionally absurdly negative
- a program that works at
-O0and fails at-O2 - a value that differs between two runs with identical input
- memory use that grows steadily over hours
For the last two, say what you would do on a platform where the relevant sanitizer is unavailable.
Exercise 2 — write the command. Give the compile command for a CI build that catches as much as possible and fails on the first finding. Justify each flag.
Exercise 3 — read the report. Given an ASan heap-use-after-free naming three locations, say what each is and which you would read first, referring to Chapter 9.10.
Exercise 4 — the coverage question. A team reports "ASan is clean on our test suite". List three questions you would ask before concluding the code is free of memory errors.
Exercise 5 — the false negative. Explain, using Chapter 9.9 §8's measurement, how a leak detector can report fewer leaks than exist, and why that is a design decision rather than a bug.
Exercise 6 — the benchmark. A colleague reports that ASan costs "almost nothing — 1.1×". Give three questions from §5's callout that you would ask about the measurement.
Exercise 7 — the platform gap. You are developing on arm64 macOS and deploying to Linux. Say which defect class you cannot detect locally, which chapter is about it, and two things you could do about it.
Exercise 8 — place the layers. Using §8's table, write three sentences for a team deciding whether sanitizers are worth adding to CI.
11. Summary
- A sanitizer rewrites your program to check itself; it finds what static analysis cannot because validity is a run-time property.
- Measured here: address, undefined, thread available; memory and leak unavailable on arm64 macOS — so 9.12's bug class has no detector on this platform.
- ASan names the kind, the access, the storage, the causing function, and a freed block's free and allocation sites.
- UBSan names the operation, the values, the type and the line — on programs with no other symptom.
- Measured: ASan 1.17× time / 1.97× memory, UBSan 1.08× / 1.61× — memory is the reliable figure, time is workload-dependent and low here.
- Two of three cost measurements were worthless; an implausible number is a signal.
- A detector, not a prover — only faults your inputs reach, and 1 of 2 leaks in a measured case.
- ASan + UBSan at
-O1 -gwith-fno-sanitize-recover=all, on your tests, never shipped. - The ladder: 1 → 4 → 9 → 11, with four defects left to design and review.
Those four have been waiting since Chapter 13.1 measured them, and they have not moved: a structure whose members were reordered under a positional initialiser, const defeated by a pointer typedef, a macro that expands to two statements, and code inside a branch nobody compiles. No warning flag sees them, no analyser traces them, no sanitizer can instrument code that was never generated. What they have in common is that each one is a perfectly well-formed program that means something other than what its author intended — and each is prevented, completely, by a habit that costs nothing and that this curriculum has already measured. The final chapter of Part I collects those habits: not a style guide, but the specific small set that removes whole classes of defect rather than fixing them one at a time.
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.
