TRT
Sign in

Foundations · Module 2 · Lesson 10 of 10

Object Lifetimes, Qualifiers, and Undefined Behavior

Build a reliable mental model for when C objects exist, what const and volatile promise, and why undefined behavior must be removed rather than debugged around.

FreeAbout 16 minutes

By the end you can

  • Distinguish an object's storage duration from the lifetime of the value it contains
  • Use const to express read-only access and understand its limits
  • Explain why volatile is not a replacement for atomics or locks
  • Identify common undefined-behavior patterns before they reach production

Builds on Portable C, POSIX, and Platform Boundaries.

01 · A question first

A pointer still contains the same hexadecimal address, but the object it used to point to has already returned from a function. Is the pointer valid? C answers this question through object lifetime, not through whether the bytes happen to remain unchanged.

Make a guess, then step through the trace below to check it.

02 · Watch it run

A pointer outlives its automatic objectstep 1 of 3
int *make_value(void) {    int value = 42;    return &value;}
  1. lifetime 0value: alive(looked at in this step)
  2. lifetime 1pointer: points to value
  3. lifetime 2frame: active

Entering make_value creates value with automatic storage duration in the function's stack frame.

Machine work: allocate value in current stack frame; value = 42

03 · The idea

Start with the problem

C lets you hold an address in a pointer, but an address is not ownership. A pointer is usable only while the object it identifies exists, has the right type and alignment, and is accessed according to the language rules.

This distinction explains many production failures: a pointer to a local variable escapes, a buffer is read one byte too far, or two threads modify an object without synchronization. The bytes may look plausible while the program is already outside C's guarantees.

Storage duration and lifetime

An object with automatic storage duration usually lives until execution leaves its block. An object with static storage duration lives for the entire program. An object created in a later block has automatic storage whose lifetime ends when execution leaves that block. Heap-allocated storage is introduced later in the track.

Do not return the address of an automatic local:

c
const char *name(void) {
    char local[] = "Ada";
    return local; /* local dies before the caller can use it */
}

Return a caller-owned buffer or a string literal when read-only access is enough. Dynamically allocated storage and its ownership rules are covered later.

Qualifiers express a contract

const says that a particular access path is read-only:

c
size_t count_bytes(const unsigned char *data, size_t length);

The function promises not to modify bytes through data. This makes APIs easier to use correctly, but it does not make the object immutable through every possible alias.

The position of const matters:

c
int value = 10;
const int *read_only = &value; /* cannot modify *read_only */
int *const fixed_address = &value; /* pointer cannot change */
const int *const fixed_read_only = &value; /* neither can change */
 
printf("%d\n", *read_only);
*read_only = 20; /* compile-time error */

const protects an access path, not necessarily the object from every alias. The original value can still be changed through a valid non-const pointer. Also, casting away const and then modifying an object that was originally declared const is undefined behavior.

volatile tells the compiler that an access has observable significance outside ordinary program flow. Hardware registers and values changed by a signal handler are common examples:

c
#include <signal.h>
#include <stdio.h>
 
static volatile sig_atomic_t stop_requested = 0;
 
static void handle_signal(int signal_number) {
    (void)signal_number;
    stop_requested = 1;
}
 
int main(void) {
    signal(SIGINT, handle_signal);
    while (!stop_requested) {
        /* Do one small unit of work, then check again. */
    }
    puts("stopping");
}

Here, volatile tells the compiler not to assume that stop_requested stays unchanged between loop iterations. The type sig_atomic_t makes the individual flag access suitable for this limited signal-notification pattern. This is not a general thread-sharing solution: volatile int ready does not make a read-modify-write atomic, prevent a data race, or establish the memory ordering that threads need. Use C11 atomics or POSIX synchronization for threads.

restrict is another contract. It tells the implementation that, for the relevant execution, accesses through one restricted pointer are not also made through an unrelated pointer to the same object. When the promise is true, the compiler may optimize more aggressively. When it is false, the program has undefined behavior. Add restrict only when the API's ownership and aliasing rules make the promise true.

The classic example is copying two separate byte ranges:

c
#include <stddef.h>
 
