volatile关键字到底有什么用?

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

这是一个非常重要但又容易被误解的关键字,我会从以下几个方面来阐述:

c语言 voliate
(图片来源网络,侵删)
  1. volatile 是什么?(核心定义)
  2. 为什么需要 volatile(解决的问题)
  3. volatile 的使用场景(具体例子)
  4. volatile 的常见误区(容易犯的错误)
  5. volatileconst 的区别
  6. volatile 的高级用法:多线程

volatile 是什么?

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

volatile 的关键词是 “易变的”“不稳定的”,它向编译器发出一个信号:这个变量的值可能会在编译器“看不见”的地方被改变。


为什么需要 volatile?(解决的问题)

编译器为了优化代码性能,会做一些“聪明”的事情,

  • 代码优化(死循环消除):如果编译器发现一个变量在一个循环中没有被修改,它可能会把循环内的变量读取操作提到循环外面,或者干脆消除整个循环。
  • 寄存器优化:编译器可能会将频繁使用的变量缓存在 CPU 的寄存器中,以减少内存访问次数。

这些优化在普通变量上是完全正确且高效的,当变量被硬件(如外设寄存器)或在其他线程中修改时,这些优化就会导致程序逻辑错误。

c语言 voliate
(图片来源网络,侵删)

volatile 就是为了解决这种“编译器优化”和“实际硬件/多线程行为”之间的矛盾而存在的,它强制编译器放弃对特定变量的优化,确保每次访问都是直接从内存中读取,或者直接写入内存。


volatile 的使用场景(具体例子)

volatile 主要用于以下两种情况:

内存映射的外设寄存器

在嵌入式系统或操作系统开发中,CPU 与硬件设备(如串口、定时器、GPIO等)通信的方式之一就是通过内存映射,硬件寄存器被映射到特定的内存地址上。

例子:一个 LED 控制寄存器

假设地址 0x40001000 是一个控制 LED 灯的寄存器,向其写入 1 点亮 LED,写入 0 熄灭 LED。

// 假设这是一个指向 LED 控制寄存器的指针
#define LED_CTRL_REG (*(volatile unsigned int *)0x40001000)
// 点亮 LED
void turn_on_led() {
    LED_CTRL_REG = 1; // 写入 1
}
// 熄灭 LED
void turn_off_led() {
    LED_CTRL_REG = 0; // 写入 0
}

如果去掉 volatile 会发生什么?

// 错误的写法
#define LED_CTRL_REG (*(unsigned int *)0x40001000)
void some_function() {
    LED_CTRL_REG = 1; // 点亮 LED
    // ... 一些其他代码 ...
    LED_CTRL_REG = 0; // 熄灭 LED
}

编译器可能会进行如下优化:它发现 LED_CTRL_REG 是一个局部变量,并且只在 some_function 内部被赋值,它可能会认为 LED_CTRL_REG = 1; 这行代码是“无副作用”的,并且后续没有再读取它,于是可能会直接删除这行代码,结果就是 LED 根本没有被点亮,加上 volatile 后,编译器知道这个地址的值可能在任何时候被外部硬件改变,或者它本身对硬件有副作用,因此会保留所有写入操作。

被多个线程共享的变量

在多线程编程中,一个线程可能会修改一个变量,而另一个线程会读取这个变量,如果这个变量没有被 volatile 修饰,编译器可能会在一个线程的缓存中(或寄存器中)保留这个变量的旧值,导致另一个线程读取到的不是最新的值。

例子:一个简单的状态标志

#include <pthread.h>
int flag = 0; // 线程A和线程B共享的标志
// 线程A
void* thread_a_func(void* arg) {
    // 做一些耗时的工作...
    sleep(1);
    flag = 1; // 通知线程B工作完成
    return NULL;
}
// 线程B
void* thread_b_func(void* arg) {
    while (flag == 0) {
        // 等待...
    }
    printf("Thread A has finished!\n");
    return NULL;
}

如果去掉 volatile 会发生什么?

编译器可能会优化 thread_b_func 中的 while (flag == 0) 循环,它可能会将 flag 的值加载到寄存器中,然后因为循环体内没有修改 flag,它就一直使用寄存器里的旧值 0,导致 while 循环成为死循环,永远无法退出。

