Skip to content
VLSI Mentor

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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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; }
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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=0xDEAD

Read 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
std::vector<int> v{1,2,3};
v[5];        // no check
v.at(5);     // checked
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
B. vector: operator[] against at()
   v.size()=3  v[5] reads 0   (no check)
   v.at(5) threw: vector

v[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:

configurationexitdiagnosticswhat it said
plain -O100—
-fsanitize=address1341heap-buffer-overflow
-fsanitize=undefined00—
_LIBCPP_HARDENING_MODE=DEBUG1341Hardening assertion
ASan + hardening1341Hardening assertion

regs[4] inside the struct:

configurationexitdiagnosticswhat it said
plain -O100—
-fsanitize=address00—
-fsanitize=undefined01index 4 out of bounds for type 'int[4]'
_LIBCPP_HARDENING_MODE=DEBUG00—
ASan + hardening00—

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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
…ERROR: AddressSanitizer: heap-buffer-overflow on address …
… is located 8 bytes after 12-byte region […)
SUMMARY: AddressSanitizer: heap-buffer-overflow o.cpp:29 in main
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
so.cpp:3:66: runtime error: index 4 out of bounds for type 'int[4]'
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/c++/v1/__vector/vector.h:406: libc++ Hardening assertion __n < size() failed: vector[] index out of bounds

ASan 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
int* p = f.regs;
p[4] = 0xDEAD;          // no declared bound, inside one allocation

UBSan 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 array

Prefer 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
void load(std::span<const int> regs);      // the length travels with the data
void load(const int* regs);                // the length does not

That 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 questionthe answermeasured
what does an out-of-range write dowrites at a computed address§1
what gets damagedan adjacent member, not the array§2, after changed, regs intact
does a negative index work tooyes — same arithmetic§2, before changed
what does v[5] on a 3-element vector give0, silently§3
and v.at(5)throws; what() is "vector"§3
does ASan catch the vector caseyes — heap-buffer-overflow, exit 134§4
does ASan catch the struct caseno§4
does UBSan catch the struct caseyes — names int[4] — but exits 0§4
does UBSan catch the vector caseno§4
is there one configuration that catches bothno§4
what catches a decayed-pointer overflownothing here§5
so what removes the classcheck 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,undefined and _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::array and std::span instead of raw arrays and bare pointers, so the length travels with the data.
  • Use at() for a genuinely unvalidated index, std::optional for an expected miss, and assert for 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] changed after and regs[-1] changed before, with regs itself 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, and at() throws with the terse what() 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=all is 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::span or std::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] = 0xDEAD changed the member after, regs[-1] = 0xFEED changed before, and regs itself 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 unhelpful what() of "vector".
  • The two commonest forms have disjoint detectors. ASan and _LIBCPP_HARDENING_MODE catch the container access (exit 134); UBSan catches the in-struct array write (naming int[4]) and misses the container one; ASan misses the struct one. No single configuration catches both — so build with -fsanitize=address,undefined and 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=all is 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 — and assert the 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.