一文理解C语言中的volatile修饰符

volatile修饰符是在嵌入式开发和多线程并发编程中常见的修饰符,理解其对于实践过程非常有帮助,此文参考了[1],并且附上了笔者的一些例子,希望对大家有所帮助。

20191202 FesianXu at UESTC

前言

volatile修饰符是在嵌入式开发和多线程并发编程中常见的修饰符,理解其对于实践过程非常有帮助,此文参考了[1],并且附上了笔者的一些例子,希望对大家有所帮助。

联系方式:

e-mail: FesianXu@gmail.com

github: https://github.com/FesianXu

知乎专栏: 计算机视觉/计算机图形理论与应用

微信公众号:机器学习杂货铺3号店


volatile修饰符用于C语言和C++中,其意在阻止编译器对其修饰对象进行任何形式的优化,有时候,这种编译器“自作主张”的优化会导致编程者意想不到的结果,因此需要引入这个关键字进行限制。

当一个对象可能会被当前代码以外的环境,在任何时刻被改变的时候,一个对象如果此时被声明为了volatile,那么其就可以脱离编译器的优化过程。当需要读取该数据的时候,系统总是会重新从内存位置中读取当前volatile类型的数据,而不是直接取其在寄存器中的值,我们要知道,为了执行效率,即便是你指定了要从之前的同一个对象中取值,编译器在优化过程中也很可能会不直接从内存中读取数据,而是直接采用寄存器中的值(我们将会从后续的例子中看到这个情况。),这个行为在一般情况下的确能够提高程序的执行速率,毕竟数据从寄存器中读取,要比从内存中读取快上好几个数量级。比如我们见一个简单例子:

1
2
3
4
5
6
// sample.c
int main(){
int i = 10;
i = i;
return 0;
}

在linux下用命令gcc -S sample.c编译,我们得到了其汇编结果,我截取了其主体,如:

1
movl  $10, -4(%rbp)

而在sample.c的变量声明中如果加上volatile修饰符,那么程序变成:

1
2
3
4
5
6
// sample.c
int main(){
volatile int i = 10;
i = i;
return 0;
}

汇编后的结果为:

1
2
3
movl  $10, -4(%rbp)
movl -4(%rbp), %eax
movl %eax, -4(%rbp)

我们对比这两次的汇编结果,我们发现,第一次没有声明其为易变的时候,编译器分析了代码的变量的关系,并且进行了优化,编译器认为,我的变量i既然在下一步还需要赋值给自己,那么何必在重新从内存中读取i的值呢,因此,从汇编来看,i = i这条c语言代码其实是无效的。 在一般的编译器优化中,因为编译器可能会认为变量i是非易变的,如果其变化了,只能是因为程序员对其进行了显式的赋值改变,因此在需要再次读取变量i的值的时候,与其重新从内存中读取,不如直接利用其已经读入到寄存器中的值,毕竟寄存器比内存快得多。

但是我们要思考下,i = i;是不是没有意义的代码呢?我们很容易认为这个答案是的确没有意义。

但是,我们假设有一种情况,在int i = 10;之后,因为某种原因,比如硬件中断,多线程的修改或者其他原因,导致此时i改变了,而不是初始的i = 10了,那么我们后续的代码i = i;就变得非常重要,因为其需要读取在内存中,新的值i,而不是简单的将其忽视掉或者简单地读取内存中的值,注意到这个时候寄存器中的值已经是“过时”了的,如果任由编译器去优化,那么你将永远无法读取传感器的值(传感器的值很多由硬件中断读取。)

通过上面的讨论,我们便能理解这两个不同的汇编结果了,在第二段汇编中,我们不仅通过movl $10, -4(%rbp)将直接数10传输到了内存-4(%rbp) (指的是寄存器%rbp中的地址所指向的内存偏移4个字节的内存位置,是相对寻址的指令),而且接下来还重新读取了该内存位置的值,并且将其赋给了自己的这个内存位置(这个过程中,因为该变量可能是易变的,因此该内存可能会被其他程序给覆盖,因此要重新读取)。

重新回到我们的讨论,那么什么时候我们需要用volatile这个修饰符呢?当属于下面几种情况的时候,应该考虑这个修饰符:

  1. 当全局变量会被中断服务函数给修改的时候。例如一个全局变量可以表示一个外部数据接口(通常全局指针被引用为内存映射IO),这意味着该数据会被动态地更新。如果我们的代码期望读取数据接口的值,那么我们就应该将其定义为volatile,以获取其数据的最新值。如果我们不这么做,编译器的优化过程会使得只读取一次该接口的数据,并且将其加载到寄存器中,接下来都只能读取该寄存器中的旧值了。
  2. 在多线程应用中的全局变量。在多线程通信中,有着多种通信方式:信号传递(message passing),邮箱(mail boxes),共享内存(shared memory)等。一个全局变量是共享内存的朴素形式。当两个线程通过全局变量共享信息时,他们需要用volatile进行修饰。因为线程是异步运行的,每个线程导致的全局变量的每次更新,都应该被其他线程重新从内存中获取。为了消除编译器优化导致的效果,这些全局变量必须要用volatile修饰。

如果我们不用volatile修饰,有可能会导致以下问题:

  1. 当编译器优化开启时,代码可能不会正常工作。
  2. 当中断发生时,代码可能不会正常工作。

Reference

[1]. https://www.geeksforgeeks.org/understanding-volatile-qualifier-in-c/