Help improve SovranCode?

Google Analytics can measure anonymous usage after you choose Allow. Essential account and progress features work either way.

SovranCode
C: Foundations for Systems Programming Errors, Debugging, and Undefined Behavior
This device
Course contentsErrors, Debugging, and Undefined Behavior · 15 titles

C foundations

What C Is and How C Programs RunYour First C ProgramVariables, Types, and OperatorsInput and Output with printf and scanfConditions and Loops

Program structure and data

Functions, Headers, and ScopeArrays and StringsPointers and Memory AddressesStructures, Enumerations, and UnionsFiles and Command-Line Arguments

Memory, tooling, and practice

Dynamic Memory AllocationPreprocessor Directives and Separate CompilationErrors, Debugging, and Undefined BehaviorBuild Tools and MakefilesProject: Command-Line Inventory Manager
C: Foundations for Systems Programming15 complete lessons

C foundations

What C Is and How C Programs RunYour First C ProgramVariables, Types, and OperatorsInput and Output with printf and scanfConditions and Loops

Program structure and data

Functions, Headers, and ScopeArrays and StringsPointers and Memory AddressesStructures, Enumerations, and UnionsFiles and Command-Line Arguments

Memory, tooling, and practice

Dynamic Memory AllocationPreprocessor Directives and Separate CompilationErrors, Debugging, and Undefined BehaviorBuild Tools and MakefilesProject: Command-Line Inventory Manager
PREVIOUS LESSONPreprocessor Directives and Separate Compilation
NEXT LESSONBuild Tools and Makefiles
Memory, tooling, and practice 360 min

Errors, Debugging, and Undefined Behavior

A crash is not a diagnosis, and a successful run is not a proof. Good C debugging turns a vague symptom into a small, repeatable claim: what input, build, state, and source line violated which contract? Learn to extract that evidence before you change code—and to recognize when the language has already stopped making promises.

What you will leave with

You will treat warnings as actionable evidence, distinguish compile/link/runtime failures, write assertions for invariants, use a source-level debugger, run sanitizers, build a minimal reproducer, clean up error paths, and identify common undefined behavior before it becomes a production mystery.

