从Arm汇编看Android C++虚函数实现原理

前言

C++通过虚函数来实现多态,从而在运行时动态决定要调用的函数。那么虚函数的调用过程具体是怎样的呢?本文将基于Arm汇编,剖析C++虚函数的调用过程。本文涉及到的代码采用ndk-r10d进行编译。由于水平有限,理解不到位的地方,还请各位指正。

初窥vtable

其实虚函数的调用是通过vtable来实现的。编译时,编译器会为每一个申明有虚函数的类生成一个vtable,也就是说vtable和类一一对应。vtable中记录了所有虚函数的地址。在对象初始化时,对象会保存相应vtable的地址,虚函数就可以根据其在vtable中的偏移来进行调用。下面来看一个实际的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class BaseA
{
private:
int baseA_field;
public:
BaseA(int baseA_field);
~BaseA();
virtual void baseA_virtual_1()
{

printf(“\t[-] BaseA::baseA_virtual_1()\n”);
}
virtual void baseA_virtual_2()
{

printf(“\t[-] BaseA::baseA_virtual_2()\n”);
}
virtual void baseA_virtual_3()
{

printf(“\t[-] BaseA::baseA_virtual_3()\n”);
}
};
BaseA::BaseA(int baseA_field)
{
printf(“[+] BaseA constructor called\n”);
this->baseA_field = baseA_field;
}
BaseA::~BaseA()
{
printf(“[+] BaseA destructor called\n\n”);
}

int main(int argc, char *argv[])
{

BaseA *baseA = new BaseA(1);
baseA->baseA_virtual_2();
delete baseA;
return 0;
}

上述代码中,类BaseA有一个字段baseA_field和3个虚函数baseA_virtual_1()、baseA_virtual_2()、baseA_virtual_3()。在main()中初始化了一个BaseA的对象,并调用了其虚函数baseA_virtual_2()。

用IDA分析mian(),F5查看其伪代码,如下图所示:

第5行申请了8byte的空间,因为BaseA有一个整型字段,再加上vtable的地址所占的空间,共8byte。
第6行调用sub_5E4(),即BaseA的构造函数,对对象进行初始化。注意调用类的实例方法时,第一个参数始终是对象的地址。BaseA的构造函数伪代码如下:

由上可以知道,vtable的地址会首先赋给baseA对象的前4个字节,然后才执行构造函数的代码。其中vtable的结构如下:

执行完构造函数后,BaseA对象的内存如下所示:

接着看main()伪代码的第7行,*(*v0 + 4)就是虚函数baseA_virtual_2的首地址,因而(*(*v0 + 4))(v0);实际就是调用baseA_virtual_2()。

无重写虚函数、单继承

有一个类SubClass继承BaseA,SubClass有一个字段subClass_field和三个虚函数subClass_virtual_1、subClass_virtual_2、subClass_virtual_3,SubClass中没有重写BaseA中的虚函数(ps:不重写虚函数没有多大意义,这里仅仅为了理解虚函数的实现机制),此时代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class SubClass: public BaseA
{
private:
int subClass_field;
public:
SubClass(int subClass_field, int baseA_field);
~SubClass();
virtual void subClass_virtula_1()
{

printf(“\t[-] SubClass::subClass_virtula_1()\n”);
}
virtual void subClass_virtula_2()
{

printf(“\t[-] SubClass::subClass_virtula_2()\n”);
}
virtual void subClass_virtula_3()
{

printf(“\t[-] SubClass::subClass_virtula_3()\n”);
}
};
SubClass::SubClass(int subClass_field, int baseA_field) :
BaseA(baseA_field)
{
printf(“[+] SubClass constructor called\n”);
this->subClass_field = subClass_field;
}
SubClass::~SubClass()
{
printf(“[+] SubClass destructor called\n\n”);
}

int main(int argc, char *argv[])
{

SubClass *subClass = new SubClass(2, 1);
subClass->subClass_virtula_3();
delete subClass;
return 0;
}

用IDA分析main(),F5查看其伪代码,如下图所示:

第5行为SubClass对象申请了0xCbyte的内存空间,即vtable地址、baseA_field和subClass_field,各4byte。
第6行调用SubClass的构造函数,对SubClass对象进行初始化,sub_760()的伪代码如下图所示:

在SubClass的构造函数中,首先会执行父类BaseA的构造函数,然后将vtable的地址赋给SubClass对象的前4个字节,这样就可以覆盖掉执行BaseA构造函数时赋给SubClass对象BaseA类vtable的地址。其中SubClass类vtable的结构如下图所示:

执行完构造函数后,SubClass对象的内存如下所示:

从上可见,SubClass的vtable前面存的是父类BaseA虚函数的地址,后面存的是SubClass中申明的虚函数的地址。
回头看main()的伪代码,第7行,*(*v0+20)得到vtable第五项的值,即subClass_virtual_3,因而(*(*v0+20))(v0)就是在调用subClass_virtual_3()。

有重写虚函数、单继承

现在将SubClass的subClass_ virtual_2()去掉,重写baseA_virtual_2(),代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class SubClass: public BaseA
{
… …
virtual void baseA_virtual_2()
{

printf(“\t[-] SubClass::baseA_virtual_2()\n”);
}
… …
}
… …
int main(int argc, char *argv[])
{

BaseA *subClass = new SubClass(2, 1);
subClass->baseA_virtual_2();
delete subClass;
return 0;
}

用IDA分析main(),F5查看其伪代码,如下图所示:

第6行调用SubClass的构造函数进行对象初始化,sub_758()的伪代码如下图所示:

第9行将vtable的值赋给SubClass对象的前4个字节,vtable的结构如下图所示:

执行完SubClass的构造函数后,SubClass对象的内存如下所示:

从上可以知道,无重写时vtable[1] = BaseA::baseA_virtual_2;重写后vtable[1] = SubClass::baseA_virtual_2。因而,在vtable中,子类重写的虚函数会覆盖相应的父类中的虚函数地址。

回到main()伪代码,第7行中,*(*v0 + 4)获取vtable中第一项的值,由于v0是SubClass对象的指针,因而*(*v0 + 4)就是SubClass::baseA_virtual_2,即(*(*v0 + 4))(v0)调用的是子类SubClass中的baseA_virtual_2()。分析到这里,相信大家对多态的实现机制应该有了一定的认识。

无重写虚函数、多继承

C++支持多继承,下面分析多继承情况下虚函数的调用机制。首先分析多继承时,子类没有继承父类虚函数的情况。考虑有如下继承关系的类:

其中main()的代码如下:

1
2
3
4
5
6
7
8
9
int main(int argc, char *argv[])
{

SubClass *subClass = new SubClass(4, 3, 2, 1);
subClass->BaseA::virtual_1();
subClass->baseB_virtual_3();
subClass->subClass_virtual_1();
delete subClass;
return 0;
}

用IDA分析main(),F5查看其伪代码,如下图所示:

第5行为SubClass对象申请了0x1C byte的空间,其中4个字段占0x10 byte,那么多出的0xC byte存的是什么呢?(其实是3个vtable的指针)
第6行调用SubClass的构造函数对SubClass对象进行初始化,sub_A1C()的伪代码如下图所示:

从上可知,由于SubClass类有三个父类,因而SubClass对象中有3个字段分别存着3个指向虚函数列表的地址,subClass类对应的vtable如下图所示:

执行完SubClass类的构造函数后,SubClass对象的内存结构如下图所示:

从上可知,SubClass对象的内存结构是由 n * (vtable指针 + 父类字段) + 子类字段(n是父类的数目)构成的,父类是按照申明的顺序排列,SubClass中的虚函数地址存储在第一个父类vtable的后面。

回到main()的伪代码:

  • 第7行调用sub_5F8(),即调用subClass -> BaseA:: virtual_1(),虽然是调用虚函数,由于采用了作用域,编译器在编译阶段就明确知道要调用的函数,因而这里并没有通过vtable来进行调用。
  • 第8行中,v0[2]是SubClass对象中BaseB类的vtable指针,*(v0[2]+8)得到虚函数BaseB::baseB_virtual_3的地址,因而(*(v0[2] + 8))(v0 + 2);对应源码中的subClass->baseB_virtual_3();。注意这里第0个参数是v0+2,而不是v0,说明调用父类的虚函数时,第0个参数是SubClass对象中该父类所占内存空间的基址(其实调用父类其他函数也是如此)。
  • 通过对第8行的分析,第9行(*(*v0 + 12))(v0);就很好理解了,即调用subClass->subClass_virtual_1();

