尹德有 杨振龙
摘要:该文主要介绍了Thunk技术的基本原理,以及如何利用Thunk技术封装基于C++语言的Windows窗口类。该文还介绍了如何利用ATL中已有的Thunk代码实现窗口类的封装以简化代码的编写工作。利用本文介绍的技术封装窗口类可极大的精简代码规模,同时代码的运行效率也较高。
关键词:Thunk;C++;Windows;窗口类;ATL;形实转换
中图分类号:TP311 文献标识码:A 文章编号:1009-3044(2018)10-0248-03
一直以来,使用C++语言封装Windows窗口类都是一个比较繁琐的工作。大多数软件开发人员都是使用第三方类库进行Windows窗口类的编写,比如最常使用的就是Microsoft公司的MFC类库以及ATL模板库等。这些类库虽然功能强大,但是它们都过于庞大、复杂,如何使用简单的技术实现轻量级的Windows窗口类(以下简称窗口类)是广大软件开发人员一直在探讨的问题。
封装窗口类的难点是如何让Windows窗口处理回调机制在消息发生时调用窗口类的非静态成员函数。
我们知道,C++在调用非静态成员函数时需要传递this指针,由于Windows系统只能调用静态的回调函数,而类的静态成员函数是没有this指针的,因此,如何将this指针传递给类的静态成员函数(窗口处理回调函数)就成为封装窗口类的关键技术。到目前为止,向类的静态成员函数传递this指针的方法主要有三种:静态表查询方式,修改窗口用户数据(USERDATA)方式,Thunk方式。其中Thunk方式是最直接、最高效的方式,本文主要讨论Thunk方式。
1 基本原理
Thunk在程序设计领域被称为形实转换程序,其基本思想就是将若干连续存储的数据直接解释成代码让CPU执行,本质上相当于直接使用机器语言编程。
利用Thunk技术封装窗口类的基本思路是:将一段精心设计的数据(实际是一些机器指令,以下将这段数据简称为Thunk数据)解释成Windows窗口处理函数,同时将this指针存储在这些数据中,从而达到传递this指针的目的。
2 关键技术
虽然理论上可以在Thunk数据中使用机器代码完成对类的非静态成员函数的调用,但是这需要完全模拟C++对类的非静态成员函数的调用机制,而这种机制是比较复杂的,并且可能会在将来有所改变。为了尽量缩短Thunk数据代码的长度,一种更好的方式是在Thunk数据代码中调用另一个静态的窗口处理函数(以下简称中转处理函数),同时采用一种技术将this指针传递给该中转处理函数。
向中转处理函数传递this指针的方式基本上有两种:
1) 在标准Windows窗口处理函数的参数列表中增加一个参数并将this指针传递给这个参数,即使用非标准形式的窗口处理函数,但这种形式会增加Thunk数据代码的复杂度;
2) 使用标准形式的窗口处理函数并将其参数列表中的某个参数修改为this指针值,这种方式将最大程度的降低Thunk数据代码的复杂度,因此采用这种方式较好。
Windows窗口处理函数的标准形式如下:
代码 1 WindowProc
其参数列表中一共有4个参数,除hwnd参数以外的其他3个参数都有其特定用途,唯有hwnd,它代表目标窗口句柄,而这个句柄完全可以预先保存在类(对象)中然后通过this指针访问它,因此可以在Thunk数据代码调用中转处理函数之前将hwnd参数用this指针值替换掉,从而达到传递this指针的目的。
在中转处理函数中,通过强制类型转换,将hwnd参数硬性解释成this指针,并通过该this指针调用类的非静态窗口处理成员函数,从而达到了让Windows窗口处理回调机制调用窗口类非静态成员函数的目的,至此就完成了窗口类的封装。
3 实现细节
使用Thunk技术封装窗口类的大体实现细节如下:
在窗口类中定义一个THUNK结构类型的成员变量_thunk及初始化该结构的成员函数InitThunk();
THUNK结构实际是一段经过仔细设计的机器代码,当将THUNK变量地址当成函数地址来解释并调用它时,将执行这段代码,在本例中就是要将其解释成WNDPROC类型的指针,即Windows的窗口处理回调函数,这样在回调发生时THUNK代码将被执行。
在窗口类中提供一个attach(HWND h)函数,其将修改目标窗口h的窗口处理函数地址,使其指向this->_thunk,在执行这条语句之前应先调用InitThunk()初始化_thunk成员,使其中保存this指针值,也就是说,每个窗口类对象中都会有一个含有对象地址信息(即this)的成员变量(即_thunk)。
THUNK结构中的其他代码完成如下工作:将Windows调用THUNK数据代码时传递过来的4个参数hwnd,uMsg,wParam,lParam中的hwnd替换成this指针值,然后调用中转处理函数。由于Windows调用THUNK代码时已经将函数参数正确的入栈且中转处理函数的调用方式及参数格式与标准Windows窗口处理函数相同,因此,调用中转处理函数的代码可简单地使用一条跳转指令直接跳转到中转处理函数的地址即可。需要注意的是,如果采用前面介绍的非标准形式的窗口处理函数来传递this指针,则需要增加修改堆栈指针、将this指针入栈等额外的操作,这无疑会增加THUNK数据代码的复杂性。
在窗口类中定义一个静态的窗口处理函数stdProc()充當中转处理函数。在stdProc()中,通过强制类型转换,将hwnd参数强制转换为this指针并通过该this指针调用窗口类的非静态窗口处理成员函数,这样就实现了让Windows窗口处理回调函数调用窗口类非静态成员函数的过程。
通過上述方法封装的窗口类,其每一个窗口对象都有不同的窗口处理函数地址,它指向this->_thunk成员变量。
使用用THUNK技术封装窗口类的本质是:用窗口类的数据成员而不是函数成员“冒充”窗口处理函数,从而拦截到窗口消息。
4 借用ATL中的Thunk代码
实现Thunk技术的关键是给出Thunk数据的对应机器代码。虽然可以通过查询文档等方式获得相应机器代码,但还有更简便的方式:利用ATL模板库中的Thunk代码。
ATL[3]是Microsoft公司继MFC后推出的用于编写COM组件的模板库,它提供了很多模板类以方便COM组件的编写,其中就包含对Windows窗口类的封装。ATL封装窗口类的方法中使用的就是Thunk技术。
ATL提供了4个与Thunk有关的类:_stdcallthunk,CDynamicStdCallThunk,CStdCallThunk,CWndProcThunk,其中前3个类位于
_stdcallthunk是ATL Thunk的基本实现,它包含了全部Thunk数据代码,其定义如下:
#pragma pack(push,1)
struct _stdcallthunk{
DWORD m_mov; // mov dword ptr [esp+0x4], pThis (esp+0x4 is hWnd)
DWORD m_this; //
BYTE m_jmp; // jmp WndProc
DWORD m_relproc; // relative jmp
BOOL Init(DWORD_PTR proc, void* pThis){
m_mov = 0x042444C7; //C7 44 24 0C
m_this = PtrToUlong(pThis);
m_jmp = 0xe9;
m_relproc = DWORD((INT_PTR)proc - ((INT_PTR)this+sizeof(_stdcallthunk)));
// write block from data cache and
// flush from instruction cache
FlushInstructionCache(GetCurrentProcess(), this, sizeof(_stdcallthunk));
return TRUE;
}
//some thunks will dynamically allocate the memory for the code
void* GetCodeAddress(){ return this; }
void* operator new(size_t){ return __AllocStdCallThunk(); }
void operator delete(void* pThunk){ __FreeStdCallThunk(pThunk); }
};
#pragma pack(pop)
代码 2 _stdcallthunk
_stdcallthunk中含有机器指令,因此是硬件相关的,上述是在x86平台下的定义,ATL还提供了几个在其他平台下的_stdcallthunk定义,有兴趣的读者可以查阅相关代码。限于篇幅,本文不对_stdcallthunk的机器代码做过多解释,因为那并不十分重要,在此只给出_stdcallthunk的使用方式:首先调用Init(DWORD_PTR proc, void* pThis)函数初始化该结构,其中传给proc参数的值就是窗口类中定义的静态中转处理函数地址,传给pThis参数的值就是窗口类对象的this指针值。初始化成功后,就可以调用GetCodeAddress()函数获取窗口处理函数的地址(正如代码中给出的那样,它其实就是_stdcallthunk结构的地址)并将目标窗口的窗口处理函数地址修改为GetCodeAddress()的返回值。
CDynamicStdCallThunk是_stdcallthunk的动态分配版本,就像_stdcallthunk定义中的注释说的那样:某些平台下,可执行代码所在的内存必须使用动态分配方式获取,即代码必须位于堆中而不能位于栈中,x86平台下的Windows系统中即是如此,因此,我们不能直接使用_stdcallthunk结构。CDynamicStdCallThunk的定义如下:
#pragma pack(push,8)
class CDynamicStdCallThunk
{
public:
_stdcallthunk *pThunk;
CDynamicStdCallThunk(){ pThunk = NULL; }
~CDynamicStdCallThunk(){
if (pThunk){
delete pThunk;
}
}
BOOL Init(DWORD_PTR proc, void *pThis){
if (pThunk == NULL){
pThunk = new _stdcallthunk;
if (pThunk == NULL){
return FALSE;
}
}
return pThunk->Init(proc, pThis);
}
void* GetCodeAddress(){ return pThunk->GetCodeAddress(); }
};
#pragma pack(pop)
代碼 3 CDynamicStdCallThunk
CDynamicStdCallThunk的使用方式与stdcallthunk类似,在此不做赘述。
CStdCallThunk根据平台的不同,可能是对CDynamicStdCallThunk的包装,也可能是对_stdcallthunk的包装:
typedef CDynamicStdCallThunk CStdCallThunk;
或
typedef _stdcallthunk CStdCallThunk;
代码 4 CStdCallThunk
CWndProcThunk是ATL窗口类中实际使用的结构(类),它除了对CStdCallThunk的成员函数签名进行了类型上的限定外,还定义了一个_AtlCreateWndData数据成员,这个成员在本文编写的窗口类中用不到,CWndProcThunk的定义如下:
class CWndProcThunk
{
public:
_AtlCreateWndData cd;
CStdCallThunk thunk;
BOOL Init(WNDPROC proc, void* pThis){ return thunk.Init((DWORD_PTR)proc, pThis); }
WNDPROC GetWNDPROC(){ return (WNDPROC)thunk.GetCodeAddress(); }
};
代码 5 CWndProcThunk
可以借用ATL提供的4个Thunk结构中的任意一个来编写我们的窗口类,但很明显,使用CWndProcThunk无论在兼容性方面还是在可维护性方面都是最好的,因此,本文使用CWndProcThunk结构封装窗口类。
这里需要说明一下,ATL已经提供了一个封装好的窗口类CWindowImpl,之所以借用ATL的Thunk结构来封装一个新的窗口类而不是直接使用ATL的窗口类是因为:ATL的窗口类额外引入了一些我们不需要的成员,并且其窗口类的行为未必完全满足某些特定需要,又或者对于某些要求精简化的程序来说,CWindowImpl过于复杂了。
5 结论
采用Thunk技术封装的窗口类无论在代码规模还是执行效率上都有很大优势,借用ATL的Thunk结构又可以进一步简化代码编写工作,同时代码的兼容性和可维护性也得到了很大的提高。
参考文献:
[1] Stanley B.Lippman,Josee Lajoie. C++ Primer[M].潘爱民,张丽,译.北京:中国电力出版社,2004.
[2] Charles Petzold. Windows程序设计[M].方敏,张胜,梁路平,译.北京:清华大学出版社,2010.
[3] BRENT RECTOR,CHRIS SELLS.深入解析ATL[M].潘爱民,新语,译.北京:中国电力出版社,2001.