C++ Part I · Module 25 · Debugging, Undefined Behavior, and Performance
Out-of-Bounds Access
An index outside an array's range does not produce an error, a trap, or a diagnostic. It produces a read or a write at an address computed exactly as the language specifies, landing on whatever happens to be there — and what is there is some other variable, some other member, or a container's bookkeeping. That is why the symptom of this bug is almost never located where the bug is: the damage appears in the thing that got overwritten, which may be read correctly, much later, by code that is entirely innocent. This chapter traces one write to the member it corrupts and shows that the array it was aimed at is untouched, which is the detail that makes the bug so hard to reason about from a symptom. Then it measures which tools report it, and that measurement has a conclusion worth knowing before you choose a debug configuration: the two most common forms of this bug are caught by different tools, and there is no single setting that catches both.
Intermediate18 min readC++Part IFundamentalsDebugging
Part I · Module 25 · Chapter 25.3 · Out-of-Bounds Access
1. What the Language Actually Does
a[i] means *(a + i). The compiler computes an address from the base and the index, scaled by the element size, and accesses it. There is no bound in the expression and none in the generated code, because the bound is not part of a pointer's or an array's run-time representation — int regs[4] carries its size in the type, which is compile-time information that is gone the moment the array decays to a pointer.
So an out-of-range index is undefined behaviour in Chapter 25.1 §1's full sense, and the usual realisation of it is simply an access at a computed address. What lives at that address is the subject of §2.
Two sizes do exist at run time and are worth separating from the above. A std::vector knows its size(), and a std::array<T, N> knows N as a compile-time constant — which is why both can be checked and a raw array cannot.
2. Tracing One Write
Three members, laid out adjacently:
struct Frame {
int before = 0xAAAA;
int regs[4] = {1,2,3,4};
int after = 0xBBBB;
};
void writeAt(Frame& f, int i, int v) { f.regs[i] = v; }A. one out-of-range write into a 4-element array
before the write: before=0xAAAA regs={1,2,3,4} after=0xBBBB
after regs[4]=0xDEAD: before=0xAAAA regs={1,2,3,4} after=0xDEAD
after regs[-1]=0xFEED: before=0xFEED regs={1,2,3,4} after=0xDEADRead the second and third lines carefully, because they contain the whole lesson.
regs[4] = 0xDEAD changed after. after is a different member with a different name and a different purpose, and it now holds a value written through regs. Nothing in the source of after's declaration, its reads, or its uses has anything to do with the array.
regs[-1] = 0xFEED changed before. A negative index is the same arithmetic in the other direction and is just as legal to write.
And regs is {1,2,3,4} in both cases — unchanged. This is the detail that defeats symptom-led debugging. A developer told that after has a wrong value will inspect after: who writes it, what its initial value is, whether a function mutates it. None of that reveals anything, because the write did not go through after's name. The only way to find it from the symptom is to ask what is adjacent in memory, which is a question about layout rather than about code.
The direction of the damage follows the layout, which means it follows Chapter 23.6 §2's alignment and ordering rules. Reorder the struct's members and a different variable gets corrupted; add a member and the corruption may land in padding and disappear. That is why this bug class is notorious for "fixing itself" after an unrelated edit.
3. The Container Case
std::vector<int> v{1,2,3};
v[5]; // no check
v.at(5); // checkedB. vector: operator[] against at()
v.size()=3 v[5] reads 0 (no check)
v.at(5) threw: vectorv[5] returned 0, which is a plausible value and no indication of anything wrong. at() threw, and libc++'s what() is the terse string "vector" — accurate about where the exception came from and unhelpful about what happened, which is worth knowing before you design a diagnostic around it.
operator[] is unchecked by design: it is the operation you use when the index is already known good, and the standard specifies no check so that the common case costs nothing. at() is the checked operation. Neither is the "right" one in general; §7 is about where the check belongs.
4. Which Tools See It
This is the measurement that should determine your debug configuration. Two bugs — the container access from §3 and the struct-member write from §2 — against five build configurations:
v[5] on a std::vector:
| configuration | exit | diagnostics | what it said |
|---|---|---|---|
plain -O1 | 0 | 0 | — |
-fsanitize=address | 134 | 1 | heap-buffer-overflow |
-fsanitize=undefined | 0 | 0 | — |
_LIBCPP_HARDENING_MODE=DEBUG | 134 | 1 | Hardening assertion |
| ASan + hardening | 134 | 1 | Hardening assertion |
regs[4] inside the struct:
| configuration | exit | diagnostics | what it said |
|---|---|---|---|
plain -O1 | 0 | 0 | — |
-fsanitize=address | 0 | 0 | — |
-fsanitize=undefined | 0 | 1 | index 4 out of bounds for type 'int[4]' |
_LIBCPP_HARDENING_MODE=DEBUG | 0 | 0 | — |
| ASan + hardening | 0 | 0 | — |
ASan and hardening catch the first and miss the second. UBSan catches the second and misses the first. No single configuration catches both.
The reports themselves are good when they come:
…ERROR: AddressSanitizer: heap-buffer-overflow on address …
… is located 8 bytes after 12-byte region […)
SUMMARY: AddressSanitizer: heap-buffer-overflow o.cpp:29 in mainso.cpp:3:66: runtime error: index 4 out of bounds for type 'int[4]'/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__vector/vector.h:406: libc++ Hardening assertion __n < size() failed: vector[] index out of boundsASan names the overflowed region and its size and the line. UBSan names the array's type, which is how it knows the bound. Hardening names the library's own precondition.
One detail with teeth: UBSan caught the struct write and still exited 0. Chapter 24.7 §5 measured the same thing, and -fno-sanitize-recover=all is the fix. A gate reading only the exit status would have recorded a pass on the one configuration that found the bug.
5. The Residual Case the Tools Cannot Reach
Worth stating plainly, because it bounds what the previous section recommends. Take the address of the array and index through the pointer:
int* p = f.regs;
p[4] = 0xDEAD; // no declared bound, inside one allocationUBSan has no int[4] to check against — p is an int*. ASan has no redzone to hit — the write lands on after, inside the same object. The access is undefined behaviour and nothing on this machine reports it.
That is the same shape as Chapter 20.3 §5's erase-in-a-loop, which was silent under every tool for an analogous reason: the stale iterator stayed inside a live allocation and below size(). A mistake that stays inside valid memory is invisible to memory tools, and that is a property of the bug rather than a weakness of the tooling.
6. Where the Symptom Appears
Because the write lands on something else, the symptom has three characteristic shapes, and recognising them is what makes you suspect this bug class early:
A variable changes with no code that writes it. §2's after. A watchpoint is the right tool — Chapter 25.5 §5 — because it reports the instruction that wrote, which will be in the array code.
A value is wrong only when some unrelated code is enabled. Adding a member, reordering fields, or a different optimisation level moves the layout, so the overflow lands somewhere else. This is why the bug "goes away" and comes back.
A container misbehaves in ways its interface should make impossible. Writing past a std::vector's buffer can corrupt the heap allocator's own bookkeeping, and the crash then happens in the next unrelated malloc or free, with a stack trace entirely inside the library. Chapter 25.7 §4 is how to read such a trace.
In all three the correct first move is the same: stop reading the code around the symptom and start asking what is adjacent to the corrupted object.
7. Check the Bound Once, Then Trust It
The frozen purpose of this chapter is the discipline, and it is a structural one rather than a list of safer functions.
Validate at the boundary, in one place. The index comes from somewhere — a file, a register field, a loop over another container. Check it where it enters your code and reject it there:
std::optional<Txn> decode(std::uint32_t word)
{
const int master = (word >> 8) & 0xF;
if (master >= kMasterCount) return std::nullopt; // checked ONCE, here
return Txn{..., master};
}After that, masters[t.master] is safe by construction and operator[] is the right operator. Chapter 23.3 §5's from_chars validation and Chapter 23.8 §1's vector reader are the same move at the file boundary.
Prefer a range-based loop or an algorithm, which cannot be off by one:
for (int v : regs) use(v); // no index exists
std::fill(regs, regs + 4, 0); // bound written once
std::fill(std::begin(regs), std::end(regs), 0); // bound derived from the arrayPrefer std::array and std::span to raw arrays. std::array<int,4> keeps size() and does not decay silently; std::span carries a pointer and a length, which is what makes a function taking it checkable:
void load(std::span<const int> regs); // the length travels with the data
void load(const int* regs); // the length does notThat is the fix for §5's residual case: the decayed pointer loses the bound, and a span is a pointer that does not.
Use at() where the index is genuinely unvalidated and you want an exception rather than a check you might forget — a lookup from user input, for instance. Chapter 22.1 §1's taxonomy applies: if a bad index is an expected outcome, return std::optional; if it is a programming error, assert it and use operator[].
Assert the precondition where it cannot be checked. Chapter 22.8 is the mechanism, and the boundary rule from Chapter 21.2 §3 applies: assert zero, one, the maximum and the maximum plus one, because those are the indices that find this.
8. The Table
| the question | the answer | measured |
|---|---|---|
| what does an out-of-range write do | writes at a computed address | §1 |
| what gets damaged | an adjacent member, not the array | §2, after changed, regs intact |
| does a negative index work too | yes — same arithmetic | §2, before changed |
what does v[5] on a 3-element vector give | 0, silently | §3 |
and v.at(5) | throws; what() is "vector" | §3 |
| does ASan catch the vector case | yes — heap-buffer-overflow, exit 134 | §4 |
| does ASan catch the struct case | no | §4 |
| does UBSan catch the struct case | yes — names int[4] — but exits 0 | §4 |
| does UBSan catch the vector case | no | §4 |
| is there one configuration that catches both | no | §4 |
| what catches a decayed-pointer overflow | nothing here | §5 |
| so what removes the class | check the bound once at the boundary; carry the length | §7 |
9. What to Actually Do
- Validate an index where it enters your code, once, and reject it there — then
operator[]is correct and cheap. - Build the debug configuration with
-fsanitize=address,undefinedand_LIBCPP_HARDENING_MODE. Measured: the three tools are complementary, not overlapping. - Add
-fno-sanitize-recover=all, because UBSan caught the struct write and exited 0. - Prefer range-based loops and algorithms over an index you have to get right.
- Use
std::arrayandstd::spaninstead of raw arrays and bare pointers, so the length travels with the data. - Use
at()for a genuinely unvalidated index,std::optionalfor an expected miss, andassertfor a programming error. - Assert the boundaries — 0, 1, max, max+1 — per Chapter 21.2 §3.
- When a variable changes with no code that writes it, set a watchpoint rather than re-reading the source.
- When a crash lands inside the allocator, suspect a heap overflow elsewhere, not a bug in the library.
10. Mistakes Worth Making Once
Debugging the corrupted variable. Nothing in after's code is wrong; the write came through regs.
Assuming the array will look wrong. Measured: regs was {1,2,3,4} after both out-of-range writes.
Assuming ASan covers out-of-bounds. It missed the struct write entirely.
Assuming UBSan covers it. It missed the vector access entirely.
Running one sanitiser and calling the configuration hardened. No single one catches both.
Reading only the exit status. UBSan found the bug and exited 0.
Passing const int* where the length matters. The bound is gone and no tool can reconstruct it.
Concluding a fix worked because the symptom moved. Reordering a struct relocates the damage.
v[i] with i from a file. Chapter 23.3 §4 measured >> accepting "12abc" as 12.
11. Interview Relevance
The frozen curriculum marks this chapter Critical. The question is usually "what happens if you index past the end of an array?", and the answer that lands goes past "undefined behaviour" to the mechanism and the symptom: the access happens at a computed address, it lands on whatever is adjacent, and therefore the array you indexed looks correct while some other variable is wrong — which is why the bug cannot be found by inspecting the thing that broke.
The measurement to quote is short: writing regs[4] changed the member declared after the array while regs itself stayed {1,2,3,4}.
The follow-up that distinguishes real experience is about tooling, and the honest answer is the matrix: ASan catches a heap overflow and misses a write that stays inside one object; UBSan catches the in-object array write because it knows the declared type and misses the container case because a T* carries no bound; libc++ hardening catches the container case from the container's own invariant. No single configuration catches both, which is why you run them together — and even then a decayed pointer defeats all three, which is the argument for std::span.
12. What You Should Be Able to Say Now
- That
a[i]is*(a + i)with no run-time bound, because an array's size lives in its type and is lost on decay. - That the damage lands on an adjacent object — measured,
regs[4]changedafterandregs[-1]changedbefore, withregsitself unchanged both times. - Why that defeats symptom-led debugging, and why reordering a struct makes the bug appear to move or vanish.
- That
v[5]on a 3-element vector returned 0 silently, andat()throws with the tersewhat()string"vector". - The detector matrix: ASan and hardening catch the container case; UBSan catches the in-struct case; no configuration catches both.
- Why: ASan checks allocation redzones, UBSan checks the declared array type, hardening checks the container invariant — three different sources of truth.
- That UBSan exited 0 on the bug it caught, so
-fno-sanitize-recover=allis required. - That a write through a decayed pointer, staying inside one allocation, is caught by nothing here — the same shape as Chapter 20.3 §5.
- That the remedy is structural: check the bound once at the boundary, then carry the length with the data via
std::spanorstd::array.
13. Exercises
Exercise 1 — trace it. Reproduce §2 and confirm which member each out-of-range write damages. Then reorder the struct's members and repeat; explain the new result from the layout.
Exercise 2 — the matrix. Build both bugs under all five configurations and reproduce the two tables. Check the exit status of each, not just the output.
Exercise 3 — the residual case. Write §5's decayed-pointer version and run it under ASan, UBSan and hardening together. Confirm that nothing reports it, and explain why from the callout.
Exercise 4 — span. Convert the function to take std::span<int> and index it. Find out whether anything catches s[4] now, with and without hardening.
Exercise 5 — the watchpoint. Set a watchpoint on after and run the program. Confirm that the reported writing instruction is in the array code, not in anything named after.
14. Summary
- An out-of-range index is an access at a computed address. There is no run-time bound: an array's size is in its type, and decaying to a pointer discards it.
- The damage lands on something else. Measured:
regs[4] = 0xDEADchanged the memberafter,regs[-1] = 0xFEEDchangedbefore, andregsitself was{1,2,3,4}after both — which is why inspecting the corrupted variable finds nothing, and why reordering a struct makes the bug move. v[5]on a 3-element vector read 0 silently;at()threw with the unhelpfulwhat()of"vector".- The two commonest forms have disjoint detectors. ASan and
_LIBCPP_HARDENING_MODEcatch the container access (exit 134); UBSan catches the in-struct array write (namingint[4]) and misses the container one; ASan misses the struct one. No single configuration catches both — so build with-fsanitize=address,undefinedand hardening. - The reason is that each tool has a different source of truth: allocation redzones, declared array types, container invariants. The misses are structural, not defects in the tools.
- UBSan exited 0 on the bug it caught, so
-fno-sanitize-recover=allis part of the configuration. - A write through a decayed pointer that stays inside one allocation is caught by nothing here — the same shape as Chapter 20.3 §5's erase-in-a-loop.
- So tools reduce this class and only structure removes it: validate the index once where it enters, then carry the length with the data —
std::span,std::array, range-based loops and algorithms — andassertthe boundaries where a check is impossible.
Chapter 25.4 takes the third shape of memory failure, where the object is not in the wrong place but no longer exists at all — and finds the compiler catching exactly what the sanitiser misses.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 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.
