Lesson 12 / 15
Undefined Behavior
Reading a dangling pointer, using freed memory, freeing a block twice, and accessing an array out of bounds are all undefined behavior; none of them run — the lesson describes each one in prose and runs its defined counterpart alongside it.
Published Updated
In this lesson
- Recognizes the four sources of undefined behavior (dangling pointer, freed memory, double free, out-of-bounds access) as contract violations
- Explains why undefined behavior exists through the compiler's right to make assumptions
- Writes and runs a defined counterpart in place of each undefined source, verifying it by running it
Contents
The previous lesson gathered four regions on a single axis: the stack, static storage, the string literal, and the heap — the common point was that who decides its lifetime is written in the source. This lesson measures the moment that contract breaks. The contract used to answer the question “who will free this block”; when the contract breaks, what appears is not an error message but undefined behavior — and the most important property of undefined behavior is that it cannot be measured.
This last sentence is not a warning, it is a definition that draws the lesson’s boundary. The four sections below describe four separate sources of undefined behavior — a dangling pointer, freed memory, a double free, an out-of-bounds access — but none of them run the source itself. The code running alongside each section is the defined counterpart that replaces that source: a check line inserted before the contract breaks. This distinction holds throughout the course — what is measured is always defined behavior — but it matters especially in this lesson, because the name of the unmeasured side is also stated explicitly in the prose.
Why Undefined Behavior Exists
The C standard does not define the meaning of every expression. Some expressions are
defined: int total = a + b; always works by the same rule. Some are
implementation-defined: the compiler makes a choice and documents that choice. But some
expressions are undefined: the standard says nothing at all about what happens — the
compiler may behave however it likes, or not behave at all.
This gap is not an oversight, it is a design decision. The compiler optimizes by assuming that a program never enters undefined behavior. For example it may assume that a pointer always points to a valid block, because pointing to an invalid block is itself undefined and the standard gives no guarantee in that case. This assumption lets the compiler drop unnecessary checks, reorder operations, and cache a value instead of recomputing it. If the assumption turns out wrong — that is, the program really does enter undefined behavior — the generated code still compiles and still does something, but what that something is is not the standard’s concern.
The effect of this assumption changes with the compiler version, the optimization level, and even the shape of the surrounding code; the same undefined expression may pass unnoticed in one build and send the program down a completely different path in another. The standard treats both outcomes as equally valid, because both fall under the word “undefined”. This is why the way to find undefined behavior is not to run the program and check whether it produced an error — the absence of an error proves nothing, it only shows that nothing was observed in that particular run, with that particular compiler.
Among the measurement core’s four classes, this lesson’s subject is unowned memory: once the contract breaks, neither the language, nor the caller, nor the callee can reach that block through a defined path any longer — the block may still sit there, but no party is left to use it safely. That is why none of the four sources below answers the question “who owns it” directly; all of them show the moment after the ownership contract has already been broken.
This is why “looking like it works” carries no proof. The next section takes up, one by one, the four sources this repository’s measurement core deliberately never runs; each one first explains in prose why the contract breaks, then runs a defined counterpart in its place.
Reading a Dangling Pointer
Once a block is freed, the pointer that pointed to it dangles: the pointer itself still
holds the old address, but the memory at that address has already been returned to the
allocator. Dereferencing a dangling pointer (*p) — even just to read it — is undefined
behavior. The memory may appear to still hold the same content, because the allocator may
not yet have handed that block to another request; that is a coincidence, not a
guarantee. The same program may hand that address to a completely different block on the
next run, or after the next allocation request in the same run.
The lesson never runs this read. Instead it runs the defined pattern that prevents a
dangling pointer from forming: assigning NULL to the pointer the moment a block is freed,
and checking that before every use.
// guard.c — the defined counterpart that prevents a dangling pointer from forming
#include <stdlib.h>
const char *dangling_counterpart(void) {
int *block = malloc(sizeof *block);
if (block != NULL) {
*block = 9;
}
free(block);
block = NULL;
return (block == NULL) ? "pointer null, no dereference" : "accessed";
}
This counterpart’s measurement is shown, together with the other three counterparts, in the
lesson’s single closing code block that contains main.
The point to note is this: pulling block to NULL changes the pointer itself, not the
memory it pointed to — that memory already returned to the allocator with the free call.
The check only prevents that same pointer variable from being used again; if another
pointer holds the same address (for example a second variable pointing at the same block),
that second pointer still dangles and this pattern does not protect it. The pattern’s scope
is limited exactly to the pointer variable it checks.
Using Freed Memory
Using freed memory (use-after-free) is a little broader than reading a dangling pointer: it
also covers writing to the block. Writing *p = value; to the same block after a free
call may mean the allocator has already reopened that memory region for reuse — the write
may land on bytes that now belong to the allocator’s own bookkeeping and corrupt the
allocator’s internal consistency. This too is undefined and may not produce an error
message; the program may keep running “normally” for a while.
Its defined counterpart is to carry a validity flag tied to the block: the flag drops when the block is freed, and every write checks that flag.
// validity.c — the pattern that uses a flag to block writes after freeing
#include <stdlib.h>
const char *use_counterpart(void) {
int *second = malloc(sizeof *second);
int valid = (second != NULL);
if (valid) {
*second = 4;
}
free(second);
second = NULL;
valid = 0;
if (valid) {
*second = 7;
}
return valid ? "written" : "not written, flag dropped";
}
This counterpart’s measurement is also shown in the same closing code block. Alongside the
flag, the pointer itself is also pulled to NULL right after free. This line is not
decoration: without it, a build with warnings enabled sees that second still appears in the
text after free and prints a warning — even though the branch never runs, because the
compiler cannot always prove that a branch will not run. Even the compiler itself keeps
tracking a freed pointer in its own view; the flag closes off the branch, pulling it to
NULL keeps the name from carrying a freed address, and together the two let the block
compile without warnings.
The flag’s placement also matters: the flag must share the same lifetime as the pointer it protects. If the flag is a local variable and the pointer passes from one function to another, the callee cannot know whether the pointer is still valid unless the flag travels with the pointer — which means the validity check itself carries ownership separate from the block it checks; whose responsibility it is to update the flag is a question of its own.
Double Free
Handing the same block to free twice is also undefined. Once the allocator frees a block,
it adds that address to its own ledger (the free list); a second free call tries to add
the same address to that ledger a second time and can corrupt the ledger’s internal
structure. The result may show up on the allocator’s next malloc call, or it may never
show up — neither is something the standard says anything about.
Its defined counterpart is to also mark that a block has been freed with a flag, and skip the second free request using that flag.
// single_free.c — the pattern that uses a flag to prevent a double free
#include <stdlib.h>
const char *single_free(void) {
int *third = malloc(sizeof *third);
int freed = 0;
if (!freed) {
free(third);
freed = 1;
}
if (!freed) {
free(third);
freed = 1;
}
return freed ? "freed once" : "not freed";
}
The second freed check here never runs — it exists as a line of code, but the freed
flag is already true at that point, so the condition never holds. This is the concrete form
of the rule set up at the start of the lesson: a line that shows the undefined path may sit
in the source, as long as it is never executed. Its measurement is also in the closing code
block.
Seen from the ownership angle, a double free is an interesting case: the moment a block is
freed, its ownership has already passed to nobody — neither the language, nor the
caller, nor the callee owns it any longer. The second free call is a call into that gap;
the allocator does not recognize that address, or recognizes it wrongly, because it no
longer holds a valid record of that address. The single-free flag does not fill this
gap, it only prevents entering it a second time.
Out-of-Bounds Access
Reaching outside an array’s [0, length) range — array[length] or further — is undefined.
The memory may physically hold something there, but that byte is not part of the array; the
compiler may drop bounds checks by assuming an array access always stays within bounds. An
out-of-bounds read yields an arbitrary value, an out-of-bounds write may corrupt another
variable’s data.
Its defined counterpart is to compare the requested index against the known length before the access.
// bounded.c — the pattern that compares an index against a length to keep access defined
const char *bounded_access(int requested) {
int array[4] = {10, 20, 30, 40};
int length = 4;
if (requested >= 0 && requested < length) {
(void)array[requested];
return "accessed";
}
return "rejected, out of bounds";
}
The requested < length check does not exist in C itself — an array access (array[i])
always silently permits it in the language, with no check at either run time or compile
time. The check is entirely a line the programmer adds; without it, an index past the
end of the array compiles and enters undefined behavior.
This shows a point that differs from the other three sources: a dangling pointer and freed memory are a timing problem — a block is valid at one point, then invalid later. Out-of-bounds access is independent of timing; even when the array was never freed and is still fully valid, the wrong index still enters undefined behavior. The common point is that in both cases the compiler adds no run-time check at all — C itself tracks neither the timing nor the index.
Running All Four Counterparts Together
None of the four is undefined; all four are patterns that prevent undefined behavior,
and all four run in a single main, in sequence.
// measurement.c — all four defined counterparts measured together in one main
#include <stdio.h>
const char *dangling_counterpart(void);
const char *use_counterpart(void);
const char *single_free(void);
const char *bounded_access(int requested);
static void observe(const char *name, const char *result) {
printf("%-28s%s\n", name, result);
}
int main(void) {
printf("%-28s%s\n", "observation", "result");
observe("dangling pointer", dangling_counterpart());
observe("freed memory", use_counterpart());
observe("double free attempt", single_free());
observe("valid index", bounded_access(2));
observe("out-of-bounds index", bounded_access(5));
return 0;
}
observation result dangling pointer pointer null, no dereference freed memory not written, flag dropped double free attempt freed once valid index accessed out-of-bounds index rejected, out of bounds
RO1. The block == NULL check here prevents the dangling pointer, it does not
measure the dangling pointer itself. Undefined behavior has no measurable “result”; the only
thing that can be measured is proof that it was never entered — that is why the output line
says “no dereference”, not “the value came out as such”.
RO2. The flag itself is an ordinary int; C has no separate “validity” type. The
guarantee comes not from the type but from the discipline of every write site checking the
flag — the compiler does not enforce this discipline, a line that forgets to check
compiles silently.
RO3. The second freed check inside single_free never ran during this run — the
output gave a single “freed once” line, with no trace of a second free.
RO4. The last two lines are the same function called with two different requested
values on the same array: the valid index stays inside the array and is accessed, the
out-of-bounds index is rejected without being accessed — both are defined, neither ever
touches past the end of the array.
What all four lines share is that none of them relies on a guarantee from the language or a
runtime. dangling_counterpart pulls the pointer to NULL with its own hands,
use_counterpart drops the flag with its own hands, single_free skips the second free with
its own hands, bounded_access compares the bound with its own hands — because none of the
four has an owning party for that block, the only guarantee left is the check written in
the source. This does not mean that none of the previous lesson’s four classes (language,
caller, callee, unowned) is in play here; on the contrary, these four examples are exactly
what makes the unowned class concrete: once a block loses its validity at some point,
neither the language, the caller, nor the callee protects it on its own anymore. If there is
protection, it is the line of code that put it there — not the compiler, not the runtime
library, and certainly not the type system. That is why this lesson’s counterpart of the
unowned class does not replace the previous lesson’s language/caller/callee classes; it
separately addresses a weak point common to all of them: the moment right after the contract
breaks.
The Bounding Measurement
Undefined behavior is not an error message. The four counterparts above give exactly the same output on every run — but that consistency comes from the counterparts being defined, it does not show that the undefined operations involved are “actually harmless”. None of this lesson’s code blocks dereferences a dangling pointer, writes to a freed block, frees a block twice, or reaches past an array’s end; all of them run the pattern that prevents those things. A program “looking like it works” is not proof that it contains no undefined behavior — the same source may behave differently when the compiler version, the optimization level, or the next allocation request changes.
This also sets the lesson’s measurement boundary: none of the programs run here shows what undefined behavior “does”, because showing such a thing would already contradict the concept’s own definition. The lesson can only measure two things — describing in prose where the contract breaks, and running the defined path that replaces the break. The difference resembles the difference between a lab experiment and a recipe: an experiment’s result can be observed, a recipe only guarantees its result if followed — what happens if it is not followed is not the recipe’s concern.
Summary
- Undefined behavior is a gap where the compiler optimizes by assuming it is never entered; “looking like it works” does not show that it is defined.
- Reading a dangling pointer, using freed memory, freeing a block twice, and reaching past an array’s bounds — all four are undefined, and none of them ran in this lesson.
- Every source has a defined counterpart: a validity flag, a single-free flag, and an index-length check — all lines the programmer adds, none enforced by the language.
- The memory this lesson measures is in the unowned class: once the contract breaks, no party obligated to free the block can reach it through a defined path any longer.
Next Step
The contract breaking shows up not only in memory access but also on the path where a function returns an error — something needs to be cleaned up there too, but this time without entering undefined behavior. The next lesson measures that cleanup through the callee class’s function: return codes, error numbers, and single-exit-point cleanup patterns.
To keep your progress and take notes, Log in
My notes
Log in to take notes.