环境
在运行测试代码时,使用了如下环境:
linux使用的是ubuntu 18,在ubuntu上使用的是g++,版本如下:
root@learner:~# g++ --version
g++ (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0
https://godbolt.org/,这个网站可以方便的将C++代码编译成汇编,同时使用颜色标识某条C++语句对应的汇编代码。
在使用之前需要可以根据自己的习惯配置一下。
使用的gcc版本是10.2
-m32
:将C++代码编译成32位的汇编
-O0
:不要进行编译优化
Intel asm syntax
:编译成intel格式的汇编,默认是AT&T格式的汇编。
Demangle identifiers
:反命名倾轧,在汇编代码中使用C++中函数的名字,这样更便于阅读。
类对象内有指向虚函数表的指针
class A
{
public:
virtual void f(){}
virtual void g(){}
virtual void h(){}
private:
int i;
};
int main()
{
A a;
cout<<"sizeof(A) = "<<sizeof(A)<<endl;
cout<<"sizeof(a) = "<<sizeof(a)<<endl;
return 0;
}
sizeof(A) = 8
sizeof(a) = 8
有虚函数的类,其生成的类对象内部有一个指向虚函数表的指针。
虚函数表
带有虚函数的类,所有的类对象共用同一个虚函数表,即虚函数表只有一个。但每个类对象都有自己的指向虚函数表的指针。
父类和子类都有自己专属的虚函数表,子类不会使用父类的虚函数表,父类也不会使用子类的虚函数表。
创建时机及位置
虚函数表是在编译时创建的,且创建完毕后就不能再修改了。更确切的说,在构建程序(预处理、编译、汇编、链接
)的处理过程中,虚函数表是在编译时就创建了。
虚函数表在静态可执行文件的.data.rel.ro.local
段内,当可执行文件载入内存后,虚函数表位于.text
段。
#include <iostream>
using namespace std;
class A
{
public:
virtual void f(){}
virtual void g(){}
virtual void h(){}
private:
int i;
};
int main()
{
A a;
return 0;
}
将这段程序编译成汇编程序:g++ -S aa.cpp -o aa.s
,在aa.s
文件中,找到虚函数表如下图:
内容
类B继承自类A,但是类A和类B都有属于自己的的虚函数表。
类B对类A中的h()虚函数进行了覆写,所以在类B的虚函数表中,使用类B的B::h()函数替换了类A中的A::h()函数。
class A
{
public:
virtual void f(){}
virtual void g(){}
virtual void h(){}
private:
int i;
};
class B : public A
{
public:
virtual void h(){}
};
int main()
{
A a;
A *pa = new A;
B b;
B *pb = new B;
return 0;
}
父类对象、子类对象、虚函数表指针、虚函数表、虚函数之间的关系如下图:
使用虚函数表指针调用虚函数
class A
{
public:
virtual void f(){
cout<<"A::f()"<<endl;
}
virtual void g(){
cout<<"A::g()"<<endl;
}
virtual void h(){
cout<<"A::h()"<<endl;
}
private:
int i;
};
typedef void (*PF)(void);
int main()
{
A a;
cout<<"对象a的起始地址 :"<<&a<<endl; //A*
cout<<"vtable的起始地址 :"<<(int**)*(int*)(&a)<<endl;
cout<<"vtable中第0个函数的地址:"<<*((int**)*(int*)(&a)+0)<<endl;
cout<<"vtable中第1个函数的地址:"<<*((int**)*(int*)(&a)+1)<<endl;
cout<<"vtable中第2个函数的地址:"<<*((int**)*(int*)(&a)+2)<<endl;
PF pf;
pf = (PF)(*((int**)*(int*)(&a)+0));
pf(); //调用f()
pf = (PF)(*((int**)*(int*)(&a)+1));
pf(); //调用g()
pf = (PF)(*((int**)*(int*)(&a)+2));
pf(); //调用h()
return 0;
}
root@learner:~/vscode/tmp# g++ aa.cpp -m32 #编译成32位程序
root@learner:~/vscode/tmp# ./a.out
对象a的起始地址 :0xffe94c84
vtable的起始地址 :0x56648ea4
vtable中第0个函数的地址:0x56646a58
vtable中第1个函数的地址:0x56646a9e
vtable中第2个函数的地址:0x56646ae4
A::f()
A::g()
A::h()
通过对象a,获取到指向虚函数表的指针,再通过虚函数表获取虚函数的地址,然后调用虚函数。
因为虚函数中没有操纵类内部的数据成员,只是打印信息,所以不会出问题,若是访问了类数据成员,一般情况下都会崩溃,因为编译器在调用虚函数的时候,会隐式的传入this指针,而此处我们调用虚函数时,并没有传入this指针。
#include <iostream>
using namespace std;
class A
{
public:
virtual void f(){
i = 100; //对变量i赋值
}
private:
int i = 0;
};
typedef void (*PF)(void);
int main()
{
A a;
PF pf;
pf = (PF)(*((int**)*(int*)(&a)+0));
pf();
return 0;
}
root@learner:~/vscode/tmp# ./a.out
Segmentation fault
现在我们在虚函数中对成员变量赋值了,所以崩溃了。
下面调试看一下什么原因导致的崩溃:
root@learner:~/vscode/tmp# g++ aa.cpp -m32 -g
root@learner:~/vscode/tmp# gdb a.out
(gdb) b 24 #在24行打上断点
Breakpoint 1 at 0x6e4: file aa.cpp, line 24.
(gdb) r #开始调试
Starting program: /root/vscode/tmp/a.out
Breakpoint 1, main () at aa.cpp:24
24 pf = (PF)(*((int**)*(int*)(&a)+0));
(gdb) p &a #打印对象a地址
$1 = (A *) 0xffffd5d4
(gdb) c #继续调试
Continuing.
#执行到第9行时,收到一个段错误信号。
Program received signal SIGSEGV, Segmentation fault.
0x565557a0 in A::f (this=0x56555790 <A::f()>) at aa.cpp:9
9 i = 100;
我们可以看到a对象的地址是0xffffd5d4
,而A::f()函数内部的this指针是0x56555790
。我们在访问虚函数时,A::func()实际访问的内存是0x56555790
内存,但这段内存并不是我们拥有的合法内存,是一份非法的内存。对这段内存赋值,是非法的。
从崩溃的提示信息A::f (this=0x56555790 <A::f()>)
上,我们也可以知道,编译在调用A::f()时,应该把对象a的地址隐式的传入,这样在A::f()函数内才能正确的使用this指针。
我们眼中的A::func()是这样的:
void A::f()
{
i = 100;
}
而编译器眼中的A::func()是这样的:
void A::f(A * const this)
{
this->i = 100;
}
我们眼中的调用函数是这样的:
A a;
a.func();
而编译器眼中是这样的:
A a;
func(&a);
为类对象初始化虚函数表指针
有虚函数的类,编译器会在构造器中安插一些代码,以用于初始化类对象的虚函数指针。
虽然我们没有为类A、类B提供默认构造器,但是为了初始化类对象的虚函数表指针,编译器帮助提供了默认构造器,同时安插了代码用于初始化类对象的虚函数表指针。
定义对象b的时候,调用类B的构造器B(){},在类B的构造器中,先调用了类A的构造器。在类A的构造器中,将类A的虚函数表指针赋值给传入的类B对象。当类A的构造器调用结束后,此时类B的虚函数表指针指向的是类A的虚函数表。类A构造器执行结束后,回到类B的构造器继续执行,接下来类B将其虚函数表指针赋给类B对象,将之前指向类A虚函数表的指针给覆盖了,之后类B对象将一直指向类B的虚函数表。
A::A() [base object constructor]:
push ebp #保存B::B()函数的ebp
mov ebp, esp #生成A::A()函数的ebp
mov edx, OFFSET FLAT:vtable for A+8 #获取类A虚函数表的入口地址
mov eax, DWORD PTR [ebp+8] #获取类A虚函数表地址,并保存到eax中。
#B::B()调用A::A()时的参数依然在栈中,也就是对象bde地址。
#但此时eax中保存的就是对象b的地址,去内存中取对象b的地址,
#不如直接使用eax中的b对象地址
mov DWORD PTR [eax], edx #将类A虚函数表地址填入b对象的前4个字节。
nop
pop ebp
ret
B::B() [base object constructor]:
push ebp #保存main函数的ebp
mov ebp, esp #生成B::B()函数的ebp
sub esp, 8 #分配8字节的栈空间
#[ebp+8]是调用B::B()传入的参数,该参数是b对象的地址。
mov eax, DWORD PTR [ebp+8] #将对象b的地址保存到eax
sub esp, 12 #分配8字节的栈空间
push eax #将对象b的地址压栈,作为调用A::A()的参数
call A::A() [base object constructor] #将eip压栈,调用A::A()。
add esp, 16 #释放栈中的16字节空间
mov edx, OFFSET FLAT:vtable for B+8 #获取类B虚函数表的入口地址
mov eax, DWORD PTR [ebp+8] #获取类B虚函数表地址,并保存到eax中。
#将类B虚函数表地址填入b对象的前4个字节,
#之前指向类A虚函数表的指针被覆盖了。
mov DWORD PTR [eax], edx
nop
leave
ret
main:
lea ecx, [esp+4]
and esp, -16
push DWORD PTR [ecx-4]
push ebp
mov ebp, esp
push ecx
sub esp, 20
sub esp, 12
lea eax, [ebp-16] #获取b对象的地址
push eax #将b对象的地址压栈,作为调用类B构造器的参数。
call B::B() [complete object constructor] #将eip压栈,调用B::B()
add esp, 16
mov eax, 0
mov ecx, DWORD PTR [ebp-4]
leave
lea esp, [ecx-4]
ret
虚函数调用过程
在调用虚函数的时候,需要先通过虚函数表找到虚函数的入口地址,得到虚函数的地址后,再调用虚函数。
#[ebp-12]中保存着类B对象的地址,也就是pb指向的类B对象。
mov eax, DWORD PTR [ebp-12] #将类B对象的地址保存到eax中
mov eax, DWORD PTR [eax] #取出类B对象前4个字节的内容到eax
add eax, 4 #此时eax指向类B虚函数表,加4使其指向g()函数指针。
mov eax, DWORD PTR [eax] #将g()函数的地址,保存到eax中。
sub esp, 12
push DWORD PTR [ebp-12] #将pb压栈,作为调用g()的参数
call eax #调用g()
add esp, 16
动多态形成的实现机制
#[ebp-12]表示A* p变量,此时p指向的是类A对象。
mov eax, DWORD PTR [ebp-12] #将p保存到eax
#获取类对象指向虚函数表的指针,此时p指向的是类A对象,
#所以此时eax指向的是类A的虚函数表
mov eax, DWORD PTR [eax]
#从类A虚函数表中取出第一个虚函数地址,也就是A::f()函数的地址。
mov eax, DWORD PTR [eax]
sub esp, 12
push DWORD PTR [ebp-12] #将p压栈,作为调用A::f()的参数。
call eax #调用A::f()
add esp, 16
sub esp, 12
push 8
call operator new(unsigned int)
add esp, 16
mov ebx, eax
sub esp, 12
push ebx
call B::B() [complete object constructor]
add esp, 16
mov DWORD PTR [ebp-12], ebx
#[ebp-12]表示A* p变量,此时p指向的是类B对象。
mov eax, DWORD PTR [ebp-12] #将p保存到eax
#获取类对象指向虚函数表的指针,此时p指向的是类B对象,
#所以此时eax指向的是类B的虚函数表
mov eax, DWORD PTR [eax]
#从类B虚函数表中取出第一个虚函数地址,也就是B::f()函数的地址。
mov eax, DWORD PTR [eax]
sub esp, 12
push DWORD PTR [ebp-12] #将p压栈,作为调用B::f()的参数。
call eax #调用A::f()
add esp, 16
通过p->f()
调用虚函数f(),若p指向对象类A,则调用类A中的f()函数;若p指向对象类B,则调用类B中的f()函数。
这也就所谓的动多态,因为p->f()在编译后,没有运行之前,是不知道到底哪个f()函数会被调用,需要在运行时,等到通过p->f()调用时,由p指向的类对象类型决定。