All Courses
C Advanced

Debugging with GDB & Valgrind

In C, you are the memory manager, the safety net, and the garbage collector all at once. There is no runtime to catch your mistakes — no out-of-bounds exception, no null-pointer warning at runtime, no automatic cleanup. A single stray pointer can corrupt memory silently, and your program might crash ten minutes later in a completely unrelated function. This makes debugging in C both a survival skill and an art form.

This lesson is a hands-on workshop. You'll learn to wield GDB, Valgrind, and Address Sanitizer — the three pillars of C debugging — to find and fix real bugs. By the end, you'll have a systematic approach to tracking down even the most elusive memory errors.

1. The GDB Debugger: Your First Line of Defense

Installation

GDB (GNU Debugger) is available on virtually every Linux distribution and on macOS via Homebrew:

# Debian/Ubuntu
sudo apt install gdb

# macOS (Homebrew)
brew install gdb

# Verify installation
gdb --version

The Golden Rule: Compile with Debug Symbols

Without debug symbols, GDB is nearly useless. Always compile with -g:

gcc -g -Wall -Wextra -o myprogram myprogram.c

The -g flag embeds source-level information (line numbers, variable names, function names) into the binary. Without it, GDB can only show you raw assembly addresses.

Starting a GDB Session

gdb ./myprogram

# Or with command-line arguments:
gdb --args ./myprogram input.txt output.txt

Essential GDB Commands

Here are the commands you'll use 90% of the time. Memorize these:

CommandShortcutWhat It Does
break <location>bSet a breakpoint (function name, line number, or file:line)
runrStart the program (with optional arguments)
continuecResume execution until next breakpoint
stepsExecute next line, stepping into function calls
nextnExecute next line, stepping over function calls
print <expr>pPrint the value of a variable or expression
backtracebtShow the call stack (which functions called which)
frame <n>fSwitch to stack frame n
info localsShow all local variables in the current frame
listlShow source code around the current line
watch <var>Break when a variable's value changes
quitqExit GDB

Inspecting Variables

The print command is remarkably powerful. It can evaluate arbitrary C expressions:

(gdb) print x
(gdb) print *ptr
(gdb) print arr[3]
(gdb) print ptr->field
(gdb) print/x flag        # hex format
(gdb) print/t bits        # binary format
(gdb) print (char*)buf    # cast and print
(gdb) print sizeof(*ptr)  # evaluate expressions

Navigating Stack Frames

When your program crashes inside a deeply nested function, backtrace shows you exactly how you got there:

(gdb) bt
#0  compute_average (data=0x0, n=10) at stats.c:42
#1  0x0000555555555234 in process (input=0x7fffffffde00) at main.c:28
#2  0x00005555555552a1 in main (argc=1, argv=0x7fffffffdf18) at main.c:15

Each stack frame is a snapshot of a function call. Use frame 1 to move up to process() and inspect its local variables to understand what arguments were passed.

Conditional Breakpoints

When a bug only manifests on the 1000th iteration of a loop, a plain breakpoint would drive you mad. Use conditions:

(gdb) break process_item if id == 42
(gdb) break loop.c:15 if i > 100
(gdb) break sort if count > 0 && ptr != 0

GDB evaluates the condition in the target's context — so you can reference any in-scope variable.

Watchpoints: Break When Memory Changes

A watchpoint triggers when a variable's value changes — invaluable for finding who corrupted a value:

(gdb) watch global_counter
(gdb) watch *0x7fffffffde00     # watch a specific memory address
(gdb) rwatch *ptr               # break on read (hardware watchpoint)

Core Dumps: Post-Mortem Debugging

A core dump is a snapshot of your program's memory at the moment of a crash. To enable them:

ulimit -c unlimited          # allow core dumps of any size
echo "core.%p" > /proc/sys/kernel/core_pattern   # name the files

# After a crash, analyze the core file:
gdb ./myprogram core.12345
(gdb) bt                     # see exactly where it crashed
(gdb) info registers         # examine CPU registers at crash
(gdb) print *ptr             # inspect memory state

