Debugging with gdb & Sanitizers
Every programmer writes bugs. What separates beginners from professionals is how quickly they find them. Adding printf calls works for small problems, but C has better tools: a debugger lets you pause a running program and inspect every variable, and sanitizers make the compiler add checks that catch memory bugs the moment they happen.
In this lesson you will compile for debugging, use gdb to step through a buggy program, read a backtrace, and catch out-of-bounds access and integer overflow with AddressSanitizer and UndefinedBehaviorSanitizer.
A program with a bug
This program should print the average of four marks, but its loop has an off-by-one error: i <= n reads one element past the end of the array.
#include <stdio.h>
double average(const int marks[], int n)
{
int total = 0;
for (int i = 0; i <= n; i++) {
total += marks[i];
}
return (double) total / n;
}
int main(void)
{
int marks[] = {72, 85, 64, 90};
printf("Average: %.2f\n", average(marks, 4));
return 0;
}The correct answer is 77.75, but the program adds whatever value happens to sit after the array, so it prints a wrong average that may change from run to run, or it may even appear correct by luck. That is typical of memory bugs: the symptom is random, so you need tools that show you the cause.
Compile for debugging
Debuggers need extra information that links machine code back to your source lines. The -g option adds it. Turn optimisation off with -O0 (or use -Og, which optimises only in ways that keep debugging easy), because an optimised program may skip or reorder lines and make variables vanish.
gcc -std=c23 -Wall -Wextra -g -O0 average.c -o averageFix every warning first. Warnings are the cheapest debugging tool you have.
Stepping through code with gdb
gdb, the GNU debugger, runs your program under its control. The main commands are:
| Command | Short | What it does |
|---|---|---|
break average / break 7 | b | Pause when a function or line is reached |
run | r | Start the program (add arguments after it) |
next | n | Run the current line, stepping over function calls |
step | s | Run the current line, stepping into function calls |
continue | c | Run until the next breakpoint |
print total | p | Show the value of a variable or expression |
info locals | Show all local variables | |
backtrace | bt | Show the chain of function calls that led here |
finish | Run until the current function returns | |
watch total | Pause whenever a variable changes | |
quit | q | Leave gdb |
A breakpoint can have a condition, so the program pauses only when something interesting happens. Here we pause inside the loop only when i reaches 4:
gdb ./average(gdb) break 7 if i == 4 Breakpoint 1 at 0x1189: file average.c, line 7. (gdb) run Starting program: /home/aarav/c-practice/average Breakpoint 1, average (marks=0x7fffffffe0b0, n=4) at average.c:7 7 total += marks[i]; (gdb) print i $1 = 4 (gdb) print total $2 = 311 (gdb) backtrace #0 average (marks=0x7fffffffe0b0, n=4) at average.c:7 #1 0x0000555555555201 in main () at average.c:15 (gdb) quit
This session is shortened, and the addresses on your computer will differ. The key facts are clear: total already holds 311, the sum of all four marks, yet the loop is about to run again with i equal to 4, an index that does not exist. The backtrace shows that average was called from line 15 of main. The fix is i < n.
When a program crashes with "Segmentation fault", run it inside gdb with run. gdb stops at the crash, and backtrace shows exactly which line and which chain of calls caused it. On macOS, use lldb, whose commands are very similar (breakpoint set, run, next, print, bt). VS Code can drive gdb or lldb for you with breakpoints in the editor, through the Run and Debug view of the C/C++ extension.
AddressSanitizer: catching memory errors
gdb helps once you suspect where the bug is. AddressSanitizer (ASan) finds memory bugs automatically. The compiler adds checks around every memory access, and the program stops with a detailed report at the first bad access: out-of-bounds reads and writes, use after free, double free and, on Linux, memory leaks.
gcc -std=c23 -Wall -Wextra -g -fsanitize=address average.c -o average
./average=================================================================
==4821==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffd6a1c2e40 ...
READ of size 4 at 0x7ffd6a1c2e40 thread T0
#0 0x55f0c3a0b2a4 in average /home/aarav/c-practice/average.c:7
#1 0x55f0c3a0b3b1 in main /home/aarav/c-practice/average.c:15
...
SUMMARY: AddressSanitizer: stack-buffer-overflow /home/aarav/c-practice/average.c:7 in averageThe report is shortened here, and the numbers will differ. Read it from the top: a 4-byte read (one int) went past an array on the stack, at line 7 in average, called from line 15. ASan points straight at the bug, even when the program would otherwise have printed a plausible-looking answer.
UndefinedBehaviorSanitizer: catching undefined behaviour
UBSan checks for undefined behaviour that is not about memory, such as signed integer overflow, invalid shifts and division by zero. Enable it with -fsanitize=undefined:
#include <limits.h>
#include <stdio.h>
int main(void)
{
int balance = INT_MAX;
balance += 1;
printf("%d\n", balance);
return 0;
}gcc -std=c23 -Wall -Wextra -g -fsanitize=undefined overflow.c -o overflow
./overflowoverflow.c:7:13: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int' -2147483648
By default UBSan reports the problem and lets the program continue, so you see the message and then the meaningless result. You can combine both sanitizers in one build with -fsanitize=address,undefined, which is a good default for every test build.
More tools worth knowing
- Valgrind (Linux) runs an unmodified program and reports memory errors and leaks:
valgrind --leak-check=full ./average. It is slower than ASan but needs no recompiling. - GCC's static analyser, enabled with
-fanalyzer, examines code paths at compile time and warns about leaks, double frees and null dereferences before you even run the program. Clang offersclang --analyzeand theclang-tidytool. printfdebugging still has its place. Print tostderrwithfprintf(stderr, ...), which is not buffered, so the message appears even if the program crashes on the next line.
Common mistakes
- Debugging an optimised build. Variables show as "optimized out" and lines jump around. Rebuild with
-g -O0. - Forgetting
-g. gdb cannot show source lines or variable names. - Ignoring warnings. Many bugs that take an hour in the debugger are reported by
-Wall -Wextrain a second. - Trusting a program because it "works". Undefined behaviour can produce correct output by luck. Run your tests with sanitizers.
- Lost
printfoutput before a crash. Normal output is buffered; print debugging messages tostderr.
Key points
- Compile with
-g -O0(or-Og) for debugging, after fixing all warnings. - In gdb,
break,run,next,step,printandbacktracelet you pause and inspect a running program. - Conditional breakpoints (
break 7 if i == 4) stop only when something interesting happens. -fsanitize=addresscatches out-of-bounds access, use after free, double free and leaks.-fsanitize=undefinedcatches signed overflow, bad shifts and other undefined behaviour.- Use sanitizers for every test build; on Windows, run them in WSL.
Exercise
This program sometimes works and sometimes crashes. Compile it with -g -fsanitize=address, predict what kind of error AddressSanitizer will report and on which line, then fix the bug.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
char *name = malloc(5);
strcpy(name, "Kabir");
printf("Hello, %s\n", name);
free(name);
return 0;
}Show solution
ASan reports a heap-buffer-overflow: a WRITE of size 6 inside strcpy, called from line 8, on an address "0 bytes after" a 5-byte region allocated on line 7. "Kabir" has 5 letters but needs 6 bytes including the '\0'. Allocate the length plus one:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
const char *source = "Kabir";
char *name = malloc(strlen(source) + 1);
if (name == nullptr) {
return EXIT_FAILURE;
}
strcpy(name, source);
printf("Hello, %s\n", name);
free(name);
return 0;
}Hello, Kabir
Sizing the buffer from strlen(source) + 1 keeps it correct for any name, and the null check handles a failed allocation. With the fix, ASan reports nothing.
Quick Quiz
1. Which option adds debugging information so gdb can show source lines?
-g stores the mapping from machine code to source lines and variable names.
2. In gdb, what is the difference between next and step?
step enters the called function, while next treats the call as a single line.
3. Which sanitizer reports reading past the end of an array?
AddressSanitizer checks every memory access and reports out-of-bounds reads and writes.
4. What does the gdb command backtrace show?
A backtrace lists each active call, from the current function back to main.
Learn C with a mentor
Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
