Skip to main content
@shmVirus

Debugging

Compiler warnings, defensive programming, assertions, error codes, debugger workflows, memory diagnostics, sanitizers, unit tests, boundary tests, and regression tests.

Debugging is evidence-driven fault isolation. A symptom—wrong output, a crash, corrupted data, a leak—is not necessarily located near its cause. The disciplined response is to reproduce the failure, reduce uncertainty, inspect violated assumptions, make one justified correction, and preserve the discovery as a test.

Testing and defensive programming do not eliminate defects, but they make incorrect states easier to detect near their origin.

Diagnostic Workflow

A repeatable workflow prevents random edits:

  1. Reproduce: identify exact input, build options, environment, and observed result.
  2. Minimize: remove irrelevant inputs or components while the failure remains.
  3. Localize: find the earliest point where actual state diverges from the contract.
  4. Explain: form a causal hypothesis that predicts the evidence.
  5. Correct: change the cause, not only the visible symptom.
  6. Verify: rerun the failing case, nearby boundaries, and the wider suite.
  7. Preserve: add a regression test and document a changed contract if necessary.

If a change does not follow from a hypothesis, it adds uncertainty. Revert experiments that do not explain the failure.

Compiler Warnings

The compiler is the first static debugging tool. Use a standard mode and strong warnings:

cc -std=c17 -Wall -Wextra -Wpedantic -Wconversion -Wshadow \
   -g -O0 program.c -o program

Not every useful warning is required by the language, and option names vary by compiler. Treat warnings as questions requiring resolution. Do not hide them with casts or disable them globally without understanding the contract.

Warnings commonly expose:

  • implicit narrowing or signed/unsigned conversions;
  • missing return paths;
  • unused values suggesting unfinished logic;
  • incompatible pointer types;
  • suspicious assignment in a condition;
  • shadowed variables;
  • format-string mismatches;
  • unreachable or unhandled cases.

Fix the first diagnostic first. A syntax error can make the parser misinterpret later lines and emit a cascade of secondary messages.

Warnings Are Not Proof

A warning-free program can still have buffer errors, wrong requirements, numeric overflow, races, leaks, and valid-but-incorrect logic. Warnings establish only that enabled analyses found no reportable pattern.

Defensive Programming

Defensive programming makes assumptions executable at boundaries.

Validate External Data

Text, files, command arguments, and network data are untrusted until syntax, range, and domain constraints pass:

bool percentage_create(int value, int *result) {
    if (result == NULL || value < 0 || value > 100) {
        return false;
    }

    *result = value;
    return true;
}

Preserve Invariants

Update related state so partial changes are not published:

bool account_withdraw(struct Account *account, long amount) {
    if (account == NULL || amount < 0 || amount > account->balance) {
        return false;
    }

    long new_balance = account->balance - amount;
    account->balance = new_balance;
    return true;
}

Compute and validate new state before committing when operations can fail.

Fail Predictably

Return structured status, leave outputs unchanged on failure when practical, and centralize cleanup. A program that detects an error but continues with invalid state is often harder to debug than one that fails at the violated boundary.

Avoid Defensive Noise

Repeated checks cannot repair an incoherent contract. Decide whether null is accepted, whether a count can be zero, and who owns storage. Then check at the appropriate public boundary and let internal helpers rely on established invariants where justified.

Assertions

assert from <assert.h> checks a programmer assumption during development:

#include <assert.h>

double mean_nonempty(const double values[], size_t count) {
    assert(values != NULL);
    assert(count > 0);

    double total = 0.0;
    for (size_t i = 0; i < count; i++) {
        total += values[i];
    }
    return total / (double)count;
}

When an assertion expression is false, it reports source information and calls abort. Defining NDEBUG before including <assert.h> removes assertion evaluation:

cc -std=c17 -DNDEBUG program.c -o program

Therefore assertions must not perform required work or validate recoverable external input:

assert(fgets(line, sizeof line, stdin) != NULL); /* wrong */

