为什么在C语言中需要使用volatile?

551

为什么在C语言中需要使用volatile?它有什么用途?它会做什么?


2
https://dev59.com/8XVD5IYBdhLWcg3wJIAK - Frederik Slijkerman
相关:何时在多线程中使用volatile?同样适用于C11及其后续版本。虽然不是必需的,但在C11之前被用来实现无锁原子加载/存储。 - Peter Cordes
C#Java类似:*“volatile”关键字有什么用途?* - Peter Mortensen
一个更早的(2008-09-16):为什么会有volatile关键字? - Peter Mortensen
18个回答

599

volatile 告诉编译器不要对与 volatile 变量有关的任何内容进行优化。

使用它至少有三个常见的原因,都涉及变量的值可以在没有可见代码的情况下发生变化的情况:

  • 当你与会自行更改值的硬件进行接口时
  • 当有另一个线程在运行并且也使用该变量时
  • 当有一个信号处理程序可能会更改变量的值时

假设你有一个映射到RAM的小硬件,并且有两个地址:一个命令端口和一个数据端口:

typedef struct
{
  int command;
  int data;
  int isBusy;
} MyHardwareGadget;

现在你想发送一些命令:
void SendCommand(MyHardwareGadget* gadget, int command, int data)
{
  // wait while the gadget is busy:
  while (gadget->isBusy)
  {
    // do nothing here.
  }
  // set data first:
  gadget->data    = data;
  // writing the command starts the action:
  gadget->command = command;
}

看起来很简单,但是由于编译器可以自由地改变数据和命令的顺序,所以可能会失败。这会导致我们的小装置使用先前的数据值发出命令。还要注意忙等待循环。编译器会对其进行优化。它会尝试聪明地只读取一次“isBusy”的值,然后进入无限循环。这不是你想要的。
解决这个问题的方法是将指针“gadget”声明为“volatile”。这样编译器就被迫按照你写的方式执行。它不能删除内存赋值,不能将变量缓存到寄存器中,也不能改变赋值的顺序。
这是正确的版本:
void SendCommand(volatile MyHardwareGadget* gadget, int command, int data)
{
  // wait while the gadget is busy:
  while (gadget->isBusy)
  {
    // do nothing here.
  }
  // set data first:
  gadget->data    = data;
  // writing the command starts the action:
  gadget->command = command;
}

61
个人而言,我更喜欢在与硬件通信时明确指定整数的大小,例如int8/int16/int32。不过你的回答很好哦 ;) - tonylo
33
是的,你应该使用固定的寄存器大小来声明变量,但嘿,这只是一个例子。 - Nils Pipenbrinck
78
在编写多线程代码时,如果处理的数据没有受到并发保护,就需要使用 "volatile"。有时会有这种情况出现,例如,在不需要明确的并发保护的情况下编写线程安全的循环消息队列,但是它仍然需要 "volatile"。请注意,这里不需要解释其他内容。 - Gordon Wrigley
23
请加强阅读 C 语言规范。Volatile 关键字只有在访问内存映射设备 I/O 或被异步中断函数访问的内存时才有明确定义的行为。它对线程的影响没有说明,并且编译器优化掉多个线程访问的内存访问是符合规范的。 - ephemient
28
完全错误。遗憾的是17个人不知道这一点。volatile 不是内存屏障,它只与避免在基于非可见副作用的假设进行优化时省略代码有关。 - v.oddou
显示剩余15条评论

249

volatile 在 C 语言中的作用是防止自动缓存变量的值。它会告诉编译器不要缓存这个变量的值,因此每次遇到该volatile 变量时,它都会从主内存中获取最新值的代码。使用这种机制是因为任何时候这个值都可能被操作系统或任何中断修改。因此,使用volatile 可以帮助我们每次都获取最新值。