2. Valgrind: The Memory Error Detective

Valgrind runs your program inside a virtual machine and tracks every memory access and allocation. It's the most powerful tool for finding memory leaks, use-after-free, buffer overflows, and uninitialized reads.

Installation

# Debian/Ubuntu
sudo apt install valgrind

# macOS (Homebrew) — note: limited support on modern macOS
brew install valgrind

Running Memcheck (the Default Tool)

valgrind ./myprogram

# For detailed leak information:
valgrind --leak-check=full --show-leak-kinds=all ./myprogram

# Track the origins of uninitialized values:
valgrind --track-origins=yes ./myprogram

Understanding Valgrind's Leak Report

After your program exits, Valgrind prints a heap summary. Understanding the categories is crucial:

  • Definitely lost: Memory that was allocated but has no remaining pointer to it anywhere in the program. This is a real leak — fix it.
  • Indirectly lost: Memory that was part of a data structure (e.g., a linked list node) whose head pointer is lost. Fixing the "definitely lost" block usually fixes this too.
  • Possibly lost: Interior pointers still exist (e.g., pointing to the middle of an allocation). Suspicious — investigate.
  • Still reachable: Memory that still has a valid pointer at program exit. Often from global/static variables. Not strictly a leak, but indicates no cleanup was done.

Here's what a typical leak report looks like:

==12345== 40 bytes in 1 blocks are definitely lost in loss record 1 of 3
==12345==    at 0x4C31B0F: malloc (in /usr/lib/valgrind/...)
==12345==    by 0x1087A1: create_user (users.c:23)
==12345==    by 0x1088F2: load_from_file (users.c:56)
==12345==    by 0x1089C3: main (main.c:12)

Valgrind shows you the exact call chain that allocated the leaked memory. Start at the bottom (main) and trace upward to the allocation site.

Common Valgrind Errors and What They Mean

Error MessageWhat It MeansTypical Cause
Invalid read/write of size NAccessing memory you don't ownBuffer overflow, use-after-free, bad pointer
Use of uninitialised value of size NReading a variable that was never written toForgot to initialize a local variable
Conditional jump or move depends on uninitialised value(s)An if or loop condition uses garbage dataSame as above — often leads to unpredictable behavior
Mismatched free() / delete / delete []Freeing memory with the wrong deallocatorUsing free() on C++ new, or vice versa
Source and destination overlap in memcpy()Using memcpy where memmove is neededOverlapping memory regions
Syscall param ... points to unaddressable byte(s)Passing bad pointers to syscalls like read()Buffer too small or already freed

3. Address Sanitizer (ASan): Compile-Time Instrumentation

Address Sanitizer is a compiler feature (GCC ≥ 4.8, Clang ≥ 3.1) that instruments your code at compile time to detect memory errors at runtime. It's faster than Valgrind (only ~2x slowdown vs. Valgrind's ~20x) and catches many of the same bugs.

# Compile with ASan enabled
gcc -g -fsanitize=address -fno-omit-frame-pointer -o myprogram myprogram.c

# Run normally — ASan will report violations with stack traces
./myprogram

ASan detects:

  • Heap buffer overflow / underflow
  • Stack buffer overflow
  • Use-after-free (heap)
  • Use-after-return (stack)
  • Double free
  • Memory leaks (with ASAN_OPTIONS=detect_leaks=1)
# Enable leak detection at runtime
ASAN_OPTIONS=detect_leaks=1 ./myprogram

Valgrind vs. ASan: Use ASan for fast, compile-time instrumentation during active development. Use Valgrind for comprehensive analysis (it catches uninitialized reads that ASan misses) and when you can't recompile. They complement each other.

4. Practical Debugging Walkthrough: Hunting a Segfault

Let's debug a real bug step by step. Here's our buggy program — it's supposed to calculate the average of an array but crashes:

#include <stdio.h>
#include <stdlib.h>

