我第一次被 sizeof 教做人。
不是在课堂。
是在一次很真实的协议联调里。
那会儿我还年轻。
看见结构体就手痒。
总觉得它天生适合当包头。
于是我写了一个很朴素的东西。
#include <cstdint>
struct Header {
std::uint8_t type;
std::uint32_t len;
};
字段少。
类型也规矩。
我甚至觉得自己有点专业。
然后我把它当成一块连续内存发出去。
对端回了我一句话。
包长不对。
我当时不服。
我说这怎么可能。
type 一字节。
len 四字节。
加起来五字节。
我还会算错?
然后我打印了 sizeof(Header)。
屏幕上回了我一个数字。
8。
那一刻我才意识到。
你写的成员。
只是账面。
真正的账。
是编译器和硬件一起算的。
sizeof不关心你心里怎么想。 它只关心机器怎么舒服。
对齐是怎么变成规矩的
对齐这件事。
不是 C++ 发明的。
也不是编译器爱管闲事。
它更像硬件留下来的家法。
早年的一些机器。
对不对齐这事很认真。
4 字节的整数。
就希望在 4 的倍数地址上。
8 字节的数。
就希望在 8 的倍数地址上。
你偏要把它放到奇怪的地址。
轻一点是慢。
重一点是直接崩。
后来很多架构变得宽容。
不对齐也能读。
但代价不会凭空消失。
它只是换成了更隐蔽的形式。
多一次访存。
多一次拆分组合。
再叠上缓存行。
再叠上向量化。
你就会明白。
所谓对齐。
就是让数据少跨界。
少折腾。
少付利息。
于是编译器会替你做一件事。
它会在成员之间塞一些填充字节。
padding。
你没写。
但它会出现在 sizeof 里。
先记住两句话
每个类型都有一个对齐要求。
你可以用 alignof(T) 看。
结构体整体也有一个对齐要求。
通常等于它所有成员里最大的那个。
于是 sizeof 的算盘就很直白。
成员要按声明顺序摆。
每个成员开始的位置要满足自己的对齐。
不够就补 padding。
最后结构体整体大小也要补到对齐倍数。
不然数组就会出事。
你以为是 5 其实是 8
我们把上面的包头缩到最小。
#include <cstdint>
struct A {
std::uint8_t type;
std::uint32_t len;
};
在很多 64 位平台上。
alignof(std::uint32_t) 是 4。
也就是说 len 更喜欢从 4 的倍数地址开始。
但 type 站在最前面。
只占 1 字节。
那 len 如果紧跟着放。
就会落在偏移 1。
编译器不喜欢。
它会把 len 往后推到偏移 4。
中间空出来的 3 个字节。
就是 padding。
你可以自己验证偏移。
#include <cstddef>
#include <cstdint>
#include <iostream>
struct A {
std::uint8_t type;
std::uint32_t len;
};
int main() {
std::cout << sizeof(A) << '\n';
std::cout << alignof(A) << '\n';
std::cout << offsetof(A, len) << '\n';
}
你会看到 offsetof(A, len) 往往是 4。
不是 1。
故事到这还没完。
结尾还有一笔。
A 的整体对齐通常也是 4。
所以 sizeof(A) 会被补到 4 的倍数。
1 + 3 + 4。
正好 8。
成员顺序一换 体积能差一倍
成员顺序这事。
标准是给你兜底的。
你怎么声明。
它就怎么排。
所以顺序不是纯粹的风格。
它会直接变成字节。
你看这个。
#include <cstdint>
struct B {
std::uint8_t a;
std::uint64_t b;
std::uint8_t c;
};
直觉会说。
1 + 8 + 1。
10。
但 std::uint64_t 往往要 8 字节对齐。
a 放在开头。
b 只能等到下一个 8 的倍数。
中间会补 7 个字节。
然后 c 放在最后。
结构体整体还要补到 8 的倍数。
所以末尾还可能再补 7 个字节。
你只写了 10。
sizeof(B) 却常见是 24。
换个顺序。
#include <cstdint>
struct C {
std::uint64_t b;
std::uint8_t a;
std::uint8_t c;
};
很多时候 sizeof(C) 会变成 16。
不是因为你变聪明了。
是因为你让大块头先坐下。
小东西再塞缝里。
这在老代码里特别常见。
你没做任何算法优化。
只是换了个声明顺序。
对象数量一多。
缓存命中率就会回报你。
末尾 padding 是为了数组
很多人只盯着成员之间的 padding。
结尾那一截更容易被忽略。
但它才是数组能对齐的原因。
你想象一下。
如果一个对象大小不是对齐倍数。
那数组里第二个元素的起始地址就可能不对齐。
硬件不答应。
编译器也不答应。
所以它会把结构体的总大小补齐。
这就是为什么有些类型。
你看起来最后一个成员已经对齐了。
sizeof 还是会再长一截。
不是浪费。
是为了让下一个对象也能从干净的边界开始。
alignof 是问规矩 alignas 是改规矩
alignof(T) 很像你去问一嘴。
这个类型的规矩到底多大。
#include <cstdint>
#include <iostream>
int main() {
std::cout << alignof(std::uint8_t) << '\n';
std::cout << alignof(std::uint32_t) << '\n';
std::cout << alignof(std::uint64_t) << '\n';
}
而 alignas(N) 更像你直接拍桌子。
我就要它按 N 对齐。
最常见的动机。
不是炫技。
是 SIMD。
或者缓存行。
比如你希望某个对象从 64 字节边界开始。
让它别和别人的数据挤在同一条缓存行里。
#include <cstdint>
struct alignas(64) Counter {
std::uint64_t value;
};
这会改变两件事。
对象起始地址的对齐。
以及对象的 sizeof。
因为整体大小也要补到 64 的倍数。
所以别把 alignas 当装饰。
它是真会让对象变大。
但有时候。
你宁愿它变大。
也不想让它在多线程里被假共享折磨。
最后说一句老规矩
如果你在做序列化。
别把结构体当协议。
结构体是给编译器和 CPU 看的。
协议是给网络和另一个进程看的。
对齐。
padding。
大小端。
ABI。
这些东西都不保证跨编译器一致。
你今天在本机上 sizeof 是 8。
不代表明天换个平台还是 8。
更不代表对端也认。
我后来学乖了。
包就是包。
字段就按字节写进去。
该 memcpy 就 memcpy。
该按端序转换就转换。
累一点。
但睡得踏实。
sizeof 为什么比你想的大。
因为你想的是语法。
它算的是机器。
这门语言就是这样。
表面上像写诗。
底下全是螺丝。