Thunk 及其用途
Thunk 简介及其在回调处理、接口封送处理和支持 C++ 中的多重继承中的应用。
引言
Thunk 是一项非常有用的技术。在这篇文章中,我将讨论 thunk 的三个典型用途
- 将回调转换为类的成员函数。
- 提供接口代理。
- 支持 C++ 中多重继承下的虚函数。
在开始之前,让我们先对 thunk 是什么有一个大致的了解。Thunk 通常是一段机器代码,它拦截客户端的调用,并在跳转到客户端调用的实际实现之前修改调用堆栈。
将回调转换为类的成员函数
库通常需要回调。回调的问题在于它们必须实现为全局函数或静态函数,这在面向对象的开发环境中可能不方便。例如,Win32 程序要求我们编写一个 WNDPROC
回调函数,其中通常有一个大的 switch/case
块,更糟糕的是,我们还需要在 WNDPROC
中定义一些静态变量来跟踪调用之间的状态。如果我们能将回调转换为类的成员函数,那么我们就可以使用成员函数而不是大的 switch/case
块,并且可以使用成员变量而不是函数的静态变量来跟踪状态。
Thunk 可以实现这种魔法。回调的问题在于它们没有 this
指针,因此 thunk 的主要工作是将 this
指针添加到调用堆栈,然后调用回调。一旦进入回调,我们就可以从调用堆栈中获取 this
指针,并使用 this
指针调用成员函数。但是,这里的回调是由 thunk 调用,而不是由任何库调用,所以我们仍然需要为库提供一个地址,每当库调用回调时都会调用该地址。而这个地址就是我们的 thunk 的地址。因此,这个过程可以概括为:
- 库调用 thunk。
- Thunk 将
this
指针添加到调用堆栈。 - Thunk 将调用转发给实际的回调。
- 回调从调用堆栈中获取
this
指针,并使用this
指针调用成员函数。
这项技术用于 ATL 的 CWindowImpl
实现中,所以我只是从 ATL 复制了代码并对其进行了修改/简化。顺便说一句,阅读这个示例不需要任何 ATL 知识。但是,如果您已经熟悉 ATL,可以跳过此示例。
使用代码:WindowWithThunk 项目
与步骤 1 相关的代码如下:
LRESULT CALLBACK StartWindowProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
// …
// Use the address of the thunk as the address of the callback, so that
// whenever the library calls the callback, it ends up calling the thunk.
WNDPROC pProc = (WNDPROC)pThis->m_pThunk;
::SetWindowLong(hWnd, GWL_WNDPROC, (LONG)pProc);
// …
}
与步骤 2 相关的代码如下:
LRESULT CALLBACK StartWindowProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
CWindowWithThunk* pThis = (CWindowWithThunk*)g_ModuleData.ExtractWindowObj();
// …
pThis->m_pThunk->Init((DWORD)TurnCallbackIntoMember, pThis);
// …
}
void _stdcallthunk::Init(DWORD proc, void* pThis)
{
// 0x042444C7 is the same as "mov dword ptr[esp+0x4]," on the x86 platform,
// so the following statements are the same as "mov dword ptr [esp+0x4], pThis"
// where [esp+0x4] is the hWnd argument that is pushed onto the call stack
// by the Windows. Here the this pointer overwrites the hWnd, but there is no harm
// because the hWnd has already been saved to the object to which the this
// pointer refers to. See figure 1.
m_mov = 0x042444C7; //C7 44 24 0C
m_this = PtrToUlong(pThis);
// …
}
与步骤 3 相关的代码如下:
LRESULT CALLBACK StartWindowProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
// …
pThis->m_pThunk->Init((DWORD)TurnCallbackIntoMember, pThis);
// …
}
void _stdcallthunk::Init(DWORD_PTR proc, void* pThis)
{
// After the this pointer has been added to the call stack, now jump to the
// actual callback (in this case, TurnCallbackIntoMember)
m_jmp = 0xe9;
m_relproc = DWORD((INT_PTR)proc - ((INT_PTR)this+sizeof(_stdcallthunk)));
}
与最后一步相关的代码如下:
LRESULT CALLBACK TurnCallbackIntoMember(HWND hWnd, UINT message,
WPARAM wParam, LPARAM lParam)
{
// Now fetch the this pointer from the call stack
CWindowWithThunk* pThis = (CWindowWithThunk*)hWnd;
// and call member functions using the this pointer
pThis->OnPaint();
}
提供接口代理
在 C++ 中,接口是没有任何实现的成员函数声明的集合。接口指针是指向 vptr 的指针,vptr 又指向实现接口中声明的那些成员函数的函数数组。接口代理(以下简称代理)对于使用接口的客户端来说与接口相同。当客户端使用代理指针调用方法时,客户端最终会调用代理的实现。代理的实现可以做任何它想做的事情,例如获取客户端推送到调用堆栈上的参数,然后将它们转发给接口的实际实现。例如,在 COM Marshaling 或任何其他 RPC 环境中,当客户端从 COM 请求接口指针时,COM 只会将代理指针返回给客户端。然后客户端使用代理指针调用方法,这最终会调用代理的实现。然后代理的实现从调用堆栈中获取参数,打包它们,并将它们发送到远程机器或其他公寓,在那里调用接口的实际实现。那么,我们应该如何为接口编写代理呢?一种答案是为每个接口编写单独的代理。但这很繁琐。一个更好的解决方案是为所有接口提供一个单一的代理。在 COM 中,有一个通用的封送处理程序(或类型库封送处理程序),它可以在类型库的帮助下为所有接口提供一个单一的代理。
所有接口的单一代理意味着我们使用单一的代理实现(方法定义)来处理客户端从所有接口发出的所有方法调用。因此,这个单一代理实现应该知道客户端正在调用哪个接口的哪个方法,因为只有知道这一点,我们才能确定调用堆栈上期望哪些参数。当客户端使用 IID(接口 ID)请求接口指针时,客户端请求的是指向 vptr 的指针,vptr 又指向 vtable。因此,我们可以创建一个 vtable,并将其与客户端提供的 IID 和方法索引相关联。方法索引是接口内方法的索引,也是 vtable 内方法的索引。如果我们知道接口中方法的总数,我们就可以知道方法的索引,而通过查询类型库使用 IID,我们就可以知道接口中方法的总数。但是,在 x86 平台上,vtable 只是一个 DWORD 数组,所以我们不能简单地用 IID 和方法索引填充 vtable。这里,我们可以再次使用 thunk。我们为每个方法准备一个 thunk,用 IID 和方法索引初始化每个 thunk,然后用每个 thunk 的地址填充 vtable。thunk 的主要工作是将 IID 和方法索引都推送到调用堆栈上,然后将调用转发给单一代理实现。单一代理实现现在可以通过使用 IID 和方法索引来确定正在调用哪个接口的哪个方法。这个过程可以概括为:
- 客户端使用 IID 请求接口指针。
- 我们用 IID 和方法索引初始化每个 thunk;用每个 thunk 的地址填充 vtable。见图 2。
- 将代理指针(指向在步骤 2 中创建的 vtable 的 vptr 的指针)返回给客户端。
- 客户端使用代理指针调用方法(客户端最终调用在步骤 2 中初始化的 thunk)。
- Thunk 将 IID 和方法索引都推送到堆栈上,然后调用单一代理实现。见图 3。
- 单一代理实现通过查询类型库使用 IID 和方法索引来确定期望哪些参数。现在,单一代理实现可以做任何它想做的事情。
使用代码:UniversalProxy 项目
与步骤 1 相关的代码如下:
int _tmain(int argc, _TCHAR* argv[])
{
IInterface_Zero* pI0;
// The client requests an interface pointer using an IID of 0
ProxyProvider(0, (void**)&pI0);
// …
}
与步骤 2 和 3 相关的代码如下:
void ProxyProvider(DWORD iid, void** ppv)
{
// Query the type library for the total number of methods within the interface
// using iid
DWORD methods = FakeTypeLibrary::GetNumOfMethods(iid);
DWORD** vptr = new DWORD*;
DWORD* vtable = new DWORD[methods];
for(DWORD midx = 0; midx < methods; ++midx)
{
thunk* pThunk = new thunk();
// The this pointer occupies 4 bytes
WORD bytes_to_pop = FakeTypeLibrary::GetAugumentStackSize(iid, midx) + 4;
// Initialize the thunk with IID and method index
pThunk->Init(iid, midx, bytes_to_pop);
// Fill the vtable with the address of each thunk
vtable[midx] = (DWORD)pThunk;
}
(*vptr) = vtable;
*ppv = vptr;
}
与步骤 4 相关的代码如下:
int _tmain(int argc, _TCHAR* argv[])
{
// …
pI0->DoSomething(3,'a');
// …
}
与步骤 5 相关的代码如下:
void Init(DWORD iid, DWORD midx, WORD bytes)
{
// push iid (interface id)
// push midx (method index)
// call ProxyImplementation
// add esp, 8 (pop iid and midx)
// retn bytes_to_pop (return and pop the normal arguments of the method)
push_interface = 0x68;
interface_id = iid;
push_method = 0x68;
method_idx = midx;
call = 0xe8;
func_offset = (DWORD)&ProxyImplementation - (DWORD)&add_esp;
// …
}
与最后一步相关的代码如下:
static void _cdecl ProxyImplementation(DWORD midx, DWORD iid, DWORD client_site_addr,
void* pThis /*, arg0, ..., argn_1*/)
{
// …
// Use iid and midx to determine what arguments should be on the stack
// In fact we should determine the arguments by querying the type library,
// I hard-code here for the purpose of simplification.
if(iid == 0)
{
if(midx == 0)
{
// Fetch the arguments from the stack
BYTE* arg_addr = (BYTE*)&pThis + 4;
int arg0 = *(int*)arg_addr;
arg_addr += 4;
char arg1 = *(char*)arg_addr;
// Call the real implementation of the interface
pRealImpl_Zero->DoSomething(arg0,arg1);
}
}
//…
}
支持 C++ 中的多重继承下的虚函数
考虑以下代码:
class Base1
{
public:
virtual ~Base1(){}
private:
int Base1Data;
};
class Base2
{
public:
virtual ~Base2()
{
cout << this->Base2Data;
}
private:
int Base2Data;
};
class Derived : public Base1, public Base2
{
public:
virtual ~Derived()
{
cout << this->DerivedData;
}
private:
int DerivedData;
};
void DeleteObj(Base2* pObj)
{
delete pObj;
}
int main()
{
Base2* pB2 = new Derived();
DeleteObj(pB2);
return 0;
}
Base2
指针 pB2
被赋予了 Derived
对象的地址。但是,必须在将新的 Derived
对象的地址保存到 pB2
之前,将其调整为指向其 Base2
子对象。执行此操作的代码由编译器生成。
Derived* temp = new Derived;
Base2 *pB2 = temp? temp+sizeof(Base1) : 0;
现在,让我们看一下 DeleteObj()
函数中的语句“delete pObj
”。此时,编译器不知道 pObj
指向什么对象。如果 pObj
指向 Base2
对象,那么 pObj
(作为 this
指针)应该被推送到调用堆栈上,并且应该调用 Base2::~Base2()
。如果 pObj
指向 Derived
对象,那么在将其推送到调用堆栈上并调用 Derived::~Derived()
之前,应该将 pObj
重新调整以指向完整的 Derived
对象。但是,由于编译器不知道 pObj
指向什么对象,因此无法确定是否需要重新调整 pObj
。因此,这个决定和重新调整只能在运行时进行。
在这里,thunk 可以再次提供帮助。我们可以为每个需要调整/重新调整 this
指针的虚函数创建一个 thunk,然后用 thunk 的地址填充 vtable 插槽。thunk 的主要工作是调整 this
指针,然后跳转到实际的虚函数。thunk 看起来像:
// Pseudo C++ code
Base2_destructor_thunk:
this -= sizeof(base1);
Derived::~Derived(this);
现在,让我们再次看看 DeleteObj()
函数。当 pObj
指向 Base2
对象时,析构函数的 vtable 插槽包含 Base2::~Base2()
的地址,因此“delete pObj
”会简单地调用 Base2::~Base2()
。当 pObj
指向 Derived
对象时,析构函数的 vtable 插槽包含 thunk 的地址(在这种情况下是 Base2_destructor_thunk
),因此“delete pObj"
调用 thunk,thunk 会调整 this
指针,然后跳转到 Derived::~Derived()
。
结论
还有其他 thunk 的用途,例如 API 挂钩、消息过滤等。但其思想是相同的:拦截调用并修改调用堆栈。WindowWithThunk 示例将 this
指针插入到调用堆栈中;UniversalProxy 示例将两个额外的参数推送到调用堆栈上;MultipleInheritance 示例修改了调用堆栈上已有的 this
指针。
致谢和参考
- ATL Internals: Working with ATL 8, Second Edition 作者:Christopher Tavares, Kirk Fertitta, Brent Rector, Chris Sells。出版商:Addison Wesley Professional。
- Inside the C++ Object Model 作者:Stanley B. Lippman。出版商:Addison Wesley。