C语言volatile关键字、内嵌汇编volatile与编译器的爱恨情仇

本文已参与「新人创作礼」活动, 一起开启掘金创作之路。

前言

  本文将详细介绍 C语言volatile与编译器的爱恨情仇,附带介绍一下内嵌汇编volatile的作用;不介绍volatile的原理。注意,本文口语描述的volatile默认是C语言volatile关键字。

//C语言volatile
volatile int a;
int inc(int *value, int add) {
	int old;
	//内嵌汇编volatile
	__asm__ volatile (
		"lock; xaddl %2, %1;"
		: "=a" (old)
		: "m" (*value), "a" (add)
		: "cc", "memory"
	);
	return old;
}

C语言volatile关键字

volatile是什么

  volatile是C语言中的一个关键字,英文翻译为“不稳定的;易变的”。注意,这里的易变,不是说用了volatile变成了易变的,而是说volatile修饰的变量是不稳定易变的,所以要加上volatile关键字,告诉编译器这个变量易变,你不要优化它。

  volatile告诉编译器它后面定义的变量是易变的了,所以编译之后的程序每次要读该变量时,都会强制去主内存读。并且因为它是不稳定的,它会禁止编译器对它指令重排,在执行到volatile变量时,其前面的所有语句都执行完,后面所有语句都未执行。且前面语句的结果对volatile变量及其后面语句可见。

volatile的性质

  所以可以总结出来volatile的两点性质:

1.保证可见性,当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
2.防止变量被编译器优化,禁止指令重排
  • volatile保证可见性:当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

  • 禁止指令重排:重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。重排序需要遵守一定规则(1重排序操作不会对存在数据依赖关系的操作进行重排序。2重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变,重排序在单线程下一定能保证结果的正确性,但是在多线程环境下,可能发生重排序,影响结果。)

  需要注意一点,保证可见性,不代表它保证原子性,我们以idx++为例来说明。

idx++分为三步骤
1. 获取idx的值
2. 对idx赋值
3. 写回idx

  假设现在有两个线程,线程1和线程2都读取了idx,好,目前停留在读取完这个阶段,如果线程1对idx进行了修改,则因为volatile的原因,根据EMSI协议,总线嗅探回设置线程2的idx为无效,并重新载入主内存的i到线程2的工作内存中。

  上面的情况是,线程1的三步执行完了,而线程2还停留在第1步。来考虑这样一种情况,线程1和线程2,获取idx,并且都对idx赋值,也就是说两个线程都已经完成了2个步骤,此时线程1将idx写回,即使总线嗅探回设置线程2的idx为无效并且重新设置,那又怎么样呢?线程2已经走完第二步了,线程2的下一条指令是写回idx,管你有效无效。

编译器

编译器优化介绍

  编译器优化常用的方法有:1. 将内存变量缓存到寄存器。由于访问寄存器要比访问内存单元快的多,编译器在存取变量时,为提高存取速度,编译器优化有时会先把变量读取到一个寄存器中;以后再取变量值时就直接从寄存器中取值。但在很多情况下会读取到脏数据,严重影响程序的运行效果;2. 指令重排序;别的就不再介绍了,我也不是很懂。

编译器开启优化

  编译器默认是不进行优化的,那么如何开启优化呢?使用GCC编译器时,在编译脚本命令加入 -On ; n: 0 ~ 3,数字代表优化等级,数字越大,优化级别越高。

gcc -O1 -O main main.c
gcc -O2 -O main main.c
gcc -O3 -O main main.c

编译器优化举例

  假设现在有一段代码,如果被编译器优化,很可能被优化成下面那段代码。注意,编译器优化,优化的不是我们写的c语言代码,而是在编译过程中优化,我们一般通过汇编可以看出来优化了什么(例如go的逃逸分析),下面那段优化后的我们能看懂的代码只是举例。

//未优化前
//全局share
int i = 10;
int main(void){
    int a, b;

    a = i;
    //...中间一些操作,中间操作对i操作
    b = i;

    if(a == b){
        printf("a = b");
    }
    else {
        printf("a != b");
    }

    return 0;
}

//优化后
//全局share
int i = 10;
int main(void){
    int a, b;

    a = i;
    //...中间一些操作,中间操作对i操作
    b = i;

    printf("a = b");
    return 0;
}

  其实我们自己也能观察到,a==b是必然事件,那么什么时候会出现a != b呢?那必然是在中间注释的那一行中,i发生了改变。

1. i 是其他子线程与主线程共享的全局变量,其他子线程有可能修改 i 值;

2. i 是中断函数与主函数共享的全局变量,中断函数有可能修改 i 值;

3. i 属于硬件寄存器,CPU可能通过硬件直接改变 i 的值(例如寄存器的标志位)

