有一次在專案開發中,需要把收到的封包做 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
sizeof 在 main 裡是 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 的差異就可能讓封包解析整個歪掉,然後你會花很多時間在錯誤的方向上找問題——就像我那天一樣。 😅