寫韌體寫久了,有些東西會變成肌肉記憶,手就是會自動打出來。指標宣告完順手 = NULL,這大概是每個 C 工程師的本能反應。
但前陣子在 debug 一個詭異的 crash,追了大半天才發現:問題出在我對 NULL、0、和「指標初始化」這幾件事的理解,其實一直有個模糊地帶。
事情是怎麼開始的
在一個專案裡,有一段大概長這樣的 code:
struct sensor_ctx {
uint8_t *buf;
uint16_t len;
int (*callback)(uint8_t *data, uint16_t size);
};
void sensor_init(struct sensor_ctx *ctx)
{
memset(ctx, 0, sizeof(*ctx));
// 後面根據設定去 assign buf 和 callback
}
看起來超正常對吧?memset 把整個 struct 清成 0,指標欄位自然就是 NULL,之後再根據需要 assign。
這段 code 在我們的 ARM Cortex-M4 平台跑了好幾個月都沒事。直到有一天,同事把類似的邏輯搬到另一個比較冷門的平台上——然後 callback 呼叫就莫名 crash 了。
NULL 到底是什麼?
這就是那個模糊地帶。我們先來看 C 標準怎麼說的。
在 C 標準裡,null pointer constant 的定義是:一個值為 0 的整數常量表達式,或者這樣的表達式被轉型成 void *。所以 0、(void *)0 都是合法的 null pointer constant。
// 這些都是 null pointer constant
int *p1 = 0;
int *p2 = (void *)0;
int *p3 = NULL; // NULL 通常被定義為 0 或 (void *)0
重點來了:當你把 null pointer constant 賦值給一個指標時,編譯器會把它轉換成該平台的 null pointer representation。
在大多數我們常用的平台上(x86、ARM),null pointer 的內部表示就是全 0 bits。所以 memset(ptr, 0, sizeof(*ptr)) 之後,裡面的指標欄位剛好就等於 NULL。
但 C 標準從來沒保證這件事。
所以問題在哪?
memset 是 bit-level 的操作,它把記憶體填成 0x00。但 null pointer 在某些平台上的 bit pattern 不一定是全 0。
雖然這種平台現在已經很罕見了(像是一些老的 CDC、Honeywell mainframe,或某些特殊的 DSP),但重點不是「你會不會遇到」,而是這反映了一個觀念問題:
你以為你在初始化指標,但其實你只是在填 0。
// ✅ 正確的指標初始化
ctx->buf = NULL;
ctx->callback = NULL;
// ⚠️ 只在 null pointer 表示為全 0 的平台上正確
memset(ctx, 0, sizeof(*ctx));
我同事遇到的那個 crash,後來查出來其實是另一個 bug 疊加造成的,不是真的因為 null pointer representation 不是全 0。但這次 debug 的過程讓我重新想了一下這件事。
那 C++ 的情況呢?
C++ 這邊更有趣一點。C++11 之後有了 nullptr,它是一個型別為 std::nullptr_t 的 prvalue,不是整數。
int *p1 = nullptr; // ✅ C++11 推薦
int *p2 = NULL; // ⚠️ 可能是 0 或 (void*)0,看實作
int *p3 = 0; // ✅ 合法但不推薦
用 nullptr 的好處在 function overload 的時候最明顯:
void process(int val);
void process(int *ptr);
process(NULL); // ❌ 可能呼叫到 process(int),因為 NULL 可能是 0
process(nullptr); // ✅ 一定呼叫 process(int *)
這個在寫比較複雜的 C++ 韌體(比如有用到 template 的 HAL 抽象層)的時候,真的會踩到。
實務上我現在怎麼做
講了一堆標準的東西,回到實際工作。我現在的習慣是這樣:
struct 初始化,能用 designated initializer 就用:
struct sensor_ctx ctx = {
.buf = NULL,
.len = 0,
.callback = NULL,
};
這樣每個欄位的意圖都很清楚,而且 C99 就支援了。沒被明確初始化的欄位會被設為 0(對指標來說就是 null pointer),這個是標準保證的。
如果真的要 memset(比如清一個很大的 struct array,一個一個設太囉嗦),我會在旁邊加個 comment 說明這裡假設 null pointer 是全 0 表示:
/* Assumes null pointer representation is all-zero-bits (true on ARM/x86) */
memset(ctx_array, 0, sizeof(ctx_array));
寫 comment 不是給別人看的,是給三個月後的自己看的。
C++ 的話,一律用 nullptr,沒什麼好猶豫的。如果 codebase 還在用 NULL,碰到的時候順手改掉。
0、NULL、nullptr、\0 的差異整理
因為每次講到這個,一定會有人把 '\0' 也拿出來問,乾脆整理一下:
0 整數常量,值為 0。用在指標 context 時是 null pointer constant。
NULL 巨集,通常定義為 0 或 (void *)0(C)/ 0(C++)。
nullptr C++11 關鍵字,型別是 std::nullptr_t,只能用在指標。
'\0' 字元常量,值為 0,型別是 int(C)/ char(C++)。
它們的「值」都跟 0 有關,但語意完全不同。在對的地方用對的那個,code 的意圖會清楚很多。
延伸:calloc vs malloc + memset
既然講到 memset 清 0 的問題,順便提一下 calloc。
// 這兩個在「把記憶體填成 0」這件事上是等價的
int *arr1 = calloc(10, sizeof(int));
int *arr2 = malloc(10 * sizeof(int));
memset(arr2, 0, 10 * sizeof(int));
calloc 保證回傳的記憶體是全 0 bits,跟 memset 一樣。所以對指標欄位來說,calloc 也有一樣的「不保證是 NULL」的理論問題。
但實務上,calloc 有個額外的好處:它會幫你檢查 count * size 有沒有 overflow。在嵌入式系統裡,這個小細節有時候蠻重要的。
最後的碎碎念
這種問題其實很典型——在 99.9% 的情況下,memset 清 0 跟正確初始化指標的效果完全一樣。你可能寫了十年韌體都不會因為這個出 bug。
但理解「為什麼一樣」跟「就當它一樣」是兩回事。知道 C 標準在這邊留了一個口,至少在寫跨平台 code 或 review 別人的 code 時,你會多一個觀察角度。
而且老實說,用 designated initializer 真的比 memset 好看多了。不管有沒有可移植性的問題,光是可讀性這點就值得換了 🍵
📌 知識點整理
| 表達式 | 本質 | 語意 | 該用在哪 |
|---|---|---|---|
0 |
整數常量 | 在指標 context 中是 null pointer constant | 數值計算,指標場合請改用 NULL |
NULL |
巨集(C: ((void *)0) 或 0、C++: 0) |
空指標 | C 語言的指標初始化 |
nullptr |
C++11 關鍵字,型別 std::nullptr_t |
空指標(型別安全) | C++ 一律用這個,別猶豫 |
'\0' |
字元常量,值為 0 | 字串結尾的 null terminator | 字串操作,跟指標無關 |
🔑 一句話記住:NULL 是語言層級的「這個指標不指向任何東西」,memset 清 0 是硬體層級的「把每個 bit 填成 0」。兩件事在大多數平台上結果一樣,但原因不一樣。
🔑 struct 初始化的安全牌:用 designated initializer(C99),每個欄位意圖明確,未列出的欄位標準保證歸零,可讀性也比 memset 好。
🔑 C++ overload 陷阱:process(NULL) 可能呼叫到 process(int) 而不是 process(int *),因為 NULL 本質上就是個 0。換成 nullptr 就沒這個問題。
🔑 calloc 比 malloc + memset 多一個好處:自動檢查 count * size 有沒有整數溢位,在嵌入式環境這點蠻實用的。