void add_arrays(size_t count,
                int restrict result[static count],
                const int restrict left[static count],
                const int restrict right[static count]) {
    for (size_t i = 0; i < count; i++) {
        result[i] = left[i] + right[i];
    }
}
 
int main(void) {
    int result[3];
    int left[3] = {1, 2, 3};
    int right[3] = {4, 5, 6};
 
    add_arrays(3, result, left, right); /* valid: the arrays are separate */
}

The restrict promise says that the arrays used for this call do not overlap in a way that creates an alias. Passing result as both the output and an unrelated input would violate the function's contract. Only add restrict when the caller can reliably satisfy that rule.

The type of a pointer matters too. A char * can inspect an object's representation, but that does not make every cast valid for reading a value of another type. Alignment, effective type, and lifetime still apply. memcpy is often the correct way to move bytes between an object representation and a separate value, especially when parsing a file or network packet.

Make ownership visible

A safe API answers three questions: who creates the object, who may modify it, and who destroys it? Write those answers into names and documentation. A function named buffer_init may fill caller-provided storage, while buffer_create may allocate and transfer ownership. A function named buffer_destroy should be safe to call at the end of the documented lifetime. If a function borrows a pointer, say that the caller must keep the object alive until the call returns.

When a later lesson introduces resizable heap storage, it will use a temporary pointer when growing it so a failed resize does not lose the original owner. The important rule here is broader: never use an object after its lifetime ends, and make ownership explicit when an API creates or destroys storage.

Recognize invalid states early

Before dereferencing a pointer, check that it is non-null when null is not permitted, the object is alive, the address is suitably aligned, and the requested range fits inside the object. Before arithmetic, check for overflow. Before converting a length to an allocation size, check multiplication. Before copying text, reserve space for the null terminator. These checks convert undefined behavior into a documented error path.

Remember alive, aligned, allowed, and inside:

  1. Alive: the object lifetime has started and has not ended.
  2. Aligned: the pointer meets the target type's alignment.
  3. Allowed: the access respects type, qualifier, and aliasing rules.
  4. Inside: every byte accessed belongs to the object's range.

If one word is false, stop and repair the design rather than predicting what the current binary will do.

This checklist is useful in code review, debugging, and API design because it turns an abstract rule into four concrete questions.

Use it before optimizing low-level code or shipping changes.

Undefined behavior is a design failure

Undefined behavior includes:

  • Dereferencing a null or dangling pointer
  • Reading beyond an array or string
  • Modifying an object more than once without a sequencing guarantee
  • Signed integer overflow
  • Accessing an object through an invalid type or misaligned pointer
  • Using an uninitialized or indeterminate value

These are not merely unusual outputs:

c
#include <limits.h>
 
int values[2] = {10, 20};
int third = values[2]; /* undefined: one past the last element */
 
int total = INT_MAX;
total += 1; /* undefined: signed integer overflow */

The compiler may use the assumption that neither operation happens when it optimizes surrounding code. If a length comes from input, validate it before indexing. If arithmetic can exceed the type's range, check before adding or use an unsigned or wider representation with a documented range.

An uninitialized value is a separate problem from an old byte pattern:

c
#include <stdio.h>
 
int count;
if (count > 0) { /* undefined: count was never initialized */
    puts("positive");
}

Initialize objects before reading them, and use sanitizers and compiler warnings to find paths that skip initialization. Do not “fix” an uninitialized read by assuming the stack starts at zero; that is not a C guarantee.

The correct response is to make the invalid state impossible or return an error before the operation. Do not rely on a debugger's memory layout, a compiler's current optimization, or a lucky test result.

Where this is used

Every later module depends on this model. Allocators manage lifetimes, file and socket APIs transfer ownership of descriptors, threads require synchronized access, and coroutine runtimes preserve stacks across suspension. Remember the rule: an address is only meaningful while its object and access contract remain valid.

04 · Prove it

Question 1 of 3 · answer from memory

When does an automatic local variable's lifetime end?

Something wrong with this lesson? Report a problem

Not started

Sign in to save your learning progress.

Sign in to save