|$ curl https://forge-ai.dev/api/markdown?path=docs/c/debugging
$cat docs/c-—-debugging-&-testing.md
updated Recently·45 min read·published

C — Debugging & Testing

CDebuggingGDBTestingAdvancedAdvanced🎯Free Tools
Why Debugging Matters

C gives you raw access to memory, which means bugs can be catastrophic — segfaults, memory corruption, security vulnerabilities. Unlike managed languages, C won't save you from yourself. Mastering debugging tools and testing practices is not optional; it is a survival skill for any serious C developer.

This guide covers the full debugging toolkit: compiler warnings, GDB, Valgrind, AddressSanitizer, unit testing, and the common bug patterns you will encounter in production C code.

Compiler Warnings

The first line of defense is always the compiler. Modern GCC and Clang can catch entire categories of bugs at compile time — but only if you let them. The default warning level is too lenient for production code.

Makefile
Bash
1# The bare minimum — always use these three together
2gcc -Wall -Wextra -pedantic -o program main.c
3
4# Promote warnings to errors so they cannot be ignored
5gcc -Wall -Wextra -pedantic -Werror -o program main.c
6
7# Detect variable shadowing (a common source of subtle bugs)
8gcc -Wall -Wextra -Wshadow -o program main.c
9
10# Catch implicit type conversions that may lose data
11gcc -Wall -Wextra -Wconversion -o program main.c
12
13# Catch format string vulnerabilities
14gcc -Wall -Wextra -Wformat-security -o program main.c
15
16# Enable all of the above at once
17gcc -Wall -Wextra -pedantic -Werror -Wshadow -Wconversion -Wformat-security -o program main.c

best practice

Always compile with -Wall -Wextra -pedantic -Werror during development. Make it a project convention enforced by your build system. Warnings that are easy to ignore today become bugs tomorrow.

Beyond basic warnings, runtime sanitizers compile directly into your binary and catch bugs at execution time with precise diagnostics:

build_debug.sh
Bash
1# AddressSanitizer + UndefinedBehaviorSanitizer (the debug combo)
2gcc -fsanitize=address,undefined -g -o program main.c
3
4# Run the program — sanitizer output will appear on stderr
5./program
6
7# If using clang, you can also add:
8clang -fsanitize=address,undefined,leak -g -o program main.c
FlagCatchesOverhead
-WallCommon mistakes: unused vars, missing returns, implicit declarationsZero (compile-time only)
-WconversionImplicit narrowing conversions, sign mismatchesZero
-WshadowInner variable reuses outer variable nameZero
-fsanitize=addressBuffer overflow, use-after-free, double-free, memory leaks~2x slower, ~3x more memory
-fsanitize=undefinedSigned overflow, null deref, shift overflow, alignment issues~2x slower
GDB — The GNU Debugger

GDB is the standard debugger on Linux and is available on macOS via LLDB (or Homebrew GDB). It lets you stop execution at any point, inspect variables, step through code line by line, and examine memory — all without modifying your source.

First, always compile with debug symbols using the -g flag. Without it, GDB cannot map addresses back to source lines:

compile_and_debug.sh
Bash
1# Compile with debug symbols — always use -g in development
2gcc -g -Wall -Wextra -o program main.c
3
4# Launch GDB on the compiled binary
5gdb ./program

Once inside GDB, these are the essential commands you need to know:

gdb_session
TEXT
1(gdb) run # Start the program from the beginning
2(gdb) run arg1 arg2 # Start with command-line arguments
3
4(gdb) break main # Breakpoint at the main function
5(gdb) break main.c:42 # Breakpoint at line 42 of main.c
6(gdb) break process_data # Breakpoint at function process_data
7(gdb) info breakpoints # List all breakpoints
8(gdb) delete 1 # Delete breakpoint number 1
9
10(gdb) next # Step over (execute current line, don't enter functions)
11(gdb) step # Step into (enter function calls)
12(gdb) finish # Run until current function returns
13(gdb) continue # Continue execution until next breakpoint
14
15(gdb) print x # Print variable x
16(gdb) print *ptr # Dereference pointer and print value
17(gdb) print sizeof(x) # Print size of variable
18(gdb) print (int[5]){1,2,3,4,5} # Print compound literal
19
20(gdb) info locals # Show all local variables in current frame
21(gdb) info args # Show function arguments
22
23(gdb) backtrace # Show call stack (also: bt)
24(gdb) backtrace full # Show call stack with local variables
25(gdb) frame 3 # Switch to stack frame 3
26
27(gdb) watch x # Break when variable x changes value
28(gdb) rwatch x # Break when variable x is read
29
30(gdb) x/16xb &variable # Examine 16 bytes in hex at address
31(gdb) x/4dw array # Examine 4 words (ints) in decimal

