[C 的那些眉角]字串結尾的 \0 — 忘記它的代價

記得曾經有一次,改了一段看起來超簡單的字串處理邏輯。改完,編譯過了,燒進去跑——直接掛。

不是 crash 那種痛快的掛法,是「有時候正常,有時候亂碼,偶爾 hard fault」這種最討厭的掛法。

花了快兩個小時才抓到兇手:一個被我忘掉的 \0

老實說這種錯我不是第一次犯了,所以決定認真把它記下來,順便整理一下這些年踩過的 null terminator 相關的坑。

\0 到底是什麼?為什麼 C 要這樣設計?

C 語言的字串其實不是什麼特別的資料型別,就是一個 char 陣列,然後靠結尾的 \0(數值 0,也就是 ASCII 的 NUL 字元)來標記「字串到這裡結束了」。

char greeting[] = "Hello";
// 記憶體裡實際長這樣:
// 'H' 'e' 'l' 'l' 'o' '\0'
// 0x48 0x65 0x6C 0x6C 0x6F 0x00

所以 "Hello" 看起來是 5 個字元,但實際佔了 6 bytes。這件事大家都知道,但在壓力大趕進度的時候,就是會忘。

為什麼 C 要這樣搞?因為早期記憶體很貴,如果像 Pascal 那樣用第一個 byte 存長度,字串最長就只能 255 個字元。用 \0 當結尾標記,理論上字串可以無限長(當然,記憶體要夠)。

代價就是——你得自己確保那個 \0 乖乖待在它該待的位置。

我這次踩的坑

場景是這樣的:模組收到一筆 MQTT payload,我要把它存到一個固定大小的 buffer 裡做後續解析。

原本的寫法:

#define PAYLOAD_BUF_SIZE 128

char payload_buf[PAYLOAD_BUF_SIZE];

void on_message(const char *data, int data_len)
{
    memcpy(payload_buf, data, data_len);
    // 然後拿 payload_buf 去做字串操作...
    printf("Received: %s\n", payload_buf);
}

看到問題了嗎?

memcpy 就是老老實實複製 data_len 個 bytes,它才不管你要不要當字串用。如果 data 裡面最後沒有帶 \0(很多通訊協定傳過來的 payload 就是不帶的),那 payload_buf 後面接的就是上一次殘留的垃圾資料。

printf%s 會一直往後讀,讀到碰到 \0 為止。運氣好,很快碰到一個 0x00,多印幾個亂碼;運氣不好,一路讀到不該讀的記憶體區塊——boom。

修正其實很簡單:

void on_message(const char *data, int data_len)
{
    if (data_len >= PAYLOAD_BUF_SIZE) {
        data_len = PAYLOAD_BUF_SIZE - 1;  // 留一個位置給 \0
    }
    memcpy(payload_buf, data, data_len);
    payload_buf[data_len] = '\0';  // 手動補上結尾

    printf("Received: %s\n", payload_buf);
}

就多兩行。但就是這兩行,害我 debug 了兩個小時。

經典翻車場景整理

寫了這麼多年韌體,null terminator 相關的 bug 真的是百看不厭(苦笑)。整理一下最常見的幾個:

strncpy 的陷阱

很多人(包括以前的我)以為 strncpystrcpy 的安全版本。嗯,算是吧,但它有個很討厭的行為:

char dest[8];
strncpy(dest, "HelloWorld", sizeof(dest));
// dest 的內容:'H' 'e' 'l' 'l' 'o' 'W' 'o' 'r'
// 注意:沒有 \0!

如果來源字串比目標 buffer 長,strncpy 不會幫你補 \0 它只保證不會寫超過你指定的長度,但不保證結果是一個合法的 C 字串。

所以用 strncpy 之後,養成習慣手動補:

strncpy(dest, src, sizeof(dest) - 1);
dest[sizeof(dest) - 1] = '\0';

或者如果你的平台有 strlcpy(BSD 系的),用它會省心很多:

strlcpy(dest, src, sizeof(dest));  // 保證 null-terminated

snprintf 的回傳值

snprintf 算是比較安全的,它會保證 null-terminated。但它的回傳值常被誤解:

char buf[8];
int ret = snprintf(buf, sizeof(buf), "Temperature: %d°C", 25);
// buf = "Tempera" (被截斷了,但有 \0)
// ret = 19 (是「如果 buffer 夠大,應該要寫入的字元數」,不含 \0)

ret >= sizeof(buf) 就代表被截斷了。我看過不少程式碼直接拿 ret 當實際寫入長度來用,然後後面就悲劇了。

手動組字串忘記算 \0

這個在嵌入式系統超常見,因為我們常常要在有限的 buffer 裡拼命塞東西:

// 要組一個 AT command
#define CMD_BUF_SIZE 32
char cmd[CMD_BUF_SIZE];

// 錯誤示範:沒考慮 \0
int len = strlen("AT+SEND=") + strlen(data);
if (len <= CMD_BUF_SIZE) {  // 應該是 < 才對
    sprintf(cmd, "AT+SEND=%s", data);
}

len == CMD_BUF_SIZE 的時候,\0 就寫到 cmd[32] 了——越界。這種 off-by-one 的 bug 超難抓,因為很可能在某些情況下不會出事(後面那塊記憶體剛好沒被用到),換個環境或者改了其他不相關的程式碼,突然就爆了。

跨語言、跨協定的字串交換

這個我在做 KVM over IP 的時候吃過虧。設備之間透過自定義協定傳資料,有些欄位是定長的:

// 通訊協定定義:裝置名稱欄位固定 16 bytes
typedef struct {
    uint8_t  type;
    char     name[16];   // 不保證 null-terminated!
    uint32_t ip_addr;
} __attribute__((packed)) device_info_t;

對方送過來的 name 如果剛好 16 個字元,是不會有 \0 的。你直接拿去 strcmpprintf 就GG。

我後來的做法是收到之後一律先做一次清理:

static void sanitize_device_name(device_info_t *info)
{
    info->name[sizeof(info->name) - 1] = '\0';
}

是的,這代表裝置名稱最長只能 15 個字元。但比起 buffer overread,我寧可少一個字元。

我現在的防禦習慣

被坑了這麼多次之後,我現在寫程式碼有幾個近乎條件反射的習慣:

Buffer 宣告的時候就清零。 雖然不是萬靈丹,但至少 buffer 裡到處都是 \0,就算忘了手動補,只要沒寫滿,字串操作不會爆掉。

char buf[128] = {0};  // 或 memset(buf, 0, sizeof(buf));

任何外部輸入進來的資料,第一件事就是確保 null-terminated。 不管文件說「會帶 \0」,不管對方保證「一定是合法字串」。我不信任何人,包括三個月前的我自己。

snprintf 取代 sprintf,用 strlcpy 取代 strcpy 能用帶長度限制的 API 就用,多打幾個字而已。

程式碼 review 的時候,看到 memcpy + 字串操作,就自動去找 \0 這個組合是 bug 高發區。

為什麼 Valgrind 和 sanitizer 很重要

光靠習慣還是會漏。在開發階段能用工具就用工具:

# AddressSanitizer:編譯時加上
gcc -fsanitize=address -g -o myapp myapp.c

# Valgrind:執行時檢查
valgrind --tool=memcheck ./myapp

不過嵌入式開發的痛苦就是,這些工具在 target 上通常跑不了。我的做法是盡量把字串處理的邏輯抽出來,在 host 上用這些工具先跑過一輪。不完美,但總比裸奔好。

嗯,就先寫到這。下次如果又因為 \0 翻車,我再來更新這篇(希望不要)。

發佈留言