为什么挥发性存在?

volatile关键字的作用是什么?在c++中它能解决什么问题?

就我而言,我从来没有明知肚明地需要它。

54707 次浏览

在开发嵌入式系统或设备驱动程序时需要volatile,在那里你需要读取或写入内存映射的硬件设备。特定设备寄存器的内容随时都可能改变,所以你需要volatile关键字来确保这样的访问不会被编译器优化。

volatile是需要的,如果你正在从内存中的一个点读取,比如说,一个完全独立的进程/设备/任何可以写入的地方。

我曾经在纯c的多处理器系统中使用双端口ram。我们使用硬件管理的16位值作为信号量,以知道另一个家伙什么时候完成。基本上我们是这样做的:

void waitForSemaphore()
{
volatile uint16_t* semPtr = WELL_KNOWN_SEM_ADDR;/*well known address to my semaphore*/
while ((*semPtr) != IS_OK_FOR_ME_TO_PROCEED);
}

没有volatile,优化器认为循环是无用的(这家伙从不设置值!他疯了,删掉那代码吧!),我的代码会在没有获得信号量的情况下继续运行,从而在以后造成问题。

  1. 您必须使用它来实现自旋锁以及一些(所有?)无锁数据结构
  2. 与原子操作/指令一起使用
  3. 曾经帮助我克服编译器的错误(在优化过程中错误地生成代码)

为嵌入式开发,我有一个循环,检查可以在中断处理程序中更改的变量。如果没有“volatile”,循环就变成了noop——就编译器所知,变量永远不会改变,所以它优化了检查。

同样的事情也适用于在更传统的环境中可能在不同线程中更改的变量,但在那里我们经常进行同步调用,因此编译器在优化方面没有那么自由。

来自Dan Saks的“像承诺一样反复无常”文章:

易失性对象是其值可能自发变化的对象。也就是说,当你声明一个对象为volatile时,你是在告诉编译器这个对象可能会改变状态,即使程序中没有任何语句显示要改变它。”

以下是他关于volatile关键字的三篇文章的链接:

一些处理器具有超过64位精度的浮点寄存器(例如。32位x86没有SSE,见Peter的评论)。这样,如果您对双精度数运行多次操作,实际上会得到比将每个中间结果截断为64位更高精度的答案。

这通常很好,但这意味着根据编译器如何分配寄存器和进行优化,对于完全相同的输入,完全相同的操作将得到不同的结果。如果您需要一致性,那么您可以使用volatile关键字强制每个操作返回内存。

它对于一些没有代数意义但减少浮点误差的算法也很有用,比如Kahan求和。代数上它是一个nop,所以它经常会被错误地优化除非一些中间变量是不稳定的。

除了volatile关键字用于告诉编译器不要优化对某些变量(可以由线程或中断例程修改)的访问外,它还可以是用于删除一些编译器错误是的,它可以是—。

例如,我在一个嵌入式平台上工作,编译器对变量的值做出了一些错误的假设。如果代码没有优化,程序可以正常运行。通过优化(这是非常必要的,因为这是一个关键的例程),代码将无法正常工作。唯一的解决方案(虽然不是很正确)是将“错误的”变量声明为volatile。

我曾经在20世纪90年代早期开发过一个大型应用程序,其中包含使用setjmp和longjmp进行基于c语言的异常处理。volatile关键字对于那些值需要保存在作为“catch”子句的代码块中的变量是必要的,以免这些变量被存储在寄存器中并被longjmp清除。

我曾经在调试构建中使用过它,当编译器坚持要优化掉一个变量时,我希望在逐步执行代码时能够看到这个变量。

除了按预期使用它,volatile还用于(模板)元编程。它可以用来防止意外重载,因为volatile属性(如const)参与了重载解析。

template <typename T>
class Foo {
std::enable_if_t<sizeof(T)==4, void> f(T& t)
{ std::cout << 1 << t; }
void f(T volatile& t)
{ std::cout << 2 << const_cast<T&>(t); }


void bar() { T t; f(t); }
};

