Phase 5 · Professional CModule 18~50 min read

Debugging, Testing & Undefined Behavior

Find defects systematically with debuggers, assertions, sanitizers, tests, static analysis, and a precise model of undefined behavior.

What you'll learn

Professional C is not about never creating defects. It is about building several fast, independent ways to expose them before users do—and leaving behind tests that prevent yesterday's defect from returning.

By the end, you'll be able to:

  • Compile with strict, actionable diagnostics
  • Inspect control flow and program state with a debugger
  • Write assertions and focused automated tests
  • Detect memory errors and undefined behavior with sanitizers
  • Distinguish undefined, unspecified, and implementation-defined behavior
A layered defect-detection workflow
01
Compiler
syntax, types, suspicious constructs
02
Static analysis
paths, contracts, resource mistakes
03
Tests
expected behavior and regressions
04
Sanitizers
memory and undefined behavior at runtime
05
Debugger
state and control flow at the failure

Compiler diagnostics

Your compiler understands C deeply enough to spot conversions, shadowed names, missing declarations, unreachable code, and other suspicious constructs. Enable diagnostics from the beginning; do not wait until the end of a project.

Terminal
# GCC or Clang: a strict development build
cc -std=c17 -Wall -Wextra -Wpedantic \
   -Wconversion -Wshadow -Werror \
   -g -O0 src/main.c -o build/app

# A separate optimized release build
cc -std=c17 -Wall -Wextra -Wpedantic \
   -O2 -DNDEBUG src/main.c -o build/app
OptionPurpose
-Wall -WextraEnable broad, high-value warning groups
-WpedanticReport code outside the selected C standard
-WconversionExpose potentially value-changing conversions
-WshadowReport declarations hiding an outer name
-WerrorTreat warnings as build failures in controlled builds
-g -O0Preserve debug information and simple stepping behavior

Tip

Warning sets vary by compiler and version. Pin your supported toolchains, document the flags, and introduce new warnings deliberately so clean builds stay achievable.

Debuggers

A debugger pauses a live program so you can inspect variables, step through statements, watch values change, and examine the call stack. Start from a repeatable failure and a concrete question such as “when does this index first become invalid?”

average.c
#include <stddef.h>
#include <stdio.h>

double average(const int values[], size_t count) {
    long total = 0;
    for (size_t i = 0; i <= count; i++) { // bug: one step too far
        total += values[i];
    }
    return (double) total / (double) count;
}

int main(void) {
    int scores[] = {84, 91, 78};
    printf("%.2f\n", average(scores, 3));
    return 0;
}
GDB session
$ cc -std=c17 -g -O0 average.c -o average
$ gdb ./average
(gdb) break average
(gdb) run
(gdb) next
(gdb) print i
(gdb) print count
(gdb) watch i
(gdb) continue
(gdb) backtrace

Key idea

The loop invariant requires i < count. At i == count, the program reads beyond the array. The debugger reveals the state; reasoning about the contract explains why it is wrong.

LLDB provides equivalent concepts with slightly different commands. Learn breakpoints, stepping, variable inspection, watchpoints, and stack traces in whichever debugger your platform supports.

Assertions & contracts

An assertion documents a condition that must be true if the programmer used the function correctly. A failed assertion aborts the program close to the violated assumption, making the defect easier to locate.

maximum.c
#include <assert.h>
#include <stddef.h>

int maximum(const int values[], size_t count) {
    assert(values != NULL); // programmer contract
    assert(count > 0);

    int result = values[0];
    for (size_t i = 1; i < count; i++) {
        if (values[i] > result) result = values[i];
    }
    return result;
}

Watch out

Assertions may disappear when NDEBUG is defined. Never use an assertion for required side effects, ordinary user input validation, recoverable file errors, or security checks. Handle those cases explicitly.

Automated testing

A unit test calls a small piece of code with known input and checks observable results. Cover normal cases, boundaries, empty or invalid inputs where supported, and regressions for bugs you have fixed.

test_clamp.c
#include <stdio.h>

static int failures = 0;

void check(int condition, const char *expression,
           const char *file, int line) {
    if (!condition) {
        fprintf(stderr, "%s:%d: CHECK(%s) failed\n",
                file, line, expression);
        failures++;
    }
}

#define CHECK(condition) check((condition), #condition, __FILE__, __LINE__)

int clamp(int value, int low, int high) {
    if (value < low) return low;
    if (value > high) return high;
    return value;
}