With assertions disabled, the input call disappears. Perform the operation normally and handle failure; reserve assertions for internal conditions that indicate programming defects.

Good assertion targets include:

  • an internal index known to be within an established bound;
  • a tagged union whose tag must match the chosen member;
  • a private helper precondition guaranteed by its callers;
  • a representation invariant after a mutation.

Error Codes

Expected failures need ordinary control flow, not assertions. A Boolean status suffices for success/failure; an enum distinguishes causes:

enum ReadStatus {
    READ_OK,
    READ_END,
    READ_TOO_LONG,
    READ_ERROR
};

Callers can choose policy:

enum ReadStatus status = read_line(input, line, sizeof line);

switch (status) {
case READ_OK:
    process(line);
    break;
case READ_END:
    break;
case READ_TOO_LONG:
    report_invalid_record();
    break;
case READ_ERROR:
    report_io_failure();
    break;
}

errno

Some library functions report details through errno. It is meaningful only when the function’s contract indicates failure; a successful function need not reset it.

errno = 0;
long value = strtol(text, &end, 10);
if (errno == ERANGE) {
    /* conversion range failure */
}

Capture errno before calling other functions if the original code is needed. Do not use errno as a general application status variable.

Error Context

Add context as an error propagates: operation, record number, path policy, and relevant identifier. Avoid duplicate messages from every layer. A lower layer can return status; the boundary that knows user context can format one useful diagnostic.

Debugger Usage

A source debugger runs a program under observation. Compile with debug information (-g on common toolchains) and begin with low optimization when learning, because optimization can reorder, combine, or remove source-level state.

Typical session goals are independent of debugger brand:

  1. start with the exact failing arguments;
  2. stop before the suspected state transition;
  3. inspect relevant objects and call frames;
  4. execute one source line or enter a call;
  5. continue until the invariant first fails.

For GDB-like command names:

gdb ./program
(gdb) break parse_record
(gdb) run input.txt
(gdb) next
(gdb) print index
(gdb) print record
(gdb) backtrace

Tool syntax varies, but the questions remain: where am I, how did control arrive, and which state first became impossible?

Breakpoints

A breakpoint pauses execution at a source line, function entry, or machine location. Useful breakpoints target decisions, not merely crashes:

  • before a suspicious write;
  • at a failure-return path;
  • on a loop boundary with an unexpected index;
  • at allocation and cleanup functions;
  • when a condition such as count > capacity becomes true.

Conditional breakpoints reduce noise in repeated loops:

break process_item if index == 999

Watchpoints, when supported, pause when a memory location changes. They are valuable when a value is corrupted but the responsible assignment is unknown.

Step Execution

Debugger commands usually distinguish:

  • step into: execute the next source line and enter called functions;
  • step over: execute called functions without entering them;
  • step out: run until the current function returns;
  • continue: run until another stop condition;
  • finish or return inspection: observe the value leaving a function.

Choose based on the current hypothesis. Stepping into every library call produces noise; stepping over the exact helper that violates its contract hides evidence.

When control seems to jump unexpectedly, remember short-circuit expressions, macros, compiler optimization, and multiple statements on one source line. Debug a warning-enabled, reproducible build and inspect the actual stack.

Variable Inspection

Inspect a small set of state tied to the invariant:

  • current index and bound;
  • pointer target and ownership state;
  • tag and union member;
  • return status and output object;
  • stream indicators;
  • call arguments and locals in each frame.

For an array loop, a focused snapshot might be:

index = 8
count = 8
values = valid allocation of 8 ints
next expression = values[index]

The defect is evident: the access requires index < count.

Beware of inspecting indeterminate or expired objects: even a debugger display can be misleading. A pointer’s numeric address does not prove its target remains live.

Call Stack

A backtrace lists active calls. Select frames to compare caller assumptions with callee parameters. In recursion, repeated frames reveal depth and progress. In memory bugs, the crash site may be a library function; the caller frame often contains the invalid pointer or size.

Logging

