一、Volatile作用

百度翻译 volatile 截图

volatile 属于 C 语言的关键字,《C Primer Puls》 是这样解释关键字的:关键字是 C 语言的词汇,由于编译器不具备真正的智能,所以你必须用编译器能理解的术语表示你的意图。 开发者告诉编译器该变量是易变的,就是希望编译器去注意该变量的状态,时刻注意该变量是易变的,每次读取该变量的值都重新从内存中读取。

  1. int i = 10;
  2. int main(void){
  3. int a, b;
  4. a = i;
  5. ...//伪代码,里面不含有对 a 、 b 以及 i的操作
  6. b = i;
  7. if(a == b){
  8. printf("a = b");
  9. }
  10. else {
  11. printf("a != b");
  12. }
  13. return 0;
  14. }

如上代码,如果选择编译器优化,可能会被编译成如下代码(当然不是在 C 语言层面上优化,而是在汇编过程优化,只是使用 C 程序举例):

  1. int i = 10;
  2. int main(void){
  3. int a, b;
  4. a = i;
  5. ...//伪代码,里面不含有对 a 、 b 以及 i的操作
  6. b = i;
  7. printf("a = b");
  8. return 0;
  9. }

因为在仅仅从 main 主函数来看,a == b 是必然的,那么在什么情况,a 和 b 不是必然相等呢?

  • i 是其他子线程与主线程共享的全局变量,其他子线程有可能修改 i 值;
  • i 是中断函数与主函数共享的全局变量,中断函数有可能修改 i 值;
  • i 属于硬件寄存器,CPU 可能通过硬件直接改变 i 的值(例如寄存器的标志位)

但是仔细想一想,好像我们都遇到过上述情况,也没有对相对应的变量使用 volatile 修饰呀?也没出现奇怪的问题呀?
注意是在开启了编译器优化,编译器其实是默认不优化的,这对入门者是友好的,但是当进入企业开发中,我们可能选择了编译器优化,以减少可执行程序大小和提高性能,这时候我们就不得不去考虑编译器优化问题,如何启动编译器优化,我们结合 GCC 编译器和 keil 开发软件分析。

使用 GCC 编译器时,在编译命令加入 -On ; n: 0 ~ 3,数字代表优化等级,数字越大,优化级别越高。如:

  1. gcc -O2 -O hello hello.c

使用 keil 软件,我们可以通过如下操作选择优化级别:
Keil进行编译优化

二、volatile关键字的应用场景

1、自定义延时函数

  1. #include <stdio.h>
  2. void delay(long val);
  3. int main(){
  4. delay(1000000);
  5. return 0;
  6. }
  7. void delay(long val){
  8. while(val--);
  9. }

上面的代码主要是通过 CPU 不断进行无意义的操作达到延时的效果,这种操作如果不启用编译器优化是可以达到预期效果的,但是启用编译器优化就会被优化成如下效果(当然不是在 C 语言层面上优化,而是在汇编过程优化,只是使用 C 程序举例):

  1. #include <stdio.h>
  2. void delay(long val);
  3. int main(){
  4. delay(1000000);
  5. return 0;
  6. }
  7. void delay(long val){
  8. ;
  9. }

注意:实际上编译器在编译优化时甚至将这个延时函数优化没了。
这个时候,delay 函数就起不了效果了,需要使用 volatile 修饰 val ;具体可见:
GCC编译器优化空操作

2、多线程共享的全局变量

多线程数据安全问题一直是系统编程常见的问题,为了解决这类问题,衍生出互斥锁、条件变量、临界区以及自旋锁等解决办法,如上都是为了线程数据同步,但是要做到线程数据同步,我们还需要注意一个编译器优化问题。

我们都知道,每一个线程虽然共享一个进程的资源,但是每个线程同样拥有自己的私有堆栈,保证每个线程函数中定义的局部变量相互之间不可见;线程间通信是十分简单的,其中一个十分常见的方式就是通过共享全局变量,全局变量对于每一个线程都是可见的,但是线程的每一次读写全局变量都是对全局变量直接操作吗,答案是否定的。例如下面这个操作(伪代码):

  1. //一个全局变量a
  2. int a = 1;
  3. int main(){
  4. int b,c,d,e,f;
  5. //多次赋值
  6. b = a;
  7. c = a;
  8. d = a;
  9. e = a;
  10. f = a;
  11. ....
  12. }
  13. void *child_pth_fun{
  14. //子线程修改a值
  15. a = 2;
  16. ......
  17. }

如果每次赋值都去内存中读入 a , 对于程序来说开销实在太大了,所以系统可能会在cpu的高速缓存中读取数据,加快程序执行效率,也正是因为优化原因,如果这个全局变量是多线程共享的,子线程可能在任意时刻改变 a 在内存中的值,但是主程序的高速缓存却是过去 a 的值,就可能出现数据未同步问题。

会出现什么问题、怎么解决此类问题、怎么去复现数据不同步问题。

3、中断函数与主函数共享的全局变量

中断函数和主函数共享的全局变量需要使用 volatile 修饰的情况是相似的。

4、硬件寄存器

什么叫硬件寄存器,学过硬件的同学应该不陌生,我们在做按键检测的时候是不是下面这种流程:
(1)设置 GPIO 对应的寄存器配置成输入模式
(2)不断地去访问 GPIO 电平标志寄存器(或者是一个寄存器的标志位)
(3)根据寄存器值的某个二进制位确定当前引脚电平

那么有没有想过一个问题,是什么去改变硬件寄存器的值?其实,硬件寄存器上的值的是和底层电路相关的,硬件寄存器的值会影响电路,电路也会反过来影响硬件寄存器的值。

所以在这种情况下,编译器更不应该拷贝副本,而应该每次读写都从内存中读写,保证数据正确,声明成 volatile 可以防止出现数据出错问题。例如:

  1. //GPIOE13 ---->LEDD7
  2. //GPIOA28 ----> KEY2
  3. //注意:裸机程序是直接在硬件上运行的程序,是不能使用标准C库。
  4. #define GPIOEALTFN0 (*(volatile unsigned int *)0xC001E020)
  5. #define GPIOEOUTENB (*(volatile unsigned int *)0xC001E004)
  6. #define GPIOEOUT (*(volatile unsigned int *)0xC001E000)
  7. #define GPIOAALTFN1 (*(volatile unsigned int *)0xC001A024)
  8. #define GPIOAOUTENB (*(volatile unsigned int *)0xC001A004)
  9. #define GPIOAPAD (*(volatile unsigned int *)0xC001A018)
  10. void _start(void) //gcc编译器中,裸机程序的入口是start,不是main
  11. {
  12. GPIOEALTFN0 &= ~(3<<26);
  13. GPIOEOUTENB |= (1<<13);
  14. GPIOAALTFN1 &= ~(3<<24);
  15. GPIOAOUTENB &= ~(1<<28);
  16. while(1)
  17. {
  18. //读取GPIO引脚电平
  19. if(!(GPIOAPAD & (1<<28)))
  20. GPIOEOUT &= ~(1<<13);
  21. else
  22. GPIOEOUT |= (1<<13);
  23. }
  24. }

这种情况加volatile的情况是最多的,比如stm32函数库底层的寄存器定义就是加了volatile的:

C语言volatile关键字详解 - 图3

C语言volatile关键字详解 - 图4

参考资料