Volatile Type Qualifier In C/C++
Introduction
The volatile type qualifier is used to indicate that an object can be modified in ways unknown to the compiler. Every access (both read and write) made through an lvalue expression of volatile-qualified type is considered an observable side effect for the purpose of optimization and is evaluated strictly according to the rules of the abstract machine. Therefore, the compiler will only perform very limited optimizations on the code that involves volatile-qualified objects.
In this blog post, we will present a few examples of using the volatile type qualifier in C/C++ programs, examine the assembly code generated by the compiler, and discuss how the volatile type qualifier can be used to disable optimization on purpose.
Non-Volatile Type Qualifier
We will create a simple programs to demonstrate the difference between the assembly code generated by optimization level 0 and 3 for a simple addition program using non-volatile type qualifier.
Adding Two Int Values
This is a simple program that adds two integer values 1 and 2 and stores the result in a third integer variable.
1 | int main() |
Optimization Level 0
Let’s compile the assembly code for this program with optimization level 0.
1 | $ g++ -S -O0 -o non_volatile_add_O0.s non_volatile_add.cpp |
1 | .file "non_volatile_add.cpp" |
The key operations of the program are described under the .LFB0
label.
1 | .LFB0: |
Specifically, the integer value 1 is loaded to the memory address -12(%rbp) and the integer value 2 is loaded to the memory address -8(%rbp).
1 | movl $1, -12(%rbp) |
Then, the value at the memory address -12(%rbp) is moved to the register %edx and the value at the memory address -8(%rbp) is moved to the register %eax.
1 | movl -12(%rbp), %edx |
Then, the value in the register %edx is added to the value in the register %eax and the result is stored in the register %eax.
1 | addl %edx, %eax |
Then, the value in the register %eax is moved to the memory address -4(%rbp).
1 | movl %eax, -4(%rbp) |
Finally, the value 0 is moved to the register %eax, which is the register to hold the return value in the x86 calling convention, and the function returns.
1 | movl $0, %eax |
The assembly code generated by optimization level 0 describes exactly what we intend to do in the program. It is straightforward and easy to understand.
Optimization Level 3
Notice that this program does not have any side effects, such as printing the sum of the two integer values. Therefore, the compiler can optimize the program by removing the addition operation and the third integer variable.
Let’s compile the assembly code for this program with optimization level 3.
1 | $ g++ -S -O3 -o non_volatile_add_O3.s non_volatile_add.cpp |
1 | .file "non_volatile_add.cpp" |
The key operations described under the the .LFB0
label become much simpler.
1 | .LFB0: |
Because the program does not have any side effects and it only returns 0, the assembly code generated by optimization level 3 is much simpler and more efficient than the one generated by optimization level 0.
1 | xorl %eax, %eax |
In fact, the assembly code only computes an XOR operation for the value on the register %eax and returns the result (on the register %eax). The result of the XOR operation of two identical values is always 0.
1 | xorl %eax, %eax |
There is no addition operation and no third integer variable in the assembly code generated by optimization level 3 at all.
Differences Between Optimization Level 0 and 3
The only difference between the assembly code generated by optimization level 0 and 3 is in the .LFB0
label.
Volatile Type Qualifier
We will also create a simple programs to demonstrate the difference between the assembly code generated by optimization level 0 and 3 for a simple addition program using volatile type qualifier and also compare the differences between the assembly code generated by optimization level 0 and 3 for the volatile version of the program with the non-volatile version of the program.
Adding Two Volatile Int Values
This time, let’s add two volatile integer values 1 and 2 and stores the result in an integer variable. Note that volatile integer and integer are actually different types.
1 | int main() |
Optimization Level 0
Let’s compile the assembly code for this program with optimization level 0.
1 | $ g++ -S -O0 -o volatile_add_O0.s volatile_add.cpp |
1 | .file "volatile_add.cpp" |
Actually, the assembly code generated for this program is exactly the same as the one generated for the non-volatile version of the program.
Optimization Level 3
Let’s compile the assembly code for this program with optimization level 3 to see what changes will be introduced.
1 | $ g++ -S -O3 -o volatile_add_O3.s volatile_add.cpp |
1 | .file "volatile_add.cpp" |
This time, the key operations described under the the .LFB0
label is simpler than the ones in the non-volatile version of the program at optimization level 0 but is more complicated than the ones in the non-volatile version of the program at optimization level 3.
1 | .LFB0: |
We could see that two integers 1 and 2 have been loaded to the memory address -8(%rsp) and -4(%rsp) respectively.
1 | movl $1, -8(%rsp) |
Then, the value at the memory address -8(%rsp) is moved to the register %eax and the value at the memory address -4(%rsp) is moved to the register %eax.
1 | movl -8(%rsp), %eax |
Then, the value in the register %eax is XORed with itself and the result is stored in the register %eax.
1 | xorl %eax, %eax |
Finally, the value in the register %eax is moved to the register %eax, which is the register to hold the return value in the x86 calling convention, and the function returns.
1 | movl %eax, %eax |
We could see that it’s actually a combination of the assembly code generated by optimization level 0 and 3 for the non-volatile version of the program. Because the volatile type qualifier is used to inhibit optimization, the assembly code generated by optimization level 3 for the volatile version of the program still performs the integer loading operations, but the addition operation and the third integer variable have been removed.
When to Use Volatile Type Qualifier
The above two examples show that the volatile type qualifier can be used to disable optimization on purpose. But it’s still a little bit unclear when we should use the volatile type qualifier in our programs. In fact, in practice, we actually don’t see the volatile type qualifier used very often because its use cases are very relatively limited.
The volatile type qualifier is usually used to declare variables that can be modified by hardware or other threads. For example, the memory-mapped I/O registers of a microcontroller are usually declared as volatile variables because they can be read and written by the hardware. The volatile type qualifier is also used to declare variables that can be modified by signal handlers. For example, the variables that are modified by the signal handler of the SIGINT signal are usually declared as volatile variables.
1 |
|
In the above example, the variable gSignalStatus
is declared as a volatile variable because it can be modified by the signal handler of the SIGINT signal. We will send the signal from the terminal to terminate the program by pressing Ctrl + C, and the program is expected to print out the message “SIGINT received.”.
1 | $ g++ volatile_signal.cpp -o volatile_signal -O3 |
If we remove the volatile type qualifier from the declaration of the variable gSignalStatus
, the program will not print out the message “SIGINT received.”.
1 |
|
The program will optimize out the while loop because the variable gSignalStatus
is not declared as a volatile variable. Therefore, the program will not even hang before we press Ctrl + C and print out the message “SIGINT received.”.
1 | $ g++ non_volatile_signal.cpp -o non_volatile_signal -O3 |
References
Volatile Type Qualifier In C/C++
https://leimao.github.io/blog/C-CPP-Volatile-Type-Qualifier/