有一次接手一個舊專案,裡面有個函式大概長這樣:
static uint8_t crc_table_ready = 0;
void crc_init(void)
{
if (crc_table_ready) return;
// build table...
crc_table_ready = 1;
}
單看沒問題。但那天我在另一個 .c 檔裡,也宣告了一個 crc_table_ready,想拿來記另一個 CRC 模組的狀態。
連結的時候,link error:
multiple definition of `crc_table_ready`
我當下第一反應是:「這是 static 欸,怎麼會撞名?」
後來才發現,我自己那個沒加 static,是全域的。人家的有 static,只在那個檔案裡活動,根本不衝突——衝突的是我沒加的那個。
這件事讓我重新想了一次 static 到底在幹嘛。它在 C 裡其實同時扮演三種完全不同的角色,只是共用同一個關鍵字,很多人(包含以前的我)都是憑感覺在用。
先講結論
| 用在哪裡 | 身份 | 效果 |
|---|---|---|
| 函式內部的區域變數 | 「持久記憶」 | 生命週期跨越函式呼叫,值會保留 |
| 檔案範圍的全域變數 | 「隱藏標記」 | 限制連結範圍(internal linkage),只有這個 .c 看得到 |
| 函式定義前 | 「私有函式」 | 同樣是限制連結範圍,這個函式外部檔案叫不到 |
看起來三種效果都跟「範圍」「生命週期」有關,但機制其實不一樣,一個一個拆。
身份一:函式裡的 static 區域變數 — 跨呼叫的記憶
一般的區域變數,函式一返回,記憶體就還給 stack,下次呼叫是全新的一份。
int counter(void)
{
int n = 0; // 每次呼叫都重新歸零
n++;
return n;
}
這個 counter() 不管呼叫幾次都回傳 1。
加上 static 之後:
int counter(void)
{
static int n = 0; // 只初始化一次,之後保留上次的值
n++;
return n;
}
這次呼叫會回傳 1、2、3、4……因為 n 不是配置在 stack 上,而是在 .data(有初始值且非零)或 .bss(初始值為 0)這種靜態記憶體區段,程式跑起來就存在,直到程式結束。
這在嵌入式裡蠻常用來做這幾件事:
- 記錄某個函式被呼叫過幾次(debug 用)
- lazy init(像開頭那個 CRC table 的例子)
- 簡單的狀態機,不想額外開一個 struct 傳來傳去
但要注意的坑:
static 區域變數不是 thread-safe,也不是 reentrant-safe。如果這個函式會被多個 task 呼叫,或者會被中斷打斷,static 變數就是共享狀態,跟全域變數的風險一模一樣,只是換了個殼藏起來,比較不容易被發現。
我自己就吃過這個虧:一個 parser 函式裡用 static 變數記剩餘的 buffer 長度,本來單執行緒跑得好好的,後來系統加了一個背景 task 也會呼叫同一個 parser,兩邊互相干擾狀態,追了半天才發現問題出在這個看起來人畜無害的 static int remain。
身份二:檔案範圍的 static 全域變數 — internal linkage
C 的變數預設是 external linkage:宣告在任何一個 .c 檔的全域範圍,只要有 extern 宣告,其他檔案都能連過去用同一份記憶體。
// module_a.c
int g_state = 0; // 沒加 static,external linkage
// module_b.c
extern int g_state; // 連過去,改的是同一份
這在多人協作、檔案一多的時候是地雷。你以為 g_state 只有你在動,結果別人在另一個檔案裡也宣告了同名變數,連結器直接幫你「合併」成同一份記憶體——如果剛好命名一樣還沒宣告成 extern,就會出現我開頭講的那種 link error;如果剛好某個編譯器/linker 選項允許 tentative definition 合併,那更可怕,會安靜地共用同一塊記憶體,行為詭異到你懷疑人生。
加上 static 之後:
// module_a.c
static int g_state = 0; // internal linkage,只有這個檔案看得到
這個變數的連結範圍被限制在 module_a.c 這個編譯單元內,其他檔案完全看不到、也連不到它,就算取一樣的名字也不會撞。
這其實是 C 語言裡最陽春的「封裝」機制——沒有 class、沒有 private 關鍵字,靠的就是這個 static,把模組內部的狀態鎖在檔案裡,只透過函式介面對外溝通。
實務上我的習慣是:
檔案範圍的變數,沒有明確理由要給別的檔案存取,一律加 static。這樣至少能保證:如果哪天有人要動這個變數,一定是透過我提供的函式,不會有人偷偷從別的檔案戳進來改。
身份三:函式前的 static — 私有函式
跟身份二的邏輯完全一樣,只是套用在函式上。
// sensor.c
static int adc_raw_to_voltage(int raw); // 只給這個檔案內部用
int sensor_read_voltage(void)
{
int raw = adc_read();
return adc_raw_to_voltage(raw); // 對外只透過這個
}
adc_raw_to_voltage() 加了 static,代表這是模組的內部實作細節,其他檔案就算宣告 extern int adc_raw_to_voltage(int); 也連不到——連結器根本找不到這個符號。
這樣做的好處:
- 介面乾淨:header 檔只需要暴露
sensor_read_voltage(),內部怎麼轉換的別人不需要知道。 - 改名/改實作不怕影響別人:反正外部連不到,你要怎麼重構這個函式都是你家的事。
- 編譯器有機會做更積極的最佳化:因為編譯器知道這個函式的呼叫者都在同一個檔案裡(甚至同一個編譯單元),有機會直接 inline 展開,省掉 call/return 的開銷。這點在資源緊張的 MCU 上,有時候還真的看得出差異。
我自己的習慣是:一個 .c 檔案裡,除了要放進 header 對外開放的函式,其他一律先預設 static,真的有外部需求再拿掉。反過來想比較容易漏東西——先全部開放,之後想到才鎖,通常是鎖不完的。
三種身份,一個共通的核心概念
拆開來看是三種效果,但如果往底層想,其實就兩件事:
- 生命週期(storage duration):身份一改變的是「這個物件活多久」——從跟著函式呼叫的 stack 生命週期,變成跟著整個程式跑的 static storage duration。
- 連結範圍(linkage):身份二、三改變的是「這個名字誰看得到」——從 external linkage(整個程式共享)變成 internal linkage(只有這個編譯單元看得到)。
同一個關鍵字,因為出現的位置不同(函式內 vs. 函式外),觸發的是不同的語意。C 語言這種「同一個 token,脈絡決定意義」的設計不只 static 一個,* 也是一樣的問題(宣告是指標、運算式裡是解參考),算是 C 語法一直被詬病的地方,但既然要吃這行飯,這種眉角還是得摸熟。
小結
我自己現在的判斷邏輯大概是:
- 函式裡要記狀態、但又不想額外傳參數或開全域 → 考慮
static區域變數,但先想清楚會不會被多執行緒/中斷共用 - 檔案裡的全域變數、輔助函式,沒有要給別人用 → 一律
static,養成習慣 - 真的要跨檔案共用的東西,透過 header 裡明確的
extern宣告和函式介面,不要讓連結器幫你「意外」合併變數
static 這個關鍵字算是 C 裡面「便宜又好用,但用不好會咬人」的典型代表。開頭那個 link error 算我運氣好,撞名撞得夠明顯,馬上就發現。真正麻煩的是那種沒撞名、但因為忘了加 static 而被別的模組意外改到值的狀況——編譯器不會警告你,程式也不會馬上掛,就是在某個角落,慢慢地把你搞瘋。
有沒有遇過忘記加 static結果被別的模組改到變數的經驗?歡迎留言講講你的踩坑故事。