C Debugging Basics
Debugging is the process of finding out why a program does not behave the way you expect, and then fixing it. In C, this skill matters more than in most languages: the compiler will happily accept code that reads uninitialized memory, walks off the end of an array, or dereferences a null pointer, and the result is often a crash (or worse, silently wrong output) far away from the line that actually caused the problem. This lesson walks through the core debugging toolkit every C programmer needs: compiler warnings, print-statement debugging, the GDB debugger, Valgrind for memory errors, and assert() for catching bad assumptions early.
Overview: How C Debugging Works
C gives you very little safety net. There is no automatic bounds checking on arrays, no garbage collector, and no runtime type checking. When something goes wrong, the operating system usually reports it as a generic signal — most commonly SIGSEGV (“segmentation fault”), which means your program tried to access memory it does not own. The program terminates immediately, and by default you get almost no information about which line caused it.
To debug effectively you need to reconstruct the internal state of the program at the moment things went wrong: the call stack, the values of local variables, and the sequence of function calls that led there. There are three broad strategies:
- Static analysis — let the compiler find problems before the program ever runs, using warning flags.
- Print debugging — insert
printf()calls to observe values and control flow as the program executes. - Interactive debugging — use a debugger like GDB to pause execution, inspect memory, and step through code line by line.
A fourth strategy, dynamic analysis with tools like Valgrind, catches an entire category of bugs (memory leaks, buffer overruns, use of uninitialized memory) that neither warnings nor a debugger will reliably show you, because the program can appear to “work” while still corrupting memory behind the scenes.
Under the hood, tools like GDB rely on debug symbols — extra metadata the compiler embeds in the executable (in a format called DWARF on Linux) that maps machine instructions back to source file names, line numbers, variable names, and type information. Without this metadata, a debugger can only show you raw addresses and registers. That is why you always compile with the -g flag while debugging.
Syntax
Compiling with debug information and warnings enabled:
gcc -g -Wall -Wextra -O0 -o program program.c
| Flag | Purpose |
|---|---|
-g |
Embed debug symbols so GDB can map addresses to source lines and variables. |
-Wall |
Enable most useful warnings (unused variables, implicit conversions, etc.). |
-Wextra |
Enable additional warnings not covered by -Wall. |
-O0 |
Disable optimizations, so the debugger’s view of variables matches the source exactly. |
Running a program under GDB:
gdb ./program
Common GDB commands used once inside the debugger:
| Command | Effect |
|---|---|
run |
Start (or restart) the program. |
break FILE:LINE |
Set a breakpoint that pauses execution at that line. |
next |
Execute the next line, stepping over function calls. |
step |
Execute the next line, stepping into function calls. |
print expr |
Print the current value of a variable or expression. |
backtrace |
Show the full call stack (which function called which). |
continue |
Resume running until the next breakpoint or crash. |
quit |
Exit GDB. |
Checking for memory errors with Valgrind:
valgrind --leak-check=full ./program
Examples
Example 1: Print Debugging a Logic Bug
Here is a function meant to find the largest value in an array of scores. It has a subtle bug: it assumes every value is non-negative.
#include <stdio.h>
int find_max(int arr[], int n) {
int max = 0;
for (int i = 0; i < n; i++) {
if (arr[i] > max) {
max = arr[i];
}
}
return max;
}
int main(void) {
int scores[5] = {-5, -2, -8, -1, -9};
printf("Max score = %d\n", find_max(scores, 5));
return 0;
}
Output:
Max score = 0
The correct answer is -1, but the function returns 0 because max was initialized to a value that is larger than every element in the array. To confirm this hypothesis, add printf() calls inside the loop to trace each comparison:
#include <stdio.h>
int find_max(int arr[], int n) {
int max = 0;
for (int i = 0; i < n; i++) {
printf("i=%d arr[i]=%d current max=%d\n", i, arr[i], max);
if (arr[i] > max) {
max = arr[i];
}
}
return max;
}
int main(void) {
int scores[5] = {-5, -2, -8, -1, -9};
printf("Max score = %d\n", find_max(scores, 5));
return 0;
}
Output:
i=0 arr[i]=-5 current max=0
i=1 arr[i]=-2 current max=0
i=2 arr[i]=-8 current max=0
i=3 arr[i]=-1 current max=0
i=4 arr[i]=-9 current max=0
Max score = 0
The trace makes the bug obvious: max never changes because arr[i] > max is never true when every element is negative and max starts at 0. The fix is to initialize max from the first element of the array instead of a magic constant:
#include <stdio.h>
int find_max(int arr[], int n) {
int max = arr[0];
for (int i = 1; i < n; i++) {
if (arr[i] > max) {
max = arr[i];
}
}
return max;
}
int main(void) {
int scores[5] = {-5, -2, -8, -1, -9};
printf("Max score = %d\n", find_max(scores, 5));
return 0;
}
Output:
Max score = -1
Example 2: Using GDB to Locate a Segmentation Fault
The following program dereferences a null pointer, which crashes the program with no useful message on its own:
#include <stdio.h>
struct Node {
int value;
struct Node *next;
};
void print_value(struct Node *node) {
printf("Value: %d\n", node->value);
}
int main(void) {
struct Node *head = NULL;
print_value(head);
return 0;
}
Output:
Segmentation fault (core dumped)
All the shell tells you is that the program crashed — not where. Compiling with gcc -g -o program program.c and running it under GDB reveals the exact line and call stack:
$ gdb ./program
(gdb) run
Program received signal SIGSEGV, Segmentation fault.
0x0000000000401156 in print_value (node=0x0) at program.c:8
8 printf("Value: %d\n", node->value);
(gdb) backtrace
#0 print_value (node=0x0) at program.c:8
#1 0x0000000000401180 in main () at program.c:14
(gdb) print node
$1 = (struct Node *) 0x0
The backtrace command shows that main called print_value with node equal to 0x0 (a null pointer), and the crash happens on line 8 when the code tries to read node->value. This tells you exactly where to add a null check, or why head should never have been passed in as NULL in the first place.
Example 3: Assertions and Valgrind
assert() (from <assert.h>) is a lightweight way to state an assumption directly in the code. If the expression is false, the program prints a message identifying the file and line, and calls abort() immediately — turning a silent wrong answer into a loud, precise failure.
#include <stdio.h>
#include <assert.h>
double average(int arr[], int n) {
assert(n > 0 && "array length must be positive");
int sum = 0;
for (int i = 0; i < n; i++) {
sum += arr[i];
}
return (double)sum / n;
}
int main(void) {
int data[4] = {4, 8, 15, 16};
printf("Average = %.2f\n", average(data, 4));
return 0;
}
Output:
Average = 10.75
Assertions catch bugs at the moment the bad assumption occurs, instead of letting execution continue with garbage values. They are commonly disabled in release builds by defining NDEBUG, so never rely on an assertion to perform work the program actually needs (like input validation of untrusted data).
Some bugs, like memory leaks, produce no visible symptom at all — the program runs and prints the correct output, but slowly consumes more and more memory. Valgrind’s memcheck tool tracks every allocation and reports what was never freed:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char *build_greeting(const char *name) {
char *buffer = malloc(50);
if (buffer == NULL) {
return NULL;
}
sprintf(buffer, "Hello, %s!", name);
return buffer;
}
int main(void) {
char *message = build_greeting("Ada");
printf("%s\n", message);
return 0;
}
Output:
Hello, Ada!
The program looks completely correct. But message is never passed to free(), so the 50 bytes allocated in build_greeting are leaked. Running valgrind --leak-check=full ./program exposes it:
==12345== HEAP SUMMARY:
==12345== in use at exit: 50 bytes in 1 blocks
==12345== total heap usage: 1 allocs, 0 frees, 50 bytes allocated
==12345==
==12345== 50 bytes in 1 blocks are definitely lost in loss record 1 of 1
==12345== at 0x4C31B25: malloc (vg_replace_malloc.c:299)
==12345== by 0x4006A9: build_greeting (program.c:6)
==12345== by 0x4006E5: main (program.c:15)
Valgrind gives the exact call stack of the allocation that was never freed, which is far more useful than the leak itself. The fix is simply to add free(message); before return 0; in main.
Under the Hood: What a Debugger Actually Does
When GDB sets a breakpoint, it temporarily overwrites the machine instruction at that address with a special trap instruction (on x86, int3). When the CPU executes that instruction, it raises an interrupt that hands control to the operating system, which in turn notifies the debugger that the process has stopped. GDB then restores the original instruction, reads the debug symbols to find out which source line and variables correspond to the current address, and displays them to you.
backtrace works by walking the call stack: each function call pushes a stack frame containing a return address and (with -g) enough information to locate its local variables. GDB follows these frame pointers from the current function back through every caller until it reaches main, reconstructing the full chain of calls that led to the current point — which is exactly what you need to find the real cause of a crash, not just its symptom.
Common Mistakes
Mistake 1: Ignoring compiler warnings. Consider this code, which compiles without -Wall:
int total;
for (int i = 0; i < 10; i++) {
total += i;
}
printf("%d\n", total);
total is used before being initialized, so the output is unpredictable garbage. Compiling with -Wall -Wextra immediately flags this as 'total' is used uninitialized. Always fix the corrected version:
int total = 0;
for (int i = 0; i < 10; i++) {
total += i;
}
printf("%d\n", total);
Mistake 2: Debugging an optimized build. Compiling with -O2 lets the compiler reorder, inline, or eliminate variables entirely, so when you try to print a variable in GDB you may see <optimized out>, and single-stepping can jump around in a way that does not match the source. Always debug a build compiled with -g -O0, and only re-test the optimized build once the bug is fixed.
Mistake 3: Treating the crash location as the bug location. A segmentation fault often shows up far from where the actual mistake was made — for example, a buffer overrun in one function might silently corrupt a pointer used by a completely unrelated function later on. Use backtrace and, for memory corruption, Valgrind, rather than assuming the line in the crash report is where the fix belongs.
Best Practices
- Always compile with
-Wall -Wextraand treat every warning as a bug until proven otherwise. - Build a separate debug binary with
-g -O0; keep an optimized-O2build only for the final release. - Reproduce the bug with the smallest possible input before reaching for a debugger.
- Use
assert()to document and check assumptions (non-null pointers, valid ranges) during development. - Run new code through Valgrind (or a sanitizer like AddressSanitizer,
-fsanitize=address) before considering it finished, especially anything usingmalloc/freeor raw pointers. - Prefer
backtracein GDB over guessing; the call stack usually tells you more than the crash line alone. - Remove or guard temporary
printf()debugging statements before committing code — or route them through a debug macro that can be compiled out.
Practice Exercises
Exercise 1: The function below is supposed to count how many elements in an array are even, but it has a bug. Compile it with -Wall -Wextra, read the warning, and fix it.
int count_even(int arr[], int n) {
int count;
for (int i = 0; i < n; i++) {
if (arr[i] % 2 == 0) count++;
}
return count;
}
Exercise 2: Write a small program that allocates memory for an array of 10 integers with malloc, fills it, prints the sum, and deliberately omits the call to free. Run it under valgrind --leak-check=full and confirm you can identify the exact allocation site from the report.
Exercise 3: Write a function that dereferences a pointer one past the end of an array (an off-by-one bug). Compile with -g, run it under GDB, and use backtrace and print to identify the exact line and the out-of-range index before fixing the loop condition.
Summary
- C provides no runtime safety net, so debugging skills are essential, not optional.
- Always compile with
-g -Wall -Wextra -O0while developing; warnings catch many bugs before the program even runs. - Print debugging (temporary
printf()traces) is fast and effective for tracing logic bugs and control flow. - GDB lets you pause execution, inspect variables, and use
backtraceto reconstruct the call stack that led to a crash. - Valgrind (and sanitizers) catch memory leaks, buffer overruns, and uninitialized reads that produce no visible symptom on their own.
assert()turns silent wrong assumptions into loud, precise failures during development.- The location where a program crashes is often just a symptom — use the call stack and memory tools to find the real cause.
