volatile关键字到底有什么用?

99ANYc3cd6
预计阅读时长 17 分钟
位置: 首页 C语言 正文

volatile 是什么?

volatile 是一个类型修饰符(Type Modifier),就像 const 一样,它告诉编译器:“不要对这个变量进行任何形式的优化,每次使用它时,都必须从它所在的内存地址中重新读取它的值。

c语言中的volatile
(图片来源网络,侵删)

volatile 的核心作用是抑制编译器的优化,确保变量的可见性顺序性


为什么需要 volatile?—— 编译器优化的“副作用”

为了理解 volatile 的必要性,我们首先要明白编译器会做什么,编译器的目标之一是生成高效的机器码,它会进行各种优化,

  • 代码移动:将循环中不变的变量读取操作移到循环外面。
  • 寄存器缓存:将频繁使用的变量值缓存在 CPU 寄存器中,以减少内存访问次数。
  • 死代码消除:移除那些看起来永远不会被执行或结果不会被使用的代码。

这些优化在单线程的、可预测的程序中是极好的,但在某些特殊场景下,这些优化会破坏程序的正确性。

一个经典的例子:硬件寄存器

c语言中的volatile
(图片来源网络,侵删)

假设你正在编写一个嵌入式程序,需要控制一个 LED 灯,这个 LED 灯的状态由一个内存映射的硬件寄存器控制,地址是 0x12345678

#define LED_CTRL_REG   (*(volatile unsigned int *)0x12345678)
void turn_on_led() {
    LED_CTRL_REG = 1; // 设置 LED 为开
}

如果没有 volatile,编译器会看到这段代码:

  1. 读取 LED_CTRL_REG 的值(从地址 0x12345678)。
  2. 将值 1 写入 LED_CTRL_REG(到地址 0x12345678)。

编译器可能会进行如下“优化”:

  • 读取优化:编译器发现你只是要写入 1,它才不关心你写入之前 LED_CTRL_REG 里是什么鬼东西呢!它可能会完全省略掉第一次的读取操作,直接生成一条 mov 指令,把 1 写入那个地址,这在大多数情况下是没问题的。
  • 更极端的优化:如果编译器发现 turn_on_led 函数被调用后,LED_CTRL_REG 的值再也没有被读过,它可能会认为 LED_CTRL_REG = 1; 这条语句是“无用的”,因为它没有产生任何“可观察到的效果”(从程序的角度看,内存被改了,但程序本身没读),于是直接删除整条语句!这会导致 LED 灯永远不会亮。

加上 volatile 后,LED_CTRL_REG 就变成了一个易变变量,编译器会收到指令:“这个家伙的值随时可能被我不知道的东西(比如硬件本身)改变,你不能相信你缓存在寄存器里的旧值。”

c语言中的volatile
(图片来源网络,侵删)

编译器会:

  1. 每次访问 LED_CTRL_REG,都生成一条从 0x12345678 读取的指令。
  2. 每次赋值给 LED_CTRL_REG,都生成一条写入 0x12345678 的指令。
  3. 绝不会进行任何可能导致操作被省略或移动的优化。

另一个例子:多线程共享变量

假设有两个线程,一个线程(生产者)更新一个全局变量,另一个线程(消费者)读取它。

int global_flag = 0; // 共享标志
// 线程1 (生产者)
void producer_thread() {
    // ...做一些耗时的工作...
    global_flag = 1; // 工作完成,设置标志
}
// 线程2 (消费者)
void consumer_thread() {
    while (global_flag == 0) {
        // 等待...
    }
    // ...处理工作...
}

在没有同步机制(如互斥锁)的情况下,consumer_thread 中的 while 循环可能会被编译器优化成死循环。

  • 编译器优化:编译器分析 consumer_thread 的代码,发现 global_flag 在循环内部没有被修改过,它可能会把 global_flag == 0 的结果计算出来,发现是 true,然后直接生成一个无限循环的汇编指令(jmp),因为它认为 global_flag 的值永远不会在循环内部改变。

  • 使用 volatile:如果我们将 global_flag 声明为 volatile

    volatile int global_flag = 0;

    编译器就知道 global_flag 的值可能会在“看不见”的地方被改变(比如另一个线程),它不会while (global_flag == 0) 优化成死循环,每次循环迭代,它都会从内存中重新读取 global_flag 的值,从而能够正确地检测到生产者线程设置的 1 并退出循环。

注意volatile 不能保证原子性,在上面的多线程例子中,global_flag = 1;global_flag == 0; 的读取/写入操作可能不是原子的,如果两个线程同时读写,仍然可能发生数据竞争。volatile 只解决了“总是从内存读”和“保证写入内存”的问题,但没有解决“操作不被打断”的问题,对于多线程同步,通常需要更强大的工具,如 stdatomic.h (C11) 或互斥锁。


