大家好,我是仲一。
调试嵌入式代码,你多半碰过这种怪事:程序跑飞了,怎么查都查不到,你随手加一行 printf 想看看走到哪了,结果——好了。跑得稳稳的。
去掉那行 printf,又坏。
这种"加个打印就好了"的问题,十有八九跟 volatile 有关。我见得最多的现场,是中断里改了个标志位,主循环里死等它,死活等不到。
这篇不背面试八股,就讲清楚一件事:volatile 到底防的是什么,它又能帮你挡掉哪类 bug。
一个现场:中断里改标志,主循环死等
先看最典型的一个。写裸机或者 RTOS 程序,经常这么干:中断里置一个标志位,主循环里轮询这个标志,等中断处理完。
int flag = 0; // 中断和主循环共享
void irq_handler(void) // 中断里
{
flag = 1;
}
int main(void) // 主循环
{
while (flag == 0)
; // 死等中断置位
/* 继续干活 */
}
看着没问题吧?逻辑完全对。中断一进来把 flag 置 1,主循环就该往下走了。
但你在开了优化的编译器下跑,它可能永远卡在那个 while 里,中断明明触发了、flag 也改了,就是跳不出去。
问题出在编译器身上。
编译器做了什么"好事"
while (flag == 0) 这个循环,循环体是空的,里面没有任何东西会去改 flag。编译器一看:这个 flag 在循环期间不会被这段代码改到,那我还每次都去内存读它干嘛?直接把第一次读到的值缓存到寄存器里,循环里反复用这个寄存器值判断就行了。
于是你的 while 循环,在优化后的机器码里变成了一个死循环——拿寄存器里那个永远为 0 的值判断,转圈转到天荒地老。
而中断呢?中断是真的去内存里把 flag 改成了 1。但主循环已经不读内存了,它读的是寄存器里的旧值。两边各干各的,永远对不上。
那行 printf 为什么能"治好"它?因为 printf 是个函数调用,编译器不敢假设它不碰内存,就老实重新从内存读 flag 了——于是就好了。这不是 printf 有魔力,是它恰好打破了编译器的优化假设。
想看得更清楚,可以用 gcc 把这段代码编出来看反汇编。-O2 下,没加 volatile 的 while 循环,编译器会生成一个空转的死循环,压根没有重新读内存的指令;加上 volatile 之后,每次循环都会有一条真正的"从内存读 flag"的 load 指令。差别就那几行汇编,但一个卡死、一个正常。这就是 volatile 在机器码层面干的活。
volatile 到底防什么
这时候 volatile 登场了。它的作用翻译成人话就一句:告诉编译器,这个变量的值可能在你看不到的地方被改掉,别给我瞎优化,每次用都老老实实去内存读。
volatile int flag = 0;
加上 volatile 之后,编译器就知道 flag 不可信,不能缓存到寄存器,每次访问都从内存读。主循环就能读到中断写进去的新值了。
所以 volatile 的正确理解是:它是个"防编译器优化"的开关,专门对付那些在程序正常执行流之外被修改的变量——中断里改的、硬件寄存器、别的任务改的。
回到刚才那段代码,为什么大家都说中断里共享的全局变量要加 volatile?因为它正好命中这个场景:值在中断(正常流之外)里被改。
另一个现场:读硬件寄存器读到死
volatile 还有一个高频用途,是读硬件寄存器。
很多芯片外设的状态寄存器,值不是软件写的,是硬件自己变的。比如你等串口发完数据,轮询一个"发送完成"位:
while (!(UART->SR & UART_SR_TC))
;
这个 UART->SR 如果没加 volatile,编译器同样可能把它当"循环里不变"的值缓存起来——但硬件可不管你的编译器怎么想,它该变就变。结果又是一个死循环。
所以指向硬件寄存器的指针,正规写法都是带 volatile 的:
#define UART_SR (*(volatile uint32_t *)0x40004400)
这个 volatile 的意思很明确:这个地址的值,硬件随时会改,别缓存。
边界一:多核 SMP 下,volatile 不够了
说到这得划一条线。上面说的"中断里改标志、主循环等",在裸机或者单核 RTOS 下是成立的——同一个核,中断和主循环看到的是同一份内存,volatile 保证每次都去读,就够了。
但一旦上了多核 SMP 系统,情况就变了。 哪怕只是"一个核写、另一个核读"的单向通知,只加 volatile 也不够。
为什么?volatile 只管编译器,让它别缓存、每次去读内存。但它管不了 CPU。CPU 写一个值,可能先落在自己的 store buffer 里,还没真正刷到对方核心能看到的内存里;对方核心读的时候,可能读到的还是旧值。这中间缺的,是内存屏障或者原子操作自带的那层保证。
简单说:volatile 约束的是编译器,内存屏障/原子操作约束的是 CPU。 单核下你只需要防编译器;多核下,编译器这关过了,CPU 那关还有坑等着你。
所以写 Linux 驱动,跨核共享的标志位,别指望一个 volatile 搞定。该用 READ_ONCE/WRITE_ONCE、原子操作,或者干脆加锁,按场景来。别把裸机那套经验直接搬过来,会翻车。
边界二:它不保证原子性,也不保证顺序
再泼一盆冷水。很多人把 volatile 理解成"线程安全",甚至拿它当锁用——两个任务共享一个变量,加上 volatile 就以为没事了。这是错的。
volatile 只保证"每次都从内存读、不缓存"。它不保证原子性,也不保证顺序。
什么叫不保证原子性?一个 counter++,看着是一句,实际编译出来是三条指令:读、加、写回。volatile 管不了这三条指令被打断——两个任务同时执行 counter++,可能互相覆盖,丢一次自增。
什么叫不保证顺序?volatile 只管自己这个变量别被优化,它管不了编译器或 CPU 把别的内存访问挪来挪去。你要的"我先写数据、再置标志"的顺序,volatile 保证不了。
所以只要涉及两个执行流同时读改写同一个变量,该上原子操作、临界区、互斥锁,还是得上。volatile 替代不了它们。
三个自检问题
判断一个变量要不要加 volatile,不用背规则,问自己三个问题就行:
- 这个变量的值,会不会在中断、硬件、另一个任务里被改?我是不是在循环里反复读它,等它变?去掉 volatile,编译器有没有可能把"读内存"优化成"读寄存器缓存"?
只要有一个"是",就该加 volatile。反过来说,如果一个变量只有你自己的代码在读写,没有外部修改者,那 volatile 是多余的——加了只是拖慢访问,没有好处。
再补个彩蛋:写 POSIX 信号处理函数的时候,里面要改的共享变量,标准只保证 volatile sig_atomic_t 这个类型是安全的。一句话带过,但面试或者 code review 的时候提一嘴,能看出你读过标准。
至于那些"加个 printf 就好了""-O0 正常、-O2 挂掉"的怪问题,下次再遇到,先想想:是不是有个共享变量忘了加 volatile,编译器在背后替你"优化"掉了。
你遇到过"加个 printf 就好了"的 bug 吗?最后查到是 volatile、内存屏障,还是别的什么原因?留言聊聊。
仲一做嵌入式已经六七年了,现在在某头部芯片公司做驱动开发,之前带过团队、也面试招过人。简历看多了,面试也面多了,所以职业怎么走、简历哪里不对、面试卡在哪儿,这些事我算是有点发言权。
这些年陆陆续续有读者找过来,问方向、问简历,也拉我做过不少模拟面试。带过的师弟师妹有大二进小米海康的,也有四个月拿到大疆、oppo、华为 offer 的,简历前前后后改过两千多份。
就一个要求:建议你得真去做。不然别来找我,浪费彼此时间。
职业方向、简历、面试,哪块有疑惑都可以找我聊聊。想聊的,公众号后台回复「咨询」。
end
149