Debugging, Testing, and Analysis
Every tool in this session exists because C will not tell you what is wrong on its own. Used together — warnings, a debugger, static analysis, tests, sanitizers, and a gate that runs them all — they turn C from a language where bugs hide into one where they are found on the day they are written.
- Drive GDB: breakpoints, stepping, inspection, watchpoints, and backtraces.
- Read a core dump from a crash that happened without you.
- Write unit tests in C and measure which lines they actually exercise.
- Reduce a failing input to the smallest case that still fails.
- Configure a build that fails on a warning, a test failure, or a sanitizer report.
1The order to apply tools
| Stage | Tool | Catches |
|---|---|---|
| Typing | Compiler warnings | Type errors, obvious mistakes — free |
| Before running | Static analysis | Null dereferences, leaks on some paths |
| While running | Sanitizers | Memory errors, undefined behavior |
| Repeatedly | Unit tests | Logic errors; regressions |
| When something fails | GDB | Everything else |
| Continuously | CI | All of the above, on every change |
Cheapest first. A warning costs nothing and is delivered instantly; a debugging session costs an hour. Most of what a beginner debugs with printf would have been a warning under -Wall -Wextra.
2GDB
gcc -std=c17 -Wall -Wextra -g -O0 prog.c -o prog # -g, and -O0 for sanity
gdb ./prog| Command | Does |
|---|---|
run / r | Start, optionally with arguments |
break f / b f | Break at a function, a line, or file.c:42 |
break f if x > 100 | Conditional breakpoint |
next / n | One line, stepping over calls |
step / s | One line, stepping into calls |
finish | Run until the current function returns |
continue / c | Run to the next breakpoint |
print x / p *p | Evaluate an expression |
p arr[3]@5 | Print five elements starting at index 3 |
backtrace / bt | The call stack |
frame 2 | Move to a caller's frame |
watch x | Stop when x changes |
info locals | Every local in this frame |
layout src | Split view with source |
The watchpoint
This is the command worth learning specifically, because it solves a problem nothing else does: who changed this variable?
(gdb) watch total
Hardware watchpoint 2: total
(gdb) continue
Hardware watchpoint 2: total
Old value = 100
New value = 0
update_totals (…) at report.c:88The debugger stops at the exact instruction that modified it and shows you the stack. For a variable being corrupted by unrelated code — week 12's out-of-range write, for instance — this is often the only practical approach.
Core dumps
When a program crashes on a machine you were not watching, the kernel can write its memory image to a file:
ulimit -c unlimited # enable core dumps for this shell
./prog # crashes
gdb ./prog core # examine the corpse
(gdb) bt
(gdb) info localsThe backtrace shows exactly where it died and with what values, without reproducing anything. On systemd systems the file is managed by coredumpctl.
3Static analysis
Analysis without running the program, so it reaches paths your tests never take.
gcc -fanalyzer -Wall -Wextra -c prog.c # GCC 10+, built in
clang --analyze prog.c
cppcheck --enable=all --std=c17 prog.c
scan-build make # clang, whole project-fanalyzer is the easiest win: it ships with GCC, needs no extra tool, and finds double frees, use-after-free, null dereferences, and leaks along specific paths — reporting the path it followed, which is what makes the report actionable.
Expect false positives. A static analyser reasons about all possible paths, including ones your invariants exclude but the code does not state. Triage them, suppress the ones you have verified, and keep the report at zero so that a new entry means something.
4Unit tests
C has no test framework in the standard library. Frameworks exist — Unity, Check, Criterion, µnit — but a usable harness is about twenty lines, and writing it once makes clear what the frameworks add.
static int tests_run = 0, tests_failed = 0;
#define CHECK(cond) do { \
tests_run++; \
if (!(cond)) { \
tests_failed++; \
fprintf(stderr, "FAIL %s:%d: %s\n", \
__FILE__, __LINE__, #cond); \
} \
} while (0)Two details in that macro matter. #cond stringifies the expression so the report shows what was tested — week 28's stringification, earning its place. And do { … } while (0) makes the macro behave as a single statement, so if (x) CHECK(y); else … parses correctly; without it, the semicolon after the closing brace breaks the else.
What to test
| Case | Why |
|---|---|
| The empty input | Zero elements is where most off-by-ones live |
| One element | Loops that assume at least two |
| The boundaries | First, last, and one past — week 10's checklist |
| Failure paths | Allocation failure, malformed input — untested by default |
| Every bug you fix | A regression test stops it returning |
Coverage
gcc --coverage -O0 -o tests tests.c lib.c
./tests
gcov lib.c
# or, readable:
lcov --capture --directory . --output-file cov.info
genhtml cov.info --output-directory reportCoverage tells you which lines ran, which is useful precisely because it exposes the error paths nobody tested. It does not tell you the tests are good — 100% coverage with no assertions proves nothing. Treat a low number as a definite problem and a high number as no evidence either way.
5Reducing a failure
A crash on a 50 MB input is nearly impossible to reason about. A crash on eleven bytes is nearly impossible to misunderstand. Reduction is a mechanical procedure:
- Confirm it reproduces reliably. An intermittent failure is usually uninitialized memory or a race; fix the reproducibility first.
- Halve the input. Does it still fail? Keep the failing half; otherwise keep the other.
- Repeat until removing anything makes it pass.
- Remove program options and features the same way.
creduce and halfempty automate this given a script that reports whether the input still fails. Week 48 produces such inputs by the thousand from a fuzzer, and reduction is what makes them usable.
6Continuous integration
Every tool above is worthless if it is only run when someone remembers. CI runs them on every change and fails the build when they complain.
name: build and test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: strict build
run: make clean && make CFLAGS="-std=c17 -Wall -Wextra -Werror -O2"
- name: sanitizers
run: make clean && make test CFLAGS="-std=c17 -Wall -Wextra -g -O1 -fsanitize=address,undefined"
- name: static analysis
run: make clean && make CFLAGS="-std=c17 -Wall -Wextra -fanalyzer"
- name: valgrind
run: |
sudo apt-get install -y valgrind
make clean && make
valgrind --error-exitcode=1 --leak-check=full ./tests
- name: coverage
run: make clean && make CFLAGS="--coverage -O0" && ./tests && gcov *.cTwo flags make this work as a gate rather than a report. -Werror turns a warning into a failed build. --error-exitcode=1 makes Valgrind's findings fail the step — without it, Valgrind prints its report and exits successfully, and nobody reads it.
Sanitizers also need ASAN_OPTIONS=detect_leaks=1 on platforms where leak detection is off by default, and they set a non-zero exit status on a finding, so a test target that runs the instrumented binary fails automatically.
7Worked example: testing the week 33 map
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
/* ---------- a twenty-line test harness ---------- */
static int tests_run = 0;
static int tests_failed = 0;
static const char *current_suite = "";
#define SUITE(name) do { current_suite = (name); \
printf("\n-- %s\n", current_suite); } while (0)
#define CHECK(cond) do { \
tests_run++; \
if (cond) { \
printf(" ok %s\n", #cond); \
} else { \
tests_failed++; \
printf(" FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \
} \
} while (0)
#define CHECK_EQ_INT(actual, expected) do { \
tests_run++; \
long a_ = (long)(actual), e_ = (long)(expected); \
if (a_ == e_) { \
printf(" ok %s == %ld\n", #actual, e_); \
} else { \
tests_failed++; \
printf(" FAIL %s:%d: %s was %ld, expected %ld\n", \
__FILE__, __LINE__, #actual, a_, e_); \
} \
} while (0)
/* ---------- the unit under test: a small string map ---------- */
typedef struct Entry {
char *key;
int value;
struct Entry *next;
} Entry;
typedef struct {
Entry **buckets;
size_t bucket_count;
size_t count;
} Map;
static size_t hash_string(const char *s)
{
size_t h = 1469598103934665603u;
for (; *s != '\0'; s++) { h ^= (unsigned char)*s; h *= 1099511628211u; }
return h;
}
static Map *map_create(size_t buckets)
{
if (buckets == 0) buckets = 8;
Map *m = malloc(sizeof *m);
if (m == NULL) return NULL;
m->buckets = calloc(buckets, sizeof *m->buckets);
if (m->buckets == NULL) { free(m); return NULL; }
m->bucket_count = buckets;
m->count = 0;
return m;
}
static void map_destroy(Map *m)
{
if (m == NULL) return;
for (size_t b = 0; b < m->bucket_count; b++) {
Entry *e = m->buckets[b];
while (e != NULL) { Entry *n = e->next; free(e->key); free(e); e = n; }
}
free(m->buckets);
free(m);
}
static bool map_put(Map *m, const char *key, int value)
{
size_t i = hash_string(key) % m->bucket_count;
for (Entry *e = m->buckets[i]; e != NULL; e = e->next) {
if (strcmp(e->key, key) == 0) { e->value = value; return true; }
}
Entry *e = malloc(sizeof *e);
if (e == NULL) return false;
size_t bytes = strlen(key) + 1;
e->key = malloc(bytes);
if (e->key == NULL) { free(e); return false; }
memcpy(e->key, key, bytes);
e->value = value;
e->next = m->buckets[i];
m->buckets[i] = e;
m->count++;
return true;
}
static bool map_get(const Map *m, const char *key, int *out)
{
size_t i = hash_string(key) % m->bucket_count;
for (Entry *e = m->buckets[i]; e != NULL; e = e->next) {
if (strcmp(e->key, key) == 0) { *out = e->value; return true; }
}
return false;
}
static bool map_remove(Map *m, const char *key)
{
size_t i = hash_string(key) % m->bucket_count;
for (Entry **link = &m->buckets[i]; *link != NULL; link = &(*link)->next) {
if (strcmp((*link)->key, key) == 0) {
Entry *dead = *link;
*link = dead->next;
free(dead->key); free(dead);
m->count--;
return true;
}
}
return false;
}
/* ---------- the tests ---------- */
static void test_empty(void)
{
SUITE("an empty map");
Map *m = map_create(8);
int v = -1;
CHECK(m != NULL);
CHECK_EQ_INT(m->count, 0);
CHECK(!map_get(m, "absent", &v));
CHECK_EQ_INT(v, -1); /* untouched on failure */
CHECK(!map_remove(m, "absent"));
map_destroy(m);
}
static void test_single(void)
{
SUITE("one entry");
Map *m = map_create(8);
int v = 0;
CHECK(map_put(m, "key", 42));
CHECK_EQ_INT(m->count, 1);
CHECK(map_get(m, "key", &v));
CHECK_EQ_INT(v, 42);
CHECK(!map_get(m, "Key", &v)); /* case sensitive */
map_destroy(m);
}
static void test_replace(void)
{
SUITE("replacing a value");
Map *m = map_create(8);
int v = 0;
map_put(m, "k", 1);
CHECK(map_put(m, "k", 2));
CHECK_EQ_INT(m->count, 1); /* not 2 */
map_get(m, "k", &v);
CHECK_EQ_INT(v, 2);
map_destroy(m);
}
static void test_collisions(void)
{
SUITE("forced collisions");
Map *m = map_create(1); /* one bucket: everything collides */
int v = 0;
for (int i = 0; i < 20; i++) {
char key[16];
snprintf(key, sizeof key, "k%d", i);
map_put(m, key, i);
}
CHECK_EQ_INT(m->count, 20);
CHECK(map_get(m, "k0", &v)); CHECK_EQ_INT(v, 0);
CHECK(map_get(m, "k19", &v)); CHECK_EQ_INT(v, 19);
CHECK(map_remove(m, "k10"));
CHECK(!map_get(m, "k10", &v));
CHECK_EQ_INT(m->count, 19);
map_destroy(m);
}
static void test_edge_keys(void)
{
SUITE("awkward keys");
Map *m = map_create(8);
int v = 0;
CHECK(map_put(m, "", 1)); /* empty key */
CHECK(map_get(m, "", &v)); CHECK_EQ_INT(v, 1);
char long_key[512];
memset(long_key, 'x', sizeof long_key - 1);
long_key[sizeof long_key - 1] = '\0';
CHECK(map_put(m, long_key, 2));
CHECK(map_get(m, long_key, &v)); CHECK_EQ_INT(v, 2);
CHECK(map_put(m, "türkçe", 3)); /* multibyte UTF-8 */
CHECK(map_get(m, "türkçe", &v)); CHECK_EQ_INT(v, 3);
map_destroy(m);
}
static void test_remove_order(void)
{
SUITE("removal from every chain position");
const char *keys[] = { "a", "b", "c" };
for (int victim = 0; victim < 3; victim++) {
Map *m = map_create(1); /* all in one chain */
for (int i = 0; i < 3; i++) map_put(m, keys[i], i);
CHECK(map_remove(m, keys[victim]));
CHECK_EQ_INT(m->count, 2);
int v;
for (int i = 0; i < 3; i++) {
if (i == victim) CHECK(!map_get(m, keys[i], &v));
else CHECK(map_get(m, keys[i], &v));
}
map_destroy(m);
}
}
int main(void)
{
puts("running tests");
test_empty();
test_single();
test_replace();
test_collisions();
test_edge_keys();
test_remove_order();
printf("\n%d checks, %d failed\n", tests_run, tests_failed);
return tests_failed == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}gcc -std=c17 -Wall -Wextra -g -O1 -fsanitize=address,undefined -o tests tests.c
./tests ; echo "exit status $?"Why these tests and not others
The empty map first. Zero elements is where off-by-ones live, and it is the case a happy-path test never reaches.
One bucket, deliberately. map_create(1) forces every key into the same chain, so the chain-walking code — normally exercised by luck — is exercised on purpose. This is how you test a code path whose occurrence is otherwise probabilistic.
Removal from each chain position. Head, middle, and tail are three different situations in linked-list code. Testing only one is testing a third of the function.
Awkward keys. The empty string, a 511-byte key, and multibyte UTF-8. Each has broken a real hash table somewhere.
The exit status. main returns non-zero when anything failed, which is what makes the suite usable from make test and from CI. A test program that always exits 0 cannot gate anything.
Measure the coverage
gcc -std=c17 --coverage -O0 -o tests_cov tests.c
./tests_cov
gcov tests.c | head -5
less tests.c.gcov # lines marked ##### were never executedLook for ##### on the allocation-failure branches inside map_put. No test reaches them, because malloc does not fail on demand. Those are the error paths week 27 warned are the least tested code in any program; reaching them needs a failure-injecting allocator or a fault-injection library.
Practise the debugger on a real bug
Introduce one, then find it without reading the diff:
/* in map_remove, forget to decrement: */
/* m->count--; */./tests # FAIL: m->count was 3, expected 2
gdb ./tests
(gdb) break map_remove
(gdb) run
(gdb) finish
(gdb) print m->countThen try the watchpoint approach instead, which finds it without knowing where to look:
(gdb) break test_remove_order
(gdb) run
(gdb) next 4
(gdb) watch m->count
(gdb) continueThe watchpoint reports every change to the field. When removal happens and no report appears, the missing decrement has identified itself.
Run the static analyser
gcc -fanalyzer -Wall -Wextra -c tests.c -o /dev/nullOn this code it should be quiet. Delete the free(e->key) in map_destroy and it reports the leak, with the path it followed to reach the conclusion — before the program has been run once.
8Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
| Debugging an optimized build | Variables "optimized out"; lines jump | Debug at -O0 -g. |
Forgetting -g | No source lines or variable names | Always build development binaries with it. |
| Testing only the happy path | Error paths ship untested | Test empty, boundary, and failure cases. |
| A test suite that always exits 0 | CI cannot detect a failure | Return non-zero when anything failed. |
| Treating coverage as a quality score | Tests with no assertions reach 100% | Use it to find untested paths only. |
Valgrind without --error-exitcode | Reports findings and exits 0 | Set it so the step fails. |
| Debugging a huge input | Too much state to reason about | Reduce it first. |
| Ignoring static-analysis output | Real findings buried in false positives | Triage to zero and keep it there. |
9Check yourself
Why debug at -O0 rather than -O2?
Because optimization removes variables, merges and reorders lines, and inlines functions, so the debugger reports "optimized out" and the execution point jumps around. At -O0 the machine code corresponds to the source line by line. Benchmarking is the reverse case — there -O0 is meaningless.
What problem does a watchpoint solve that a breakpoint cannot?
Finding who modified a variable when you do not know where the write happens. A breakpoint requires knowing the location; a watchpoint stops at whatever instruction changes the value and shows the stack. For a variable corrupted by an out-of-range write elsewhere, it is often the only practical method.
Why force every key into one bucket in a hash-table test?
Because the chain-walking code is otherwise exercised only when collisions happen to occur, which is probabilistic and may not happen at all in a small test. Creating the table with one bucket makes collision handling deterministic, so removal from the head, middle, and tail of a chain can all be tested on purpose.
Why is 100% coverage not evidence that the tests are good?
Because coverage records which lines executed, not whether anything was checked. A suite that calls every function and asserts nothing reaches full coverage and detects no bugs. Low coverage is a definite problem — those paths are untested — but high coverage is no evidence in the other direction.
Why must a test program return a non-zero exit status on failure?
Because that is the only thing make, a shell script, and a CI system can see. A suite that prints "FAIL" and exits 0 is a report nobody reads; one that exits non-zero fails the build and blocks the change. It is the same exit-status discipline week 23 applied to command-line tools.
10Where this leads
Week 36 addresses the class of bug that none of these tools reliably catches at -O0: undefined behavior, which can make a correct-looking program fail only once the optimizer believes it. The undefined-behavior sanitizer you have been running alongside AddressSanitizer becomes the main instrument there.