Procedural Programming with C · Advanced · Week 35

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.

By the end of this week you can
  • 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

StageToolCatches
TypingCompiler warningsType errors, obvious mistakes — free
Before runningStatic analysisNull dereferences, leaks on some paths
While runningSanitizersMemory errors, undefined behavior
RepeatedlyUnit testsLogic errors; regressions
When something failsGDBEverything else
ContinuouslyCIAll 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
CommandDoes
run / rStart, optionally with arguments
break f / b fBreak at a function, a line, or file.c:42
break f if x > 100Conditional breakpoint
next / nOne line, stepping over calls
step / sOne line, stepping into calls
finishRun until the current function returns
continue / cRun to the next breakpoint
print x / p *pEvaluate an expression
p arr[3]@5Print five elements starting at index 3
backtrace / btThe call stack
frame 2Move to a caller's frame
watch xStop when x changes
info localsEvery local in this frame
layout srcSplit 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:88

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

The 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

CaseWhy
The empty inputZero elements is where most off-by-ones live
One elementLoops that assume at least two
The boundariesFirst, last, and one past — week 10's checklist
Failure pathsAllocation failure, malformed input — untested by default
Every bug you fixA 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 report

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

  1. Confirm it reproduces reliably. An intermittent failure is usually uninitialized memory or a race; fix the reproducibility first.
  2. Halve the input. Does it still fail? Keep the failing half; otherwise keep the other.
  3. Repeat until removing anything makes it pass.
  4. 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 *.c

Two 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 executed

Look 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->count

Then 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) continue

The 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/null

On 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

MistakeWhat happensFix
Debugging an optimized buildVariables "optimized out"; lines jumpDebug at -O0 -g.
Forgetting -gNo source lines or variable namesAlways build development binaries with it.
Testing only the happy pathError paths ship untestedTest empty, boundary, and failure cases.
A test suite that always exits 0CI cannot detect a failureReturn non-zero when anything failed.
Treating coverage as a quality scoreTests with no assertions reach 100%Use it to find untested paths only.
Valgrind without --error-exitcodeReports findings and exits 0Set it so the step fails.
Debugging a huge inputToo much state to reason aboutReduce it first.
Ignoring static-analysis outputReal findings buried in false positivesTriage 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.