Debugging with gdb & Sanitizers

C · Lesson 29 of 32 8 min read Updated

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.

C
#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.

Terminal
gcc -std=c23 -Wall -Wextra -g -O0 average.c -o average

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

CommandShortWhat it does
break average / break 7bPause when a function or line is reached
runrStart the program (add arguments after it)
nextnRun the current line, stepping over function calls
stepsRun the current line, stepping into function calls
continuecRun until the next breakpoint
print totalpShow the value of a variable or expression
info localsShow all local variables
backtracebtShow the chain of function calls that led here
finishRun until the current function returns
watch totalPause whenever a variable changes
quitqLeave 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:

Terminal
gdb ./average
Output
(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.

Terminal
gcc -std=c23 -Wall -Wextra -g -fsanitize=address average.c -o average
./average
Output
=================================================================
==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 average

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

C
#include <limits.h>
#include <stdio.h>

int main(void)
{
    int balance = INT_MAX;
    balance += 1;
    printf("%d\n", balance);
    return 0;
}
Terminal
gcc -std=c23 -Wall -Wextra -g -fsanitize=undefined overflow.c -o overflow
./overflow
Output
overflow.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 offers clang --analyze and the clang-tidy tool.
  • printf debugging still has its place. Print to stderr with fprintf(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 -Wextra in a second.
  • Trusting a program because it "works". Undefined behaviour can produce correct output by luck. Run your tests with sanitizers.
  • Lost printf output before a crash. Normal output is buffered; print debugging messages to stderr.

Key points

  • Compile with -g -O0 (or -Og) for debugging, after fixing all warnings.
  • In gdb, break, run, next, step, print and backtrace let you pause and inspect a running program.
  • Conditional breakpoints (break 7 if i == 4) stop only when something interesting happens.
  • -fsanitize=address catches out-of-bounds access, use after free, double free and leaks.
  • -fsanitize=undefined catches 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.

C
#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:

C
#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;
}
Output
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?

2. In gdb, what is the difference between next and step?

3. Which sanitizer reports reading past the end of an array?

4. What does the gdb command backtrace show?

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