胡言乱语的开头
事情还得从两天前的面试说起。没有自我介绍,面试官问我的第一个问题就是:C++ 中定义一个空的类,那么它的 sizeof 是多少?我内心:woc 我怎么知道。面试官继续发问:如果给这个类中加一个空的虚析构函数,那么它的 sizeof 是多少?连蒙带猜给了个答案。面试官再次发问:如果给这个类中加一个静态函数,那么它的 sizeof 是多少?我:QAQ。
虽然从我个人角度去看,这几个问题似乎没什么意义 给自己菜找借口,但是不让我运行一下我还真的不敢确定地说出答案是多少。因此这也暴漏了我在相关方面的短板。所以呢,还是要好好研究一下,顺便复习一下编译链接过程的相关知识。
sizeof
先简单回顾一下 sizeof。我们很早就知道 sizeof 是个运算符,用来查询一个对象或者一个类型的大小,单位是 byte。对于基本的内置类型,sizeof 的使用不存在什么问题。下面列出一些可能令人困惑的地方:
sizeof 是全能的吗?显然不是。我们不可以对 function,incomplete type,以及 bit field 使用 sizeof 。incomplete type 是指缺乏决定其大小的信息的类型(这不是循环解释 233),比如 void,比如 extern char a[]。前两者不能用是因为编译器不知道它们的大小是多少。是的,完成 sizeof 功能的是编译器,我们在后面的汇编代码中也可以看到这一点。显然对于前二者而言,不在链接后或运行时是无法知道其大小的。对于最后一条,纯粹是因为 sizeof 的单位没法表示。
sizeof(pointer) 是指针本身的大小;sizeof(reference) 是被引对象的大小;sizeof(array) 是数组的大小;sizeof(class) 是对应的对象数组中单个元素的大小,这其中包括对象的内容和可能的填充。
空对象的大小
按照我的歪理常理推断,一个空的对象中什么都没有,那大小自然是 0 了。可是,当我运行下面的代码后
1 2 3 4 5 6 7 8 9 10
| #include <iostream>
class A{};
int main() { A a; std::cout << sizeof(a) << std::endl; std::cout << sizeof(A) << std::endl; return 0; }
|
得到的运行结果是两个 1。看着结果我不禁陷入懵逼。这个结果可以得到两个结论:sizeof 对类和对象不做区分,认为二者都是 class type;空对象的大小为 1。前者没什么好说的,但是空对象里面这 1 byte 的空间到底放了什么东西呢?尤其是当我们对比运行以下代码后:
1 2 3 4 5 6 7 8 9 10 11
| #include <iostream>
class A{ char ch; };
int main() { A a; std::cout << sizeof(a) << std::endl; return 0; }
|
会得到与空对象相同的结果。进行分析:后者的 1 byte 必然存放的是 ch ,而前者的 1 byte 并没有存放任何有意义的东西(不然后者的大小就不会是 1 byte 了)。那么空对象的 1 byte 的作用就显而易见了:占位。
为什么要占位呢?
- 编译器靠什么来区分不同的对象(变量)呢?不是变量名,而是与之绑定的存储对象(变量)的地址。因此,地址就是一个对象的
unique identifier,对于空对象也是如此。要想区分不同的空对象,就要对每个空对象分配不同的地址。因此空对象的大小自然不能为 0。
- 让我们设想一个空对象数组。如果空对象的大小为
0,那么这个数组中指针的加法就会失效,这显然是不可以接受的。
加个虚函数
面试时候回答空对象的大小加一个指向虚表的指针的大小。而根据上述实验的结论,大小应该是 8(64 位机器和编译器)。使用如下代码进行实验:
1 2 3 4 5 6 7 8 9 10 11 12 13
| #include <iostream>
class AAA{ public: virtual ~AAA(){} };
int main() { AAA a; std::cout << sizeof(a) << std::endl; return 0; }
|
结果是 8。里面应该只有一个指向虚表的指针。但是为了验证这个猜想,我们需要把类的内存布局打印出来。怎么打印呢?我在 gcc 的 documentation 里找到了这样一个编译选项(gcc 版本 9.3.0,编译选项在 3.17 GCC Developer Options 中):
1 2 3
| -fdump-lang-class
Dump class hierarchy information. Virtual table information is emitted unless ’‘slim’’ is specified. This option is applicable to C++ only.
|
注:之前在 SO 中看到有人说 -fdump-class-hierarchy 这个编译选项,不过使用时发现编译器报错。后经查找,gcc 在 8.0 后就废除了这个编译选项(链接)。
编译后得到文件 test1.cpp.001l.class,查找到 AAA 相关内容如下:
1 2 3 4 5 6 7 8 9 10 11 12
| Vtable for AAA AAA::_ZTV3AAA: 4 entries 0 (int (*)(...))0 8 (int (*)(...))(& _ZTI3AAA) 16 (int (*)(...))AAA::~AAA 24 (int (*)(...))AAA::~AAA
Class AAA size=8 align=8 base size=8 base align=8 AAA (0x0x7f93df7a8d80) 0 nearly-empty vptr=((& AAA::_ZTV3AAA) + 16)
|
猜想无误。
加个静态函数
这个问题其实是在问,(静态)成员函数放在哪里。其实如果对 sizeof 熟悉的话,是可以直接推测出来:sizeof(class) 没问题,sizeof(function) 不行,所以 function 不在 class 里面。当然这个推测有点扯淡,我们还是直接实验一下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| #include <iostream>
class AAA{ public: static void funnnnnnn() { for (int i = 0; i < 10; i++) std::cout << i << std::endl; } };
int main() { AAA a; a.funnnnnnn(); return 0; }
|
使用命令 gcc -fdump-lang-class -c test2.cpp 编译,得到文件 test2.cpp.001l.class 的相关内容:
1 2 3 4
| Class AAA size=1 align=1 base size=0 base align=1 AAA (0x0x7f02ed6c8d80) 0 empty
|
和空类是一样的。那么静态函数放在哪里了呢?我们使用命令
1 2
| $ gcc -c test2.cpp $ objdump -s -d test2.o > out2
|
查看编译后的 elf 文件,找到相关信息如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| Disassembly of section .text._ZN3AAA9funnnnnnnEv:
0000000000000000 <_ZN3AAA9funnnnnnnEv>: 0: f3 0f 1e fa endbr64 4: 55 push %rbp 5: 48 89 e5 mov %rsp,%rbp 8: 48 83 ec 10 sub $0x10,%rsp c: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp) 13: 83 7d fc 09 cmpl $0x9,-0x4(%rbp) 17: 7f 2c jg 45 <_ZN3AAA9funnnnnnnEv+0x45> 19: 8b 45 fc mov -0x4(%rbp),%eax 1c: 89 c6 mov %eax,%esi 1e: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 25 <_ZN3AAA9funnnnnnnEv+0x25> 25: e8 00 00 00 00 callq 2a <_ZN3AAA9funnnnnnnEv+0x2a> 2a: 48 89 c2 mov %rax,%rdx 2d: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax # 34 <_ZN3AAA9funnnnnnnEv+0x34> 34: 48 89 c6 mov %rax,%rsi 37: 48 89 d7 mov %rdx,%rdi 3a: e8 00 00 00 00 callq 3f <_ZN3AAA9funnnnnnnEv+0x3f> 3f: 83 45 fc 01 addl $0x1,-0x4(%rbp) 43: eb ce jmp 13 <_ZN3AAA9funnnnnnnEv+0x13> 45: 90 nop 46: c9 leaveq 47: c3 retq
|
注意 C++ 代码中的名字(变量名、函数名、类名)都会被编译器修饰,可以通过 builtin 工具 c++filt 进行解析。和本文无关,这里不再详细展开。
我们可以看到这个静态成员函数也是规规矩矩呆在.text 段,只不过编译器进一步给它加了作用域限定 ._ZN3AAA9funnnnnnnEv。除此之外和正常的函数没什么不同。为了说明这一点,我们给源代码加了个同名函数
1 2 3 4
| void funnnnnnn() { for (int i = 0; i < 10; i++) std::cout << i << std::endl; }
|
再次进行编译。得到 void funnnnnnn() 的汇编内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| 0000000000000018 <_Z9funnnnnnnv>: 18: f3 0f 1e fa endbr64 1c: 55 push %rbp 1d: 48 89 e5 mov %rsp,%rbp 20: 48 83 ec 10 sub $0x10,%rsp 24: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp) 2b: 83 7d fc 09 cmpl $0x9,-0x4(%rbp) 2f: 7f 2c jg 5d <_Z9funnnnnnnv+0x45> 31: 8b 45 fc mov -0x4(%rbp),%eax 34: 89 c6 mov %eax,%esi 36: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 3d <_Z9funnnnnnnv+0x25> 3d: e8 00 00 00 00 callq 42 <_Z9funnnnnnnv+0x2a> 42: 48 89 c2 mov %rax,%rdx 45: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax # 4c <_Z9funnnnnnnv+0x34> 4c: 48 89 c6 mov %rax,%rsi 4f: 48 89 d7 mov %rdx,%rdi 52: e8 00 00 00 00 callq 57 <_Z9funnnnnnnv+0x3f> 57: 83 45 fc 01 addl $0x1,-0x4(%rbp) 5b: eb ce jmp 2b <_Z9funnnnnnnv+0x13> 5d: 90 nop 5e: c9 leaveq 5f: c3 retq
|
嘿!完全一样!
放在结尾
这一通实验搞下来,也恢复增加了不少关于汇编的知识,想想另开一篇吧。