C语言volatile关键字使用场景举例

下面测试默认编译器开启优化来测试

自定义延时函数(防止变量被优化)

  现在我们的本意是想让cpu空转一会,而编译器的优化会导致达不到我们想要的效果。编译器的优化我们可以通过汇编查看出来,优化之后的 delay 函数仅仅只有函数跳转指令,汇编这里就不放出来了,下面放伪代码。

//未优化前
#include <stdio.h>
 
void delay(long val);
int main(){
					
	delay(1000000);
 
	return 0;
}
 
void delay(long val){
 
	while(val--);
}

//优化后
#include <stdio.h>
 
void delay(long val);
int main(){
					
	delay(1000000);
 
	return 0;
}
 
void delay(long val){
 
	;
}

  由于被编译器优化掉了,那么我们不想val这个变量被优化怎么办呢,使用volatile关键字,现在就可以达到我们预期的效果了。我们引入了volatile,还记得volatile的性质吗?

  • 防止变量被编译器优化
#include <stdio.h>
void delay(long val);
 
int main(){
					
	delay(1000000);
 
	return 0;
 
}
 
void delay(volatile long val){
 
	while(val--);
}

多线程共享的全局变量(保证可见性)

  我们知道多线程中,每个线程都可以操作全局变量,但是线程的每一次读写全局变量都是对全局变量直接操作吗,答案是否定的。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
 
int a = 1;
 
void *child_pth_fun(void *arg);
 
int main(){
 
	int b, c;
	volatile int val = 10000000;
	
	//创建子线程
	pthread_t child_pth_id;
	pthread_create(&child_pth_id, NULL, child_pth_fun, NULL);
		
	b = a;
	
	//模拟一段很长的程序段,消耗主线程时间片,以便系统调度子线程
	while(val--);
 
	c = a;
	if(c == b)
	printf("In main pthread: a=%d, b=%d, c=%d\n", a, b, a);
	
 
	pthread_join(child_pth_id, NULL);
	return 0;
 
}
 
void *child_pth_fun(void *arg){
 
    //子线程修改共享的全局变量
    a = 4;		
    printf("In child pthread: a=%d\n", a);
				
}

  我们预期输出的是In main pthread: a=4, b=1, c=4 而实际输出的是In child pthread: a=4 In main pthread: a=1, b=1, c=1

  解决办法很简单,只需要在共享的全局变量加以volatie修饰即可

volatile int a = 1;

  我们可以想一想为什么会这样,如果每次赋值都去内存中读入 a , 对于程序来说开销实在太大了,这时候编译器优化会引入一个中间变量,加快程序执行效率,也正是因为优化原因,如果这个全局变量是多线程共享的,子线程可能在任意时刻改变a的值,但是主程序引入的中间变量值确是过去a的值,就可能出现数据未同步问题。

  而我们引入了volatile,还记得volatile的性质吗?

  • 保证可见性,当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

volatile使用场景总结

  1. 中断服务程序中修改的供其它程序检测的变量,需要加volatile(当变量在触发某中断程序中修改,而编译器判断主函数里面没有修改该变量,因此可能只执行一次从内存到某寄存器的读操作,而后每次只会从该寄存器中读取变量副本,使得中断程序的操作被短路)

  2. 多线程环境下各任务间共享的标志,应该加volatile;(在本次线程内, 当读取一个变量时,编译器优化时有时会先把变量读取到一个寄存器中;以后,再取变量值时,就直接从寄存器中取值;当内存变量或寄存器变量在因别的线程等而改变了值,该寄存器的值不会相应改变,从而造成应用程序读取的值和实际的变量值不一致。)

  3. 存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能由不同意义;

内嵌汇编volatile

  这个函数的作用就是原子性的增加一个值,本文我们不关注函数,来关注内嵌汇编的语法。

int inc(int *value, int add) {
	int old;
	//内嵌汇编volatile
	__asm__ volatile (
		"lock; xaddl %2, %1;"
		: "=a" (old)
		: "m" (*value), "a" (add)
		: "cc", "memory"
	);
	return old;
}
  • _asm _ :表示后面的代码为内嵌汇编
  • volatile:表示编译器不要优化代码,后面的指令 保留原样

内嵌汇编语法如下:

__asm__(汇编语句模板: 输出部分: 输入部分: 破坏描述部分)

具体的内容看下面的参考文章即可,这里我们的目的已经达到了。在内嵌汇编中,volatile的意思就代表编译器不要优化代码,后面的指令 保留原样。

参考文章

C语言volatile关键字详解

linux 源码中__asm__ __volatile__作用

猜你喜欢

转载自juejin.im/post/7127542458572865544