C++ / NAMESPACES, HEADERS, AND BUILDS
Debugging with gdb and reading a backtrace
Compile with -g, run a crashing program under gdb, read a backtrace frame by frame, and inspect locals in the frame that actually holds the bug.
What you will learn
- Build with -g -O0 -fno-omit-frame-pointer so bt can show files, lines, and arguments
- Read bt innermost first: #0 is where it stopped, the highest number is main
- Switch frames with up, down, frame N before reading info locals or print
- Open a crash you cannot rerun with gdb ./prog core after ulimit -c unlimited
Understanding Debugging with gdb and reading a backtrace
Every call pushes a frame that holds the return address, saved registers, and the callee's locals, so at any instant the live frames form a chain from the function currently executing back to main. The bt command walks that chain and prints it innermost first, numbering it #0, #1, #2 and so on, which means #0 is where execution stopped and the highest-numbered frame is main. In the program below the crash produces four list::total frames plus main, and each total frame carries its own n; reading those argument values from #0 outward gives 0x0, then the address of c, then b, then a, which is the shape of a list walk that never checked for the terminating null.
gdb can only translate an address into walk.cpp:11 and a name like n because the compiler recorded that mapping in the binary, which is what -g does: it adds DWARF sections and changes no instructions. At -O2 the mapping degrades honestly rather than lying loudly, but it still degrades: small functions are inlined so their frames vanish or are marked as inlined, tail calls can collapse a frame, and info locals answers <optimized out> for a variable that only ever lived in a register that has since been reused. So reproduce a crash in a -g -O0 build first, and treat a backtrace from an optimized binary as a hint rather than a transcript.
gdb always has exactly one selected frame, and print n, info locals, and list are all answered in that frame's scope; up, down, and frame 3 change the selection without changing a single byte of program state. That is why the usual move after bt is to walk outward from #0 until you reach the first frame you own, because #0 is only where the invalid operation happened while the bad value was normally produced by a caller. Note also that gdb prints demangled C++ names, so a frame reads list::total (n=0x0) with its namespace and parameter types spelled out, which is how you tell apart same-named functions in different namespaces and the exact spelling you pass to break list::total.
<iostream>
namespace list {
struct Node {
int value;
Node* next;
};
int total(const Node* n) {
return n->value + total(n->next); // bug: no base case, so nullptr is dereferenced
}
} // namespace list
int main() {
list::Node c{3, nullptr};
list::Node b{2, &c};
list::Node a{1, &b};
std::cout << "walking 3 nodes" << std::endl;
int t = list::total(&a); // the crash happens inside this call
std::cout << "total = " << t << std::endl;
std::cout << "done" << std::endl;
}
A backtrace is the chain of calls still on the stack printed innermost first, so frame #0 shows where the program stopped while the frame that produced the bad value is usually further out.
Worked examples
Where an uncaught exception came from
Shows what the terminate message tells you and what only a backtrace can tell you.
<iostream>
<stdexcept>
<string>
namespace csv {
int to_int(const std::string& field) {
if (field.empty())
throw std::runtime_error("empty field in column");
return std::stoi(field);
}
} // namespace csv
int main() {
std::cout << "row 1: " << csv::to_int("42") << std::endl;
int v = csv::to_int(""); // nothing catches this
std::cout << "row 2: " << v << std::endl;
}
Example explained
Line 1csv::to_int throws with no handler anywhere on the stack, so the runtime calls std::terminate, and libstdc++'s default handler prints the exception type and what() to stderr before calling abort.
Line 2GCC calls terminate at the throw point without unwinding, so to_int's frame is still live: catch throw followed by bt inside gdb names the throwing line, which those two stderr lines never do.
Line 3std::endl flushed row 1 before the abort; abort discards unflushed stream buffers, so a plain \n would have lost that line whenever stdout is a redirected file rather than a terminal.
Line 4Aborted (core dumped) is the shell reporting that the process died on SIGABRT, not output from the program.
A hand-rolled backtrace
Builds a shadow stack so you can see, without gdb, exactly which calls a backtrace lists and in what order.
<iostream>
<string>
<vector>
std::vector<std::string> shadow; // stands in for the real call stack
struct Trace {
explicit Trace(const std::string& name) { shadow.push_back(name); }
~Trace() { shadow.pop_back(); }
};
void print_backtrace() {
int frame = 0;
for (auto it = shadow.rbegin(); it != shadow.rend(); ++it)
std::cout << "#" << frame++ << " " << *it << "\n";
}
int depth_sum(int n) {
Trace t("depth_sum(n=" + std::to_string(n) + ")");
if (n == 0) {
print_backtrace();
return 0;
}
return n + depth_sum(n - 1);
}
int main() {
Trace t("main()");
int s = depth_sum(3);
std::cout << "sum = " << s << "\n";
}
Example explained
Line 1Trace's constructor and destructor bracket each call, so shadow contains exactly the calls that have started and not yet returned, which is the same set gdb's unwinder walks.
Line 2print_backtrace iterates with rbegin/rend, so the newest call is numbered #0 and main() comes last, matching the direction bt prints.
Line 3Frames #1 to #3 are one function appearing three times with different arguments, because each call has its own frame and its own copy of n.
Line 4The Trace object in main lives for the whole program, mirroring the fact that main's frame sits at the bottom of every backtrace.
Important notes
Dereferencing a null pointer is undefined behaviour, so an optimized build may move the crash, turn the recursion into a loop, or delete the faulting read entirely; reproduce at -O0 before trusting what bt shows.
If the process dies outside gdb and leaves nothing to inspect, enable cores with ulimit -c unlimited and open the dump with gdb ./walk core; on distributions where cores are handed to systemd-coredump, coredumpctl gdb reaches the same frames.
Common mistakes
Debugging a binary built without -g, or a stale binary from before the last edit: bt shows frames like ?? () or line numbers that point at unrelated statements, and the reader ends up editing code that never ran.
Assuming the defect is in frame #0. When #0 is inside std::vector::operator[] or memcpy, the library is fine; the bad index or pointer was produced in one of your own frames further out, and patching the innermost editable frame only hides it.
Treating the last line printed by std::cout as the crash site. A segfault or abort discards unflushed buffers, so the final one or two lines never appear and the statement before the real one gets blamed.
Try it yourself
Change, predict, then run
Take the main program and add std::cout << "total(" << n << ")\n" << std::flush; as the first line of list::total, then run it. Write out the backtrace gdb would print at the crash, starting at frame #0, with the value of n in each frame.
Open the C++ workspaceCheck your understanding
gdb stops on SIGSEGV and bt prints five frames: #0 list::total (n=0x0), #1 through #3 list::total with real addresses, and #4 main. What does that tell you?
- Four nested calls to total are still active and the innermost one was handed a null pointer, so the walk ran one step past the last node
- The crash is in main, because main is the frame gdb listed last
- The stack is corrupted, since one function cannot legitimately appear four times in a backtrace
- gdb printed one frame per list node and only frame #4 corresponds to a real call
Show answer
bt is ordered innermost first, so #0 is the call that faulted and #4 is the oldest call still on the stack; each recursive call has its own frame with its own copy of n, which is why only #0 shows n=0x0. Option 2 misreads the ordering: main appears last because it is the oldest active frame, not because the fault happened there.