前言
一直以来,都对c++如何实现虚函数动态绑定一知半解。虽然知道虚函数的动态绑定通过虚指针和虚表实现,虚指针存在每个对象中,它记录虚表的地址。但是对于具体的细节,还是不明白。
最近看了《深入理解计算机系统》这本书,在《程序的机器级表示》这一章中,讲到AT&T风格的x86汇编。学习这一章时,我同时还参考了《Professional Assembly Language》一书,对汇编有了一些基本了解。结合《深入理解计算机系统》一书所附带的bomb
实验,对汇编有了基本的掌握。同时,在《深入理解计算机系统》一书的“链接”这一部分,讲到了ELF文件格式,以及可重定位目标文件和可执行目标文件,共享对象的格式。在学习链接这一部分时,发现《深入理解计算机系统》一书对于动态链接涉及内容不多,因此参考了《程序员的自我修养》一书(别被名字给骗了,这本书其实是讲Linux和Window的链接和装载的,顺便吐槽一下这本书,好多错误!)。
在了解这些的基础上,我萌发了看编译器编译结果的想法。本文直接从一段涉及动态绑定的c++代码编译出的汇编代码入手,逐行分析每条/连续若干条指令的意图。阅读本文需要具备以下两个条件:
- 对可重定位目标文件结构有基本了解,知道
.text
、.data
section是什么 - 了解基本的AT&T汇编语法,知道什么是label,汇编器伪指令,
mov
、push
、pop
等指令的用途
1. OS以及g++版本
- OS,64位ubuntu
uname -a
Linux ubuntu 5.4.0-37-generic #41-Ubuntu SMP Wed Jun 3 18:57:02 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
- g++
g++ --version
g++ (Ubuntu 9.3.0-10ubuntu2) 9.3.0 Copyright (C) 2019 Free Software Foundation, Inc. This is free software; see the source for copying conditions. There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
2.源码
class ClassA{ public: int field1; ClassA() : field1('a'){} ~ClassA(){} virtual int virtual_function1() = 0; virtual int virtual_function2() = 0; }; class ClassB : public ClassA{ public: int field2; int field3; ClassB():field2('b'){ field3 = 'c'; } int virtual_function1() override { return 1; } int virtual_function2() override { return 2; } }; int main(){ ClassA *pointer = new ClassB(); int result = pointer->virtual_function1(); return 0; }
- 共两个class,其中ClassA是纯抽象类
- 包含一个int成员,初始化为'a',即97
- 两个纯虚函数
virtual_function1
和virtual_function2
- ClassB公有方式继承自ClassA,且提供了虚函数的实现,
virtual_function1
和virtual_function2
分别返回常量1和2 - main函数中将一个ClassA类型的指针指向一个动态分配的ClassB对象,并通过该指针调用虚函数
- 数据成员的目的在于观察构造函数初始化列表和的函数体的执行过程
3.编译过程
g++ main.cpp -g -S -o main.s
- 上述命令产生main.s文件,也是本文的主要分析对象,main.s的全部源码附在文末
- 不使用编译器优化选项,保证汇编代码的可读性
- 分析过程 忽略与语义无关的伪指令 ,即那些以 .开头的指令,诸如.text,.global之类的,编译器还会在源码之中插入一些对齐、标准过程调用的伪指令,与语义无关,贴出来的关键部分代码都将忽略它们
4. 从main函数着手
.text .globl main .type main, @function main: ;从第4行开始,到35行`ret`指令结束,是main函数的函数体,下面逐行分析每一条指令的语义 .LFB11: endbr64 ; 64位模式下终止间接分支,不知道有啥用,汇编完是nop指令,貌似没有实际作用 pushq %rbp ; 7~8:变长栈帧的函数调用机制,与函数语义无关 movq %rsp, %rbp pushq %rbx ; 保存rbx寄存器,函数体中修改了该寄存器存 subq $24, %rsp ; 为局部变量pointer和result分配空间,pointer占8B,result占4B movl $24, %edi ; 以24为参数,调用一个动态链接的函数,这里我没有细究,猜测是分配动态存储的 call _Znwm@PLT ; 分配24B的动态存储,其基址保存在rax寄存器中 movq %rax, %rbx ;动态存储基址存在rbx寄存器中,也就是对象的基址 movq %rbx, %rdi ; 19~20: 以动态存储的基址为参数,调用了ClassB的构造函数,这里是我们下一步的分析目标,先记住 call _ZN6ClassBC1Ev movq %rbx, -24(%rbp) ; 对象基址存在局部变量pointer中 movq -24(%rbp), %rax ; rax现在保存对象基址 movq (%rax), %rax ;取对象的前8B放入rax,实际上就是ClassB的虚函数表基址,是在ClassB的构造函数中填入的 movq (%rax), %rdx ;虚表其实是一个指针数组,取虚表第0项,也就是一个虚函数的地址,放入rdx movq -24(%rbp), %rax ; 对象基址放入rax movq %rax, %rdi ;29~30:以对象基址为第一个参数,调用虚函数 call *%rdx movl %eax, -28(%rbp); 虚函数的返回地址,放入局部变量result movl $0, %eax ;eax置0,准备从main函数返回了,main中return 0;还记得吗? addq $24, %rsp ; 局部变量销毁 popq %rbx ;回复保存的寄存器rbx popq %rbp ;销毁栈帧 ret ;跳转到栈中记录的返回地址
重要的部分:
- 19~20行,调用ClassB的构造函数
- 22~30行,调用一个虚函数
5. ClassB Ctor.
主要到main的第20行,call _ZN6ClassBC1Ev
,但是并没有发现_ZN6ClassBC1Ev
这个label,但是发现了
.weak _ZN6ClassBC1Ev .set _ZN6ClassBC1Ev,_ZN6ClassBC2Ev
原来_ZN6ClassBC1Ev
只是_ZN6ClassBC2Ev
的别名,所以,看_ZN6ClassBC2Ev
就好了:
.section .text._ZN6ClassBC2Ev,"axG",@progbits,_ZN6ClassBC5Ev,comdat .align 2 .weak _ZN6ClassBC2Ev .type _ZN6ClassBC2Ev, @function _ZN6ClassBC2Ev: .LFB7: endbr64 pushq %rbp movq %rsp, %rbp ; 8~9: 变长栈帧的的函数调用惯用机制 subq $16, %rsp ;局部变量分配16B空间 movq %rdi, -8(%rbp) ; 构造函数只有一个参数——对象基址,存入局部变量,应该就是隐藏的this movq -8(%rbp), %rax ; movq %rax, %rdi ; 15~16:以对象基址为参数,调用ClassA Ctor. 也是下一步的分析目标 call _ZN6ClassAC2Ev leaq 16+_ZTV6ClassB(%rip), %rdx ;使用PC相对寻址,计算出虚表基址,放入rdx寄存器,下一步看一看_ZTV6ClassB处到底是什么? movq -8(%rbp), %rax ; 从局部变量中取出对象基址,放入rax movq %rdx, (%rax) ; 对象的前8B存放了虚表的基址 movq -8(%rbp), %rax ; 对象的12~15B赋值为98,也就是字符'b' movl $98, 12(%rax) movq -8(%rbp), %rax ; 对象的16~19B赋值为99,也就是字符'c' movl $99, 16(%rax) nop leave ; 销毁栈帧并返回 ret
- 12~13行,保存对象基址到局部变量
- 15~16行,以对象基址为参数,调用父类的构造函数
- 18~20行,初始化虚指针
- 22~26行,初始化成员变量
从上面的代码,我们可以得到几个结论:
- 结论1:构造函数先调用了父类(ClassA)的构造函数
- 结论2:虚表基址(虚指针)的填入在父类构造函数之后
- 结论3:初始化列表在构造函数体之前执行
- 结论4:返回之前没有修改eax寄存器,也就是说,Ctor.没有返回值!rax寄存器的值,完全依赖于函数体中如何使用它!
还产生了几个问题:
- 问题1:第10行分配局部变量,何时销毁的?
- 问题2:父类构造函数做了什么?
- 问题3:label
_ZTV6ClassB
处存放的是什么?
带着问题,首先看_ZN6ClassAC2Ev
——ClassA Ctor.
6. ClassA Ctor.
_ZN6ClassAC2Ev: .LFB1: endbr64 pushq %rbp movq %rsp, %rbp movq %rdi, -8(%rbp) ;保存对象基址到局部变量this leaq 16+_ZTV6ClassA(%rip), %rdx ; 这里又一次遇到使用PC相对寻址计算一个虚表基址,_ZTV6ClassA和之前的_ZTV6ClassB何其相似!所以,我猜测_ZTV6ClassA+16就是Class的虚表基址 movq -8(%rbp), %rax movq %rdx, (%rax) ;这里也是填充到对象的前8B movq -8(%rbp), %rax ;对象的8~11字节是成员变量,赋值97,不就是字符'a'吗?! movl $97, 8(%rax) nop popq %rbp ;构造函数结束! ret
结论:
- ClassA Ctor.也在对象的前8B填充了一个地址
- 又一次遇到类似于
_ZTV6ClassB
的label——_ZTV6ClassA
那么,_ZTV6ClassA
和_ZTV6ClassB
处到底放的是啥?
7. 虚表的内容
.weak _ZTV6ClassA .section .data.rel.ro._ZTV6ClassA,"awG",@progbits,_ZTV6ClassA,comdat .align 8 .type _ZTV6ClassA, @object .size _ZTV6ClassA, 32 _ZTV6ClassA: .quad 0 .quad _ZTI6ClassA .quad __cxa_pure_virtual .quad __cxa_pure_virtual
7~10行,是label _ZTV6ClassA
的内容,这其实就是一个数组,定义了4个quad
,也就是4个4 字长的变量。
再看_ZTV6ClassB
:
.weak _ZTV6ClassB .section .data.rel.ro.local._ZTV6ClassB,"awG",@progbits,_ZTV6ClassB,comdat .align 8 .type _ZTV6ClassB, @object .size _ZTV6ClassB, 32 _ZTV6ClassB: .quad 0 .quad _ZTI6ClassB .quad _ZN6ClassB17virtual_function1Ev .quad _ZN6ClassB17virtual_function2Ev
从7~10行,以同样的格式定义了一个4个4字长的变量数组。
- 定义数组元素为4字长的原因是,x86_64体系结构下,一个地址(虚拟地址)的长度是4字,也就是8B
- 所处的section都是.data.rel.ro.xxx,只读数据区下的pseudo section
- 数组第0项都是0,这是一个空指针!
- 最后两个元素,通过label名字,可以猜测:就是函数的基址
_ZTV6ClassA
的函数基址,通过label名字,可以猜测,是标准库预定义的某个函数,从名字上看,xxxx_pure_virtual
,就是纯虚函数的实现,但是这两个函数永远不会被调用!ClassA Ctor.在对象中填充的虚表基址,在ClassB Ctor.中覆盖了!- Ctor.中计算虚表基址是,使用的表达式是
16+_ZTV6ClassA(%rip)
和16+_ZTV6ClassB(%rip)
,都在label的基础上+16,正好跳过了数组的前两项。
问题:
- 数组第二项是啥?
- 对于3、4两项是虚函数实现的猜测是否正确?
8. 虚函数的实现
直接贴出_ZN6ClassB17virtual_function1Ev
和_ZN6ClassB17virtual_function2Ev
处的代码:
.section .text._ZN6ClassB17virtual_function1Ev,"axG",@progbits,_ZN6ClassB17virtual_function1Ev,comdat .align 2 .weak _ZN6ClassB17virtual_function1Ev .type _ZN6ClassB17virtual_function1Ev, @function _ZN6ClassB17virtual_function1Ev: .LFB9: endbr64 pushq %rbp movq %rsp, %rbp movq %rdi, -8(%rbp) ;第一个参数是对象基址,保存在局部变量中,也就是this movl $1, %eax ;函数返回常量1,正好就是ClassB对于virtual_function1的实现逻辑 popq %rbp ret
.section .text._ZN6ClassB17virtual_function2Ev,"axG",@progbits,_ZN6ClassB17virtual_function2Ev,comdat .align 2 .weak _ZN6ClassB17virtual_function2Ev .type _ZN6ClassB17virtual_function2Ev, @function _ZN6ClassB17virtual_function2Ev: .LFB10: endbr64 pushq %rbp movq %rsp, %rbp movq %rdi, -8(%rbp) movl $2, %eax ;具有相同的逻辑,返回2,正好就是ClassB对于virtual_function2的实现 popq %rbp ret
以上虚函数的实现版本,都处于.text节,也就是代码段
9. 类型信息
_ZTV6ClassB
和_ZTV6ClassA
数组的第2项,引用类似的label——_ZTI6ClassB
和_ZTI6ClassA
,那么这两个lable存放的是什么?
.weak _ZTI6ClassA .section .data.rel.ro._ZTI6ClassA,"awG",@progbits,_ZTI6ClassA,comdat .align 8 .type _ZTI6ClassA, @object .size _ZTI6ClassA, 16 _ZTI6ClassA: .quad _ZTVN10__cxxabiv117__class_type_infoE+16 .quad _ZTS6ClassA .weak _ZTS6ClassA .section .rodata._ZTS6ClassA,"aG",@progbits,_ZTS6ClassA,comdat .align 8 .type _ZTS6ClassA, @object .size _ZTS6ClassA, 8 _ZTS6ClassA: .string "6ClassA"
7~8行,又是数组,包含两个4字成员,即两个指针
- 通过第0项所引用的label——
_ZTVN10__cxxabiv117__class_type_infoE
,可以猜测又是某个对象的基址,但是该label在当前文件中并不存在,可以猜测是动态库中定义的,这里暂不深究。 - 第1项就定义在第15行,是一个字符串,猜测就是类型名称字符串,所处节在.rodata,即,只读数据区
- 可以猜测,这就是ClassA的type_info结构体
.weak _ZTI6ClassB .section .data.rel.ro._ZTI6ClassB,"awG",@progbits,_ZTI6ClassB,comdat .align 8 .type _ZTI6ClassB, @object .size _ZTI6ClassB, 24 _ZTI6ClassB: .quad _ZTVN10__cxxabiv120__si_class_type_infoE+16 .quad _ZTS6ClassB .quad _ZTI6ClassA .weak _ZTS6ClassB .section .rodata._ZTS6ClassB,"aG",@progbits,_ZTS6ClassB,comdat .align 8 .type _ZTS6ClassB, @object .size _ZTS6ClassB, 8 _ZTS6ClassB: .string "6ClassB"
7~9行,一个长度为3的四字数组,共三个指针
- 第0项,仍是一个动态库提供的label
- 第1项,还是类型名称字符串,定义在第16行,处于.rodata节
- 第2项,就是
_ZTI6ClassA
,即ClassA type info的地址
10. 结论
- 对象的前8B存放虚表基址,因为是64位代码,一个指针占8字节
- 有虚函数的类都有一个虚表,存放在数据区,并且是只读的
- 对于纯虚函数,标准库提供了默认的实现,但是永远不会被调用
- 对于虚函数的实现,函数体存放在代码段
- 虚函数表之前是类型信息的指针和一个空指针
- 虚表地址在构造函数执行期间填充到对象中,父类和子类都填充了,只不过父类填充的虚指针被子类构造函数覆盖了!
- 不开编译优化,产生了大量没有用的指令!
最后来张图说明:
附录 main.s主要内容(删除了调试信息和无用的伪指令)
.file "main.cpp" .text .Ltext0: .section .text._ZN6ClassAC2Ev,"axG",@progbits,_ZN6ClassAC5Ev,comdat .align 2 .weak _ZN6ClassAC2Ev .type _ZN6ClassAC2Ev, @function _ZN6ClassAC2Ev: .LFB1: endbr64 pushq %rbp movq %rsp, %rbp movq %rdi, -8(%rbp) leaq 16+_ZTV6ClassA(%rip), %rdx movq -8(%rbp), %rax movq %rdx, (%rax) movq -8(%rbp), %rax movl $97, 8(%rax) nop popq %rbp ret .LFE1: .size _ZN6ClassAC2Ev, .-_ZN6ClassAC2Ev .weak _ZN6ClassAC1Ev .set _ZN6ClassAC1Ev,_ZN6ClassAC2Ev .section .text._ZN6ClassBC2Ev,"axG",@progbits,_ZN6ClassBC5Ev,comdat .align 2 .weak _ZN6ClassBC2Ev .type _ZN6ClassBC2Ev, @function _ZN6ClassBC2Ev: .LFB7: endbr64 pushq %rbp movq %rsp, %rbp subq $16, %rsp movq %rdi, -8(%rbp) movq -8(%rbp), %rax movq %rax, %rdi call _ZN6ClassAC2Ev leaq 16+_ZTV6ClassB(%rip), %rdx movq -8(%rbp), %rax movq %rdx, (%rax) movq -8(%rbp), %rax movl $98, 12(%rax) movq -8(%rbp), %rax movl $99, 16(%rax) nop leave ret .LFE7: .size _ZN6ClassBC2Ev, .-_ZN6ClassBC2Ev .weak _ZN6ClassBC1Ev .set _ZN6ClassBC1Ev,_ZN6ClassBC2Ev .section .text._ZN6ClassB17virtual_function1Ev,"axG",@progbits,_ZN6ClassB17virtual_function1Ev,comdat .align 2 .weak _ZN6ClassB17virtual_function1Ev .type _ZN6ClassB17virtual_function1Ev, @function _ZN6ClassB17virtual_function1Ev: .LFB9: endbr64 pushq %rbp movq %rsp, %rbp movq %rdi, -8(%rbp) movl $1, %eax popq %rbp ret .LFE9: .size _ZN6ClassB17virtual_function1Ev, .-_ZN6ClassB17virtual_function1Ev .section .text._ZN6ClassB17virtual_function2Ev,"axG",@progbits,_ZN6ClassB17virtual_function2Ev,comdat .align 2 .weak _ZN6ClassB17virtual_function2Ev .type _ZN6ClassB17virtual_function2Ev, @function _ZN6ClassB17virtual_function2Ev: .LFB10: endbr64 pushq %rbp movq %rsp, %rbp movq %rdi, -8(%rbp) movl $2, %eax popq %rbp ret .LFE10: .size _ZN6ClassB17virtual_function2Ev, .-_ZN6ClassB17virtual_function2Ev .text .globl main .type main, @function main: .LFB11: endbr64 pushq %rbp movq %rsp, %rbp pushq %rbx subq $24, %rsp movl $24, %edi call _Znwm@PLT movq %rax, %rbx movq %rbx, %rdi call _ZN6ClassBC1Ev movq %rbx, -24(%rbp) movq -24(%rbp), %rax movq (%rax), %rax movq (%rax), %rdx movq -24(%rbp), %rax movq %rax, %rdi call *%rdx movl %eax, -28(%rbp) movl $0, %eax addq $24, %rsp popq %rbx popq %rbp ret .LFE11: .size main, .-main .weak _ZTV6ClassB .section .data.rel.ro.local._ZTV6ClassB,"awG",@progbits,_ZTV6ClassB,comdat .align 8 .type _ZTV6ClassB, @object .size _ZTV6ClassB, 32 _ZTV6ClassB: .quad 0 .quad _ZTI6ClassB .quad _ZN6ClassB17virtual_function1Ev .quad _ZN6ClassB17virtual_function2Ev .weak _ZTV6ClassA .section .data.rel.ro._ZTV6ClassA,"awG",@progbits,_ZTV6ClassA,comdat .align 8 .type _ZTV6ClassA, @object .size _ZTV6ClassA, 32 _ZTV6ClassA: .quad 0 .quad _ZTI6ClassA .quad __cxa_pure_virtual .quad __cxa_pure_virtual .weak _ZTI6ClassB .section .data.rel.ro._ZTI6ClassB,"awG",@progbits,_ZTI6ClassB,comdat .align 8 .type _ZTI6ClassB, @object .size _ZTI6ClassB, 24 _ZTI6ClassB: .quad _ZTVN10__cxxabiv120__si_class_type_infoE+16 .quad _ZTS6ClassB .quad _ZTI6ClassA .weak _ZTS6ClassB .section .rodata._ZTS6ClassB,"aG",@progbits,_ZTS6ClassB,comdat .align 8 .type _ZTS6ClassB, @object .size _ZTS6ClassB, 8 _ZTS6ClassB: .string "6ClassB" .weak _ZTI6ClassA .section .data.rel.ro._ZTI6ClassA,"awG",@progbits,_ZTI6ClassA,comdat .align 8 .type _ZTI6ClassA, @object .size _ZTI6ClassA, 16 _ZTI6ClassA: .quad _ZTVN10__cxxabiv117__class_type_infoE+16 .quad _ZTS6ClassA .weak _ZTS6ClassA .section .rodata._ZTS6ClassA,"aG",@progbits,_ZTS6ClassA,comdat .align 8 .type _ZTS6ClassA, @object .size _ZTS6ClassA, 8 _ZTS6ClassA: .string "6ClassA" .text