Skip to content

VOL 03 / CH 16 / LESSON 01

16.1 Object Layout, Aliasing, and volatile ​

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. Using C11/C17 as its baseline, this lesson separates the two contracts 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 until free or realloc ends the old object's lifetime

Ordinary non-VLA automatic objects live from block entry to exit; a variable-length array (VLA) instead lives from its declaration until execution leaves its scope. Storage duration does not determine 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

Toolchains commonly place 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.

Linkers merge and reorder sections, while loaders map segments. ASLR changes base addresses; shared libraries, PIE, sanitizers, and different operating systems also change 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; 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.

Save as layout.c. The program uses sizeof, alignof, and offsetof to inspect layout. The extra address-modulo check assumes a typical Linux flat-address ABI with uintptr_t; ISO C does not generally guarantee that integer pointer representations encode alignment this way:

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 compiler must diagnose the constraint violation. Address that diagnostic when porting; 64-byte extended alignment is not supported everywhere.

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 initializers and debugger expectations.

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. Working on x86 Does Not Make Misaligned Access Portable ​

Casting an arbitrary byte address to uint32_t * and dereferencing it may violate several requirements:

  • Pointer alignment;
  • 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));

bytes must still refer to valid storage, with the entire range at offset available; memcpy does not check bounds. It avoids a misaligned typed read; 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 copying an object representation, 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 through expressions based on restrict-qualified pointers during block execution. If a caller passes overlapping ranges that violate these associated requirements and such accesses occur, behavior is undefined.

Here left and right are read-only and may overlap or even be identical. For accesses that occur, the output write range must not overlap either input read range. This is an access contract, not a requirement for three distinct pointer values, and it can make vectorization 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. Volatile Access Semantics and Thread Synchronization ​

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:

Implementation-defined 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-handler 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. POSIX handlers may call only async-signal-safe interfaces; ISO C alone permits a narrower set of library calls. Do not mix the two lists.

setjmp/longjmp boundaries. In functions that call setjmp, if certain automatic local objects are modified afterward and are not volatile-qualified, their values become indeterminate after 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.

For a nonzero size, successful realloc ends the old object's lifetime even if the returned address is numerically unchanged. Stop using or comparing the old pointer and its aliases; rebuild aliases through the returned pointer. A NULL failure preserves the original object, so receive the result in a temporary. Zero-size realloc has implementation-dependent boundaries in C17 and undefined behavior in C23; explicit free is clearer.

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

readelf -S shows file sections, readelf -l shows loadable segments, and /proc/PID/maps shows mappings from one execution. 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, without synchronizing threads.

For inter-thread visibility, return to the companion C Concurrency Memory Model. The next chapter moves on to Assembly and Calling Conventions.

Check the Language Rules ​

Built with VitePress | Software Systems Atlas