Logging complements interactive debugging when a failure is intermittent or remote. Log state transitions and identifiers, not every line:

fprintf(stderr,
        "resize: old_capacity=%zu requested=%zu count=%zu\n",
        old_capacity, new_capacity, count);

Avoid secrets, personal data, raw passwords, and unbounded external strings. Logs change timing and can hide or expose some defects. Make optional logging compile-time or runtime configurable, and keep its side effects from changing program logic.

Memory Diagnostics

Memory defects include:

  • out-of-bounds access;
  • use after free or lifetime end;
  • double free;
  • invalid free;
  • uninitialized reads;
  • leaks;
  • overlapping copies where overlap is forbidden;
  • allocation-size overflow.

Different tools detect different subsets. No single clean run proves memory safety.

Sanitizers

Common compilers provide runtime instrumentation. A typical development build is:

cc -std=c17 -Wall -Wextra -Wpedantic -g -O1 \
   -fsanitize=address,undefined -fno-omit-frame-pointer \
   program.c -o program

AddressSanitizer commonly detects many out-of-bounds and use-after-free errors. UndefinedBehaviorSanitizer detects selected undefined operations such as some signed overflows and invalid shifts. Availability and exact coverage are toolchain-specific.

Run the instrumented executable with representative and boundary tests. Investigate the first report first; later reports may be consequences.

Sanitizers alter memory layout and timing. A failure that disappears under instrumentation is not disproved. Keep the original reproducer and use complementary tools.

Leak Analysis

Leak detectors report allocations still reachable or lost at exit. “Still reachable” can reflect deliberate process-lifetime caches, but every report deserves an ownership explanation. A destructor not called in tests often reveals incomplete lifecycle design.

Static Analysis

Static analyzers explore paths without executing the program and can report null dereferences, leaks, unchecked values, and suspicious control flow. They produce false positives and depend on visible contracts. Treat a report as a hypothesis to prove or refute, and encode clarified assumptions in code.

Unit Testing

A unit test calls a focused function with controlled input and checks observable results. A tiny C17 harness can use ordinary functions:

#include <stdbool.h>
#include <stdio.h>

static int failures;

static void expect_int(const char *case_name,
                       int expected, int actual) {
    if (expected != actual) {
        fprintf(stderr, "%s: expected %d, got %d\n",
                case_name, expected, actual);
        failures++;
    }
}

static int clamp(int value, int minimum, int maximum) {
    if (value < minimum) {
        return minimum;
    }
    if (value > maximum) {
        return maximum;
    }
    return value;
}

int main(void) {
    expect_int("below", 0, clamp(-2, 0, 10));
    expect_int("lower boundary", 0, clamp(0, 0, 10));
    expect_int("inside", 6, clamp(6, 0, 10));
    expect_int("upper boundary", 10, clamp(10, 0, 10));
    expect_int("above", 10, clamp(14, 0, 10));

    if (failures != 0) {
        fprintf(stderr, "%d test(s) failed\n", failures);
        return 1;
    }

    puts("all tests passed");
    return 0;
}

The process status lets automated builds detect failure. A larger project should use a framework or shared harness, but the essential pattern remains arrange input, act once, and assert a focused outcome.

Testable Design

Pure computation functions are easy to test. I/O-heavy functions need streams, temporary files, or dependency boundaries. Separating parsing from acquisition and computation from presentation creates units with controlled inputs.

Tests are consumers of module interfaces. If a function is impossible to test without changing global state or reading a real terminal, its dependencies may be too hidden.

Boundary Testing

Defects cluster at boundaries. For a range [minimum, maximum], test:

  • just below minimum;
  • exactly minimum;
  • a typical interior value;
  • exactly maximum;
  • just above maximum.

For arrays and buffers, include:

  • zero elements when allowed;
  • one element;
  • capacity minus one;
  • exact capacity;
  • one more than capacity;
  • maximum representable size calculations without actually exhausting the machine.

