编译器为VS2017
先看一个简单的虚继承
#include <stdio.h>
class Base {
public:
virtual void __stdcall Output() {
printf("Class Base/n");
}
};
class Derive : public Base {
public:
void __stdcall Output() {
printf("Class Derive/n");
}
};
void Test(Base *p) {
p->Output();
}
int __cdecl main(int argc, char* argv[]) {
Derive obj;
Test(&obj);
return 0;
}
反汇编后跟踪下执行流程
首先要明确栈地址是从高到低的。栈底基址ebp内存高地址,栈顶esp内存低地址。
解析如下:
00E219F0 push ebp
//即将上层函数在调用main前的基址指针寄存器ebp压栈
00E219F1 mov ebp,esp
//更新main的栈基址ebp为原栈顶esp
00E219F3 sub esp,0CCh
//新的栈顶在原栈顶下移0xCCh,至于为何大小是0xCCh待研究。
00E219F9 push ebx
00E219FA push esi
00E219FB push edi
//保存相关栈的原始数据,先压栈,并在main函数结束前需要弹栈进行恢复
00E219FC lea edi,[ebp-0CCh]
//因为栈大小是0xCCh,把ebp-0CCh即esp寄存器的值加载到edi中,edi存的是esp在push三个寄存器前的地址。
00E21A02 mov ecx,33h
//ecx是计数器(counter), 是重复(REP)前缀指令和LOOP指令的内定计数器
00E21A07 mov eax,0CCCCCCCCh
//INT 3指令的目的就是使CPU中断(break)到调试器,其机器码就是我们熟悉的0XCC, 在调试时,防止编译器把栈上的内容当作指令来执行。一旦编译器执行了0XCC,就会产生INT3中断,这里把eax设定为0xCCCCCCCCh
00E21A0C rep stos dword ptr es:[edi]
//rep指令的目的是重复其上面的指令.ECX的值是重复的次数.STOS指令的作用是将eax中的值拷贝到以指针ES:EDI(如ES=023H为段选择子,EDI=12EAB5H为线形地址偏移,经段描述符后,变为线性地址,再经分页机制,转为物理地址)指向的地址,如果设置了标志位DF(direction flag), 那么edi会在该指令执行后减小, 如果没有设置, 那么edi的值会增加,并根据对寄存器DI作相应增减。该指令不影响任何标志位。执行完此指令,栈区的0xCC = 0x33 * 4(dword ptr)长度的main函数栈空间为0xCC
可见初始化大小为0x33 = 51个dword ptr大小。dword ptr即双字指针,这里32位机即4字节指针
00E21A0E lea ecx,[obj]
//obj的地址放到ecx寄存器
00E21A11 call Derive::Derive (0E2130Ch)
//调用Derive::Derive构造
00E217B0 push ebp
00E217B1 mov ebp,esp
00E217B3 sub esp,0CCh
00E217B9 push ebx
00E217BA push esi
00E217BB push edi
//更新Derive::Derive函数栈,及保存main现场,
00E217BC push ecx
//ecx寄存器值压栈,因为后面要用到这个计数寄存器,由上面可知ecx实际保存的是obj的地址即0x0075FE7C
00E217BD lea edi,[ebp-0CCh]
00E217C3 mov ecx,33h
00E217C8 mov eax,0CCCCCCCCh
00E217CD rep stos dword ptr es:[edi]
//同前面初始化Derive::Derive函数栈
00E217CF pop ecx
//弹栈还原ecx为obj的地址
00E217D0 mov dword ptr [this],ecx
//用ecx即obj的地址给this赋值,这里操作过程中因为重新运行了,导致各值变化了,运行此行前,this值为的值0xcccccccc执行此行后,this值变为obj的地址
00E217D3 mov ecx,dword ptr [this]
//再把this值给ecx
00E217D6 call Base::Base (0E21037h)
//调用 Base::Base
00E21760 push ebp
00E21761 mov ebp,esp
00E21763 sub esp,0CCh
00E21769 push ebx
00E2176A push esi
00E2176B push edi
00E2176C push ecx
00E2176D lea edi,[ebp-0CCh]
00E21773 mov ecx,33h
00E21778 mov eax,0CCCCCCCCh
00E2177D rep stos dword ptr es:[edi]
00E2177F pop ecx
//上面这些之前已经讲过,不再提,ecx此时存的依然是obj的this地址即Derived子类对象的地址
00E21780 mov dword ptr [this],ecx
//这里因为单继承子类的开始位置同时也是基类的开始位置,这行将ecx的值存到基类对象的this
00E21783 mov eax,dword ptr [this]
//这行将基类this值复制到eax寄存器
00E21786 mov dword ptr [eax],offset Base::`vftable' (0E27B34h)
//上面这行就是保存虚表指针的核心代码,即将0E27B34h拷贝到this地址的前双字4字节中,即基类对象的首地址
00E2178C mov eax,dword ptr [this]
//将基类的this重新放回eax,eax通常也用来存放函数返回值。
看下虚表地址0E27B34h有什么
找到内存0E27B34h位置,前4个字节ad 12 e2 00大小端转换下即0x00e212ad,我们看下这个位置是什么
可以看出是一条jmp指令用于跳转到Base::Output (0E21810h)地址
即虚表指针指向一块内存地址(虚表的位置),虚表中存放的是一些跳转指令,跳转指令跳转到对应的虚函数实现的位置,进行调用。
关于内存0E27B34h第二个双字的位置,有00 00 00 00,这里应该虚表大小结束标志,就像指针数组使用NULL作为有效数据的结束标记一样。这个暂时不深究。
先不管虚函数的执行。再来看看拷贝后的基类的this中虚表指针位置有啥变化,回到Base::Base拷贝虚表后的0x00E21786位置
指向拷贝虚表指针前
执行后
由上面的图可知0x00e27b34就是vfptr的地址
至此基类的this指针中的vfptr的值设定好了。
后面把基类的this地址拷到eax后恢复子类构造Derive::Derive函数现场,并ret返回到Derive::Derive。
00E2178F pop edi
00E21790 pop esi
00E21791 pop ebx
00E21792 mov esp,ebp
00E21794 pop ebp
00E21795 ret
回到Derive::Derive的
00E217DB mov eax,dword ptr [this]
//eax本来是存了Base::Base的基类this地址的,这里因为简单的单继承,基类和子类的this地址是一样的,拿子类的this覆盖了eax的值
00E217DE mov dword ptr [eax],offset Derive::`vftable' (0E27B50h)
//拿子类的虚表地址放到eax寄存器值子类对象this指向的地址的前4个字节。
00E217E4 mov eax,dword ptr [this]
//子类对象的this作为返回值放到eax。
我们再看下子类的虚表地址0E27B50h存放了什么
f9 11 e2 00改成0x00e211f9
即子类Output的实现位置
至此完成了基类子类构造基类构造,基类构造初始化vfptr和子类对象初始化vfptr的过程。
接下来我们看下虚函数的调用过程,如上obj的虚表已经被初始化而且是Derived::Output的jmp地址,继续运行到
00E21A16 lea eax,[obj]
00E21A19 push eax
//指针参数obj压栈
00E21A1A call Test (0E2123Ah)
调用Test函数
00E21A1F add esp,4
//加4意思是从堆栈中推出4个字节,这里是因为在main调用Test之前有eax即obj地址压栈,这里相当于回收这占用的栈空间
清栈操作后面再说。进入到Test看下
00E218EE mov eax,dword ptr [p]
//p的内容及obj对象的首地址存到eax
00E218F1 mov ecx,dword ptr [eax]
//取eax即obj对象的前4个字节就是vfptr的值存到ecx
00E218F3 mov esi,esp
//esp暂存到esi,堆栈平衡检查后面
00E218F5 mov edx,dword ptr [p]
//p的值存到edx
00E218F8 push edx
//edx压栈
00E218F9 mov eax,dword ptr [ecx]
// 将ecx寄存器中的vfptr值指向的地址的前4个字节即虚表中的Derived::Output的跳转指令地址给eax
00E218FB call eax
//调用eax即调用jmp
00E218FD cmp esi,esp
//堆栈平衡检查
00E218FF call __RTC_CheckEsp (0E21131h)
//堆栈平衡检查
对照前面的图,
EAX = 00E211F9即
EDX = 0019F9E8即p的值也是obj的地址
我们看Test的右扩号还执行了一些操作,包括弹栈,恢复现场操作。
但在这之前发现后压栈的push edx并没有弹栈
进一步跟踪发现call eax 前后栈esp信息如下
可见在调用后从call eax 退出时,多弹了4个字节,但在反汇编中并没有体现,这里也不再研究汇编代码。