C Part I · Module 13 · Writing Correct and Reliable C
Out-of-Bounds Access
Both of the previous chapter's dangerous shapes ended the same way: a loop whose bound was wrong, running past the data it was meant to visit. What happens then is the subject this module has circled since Chapter 6.4 without treating head-on. An index one past the end is not an error C reports — it is an ordinary read or write of whatever occupies that address, which belongs to something else. The usual account says the something else is the variable you declared next. This chapter measures that claim and finds it false, which turns out to matter: the real answer is that you cannot predict what an overrun hits, the answer changes when you change a compiler flag, and that unpredictability is exactly why the damage surfaces somewhere unconnected to the line that caused it.
Intermediate19 min readCPart IFundamentalsCorrectness
Part I · Module 13 · Chapter 13.6 · Out-of-Bounds Access
1. What the Language Does
int a[4];
a[4] = 99;Chapter 6.4 established the rule and it is worth restating in this module's vocabulary: accessing an array outside the range [0, n) is undefined behaviour. Not an error, not a trap, not a defined-but-wrong value — Chapter 13.2's category, with everything that follows.
What the generated code does is simpler and more alarming: a[i] compiles to an address computation and a memory access. There is no length stored anywhere, nothing to compare i against, and no instruction that could fail. The hardware is asked for the contents of an address and supplies them.
An out-of-range access is not a failed array access. It is a successful access to something else.
One-past-the-end is the single exception worth remembering, and it is narrow: Chapter 9.11 §2 established that the pointer value a + n may be formed and compared. Dereferencing it is still undefined.
2. What It Actually Hits
The usual explanation is that a[4] overwrites whatever was declared next. That is checkable, so here it is checked — with fully defined code that takes addresses and compares them as integers, never accessing anything out of range.
int before = 111;
int a[4] = { 0, 1, 2, 3 };
int after = 999;declared order: before, a[4], after
&before - &a[0] = -8 bytes
&after - &a[0] = -12 bytes
sizeof a = 16 bytes, so a occupies [0, 16)
where would a[4] land? at offset +16 from &a[0]
is that 'after'? NO
is that 'before'? NONeither. Both neighbours are at negative offsets — below the array, not above it — and they are not even in declaration order: after, declared last, sits furthest down.
And the arrangement is not stable:
--- -O0 ---
&before - &a[0] = -8 bytes
&after - &a[0] = -12 bytes
--- -O2 ---
&before - &a[0] = -4 bytes
&after - &a[0] = -8 bytesChanging one flag moved both of them.
The compiler arranges local storage however suits it — by alignment, by register pressure, by whatever the optimiser decided — and nothing in the language requires declaration order, adjacency, or stability between builds.
So the honest version of the trace is not "it overwrites after". It is:
You do not know what is there. It may be another local, part of a saved register, padding, the return address, or storage that is not in use at all.
It is not the same between builds. Both offsets moved with one flag.
Which is why the symptom appears somewhere unconnected. This is Chapter 9.11 §4's distance between cause and symptom, arriving from a different direction: the corruption lands on whatever the layout put there, the code that owns that storage misbehaves later, and the stack trace points at code that is entirely correct.
And "it seemed to work" means nothing, because the storage you hit may have been unused in the build you tested.
3. The Three Places It Happens
On the stack, as above. Also the place where a sufficiently large overrun reaches the return address, which is the mechanism behind a long-standing family of security vulnerabilities.
In allocated memory. An overrun past a malloc block runs into the allocator's own bookkeeping — the data structures that record block sizes and free lists. Corrupting those produces a crash inside malloc or free, arbitrarily later, in a call that has nothing to do with the bug. Chapter 9.10 §3 measured that freed storage is handed back out at the identical address, which is the same theme: the allocator's memory is shared, and damage to it travels.
In a struct or a global. The neighbours are different but the principle is identical, with one wrinkle — Chapter 10.5 measured that a structure contains padding, so an overrun from one member may land in padding and do nothing visible at all. That is the worst outcome, because the bug then has no symptom until the layout changes.
4. What Catches It
int a[4] = { 0, 1, 2, 3 };
a[4] = 99; /* constant index */warning: array index 4 is past the end of the array (that has type 'int[4]')
[-Warray-bounds]Caught — -Warray-bounds is part of -Wall.
Now the same access with the index in a variable:
int a[4] = { 0, 1, 2, 3 };
int i = 4;
a[i] = 99;$ cc -std=c17 -Wall -Wextra -Wpedantic -c b2.c -o /dev/null
0 warningsNothing.
The compiler catches the case nobody writes and is blind to the case everybody writes. A constant index past the end of a visible array is a typo; a variable index is how every real loop works, and its value is a run-time property the compiler usually cannot know.
A sanitizer does see it:
==59772==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x00016d19a6b0
WRITE of size 4 at 0x00016d19a6b0 thread T0
Address 0x00016d19a6b0 is located in stack of thread T0 at offset 48 in frame
SUMMARY: AddressSanitizer: stack-buffer-overflow b2.c:6 in mainThe access, its size, where the storage came from, and the line. That is Chapter 13.10's subject; here it is the measurement that completes the picture — and note its condition, which matters: a sanitizer reports only accesses that actually happen, so it finds the bug only for inputs that reach it.
5. Why Checking Everywhere Does Not Work
The instinctive response is to check at each access:
for (size_t i = 0; i < n; i++) {
if (i < count) { /* again? */
total += values[i];
}
}Three problems, and they are why c.ts asks for a different design.
It is noise. A check that can never fail still has to be read, and a reader who sees redundant checks stops reading any of them carefully.
It does not say what to do. A check at the point of access has no good failure path — returning early from deep inside a loop usually leaves the caller with a partial result and no explanation.
And it is incomplete anyway. The check has to know the bound, which means the bound has to be available at every access, which means it was being carried along all the time — at which point one check at the start would have done.
6. Check Once at the Boundary, Trust Afterwards
The design c.ts asks for.
Establish the invariant where untrusted length meets trusted buffer. Inside that boundary, the code may assume it and does not re-check.
#include <stddef.h>
/* The boundary. Everything the caller supplied is validated here. */
int summarise(const int *values, size_t count, int *out_total)
{
if (values == NULL || out_total == NULL) { return -1; }
if (count == 0) { return -1; }
if (count > MAX_SAMPLES) { return -1; }
*out_total = total_of(values, count); /* trusted from here on */
return 0;
}
/* Inside the boundary. No re-checking; the contract is the parameter pair. */
static int total_of(const int *values, size_t count)
{
int total = 0;
for (size_t i = 0; i < count; i++) { /* i < count, by construction */
total += values[i];
}
return total;
}Four properties, each from an earlier chapter:
The pointer and the count travel together, always — Chapter 7.7's pairing. A function that receives one without the other cannot be correct, and a function that receives both does not need anything else.
The loop bound is the count, so the invariant is maintained by construction rather than by a test. i < count is the only comparison and it is the loop's own condition.
The validation is at the boundary and reports through a return value, so there is one place that can fail and one place that says so — Chapter 12.1's convention A and Chapter 9.8 §8's commit-on-success.
The inner function is static, so Chapter 11.4 §4's internal linkage means it cannot be called from outside the boundary. That is not a style choice; it is what makes "trusted afterwards" true rather than hopeful.
The last point is the one that turns a convention into a property:
A function that may assume its arguments are valid should be unreachable from code that has not validated them.
staticis how C expresses that.
7. Making the Bound Hard to Get Wrong
Five habits, each measured somewhere in this curriculum.
Derive the count, never restate it. sizeof a / sizeof a[0] in the scope that declares the array — Chapter 6.1 — and never a literal that can drift.
Remember that the idiom dies at the function boundary. Chapter 10.6 §5 measured the quotient giving 4 in main and 0 inside the function that received the array. Compute it once, where the array is declared, and pass it.
Prefer < to <=. i <= n is off by one against a count; the half-open convention [0, n) is what every C idiom assumes, and mixing conventions is where off-by-one errors come from.
Never subtract from an unsigned bound that could be zero. Chapter 13.5 §5 measured strlen("") - 1 as 18446744073709551615. Write i + 1 < n.
Take the capacity as a parameter for anything that writes. Chapter 8.5's output-buffer rule — a function that writes into a caller's buffer and was not told its size cannot be correct, and snprintf is the model.
And one structural habit worth more than the rest: keep the bound and the data in the same place. A pointer and a length passed together, or a structure holding both, cannot drift apart. Two separate variables maintained in parallel can, and will — which is Chapter 10.1's measured parallel-array failure in a different costume.
8. What You Should Be Able to Say Now
- Out-of-range access is undefined behaviour; the generated code has no length to check against and the access simply succeeds.
- One-past-the-end may be formed and compared, never dereferenced (9.11 §2).
- Measured: neither neighbouring variable sat where
a[4]would land — both were at negative offsets, and not in declaration order. - Measured: the layout moved with the optimisation level —
-8and-12at-O0,-4and-8at-O2. - So you cannot predict what an overrun hits, and "it seemed to work" means nothing — the storage may have been unused in that build.
- That unpredictability is why the symptom appears somewhere unconnected (9.11 §4).
- Three sites: the stack (including the return address), allocated memory (the allocator's bookkeeping, failing later elsewhere), and structs, where an overrun may land in padding and have no symptom at all.
- Measured: a constant index past the end is caught by
-Warray-boundsin-Wall; the same index in a variable produced no warning. - ASan reports it with the access, the size, the storage and the line — for inputs that actually reach it.
- Checking at every access is noise, has no good failure path, and is redundant with carrying the bound.
- Check once at the boundary and trust afterwards — and make inner functions
staticso unvalidated code cannot reach them. - The bound must come from the data: derive it, pass it with the pointer, never subtract from an unsigned zero, and take a capacity for anything that writes.
9. Exercises
Exercise 1 — find the overrun. Each has one. Give the input or state that triggers it.
for (int i = 0; i <= n; i++) { a[i] = 0; }
for (size_t i = 0; i < strlen(s) - 1; i++) { }
char buf[8]; strcpy(buf, name);
int a[4]; int *p = a; for (int i = 0; i < 4; i++) { *p++ = i; } *p = 5;
void f(int a[], size_t n) { for (size_t i = 0; i < sizeof a / sizeof a[0]; i++) { } }Exercise 2 — predict the diagnostics. For each, say whether -Wall -Wextra warns, and what ASan would report.
int a[4]; a[4] = 1;
int a[4]; int i = 4; a[i] = 1;
int a[4]; int i = get(); a[i] = 1;Exercise 3 — refute the story. A colleague says an overrun "just overwrites the next variable, so declare a dummy afterwards as padding". Using §2's measurements, give three reasons this does not work.
Exercise 4 — move the check. Rewrite this so the bound is checked once at a boundary and the inner loop trusts it. Say what you made static and why.
int process(int *v, size_t n, size_t limit) {
int t = 0;
for (size_t i = 0; i < n; i++) {
if (i < limit && v != NULL) { t += v[i]; }
}
return t;
}Exercise 5 — trace the bound. For each, say where the bound came from and what guarantees it matches the buffer.
read_into(buf, sizeof buf);
read_into(buf, BUF_SIZE);
read_into(buf, header.length);
read_into(buf, n); /* n was computed 40 lines earlier */Exercise 6 — the padding case. Explain why an overrun from one structure member into padding may produce no symptom, why that is worse than a crash, and which chapter measured that padding exists.
Exercise 7 — the allocator. Explain why overrunning a malloc block often crashes inside an unrelated later call to free, and what that does to the usefulness of the stack trace.
Exercise 8 — design the interface. Write the signature and contract, in Chapter 12.1's four-question form, for a function that copies a caller-supplied byte range into an internal buffer. State every check it performs and which of them its callees may then assume.
10. Summary
- Out-of-range access is undefined, and the generated code has nothing to check against — the access succeeds against something else.
- Measured: neither neighbour was where
a[4]would land; both were below the array and not in declaration order. - Measured: the layout changed with the optimisation level.
- So you cannot predict what it hits, "it worked" proves nothing, and the symptom lands somewhere unconnected.
- Stack · allocated memory · structs — and in a struct it may hit padding and show nothing.
- Measured: a constant index past the end is caught by
-Wall; the same index in a variable is not. - ASan names the access, the size, the storage and the line.
- Checking at every access is noise. Check once at the boundary and trust after, with
staticmaking that true. - The bound must come from the data — derive it, carry it with the pointer, never subtract from unsigned zero, always take a capacity when writing.
Three chapters have now ended by naming a warning flag that would have caught something, and the flags have been accumulating without ever being treated as a subject: -Wundef from Chapter 11.7, -Wformat-nonliteral from Chapter 12.2, -Wtautological-unsigned-zero-compare from Chapter 13.5, and the observation in this one that -Warray-bounds sits inside -Wall while -Wsign-compare sits inside -Wextra. Chapter 13.1 measured what the default set is worth — one defect out of fifteen — and left open what a better set is worth. The next chapter settles that: which flags actually earn their place, what each one's warnings are really telling you, and why the most useful thing you can do with a warning is stop treating it as advice.
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.
