Skip to content

16.1 Object Layout, Aliasing, and volatile

Walk to the part of the computer closest to C, where the safety barriers are noticeably fewer. On the address map, you see stack, mapping, and allocator regions, making it easy to mistakenly treat this Linux implementation as a C language contract and thus conclude things like "local variables must be on the stack" or "malloc must cause the heap to grow upward."

C first defines the object, storage duration, alignment, and allowed access modes; the OS and toolchain then place the program into ELF mappings, thread stacks, and dynamic allocation regions. This lesson breaks down the two-layer agreement and discusses struct padding, aliasing, restrict, and volatile. The next lesson will cover atomic ordering between threads.

1. C Standard first defines storage duration

C objects have several types of storage duration:

Storage periodTypical sourcesLifecycle
automaticordinary block-scope local objectsbegin when entering the block containing the declaration, end when leaving the block
staticfile-scope objects, static local objectsentire program execution period
thread_Thread_local objectcorresponding thread execution period
allocatedmalloc/calloc/realloc returned storagefrom successful allocation to free or program end

Storage duration doesn't mean a fixed physical location. The optimizer can keep local values solely in registers, or even eliminate the object entirely; it's only during take-address operations or in debug builds that it's more likely to occupy space in a call stack frame.

malloc does not promise storage from a single contiguous heap that grows toward higher addresses. An allocator may obtain storage from the program break, mmap regions, arenas, or reserved mappings.

2. Common ELF/Linux mappings are just an implementation diagram

A common mapping for an ELF process includes:

text
executable code and read-only data
writable initialized data
zero-initialized static storage
allocator-managed regions
shared libraries and mmap regions
per-thread stacks

The toolchain places machine code in .text, readonly constants in .rodata, non-zero static objects in .data, and zero-initialized static objects in .bss. The ELF's SHT_NOBITS section records memory size without storing equivalent zero bytes in the file, so large zero-initialized arrays do not need to expand the file proportionally.

The linker can merge and reorder sections, the loader maps pages by segment, ASLR can change the base address, shared libraries, PIE, sanitizers, and different operating systems all affect the layout. The diagram below can only help with localization and is not guaranteed by the C language:

text
lower virtual addresses
  executable mappings
  writable program mappings
  allocator / mmap / shared libraries
  thread stacks
higher virtual addresses

“The stack always grows downward from the highest address, and the heap always grows upward from after data” is a common feature of certain ABIs or implementations and should not be included in portable program logic.

3. Separate pointer objects from the objects they point to

c
const char *message = "hello";

There are at least two objects here:

  • message is a mutable pointer object; typically it has static storage duration when defined at file scope;
  • String literals correspond to character arrays, and modifying their elements results in undefined behavior.

const char * indicates that the character pointed to cannot be modified via this pointer, not that the pointer itself is immutable. If the pointer itself also needs to be fixed, then write:

c
const char *const message = "hello";

Understanding this hierarchy is more useful than putting everything into a "data section."

4. Alignment and padding

Each complete object type has an alignment requirement. Array elements are stored contiguously, so sizeof(T) must contain sufficient trailing padding to ensure the next element also satisfies T's alignment.

The following program observes layout using standard interfaces, without expressing any platform-specific result as a linguistic law:

c
#include <stdalign.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

struct P {
    char first;
    int number;
    short small;
};

struct Q {
    int number;
    short small;
    char first;
};

int main(void) {
    alignas(64) struct Q cache_aligned = { 0 };

    printf("P: size=%zu align=%zu offsets=%zu,%zu,%zu\n",
           sizeof(struct P), alignof(struct P),
           offsetof(struct P, first),
           offsetof(struct P, number),
           offsetof(struct P, small));
    printf("Q: size=%zu align=%zu offsets=%zu,%zu,%zu\n",
           sizeof(struct Q), alignof(struct Q),
           offsetof(struct Q, number),
           offsetof(struct Q, small),
           offsetof(struct Q, first));

    uintptr_t address = (uintptr_t)&cache_aligned;
    printf("explicitly aligned: %s\n",
           address % 64 == 0 ? "yes" : "no");
    return address % 64 == 0
        ? EXIT_SUCCESS : EXIT_FAILURE;
}
bash
cc -std=c17 -O2 -Wall -Wextra layout.c
./a.out

On common ABIs, P might be larger than Q because padding is needed before and after int; the exact sizes are determined by the implementation's type sizes and alignment. alignas(64) requires 64-byte alignment for this object, and if the implementation does not support this alignment, the program must be rejected at compile time, not silently fail at runtime.

Member reordering is not always possible

Rearranging member order might reduce padding but will change:

  • ABI and conventions with external libraries;
  • Binary file or network protocol layout;
  • Layout corresponding to hardware registers;
  • Which fields on a cache line are adjacent to each other;
  • Source code initializer and debugger tools expected.

When persisting or transmitting structs, don't directly write sizeof(struct) raw bytes. Padding values are not guaranteed, and endianness and type sizes lack cross-platform protocol semantics. Instead, explicitly encode fields, see Byte Order and Bitwise Operations.

5. Misaligned accesses can't be promoted based on "x86 can run"

Cast any byte address directly to uint32_t * and dereference it may simultaneously violate:

  • Align with requirements;
  • Object effective type/alias rules;
  • buffer boundary;
  • Byte order expectation.

When reading external byte streams, memcpy is a more portable starting point:

