CD63.【C++ Dev】多态(2): 剖析虚函数表的前置知识
目录
1.虚函数表和虚函数指针的简单回顾
2.子类虚表是在父类虚表的基础上修改生成的
3.子类赋值给父类对象的切片,不会拷贝虚表
3.常见编译器虚表的格式
VS2022
Dev C++
Linux上的VSCODE
4.虚表存储的位置
方法1:Windows下使用IDA验证
方法2:Linux下看进程运行时的映射然后看ELF文件
方法3比较地址
5.练习:通过虚函数表手动取地址调用函数
1.虚函数表和虚函数指针的简单回顾
回顾继承和多态参见以下文章:
CD57.【C++ Dev】继承(1)
CD58.【C++ Dev】继承(2)
CD59.【C++ Dev】继承(3) 菱形继承、菱形虚拟继承和虚基表的反汇编分析
CD60.【C++ Dev】继承(4) 继承和组合
CD61.【C++ Dev】多态(1)
CD62.【C++ Dev】继承和多态的练习题
1.虚函数表也称虚表,其存储着虚函数的指针,对象的多态性需要通过虚表和虚函数指针来实现
2.虚函数表不等于虚基表
参见CD59.【C++ Dev】继承(3) 菱形继承、菱形虚拟继承和虚基表的反汇编分析文章回顾虚基表
3.虚表指针被定义在对象的首地址处
例如以下代码:
#include <iostream>
using namespace std;
class non_VIP_member
{
public:virtual void price() const{cout << "Full price" << endl;}int val = 100;
};class VIP_member :public non_VIP_member
{
public:virtual void price() const{cout << "Discounted price" << endl;}int val = 80;
};int main()
{VIP_member mem;return 0;
}
可以看到虚函数表的指针_vfptr被放在第一位,即对象的首地址处
2.子类虚表是在父类虚表的基础上修改生成的
例如以下代码:
#include <iostream>
using namespace std;
class non_VIP_member
{
public:virtual void price() const{cout << "Full price" << endl;}virtual void func1() { }virtual void func2() { }virtual void func3() { }
};class VIP_member :public non_VIP_member
{
public:virtual void price() const{cout << "Discounted price" << endl;}
};int main()
{non_VIP_member mem1;VIP_member mem2;return 0;
}
运行结果:
子类虚表是在父类虚表的基础上修改生成的,见下图:
结论:子类先将父类中的虚表内容拷贝一份, 如果子类重写了基类中某个虚函数,用子类自己的虚函数覆盖虚表中父类的虚函数,子类自己新增加的虚函数按其在子类中的声明次序增加到子类虚表的最后
3.子类赋值给父类对象的切片,不会拷贝虚表
#include <iostream>
using namespace std;
class non_VIP_member
{
public:virtual void price() const{cout << "Full price" << endl;}virtual void func1() { }virtual void func2() { }virtual void func3() { }
};class VIP_member :public non_VIP_member
{
public:virtual void price() const{cout << "Discounted price" << endl;}
};int main()
{non_VIP_member mem1;VIP_member mem2;mem1 = mem2;return 0;
}
执行mem1=mem2前虚表的情况:
执行mem1=mem2后虚表的情况:
会发现执行mem1=mem2后虚表是不变的
设想: 如果执行mem1=mem2后mem1的虚表改成了mem2的虚表,会导致使用父类指针或引用反而会调用子类对象的成员函数,这是不符合多态调用的!
要明确: 多态继承的是接口,重写的是实现
下面手动修改虚表来满足设想的情况
int main()
{non_VIP_member mem1;VIP_member mem2;mem1 = mem2;non_VIP_member& mem1_ref = mem1;mem1_ref.price();return 0;
}
在mem1_ref.price()处下断点,触发断点后,打开监视窗口,手动修改mem1的_vfptr[0]的值,然后查看运行结果,见以下动图
3.常见编译器虚表的格式
虚函数表本质是一个存虚函数指针的指针数组
VS2022
Visual Studio 2022编译形成的虚表的结尾是nullptr:
class Myclass
{
public:virtual void func1() { }virtual void func2() { }virtual void func3() { }
};int main()
{Myclass obj;return 0;
}
VS2022+Debug+x86下的运行结果:
Dev C++
Dev C++,编译器TDM-GCC 4.9.2 32-bit Release:
#include <iostream>
using namespace std;
class Myclass
{
public:virtual void func1() { }virtual void func2() { }virtual void func3() { }
};int main()
{Myclass obj;cout<<&obj<<endl;getchar(); return 0;
}
由于Dev C++不能直接看内存,而且使用内置的GDB调试器操作比较麻烦,采用Cheat Engine查看,操作见下方动图:
Linux上的VSCODE
Linux mint+VSCODE+g++下,x64+debug,运行结果:
转到反汇编,记下三个函数的地址:
看看obj对象的虚表指针指向的虚表:
会发现虚表的结尾不是nullptr
结论: 虚表的格式和不同的编译器有关
4.虚表存储的位置
虚表存储在哪里?
A. 栈区 B.堆区 C.静态区(数据段) D.常量区(代码段)
分析:
首先排除堆区,虚表由编译器生成,肯定不在堆上,其他区域均有可能
方法1:Windows下使用IDA验证
为方便使用IDA验证,需要修改以上代码,打印虚表的首地址
方法:取出obj的前4个字节,这是vfptr的值,等于虚表的地址
取出obj的前4个字节需要先强制类型转换为int*:(int*)(&obj),然后解引用:*((int*)(&obj))
#include <iostream>
using namespace std;
class Myclass
{
public:virtual void func1() {}virtual void func2() {}virtual void func3() {}
};int main()
{Myclass obj;int vfptr = (*(int*)(&obj));printf("0x%08X", vfptr);getchar();//让进程暂停,相当于下断点return 0;
}
VS+Debug+x86下生成可执行文件,让IDA加载可执行文件和对应的pdb文件
保持默认选项不变,点OK
要想进行调试,需要选择调试器,菜单栏选Debugger→Select debugger...
之后选择Local Win32 debuger,点OK
菜单栏选Debugger→Start process
点OK
看到程序打印的地址,使用IDA跳转
菜单栏选Jump→Jump to address
填上打印的地址:
看IDA View-RIP窗口:
调试信息显示是vftable,确实是虚函数表,而且在.rdata段中
微软官方网站查询Windows PE文件格式:learn.microsoft.com pe-format
.rdata是read-only initialized data的缩写,即只读初始化数据
由于具有只读属性,排除栈段和数据段,因为它们都可写,因此虚函数表在常量区
方法2:Linux下看进程运行时的映射然后看ELF文件
编译命令:
g++ -g -m32 test.cpp
先查进程的PID:
ps ajx | head -1 && ps ajx | grep "a.out"
再用pmap命令:
pmap [pid]
运行结果:
虚函数表所处的段是只读的
看看虚函数表存储在ELF文件的哪个段:
先看符号表:
readelf -s ./a.out
_ZTV是虚函数表的前缀
g++提供了一个c++filt工具,可以解码符号名的含义
记住地址00003eb8
再打印a.out文件的所有段:
readelf -S ./a.out
结论:虚函数表存储在ELF文件的.data.rel.ro段,即可重定位的只读数据段
方法3比较地址
修改上方代码:
#include <iostream>
using namespace std;
class Myclass
{
public:virtual void func1() {}virtual void func2() {}virtual void func3() {}
};static int static_val;
int main()
{Myclass obj;int stack_val;const char* string_ptr = "teststring";printf("栈段: 0x%p\n", &stack_val);printf("代码段: 0x%p\n", main);int vfptr = (*(int*)(&obj));printf("静态区: 0x%p\n", &static_val);printf("常量区: 0x%p\n", string_ptr);printf("虚函数表的地址: 0x%08X\n", vfptr);return 0;
}
运行结果:
计算地址差值:
0x000A7B40 - 0x000A7B34 = 0xC
0x000AA2DC - 0x000A7B34 = 0x27A8
0x000A7B34 - 0x000A130C = 0x6828
0x004FFD68 - 0x000A7B34 = 0x458234
可见0x000A7B40 - 0x000A7B34差值最小,因此虚函数表在常量区
5.练习:通过虚函数表手动取地址调用函数
要求: 取出各个函数的地址放到函数指针中,然后手动调用
#include <iostream>
using namespace std;
class Myclass
{
public:virtual void func1() {cout << "virtual void func1()" << endl;}virtual void func2() {cout << "virtual void func2()" << endl;}virtual void func3() {cout << "virtual void func3()" << endl;}
};int main()
{Myclass obj;//在此处补充代码return 0;
}
分析
先取出虚表首地址:
int* vfptr = (int*)(*(int*)(&obj));
然后依次取出虚函数表中存储的虚函数指针vfptr[i],像数组一样访问,利用VS下虚函数表的结尾是nullptr这个终止条件
先看看能不能打印:
for (int i = 0; vfptr[i]; i++)//vfptr[i];指的是vfptr[i] != nullptr;
{printf("vftable%d : 0x%08X\n", i, vfptr[i]);
}
Debug+x86下运行结果:正确打印
现在可以直接调用vfptr[i]指向的函数(复习函数指针参见45.【C语言】指针(重难点)(H)文章)
int main()
{Myclass obj;int* vfptr = (int*)(*(int*)(&obj));typedef void(*func_ptr)();//定义函数指针for (int i = 0; vfptr[i]; i++){func_ptr func = (func_ptr)vfptr[i];func();}return 0;
}
注:此方法可以强行访问私有成员,突破语法限制,但开发中不建议使用
运行结果: