C / POINTERS
Dangling pointers and the discipline that prevents them
Spot the exact moment a pointer outlives its object, and apply free-and-null, single ownership and index-instead-of-pointer to prevent it.
What you will learn
- Name the event that ends an object's lifetime and retire its pointer at that point
- Free through a T** helper that assigns NULL, making a second release harmless
- Store an index, not an interior pointer, when a block may be realloc'd
- Clear every alias of a freed block, not just the pointer you passed to free
Understanding Dangling pointers and the discipline that prevents them
A dangling pointer is not a special kind of pointer. It is an ordinary pointer whose target has stopped existing: the bits in the pointer variable are exactly what they were, but the object at that address is gone. Three everyday events end a lifetime while leaving the pointer untouched: a call to free, the closing brace of the block that declared an automatic object, and a realloc that moves a block to a new address. That is the whole trap, because nothing observable about the pointer changes at the instant it goes bad, so no test on the pointer by itself can detect the problem.
Reading through a dangling pointer often appears to work, which is why these bugs survive testing. free hands the block back to the allocator, which is then free to write free-list bookkeeping into the first bytes, hand the same block to the next malloc, or return whole pages to the operating system. Whether you see the old data, allocator internals, another object's data, or a segfault depends on allocation patterns elsewhere in the program. The standard is blunter than any allocator: when an object's lifetime ends, the value of every pointer to it becomes indeterminate, so even copying, printing or comparing that pointer is undefined, not just dereferencing it.
The prevention is structural, not vigilance. Give each block exactly one owning pointer, release it through a helper that takes the address of that pointer so the same statement that frees also clears, never let the address of an automatic object escape the block that declared it, and after every realloc rebuild interior pointers from saved offsets rather than trusting the old ones. Compiling development builds with -fsanitize=address turns the residual mistakes into an immediate report at the first bad access instead of corruption discovered three functions later.
<stdio.h>
<stdlib.h>
<string.h>
/* Takes the address of the caller's pointer so the caller's copy can be cleared. */
static void release(char **slot)
{
free(*slot); /* free(NULL) is defined to do nothing */
*slot = NULL; /* the address is retired together with the object */
}
int main(void)
{
char *msg = malloc(16);
if (msg == NULL) {
return 1;
}
strcpy(msg, "still valid");
printf("before release: %s\n", msg);
release(&msg);
printf("after release: msg is %s\n", msg == NULL ? "NULL" : "an old address");
release(&msg); /* harmless second call: free(NULL) does nothing */
printf("second release: msg is %s\n", msg == NULL ? "NULL" : "an old address");
if (msg != NULL) {
printf("unreachable: %s\n", msg);
} else {
printf("guarded read skipped; nothing dangling was dereferenced\n");
}
return 0;
}
A pointer's validity comes from the lifetime of the object it points at, so the address must be retired at the instant that lifetime ends.
Worked examples
realloc can move the block under your pointer
Shows why an interior pointer must be rebuilt from a saved index after the block grows.
<stdio.h>
<stdlib.h>
int main(void)
{
int *a = malloc(2 * sizeof *a);
if (a == NULL) {
return 1;
}
a[0] = 10;
a[1] = 20;
size_t idx = 1; /* an index survives a move; an address may not */
int *p = &a[idx];
printf("through p before growth: %d\n", *p);
int *grown = realloc(a, 64 * sizeof *a);
if (grown == NULL) {
free(a);
return 1;
}
a = grown; /* the old base address is dead from here on */
p = &a[idx]; /* rebuild the interior pointer from the index */
printf("through p after growth: %d\n", *p);
printf("recovered element %zu = %d\n", idx, a[idx]);
free(a);
a = NULL;
p = NULL;
return 0;
}
Example explained
Line 1int *p = &a[idx]; points inside the block that a owns, so p lives exactly as long as that block does.
Line 2realloc may copy the data to a new address and release the old block, which kills both the old value of a and p.
Line 3a = grown; refreshes the base pointer, but nothing can refresh p automatically, so it is re-derived from idx.
Line 4Keeping the offset instead of the address is what makes the code correct whether or not the move happens.
A closing brace ends a lifetime
Demonstrates that an automatic object dies at the end of its block, not at the end of the function.
<stdio.h>
int main(void)
{
int *outer = NULL;
{
int inner = 42;
outer = &inner;
printf("inside the block: %d\n", *outer);
} /* inner's lifetime ends here, so outer would now dangle */
outer = NULL;
printf("after the block: outer is %s\n", outer == NULL ? "NULL" : "stale");
int copy;
{
int inner = 7;
copy = inner; /* copy the value out instead of keeping an address */
}
printf("copied value survives: %d\n", copy);
return 0;
}
Example explained
Line 1outer = &inner; is legal while the block is running, so *outer prints 42 with inner still alive.
Line 2The closing brace ends inner's lifetime, so the stored address goes bad even though main has not returned.
Line 3outer = NULL; placed where the object dies keeps the stale address from being reachable later in main.
Line 4The second block copies the value rather than the address, so nothing afterwards depends on inner's storage.
Aliases dangle too
Shows that free invalidates every pointer to a block, so clearing only the argument is not enough.
<stdio.h>
<stdlib.h>
struct node {
int *data;
};
int main(void)
{
int *owner = malloc(sizeof *owner);
if (owner == NULL) {
return 1;
}
*owner = 7;
struct node n;
n.data = owner; /* a second pointer to the same block */
printf("owner sees %d, n.data sees %d\n", *owner, *n.data);
free(owner); /* one call ends the lifetime for every alias */
owner = NULL;
n.data = NULL; /* the alias has to be retired as well */
printf("owner: %s, n.data: %s\n",
owner == NULL ? "NULL" : "stale",
n.data == NULL ? "NULL" : "stale");
return 0;
}
Example explained
Line 1n.data = owner; creates a second name for one allocation, and both dereferences read the same int.
Line 2free(owner) ends the lifetime of the block itself, which invalidates every pointer to it, not only the argument passed.
Line 3owner = NULL; protects one access path; n.data would still hold the old address until it is cleared too.
Line 4Choosing a single owner per block is what keeps the list of aliases you must remember short enough to be reliable.
Important notes
free(NULL) is defined to do nothing, which is exactly why the free-and-null pattern makes an accidental second release harmless; calling free again on a pointer that was already freed is undefined and typically corrupts the allocator's internal structures.
The standard makes a pointer's value indeterminate as soon as its object's lifetime ends, so printing or comparing a freed pointer is already undefined behaviour, not just dereferencing it.
Common mistakes
Writing if (p != NULL) after free(p) as a safety check: free receives a copy of the pointer and cannot change your variable, so the guard always passes and the program reads reclaimed memory.
Concluding a use-after-free is fine because the old value still printed correctly during testing: the allocator simply had not reused the block yet, and the same code silently corrupts data as soon as allocation patterns change.
Returning a pointer to a local, such as &n or a char buf[64] declared in the function: the next call reuses that stack space, so the caller reads whatever that call happened to write there.
Try it yourself
Change, predict, then run
Write void str_release(char **s) that calls free(*s) and then sets *s = NULL, use it on a malloc'd copy of a string in main, and call it twice on the same variable while printing whether the pointer is NULL after each call.
Open the C workspaceCheck your understanding
A program does free(p), then later runs if (p != NULL) puts(p); and during testing the original text prints correctly. What can you conclude?
- The guard is doing its job, because free sets the pointer to NULL when the block is really gone
- Reads of freed memory are safe; only writing into a freed block is undefined
- Nothing reliable: free left p's bits unchanged so the test always passes, and the allocator had not reused the block yet, so an undefined read happened to look right
- It is safe because puts only reads, and freeing a block copies its contents to read-only memory first
Show answer
free takes the pointer by value and cannot alter the caller's variable, so p != NULL is true after every free; the text that appeared was just bytes the allocator had not overwritten yet. Option 2 is the tempting one because a read feels passive, but the object's lifetime ends at free, and real allocators write free-list links into the first bytes of a released block or hand whole pages back to the operating system, so a read can return garbage or fault outright.