- Learn
- Computer Science & IT
- Programming
- Programming in C
- Debugging and Undefined Behavior in C
Debugging, Undefined Behavior and Safe C
IntermediateLesson 8 of 8 in Programming in C Course
What you will learn
- Turn on the compiler's warnings, read its diagnostics and choose build settings that make bugs visible early, before the program ever runs.
- Turn on warnings and read compiler messagesCompile with a strict set of warning flags, read each diagnostic line by line and fix the cause instead of silencing the message.
- Choose build settings for debugging, testing and releaseUse separate debug and release builds, know what -g and the optimization levels change, and add static analysis and hardening flags where they belong.
- Turn on warnings and read compiler messages
- Track down bugs methodically: reproduce and isolate the failure, then use gdb or lldb to pause the program, step through it, inspect values and find where a crash or a wrong value really comes from.
- Debug methodically: reproduce, isolate and test a hypothesisTurn a vague failure into a precise one, read what the crash message tells you, and narrow the cause down with small, controlled experiments.
- Run, pause and step through a program in gdb or lldbStart a program under gdb or lldb, set breakpoints, step line by line and inspect variables and arrays while the program is paused.
- Diagnose crashes and unexpected valuesUse the call stack after a crash, move between frames, and use watchpoints, conditional breakpoints and core dumps to find where a bad value comes from.
- Debug methodically: reproduce, isolate and test a hypothesis
- Explain what undefined behavior is and why it makes C programs unpredictable, and recognize its most common sources in arithmetic, expressions, pointers and memory.
- Understand what undefined behavior meansDistinguish undefined, implementation-defined and unspecified behavior, and explain why code with undefined behavior can seem to work and then fail.
- Spot undefined behavior in arithmetic and expressionsRecognize signed overflow, bad division and shifts, out-of-range conversions, unsequenced side effects and uninitialized reads, and write the checks that avoid them.
- Spot undefined behavior with pointers, arrays and memoryRecognize out-of-bounds access, bad strings, null and dangling pointers, use after free and wrong allocation sizes, and know the safe pattern for each.
- Understand what undefined behavior means
- Make a program check its own assumptions while it runs: state them with assertions, and build test versions with sanitizers that stop at memory errors and undefined behavior and report exactly where they happened.
- State assumptions with assertionsUse assert for conditions that only a bug can break, understand what NDEBUG does, and keep assertions separate from handling expected errors.
- Find memory errors with AddressSanitizerBuild a test version with AddressSanitizer, read its error reports, and know what it finds, what it misses and what it costs.
- Detect undefined behavior with UBSan and build a checking routineUse UndefinedBehaviorSanitizer and know the other sanitizers, then combine warnings, sanitizers and tests into a routine that catches errors early.
- State assumptions with assertions
- Apply secure coding rules such as the SEI CERT C rules to strings, buffers, input and errors, and weigh what C's lack of memory safety costs against what it offers.
- Use a secure coding standardExplain how the SEI CERT C Coding Standard is organized, read a rule with its examples, and know the core rules that prevent the most common C bugs.
- Handle strings and buffers safelyAlways know how large a buffer is, read and copy strings with functions that respect that size, and detect truncation.
- Validate input and handle errorsTreat all outside data as untrusted, convert text to numbers with full error checks, and check the result of every library call that can fail.
- Weigh memory-safety trade-offsExplain what memory safety is, why C lacks it, what that costs and what C offers in return, and how layers of defense and memory-safe languages reduce the risk.
- Use a secure coding standard
Debugging and Undefined Behavior in C study guide
1Let the compiler find problems for you
The compiler can point out many problems before you run a program. Learn to read its messages as evidence about your code, then choose flags that reveal more of what matters. Debugging information, optimization, static analysis, and release hardening serve different jobs. Keeping those jobs clear helps you build a program you can inspect during development and test again in the form your users will run.
1.1 Turn on warnings and read compiler messages
Warnings give you a chance to repair a defect before an input triggers it. An error prevents a successful build. A warning allows the build to continue, but reports something you need to understand: suspicious valid code, possible undefined behavior, or a language-rule violation the compiler accepts.
Choose useful checks
Start GCC or Clang with an explicit language version and a broad warning set:
clang -std=c17 -Wall -Wextra -Wpedantic -g program.c -o program
-Wall enables many common checks, not all warnings. -Wextra adds more, including signed/unsigned comparisons in C. -Wpedantic diagnoses many departures from the chosen ISO standard; it is not a complete conformance test. MSVC's /W4 serves a similar purpose on Windows.
Add checks that match the risks in your code:
| Flag | What it helps reveal |
|---|---|
-Wconversion | Implicit conversions that may change a value |
-Wshadow | A local declaration hiding another variable |
-Wformat=2 | Additional checks of format strings |
-Werror | Makes enabled warnings fail the build |
For example, double price = 3.7; int whole = price; discards the fraction. -Wconversion makes that conversion visible. If truncation is intended and the value fits, (int)price states the choice. If the fraction matters, retain a floating-point type. For nearest-integer rounding, lround from <math.h> has a different job from a cast.
Read the first useful message
A diagnostic normally identifies a source location, a message, and the warning flag. A caret marks the relevant expression; following notes may identify a declaration or suggest a repair. Read the entire group. When one syntax error causes many later messages, fix the first error and compile again before chasing the rest.
Suppose a program contains these three lines, with average declared as double:
if (count = 0) { /* report no data */ }
printf("%d\n", average);
int unused;
The assignment in the condition sets count to zero, making the condition false. If the intention was a comparison, use count == 0. Adding parentheses around the assignment can silence a warning while leaving the bug intact.
The format %d expects an int, but the argument is a double. Use %f; a format mismatch is undefined behavior, not a request to convert the value. Delete the unused declaration if it serves no purpose. These are three different causes, so each needs its own repair.
Follow the evidence
Pay close attention to uninitialized values, missing returns, addresses of local variables being returned, and sizeof on array parameters. A missing header can leave a function undeclared; recent GCC and Clang releases reject this by default. Do not add a cast simply to make a diagnostic disappear. Explain the intended types and values first, then make the code express that intent.
A warning-free program can still have a faulty loop condition or a bad index supplied by input. Some warnings also depend on optimization and data-flow analysis, and different compilers catch different cases. Keep the warning set enabled in ordinary builds, then investigate run-time behavior with the debugger and testing tools.
Key Takeaways
- Choose the C version explicitly.
-Walldoes not enable every warning.- Repair the cause of a diagnostic before suppressing it.
- A format conversion must match the argument type.
- A clean build still needs run-time testing.
1.2 Choose build settings for debugging, testing and release
The same source can be built for easy inspection, intensive testing, or efficient release. Build settings decide what information you can see and which extra checks execute. Give each build a clear purpose.
Keep source information
The -g flag adds names, types, and mappings between machine instructions and source lines. It lets a debugger show your code and helps sanitizer reports identify locations. It does not turn on run-time checks or disable optimization. Without debug information, some function names may remain available, but source lines and local variables are much harder to recover.
Optimization changes how closely execution follows source order:
| Setting | Usual purpose | What you may see |
|---|---|---|
-O0 | Straightforward debugging | Source order is usually easiest to follow |
-Og | Debugging with light optimization | Some transformations remain |
-O2 | Efficient release build | Reordered work and removed variables |
An optimized debugger session can skip lines or display a variable as "". The compiler has preserved the behavior of defined code, but it may no longer keep every source-level intermediate value. -O0 does not literally translate every C line into one machine instruction.
Build for the job
A simple debug command is:
gcc -std=c17 -Wall -Wextra -Wpedantic -g -O0 program.c -o program-debug
A testing build adds sanitizers. A release build commonly uses -O2, and may define NDEBUG to remove assertions. Ordinary input validation and error handling must remain active when assertions disappear. Test the release configuration too: a problem visible only with optimization is a strong reason to investigate undefined behavior, including uninitialized reads and signed overflow.
Keep shared flags in a Makefile or another build configuration. Separate output names prevent accidentally running an old release binary while inspecting newly edited source. After every source change, rebuild the executable you intend to run.
Add analysis and hardening
Static analysis follows possible paths through the source without executing the program. Try gcc -fanalyzer or clang --analyze; scan-build, clang-tidy, and cppcheck offer other analysis workflows. An analyzer may notice that an error branch frees a pointer twice even when your tests never enter that branch. Review its reasoning: possible-path analysis can produce false alarms.
Dynamic analysis checks actual execution. Sanitizers and Valgrind see only the paths you run, so their coverage depends on your inputs. Combine both kinds of analysis.
Release hardening limits damage from remaining defects. -fstack-protector-strong places checks in selected stack frames to detect many overwrites. On supporting systems, _FORTIFY_SOURCE adds checks to library calls when object sizes can be determined. It requires optimization; level 2 is common, while level 3 requires suitable compiler and C-library support. These are platform features, not ISO C guarantees.
A hardened program can stop when it detects corruption. The invalid operation still needs a source fix; changing release flags cannot correct an out-of-bounds loop.
Key Takeaways
- Use
-gfor source-level debug information. - Debug with
-O0or-Og, and test the release build too. - Keep input checks active when
NDEBUGremoves assertions. - Static analysis and run-time analysis find different paths and defects.
- Keep build flags in a repeatable configuration.
- Hardening reduces damage from bugs that remain.
2Find and fix bugs with a debugger
A crash tells you where execution stopped; the mistake may have happened much earlier. Start with a repeatable failure and a small input, then use a debugger to inspect the program at chosen moments. Breakpoints and stepping show how values develop. Stack frames, watchpoints, and conditional breakpoints help trace a bad value back to the code that created it, so each experiment answers a specific question.
2.1 Debug methodically: reproduce, isolate and test a hypothesis
A failure is the behavior you see: a crash, a wrong total, or output that never appears. A bug is the mistake that caused it. Keeping those separate prevents you from changing the crash line before finding out how the program reached it.
Make the failure repeatable
Record the command, input, build flags, expected result, and actual result. Keep the failing input unchanged while you experiment. If a program fails only on one file, save that file before editing it. Reproduction gives you a way to judge whether a proposed fix works.
Next, shrink the case while preserving the failure. Remove unrelated records or code and rerun. Suppose one record among 1,000 independently triggers a crash. Trying halves can locate it in about 10 rounds. If neither half fails alone, the failure may require a sequence of records or state built up earlier. Preserve that sequence and remove smaller pieces around it. Halving is useful only when the retained input still exercises the cause.
Ask one testable question
An empty field in a minimized file suggests a hypothesis: "The parser uses the field before checking whether it exists." Test that with a small input containing an empty field. Inspect the returned pointer, or add one targeted check. Change one thing at a time; if you alter the parser, buffer size, and input together, success will not tell you which change mattered.
Fix the broken rule, rerun the minimized case, then rerun the original input and existing tests. Save the trigger as a regression test so a later edit cannot silently restore the same failure.
Interpret the stop
"Segmentation fault" usually reports SIGSEGV: the system stopped an invalid memory access. A null pointer, freed pointer, or out-of-range address is a common cause. An invalid access can also escape an immediate crash, so silence alone tells you little.
"Aborted" commonly reports SIGABRT, raised by abort(). A failed assertion or a library check detecting heap corruption can cause it. In bash, common signal exit statuses are 139 for SIGSEGV and 134 for SIGABRT; these numbers are shell conventions, not C return values your program must use.
Observe without losing output
A short diagnostic can expose the first wrong value:
fprintf(stderr, "index=%zu total=%d\n", index, total);
Labels make the output readable when the same line runs many times. stderr is useful for diagnostics because it is not fully buffered; stdout redirected to a file or pipe is normally fully buffered, so a crash can lose pending output. Remove temporary prints after the repair.
Explain a suspicious function line by line aloud. This "rubber duck" method forces each operation to match your intended behavior. If a bug changes when you add prints or change optimization, investigate undefined behavior with sanitizers. Making an array larger until a crash disappears can hide an invalid index without repairing it.
Key Takeaways
- Save an exact reproduction before changing code.
- Minimize input while preserving the failure.
- Test one hypothesis with one controlled change or observation.
- Trace a crash back to its cause, which may be earlier.
- Rerun the original case and retain a regression test.
2.2 Run, pause and step through a program in gdb or lldb
A debugger lets you pause a running program and inspect the values that explain its behavior. Use GDB on many Linux systems or LLDB on macOS. IDE debugger buttons use the same ideas: break, run, inspect, and step.
Start from the right executable
Compile with -g -O0, then open the result with gdb ./program or lldb ./program. At the prompt, run records.txt 3 starts it with two arguments. In GDB, run < input.txt redirects standard input; in LLDB, use process launch -i input.txt. Use quit to leave.
Set a breakpoint before running. break main in GDB and b main in LLDB stop at the start of main. Both accept a file-and-line location such as b program.c:12. Full LLDB commands include breakpoint set --name main and breakpoint set --file program.c --line 12.
A breakpoint normally stops before the indicated source statement executes. If the selected line assigns a value, print after stepping over it to inspect the new value. Rebuild after source edits: the debugger executes machine code, not the text open in your editor.
Choose how to advance
| Command | Purpose in GDB and LLDB |
|---|---|
next or n | Execute the current line, normally stepping over calls |
step or s | Enter a called function when source information allows |
finish | Run until the selected function returns |
continue or c | Resume until another stop or program exit |
Other breakpoints can interrupt next or finish. Use next for a library call you do not need to inspect. If you step into unwanted code, finish returns control to the caller.
Inspect a loop one pass at a time
Consider a total initialized to zero and this array:
int a[5] = {4, 8, 15, 16, 23};
for (int i = 0; i <= 5; ++i) {
sum += a[i];
}
Stop at the body and print i and sum before the addition. After the first five additions, the running totals are 4, 12, 27, 43, and 66. The next stop has i == 5, but valid indices end at 4. You have found the extra iteration without needing to read the invalid element. Change the condition to i < 5, rebuild, and rerun.
print expr or p expr evaluates an expression in either debugger. Start with safe observations such as p i and p sum; a debugger expression that dereferences an invalid pointer can itself attempt an invalid read. GDB's display i repeats the print at every stop. info locals in GDB and frame variable in LLDB show local variables. For five valid elements through ptr, use GDB's p *ptr@5 or LLDB's parray 5 ptr.
You can test a hypothesis by changing a paused value: GDB accepts set var i = 4, while LLDB accepts expr i = 4. This changes one run, not the source. To manage stops, use GDB's info breakpoints and delete 1, or LLDB's breakpoint list and breakpoint delete 1.
Key Takeaways
- Build and rebuild the executable with debug information.
- A stopped source statement normally has not executed yet.
- Use
next,step,finish, andcontinuefor different amounts of progress. - Inspect indices and bounds before evaluating memory expressions.
2.3 Diagnose crashes and unexpected values
A crash inside a library often begins with an invalid argument from your own code. The call stack shows the active function calls. Use it to move from the stop location to the caller whose values you can explain.
Read frames from the current call outward
Type bt in either debugger. Frame #0 is the innermost active call; higher frame numbers are its callers. A frame gives the function's execution context, including available arguments and local variables. Optimization may remove some of that information.
Suppose find_name(2) returns no match, and the caller does this:
const char *name = find_name(2);
printf("%zu\n", strlen(name));
The stop may be inside strlen. In bt, find the first frame belonging to your code. Select it with frame 1 in GDB or frame select 1 in LLDB when it is frame 1. Print name. A null value explains why the string operation failed. The caller must handle the missing name before calling strlen; editing the library cannot repair the argument.
Use up and down to move among callers and callees. Selecting a frame changes what the debugger displays and which local variables an expression refers to. It does not resume execution or undo a call.
Stop where a value changes
A breakpoint asks, "What happens at this location?" A watchpoint asks, "Which operation changes this value?" If a stored count becomes wrong long before a crash, pause while the count is in scope and use:
GDB: watch count
LLDB: watchpoint set variable count
Continue until the debugger stops on a change. Inspect the old and new values and the writing instruction's source location. A change may be legitimate, so compare it with the data structure's rule. Hardware offers only a small number of watchpoints; four is common on x86-64. A local variable's storage is meaningful only while its function is active.
For a known late iteration, use a conditional breakpoint. GDB's break 20 if i == 999 pauses at line 20 only when the condition holds. LLDB's equivalent is breakpoint set --line 20 --condition 'i == 999'. This saves hundreds of manual steps.
Inspect a saved crash
A core dump records a crashed process's state. Where core dumps are enabled, ulimit -c unlimited allows them for programs started from that shell. Open a saved dump with gdb ./program core or lldb ./program -c core, using the executable that produced it. System policies control whether a dump is saved and where it goes; systemd-based Linux systems may manage it through coredumpctl.
Run bt, select your frame, and inspect values as in a live crash. The dump cannot continue the program. If malloc or free detects corruption, the original bad write may be absent from this stack. Reproduce with AddressSanitizer to stop closer to that write. If a local value looks arbitrary, check whether execution has reached its initializer before blaming the debugger.
Key Takeaways
- Use
btto trace the active calls. - Select a frame in your code and inspect the arguments passed to the library.
- Watchpoints stop on value changes; conditional breakpoints stop at chosen states.
- A core dump supports inspection after a crash.
- The corruption may predate the stack visible at the final crash.
3Recognize and avoid undefined behavior
Some C operations have no result guaranteed by the language. They can appear to work in one build and fail in another, making ordinary trial and error misleading. Learn to separate undefined behavior from documented platform choices and permitted variation. Then inspect arithmetic, expression order, array bounds, and memory lifetimes, placing checks before an operation crosses one of those boundaries.
3.1 Understand what undefined behavior means
Undefined behavior, or UB, means the C standard imposes no requirements on what happens after an invalid operation. It does not promise an exception, a useful error message, or a predictable wrapped value. A program may crash, corrupt data, print a plausible answer, or appear to work.
Separate three kinds of behavior
| Kind | What the language permits | Example in C17 |
|---|---|---|
| Undefined | No required behavior | Signed integer overflow |
| Implementation-defined | A documented implementation choice | Whether plain char is signed |
| Unspecified | A permitted choice that need not be documented | Order of evaluating independent function arguments |
Implementation-defined behavior can be used deliberately when the documented platform is part of a program's design. For portable code, avoid assuming that all implementations make the same choice. Unspecified behavior is also valid: f(a(), b()) can evaluate either independent call first. Write code whose correctness survives that allowed variation. The width of int and, in C17, right-shifting a negative signed integer are further platform-dependent choices.
Why optimization exposes bugs
C leaves array-bound and signed-addition checks to the programmer. The compiler may optimize on the assumption that the program obeys the language's rules. An attempted overflow detector can violate the very rule it tries to test:
int will_overflow(int x) {
return x + 1 < x;
}
For every x where signed addition is defined, x + 1 is larger than x. The compiler can therefore replace the result with zero. At INT_MAX, performing the addition is UB, so the compiler owes the caller no wrapped result. Seeing a different answer in an unoptimized build does not make this a valid test.
The safe test runs before the operation:
if (x == INT_MAX) {
/* handle overflow */
} else {
++x;
}
Use <limits.h> for the implementation's limits. A hard-coded maximum assumes a particular type width; the macro names the actual limit.
Put checks before first use
This ordering is also essential for pointers:
if (p == NULL) {
return -1;
}
int v = *p;
Reversing those statements lets a null dereference occur before the protection. Once code dereferences p, an optimizer may reason that the defined path already requires p to be non-null and remove a later null test.
Changing flags, adding a print, or moving a variable can alter the visible symptom of UB. A successful run establishes only what that build did for that input. To repair the code, identify which operation lacks a defined meaning and which condition must hold before it. Warnings can identify some cases; static analysis can explore possible paths; ASan and UBSan check many executed operations. Coding rules provide the corresponding source-level patterns, such as checking an index or an arithmetic bound before using it.
Key Takeaways
- UB guarantees neither a crash nor a stable result.
- Implementation-defined choices must be documented.
- Unspecified choices remain within the language rules.
- Optimizers can rely on signed overflow never occurring in defined code.
- Validate a pointer or arithmetic bound before the risky operation.
3.2 Spot undefined behavior in arithmetic and expressions
Arithmetic can fail before you have a chance to inspect its result. Check the operands while their values are still valid, and know which type performs each operation.
Signed and unsigned limits
Signed overflow is undefined. With a 32-bit int, INT_MAX is 2,147,483,647; adding one is not a portable way to obtain a negative number. INT_MIN / -1, -INT_MIN, and oversized multiplication also fail when the mathematical result does not fit.
Unsigned arithmetic wraps within its type's range. For a 32-bit unsigned int, UINT_MAX + 1u is zero, and 0u - 1u is 4,294,967,295. This is defined but can still break a size calculation. An unsigned reverse loop using i >= 0 never reaches a negative stopping value.
For general signed addition, test before adding:
if ((b > 0 && a > INT_MAX - b) ||
(b < 0 && a < INT_MIN - b)) {
/* report overflow */
} else {
result = a + b;
}
The subtractions used in these tests stay in range because each is guarded by the sign of b. Computing the sum first and then comparing it is too late. A wider type helps only when it can hold every intermediate result: convert an operand before the operation. C23 provides ckd_add in <stdckdint.h>; GCC and Clang offer __builtin_add_overflow.
Division, shifts, and conversions
Reject zero before integer division or remainder. Also reject the signed minimum divided by negative one when its result cannot fit. IEEE 754 floating-point systems commonly produce infinity or NaN for division by zero, allowing invalid numerical results to spread without an integer-style trap.
For bit operations, use unsigned operands. The shift count must be nonnegative and less than the width of the promoted left operand. With 32-bit operands, shifting by 32 is undefined. In C17, left-shifting a negative signed value is undefined; 1 << 31 is also undefined for a 32-bit int, while 1u << 31 is valid for a 32-bit unsigned int.
A floating-to-integer conversion requires the truncated value to fit, even when the destination is unsigned. (unsigned)-1.0 is undefined. Integer-to-unsigned conversion instead reduces the value into the destination's range: (unsigned)-1 becomes UINT_MAX. Out-of-range integer-to-signed conversion is implementation-defined in C17. Check ranges before narrowing; -Wconversion helps expose implicit changes.
Make evaluation order harmless
printf("%d %d\n", i, ++i); reads and changes i without the required ordering. i = i++ + 1; and a[i] = i++; also contain unsequenced operations. Split the work into separate statements with an explicit intended order.
Initialize automatic variables before reading them: int sum = 0; makes sum += x; meaningful. Globals and static variables start at zero. C23's two's complement representation requirement does not define signed overflow; portable code still needs the checks.
Key Takeaways
- Check signed arithmetic before computing its result.
- Unsigned wrap is defined but can make sizes and loops wrong.
- Check division operands and shift counts.
- Floating-to-unsigned conversion differs from integer-to-unsigned conversion.
- Sequence changes explicitly and initialize values before use.
- C23 still leaves signed overflow undefined.
3.3 Spot undefined behavior with pointers, arrays and memory
A pointer needs more than a plausible address. The object must exist, the access must stay in its bounds, and the operation must be allowed for that object. These checks explain both immediate crashes and corruption discovered much later.
Stay inside the object
An array of n elements has indices from zero through n - 1. A pointer one past its last element can mark a loop end, but you must not dereference it. i <= n visits one element too many. CERT ARR30-C covers these bounds errors.
An out-of-bounds write may land in accessible memory belonging to another object. The program can continue until a damaged count, pointer, or allocator record is used. A later crash in free does not identify the original write. ASan can stop at the write when its checks detect the boundary violation.
Make characters into a valid string
A C string ends with '\0'. Its storage needs one byte beyond its character count. C accepts char s[5] = "hello";, but all five bytes hold letters, leaving no terminator. Passing it to strlen or %s can read beyond the array. Use enough space, and ensure a terminator is actually present. STR31-C addresses capacity; STR32-C addresses passing a terminated string.
A string literal is not writable. After char *p = "hi";, p[0] = 'H'; is undefined. Use char p[] = "hi"; for a writable copy, or const char *p to make attempts to change the literal visible to the compiler. This is STR30-C.
Respect lifetime and ownership
Check NULL before dereferencing a result from an allocation, file open, or search. When a block is freed, every pointer into it loses access to a live object. Do not read it, write it, or free it again. Free only the appropriate pointer returned by a dynamic allocation, never a local array or an interior pointer. free(NULL) is harmless, but setting one pointer to null after freeing does not repair other aliases.
A function that returns a local array's address returns a dangling pointer:
char *greeting(void) {
char text[16] = "Welcome";
return text;
}
The array's lifetime ends when the function returns. Give the caller a buffer and capacity to fill, or allocate a result whose ownership passes to the caller. A successful print immediately after return cannot extend the old array's lifetime.
Allocate bytes for every element
malloc(n) requests bytes, not n objects of the pointed-to type. The usual element-based expression is n * sizeof *a. First prevent that product from wrapping:
if (n > SIZE_MAX / sizeof *a) {
/* reject the requested size */
}
Include <stdint.h> for SIZE_MAX and <stdlib.h> for allocation functions. Once the size is valid, allocate, check the returned pointer, and write each value before reading it. malloc leaves memory uninitialized. calloc(n, sizeof *a) zeroes its storage and mainstream libraries reject overflowing products. A size check, an allocation-result check, and a valid index solve different problems; all three matter.
Key Takeaways
- Only dereference pointers within a live object’s bounds.
- Reserve and store the string terminator.
- Do not modify string literals.
- Freeing an allocation invalidates access through all its aliases.
- Return storage with a lifetime the caller can use.
- Check the element-count multiplication and allocation result.
4Catch errors at run time with assertions and sanitizers
You can make a test build check more than its final output. Assertions check the assumptions your own code promises to maintain, while sanitizers check operations such as memory access and arithmetic as they happen. Learn to read each report, choose a tool for the error you suspect, and run inputs that reach the risky paths. A useful checking routine connects a detected error to a fix and a lasting test.
4.1 State assumptions with assertions
An assertion states something that should be true because your own code is correct. It turns a hidden broken assumption into a stop near the place where that assumption matters.
Check a programming contract
Include <assert.h> and write assert(condition);. A true condition lets execution continue. A false condition prints diagnostic information to stderr and calls abort(). The message identifies the expression, source location, and function; its exact wording depends on the platform.
For an internal averaging helper whose caller promises valid arguments:
double average(const int *a, size_t n) {
assert(a != NULL);
assert(n > 0);
/* compute the average */
}
An accidental call with zero elements fails at the broken promise. Under a debugger, bt shows the assertion and abort machinery, followed by the calls in your code. Select the caller's frame to inspect how it supplied zero.
Contracts occur at several points:
| Kind | What it states | Example |
|---|---|---|
| Precondition | Required on entry | A nonempty input array |
| Postcondition | Guaranteed after work | An array is sorted |
| Invariant | Maintained across steps | A stored count matches the structure |
An assertion is executable documentation: it both names the property and checks it during a test run. Choose expressions that observe state without changing it.
Keep required work outside assertions
Defining NDEBUG before including <assert.h>, commonly with -DNDEBUG, disables assert. Its expression is not evaluated. This creates a serious bug if the expression performs needed work:
assert(++count <= MAX);
The increment disappears in a build with NDEBUG. Write the action separately:
++count;
assert(count <= MAX);
The same rule excludes opening files, allocating memory, or closing files inside an assertion. Those operations must occur in all configurations. Their failures need ordinary error handling.
Validate expected failures normally
A missing input file, invalid user text, and exhausted memory can occur during correct program use. Check them with if, report the error, and return or clean up. A private helper can assert a validated range, but the public input boundary must enforce that range with checks that remain in release builds.
For example, always test fclose(f) == EOF in ordinary control flow when closing an output file. Buffered output may fail during the close; removing the call or its check can hide lost data.
Check fixed facts at compile time
A static assertion tests a constant expression when compiling. In C17, <assert.h> provides static_assert as a macro for C11's _Static_assert:
static_assert(sizeof(int) == 4, "requires a 4-byte int");
A false condition fails the build. It cannot check a user-entered count known only at run time. C23 makes static_assert a keyword and permits omitting the message. Use this distinction to place each check where its required information is available.
Key Takeaways
- Assertions check internal programming promises.
- A failed run-time assertion reports its location and aborts.
- Keep side effects out of assertion expressions.
- Use ordinary error handling for invalid input and resource failures.
NDEBUGdisables run-time assertions.- Static assertions check constant expressions during compilation.
4.2 Find memory errors with AddressSanitizer
AddressSanitizer, usually called ASan, adds checks to compiled memory operations. It can report a bad access at the moment it happens, before corrupted data causes a distant crash.
Build with the checks linked in
Use GCC or Clang to compile and link with ASan:
clang -std=c17 -g -fsanitize=address -fno-omit-frame-pointer program.c -o program-asan
-g supplies source locations. Keeping frame pointers helps stack traces. For a multi-file project, instrument the relevant source files and use the sanitizer flag at the final link step too. MSVC provides /fsanitize=address on supported Windows builds.
ASan detects many overflows of heap blocks, stack arrays, and global arrays. It also detects use after free, double free, invalid free, and supported use-after-scope errors. On Linux, the usual ASan runtime includes leak checking at exit. Apple's bundled Clang on macOS does not supply that leak detection; LLVM toolchains and platform support differ.
Follow a report to the cause
Consider a successfully allocated array, with 4-byte int:
int *a = malloc(10 * sizeof *a);
for (int i = 0; i <= 10; ++i) {
a[i] = i;
}
The allocation contains 40 bytes. Indices zero through nine use those bytes. Index ten starts immediately beyond them. ASan can report "heap-buffer-overflow", "WRITE of size 4", and a location zero bytes after a 40-byte region. The write trace identifies the assignment; the allocation trace identifies the malloc that established the block's bounds.
Read reports in this order:
- Identify the error category and whether the access was a read or write.
- Find the first frame in your own code in the access trace.
- Compare the access with the allocated region and its element size.
- For a lifetime error, also find the allocation and free traces.
Here the repair is i < 10. Allocating another element while retaining the wrong stopping rule hides the mismatch between the intended count and loop. ASan stops at the first detected error by default. Fix it, rebuild, and run again.
Understand the checker's boundaries
ASan tracks permitted memory using shadow memory. Forbidden redzones surround supported allocations and arrays. Freed blocks spend time in a quarantine before reuse, helping expose stale accesses. A write past an array member into another member of the same struct may stay inside a larger valid region and escape detection.
ASan does not check ordinary uninitialized-value reads. Valgrind's Memcheck can find those uses and many heap defects without recompiling, but commonly runs about 20 to 30 times slower. ASan's typical slowdown is around two times, with additional memory use. Do not combine an ASan build with Valgrind, MemorySanitizer, or ThreadSanitizer. Run edge inputs deliberately: an error on an unexecuted path cannot appear in the report.
Key Takeaways
- Compile and link with
-fsanitize=addressand debug information. - Read the access trace and the allocation or free traces together.
- Repair the first reported cause, rebuild, and rerun.
- ASan does not detect ordinary uninitialized-value reads.
- Exercise edge inputs to reach risky memory accesses.
4.3 Detect undefined behavior with UBSan and build a checking routine
A memory checker does not cover every invalid C operation. UndefinedBehaviorSanitizer, or UBSan, instruments many arithmetic, pointer, and array operations. Pair it with ASan so a test run can detect more than one class of defect.
Make arithmetic failures visible
With 32-bit int, adding two values of 2,000,000,000 needs a total of 4,000,000,000. That result exceeds INT_MAX. A UBSan report identifies signed integer overflow at the addition and names the operands. Printing a wrapped-looking negative answer without the checker would not make the operation valid.
Use a total type that can represent the accepted range, or reject an addition before it overflows. Converting the already-overflowed result to long long is too late; the operation itself must use a suitable type.
A common test command is:
clang -std=c17 -Wall -Wextra -g -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover=undefined program.c -o program-check
UBSan normally allows recovery after many reports, so later code may continue. -fno-sanitize-recover=undefined makes those failures stop the test with an error status. That helps automated checks detect a bug even when the final printed output looks right.
Match the tool to the defect
| Tool | Main purpose | Important limit |
|---|---|---|
| UBSan | Many undefined arithmetic, shift, null, alignment, and array operations | Does not detect every kind of UB |
| ASan | Many bounds and lifetime errors | Does not track ordinary uninitialized values |
| MemorySanitizer | Uses of uninitialized memory | Needs instrumented dependencies for reliable results |
| ThreadSanitizer | Data races | Requires a separate build from ASan |
Unsigned wrap is defined, so the undefined group does not report it. Clang has a separate -fsanitize=unsigned-integer-overflow check for code where wrap signals a bug. Do not infer that every unwanted integer result is UB.
MemorySanitizer is a Clang tool used mainly on Linux; uninstrumented libraries can confuse its tracking. ThreadSanitizer detects conflicting accesses by threads without synchronization, with at least one write. The accesses need not occur at the exact same instant. Neither tool combines with ASan in a single build.
Make checking routine
Keep warnings enabled and resolve them. Build with ASan and UBSan, then run empty inputs, one-element inputs, full buffers, invalid text, and values near the accepted limits. Run static analysis to inspect paths the tests may miss, and repeat the checks automatically when code changes. Also run the release configuration, where optimization and assertion settings differ.
Fuzzing supplies many generated or mutated inputs. libFuzzer and AFL++ can expose unusual parser paths; sanitizers turn a triggered invalid operation into a useful report. Save an input that finds a defect, minimize it without losing the failure, repair the cause, and keep it as a regression test. A clean run is evidence about the executed inputs, not a guarantee for all possible runs.
Key Takeaways
- Use UBSan for many arithmetic and expression errors.
- Make UBSan failures stop automated tests when recovery would hide them.
- Unsigned wrap needs a separate check when it is unwanted.
- Use separate builds for MemorySanitizer or ThreadSanitizer.
- Combine edge tests, fuzzing, static analysis, and regression inputs.
- Sanitizer coverage depends on execution.
5Write safer C code
Safer C begins where data enters a program and continues through every size calculation, copy, allocation, and error path. A coding standard turns recurring defects into concrete checks you can apply while writing code. Learn to preserve buffer sizes, validate complete numeric inputs, and keep resources under clear ownership. Then weigh these responsibilities against C's control over memory and the protection offered by memory-safe languages.
5.1 Use a secure coding standard
A secure coding standard collects concrete rules that prevent recurring defects. It gives a reviewer more useful questions than "Does this look safe?" Each rule connects a risky operation with the condition or design needed to make it safe.
Read a rule for its reason
The SEI CERT C Coding Standard comes from Carnegie Mellon University's Software Engineering Institute. The 2016 book edition is accompanied by an evolving online version. It distinguishes rules, whose violation is likely to cause defects, from recommendations that improve reliability and security.
Identifiers combine a category, number, and -C. Rules use numbers 30–99; recommendations use 00–29. For example, INT32-C concerns signed overflow, while DCL00-C is a declaration recommendation. Categories include declarations (DCL), expressions (EXP), integers (INT), floating point (FLP), arrays (ARR), strings (STR), memory (MEM), I/O (FIO), environment (ENV), errors (ERR), and miscellaneous concerns (MSC).
A rule page normally explains the defect, shows noncompliant code, presents compliant solutions, and rates risk. Read the assumptions of each solution. A replacement function is useful only when its behavior actually meets the requirement.
Apply a rule to a data path
FIO30-C concerns format strings. This code treats outside data as a formatting program:
printf(user_name);
A user name containing format conversions makes printf look for arguments that were not supplied; %n can cause a memory write. Keep the format under program control:
printf("%s", user_name);
Alternatively, fputs(user_name, stdout) prints the text without format processing. -Wformat=2 includes checks that help find unsafe formats. The repair changes how data is interpreted, not simply how long the string can be.
Build a useful checklist
For memory, ask whether indices stay inside objects (ARR30-C), string storage includes the terminator (STR31-C), freed storage is never accessed (MEM30-C), owned allocations are released (MEM31-C), only valid allocations are freed (MEM34-C), and enough bytes are allocated (MEM35-C).
For expressions, check unintended unsigned wrap (INT30-C), signed overflow (INT32-C), invalid division (INT33-C), uninitialized reads (EXP33-C), and null dereferences (EXP34-C). At library boundaries, handle reported failures (ERR33-C) and conversion errors (ERR34-C). Avoid system() as directed by ENV33-C because a shell interprets its input.
Risk tables rate severity, likelihood, and remediation cost from 1 to 3; a higher cost score means cheaper remediation. Their product is the priority, from 1 to 27. Levels group priorities into L1 (12–27), L2 (6–9), and L3 (1–4). This helps order repairs without declaring lower-priority defects harmless.
Use rules during code review and use analyzer findings mapped to their identifiers. MISRA C is another standard used in safety-critical embedded work. Rules help prevent defects, while warnings, tests, and sanitizers check whether the implementation actually follows them. Memorizing identifiers alone cannot detect a missing terminator or a skipped error branch.
Key Takeaways
- Read the failure mechanism and the compliant solution together.
- CERT identifiers distinguish categories, rules, and recommendations.
- Keep outside data out of format strings.
- Apply concrete checks to bounds, lifetime, arithmetic, and errors.
- Use risk ratings to order work, then verify the code with tools.
5.2 Handle strings and buffers safely
Safe string handling requires a valid source, a known destination capacity, and a decision about text that will not fit. A size-limited function prevents some overwrites, but you must still check whether the whole operation succeeded.
Carry capacity with the pointer
sizeof measures the type of its operand. For a local char buf[20], it gives 20 bytes. In void fill(char buf[]), the parameter is adjusted to a pointer; sizeof buf measures that pointer, not the caller's array. Pass a capacity parameter, such as fill(buf, sizeof buf), while the caller still has the array type.
A string containing five characters needs six bytes including '\0'. The capacity counts every writable byte; the string length excludes the terminator. This is why malloc(strlen(src)) allocates one byte too few for a copy.
Read a bounded line
Use fgets(buf, sizeof buf, stdin) and check its return value before using the contents. It stores at most capacity minus one characters, then a terminator. It retains a newline when that newline fits. After a successful read, buf[strcspn(buf, "\n")] = '\0'; removes a stored newline.
If a long line fills the buffer, unread characters remain for the next call. Decide whether to reject and drain the rest, read more, or accept truncation. Removing a newline does not establish that you read the complete line. gets cannot take a capacity and was removed in C11; never use it.
For a single word in char name[20], %19s in scanf limits stored characters and leaves room for the terminator. Check that the expected number of conversions succeeded. For untrusted numeric text, prefer a complete line followed by checked strtol; %d has undefined behavior when the converted value is unrepresentable, even if you inspect scanf's return count.
Detect a shortened copy
strcpy and strcat do not know the destination capacity. strncpy may leave the result unterminated. With a positive capacity, snprintf terminates the stored text and reports how many characters the complete output required:
char dst[8];
int r = snprintf(dst, sizeof dst, "%s", "debugging");
if (r < 0) {
/* report encoding failure */
} else if ((size_t)r >= sizeof dst) {
/* report truncation */
}
The stored string is "debuggi", and r is 9. The full output needs ten bytes including its terminator. Check negativity before converting the result to size_t. A fitting result requires r to be less than the capacity.
Match the operation to the storage
Use memmove when source and destination overlap; overlapping memcpy is undefined. strlcpy is available on BSD systems, macOS, and glibc 2.38 onward, but is not ISO C. Annex K functions are optional and unavailable in many libraries.
Keep user data out of the format itself. Match each conversion to its argument: %d for int, %ld for long, %zu for size_t, %f for double, and %s for a terminated string. %p requires a void *, so pass an appropriate pointer conversion. A width limit cannot repair a mismatched argument type.
Key Takeaways
- Pass destination capacity alongside a pointer.
- Reserve one byte for the string terminator.
- Handle incomplete input lines explicitly.
- Check
snprintffor failure and truncation. - Use
memmovefor overlapping ranges. - Match every format conversion to its argument type.
5.3 Validate input and handle errors
Outside data can be syntactically valid and still unsafe for the operation you intend. Validate its length, complete format, and useful range before it becomes an index, allocation size, or loop count.
Parse the whole number
atoi gives no reliable way to separate a valid zero from invalid text, and an unrepresentable result has undefined behavior. strtol provides an end pointer and a range error. After reading a complete line and removing its newline, a strict decimal parser can use:
errno = 0;
char *end;
long v = strtol(text, &end, 10);
if (end == text || *end != '\0' || errno == ERANGE) {
/* reject the numeric text */
} else if (v < 0 || v > 150) {
/* reject this application's range */
} else {
int age = (int)v;
/* use age */
}
Include <errno.h> and <stdlib.h>. The first test separates three failures: no digits, trailing text, and a value outside long. The application-range test comes before narrowing to int. strtol accepts leading spaces and an optional sign; decide whether the application's grammar permits them.
For "42abc", conversion can return 42, but end points to the remaining letters. For "abc", no digits are consumed. For an oversized decimal integer, errno becomes ERANGE. A nonzero returned number alone establishes neither complete conversion nor a safe range.
Preserve ownership across failure
Every operation that can fail needs a response. Check allocation results, fopen, fgets, conversion counts, and output completion. fgets returning NULL can mean end of input or a read error. fclose can report a buffered write failure. Use error information appropriate to the function; many library calls set errno, which perror or strerror can turn into a readable diagnostic.
For positive resizing, protect the size calculation and the original allocation:
if (new_n == 0) {
free(a);
a = NULL;
} else if (new_n > SIZE_MAX / sizeof *a) {
/* reject the size */
} else {
int *tmp = realloc(a, new_n * sizeof *a);
if (tmp == NULL) {
/* handle failure; a still owns its old block */
} else {
a = tmp;
}
}
Assigning realloc directly to the only owning pointer loses the old block's address on failure. Handle zero separately: C17 makes zero-size realloc implementation-defined, while C23 makes it undefined. After successful resizing, use the returned pointer and discard stale aliases.
Leave a clear error path
A single cleanup section can release acquired resources in reverse order. Initialize owned pointers to NULL, close only opened files, and return a failure status after reporting the cause. A goto cleanup can make these paths easier to audit.
Avoid system() for tasks involving outside text: the shell can interpret that text as commands. Prefer a library operation, or a suitable program-launch interface with arguments passed separately. Treat command-line arguments, environment values, file contents, and network data as input requiring the same care as typed text.
Key Takeaways
- Validate complete conversion and application range before using a number.
- Reset
errnobeforestrtoland inspect its end pointer. - Check library failures and preserve resources on every path.
- Use a temporary pointer for positive-size
realloc. - Handle zero sizes and multiplication overflow before resizing.
- Release only resources actually acquired.
- Keep outside text out of shell command strings.
5.4 Weigh memory-safety trade-offs
Memory safety means accesses stay within storage a program is entitled to use. C gives direct memory control and leaves boundary and lifetime checks to the programmer.
Distinguish bounds from time
Spatial safety keeps an access inside its object's bounds. Writing element ten of a ten-element array violates it. Temporal safety keeps access inside the object's lifetime. Reading an allocation after free, freeing it twice, or using a returned local address violates lifetime rules.
The consequences can extend beyond a crash. A read can expose confidential data. A write can corrupt a file, change control data, or create a security vulnerability when outside input controls the operation. A program that continues after corruption may produce convincing but false results.
This burden affects experienced teams too. Microsoft reported in 2019 that about 70% of the vulnerabilities it had assigned CVE identifiers to, year after year over more than a decade, concerned memory safety. In December 2023, CISA and partner agencies urged software makers to plan moves toward memory-safe languages. These are specific historical observations, not a measured percentage for every C program.
Understand the reasons to choose C
C offers control over layout, allocation, and access to hardware, without requiring a garbage collector. Its small runtime suits constrained firmware. It has broad compiler support, large existing codebases, and interfaces that many other languages can call. These benefits explain its use in operating-system kernels, embedded software, runtimes, and libraries.
They do not make every C program predictably fast: algorithms, allocation, caches, and system calls still matter. The relevant benefit is control over costs and runtime requirements.
| Approach | Memory protection | Cost or responsibility |
|---|---|---|
| C | Programmer-enforced bounds and lifetimes | Manual reasoning, checks, and testing |
| Safe Rust | Ownership, borrowing, and bounds checks | Learning its ownership rules |
| Go, Java, C#, Python | Managed lifetimes and bounds checks | Runtime and memory overhead vary |
| Swift | Reference counting and checked access | Ownership and runtime costs vary |
Unsafe operations and interfaces to C need special care in otherwise memory-safe systems. Memory safety also leaves logic errors and input-driven injection to solve.
Choose layers for the remaining risk
Prevent defects with coding rules, warnings, and review. Detect defects with static analysis, edge tests, sanitizers, and fuzzing. Limit remaining damage with stack protectors, supported _FORTIFY_SOURCE checks, address space layout randomization (ASLR), and non-executable memory. A mitigation can stop an attack or turn corruption into a crash while leaving the source defect present.
Costs guide deployment. ASan typically doubles execution time and increases memory use; Valgrind commonly slows execution by 20 to 30 times. They fit testing better than ordinary release use. Lower-cost hardening commonly remains in release builds.
For a new internet-file parser with no constraint requiring C, a memory-safe language reduces a major risk. For existing C firmware on a microcontroller with 32 KB of memory, language support and resource limits may favor C with strong checks. Base the choice on constraints, input exposure, and maintainability; a gradual replacement of a risky parser can also be practical.
Key Takeaways
- Spatial safety concerns bounds; temporal safety concerns lifetimes.
- Memory defects can expose data and corrupt results.
- C offers direct control and broad compatibility with a manual safety burden.
- Memory-safe languages remove many memory defects but leave other bug classes.
- Combine prevention, detection, and release mitigations.
- Choose a language using actual resource and integration constraints.
Debugging and Undefined Behavior in C examples
1Build a program you can inspect
9 steps
- 1IntroductionStart before the first run
A program can compile and still contain a defect the compiler knows how to flag. Your first job is to ask for that feedback, as warnings, and interpret it.
You will inspect an accidental assignment and a format mismatch, then choose settings for a debugger session (a debugger build) and a release build. These decisions prepare later tools to show useful evidence instead of unexplained addresses or missing local values.
- 2ExplanationChoose the warning set
Start with
-std=c17 -Wall -Wextra -Wpedantic. The first flag fixes the language version; the others enable common warnings, additional warnings, and diagnostics for many departures from that standard.-Walldoes not mean every warning. Add-Wconversionfor value-changing implicit conversions and-Wformat=2for stricter format checks.-Werrorchanges enabled warnings into build errors. - 3ExampleRead the condition
The programmer wants to report an empty collection:
if (items = 0) { puts("No items"); }A compiler warning points to assignment in a condition. First ask what the expression does: it stores zero in
items, then the condition tests that zero. The body never runs. Replace=with==to test the existing count; adding parentheses around the assignment would preserve the wrong operation. - 4ChallengeRepair the output type
double ratio = 2.5;is printed withprintf("%d\n", ratio);. What should change, and why?---reveal---
Use
printf("%f\n", ratio);.%dexpects anint, while%fexpects adoubleinprintf. A mismatched format has undefined behavior. Casting tointwould change the number being printed by dropping its fraction, so it would not meet the goal of printing the ratio. - 5TipRead messages in order
A diagnostic usually gives the source location, the problem, and the flag responsible. Read related notes too; they may identify the conflicting declaration.
Fix the first compiler error, then rebuild. A missing semicolon can confuse later parsing and create a cascade of messages. After errors are gone, inspect each warning's cause. A warning-free build still needs tests for logic and input-dependent errors.
- 6ExplanationPreserve useful state
For a simple debugger build, add
-g -O0. Debug information maps execution to source names and lines; low optimization keeps more intermediate state available for inspection.-gdoes not disable optimization. With-O2, code can still be debugged, but lines may appear to jump and variables can be optimized away.-Ogis another development choice that keeps light optimization. - 7ConnectionChoose checks for different jobs
Static analysis follows possible source paths without running the program. A sanitizer checks operations on paths the tests actually execute. Use both to extend coverage.
Release hardening serves a further purpose: stack protectors and supported fortification checks can detect or limit some remaining corruption. Fortification requires optimization and suitable library support. A detected overwrite still needs a source repair.
- 8ChallengeKeep validation in release
A debug build uses strict warnings and assertions. A release build adds
-O2 -DNDEBUG. The programmer proposes usingassert(valid_input)as the only input check. What must change before release?---reveal---
Validate input with ordinary control flow and handle failure.
NDEBUGremoves evaluation of assertions, so the proposed check vanishes. Keep warnings enabled and test the release configuration as well as the debug build; different settings can expose hidden defects. - 9RecapConnect the build settings
Use compiler messages to identify operations needing repair. Match formats to types, investigate conversions, and fix early errors before following their downstream messages.
For the next debugging session, build the right executable with source information and a suitable optimization level. Keep configurations repeatable, add analysis and sanitizer testing, and retain input checks when release settings remove assertions.
2Trace a wrong total and a crash
12 steps
Save this lesson to follow every Walkthrough step by step.
- 1IntroductionTurn a symptom into a question
- 2ExplanationPreserve the failing case
- 3ChallengeChoose a controlled experiment
- 4ExamplePause at the loop body
- 5ExampleFollow the valid passes
- 6ChallengeFix and verify the extra pass
- 7TipStep only into useful code
- 8ExampleMove to the caller
- 9ExplanationCatch a change when it happens
- 10ChallengeInvestigate an allocator crash
- 11ConnectionUse a saved crash state
- 12RecapChoose the view that answers the question
3Check the operation before it becomes invalid
12 steps
Save this lesson to follow every Walkthrough step by step.
- 1IntroductionAsk what C guarantees
- 2ExplanationDistinguish variation from a defect
- 3ExampleAn overflow test that arrives too late
- 4ChallengePlace the pointer check
- 5ExampleGuard a signed sum
- 6Key InsightDefined wrap can still break the program
- 7ChallengeWiden before multiplying
- 8TipCheck several expression boundaries
- 9ExampleSeparate an end marker from an element
- 10ExampleFollow every alias through release
- 11ChallengeRepair a total and its lifetime
- 12RecapCheck type, range, bounds, and lifetime
4Read checks as evidence of a specific defect
11 steps
Save this lesson to follow every Walkthrough step by step.
- 1IntroductionCheck more than final output
- 2ExplanationAssert an internal promise
- 3ChallengeKeep the operation in every build
- 4ExplanationBuild an instrumented executable
- 5ExampleRead the reported region
- 6ChallengeRepair the matching loop
- 7Key InsightA missing report has several explanations
- 8ExampleMake arithmetic failure stop the test
- 9ExplanationChoose other checkers deliberately
- 10ChallengeImprove an incomplete checking routine
- 11RecapTurn reports into repeatable checks
5Keep input, sizes, and ownership explicit
15 steps
Save this lesson to follow every Walkthrough step by step.
- 1IntroductionFollow data through the program
- 2ExplanationUse the rule as a question
- 3ChallengeKeep input as data
- 4ExamplePass capacity from the caller
- 5ExampleDistinguish a safe prefix from success
- 6ChallengeHandle an incomplete line
- 7ExampleInspect the end of conversion
- 8ExampleApply the program’s range
- 9ExamplePreserve a resize owner
- 10ChallengePlan the failure path
- 11ExplanationIdentify the safety boundary
- 12ConnectionChoose layers with different jobs
- 13ChallengeMake a language decision
- 14ChallengeReview the complete input path
- 15RecapKeep each boundary visible
Debugging and Undefined Behavior in C practice questions
Set 1: Compiler clues and debugger decisions10 Practice Questions
Question 1 of 10
-Wall build silently converts a double count to int. What extra warning flag targets this implicit value change?Show answerHide answer
-Wconversion targets implicit conversions that may alter a value, including dropping a floating-point fraction. -Wshadow concerns a hidden declaration with the same name, not a change of type. A warning should lead to checking the intended value before adding a cast.Question 2 of 10
double reading = 7.25; and calls printf("%d\n", reading);. What repair preserves that goal?Show answerHide answer
printf expects a double for %f, so changing the format preserves the fractional value and matches the argument. Casting to int could satisfy %d but would discard the fraction the program must print. The original mismatch has undefined behavior; the format is not an automatic numeric conversion.Question 3 of 10
Show answerHide answer
-g adds the debug information that maps execution to source locations, types, and names. -Wextra enables additional diagnostics during compilation but does not provide those mappings. Optimization remains a separate choice and may still remove values even when debug information is present.Question 4 of 10
Show answerHide answer
Question 5 of 10
Show answerHide answer
next normally executes the current source line while stepping over its function calls. step is intended to enter a called function when useful source information exists. A breakpoint inside the call can still interrupt next, so the command describes stepping behavior rather than disabling all stops.Question 6 of 10
result = compute();. Printing result shows its earlier value, and no call has yet been executed at this stop. How should you inspect the computed result?Show answerHide answer
result still contains its earlier value. Running the line with next allows the call and assignment to complete before inspection. Repeating a print without resuming observes the same state and supplies no evidence that the assignment failed.Question 7 of 10
strlen and frame two in your render function. You want to inspect render’s caption pointer in GDB. What should you do?Show answerHide answer
frame 2 selects render, where caption can be inspected. Staying in frame zero examines the library context, and resuming execution is unnecessary when the evidence you need is already in a caller’s frame.Question 8 of 10
Show answerHide answer
Question 9 of 10
-O0 and -O2. At -O2, an intermediate variable is also unavailable in the debugger. What investigation addresses both observations?Show answerHide answer
Question 10 of 10
free after reading many records. Enlarging a name buffer stops the visible crash, but the saved input still causes ASan to report a write past that buffer. What action addresses the cause?Show answerHide answer
Set 2: Types, ranges, and live objects10 Practice Questions
Save this lesson to see every answer and practice every set.
Question 1 of 10
merge(left(), right()). Either evaluation order is permitted and no shared state creates a conflict. How is that order classified in C?Show answerHide answer
Save this lesson to see this answer.
Question 2 of 10
unsigned int. What value does unsigned int slots = 0u - 1u; store?Show answerHide answer
Save this lesson to see this answer.
Question 3 of 10
unsigned int. An input supplies shift = 32. What is the status of 1u << shift?Show answerHide answer
Save this lesson to see this answer.
Question 4 of 10
Show answerHide answer
Save this lesson to see this answer.
Question 5 of 10
local after declaring char local[24] = "ready";. Why is reading the result in the caller invalid?Show answerHide answer
Save this lesson to see this answer.
Question 6 of 10
int a and positive int b, what condition detects an overflowing a + b without evaluating that sum?Show answerHide answer
Save this lesson to see this answer.
Question 7 of 10
int old = *p; before if (p == NULL) return -1;. A null-input test seems to reach the error return at low optimization but crashes in release. What repair establishes a defined null-input path?Show answerHide answer
Save this lesson to see this answer.
Question 8 of 10
int and 64-bit long long, both factors are int: units = 70000, rate = 40000. What expression correctly computes their product in long long?Show answerHide answer
Save this lesson to see this answer.
Question 9 of 10
p; alias points into it. A positive-size realloc succeeds and the block moves. The function assigns the result to p, writes through alias, then frees both p and alias. What ownership repair is required?Show answerHide answer
Save this lesson to see this answer.
Question 10 of 10
-fsanitize=undefined. A size_t multiplication wraps but produces no report. Why can this happen?Show answerHide answer
Save this lesson to see this answer.
Set 3: Assertions and sanitizer reports10 Practice Questions
Save this lesson to see every answer and practice every set.
Question 1 of 10
Show answerHide answer
Save this lesson to see this answer.
Question 2 of 10
assert(user_count > 0); as its only rejection of invalid user counts. Release builds define NDEBUG. What should replace that protection?Show answerHide answer
Save this lesson to see this answer.
Question 3 of 10
Show answerHide answer
Save this lesson to see this answer.
Question 4 of 10
Show answerHide answer
Save this lesson to see this answer.
Question 5 of 10
Show answerHide answer
Save this lesson to see this answer.
Question 6 of 10
-fsanitize=undefined both stay silent, but the neighboring value and allocation size are wrong. What investigation addresses both defects?Show answerHide answer
Save this lesson to see this answer.
Question 7 of 10
Show answerHide answer
Save this lesson to see this answer.
Question 8 of 10
Show answerHide answer
Save this lesson to see this answer.
Question 9 of 10
assert(++used < capacity); before storing at index used. Tests pass, but with NDEBUG it repeatedly overwrites one slot. What source change preserves the intended state update in both builds?Show answerHide answer
Save this lesson to see this answer.
Question 10 of 10
size_t, allocates a count-dependent buffer, and fills it. It checks neither conversion completion nor application range. What repair order addresses the input-to-memory path?Show answerHide answer
Save this lesson to see this answer.
Set 4: Strings, numeric input, and resource ownership10 Practice Questions
Save this lesson to see every answer and practice every set.
Question 1 of 10
Show answerHide answer
Save this lesson to see this answer.
Question 2 of 10
char line[48] to read_line(char line[]) on a system with eight-byte pointers. What does sizeof line measure inside the function?Show answerHide answer
Save this lesson to see this answer.
Question 3 of 10
snprintf is called with a destination capacity of 11 and returns 11. What does this establish?Show answerHide answer
Save this lesson to see this answer.
Question 4 of 10
errno and converts "64kg" with strtol. Conversion returns 64 and errno stays zero. What must cause rejection?Show answerHide answer
Save this lesson to see this answer.
Question 5 of 10
strtol, checks only errno, casts to size_t, and multiplies by the element size for allocation. What checks must precede allocation?Show answerHide answer
Save this lesson to see this answer.
Question 6 of 10
a. After a positive input count is read into new_n, the code does a = realloc(a, new_n * sizeof *a); a[new_n - 1] = 0;. Every valid count must be handled, and a failed resize must preserve the old array. What sequence is required?Show answerHide answer
Save this lesson to see this answer.
Question 7 of 10
fgets, with no newline stored. The application requires one complete record per line. What handling avoids treating a possible remainder as a new record?Show answerHide answer
Save this lesson to see this answer.
Question 8 of 10
strncpy(dst, src, sizeof dst) and later prints dst with %s. Assume eight-byte pointers. What redesign meets the requirement?Show answerHide answer
Save this lesson to see this answer.
Question 9 of 10
size_t count from a file and immediately does a = realloc(a, count * sizeof *a);. Empty arrays are permitted, and a failed request must preserve the old array. What redesign handles these requirements for every count?Show answerHide answer
Save this lesson to see this answer.
Question 10 of 10
Show answerHide answer
Save this lesson to see this answer.
Set 5: Repair interacting defects10 Practice Questions
Save this lesson to see every answer and practice every set.
Question 1 of 10
Show answerHide answer
Save this lesson to see this answer.
Question 2 of 10
Show answerHide answer
Save this lesson to see this answer.
Question 3 of 10
Show answerHide answer
Save this lesson to see this answer.
Question 4 of 10
int i = 4;, a programmer writes printf("%d %d\n", i, ++i);. What repair makes an intended "old value, then new value" output well-defined?Show answerHide answer
Save this lesson to see this answer.
Question 5 of 10
word, computes int bytes = strlen(word);, allocates that many bytes, copies the complete string, frees the new block, and returns its address. Inputs may be too long for int. What design addresses the size and lifetime path?Show answerHide answer
Save this lesson to see this answer.
Question 6 of 10
strtol, and checks only errno != ERANGE. It then casts the result to int for an array index. What missing checks can still admit invalid input?Show answerHide answer
Save this lesson to see this answer.
Question 7 of 10
Show answerHide answer
Save this lesson to see this answer.
Question 8 of 10
Show answerHide answer
Save this lesson to see this answer.
Question 9 of 10
Show answerHide answer
Save this lesson to see this answer.
Question 10 of 10
Show answerHide answer
Save this lesson to see this answer.
Stuck on your own question? Homework Help
Key Terms
A
AddressSanitizer
ASLR
Assertion
B
Backtrace
bt helps you locate the first source frame belonging to your own code.Breakpoint
sum += a[i] lets you inspect i without evaluating the array access.Buffer capacity
Bug
C
Call stack
main.Checked arithmetic
ckd_add reports whether a sum fits so the program can handle failure explicitly.Cleanup path
Compiler diagnostic
%d and identify the double argument that does not match it.Compiler error
Compiler warning
Compliant solution
%s format keeps supplied text in the data argument of a printf call.Conditional breakpoint
i == 650 avoids manually stepping through earlier loop iterations.Core dump
D
Dangling pointer
Data race
Debug build
-g -O0 build makes a faulty loop easier to follow one source statement at a time.Debug information
-g flag helps a debugger identify the source line and locals at an ordinary breakpoint.Debugger
Defense in depth
Double free
E
End pointer
strtol identifies the first unconverted character, or the input start if no digits were consumed.Expression evaluation
count observes a scalar; evaluating *p attempts an access through that pointer.F
Failure
free can be the visible failure caused by a write much earlier in execution.Format string
printf format uses %zu for size_t; user text belongs in a separate data argument.Fuzzing
H
Hardening
Hypothesis
I
Implementation-defined behavior
char type is signed or unsigned.Implicit conversion
double to an int drops the fractional part when the truncated value fits.Indeterminate value
int sum; and immediately using sum += x; reads a value that was never initialized.Instrumentation
Integer promotion
int or unsigned int before many arithmetic and shift operations.Invariant
M
Memory-safe language
MemorySanitizer
Minimal reproducer
N
Noncompliant code
printf(input) lets outside data supply format conversions and violates FIO30-C.Null terminator
O
Object lifetime
Optimization
-O2, a debugger may show no separate value for a local the compiler removed.Out-of-bounds access
Overlapping ranges
memmove because memcpy forbids overlap.Ownership
realloc fails.P
Portability
INT_MAX avoids assuming every implementation gives int the same maximum value.Postcondition
Precondition
Q
Quarantine
R
Range error
errno, a strtol range error sets ERANGE even though a limit value is returned.Redzone
Regression test
Release build
NDEBUG removes its assertions.Risk priority
S
Secure coding standard
SEI CERT C Coding Standard
Shadow memory
Shell injection
system() string from an unchecked argument lets a shell interpret supplied syntax.Signed integer overflow
int, adding one to INT_MAX cannot produce a portable, defined next value.Spatial memory safety
Stack frame
name pointer, selecting the caller's frame after a crash makes that pointer available for inspection.Static analysis
Static assertion
static_assert can reject a platform where a required int size assumption is false.Stepping
next to run over a call and step when you need to enter the function's own code.String length
strlen("safe") is four, while storing the complete string requires five bytes.T
Temporal memory safety
free violates lifetime safety even when its address still looks familiar.ThreadSanitizer
Truncation
snprintf return at least as large as capacity signals that the full text did not fit.U
Undefined behavior
UndefinedBehaviorSanitizer
Unsequenced side effects
i and ++i to one call mixes a read and a change without a safe ordering.Unsigned wrap
Unspecified behavior
Untrusted input
Use after free
V
Variable shadowing
count may receive updates while the outer count remains unchanged.W
Watchpoint
About this lesson
Make your own Study Materials
Turn your notes, slides and documents into Study Guides, Practice Questions, Flashcards and 6 more tools.