Systems Programming · C and the ABI

Strict Aliasing and the Effective Type of an Object

Why two pointers of different types cannot in general name the same memory, which union and character-type accesses are permitted, and what an optimiser does with the rule.

A function that writes through a float * and reads through an int * at the same address compiles without a diagnostic and may return either the value just written or the previous value. The choice depends on the optimisation level and on the surrounding code. The rule that permits this is strict aliasing, and it is the reason a cast between pointer types is not the harmless reinterpretation it appears to be.

The rule is a promise the programmer makes to the compiler: two lvalues of incompatible types do not designate the same object. In exchange, the compiler may assume that a store through one pointer cannot affect a load through the other, and may keep values in registers across the store. Almost every non-trivial optimisation depends on that assumption.

The effective type of a region of storage

The rule is stated in terms of effective type. Every object that has a declared type has that type as its effective type. For an object allocated by malloc or declared as an array of bytes, the effective type is established by the first store: storing through an lvalue of type T sets the effective type of the region to T for that access and for subsequent accesses that do not modify it. A copy into a malloced buffer with memcpy from an object of type T sets the effective type to T, which is why memcpy is the sanctioned way to reinterpret bytes: it is defined to copy the representation, and the resulting object has the source’s effective type.

The aliasing rule then says that an object’s stored value may be accessed only by an lvalue of a type compatible with its effective type, or by an lvalue of a signed or unsigned type corresponding to the effective type, or by a character type. The character-type exception is what makes memcpy and byte-oriented code legal: char, signed char, and unsigned char are all character types in C, and any of them may inspect any object.

The corresponding signed or unsigned type exception covers the common case of reading a long through an unsigned long pointer. It does not cover reading an int through a float pointer, nor accessing an array of int through a pointer to a differently-typed element in a way that crosses element boundaries. A structure and its first member are a special case: a pointer to the structure, suitably converted, points to its initial member and vice versa, so that direction of access is permitted even though the types are not compatible.

What the optimiser does with the assumption

The value of the rule is visible in the code the compiler produces. Consider a function that stores through one pointer and then loads through another.

void scale(int *dst, float *src, int n)
{
    for (int i = 0; i < n; i++)
        dst[i] = (int)(src[i] * 2.0f);
}

Under strict aliasing, dst and src cannot overlap, so the compiler may load a block of src, convert it, and store a block of dst without re-reading src after each store. The loop can be unrolled and vectorised, because no store can invalidate a previously loaded value. Without the assumption, every store to dst would have to be treated as a potential write to the memory src points at, and the loop would have to reload after each iteration.

The same reasoning appears at a smaller scale. In a function that takes a struct pair * and an int *, a store through the int * cannot affect a field of the struct, so a field loaded before the store need not be reloaded after it. If a caller passes a pointer into the middle of the struct, the assumption is false, and the optimised code reads a stale value. The failure is not a crash; it is a wrong answer that appears only at higher optimisation levels and only when the compiler chooses to keep the value in a register.

GCC documents -fstrict-aliasing as enabled at -O2 and above, and both GCC and Clang provide -fno-strict-aliasing to disable the assumption. Disabling it costs performance across the whole translation unit, which is why it is a last resort rather than a design choice.

The union question and the type-punning idiom

The classic way to reinterpret a float as its bit pattern is to write through one union member and read through another. Whether that is permitted has a history.

In C89 and C99 as originally written, reading a union member other than the last one stored was unspecified or implementation-defined. A later technical corrigendum to C99 added a footnote permitting the practice when the read is of a type that corresponds to the stored type, and GCC and Clang both document type punning through a union as supported. The standard’s own text, though, describes a union as holding the value of at most one member at a time, so the footnote is an explicit permission for a case the normative wording does not otherwise cover.

The practical position is that union punning works on the major compilers and is used widely in floating-point code, but it is not portable in the strict sense. The fully portable form is a memcpy between two objects of the respective types, which the compiler recognises and compiles to a single move.

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

uint32_t float_bits(float f)
{
    uint32_t u;
    memcpy(&u, &f, sizeof u);   /* defined; typically a single register move */
    return u;
}