volatile 的核心作用总结

  1. 保证可见性

    • 当一个线程修改了一个 volatile 变量时,新值会立刻同步到主内存。
    • 当另一个线程读取一个 volatile 变量时,会直接从主内存读取,而不是从自己的工作内存(或CPU缓存/寄存器)中读取。
    • 这确保了所有线程都能“看到”这个变量的最新值。
  2. 防止编译器优化

    • 禁止指令重排序:编译器不会随意地重新安排 volatile 变量的读写顺序。
    • 禁止死代码消除:所有对 volatile 变量的读写操作都会被保留,即使编译器认为它们是“无用的”。
    • 禁止寄存器缓存:每次访问 volatile 变量,都必须从内存中读取,或写入内存,不能长期缓存在寄存器里。

volatile 的典型使用场景

你应该在以下情况考虑使用 volatile

  1. 内存映射的硬件寄存器

    控制寄存器、状态寄存器、数据端口等,硬件可以在任何时候改变它们,程序也需要随时读取它们的状态。

  2. 中断服务程序 中共享的变量

    • 当一个普通线程/函数和中断服务程序共享一个全局变量时,这个变量必须声明为 volatile

    • 示例

      volatile int g_interrupt_flag = 0;
      void main_loop() {
          while (1) {
              if (g_interrupt_flag) {
                  // 处理中断
                  g_interrupt_flag = 0;
              }
              // ...其他任务...
          }
      }
      void ISR_Handler() { // 中断服务程序
          // ...硬件中断发生...
          g_interrupt_flag = 1; // 设置标志
      }

      如果没有 volatilemain_loop 中的 if (g_interrupt_flag) 可能会被优化掉,导致永远无法检测到中断。

  3. 多线程/多任务环境中共享的非原子变量

    • 当多个线程共享一个变量,且这个变量的读写操作本身是原子的(比如一个 int 在大多数系统上就是原子的),但需要确保可见性时,可以使用 volatile
    • 重要提醒:这仅适用于简单的标志位,如果涉及“读-改-写”操作(如 i++),volatile 是不够的,因为 i++ 不是原子操作,这时应该使用原子类型(stdatomic.h)。
  4. 全局变量,其地址可能被 setjmplongjmp 使用

    • 这是一种比较少见的情况,setjmp/longjmp 会绕过正常的函数调用栈,导致栈上的变量状态不一致。volatile 可以帮助在这种情况下保持某些变量的正确性。

volatile 的常见误区

  1. 误区:volatile 可以保证原子性。

    • 错误volatile 不能保证复合操作的原子性。
    • 例子volatile int counter = 0; counter++; 这个操作不是原子的,它包含“读取-修改-写入”三个步骤,在多线程环境下,如果两个线程同时执行 counter++,可能会导致结果不正确(丢失一次更新),要保证原子性,应使用原子操作。
  2. 误区:volatile 可以替代锁,用于多线程同步。

    • 错误volatile 的功能非常有限,它只保证了可见性和一定的顺序性,但没有提供互斥访问的保证,对于复杂的共享数据结构,必须使用互斥锁、信号量等同步原语。
  3. 误区:volatile 能让变量变得线程安全。

    • 错误,线程安全是一个更广泛的概念,它不仅要求可见性,还要求原子性和同步。volatile 只是线程安全工具箱中的一件小工具,而不是万能药。

volatileconst 的结合使用

volatileconst 可以同时修饰一个变量,这在嵌入式开发中很常见。

// 一个指向只读硬件寄存器的指针,这个寄存器的值可能随时被硬件改变
const volatile int * const hardware_reg = (const volatile int *)0x12345678;

我们来分解这个声明:

  • 最右边的 consthardware_reg 是一个指针常量,意味着它本身的值(即它指向的地址 0x12345678)不能被修改,你不能让它指向别的地方。
  • 中间的 volatile:它指向的内存地址(0x12345678是易变的,可能被硬件改变,所以每次读取都必须从内存中读。
  • 最左边的 const:它指向的 int 是只读的,你试图通过 *hardware_reg = some_value; 来修改这个地址的值是不允许的,编译器会报错。

这个声明完美地描述了一个“只读的、由硬件自动更新的”内存映射寄存器。


特性 描述
核心作用 抑制编译器优化,确保变量的可见性顺序性
关键点 每次访问都从内存读取,每次写入都立即写入内存。
主要用途 硬件寄存器
中断服务程序共享变量
多线程中的简单标志位
不能做什么 不能保证原子性i++ 不安全)
不能替代锁(不提供互斥)
不能保证线程安全
const 可以结合使用,如 const volatile int *,表示指向只读、易变内存的指针。

volatile 的黄金法则:当一个变量的值可以被程序自身之外的实体(如硬件、另一个线程、操作系统)改变时,就应该将其声明为 volatile

-- 展开阅读全文 --
头像
织梦后台自动裁切图片,尺寸如何自定义设置?
« 上一篇 03-19
织梦CMS5.7安装步骤详解?
下一篇 » 03-19
取消
微信二维码
支付宝二维码

目录[+]