-
-
[原创]从反汇编的角度学C/C++之虚函数,单继承与多态
-
2021-10-2 20:14 6218
-
一.虚函数
虚函数最为C++可以说是最重要的一个特征被广泛应用,那么它的内部机制究竟如何,它和普通函数使用的区别在哪里。定义如下的类
class Base { public: Base() { x = 1; y = 2; } void fun() { printf("fun\n"); } virtual void fun1() { printf("virtual fun1\n"); } virtual void fun2() { printf("virtual fun2\n"); } virtual void fun3() { printf("virtual fun3\n"); } private: int x; int y; };
以上类定义中,有3个虚函数和1个普通的成员函数,我们首先看看增加了虚函数以后类变量是否有所变量以及对构造函数是否有影响
class Base { public: Base() 00BF1000 push ebp 00BF1001 mov ebp,esp 00BF1003 sub esp,0CCh 00BF1009 push ebx 00BF100A push esi 00BF100B push edi 00BF100C push ecx //保存类变量地址 00BF100D lea edi,[ebp-0CCh] 00BF1013 mov ecx,33h 00BF1018 mov eax,0CCCCCCCCh 00BF101D rep stos dword ptr es:[edi] 00BF101F pop ecx 00BF1020 mov dword ptr [this],ecx //将类变量地址放到局部变量 00BF1023 mov ecx,offset _D3472036_test@cpp (0C8200Bh) 00BF1028 call __CheckForDebuggerJustMyCode (0BF14E0h) 00BF102D mov eax,dword ptr [this] //取出类变量地址赋给eax 00BF1030 mov dword ptr [eax],offset Base::`vftable' (0C631B4h) //将0C631B4赋给地址 { x = 1; 00BF1036 mov eax,dword ptr [this] //取出类变量地址赋给eax 00BF1039 mov dword ptr [eax+4],1 //在地址偏移4的地方赋值1 y = 2; 00BF1040 mov eax,dword ptr [this] //取出类变量地址赋给eax 00BF1043 mov dword ptr [eax+8],2 //在地址偏移8的地方赋值2 } 00BF104A mov eax,dword ptr [this] 00BF104D pop edi 00BF104E pop esi 00BF104F pop ebx 00BF1050 add esp,0CCh 00BF1056 cmp ebp,esp 00BF1058 call _RTC_CheckEsp (0BF14A0h) 00BF105D mov esp,ebp 00BF105F pop ebp 00BF1060 ret
由以上反汇编代码可以看出对于有虚函数的类变量,在初始化类成员之前会首先赋给一个地址,这个地址其实就是虚函数表的地址。这个地址在类变量的偏移为0的地方,随后的类成员依次向下排布。此时查看类变量地址对应的内存如下
可以看到对于有虚函数的类变量地址处首先存储的是我们的虚函数表,然后才是我们的类成员,至于这个虚函数表的内容是啥,为了方便展示这边使用IDA查看,如下图所示
可以看到这个地址对应了3个4地址,而这3个地址其实就是我们所写的三个虚函数。那么对于虚函数的调用与普通的成员函数有什么区别
Base base; 00481298 lea ecx,[base] 0048129B call Base::Base (0481000h) base.fun(); 004812A0 lea ecx,[base] 004812A3 call Base::fun (0481160h) //普通成员函数的调用 base.fun1(); 004812A8 lea ecx,[base] 004812AB call Base::fun1 (0481070h) //对虚函数的调用 Base *pBase = &base; 004812B0 lea eax,[base] 004812B3 mov dword ptr [pBase],eax //取出base地址并赋给pBase pBase->fun(); 004812B6 mov ecx,dword ptr [pBase] 004812B9 call Base::fun (0481160h) //普通成员函数的调用 pBase->fun1(); 004812BE mov eax,dword ptr [pBase] //取出pBase的值赋给eax,此时这个值就是base的地址 004812C1 mov edx,dword ptr [eax] //取出地址中的第一个元素内容,由上分析知道这个就是虚函数表的地址 004812C3 mov esi,esp 004812C5 mov ecx,dword ptr [pBase] //取出pBase的值赋给ecx 004812C8 mov eax,dword ptr [edx] //虚函数表地址偏移0处的地址所保存的值,此时这个值是我们第一个虚函数的地址赋给eax 004812CA call eax //调用虚函数
由上可以看出如果是对象对虚函数或者是对象指针对普通函数的调用都是直接call函数地址,而如果是对象指针对虚函数调用,程序首先会去虚函数表中找到对应的虚函数地址,在对它进行call。
二.继承
继承作为面向对象的核心概念被程序员广泛使用,那么对象的继承在内存中是如何表现得,修改上述得类定义如下
class Base { public: Base() { x = 1; y = 2; } private: int x; int y; }; class Sub : public Base { public: Sub() { this->z = 3; } private: int z; };
根据如上得定义,类Sub继承Base,首先看看继承得类构造函数的变化如下
Sub() 00121060 push ebp 00121061 mov ebp,esp 00121063 sub esp,0CCh 00121069 push ebx 0012106A push esi 0012106B push edi 0012106C push ecx 0012106D lea edi,[ebp-0CCh] 00121073 mov ecx,33h 00121078 mov eax,0CCCCCCCCh 0012107D rep stos dword ptr es:[edi] 0012107F pop ecx 00121080 mov dword ptr [this],ecx 00121083 mov ecx,offset _D3472036_test@cpp (01B200Bh) 00121088 call __CheckForDebuggerJustMyCode (0121310h) 0012108D mov ecx,dword ptr [this] //取出类变量地址赋给ecx 00121090 call Base::Base (0121000h) //对于父类构造函数的调用 { this->z = 3; 00121095 mov eax,dword ptr [this] //取出类变量地址 00121098 mov dword ptr [eax+8],3 //类变量地址偏移8的地方赋值为3 } 0012109F mov eax,dword ptr [this] 001210A2 pop edi 001210A3 pop esi 001210A4 pop ebx 001210A5 add esp,0CCh 001210AB cmp ebp,esp 001210AD call _RTC_CheckEsp (01212D0h) 001210B2 mov esp,ebp 001210B4 pop ebp 001210B5 ret
可以看到,对于子类,首先会调用父类的构造函数,而子类的成员地址,也会往后排列,上述父类构造函数的内容如下:
Base() 00121000 push ebp 00121001 mov ebp,esp 00121003 sub esp,0CCh 00121009 push ebx 0012100A push esi 0012100B push edi 0012100C push ecx //保存类变量地址到栈中 0012100D lea edi,[ebp-0CCh] 00121013 mov ecx,33h 00121018 mov eax,0CCCCCCCCh 0012101D rep stos dword ptr es:[edi] 0012101F pop ecx //把类变量地址恢复到ecx 00121020 mov dword ptr [this],ecx //将类变量地址赋给局部变量 00121023 mov ecx,offset _D3472036_test@cpp (01B200Bh) 00121028 call __CheckForDebuggerJustMyCode (0121310h) { x = 1; 0012102D mov eax,dword ptr [this] //将类变量地址赋给eax 00121030 mov dword ptr [eax],1 //地址偏移0处赋值为1 y = 2; 00121036 mov eax,dword ptr [this] 00121039 mov dword ptr [eax+4],2 //地址偏移4处赋值为2 } 00121040 mov eax,dword ptr [this] 00121043 pop edi 00121044 pop esi 00121045 pop ebx 00121046 add esp,0CCh 0012104C cmp ebp,esp 0012104E call _RTC_CheckEsp (01212D0h) 00121053 mov esp,ebp 00121055 pop ebp 00121056 ret
由上可以得出结果,在子类构造函数中,首先会调用父类的构造函数对类变量地址空间中的变量进行赋值,其次才是本类的成员赋值。所以继承的子类中的成员变量的排列是在父类成员变量后面,在内存中查看子类变量的地址得到如下结果
这里有一个概念是继承中可以选择public得公有继承和private得继承继承,区别就是private得继承会把父类得成员变量和函数变成私有的在内存中保存,但是这只是编译器的检查,根据之前发的文章可以知道我们可以很容易的用指针的方式访问这些私有成员。
三.虚函数与继承
接下来看看虚函数在继承的子类中是如何保存的,修改上述的定义如下
class Base { public: Base() { this->x = 1; this->y = 2; } virtual void fun1() { printf("Base fun 1\n"); } virtual void fun2() { printf("Base fun 2\n"); } virtual void fun3() { printf("Base fun 3\n"); } private: int x; int y; }; class Sub : public Base { public: Sub() { this->z = 3; } virtual void fun1() { printf("Sub fun 1\n"); } virtual void fun3() { printf("Sub fun 3\n"); } virtual void fun4() { printf("Sub fun 4\n"); } private: int z; };
首先我们依然先查看构造函数的调用情况
Sub() 00CD1070 push ebp 00CD1071 mov ebp,esp 00CD1073 sub esp,0CCh 00CD1079 push ebx 00CD107A push esi 00CD107B push edi 00CD107C push ecx 00CD107D lea edi,[ebp-0CCh] 00CD1083 mov ecx,33h 00CD1088 mov eax,0CCCCCCCCh 00CD108D rep stos dword ptr es:[edi] 00CD108F pop ecx 00CD1090 mov dword ptr [this],ecx 00CD1093 mov ecx,offset _D3472036_test@cpp (0D6200Bh) 00CD1098 call __CheckForDebuggerJustMyCode (0CD1620h) 00CD109D mov ecx,dword ptr [this] 00CD10A0 call Base::Base (0CD1000h) //调用父类构造函数 00CD10A5 mov eax,dword ptr [this] 00CD10A8 mov dword ptr [eax],offset Sub::`vftable' (0D431CCh) //将虚函数表地址赋给类变量偏移0处的地址 { this->z = 3; 00CD10AE mov eax,dword ptr [this] 00CD10B1 mov dword ptr [eax+0Ch],3 //对类成员进行初始化 } 00CD10B8 mov eax,dword ptr [this] 00CD10BB pop edi 00CD10BC pop esi 00CD10BD pop ebx 00CD10BE add esp,0CCh 00CD10C4 cmp ebp,esp 00CD10C6 call _RTC_CheckEsp (0CD15E0h) 00CD10CB mov esp,ebp 00CD10CD pop ebp 00CD10CE ret
唯一的变化就是在调用完父类构造函数后,程序首先对类变量地址为0的地方赋值了一个虚函数表的地址,接下来看看父类构造函数的内容
Base() 00CD1000 push ebp 00CD1001 mov ebp,esp 00CD1003 sub esp,0CCh 00CD1009 push ebx 00CD100A push esi 00CD100B push edi 00CD100C push ecx 00CD100D lea edi,[ebp-0CCh] 00CD1013 mov ecx,33h 00CD1018 mov eax,0CCCCCCCCh 00CD101D rep stos dword ptr es:[edi] 00CD101F pop ecx 00CD1020 mov dword ptr [this],ecx 00CD1023 mov ecx,offset _D3472036_test@cpp (0D6200Bh) 00CD1028 call __CheckForDebuggerJustMyCode (0CD1620h) 00CD102D mov eax,dword ptr [this] 00CD1030 mov dword ptr [eax],offset Base::`vftable' (0D431B4h) //将虚函数表地址赋给类变量偏移0处的地址 { x = 1; 00CD1036 mov eax,dword ptr [this] 00CD1039 mov dword ptr [eax+4],1 y = 2; 00CD1040 mov eax,dword ptr [this] 00CD1043 mov dword ptr [eax+8],2 //为类成员变量赋值 } 00CD104A mov eax,dword ptr [this] 00CD104D pop edi 00CD104E pop esi 00CD104F pop ebx 00CD1050 add esp,0CCh 00CD1056 cmp ebp,esp 00CD1058 call _RTC_CheckEsp (0CD15E0h) 00CD105D mov esp,ebp 00CD105F pop ebp 00CD1060 ret
可以看到父类构造函数的内容和最开始讲解虚函数时候的内容是一样的,此时在用IDA查看两张虚函数表的内容如下
虚函数表里面存着的是各个虚函数的地址,经过查看以后对着写函数进行重命名如下
可以看到虚函数Base_fun2由于子类没用重载他,所以也在子类的虚函数表里面,而fun1, fun3由于被覆盖了,所以虚函数表存储的是Sub_fun1和Sub_fun3。由此可以得出结论,父类的虚函数会如果没有被子类重载,那么子类的虚函数表中就会有父类的虚函数,如果被重载了,虚函数表中就会存储子类的虚函数。
四.多态
根据上面的内容可以总结出以下三点结论
当用指针调用虚函数的时候,程序会从虚函数表中找到相应的函数进行调用。
子类调用构造函数的时候,先调用父类的构造函数,父类的构造函数会先用自己的虚函数表覆盖在最开始的类变量地址中,接着子类在将自己的虚函数表覆盖在开始的类变量地址中,然后在对变量成员进行初始化。
如果子类中有对父类的虚函数进行重载,那么子类的虚函数表中存储的这个虚函数就是子类重载后的虚函数。
跟据上面的三点结论,就应该可以很好理解多态这一概念了,修改类定义如下
class Base { public: virtual void fun1() { printf("Base fun 1\n"); } }; class Sub1 : public Base { public: virtual void fun1() { printf("Sub1 fun 1\n"); } }; class Sub2 : public Base { public: virtual void fun1() { printf("Sub2 fun 1\n"); } };
此时main函数中的代码如下
int main() { Sub1 sub1; Sub2 sub2; Base *pBase; pBase = (Base *)&sub1; pBase->fun1(); pBase = (Base *)&sub2; pBase->fun1(); return 0; }
对应的反汇编如下
Sub1 sub1; 000812B8 lea ecx,[sub1] Sub1 sub1; 000812BB call Sub1::Sub1 (081040h) //调用Sub1构造函数 Sub2 sub2; 000812C0 lea ecx,[sub2] 000812C3 call Sub2::Sub2 (081090h) //调用Sub2构造函数 Base *pBase; pBase = (Base *)&sub1; 000812C8 lea eax,[sub1] 000812CB mov dword ptr [pBase],eax //将sub1的地址给到pBase pBase->fun1(); 000812CE mov eax,dword ptr [pBase] //取出pBase的值赋给eax,此时eax的值就是sub1的地址 000812D1 mov edx,dword ptr [eax] //地址最开始处的数据就是虚函数表的地址 000812D3 mov esi,esp 000812D5 mov ecx,dword ptr [pBase] //将pBase中sub1的地址赋给ecx 000812D8 mov eax,dword ptr [edx] //虚函数表地址第一个函数的地址 000812DA call eax //调用这个函数 000812DC cmp esi,esp 000812DE call _RTC_CheckEsp (081560h) pBase = (Base *)&sub2; 000812E3 lea eax,[sub2] 000812E6 mov dword ptr [pBase],eax pBase->fun1(); 000812E9 mov eax,dword ptr [pBase] 000812EC mov edx,dword ptr [eax] 000812EE mov esi,esp 000812F0 mov ecx,dword ptr [pBase] 000812F3 mov eax,dword ptr [edx] 000812F5 call eax //与上面同样的操作
首先可以看到,当类对象有虚函数的时候,即使我们没用构造函数,编译器也会为我们生成构造函数。
其次可以看到通过指针调用的时候,两次都是从虚函数表中找到函数进行调用,也就是说此时虚函数表中对应位置的函数是谁,他就调用谁。那么究竟是谁就要看看这两个构造函数里面的内容。
Sub1::Sub1: 00F11040 push ebp 00F11041 mov ebp,esp 00F11043 sub esp,0CCh 00F11049 push ebx 00F1104A push esi 00F1104B push edi 00F1104C push ecx 00F1104D lea edi,[ebp-0CCh] 00F11053 mov ecx,33h 00F11058 mov eax,0CCCCCCCCh 00F1105D rep stos dword ptr es:[edi] 00F1105F pop ecx 00F11060 mov dword ptr [this],ecx 00F11063 mov ecx,dword ptr [this] 00F11066 call Base::Base (0F11000h) 00F1106B mov eax,dword ptr [this] 00F1106E mov dword ptr [eax],offset Sub1::`vftable' (0F831C8h) //sub1虚函数表地址 00F11074 mov eax,dword ptr [this] 00F11077 pop edi 00F11078 pop esi 00F11079 pop ebx 00F1107A add esp,0CCh 00F11080 cmp ebp,esp 00F11082 call _RTC_CheckEsp (0F11560h) 00F11087 mov esp,ebp 00F11089 pop ebp 00F1108A ret Sub2::Sub2: 00F11090 push ebp 00F11091 mov ebp,esp 00F11093 sub esp,0CCh 00F11099 push ebx 00F1109A push esi 00F1109B push edi 00F1109C push ecx 00F1109D lea edi,[ebp-0CCh] 00F110A3 mov ecx,33h 00F110A8 mov eax,0CCCCCCCCh 00F110AD rep stos dword ptr es:[edi] 00F110AF pop ecx 00F110B0 mov dword ptr [this],ecx 00F110B3 mov ecx,dword ptr [this] 00F110B6 call Base::Base (0F11000h) 00F110BB mov eax,dword ptr [this] 00F110BE mov dword ptr [eax],offset Sub2::`vftable' (0F831DCh) //sub2虚函数表地址 00F110C4 mov eax,dword ptr [this] 00F110C7 pop edi 00F110C8 pop esi 00F110C9 pop ebx 00F110CA add esp,0CCh 00F110D0 cmp ebp,esp 00F110D2 call _RTC_CheckEsp (0F11560h) 00F110D7 mov esp,ebp 00F110D9 pop ebp 00F110DA ret
可以看到这两个子类变量在调用完父类构造函数以后分别用不同的虚函数表地址覆盖在类变量最开始的4字节。在IDA中查看这两个地址
可以看到这两个虚函数表地址中的虚函数是两个不同的函数,而这两个函数其实就是我们两个子类中重载的虚函数,由此程序便实现了多态。最终的运行结果如图
[培训]内核驱动高级班,冲击BAT一流互联网大厂工 作,每周日13:00-18:00直播授课