The memcpy version has no aliasing problem because the character-type exception covers the copy, and the destination’s effective type becomes uint32_t. Compilers at ordinary optimisation levels reduce the two-object copy to a load and a store, or to nothing at all when the value stays in a register.

Where the rule is routinely broken

Four patterns account for most strict-aliasing violations in real code.

The first is a cast between pointer types followed by a dereference. int i = 0; float *f = (float *)&i; *f = 1.0f; writes a float representation into an int object. The object’s effective type is int, the store is through a float lvalue, and the rule is violated. The write itself usually succeeds; the subsequent read of i may return the old value, because the compiler is entitled to assume the store did not touch it.

The second is a generic container-of macro, which recovers a structure pointer from a pointer to one of its members by subtracting the member’s offset. The arithmetic is unremarkable when the recovered type is the type of the object that actually contains the member, since the access then uses the object’s own effective type. It becomes a violation when the macro is used generically, so that a member embedded in a wrapper structure is accessed through a pointer to a different structure type. Intrusive containers that require the link member to sit in a known header structure avoid the problem by making the recovered type the real one; a macro that assumes every embedding structure has the same layout does not.

The third is a byte buffer reused for several types. A buffer allocated as char[] and cast to struct header *, written, then cast to struct body *, has an effective type that changes with each store. Reading the header after writing the body is a violation unless the header was copied out first. The same buffer used as a ring of records of one type is fine; the same buffer reinterpreted as two overlapping types is not. Packed structures make this easier to trigger by accident, since a member access through a cast pointer to a packed type is both an aliasing question and an alignment question, as described in bitfield allocation and packed structures.

The fourth is a function that takes a void * and casts it to the caller’s type. The cast itself is harmless; the problem is that the function’s parameter type gives the compiler no information about what it may alias, so the compiler must assume the worst and cannot optimise across the call. The remedy is a parameter of the actual type, or a restrict qualifier that promises the pointer does not alias any other pointer used in the same scope.

restrict and the promise in the other direction

restrict is the explicit form of the aliasing promise. A restrict-qualified pointer parameter asserts that the object it points to is accessed only through that pointer, and through pointers derived from it, for the duration of the block. A store through a restrict pointer cannot affect a load through any other pointer in the same scope, which permits exactly the block-level optimisation that strict aliasing enables for distinct types.

The promise is stronger and easier to break. memcpy and memmove are the canonical pair: memcpy takes restrict pointers and is undefined for overlapping regions, while memmove does not and handles overlap. The standard library’s memcpy can therefore copy in large aligned blocks without checking for overlap, while memmove must compare the addresses and choose a direction. The cost difference is the price of the overlap check, and the distinction between the two functions exists precisely because the check cannot be avoided when overlap is possible.

A restrict violation is undefined behaviour with no diagnostic. Passing overlapping buffers to a restrict-qualified function that writes through one and reads through the other produces a result that depends on the copy direction and block size the compiler chose, which is why the failure looks like intermittent corruption rather than a consistent error.

Living with the rule

The practical discipline has three parts. Reinterpret bytes only through character types or memcpy, never through a cast pointer. Keep a region’s effective type stable for the duration of its use, and copy out before changing it. Use restrict only where the no-overlap property is actually guaranteed by the caller’s contract, and document it, because it is the one promise whose violation cannot be detected.

The compiler’s diagnostics help less than they might. -Wstrict-aliasing in GCC warns on some patterns at some levels, and its coverage is incomplete by design; the analysis that would catch every violation is equivalent to the optimisation the rule enables. The warnings that do appear are worth fixing, and their absence is not evidence that the code is correct.

The rule connects to the layout questions elsewhere in this cluster. A cast that changes a pointer’s type also changes the offsets the compiler uses to address members, which is why struct layout and aliasing interact: two structures with the same members in the same order still have incompatible types, and accessing one through the other’s pointer is a violation even when every offset matches. The exception for a common initial sequence applies only within a union, not across a cast.

References

  1. ISO/IEC 9899:2018 — Programming languages — C — ISO/IEC
  2. ISO/IEC 9899:2024 — Programming languages — C — ISO/IEC
  3. GCC Manual — Options That Control Optimization — GNU Project
  4. Clang Documentation — Language Extensions — LLVM Project