Understanding Strict-Aliasing Rules
TL;DR
The strict-aliasing rules allow the compiler to assume that your code does not alias between incompatible types. In this way, the compiler can perform many more optimizations. Also, when it comes to circumventing the type system, these rules lead programmers to achieve type puns in standard methods, avoiding the crazy jungle of unregulated aliasing.
What is aliasing?
You get aliasing when there is more than one variable (to be more precise an lvalue) referring to the same memory location. We can achieve this effect using pointers, as in this example:
int ival;
int alias_ival = &ival;
Here alias_ival points to the same memory location as ival, so writing or reading that data via either variable has the same effect. It’s just about using another name for the same thing. That’s aliasing.
We can also get aliasing in a case like this:
void foo(int* iptr_1, int* iptr_2) {
/* do something with those parameters */
}
int main() {
int ival;
foo(&ival, &ival);
}
Since we pass the same address to the two parameters iptr_1 and iptr_2, they alias each other within the foo’s body.
Wild aliasing
If C and C ++ were a wild west with no rules about aliasing, compilers would be unable to optimize many pieces of code. They should always consider that anything can alias anything. They would constantly live in a state of paranoia because a change made via a pointer could ideally involve any other memory location. So there would be no room for optimizations.
Now we understand why the standards need to set clear rules on this matter.
Welcome strict-aliasing rules
Both the C and C ++ standards define which types can alias which other types.
The strict-aliasing rules are here to allow compiler creators to safely assume, under certain conditions, that a change to one variable will not affect the value of another variable. And on the other hand, they let them know when two variables could otherwise refer to the same memory location.
Compilers can assume that we follow strict-aliasing rules, and thus they can perform a whole bunch of optimizations. If we don’t follow these rules, we end up in the dark world of Undefined Behavior. In that foggy place, we can no longer be sure of anything. Our program would no longer be reliable and therefore better stay away from it.
Unfortunately, in the world of undefined behavior, our program may still work in some cases, giving us a false sense of reliability. What can happen is that maybe with a later version of the compiler or maybe just by setting a different optimization level, everything breaks down. Now we can see why it is essential to understand the strict-aliasing rules to avoid violating them.
An example of undefined (and unexpected!) behavior
#include <stdio.h>
int foo(int* iptr, float* fptr) {
*iptr = 1;
*fptr = 0.F;
return *iptr;
}
int main() {
int ival = 0;
printf("ival (expected 0): %d\n", ival);
ival = foo(&ival, (float *)&ival);
printf("ival (expected 0): %d\n", ival);
}
In this small example we pass to the foo function the address of ival in both the iptr and the fptr parameter. Then we store back the result to ival.
ival = foo(&ival, (float *)&ival);
Let’s say we’re sure what we’re doing, to avoid getting an “incompatible pointer types” warning we need to cast the second argument to a floating point pointer when calling foo.
(float *)&ival
Now, calling foo this way, iptr and fptr both refer to the same memory location. So we would expect that when we assign the value zero to fptr, since it is the last assignment in the function body, we also set the return value of the foo function to zero. If by any chance you are wondering, yes, the number zero has the same binary representation as both int and float: all bits are set to zero.
So, following this logic, we would expect both printfs to display zero. If we compile with gcc and we start by setting the optimization level to zero (-O0), we might be right.
gcc example.c -o example -O0
In fact, the output of our program in this case is:
ival (expected 0): 0
ival (expected 0): 0
So for now, no surprise. The program followed our straightforward logic. However, let’s say we now change the optimization level, setting it to two (-O2).
gcc example.c -o example -O2
This time we get an unexpected result, if we don’t know the strict-aliasing rules of course:
ival (expected 0): 0
ival (expected 0): 1
The second time we print ival we get 1. This happens because the compiler performs optimizations assuming that the *fptr = 0.F assignment cannot change the value pointed to by iptr, since the two pointers are not of compatible types. Simply, the compiler is assuming that we follow the strict-aliasing rules. Since, according to its logic, the value pointed to by iptr cannot be changed, it then returns a fixed value of 1, without loading the actual content of iptr back into the registers.
The rules
So let’s see which rules we have to follow. The C11 ISO/IEC Standard, in section 6.5 “Expressions”, paragraph 7, tells us:
An object shall have its stored value accessed only by an lvalue expression that has one of the following types:
- a type compatible with the effective type of the object
- a qualified version of a type compatible with the effective type of the object
- a type that is the signed or unsigned type corresponding to the effective type of the object
- a type that is the signed or unsigned type corresponding to a qualified version of the effective type of the object
- an aggregate or union type that includes one of the aforementioned types among its members (including, recursively, a member of a subaggregate or contained union)
- a character type
Translating these cryptic words and summarizing:
- In most cases, compatible types simply mean the same type. At most, it can be varied by combining the
const,signed,unsigned,volatilekeywords. - A
structor aunioncan alias types contained inside them. - A character type can alias anything. This means that
char *,unsigned char *,signed char *, can point to anything in memory.
Prevent aliasing with the restrict keyword
All the rules we’ve seen above should always remain in the mind of a programmer to know when you can alias or not. The restrict keyword, instead, forces you not to alias. You can use this keyword in a pointer declaration to promise to the compiler that only the pointer itself or a value directly derived from it (e.g. pointer + 1) can be used to access the pointed object during the whole lifetime of that pointer. In substance, here you are promising not to alias that pointer.
For example, in the function permitted_aliasing you can even pass the same address as ptr_a and ptr_b parameters (so, doing aliasing). This way, the compiler cannot perform a whole bunch of optimizations.
void permitted_aliasing(int* ptr_a, int* ptr_b);
On the other hand, in the function forbidden_aliasing you can’t pass the same address as ptr_a and ptr_b and you also promise not alias them inside the function body. So, in this case aliasing is forbiddend on those pointers. Now, the compiler is free to optimize the access of those pointed objects.
void forbidden_aliasing(int* restrict ptr_a, int* restrict ptr_b);
Circumventing the type system
At this point we might ask ourselves what can push us to alias between incompatible types, breaking the strict-aliasing rules. We can find the answer if we think that C and C ++ are strongly typed languages.
The key idea is that we may want to find a way to circumvent the type system and reinterpret an object as a different type. We are talking about type puns, and we’ll address this topic with no haste in another Dev Tip.