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()函數自行修改。