[C 的那些眉角]static 關鍵字的三種身份——比你想的更重要

有一次接手一個舊專案,裡面有個函式大概長這樣:

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); 也連不到——連結器根本找不到這個符號。

這樣做的好處:

  1. 介面乾淨:header 檔只需要暴露 sensor_read_voltage(),內部怎麼轉換的別人不需要知道。
  2. 改名/改實作不怕影響別人:反正外部連不到,你要怎麼重構這個函式都是你家的事。
  3. 編譯器有機會做更積極的最佳化:因為編譯器知道這個函式的呼叫者都在同一個檔案裡(甚至同一個編譯單元),有機會直接 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結果被別的模組改到變數的經驗?歡迎留言講講你的踩坑故事。

發佈留言