繁体   English   中英

为什么 volatile 存在?

[英]Why does volatile exist?

volatile关键字有什么作用? 在C++中它解决了什么问题?

就我而言,我从来没有故意需要它。

如果您从内存中的某个位置读取数据,例如一个完全独立的进程/设备/任何可能写入的内容,则需要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 ,优化器将循环视为无用(这家伙从不设置值!他疯了,摆脱那些代码!)并且我的代码将在没有获得信号量的情况下继续运行,从而导致以后出现问题。

在开发嵌入式系统或设备驱动程序时需要volatile ,您需要读取或写入内存映射硬件设备。 特定设备寄存器的内容可能随时更改,因此您需要volatile关键字以确保此类访问不会被编译器优化掉。

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

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

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

来自 Dan Saks 的“Volatile as a promise”文章:

(...) 一个 volatile 对象是一个其值可能会自发改变的对象。 也就是说,当你将一个对象声明为 volatile 时,你是在告诉编译器该对象可能会改变状态,即使程序中的语句似乎没有改变它。”

以下是他关于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)

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()等 — 无法安全使用)。

我在 1990 年代初期曾经开发过的一个大型应用程序包含使用 setjmp 和 longjmp 的基于 C 的异常处理。 volatile 关键字对于其值需要保留在充当“catch”子句的代码块中的变量是必需的,以免这些变量存储在寄存器中并被 longjmp 清除。

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

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

当编译器坚持优化掉一个我希望能够在我逐步执行代码时看到的变量时,我在调试版本中使用了它。

除了按预期使用它外, 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 无论如何都不会传递非 volatile T 但是, volatile版本更糟糕,因此如果非 volatile f可用,则永远不要在重载决议中选择。

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

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

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

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

考虑以下情况

1) 被范围外的中断服务程序修改的全局变量。

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

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

1) 打开优化后,代码可能无法按预期工作。

2) 启用和使用中断时,代码可能无法按预期工作。

Volatile:程序员最好的朋友

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

即使没有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限定的左值完成的,编译器可以按照它认为合适的方式重新排序和合并它们。 如果使用volatile限定的左值完成操作,则质量实现应提供针对其预期平台和应用程序领域的代码可能需要的任何额外排序保证,而不要求程序员使用非标准语法。

不幸的是,许多编译器没有确定程序员需要什么保证,而是选择提供标准规定的最低限度的保证。 这使得volatile比它应该的有用得多。 例如,在 gcc 或 clang 上,需要实现基本“切换互斥锁”的程序员必须执行一个四件事:

  1. 将互斥锁的获取和释放放在编译器无法内联的函数中,并且无法对其应用整体程序优化。

  2. 将互斥锁保护的所有对象限定为volatile如果所有访问都发生在获取互斥锁之后和释放它之前,则不需要这样做。

  3. 使用优化级别 0 强制编译器生成代码,就好像所有未限定register对象都是volatile

  4. 使用特定于 gcc 的指令。

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

  1. 确保在每个需要获取或释放的地方执行volatile限定的写入。

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

所有的答案都很棒。 但最重要的是,我想分享一个例子。

下面是一个小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 = 8;之间的某处更改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循环现在可用于忙等待。

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

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

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

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

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

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

易失性读取就像输入操作(如scanf或使用cin ):值似乎来自程序的外部,因此任何依赖于该值的计算都需要在它之后开始

易失性写入就像输出操作(如printfcout的使用):该值似乎是在程序外部传达的,因此如果该值取决于计算,则需要在 之前完成

因此,可以使用一对 volatile 读/写来驯服基准并使时间测量有意义

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

我想引用 Herb Sutter 在他的GotW #95 中的话,可以帮助理解volatile变量的含义:

C++ volatile变量(在C#Java等语言中没有类似物)总是超出了本文和任何其他关于内存模型和同步的文章的范围。 那是因为C++ volatile变量根本与线程或通信无关,也不与这些东西交互。 相反, C++ volatile变量应该被视为进入语言之外的不同领域的门户——根据定义不遵守语言的内存模型的内存位置,因为该内存位置是由硬件访问的(例如,由子卡写入) ,拥有多个地址,或者“奇怪”且超出语言范围。 因此, C++ volatile变量通常是每个关于同步的指南的例外,因为使用普通工具(互斥锁、原子等)总是天生“活泼”和不可同步的,并且更普遍地存在于语言和编译器的所有正常之外,包括它们通常不能被编译器优化(因为编译器不允许知道它们的语义;一个volatile int vi;可能不像普通的int那样表现,你甚至不能假设像vi = 5; int read_back = vi;这样的代码vi = 5; int read_back = vi;保证会导致read_back == 5 ,或者像int i = vi; int j = vi;这样的代码读取 vi 两次将导致i == j ,如果vi是硬件计数器,则它不会为真)。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM