1. 一个死循环引出的问题:volatile 究竟管什么
前几年接手一个嵌入式项目,主循环里有一行再普通不过的等待代码:while (!g_frame_ready) { },g_frame_ready在串口中断服务程序里被置 1。逻辑上毫无问题,实际跑起来主循环纹丝不动,串口数据明明收进来了,中断也确实进了。加了句打印就好了,去掉打印又卡死。当时把中断优先级、串口寄存器、时钟配置全翻了一遍,最后在反汇编里看到那行等待被编译成了jmp .L2的无条件自跳——编译器认为g_frame_ready在这个循环里没有任何修改者,第一次读进寄存器之后就再也不回内存取值了。
这就是volatile这个关键字存在的全部理由:它不是让你"多线程安全"的万能药,也不是"防止编译器优化"的玄学开关,它只做一件事——告诉编译器这个对象的每一次读写都是可观测的副作用,不许缓存、不许删除、不许合并。仅此而已。
写这篇文章的目标读者很明确:写过单片机裸机程序、被中断和主循环共享变量坑过的人;学过一点多线程、看到网上"volatile 能保证可见性"就照抄的人;以及在面试里被问到"volatile 和 atomic 有什么区别"答不上来的人。我会把编译器优化行为、中断场景的必需性、多线程场景的失效边界、以及排查这类问题的具体手段,从头到尾拆一遍。文中涉及的平台细节会点明是基于常见编译器的通用行为,具体到某个工具链需要自己验证,这点我先说在前面。
先说结论性的三句话,后面全部展开:中断与主循环共享的简单标志,volatile 是必需品;硬件寄存器映射,volatile 是必需品;多线程之间的同步,volatile 是必要但不充分的,绝大多数情况下它压根不够用。
1.1 编译器"帮你"做出的错误决定
要理解 volatile,得先理解编译器在没有它的时候会干什么。现代编译器做优化的前提假设是:程序的行为完全由抽象机语义决定,一个变量只要在当前执行流里没有写入者,它的值就不会变。基于这个假设,编译器会做几类对你来说很致命的变换。
第一类是寄存器缓存。循环里反复读同一个变量,编译器会把它提到寄存器里只读一次。在单线程语义下这是完全正确的优化,因为循环体里没写它。但中断是异步插进来的执行流,编译器看不到,也不认为它是"当前执行流的一部分"。于是就出现了开头那一幕。
第二类是删除看似无用的读写。你写*p_reg = 1;然后又写*p_reg = 1;,编译器觉得第二次写是冗余的,直接删掉。对普通内存这对,对硬件寄存器就是灾难——很多外设的清标志、启动转换、喂狗操作,写同样的值恰恰是有效操作。
第三类是重排与合并。编译器可能把两次相邻的 volatile 之外的访问调换顺序,或者把两个字节的写合并成一次半字写。前者会破坏你精心设计的时序依赖,后者在访问宽度敏感的寄存器上会直接让外设行为异常。
这些变换在-O0下基本不会发生,所以很多人"我本地跑得好好的",换到-O2或者换一颗芯片就炸了。这也是为什么这类 bug 特别难复现——它跟编译器版本、优化等级、甚至代码里加没加一句打印都有关系。
注意:加了打印就好、删了打印就坏,这个现象本身就是"编译器把变量缓存了"的强信号。别急着怀疑硬件,先看反汇编。
1.2 volatile 的真实语义边界
C 标准对 volatile 的描述集中在"副作用"和"抽象机可观测行为"上。翻译成人话是三条:
- 对 volatile 对象的访问,构成程序的副作用,编译器不得删除;
- 对 volatile 对象的访问,严格按照抽象机规定的顺序求值,同一执行流内前后两个 volatile 访问的相对顺序不能被调换;
- 每次访问都必须真正落到内存(或者落到该对象所在的可观测位置),不能把值长期留在寄存器里跨访问复用。
注意第三条的措辞,是"不能跨访问复用",而不是"每次都必然产生一条内存读写指令"——在 x86 这类强内存模型上,编译器确实会生成真实的 load/store;在某些架构上,如果对象被判定为局部且地址未被取用,行为可能更微妙,但标准层面要求的是可观测语义一致。
再看它不保证什么,这一段是重点:
| 能力 | volatile 是否提供 | 说明 |
|---|---|---|
| 禁止编译器删除访问 | 是 | 标准强制 |
| 禁止编译器跨访问缓存 | 是 | 标准强制 |
| 保证单条访问的原子性 | 否 | 32 位机上的 64 位访问照样撕裂 |
| 保证多核/多线程内存可见性 | 否 | 需要 memory barrier 或原子类型 |
| 保证 CPU 层面的执行顺序 | 否 | 编译期有序,硬件可能乱序 |
| 建立 happens-before 关系 | 否 | 这是原子类型和锁的职责 |
| 替代 cache 维护操作 | 否 | DMA 场景必须显式 flush/invalidate |
这张表我建议每个写底层代码的人都背下来。网上大量"volatile 保证可见性"的说法,在国内语境下通常指"保证编译器不去缓存它",跟 Java 里 volatile 的语义(带内存屏障的可见性保证)完全是两回事。把 Java 的理解搬到 C 上,是这类 bug 最主要的来源。
1.3 三类场景的适用性判断
我把实际工作里遇到的 volatile 使用场景分成三类,判断标准很干脆。
第一类,必须加,不加必出问题:中断服务程序与主流程共享的标志、状态变量;内存映射的硬件寄存器;信号处理函数里修改的volatile sig_atomic_t类型变量;setjmp/longjmp之间被修改的局部变量。
第二类,加了有意义但不够:中断与主流程共享的缓冲区索引、环形队列的读写指针。这些加了 volatile 能保证每次都真读真写,但多字节变量在 8 位或 16 位机上的读写撕裂问题、以及"先写数据再更新索引"的顺序问题,volatile 管不了,得配合临界区或内存屏障。
第三类,加了基本没用甚至有害:多线程之间的计数器、任务间传递的结构体、任何需要原子性的读改写操作。这类场景加 volatile 只是让编译器别优化,但 CPU 的乱序执行、store buffer、cache 一致性协议该怎样还怎样,而且因为多了强制内存访问,性能还降了。
后面几章我会按这三类分别展开,重点放在第二类和第三类,因为这两类才是真正容易写错的地方。
2. 从编译器视角拆解 volatile 的生成规则
2.1 看一眼汇编就知道差在哪
与其背定义,不如直接看代码和反汇编的对照。下面这段是典型的"等标志"结构:
/* 版本 A:没有 volatile */ int g_ready = 0; void wait_a(void) { while (!g_ready) { /* 空转 */ } do_something(); } /* 版本 B:加了 volatile */ volatile int g_ready_v = 0; void wait_b(void) { while (!g_ready_v) { /* 空转 */ } do_something(); }用常见的交叉编译器在-O2下编译,版本 A 的循环体大概率会变成类似这样的东西(不同工具链细节不同,但形态一致):
wait_a: mov eax, DWORD PTR g_ready[rip] ; 只读一次 test eax, eax jne .Ldone .Lloop: jmp .Lloop ; 死循环,永不回读 .Ldone: ...而版本 B 会变成:
wait_b: .Lloop: mov eax, DWORD PTR g_ready_v[rip] ; 每轮都重新读内存 test eax, eax je .Lloop ...差别就在那条mov指令的位置。版本 A 里它被提到了循环外面,版本 B 里它老老实实待在循环体内。这就是"每次访问都落到内存"的实际含义。
这里有个容易被忽略的细节:volatile修饰的是对象还是指针,效果完全不同,这三种写法含义各异:
volatile int *p1; /* 指向 volatile int 的指针,*p1 每次真读,p1 本身可被优化 */ int * volatile p2; /* volatile 指针,p2 每次真读,*p2 不保证 */ volatile int * volatile p3;/* 两者都 volatile */访问硬件寄存器时要用第一种或第三种;如果指针本身在中断里被改写,就得用第二种或第三种。我在代码评审里见过不少把volatile int *当成"指针本身 volatile"来理解的,结果指针被缓存了,行为诡异。
2.2 顺序性:volatile 保证什么、不保证什么
很多人以为加了 volatile 就有顺序保证了,这个理解对不对?半对。
编译期:C 标准规定,同一执行流中前后两个 volatile 访问的相对顺序不能被编译器调换。你写:
volatile int *reg_a = (volatile int *)0x40001000; volatile int *reg_b = (volatile int *)0x40001004; *reg_a = 1; /* 先写 A */ *reg_b = 2; /* 后写 B */编译器不会把这两条交换位置。这一点很重要,很多外设的时序要求就是靠这个保证的,比如"先写数据寄存器,再写启动位"。
但,编译器不会调换,不代表 CPU 不会。在带 store buffer 或多发射乱序执行的处理器上,两条 store 指令完全可能以相反的顺序到达总线。如果你的外设对写顺序敏感(比如某些控制器的命令寄存器与参数寄存器),只在源码层面靠 volatile 是危险的,需要插入显式的内存屏障指令(如 ARM 的DMB)。
还有一类更隐蔽的顺序问题:volatile 与非 volatile 访问之间的顺序。标准并没有规定 volatile 写和普通写之间不能重排。也就是说:
buffer[0] = data; /* 普通写 */ g_flag = 1; /* volatile 写 */编译器完全有可能把g_flag = 1挪到buffer[0] = data前面去。这时候中断那边看到 flag 置位了,去读 buffer 却读到旧数据。这个 bug 在真机上表现为"偶发读到脏数据",概率极低,排查起来极其痛苦。
解决办法是在中间插一道编译器屏障:
buffer[0] = data; __asm__ __volatile__("" ::: "memory"); /* 编译器屏障,告诉编译器内存可能被改动 */ g_flag = 1;"" ::: "memory"这个空汇编的意义是:告诉编译器这段内联汇编会读写内存,因此它不能把内存访问跨越这条线重排。这条语句不生成任何机器指令,纯粹是给编译器看的。如果还需要 CPU 层面的顺序,就得换成带真实指令的屏障,比如__sync_synchronize()或者对应架构的 barrier 内建函数。
2.3 volatile 与编译器屏障的分工
这两者经常被混为一谈,我用一句话区分:volatile 管"这一次访问不能被优化掉",编译器屏障管"这些访问之间的顺序不能被调换"。
volatile是逐个对象生效的,你给变量加了它就只保护这个变量。而编译器屏障是一个"栅栏",把栅栏两边所有的内存访问隔开。实际写驱动或中断通信代码时,常见组合方式是:
typedef struct { unsigned char buf[256]; volatile unsigned int head; /* 写指针,中断写,主流程读 */ volatile unsigned int tail; /* 读指针,主流程写,中断读 */ } ring_t;写入方(中断)的流程:
void isr_push(ring_t *r, unsigned char c) { unsigned int next = (r->head + 1) & 0xFF; if (next == r->tail) { return; /* 满,丢弃 */ } r->buf[r->head] = c; /* 先写数据 */ __asm__ __volatile__("" ::: "memory"); /* 屏障:数据必须先于 head 可见 */ r->head = next; /* 后更新 head */ }读取方(主流程)的流程:
int main_pop(ring_t *r, unsigned char *out) { if (r->head == r->tail) { return 0; } *out = r->buf[r->tail]; /* 先读数据 */ __asm__ __volatile__("" ::: "memory"); /* 屏障:数据读完才能推进 tail */ r->tail = (r->tail + 1) & 0xFF; return 1; }注意这里的顺序设计逻辑:数据缓冲区本身不需要 volatile(它被屏障保护,不会被跨越重排),但head/tail 索引必须 volatile(否则主流程的while (r->head == r->tail)会被优化成死循环)。这是一套很典型的"volatile + 屏障"配合模式,比给整个结构体加 volatile 要干净得多,性能也更好。
给整个结构体加 volatile 会带来副作用:每一次成员访问都强制真读真写,memcpy这类库函数因为参数类型不匹配还会直接编译不过或者行为未定义。我在一个项目里见过把 512 字节的帧缓冲区整体标 volatile 的,性能直接掉了一截,后来改成只对索引加 volatile 就恢复了。
3. 中断服务程序里的 volatile 实战
3.1 标志位为什么必须加 volatile
中断场景是 volatile 最不可替代的地方,原因前面说过:ISR 对编译器来说是"不可见的执行流",编译器在分析主流程时,完全不知道有个中断随时会改内存。所以任何跨越"中断与主流程"边界的变量,都应该加 volatile。
具体来说有这么几类:
- 事件标志:
volatile int g_uart_rx_done;这类,ISR 置位,主流程轮询清除。 - 计数器:计时中断里递增的 tick 计数,主流程用来做延时或超时判断。这类要注意位宽,8 位机上的 32 位 tick 读取可能撕裂。
- 状态机变量:ISR 推进状态,主流程依据状态做动作。
- 共享的小数据:比如最近一次的 ADC 采样值。
反过来说,只在单一执行流里访问的变量千万不要乱加 volatile。加了会禁止编译器把它放进寄存器,循环里每次访问都变成一次内存读写,在一些紧凑的循环里性能损失能到 20% 以上,而且在多核平台上还会增加 cache 抖动。我见过有人"为了保险"把整个模块的全局变量全标了 volatile,代码跑得又慢又难维护,这是典型的用力过猛。
提醒:局部变量加 volatile 基本没有意义。函数的局部变量在栈上,地址没有被传递出去,中断不可能访问到它(除非你显式把地址存到全局传给中断,那种情况属于设计问题)。
3.2 环形缓冲区:volatile 够用的边界在哪
上一章给了环形队列的代码,这里补充几个我在实际项目里踩过的坑。
第一个坑,索引位宽与撕裂。在 8 位机上,如果 head/tail 用unsigned int(16 位),而总线是 8 位的,那么读一个 16 位变量需要两条指令。假设主流程正在读 head,读完低字节、还没读高字节,中断进来把 head 从0x00FF改成了0x0100,主流程读到的就是0x0000或者0x01FF,索引直接错乱。解决办法要么把索引限制在单字节能表示的范围内(缓冲区 256 字节以内,索引用unsigned char),要么读的时候关中断:
unsigned int head_snapshot(void) { unsigned int h; /* 关中断,读,开中断 —— 具体实现依赖平台 */ __disable_irq(); h = g_ring.head; __enable_irq(); return h; }第二个坑,满/空的判定。经典的"留一个空位"方案(next == tail判满)逻辑简单,但要在两端都维持一致的判定条件。我见过一边用head == tail判空、另一边用(head + 1) % N == tail判满,但 N 不是 2 的幂,取模运算在 8 位机上开销巨大还容易出错。缓冲区大小一律取 2 的幂,用位与代替取模,这是基本操作。
第三个坑,多字节数据的写入。环形队列里放的不是单字节而是结构体时,"先写数据后更新索引"的顺序保证就格外重要,因为结构体写入会被拆成多条指令。这时候屏障不能省,而且如果生产者是 DMA 而不是 CPU,还得处理 cache 一致性——volatile 在这里一点忙都帮不上,它管不了 cache。
3.3 硬件寄存器与 DMA 场景的特殊处理
硬件寄存器访问是 volatile 的另一个铁律场景。标准写法是:
#define UART_BASE 0x40004000UL #define UART_SR (*(volatile unsigned int *)(UART_BASE + 0x00)) #define UART_DR (*(volatile unsigned int *)(UART_BASE + 0x04)) #define UART_CR (*(volatile unsigned int *)(UART_BASE + 0x08))这里用宏而不是变量,是为了避免有人取地址后乱传。几个要点:
- 只读寄存器用
const volatile:#define XXX_REG (*(const volatile unsigned int *)ADDR),这样误写会直接编译报错,比运行时发现外设被写坏强得多。 - 不要用位域访问寄存器。
volatile struct { unsigned int a : 3; ... }看似优雅,但位域的读改写序列是实现定义的,编译器可能生成"读整个字、改几位、写回整个字"的序列,这对有写 1 清零语义的寄存器是致命的——你本来只想改 A 位,结果把 B 位的清标志也写了一遍。 - 不要用
memcpy访问映射区。memcpy的形参不是 volatile 指针,编译器不知道目标区域是易变的,可能用宽指令批量搬运,也可能做对齐优化,行为不可控。老老实实写循环,指针带 volatile。
DMA 场景要单独说。DMA 传输期间,CPU 和 DMA 控制器是两条独立的访问路径,volatile 保证的是"编译器不缓存",但保证不了 cache 里的一致性。如果 CPU 先把数据写进一块带 cache 的缓冲区,然后启动 DMA 发送,DMA 从内存读到的可能是旧数据,因为新数据还躺在 cache 里没写回。正确做法是启动 DMA 前显式 clean cache(把 cache 写回内存),DMA 接收完成后显式 invalidate cache(丢弃 cache 里的旧副本),这属于平台相关的 cache 维护操作,和 volatile 是两个层面的东西。
我见过一份代码在这上面翻过车:接收缓冲区标了 volatile,看起来"很规范",但没做 invalidate,导致偶尔读到上一次的残留数据。定位花了整整两天,最后是在 DMA 完成中断里加了一句 cache 无效化解决的。
4. 多线程场景:volatile 最常见的误用
4.1 volatile 不等于原子
这是最需要掰扯清楚的一条。看这段代码:
volatile int counter = 0; void *worker(void *arg) { for (int i = 0; i < 100000; i++) { counter++; /* 读-改-写,三步操作 */ } return NULL; }开四个线程跑,最后 counter 是多少?不一定是 400000,实际可能是 20 万到 30 万之间的某个数。原因是counter++在机器层面是"load、add、store"三条指令,volatile 只保证这三条指令都真实发生,不保证它们整体不可分割。两个线程可能同时 load 到 100,各自加 1 写回 101,一次自增凭空消失。
加 volatile 解决不了这个问题,加 mutex 或者改用_Atomic int才能解决。很多初学者看到"volatile 保证可见性"就以为它能替代锁,这是把 Java 的语义硬搬到 C 上,属于典型的跨语言误读。
真正需要原子性的场景清单:计数器、引用计数、标志位的测试并设置、任何形式的读改写。判断方法很简单——如果你的操作是"读一个值、基于它算一个新值、写回去",它就不是原子的,volatile 救不了。
4.2 C11 内存模型下 volatile 的真实地位
C11 引入了正式的内存模型,把原子类型(_Atomic)、内存序(memory order)、以及 happens-before 关系定义清楚了。在这个体系里,volatile 的位置很尴尬:它有副作用语义,但不参与同步关系。
具体说,C11 标准里并没有规定"一个线程写 volatile 变量,另一个线程读同一个 volatile 变量,两者之间存在 happens-before 关系"。编译器完全可以在 volatile 访问周围重排普通内存访问(前面说的屏障问题),CPU 也可以乱序执行。所以靠 volatile 做线程间同步,在标准层面就是未定义行为,只是在实际的某些平台上碰巧能跑。
这种"碰巧能跑"是最坏的情况,因为它在你的测试机上可能一千次都对,在客户的双核设备上一分钟崩一次。我在一个跨平台项目里就吃过这个亏:开发机是单核虚拟机,volatile 标志同步看起来完美;上线到双核板子上,偶发的空指针崩溃查了两周,最后定位到消费线程看到了标志但没看到数据。
正确的替代方案按场景排序:
- 只需要一个布尔标志做线程间通知:用
_Atomic int或atomic_bool,配memory_order_acquire/memory_order_release。 - 需要计数器:
atomic_fetch_add。 - 需要保护一段临界区:互斥量(mutex),注意必须用同一把锁覆盖所有访问点。
- 无锁数据结构:必须系统性设计内存序,用 acquire-release 或 seq_cst,这一步没有捷径。
4.3 正确组合拳:_Atomic、互斥量与内存屏障
用_Atomic改写前面的例子,代码量和 volatile 差不多,但语义正确:
#include <stdatomic.h> atomic_int counter = 0; atomic_bool g_ready = false; void worker(void) { for (int i = 0; i < 100000; i++) { atomic_fetch_add_explicit(&counter, 1, memory_order_relaxed); } } /* 生产者 */ void producer(void *data) { fill_buffer(data); atomic_store_explicit(&g_ready, true, memory_order_release); } /* 消费者 */ void consumer(void) { while (!atomic_load_explicit(&g_ready, memory_order_acquire)) { /* 等待 */ } use_buffer(); }memory_order_release在 store 之前建立屏障,保证fill_buffer的所有写在标志置位前对其它线程可见;memory_order_acquire在 load 之后建立屏障,保证读到标志之后的所有读能看到之前写入的数据。这一对语义才是"可见性保证"的正解,volatile 做不到。
如果平台编译器不支持 C11 原子操作(一些老旧的嵌入式工具链),退路是用编译器内建函数,GCC/Clang 的__sync_*系列或者__atomic_*系列,语义等价但接口更老。再退一步就是互斥量加 volatile,虽然笨重但至少正确——不过要注意,互斥量本身提供了同步,加了它之后 volatile 其实就只剩"防止编译器把它优化没了"这一点作用,而这一点锁的调用本身已经保证,属于冗余。
最后给一个自旋锁的最小实现,展示内存序怎么配合:
typedef struct { atomic_flag flag; } spinlock_t; void spin_lock(spinlock_t *l) { while (atomic_flag_test_and_set_explicit(&l->flag, memory_order_acquire)) { /* 自旋,可以加个 pause 指令降低总线和功耗 */ } } void spin_unlock(spinlock_t *l) { atomic_flag_clear_explicit(&l->flag, memory_order_release); }这里 acquire 和 release 的位置不是随便放的:加锁时用 acquire 保证锁之后的临界区代码不会被提到锁之前执行;解锁时用 release 保证临界区代码不会被拖到解锁之后。这两个约束配合起来,临界区就有了互斥和可见性双重保证。
5. 常见问题与排查技巧实录
5.1 问题速查表
把实际项目里反复遇到的症状和成因整理成表,遇到问题时先对照这张表缩小范围:
| 现象 | 高概率成因 | 排查方向 |
|---|---|---|
| 轮询等待死循环,加打印就正常 | 共享标志未加 volatile,被提到寄存器 | 看反汇编的循环体,确认有无回读 |
| 换了优化等级就崩 | 依赖了-O0的未定义行为 | 用-O2加-Wall -Wextra重新审 |
| 中断里改了变量,主流程读不到 | 同上,或变量被编进寄存器跨函数复用 | objdump 对照 |
| 数据偶尔错位一个字节 | 多字节索引撕裂,或先更新索引后写数据 | 检查索引位宽、检查写入顺序 |
| DMA 收到旧数据 | cache 未 invalidate | 看是否有 cache 维护调用 |
| 寄存器写了两遍,第二次没生效 | 被编译器合并或删除 | 确认寄存器指针带 volatile |
| 多线程计数不准 | volatile 被当成原子用 | 换_Atomic或加锁 |
| 双核上偶发崩溃,单核测试正常 | 缺内存屏障,硬件乱序 | 用 acquire/release 或原子类型 |
| volatile 结构体传给 memcpy 编译报错 | 类型不匹配,volatile 会丢弃 | 改为逐字节循环,指针带 volatile |
| 信号处理函数里改的变量没生效 | 局部变量未加volatile sig_atomic_t | 按标准类型声明 |
5.2 排查方法论:从源码到汇编的对照
遇到这类问题,我固定的排查顺序是这样:
第一步,确认问题是否跟优化相关。把优化等级从-O2降到-O0再看,如果现象消失,几乎可以锁定是优化暴露了未定义行为。注意这只是定位手段,不是修复方案——把优化等级降下来发布是掩耳盗铃,哪天换个编译选项又会炸。
第二步,反汇编关键函数。用objdump -d -S把源码和汇编交错输出,直接看那个变量在循环体里到底是"每轮一条 load"还是"提到循环外只 load 一次"。这一步最直接,不要靠猜。
objdump -d -S -O2 build/app.elf | less在输出里搜变量名或者对应的地址,看它出现在循环标签的里面还是外面。如果循环体内只有比较和跳转,没有对那个地址的 load,答案就明确了。
第三步,检查可见性边界。列出所有跨执行流(中断、线程、DMA)访问的变量,逐个确认:加没加 volatile、访问是不是原子、有没有顺序依赖、多字节访问会不会撕裂。
第四步,检查内存序。涉及多线程的,确认同步点用的是原子操作还是裸 volatile。涉及硬件顺序依赖的,确认有没有插屏障。
第五步,做压力测试。这类 bug 的特点是偶发,正常跑十分钟不出问题不代表没有。至少要做高频触发、满负载、边界条件三种压力场景,同时开着断言和日志。
心得:这类问题最忌讳"看起来没问题"。编译器优化出的 bug 跟你写代码时的直觉是冲突的,你越觉得"这行写得明明白白怎么会错",越要去看反汇编。
5.3 几个容易忽略的细节
第一,函数传参会丢 volatile。把 volatile 变量按值传给形参是普通类型时,形参上没有 volatile 限定,函数内部对这个形参的访问编译器可以随意优化。如果函数内需要多次读取这个值并期望每次都真读,形参本身也得带 volatile,或者干脆传指针。同样的道理,全局 volatile 变量通过memcpy复制到本地缓冲区之后,本地缓冲区就不是易变的了,后续读它读到的是快照,不是最新值。
第二,volatile 变量的地址可以传,但别传错。volatile int *传给int *形参,编译期会告警,运行期行为未定义。这类告警一律当错误处理,别用强制转换糊过去。
第三,volatile和const可以共存,而且很有用。const volatile表示"这个对象程序不能改,但外部可能改",正好对应只读的状态寄存器。这个组合在访问外设状态位时应该成为默认选择。
第四,静态分析工具能抓一部分问题,但抓不全。MISRA 之类的规则集会提示"跨中断共享变量应加 volatile",但规则集没法判断你的多线程同步是否正确。工具给的是下限,不是及格线。
第五,代码评审时把"跨执行流共享变量"列成一张清单。我现在的习惯是在模块头文件里专门开一个注释块,列出哪些变量是与中断或其它线程共享的、用什么机制保护、谁写谁读。这张清单在重构时能救命,因为加了 volatile 的变量在类型系统里看着和普通变量一模一样,靠肉眼是发现不了的。
最后一个经验:这类问题在实际项目里往往不是"一个变量漏了 volatile"这么简单,而是一整套共享数据的设计没有想清楚。我见过太多案例是在出问题之后一个个补 volatile,补到后来代码里全是 volatile,问题还在出。真正有效的做法是在设计阶段就把"谁是生产者、谁是消费者、边界在哪里、用什么机制保护"这四件事写下来,volatile 只是其中最基础的一环,剩下还有原子性、顺序、cache 一致性要一并考虑。把这套想清楚了,回头再看那些"加个 volatile 就好了"的说法,你会发现它省掉的恰恰是最关键的那部分思考。