有一次在對接兩邊的通訊,一邊是 MCU,一邊是 PC 端的測試工具。協定 header 很單純:1 byte type、4 bytes length、2 bytes sequence,加起來 7 bytes。
我很有自信地在 MCU 這邊定義了一個 struct,直接 send() 整個 struct 出去。結果 PC 那邊收到的 length 永遠是亂碼,seq 更是一塌糊塗。
Wireshark 一抓,發現封包長度不是 7,竟然是 12。
原來,多出來的 5 個 byte,不是我寫的,是編譯器幫我「貼心」塞的。🙃
先看現象
#include <stdio.h>
#include <stddef.h>
#include <stdint.h>
struct bad {
uint8_t type; // 1
uint32_t len; // 4
uint16_t seq; // 2
};
int main(void)
{
printf("sizeof = %zu\n", sizeof(struct bad));
printf("type=%zu len=%zu seq=%zu\n",
offsetof(struct bad, type),
offsetof(struct bad, len),
offsetof(struct bad, seq));
return 0;
}
我自己算的是 1 + 4 + 2 = 7。實際跑出來:
sizeof = 12
type=0 len=4 seq=8
len 不是在 offset 1,是在 offset 4。中間那 3 個 byte 就是 padding。
編譯器到底在幹嘛
不是亂塞,規則其實就三條:
- 每個成員的 offset 必須是它自己 alignment 的倍數。
uint32_t要對齊 4,所以 offset 只能是 0、4、8… - 整個 struct 的 alignment = 成員中最大的那個 alignment。
- 整個 struct 的 sizeof 必須是它自己 alignment 的倍數。 這就是「尾巴 padding」的來源。
照這三條手算一次 struct bad:
| 成員 | 大小 | alignment | offset | 說明 |
|---|---|---|---|---|
| type | 1 | 1 | 0 | 放 0 |
| (padding) | 3 | — | 1~3 | 為了讓 len 對齊 4 |
| len | 4 | 4 | 4 | |
| seq | 2 | 2 | 8 | |
| (padding) | 2 | — | 10~11 | struct 對齊 4,12 才是 4 的倍數 |
所以是 12。這個我自己第一次看的時候,被最後那 2 個 byte 卡住,因為「尾巴為什麼要補」沒有人告訴過我。
答案是陣列。如果 sizeof 是 10,那 arr[1] 的 len 就會落在 offset 14,沒對齊。為了讓每個元素都對齊,尾巴一定要補滿。
重排欄位,立刻省 4 個 byte
同樣的成員,換個順序:
struct good {
uint32_t len; // offset 0
uint16_t seq; // offset 4
uint8_t type; // offset 6
}; // 尾巴補 1 byte → sizeof = 8
實測 sizeof(struct good) == 8。
一般的經驗法則是「大的放前面、小的放後面」,padding 通常最少。但這只是經驗法則,不是定理,成員有巢狀 struct 或陣列的時候要實際算。
巢狀 struct:這裡最容易算錯
struct inner { char c; int i; }; // sizeof = 8, alignof = 4
struct outer { char a; struct inner in; char b; };
直覺會想:inner 是 8,加兩個 char,大概 10 吧?
實測是 16:
outer a=0 in=4 b=12 sizeof=16
a 在 0,in 的 alignment 是 4 所以跳到 4,佔 8 byte 到 12,b 在 12,尾巴再補 3 byte 湊到 16。
巢狀的 struct 是整塊當成一個「alignment 4 的成員」在處理,不會把裡面的 padding 拿去重複利用。
同一份 code,換平台 sizeof 就變
這個才是真正會咬人的地方。
struct dbl { char c; double d; };
- x86-64(我實測):
sizeof == 16,double對齊 8 - ARM EABI(Cortex-M / Cortex-A 常見的 ABI):一樣是 16,
double對齊 8 - i386 System V:
double在 struct 裡只要求對齊 4,所以是 12
最後這個 i386 的數字我手邊沒有 32 位元環境實測,是照 ABI 規定寫的,有疑慮的話請在你自己的環境驗證一次。
另外 long、指標、size_t 這些「跟平台字長綁在一起」的型別,在 32 位元跟 64 位元上大小就不一樣,放進 struct 裡 padding 當然跟著變。
所以結論很無聊但很重要:不要假設 struct 的 layout 在不同編譯器、不同平台、不同編譯選項下一樣。
怎麼驗證,不要用算的
我現在的習慣是不信自己的心算,讓編譯器講。
1. offsetof + sizeof,像上面那樣印出來。
2. -Wpadded:GCC / Clang 都有,有 padding 的地方它會直接告訴你補了幾個 byte。
warning: padding struct to align 'len' [-Wpadded]
warning: padding struct size to alignment boundary with 2 bytes [-Wpadded]
這個選項在整個專案打開會被標準函式庫 header 洗版,我是挑單一檔案或單一 struct 的時候開來看。
3. pahole(dwarves 套件):吃 debug info,直接列出整個 struct 每個 hole 在哪、多大。Linux kernel 開發的人應該都用過。
4. 把假設寫成 _Static_assert,讓編譯器幫你守門:
_Static_assert(sizeof(struct hdr) == 7, "hdr size changed");
_Static_assert(offsetof(struct hdr, seq) == 5, "hdr.seq offset");
C11 以後才有 _Static_assert,C++ 用 static_assert。舊的工具鏈沒有的話,只能用 typedef char check[(cond) ? 1 : -1]; 這類土法煉鋼的寫法。
這一步我覺得最值得做,因為它能抓到「三個月後有人在中間插了一個欄位」這種事。
那 packed 呢?
要拿掉 padding 最直接的做法:
struct __attribute__((packed)) hdr {
uint8_t type;
uint32_t len;
uint16_t seq;
};
// sizeof = 7, len 在 offset 1, seq 在 offset 5
(MSVC 是 #pragma pack(push, 1),這篇就不展開。)
sizeof 確實變成 7 了,我實測 offsetof(len) == 1、offsetof(seq) == 5。看起來問題解決?
沒有。我自己對 packed 的態度是能不用就不用,原因有三個。
1. 你製造了 unaligned 欄位
len 現在在 offset 1,是個不對齊的 uint32_t。
- 直接用
h.len存取:編譯器知道它是 packed,會產生安全的指令(可能是拆成多個 byte 存取),這沒問題,只是比較慢。 - 但你如果拿它的指標:
struct hdr h;
uint32_t *p = &h.len; // GCC 9 以後會警告 -Waddress-of-packed-member
*p = 1; // 這裡編譯器已經不知道它沒對齊了
在 x86 上通常「碰巧能跑」,但在不允許非對齊存取的核心上就是 HardFault。以我的理解,Cortex-M0 / M0+ 完全不支援非對齊存取;M3 / M4 以上單一的 LDR/STR 可以,但 LDM/LDRD 這類多字組指令不行,而且可以被設定成一律 trap。細節請以你用的核心的 TRM 為準,我不敢說全部記得。
這種 bug 最陰的地方是:換個 optimization level、換個編譯器版本,才突然爆。
2. packed 解決的是 padding,不是 endianness
我的 MCU 是 little-endian,PC 也是,所以那次沒事。但如果對面是 big-endian 的 DSP 或網路位元組序的協定,len 的 byte 順序還是會反。
packed 只是讓 layout 看起來像協定,沒有讓它「真的是」協定。
3. 它不是標準 C
__attribute__((packed)) 是 GCC / Clang 的擴充。跨編譯器的時候要自己包一層巨集,而且不同編譯器對 packed + bitfield 的行為並不一致。
我現在的做法:資料線上的格式,自己序列化
只要這個 struct 會離開這個程式——網路、UART、flash、跟別的 core 或 bootloader 共用——我就不再把 struct 當成 wire format,而是明確地寫 parse / serialize:
static inline uint32_t get_le32(const uint8_t *p)
{
return (uint32_t)p[0]
| ((uint32_t)p[1] << 8)
| ((uint32_t)p[2] << 16)
| ((uint32_t)p[3] << 24);
}
struct hdr { // 這個 struct 只是記憶體裡的表示,layout 隨便
uint32_t len;
uint16_t seq;
uint8_t type;
};
int hdr_parse(const uint8_t *buf, size_t n, struct hdr *out)
{
if (n < 7) return -1;
out->type = buf[0];
out->len = get_le32(buf + 1);
out->seq = (uint16_t)(buf[5] | (buf[6] << 8));
return 0;
}
缺點很明顯:字多、無聊、欄位多的時候容易手滑寫錯 offset。
好處是三件事全部一次解決:沒有 padding 問題、沒有 unaligned 問題、byte order 寫得明明白白。而且 buf 不需要對齊,這在 DMA buffer 或從 flash 讀出來的資料上很實用。
如果協定欄位很多,我會考慮用 code generator 產這些 parse / serialize,而不是手寫。這個我還沒有找到自己滿意的方案,後面可能會另外寫。
幾個其他的坑
- bitfield:成員怎麼排進 storage unit、MSB 還是 LSB 先放,都是 implementation-defined。想用 bitfield 對應硬體暫存器或協定 flag,換編譯器就要重新驗證。我自己寧可用 mask 和 shift。
memcmp比較 struct:padding 裡的值是不確定的。兩個欄位完全相同的 struct,memcmp可能不等於 0。要比較就逐欄位比。- 把整個 struct 寫進 flash 或傳出去:padding 裡可能殘留 stack 上的舊資料,有資訊外洩的疑慮。至少先
memset(&s, 0, sizeof s)。 - 改 struct 順序會破壞相容性:如果舊版韌體已經把整個 struct 存在 flash 裡,升級後你重排欄位,舊資料就讀不對了。這也是我不想把 struct 直接當儲存格式的原因。
小結
sizeof 跟你算的不一樣,不是 compiler 有 bug,是你忽略了 alignment 規則。
我現在的原則很簡單:struct 只管記憶體裡的表示,離開程式的資料就明確序列化;一定要假設 layout 的地方,用 _Static_assert 釘死。
至於 packed,我還是會用,主要在對應固定格式的硬體 / 韌體映像 header 的時候。但每次用我都會心裡毛一下,確認沒有人拿它的欄位去取指標。
💬 來聊聊
你有被 struct padding 咬過嗎?跨平台、跨核心的資料格式,你是用 packed、手寫序列化,還是別的做法?
如果你有遇過__attribute__((packed))在特定平台上爆掉的案例,也歡迎留言,交流一下您踩過的坑。