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.
Debug by narrowing evidence, not by guessing
Record the exact command, input, environment, and build flags that reveal the symptom.
Decide whether the evidence comes from compilation, linking, a normal error return, a crash, or a failed invariant.
Remove unrelated code and input until the smallest program still fails.
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 */
}Syntax or type diagnostic
Source cannot be translated consistently. Fix the earliest reported error first because one parser error can produce many downstream messages.
Undefined reference
Declarations compiled, but a required definition was not linked. Check the object and library list, not the function's source spelling alone.
Failure under input or state
The executable ran far enough to reveal a broken assumption. Preserve the case and inspect its state with tools.
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.
printf is a contract
Use a matching conversion such as %zu for size_t. A mismatched variadic format can itself cause undefined behavior.
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.
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
fopen failure path have stated meanings.char signedness.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;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) backtraceWhen 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.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.
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.