这是合法的;这两个重载都可能是可调用的,并且执行几乎相同的操作。volatile重载中的强制转换是合法的,因为我们知道bar无论如何都不会传递非易失性的T。然而,volatile版本严格来说更糟糕,所以如果非易失性f可用,永远不要在重载分辨率中选择。

注意,代码实际上从来不依赖于volatile内存访问。

在实现无锁数据结构时必须使用volatile。否则,编译器可以自由地优化对变量的访问,这将改变语义。

换句话说,volatile告诉编译器对这个变量的访问必须对应于物理内存的读/写操作。

例如,这是Win32 API中InterlockedIncrement的声明方式:

LONG __cdecl InterlockedIncrement(
__inout  LONG volatile *Addend
);

在标准C中,使用volatile的一个地方是与信号处理程序一起。事实上,在标准C中,在信号处理程序中可以安全地做的所有事情是修改volatile sig_atomic_t变量,或快速退出。事实上,AFAIK,这是标准C中唯一需要使用volatile来避免未定义行为的地方。

ISO/IEC 9899:2011§7.14.1.1 signal函数

¶5如果该信号不是调用abortraise函数的结果,则 如果信号处理程序引用任何带有静态或线程的对象,则行为未定义 对象的值以外的非无锁原子对象的存储持续时间 对象声明为volatile sig_atomic_t,或者信号处理程序调用任何函数 在标准库中除了abort函数,_Exit函数和 quick_exit函数,或者signal函数的第一个参数等于 与引起处理程序调用的信号相对应的信号号。 此外,如果对signal函数的调用导致SIG_ERR返回,则 errno的值是不确定的

252)如果任何信号是由异步信号处理程序生成的,该行为是未定义的。

这意味着在标准C中,你可以这样写:

static volatile sig_atomic_t sig_num = 0;


static void sig_handler(int signum)
{
signal(signum, sig_handler);
sig_num = signum;
}

除此之外就没什么了。

POSIX对于在信号处理程序中可以做的事情要宽松得多,但仍然存在限制(其中一个限制是标准I/O库——printf()等——不能安全地使用)。

volatile关键字的目的是防止编译器对可能以编译器无法确定的方式改变的对象应用任何优化。

声明为volatile的对象在优化中被省略,因为它们的值可以在任何时候被当前代码范围之外的代码更改。系统总是从内存位置读取volatile对象的当前值,而不是在请求时将其值保存在临时寄存器中,即使之前的指令从同一对象中请求值。

考虑以下情况

1)由中断服务例程在作用域外修改的全局变量。

2)多线程应用程序中的全局变量。

如果不使用volatile限定符,可能会出现以下问题

1)当优化被打开时,代码可能不会像预期的那样工作。

2)当中断被启用和使用时,代码可能不会像预期的那样工作。

Volatile:程序员最好的朋友

https://en.wikipedia.org/wiki/Volatile_(computer_programming)

我应该提醒你的一个用法是,在信号处理函数中,如果你想访问/修改一个全局变量(例如,将其标记为exit = true),你必须将该变量声明为'volatile'。

你的程序似乎即使没有volatile关键字也能工作?也许这就是原因:

如前所述,volatile关键字有助于以下情况

volatile int* p = ...;  // point to some memory
while( *p!=0 ) {}  // loop until the memory becomes zero

但是,一旦调用外部函数或非内联函数,似乎几乎没有任何影响。例如:

while( *p!=0 ) { g(); }

然后不管有没有volatile都会生成几乎相同的结果。

只要g()可以完全内联,编译器就可以看到正在发生的一切,因此可以进行优化。但是,当程序调用一个编译器看不到发生什么的地方时,编译器再做任何假设就不安全了。因此,编译器生成的代码总是直接从内存中读取。

但是要注意,当你的函数g()变成内联(由于显式更改或由于编译器/链接器的聪明)时,如果你忘记volatile关键字,你的代码可能会崩溃!

因此,我建议添加volatile关键字,即使你的程序似乎没有。它使意图在未来的变化方面更加清晰和强大。