1
出现了吗?'volatile' 最初不是从 C++ 借来的吗?好像我记得... - syntaxerror
2
这不仅仅是关于volatile的 - 如果指定为volatile,它还会禁止一些重新排序。 - FaceBro
6
volatile 的目的是让编译器在进行优化的同时,仍然允许程序员实现不进行这种优化时的语义。标准的作者们希望质量优秀的实现能够支持针对它们的目标平台和应用领域所需的任何语义,并且并不希望编译器的编写者寻求提供符合标准但是不完全愚蠢的最低质量语义(请注意标准的作者在理论基础中明确承认……)。 - supercat
3
@syntaxerror 当C语言比C++更早推出十多年(无论是首次发布还是首个标准),它怎么可能从C++中借鉴呢? - phuclv
1
一个人怎么能说“编译器缓存变量”呢?这不是CPU的工作吗?编译器在运行时没有积极参与... - Abdel Aleem
显示剩余5条评论

225

volatile的另一个用途是信号处理程序。如果您有以下代码:

int quit = 0;
while (!quit)
{
    /* very small loop which is completely visible to the compiler */
}
编译器可以注意到循环体未触及到 quit 变量并将循环转换为 while (true) 循环。即使 quit 变量在 SIGINTSIGTERM 信号处理程序中设置; 编译器也无法知道。
但是,如果 quit 变量被声明为 volatile,编译器就会被强制每次都加载它,因为它可以在其他地方被修改。这正是您在此情况下想要的。

1
当你说“编译器被迫每次加载它”,这是否意味着当编译器决定优化某个变量并且我们没有将该变量声明为易失性时,在运行时该特定变量会被加载到CPU寄存器而不是内存中? - Amit Singh Tomar
4
这段话的意思是:它指的就是字面意思:每次代码检查该值时都会重新加载。否则,编译器可以假设不带引用变量的函数不能修改它,因此假定像CesarB所想的那样,上面的循环不设置“quit”,编译器可以将其优化为常量循环,假设在迭代之间无法更改“quit”。请注意:这并不一定是实际线程安全编程的良好替代品。 - underscore_d
6
@PierreG. 不会,除非有明确说明,否则编译器始终假定代码是单线程的。也就是说,在没有 volatile 或其他标记的情况下,它将假定一旦变量进入循环,循环外部不会对其进行修改,即使它是全局变量。 - CesarB
谢谢CesarB。那么在这种情况下,编译器能够优化像这样的while循环:while(gloval_variable <> 0) {} 并使这个循环永远锁定吗? - Pierre G.
2
@PierreG。是的,例如使用gcc -O3 -S编译extern int global; void fn(void) { while (global != 0) { } },并查看生成的汇编文件,在我的机器上它会执行movl global(%rip), %eax; testl %eax, %eax; je .L1; .L4: jmp .L4,即如果全局变量不为零,则进入无限循环。然后尝试添加volatile并查看差异。 - CesarB
显示剩余4条评论

70

volatile 关键字告诉编译器,你的变量可能会被其他方式改变,而不仅仅是访问它的代码。例如,它可能是一个 I/O 映射的内存位置。如果在这种情况下没有指定此关键字,则某些变量访问可能会被优化,例如,它的内容可以保存在寄存器中,并且不再读取内存位置。


当硬件或其他程序/驱动程序特别更改内存地址时,如果这是有意的行为,您可以将其设置为易失性以通知编译器不要优化对内存地址的访问。 - Lewis Kelsey

33
看看Andrei Alexandrescu的这篇文章,"volatile - 多线程程序员的好朋友"
引用: volatile关键字被设计用来防止编译器优化,在某些异步事件存在的情况下可能导致代码错误。例如,如果你将一个原始变量声明为volatile,编译器不允许将其缓存到寄存器中——这是一种常见的优化方式,但如果该变量在多个线程之间共享,那么这种优化将会带来灾难性的后果。因此,一般规则是,如果你有原始类型的变量必须在多个线程之间共享,就将这些变量声明为volatile。但实际上,你可以通过这个关键字做更多的事情:你可以使用它来捕获不安全的线程代码,并且可以在编译时进行检查。本文展示了如何实现这一点;解决方案涉及一个简单的智能指针,它还可以轻松地对代码的关键部分进行序列化。
这篇文章适用于C和C++。

