Win32 中的 Thunking:简化到非静态成员函数的调用
对Thunking的简要介绍,以及一个演示如何处理这项工作的简单库。
引言
有时,标准的语言调用机制无法完全满足我们的需求。大多数情况下,这与API的调用约定和签名兼容性有关,特别是**指向成员函数的指针**。考虑以下示例:
// PlatformSDK function, which takes a function pointer as callback
HHOOK SetWindowsHookEx(
int idHook,
int (__stdcall *lpfn)(),
HINSTANCE hMod,
DWORD dwThreadId
);
class MyClass
{
public:
void ourCallbackTarget()
{
// .. do something
}
};
// We want to pass a pointer to ourCallbackTarget to SetWindowsHookEx!
在面向对象的编程中,针对Win32 API,这是一个非常典型的场景。有一个API或第三方库函数,它接受一个函数指针作为参数,例如,在API或库完成了一项耗时的操作或枚举时被调用。由于我们的程序是面向对象的,我们希望传入的回调函数是类的一个成员;而问题就出在这里。SetWindowsHookEx
函数需要一个非成员函数的指针,我们不能简单地将一个成员函数指针转换为适合它的类型。
本文将展示问题所在,说明Thunking为何是一种方便的解决方案,并提出一种使用汇编语言进行简单调用约定调整的方法。最后,将演示并解释一个处理**所有**底层工作的通用库,名为Thunk32
。
Thunk32库的特性
- 创建成员函数回调的一种非常简单的方法,可将它们传递给需要非成员函数指针的API函数。
- 回调可以使用任意数量的参数,任何类型。
- 回调可以返回任何值。
- 非常快速(仅3条指令)的调用约定调整。
Thunk32库要求
- 优秀的 Boost 库
问题
大多数Win32 API回调,例如传递给RegisterClassEx的WndProc
,都被声明为带有WINAPI
或CALLBACK
修饰符的非成员函数。这本质上归结为__stdcall
调用约定。简而言之,调用约定是对参数如何传递给函数以及函数返回时堆栈如何清理的描述。当一个带有__stdcall
约定的函数(参数从右到左传递,调用者在函数返回后弹出其参数)被调用时,堆栈大致如下:
[return address to caller]
[argument 1]
[...]
[argument n]
另一方面,具有相同调用约定的成员函数则看起来像这样:
[return address to caller]
[pointer to class instance] <== "this" pointer here!
[argument 1]
[...]
[argument n]
调用显然不兼容,这就是为什么不允许在需要非成员函数时传递非静态成员函数的指针。当然,有一些方法可以解决这个问题。
如果回调机制以某种方式使我们能够将自定义参数(例如MyClass
实例指针)传递回回调,那么MyClass::callBack
可以轻松声明为static
。Static
成员函数具有与非成员函数相同的堆栈签名,因此可以避免调用约定的麻烦。还可以通过使用一个static
"跳板"函数来进一步适应此方法,该函数调用"真正的"非静态成员回调。一个类似的模板类泛型描述可以在此处找到。在某些情况下,如上面提到的SetWindowsHookEx
,以及RegisterClassEx
/ SetWindowLong
的WndProc
,没有如此方便的方法来传递指针。因此,我们只能依赖其他或多或少棘手的选项,例如将实例指针存储在全局变量中。单词"global"应该立即在你面向代码的思维中触发闪烁的灯光和震耳欲聋的警报,如果它没有,我相信"线程同步"会。
总而言之,这些是需要Thunk的原因:
- 我们不能将一个指向非静态成员函数的指针传递给一个需要指向非成员函数的指针的函数——C++标准不允许我们这样做。由于没有办法将实例指针(
this
)指示给非成员函数,如果我们允许这种转换,那将导致崩溃。 - 我们不能传递指向静态成员函数的指针,因为许多需要回调的API函数无法将用户定义的参数(特别是实例指针)传递回回调。因此,静态函数将无法正确地作用于我们希望它操作的类实例,所以这个解决方案实际上并不是面向对象的。
- 函数对象,如
boost::bind
,不能使用。这仅仅是因为通常用于执行函数对象的operator()
,**也是**一个非静态成员函数。**再次强调,这不能转换为非成员函数!** - Thunk非常快。只需要几条指令就可以调整调用约定,使用它们应该几乎不会产生可察觉的开销。
通过Thunking解决困境
在上面的例子中,我们希望调用SetWindowsHookEx
并将MyClass
类的一个成员作为参数传递,最终调用MyClass::ourCallbackTarget
。此外,我们非常希望解决方案能够扩展到虚函数。因此,如果类MyDerivedClass
公开继承MyClass
,并且回调是virtual void
;那么在MyDerivedClass
的实例上对MyClass::ourCallbackTarget
进行Thunked调用应该能导向MyDerivedClass::ourCallbackTarget
。
那么,到目前为止我们知道了什么?让我们总结一下:
- 我们希望Thunk成为
MyClass
的一个数据成员。C++标准允许我们将数据成员转换为函数指针。 - Thunk将是一系列机器码指令,它应该将我们导向
MyClass::ourCallbackTarget
。 - Thunk必须能够用任何返回类型、任意数量的参数和
__stdcall
调用约定来调用。 - 在Thunk内部,调用约定必须被更改,以匹配从非成员
__stdcall
到非静态成员__thiscall
的过渡。
将Thunk设置为MyClass
的严格机器码成员相对简单。我们只需要一个包含几个合适变量的struct
。不过,此时有几个关键点需要记住。首先,为了使结构体成为连续的内存块,成员之间没有任何填充,无论每个成员的大小如何,我们需要让编译器了解这一点。正如接下来的例子所示,pack pragma将完成这项工作。其次,为了使一块内存可执行,即使有DEP(请参阅http://en.wikipedia.org/wiki/Data_Execution_Prevention),我们也必须分配该块并将其标记为可执行。在本例中,使用带有PAGE_EXECUTE_READWRITE
标志的VirtualAlloc
即可。在附件的库Thunk32
中,使用了一个private
堆对象来代替VirtualAlloc
。每个Thunk将在该堆中分配和释放自己的空间。使用private
堆可以避免VirtualAlloc
分配的块具有与页面大小相对应的最小尺寸,这可能导致在Thunk数量较多时内存使用量较高。
关于到目前为止的示例,如下所示:
struct THUNK
{
// store the current packing, and set 1 byte
// as the new one. this prevents padding.
#pragma pack(push, 1)
ULONG block1; // 4 bytes
UCHAR block2; // 1 byte
ULONG block3; // 4 bytes
#pragma pack(pop) // restore previous packing
};
class MyClass
{
public:
MyClass()
{
callbackThunk = reinterpret_cast<THUNK*>(
VirtualAlloc(NULL,
sizeof(THUNK),
MEM_COMMIT,
PAGE_EXECUTE_READWRITE));
if(callbackThunk == NULL)
{
/* throw an exception to notify about
the allocation problem. */
}
}
~MyClass()
{
VirtualFree(callbackThunk, sizeof(THUNK), MEM_DECOMMIT);
}
THUNK* callbackThunk;
void ourCallbackTarget()
{
}
};
这演示了struct
成员打包,Thunk结构可能是什么样子,以及如何分配/释放所有这些。
下一步是填充有意义的内容。由于VC++中非静态成员函数的默认调用约定是__thiscall
,这本质上与__stdcall
相同,只是实例指针通过ECX
传递;这就是Thunk将要调整回调以适应的。这将需要少量汇编。
lea ecx, instance pointer
mov eax, address of callback
jmp eax
第一行将实例指针放入ECX
。接下来,将回调的地址移入EAX
,最后我们跳转到该地址。瞧,我们的回调被执行了。它很简单,而且很快。
接下来,是将这段代码放入Thunk中。Thunk将以字节码的形式存储。这意味着汇编指令将被翻译成它们的数字/字节表示。要将上面的汇编块转换为字节码,您只需在一个虚拟的VC++项目中编译它,添加一个断点,然后显示反汇编。这应该会显示指令以及表示它们的字节。这些字节可以放在Thunk结构中,并加上类实例和目标函数的正确地址。包装起来并稍微收紧后,Thunk和初始化看起来是这样的:
struct THUNK
{
#pragma pack(push, 1)
unsigned short stub1; // lea ecx,
unsigned long nThisPtr; // this
unsigned char stub2; // mov eax,
unsigned long nJumpProc; // pointer to destination function
unsigned short stub3; // jmp eax
#pragma pack(pop)
};
MyClass::MyClass()
{
callbackThunk = reinterpret_cast<THUNK*>(
VirtualAlloc(NULL,
sizeof(THUNK),
MEM_COMMIT,
PAGE_EXECUTE_READWRITE));
if(callbackThunk == NULL)
{
/* throw an exception to notify about
the allocation problem. */
}
// See declaration of the THUNK struct for a byte code explanation
callbackThunk->stub1 = 0x0D8D;
callbackThunk->nThisPtr = reinterpret_cast<ULONG>(this);
callbackThunk->stub2 = 0xB8;
// Fetch address to the destination function
callbackThunk->nJumpProc = brute_cast<ULONG>(&MyClass::ourCallbackTarget);
callbackThunk->stub3 = 0xE0FF;
// Flush instruction cache. May be required on some architectures which
// don't feature strong cache coherency guarantees, though not on neither
// x86, x64 nor AMD64.
FlushInstructionCache(GetCurrentProcess(), callbackThunk, sizeof(THUNK));
}
上面代码中唯一需要描述的是对brute_cast
的调用。这只是一个模板函数,它强制在几乎任何类型之间进行转换,这里用于将一个指针转换为ULONG
。这是必需的,因为没有合法的途径可以将成员函数的地址转换为任意指针。您可以将成员函数转换为指针的引用,然后取消引用并将其转换为ULONG(phew)
,但奇怪的是,这**不能在类本身内部完成**(这正是本文使用的示例)。brute_cast
函数是一种清晰且相当体面的处理转换的方式,成本也同样很低。在额外的指针地址解引用和brute_cast
这两种情况下,编译器应该(并且将会)将其优化为一条指令。
从&Class::MemberFunction
获得的实际地址指向以下两件事之一:如果您启用了增量链接,它可能指向另一个Thunk,该Thunk跳转到函数的vcall入口(如果函数是虚函数),否则跳转到实际函数。如果未启用增量链接,指针将指向vcall或函数本身。有关虚函数和vtbl/vcall的更多信息,请参阅http://en.wikipedia.org/wiki/VTBL。至于brute_cast
,它看起来是这样的:
template<typename Target, typename Source>
inline Target brute_cast(const Source s)
{
BOOST_STATIC_ASSERT(sizeof(Target) == sizeof(Source));
union { Target t; Source s; } u;
u.s = s;
return u.t;
}
在您提问之前,BOOST_STATIC_ASSERT
是一个编译时断言,在这里用于防止不同大小类型之间的转换(例如,32位int
到8位char
)。
所以,这就是了。一种简单而有效的方法来调用Thunk。附件的库将此处展示的原理封装到一个类型安全的框架中,该框架可以很容易地从任何面向对象的Win32应用程序中使用。
Using the Code
使用附件的Thunk32
库来实现上述目标很简单。下面是一个演示:
我们有一个dummy函数,它看起来很像Winapi和PlatformSDK中的函数,它接受一个函数指针:
void someCallbackMechanism(int (__stdcall *func)(int, int))
{
(*func)(10, 10);
}
示例类增加了两个额外的成员变量,即Thunk32
类的实例,它们负责所有工作:
class MyClass
{
public:
indev::Thunk32<MyClass, int(int, int)> simpleCallbackThunk;
MyClass()
{
simpleCallbackThunk.initializeThunk(this, &MyClass::simpleCallback);
}
virtual int simpleCallback(int i1, int i2)
{
cout << "MyClass::simpleCallback hit" << endl;
return 10;
}
};
ThunksimpleCalbackThunk
有两个模板参数;函数所属的类类型,以及函数的签名。对于上面的声明,类类型是MyClass
,签名匹配一个返回int
并接受两个int
参数的函数。构造函数中的初始化将Thunk与指向this
的实例指针以及目标函数&MyClass::simpleCallback
的地址进行关联。
现在我们扩展前面文章中的示例,以包含一个派生类。这个MyDerivedClass
没什么特别之处——它只是提供了一个simpleCallback
函数的新实现:
class MyDerivedClass : public MyClass
{
public:
virtual int simpleCallback(int i1, int i2)
{
cout << "MyDerivedClass::simpleCallback hit, "
<< "heading for parent class" << endl;
return MyClass::simpleCallback(i1, i2);
}
};
下面的示例将首先创建一个基类实例,然后将Thunked回调传递给someCallbackMechanism
。someCallbackMechanism
所做的调用将与直接调用MyClass::simpleCallback
时一样,除了Thunk应用的调用约定调整。之后,将创建一个派生类实例,并用基类引用它。通过这个基类引用,再次调用someCallbackMechanism
。这一次,someCallbackMechanism
中的回调将首先命中Thunk,然后是vtable条目,最后是派生的MyDerivedClass::simpleCallback
;就像在基类引用上直接调用该函数一样。
int main()
{
/*
First demo: Should have the callback mechanism call MyClass::simpleCallback.
*/
cout << "First demo" << endl;
MyClass myClassInstance;
someCallbackMechanism(myClassInstance.simpleCallbackThunk.getCallback());
cout << endl;
/*
Second demo: Should have the callback mechanism call MyDerivedClass::simpleCallback,
which goes on to call MyClass::simpleCallback.
*/
cout << "Second demo" << endl;
MyDerivedClass myDerivedInstance;
MyClass& myClassReference = myDerivedInstance;
someCallbackMechanism(myClassReference.simpleCallbackThunk.getCallback());
cout << endl;
cout << "Press enter to exit" << endl;
cin.get();
return 0;
}
MyDerivedClass::simpleCallback
碰巧在向标准输出写入一些文本后调用基类的simpleCallback
。这意味着打印的文本将是:
First demo
MyClass::simpleCallback hit
Second demo
MyDerivedClass::simpleCallback hit, heading for parent class
MyClass::simpleCallback hit
Press enter to exit
关注点
仔细阅读源代码。它在任何标准下都不冗长,但确实使用了一些优秀的boost框架提供的方便的预处理器函数。
历史
- 2006年12月16日(文章):上传了文章和库版本1.0.1。
- 2006年12月19日(库):在初始化Thunk值后添加了对
FlushInstructionCache
的调用,这在某些嵌入式CPU上可能需要。谢谢Stephen Hewitt。 - 2006年12月19日(库):更新为使用
__thiscall
调整。 - 2006年12月22日(库):引入了
private
堆对象来代替VirtualAlloc
,以避免潜在的高内存使用量。