IV · C++ 与 Linux

对象布局:对齐、填充、vptr 和 vtable

一个 C++ 对象在内存里就是一段连续的字节。成员要落在自己的对齐上,编译器就在中间和末尾补填充,所以 sizeof 往往比成员加起来大,调一下顺序就能变小;空类占 1 字节,做基类或标了 [[no_unique_address]] 可以不占。有虚函数的类,每个对象开头多一个 vptr,指向这个类唯一的一张 vtable,表里除了函数地址还有 offset_to_top 和 typeinfo。多继承时对象里有两个 vptr,转成第二个基类的指针,数值会加 16,第二张表里放的是先调整 this 再跳过去的 thunk。最后讲怎么用 sizeof、-fdump-lang-class、pahole 和 gdb 亲眼看到这些。

本页目录

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 字节的缓存行上看差别更直观:

一条 64 字节的缓存行,能放几个0816243240485664Bada填充 7bcdBad[1]Bad[2] 的前 16 字节GoodbdacGood[1]Good[2]Good[3]跨行成员填充数组里后面的元素Bad 一条行放 2 个多,第 3 个跨到下一条行;Good 正好放 4 个,一个都不跨。

一百万个元素的数组,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,只有实际类型知道,所以"析构 + 释放"整个都要走虚调用,就得在表里单独占一格。

对象 v(栈上或堆上)+0vptr+8a+12bsizeof(V) = 16V 的 vtable(.data.rel.ro)−16offset_to_top = 0−8typeinfo 指针0~V(),完整析构 D1+8~V(),带 delete 的 D0+16f()typeinfo for Vtypeid、dynamic_cast 用代码段 .textV::~V(两个版本)V::fvptr 指向第一个函数那一格,往前两格是 offset_to_top 和 typeinfo,往后是虚函数,按声明顺序排。一个类只有一张表,所有 V 对象的 vptr 都指向同一处;对象里只多这 8 字节。

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) 就靠这一格找回完整的对象。

Derive*、BaseA* 指这里 ↓BaseB* 指这里(+16)↓Derive dvptrabvptrabc填充0816243240BaseA 子对象BaseB 子对象Derive 自己vtable for Derive 前半段后半段(给 BaseB 子对象用)offset_to_top = 0typeinfo for Derive~Derive(D1)~Derive(D0)FuncB → Derive::FuncBFuncC → Derive::FuncCoffset_to_top = −16typeinfo for Derivethunk:this −16,再跳 ~Derive(D1)thunk:this −16,再跳 ~Derive(D0)thunk:this −16,再跳 FuncC两个 vptr 指向同一个 vtable 的两段;经 BaseB* 调用的函数先把 this 减 16,再进 Derive 的实现。

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 告诉你运行起来的对象里实际是什么,三样对上了,布局就没有猜的成分。

对象摆好以后放在哪一段(栈、堆、.data),见进程地图;new 一个对象时内存从哪来,见 new 一次走到哪。