Memory Model and Alignment

This section documents C23 (ISO/IEC 9899:2024), per ISO/IEC JTC1/SC22/WG14’s freely available working draft N3220, which WG14 documents as differing from the published standard only editorially — the reference these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against the WG14 draft and cppreference.com’s C reference before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

C exposes memory as one uniform, byte-addressable space: every object occupies a contiguous sequence of bytes, and those bytes can be examined directly. What C then layers on top of that — effective types and the strict aliasing rule — is what lets the optimizer work, and is the part most easily violated by accident.

The Uniform Memory Model

Every object has:

  • A size in bytes (sizeof), a byte being CHAR_BIT bits (8 in practice).

  • An address (&), except for bit-fields and objects declared register.

  • An alignment: the address must be a multiple of it.

  • An object representation: the sizeof(T) bytes that encode its value.

The bytes of any object may be inspected through a pointer to unsigned char — this is one of the very few type-punning operations the standard blesses outright:

#include <stdio.h>

int main(void)
{
    double value = 1.0;
    const unsigned char *bytes = (const unsigned char *)&value;

    printf("sizeof(double) = %zu, bytes:", sizeof value);
    for (size_t i = 0; i < sizeof value; ++i) {
        printf(" %02X", bytes[i]);      // 00 00 00 00 00 00 F0 3F on a little-endian x86
    }
    putchar('\n');
    return 0;
}

unsigned char is special because it has no padding bits, no trap representations, and is explicitly permitted to alias any object type. char and signed char are not interchangeable with it for this purpose — use unsigned char for raw bytes, always.

Byte order is not specified by C. That same loop is how you discover it, and why any format that leaves the process needs explicit serialization:

#include <stdint.h>
#include <stdio.h>

static bool is_little_endian(void)
{
    const uint16_t probe = 0x0102u;
    return *(const unsigned char *)&probe == 0x02u;
}

// Portable serialization: build the bytes yourself, no casting involved.
static void store_be32(unsigned char out[4], uint32_t value)
{
    out[0] = (unsigned char)(value >> 24);
    out[1] = (unsigned char)(value >> 16);
    out[2] = (unsigned char)(value >> 8);
    out[3] = (unsigned char)value;
}

static uint32_t load_be32(const unsigned char in[4])
{
    return (uint32_t)in[0] << 24 | (uint32_t)in[1] << 16
         | (uint32_t)in[2] << 8  | (uint32_t)in[3];
}

int main(void)
{
    unsigned char wire[4];
    store_be32(wire, 0xDEADBEEFu);

    printf("%s, round trip = %08X\n",
           is_little_endian() ? "little-endian" : "big-endian", load_be32(wire));
    return 0;
}

C23 adds <stdbit.h> with __STDC_ENDIAN_NATIVE__, __STDC_ENDIAN_LITTLE__ and __STDC_ENDIAN_BIG__ so endianness can finally be tested at compile time.

Effective Types and Strict Aliasing

The strict aliasing rule: an object’s stored value may only be accessed through an lvalue of a compatible type — with the exceptions of a signed/unsigned variant of it, a qualified version of it, an aggregate containing it, or a character type.

The rule exists so the compiler can assume that a write through an int * cannot change what a float * points at, and therefore keep values in registers. Violating it produces code that works at -O0 and breaks at -O2:

static float bad_bit_cast(int bits)
{
    return *(float *)&bits;         // UNDEFINED: reads an int object through a float lvalue
}
warning: dereferencing type-punned pointer will break strict-aliasing rules
         [-Wstrict-aliasing]

There are exactly three correct ways to reinterpret bytes.

1. memcpy — Always Correct

#include <stdint.h>
#include <stdio.h>
#include <string.h>

static float bits_to_float(uint32_t bits)
{
    float result;
    static_assert(sizeof result == sizeof bits, "float must be 32 bits here");

    memcpy(&result, &bits, sizeof result);      // no aliasing violation, and free at -O2
    return result;
}

static uint32_t float_to_bits(float value)
{
    uint32_t bits;
    memcpy(&bits, &value, sizeof bits);
    return bits;
}