Conditional breakpoints are powerful for stopping only when a specific condition is true — essential for loops or recurring functions:

debug_notes.c
C
1/* Set a conditional breakpoint in GDB:
2 (gdb) break process_order if order_id == 1042
3
4 Or set one programmatically via GDB commands file: */
5
gdb_advanced.sh
Bash
1# GDB batch mode — useful for automated debugging / CI
2gdb -batch -ex "run" -ex "bt" -ex "info locals" ./program
3
4# Generate a core dump on crash for offline analysis
5ulimit -c unlimited
6./program # crashes, produces core file
7gdb ./program core
8
9# Inside GDB with core dump:
10(gdb) bt # See where the crash happened
11(gdb) info registers # Show CPU register values
12(gdb) print errno # Check the error number
13(gdb) thread apply all bt # Show backtrace for all threads (multithreaded)

info

Use -g3 instead of -g to include macro definitions in the debug info. This lets you inspect macro-expanded values inside GDB.
Valgrind

Valgrind is a dynamic analysis framework that instruments your binary at runtime without recompilation. Its Memcheck tool is the gold standard for detecting memory errors in C programs.

valgrind_check.sh
Bash
1# Basic memory check — the most common Valgrind invocation
2valgrind --leak-check=full --show-leak-kinds=all ./program
3
4# With more verbose output and trace origins of leaks
5valgrind --leak-check=full --show-leak-kinds=all \
6 --track-origins=yes \
7 --verbose \
8 ./program
9
10# Save output to a file for later analysis
11valgrind --leak-check=full --log-file=valgrind.log ./program
12
13# Generate call trace for every allocation
14valgrind --leak-check=full --trace-children=yes ./program

Understanding Valgrind's leak categories is critical:

CategoryMeaningAction Required
definitely lostNo pointers to the memory block exist anymoreMust fix — real leak
indirectly lostOnly reachable via a definitely-lost pointerFix parent block
possibly lostPointer exists but points somewhere into the blockInvestigate
still reachablePointers to block still exist at exitUsually harmless

Common Valgrind error patterns you will encounter:

valgrind_output.txt
TEXT
1==12345== Invalid read of size 4
2==12345== at 0x4E2A: process_data (main.c:42)
3==12345== by 0x4A01: main (main.c:10)
4==12345== Address 0x0 is not stack'd, malloc'd or (recently) free'd
5==12345==
6==12345== Use of uninitialised value of size 8
7==12345== at 0x4E30: compute_sum (main.c:55)
8==12345==
9==12345== Mismatched free() / delete / delete[]
10==12345== at 0x4C80: free (vg_replace_malloc.c:xxx)
11==12345== by 0x4B20: cleanup (main.c:67)
12==12345== Block was alloc'd at
13==12345== at 0x4C10: malloc (vg_replace_malloc.c:xxx)
14==12345== by 0x4A80: allocate_buffer (main.c:20)

warning

Valgrind slows your program by 10-50x. It is not suitable for performance testing. Run it on debug builds with -g symbols for meaningful output.

Beyond Memcheck, Valgrind includes tools for profiling:

valgrind_tools.sh
Bash
1# Cachegrind — cache hit/miss profiling
2valgrind --tool=cachegrind ./program
3cg_annotate cachegrind.out.12345
4
5# Callgrind — call graph and instruction count profiling
6valgrind --tool=callgrind ./program
7callgrind_annotate callgrind.out.12345
8
9# Suppressions — ignore known third-party leaks
10valgrind --suppressions=my_suppressions.supp ./program
AddressSanitizer (ASan)

