Windows系统上创建线程可以使用CreateThread() API,这个API的原型是:
HANDLE WINAPI CreateThread(
__in LPSECURITY_ATTRIBUTES lpThreadAttributes,
__in SIZE_T dwStackSize,
__in LPTHREAD_START_ROUTINE lpStartAddress,
__in LPVOID lpParameter,
__in DWORD dwCreationFlags,
__out LPDWORD lpThreadId
);
第二个参数就是指定新线程栈空间的大小,如果这个参数输入0,则Windows给线程指定一个默认值,这个默认值的大小是1M字节(这个数字来自MSDN文档)。关于dwStackSize参数有个很有意思的细节,后面会介绍到。对于使用默认栈空间大小的线程来说,调用算法系列文章第7篇提到的递归版本的IsEvenNumber()函数时,当n的值大于10000时就会导致栈溢出。在Windows系统上栈溢出会导致线程的意外终止,这种线程的意外终止通常都会导致整个软件无法正常工作。如果在递归计算的过程中能够提前预知到这种情况的堆栈溢出并终止后续的递归运算,对提高程序的安全性和健壮性都很有帮助,本话题就讨论了一种能够应用与Windows系统的检测方法。
检测的方法很简单,就是在递归算法的下一次嵌套调用之前,判断一下线程当前栈地址与线程栈空间边界的差值,当差值小于事先指定的安全值时就设置出错标志,并终止进一步的嵌套调用,使已经进行过的递归调用安全地“回溯”到算法起始位置。设置安全值的意义在于当溢出即将发生时,需要做一些特殊处理,这些特殊处理可能涉及一些函数调用(包括操作系统的API),因此需要预留一些栈空间来保证这些操作正常进行。要对函数递归调用嵌套太深导致的线程堆栈溢出进行检测,必须要知道两个属性,一个是线程当前栈指针,另一个是线程栈空间的边界。线程栈空间的边界与栈的增长方向有关,Windows系统的线程堆栈是从高地址方向向低地址方向增长的,因此栈边界就是线程栈基址与线程栈空间大小的差值。
Windows提供了API GetThreadContext()用于获取线程某一时刻的上下文信息(对于64位的应用程序,对应的API是Wow64GetThreadContext()),其中寄存器信息部分包括ESP寄存器的值,这个API的原型是:
BOOL WINAPI GetThreadContext(
__in HANDLE hThread,
__in_out LPCONTEXT lpContext
);
使用GetThreadContext()获取线程当前栈指针的代码如下:
CONTEXT thCtx;
HANDLE hThread = ::GetCurrentThread();
thCtx.ContextFlags = CONTEXT_FULL;
/*函数调用点 A*/
if(::GetThreadContext(hThread, &thCtx))
{
// using thCtx.Esp;
}
这段代码存在一个问题,就是thCtx.Esp的值实际上是线程运行在GetThreadContext()函数内部某个位置时的栈指针,并不是“函数调用点 A”处的栈指针。通过多次对比实验,我们发现thCtx.Esp的值和“函数调用点 A”处实际的ESP值存在一个固定的差值(thCtx.Esp的值比“函数调用点 A”处实际的ESP值小),通过补偿这个差值,可以比较准确的得到线程运行到某位位置时的栈指针。
如果编译器支持嵌入式汇编代码,则可以直接通过ESP寄存器获取线程当前位置的栈指针,对于微软的编译器,可以这样做:
DWORD stack = 0;
__asm
{
mov eax, esp
mov stack, eax
}
相对于线程栈指针来说,获取线程栈基址和栈空间边界是个比较麻烦的事情,因为没有API可以直接获取这些值,因此只能用到一些所谓的未公开的文档中提到的方法。在介绍这些方法之前首先要介绍一个未公开的数据结构:TEB(Thread Environment block)。TEB是记录线程信息的一个重要的数据结构,系统为每个线程创建一个对应的TEB结构存储线程相关的信息,根据未公开的文档介绍,在Ring 3层次上的TEB偏移 0x04位置就是线程的栈基址,偏移0x08位置就是线程栈空间的下限(Windows系统的栈是向低地址方向增长的)。有了这个信息,剩下的事情就是找到线程的TEB在内存中的地址。这就需要另一个重要的,但是很少有人关注的信息,那就是FS段选择器永远指向当前线程的TEB结构,其中0x18偏移位置就是TEB在内存中的镜像地址。有了这个镜像内存地址,就可以通过+0x04偏移得到线程栈基址,+0x08偏移得到线程栈空间边界。下面就是获取这两个值的封装函数,用了嵌入式汇编代码:
DWORD GetCurrentThreadStackBase()
{
DWORD stackBase = 0;
__asm
{
mov eax, fs:[18h] /*TEB*/
mov eax, [eax + 0x04]
mov stackBase, eax
}
return stackBase;
}
DWORD GetCurrentThreadStackLimit()
{
DWORD stackLimit = 0;
__asm
{
mov eax, fs:[18h] /*TEB*/
mov eax, [eax + 0x08]
mov stackLimit, eax
}
return stackLimit;
}
以上的偏移位置都是基于Windows XP系统的,其他版本的Windows可能会有变化,但是都可以从网上查到,也可以通过调试符号自己计算。至此,所有的准备功课都做完了,以上文提到的递归版本的IsEvenNumber()函数为例,可以这样进行栈溢出预防:
bool IsEvenNumber(int n)
{
DWORD stack = GetCurrentThreadStack();
DWORD stackLimit = GetCurrentThreadStackLimit();
if(overSign == 1)
{
return false; /*出错了,需要“回溯”*/
}
if((stack - stackLimit) < STACK_LIMIT_OPT)
{
/*可以在这里安排设置错误标志的代码*/
overSign = 1;
return false; /*强制返回,使得前面的递归调用安全地“回溯”*/
}
if(n >= 2)
return IsEvenNumber(n - 2);
else
{
if(n == 0)
return true;
else
return false;
}
}
STACK_LIMIT_OPT就是前面提到的那个安全值,这个值的大小需要根据出错处理流程的差异进行调整。
前文提到,CreateThread() API函数的dwStackSize参数隐藏了一些很有意思的细节,这里就说明一下。MSDN文档中提到,调用CreateThread() 函数创建线程时,如果dwStackSize参数传0值,Windows给线程指定的栈空间大小是系统默认值,也就是1M字节。但是实际上这1M字节并不是立即保留给线程独立使用的,而是首先预保留4K字节,随着线程的使用逐步增加。也就是说,线程TEB结构中的栈空间边界并不是一开始就设置为“线程栈基址-1M字节”后的值,而是“线程栈基址-4K字节”后的值。通过调试可以观察到,随着递归调用的进行,线程TEB结构中的栈空间边界值不断变化,直到最后达到“线程栈基址-1M字节”为止。这是Windows系统为节省内存做的一种策略,使得同等条件下系统能够支持创建更多的线程。如果调用CreateThread() 函数创建线程时,指定了dwStackSize的值会怎样呢?结果就是Windows一下子为线程保留了dwStackSize指定大小的栈空间(会按照64k为单位对dwStackSize进行圆整),栈空间边界的值初始化为“线程栈基址-dwStackSize”,并保持不变。
了解到这个细节之后,你就会发现IsEvenNumber()函数中所做的溢出判断是不安全的,在dwStackSize参数使用了0值的情况下会失效,因为TEB结构中的线程栈空间边界是个不断变化的值。在这种情况下,通过线程栈基址结合栈空间大小进行判断可能会更安全一点,这里就不再赘述了,读者可以使用本文提到的GetCurrentThreadStackBase()函数自行修改。