int main(void)
{
    printf("%g <-> %08X\n", bits_to_float(0x3F800000u), float_to_bits(1.0f));
    return 0;
}

Every mainstream compiler turns a memcpy of a register-sized object into a single move at -O1 and above — this is the idiom to reach for, not a fallback.

2. A union — Permitted, Even Reading a Member Other Than the Last Written

Writing one member and reading another is implementation-defined rather than undefined in C (unlike C++), and every mainstream compiler documents it as working:

#include <stdint.h>
#include <stdio.h>

union FloatBits {
    float as_float;
    uint32_t as_bits;
};

int main(void)
{
    union FloatBits u = { .as_float = 1.0f };
    printf("%08X\n", u.as_bits);        // 3F800000 -- fine through a union
    return 0;
}

The catch is that this only works when the punning happens through the union object itself; taking a float * into a union member and using it elsewhere brings the aliasing rule back.

3. unsigned char Access — For Inspection

Reading any object’s bytes through unsigned char is always allowed, as in the first example on this page.

The Escape Hatch

-fno-strict-aliasing tells GCC/Clang to abandon the assumption. The Linux kernel builds with it. It is a legitimate choice for a codebase full of legacy punning, and it costs real optimization — so prefer fixing the code.

Explicit Pointer Conversions

#include <stdint.h>
#include <stdio.h>

struct Header { uint16_t magic; uint16_t length; };

int main(void)
{
    // Object pointer <-> void *: implicit, always safe.
    struct Header h = { .magic = 0xC0DEu, .length = 8u };
    void *opaque = &h;
    struct Header *back = opaque;

    // Object pointer <-> integer: use uintptr_t, the only type guaranteed to fit.
    uintptr_t as_int = (uintptr_t)&h;
    struct Header *round_tripped = (struct Header *)as_int;

    printf("%04X %u %d\n", back->magic, round_tripped->length, back == round_tripped);
    return 0;
}

What is not safe: converting between incompatible object pointer types and then dereferencing (the aliasing rule above), and converting to a type with stricter alignment than the object has. Casting a unsigned char * from the middle of a buffer to uint32_t * and dereferencing it is undefined on both counts — misaligned access is a crash on some architectures and a silent slowdown on x86. memcpy out of the buffer instead.

Function pointers are a separate universe: converting one function-pointer type to another and calling it is undefined, and there is no guarantee a function pointer fits in void * (POSIX requires it, C does not).

Alignment

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

struct Vec4 { float v[4]; };

int main(void)
{
    printf("alignof(char)=%zu int=%zu double=%zu max_align_t=%zu\n",
           alignof(char), alignof(int), alignof(double), alignof(max_align_t));

    // C11/C23: over-align an object -- e.g. to a cache line or for SIMD.
    alignas(64) unsigned char cache_line[64] = { 0 };
    alignas(16) struct Vec4 simd = { .v = { 1.0f, 2.0f, 3.0f, 4.0f } };

    // Over-aligned dynamic memory: size must be a multiple of the alignment.
    void *block = aligned_alloc(64, 128);
    if (block == nullptr) {
        return EXIT_FAILURE;
    }

    printf("cache_line %% 64 = %zu, simd %% 16 = %zu, block %% 64 = %zu\n",
           (uintptr_t)cache_line % 64u, (uintptr_t)&simd % 16u, (uintptr_t)block % 64u);

    free(block);                    // aligned_alloc memory is freed with plain free
    return 0;
}

The rules worth committing to memory:

  • Every type’s alignment is a power of two dividing its size; max_align_t’s alignment is the strictest any standard type needs, and it is what `malloc guarantees.

  • malloc/calloc/realloc return memory suitably aligned for any object type — you never need aligned_alloc unless you need over-alignment (SIMD, cache lines, DMA).

  • alignas may only increase alignment (alignas(1) int is an error), and applies to declarations, not expressions. C11 spelled it _Alignas.

  • Under-aligned access is undefined behavior: -fsanitize=alignment (part of -fsanitize=undefined) catches it.

Over-alignment is also how you avoid false sharing: two atomics on the same cache line serialize even though the code never shares them. See Atomics and Memory Consistency.

See Also

References