Debug by narrowing evidence, not by guessing

  • 01
    Reproduce

    Record the exact command, input, environment, and build flags that reveal the symptom.

  • 02
    Classify

    Decide whether the evidence comes from compilation, linking, a normal error return, a crash, or a failed invariant.

  • 03
    Minimize

    Remove unrelated code and input until the smallest program still fails.

  • 04
    Prove and prevent

    Fix the violated contract, add a regression test, then re-run the full diagnostic build.

  • Do not start by sprinkling prints throughout a large program or changing several lines at once. Those moves can hide a timing-dependent bug and destroy the evidence. First preserve a failing case. Your desired end state is a test that fails before the fix and passes after it.

    Make the compiler your first reviewer

    Many C mistakes are visible before the program runs: a wrong format specifier, missing return, accidental conversion, uninitialized value, or declaration that does not match its use. Compile with a modern language mode and strong warnings from the start. Understand each warning; do not silence one merely to make the build green.

    cc -std=c17 -Wall -Wextra -Wpedantic -Wconversion -g \
      src/main.c src/scores.c -o build/score-report
    
    /* Example: the compiler can question this conversion. */
    int read_count(size_t count) {
      return count; /* size_t may not fit in int */
    }
    COMPILE

    Syntax or type diagnostic

    Source cannot be translated consistently. Fix the earliest reported error first because one parser error can produce many downstream messages.

    LINK

    Undefined reference

    Declarations compiled, but a required definition was not linked. Check the object and library list, not the function's source spelling alone.

    RUNTIME

    Failure under input or state

    The executable ran far enough to reveal a broken assumption. Preserve the case and inspect its state with tools.

    Warnings are not cosmetic

    Adding an explicit cast only to hide a warning can turn a compiler check into a quiet data-loss bug. Confirm the value range and API contract first; then choose a safe type, bounds check, or conversion strategy.

    Warning case study: trace a type mistake to its contract

    Consider a score importer. It counts records with size_t, because a count cannot be negative and may exceed the range of int. The presentation layer then promises an int result without checking whether that promise is possible.

    #include <limits.h>
    #include <stddef.h>
    
    int display_count(size_t count) {
      return count; /* A warning may report a narrowing conversion. */
    }
    
    /* Repair the contract rather than suppressing the warning. */
    int display_count(size_t count, int *out_count) {
      if (out_count == NULL || count > INT_MAX) return 0;
      *out_count = (int)count; /* Proven safe by the range check. */
      return 1;
    }

    The cast in the repaired version is not the fix; it is a documented consequence of the preceding proof. Work through warnings in this order: identify the values and their ranges, decide which type expresses the domain, validate before conversion, then make the conversion visible at one boundary. The same reasoning applies to signed/unsigned comparisons, printf format strings, and pointer-to-integer conversions.

    FORMAT

    printf is a contract

    Use a matching conversion such as %zu for size_t. A mismatched variadic format can itself cause undefined behavior.

    SIGN

    Do not compare by accident

    Convert only after checking the non-negative range. Comparing int index with size_t count can convert the negative index to a huge unsigned value.

    RETURN

    Preserve failure information

    Do not collapse allocation, parse, and range failures into an arbitrary value that could also be valid data.

    C behavior has four important categories

    DEFINEDThe standard specifies itFor example, valid array access and a checked fopen failure path have stated meanings.
    IMPLEMENTATION-DEFINEDThe implementation chooses and documents itExamples can include the size of some integer types or plain char signedness.
    UNSPECIFIEDOne permitted result may varyDo not write logic that needs a particular allowed evaluation order.
    UNDEFINEDNo requirements remainOut-of-bounds access, use-after-free, signed overflow, and invalid shifts are not recoverable program states.

    Undefined behavior (UB) is not an exception mechanism. It can appear correct in a debug build, crash only with another compiler, or let an optimizer make transformations that surprise you. The correct response is to remove the UB from every possible execution path—not to test until it seems harmless.

    int values[5] = {0};
    values[5] = 9;          /* UB: valid indexes end at 4 */
    
    int *pointer = malloc(sizeof *pointer);
    free(pointer);
    *pointer = 4;           /* UB: use-after-free */
    
    int max = INT_MAX;
    max += 1;               /* UB: signed integer overflow */
    
    int value = 1;
    value = value++ + 1;    /* UB: unsequenced modification/read */

    A practical catalog of behavior you must design out

    Memorizing a label is less useful than recognizing the precondition each operation needs. The following bugs often pass a small test suite because their failure depends on stack layout, allocator state, input, architecture, or optimization.

    /* 1. A string literal is not a writable character array. */
    char *label = "total";
    label[0] = 'T';                /* UB */
    char writable_label[] = "total";
    writable_label[0] = 'T';       /* valid: this array owns writable bytes */
    
    /* 2. %s needs a terminating null byte within the array. */
    char code[3] = {'C', '1', '7'};
    printf("%s\n", code);        /* UB: scans beyond code looking for '\0' */
    printf("%.3s\n", code);      /* bounded output */
    
    /* 3. Shift counts and shifted values need a valid range. */
    unsigned flags = 1u;
    flags <<= 32;                  /* UB when unsigned is 32 bits */
    
    /* 4. A pointer is only usable while its object is alive. */
    int *bad_address(void) {
      int local = 42;
      return &local;               /* caller receives a dangling pointer */
    }

    Other high-value checks: never call a function through an incompatible function-pointer type; do not modify an object declared const; do not pass overlapping buffers to functions whose contract disallows overlap; initialize every scalar before it is read; and do not assume a raw byte representation is a valid value for every type. If a library function has a precondition, violate it only in a dedicated negative test under a sanitizer—never in application logic.

    Portability is part of correctness

    C leaves some choices to the implementation so it can run on many systems. Before serializing data, parsing bytes, or relying on numeric limits, ask whether a behavior is defined for your target, implementation-defined but documented by the target, or simply unspecified. Make the dependency explicit and test the target configurations you claim to support.

    #include <limits.h>
    #include <stdint.h>
    #include <stdio.h>
    
    /* Do not assume char is signed. State the conversion you need. */
    int byte_to_number(unsigned char byte) {
      return (int)byte; /* Always 0 through UCHAR_MAX. */
    }
    
    /* Discover limits instead of guessing them. */
    printf("int: %d..%d, byte bits: %d\n", INT_MIN, INT_MAX, CHAR_BIT);
    
    /* When a stored format needs an exact width, choose an exact-width type
       only after confirming the platform provides it. */
    uint32_t record_id = 42u;
    Optimization does not create the bug

    An optimized build can expose undefined behavior because the compiler may assume the program obeys the language rules. Do not “fix” a release-only failure by turning optimization off permanently. Use an unoptimized, instrumented build to locate the contract violation; then prove the corrected code is valid at normal optimization too.

    Use checks to state contracts at the boundary

    External input, files, allocation, and system calls can fail in ordinary, expected ways. Validate them with an explicit error path. Assertions serve a different purpose: they document a programmer invariant that must already hold if the API is being used correctly.

    #include <assert.h>
    #include <errno.h>
    #include <stdio.h>
    #include <string.h>
    
    int print_first_score(const int values[], size_t count) {
      if (values == NULL || count == 0) {
        fprintf(stderr, "print_first_score: expected at least one value\n");
        return 0; /* Recoverable caller/input error. */
      }
      assert(count <= 10000); /* Internal design invariant. */
      printf("%d\n", values[0]);
      return 1;
    }
    
    FILE *input = fopen(path, "r");
    if (input == NULL) {
      fprintf(stderr, "%s: %s\n", path, strerror(errno));
      return 1;
    }

    Do not use assert for required user-input validation: builds can define NDEBUG, which disables assertion expressions. A required check must still execute and return or report a controlled error in every build configuration.

    Use the debugger to inspect the state where it diverges

    Build with -g and keep optimization low while investigating a bug, commonly -O0 or -Og when supported. Run the program under gdb or lldb, stop at a suspicious function or crash, inspect variables, and walk the call stack. A debugger answers “what was true here?” more reliably than a print statement added after the fact.

    cc -std=c17 -Wall -Wextra -g -O0 src/main.c src/scores.c -o build/score-report
    
    # gdb workflow (LLDB has comparable break, run, next, step, frame, and print commands)
    gdb --args ./build/score-report data/scores.txt
    (gdb) break scores_average
    (gdb) run
    (gdb) print count
    (gdb) print values[0]
    (gdb) next
    (gdb) backtrace

    When a crash stops inside a library function, move up the stack to your own call site. Check the arguments and the contract that produced them. A null pointer in fprintf or a bad file handle is usually caused earlier, not in the line where the library finally dereferences it.

    Debugger case study: find the first invalid state

    This function should parse exactly count integers. Its loop has a one-past-the-end write. Do not start by changing <= to <; first use the debugger to establish why the write is invalid and add a test that would catch a future regression.

    int parse_fixed_scores(FILE *input, int values[], size_t count) {
      for (size_t index = 0; index <= count; index++) { /* bug */
        if (fscanf(input, "%d", &values[index]) != 1) return 0;
      }
      return 1;
    }
    # 1. Build with symbols and a sanitizer.
    cc -std=c17 -Wall -Wextra -g -O0 -fsanitize=address \
      parse.c parse_test.c -o build/parse-test
    
    # 2. Set a breakpoint before the write; observe the final loop iteration.
    gdb ./build/parse-test
    (gdb) break parse_fixed_scores
    (gdb) run
    (gdb) display index
    (gdb) display count
    (gdb) next
    
    # At index == count, &values[index] points one element beyond the object.
    # 3. Repair the bound, then test count 0, count 1, and the exact capacity.

    A debugger is strongest when you arrive with a hypothesis: “the last valid index must be less than count.” Use breakpoints to test that claim. Use a watchpoint when a value changes unexpectedly: watch summary.total stops execution at the write that changed it, which is often earlier than the bad output.

    Instrument the program for memory and UB evidence

    Sanitizers add runtime checks that detect many violations at the moment they occur. They are especially valuable for C because an invalid access can otherwise corrupt state long before it becomes visible. Use a debug build, run meaningful tests, and keep the complete report with its stack trace.

    # GCC or Clang, where supported:
    cc -std=c17 -Wall -Wextra -Wpedantic -g -O1 \
      -fsanitize=address,undefined -fno-omit-frame-pointer \
      src/main.c src/scores.c -o build/score-report-sanitized
    
    ./build/score-report-sanitized data/boundary-scores.txt
    
    # AddressSanitizer: out-of-bounds, use-after-free, many leaks.
    # UndefinedBehaviorSanitizer: selected invalid arithmetic, shifts, alignment,
    # and other language-rule violations.
    A clean sanitizer run is evidence, not a guarantee

    Sanitizers only inspect paths your tests execute, and each sanitizer covers a defined family of errors. Keep bounds checks, ownership contracts, reviews, and tests; instrumented builds make those habits much more effective.

    Read a sanitizer report from the first bad access backward

    A sanitizer report normally identifies the invalid operation, the source line, and the allocation or deallocation stack that made it invalid. Read the first report, not the final crash. For a heap use-after-free, answer four questions before changing code: where was the object allocated, who owned it, where did ownership end, and why did a later path still use an alias?

    /* Typical investigation notes for a use-after-free
       invalid write: render_report.c:42
       freed by: report_destroy at report.c:81
       allocated by: report_load at report.c:19
    
       Contract repair:
       - report_destroy owns report->rows and resets it to NULL.
       - render_report borrows a live Report only.
       - caller never invokes render_report after destruction.
       - add a lifecycle test that exercises load → render → destroy.
    */

    Sanitizers do not replace checking return values. If an allocation failure returns NULL and the next line dereferences it, the report shows the symptom; the durable fix is to represent allocation failure in the API and require callers to follow that path.

    Build a minimal reproducer before changing the world

    Suppose the inventory importer reports an impossible total. Preserve the input file, command line, compiler version, flags, and output. Then reduce it: remove records until one smallest file still fails, replace the application with a focused function call, and print only the values needed to establish the broken invariant.

    /* regression: a count of 0 must never divide by zero */
    double scores_average(const int values[], size_t count) {
      if (values == NULL || count == 0) return 0.0;
    
      long total = 0;
      for (size_t index = 0; index < count; index++) {
        total += values[index];
      }
      return (double)total / (double)count;
    }
    
    /* Test both: scores_average(NULL, 0) and one known non-empty array. */

    The regression test should express behavior, not merely repeat an old implementation detail. Here the contract says what an empty input returns. If your domain needs to distinguish an empty average from 0.0, return a status code and write the value through an output pointer instead.

    Errors must unwind ownership deliberately

    Failure handling is part of program structure. After one resource acquisition succeeds, every later failure must release exactly what has been acquired. A single cleanup label is often clearer than nested branches in a function that owns files and heap storage.

    int import_scores(const char *path) {
      FILE *input = NULL;
      int *values = NULL;
      int status = 1;
    
      input = fopen(path, "r");
      if (input == NULL) goto cleanup;
      values = malloc(100 * sizeof *values);
      if (values == NULL) goto cleanup;
      if (!read_scores(input, values, 100)) goto cleanup;
    
      status = 0;
    cleanup:
      free(values);       /* free(NULL) is safe */
      if (input != NULL) fclose(input);
      return status;
    }

    Put a useful message near the failed operation when the caller cannot add better context. Do not log secrets, raw credentials, or untrusted data unnecessarily; an error message should identify the operation, safe input context such as a filename, and the system error when available.

    Independent lab: investigate a broken score report

    Create a deliberately faulty program with a one-past-the-end write, an unchecked fopen, and a zero-count average. Then repair it using a written diagnostic record—not trial-and-error edits.

    01Capture the commandSave the exact compiler command, flags, executable arguments, and the smallest input that reproduces the bug.
    02Fix compiler evidence firstBuild with strong warnings; explain every one before moving to runtime investigation.
    03Run an instrumented buildUse AddressSanitizer and UndefinedBehaviorSanitizer where your compiler supports them, then follow the first report to your source line.
    04Make it permanentWrite a regression test for each repaired contract: empty input, missing file, and the array boundary.

    Lesson review

    • Preserve and minimize a failing case before you edit; a repeatable failure is your strongest debugging asset.
    • Use strong compiler warnings and fix their cause rather than hiding them with casts or disabled flags.
    • Separate compiler, linker, normal runtime error, and invariant failures so you use the right evidence.
    • Undefined behavior is not a predictable error: remove out-of-bounds access, dangling use, signed overflow, invalid shifts, and unsequenced modifications.
    • Use normal error paths for recoverable external failure; use assertions to document internal invariants.
    • Debuggers expose state at the failure point; sanitizers catch many memory and language-rule violations on executed paths.
    • On any error path, release every resource already acquired and leave no ambiguous owner behind.
    KNOWLEDGE CHECK

    Turn a failure into evidence

    Check the diagnostic loop before you move on to build automation and a complete command-line project.

    01What is the best first response to a compiler warning in a new C program?
    02Which statement about undefined behavior is correct?
    03Which is an example of undefined behavior?
    04What does an assertion primarily communicate?
    05Why compile with -g while debugging?
    06What does an AddressSanitizer build most directly help find while tests run?
    07What is a useful way to make an intermittent bug reproducible?
    08What should code do after a failing fopen, malloc, or parse operation?
    PREVIOUS LESSONPreprocessor Directives and Separate Compilation
    NEXT LESSONBuild Tools and Makefiles
    ON THIS PAGEErrors, Debugging, and Undefined BehaviorDiagnostic loopCompiler warningsWarning case studyBehavior categoriesUB catalogPortable CAssertions and errorsDebugger workflowDebugger case studySanitizersReading reportsMinimal reproducersCleanup pathsIndependent labLesson reviewKnowledge check
    Course contents