float compute_average(int *data, int n) {
    int sum = 0;
    for (int i = 0; i < n; i++) {
        sum += data[i];
    }
    return (float)sum / n;
}

int main() {
    int *scores = (int *)malloc(5 * sizeof(int));
    scores[0] = 85;
    scores[1] = 92;
    scores[2] = 78;
    scores[3] = 91;
    scores[4] = 88;

    free(scores);                 // <-- Bug: freed too early!

    float avg = compute_average(scores, 5);   // <-- use-after-free
    printf("Average: %.2f\n", avg);

    return 0;
}

Step 1: Compile with Debug Symbols and Run Under GDB

gcc -g -Wall -o average average.c
gdb ./average
(gdb) run
Program received signal SIGSEGV, Segmentation fault.
0x0000555555555189 in compute_average (data=0x5555555592a0, n=5) at average.c:7
7           sum += data[i];

Step 2: Inspect the Crashing Line

(gdb) list
2
3       float compute_average(int *data, int n) {
4           int sum = 0;
5           for (int i = 0; i < n; i++) {
6               sum += data[i];      # <-- crash here
7           }
8           return (float)sum / n;
9       }

Step 3: Check the Pointer Value

(gdb) print data
$1 = (int *) 0x5555555592a0
(gdb) print *data
Cannot access memory at address 0x5555555592a0

The pointer is non-null but the memory isn't accessible. This is the classic signature of use-after-free.

Step 4: Backtrace to Find the Call Site

(gdb) bt
#0  compute_average (data=0x5555555592a0, n=5) at average.c:7
#1  0x0000555555555212 in main () at average.c:21

Step 5: Move to main's Frame and Inspect

(gdb) frame 1
(gdb) list
17          scores[1] = 92;
18          scores[2] = 78;
19          scores[3] = 91;
20          scores[4] = 88;
21
22          free(scores);
23
24          float avg = compute_average(scores, 5);

There it is — free(scores) on line 22, followed by the call on line 24 using the now-invalid pointer. The fix: move free(scores) to after the compute_average call.

Confirming with Valgrind

valgrind --leak-check=full ./average
==12345== Invalid read of size 4
==12345==    at 0x1087A1: compute_average (average.c:7)
==12345==    by 0x1088F2: main (average.c:24)
==12345==  Address 0x4a6a040 is 0 bytes inside a block of size 20 free'd
==12345==    at 0x4C31B0F: free
==12345==    by 0x1088E0: main (average.c:22)

Valgrind tells you exactly which line freed the memory (average.c:22) and which line tried to read it afterward (average.c:7).

5. Common C Bugs and How to Spot Them

5.1 Buffer Overflow

Writing past the end of an array is the single most dangerous bug in C. It corrupts adjacent memory silently.

int arr[5];
for (int i = 0; i <= 5; i++) {   // Bug: i <= 5 writes to arr[5]!
    arr[i] = i * 10;
}

How to spot it: Off-by-one loop conditions (<= instead of <). Use Valgrind or ASan — they'll catch heap buffer overflows immediately. Stack overflows are harder; compile with -fstack-protector-strong.

5.2 Use-After-Free

