作者:MindStorm 看雪学院
1、前言
C++ 通过虚函数来实现多态, 从而在运行时动态决定要调用的函数。 那么虚函数的调用过程具体是怎样的呢? 本文将基于 Arm 汇编, 剖析 C++虚函数的调用过程。 本文涉及到的代码采用 ndk-r10d 进行编译。 由于水平有限, 理解不到位的地方,还请各位指正。
2、初窥 vtable
其实虚函数的调用是通过 vtable 来实现的。编译时, 编译器会为每一个申明有虚函数的类生成一个 vtable, 也就是说 vtable 和类一一对应。 vtable 中记录了所有虚函数的地址。 在对象初始化时, 对象会保存相应 vtable 的地址, 虚函数就可以根据其在 vtable 中的偏移来进行调用。 下面来看一个实际的例子:
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()。
3、无重写虚函数、 单继承
有一个类 SubClass 继承 BaseA, SubClass 有一个字段 subClass_field 和三个虚函数 subClass_virtual_1、 subClass_ virtual_2、 subClass_ virtual_3, SubClass 中没有重写 BaseA 中的虚函数( ps: 不重写虚函数没有多大意义,这里仅仅为了理解虚函数的实现机制), 此时代码如下:
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 对象申请了 0xC byte 的内存空间, 即 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()。
4、有重写虚函数、 单继承
现在将 SubClass 的 subClass_ virtual_2()去掉, 重写 baseA_virtual_2(), 代码
如下:
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()。
分析到这里,相信大家对多态的实现机制应该有了一定的认识。
5、无重写虚函数、 多继承
C++支持多继承, 下面分析多继承情况下虚函数的调用机制。 首先分析多继承时,子类没有继承父类虚函数的情况。 考虑有如下继承关系的类:
其中 main()的代码如下:
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();
6、有重写虚函数、 多继承
接下来分析多继承时,有重写虚函数的情况。 考虑有如下继承关系的类:
在 main() 中将 SubClass 对象指针转为不同的父类指针进行虚函数调用,main()的代码如下:
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。
7、总结
本文从 Arm 汇编的角度分析了 Android 中 C++ 虚函数的实现机制。编译时,编译器会为每一个申明有虚函数的类生成一个 vtable, 当对象初始化时,会将 vtable 的地址赋给对象,这样虚函数就可以根据其在 vtable 中的偏移来进行调用。
其中,一个对象所占的内存空间不仅与类的字段有关系,还与类的继承关系有关,对于单继承,一个对象会有一个 vtable 的指针; 如果继承关系中存在多继承, 那么该对象会为每一个多继承的父类保存一个 vtable 的指针。
8、参考
http://blog.csdn.net/haoel/article/details/1948051/