[C 的那些眉角]struct padding 和 alignment — 為什麼 sizeof 結果跟你算的不一樣

有一次在對接兩邊的通訊,一邊是 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。


編譯器到底在幹嘛

不是亂塞,規則其實就三條:

  1. 每個成員的 offset 必須是它自己 alignment 的倍數。 uint32_t 要對齊 4,所以 offset 只能是 0、4、8…
  2. 整個 struct 的 alignment = 成員中最大的那個 alignment。
  3. 整個 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)) 在特定平台上爆掉的案例,也歡迎留言,交流一下您踩過的坑。

發佈留言