sizeof 一个结构体,结果常常比成员大小加起来要大;一个什么成员都没有的空类,sizeof 是 1;一个类加了虚函数,每个对象就大了 8 字节;多继承时把派生类指针转成第二个基类的指针,指针的数值会变。这几件事背后是同一个问题:编译器怎么把一个对象摆进一段连续的字节里。这篇把它们一个个画在字节格子上,数字都按 x86-64 Linux 上的 Itanium C++ ABI(GCC 和 Clang 都用它),用 GCC 16 编译实测。
1. 对齐和填充:sizeof 为什么比成员加起来大
1.1 对齐(alignment):一个类型的地址必须是几的倍数
内存按 64 字节一条缓存行搬进 CPU(硬件地图:一次读怎么走)。一个 8 字节的 int64_t 如果放在 8 的倍数地址上,它一定整个落在同一条行里,一次就读完;要是放在地址 60,就会跨两条行,一次读要碰两条,x86 上能跑但更慢,一些老的 ARM 和别的架构上直接出错,原子操作跨行在 x86 上也会变得极慢。所以 ABI 给每个类型规定了对齐:它的地址必须是这个数的倍数。基本类型的对齐就是自己的大小,char 是 1,int32_t 是 4,int64_t、double、指针是 8,用 alignof(T) 可以查到。结构体的对齐取成员里最大的那个。
编译器摆成员时有两条硬规矩:成员按声明顺序排,每个成员的偏移必须是它对齐的倍数。
struct Bad {
char a; // 偏移 0
int64_t b; // 要 8 的倍数:偏移 8,中间空 7 字节
char c; // 偏移 16
int32_t d; // 要 4 的倍数:偏移 20,中间空 3 字节
};
static_assert(sizeof(Bad) == 24 && alignof(Bad) == 8);
static_assert(offsetof(Bad, b) == 8 && offsetof(Bad, d) == 20);
成员一共 1 + 8 + 1 + 4 = 14 字节,sizeof 却是 24。中间空出来的 7 字节和 3 字节叫填充(padding),里面什么都没有,只是为了让 b 和 d 落在自己的对齐上。sizeof 是成员加填充,填充是对齐规则逼出来的。
1.2 末尾填充:为了数组里下一个也对齐
填充不只出现在成员之间,结构体末尾也会补:
struct Tail { int64_t b; char c; }; // 8 + 1 = 9,sizeof 却是 16
原因在数组。Tail arr[2] 里,arr[1] 紧跟在 arr[0] 后面,地址是 arr + sizeof(Tail)。要是 sizeof 是 9,arr[1].b 就落在 9 的位置,不再是 8 的倍数了。所以编译器把大小向上补到对齐的整数倍,sizeof 一定是 alignof 的整数倍,数组里每一个元素才都对齐。
1.3 调整顺序:从大到小排,填充最少
填充多少取决于成员的顺序。把 Bad 的成员按对齐从大到小重排:
struct Good {
int64_t b; // 0
int32_t d; // 8
char a; // 12
char c; // 13,末尾补 2 字节到 16
};
static_assert(sizeof(Good) == 16);
大的在前,后面每个成员的偏移自然就是它对齐的倍数,只剩末尾一点填充。同样四个成员,从 24 字节变成 16 字节。放在一条 64 字节的缓存行上看差别更直观:
一百万个元素的数组,Bad 占 24 MB,Good 占 16 MB,从头扫一遍要搬的缓存行也少三分之一。所以定义结构体时,按对齐从大到小排成员,是不花任何代价的省空间办法。
但大小不是唯一要考虑的。经常一起读写的成员放在一起,读一个时另一个已经跟着进了缓存;反过来,两个线程各写各的成员,如果它们挤在同一条行里,这条行会在两个核之间来回搬(缓存行乒乓),这时要故意加填充把它们隔开,alignas(64) 就是干这个的:
struct Counter { alignas(64) int64_t n; }; // 每个 Counter 独占一条行
static_assert(sizeof(Counter) == 64);
还有一个方向相反的工具:#pragma pack(1) 或 __attribute__((packed)) 去掉所有填充,Bad 会变成 14 字节。代价是 b 落在偏移 1,不再对齐,编译器访问它要生成能处理不对齐的代码,取 &p.b 当普通的 int64_t* 用也不安全。它只适合描述网络包头、文件格式这类字节排布由外部规定的数据。
1.4 空类:sizeof 是 1,做基类时可以是 0
一个没有任何成员的类,sizeof 是 1,不是 0。C++ 要求两个不同的对象地址不同:Empty a, b; 里 &a != &b,Empty arr[10] 里每个元素地址都不一样。大小是 0 就做不到,所以编译器给它 1 个字节,里面什么都不存。
这 1 字节放进别的结构体里,还会带出填充:
struct Empty {};
struct WithEmpty { Empty e; int x; }; // e 占 1,补 3,x 在 4:共 8
struct EboBase : Empty { int x; }; // 4
struct NUA { [[no_unique_address]] Empty e; int x; }; // 4(C++20)
static_assert(sizeof(Empty) == 1 && sizeof(WithEmpty) == 8);
static_assert(sizeof(EboBase) == 4 && sizeof(NUA) == 4);
空类当基类时,编译器可以让它和派生类的第一个成员共用地址,不占空间,这叫空基类优化(empty base optimization,EBO)。C++20 的 [[no_unique_address]] 把同样的待遇给了成员:标了它的空成员可以不占空间。空类单独存在要占 1 字节,嵌进别人里面可以占 0。
标准库到处在用这一点。std::unique_ptr<int, Del> 带一个没有成员的删除器,大小仍然是 8,和裸指针一样,删除器没有多占一个字节;容器里存的分配器也一样。MSVC 不认 [[no_unique_address]],要写 [[msvc::no_unique_address]],跨平台的代码要注意。
2. 带虚函数的对象:多出一个 vptr
2.1 vptr:对象开头的 8 字节
虚函数调用怎么找到函数、要付哪些代价,虚函数那篇讲过:每个对象开头藏一个 vptr,指向自己那个类的 vtable,调用时读 vptr、再从表里读函数地址、跳过去。这里只看它在对象里占了哪:
struct V {
virtual ~V() = default;
virtual void f() {}
int a; // 偏移 8
int b; // 偏移 12
};
struct V1 { virtual void f() {} int a; }; // 8 + 4,末尾补 4:16
static_assert(sizeof(V) == 16 && sizeof(V1) == 16);
vptr 是一个指针,占 8 字节、要 8 对齐,编译器把它放在偏移 0,成员接在后面。V1 只有一个 int,也是 16 字节,因为对齐变成了 8,末尾要补齐。虚函数的地址都在表里,表每个类只有一张,所以对象里只多一个指针,虚函数再多也只是表变长。
2.2 vtable 里有什么:offset_to_top、typeinfo、函数地址
g++ -fdump-lang-class -c v.cpp 会把每个类的布局和 vtable 写进一个 .class 文件,V 的那一段是:
Vtable for V
V::_ZTV1V: 5 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI1V)
16 (int (*)(...))V::~V
24 (int (*)(...))V::~V
32 (int (*)(...))V::f
Class V
size=16 align=8
V (0x...) 0
vptr=((& V::_ZTV1V) + 16)
最后一行说明 vptr 不指向表头,而是指向偏移 16,也就是第一个函数那一格。这一格叫地址点(address point),虚函数按声明顺序从这里往后排,调用时用"vptr + 8 × 序号"取地址。地址点前面还有两格:
- vptr[-1]:typeinfo 指针,指向这个类的
type_info对象。typeid(*p)和dynamic_cast都要先通过它知道对象的实际类型。 - vptr[-2]:offset_to_top,这个 vptr 离整个对象开头有多远。单继承时永远是 0,到多继承才用得上(3.3)。
析构函数占了两格,因为它有两个版本:完整析构(GCC 符号里的 D1)只析构对象、不释放内存,用在栈上的对象和成员上;带 delete 的析构(D0)先调 D1,再调 operator delete 释放内存。delete p 时 p 可能是基类指针,要释放多大的内存、调谁的 operator delete,只有实际类型知道,所以"析构 + 释放"整个都要走虚调用,就得在表里单独占一格。
vtable 放在哪个段也有讲究。表里存的是函数的绝对地址,而程序默认编成位置无关(PIE),.so 更是装到哪都行,这些地址要等装载时才知道,得由动态链接器在启动时填进去。所以 vtable 不能直接放在只读的 .rodata,而是放在 .data.rel.ro:装载时可写,动态链接器把地址填好以后,再用 mprotect 改成只读,这一步叫 RELRO(relocation read-only)。进程地图的段表里 .rodata 那一行写的"重定位后的 vtable",指的就是它。vtable 运行时是只读的,改不了;能被改的是对象里的 vptr,堆溢出把 vptr 覆盖成假表的地址,下一次虚调用就会跳到攻击者想去的地方,这是经典的利用手法。
2.3 vptr 什么时候写进去:构造函数一层一层改
vptr 由构造函数写进对象,而且不是一次写好。构造一个 Derived 时,先跑 Base 的构造函数,它一开头把 vptr 设成 Base 的表;Base 的构造跑完,轮到 Derived 的构造函数,再把 vptr 改成 Derived 的表。所以在 Base 的构造函数里调虚函数,调到的是 Base 的版本:
struct Base {
Base() { hello(); } // 这时 vptr 指向 Base 的表
virtual void hello() { std::puts("Base::hello"); }
virtual ~Base() = default;
};
struct Derived : Base {
std::string name = "Derived";
void hello() override { std::puts(name.c_str()); }
};
int main() { Derived d; d.hello(); } // 输出 Base::hello,再输出 Derived
这是故意的:Base 构造时,Derived 的成员 name 还没构造,要是这时调到 Derived::hello,它读的就是一个还不存在的 std::string。析构反过来走:~Derived 跑完,vptr 被改回 Base 的表,再跑 ~Base,~Base 里调虚函数同样只到 Base 那一层。对象在构造和析构的过程中,类型是一层一层变的,vptr 跟着变。
面试常问的两个"为什么"都从这里来。构造函数不能是虚函数:虚调用要先读对象里的 vptr,而构造之前对象还不存在、vptr 还没写,况且构造时你本来就写明了要造哪个类型,不需要按实际类型分派。基类的析构函数要是虚的:Base* p = new Derived; delete p; 时,析构不是虚的,就只会调 ~Base,Derived 的成员没析构,operator delete 拿到的大小也是 Base 的,结果是未定义行为。
3. 继承:基类子对象怎么摆
3.1 单继承:基类在前,派生类接着往后排
派生类对象里包着一个完整的基类对象,叫基类子对象,它放在偏移 0,派生类自己的成员接在后面。基类有 vptr 时,派生类直接用这个 vptr,不再另加,它的表也是在基类那张表的基础上改:覆盖了的函数换成自己的地址,新增的虚函数接在后面。所以 Derived* 和 Base* 指向同一个对象时,单继承下两个指针的数值一样,转换不用任何指令。
struct V1 { virtual void f() {} int a; }; // 16:vptr 8,a 4,末尾空 4
struct VD : V1 { int c; }; // 还是 16:c 在偏移 12
static_assert(sizeof(VD) == 16);
VD 多了一个成员,大小却没变:c 被塞进了 V1 末尾那 4 字节填充里。V1 有虚函数,不是 C++03 意义上的 POD,Itanium ABI 允许派生类复用这种基类末尾的填充。这也说明不能对基类子对象整体 memcpy 一个 sizeof(V1) 的大小,那会把 c 一起覆盖掉。
3.2 多继承:两个基类子对象,两个 vptr
两个基类都有虚函数时,派生类对象里就有两个基类子对象,各带一个 vptr:
struct BaseA { virtual ~BaseA() = default; virtual void FuncB() {} int a; int b; };
struct BaseB { virtual ~BaseB() = default; virtual void FuncC() {} int a; int b; };
struct Derive : BaseA, BaseB {
void FuncB() override {}
void FuncC() override {}
int c;
};
static_assert(sizeof(BaseA) == 16 && sizeof(Derive) == 40);
BaseA 子对象在偏移 0,和 Derive 共用开头的 vptr;BaseB 子对象在偏移 16,带着自己的 vptr;c 在 32,末尾补 4 字节到 40。BaseB 的成员函数编译时只认识 BaseB 的布局:vptr 在偏移 0,a 在 8。要让它们能处理 Derive 里的那一份,传给它们的指针就必须指向偏移 16。所以把 Derive* 转成 BaseB*,编译器会生成一条加法:
_Z2upP6Derive: # BaseB* up(Derive* d) { return d; }
xorl %edx, %edx
leaq 16(%rdi), %rax # d + 16
testq %rdi, %rdi
cmove %rdx, %rax # d 是空指针就还返回空指针
ret
空指针转换后必须还是空指针,所以有一次判断;引用不可能为空,转引用就只有那条 leaq。多继承时,同一个对象转成不同基类的指针,数值不一样。 用 static_cast 或隐式转换,编译器知道该加多少;绕过它,比如先转成 void* 再转成 BaseB*,或者用 reinterpret_cast,就没人加这 16,拿到的是一个指错位置的指针。
3.3 this 调整:第二张表里放的是 thunk
BaseB* p = &d; p->FuncC(); 时,调用处只知道 p 是个 BaseB*,于是读 p 指向的那个 vptr,也就是偏移 16 的第二个 vptr,从它指的表里取 FuncC。可 Derive::FuncC 编译时假定 this 指向 Derive 的开头,读 c 用的是 this + 32,现在传进来的 this 却多了 16。所以第二张表里放的不是 Derive::FuncC 本身,而是一小段先把 this 改回来的代码,叫 thunk:
Vtable for Derive
Derive::_ZTV6Derive: 11 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI6Derive)
16 (int (*)(...))Derive::~Derive
24 (int (*)(...))Derive::~Derive
32 (int (*)(...))Derive::FuncB
40 (int (*)(...))Derive::FuncC
48 (int (*)(...))-16
56 (int (*)(...))(& _ZTI6Derive)
64 (int (*)(...))Derive::_ZThn16_N6DeriveD1Ev
72 (int (*)(...))Derive::_ZThn16_N6DeriveD0Ev
80 (int (*)(...))Derive::_ZThn16_N6Derive5FuncCEv
_ZThn16_N6Derive5FuncCEv: # non-virtual thunk to Derive::FuncC()
subq $16, %rdi # this 从 BaseB 子对象退回 Derive 开头
jmp .LTHUNK0 # 跳到 Derive::FuncC 的函数体
_ZThn16_N6DeriveD0Ev: # non-virtual thunk to Derive::~Derive(),D0 版
subq $16, %rdi
movl $40, %esi # 按整个 Derive 的大小
jmp _ZdlPvm # operator delete(void*, size_t)
Derive 的 vtable 其实是一个数组分成两段:前半段给 BaseA 子对象和 Derive 自己用,后半段给 BaseB 子对象用,两个 vptr 各指向一段的地址点。第二段的 offset_to_top 是 −16:从 BaseB 子对象往前 16 字节才是整个对象的开头,dynamic_cast<void*>(p) 和 typeid(*p) 就靠这一格找回完整的对象。
D0 的 thunk 说明了另一件事:delete 一个指向 BaseB 子对象的指针时,要释放的是从偏移 0 开始、40 字节的整块内存,不是从偏移 16 开始的 16 字节。thunk 先把指针减回去,再按 Derive 的大小调 operator delete,这就是 2.2 里析构函数要单独占表格的用处。经第二个基类调用虚函数,要多走一次 thunk 调整 this,第一个基类没有这一步。
3.4 虚继承:菱形里只留一份基类
VB 和 VC 都继承 BaseA,Diamond 再同时继承 VB 和 VC,普通继承下 Diamond 里会有两份 BaseA,d.a 不知道指哪个。虚继承让它们共用一份:
struct VB : virtual BaseA { int x; };
struct VC : virtual BaseA { int y; };
struct Diamond : VB, VC { int z; };
static_assert(sizeof(VB) == 32 && sizeof(Diamond) == 48);
代价是那一份 BaseA 的位置不再固定。它被放到对象末尾,在单独的 VB 对象里离开头 16 字节,在 Diamond 里的 VB 部分看来就是另一个距离,编译 VB 的成员函数时根本不知道将来会被嵌在哪种对象里。所以这个距离只能运行时查:Itanium ABI 把它存在 vtable 里 offset_to_top 再往前的一格(vbase_offset),VB 的表开头那一格就是 16。访问虚基类的成员要先读 vptr、再读这个偏移、加上,然后才读到成员,比普通成员多两次读。MSVC 的做法不同,它在对象里另放一个虚基类表指针(vbptr)指向单独的一张表,很多资料里的 vbptr、vbtable 说的是 MSVC。虚继承把"基类在哪"推迟到运行时查,换来菱形里只有一份基类。
4. 自己看:sizeof、-fdump-lang-class、pahole、gdb
4.1 static_assert:把布局写成编译期检查
前面的代码都用了 static_assert(sizeof(...) == ...)。布局要对外承诺的结构体(写进文件、发到网络、和 C 代码共享),把大小和关键成员的 offsetof 写成 static_assert,以后有人加了成员、换了编译选项改变了布局,编译直接失败,不会等到运行时才读出乱码。布局靠 static_assert 钉住,比靠注释说明可靠。offsetof 只保证对标准布局类型有效,有虚函数的类用它,GCC 会给一个 -Winvalid-offsetof 警告。
4.2 -fdump-lang-class 和 Clang 的对应选项
g++ -fdump-lang-class -c x.cpp 生成 x.cpp.001l.class(文件名里的编号随版本变),里面是每个类的大小、对齐、基类子对象的偏移和完整的 vtable,2.2、3.3 里贴的就是它。Clang 用 -Xclang -fdump-record-layouts 看对象布局,-Xclang -fdump-vtable-layouts 看 vtable。
4.3 pahole:在编出来的程序里找空洞
pahole 在 Linux 上的 dwarves 包里,读带 -g 编出来的程序里的调试信息,逐个成员列出偏移和大小,在填充的地方标出 hole 有几个字节,最后统计整个结构体占几条缓存行、空洞一共多少。它看的是真正编出来的程序,所以能一次检查整个项目里所有结构体,适合找"哪些结构体值得重排"。
4.4 gdb:ptype /o、info vtbl
不想装别的工具,gdb 也能看(同样要 -g)。ptype /o 把偏移、大小和空洞一起列出来:
(gdb) ptype /o Bad
/* offset | size */ type = struct Bad {
/* 0 | 1 */ char a;
/* XXX 7-byte hole */
/* 8 | 8 */ int64_t b;
/* 16 | 1 */ char c;
/* XXX 3-byte hole */
/* 20 | 4 */ int32_t d;
/* total size (bytes): 24 */
}
info vtbl 顺着对象里的每个 vptr 把表打出来,多继承时能直接看到第二张表里的 thunk;p d 会显示每个 vptr 指向 vtable 的哪个位置(下面省略了地址和没初始化的成员值,p d 那行换了行):
(gdb) info vtbl d
vtable for 'Derive' @ 0x... (subobject @ 0x...):
[0]: 0x... <Derive::~Derive()>
[1]: 0x... <Derive::~Derive()>
[2]: 0x... <Derive::FuncB()>
[3]: 0x... <Derive::FuncC()>
vtable for 'BaseB' @ 0x... (subobject @ 0x...):
[0]: 0x... <non-virtual thunk to Derive::~Derive()>
[1]: 0x... <non-virtual thunk to Derive::~Derive()>
[2]: 0x... <non-virtual thunk to Derive::FuncC()>
(gdb) p d
$1 = {<BaseA> = {_vptr.BaseA = 0x... <vtable for Derive+16>, a = ..., b = ...},
<BaseB> = {_vptr.BaseB = 0x... <vtable for Derive+64>, a = ..., b = ...}, c = ...}
+16 和 +64 正好对上 3.3 那张表:两个 vptr 分别指向前半段和后半段的地址点。sizeof 告诉你多大,-fdump-lang-class 告诉你编译器打算怎么摆,gdb 告诉你运行起来的对象里实际是什么,三样对上了,布局就没有猜的成分。