1. 环境和工具
- Ubuntu 18.04.4 LTS
- g++ 7.5.0
- objdump 静态反汇编
- gdb + peda插件 动态调试
2. 从一个程序说起
#include <iostream>
class AA
{
public:
virtual void print() {std::cout << "AA" <<std::endl;}
};
class BB
{
public:
virtual void print() {std::cout << "BB" <<std::endl;}
};
int main(int argc,char *argv[])
{
AA *p = (AA *)new BB();
p->print();
return 0;
}
输出结果:
BB
这段代码不是c++的标准写法,按照以往的认识BB和AA没有继承之间关系,应该没法出现多态的情况。但事实让我惊讶,这让我对c++多态的实现原理和虚函数表有了兴趣,最终决定逆向反汇编去看看其中的内存结构和虚函数调用流程
3. 无继承
3.1 虚函数指针的产生条件与初始化
- 类里面有虚函数
- 在构造函数中初始化
要验证很简单写两个类对照
案例1:
#include <iostream>
class AA
{
public:
void print() {std::cout << "AA" << std::endl;};
int a;
};
class BB
{
public:
virtual void print() {std::cout << "BB" << std::endl;};
int b;
};
int main(int argc,char *argv[])
{
AA *pa = new AA();
BB *pb = new BB();
pa->print();
pb->print();
return 0;
}
使用AA和BB做对照,下面是使用objdump反汇编出来的部分代码。为大家更好理解写一些原始代码注释
00000000000009ba <main>:
9ba: 55 push %rbp
9bb: 48 89 e5 mov %rsp,%rbp
9be: 53 push %rbx
9bf: 48 83 ec 28 sub $0x28,%rsp
9c3: 89 7d dc mov %edi,-0x24(%rbp)
9c6: 48 89 75 d0 mov %rsi,-0x30(%rbp)
9ca: bf 04 00 00 00 mov $0x4,%edi
9cf: e8 9c fe ff ff callq 870 <_Znwm@plt> //分配内存 返回到rax
9d4: c7 00 00 00 00 00 movl $0x0,(%rax) //初始化第一个成员,成员变量a
9da: 48 89 45 e0 mov %rax,-0x20(%rbp) //局部比例赋值,pa
AA *pa = (AA *)calloc(1,sizeof(AA));//类似于
9de: bf 10 00 00 00 mov $0x10,%edi
9e3: e8 88 fe ff ff callq 870 <_Znwm@plt> //分配内存
9e8: 48 89 c3 mov %rax,%rbx
9eb: 48 c7 03 00 00 00 00 movq $0x0,(%rbx) //初始化第一个成员,虚函数指针
9f2: c7 43 08 00 00 00 00 movl $0x0,0x8(%rbx) //初始化第二个成员,成员变量b
void *p = calloc(1,sizeof(BB));//类似于
9f9: 48 89 df mov %rbx,%rdi //rdi = p,this指针的由来
9fc: e8 fd 00 00 00 callq afe <_ZN2BBC1Ev> //构造函数
a01: 48 89 5d e8 mov %rbx,-0x18(%rbp) //局部比例赋值,pb
BB *pb = p;
.....
这是main函数的一些解析,再解析_ZN2BBC1Ev之前(BB的构造函数)先总结并了解一些东西
- 如果类的没有虚函数并且成员变量没有一开始初始化,并不会有构造函数(AA()不存在)
- 类成员函数的调用规则是,调用前rdi寄存器保存类的地址,在进入成员函数的时候用局部变量保存rdi的值,也就是传说中的this指针(调用约定64位下前6个参数由寄存器保存。rdi保存第一个参数,rsi保存第二个…)
下面来分析_ZN2BBC1Ev(BB的构造函数)
0000000000000afe <_ZN2BBC1Ev>:
afe: 55 push %rbp
aff: 48 89 e5 mov %rsp,%rbp
b02: 48 89 7d f8 mov %rdi,-0x8(%rbp) //在调用之前rdi保存的类地址
this = rdi;
b06: 48 8d 15 63 12 20 00 lea 0x201263(%rip),%rdx //传说中的虚表
b0d: 48 8b 45 f8 mov -0x8(%rbp),%rax //rax = this
b11: 48 89 10 mov %rdx,(%rax) //保存虚表
this->ptr = rdx //虚表指针保存虚表
b14: 90 nop
b15: 5d pop %rbp
b16: c3 retq
这个函数并不难,由于我们并没有直接对成员变量b赋值,所以构造函数中只对第一个成员(虚表指针)做了赋值
了解一下构造函数对成员变量赋值的模式
3.1.1 构造函数对成员变量的初始化模式
mov -0x8(%rbp),%rax
mov %rdx,xxx(%rax)
-0x8(%rbp) 表示this指针
xxx是偏移偏移量,第一个成员就是0 =》 mov %rdx,(%rax)
为了验证”lea 0x201263(%rip),%rdx“中保存的是虚表,我们来用gdb动态调试一下
运行到这里我们用x/20gz 查看rdx的值,然后我们使用disassemble查看
使用disassemble 查看反汇编代码。可以看到”lea 0x201263(%rip),%rdx“确实保存了虚表
总结:
- 构造函数中使用”lea 0xxxxx(%rip),%rdx“的方式拿到虚表
- 一个单独的类(无父类)虚表指针是成员变量的第一个成员
下面来通过下面的代码证实结论
案例2:
#include <iostream>
class AA
{
public:
virtual void print() {std::cout << "AA" << std::endl;};
};
void print_aaa()
{
printf("aaa\n");
}
int main(int argc,char *argv[])
{
AA *p = new AA();
//申请一块内存,用来作为函数地址表
unsigned long *pfunctions = (unsigned long *)calloc(1,8);
pfunctions[0] = (unsigned long)print_aaa;
//拿到第一个成员的地址 虚表指针
unsigned long *ptr = (unsigned long *)p;
//改变虚表指针的指向
*ptr = (unsigned long)pfunctions;
//调用函数print函数
p->print();
return 0;
}
程序输出:
aaa
这里我们改变了虚函数的指针,指向一块填写的内存然后调用print函数,结果成功的执行到了写入内存块的函数。(注意普通函数和成员函数的调用约定是不同的会导致参数的乱序,所以只使用了无参函数)
下面分析一下执行函数的汇编代码(有点长删减了一些代码)
0000000000000a5d <main>:
......
a72: e8 79 fe ff ff callq 8f0 <_Znwm@plt> //申请内存,内存地址保存在rax
a77: 48 89 c3 mov %rax,%rbx //rbx = rax
a7a: 48 c7 03 00 00 00 00 movq $0x0,(%rbx) //第一个成员清0 (虚函数指针)
a81: 48 89 df mov %rbx,%rdi //rdi保存内存地址(调用约定,函数内生成this指针)
a84: e8 ed 00 00 00 callq b76 <_ZN2AAC1Ev> //构造函数
a89: 48 89 5d d8 mov %rbx,-0x28(%rbp) //内存地址赋值给局部变量
AA *p = new AA();
......
调用流程
ac1: 48 8b 45 d8 mov -0x28(%rbp),%rax //rax拿到类地址 (在a89行保存了地址)
ac5: 48 8b 00 mov (%rax),%rax //拿第一个成员的值(虚函数表指针)
ac8: 48 8b 00 mov (%rax),%rax //拿虚函数表的第一个成员的值
acb: 48 8b 55 d8 mov -0x28(%rbp),%rdx //rdx拿到类地址
acf: 48 89 d7 mov %rdx,%rdi //rdi拿到类地址(调用约定)
ad2: ff d0 callq *%rax //执行函数
p->print();
......
调用流程的代码是固定的,也就是**“p->print();”生成的代码是固定的模式**
3.1.2 虚函数的调用模式
mov -0xxx(%rbp),%rax //rax拿到类地址
mov (%rax),%rax //拿第一个成员的值(虚函数表指针)
add $0x0,%rax //+偏移(如果是第一个虚函数则没有)
mov (%rax),%rax //拿虚函数表成员的值
mov -0xxx(%rbp),%rdx //rdx拿到类地址
mov %rdx,%rdi //rdi拿到类地址(调用约定)用于生成this指针
callq *%rax //执行函数
上面的代码案例总结:
- 虚函数指针的生成方法(有虚函数的存在),初始化在构造函数中
- this指针的生成和调用约定
- 一个单独的类(无父类)虚表指针是成员变量的第一个成员
- 虚函数的调用流程
有了这些基础我们来分析最开始的代码
#include <iostream>
class AA
{
public:
virtual void print() {std::cout << "AA" <<std::endl;}
};
class BB
{
public:
virtual void print() {std::cout << "BB" <<std::endl;}
};
int main(int argc,char *argv[])
{
AA *p = (AA *)new BB();
p->print();
return 0;
}
分析者个代码只要上面的1,4基础
首先是 “new BB()” 分配内存并执行构造函数,这里初始化了虚函数指针(明白虚函数表里存的是BB::print)
然后是 “p->print();” 虚函数的调用流程
mov -0xxx(%rbp),%rax //rax拿到类地址
mov (%rax),%rax //拿第一个成员的值(虚函数表指针)
mov (%rax),%rax //拿虚函数表的第一个成员的值
mov -0xxx(%rbp),%rdx //rcx拿到类地址
mov %rdx,%rdi //rdi拿到类地址(调用约定)
callq *%rax //执行函数
由于模板是固定的所以就是执行虚函数表的第一个函数(而这个虚函数表是在BB初始化的,所以是BB::print)
所以多态的实现最关键的地方在于构造函数初始化虚表指针
为了加深印象更好的理解这几个规则。在写一个案例
案例3:
#include <iostream>
class AA
{
public:
virtual void print() {std::cout << "AA" << std::endl;};
virtual void print_t() {};
};
void print_aaa()
{
printf("aaa\n");
}
void print_bbb()
{
printf("bbb\n");
}
int main(int argc,char *argv[])
{
//构建函数表
unsigned long *pfunctions = (unsigned long *)calloc(1,16);
pfunctions[0] = (unsigned long)print_aaa;
pfunctions[1] = (unsigned long)print_bbb;
AA *p = (AA *)&pfunctions;
p->print();
p->print_t();
return 0;
}
案例输出:
aaa
bbb
这里直接没有类,手动去构造一个函数表,让类AA的指针去执行两个虚函数。(借助虚函数的调用模式实现)
这个代码也不是c++的标准写法,但这个确实是理解虚函数调用模式的好案例
3.2 探究虚函数表
案例4:
#include <iostream>
class AA
{
public:
virtual void print() {std::cout << "AA" << std::endl;};
virtual void print_t() {std::cout << "AA_t" <<std::endl;};
};
int main(int argc,char *argv[])
{
AA *p = new AA();
p->print();
p->print_t();
return 0;
}
直接objdump -sd 程序名 找到AA的构造函数
0000000000000b0e <_ZN2AAC1Ev>:
b0e: 55 push %rbp
b0f: 48 89 e5 mov %rsp,%rbp
b12: 48 89 7d f8 mov %rdi,-0x8(%rbp)
b16: 48 8d 15 4b 12 20 00 lea 0x20124b(%rip),%rdx
b1d: 48 8b 45 f8 mov -0x8(%rbp),%rax
b21: 48 89 10 mov %rdx,(%rax)
b24: 90 nop
b25: 5d pop %rbp
b26: c3 retq
找到b16拿到虚函数指针。地址为rip + 0x20124b 也就是 b1d + 0x20124b = 0x201d68
搜索201d68(虚函数表地址)
找到201d68,可以看到保存的值。由于linux系统使用的是小端(高位高地址,低位低地址),这是的地址是从右往左递增,所以第一个虚函数地址是 0xa9e
搜索0xa9e地址
这就是AA::print函数**(由于c++要支持函数重载所以编译的符号会加上前后缀以区分同名函数**)
我们找到了虚函数表的位置位于“.data.rel.ro”段。所以可以确定我们的程序中没有填写虚函数表的过程,虚函数表是编译器生成,然后在构造函数中给虚函数指针初始化
4. 单继承
探究继承跟虚函数表的关系
案例5:
#include <iostream>
class AA
{
public:
AA(){
m_a1 = 1;
};
virtual void print() {std::cout << "AA" << std::endl;};
virtual void print_t() {std::cout << "AA_t" <<std::endl;};
int m_a1;
};
class BB : public AA
{
public:
BB(){
m_b1 = 11;
m_b2 = 12;
}
virtual void print() {std::cout << "BB" << std::endl;};
virtual void print_b() {std::cout << "BB_b" << std::endl;};
int m_b1;
int m_b2;
};
int main(int argc,char *argv[])
{
AA *p = new BB();
p->print();
p->print_t();
return 0;
}
在BB中重写一个虚函数,一个不重写,另外在写一个虚函数
之前测试过,虚函数指针在构造函数中初始化因此我们直接反汇编BB的构造
0000000000000b88 <_ZN2AAC1Ev>: //AA的构造函数
b88: 55 push %rbp
b89: 48 89 e5 mov %rsp,%rbp
b8c: 48 89 7d f8 mov %rdi,-0x8(%rbp) //this指针
b90: 48 8d 15 b9 11 20 00 lea 0x2011b9(%rip),%rdx //虚函数表
b97: 48 8b 45 f8 mov -0x8(%rbp),%rax
b9b: 48 89 10 mov %rdx,(%rax) //虚函数指针指向虚函数表
b9e: 48 8b 45 f8 mov -0x8(%rbp),%rax
ba2: c7 40 08 01 00 00 00 movl $0x1,0x8(%rax) //初始化m_a1
this->m_a1 = 1;
ba9: 90 nop
baa: 5d pop %rbp
bab: c3 retq
0000000000000c1c <_ZN2BBC1Ev>: //BB的构造函数
c1c: 55 push %rbp
c1d: 48 89 e5 mov %rsp,%rbp
c20: 48 83 ec 10 sub $0x10,%rsp
c24: 48 89 7d f8 mov %rdi,-0x8(%rbp) //this指针
c28: 48 8b 45 f8 mov -0x8(%rbp),%rax
c2c: 48 89 c7 mov %rax,%rdi //类地址传给AA的构造
c2f: e8 54 ff ff ff callq b88 <_ZN2AAC1Ev> //调用AA的构造
c34: 48 8d 15 ed 10 20 00 lea 0x2010ed(%rip),%rdx //虚函数表
c3b: 48 8b 45 f8 mov -0x8(%rbp),%rax
c3f: 48 89 10 mov %rdx,(%rax) //虚函数指针指向虚函数表
c42: 48 8b 45 f8 mov -0x8(%rbp),%rax
c46: c7 40 0c 0b 00 00 00 movl $0xb,0xc(%rax) //初始化m_b1
this->m_b1 = 11;
c4d: 48 8b 45 f8 mov -0x8(%rbp),%rax
c51: c7 40 10 0c 00 00 00 movl $0xc,0x10(%rax) //初始化m_b2
this->m_b2 = 12;
c58: 90 nop
c59: c9 leaveq
c5a: c3 retq
在调用BB的构造函数时,会在其构造函数中调用AA构造函数并初始化AA的内存部分,调用完之后在初始化BB的内存部分。可以看到两个构造函数都初始化了虚函数指针(c3f,b9b)最后以c3f为最后的值 (后写入),继承后虚函数指针也只有一份
内存图:
用之前找虚函数表的方法找虚函数表的位置
BB 201d28
AA 201d50
可以看到BB的三个虚函数地址: c5c、be4、c94 (不太清楚以什么终止,但是0地址不可能是函数)
可以看到AA的三个虚函数地址: bac、be4
从数据可以看出单继承虚函数是由本生所有虚函数 + 未重写父类的虚函数合集(这个结论其实初学c++就是知道的,这个案例主要是想看继承后的内存模型)
5. 多继承
探究继承跟虚函数表的关系
案例6:
#include <iostream>
class AA
{
public:
AA(){
m_a1 = 1;
m_a2 = 2;
}
virtual void print() {std::cout << "AA" << std::endl;}
virtual void print_t() {std::cout << "AA_t" << std::endl;}
int m_a1;
int m_a2;
};
class CC
{
public:
CC(){
m_c1 = 11;
m_c2 = 12;
}
virtual void print() {std::cout << "CC" << std::endl;}
virtual void print_t() {std::cout << "CC_t" << std::endl;}
int m_c1;
int m_c2;
};
class DD
{
public:
DD(){
m_d1 = 21;
m_d2 = 22;
}
virtual void print_d() {std::cout << "DD" << std::endl;}
virtual void print_dd() {std::cout << "DD_t" << std::endl;}
int m_d1;
int m_d2;
};
class BB : public AA , public CC , public DD
{
public:
BB(){
m_b1 = 31;
m_b2 = 32;
}
virtual void print() {std::cout << "BB" << std::endl;}
void print_t() {std::cout << "BB_t" << std::endl;}
int m_b1;
int m_b2;
};
int main(int argc,char *argv[])
{
BB *pb = new BB();
printf("pb = %p\n",pb);
AA *pa = (AA *)pb;
printf("pa = %p\n",pa);
CC *pc = (CC *)pb;
printf("pc = %p\n",pc);
DD *pd = (DD *)pb;
printf("pd = %p\n",pd);
return 0;
}
程序输出:
pb = 0x559d11172e70
pa = 0x559d11172e70
pc = 0x559d11172e80
pd = 0x559d11172e90
说实话一开始看到这个输出我觉得很纳闷,指针赋值地址居然变了。要弄清楚这个问题我们先来探究多继承的内存结构是怎么样的。
我们从BB的构造函数入手去探究其内存结构
0000000000000dda <_ZN2AAC1Ev>:
dda: 55 push %rbp
ddb: 48 89 e5 mov %rsp,%rbp
dde: 48 89 7d f8 mov %rdi,-0x8(%rbp)
de2: 48 8d 15 0f 0f 20 00 lea 0x200f0f(%rip),%rdx
de9: 48 8b 45 f8 mov -0x8(%rbp),%rax
ded: 48 89 10 mov %rdx,(%rax) //初始化虚函数指针,最后会在BB覆盖
df0: 48 8b 45 f8 mov -0x8(%rbp),%rax
df4: c7 40 08 01 00 00 00 movl $0x1,0x8(%rax)
this->m_a1 = 1;
dfb: 48 8b 45 f8 mov -0x8(%rbp),%rax
dff: c7 40 0c 02 00 00 00 movl $0x2,0xc(%rax)
this->m_a2 = 2;
e06: 90 nop
e07: 5d pop %rbp
e08: c3 retq
0000000000000e7a <_ZN2CCC1Ev>:
e7a: 55 push %rbp
e7b: 48 89 e5 mov %rsp,%rbp
e7e: 48 89 7d f8 mov %rdi,-0x8(%rbp) //this指针(注意是类地址偏移16)
e82: 48 8d 15 4f 0e 20 00 lea 0x200e4f(%rip),%rdx
e89: 48 8b 45 f8 mov -0x8(%rbp),%rax
e8d: 48 89 10 mov %rdx,(%rax) //初始化虚函数指针,最后会在BB覆盖
e90: 48 8b 45 f8 mov -0x8(%rbp),%rax
e94: c7 40 08 0b 00 00 00 movl $0xb,0x8(%rax)
this->m_c1 = 11;
e9b: 48 8b 45 f8 mov -0x8(%rbp),%rax
e9f: c7 40 0c 0c 00 00 00 movl $0xc,0xc(%rax)
this->m_c2 = 12;
ea6: 90 nop
ea7: 5d pop %rbp
ea8: c3 retq
0000000000000f1a <_ZN2DDC1Ev>:
f1a: 55 push %rbp
f1b: 48 89 e5 mov %rsp,%rbp
f1e: 48 89 7d f8 mov %rdi,-0x8(%rbp) //this指针(注意是类地址偏移32)
f22: 48 8d 15 8f 0d 20 00 lea 0x200d8f(%rip),%rdx
f29: 48 8b 45 f8 mov -0x8(%rbp),%rax
f2d: 48 89 10 mov %rdx,(%rax) //初始化虚函数指针,最后会在BB覆盖
f30: 48 8b 45 f8 mov -0x8(%rbp),%rax
f34: c7 40 08 15 00 00 00 movl $0x15,0x8(%rax)
this->m_d1 = 21;
f3b: 48 8b 45 f8 mov -0x8(%rbp),%rax
f3f: c7 40 0c 16 00 00 00 movl $0x16,0xc(%rax)
this->m_d2 = 22;
f46: 90 nop
f47: 5d pop %rbp
f48: c3 retq
0000000000000fba <_ZN2BBC1Ev>:
fba: 55 push %rbp
fbb: 48 89 e5 mov %rsp,%rbp
fbe: 48 83 ec 10 sub $0x10,%rsp
fc2: 48 89 7d f8 mov %rdi,-0x8(%rbp) //this指针
fc6: 48 8b 45 f8 mov -0x8(%rbp),%rax
fca: 48 89 c7 mov %rax,%rdi //调用约定,rdi保存类地址
fcd: e8 08 fe ff ff callq dda <_ZN2AAC1Ev> //AA的构造
fd2: 48 8b 45 f8 mov -0x8(%rbp),%rax
fd6: 48 83 c0 10 add $0x10,%rax //类的基地址偏移this + 16
fda: 48 89 c7 mov %rax,%rdi
fdd: e8 98 fe ff ff callq e7a <_ZN2CCC1Ev> //CC的构造
fe2: 48 8b 45 f8 mov -0x8(%rbp),%rax
fe6: 48 83 c0 20 add $0x20,%rax //类的基地址偏移32
fea: 48 89 c7 mov %rax,%rdi
fed: e8 28 ff ff ff callq f1a <_ZN2DDC1Ev> //DD的构造
ff2: 48 8d 15 5f 0c 20 00 lea 0x200c5f(%rip),%rdx //第一个虚函数表
ff9: 48 8b 45 f8 mov -0x8(%rbp),%rax
ffd: 48 89 10 mov %rdx,(%rax) //第一个虚函数表给类的起始地址
1000: 48 8d 15 71 0c 20 00 lea 0x200c71(%rip),%rdx //第二个虚函数表
1007: 48 8b 45 f8 mov -0x8(%rbp),%rax
100b: 48 89 50 10 mov %rdx,0x10(%rax) //第二个虚函数表给this+16
100f: 48 8d 15 82 0c 20 00 lea 0x200c82(%rip),%rdx //第三个虚函数表
1016: 48 8b 45 f8 mov -0x8(%rbp),%rax
101a: 48 89 50 20 mov %rdx,0x20(%rax) //第三个虚函数表给this+32
101e: 48 8b 45 f8 mov -0x8(%rbp),%rax
1022: c7 40 30 1f 00 00 00 movl $0x1f,0x30(%rax)
this->m_b1 = 31;
1029: 48 8b 45 f8 mov -0x8(%rbp),%rax
102d: c7 40 34 20 00 00 00 movl $0x20,0x34(%rax)
this->m_b2 = 32;
1034: 90 nop
1035: c9 leaveq
1036: c3 retq
在BB的构造函数中按照继承顺序先后调用AA,CC,DD的构造函数,最后初始化了三个虚函数表指针。
在调用构造函数前,rdi的值是用来生成this指针的。根据这一点和各个构造函数中初始化的值我们可以推断多继承的内存结构
内存图:
现在我们再看一下输出:
pb = 0x559d11172e70
pa = 0x559d11172e70
pc = 0x559d11172e80
pd = 0x559d11172e90
pa = pb + 0,pc = pb + 16,pd = pb + 32
- 我们可以看到指针都指向了属于他们类的内存部分,并且BB本身没有虚函数指针(其实是合一了,和单继承一样。可以理解为只有一个亲爹?)
- 属于BB的内存叠在最后
为什么指针要指向属于他们的内存部分?可以看看之前分析的虚函数调用流程
mov -0xxx(%rbp),%rax //rax拿到类地址
mov (%rax),%rax //拿第一个成员的值(虚函数表指针)
add $0x0,%rax //+偏移(如果是第一个虚函数则没有)
mov (%rax),%rax //拿虚函数表的第一个成员的值
由于调用流程的模式一直是这样,默认第一个成员是虚函数指针。
6. 总结
- 了解虚表指针产生条件和初始化
- 虚函数的调用模式
- 继承下的虚表指针和内存模型
下面是我逆向之后,改变对c++的一些认识误区
- 默认构造函数不存在(没有虚函数和对成员的初始化的条件下。)
- 继承下的构造函数调用先调用父类的构造,然后调用子类的构造(其实是子类构造先调用,只不过在子类构造函数中会先调用父类构造)