[C 的那些眉角]全域變數的誘惑 — 為什麼要克制使用

全域變數的誘惑 — 為什麼要克制使用

有一次接手一個前人留下來的 firmware 專案,開機卡在某個地方,重現條件很怪:只有在 UART 收到特定指令、又剛好在某個 timer 中斷觸發的時候才會掛。

追了兩天,最後發現是一個叫 g_status 的全域變數,被三個地方在改:主迴圈、UART ISR、還有一個 timer callback。三方搶著寫,沒有人管同步。

那一刻我就在想,當初寫這個的人,大概也只是想「方便」而已。

全域變數為什麼這麼好用

老實說我懂那個誘惑。在嵌入式環境裡,全域變數真的很香:

  • 不用傳參數,函式簽章乾乾淨淨
  • ISR 跟主程式之間要共享狀態,全域變數最直接
  • 不用煩惱 stack 夠不夠用,放在 .bss.data 裡,記憶體位置固定
  • debug 的時候用 GDB 直接 print g_xxx 就看到了

尤其資源吃緊的 MCU,每個 byte 都要算,全域變數那種「我就放這、大家自己拿」的爽快感,確實有它的道理。

我不想假裝自己從來不用全域變數,那是騙人的。問題不在「用」,在「不受控地用」。

真正會出事的點

全域變數本身不會咬人,咬人的是它的兩個副作用。

一、誰都可以改,所以你不知道是誰改的

前面那個 g_status 就是典型。當一個變數可以被 N 個地方寫入,你 debug 的時候就要同時懷疑 N 個地方。最慘的是 ISR,它隨時可能插進來,把你主程式讀到一半的值改掉。

// 主程式
if (g_status == READY) {
    // ⚠️ 這中間 ISR 可能把 g_status 改成 BUSY
    do_something();   // 結果你以為還是 READY
}

這種 race condition 不會每次都出事,它會挑你 demo 給客戶看的時候出事。

二、它讓模組之間偷偷耦合

兩個本來不相干的檔案,因為共用了一個全域變數,就黏在一起了。你想把其中一個模組搬去別的專案,結果一編譯發現缺一堆 extern,才知道它暗地裡依賴了多少東西。

這種耦合最討厭的地方是它不寫在介面上。看 header 看不出來,要追原始碼才知道。

我現在會怎麼處理

我不是要喊「全域變數是萬惡之源」那種口號,太極端了。我的做法比較土法煉鋼:

能縮小作用域就縮小。 一個變數如果只有這個檔案在用,就加 static,關在 translation unit 裡。光是這一步就擋掉一大半的誤用。

// ❌ 真全域,整個專案都看得到也都能改
uint32_t g_packet_count;

// ✅ 檔案內全域,作用域關起來
static uint32_t s_packet_count;

ISR 跟主程式共享的,一定加 volatile,而且想清楚同步。 volatile 只保證每次都從記憶體重讀,它不保證原子性。多 byte 的變數還是會被中斷切兩半。

// 旗標類的單一 byte,volatile 通常夠
static volatile uint8_t s_flag;

// 但這種多 byte 的,讀寫之間要關中斷或用其他機制
static volatile uint32_t s_counter;

真的要跨模組共享的狀態,我傾向包成一個模組,用 getter/setter 控制進出。 麻煩一點,但至少改的入口收斂到一個地方,要加 log、要加保護、要 debug 都好下手。

// status.c
static uint8_t s_status;

uint8_t status_get(void) { return s_status; }
void status_set(uint8_t v) { /* 這裡可以加保護、加 log */ s_status = v; }

這樣寫確實比 g_status = BUSY 多敲幾個字,但那個 g_status 害我加了兩天班,這幾個字我認了。

還在想的部分

說實話,getter/setter 那套在 PC 上很自然,但在很吃效能的 ISR 裡,多一層函式呼叫的 overhead 到底值不值得,我自己也沒有標準答案。有時候熱路徑上我還是會直接讀變數,只是會在註解寫清楚「這裡為什麼可以這樣」。

另外像 RTOS 的環境,跨 task 共享狀態其實該用 queue 或 mutex 而不是全域變數,這又是另一個題目了,之後有機會再寫。

全域變數不是不能用,是要知道自己在用它換什麼、又賠上什麼。方便是真的方便,帳是真的會還。


💬 你有沒有被某個全域變數坑過的經驗?或是你有自己一套管理共享狀態的做法?留言聊聊,我也想知道別人都怎麼處理這件事。

發佈留言