加上 volatile 后:

volatile int flag = 0; // 关键!
// ... 线程函数不变 ...

volatile 会强制每次循环都从内存中重新读取 flag 的值,当线程A将 flag 修改为 1 后,线程B的下一次循环检查就能立即看到这个变化,从而正确退出循环。


volatile 的常见误区

volatile 能保证原子性

这是最常见的误区! volatile 只能保证变量的可见性(一个线程的修改对其他线程立即可见),但它不能保证操作的原子性

volatile int counter = 0;
void increment() {
    counter++; // 这不是一个原子操作!
}

counter++ 这个操作在底层通常需要三步:

  1. 从内存读取 counter 的值到寄存器。
  2. 在寄存器中将值加 1。
  3. 将寄存器中的新值写回内存。

在多线程环境下,如果两个线程同时执行 increment(),它们可能在第一步和第二步之间发生交错,导致最终结果小于预期(从 0 开始,两个线程同时 ,结果可能还是 1)。

要保证原子性,需要使用原子操作(如 C11 的 stdatomic.h 或特定平台的汇编指令)。

volatile 能保证同步

volatile 不能替代锁(如 mutex),它不能防止多个线程同时修改共享数据导致的数据竞争,它只是解决了“看到最新值”的问题,但没有解决“修改过程不被打断”的问题。


volatileconst 的区别

constvolatile 是两个完全不同的概念,但它们可以同时使用。

特性 const volatile
含义 常量 易变
对编译器的指令 “请保护这个变量,不要尝试去修改它。” “请不要对这个变量做任何优化,它的值可能会被外部改变。”
主要目的 防止代码意外地修改一个本不该被修改的值。 确保每次都从内存读取,得到的是最新的值。
可变性 编译期保证其值“看起来”不会被修改。 运行期保证其值“可能”会被外部改变。
同时使用 const volatile int *p; 是合法的,一个只读的状态寄存器,它的值由硬件改变,但程序不能写入它。 它告诉编译器:1. 我不会修改它(const)。 2. 但它的值可能会被硬件改变,所以每次都要重新读取(volatile)。

volatile 的高级用法:多线程

在 C11 标准中,引入了 <stdatomic.h> 来处理原子操作。volatile 在多线程中的作用被更精确的原子类型(如 atomic_int)所补充和增强。

一个现代、健壮的多线程共享变量应该这样声明:

#include <stdatomic.h>
// 原子整数,兼具 volatile 的可见性和原子操作的保证
atomic_int flag = 0;
// 线程A
void* thread_a_func(void* arg) {
    // ... work ...
    atomic_store(&flag, 1); // 原子地写入 1
    return NULL;
}
// 线程B
void* thread_b_func(void* arg) {
    // 原子地读取,并检查是否等于 0
    while (atomic_load(&flag) == 0) {
        // spin-lock
    }
    printf("Thread A has finished!\n");
    return NULL;
}

这里的 atomic_int 类型本身就隐含了 volatile 的语义,并提供了更强的原子性保证,在新的代码中,应优先使用原子操作而不是单独的 volatile


  • 核心作用volatile 是一个类型修饰符,告诉编译器不要对变量进行优化,每次访问都必须从内存中读取或写入。
  • 主要目的:确保与硬件寄存器交互的正确性,以及在多线程环境中保证变量的可见性
  • 不能做什么
    • 不能保证原子性i++ 操作不是原子的。
    • 不能保证同步:不能替代互斥锁来防止数据竞争。
    • 不能保证线程安全:它只是让线程能看到最新的值,但如果多个线程同时修改,仍然是不安全的。
  • 现代实践:在多线程编程中,对于简单的标志位,volatile 可以工作,但对于复杂的共享数据,应优先使用 C11 的 <stdatomic.h> 中的原子类型,它们提供了更全面的保证。

volatile 的本质:“信我,这个变量不老实,别瞎优化。” 当你确定一个变量的变化不受你当前代码的控制时,就应该使用 volatile

-- 展开阅读全文 --
头像
C语言如何适配PowerPC架构?
« 上一篇 今天
Project C语言是什么?如何入门学习?
下一篇 » 今天
取消
微信二维码
支付宝二维码

目录[+]