AddressSanitizer is a compiler-based memory error detector that is faster than Valgrind and integrates directly into GCC and Clang. It instruments every memory access at compile time, catching bugs with minimal overhead.

asan_build.sh
Bash
1# Compile with AddressSanitizer — must include -g for symbols
2gcc -fsanitize=address -g -o program main.c
3
4# Run — ASan output appears on stderr if errors are found
5./program
6
7# With leak detection (enabled by default on Linux, explicit elsewhere)
8gcc -fsanitize=address -fsanitize=leak -g -o program main.c
9
10# ASan environment variables for runtime control
11ASAN_OPTIONS=detect_leaks=1:halt_on_error=0 ./program

What ASan detects with clear, actionable error messages:

asan_examples.c
C
1#include <stdlib.h>
2#include <string.h>
3
4/* Example: buffer overflow — ASan catches this immediately */
5void overflow_example(void) {
6 char *buf = malloc(32);
7 strcpy(buf, "This string is way too long for a 32-byte buffer");
8 free(buf);
9}
10
11/* Example: use-after-free — ASan poisons freed memory */
12void use_after_free_example(void) {
13 int *arr = malloc(10 * sizeof(int));
14 free(arr);
15 arr[5] = 42; /* BUG: accessing freed memory */
16}
17
18/* Example: stack buffer overflow */
19void stack_overflow(void) {
20 int small[4] = {1, 2, 3, 4};
21 int big[10];
22 memcpy(big, small, sizeof(big)); /* copies too much */
23}
24
25/* Example: double-free */
26void double_free_example(void) {
27 char *ptr = malloc(64);
28 free(ptr);
29 free(ptr); /* BUG: second free of same pointer */
30}
Error TypeASan Output TagDescription
Heap buffer overflowheap-buffer-overflowAccess beyond allocated heap memory
Stack buffer overflowstack-buffer-overflowAccess beyond local array bounds
Use-after-freeheap-use-after-freeAccessing memory after free()
Double-freedouble-freeCalling free() on already-freed memory
Use-after-returnstack-use-after-returnAccessing local variable after function returns
Memory leakmemory-leakAllocated memory never freed
🔥

pro tip

ASan uses "shadow memory" — a compact bitmap that tracks which bytes are valid, freed, or poisoned. Every memory access is checked against this shadow map at runtime. This is why ASan is ~2x faster than Valgrind for most workloads.
UndefinedBehaviorSanitizer (UBSan)

UBSan detects undefined behavior at runtime. In C, undefined behavior means the standard imposes no requirements — the compiler can do anything, including seemingly impossible things. UBSan makes these violations visible.

ubsan_build.sh
Bash
1# Compile with UBSan — lightweight, fast, catches subtle bugs
2gcc -fsanitize=undefined -g -o program main.c
3
4# Combine with ASan for comprehensive coverage
5gcc -fsanitize=address,undefined -g -o program main.c
6
7# Runtime output example:
8# main.c:15:15: runtime error: signed integer overflow
9# main.c:22:5: runtime error: null pointer passed as argument 2
ubsan_examples.c
C
1#include <limits.h>
2#include <stdio.h>
3#include <stdlib.h>
4
5/* Signed integer overflow — undefined behavior */
6int overflow_example(void) {
7 int x = INT_MAX;
8 return x + 1; /* UB: signed overflow */
9}
10
11/* Null pointer dereference */
12void null_deref(void) {
13 int *p = NULL;
14 *p = 42; /* UB: null pointer dereference */
15}
16
17/* Shift by negative or too-large amount */
18int shift_example(int val) {
19 return val << 32; /* UB: shift exceeds type width */
20}
21
22/* Division by zero */
23int divide_by_zero(int a, int b) {
24 return a / b; /* UB: if b == 0 */
25}
26
27/* Misaligned pointer access */
28void misaligned_access(void) {
29 char buf[16];
30 int *ip = (int *)(buf + 1); /* UB: misaligned cast */
31 *ip = 42;
32}
Unit Testing in C

