Type punning in C and C++ silently fails at -O2
A pointer cast that has always worked in practice can be undefined behavior and disappear from the binary as soon as the compiler turns on aggressive optimizations. Understand why this is even more treacherous in C++.
A post published on September 21, 2026 on the Personal Workflow Blog (read here) reignited a discussion that anyone who has worked with binary serialization, network protocols, or direct hardware access in C knows by heart: type punning, the practice of interpreting the same memory region as different types when writing and reading. The author describes a bug that took a while to track down: a pointer cast from float* to uint32_t* worked perfectly at -O0 and silently broke at -O2. The cause wasn't a logic error, it was undefined behavior that only manifests when the optimizer decides to trust what the language promises.
What type punning is and why it exists
Type punning is indispensable in low-level code: extracting the IEEE-754 exponent from a float, assembling a network packet byte by byte, or reinterpreting a struct as an array. The problem, as the post neatly summarizes, is that "works in practice" and "has defined behavior" are different things. Modern compilers aren't obligated to preserve the behavior you observed empirically, only what the language standard guarantees.
The two safe approaches in C: union and memcpy
In C, two techniques are defined behavior. The first is the union:
union {
float f;
uint32_t bits;
} pun;
pun.f = 3.14f;
uint32_t exp = (pun.bits >> 23) & 0xff; // extracts the IEEE-754 exponentThe same pattern works for taking apart entire structs, like a struct color { float r, g, b, a; } read as an array of floats through a union.
The second is memcpy, which looks expensive but the compiler optimizes it down to a simple register move:
float f = 3.14f;
int i;
memcpy(&i, &f, sizeof(f)); // defined behavior, becomes a single instructionBoth cost zero at runtime once compiled. The difference from the pointer cast isn't performance, it's guarantee.
The pointer cast that works until it stops working
The most common pattern, and the most dangerous, is the direct cast:
int *p = (int *) &f;
int i = *p;This compiles, runs, and gives the "correct" answer on any platform you test it on. And yet it's undefined behavior, because it violates the strict aliasing rule: an object can only be accessed through an lvalue of its effective type, a qualified version of it, or a char type. A pointer converted to an unrelated type breaks this rule, and the compiler is free to assume that this never happens.
The real bug: why -O1 and -O2 give different answers
The post's example shows the practical effect of this freedom. Given the code:
struct c {
uint32_t a;
uint32_t b;
};
uint32_t bar(uint64_t *u64, struct c *c) {
if (c->a == 2) {
*u64 = 4;
}
return c->a;
}
int main() {
struct c c = { 2, 3 };
return bar((uint64_t *) &c, &c);
}With GCC or Clang at -O2, this function returns 2. At -O1 or below, it returns 0. The difference: the compiler sees u64 as uint64_t* and c as struct c*, different types, and assumes they don't alias each other. The second check c->a == 2 is eliminated because, under this assumption, writing to *u64 could never change c->a. From the standard's point of view, the compiler is right, even though the two pointers point to the same memory address in practice.
It's this kind of behavior that makes strict aliasing one of the most common causes of bugs that "only show up with optimization turned on" in C and C++, and that tends to be wrongly dismissed as a compiler bug when it's actually code with UB that ran out of luck.
Why C and C++ diverge here
The post points to a difference in philosophy that explains why the same technique is riskier in C++: in C, types are a way of interpreting memory; in C++, types are first-class citizens, and the compiler has explicit permission to assume that different types never alias each other. This gives the C++ compiler even more room for aggressive optimizations under the "as-if rule," the rule that allows any transformation as long as observable behavior doesn't change, as far as the compiler can tell. The post cites the article "Taking a Byte Out of C++, Avoiding Punning by Starting Lifetimes" as a deeper reference on this language design choice.
What changes for those who maintain legacy code
C and C++ code written in the 2000s and 2010s is full of pointer casts for type punning, often because the union seemed "too verbose" or because the team never read the strict aliasing rule all the way through. That code runs today at -O0 or -O1 with no apparent problem. The concrete risk: any toolchain update, build flag change, or bump from -O1 to -O2/-O3 in a release step can expose different behavior with no source code change at all. This is particularly treacherous in pipelines where debug builds use -O0 and production uses -O2 or higher: the bug simply doesn't exist in debug.
The post's practical rule is straightforward: in C, use union or memcpy for type punning, never a pointer cast. In C++, the same recommendation applies, with an added catch: the compiler has even more freedom to break this kind of code under the as-if rule.
The post's author closed out his own case like this: a pointer cast from float* to uint32_t* in a hot loop, optimized under strict aliasing assumptions at -O2, causing the written values to never show up where they were expected. The fix with a union took ten minutes. Finding the bug took much longer.
Translated from the Brazilian Portuguese original · Read the original
Janas roda modelos de IA maiores que a RAM, direto do SSD
O projeto Janas-LLM transmite os experts de modelos MoE do SSD para a memória sob demanda e afirma superar o llama.cpp em velocidade em todos os 13 modelos testados.
