1. Quick orientation in the report
When you see an AddressSanitizer or UndefinedBehaviorSanitizer output for the first time, you may want to close the terminal and go grow potatoes in the woods. At least there “out of range” is written in human terms. But the sanitizer report is long not out of malice: it deliberately gives maximum context so you can find the root cause, not just the “place where everything exploded”.
The main idea of this lecture is very practical: read the sanitizer report not “top to bottom”, but like a map. This map has several landmarks: the problem type, coordinates (file/line), the call stack and sometimes extra diagnostics (for example, “by how many bytes you went out of bounds”). If you learn to pick out these landmarks, even the longest report becomes a short investigation script: “what happened → where it was caught → who brought the bad data → why they brought it”.
What to look for with your eyes in the first 10 seconds
Any decent sanitizer report, even if it looks different in GCC/Clang and on different OSes, almost always contains the same semantic blocks. Your job is to learn to recognize these blocks like road signs: “caution, curve”, “dead end”, “you can turn back here”.
Below is a handy “map” (table) of what is usually important for a newcomer. This is not a reference for all report fields, but a set of “beacons” that help you quickly understand where to look.
| Block in the report | Typical appearance | What it means for you |
|---|---|---|
| Problem type | |
This is the “disease name”. Start from it: what exactly is violated. |
| Place of detection | |
This is where the sanitizer noticed the problem. It is not always the place where you computed the wrong index/divisor. |
| Stack trace | |
This is “how we got here”. The root cause is almost always found by following it. |
| Extra details (ASan) | |
Hints about the scale and type of memory access. Useful, but secondary. |
| Runtime frames | References to libc, startup, sanitizer runtime | You can often ignore them until you learn to catch the root cause. |
The most important rule of this lecture sounds boring but saves hours: find the first stack line that points to your source file, and start unwinding the chain upward semantically. You don't need to “understand the whole report”. You need to understand enough to find the code line and the conditions under which it breaks.
Algorithm for reading the report
Almost any diagnostic (and sanitizers are very honest about this) benefits from a repeatable algorithm. You don't want to improvise every time like a detective in a bad TV show. You want a simple procedure: do it once, do it twice — and your brain does not overheat.
Let's fix a “mental conveyor”. It's short and it almost always works:
flowchart TD
A[Find the problem type] --> B[Find the first reference to your .cpp/.hpp]
B --> C[Look at the code line: what dangerous operation is done]
C --> D[Climb the stack: who passed the arguments]
D --> E[Formulate an invariant: what must be true]
E --> F[Find the root cause: where the invariant was violated]
Pay attention to the step “formulate an invariant”. This is more important than it seems. The sanitizer tells you “it broke here”, but it does not always tell which condition you should have guaranteed. The condition is usually very human: “index must be less than size”, “divisor must not be zero”, “shift must be in a valid range”. Your job is to turn that into a check, a function design, or at least an understanding of “how this could happen”.
3. Mini-project for examples
To make reports concrete and “about your code”, we need a small project that is easy to extend. Let it be a console app that stores a set of integers and executes commands. We are not building a century architecture — we care about the call chain where an error can “be born” in one place and “explode” in another.
The idea: the user types numbers (in a single line), then types a command. Commands are simple: last2avg (average of last two), norm (normalization: x / sum), and exit.
Let's start with a skeleton (safe for now):
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<int> data{10, 20, 30};
std::cout << "data size = " << data.size() << '\n'; // data size = 3
std::cout << "Type command: last2avg / norm / exit\n";
std::string cmd;
while (std::cin >> cmd && cmd != "exit") {
std::cout << "Unknown command\n";
}
}
Boring, but this is a “build point”: we'll add functions so sanitizer reports show a call chain, not a lonely main. If the stack always contains only one function, you won't learn to find the root cause — you'll just jab at the line where everything crashed.
4. Analysis of common scenarios
ASan example: out-of-bounds on std::vector
A common situation: you want to get the “last element” and accidentally write v[v.size()]. That makes sense in natural language (“size means last”), but in C++ size is a count, and the last index is size() - 1. This mistake is very common, and the sanitizer is good at catching it as close as possible to the read.
Let's add a chain of functions: command → compute → read element. Note: the dangerous line is disabled so the example won't “fire” by default.
#include <cstddef>
#include <vector>
int ReadAt(const std::vector<int>& v, std::size_t i) {
#if 0
return v[i]; // potential UB if i is out of range
#else
return 0;
#endif
}
int LastTwoAverage(const std::vector<int>& v) {
std::size_t last = v.size(); // BUG in the idea: last should be size()-1
int a = ReadAt(v, last); // this is where the "explosion" will happen when enabled
int b = ReadAt(v, last - 1);
return (a + b) / 2;
}
Now imagine you enabled the dangerous line (#if 1) and ran under ASan. You may get a report that looks roughly like this (simplified, without extra details):
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x...
READ of size 4 at 0x... thread T0
#0 ReadAt(std::vector<int> const&, unsigned long) main.cpp:8
#1 LastTwoAverage(std::vector<int> const&) main.cpp:15
#2 main main.cpp:42
...
Here the main exercise of the lecture begins. Don't read everything. Make four eye movements.
First you note the type: heap-buffer-overflow. This means “we read/write beyond the allocated heap buffer”. For std::vector this is typical: its memory usually lives on the heap. It's already clear that we have an index problem.
Next you find the first reference to your file. In the example it's ReadAt … main.cpp:8. And immediately the temptation arises: “aha! error in ReadAt!”. But ReadAt is just the “place of detection”; it honestly does what it was asked: “read at index i”. The real question is: why did i become invalid?
This is where the stack comes in. The next line #1 LastTwoAverage … main.cpp:15 shows who called ReadAt and with what intent. We go back into LastTwoAverage and see the line std::size_t last = v.size(). This is the root cause: we confused “size” with “last index”. The sanitizer helped by showing the path: ReadAt → LastTwoAverage → main.
It's useful to imagine the stack as a “ladder of calls”:
flowchart BT
A[main] --> B[LastTwoAverage]
B --> C[ReadAt]
C --> D["operator[] reads memory"]
At this point you can already formulate the invariant: “if I'm going to read v[i], then it must be i < v.size()”. This invariant applies not only to ReadAt, but to everyone who calls it.
The fix (in idea) is usually trivial: last index is v.size() - 1, but only if the vector is not empty. We don't write assert here (that's another lecture), but state the thought: “need an emptiness check and correct index arithmetic”.
UBSan example: division by zero
After an ASan report, an UBSan report usually looks almost friendly: it often prints runtime error: ... and shows coordinates. But the trick here is different: sometimes division by zero “is born” far from the division site, and without the stack you'll fix the symptom, not the cause.
Let's do normalization: take the sum and divide elements by the sum. If the sum equals zero (for example, all numbers are zero), division becomes a problem.
#include <vector>
int Sum(const std::vector<int>& v) {
int s = 0;
for (int x : v) s += x;
return s;
}
int NormalizeFirst(const std::vector<int>& v) {
int total = Sum(v);
#if 0
return v[0] / total; // UB when total == 0 (usually diagnosed by UBSan)
#else
return 0;
#endif
}
A typical UBSan “skeleton” may look like this:
main.cpp:16:17: runtime error: division by zero
#0 NormalizeFirst(std::vector<int> const&) main.cpp:16
#1 main main.cpp:45
There's less noise here, but the reading is the same. First you note the type: division by zero. So your invariant is simple: “the divisor must be non-zero”.
Next you see the location: NormalizeFirst. And in this function you search who produced the divisor: int total = Sum(v). This hints that “the root cause may be in the data or in Sum”.
If Sum is correct (and it likely is), then the root cause is not in summation arithmetic, but that you didn't account for a valid scenario where the sum equals 0. And this is an important distinction from many “normal bugs”: sometimes the root cause is not a “wrong line” but an “unhandled case”.
That's why it's useful to express invariants in words: “normalization is only possible when total != 0”. If you say that out loud, you already know where to put the check: before the division.
“Place of detection” vs “place of birth”
Now we will make a slightly trickier example to reinforce the main lecture idea. The error is detected in ReadAt, but it is born while parsing a command. That's exactly the scenario that causes newcomers to “fix in the wrong place”.
Suppose you have a command get i that returns an element by index. The index comes from the user. We intentionally do not do robust input and bounds checking yet — we need a “plot” for the stack.
#include <cstddef>
#include <iostream>
#include <string>
#include <vector>
int ReadAt(const std::vector<int>& v, std::size_t i) {
#if 0
return v[i]; // UB when i >= v.size()
#else
return 0;
#endif
}
std::size_t ParseIndex(const std::string& s) {
return static_cast<std::size_t>(std::stoi(s));
}
int ProcessGet(const std::vector<int>& v, const std::string& arg) {
std::size_t i = ParseIndex(arg);
return ReadAt(v, i);
}
How will the ASan “skeleton” look? It will almost certainly point to ReadAt. For example:
ERROR: AddressSanitizer: heap-buffer-overflow
#0 ReadAt(...) main.cpp:8
#1 ProcessGet(...) main.cpp:21
#2 main main.cpp:50
If you stop at ReadAt and start “improving ReadAt”, you may fall into infinite refactoring: replace [] with at(), add prints, add something else. That can be useful sometimes, but the root cause will remain: ParseIndex can return anything, and you didn't check the range.
That's why the stack is not “extra lines”. It's the direct answer to “who brought the bomb”.
In real code you would define a contract: ProcessGet must either check i < v.size(), or return an error/message. But even if you haven't learned all error-handling strategies yet, the skill of “climbing the stack and finding where the data became bad” is already half of mature development.
5. Useful tricks and details
How to tell “my frames” from “not mine” in a stack trace
When you start working with sanitizers, the call stack often contains not only your code but half of the standard library, the runtime, entry points and even some mysterious addresses. At this point a newcomer's brain says: “I don't understand anything, so it's not my fault”. Spoiler: it's almost always yours.
The conceptual trick is simple. Mentally split the stack into two parts: the “upper frames” are usually closer to the crash site, the “lower” ones are closer to main and process startup. Your goal is to find the first line that points to your file (main.cpp, src/..., include/...). Everything “around” is secondary for now.
If the report has 30 lines and your code appears at #0, #1, #2, you're already in a good situation: the chain is short and the root cause is nearby.
If your code appears only at #7, and above there are std::..., libc++, libstdc++, then it usually means you called something from the standard library with wrong preconditions. Then your “anchor” is the frame with your file, and you read up the stack asking: “what arguments did I pass and what conditions should I have guaranteed?”
Mini cheat sheet for ASan details
ASan sometimes shows many “micro details”: read size, address, “0 bytes to the right”, shadow bytes. A newcomer can easily get stuck and start analyzing bytes like an archaeologist who found an old sock and tries to infer the culture from the hole in the heel.
Let's be clear: details are needed, but in moderation.
If you see “READ of size 4”, that usually means an int read (often 4 bytes). If “READ of size 1” — it might be char or a byte access. This helps to understand what operation happened, but the stack more often gives the root cause.
A phrase like “0 bytes to the right of 12-byte region” often means “you hit exactly the first byte past the array”. For a vector<int> of three elements this is the classic case: 3 * 4 = 12 bytes, and index 3 is “exactly out of bounds”.
This is useful as hypothesis confirmation: “aha, indeed confused size and last index”.
6. Common mistakes when reading a sanitizer report
Mistake #1: reading the report strictly top-to-bottom like a novel.
That wastes time on runtime lines and you lose the anchors. Read the report like a map: first the problem type, then the first reference to your code, then the stack, and only if needed — the byte/address details.
Mistake #2: fixing the line pointed to by the sanitizer without climbing the stack.
The sanitizer almost always shows the “place of detection”. But the root cause is often higher: where the index was computed, where the divisor was obtained, where size wasn't checked. If you don't go through the chain #0 → #1 → #2, you risk endlessly “treating symptoms”.
Mistake #3: ignoring the phrase “runtime error: …” as if it were just a warning.
UBSan is polite, but the meaning is strict: something happened after which the language does not guarantee normal behavior. This is not “sometimes happens”, it's a signal “you cannot continue as if nothing happened”.
Mistake #4: being scared of standard library frames and concluding “STL is broken”.
STL very rarely breaks; passing incorrect preconditions to it is extremely common. If your code appears in the stack at least once, start from it and ask: “which contract did I violate?”
Mistake #5: confusing “vector is empty” with “vector is too small”.
The check if (!v.empty()) protects against v[0], but does not protect against “I want the last two elements”. For “last two” you need v.size() >= 2. ASan will usually show the out-of-bounds, but the root cause will be a wrongly stated correctness condition.
GO TO FULL VERSION