目录
学过 C++ 的朋友肯定都知道:在 C++ 类外访问 private 和 protected 成员,编译器是会报错的。
如果你恰好对汇编也有所了解,那么你肯定也知道成员函数读写一个字段的底层实现,往往只是一条 mov 指令。
基于以上两点实事,我们可以得到这样一个初步的结论:这些规则是编译器实现的,访问修饰符影响的是编译器的行为,访问规则拦的是变量的名字,而不是真的在运行时阶段给对象里加了一把锁。
我们学了 C++ 后都会了解到 在 C++ 里 struct 与 class 的区别,只不过是默认访问权限的区别。
但是它们定义的对象到底是如何在内存中排布的呢?访问修饰符到底占不占用内存资源?在继承关系中对象的内存又是如何分布的?类成员的声明顺序是又是如何影响类对象的尺寸的?
本文将深入探究这些问题的真相。
struct 与 class:同一套内存机制,只差默认开关
C++ 标准里,struct 和 class 定义的是同一种类型,两者的差别只有默认访问权限不同。
用 struct 定义的类,成员和基类默认是 public。
用 class 定义的类,成员和基类默认是 private。
这个差别只决定编译器是否允许在类外通过名字访问成员,不影响对象在内存中的排布。
下面用相同的成员定义做对比来验证上述的结论。
第一个类用 struct 声明,第二个类用 class 声明并显式写上 public,以此将两个类的成员都定义为 public。
// struct 版本:成员默认 public
struct S { int a; int b; };
// class 版本:显式写成 public,与 struct 等价
class C {
public:
int a;
int b;
};
int use_s(void) {
S s;
s.a = 1;
s.b = 2;
return s.a + s.b;
}
int use_c(void) {
C c;
c.a = 1;
c.b = 2;
return c.a + c.b;
}
两个函数编译后的汇编指令如下。
# use_s:s.a 在 [rbp-8],s.b 在 [rbp-4]
mov DWORD PTR [rbp-8], 1
mov DWORD PTR [rbp-4], 2
mov edx, DWORD PTR [rbp-8]
mov eax, DWORD PTR [rbp-4]
add eax, edx
# use_c:c.a 在 [rbp-8],c.b 在 [rbp-4],与 use_s 完全相同
mov DWORD PTR [rbp-8], 1
mov DWORD PTR [rbp-4], 2
mov edx, DWORD PTR [rbp-8]
mov eax, DWORD PTR [rbp-4]
add eax, edx
s.a 和 c.a 都在栈帧的 [rbp-8],s.b 和 c.b 都在栈帧的 [rbp-4]。
每次访问都是对一个 4 字节内存单元的 mov 操作。
两个函数的指令完全相同,因为两个对象在内存中的布局完全相同。
基于以上事实,我们可以得到这样一个结论:struct 和 class 在机器层面使用的是同一套布局机制。两者的差别只在编写代码时的默认访问权限,而访问权限专属于编译期检查,访问修饰符并没有改变对象的内存布局。
图1:struct 与 class 成员访问指令完全一致,布局相同
成员变量按声明顺序排,对齐会留出填充
对象内存布局会遵守一些基本规则:
- 非静态数据成员按声明顺序分配,后声明的成员地址更高(访问权限对这条规则的影响,在”尾部填充”一节说明)。
- 每种类型都有对齐要求,比如 x86-64 上的 int 要求地址是 4 的倍数。为了满足对齐要求,编译器会在成员之间或对象末尾主动插入填充字节。这条规则对 struct 和 class 同样适用。
如果你理解了上面的内存布局规则,其实就很好理解为什么成员的声明顺序会影响对象的大小了。
现在通过下面的例子来展示成员的声明顺序是如何影响对象大小的。
// 把小成员夹在大成员中间,会产生填充
struct A { char c; int x; char d; };
// 把大成员放在最前,填充更少
struct B { int x; char c; char d; };
int set_A(void) {
A a;
a.c = 1;
a.x = 2;
a.d = 3;
return a.c + a.x + a.d;
}
set_A 中三个成员在栈上的位置如下:
# A 的对象占 12 字节,从 [rbp-12] 开始:
# a.c 在 [rbp-12](char,对象内偏移 0)
# [rbp-11] 到 [rbp-9] 是 3 字节填充,为 int x 对齐
# a.x 在 [rbp-8] (int,对象内偏移 4)
# a.d 在 [rbp-4] (char,对象内偏移 8)
# [rbp-3] 到 [rbp-1] 是 3 字节尾部填充
mov BYTE PTR [rbp-12], 1
mov DWORD PTR [rbp-8], 2
mov BYTE PTR [rbp-4], 3
...
a.c 占偏移 0 处的 1 个字节。
int 要求 4 字节对齐,所以编译器在 c 和 x 之间留出 3 个填充字节,x 从偏移 4 开始,d 在偏移 8 开始。
对象的大小必须是其对齐要求的整数倍,因此末尾需要再补 3 个字节,最终 sizeof(A) 为 12。
把成员顺序改成 B 的写法,填充的字节就少了。
x 在偏移 0 开始,c 在偏移 4 开始,d 在偏移 5 开始,末尾补再补 2 个字节,最终 sizeof(B) 为 8。
与实测结果一致:
sizeof(A)=12 c=0 x=4 d=8
sizeof(B)=8 x=0 c=4 d=5
成员的声明顺序决定各自的偏移位置,对齐填充的字节数最终决定了对象的大小。
在定义 class 或 struct 时把较大的成员放在前面,把尺寸相近的成员放在一起,是减少填充字节数的有效方法。
了解这个规则有什么好处呢?
显而易见:可以节省内存,节省 CPU 时间,节省磁盘空间,如果数据结构要在网络上传输,那就还可以节省网络带宽。
当然如果你不在乎这些,也可以随便写。
图2:成员顺序决定填充,A 比 B 多 4 字节填充
private/public/protected:编译期的门禁,机器里没有锁
通过前面几节的介绍,其实我们能看出来访问修饰符对内存的布局是没有任何影响的,它们约束的只是我们写代码的方式。
本节将结合代码对此结论进行验证。下面是一个账户类,数据成员 balance 是 private,对外只提供读写函数。
class Account {
int balance;
public:
int pub;
int get(void) { return balance; }
void set(int v) { balance = v; }
};
balance 是 private 成员,pub 是 public 成员。
两者在对象里都是普通的 int 类型成员变量。
下面是读取和写入 balance 的汇编。
# Account::get:读 private 成员 balance(偏移 0)
mov QWORD PTR [rbp-8], rdi # this 从 rdi 传入,先存到栈上
mov rax, QWORD PTR [rbp-8]
mov eax, DWORD PTR [rax] # 读 this->balance(偏移 0)
# Account::set:写 private 成员 balance(偏移 0)
mov QWORD PTR [rbp-8], rdi # this
mov DWORD PTR [rbp-12], esi # 参数 v
mov rax, QWORD PTR [rbp-8]
mov edx, DWORD PTR [rbp-12]
mov DWORD PTR [rax], edx # 写 this->balance(偏移 0)
get 用 mov eax, DWORD PTR [rax] 读取 balance。
set 用 mov DWORD PTR [rax], edx 写入 balance。
这两条指令和读写任何 public int 成员的指令没有区别,都是用 this 指针加偏移访问内存。
从汇编代码中可以看出 private 修饰符并没有给指令增加任何检查,也没有在对象里占用任何字节。
再看下面这段代码,代码中试图在类外直接写 balance。
// illegal.cpp
class Account {
int balance;
public:
int pub;
};
void f(void) {
Account a;
a.balance = 1;
}
GCC 在编译阶段拒绝这段代码:
illegal.cpp: In function 'void f()':
illegal.cpp:9:7: error: 'int Account::balance' is private within this context
9 | a.balance = 1;
| ^~~~~~~
illegal.cpp:2:9: note: declared private here
2 | int balance;
| ^~~~~~~
这个错误由编译器在检查名字能否访问时报出的。
程序在编译完成后,可执行文件里没有任何与访问权限有关的运行时检查。
既然检查只发生在编译期,那么原则上我们不通过名字、而是直接按地址写入,就能改动 private 成员的数据。
下面的代码就是按照这个思路实现的:把对象地址转成 char*,再加上 balance 的偏移 0,再转成 int* 并写入 777。
// 按偏移直接写入 balance 所在的内存
int bypass(Account* p) {
*reinterpret_cast<int*>(
reinterpret_cast<char*>(p) + 0) = 777;
// get 读到的就是刚写进去的 777
return p->get();
}
int main(void) {
Account acc;
acc.set(42);
std::printf("set(42) -> get()=%d\n", acc.get());
std::printf("bypass -> get()=%d\n", bypass(&acc));
std::printf("pub offset=%td\n", (char*)&acc.pub - (char*)&acc);
}
bypass 对应的汇编。
# bypass:把 777 写进 p 指向对象的偏移 0
mov QWORD PTR [rbp-8], rdi # p
mov rax, QWORD PTR [rbp-8]
mov DWORD PTR [rax], 777 # 写偏移 0
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call _ZN7Account3getEv # 调用 Account::get,从偏移 0 读回
mov DWORD PTR [rax], 777 和 set 里的 mov DWORD PTR [rax], edx 写的是同一块内存,都是对象的偏移 0。
运行结果如下。
set(42) -> get()=42
bypass -> get()=777
pub offset=4
至此可证类外不能访问 private 成员,这条规则由编译器实现并在编译期执行的。
关于这个例子,还需要说明两点:
-
按标准,这段代码的行为是未定义的。Account 同时有 private 和 public 数据成员,所以它不是标准布局类(standard-layout class)。对于非标准布局类,标准不保证对象的地址可以当作第一个成员的地址来使用。这段代码能得到 777,是因为 GCC 恰好把 balance 放在了偏移 0 的位置。只要给类加一个虚函数,balance 的偏移就会变成 8。本文用它只是为了说明原理,实际代码不要这样写。
-
private 不提供运行时安全。它拦不住故意绕过编译器的代码,也拦不住拿到对象地址的其他函数改写这块内存。C++ 的封装可以帮助程序员避免误用成员,但不会在内存中建立隔离屏障。需要运行时隔离的场景,比如进程之间或权限之间的隔离,要依靠操作系统等语言之外的机制。
protected 的机制与 private 相同。
派生类的成员函数可以访问基类的 protected 成员,类外的代码不能访问,这同样由编译器在编译期实现。
下面是 protected 的例子。
// illegal_prot.cpp
class Base {
protected:
int x;
};
void f(void) {
Base b;
b.x = 1;
}
illegal_prot.cpp: In function 'void f()':
illegal_prot.cpp:8:7: error: 'int Base::x' is protected within this context
8 | b.x = 1;
| ^
illegal_prot.cpp:3:9: note: declared protected here
3 | int x;
| ^
private 和 protected 访问控制都是在编译器实现的,细节可以参考上文对 private 的探索,这里就不再展开了,如有兴趣也可以参考上文 private 的内容自行实验。
至此可以确认三种访问修饰符都不会生成运行时检查,也不会占用对象的字节。
图3:private 只在编译期拦截名字,运行时内存没有保护
访问修饰符会改变布局吗:尾部填充的例子
前文只说明了访问修饰符不生成指令、不占字节,还不能说明它对布局完全没有影响。
当然凡事都不能太绝对,尤其是在 C++ 这门编程语言里更是如此。
下面的例子将说明访问修饰符时如何通过内存布局规则间接改变派生类的布局的。
两个基类的成员完全相同,唯一的区别是一个用 public,一个用 private。
两个派生类都在基类之后加一个 char d。
// 基类的成员全部 public
struct PubBase { int i; char c; };
struct PubDerived : PubBase { char d; };
// 基类的成员全部 private,其余完全相同
class PrivBase { int i; char c; public: int get(void) { return i + c; } };
struct PrivDerived : PrivBase { char d; };
void set_pub_d(PubDerived* p) { p->d = 1; }
void set_priv_d(PrivDerived* p) { p->d = 1; }
实测结果:
sizeof(PubBase)=8 sizeof(PubDerived)=12 d_off=8
sizeof(PrivBase)=8 sizeof(PrivDerived)=8 d_off=5
两个基类的大小都是 8,两个派生类的布局却不同。
PubDerived 的 d 在偏移 8,对象大小为 12。
PrivDerived 的 d 在偏移 5,对象大小为 8。
汇编也能看出这个差别:
# set_pub_d:d 在偏移 8
mov rax, QWORD PTR [rbp-8]
mov BYTE PTR [rax+8], 1
# set_priv_d:d 在偏移 5
mov rax, QWORD PTR [rbp-8]
mov BYTE PTR [rax+5], 1
这个差别来自 Itanium C++ ABI 的布局规则。
两个基类的内部布局相同:i 在偏移 0,c 在偏移 4,偏移 5 到 7 是 3 字节尾部填充。
区别在于派生类能不能使用这 3 个字节:
- ABI 在排布派生类时,基类只占用它的非虚大小(nvsize),下一个成员从这个位置开始。
- 如果基类是”布局意义上的 POD”(POD for the purpose of layout),它的 nvsize 等于 sizeof,也就是包含尾部填充。PubBase 是 POD,nvsize 为 8,所以 d 只能放在偏移 8。
- ABI 中 POD 的定义沿用 C++03 标准。C++03 规定,含有 private 或 protected 非静态数据成员的类不是 POD。PrivBase 因此不是 POD,它的 nvsize 只算到最后一个成员 c 的末尾,也就是 5。d 于是放进了 PrivBase 的尾部填充,落在偏移 5。
ABI 在规范中给出了不复用 POD 尾部填充的理由:在 CWG issue 43 解决之前,标准不允许把这部分空间用作他用,而且保留尾部填充有时还能让这个类型的复制更快。
除了尾部填充,访问修饰符还在标准层面影响布局相关的保证:
- 标准布局类要求所有非静态数据成员的访问权限相同。只有标准布局类才保证对象地址和第一个成员的地址可以互相转换,offsetof 也只对标准布局类有保证。
- C++11 到 C++20 的标准只保证访问权限相同的成员按声明顺序排列,访问权限不同的成员之间的顺序是未指定的。C++23 采纳提案 P1847R4 后,所有非静态数据成员都必须按声明顺序排列。
- Itanium ABI 不区分访问权限,所有非静态数据成员都按声明顺序分配,因此在 GCC 上看不到因访问权限不同而重排成员的情况。
因此准确的说法是:虽然访问修饰符不生成运行时检查,也不占用对象的字节,但是它决定了一个类是不是 POD、是不是标准布局类,进而影响派生类能否复用基类的尾部填充。
图4:只改访问修饰符,派生类成员 d 从偏移 8 变成偏移 5
单一继承:基类子对象在前,派生类成员在后
虽然访问修饰符不占字节,但对象怎么排仍由声明顺序和继承关系决定。
本节开始会探索继承关系对内存布局的影响。
首先探索单一继承时,派生类对象里包含一个完整的基类子对象。
在没有虚函数的情况下,基类子对象从派生类对象的偏移 0 开始,派生类自己的成员排在后面。
// 基类的 protected 成员
class Base {
protected:
int x;
public:
int getx(void) { return x; }
};
// 派生类自己的成员
class Derived : public Base {
int y;
public:
// x 来自基类,y 是派生类自己的成员
int sum(void) { return x + y; }
};
Derived::sum 同时读取基类的 protected 成员 x 和派生类自己的成员 y。
两次读取都是 this 加偏移:
# Derived::sum
mov QWORD PTR [rbp-8], rdi # this
mov rax, QWORD PTR [rbp-8]
mov edx, DWORD PTR [rax] # 读 this->x(偏移 0,基类子对象的首成员)
mov rax, QWORD PTR [rbp-8]
mov eax, DWORD PTR [rax+4] # 读 this->y(偏移 4,派生类自己的成员)
add eax, edx
x 在偏移 0,这是基类子对象的起点。
y 在偏移 4,紧跟在基类子对象后面,sizeof(Derived) 为 8。
Base 的大小是 4,没有尾部填充,因此这里不涉及上一节的尾部填充复用。
protected 在这里的作用是允许 Derived 的成员函数访问 x,这是编译期检查,生成的指令仍然是普通的内存读写。
图5:单继承下基类子对象占偏移 0,派生类成员随后排列
虚函数指针与虚函数表:对象持指针,表由编译器生成
上一节的单继承里,基类子对象占偏移 0,派生类成员紧随其后。
众所周知类一旦有虚函数,对象里就会多出一个虚表指针(vptr)。
vptr 指向编译器为这个类生成的虚函数表(vtable)。
虚函数表的主体是一个函数指针数组。
先看 vptr 对布局的影响。
下面两个类都只有一个 int a,区别是第二个类的 get 是虚函数。
class NoVirt { int a; public: int get(void) { return a; } };
class Virt { int a; public: virtual int get(void) { return a; } };
int call_virt(Virt* p) { return p->get(); }
sizeof(NoVirt)=4 sizeof(Virt)=16
NoVirt 只有 4 字节的 int。
Virt 在偏移 0 放了 8 字节的 vptr,a 被推到偏移 8,末尾补 4 字节,使大小成为 8 的倍数,合计 16 字节。
Itanium ABI 规定,如果一个含虚函数的类没有可以共用 vptr 的基类,就把 vptr 放在偏移 0。
通过指针调用虚函数时,汇编里可以看到 vptr 的使用过程。
# call_virt(Virt* p):通过 vptr 找到虚函数再调用
mov QWORD PTR [rbp-8], rdi # p
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax] # 取 p->_vptr.Virt(偏移 0)
mov rdx, QWORD PTR [rax] # 取虚函数表第 0 个槽里的函数地址
mov rax, QWORD PTR [rbp-8]
mov rdi, rax # this 作为第一个参数传入
call rdx # 调用虚函数
图6:vptr 占偏移 0,把数据成员向后推
下面用一个有多个虚函数的继承体系观察虚函数表的内容。
Rect 重写了 Shape 的两个虚函数。
class Shape {
public:
int id;
virtual int area() { return 0; }
virtual int peri() { return 0; }
virtual ~Shape() {}
};
class Rect : public Shape {
public:
int w, h;
int area() override { return w * h; }
int peri() override { return 2 * (w + h); }
};
int call_area(Shape* p) { return p->area(); }
int call_peri(Shape* p) { return p->peri(); }
实测对象大小、成员偏移和虚函数调用结果(w=3,h=4):
sizeof(Shape)=16 sizeof(Rect)=24
id_off=8 w_off=12 h_off=16
area=12 peri=14
Shape 包含 vptr(8 字节)和 int id(偏移 8),对齐后为 16 字节。Rect 的 w 在偏移 12,而不是 16。
因为Shape 有虚函数,所以它不是 POD,它的 nvsize 是 12,Rect 的成员可以使用 Shape 的尾部填充。
h 在偏移 16,Rect 的大小为 24。
Rect 没有自己的 vptr,它和 Shape 共用偏移 0 处的那一个。
Itanium ABI 把这种共用 vptr 的基类称为主基类(primary base)。
调用虚函数时,编译器从 vptr 出发,按固定偏移取出函数指针。
area 在第 0 个槽,peri 在第 1 个槽,相差 8 字节:
# call_area(Shape* p):取虚函数表第 0 个槽(area)
mov rax, QWORD PTR [rbp-8] # p
mov rax, QWORD PTR [rax] # 取 p->_vptr.Shape
mov rdx, QWORD PTR [rax] # 取第 0 个槽
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call rdx
# call_peri(Shape* p):取虚函数表第 1 个槽(peri)
mov rax, QWORD PTR [rbp-8] # p
mov rax, QWORD PTR [rax] # 取 p->_vptr.Shape
add rax, 8 # 跳过 8 字节,指向第 1 个槽
mov rdx, QWORD PTR [rax]
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call rdx
编译器为 Rect 生成的虚函数表如下。
表的开头有两项管理信息,函数指针排在它们之后。
_ZTV4Rect:
.quad 0 # offset-to-top:到完整对象起点的距离
.quad _ZTI4Rect # RTTI 指针,指向 Rect 的类型信息
.quad _ZN4Rect4areaEv # 槽 0:Rect::area
.quad _ZN4Rect4periEv # 槽 1:Rect::peri
.quad _ZN4RectD1Ev # 槽 2:析构函数(完整对象析构)
.quad _ZN4RectD0Ev # 槽 3:析构函数(析构后释放内存,供 delete 使用)
对象里的 vptr 不指向表的开头,而是指向跳过前两项(16 字节)之后的槽 0。
Rect 的构造函数可以证明这点:它先调用 Shape 的构造函数,再把 _ZTV4Rect+16 写进偏移 0:
# Rect::Rect()(基类子对象构造函数 _ZN4RectC2Ev)
...
call _ZN5ShapeC2Ev # 先构造 Shape 子对象
mov edx, OFFSET FLAT:_ZTV4Rect+16 # 虚函数表地址 + 16
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx # 写入 this->_vptr.Shape
...
表头两项的用途是:
- offset-to-top 记录从这个 vptr 所在位置到完整对象起点的距离,单继承下为 0;
- RTTI 指针指向类型信息对象,typeid 和 dynamic_cast 需要用到它(ABI 2.5 节、2.9 节)。
虚函数表放在哪个段,取决于是否生成位置无关代码(PIE)。
Compiler Explorer 上的 GCC 默认不生成 PIE,虚函数表放在只读段 .rodata:
.section .rodata._ZTV4Rect,"aG",@progbits,_ZTV4Rect,comdat
加上 -fPIE 后,虚函数表放在 .data.rel.ro.local:
.section .data.rel.ro.local._ZTV4Rect,"awG",@progbits,_ZTV4Rect,comdat
Ubuntu 等发行版的 GCC 默认生成 PIE,所以在这些系统上看到的通常是第二种。
标志中的 w 表示这个段可写。PIE 程序加载到的地址不固定,表中的函数地址要由动态链接器在加载时填写,因此这个段在加载时必须可写。
链接时启用 RELRO(链接器选项 -z relro,Ubuntu 等发行版默认启用)后,.data.rel.ro 类的段会被放进 RELRO 区域,动态链接器填完地址后,再把这块内存设为只读。
图7:对象的 vptr 指向虚函数表中的第一个虚函数槽
上面图6、图7对应刚才的布局与表结构。
再看一个边界情况:如果基类没有虚函数,而派生类新增了虚函数,基类子对象就不在偏移 0 了。
// 基类没有虚函数,派生类新增虚函数
struct Plain { int x; };
struct Poly : Plain {
int y;
virtual int get(void) { return x + y; }
};
Plain* to_plain(Poly* p) { return p; }
sizeof(Poly)=16 x_off=8 y_off=12
Plain 没有虚函数,不能与 Poly 共用 vptr,所以 Poly 需要自己的 vptr,并把它放在偏移 0。
Plain 子对象被排到偏移 8。于是 Poly* 转成 Plain* 时,指针的值要加 8:
# to_plain(Poly* p)
cmp QWORD PTR [rbp-8], 0 # p 是空指针吗
je .L6 # 是:结果仍为空指针
mov rax, QWORD PTR [rbp-8]
add rax, 8 # 否:p + 8 就是 Plain 子对象的地址
jmp .L8
.L6:
mov eax, 0
.L8:
...
标准要求空指针转换后仍是空指针,所以编译器先检查 p 是否为空,再加偏移。
虚函数带来的开销包括:
- 每个对象多一个 vptr,每个类有一张虚函数表和一个 RTTI 对象;
- 每次虚调用要多读两次内存,再做一次间接调用;
- 构造函数要负责写入 vptr。
这些结构中没有任何一项记录访问修饰符的信息。
多继承:两个基类子对象各带虚表指针,次基类靠 thunk 调整 this
前面单继承里对象通常只有一个 vptr,并且可以和主基类共用。
多继承时,派生类同时有两个或更多基类。
每个基类都以完整子对象的形式放进派生类对象,所以派生类对象里会有多段基类布局,每段都可能有自己的 vptr。
下面的 D 同时继承 B1 和 B2,并重写了两个基类的虚函数:
class B1 {
public:
int x;
virtual int f1() { return x; }
};
class B2 {
public:
int y;
virtual int f2() { return y; }
};
class D : public B1, public B2 {
public:
int z;
int f1() override { return x + z; }
int f2() override { return y + z; }
};
int call_f2(B2* p) { return p->f2(); }
B2* to_b2(D* p) { return p; }
实测结果(x=1,y=2,z=3):
sizeof(D)=32 x_off=8 y_off=24 z_off=28
to_b2 offset=16 call_f2=5
D 的布局如下:
- B1 子对象在偏移 0,包含 vptr(偏移 0)和 int x(偏移 8)。B1 是 D 的主基类,D 与它共用这个 vptr。
- B1 的 nvsize 是 12。B2 子对象要求 8 字节对齐,所以从偏移 16 开始,偏移 12 到 15 是填充。
- B2 子对象包含第二个 vptr(偏移 16)和 int y(偏移 24)。
- B2 有虚函数,不是 POD,nvsize 是 12,所以 D 的 z 可以使用 B2 的尾部填充,放在偏移 28。
- 整个对象的大小是 32 字节。
B2 子对象不在偏移 0,所以把 D* 转成 B2* 时,编译器要把指针加 16。
这个偏移在编译期就已确定,不需要查虚函数表。
# to_b2(D* p)
cmp QWORD PTR [rbp-8], 0 # p 是空指针吗
je .L12
mov rax, QWORD PTR [rbp-8]
add rax, 16 # p + 16 就是 B2 子对象的地址
jmp .L14
.L12:
mov eax, 0
.L14:
...
D 的虚函数表由两组组成,一组供偏移 0 的 vptr 使用,另一组供偏移 16 的 vptr 使用。
_ZTV1D:
.quad 0 # 第一组:offset-to-top = 0
.quad _ZTI1D # RTTI
.quad _ZN1D2f1Ev # 偏移 0 的 vptr 指向这里(+16):D::f1
.quad _ZN1D2f2Ev # D::f2
.quad -16 # 第二组:offset-to-top = -16
.quad _ZTI1D # RTTI
.quad _ZThn16_N1D2f2Ev # 偏移 16 的 vptr 指向这里(+48):D::f2 的 thunk
第二组的 offset-to-top 是 -16,表示从偏移 16 的 vptr 回到对象起点要减 16。
第二组的槽里放的是一个 thunk,而不是 D::f2 本身。
thunk 是编译器生成的一小段代码,它先调整参数,再跳转到真正的函数。
通过 B2* 调用 f2 时,指针指向 D 内部的 B2 子对象(偏移 16),而 D::f2 需要的 this 是整个 D 对象的地址。
thunk 负责把 this 减 16。
.set .LTHUNK0,_ZN1D2f2Ev # .LTHUNK0 是 D::f2 的本地别名
...
_ZThn16_N1D2f2Ev: # D::f2 的 thunk,名字里的 n16 表示 this 减 16
sub rdi, 16 # this 从 B2 子对象回退到 D 的起点
jmp .LTHUNK0 # 跳到 D::f2
call_f2 本身并不知道 thunk 的存在,它只是按 B2 的虚函数表布局取第 0 个槽。
# call_f2(B2* p)
mov rax, QWORD PTR [rbp-8] # p(指向 B2 子对象)
mov rax, QWORD PTR [rax] # 取 p->_vptr.B2
mov rdx, QWORD PTR [rax] # 取第 0 个槽,对 D 对象来说是 thunk
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call rdx # 进入 thunk,调整 this 后进入 D::f2
如果 D 没有重写 f2,第二组的槽里放的就是 B2::f2 本身(实测为 _ZN2B22f2Ev)。
这时传入的 this 正好指向 B2 子对象,不需要 thunk。
多继承与单继承的主要区别在于:非首个基类子对象不在偏移 0,指针转换时要加减偏移;通过它调用派生类重写的虚函数时,要经过 thunk 调整 this。
图8:多继承下两个基类子对象各带 vptr,次基类的虚函数经 thunk 调整 this
菱形继承:不用虚继承时,公共基类有两份
菱形继承是指派生类经过两条路径继承了同一个基类。
先看不使用虚继承的情况。
class A {
public:
int a;
virtual void foo() {}
};
class B : public A {
public:
int b;
};
class C : public A {
public:
int c;
};
class D : public B, public C {
public:
int d;
};
实测结果:
sizeof(A)=16 sizeof(B)=16 sizeof(C)=16 sizeof(D)=40
B::a_off=8 b_off=12 C::a_off=24 c_off=28 d_off=32
D 里有两份 A:一份在 B 子对象里(a 在偏移 8),另一份在 C 子对象里(a 在偏移 24)。
B 从偏移 0 开始,C 从偏移 16 开始,d 在偏移 32,对象大小为 40。
图9:不用虚继承时,菱形继承在 D 中留下两份 A
因为有两份 a,直接写 p->a 时编译器不知道该用哪一份。
用更短的写法触发二义性错误。
// ambiguous.cpp
class A { public: int a; virtual void foo() {} };
class B : public A { public: int b; };
class C : public A { public: int c; };
class D : public B, public C { public: int d; };
int get_a(D* p) {
return p->a;
}
ambiguous.cpp: In function 'int get_a(D*)':
ambiguous.cpp:7:15: error: request for member 'a' is ambiguous
7 | return p->a;
| ^
ambiguous.cpp:1:23: note: candidates are: 'int A::a'
1 | class A { public: int a; virtual void foo() {} };
| ^
ambiguous.cpp:1:23: note: 'int A::a'
这时必须写成 p->B::a 或 p->C::a,指明使用哪一份。
如果设计上只需要一份 A,就要使用虚继承。
虚继承:共享的虚基类放在末尾,通过虚表中的偏移定位
把 B 和 C 改成虚继承 A,D 里就只保留一份 A:
class A {
public:
int a;
virtual void foo() {}
};
class B : virtual public A {
public:
int b;
};
class C : virtual public A {
public:
int c;
};
class D : public B, public C {
public:
int d;
};
int access_a(B* p) { return p->a; }
实测结果(obj 是一个 D 对象,solo 是一个单独的 B 对象):
sizeof(A)=16 sizeof(B)=32 sizeof(C)=32 sizeof(D)=48
b_off=8 c_off=24 d_off=28 a_off=40
A in B: 16 ; A in D (via B*): 32
access_a via B* = 7 ; obj.a = 7
D 的布局如下:
- B 子对象在偏移 0,包含 B 的 vptr(偏移 0)和 int b(偏移 8)。
- C 子对象在偏移 16,包含 C 的 vptr(偏移 16)和 int c(偏移 24)。
- D 自己的 int d 在偏移 28,使用了 C 的尾部填充。
- 共享的 A 子对象放在最后,从偏移 32 开始,包含 A 的 vptr(偏移 32)和 int a(偏移 40)。
- 整个对象的大小是 48 字节。
Itanium ABI 规定,虚基类在所有非虚部分之后分配。
含有虚基类的类需要通过 vptr 找到虚函数表中的虚基类偏移。
ABI 只允许一个虚基类在它是 nearly empty 类(除 vptr 外没有数据成员)时充当主基类,A 有数据成员 a,不满足这个条件,因此 B 和 C 不能与 A 共用 vptr,只能各自再放一个。
问题在于:从一个 B* 出发,A 在哪里?输出的第三行给出了答案。
单独的 B 对象里,A 在偏移 16,D 对象里的 B 子对象,A 在偏移 32。
同一个 B* 指向不同类型的对象时,到 A 的距离不同,所以编译器不能把这个偏移写死在代码里,只能在运行时查找。
Itanium ABI 没有单独的”虚基类指针”。
虚基类的偏移(vbase offset)存放在虚函数表里,位于 vptr 指向位置的前面。
下面是 access_a 的汇编。
# access_a(B* p):先查出 A 的偏移,再读 a
mov rax, QWORD PTR [rbp-8] # p
mov rax, QWORD PTR [rax] # 取 p->_vptr.B
sub rax, 24 # vptr 前 24 字节处存放 A 的偏移
mov rax, QWORD PTR [rax] # 读出偏移:D 对象中为 32
mov rdx, rax
mov rax, QWORD PTR [rbp-8] # p
add rax, rdx # p + 偏移 = A 子对象的地址
mov eax, DWORD PTR [rax+8] # 读 A::a(A 的 vptr 占 8 字节,a 在其后)
D 的虚函数表由三组组成,分别供 B、C、A 三个 vptr 使用。
_ZTV1D:
.quad 32 # [0] B 组:vbase offset,B 到 A 的距离为 32
.quad 0 # [1] offset-to-top = 0
.quad _ZTI1D # [2] RTTI;B 的 vptr 指向下一项(_ZTV1D+24)
.quad 16 # [3] C 组:vbase offset,C 到 A 的距离为 16
.quad -16 # [4] offset-to-top = -16
.quad _ZTI1D # [5] RTTI;C 的 vptr 指向下一项(_ZTV1D+48)
.quad 0 # [6] A 组:vcall offset,没有类重写 foo,所以为 0
.quad -32 # [7] offset-to-top = -32
.quad _ZTI1D # [8] RTTI;A 的 vptr 指向下一项(_ZTV1D+72)
.quad _ZN1A3fooEv # [9] A::foo
B 的 vptr 指向 _ZTV1D+24,往前 24 字节正是 vbase offset,值为 32。
C 的 vptr 指向 _ZTV1D+48,往前 24 字节是 C 的 vbase offset,值为 16,因为 C 子对象在偏移 16,到偏移 32 的 A 相差 16。
vcall offset 用于在虚基类中调整 this,当派生类重写虚基类的虚函数时会用到它,这个例子里没有重写,因此为 0。
单独的 B 对象使用 B 自己的虚函数表 _ZTV1B,其中第一项就是 vbase offset,为 16,与前面实测的”A in B: 16”一致:
_ZTV1B:
.quad 16 # vbase offset:单独的 B 对象中,A 在偏移 16
.quad 0
.quad _ZTI1B
...
虚继承还让构造过程变复杂。
构造 D 时,B 的构造函数在运行,而此时 A 的位置要按 D 的布局来算,不能用 _ZTV1B。
GCC 为此生成了构造期虚表(construction vtable),并用 VTT(virtual table table)把这些表的地址传给基类的构造函数。
VTT 的地址作为第二个参数,通过 rsi 传入。
D 的构造函数如下。
# D::D()(完整对象构造函数 _ZN1DC1Ev)
...
add rax, 32
mov rdi, rax
call _ZN1AC2Ev # 先构造偏移 32 处的虚基类 A
mov rax, QWORD PTR [rbp-8]
mov edx, OFFSET FLAT:_ZTT1D+8 # 把 VTT 中属于 B 的部分传给 B 的构造函数
mov rsi, rdx
mov rdi, rax
call _ZN1BC2Ev # 构造偏移 0 处的 B
mov rax, QWORD PTR [rbp-8]
add rax, 16
mov edx, OFFSET FLAT:_ZTT1D+24 # 把 VTT 中属于 C 的部分传给 C 的构造函数
mov rsi, rdx
mov rdi, rax
call _ZN1CC2Ev # 构造偏移 16 处的 C
mov edx, OFFSET FLAT:_ZTV1D+24 # 最后写入 D 的三个 vptr
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx # B 的 vptr
mov rax, QWORD PTR [rbp-8]
add rax, 32
mov edx, OFFSET FLAT:_ZTV1D+72
mov QWORD PTR [rax], rdx # A 的 vptr
mov edx, OFFSET FLAT:_ZTV1D+48
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax+16], rdx # C 的 vptr
...
VTT 中属于 B 的构造期虚表是 _ZTC1D0_1B,它的第一项 vbase offset 是 32,而不是 _ZTV1B 中的 16:
_ZTC1D0_1B:
.quad 32 # 在 D 中构造 B 时,A 在 B 之后 32 字节处
.quad 0
.quad _ZTI1B
...
虚继承用一次运行时查表换来了只有一份 A 的结果,避免了数据重复和二义性。
代价是:B 和 C 各多一个 vptr,访问虚基类成员要多读两次内存,构造对象时需要 VTT 和构造期虚表。
图10:虚继承把共享的 A 合并为一份放在末尾,B、C 通过虚表中的 vbase offset 找到它
总结
把前文串起来,关于 C++ 对象的内存布局和访问控制,可以归纳出五点:
-
struct 和 class 使用同一套布局机制,只是默认访问权限不同。两者的成员访问指令完全相同,对象布局完全相同。
-
非静态数据成员按声明顺序排列,对齐填充位于成员之间和对象末尾,决定了对象的大小。把较大的成员放在前面,可以减少填充。
-
private、public、protected 不生成任何运行时检查,也不占用对象的字节。无论成员用哪种修饰符,读写它都是 this 加偏移的普通 load 和 store。类外不能访问,是因为编译器在编译期拒绝了这种写法;运行时,任何能算出地址的代码都能读写这块内存。但访问修饰符会影响一个类是不是 POD、是不是标准布局类。在 Itanium ABI 下,这会决定派生类能否复用基类的尾部填充:只把基类成员从 public 改成 private,派生类成员的偏移就从 8 变成了 5。
-
含虚函数的类在对象里多一个 vptr,vptr 指向虚函数表中的第一个虚函数槽。虚函数表由编译器生成,表头是 offset-to-top 和 RTTI 指针,后面是函数指针。非 PIE 程序中,虚函数表放在 .rodata;PIE 程序中,虚函数表放在 .data.rel.ro 类的段里,启用 RELRO 时,加载完成后变为只读。
-
单继承时,基类子对象通常在偏移 0,派生类与基类共用一个 vptr;如果基类没有虚函数而派生类有,基类子对象会被排到 vptr 之后。多继承时,非首个基类子对象不在偏移 0,指针转换要加减偏移,调用派生类重写的虚函数要经过 thunk 调整 this。菱形继承不用虚继承时,公共基类有两份;使用虚继承后,公共基类只保留一份,放在对象末尾,访问它时要从虚函数表中读出 vbase offset。
参考资料
[1] ISO/IEC 14882:2023(C++ 标准),[class.pre] 与 [class.access]:用 struct 和 class 定义的都是类,区别只在成员和基类的默认访问权限;访问控制作用于名字。
[2] ISO/IEC 14882:2023,[class.prop]:标准布局类要求所有非静态数据成员具有相同的访问控制;[basic.compound]:标准布局类对象与其第一个非静态数据成员可以指针互转。
[3] ISO/IEC 14882:2020(N4861),[class.mem]/19:访问控制相同的非静态数据成员按声明顺序分配,访问控制不同的成员之间顺序未指定。ISO/IEC 14882:2023,[expr.rel]/4.2:后声明的非静态数据成员地址更高。WG21 P1847R4,Make declaration order layout mandated。
[4] ISO/IEC 14882:2023,[basic.align] 与 [expr.sizeof]:对齐要求与对象大小。
[5] ISO/IEC 14882:2023,[class.mi]、[class.virtual] 与 [class.member.lookup]:多继承、虚函数、虚基类,以及成员名字查找的二义性。
[6] Itanium C++ ABI(https://itanium-cxx-abi.github.io/cxx-abi/abi.html):1.1 节术语表中的 “POD for the purpose of layout”、“thunk”、“primary base class”、“nearly empty class”;2.2 节,POD 类型的 dsize、nvsize 等于其大小;2.4 节,非 POD 类的布局算法(vptr 位置、主基类、nvsize、虚基类最后分配);2.5 节,虚函数表的组成与顺序(vcall offset、vbase offset、offset-to-top、RTTI、函数指针);2.6 节,VTT 与构造期虚表;3.1.2 节,this 作为第一个参数传入,基类子对象构造函数额外接收 VTT 参数。
[7] System V Application Binary Interface,AMD64 Architecture Processor Supplement(x86-64 psABI):LP64 下 int 为 4 字节、指针为 8 字节,基本类型的对齐要求;第一个和第二个整数或指针参数分别通过 rdi 和 rsi 传递。
[8] GCC 在线文档(Option Summary):-masm=intel、-fverbose-asm、-fno-stack-protector、-fPIE 等选项的含义。
[9] Compiler Explorer(https://godbolt.org):本文所用的 x86-64 gcc 13.3 编译与执行环境。