Thunk技术就是申请一段可执行的内存,并通过手动构造CPU指令的形式来生成代码块,实现针对一些特定问题的解决方案,如:
- 通知我们要在窗口的回调函数里获得包含此窗口的类的指针得把窗口类指针放到窗口绑定的数据里,可是有一种技术可以帮助你省去这种绑定的麻烦。(如常见的问题:类的成员函数不能作为回调函数,也不能通过参数传递this的情况)。如下图:
因为是 WindowProc 是 __stdcall 调用约定,就算我们多压入了一个this参数,也不管调用者的事, 因为堆栈是由被调用者(windowProc)来清理的。虽然只有4个显式参数, 但作为成员函数的WindowProc在结束的时候是用ret 14h返回的,this被自动清除,你知道为什么吗?
我们只需构造如下的3条简单的指令即可:
- 我们知道一个窗口对映一个对象,很自然设计一个类。如MyWin *p1和MyWin *p2,在类里有窗口的句柄,这样就进入到window的程序世界里了。通过类可以找到窗口句柄,但通过窗口句柄怎么找到类,这就是一个比较难办的问题。
- 虚函数多继承情况。如class B : public A1, public A2, public A3{}。所谓的thunk就是一段汇编代码,这段汇编代码可以以适当的偏移值来调整this指针以跳到对应的虚函数中去,并调用这个函数,也就是说当使用A1的指针指向B的对象,不需要发生偏移,而使用A2的指针指向B则需要进行偏移sizeof(A1)个字节。并跳转到A1中的函数来执行。这就是通过thunk的jmp指令跳转到这个函数。
所以具体的虚函数表中的情况如下:
①如果两个基类中的虚函数名字不同,派生类只重写了第二个基类的虚函数,则不会产生thunk用以跳转。
②如果基类中虚函数名字相同,派生类如果重写,将会一次性重写两个基类的虚函数,这时候第二个基类的虚函数表中存放的就是thunk对象,当指针指向此处的时候,会自动跳转到A类的对应虚函数(已经被B重写)执行。如下图:重写虚函数f()
③第一个基类的虚函数被重写与否都不会产生thunk对象,因为这个类是被别的基类指针跳转的目标,而这个类的指针施行多态的时候是不会发生跳转的。
④派生类的重新定义的虚函数将会排在第一个虚函数表内部A1虚函数的后面,但是当A2调用这个函数的时候,会通过thunk技术跳转回第一个类的虚函数表以执行相对应的虚函数。如图:
⑤除了第一个基类的虚析构函数,其他基类的析构函数都是thunk对象。 如下图:
综上所述,thunk对象用于所有基类都被派生类重写后,调用虚函数将跳到最开始的基类部分。或者派生类中定义的虚函数也会跳转到第一个基类的虚函数表中。而仅出现在后面的基类的虚函数表中的虚函数,无论被重写与否都不会产生thunk对象。因为这里不会在第一个基类中由对应的虚函数指针。
Q&&A
1. C++的编译器应该是保证虚函数表的指针存在于对象实例中最前面的位置(这是为了保证取到虚函数表的有最高的性能——如果有多层继承或是多重继承的情况下)
2. 子类的成员函数被放到了第一个父类的表中。(所谓的第一个父类是按照声明顺序来判断的)这样做就是为了解决不同的父类类型的指针指向同一个子类实例,而能够调用到实际的函数。如:B* pb = new B; 派生类的指针指向派生类的对象,由于派生类的对象模型没有自己的虚表指针,直接借用的第一个父类的虚表指针索引到正确的虚函数。如图:
以上,就是三种常见的Thunk技术的应用。当然还有很多,这里不一一列举。
我们在开发中经常遇到调用约定类型
,如__cdecl
、stdcall
、PASCAL
、fastcall
。这些调用约定类型就用来指定函数参数的传递方式的。上面几种约定类型,除了fastcall
是使用寄存器方式传递参数外,其他的都是使用堆栈传递参数的。
堆栈是一种“后进先出”的数据结构,ESP寄存器始终指向栈顶。栈中数据地址从底部到顶部依次减小,也就是说,栈底对应高地址,栈顶对应低地址。
调用函数时,调用者依次把参数压栈,然后调用函数,函数被调用之后,在堆栈中取得参数数据。函数调用结束以后,堆栈需要恢复到函数调用之前的样子,而到底是由调用者来恢复还是由函数自身来恢复,根据不同的调用约定类型采用不同的方式。
约定类型 | __cdecl | stdcall | PASCAL | fastcall |
---|---|---|---|---|
参数传递顺序 | 从右到左 | 从右到左 | 从左到右 | 使用寄存器 |
堆栈平衡者 | 调用者 | 函数自身 | 函数自身 | 函数自身 |
__cdcel是C/C++/MFC程序默认的调用约定。
stdcall是绝大多数Win32 API函数的约定方式,也有少部分使用__cdcel约定方式(如wsprintf等)。
在Windows C/C++开发中常用的就是__cdecl和stdcall这2种调用约定。
按照不同的调用约定来调用函数int add(int a, int b)。从调用者的视角来看,其汇编代码分别表示如下:
__cdecl
push b ;参数按从右到左传递
push a
call add
add esp, 8 ;调用者在函数外部平衡堆栈
stdcall
push b ;参数按从右到左传递
push a
call add ;函数自己内部平衡堆栈,调用者不需要平衡堆栈
以下主要介绍1中涉及的技术方案,通过代码体现(__cdecl、__stdcall指令有区别,不含调用端代码)
Thunk技术的大致步骤(以下代码有体现):
<1>. 在指定的内存区域(可以使用VirtualAlloc函数来分配)写入模拟函数调用的汇编指令:
// 该行用于存储函数的返回地址,因为在进入调用函数后,栈顶存储的就是函数的返回地址,所以可以直
// 接使用[esp]。push dword ptr [esp]
// 将pThis指针放入到函数的第一个参数位置(注意参数入栈是从右往左入栈的,而上面一行代码又压入
// 了函数返回地址,所以是[esp+4])mov dword ptr [esp+4], pThis
// 直接使用jmp跳转到指定的相对地址(留意相对地址的计算方式)
jmp XXXXXXX
<2>. 通过Windows提供的FlushInstructionCache函数来更新指令缓存
<3>. 最后再将指定的内存区域的起始地址作为函数指针来使用。
__stdcall:
void* Thunk::fStdcall(void* pThis, void* mfun)
{
/****************************************************************************************
machine code assembly code comment
------------------------------------------------------------------------------------------
FF 34 24 push dword ptr[esp] ;再次压入返回地址
C7 44 24 04 ?? ?? ?? ?? mov dword ptr[esp+4],this ;传入this指针
E9 ?? ?? ?? ?? jmp (relative target) ;转到成员函数
****************************************************************************************/
// 组织汇编语句
// push
m_pthis->m_stdcall.push[0] = 0xFF;
m_pthis->m_stdcall.push[1] = 0x34;
m_pthis->m_stdcall.push[2] = 0x24;
// +4 mov
m_pthis->m_stdcall.mov = 0x042444C7;
m_pthis->m_stdcall.pthis = (byte4)pThis;
// jmp
m_pthis->m_stdcall.jmp = 0xE9;
m_pthis->m_stdcall.addr = (byte4)mfun - ((byte4)&m_pthis->m_stdcall.jmp + 5);
// 要跳转的回调入口
cout << (byte4)mfun << " " << ((byte4)&m_pthis->m_stdcall.jmp + 5) << " " << m_pthis->m_stdcall.addr << endl;
// 刷新指定进程的指令高速缓存,让CPU加载新的指令
FlushInstructionCache(GetCurrentProcess(), &m_pthis->m_stdcall, sizeof(m_pthis->m_stdcall));
return &m_pthis->m_stdcall;
}
__cdecl:
void* Thunk::fCdeclcall(void* pThis, void* mfun)
{
/****************************************************************************************
machine code assembly code comment
------------------------------------------------------------------------------------------
3E 8F 05 ?? ?? ?? ?? pop dword ptr ds:[?? ?? ?? ??] ;弹出并保存返回地址
?? ?? ?? ?? push this ;压入this指针
?? ?? ?? ?? push my_ret ;压入我的返回地址
9E ?? ?? ?? ?? jmp (relative target) ;跳转到成员函数
C4 04 add esp,4 ;清除this栈
3E FF 25 ?? ?? ?? ?? jmp dword ptr ds:[?? ?? ?? ??] ;转到原返回地址
****************************************************************************************/
// 组织汇编语句
// pop
m_pthis->m_cdecl.pop_ret[0] = 0x3E;
m_pthis->m_cdecl.pop_ret[1] = 0x8F;
m_pthis->m_cdecl.pop_ret[2] = 0x05;
*(byte4*)&m_pthis->m_cdecl.pop_ret[3] = (byte4)&m_pthis->m_cdecl.ret_addr;
// push
m_pthis->m_cdecl.push_this[0] = 0x68;
*(byte4*)&m_pthis->m_cdecl.push_this[1] = (byte4)pThis;
// push
m_pthis->m_cdecl.push_my_ret[0] = 0x68;
*(byte4*)&m_pthis->m_cdecl.push_my_ret[1] = (byte4)&m_pthis->m_cdecl.add_esp[0];
// jmp
m_pthis->m_cdecl.jmp_mfun[0] = 0xE9;
*(byte4*)&m_pthis->m_cdecl.jmp_mfun[1] = (byte4)mfun - ((byte4)&m_pthis->m_cdecl.jmp_mfun + 5);
cout << (byte4)mfun << " " << ((byte4)&m_pthis->m_stdcall.jmp + 5) << " " << * (byte4*)&m_pthis->m_cdecl.jmp_mfun[1] << endl;
// add
m_pthis->m_cdecl.add_esp[0] = 0x83;
m_pthis->m_cdecl.add_esp[1] = 0xC4;
m_pthis->m_cdecl.add_esp[2] = 0x04;
// jmp
m_pthis->m_cdecl.jmp_ret[0] = 0x3E;
m_pthis->m_cdecl.jmp_ret[1] = 0xFF;
m_pthis->m_cdecl.jmp_ret[2] = 0x25;
*(byte4*)&m_pthis->m_cdecl.jmp_ret[3] = (byte4)&m_pthis->m_cdecl.ret_addr;
cout << *(byte4*)&m_pthis->m_cdecl.jmp_ret[3] << " " << (byte4)&m_pthis->m_cdecl.ret_addr << endl;
// 刷新指定进程的指令高速缓存,让CPU加载新的指令
FlushInstructionCache(GetCurrentProcess(), &m_pthis->m_cdecl, sizeof(m_pthis->m_cdecl));
return &m_pthis->m_cdecl;
}