在C语言的早期,编译器会将所有读取和写入左值的操作解释为内存操作,以与代码中出现的读取和写入相同的顺序执行。如果给编译器一定的自由来重新排序和合并操作,那么在许多情况下,效率可以大大提高,但这存在一个问题。尽管操作通常是按照特定的顺序指定的,只是因为有必要按照一些顺序指定它们,因此程序员从许多同样好的替代方案中选择了一个,但情况并不总是如此。有时,某些操作以特定的顺序发生是很重要的。

具体哪些排序细节是重要的,这取决于目标平台和应用领域。标准没有提供特别详细的控制,而是选择了一个简单的模型:如果一个访问序列是用不符合volatile的左值完成的,编译器可以根据它认为合适的方式重新排序和合并它们。如果一个操作是用__abc0限定的左值完成的,一个质量实现应该提供针对其预期平台和应用程序字段的代码可能需要的任何额外排序保证,而不要求程序员使用非标准语法。

不幸的是,许多编译器并没有确定程序员需要什么样的保证,而是选择提供标准规定的最低限度的保证。这使得volatile远没有它应有的用处。例如,在gcc或clang上,程序员需要实现一个基本的“切换互斥”;[一个已经获得并释放互斥量的任务直到另一个任务释放互斥量才会再次释放互斥量]必须做以下四件事之一:

  1. 将互斥量的获取和释放放在编译器不能内联的函数中,并且不能应用整个程序优化。

  2. 将互斥锁保护的所有对象限定为volatile——如果所有访问都发生在获得互斥锁之后和释放互斥锁之前,那么这就不应该是必要的。

  3. 使用优化级别0强制编译器生成代码,就好像所有不符合register的对象都是volatile一样。

  4. 使用gcc特定的指令。

相比之下,当使用更适合系统编程的高质量编译器时,例如icc,人们将有另一种选择:

  1. 确保每个需要获取或释放的地方都执行符合__abc0条件的写操作。

获得一个基本的“交接互斥”;需要volatile读取(看看它是否准备好了),并且不应该需要volatile写入(另一方不会试图重新获取它,直到它被归还),但必须执行毫无意义的volatile写入仍然比gcc或clang下可用的任何选项要好。

其他答案已经提到避免一些优化,以便:

  • 使用内存映射寄存器(或MMIO)
  • 写入设备驱动程序
  • 允许更容易的程序调试
  • 使浮点计算更具确定性

当你需要一个值看起来来自外部,不可预测,避免编译器基于已知值进行优化时,当一个结果实际上没有使用,但你需要计算它时,或者当它被使用,但你想要计算它几次作为基准时,你需要计算在精确的点开始和结束时,Volatile是必不可少的。

volatile读取类似于输入操作(如scanfcin的使用):该值似乎来自程序外部,因此任何依赖于该值的计算都需要在它之后开始

volatile写类似于输出操作(如printfcout的使用):该值似乎是在程序外部传递的,因此如果该值依赖于计算,则需要在之前完成

所以一对易变的读/写可以用来控制基准测试,使时间度量有意义

如果没有volatile,你的计算可以在因为没有什么可以阻止重新排序计算函数,如时间测量之前由编译器启动。

所有的答案都很好。但在此之上,我想分享一个例子。

下面是一个小的cpp程序:

#include <iostream>


int x;


int main(){
char buf[50];
x = 8;


if(x == 8)
printf("x is 8\n");
else
sprintf(buf, "x is not 8\n");


x=1000;
while(x > 5)
x--;
return 0;
}

现在,让我们生成上述代码的程序集(我将只粘贴与此相关的程序集的部分):

生成程序集的命令:

g++ -S -O3 -c -fverbose-asm -Wa,-adhln assembly.cpp

会众是这样。

main:
.LFB1594:
subq    $40, %rsp    #,
.seh_stackalloc 40
.seh_endprologue
# assembly.cpp:5: int main(){
call    __main   #
# assembly.cpp:10:         printf("x is 8\n");
leaq    .LC0(%rip), %rcx     #,
# assembly.cpp:7:     x = 8;
movl    $8, x(%rip)  #, x
# assembly.cpp:10:         printf("x is 8\n");
call    _ZL6printfPKcz.constprop.0   #
# assembly.cpp:18: }
xorl    %eax, %eax   #
movl    $5, x(%rip)  #, x
addq    $40, %rsp    #,
ret
.seh_endproc
.p2align 4,,15
.def    _GLOBAL__sub_I_x;   .scl    3;  .type   32; .endef
.seh_proc   _GLOBAL__sub_I_x