C has no built-in test framework. You either write your own lightweight test harness or adopt one of the established frameworks. The key principle is the same as any language: isolate, exercise, and verify.

The simplest approach is an assert-based macro system:

test_harness.c
C
1#include <stdio.h>
2#include <stdlib.h>
3#include <string.h>
4
5/* Minimal test framework using macros */
6#define TEST(name) static void name(void)
7#define ASSERT_EQ(a, b) do { \
8 if ((a) != (b)) { \
9 fprintf(stderr, "FAIL: %s:%d: %s != %s\n", \
10 __FILE__, __LINE__, #a, #b); \
11 exit(1); \
12 } \
13} while (0)
14
15#define ASSERT_STR_EQ(a, b) do { \
16 if (strcmp((a), (b)) != 0) { \
17 fprintf(stderr, "FAIL: %s:%d: \"%s\" != \"%s\"\n", \
18 __FILE__, __LINE__, (a), (b)); \
19 exit(1); \
20 } \
21} while (0)
22
23#define ASSERT_NOT_NULL(p) do { \
24 if ((p) == NULL) { \
25 fprintf(stderr, "FAIL: %s:%d: %s is NULL\n", \
26 __FILE__, __LINE__, #p); \
27 exit(1); \
28 } \
29} while (0)
30
31#define RUN_TEST(test_func) do { \
32 printf(" Running %s...\n", #test_func); \
33 test_func(); \
34 printf(" PASS\n"); \
35} while (0)
36
37/* Tests for a hypothetical string utility */
38int string_length(const char *s) {
39 int len = 0;
40 while (s[len]) len++;
41 return len;
42}
43
44TEST(test_string_length_empty) {
45 ASSERT_EQ(string_length(""), 0);
46}
47
48TEST(test_string_length_hello) {
49 ASSERT_EQ(string_length("hello"), 5);
50}
51
52TEST(test_string_length_long) {
53 ASSERT_EQ(string_length("a long string with spaces"), 25);
54}
55
56int main(void) {
57 printf("Running tests...\n");
58 RUN_TEST(test_string_length_empty);
59 RUN_TEST(test_string_length_hello);
60 RUN_TEST(test_string_length_long);
61 printf("All tests passed.\n");
62 return 0;
63}

For real projects, use an established framework like Unity (ThrowTheSwitch.org), which provides rich assertions, setup/teardown, and CI integration:

test_unity.c
C
1#include "unity.h"
2
3/* Setup runs before each test */
4void setUp(void) {
5 /* allocate resources, reset state */
6}
7
8/* Teardown runs after each test */
9void tearDown(void) {
10 /* free resources, clean up */
11}
12
13void test_add_positive_numbers(void) {
14 TEST_ASSERT_EQUAL_INT(4, add(2, 2));
15}
16
17void test_add_zero(void) {
18 TEST_ASSERT_EQUAL_INT(5, add(5, 0));
19}
20
21void test_add_negative(void) {
22 TEST_ASSERT_EQUAL_INT(-3, add(-1, -2));
23}
24
25void test_divide_by_zero_returns_error(void) {
26 int result;
27 int status = safe_divide(10, 0, &result);
28 TEST_ASSERT_EQUAL_INT(-1, status);
29}
30
31int main(void) {
32 UNITY_BEGIN();
33 RUN_TEST(test_add_positive_numbers);
34 RUN_TEST(test_add_zero);
35 RUN_TEST(test_add_negative);
36 RUN_TEST(test_divide_by_zero_returns_error);
37 return UNITY_END();
38}

Measuring test coverage with gcov and lcov:

coverage.sh
Bash
1# Compile with coverage instrumentation
2gcc -fprofile-arcs -ftest-coverage -o program main.c tests.c
3
4# Run tests to generate coverage data
5./program
6
7# Generate coverage report
8gcov main.c
9
10# Generate HTML coverage report (requires lcov)
11lcov --capture --directory . --output-file coverage.info
12genhtml coverage.info --output-directory coverage_report/
13
14# Open report
15open coverage_report/index.html

