本文内容:volatile关键字的含义,它与barrier()和编译乱序的关系,以及内核里面READ_ONCE()、WRITE_ONCE()的实现原理。
作者简介:李浩,就职于南京富士通南大软件,熟悉 x86 架构,对内存和文件系统有些研究。
最常见的用法
如果一个变量被声明为 volatile 的,就是告诉编译器即使我们当前编译的代码不会修改这个变量,该变量对应的内存数据也可能会由于其他原因而被修改,这可能的原因有很多,比如该变量对应的内存位置是使用 memory mapped I/O 机制映射的一个外设端口,即我们本质上是在访问一个硬件寄存器,它的值的变化当然不受程序控制。
那为什么要告诉编译器这个信息呢?因为这样的话,生成汇编代码时,每次使用该变量时都会去对内存位置做一次读访问以获取最新的值。相反,如果不加 volatile,那么编译器为了效率,很可能先把该变量加载到寄存器,以后需要用时就都去读这个寄存器了,不会再去读内存,即使内存的数据变动了我们的代码也不知道,还在用寄存器里的老数据。我们常用的 ioread 函数就封装了 volatile 操作,保证能读到最新数据,具体定义可以参见 build_mmio_read。
但要注意,除了这种 memory mapped I/O 以及其他少数几个特殊情况[1],如果一个变量可能被多个过程并发访问,这种情况不应该使用 volatile 关键字来保证每个过程都能看到该变量的最新值,正确的做法是使用锁来保护它,加锁成功后只需要把被保护变量从内存读一次扔到寄存器就行了,后面都用寄存器的值,这样效率高,在我们出临界区之前锁机制会保证不会有其他过程来修改此变量,所以寄存器里的数据一直是有效的。这个时候如果画蛇添足把被保护变量声明为 volatile,会阻止编译器在临界区内对该变量的读取优化,每次都要从内存读,这显然没必要。
阻止编译乱序
volatile 的另一种用法需要结合 READ_ONCE/WRITE_ONCE 这两个宏来看,内核注释里提到这两个宏有阻止编译乱序的作用。
The compiler is also forbidden from reordering successive instances ofREAD_ONCE and WRITE_ONCE
我们下文以 READ_ONCE 读取变量为例展开分析。
从内核对这个宏的定义来看,它的本质其实就是使用 volatile 关键字对变量做了类型修饰,怎么看都不像是能起到阻止乱序的作用。
#define READ_ONCE(x) \({\compiletime_assert_rwonce_type(x); \__READ_ONCE(x); \})#define __READ_ONCE(x) (*(const volatile __unqual_scalar_typeof(x) *)&(x))
所以我们只好试一试。
首先是一段 C 语言代码:
int a, b;int i, j;void foo(){a = i;b = j/16;}
使用 gcc -O2 example.c -S 生成汇编:
movl j(%rip), %edx // 读取 jmovl i(%rip), %eax // 读取 itestl %edx, %edxmovl %eax, a(%rip)leal 15(%rdx), %eaxcmovns %edx, %eaxsarl $4, %eaxmovl %eax, b(%rip)
很明显地看到 i 和 j 的读取顺序与 C 语言语句颠倒了。那么为了阻止这种优化,我们首先试下编译屏障 barrier(),看看效果如何。
#define barrier() __asm__ __volatile__("": : :"memory")int a, b;int i, j;void foo(){a = i;barrier();b = j/16;}
汇编如下:
movl i(%rip), %eax // 读取 imovl %eax, a(%rip) // 写入 a-------------------------------- 屏障在此movl j(%rip), %edx // 读取 jtestl %edx, %edxleal 15(%rdx), %eaxcmovns %edx, %eaxsarl $4, %eaxmovl %eax, b(%rip) // 写入 b
显然,barrier() 编译屏障很管用,它告诉编译器:在 barrier() 前后是两个世界,屏障前的语句不能跑到屏障后,反之亦然,也就是编译乱序不能穿透屏障。所以,读取 i 写入 a 和 读取 j 写入 b 这两组操作被屏障隔离了。
在见识了编译屏障的作用后,我们再试试 volatile 究竟有没有起到类似的作用。
#define __READ_ONCE(x) (*(const volatile int *)&(x))int a, b;int i, j;void foo(){a = __READ_ONCE(i);b = __READ_ONCE(j)/16;}
汇编如下:
movl i(%rip), %eax // 读取 imovl j(%rip), %edx // 读取 jmovl %eax, a(%rip) // 写入 atestl %edx, %edxleal 15(%rdx), %eaxcmovns %edx, %eaxsarl $4, %eaxmovl %eax, b(%rip) // 写入 b
可以看到 i 和 j 的读取顺序被保证了。但是注意,volatile 毕竟不是编译屏障,不能把第一条 C 语句和第二条语句完全隔离开,所以我们用 __READ_ONCE 能保证的也只是 i 和 j 的读取顺序,其他的写入顺序或者读写之间的顺序无法被保证 (比如读取 j 和写入 a 就颠倒了)。
那编译器为何会对 volatile 有这样的约束行为呢,这是因为 C 标准做出了如下规定:
The least requirements on a conforming implementation are:At sequence points, volatile objects are stable in the sense that previous accesses are complete and subsequent accesses have not yet occurred..........The following are the sequence points described in 5.1.2.3:The end of a full expression: an initializer (6.7.8); the expression in an expressionstatement (6.8.3); the controlling expression of a selection statement (if or switch)(6.8.4); the controlling expression of a while or do statement (6.8.5); each of theexpressions of a for statement (6.8.5.3); the expression in a return statement(6.8.6.4).
这里引出了 sequence point 的概念,简单来说就是 sequence point 之前的表达式所造成的影响不能扩散到 sequence point 之后。尤其是对于 volatile 变量来说,以一个 sequence point 为分界点,对于前面 volatile 变量的访问必须完成,且对于后面 volatile 变量的访问必须没有开始。遵照如上标准,; 就是个 sequence point,那么 a = __READ_ONCE(i) 和 b = __READ_ONCE(j)/16 之间隔着一个 sequence point,所以对 i 的访问必须放在 j 之前。
但需要注意的是,编译器只是保证 volatile 变量与 volatile 变量的读取不会被乱序,但是 non-volatile 变量和 volatile 变量的读取顺序依然是可以被乱序的。
比如我们把 j 的 __READ_ONCE 去掉:
#define __READ_ONCE(x) (*(const volatile int *)&(x))int a, b;int i, j;void foo(){a = __READ_ONCE(i);b = j/16;}
产生的汇编如下:
movl j(%rip), %edx // 读取 jmovl i(%rip), %eax // 读取 itestl %edx, %edxmovl %eax, a(%rip)leal 15(%rdx), %eaxcmovns %edx, %eaxsarl $4, %eaxmovl %eax, b(%rip)
可以看到 i 和 j 的读取顺序又颠倒了。
到这里,我们就把 READ_ONCE 也即 volatile 在变量读取中的作用分析完了,它可以保证变量严格地按照代码给出的顺序去读。同理,WRITE_ONCE 则是保证了变量的写入顺序。
那么如果 READ_ONCE 和 WRITE_ONCE 两者混合使用,又会怎样呢。其实,按照 C 标准,没有特指这个保序只针对读与读或写与写,所以读写混合的顺序也会得到保证。下面举个例子:
int a, b;int i;void foo(){a = i/16;b = 0;}
这个函数的汇编如下:
movl $0, b(%rip) // 写入 bmovl i(%rip), %edx // 读取 itestl %edx, %edxleal 15(%rdx), %eaxcmovns %edx, %eaxsarl $4, %eaxmovl %eax, a(%rip) // 写入 a
可以看到读取 i 、写入 a、写入 b 这三者的顺序已经彻底打乱了。
我们用上 volatile:
#define __READ_ONCE(x) (*(const volatile int *)&(x))#define __WRITE_ONCE(x, val) do {*(volatile typeof(x) *)&(x) = (val);} while(0)int a, b;int i;void foo(){a = __READ_ONCE(i)/16;__WRITE_ONCE(b, 0);}
生成的汇编如下:
movl i(%rip), %edx // 读取 imovl $0, b(%rip) // 写入 btestl %edx, %edxleal 15(%rdx), %eaxcmovns %edx, %eaxsarl $4, %eaxmovl %eax, a(%rip) // 写入 a
可见,i 的读取和 b 的写入是严格按照 C 代码的顺序来的,说明 volatile 生效了。但是 a 的写入被放到 b 写入的后面了,这是因为 a 在被写入时没有被 volatile 修饰。如果我们把代码改成这样:
__WRITE_ONCE(a, __READ_ONCE(i)/16);__WRITE_ONCE(b, 0);
生成的汇编就会如下:
movl i(%rip), %edx // 读取 itestl %edx, %edxleal 15(%rdx), %eaxcmovns %edx, %eaxsarl $4, %eaxmovl %eax, a(%rip) // 写入 amovl $0, b(%rip) // 写入 b
可以看到现在的顺序和 C 代码完全对应了。不过其实可以写的更简单一点,因为 a 需要靠 i 算出来,有计算依赖,所以编译器会保证 i 的读取在 a 写入之前,第一行的 __READ_ONCE 可以去掉,写成下面这样效果是一样的:
__WRITE_ONCE(a, i/16);__WRITE_ONCE(b, 0);
后记
volatile 这种防止乱序的作用在 Java 中相当清晰,JVM 本身就类似于一个操作系统,Java 编译为字节码后也有指令重排导致编译乱序的问题,所以 Java 中的 volatile 关键字明确带有阻止优化的作用,这已经在 Java 开发者中成为了常识,而 C 语言中的 volatile 却稍显隐晦。
References
[1] 特殊情况:
https://www.kernel.org/doc/html/latest/process/volatile-considered-harmful.html
更多精彩,尽在"Linux阅码场",扫描下方二维码关注


别忘了分享、点赞或者在看哦~