Strict aliasingtype punning that works until you turn on the optimizer
Casting a float * to an int * and dereferencing it is undefined behaviour, not a portability wrinkle. The same function returns 1073741824 at -O0 and 1 at -O2, from one binary, on one machine.
#The warning
This is the string people paste into a search box:
warning: dereferencing type-punned pointer will break strict-aliasing rules [-Wstrict-aliasing]It needs two things at once, which is why it shows up the day someone raises the optimization level on a build that has been quiet for years. -Wstrict-aliasing only does anything while -fstrict-aliasing is active, and -fstrict-aliasing turns on at -O2. It also is not on by itself: you get it from -Wall. Neither flag alone produces a word.
Then there is the part worth knowing before you trust it. Two ways of writing the same undefined behaviour, measured on GCC 11.4.0:
/* A: the cast and the dereference in one expression */
float f = 1.0f;
int i = *(int *)&f;
/* B: the same address, reached through a function */
int punned(float *f, int *i) {
*i = 1;
*f = 2.0f; /* compiler may assume this cannot touch *i */
return *i;
}
punned((float *)&x, &x);| Flags | A, direct cast | B, through a function |
|---|---|---|
-O0 -Wall, -O1 -Wall | silent | silent |
-O2 alone | silent | silent |
-O2 -Wall | warns | silent |
-O2 -Wall -Wextra | warns | silent |
-O2 -Wstrict-aliasing=2 | warns | warns |
Read the second column. -Wall selects -Wstrict-aliasing=3, the most conservative of the three levels, which catches a cast and a dereference sitting in one expression and nothing further. Version B is the one that actually miscompiles below, and -Wall -Wextra has nothing to say about it. -Wstrict-aliasing=2 finds it, at the documented cost of more false positives.
Two more details. The diagnostic points at the cast in the caller rather than the dereference inside the callee, so the line number sends you to the wrong function. And GCC only reports what it can see through in one translation unit: put punned in another file and even level 2 goes quiet while the undefined behaviour does not.
#What it costs at -O2
Version B above, GCC 11.4.0, same source, same machine:
| Build | Output |
|---|---|
-O0 | 1073741824 |
-O1 | 1073741824 |
-O2 | 1 |
-O3 | 1 |
-O2 -fno-strict-aliasing | 1073741824 |
1073741824 is 0x40000000, the bit pattern of 2.0f, which is what the machine actually holds at that address. 1 is what the optimizer believes: *f and *i have incompatible types, so the store through f cannot have changed *i, so the load of *i can be replaced with the 1 that was stored two lines earlier.
Neither answer is a bug in GCC. The program has no defined meaning, so both are permitted, and the optimization level decides which one you get. This is the same failure shape as goto skipping an initializer: the debug build and the release build disagree, and the release build is the one shipping.
#The effective type rule
C23 6.5p7 (unchanged from C11) lists the lvalue types through which an object's stored value may be accessed:
- a type compatible with the effective type of the object
- a qualified version of such a type
- the signed or unsigned type corresponding to the effective type, or to a qualified version of it
- an aggregate or union type containing one of the above among its members, recursively
- a character type
The effective type of a declared object is its declared type. For malloc'd memory there is no declared type, so the effective type is set by whatever lvalue last stored into it, and reading it back through an incompatible type is the same violation.
Note what is on the list and what is not, because the difference is measurable. int and unsigned int are on it, so writing through one and reading the other survives the optimizer: the same test returns 2 at -O0 and 2 at -O2. long and long long are not on it, even though both are 8 bytes here with identical representation. The equivalent test returns 2 at -O0 and 1 at -O2. Compatibility is a property of the type, not of the layout, and no amount of "but they are the same width" changes it.
The character-type entry is one-directional. You may read any object byte by byte through char *; you may not write an int through a float * and then claim the character exemption covers it.
#Unions: legal in C
Writing one union member and reading another is defined in C. C23 6.5.3.4 footnote 93 says so directly: when the member read is not the member last written, "the appropriate part of the object representation of the value is reinterpreted as an object representation in the new type as described in 6.2.6 (a process sometimes called type punning)."
int f2i(float f) {
union { float f; int i; } u = { .f = f };
return u.i; /* defined in C */
}Returns 1065353216 for 1.0f, at every optimization level, with no warning under -Wall. Two caveats travel with it. The same footnote warns the result "may be a non-value representation", so punning into a type with trap representations or padding bits is still on you. And this is a C rule: C++ has no equivalent sanction, so a header shared between the two languages cannot rely on it.
#memcpy, and why it is free
The portable answer is memcpy, and the objection to it is always performance. That objection has not been true for a long time:
int f2i(float f) { int i; memcpy(&i, &f, sizeof i); return i; }Compiled with gcc -O2 on aarch64, the entire function is:
0000000000000000 <f2i>:
0: 1e260000 fmov w0, s0
4: d65f03c0 retOne instruction and a return. No call, no stack traffic, no copy. GCC and Clang both recognise a fixed-size memcpy between two objects of the same width and lower it to a register move. You are writing the operation you mean and paying nothing for it.
The rule of thumb: if the size is a compile-time constant and the objects do not overlap, assume the memcpy disappears, and check the disassembly rather than the folklore if it matters.
#The escape hatches
-fno-strict-aliasing turns the whole class of optimization off for a translation unit. It is what the Linux kernel builds with, and it is the right answer for a codebase with too much existing punning to audit. It is not free: the compiler must assume any store through any pointer can touch any object, which blocks vectorization and load hoisting across pointer writes. Reach for it as a mitigation, not a design.
__attribute__((may_alias)) is the surgical version, supported by GCC and Clang. A type marked with it is treated as capable of aliasing anything, so you can exempt one struct rather than the whole file.
Character types. Reading any object byte by byte through unsigned char * is always allowed. This is why memcmp and hashing a struct by its bytes are defined operations, and also why they will happily read uninitialized padding.
What does not work: casting through void *, or through an intermediate variable, to silence the warning. Both make the diagnostic go away and leave the undefined behaviour in place. If your fix was to add a cast, you have hidden the problem from the compiler and not from the optimizer.
#How Prism handles it
Prism does not change aliasing rules. It preprocesses, transforms, and hands the result to your real compiler, so -fno-strict-aliasing, -Wstrict-aliasing and everything else pass through to cc unchanged and behave exactly as they would without Prism in the chain.
That is worth stating plainly, because it is the honest answer for most of this page. Strict aliasing is a rule about what your code means, not about what a transpiler can insert. The fix is memcpy or a union, in your source, whatever compiles it.
#Further reading
- ISO/IEC 9899:2024 (C23), N3220 working draft: 6.5p6 and p7 (effective type and the access rule), 6.5.3.4 footnote 93 (union punning).
- GCC Optimize Options:
-fstrict-aliasing,-fno-strict-aliasing, and which levels enable it. - Mike Acton, "Understanding Strict Aliasing", still the clearest treatment of the pointer cases.
- SEI CERT C, EXP39-C, with the non-compliant and compliant pairs.
Measured on GCC 11.4.0, Ubuntu 22.04, aarch64. The x86_64 instruction for the memcpy case differs; the instruction count does not.