Dereferencing a pointer after free() has been called on it. The memory may still appear valid (the contents aren't erased), making this a ticking time bomb.

int *p = malloc(sizeof(int));
*p = 42;
free(p);
printf("%d\n", *p);   // Undefined behavior!

Defense: Set pointers to NULL immediately after freeing:

free(p);
p = NULL;   // Any subsequent dereference will segfault reliably

5.3 Double Free

Calling free() twice on the same pointer corrupts the heap allocator's internal data structures.

int *p = malloc(100);
free(p);
free(p);   // BOOM — heap corruption

Defense: Same approach — set to NULL after freeing. free(NULL) is a harmless no-op.

5.4 Uninitialized Variables

Local variables in C are not zero-initialized. They contain whatever garbage was in that memory:

int result;              // Contains random value
if (result > 0) {        // Undefined behavior — reading uninitialized memory
    printf("Positive\n");
}

How to spot it: Valgrind's "Conditional jump or move depends on uninitialised value" warning. Compilers with -Wall -Wextra may also warn. Always initialize:

int result = 0;

5.5 Off-by-One Errors

The classic fencepost problem. C arrays are 0-indexed with length n, so valid indices are 0 to n-1:

char buf[10];
// buf[10] is out of bounds! Valid indices: buf[0] through buf[9]
strcpy(buf, "Hello World!");  // 12 characters + null terminator = overflow!

Defense: Use strncpy instead of strcpy, or better, always verify buffer sizes before copying.

5.6 Returning Pointers to Local Variables

char *get_message() {
    char msg[] = "Hello";    // Allocated on the stack
    return msg;              // Dangling pointer! msg is destroyed on return
}

Fix: Use malloc, static, or have the caller provide the buffer.

6. Exercises: Test Your Debugging Skills

Exercise 1: The Vanishing Free

The following program runs without crashing but leaks memory. Use Valgrind to identify the leak and fix it.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

char *duplicate(const char *src) {
    char *copy = malloc(strlen(src) + 1);
    strcpy(copy, src);
    return copy;
}

int main() {
    char *names[3];
    names[0] = duplicate("Alice");
    names[1] = duplicate("Bob");
    names[2] = duplicate("Charlie");

    for (int i = 0; i < 3; i++) {
        printf("%s\n", names[i]);
    }

    // TODO: Free the memory
    return 0;
}

What to do: Run it under Valgrind with --leak-check=full. You should see 3 "definitely lost" blocks. Add the necessary free() calls.

Exercise 2: The Corrupted Counter

This program sometimes prints garbage values for the counter. Use GDB to find the uninitialized variable.

#include <stdio.h>

int process(int x) {
    int accumulator;
    if (x < 10) {
        accumulator = x * 2;
    }
    return accumulator;   // What happens when x >= 10?
}

int main() {
    for (int i = 0; i < 20; i++) {
        printf("process(%d) = %d\n", i, process(i));
    }
    return 0;
}

What to do: Set a breakpoint at the return accumulator; line and use print accumulator. When x >= 10, accumulator was never assigned — you'll see garbage. Fix: initialize accumulator = 0.

Exercise 3: The Phantom Write

This program allocates a buffer, writes to it, but somehow corrupts the heap. Use Valgrind or ASan to find the off-by-one error.

#include <stdio.h>
#include <stdlib.h>

int main() {
    int *buffer = malloc(10 * sizeof(int));

    for (int i = 0; i <= 10; i++) {    // Spot the bug?
        buffer[i] = i * i;
    }

    for (int i = 0; i < 10; i++) {
        printf("buffer[%d] = %d\n", i, buffer[i]);
    }

    free(buffer);
    return 0;
}

What to do: Compile with -fsanitize=address and run, or run under Valgrind. The bug: i <= 10 writes 11 elements into a 10-element array. Fix: change to i < 10.

Debugging Checklist

When you encounter a bug in C, work through this checklist systematically:

  1. Reproduce it reliably. A bug you can trigger on demand is already half-solved.
  2. Compile with warnings. gcc -g -Wall -Wextra -Wpedantic. Fix every warning — they're often bugs in disguise.
  3. Run under GDB. Set a breakpoint near the crash site and inspect variables. Use backtrace to understand the call path.
  4. Run under Valgrind. It catches memory errors that don't always crash. Pay attention to the first error reported — subsequent ones may be cascading effects.
  5. Try ASan. Faster than Valgrind and catches slightly different classes of bugs. Use both.
  6. Narrow the scope. Comment out code until the bug disappears, then add it back line by line. Bisection works.
  7. Add assertions. assert(ptr != NULL) makes assumptions explicit and catches violations early.
  8. Write a minimal test case. If you can reproduce the bug in 20 lines of code, you can often spot it just by staring at it.