For numeric code, consider zero, sign changes, type limits, overflow guards, rounding thresholds, and invalid conversions. For files, consider empty input, final line without newline, long line, malformed middle record, read error simulation, and write/close failure where test infrastructure permits.

Boundary tests come from contracts, not guesswork.

Regression Testing

A regression test reproduces a previously observed defect. Add it before or with the fix:

  1. create the smallest input that fails on the old code;
  2. assert the required result or status;
  3. verify that the test fails for the understood reason;
  4. apply the correction;
  5. verify the new test and existing suite;
  6. keep the test permanently.

A defect often identifies a class of nearby cases. An overflow at INT_MAX suggests testing INT_MAX - 1, INT_MAX, and relevant negative limits—not only the exact reported value.

Fault Investigation

Symptom: a program crashes after reading eight integers into an eight-element array.

Evidence stepObservationConsequence
reproducecrash occurs with exactly eight accepted valuesboundary-related hypothesis
warning buildno compile diagnosticcontinue with runtime evidence
sanitizerwrite past end at values[count]localizes invalid access
debuggercount == 8, capacity == 8 before writefull-state check is late
code reviewwrite occurs before count < capacity testidentifies cause
correctionvalidate capacity before indexingpreserves bound invariant
regressioncases for 0, 7, 8, and 9 supplied valuesprotects boundary class

The correction is not “increase the array to nine.” That would move the defect to a different input. The cause is an invalid state transition relative to capacity.

Parser Regression Case

A parser is a useful debugging target because failure has layers: no digits, numeric overflow, unwanted trailing text, and application range. The following complete test program checks both results and the promise that output remains unchanged after failure.

#include <ctype.h>
#include <errno.h>
#include <limits.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>

static bool parse_int_range(const char *text,
                            int minimum, int maximum,
                            int *result) {
    if (text == NULL || result == NULL || minimum > maximum) {
        return false;
    }

    errno = 0;
    char *end;
    long candidate = strtol(text, &end, 10);

    if (end == text || errno == ERANGE ||
        candidate < INT_MIN || candidate > INT_MAX) {
        return false;
    }
    while (isspace((unsigned char)*end)) {
        end++;
    }
    if (*end != '\0' || candidate < minimum || candidate > maximum) {
        return false;
    }

    *result = (int)candidate;
    return true;
}

static int failures;

static void expect_parse(const char *name, const char *text,
                         int minimum, int maximum,
                         bool expected_ok, int expected_value) {
    int result = 777;
    bool actual_ok = parse_int_range(
        text, minimum, maximum, &result);

    if (actual_ok != expected_ok) {
        fprintf(stderr, "%s: expected status %d, got %d\n",
                name, expected_ok, actual_ok);
        failures++;
        return;
    }
    if (actual_ok && result != expected_value) {
        fprintf(stderr, "%s: expected value %d, got %d\n",
                name, expected_value, result);
        failures++;
    }
    if (!actual_ok && result != 777) {
        fprintf(stderr, "%s: failure changed output to %d\n",
                name, result);
        failures++;
    }
}

int main(void) {
    expect_parse("lower", "0", 0, 100, true, 0);
    expect_parse("upper", "100\n", 0, 100, true, 100);
    expect_parse("inside", " 42 ", 0, 100, true, 42);
    expect_parse("below", "-1", 0, 100, false, 0);
    expect_parse("above", "101", 0, 100, false, 0);
    expect_parse("empty", "", 0, 100, false, 0);
    expect_parse("junk", "12x", 0, 100, false, 0);
    expect_parse("huge", "999999999999999999999999",
                 0, 100, false, 0);

    if (failures != 0) {
        fprintf(stderr, "%d test(s) failed\n", failures);
        return EXIT_FAILURE;
    }

    puts("all parser tests passed");
    return EXIT_SUCCESS;
}

Suppose an earlier implementation assigned *result = (int)candidate immediately after strtol, before checking trailing text and the allowed range. A test that checks only the returned Boolean would miss the partial-state defect. Initializing the output to 777 and verifying it after every rejected input turns the failure guarantee into an observable regression test.

The test cases come from partitions:

valid: lower boundary, interior, upper boundary, accepted whitespace
lexically invalid: empty, no digits, trailing junk
representationally invalid: long outside range
domain invalid: int below or above requested interval
interface invalid: null pointers, reversed minimum/maximum

One example from each partition is more informative than many random ordinary values. Boundaries add the points where comparisons change truth.

Reproduction Packet

For a defect another person or future you must investigate, preserve:

  • exact source revision and build configuration;
  • compiler identity, options, and relevant environment;
  • smallest known input that triggers the symptom;
  • expected result and actual result;
  • process status and captured diagnostics;
  • sanitizer or debugger output beginning with the first report;
  • whether the failure is deterministic, timing-sensitive, or platform-specific.

“It crashes sometimes” is a symptom report, not a reproduction. Reducing a large failing input should preserve the same first failure. Remove irrelevant records, fields, and operations systematically; if the failure disappears, restore the last removed part and reduce somewhere else. This process, often called test-case reduction, separates causal conditions from background noise.

Do not reduce so aggressively that the defect class changes. A null dereference observed after earlier heap corruption is not the same reproducer if removing setup merely creates a direct null pointer. Sanitizer stack traces, watchpoints, and invariants help confirm that the reduced case still reaches the understood cause.

Hypothesis Ledger

Write debugging as falsifiable statements:

HypothesisExperimentEvidence that rejects it
count exceeds capacity before writebreak before write and inspect bothevery failing run has count < capacity
parser accepts numeric prefixtest "12x" and inspect end pointerend points to null after full conversion
pointer was freed on one error pathbreak on free, record owner and stackno release occurs before failing access
optimization exposes undefined behaviourcompare instrumented builds and inspect UB reportsresult difference has defined documented cause

Change one meaningful variable per experiment. Large speculative edits destroy evidence: if the symptom disappears, no one knows which change mattered or whether it was merely hidden.

A debugger watchpoint is valuable when a correct value becomes corrupt. Set it after initialization and stop on the first write, rather than stepping until a later read notices damage. For heap memory, an address may be reused; combine the address with allocation lifetime and call context.

Repair Standard

A repair is complete when it:

  1. removes the root cause rather than relocating the boundary;
  2. preserves or deliberately revises the documented interface;
  3. handles neighbouring cases from the same defect class;
  4. introduces no unchecked cleanup or failure path;
  5. passes strict diagnostics and relevant dynamic tools;
  6. includes a regression test that fails under the old defect;
  7. leaves code clearer about the invariant that was violated.

Increasing a buffer, adding a cast, swallowing an error, or retrying an operation can hide a symptom while the invalid transition remains. The regression test should assert the semantic contract, not a private implementation detail, unless the defect concerns that internal invariant directly.

Tool output is evidence, not a verdict. A warning may be a false positive, but dismissing it requires a concrete argument. A clean run exercises only the paths taken with that input. Combine static reasoning, targeted tests, runtime instrumentation, and code review according to the failure class.

Assertion Boundary

Assertions express internal facts that correct program logic should already guarantee:

assert(buffer != NULL);
assert(size <= capacity);

External input, allocation failure, missing files, and network errors are expected runtime possibilities. Handle them with ordinary control flow. Assertions may be removed when NDEBUG is defined, so an expression with required side effects must never appear only inside assert.

Place an assertion after a mutation to check that the invariant was restored and at internal boundaries where a caller within the program promised a precondition. Avoid flooding code with assertions of every assignment; choose properties whose failure localizes a broken state transition.

Defect Classes

Classifying the defect guides tools and tests:

ClassTypical evidenceUseful first tools
type/contract mismatchcompiler diagnosticstrict warnings, header review
bounds/lifetimecrash or sanitizer reportASan, invariant trace, debugger
undefined arithmeticoptimization-sensitive resultUBSan, range proof
leak/ownershipgrowing memory or exit reportleak detector, ownership table
parsing/domainwrong acceptance or messagepartition tests, cursor trace
file/environmentpartial result or statusinjected failures, operation context
nondeterministic stateintermittent order/timingreproducible logging, race-aware tools where applicable