还请参阅Scott Meyers和Andrei Alexandrescu的文章“C++ and the Perils of Double-Checked Locking”:

因此,在处理某些内存位置(例如内存映射端口或由中断服务例程引用的内存)时,必须暂停一些优化。volatile用于指定对这些位置的特殊处理,具体包括:(1)volatile变量的内容是“不稳定的”(可能通过编译器未知的方式发生变化),(2)对volatile数据的所有写入都是“可观察的”,因此它们必须被严格执行,以及(3)对volatile数据的所有操作按照它们在源代码中出现的顺序执行。前两条规则确保正确的读取和写入。最后一条规则允许实现混合输入和输出的I/O协议。这大致是C和C++的volatile所保证的。


1
标准是否规定,如果值从未被使用,读取是否被视为“可观察行为”?我的印象是应该这样,但当我在其他地方声称时,有人向我提出了引证的挑战。在任何平台上,如果对易失变量的读取可能具有任何影响,编译器都应该要求生成执行每个指示读取恰好一次的代码;如果没有这个要求,编写生成可预测读取序列的代码将会很困难。 - supercat
编译器不允许将其缓存到寄存器中。大多数RISC架构都是寄存器机器,因此任何读取-修改-写入操作都必须将对象缓存在寄存器中。volatile不能保证原子性。 - too honest for this site
2
@ Olaf:将内容加载到寄存器中与缓存不是同一件事。 缓存会影响加载或存储的数量或其时间。 - supercat
1
@Olaf:标准并没有试图指定什么“volatile”行为是必要的,以使实现适用于任何特定目的,例如编程特定的真实机器。相反,它期望实现者认识到真实目标机器可能与抽象机器不同的方式,因此认识到特定平台的用户需要什么语义,并给予他们。在硬件平台上,其中负载或存储可能触发影响程序执行的操作时,优质的实现应将这些操作视为... - supercat
1
尽管它们可能调用未知函数[因为它们很可能会这样做],但gcc和clang的作者似乎认为,由于这些事情在“抽象机器”上不会发生,所以在针对真实机器时没有理由允许这种可能性。 - supercat
显示剩余3条评论

31

我的简单解释如下:

在某些情况下,基于逻辑或代码,编译器会对其认为不会改变的变量进行优化。 volatile 关键字可以防止变量被优化。

例如:

bool usb_interface_flag = 0;
while(usb_interface_flag == 0)
{
    // execute logic for the scenario where the USB isn't connected 
}

从上面的代码中,编译器可能认为usb_interface_flag被定义为0,并且在while循环中它将永远是0。经过优化后,编译器将始终将其视为while(true),导致无限循环。

为避免这种情况,我们将标志声明为volatile,告诉编译器该值可能会被外部接口或程序的其他模块更改,即请不要对其进行优化。这就是volatile的用例。


20

volatile 的一个边缘用途是以下内容。假设你想要计算函数 f 的数值导数:

double der_f(double x)
{
    static const double h = 1e-3;
    return (f(x + h) - f(x)) / h;
}
问题在于由于舍入误差,x+h-x通常不等于h。想一想:当你减去非常接近的数字时,你会失去很多有效数字,这可能破坏导数的计算(比如1.00001-1)。一个可能的解决方法是:
double der_f2(double x)
{
    static const double h = 1e-3;
    double hh = x + h - x;
    return (f(x + hh) - f(x)) / hh;
}

但是,根据你的平台和编译器开关的不同,那个函数的第二行可能会被一个高度优化的编译器覆盖。因此你应该写成

    volatile double hh = x + h;
    hh -= x;

为了强制编译器读取包含hh的内存位置,放弃可能出现的优化机会。