Mocking in C is typically done through function pointer substitution:

test_mocking.c
C
1#include <stdio.h>
2
3/* Production code uses a function pointer for I/O */
4typedef int (*read_fn_t)(char *buf, int max_len);
5
6int process_input(read_fn_t reader) {
7 char buf[256];
8 int n = reader(buf, sizeof(buf));
9 if (n <= 0) return -1;
10 /* process buf... */
11 return n;
12}
13
14/* Mock for testing */
15static int mock_read_return_42(char *buf, int max_len) {
16 (void)max_len;
17 snprintf(buf, 256, "test data");
18 return 42;
19}
20
21static int mock_read_return_error(char *buf, int max_len) {
22 (void)buf;
23 (void)max_len;
24 return -1;
25}
26
27void test_process_input_success(void) {
28 int result = process_input(mock_read_return_42);
29 /* assert result == 42 */
30}
31
32void test_process_input_failure(void) {
33 int result = process_input(mock_read_return_error);
34 /* assert result == -1 */
35}
Debugging Strategies

When GDB or sanitizers are not available, or when you need quick answers, printf debugging is the oldest trick in the book. The key is strategic placement and flushing:

debug_printf.c
C
1#include <stdio.h>
2#include <stdlib.h>
3
4/* Debug macro — compiles to nothing in release builds */
5#ifdef DEBUG
6 #define DBG_LOG(fmt, ...) fprintf(stderr, "[DEBUG %s:%d] " fmt "\n", \
7 __FILE__, __LINE__, ##__VA_ARGS__)
8#else
9 #define DBG_LOG(fmt, ...) ((void)0)
10#endif
11
12void process_order(int order_id, double total) {
13 DBG_LOG("entering process_order: id=%d total=%.2f", order_id, total);
14
15 if (order_id < 0) {
16 DBG_LOG("invalid order_id: %d", order_id);
17 return;
18 }
19
20 /* Without fflush, output may be lost before a crash */
21 fprintf(stderr, "about to call validate...\n");
22 fflush(stderr);
23
24 validate_order(order_id, total);
25
26 DBG_LOG("order %d processed successfully", order_id);
27}
28
29int main(void) {
30 DBG_LOG("program starting");
31 process_order(42, 99.95);
32 process_order(-1, 0.0);
33 DBG_LOG("program finished");
34 return 0;
35}

Core dumps and system tracing tools provide deeper insight when a crash occurs:

crash_analysis.sh
Bash
1# Enable core dumps
2ulimit -c unlimited
3echo "/tmp/core.%e.%p.%t" | sudo tee /proc/sys/kernel/core_pattern
4
5# Run the program — it will dump core on crash
6./program
7
8# Analyze the core dump
9gdb ./program /tmp/core.program.12345.1234567890
10(gdb) bt full # Full backtrace with local variables
11(gdb) info registers # CPU state at crash
12
13# strace — trace system calls (invaluable for I/O bugs)
14strace -f ./program # trace all syscalls
15strace -e trace=open,read,write ./program # trace file I/O
16strace -e trace=network ./program # trace network ops
17strace -c ./program # syscall summary/timing
18
19# ltrace — trace library calls (malloc, printf, etc.)
20ltrace ./program
21ltrace -c ./program # summary of library call costs

Memory debugging patterns for production code:

canary_debug.c
C
1#include <stdlib.h>
2#include <string.h>
3
4/* Canary words — detect buffer overflows in custom allocators */
5#define CANARY_VALUE 0xDEADBEEF
6
7typedef struct {
8 size_t size;
9 unsigned int canary;
10 char data[];
11} AllocHeader;
12
13void *safe_malloc(size_t size) {
14 AllocHeader *h = malloc(sizeof(AllocHeader) + size);
15 if (!h) return NULL;
16 h->size = size;
17 h->canary = CANARY_VALUE;
18 return h->data;
19}
20
21int safe_free(void *ptr) {
22 if (!ptr) return 0;
23 AllocHeader *h = (AllocHeader *)ptr - 1;
24 if (h->canary != CANARY_VALUE) {
25 /* Buffer overflow detected! */
26 return -1;
27 }
28 h->canary = 0; /* poison the canary */
29 free(h);
30 return 0;
31}
Common Bug Patterns

These are the most frequent and dangerous bugs in C code. Recognizing them on sight will save you hours of debugging:

common_bugs.c
C
1#include <stdio.h>
2#include <stdlib.h>
3#include <string.h>
4
5/* BUG 1: Off-by-one error */
6void off_by_one(void) {
7 int arr[10];
8 for (int i = 0; i <= 10; i++) { /* should be < 10 */
9 arr[i] = i; /* writes past end of array */
10 }
11}
12
13/* BUG 2: Null pointer dereference */
14void null_deref(char *str) {
15 /* str could be NULL — no check before use */
16 printf("length: %zu\n", strlen(str)); /* crash if NULL */
17}
18
19/* BUG 3: Uninitialized variable */
20int uninitialized_use(int flag) {
21 int result;
22 if (flag) {
23 result = 42;
24 }
25 /* BUG: if flag is 0, result is uninitialized */
26 return result;
27}
28
29/* BUG 4: Format string vulnerability */
30void format_string(char *user_input) {
31 /* NEVER pass user input as format string directly */
32 printf(user_input); /* user can inject %x %x %x to leak stack */
33 /* CORRECT: printf("%s", user_input); */
34}
35
36/* BUG 5: Integer overflow leading to small allocation */
37void integer_overflow(size_t count) {
38 size_t total = count * sizeof(int); /* can overflow */
39 int *arr = malloc(total); /* allocates tiny buffer */
40 for (size_t i = 0; i < count; i++) {
41 arr[i] = 0; /* heap buffer overflow */
42 }
43 free(arr);
44}
45
46/* BUG 6: Use-after-free */
47void use_after_free(void) {
48 char *ptr = malloc(64);
49 strcpy(ptr, "hello");
50 free(ptr);
51 printf("%s\n", ptr); /* accessing freed memory */
52}
53
54/* BUG 7: Dangling pointer */
55char *dangling_pointer(void) {
56 char local[] = "stack data";
57 return local; /* returns pointer to stack (invalid) */
58}
59
60/* BUG 8: Memory leak in loop */
61void leak_in_loop(void) {
62 for (int i = 0; i < 100; i++) {
63 char *buf = malloc(1024);
64 /* forgot to free(buf) — leaks 100KB per call */
65 process(buf);
66 }
67}

warning

Format string vulnerabilities (passing user input as the format string to printf/sprintf) are a critical security flaw. They allow attackers to read stack memory and potentially write arbitrary values. This class of bug has been used in real-world exploits for decades.

Defensive patterns that prevent entire classes of bugs:

defensive_c.c
C
1#include <stdio.h>
2#include <stdlib.h>
3#include <string.h>
4
5/* Safe string copy with explicit size and null termination */
6void safe_strcpy(char *dst, size_t dst_size, const char *src) {
7 if (dst_size == 0) return;
8 size_t i;
9 for (i = 0; i < dst_size - 1 && src[i] != '\0'; i++) {
10 dst[i] = src[i];
11 }
12 dst[i] = '\0';
13}
14
15/* Safe integer multiplication (check for overflow) */
16int safe_mul(size_t a, size_t b, size_t *result) {
17 if (a != 0 && b > SIZE_MAX / a) {
18 return -1; /* overflow would occur */
19 }
20 *result = a * b;
21 return 0;
22}
23
24/* NULL-checked allocation wrapper */
25void *checked_malloc(size_t size) {
26 void *ptr = malloc(size);
27 if (!ptr && size > 0) {
28 fprintf(stderr, "fatal: malloc(%zu) failed\n", size);
29 abort();
30 }
31 return ptr;
32}
33
34/* Safe free pattern — NULL the pointer after freeing */
35#define SAFE_FREE(ptr) do { \
36 free(ptr); \
37 (ptr) = NULL; \
38} while (0)
$Blueprint — Engineering Documentation·Section ID: C-DEBUGGING·Revision: 1.0