你可以在程序集中看到,没有为sprintf生成程序集代码,因为编译器假设x不会在程序外部发生变化。while循环也是如此。由于优化,while循环被完全删除,因为编译器认为它是一个无用的代码,因此直接将5分配给x(参见movl $5, x(%rip))。

如果外部进程/硬件将改变x的值在x = 8;if(x == 8)之间的某个位置,问题就会发生。我们希望else块可以工作,但不幸的是编译器已经删除了这部分。

现在,为了解决这个问题,在assembly.cpp中,让我们将int x;改为volatile int x;,并快速查看生成的程序集代码:

main:
.LFB1594:
subq    $104, %rsp   #,
.seh_stackalloc 104
.seh_endprologue
# assembly.cpp:5: int main(){
call    __main   #
# assembly.cpp:7:     x = 8;
movl    $8, x(%rip)  #, x
# assembly.cpp:9:     if(x == 8)
movl    x(%rip), %eax    # x, x.1_1
# assembly.cpp:9:     if(x == 8)
cmpl    $8, %eax     #, x.1_1
je  .L11     #,
# assembly.cpp:12:         sprintf(buf, "x is not 8\n");
leaq    32(%rsp), %rcx   #, tmp93
leaq    .LC0(%rip), %rdx     #,
call    _ZL7sprintfPcPKcz.constprop.0    #
.L7:
# assembly.cpp:14:     x=1000;
movl    $1000, x(%rip)   #, x
# assembly.cpp:15:     while(x > 5)
movl    x(%rip), %eax    # x, x.3_15
cmpl    $5, %eax     #, x.3_15
jle .L8  #,
.p2align 4,,10
.L9:
# assembly.cpp:16:         x--;
movl    x(%rip), %eax    # x, x.4_3
subl    $1, %eax     #, _4
movl    %eax, x(%rip)    # _4, x
# assembly.cpp:15:     while(x > 5)
movl    x(%rip), %eax    # x, x.3_2
cmpl    $5, %eax     #, x.3_2
jg  .L9  #,
.L8:
# assembly.cpp:18: }
xorl    %eax, %eax   #
addq    $104, %rsp   #,
ret
.L11:
# assembly.cpp:10:         printf("x is 8\n");
leaq    .LC1(%rip), %rcx     #,
call    _ZL6printfPKcz.constprop.1   #
jmp .L7  #
.seh_endproc
.p2align 4,,15
.def    _GLOBAL__sub_I_x;   .scl    3;  .type   32; .endef
.seh_proc   _GLOBAL__sub_I_x

在这里你可以看到生成了sprintfprintfwhile循环的汇编代码。这样做的好处是,如果x变量被一些外部程序或硬件更改,则将执行代码的sprintf部分。类似地,while循环现在可以用于忙等待。

我想引用Herb Sutter在他的GotW # 95中的一句话,这有助于理解volatile变量的含义:

C++ volatile变量(在像C#Java这样的语言中没有类似的变量)总是超出了本文和任何其他关于内存模型和同步的文章的范围。这是因为C++ volatile变量根本不是关于线程或通信的,并且不与这些东西交互。相反,C++ volatile变量应该被视为进入语言之外的不同世界的门户——根据定义,这个内存位置不遵守语言的内存模型,因为该内存位置是由硬件访问的(例如,由子卡写入),有多个地址,或者是“奇怪的”并且超出了语言。因此,C++ volatile变量对于所有关于同步的指导原则来说都是一个例外,因为它们总是固有的“racy”和无法使用正常工具(互斥对象,原子等)进行同步,并且更普遍地存在于所有正常的语言和编译器之外,包括编译器通常不能优化它们(因为编译器不允许知道它们的语义;volatile0的行为可能不像普通的volatile1,你甚至不能假设像volatile2这样的代码一定会导致volatile3,或者像volatile4这样的代码读取vi两次会导致volatile5,如果volatile6是一个硬件计数器,这将是不正确的)。