int main(void) {
    CHECK(clamp(5, 0, 10) == 5);   // normal case
    CHECK(clamp(-1, 0, 10) == 0);  // below boundary
    CHECK(clamp(11, 0, 10) == 10); // above boundary
    CHECK(clamp(0, 0, 10) == 0);   // exact boundary

    if (failures == 0) puts("All tests passed");
    return failures == 0 ? 0 : 1;
}
  • Keep tests deterministic, isolated, and fast
  • Return a nonzero process status when any test fails
  • Separate reusable implementation files from the production main
  • Test public behavior; avoid coupling every test to internal implementation
  • Run the suite on every meaningful change

Note

The tiny harness demonstrates the mechanism. Larger projects can adopt a C testing framework, but clear test cases and automated execution matter more than framework choice.

Sanitizers

Sanitizers instrument a development build. AddressSanitizer detects many out-of-bounds and use-after-free errors; UndefinedBehaviorSanitizer detects operations such as invalid shifts and signed overflow while the affected path runs.

Terminal
# Build an instrumented executable with GCC or Clang
cc -std=c17 -g -O1 \
   -fsanitize=address,undefined \
   -fno-omit-frame-pointer \
   tests.c -o tests

# Run tests normally; the runtime reports detected defects
./tests

# Valgrind is another option on supported platforms
valgrind --leak-check=full ./tests

Watch out

A clean sanitizer run proves only that the executed paths did not trigger a detectable problem. It does not prove the whole program correct. Combine sanitizers with strong tests, code review, and static analysis.

Static analysis

Static analyzers explore source and possible control-flow paths without relying only on one execution. They can identify leaks, null dereferences, uninitialized values, ignored return codes, and mismatched resource ownership.

TechniqueFinds defectsMain limitation
Compiler warningsDuring every buildFocused on language and local diagnostics
Static analysisAcross possible code pathsCan produce false positives and needs configuration
Unit/integration testsWhen checked behavior differsLimited to designed cases
SanitizersWhen an instrumented path executesRuntime overhead and incomplete path coverage
DebuggerDuring an investigated executionInteractive diagnosis, not prevention

Tip

Treat analyzer output as engineering evidence: understand the path, fix the root cause, or document a narrowly scoped suppression. Do not normalize an ever-growing warning backlog.

Undefined behavior

The C standard places behaviors into important categories. When behavior is undefined, the standard imposes no requirements; an optimizer may assume that path never occurs. The result is not guaranteed to be a friendly crash.

behavior.c
#include <limits.h>
#include <stddef.h>
#include <stdio.h>

int main(void) {
    int values[3] = {10, 20, 30};

    // Undefined: index is outside the array.
    // printf("%d\n", values[3]);

    // Undefined: signed integer overflow.
    // int impossible = INT_MAX + 1;

    // Defined unsigned arithmetic wraps modulo UINT_MAX + 1.
    unsigned counter = UINT_MAX;
    counter++;
    printf("%u\n", counter);

    // Defined because the pointer is valid and checked before use.
    int *selected = &values[1];
    if (selected != NULL) printf("%d\n", *selected);
    return 0;
}
CategoryMeaningExample
DefinedThe standard specifies the resultUnsigned integer wraparound
Implementation-definedThe implementation chooses and documents a resultSize of int; signed right shift behavior
UnspecifiedOne of several allowed results; no documentation requiredOrder of evaluating function arguments
UndefinedThe standard gives no requirementsOut-of-bounds access; signed overflow; use after free

Key idea

Common sources of undefined behavior include invalid pointer use, out-of-range indexing, signed overflow, invalid shifts, modifying a string literal, mismatched format specifiers, and reading an uninitialized automatic object.

A professional workflow

  1. Reduce the failure to a repeatable input or test
  2. Build with strict warnings and debug information
  3. Run the test suite under address and undefined-behavior sanitizers
  4. Use the debugger to inspect the earliest incorrect state
  5. Fix the violated invariant or ownership rule—not just the final symptom
  6. Add a regression test, rerun analysis, and review nearby code

Tip

Change one variable at a time while diagnosing. Random edits destroy evidence; a small hypothesis followed by a focused experiment teaches you why the program failed.

Recap & quick check

Key takeaways

  • Strict compiler diagnostics are the first and cheapest quality gate.
  • Debuggers expose runtime state, while invariants explain why that state is invalid.
  • Assertions enforce programmer contracts; tests verify repeatable observable behavior.
  • Sanitizers and static analysis find different classes of defects and work best together.
  • Undefined behavior has no portable meaning and must be prevented, not merely observed.

Quick check

1. Which condition is appropriate for an assertion?

2. What does AddressSanitizer primarily help detect?

3. Why is one clean sanitizer run insufficient proof of correctness?

4. What guarantee does C give after signed integer overflow?

Next: Module 19 — Build Tools, Portability & Secure C, where these quality checks become part of a reproducible project pipeline.