c
uint32_t value;
memcpy(&value, bytes + offset, sizeof(value));

This solves the safe movement to the properly aligned object; if the data format specifies network byte order, explicit decoding is still required. Compilers can typically optimize known-length memcpy into load instructions suitable for the target architecture.

6. Alias rules let the optimizer cache values

An alias means two expressions might be accessing the same memory. If the compiler must assume that any pointer type can alias with another, many values can't remain in registers, and vectorization is limited.

C allows viewing object representation through lvalues of character type. Direct reinterpretation and dereferencing of other incompatible types may violate effective type/compatible type rules. When bit manipulation is needed, prefer using memcpy; when numeric conversion is required, use explicit conversions.

Union type punning, common initial sequences, and compiler extensions each have their boundaries, and "same bits in memory" should not be taken as justification for arbitrary pointer casting.

7. restrict is a promise that the caller must fulfill

Consider element-wise addition:

c
void add_arrays(size_t length,
                const float *restrict left,
                const float *restrict right,
                float *restrict output) {
    for (size_t i = 0; i < length; ++i) {
        output[i] = left[i] + right[i];
    }
}

restrict is not a runtime check, nor is it "there's only one pointer to this address in the entire program." It imposes constraints on how objects are accessed via bounded pointers during block execution. If a caller passes overlapping ranges that violate these associated requirements and such accesses occur, behavior is undefined.

The compiler can thus avoid conservatively preserving ordering for overlapping regions and vectorization becomes easier. Even without restrict, the compiler might generate runtime alias checks, using a fast version on non-overlapping paths.

Use restrict only when the API clearly expresses the intent and the caller can reliably guarantee no overlap. Adding it casually just to "see if it's faster" might turn a correct program into undefined behavior.

8. Console warning lights require volatile; the multithreaded protocol is insufficient

A narrow boundary shared between memory-mapped device registers or signal handlers requires the program to actually perform the corresponding access; volatile can express such requirements. It does not establish atomicity or happens-before between threads and cannot be used to replace the next lesson's concurrency memory model.

Accessing a volatile-qualified object is one of the observable behaviors in the abstract machine, and exactly which accesses occur and when they complete are strictly constrained by the full expression and implementation-defined behavior. Calling this "prohibiting compiler optimization" misses critical boundaries and risks being misused as a thread-synchronization mechanism.

Common legal uses include:

Implement the specified MMIO. The platform ABI or device SDK might require accessing registers through volatile-qualified lvalues. Addresses, widths, ordering, and barriers are still specified by the platform; ISO C itself does not define physical device registers.

Signal Processing and Communication. Between an asynchronous signal handler and the main flow, limited communication is allowed using volatile sig_atomic_t as a standard permitted flag:

c
#include <signal.h>

static volatile sig_atomic_t stop_requested = 0;

static void handle_signal(int number) {
    (void)number;
    stop_requested = 1;
}

sig_atomic_t is not "implicit volatile"; both serve different purposes. The handler can still only call async-signal-safe functions.

setjmp/longjmp boundaries. In functions that call setjmp, if certain automatic local objects are modified afterward and are not volatile-qualified, their values become undefined upon return from longjmp. This is a very specific language rule, not general thread visibility.

volatile does not provide:

  • atomic read-modify-write;
  • Cross-thread happens-before;
  • mutex: mutual exclusion;
  • cache flush;
  • A portable hardware memory barrier.

Thread-shared data should use _Atomic, mutexes, or other synchronization primitives.

9. After an object's lifecycle ends, its address cannot be used arbitrarily

After free(pointer), the lifecycle of the corresponding allocated object ends. Even if the bytes are not yet overwritten, reading through the old pointer is still use-after-free.

When realloc successfully returns a new address, the old pointer becomes invalid; if it fails and returns NULL, the original object remains valid. Therefore, you should assign the return value to a temporary variable.

Addresses having the same numerical value don't prove they refer to the same object. Allocators can quickly reuse recently freed addresses, which is one source of the ABA problem in lock-free algorithms. Object identity and lifetime can't be determined just by printing pointers.

10. Tools for Observing Layout

Can be used in combination:

bash
readelf -S program
readelf -l program
nm -S program
cat /proc/PID/maps

Look at the section of the link file, readelf -S; look at the loaded segment, readelf -l; look at the virtual mapping during a run, /proc/PID/maps. These address different questions, and you can't take the section table and treat it as the runtime page table.

Optimizing builds may result in variables lacking stable memory locations. The debugger showing optimized out does not mean the source code variable "disappeared"; rather, it indicates that the compiled program no longer requires an independently locatable object.

11. Summary

The C object model and the operating system's address space must be understood in layers:

  • C defines storage duration, but does not guarantee that "local = stack, malloc = contiguous heap";
  • ELF sections, loading segments, and runtime mappings are three levels;
  • Structures include internal and trailing padding, with layout constrained by ABI and alignment rules;
  • The original struct byte layout is not suitable as a cross-platform serialization format;
  • memcpy is an important tool for handling unaligned external bytes and object representation;
  • restrict is an alias promise that the caller must fulfill;
  • volatile has MMIO, signals, and setjmp among other specific boundaries, desynchronizing threads.

How threads establish a visible ordering, return to the companion C Concurrency Memory Model. The next chapter moves on to Assembly and Calling Conventions.

Built with VitePress | Software Systems Atlas