[C 的那些眉角]永遠初始化你的變數 — 那個神秘的值

剛學 C 的時候,老師說過:「變數要記得初始化。」

我點點頭,然後還是繼續寫:

int count;
char buf[64];
int *ptr;

反正當下程式跑起來好像也沒問題。

直到有一天,發現程式出現一個「偶發性」的奇怪行為。
有時候正常,有時候不正常,完全沒有規律。

Debug 了兩天,最後發現是一個沒有初始化的變數,
裡面的值取決於上次那塊記憶體被誰用過、放了什麼東西。

在開發機上跑都正常,因為記憶體的初始狀態剛好是 0。
換到客戶的環境,跑幾個小時之後記憶體狀態不一樣了,就爆了。

那次之後,我對「初始化」這件事認真多了。


未初始化的變數裡面是什麼?

答案是:不知道。

C 語言規格說,讀取未初始化的自動變數(local variable)是 undefined behavior

實際上,那塊 stack 記憶體裡放的是「上一次被使用時留下的值」,
也就是俗稱的垃圾值(garbage value)

void foo(void) {
    int x;
    printf("%d\n", x);  // 可能是 0,可能是 32767,可能是任何值
}

在你的開發環境跑,可能每次都印出 0,
因為這塊 stack 剛好沒被動過。

但這不代表程式是對的,只是剛好沒出事。


三種記憶體區域,行為不一樣

這裡要稍微分一下,不同區域的變數,初始化行為不同:

Global / Static 變數 — 自動初始化為 0

int global_count;           // 保證是 0
static int module_state;    // 保證是 0

void foo(void) {
    static int call_count;  // 保證是 0(只有第一次)
}

這些變數放在 BSS segment,程式啟動時會被清零。
所以就算你沒有明確初始化,值也是 0。

但我還是建議明確寫出來:

int global_count = 0;
static int module_state = STATE_IDLE;

原因是讓讀程式碼的人知道你是故意給 0,不是忘記初始化
這兩件事看起來一樣,但意圖完全不同。


Local 變數 — 不會自動初始化

void foo(void) {
    int count;        // 垃圾值
    char buf[64];     // 64 個垃圾值
    int *ptr;         // 可能指向任何地方
}

這些放在 stack,不會自動清零。
一定要自己初始化。

void foo(void) {
    int count = 0;
    char buf[64];
    memset(buf, 0, sizeof(buf));
    int *ptr = NULL;
}

Heap 記憶體 — 看你用哪個函式

// malloc:不初始化,裡面是垃圾值
int *arr = malloc(10 * sizeof(int));

// calloc:初始化為 0
int *arr = calloc(10, sizeof(int));

malloc 之後如果沒有初始化就直接使用,
跟 local 變數一樣,是 undefined behavior。


嵌入式的特別狀況

在嵌入式開發,這個問題更微妙。

Reset 之後不一定是乾淨的

某些 MCU 在 warm reset(軟體重置)之後,
RAM 的內容不會被清除,還是上次執行時的值。

如果你的程式依賴「reset 之後變數是 0」這個假設,
在 cold boot(重新上電)正常,warm reset 就可能出問題。

// 這個假設在 warm reset 後可能是錯的
static int error_count;  // 以為是 0,其實是上次的值

解法是明確初始化,或是在 startup code 裡確保 BSS 被清零。


Struct 的初始化

Struct 很容易漏掉某些欄位:

typedef struct {
    uint8_t  device_id;
    uint16_t timeout_ms;
    bool     is_enabled;
    uint8_t  retry_count;
} DeviceConfig;

// ❌ 只初始化部分欄位,其他是垃圾值
DeviceConfig cfg;
cfg.device_id = 1;
cfg.is_enabled = true;
// timeout_ms 和 retry_count 是垃圾值!

我的習慣是先用 memset 清零,再設定需要的值:

// ✅
DeviceConfig cfg;
memset(&cfg, 0, sizeof(cfg));
cfg.device_id = 1;
cfg.is_enabled = true;
// timeout_ms = 0, retry_count = 0,至少是已知的值

或是用 C99 的 designated initializer,更清楚:

// ✅ C99 寫法,沒有列出的欄位自動是 0
DeviceConfig cfg = {
    .device_id  = 1,
    .is_enabled = true,
};

這個寫法我很喜歡,一眼就知道哪些欄位是刻意設定的,
哪些是用預設值(0)。


指標一定要初始化為 NULL

未初始化的指標是最危險的:

// ❌
uint8_t *data_buf;

if (some_condition) {
    data_buf = malloc(256);
}

// 如果 some_condition 是 false,data_buf 是垃圾值
// 這裡 dereference 就是 undefined behavior,通常直接 crash
process_data(data_buf);
// ✅
uint8_t *data_buf = NULL;

if (some_condition) {
    data_buf = malloc(256);
}

if (data_buf != NULL) {
    process_data(data_buf);
}

初始化為 NULL 之後,至少你可以檢查它,
不會在不知情的情況下存取到隨機的記憶體位置。


編譯器可以幫你抓

開啟警告之後,編譯器通常可以抓到明顯的未初始化使用:

gcc -Wall -Wextra -Wuninitialized your_code.c
warning: 'count' is used uninitialized [-Wuninitialized]

但編譯器不是萬能的,複雜的控制流程它不一定抓得到:

int result;

if (condition_a) {
    result = compute_a();
} else if (condition_b) {
    result = compute_b();
}
// 如果兩個條件都不成立,result 是未初始化的
// 某些情況下編譯器不會警告

return result;  // 潛在的問題

所以還是要養成習慣,不能完全依賴編譯器。


說實話

初始化這件事,說起來很簡單,做起來需要一點紀律。

趕進度的時候,宣告完變數就直接用,
反正在自己機器上跑都正常。

但嵌入式產品的問題是,你的開發環境和客戶的環境不一樣,
記憶體狀態不一樣,跑的時間不一樣,溫度不一樣。

那種「在我這裡沒問題」的 bug,
通常都是某個地方依賴了未定義的行為。

多打幾個 = 0= NULLmemset
真的可以省掉很多不必要的 debug 時間。


這篇的 Checklist

  • [ ] Local 變數宣告時有明確初始化
  • [ ] 指標初始化為 NULL,不是讓它指向隨機位置
  • [ ] malloc 之後有初始化記憶體內容(或改用 calloc
  • [ ] Struct 初始化有用 memset 或 designated initializer
  • [ ] Global / static 變數有明確寫出初始值,表示是故意的
  • [ ] 複雜控制流程的變數,確認所有路徑都有初始化
  • [ ] 開啟編譯器的 -Wuninitialized 警告
分類: 程式相關,標籤: , , , , , , , , , , , , , , 。這篇內容的永久連結

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *