[C 的那些眉角]volatile 到底是什麼 — 我花了很久才搞懂

volatile 是我學 C 語言以來,
花最久時間才真正搞懂的關鍵字。

不是因為語法複雜,
而是因為它的效果是「阻止編譯器做某些優化」,
在沒有優化的情況下,
加不加 volatile 看起來沒有差別,
讓人誤以為自己懂了。

直到有一天,開啟了 -O2 優化,
程式行為突然變了,
才發現原來自己一直用錯。


先從一個例子開始

int flag = 0;

void wait_for_flag(void) {
    while (flag == 0) {
        // 等待 flag 被設為 1
    }
    // flag 變成 1 了,繼續執行
}

這段程式碼,在沒有優化的情況下,
每次迴圈都會重新讀取 flag 的值,
看起來正常。

但開啟 -O2 之後,
編譯器可能把它優化成這樣:

void wait_for_flag(void) {
    if (flag == 0) {
        while (1) {
            // 無限迴圈,永遠不會結束
        }
    }
}

為什麼?

因為編譯器看到 wait_for_flag 這個函式,
在它的視角裡,flag 沒有被這個函式修改,
所以它認為「如果進入迴圈的時候 flag == 0
那它永遠都是 0,不需要每次都重新讀取」。

這個優化在單執行緒、單核心的情況下是合理的,
但如果 flag 是被中斷服務函式(ISR)或另一個執行緒修改的,
編譯器不知道,就優化錯了。

解法:

volatile int flag = 0; // 加上 volatile

volatile 告訴編譯器:
「這個變數可能在你看不到的地方被修改,
每次存取都必須重新從記憶體讀取,不能快取在暫存器裡。」


volatile 到底做了什麼

volatile 對編譯器說了兩件事:

  1. 每次讀取都必須從記憶體讀,不能用暫存器裡的快取值
  2. 每次寫入都必須寫到記憶體,不能只更新暫存器
volatile uint8_t reg = 0;

// 沒有 volatile 的情況,編譯器可能優化成:
// 只讀一次,存在暫存器,之後都用暫存器的值
for (int i = 0; i < 100; i++) {
    if (reg & 0x01) { ... } // 可能每次都用同一個值
}

// 有 volatile 的情況:
// 每次迴圈都重新從記憶體讀取 reg
for (int i = 0; i < 100; i++) {
    if (reg & 0x01) { ... } // 每次都是最新的值
}

什麼情況需要用 volatile

情況一:硬體暫存器(Memory-Mapped I/O)

這是嵌入式最常見的使用場景。

硬體暫存器的值可能隨時被硬體改變,
和一般的記憶體不一樣,
必須用 volatile 告訴編譯器不要優化:

// 定義硬體暫存器的位址
#define UART_STATUS_REG (*((volatile uint8_t *)0x40001000))
#define UART_DATA_REG (*((volatile uint8_t *)0x40001004))

// 等待 UART 傳輸完成
void uart_wait_tx_done(void) {
    // 每次都重新讀取狀態暫存器,不能用快取值
    while (!(UART_STATUS_REG & 0x01)) {
        // 等待 TX done bit 被硬體設為 1
    }
}

// 讀取 UART 資料
uint8_t uart_read_byte(void) {
    return UART_DATA_REG; // 每次讀取都從硬體暫存器讀
}

如果沒有 volatile
編譯器可能只讀一次 UART_STATUS_REG
然後一直用那個值,
while 迴圈就永遠不會結束(或是直接被優化掉)。


情況二:ISR 和主程式共享的變數

// ❌ 沒有 volatile,可能被優化
static int g_rx_count = 0;

// ISR:每收到一個 byte,計數加一
void UART_IRQHandler(void) {
    g_rx_count++;
}

// 主程式:等待收到足夠的資料
void wait_for_data(void) {
    while (g_rx_count < 10) {
        // 編譯器可能把 g_rx_count 快取在暫存器
        // 即使 ISR 改了記憶體裡的值,這裡還是用舊值
    }
}
// ✅ 加上 volatile
static volatile int g_rx_count = 0;

情況三:多執行緒共享的旗標(有限制)

static volatile bool g_task_done = false;

void worker_task(void *arg) {
    do_heavy_work();
    g_task_done = true; // 通知主執行緒工作完成
}

void main_task(void) {
    while (!g_task_done) {
        // 等待
    }
    // 繼續處理
}

這裡要特別說明:
volatile 不能取代互斥鎖(Mutex)或記憶體屏障(Memory Barrier)。

volatile 只能防止編譯器優化,
不能防止 CPU 的亂序執行(Out-of-order Execution)和快取問題。

在多核心系統,
即使用了 volatile
也不能保證一個核心的寫入立刻對另一個核心可見。

如果需要多執行緒安全,
要用 atomic(C11)、mutex、或是 memory barrier。


情況四:setjmp / longjmp