在求导公式中,使用 hhh 有什么区别?当计算 hh 时,最后一个公式与第一个公式没有区别。也许应该是 (f(x+h) - f(x))/hh - Sergey Zhukov
2
hhh之间的区别在于,hh通过操作x + h - x被截断为某个负二次幂。在这种情况下,x + hhx恰好相差hh。您也可以采用您的公式,它将给出相同的结果,因为x + hx + hh是相等的(这里重要的是分母)。 - Alexandre C.
3
将这句话更易读的方式不是这样写吗?x1=x+h; d = (f(x1)-f(x))/(x1-x),而不使用“volatile”。 - Sergey Zhukov
编译器能够清除函数的第二行中的任何引用吗? - CoffeeTableEspresso
@CoffeeTableEspresso:不好意思,我越了解浮点数,就越相信编译器只有在明确告知的情况下,才能进行优化,例如使用“-ffast-math”或等效选项。 - Alexandre C.

11
有两个用途。这些在嵌入式开发中经常使用。
1. 编译器不会对使用带有volatile关键字定义的变量的函数进行优化。 2. Volatile用于访问RAM、ROM等精确的内存位置。它更常用于控制内存映射设备、访问CPU寄存器和定位特定的内存位置。
请参考汇编清单中的示例。 关于在嵌入式开发中使用C语言"volatile"关键字的用法

2
编译器不会优化使用volatile关键字定义的变量的函数 - 这是完全错误的。 - too honest for this site
此外,volatile 可以用于强制内存读取或写入为特定大小。例如,可能存在仅支持 8 位或 16 位的内存,而错误类型的读取或写入将产生不良影响。如果没有 volatile,编译器可以自由地将四个 8 位写入组合成一个 32 位写入,即使内存不支持这种写入方式。甚至有些情况下,for 循环会被更改为 memset,试图向仅支持 16 位的内存写入字节,但使用 volatile 后这种情况就消失了。 - Dwedit

10

我来举另一个需要使用volatile关键字的场景。

假设你为了更快的I/O操作,将一个文件进行了内存映射,并且这个文件可能在后台发生变化(例如该文件不在您的本地硬盘上,而是由另一台计算机通过网络提供)。

如果你通过指向非volatile对象的指针(在源代码级别)访问内存映射文件的数据,那么编译器生成的代码可以在你没有意识到的情况下多次获取相同的数据。

如果这些数据恰好发生了变化,你的程序可能会使用两个或多个不同版本的数据,并进入不一致状态。这不仅会导致程序逻辑上的错误行为,还可能导致安全漏洞,尤其是当它处理来自不受信任的文件或位置的文件时。

如果你关心安全(应该的),这是一个重要的情况需要考虑。


9
在我看来,你不应该对volatile抱有太高的期望。举个例子,看看Nils Pipenbrinck的高票答案中的例子。
我要说的是,他的例子并不适合volatilevolatile只用于: 防止编译器进行通常有用和可取的优化。它与线程安全、原子访问甚至内存顺序无关。
在那个例子中:
    void SendCommand (volatile MyHardwareGadget * gadget, int command, int data)
    {
      // Wait while the gadget is busy:
      while (gadget->isbusy)
      {
        // Do nothing here.
      }

      // Set data first:
      gadget->data    = data;

      // Writing the command starts the action:
      gadget->command = command;
    }

在编译器生成的代码中,gadget->data = datagadget->command = command 之前只有在编译代码中才能保证。
在运行时,处理器可能会根据处理器架构重新排序数据和命令的赋值顺序。硬件可能会获取错误的数据(假设一个小工具被映射到硬件I/O)。在数据和命令赋值之间需要使用内存屏障。

4
我会说,volatile 的使用是为了防止编译器进行通常有用和理想的优化。按照所写的方式,它听起来像是 volatile 没有任何原因地降低了性能。至于是否足够,这将取决于程序员可能比编译器更了解系统的其他方面。另一方面,如果处理器保证写入特定地址的指令将刷新 CPU 缓存,但编译器没有提供刷新寄存器缓存变量的方法,CPU 就不知道,那么刷新缓存就是无用的。 - supercat

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接