Fork me on GitHub
0%

理解c/c++ Volatile关键字

网络上对于volatile的解读众说纷纭,而且其中较多的是java内存模型。

本篇文章将基于自己的学习和实验,针对C/C++语言嵌入式开发场景进行volatile关键字的分析

结论

volatile 关键字是 C /C++ 编程语言的一部分,作为一种类型修饰符。我将其作用总结为告诉编译器禁止优化其所修饰对象的读写访存操作及指令顺序,保证变量的可见性

可见性:当一个线程修改了共享变量后,其他线程能够立即得知这个修改。

有序性:程序执行的顺序按照代码的先后顺序执行。

原子性:一个操作或多个操作要么全部执行,且执行的过程不会被任何因素打断,要么就都不执行。

在《程序员的自我修养》这本书中讲到,volatile基本可以做到两个事情:

  1. 阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回
  2. 阻止编译器调整操作volatile变量的指令顺序

对于第一个作用volatile是可以很好的做到,而对于第二个作用编译器层面可以保证指令有序但是我们无法阻止cpu动态调整顺序。真正保证有序性是需要通过内存屏障完成的,而volatile并没有内存屏障的功能,gcc 提供了一个内联函数 asm volatile (“” : : : “memory”) 编译器屏障,具体平台相关内存屏障需要到具体的结构平台去参考

理解volatile

Collins对于Volatile释义:A situation that is volatile is likely to change suddenly and unexpectedly.

volatile 本身释义为 易失的、易变的

对于用volatile所修饰的对象,在其自身的含义基础上,有三个副词可以很好的对其性质进行诠释:

  • likely 可能地,指对象的状态可能变化、也可能不变保持状态,强调结果;

  • suddenly: 突然地,指对象状态的瞬时变化,强调过程;

  • unexpectedly: 不可预期地,指对象变化的时间结果都不可预期;

应用场景

根据含义性质,在嵌入式开发中以下三种场景是一定要考虑使用volatile关键字的(大多数情况一定使用!!!)

  1. 多线程任务读写同一全局变量,
  2. 中断服务程序修改的全局变量
  3. 内存映射外设寄存器

测试用例

中断场景

下面一段代码,我们所期待的结果是主程序运行,直到crtl+c按键按下程序退出,代码编译执行之后并不是我们所预期的样子。如果sig_done变量不通过volatile修饰程序将永远不会退出,而volatile关键字很好的解决了这一个问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
#include <signal.h>
#include <stdbool.h>

static bool sig_done = false;
//static volatile bool sig_done = false;
void handler(int sig)
{
if(sig == SIGINT)
┆ sig_done = true;
}

int main(int argc , char *argv[])
{
printf("main start, press ctrl+c go on\n");
signal(SIGINT, handler);
while(!sig_done) {
}
printf("main exit\n");
return 0;
}

//编译命令
gcc main.c -02
objdump -d a.out > a.s

通过反汇编比较两个程序的异同(左侧volatile,右侧无volatile)

image

没有voaltile的while实现方式为:

  1. 比较rip寄存器+0x2f65地址处的值(sig_done)与 0 是否相等
  2. 不相等跳转到main+0x30即上一条指令处
  3. 跳转

有volatile的while实现方式:

  1. nopl (%rax) 编译器开启优化后使指令按字对齐,减少取指令的时钟周期。

  2. 将 rip寄存器+0x2f65地址处的值放到eax寄存器中

    movzbl指令负责拷贝一个字节,并用0填充其目的操作数中的其余各位,这种扩展方式叫“零扩展”。

    movsbl指令负责拷贝一个字节,并用源操作数的最高位填充其目的操作数中的其余各位,这种扩展方式叫“符号扩展”。

  3. test %al,%al 对eax寄存器低位逻辑与

    Test命令:将两个操作数进行逻辑与运算,并根据运算结果设置相关的标志位(ze: zero flag)。但是,Test命令的两个操作数不会被改变。运算结果在设置过相关标记位后会被丢弃。

  4. je 如果ZF(零标志位)=1,零标志位为1(真值)说明结果为0,则转到label所指的指令语句;否则跳过这条语句,执行下条语句

多线程场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <stdio.h>
#include <unistd.h>
#include <thread>

static bool mstop = true;
//static volatile bool mstop = true;

void first_thread()
{
printf("%s start\n", __func__);
while(mstop) {
}
printf("%s exit\n", __func__);
}

void second_thread()
{
sleep(3);
printf("%s start\n", __func__);
mstop = false;
printf("%s exit\n", __func__);
}

int main(int argc, char *argv[])
{
std::thread fthread{first_thread};
std::thread sthread{second_thread};
fthread.join();
sthread.join();
while(1);
return 0;
}

外设寄存器

对于外设通常设置有状态寄存器,我们可以通过循环读取状态寄存器的值来查看对应外设的状态。对于如下示例,如果没有使用volatile关键字,编译器可能生成的代码是读取一个固定地址的值而不会每次都从主存中读取新的值(或者读取n次状态,生成的代码只读取了一次,然后使用n次相同的值)

1
2
3
4
5
6
//设置寄存器
*(volatile unsigned int *) STATUS_BASE_ADDR = 1 << 24;
//读寄存器
while(!(*(volatile unsigned int *) STATUS_BASE_ADDR)) {
// do action
}

线程安全的单例模式

该部分不是volatile的使用,而是对内屏屏障的理解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
//mp3decoder.h
class mp3decoder : public decoderImp
{
public:
static mp3decoder* getInstance();
private:
mp3decoder();
~mp3decoder();
static mp3decoder* mdecoder;
};

//mp3decoder.cpp
//线程不安全,适用于单线程
mp3decoder* mp3decoder::mdecoder = NULL;
mp3decoder* mp3decoder::getInstance()
{
if(mdecoder == NULL)
mdecoder = new mp3decoder();
return mdecoder;
}

//线程安全,但锁的代价太高
mp3decoder* mp3decoder::getInstance()
{
lock();
if(mdecoder == NULL)
mdecoder = new mp3decoder();
return mdecoder;
}

//线程安全,锁的代价太高
mp3decoder* mp3decoder::getInstance()
{
lock();
if(mdecoder == NULL)
mdecoder = new mp3decoder();
return mdecoder;
unlock();
}

//Double-Checked Locking Pattern (DCLP 双检查锁),指令重排序reorder不安全
mp3decoder* mp3decoder::getInstance()
{
if (mdecoder == NULL) {
lock();
if(mdecoder == NULL)
mdecoder = new mp3decoder();
unlock();
return mdecoder;
}
}

//内存屏障
mp3decoder* mp3decoder::getInstance()
{
if (mdecoder == NULL) {
if(mdecoder == NULL) {
mp3decoder *tmp = new mp3decoder();
MemoryBarrier();
mdecoder = tmp;
}
return mdecoder;
}
}

// mdecoder = new mp3decoder()在cpu层面动作:
// 1. 分配内存
// 2. 调用构造函数
// 3. 将内存地址赋值给mdecoder指针
// 由于2、3对于编译器来讲是有可能进行优化的,即先将指针赋值再调用构造函数,导致线程不安全!!

参考文章: