版本信息:Linux操作系统,x86架构,Linux操作系统下GCC 9.3.1版本。GCC 9.3.0手册。
看了外面很多写volatile的文章,笔者算是认为“乱七八糟”,根本没有任何论证就在下定义,所以笔者特意写这篇关于volatile的文章。
先看一下GCC文档给的volatile说明:
一言以蔽之:让编译器不再去优化被volatile修饰的变量的操作。但是volatile并不能做内存屏障的功能,想使用内存屏障请使用平台相关的屏障指令,比如GCC提供了一个内联asm volatile ("" : : : "memory");的编译器屏障。详情平台相关的内存屏障请关注特定平台的操作手册~!
笔者有在很多帖子里面看过,他们都一致的说到:volatile可以作为内存屏障保证内存的可见性,这压根就是一个错误的引导,所以这也促使笔者写在这篇文章。
既然上述说明了volatile关键字可以避免编译器优化,那么下面笔者用2个列子来说明一下。
// 没优化:
int a = 10;
int b = a;
int c = a;
int d = a;
// 对应的汇编代码
sub 16,esp // 开辟栈帧
mov $10,(esp-12) // 把立即数10放入到esp-12的栈帧位置,这也对应a变量。
mov (esp-12) (esp-8) // 把(esp-12)的值放入到(esp-8)的位置,这也对应b变量
mov (esp-12) (esp-4) // 把(esp-12)的值放入到(esp-4)的位置,这也对应c变量
mov (esp-12) (esp) // 把(esp-12)的值放入到(esp)的位置,这也对应d变量
// 总结,每次从内存中拿
比如这个很简单的列子,定义一个变量a,然后把a赋值给b、c、d。
看汇编代码,可以清楚的看到,每次赋值都是从内存地址中拿去值,这也就需要访问多次内存。影响到代码的执行效率。那么,编译器会如何优化呢?
既然b、c、d都使用的a变量,而A变量为10,那么可不可以这样写呢?
// 优化:
int a = 10;
int b = 10;
int c = 10;
int d = 10;
// 对应的汇编代码:
sub 16,esp // 开辟栈帧
mov $10,(esp-12) // 把立即数10放入到esp-12的栈帧位置,这也对应a变量。
mov (esp-12),eax // 把esp-12的栈帧位置对应的值,也就是10放入到eax寄存器中。
mov eax (esp-8) // 把eax寄存器的值放入到(esp-8)的位置,这也对应b变量
mov eax (esp-4) // 把eax寄存器的值放入到(esp-4)的位置,这也对应c变量
mov eax (esp) // 把eax寄存器的值放入到(esp)的位置,这也对应d变量
// 总结,每次从eax寄存器拿,此时,可以把eax想成一个缓存寄存器。
可以从汇编代码看出,把a变量的值放入到eax寄存器中,然后把eax寄存器的值赋值给b、c、d变量,这样就只需要访问一次内存了。此时,我们需要考虑,假如赋值b、c、d的过程中,a的值发生了改变了呢?那么对于b、c、d来说还是赋值的原值,所以就出现了问题。
这是一个很简单的编译器优化的例子,代码就是假设的代码,汇编也是伪汇编,那么,为得到读者的认可,笔者也是写了一个真实的案例。
// demo.c案例
#include
#include
#include
#include
/*全局变量*/
int gnum = 1;
/*线程1的服务程序*/
static void pthread_func_1 (void)
{
while(gnum == 1){
}
}
int main (void)
{
/*线程的标识符*/
pthread_t pt_1 = 0;
int ret = 0;
/*
创建线程1
*/
ret = pthread_create( &pt_1, //线程标识符指针
NULL, //默认属性
(void *)pthread_func_1,//运行函数
NULL); //无参数
if (ret != 0)
{
perror ("pthread_1_create");
}
/* 主线程停1秒,让p1线程成功被CPU调度 */
sleep(1);
/* 改变全局属性gnum的值,让p1线程停下来。 */
gnum = 0;
/* 等待线程1的结束 */
pthread_join (pt_1, NULL);
printf ("main programme exit!/n");
return 0;
}
这段代码很简单,使用pthread创建一个p1线程,p1线程里面写了一个while循环,循环条件是判断全局变量gnum是否为1。main线程启动p1线程,同时main线程休眠1秒,让p1线程得到CPU的调度,然后把全局变量gnum设置为0,让p1线程的while结束。main线程使用join等待p1线程执行结束,p1线程结束后main线程打印main programme exit。
gcc普通编译:
// gcc普通编译后
gcc -pthread demo.c
// objdump指令查看反汇编
objdump -S a.out
// 反编译后p1线程代码段的汇编代码
000000000040068d :
40068d: 55 push %rbp
40068e: 48 89 e5 mov %rsp,%rbp
400691: 90 nop
400692: 8b 05 bc 09 20 00 mov 0x2009bc(%rip),%eax # 601054 // 每次还从0x2009bc(%rip)获取全局的gnum变量放入eax寄存器
400698: 83 f8 01 cmp $0x1,%eax // 拿1和eax寄存器做比较,比较结果放入到flags寄存器中。
40069b: 74 f5 je 400692 // 如果比较成功就直接跳到400692这行代码段地址,如果不成功就直接往下执行
40069d: 5d pop %rbp
40069e: c3 retq
可以清楚的看到每次都是从0x2009bc(%rip)获取值给%eax寄存器,然后cmp做比较,je是成功就跳转到400692代码段地址。然后继续mov获取值,cmp比较,je跳转,周而复始......
gcc -O4编译:
// gcc -O4编译后
gcc -O4 -pthread demo.c
// objdump指令查看反汇编
objdump -S a.out
// 反编译后p1线程代码段的汇编代码
00000000004006f0 :
4006f0: 83 3d 69 09 20 00 01 cmpl $0x1,0x200969(%rip) # 601060 // 比较一次,把结果放入到flags寄存器中
4006f7: 75 07 jne 400700 // 如果不等于就直接退出
4006f9: eb fe jmp 4006f9 // 一直循环本行,也就是直接无脑死循环(没有退出条件的死循环)
4006fb: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
400700: f3 c3 repz retq
400702: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
400709: 00 00 00
40070c: 0f 1f 40 00 nopl 0x0(%rax)
这里执行的话就直接死循环了。
这里也比较直观,cmpl比较一次,如果不等于就jne直接返回,如果等于就执行jmp 4006f9,就开始无退出条件的死循环了,不管你后续全局变量gnum值是否改变都无条件死循环。所以这就是编译器优化,导致的问题,那么使用volatile修饰全局变量gnum,看看效果。
volatile修饰后gcc -O4编译:
// volatile修饰后gcc -O4编译:
gcc -O4 -pthread demo.c
// objdump指令查看反汇编
objdump -S a.out
// 反编译后p1线程代码段的汇编代码
00000000004006f0 :
4006f0: 8b 05 5e 09 20 00 mov 0x20095e(%rip),%eax # 601054 // 每次从0x20095e(%rip)获取全局的gnum变量放入eax寄存器
4006f6: 83 f8 01 cmp $0x1,%eax // 拿1和eax寄存器做比较,比较结果放入到flags寄存器中。
4006f9: 74 f5 je 4006f0 // 如果比较成功就直接跳到4006f0这行代码段地址,如果不成功就直接往下执行
4006fb: f3 c3 repz retq
4006fd: 0f 1f 00 nopl (%rax)
volatile 和gcc的O4优化后的代码特别特别的精简。可以清楚的看到mov 0x20095e(%rip),%eax每次都从0x20095e(%rip)地址获取变量给eax寄存器,然后cmp比较,je跳转。所以这跟普通编译的写法是是一样的(单指操作被volatile修饰的变量)
内联汇编volatile修饰后gcc -O4编译:
int gnum = 1;
/*线程1的服务程序*/
static void pthread_func_1 (void)
{
while(gnum == 1){
__asm__ __volatile__("": : :"memory")
}
}
// 使用内联汇编volatile编译器优化:
gcc -O4 -pthread demo.c
// objdump指令查看反汇编
objdump -S a.out
// 反编译后p1线程代码段的汇编代码
00000000004006f0 :
4006f0: eb 06 jmp 4006f8
4006f2: 66 0f 1f 44 00 00 nopw 0x0(%rax,%rax,1)
4006f8: 83 3d 61 09 20 00 01 cmpl $0x1,0x200961(%rip) # 601060 // 拿0x200961(%rip)全局变量gnum的值和1比较。
4006ff: 74 f7 je 4006f8 // 如果相等就跳转到4006f8。
400701: f3 c3 repz retq
400703: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
40070a: 00 00 00
40070d: 0f 1f 00 nopl (%rax)
这里cmpl直接比较,然后je跳转。比较精简。每次也是从0x200961(%rip)地址获取最新值。所以不会出现无条件的死循环的情况。
在Linux内核中,禁止volatile关键字的出现,但是里面都是使用内联汇编volatile的形式禁止编译器优化,当然内存屏障也是可以禁止编译器优化的(对于内存屏障这里点到即可,详情看不同平台的操作手册)。当然Linux内核代码量特别大,如果很多地方不让编译器优化的话,效率会降低,一个操作系统如果性能都不行,那肯定是说不过去的。
如下图所示:使用了volatile修饰的变量在不同的代码段之间执行都会影响到代码段的优化,而内联汇编volatile就可以按需选择,就不会全部影响到。所以读者可以按需选择。
最后,如果本帖对您有一定的帮助,希望能点赞+关注+收藏!您的支持是给我最大的动力,后续会一直更新各种框架的使用和框架的源码解读~!