C / MULTI-FILE PROGRAMS AND BUILDS
Debugging with gdb: breakpoints and backtraces
Use gdb to stop a multi-file C program at a chosen location, walk the call stack with bt, and inspect the frame whose caller supplied the bad value.
What you will learn
- Build every translation unit with -g -O0 so each backtrace frame shows source and locals
- Set breakpoints as function, file.c:line or file.c:function, and add if conditions
- Read bt, then frame N or up, to inspect the caller that passed the bad value
- Spot <optimized out> and missing frames as a signal to rebuild that file at -O0
Understanding Debugging with gdb: breakpoints and backtraces
gcc -g does not change the machine code it generates; it adds DWARF debug info to the object file: a table mapping instruction addresses back to file names and line numbers, plus a description of where each variable lives at each point (which register, which stack offset). gdb reads that table, which is why it can show you the line s[i] == c instead of an address. Debug info is per translation unit, so if one .c file in your build was compiled without -g, frames from that file show up in a backtrace as a bare function name followed by "No symbol table info available" while every other frame looks normal.
A breakpoint is a location, not a piece of text: gdb resolves break count_char, break score.c:9, or break score.c:count_char to one address and puts a trap instruction there. Because static functions are still described in debug info, break count_char works even though the linker never exported that name and nm shows it as a local symbol. If two translation units each define a static function with the same name, a single breakpoint number resolves to two locations, listed as 1.1 and 1.2, and disable 1.2 keeps only the one you care about. Adding a condition, as in break count_char if c == 'n', makes gdb evaluate that expression in the newly created frame on every hit and stop only when it is true.
A backtrace is a walk of saved return addresses: each line is one call that has started and not yet returned, with frame 0 being where execution is stopped. print and info locals resolve names in the selected frame only, so frame 1 or up is how you reach the caller's variables, and that is usually where the wrong value was created; the crash site is normally an innocent victim of a bad argument. When a backtrace has fewer frames than you expect, or prints values as <optimized out>, the compiler inlined or tail-called those functions away, so rebuild that file with -O0 or -Og before drawing conclusions from the stack.
<stdio.h>
<string.h>
/* build: gcc -g -O0 -o score score.c debug: gdb ./score */
static int count_char(const char *s, char c)
{
int n = 0;
for (size_t i = 0; i < strlen(s) - 1; i++) /* off by one: last char never tested */
if (s[i] == c)
n++;
return n;
}
static int score_word(const char *w)
{
return 10 * count_char(w, 'a') + count_char(w, 'n');
}
int main(void)
{
const char *words[] = { "banana", "alpaca", "n" };
for (int i = 0; i < 3; i++)
printf("%s -> %d\n", words[i], score_word(words[i]));
return 0;
}
/* gdb session that locates the bug:
break score.c:count_char
run
info args -> s = "banana", c = 97 'a'
print s[5] -> 97 'a' the character the loop never reaches
bt -> count_char, score_word, main
frame 2
print i -> 0
*/
Debug info lets gdb map addresses back to your source, so a breakpoint tells you where the program is and a backtrace tells you which chain of calls put it there.
Worked examples
Backtrace after a segmentation fault
A null pointer reaches a leaf function, and the backtrace names the caller that produced it.
<stdio.h>
static int sum_row(const int *row, int n)
{
int s = 0;
for (int i = 0; i < n; i++)
s += row[i];
return s;
}
static int total(int **rows, int nrows, int ncols)
{
int t = 0;
for (int i = 0; i < nrows; i++)
t += sum_row(rows[i], ncols);
return t;
}
int main(void)
{
int a[] = { 1, 2, 3 };
int b[] = { 4, 5, 6 };
int *rows[3] = { a, b, NULL }; /* third row never allocated */
setbuf(stdout, NULL);
printf("starting\n");
printf("total = %d\n", total(rows, 3, 3));
return 0;
}
Example explained
Line 1setbuf(stdout, NULL) makes "starting" appear before the crash; with default buffering into a pipe that line would die unflushed in the buffer.
Line 2rows[2] is NULL, so on the third iteration of total's loop sum_row dereferences a null pointer at s += row[i].
Line 3gdb stops at that instruction and bt shows sum_row (frame 0), total (frame 1), main (frame 2); frame 1 then print i gives 2 and print rows[i] gives (int *) 0x0.
Line 4The second output line is written by the shell after the process dies from SIGSEGV, not by the program, and its exact wording depends on your shell and core limit.
Conditional breakpoint for the one bad input
Stopping only on the call that misbehaves instead of stepping through the correct ones.
<stdio.h>
static int digit_sum(int n)
{
int s = 0;
while (n > 0) { /* negative n falls straight through */
s += n % 10;
n /= 10;
}
return s;
}
int main(void)
{
int v[] = { 17, 205, -34, 9 };
for (int i = 0; i < 4; i++)
printf("digit_sum(%d) = %d\n", v[i], digit_sum(v[i]));
return 0;
}
/* in gdb: break digit_sum if n < 0 then run, bt, frame 1, print i */
Example explained
Line 1while (n > 0) is already false for -34, so digit_sum returns the initial s, which is 0 rather than 7.
Line 2break digit_sum if n < 0 evaluates n in the callee's frame at every hit, so gdb skips the two correct calls and stops exactly once.
Line 3From that stop, bt followed by frame 1 shows main with i == 2, identifying the offending input without adding printf calls.
Line 4finish runs to the return and prints "Value returned is $1 = 0", which proves the wrong number comes from digit_sum and not from the printf format.
Two static helpers with the same name
Disambiguating a breakpoint when each object file has its own static clamp.
/* build: gcc -g -O0 -c util.c main.c && gcc -g -o app util.o main.o */
/* ---- util.h ---- */
UTIL_H
UTIL_H
int scale(int v);
/* ---- util.c ---- */
"util.h"
static int clamp(int v, int lo, int hi)
{
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
int scale(int v)
{
return clamp(v * 3, 0, 100);
}
/* ---- main.c ---- */
<stdio.h>
"util.h"
static int clamp(int v, int lo, int hi)
{
return v < lo ? lo : (v > hi ? hi : v);
}
int main(void)
{
printf("%d %d\n", scale(10), clamp(200, 0, 50));
return 0;
}
Example explained
Line 1Both object files describe a static clamp, so break clamp resolves to two locations and gdb reports the breakpoint with (2 locations), numbered 1.1 and 1.2 in info breakpoints.
Line 2break util.c:clamp names one file and therefore one location; it is hit once, with v = 30, coming from scale(10).
Line 3bt at that stop reads clamp, scale, main, which is how you confirm you are in util.c's clamp and not the one in main.c.
Line 4static restricts linkage, not gdb's view: the name comes from the debug info in util.o, so you never need to remove static to break on a helper.
Important notes
Debug info stores the compile-time path of each source file, so building in one directory and debugging in another gives correct symbols and line numbers but 'No such file or directory' for the source text; point gdb at the sources with dir ../src or set substitute-path.
A backtrace made mostly of ?? frames usually means a saved return address was overwritten, for example by a buffer overrun, so treat it as evidence of stack corruption rather than a gdb failure and trust only the frames you recognise.
Common mistakes
Adding -g to CFLAGS and rerunning the build without touching the sources: the .o files still look up to date, so you keep debugging the old optimized objects and every local prints as <optimized out>.
Writing break 42 with no file name: gdb applies the line number to the current source file, usually the one holding main, so the breakpoint lands in the wrong translation unit or gdb reports no such line, and the program runs to completion.
Typing print i right after bt while frame 0 is a callee: gdb answers 'No symbol "i" in current context' and the reader concludes the variable is gone, when the fix is to select the caller's frame first.
Try it yourself
Change, predict, then run
Paste the score program into the editor and add a printf at the top of count_char that prints s, c and the loop bound strlen(s) - 1, then say which character index is never tested and fix the condition so the program prints 32, 30 and 1.
Open the C workspaceCheck your understanding
You are stopped inside sum_row and bt shows sum_row (frame 0), total (frame 1), main (frame 2). print i replies 'No symbol "i" in current context', even though main clearly declares an i. What is going on?
- main's i was optimized out, so gdb cannot read it even in a -g build.
- bt prints frames only; it discards the locals of every frame it lists.
- print resolves names in the selected frame, which is still frame 0; frame 2 or up makes main's i visible.
- i lives in a register that sum_row overwrote, so the name no longer resolves.
Show answer
Each frame carries its own set of visible names, and bt only lists frames without changing which one is selected, so print keeps looking in sum_row, where no i is declared; after frame 2 (or two ups) print i works. The first option is tempting because <optimized out> is a real symptom of optimized builds, but in that case gdb has found the variable and says its value is optimized out rather than reporting no such symbol.