有重写虚函数、多继承

接下来分析多继承时,有重写虚函数的情况。考虑有如下继承关系的类:

在main()中将SubClass对象指针转为不同的父类指针进行虚函数调用,main()的代码如下:

1
2
3
4
5
6
7
8
9
10
int main(int argc, char *argv[])
{

SubClass *subClass = new SubClass(4,3,2,1);
((BaseA *)subClass)->virtual_1();
((BaseB *)subClass)->virtual_2();
((BaseC *)subClass)->virtual_1();
((BaseC *)subClass)->baseC_virtual_2();
delete subClass;
return 0;
}

用IDA分析main(),F5查看其伪代码,如下图所示:

第6行调用SubClass的构造函数对SubClass对象进行初始化,其构造函数sub_758()的伪代码如下图所示:

从上可知,编译器对SubClass类的构造函数进行了优化,将对父类构造函数的调用优化成了inline的形式。第16-18行是对vtable指针进行初始化,vtable的结构如下图所示:

执行完SubClass类的构造函数后,SubClass对象的内存结构如下图所示:

下面对SubClass对象内存布局进行详细分析(可以与多继承无重写虚函数时的内存结构进行对比):

  • 3个父类都定义了虚函数virtual_1,并且SubClass重写了virtual_1,因而3个父类对应vtable中virtual_1的值都改为了SubClass::virtual_1。这样,当SubClass对象指针转为任意父类的指针调用virtual_1时,调用的都是SubClass::virtual_1;
  • SubClass重写了BaseA和BaseB中的虚函数virtual_2,因而BaseA和BaseB对应vtable中virtual_2的值都改为了SubClass::virtual_2。
  • SubClass还重写了BaseC中的虚函数baseC_virtual_2,因而BaseC对应vtable中baseC_virtual_2的值改为了SubClass::baseC_virtual_2。这样当SubClass对象指针转为BaseC类型的指针调用baseC_virtual_2时,调用的就是SubClass::baseC_vritual_2。注意,由于重写的虚函数baseC_virtual_2不是第一个父类BaseA的虚函数,所以在SubClass类的虚函数列表(位于BaseA虚函数列表的后面)中,还需要按照申明顺序添加SubClass::baseC_virtual_2,这样,SubClass对象的指针就可以调用SubClass::baseC_virtual_2了。

接着分析main()的伪代码:

  • 第7行,**v0就是SubClass::virtual_1;(**v0)(v0);对应源码中的((BaseA *)subClass)->virtual_1();。
  • 第8行,*(*(v0 + 8) + 4)得到BaseB对应vtable中的SubClass::virtual_2 ,(*(*(v0 + 8) + 4))(v0 + 8);就是((BaseB *)subClass)->virtual_2();。
  • 第9行,**(v0 + 16)得到BaseC对应vtable中的SubClass::virtual_1,(**(v0 + 16))(v0 + 16);就是((BaseC *)subClass)->virtual_1();。
  • 第10行,*(*(v0 + 16) + 4) 得到BaseC对应vtable中的SubClass::baseC_virtual_2,(*(*(v0 + 16) + 4))(v0 + 16);就是((BaseC *)subClass)->baseC_virtual_2();

至此,分析完了C++虚函数实现的基本原理。对于多重继承的情况,和前面的类似。比如,还有一个类SubSubClass继承SubClass,并重写了虚函数virtual_1,那么将SubClass的vtable中所有的SubClass::virtual_1替换为SubSubClass::virtual_1,得到的结果就是SubSubClass的vtable。

总结

本文从Arm汇编的角度分析了Android中C++虚函数的实现机制。编译时,编译器会为每一个申明有虚函数的类生成一个vtable,当对象初始化时,会将vtable的地址赋给对象,这样虚函数就可以根据其在vtable中的偏移来进行调用。其中,一个对象所占的内存空间不仅与类的字段有关系,还与类的继承关系有关,对于单继承,一个对象会有一个vtable的指针;如果继承关系中存在多继承,那么该对象会为每一个多继承的父类保存一个vtable的指针。

参考