PART III
So far, we have treated memory as something with a location and a size.
There is another property that matters just as much:
lifetime.
A pointer can have the right address.
The access can have the right size.
The arithmetic can be correct.
And the access can still be invalid because the object no longer exists.
This gives us another dimension of the problem:
Where is the memory?
How much memory is there?
Does the object still exist?
A memory object has to satisfy all three.
Consider:
p = malloc(size);
The important result is not merely an address.
The program has obtained storage that can be used for an object, subject to the relevant rules.
Conceptually:
allocation
->
object exists
->
pointer identifies object
->
accesses are possible
The object's lifetime begins as part of this process.
The program must then eventually stop using the object and release its storage.
That creates a temporal boundary in addition to the spatial boundary.
Consider:
p = malloc(size);
...
free(p);
After the `free`, the program cannot continue to use `p` as though the allocated object still existed.
The pointer value may still be stored in the variable.
That does not mean the object still exists.
This distinction is fundamental:
pointer value
is not the same thing as:
live object.
A pointer can remain in memory after the object it once identified has ceased to exist.
Consider:
p = malloc(100);
...
free(p);
...
memcpy(dst, p, 10);
The copy length is only 10 bytes.
The address was originally valid.
The allocation was originally large enough.
But the object has been freed.
The problem is therefore not:
wrong amount
or:
wrong address.
It is:
wrong lifetime.
The pointer identifies storage that is no longer a live object available for the intended access.
The pattern is:
use
->
free
->
use again
The second use occurs after the lifetime has ended.
The operation might be:
read
write
pointer dereference
function call through a stored function pointer
structure member access
copy
comparison
size calculation based on object contents.
The exact operation does not matter.
The key property is that the program relies on an object after its lifetime has ended.
After:
free(p);
the allocator may leave the bytes unchanged for some time.
A later access through `p` might therefore appear to work.
This is one reason use-after-free bugs can be intermittent.
The memory may contain:
old contents
or:
allocator metadata
or:
contents belonging to a new allocation.
The program has lost the guarantee that the memory represents the original object.
Suppose:
p = malloc(100);
free(p);
Then:
q = malloc(100);
The allocator may return the same region for `q`.
Now:
p
and:
q
may have the same numerical address.
But they do not represent the same logical object.
The old object has ended.
A new object now occupies the storage.
Using `p` as though it still referred to the old object can therefore affect the new object.
This is one of the reasons use-after-free bugs can become security vulnerabilities.
Consider:
p = malloc(100);
...
free(p);
q = malloc(100);
...
p[0] = attacker_value;
If the allocator reused the same storage for `q`, the write through `p` may modify `q`.
The attacker has effectively gained a way to modify an object that the program did not intend to expose through `p`.
The exact result depends on allocator behavior and object layout.
But the general pattern is important:
expired pointer
->
reused storage
->
access to a different object.
The same idea works for reads.
Suppose:
p = malloc(100);
...
free(p);
q = malloc(100);
...
send_to_user(p, 100);
If the storage has been reused, the read may expose data belonging to `q`.
The original pointer still has a plausible address.
The size is still exactly 100.
The problem is that the memory no longer belongs to the object the program believes it is reading.
This is another example of why location and lifetime cannot be considered independently.
A common defensive technique is:
free(p);
p = NULL;
This is useful because an accidental later dereference is more likely to fail obviously rather than access a reused allocation.
But this technique does not solve the general problem.
Suppose there are two pointers:
p
q
both referring to the same allocation.
Then:
free(p);
p = NULL;
does not change `q`.
The object is still freed.
`q` is now stale.
This is why ownership and aliasing matter.
Consider:
p = malloc(100);
q = p;
Now there are two pointer variables.
There is still only one allocation.
If code does:
free(p);
then:
q
does not automatically become NULL.
It still contains the old pointer value.
The program therefore has to know that `q` is an alias of the object whose lifetime has ended.
This is one reason ownership discipline is so valuable in C.
Without it, the question:
"Who may free this object?"
can become surprisingly difficult.
A useful design concept is ownership.
An owner is the part of the program responsible for determining:
who creates the object
who may release it
how long it remains available
which other code may temporarily reference it.
C does not enforce ownership in the type system.
The program has to establish it by convention and design.
Many lifetime bugs are really failures of ownership reasoning.
A function may receive a pointer without becoming responsible for freeing it.
For example:
void process(struct object *p);
The caller might retain ownership.
The function may temporarily use:
p
but must not assume that it can:
free(p);
unless the interface explicitly establishes that responsibility.
Conversely, if the caller frees the object while the function still intends to use it, the function can suffer a use-after-free.
The lifetime contract needs to be clear.
Consider:
char *get_name(void)
{
char buffer[32];
...
return buffer;
}
The pointer returned by the function refers to an object whose lifetime ends when the function returns.
The returned address may still look perfectly plausible.
But the object no longer exists.
This is a lifetime error without `free`.
The lesson is important:
> Lifetime is not synonymous with heap allocation.
Automatic objects also have lifetimes.
Consider:
struct item *get_item(void)
{
struct item item;
...
return &item;
}
The address can be formed correctly.
The object can be accessed correctly while the function is executing.
After the function returns, the object's lifetime has ended.
The pointer is therefore invalid for later use.
This is often called a dangling pointer.
The same concept applies to other objects whose lifetimes have ended.
Objects with static storage duration have a much longer lifetime.
For example:
static struct object object;
A pointer to such an object does not become invalid merely because the function that produced the pointer returned.
This illustrates why the lifetime question must be answered from the object's storage duration and ownership model.
The pointer itself does not tell us enough.
Suppose:
p = malloc(100);
free(p);
q = malloc(20);
memcpy(p, src, 20);
The copy length is:
20
The old allocation had:
100
bytes.
But that is no longer relevant.
The old object's lifetime has ended.
The fact that the new allocation contains only 20 bytes may introduce another issue, but the first problem is already sufficient:
p is stale.
This illustrates an important ordering principle.
A range can be:
correctly sized
and:
correctly located
relative to an object that no longer exists.
The access is still invalid.
Suppose:
p = malloc(100);
q = p + 50;
free(p);
The pointer:
q
was derived from the allocation.
After the allocation is freed, `q` cannot be treated as a pointer into a still-live 100-byte object.
The lifetime ended for the allocation as a whole.
This is why a pointer into the middle of an object is not independent of the lifetime of the object containing it.
Consider:
struct object {
char *data;
size_t length;
};
A shallow copy:
copy = *original;
copies the pointer.
It does not create a new lifetime for the data.
If:
free(original.data);
then:
copy.data
may now be stale as well.
This is one reason copying structures containing pointers requires understanding ownership.
The bytes of the structure may have been copied correctly.
The resources represented by those bytes have not necessarily been duplicated.
Some programs solve shared lifetime problems with reference counting.
Conceptually:
acquire reference
->
reference count increases
release reference
->
reference count decreases
count reaches zero
->
object is freed
The important invariant is:
object remains alive while references that may use it remain outstanding.
Reference counting is not automatically correct.
It can fail through:
missing increments
missing decrements
duplicate decrements
reference cycles
races.
But the underlying purpose is clear:
make lifetime explicit.
Spatial memory safety asks:
Is this address inside the object?
Lifetime safety asks:
Does the object still exist?
These are related forms of boundary checking.
A spatial boundary looks like:
beginning -------- end
A temporal boundary looks like:
creation -------- destruction
A valid access must fall inside both.
Conceptually:
correct location
+
correct size
+
live object
=
valid memory access.
For any pointer, ask:
What object does it identify?
Who owns that object?
When was it created?
When can it be destroyed?
Can this code execute after destruction?
Are there aliases?
Can another operation free or replace the object?
These questions are often more productive than searching for `free()` calls in isolation.
The dangerous operation may be many functions away from the code that actually destroys the object.
Memory is not simply:
address + size.
It is:
object
+
location
+
size
+
lifetime.
A pointer becomes dangerous when the program forgets one of those dimensions.
A stale pointer may still contain the correct address.
The allocation may once have been large enough.
The access may still use the correct length.
None of that restores the object's lifetime.
Once an object has ceased to exist, the program must stop treating its old pointers as references to that object.
That brings us to the other half of lifetime management:
freeing memory correctly.