The categories overlap. A wrapped allocation-size calculation can become a bounds defect; an ownership error can manifest inside a file buffer. Follow the earliest invalid transition rather than the final symptom category.

Test Independence

Each test should establish its own state and release what it owns. Dependence on test order hides persistent globals, leaked files, or unreset static counters. Run tests individually, in different orders, and repeatedly when the harness permits.

Random testing can explore many cases, but record the seed and smallest failing input. Property checks—such as “parsing a formatted valid value returns the original” or “reversing twice restores an array”—cover more than a list of expected examples. They complement boundary cases; they do not replace exact contracts.

When testing failure paths, inject deterministic allocator, read, or write failures behind controlled interfaces. Filling a real disk or hoping malloc fails is unreliable and hazardous. Verify cleanup and state guarantees after each injected point.

Diagnostic Traps

  • Editing without a reproducer: there is no stable way to know whether a change helped.
  • Suppressing warnings with casts: the type mismatch remains unexplained.
  • Using assertions for user input or required side effects: assertions may be disabled.
  • Treating the crash location as the cause: memory corruption often occurs earlier.
  • Printing every variable: excessive logging hides the relevant invariant and can alter timing.
  • Assuming one sanitizer covers every defect: tools have limited, different detection models.
  • Testing only typical values: boundaries and invalid states remain unexamined.
  • Fixing without a regression test: the exact defect can return unnoticed.

Evidence Loop

  • Debugging progresses from reproducible evidence to localization, causal explanation, correction, and regression protection.
  • Strict compiler diagnostics catch many contract mismatches before execution.
  • Defensive checks validate boundaries; assertions detect internal programmer errors and may be compiled out.
  • Status values and contextual diagnostics make expected failure part of normal control flow.
  • Breakpoints, stepping, watchpoints, variable inspection, and backtraces test concrete hypotheses.
  • Sanitizers, leak detectors, and static analyzers provide complementary evidence rather than proof of correctness.
  • Unit tests benefit from narrow interfaces and separated computation.
  • Boundary tests derive from contracts, and every fixed defect should become a regression test.

Debugging Problems

Name the Evidence

  1. Distinguish symptom, failure, defect, root cause, and regression.
  2. Why must assertions not perform required input or output?
  3. What evidence does a clean sanitizer run provide, and what does it not prove?
  4. Explain why the first compiler or sanitizer diagnostic deserves priority.

Reproduce the Fault

  1. Compile an existing program with strict warnings and classify each diagnostic by violated contract.
  2. Use a debugger to stop at a function, inspect its arguments, step through a loop, and compare caller and callee frames.
  3. Run an intentionally faulty allocation exercise under available address and undefined-behaviour instrumentation. Record the first invalid operation rather than only the final crash.

Isolate the Cause

  1. Repair a parser that validates input only with assert.
  2. Diagnose an array loop that succeeds for seven elements but fails at eight.
  3. Find an ownership defect where two cleanup paths free the same allocation.
  4. Correct a function that writes an output value before all validation succeeds, leaving partial state on failure.

Lock the Repair

  1. Build a unit-test file for integer clamping, including invalid preconditions under the interface’s chosen policy.
  2. Derive boundary tests for a bounded line reader, numeric converter, and dynamic allocation-size calculation.
  3. Add a regression test for an off-by-one defect and demonstrate that it fails when the old condition is restored.

Make Failure Visible

  1. Refactor a function that combines file reading, parsing, calculation, and printing into testable boundaries. List which failures belong to each interface.
  2. Create a project debugging checklist covering build flags, reproducibility data, sanitizer configurations, cleanup checks, and regression requirements.
  3. Review a diagnostic message set for usefulness and privacy. Add operation context without leaking secrets or duplicating messages at every layer.