Systems Programming · Unsafe Code

Strict Aliasing and the Cases Where Type Punning Is Legal

The effective type rules permit far more access through differing types than the folklore suggests, and the sanctioned escape hatches are narrower than the compiler flags that disable the analysis.

The rule that forbids reading a float through an int * is one of the most repeated statements in C, and one of the most frequently overstated. The standard does not forbid accessing an object through a pointer of a different type in general. It defines, for each object, a single effective type, and it constrains which lvalues may be used to access that object. The constraints have exceptions, the exceptions cover the idioms programmers actually need, and the boundary between the permitted and the forbidden is precise enough to state.

How an object acquires an effective type

For an object with a declared type, the effective type is that type. For an object obtained from malloc, the effective type is set by the first store into it: if the store is through an lvalue of type T, the effective type becomes T for the bytes written. Subsequent stores may change it, but only through lvalues that are already permitted to access the object, which is where the recursion becomes interesting.

The access rule then says that an object’s stored value may be read or modified only through an lvalue whose type is compatible with the effective type, or is a qualified version of it, or is a signed or unsigned type corresponding to it, or is an aggregate or union type containing one of the above, or is a character type.

Each clause of that list is a permission, and the list is longer than the folklore.

The signed and unsigned clause means that reading an unsigned int through an int * is legal, and vice versa. The representation is shared and the standard explicitly permits the pairing, which is why code that inspects the sign bit of a value by casting a pointer does not violate this rule.

The aggregate clause covers the container direction: an lvalue whose type is a structure or union that includes the object’s effective type among its members may access that object. This is what makes assignment of a whole structure legal, since the assignment writes each member through an lvalue of the aggregate type. It does not license the reverse, casting a pointer to a structure into a pointer to one of its members and back; the member-to-container direction works only because the address of a structure and the address of its first member are the same, which the standard guarantees but which is a separate fact from the access rule.

The character clause is the widest. An lvalue of type char, signed char, or unsigned char may access any object. This is the clause that makes memcpy, memcmp, and every serialisation routine legal. It covers the fixed-width typedefs that resolve to one of those three types, which is the usual case for int8_t and uint8_t. It does not cover the wide character types: char16_t, char32_t, and wchar_t are distinct types even where their representation matches a narrow character type, so an lvalue of one of them may not access an arbitrary object.

What the rule actually forbids

Given those permissions, the forbidden set is smaller than it appears. Reading a float object through an int * is forbidden, since neither type is compatible with the other and neither is a character type. Reading a uint32_t through a float * is forbidden for the same reason. Accessing a struct through a pointer to a different struct type with a different member list is forbidden. Accessing an array of double through an int * is forbidden.

What is permitted, and frequently assumed to be forbidden, includes reading any object byte by byte, reinterpreting between signed and unsigned variants of the same width, accessing a union member through the union type itself, and copying bytes between objects of different types with memcpy.

The prohibition exists for the optimiser, not for the hardware. If the compiler can assume that a store through a float * cannot affect an object accessed through an int *, it can keep the integer in a register across the store. If it cannot make that assumption, it must reload. The assumption is what makes inner loops over arrays of a single type compile to code that touches memory once per iteration instead of twice.

The sanctioned punning mechanisms

Three techniques are legal and portable, and they differ in cost.

memcpy between objects of different types is fully defined, because the character-type clause makes the byte copy legal and the destination object’s effective type governs subsequent reads through it. Compilers recognise the pattern of a fixed-size memcpy into a local and lower it to a register move; the function call is not necessarily a call.

float f = 1.0f;
uint32_t bits;
memcpy(&bits, &f, sizeof bits);   /* defined; usually compiles to a move */

A union with members of the types in question is permitted in C: writing one member and reading another is implementation-defined rather than undefined, and the value read is the reinterpretation of the stored bytes, subject to the implementation’s representation. In C++ the same construct is undefined behaviour, which is why code shared between the two languages must not use it. The C standard’s guarantee is weaker than it looks: the result depends on the representation, so a union pun between float and uint32_t is defined only in the sense that the implementation must document what it does.

std::bit_cast in C++20 is the modern form. It requires the two types to be trivially copyable and the same size, and it produces the value with the same object representation, with no aliasing question at all because no pointer is involved. Its constraint set is exactly the set of cases where the reinterpretation is meaningful.

A fourth technique, casting the pointer and dereferencing it, is what the rule forbids. It is also what most legacy code does, and it is why the flag exists. It is worth separating from the pointer-validity question: a cast can satisfy the aliasing rule and still produce a pointer the compiler may not dereference, as Pointer Provenance and the Cost of Integer Round-Trips describes.

Why -fno-strict-aliasing is a blunt instrument

The type-based alias analysis can be disabled wholesale, and doing so is common in codebases that cannot audit every cast. The flag does not make the pointer cast legal; it makes the compiler stop exploiting the assumption. A program compiled with it may behave as intended on that compiler version, and the same program compiled without it, or with a compiler that infers the same facts from a different analysis, may not.

The flag also costs more than the casts it protects. Disabling type-based alias analysis forces the compiler to assume that any store through any pointer may affect any load through any pointer, which suppresses load hoisting across calls and inhibits vectorisation of loops over arrays whose element type it cannot prove distinct from a pointer used elsewhere. The cost is not uniform: pointer-heavy code loses more than array-heavy code, and code that already uses restrict on its hot pointers loses less.

The narrower tool is the may_alias attribute, which marks a specific type as exempt from the analysis. A type so marked can be used to access any object without the compiler concluding that accesses through it are disjoint from accesses through other types. The attribute is a GCC extension, and Clang accepts it. It is the right instrument when a single type, such as a vector type used for unaligned loads, is the source of the casts.

restrict is the opposite instrument. It is a promise from the programmer that a pointer is the only route to the object it points at for the duration of the block, and it lets the compiler assume disjointness that it could not otherwise prove. A restrict violation is undefined behaviour, and the violation is silent.

Reading a struct through a byte pointer, and back

A pattern that appears in every network stack is casting a received buffer to a structure pointer. The buffer is a char array, so the character-type clause permits the access in one direction, but the alignment requirement is separate: a structure with a 4-byte member must be aligned to 4 bytes, and a buffer at an odd address is not. On x86-64 an unaligned load is legal and slow; on a target that faults, it is a crash. The standard’s requirement is unconditional.

The reverse direction, taking a structure and reading it as bytes, is always permitted, which is why checksum and hash routines can operate on any object. The subtlety is padding: the bytes between members have unspecified values, and a hash that includes them produces different results for two structures with equal members. A serialisation routine that copies a structure wholesale also copies the padding, which leaks whatever was in those bytes and breaks interoperability with a peer whose compiler laid the structure out differently.

Both problems have the same fix and it is not a compiler flag: define the wire format field by field, and never transmit a structure’s object representation. The unsafe boundary in a language that marks these operations is the right place to state the alignment precondition, which is the subject of What Makes a Safe Wrapper Around Unsafe Code Sound. The aliasing rule and the alignment rule are independent, and a cast that satisfies one can violate the other.

References

  1. ISO/IEC 9899:2011, 6.5 Expressions — ISO/IEC
  2. GCC documentation: Type-punning and -fstrict-aliasing — GCC project
  3. C++ reference: std::bit_cast — cppreference