寫在前面
此系列是本人一個字一個字碼出來的,包括示例和實驗截圖。由於系統內核的復雜性,故可能有錯誤或者不全面的地方,如有錯誤,歡迎批評指正,本教程將會長期更新。 如有好的建議,歡迎反饋。碼字不易,如果本篇文章有幫助你的,如有閑錢,可以打賞支持我的創作。如想轉載,請把我的轉載信息附在文章后面,並聲明我的個人信息和本人博客地址即可,但必須事先通知我。
你如果是從中間插過來看的,請仔細閱讀 羽夏看Win系統內核——簡述 ,方便學習本教程。
看此教程之前,問幾個問題,基礎知識儲備好了嗎?保護模式篇學會了嗎?練習做完了嗎?沒有的話就不要繼續了。
🔒 華麗的分割線 🔒
並發與同步
並發是指多個線程在同時執行。單核是分時執行,不是真正的同時,而多核是在某一個時刻,會同時有多個線程再執行。同步則是保證在並發執行的環境中各個線程可以有序的執行。但是這個定義是不太准確,我們給幾個示例請判斷如下代碼是否是並發,先看如下代碼:
void proc1()
{
int x = 5;
printf("%d",x);
}
void proc2()
{
int x = 6;
printf("%d",x);
}
請問上面這兩個函數同時執行,存在不存在並發問題呢?其實並不存在,因為局部變量是在棧中分配的,你用你的,我用我的,互不影響。如果是下面的代碼:
int x = 6;
void proc1()
{
printf("%d",x);
}
void proc2()
{
printf("%x",x);
}
這個也是不存在並發問題的,因為這兩個函數雖然都是用到的是同一個變量,但是,它們並沒有修改此變量,這兩個函數怎么執行都不會互相影響,故也不存在並發問題。但是,代碼這樣一改就不行了:
int x = 6;
void proc1()
{
x--;
}
void proc2()
{
++x;
}
因為這兩個函數執行都會修改全局變量,它們的執行會影響結果,故存在並發問題。如果其他操作用到這個值,將會影響判斷。
臨界區
在學習臨界區之前,先看看如下代碼:
int x = 6;
void proc1()
{
x++;
}
請問這一行x++代碼,是不是線程安全的?
答案是不是,盡管我們的C代碼是只有一句,但是翻譯成匯編,它就不是一句了。我們假設全局變量x經過編譯器編譯后的地址為0x12345678,那么匯編就翻譯成如下幾句匯編:
mov eax,[0x12345678]
add eax,1
mov [0x12345678],eax
如果任何一處匯編執行的時候被時鍾中斷進行切換線程,大量的線程執行此函數的結果是不一樣的。比如線程1執行到add eax,1完成被切換走了,線程2執行完此流程,那么,最終這兩個線程執行的結果x的值為2,這就是典型的線程安全問題。如果我改成用INC DWORD PTR DS:[0x12345678]這個匯編來實現此函數功能,這代碼安全嗎?
對於單核,這個是沒問題的。但是對於多核這是有問題的。就和同時執行兩個線程來修改同一個變量的原因是一樣的,CPU實現肯定是讀取地址獲取數值,然后使用加法器進行加一,然后放回去。但是如何實現多核下的線程安全呢?
如下匯編就解決了這個問題:
LOCK INC DWORD PTR DS:[0x12345678]
對,前面加一個LOCK,這個也是一條匯編指令。它是一個鎖,鎖的是你要執行指令的地址,而不是匯編執行的執行。也就是說0x12345678在同一時刻只能由一個核進行訪問修改。這就解決了多核下的線程安全,這種操作也被稱之為原子操作,雖然原子可以再分,但是意思就是不能再分割的操作。
Windows為了方便我們應用原子操作,也封裝好了幾個函數:InterlockedIncrement、InterlockedExchangeAdd、InterlockedDecrement、InterlockedFlushSList、InterlockedExchange、InterlockedPopEntrySList、InterlockedCompareExchange、InterlockedPushEntrySList。由於怎樣用函數不是我們教程講解的重點,我們研究的是怎樣實現,所以這塊地方就不贅述了。我們來看看微軟是怎么實現原子操作加的:
; int __fastcall InterlockedIncrement(LPLONG lpAddend)
public InterlockedIncrement
InterlockedIncrement proc near
mov eax, 1
lock xadd [ecx], eax
inc eax
retn
InterlockedIncrement endp
這幾行就是實現原子操作的。由於是調用約定是快速調用,這個lpAddend參數是由ecx傳遞的。xadd指令就是實現先交換,再相加放到目的操作數,如下是白皮書描述:
Description
Exchanges the first operand (destination operand) with the second operand (source operand), then loads the sum of the two values into the destination operand. The destination operand can be a register or a memory location; the source operand is a register.Operation
TEMP ← SRC + DEST;
SRC ← DEST;
DEST ← TEMP;
此原子加法前面加了鎖而ecx指向的變量是多個線程可能訪問的,故線程安全。
如果我寫了balabala一個代碼塊,想要解決線程安全問題,我想使用LOCK方式解決可以嗎?比如下面這樣:
LOCK INC DWORD PTR DS:[0x12345678]
LOCK INC DWORD PTR DS:[0x12345678]
LOCK INC DWORD PTR DS:[0x12345678]
LOCK INC DWORD PTR DS:[0x12345678]
LOCK INC DWORD PTR DS:[0x12345678]
這樣雖然每一行都保證只能一個線程占有資源,但是保證這些代碼只能由一個線程運行,還是不能實現的,所以不能實現線程安全。
好了,鋪墊了這么多,我們來講講臨界區是啥。一次只允許一個線程進入直到離開,這樣的東西就是臨界區,打比方一堆人排隊上一個單間廁所,一次只能由一個人進一個人出,如果人是線程,坑是變量等資源,那么這個廁所就是所謂的臨界區。用代碼演示一下:
DWORD dwFlag = 0; //實現臨界區的方式就是加鎖
//鎖:全局變量 進去加一 出去減一
if(dwFlag == 0) //進入臨界區
{
dwFlag = 1;
//.......
//一堆代碼
//.......
dwFlag = 0; //離開臨界區
}
當然這個代碼是有問題的,不能夠實現臨界區,只是思想展示的示例。如果真正的實現臨界區,就必須用匯編,如下是實現進入臨界區的代碼:
Lab:
mov eax,1
lock xadd [Flag],eax
cmp eax,0
jz endLab
dec [Flag]
//調用線程等待Sleep ……
endLab:
ret
其中Flag是上面的全局變量,也就是所謂的鎖。我們再看看如何退出臨界區:
lock dec [Flag]
為什么加匯編加lock我就不贅述了。不過上面的實現,性能比較差,因為一旦兩個線程同時執行,一個線程正在跑着,另一個就去睡大覺了。對於臨界區,就介紹這么多。
自旋鎖
自旋鎖也是用來解決同步問題的,為什么這個名字,我們首先看看微軟是如何實現自旋鎖這個東西的,故先定位到如下函數:
; void __stdcall KeAcquireSpinLockAtDpcLevel(PKSPIN_LOCK SpinLock)
public KeAcquireSpinLockAtDpcLevel
KeAcquireSpinLockAtDpcLevel proc near
SpinLock = dword ptr 4
mov ecx, [esp+SpinLock]
loc_469998: ; CODE XREF: KeAcquireSpinLockAtDpcLevel+14↓j
lock bts dword ptr [ecx], 0
jb short loc_4699A2
retn 4
; ---------------------------------------------------------------------------
loc_4699A2: ; CODE XREF: KeAcquireSpinLockAtDpcLevel+9↑j
; KeAcquireSpinLockAtDpcLevel+18↓j
test dword ptr [ecx], 1
jz short loc_469998
pause
jmp short loc_4699A2
KeAcquireSpinLockAtDpcLevel endp
lock bts dword ptr [ecx], 0這行代碼的作用是將ECX指向數據的第0位置1,如果[ECX]原來的值為0,那么CF = 0,否則CF = 1,lock保證了只能單核處理。
如果CF = 1,也就是說這個已經上鎖了,就跳轉到loc_4699A2這個地方,如果鎖上着,就繼續往下走,pause會讓CPU暫停一會,然后死循環,轉起圈,直至鎖被釋放,這就是所謂的自旋鎖。
自旋鎖只對於多核才有意義,如果是單核反而會造成大量的性能損失。自旋鎖與臨界區、事件、互斥體一樣,都是一種同步機制,都可以讓當前線程處於等待狀態,區別在於自旋鎖不用切換線程,有關自旋鎖的知識就介紹這么多。
本節練習
本節的答案將會在下一節的正文給出,務必把本節練習做完后看下一個講解內容。不要偷懶,實驗是學習本教程的捷徑。
俗話說得好,光說不練假把式,如下是本節相關的練習。如果練習沒做好,就不要看下一節教程了,越到后面,不做練習的話容易夾生了,開始還明白,后來就真的一點都不明白了。本節練習不多,請保質保量的完成,答案解析將在正文展示。
1️⃣ 自己實現一個臨界區,不得使用本篇章介紹的實現。
2️⃣ 多核情況下,實現在高並發的內核函數內部進行Hook,而不能出錯。
