[C 的那些眉角]sizeof 的陷阱 — 指標傳進函式就失效了

有一次在專案開發中,需要把收到的封包做 checksum 驗證。邏輯很單純,把 buffer 的每個 byte 加起來,對吧?

我寫了一個驗證函式,丟進去跑,結果 checksum 每次都不對。封包明明是對的,Wireshark 抓出來也沒問題,但我的函式就是算錯。

花了快一個小時在懷疑通訊協定、懷疑 byte order、懷疑硬體……最後發現問題出在 sizeof。一個我以為自己很熟的東西。

問題長這樣

簡化後的程式碼大概是這樣:

#include <stdio.h>
#include <stdint.h>

uint8_t calc_checksum(uint8_t buf[]) {
    uint8_t sum = 0;
    size_t len = sizeof(buf);  // ← 問題在這
    printf("len = %zu\n", len);
    for (size_t i = 0; i < len; i++) {
        sum += buf[i];
    }
    return sum;
}

int main(void) {
    uint8_t packet[16] = {
        0x01, 0x02, 0x03, 0x04,
        0x05, 0x06, 0x07, 0x08,
        0x09, 0x0A, 0x0B, 0x0C,
        0x0D, 0x0E, 0x0F, 0x10
    };

    printf("sizeof in main: %zu\n", sizeof(packet));  // 16
    uint8_t cs = calc_checksum(packet);
    printf("checksum: 0x%02X\n", cs);

    return 0;
}

跑出來的結果:

sizeof in main: 16
len = 8
checksum: 0x24

sizeofmain 裡是 16,進了函式變成 8。Checksum 當然就錯了,因為它只算了前 8 個 byte。

為什麼會這樣?

C 語言有一條很根本但容易忘記的規則:陣列傳進函式時,會退化(decay)成指標。

所以這兩個寫法其實是一模一樣的:

uint8_t calc_checksum(uint8_t buf[]);    // 看起來像陣列
uint8_t calc_checksum(uint8_t *buf);     // 實際上是指標

編譯器不管你寫 buf[] 還是 buf[16] 還是 *buf,到了函式參數這關,全部都是指標。所以 sizeof(buf) 拿到的是指標的大小,在 64-bit 系統上就是 8,32-bit 系統上是 4。

跟你的陣列有多大完全無關。

更狡猾的版本

有時候你會看到這種寫法,以為加了大小就沒事:

void process(uint8_t data[128]) {
    printf("%zu\n", sizeof(data));  // 還是 8,不是 128
}

不,那個 128 完全是裝飾品。編譯器會默默忽略它。GCC 連個 warning 都不給你(除非你開 -Wsizeof-pointer-memaccess 之類的選項),這是我覺得最坑的地方。

正確的做法

老實把長度當參數傳進去,沒有捷徑:

uint8_t calc_checksum(const uint8_t *buf, size_t len) {
    uint8_t sum = 0;
    for (size_t i = 0; i < len; i++) {
        sum += buf[i];
    }
    return sum;
}

int main(void) {
    uint8_t packet[16] = { /* ... */ };
    uint8_t cs = calc_checksum(packet, sizeof(packet));  // sizeof 在這裡是對的
    return 0;
}

sizeof 只在陣列宣告的那個 scope 才知道真正的大小。一旦傳出去,資訊就丟了。所以在傳出去之前就把 sizeof 的結果一起帶走。

幾個相關的坑順便提一下

sizeof 用在字串上

char msg[] = "Hello";
printf("%zu\n", sizeof(msg));   // 6,包含 '\0'
printf("%zu\n", strlen(msg));   // 5,不包含 '\0'

sizeof 算的是記憶體大小(含結尾的 \0),strlen 算的是字串長度。搞混的話,buffer 計算就會差一個 byte,剛好是 off-by-one 的經典來源。

用 macro 算陣列長度

#define ARRAY_LEN(arr) (sizeof(arr) / sizeof((arr)[0]))

這個 macro 很常見,但它一樣只能在陣列宣告的 scope 用。拿指標去套,不會報錯,只會給你一個錯誤的數字。

GCC 和 Clang 有 __builtin_types_compatible_p 可以做編譯期檢查,不過比較少人用:

#define ARRAY_LEN(arr)                                          \
    (sizeof(arr) / sizeof((arr)[0]) +                           \
     sizeof(typeof(int[1 - 2 *                                  \
        !!__builtin_types_compatible_p(typeof(arr),             \
            typeof(&(arr)[0]))])) * 0)

這段看起來很醜,但如果你不小心拿指標去用,編譯器會直接報錯,而不是默默算出垃圾值。在 safety-critical 的專案裡,這種防呆是值得的。

C99 的 VLA 參數

C99 有一個語法讓你在函式參數裡標示陣列大小:

void process(size_t n, uint8_t data[n]) {
    // sizeof(data) 還是指標大小,不是 n
    // 但至少語意上比較清楚
}

說實話我自己不太用這個寫法。它不會改變 sizeof 的行為,而且 VLA 在嵌入式開發裡本來就有點爭議(stack overflow 風險),很多 coding standard 直接禁用。

防踩坑的習慣

我現在寫 C 會盡量遵守幾個原則:

  • 函式參數用 const type *buf, size_t len 的組合,不用陣列語法,這樣看程式碼的人不會產生「sizeof 可以用」的錯覺。
  • sizeof 只在宣告陣列的地方用,用完馬上存成變數或當參數傳走。
  • -Wall -Wextra,雖然不一定抓得到這個問題,但能多抓一些總是好的。
  • Code review 特別注意函式內的 sizeof,只要看到 sizeof 作用在函式參數上,就要警覺。

說起來都是很基本的東西,但就是這種「我知道啊」的東西最容易出事。寫韌體的時候,一個 byte 的差異就可能讓封包解析整個歪掉,然後你會花很多時間在錯誤的方向上找問題——就像我那天一樣。 😅

發佈留言