#include <setjmp.h>

jmp_buf env;

void some_function(void) {
    // volatile 確保 longjmp 之後這個值還是正確的
    volatile int local_var = 0;

    if (setjmp(env) == 0) {
        local_var = 42;
        do_something_that_might_longjmp();
    } else {
        // longjmp 跳回來了
        // 如果 local_var 沒有 volatile,它的值可能是未定義的
        printf("local_var = %d\n", local_var);
    }
}

longjmp 之後,沒有 volatile 的區域變數值是未定義的,
這個比較少見,但要知道。


volatile 不能做什麼

這個很重要,很多人誤解 volatile 的能力。

不能保證原子性

volatile int counter = 0;

// 在 ISR 裡
counter++;

// 在主程式裡
counter++;

counter++ 不是原子操作,
它實際上是三個步驟:讀取、加一、寫入。

如果 ISR 在主程式執行到一半的時候打斷,
就會有 race condition,
volatile 救不了這個。

要用原子操作(_Atomic__sync_fetch_and_add)或關中斷。


不能保證多核心的可見性

// Core 0
volatile bool ready = false;
volatile int data = 0;

data = 42;
ready = true; // 通知 Core 1

// Core 1
while (!ready) {}
printf("%d\n", data); // 不一定印出 42!

在多核心系統,
CPU 有自己的快取,
Core 0 寫入的值不一定立刻對 Core 1 可見,
volatile 不能解決這個問題。

需要 memory barrier:

// Core 0
data = 42;
__sync_synchronize(); // memory barrier
ready = true;

// Core 1
while (!ready) {}
__sync_synchronize(); // memory barrier
printf("%d\n", data); // 現在可以保證是 42

不能讓程式碼變快

有人誤以為 volatile 可以讓程式碼更快,
因為「直接讀記憶體,不用快取」。

實際上相反,
volatile 會讓程式碼變慢
因為它阻止了編譯器的優化。

所以不要亂加 volatile
只在真正需要的地方加。


一個真實踩過的坑

之前在做一個 RTOS 專案,
有一個全域旗標控制某個 task 的行為:

static bool g_config_updated = false;

// Task A:更新設定後設旗標
void config_task(void) {
    update_config();
    g_config_updated = true;
}

// Task B:偵測到旗標後重新載入設定
void worker_task(void) {
    while (1) {
        if (g_config_updated) {
            reload_config();
            g_config_updated = false;
        }
        do_work();
    }
}

在 debug build(沒有優化)下,
一切正常。

換成 release build(-O2)之後,
worker_taskif (g_config_updated) 永遠不成立,
設定更新之後沒有效果。

原因就是 g_config_updated 沒有加 volatile
編譯器把它優化掉了。

加上 volatile 之後:

static volatile bool g_config_updated = false;

問題解決。

但後來想想,
這個情況其實應該用 RTOS 的事件旗標(Event Flag)或訊號量(Semaphore),
而不是用 volatile 的全域變數,
因為 volatile 不能保證原子性,
嚴格來說還是有 race condition 的風險。

volatile 是解決了問題,
但不是最正確的解法。


總結一下 volatile 的使用場景

場景 需要 volatile 備註
硬體暫存器(MMIO) ✅ 必須 最核心的使用場景
ISR 和主程式共享變數 ✅ 需要 單核心裸機
多執行緒共享變數 ⚠️ 不夠 還需要 mutex 或 atomic
多核心共享變數 ⚠️ 不夠 還需要 memory barrier
一般的區域變數 ❌ 不需要 不要亂加
setjmp / longjmp 的區域變數 ✅ 需要 比較少見

說實話

volatile 是那種「以為懂了,但其實沒懂」的關鍵字。

我第一次學的時候,
老師說「volatile 是告訴編譯器不要優化這個變數」,
我以為我懂了,
然後就到處亂加,
以為加了就安全了。

後來才慢慢理解,
volatile 解決的是「編譯器優化」的問題,
不是「多核心可見性」的問題,
也不是「原子性」的問題。

這三個問題長得很像,
但需要不同的工具解決。

現在我的判斷方式很簡單:

  • 硬體暫存器 → 一定加 volatile
  • ISR 共享變數 → 加 volatile,同時考慮關中斷保護
  • 多執行緒 → 用 _Atomic 或 mutex,不只是 volatile
  • 不確定 → 先想清楚「這個變數會被誰在什麼時候改」

實戰 Checklist

  • [ ] 硬體暫存器的存取有加 volatile
  • [ ] ISR 和主程式共享的旗標有加 volatile
  • [ ] 沒有把 volatile 當成多執行緒安全的解法
  • [ ] 多執行緒共享的資料有用 mutex 或 atomic 保護
  • [ ] 沒有在不需要的地方亂加 volatile(會影響效能)
  • [ ] 開啟 -O2 之後有重新測試 ISR 相關的邏輯

發佈留言