C's Strict Aliasing Trap: The Type Pun That Optimizers Erase

2026-05-08

This C code does some classic IEEE 754 bit-twiddling: reinterpreting uint32_t bits as a float, and flipping the sign bit of a float by toggling its top bit. It compiles cleanly with -Wall -Wextra, runs correctly at -O0, and passes its tests. Then someone enables -O2 in CI and the world catches fire.

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

float negate_via_bits(float x) {
    uint32_t *bits = (uint32_t *)&x;
    *bits ^= 0x80000000u;        // flip sign bit
    return x;
}

float int_to_float(uint32_t i) {
    return *(float *)&i;          // reinterpret bit pattern
}

int main(void) {
    float pi = 3.14159f;
    printf("negated: %f\n",   negate_via_bits(pi));     // expect -3.14159
    printf("from bits: %f\n", int_to_float(0x40490FDBu)); // expect ~3.14159
    return 0;
}

The Bug

Both functions violate the strict aliasing rule (C11 §6.5 ¶7): an object's stored value may only be accessed through an lvalue of a compatible type, a signed/unsigned variant, a char-like type, or a containing aggregate. float and uint32_t are none of those for each other. Reading or writing a float through a uint32_t* is undefined behavior, full stop.

At -O0, the compiler dutifully loads, XORs, and stores, so it appears to work. At -O2, the optimizer's alias analysis assumes pointers of unrelated types cannot reference the same memory. So in negate_via_bits, GCC and Clang are free to:

Worse, the behavior is fragile: it can change between compiler versions, between functions in the same file (depending on whether they get inlined), and between debug and release builds. The bug doesn't even need to be in hot code; once you've hit UB, the compiler is permitted to do anything, including miscompiling code you didn't think was related.

The Fix

Use memcpy. It's the canonical, well-defined way to type-pun in C, and every modern compiler optimizes a fixed-size memcpy between two stack slots into a single register move — zero overhead.

float negate_via_bits(float x) {
    uint32_t bits;
    memcpy(&bits, &x, sizeof bits);
    bits ^= 0x80000000u;
    float out;
    memcpy(&out, &bits, sizeof out);
    return out;
}

float int_to_float(uint32_t i) {
    float f;
    memcpy(&f, &i, sizeof f);
    return f;
}

A union with both members written and read is also allowed in C99+ (a deliberate carve-out from the aliasing rule, though not in C++). C++20 offers std::bit_cast; C23 standardizes a memcpy-equivalent intrinsic. What you must never do is the *(T*)&x dance, no matter how idiomatic it looks in legacy code or how many tutorials show it.

One last gotcha: -fno-strict-aliasing "fixes" the symptom by disabling the optimization, and the Linux kernel famously builds with it. That's a deliberate engineering trade-off in a project that controls its own toolchain — not advice for application code, where you're trading away free performance for an undefined-behavior landmine.

Key Takeaway: Type-punning through pointer casts is undefined behavior in C; use memcpy (or a union) so the optimizer can't assume the aliases away.

All newsletters