Chapter 18 of 24
18. Errors, debugging, and testing
Make a problem reproducible
Programming errors are normal. Debugging is the process of turning “it does not work” into a specific, repeatable observation and then finding the cause. Testing checks whether the program behaves as intended, including for edge cases.
- Record the exact input and steps that produce the problem.
- Use compiler diagnostics and a debugger to narrow down where actual behavior first differs from expected behavior.
- Make one fix, rerun the reproduction, and add a test so the same mistake is less likely to return.
Separate compile-time errors, runtime failures, and incorrect results; each calls for different evidence. The rest of this chapter develops a repeatable workflow and testing layers.
Error categories
- Syntax/constraint error: invalid code, usually diagnosed by the
compiler. - Linker error: a required definition or library is missing or duplicated.
- Runtime error: the program fails while running.
- Logic error: the program runs but produces the wrong result.
- Undefined behavior: the C standard does not define what happens.
enum Result {
RESULT_OK,
RESULT_BAD_INPUT,
RESULT_IO_ERROR
};
enum Result parse_one_line(const char *line)
{
if (line == NULL) {
return RESULT_BAD_INPUT;
}
// A real parser would validate the entire line here.
return RESULT_OK;
}
An explicit status distinguishes success, invalid input, and I/O failure
instead of making all outcomes look like a valid value. Undefined behavior
should be fixed, not represented as a result state.
A reliable debugging process
- Reproduce the problem with the smallest input that shows it.
- Read the first useful compiler or runtime diagnostic.
- Inspect the values and assumptions near the failure.
- Use a debugger to step through code and inspect variables.
- Fix the cause, then add a test that would have caught it.
Learn a debugger such as GDB, LLDB, or the Visual Studio debugger. Use
sanitizers when available, for example with GCC or Clang:
gcc -std=c17 -Wall -Wextra -Wpedantic -g \
-fsanitize=address,undefined main.c -o main
For GDB, a basic session can set a breakpoint, run the program, step, and
inspect a variable:
gdb ./main
(gdb) break main
(gdb) run
(gdb) next
(gdb) print count
(gdb) continue
Use the equivalent commands for LLDB or the Visual Studio debugger.
Sanitizers can reveal many memory errors and instances of undefined behavior,
but they do not prove a program is correct.
Write tests for normal cases, boundary values, invalid input, and failure
paths. A small function with explicit inputs and outputs is easier to test
than a large function coupled to console input.
Testing in layers
Start with small functions whose results can be checked directly. Test
ordinary inputs, boundary inputs (such as zero items or the largest allowed
value), invalid inputs, and failure cases such as allocation or file-open
failure. When a bug is fixed, add a regression test that fails before the
fix and passes afterward.
Assertions from <assert.h> are useful for internal assumptions during
development:
#include <assert.h>
#include <stddef.h>
int main(void)
{
size_t length = 3;
size_t capacity = 8;
assert(length <= capacity);
return 0;
}
An assertion is not user-input validation. It may be disabled when
NDEBUG is defined, so never rely on it to protect an external boundary or
perform an operation with required side effects.
In a debugger, set a breakpoint near the faulty operation, step over or into
calls, and inspect local variables. Watch array indices, pointer targets, and
the state of resources. A sanitizer instruments a build to detect some
classes of errors while the program runs. Use test inputs that exercise the
code in question; unexecuted paths cannot be checked by a runtime sanitizer.
Keep a minimal failing example. Removing unrelated code often reveals a
wrong assumption and makes the issue easier to reproduce and fix.
Checkpoint: For a function that searches an array, write tests for an
empty array, a match at the first and last positions, and no match.
Compiler warnings and static analysis
Warnings often identify code that compiles but does not match your likely
intent: implicit conversions, unused values, suspicious format strings, or
missing returns. Fix the cause rather than silencing a warning without
understanding it. Some warnings depend on optimization level or compiler;
build with the actual supported toolchains.
Static analyzers inspect possible execution paths without running one
particular input. They can identify likely null dereferences, leaks, or
unreachable code, but they can also produce false positives and cannot prove
correctness. Treat results as evidence to investigate.
# GCC or Clang: warnings plus common extra diagnostics
gcc -std=c17 -Wall -Wextra -Wpedantic -Wconversion -Wshadow \
-c main.c -o main.o
Compiler options differ across implementations. Add stricter options
gradually, understand each diagnostic, and do not silence warnings without
reviewing the underlying code.
Assertions and test boundaries
An assertion expresses an internal condition that should always hold if the
program is correct. A precondition is what a caller must satisfy; public
functions should either validate it or document it clearly. An invariant is a
condition maintained throughout the lifetime of an object or data structure.
Tests should cover transitions that might break invariants, such as a vector
growing from capacity zero, a removal from a one-item list, and an allocation
failure.
For output that is difficult to compare, separate the core computation from
printing and test the core result directly. Keep tests repeatable: use fixed
inputs and avoid relying on current time, random behavior, or external files
unless the test controls those dependencies.
#include <assert.h>
int add_nonnegative(int left, int right)
{
assert(left >= 0);
assert(right >= 0);
return left + right;
}
int main(void)
{
assert(add_nonnegative(2, 3) == 5);
return 0;
}
These assertions express assumptions and a simple test. They do not check
integer overflow, and assertions are not a substitute for validating user
input in a production interface.