漫談相容核心之十二:Windows的APC機制
阿新 • • 發佈:2019-01-04
前兩篇漫談中講到,除ntdll.dll外,在啟動一個新程序執行時,PE格式DLL映像的裝入和動態連線是由ntdll.dll中的函式LdrInitializeThunk()作為APC函式執行而完成的。這就牽涉到了Windows的APC機制,APC是“非同步過程呼叫(Asyncroneus Procedure Call)”的縮寫。從大體上說,Windows的APC機制相當於Linux的Signal機制,實質上是一種對於應用軟體(執行緒)的“軟體中斷”機制。但是讀者將會看到,APC機制至少在形式上與軟體中斷機制還是有相當的區別,而稱之為“非同步過程呼叫”確實更為貼切。
APC與系統呼叫是密切連繫在一起的,在這個意義上APC是系統呼叫介面的一部分。然而APC又與裝置驅動有著很密切的關係。例如,ntddk.h中提供“寫檔案”系統呼叫ZwWriteFile()、即NtWriteFile()的呼叫介面為:
NTSTATUS
NTAPI
ZwWriteFile(
IN HANDLE FileHandle,
IN HANDLE Event OPTIONAL,
IN PIO_APC_ROUTINE ApcRoutine OPTIONAL,
IN PVOID ApcContext OPTIONAL,
OUT PIO_STATUS_BLOCK IoStatusBlock,
IN PVOID Buffer,
IN ULONG Length,
IN PLARGE_INTEGER ByteOffset OPTIONAL,
IN PULONG Key OPTIONAL
);[/code] 這裡有個引數ApcRoutine,這是一個函式指標。什麼時候要用到這個指標呢?原來,檔案操作有“同步”和“非同步”之分。普通的寫檔案操作是同步寫,啟動這種操作的執行緒在核心進行寫檔案操作期間被“阻塞(blocked)”而進入“睡眠”,直到裝置驅動完成了操作以後才又將該執行緒“喚醒”而從系統呼叫返回。但是,如果目標檔案是按非同步操作開啟的,即在通過W32的API函式CreateFile()開啟目標檔案時把呼叫引數dwFlagsAndAttributes設定成FILE_FLAG_OVERLAPPED,那麼呼叫者就不會被阻塞,而是把事情交給核心、不等實際的操作完成就返回了。但是此時要把ApcRoutine設定成指向某個APC函式。這樣,當裝置驅動完成實際的操作時,就會使呼叫者執行緒執行這個APC函式,就像是發生了一次中斷。執行該APC函式時的呼叫介面為: [code]typedef
VOID
(NTAPI *PIO_APC_ROUTINE) (IN PVOID ApcContext,
IN PIO_STATUS_BLOCK IoStatusBlock, IN ULONG Reserved);[/code] 這裡的指標ApcContext就是NtWriteFile()呼叫介面上傳下來的,至於作什麼解釋、起什麼作用,那是包括APC函式在內的使用者軟體自己的事,核心只是把它傳遞給APC函式。
在這個過程中,把ApcRoutine設定成指向APC函式相當於登記了一箇中斷服務程式,而裝置驅動在完成實際的檔案操作後就向呼叫者執行緒發出相當於中斷請求的“APC請求”,使其執行這個APC函式。
從這個角度說,APC機制又應該說是裝置驅動框架的一部分。事實上,讀者以後還會看到,APC機制與裝置驅動的關係比這裡所見的還要更加密切。此外,APC機制與異常處理的關係也很密切。 不僅核心可以向一個執行緒發出APC請求,別的執行緒、乃至目標執行緒自身也可以發出這樣的請求。Windows為應用程式提供了一個函式QueueUserAPC(),就是用於此專案的,下面是ReactOS中這個函式的程式碼: [code]DWORD STDCALL
QueueUserAPC(PAPCFUNC pfnAPC, HANDLE hThread, ULONG_PTR dwData)
{
NTSTATUS Status; Status = NtQueueApcThread(hThread, IntCallUserApc,
pfnAPC, (PVOID)dwData, NULL);
if (Status)
SetLastErrorByStatus(Status); return NT_SUCCESS(Status);
}[/code] 引數pfnAPC是函式指標,這就是APC函式。另一個引數hThread是指向目標執行緒物件(已開啟)的Handle,這可以是當前執行緒本身,也可以是同一程序中別的執行緒,還可以是別的程序中的某個執行緒。值得注意的是:如果目標執行緒在另一個程序中,那麼pfnAPC必須是這個函式在目標執行緒所在使用者空間的地址,而不是這個函式在本執行緒所在空間的地址。最後一個引數dwData則是需要傳遞給APC函式的引數。
這裡的NtQueueApcThread()是個系統呼叫。“Native API”書中有關於NtQueueApcThread()的一些說明。這個系統呼叫把一個“使用者APC請求”掛入目標執行緒的APC佇列(更確切地說,是把一個帶有函式指標的資料結構掛入佇列)。注意其第二個引數是需要執行的APC函式指標,本該是pfnAPC,這裡卻換成了函式IntCallUserApc(),而pfnAPC倒變成了第三個引數,成了需要傳遞給IntCallUserApc()的引數之一。IntCallUserApc()是kernel32.dll內部的一個函式,但是並未引出,所以不能從外部直接加以呼叫。
APC是針對具體執行緒、要求由具體執行緒加以執行的,所以每個執行緒都有自己的APC佇列。核心中代表著執行緒的資料結構是ETHREAD,而ETHREAD中的第一個成分Tcb是KTHREAD資料結構,執行緒的APC佇列就在KTHREAD裡面: [code]typedef struct _KTHREAD
{
. . . . . .
/* Thread state (one of THREAD_STATE_xxx constants below) */
UCHAR State; /* 2D */
BOOLEAN Alerted[2]; /* 2E */
. . . . . .
KAPC_STATE ApcState; /* 34 */
ULONG ContextSwitches; /* 4C */
. . . . . .
ULONG KernelApcDisable; /* D0 */
. . . . . .
PKQUEUE Queue; /* E0 */
KSPIN_LOCK ApcQueueLock; /* E4 */
. . . . . .
PKAPC_STATE ApcStatePointer[2]; /* 12C */
. . . . . .
KAPC_STATE SavedApcState; /* 140 */
UCHAR Alertable; /* 158 */
UCHAR ApcStateIndex; /* 159 */
UCHAR ApcQueueable; /* 15A */
. . . . . .
KAPC SuspendApc; /* 160 */
. . . . . .
} KTHREAD;[/code] Microsoft並不公開這個資料結構的定義,所以ReactOS程式碼中對這個資料結構的定義帶有逆向工程的痕跡,每一行後面的十六進位制數值就是相應結構成分在資料結構中的位移。這裡我們最關心的是ApcState,這又是一個數據結構、即KAPC_STATE。可以看出,KAPC_STATE的大小是0x18位元組。其定義如下: [code]typedef struct _KAPC_STATE {
LIST_ENTRY ApcListHead[2];
PKPROCESS Process;
BOOLEAN KernelApcInProgress;
BOOLEAN KernelApcPending;
BOOLEAN UserApcPending;
} KAPC_STATE, *PKAPC_STATE, *__restrict PRKAPC_STATE;[/code] 顯然,這裡的ApcListHead就是APC佇列頭。不過這是個大小為2的陣列,說明實際上(每個執行緒)有兩個APC佇列。這是因為APC函式分為使用者APC和核心APC兩種,各有各的佇列。所謂使用者APC,是指相應的APC函式位於使用者空間、在使用者空間執行;而核心APC,則相應的APC函式為核心函式。
讀者也許已經注意到,KTHREAD結構中除ApcState外還有SavedApcState也是KAPC_STATE資料結構。此外還有ApcStatePointer[2]和ApcStateIndex兩個結構成分。這是幹什麼用的呢?原來,在Windows的核心中,一個執行緒可以暫時“掛靠(Attach)”到另一個程序的地址空間。比方說,執行緒T本來是屬於程序A的,當這個執行緒在核心中執行時,如果其活動與使用者空間有關(APC就是與使用者空間有關),那麼當時的使用者空間應該就是程序A的使用者空間。但是Windows核心允許一些跨程序的操作(例如將ntdll.dll的映像裝入新創程序B的使用者空間並對其進行操作),所以有時候需要把當時的使用者空間切換到別的程序(例如B) 的使用者空間,這就稱為“掛靠(Attach)”,對此我將另行撰文介紹。在當前執行緒掛靠在另一個程序的期間,既然使用者空間是別的程序的使用者空間,掛在佇列中的APC請求就變成“牛頭不對馬嘴”了,所以此時要把這些佇列轉移到別的地方,以免亂套,然後在回到原程序的使用者空間時再於恢復。那麼轉移到什麼地方呢?就是SavedApcState。當然,還要有狀態資訊說明本執行緒當前是處於“原始環境”還是“掛靠環境”,這就是ApcStateIndex的作用。程式碼中為SavedApcState的值定義了一種列舉型別: [code]typedef enum _KAPC_ENVIRONMENT
{
OriginalApcEnvironment,
AttachedApcEnvironment,
CurrentApcEnvironment
} KAPC_ENVIRONMENT;[/code] 實際可用於ApcStateIndex的只是OriginalApcEnvironment和AttachedApcEnvironment,即0和1。讀者也許又要問,在掛靠環境下原來的APC佇列確實不適用了,但不去用它就是,何必要把它轉移呢?再說,APC佇列轉移以後,ApcState不是空下來不用了嗎?問題在於,在掛靠環境下也可能會有(針對所掛靠程序的)APC請求(不過當然不是來自使用者空間),所以需要有用於兩種不同環境的APC佇列,於是便有了ApcState和SavedApcState。進一步,為了提供操作上的靈活性,又增加了一個KAPC_STATE指標陣列ApcStatePointer[2],就用ApcStateIndex的當前值作為下標,而陣列中的指標則根據情況可以分別指向兩個APC_STATE資料結構中的一個。
這樣,以ApcStateIndex的當前數值為下標,從指標陣列ApcStatePointer[2]中就可以得到指向ApcState或SavedApcState的指標,而要求把一個APC請求掛入佇列時則可以指定是要掛入哪一個環境的佇列。實際上,當ApcStateIndex的值為OriginalApcEnvironment、即0時,使用的是ApcState;為AttachedApcEnvironment、即1時,則用的是SavedApcState。
每當要求掛入一個APC函式時,不管是使用者APC還是核心APC,核心都要為之準備好一個KAPC資料結構,並將其掛入相應的佇列。 [code]typedef struct _KAPC
{
CSHORT Type;
CSHORT Size;
ULONG Spare0;
struct _KTHREAD* Thread;
LIST_ENTRY ApcListEntry;
PKKERNEL_ROUTINE KernelRoutine;
PKRUNDOWN_ROUTINE RundownRoutine;
PKNORMAL_ROUTINE NormalRoutine;
PVOID NormalContext;
PVOID SystemArgument1;
PVOID SystemArgument2;
CCHAR ApcStateIndex;
KPROCESSOR_MODE ApcMode;
BOOLEAN Inserted;
} KAPC, *PKAPC;[/code] 結構中的ApcListEntry就是用來將KAPC結構掛入佇列的。注意這個資料結構中有三個函式指標,即KernelRoutine、RundownRoutine、NormalRoutine。其中只有NormalRoutine才指向(執行)APC函式的請求者所提供的函式,其餘兩個都是輔助性的。以NtQueueApcThread()為例,其請求者(呼叫者)QueueUserAPC()所提供的函式是IntCallUserApc(),所以NormalRoutine應該指向這個函式。注意真正的請求者其實是QueueUserAPC()的呼叫者,真正的目標APC函式也並非IntCallUserApc(),而是前面的函式指標pfnAPC所指向的函式,而IntCallUserApc()起著類似於“門戶”的作用。 現在我們可以往下看系統呼叫NtQueueApcThread()的實現了。 [code]NTSTATUS
STDCALL
NtQueueApcThread(HANDLE ThreadHandle, PKNORMAL_ROUTINE ApcRoutine,
PVOID NormalContext, PVOID SystemArgument1, PVOID SystemArgument2)
{
PKAPC Apc;
PETHREAD Thread;
KPROCESSOR_MODE PreviousMode = ExGetPreviousMode();
NTSTATUS Status; /* Get ETHREAD from Handle */
Status = ObReferenceObjectByHandle(ThreadHandle, THREAD_SET_CONTEXT,
PsThreadType, PreviousMode, (PVOID)&Thread, NULL);
. . . . . .
/* Allocate an APC */
Apc = ExAllocatePoolWithTag(NonPagedPool, sizeof(KAPC), TAG('P', 's', 'a', 'p'));
. . . . . .
/* Initialize and Queue a user mode apc (always!) */
KeInitializeApc(Apc, &Thread->Tcb, OriginalApcEnvironment,
KiFreeApcRoutine, NULL, ApcRoutine, UserMode, NormalContext);
if (!KeInsertQueueApc(Apc, SystemArgument1, SystemArgument2,
IO_NO_INCREMENT))
{
Status = STATUS_UNSUCCESSFUL;
} else {
Status = STATUS_SUCCESS;
}
/* Dereference Thread and Return */
ObDereferenceObject(Thread);
return Status;
}[/code] 先看呼叫引數。第一個引數是代表著某個已開啟執行緒的Handle,這說明所要求的APC函式的執行者、即目標執行緒、可以是另一個執行緒,而不必是請求者執行緒本身。第二個引數不言自明。第三個引數NormalContext,以及後面的兩個引數,則是準備傳遞給APC函式的引數,至於怎樣解釋和使用這幾個引數是APC函式的事。看一下前面QueueUserAPC()的程式碼,就可以知道這裡的APC函式是IntCallUserApc(),而準備傳給它的引數分別為pfnAPC、dwData、和NULL,前者是真正的目標APC函式指標,後兩者是要傳給它的引數。
根據Handle找到目標執行緒的ETHREAD資料結構以後,就為APC函式分配一個KAPC資料結構,並通過KeInitializeApc()加以初始化。 [code][NtQueueApcThread() > KeInitializeApc()] VOID
STDCALL
KeInitializeApc(IN PKAPC Apc,
IN PKTHREAD Thread,
IN KAPC_ENVIRONMENT TargetEnvironment,
IN PKKERNEL_ROUTINE KernelRoutine,
IN PKRUNDOWN_ROUTINE RundownRoutine OPTIONAL,
IN PKNORMAL_ROUTINE NormalRoutine,
IN KPROCESSOR_MODE Mode,
IN PVOID Context)
{
. . . . . . /* Set up the basic APC Structure Data */
RtlZeroMemory(Apc, sizeof(KAPC));
Apc->Type = ApcObject;
Apc->Size = sizeof(KAPC);
/* Set the Environment */
if (TargetEnvironment == CurrentApcEnvironment) {
Apc->ApcStateIndex = Thread->ApcStateIndex;
} else {
Apc->ApcStateIndex = TargetEnvironment;
}
/* Set the Thread and Routines */
Apc->Thread = Thread;
Apc->KernelRoutine = KernelRoutine;
Apc->RundownRoutine = RundownRoutine;
Apc->NormalRoutine = NormalRoutine;
/* Check if this is a Special APC, in which case we use KernelMode and no Context */
if (ARGUMENT_PRESENT(NormalRoutine)) {
Apc->ApcMode = Mode;
Apc->NormalContext = Context;
} else {
Apc->ApcMode = KernelMode;
}
}[/code] 這段程式碼本身很簡單,但是有幾個問題需要結合前面NtQueueApcThread()的程式碼再作些說明。
首先,從NtQueueApcThread()傳下來的KernelRoutine是KiFreeApcRoutine(),顧名思義這是在為將來釋放PKAPC資料結構做好準備,而RundownRoutine是NULL。
其次,引數TargetEnvironment說明要求掛入哪一種環境下的APC佇列。實際傳下來的值是OriginalApcEnvironment,表示是針對原始環境、即當前執行緒所屬(而不是所掛靠)程序的。注意程式碼中所設定的是Apc->ApcStateIndex、即PKAPC資料結構中的ApcStateIndex欄位,而不是KTHREAD結構中的ApcStateIndex欄位。另一方面,ApcStateIndex的值只能是OriginalApcEnvironment或AttachedApcEnvironment,如果所要求的是CurrentApcEnvironment就要從Thread->ApcStateIndex獲取當前的環境值。
最後,APC請求的模式Mode是UserMode。但是有個例外,那就是:如果指標NormalRoutine為0,那麼實際的模式變成了KernelMode。這是因為在這種情況下沒有使用者空間APC函式可以執行,唯一將得到執行的是KernelRoutine,在這裡是KiFreeApcRoutine()。這裡的巨集操作ARGUMENT_PRESENT定義為: [code]#define ARGUMENT_PRESENT(ArgumentPointer) \
((BOOLEAN) ((PVOID)ArgumentPointer != (PVOID)NULL))[/code] 回到NtQueueApcThread()程式碼中,下一步就是根據Apc->ApcStateIndex、Apc->Thread、和Apc->ApcMode把準備好的KAPC結構掛入相應的佇列。根據APC請求的具體情況,有時候要插在佇列的前頭,一般則掛在佇列的尾部。限於篇幅,我們在這裡就不看KeInsertQueueApc()的程式碼了;雖然這段程式碼中有一些特殊的處理,但都不是我們此刻所特別關心的。
如果跟Linux的Signal機制作一類比,那麼NtQueueApcThread()相當於設定Signal處理函式(或中斷服務程式)。在Linux裡面,Signal處理函式的執行需要受到某種觸發,例如收到了別的執行緒或某個核心成分發來的訊號;而執行Signal處理函式的時機則是在CPU從核心返回目標執行緒的使用者空間程式的前夕。可是Windows的APC機制與此有所不同,一般來說,只要把APC請求掛入了佇列,就不再需要觸發,而只是等待執行的時機。對於使用者APC請求,這時機同樣也是在CPU從核心返回目標執行緒使用者空間程式的前夕(對於核心APC則有所不同)。所以,在某種意義上,把一個APC請求掛入佇列,就同時意味著受到了觸發。對於系統呼叫NtQueueApcThread(),我們可以理解為是把APC函式的設定與觸發合在了一起。而對於非同步的檔案讀寫,則APC函式的設定與觸發是分開的,核心先把APC函式記錄在別的資料結構中,等實際的檔案讀寫完成以後才把APC請求掛入佇列,此時實際上只是觸發其執行。不過那已是屬於裝置驅動框架的事了。所以,一旦把APC請求掛入佇列,就只是等待執行時機的問題了。從這個意義上說,“非同步過程呼叫”還真不失為貼切的稱呼。 下面就來看執行APC的時機,那是在(系統呼叫、中斷、或異常處理之後)從核心返回使用者空間的途中。 [code]_KiServiceExit: /* Get the Current Thread */
cli
movl %fs:KPCR_CURRENT_THREAD, %esi
/* Deliver APCs only if we were called from user mode */
testb $1, KTRAP_FRAME_CS(%esp)
je KiRosTrapReturn
/* And only if any are actually pending */
cmpb $0, KTHREAD_PENDING_USER_APC(%esi)
je KiRosTrapReturn
/* Save pointer to Trap Frame */
movl %esp, %ebx
/* Raise IRQL to APC_LEVEL */
movl $1, %ecx
call @
/* Save old IRQL */
pushl %eax
/* Deliver APCs */
sti
pushl %ebx
pushl $0
pushl $UserMode
call [email protected]
cli
/* Return to old IRQL */
popl %ecx
call @[email protected]
. . . . . .[/code] 這是核心中處理系統呼叫返回和中斷/異常返回的程式碼。在返回前夕,這裡先通過%fs:KPCR_CURRENT_THREAD取得指向當前執行緒的ETHREAD(從而KTHREAD)的指標,然後依次檢查:
● 即將返回的是否使用者空間。
● 是否有使用者APC請求正在等待執行(KTHREAD_PENDING_USER_APC是 ApcState.KernelApcPending在KTHREAD資料結構中的位移)。
要是通過了這兩項檢查,執行鍼對當前執行緒的APC請求的時機就到了,於是就呼叫KiDeliverApc()去“投遞”APC函式,這跟Linux中對Signal的處理又是十分相似的。注意在呼叫這個函式的前後還分別呼叫了KfRaiseIrql()和KfLowerIrql(),這是為了在執行KiDeliverApc()期間讓核心的“中斷請求級別”處於APC_LEVEL,執行完以後再予恢復。我們現在暫時不關心“中斷請求級別”,以後會回到這個問題上。
前面講過,KTHREAD中有兩個KAPC_STATE資料結構,一個是ApcState,另一個是SavedApcState,二者都有APC佇列,但是要投遞的只是ApcState中的佇列。
注意在call指令前面壓入堆疊的三個引數,特別是首先壓入堆疊的%ebx,它指向(系統空間)堆疊上的“中斷現場”、或稱“框架”,即CPU進入本次中斷或系統呼叫時各暫存器的值,這就是下面KiDeliverApc()的呼叫引數TrapFrame。
下面我們看KiDeliverApc()的程式碼。 [code][KiDeliverApc()] VOID
STDCALL
KiDeliverApc(KPROCESSOR_MODE DeliveryMode,
PVOID Reserved,
PKTRAP_FRAME TrapFrame)
{
PKTHREAD Thread = KeGetCurrentThread();
. . . . . . ASSERT_IRQL_EQUAL(APC_LEVEL); /* Lock the APC Queue and Raise IRQL to Synch */
KeAcquireSpinLock(&Thread->ApcQueueLock, &OldIrql); /* Clear APC Pending */
Thread->ApcState.KernelApcPending = FALSE; /* Do the Kernel APCs first */
while (!IsListEmpty(&Thread->ApcState.ApcListHead[KernelMode])) {
/* Get the next Entry */
ApcListEntry = Thread->ApcState.ApcListHead[KernelMode].Flink;
Apc = CONTAINING_RECORD(ApcListEntry, KAPC, ApcListEntry);
/* Save Parameters so that it's safe to free the Object in Kernel Routine*/
NormalRoutine = Apc->NormalRoutine;
KernelRoutine = Apc->KernelRoutine;
NormalContext = Apc->NormalContext;
SystemArgument1 = Apc->SystemArgument1;
SystemArgument2 = Apc->SystemArgument2;
/* Special APC */
if (NormalRoutine == NULL) {
/* Remove the APC from the list */
Apc->Inserted = FALSE;
RemoveEntryList(ApcListEntry);
/* Go back to APC_LEVEL */
KeReleaseSpinLock(&Thread->ApcQueueLock, OldIrql);
/* Call the Special APC */
DPRINT("Delivering a Special APC: %x\n", Apc);
KernelRoutine(Apc, &NormalRoutine, &NormalContext,
&SystemArgument1, &SystemArgument2); /* Raise IRQL and Lock again */
KeAcquireSpinLock(&Thread->ApcQueueLock, &OldIrql);
} else {
/* Normal Kernel APC */
if (Thread->ApcState.KernelApcInProgress || Thread->KernelApcDisable)
{
/*
* DeliveryMode must be KernelMode in this case, since one may not
* return to umode while being inside a critical section or while
* a regular kmode apc is running (the latter should be impossible btw).
* -Gunnar
*/
ASSERT(DeliveryMode == KernelMode); KeReleaseSpinLock(&Thread->ApcQueueLock, OldIrql);
return;
}
/* Dequeue the APC */
RemoveEntryList(ApcListEntry);
Apc->Inserted = FALSE;
/* Go back to APC_LEVEL */
KeReleaseSpinLock(&Thread->ApcQueueLock, OldIrql);
/* Call the Kernel APC */
DPRINT("Delivering a Normal APC: %x\n", Apc);
KernelRoutine(Apc,
&NormalRoutine,
&NormalContext,
&SystemArgument1,
&SystemArgument2);
/* If There still is a Normal Routine, then we need to call this at PASSIVE_LEVEL */
if (NormalRoutine != NULL) {
/* At Passive Level, this APC can be prempted by a Special APC */
Thread->ApcState.KernelApcInProgress = TRUE;
KeLowerIrql(PASSIVE_LEVEL);
/* Call and Raise IRQ back to APC_LEVEL */
DPRINT("Calling the Normal Routine for a Normal APC: %x\n", Apc);
NormalRoutine(&NormalContext, &SystemArgument1, &SystemArgument2);
KeRaiseIrql(APC_LEVEL, &OldIrql);
} /* Raise IRQL and Lock again */
KeAcquireSpinLock(&Thread->ApcQueueLock, &OldIrql);
Thread->ApcState.KernelApcInProgress = FALSE;
}
} //end while[/code]
引數DeliveryMode表示需要“投遞”哪一種APC,可以是UserMode,也可以是KernelMode。不過,KernelMode確實表示只要求執行核心APC,而UserMode卻表示在執行核心APC之外再執行使用者APC。這裡所謂“執行核心APC”是執行核心APC佇列中的所有請求,而“執行使用者APC”卻只是執行使用者APC佇列中的一項。
所以首先檢查核心模式APC佇列,只要非空就通過一個while迴圈處理其所有的APC請求。佇列中的每一項(如果佇列非空的話)、即每一個APC請求都是KAPC結構,結構中有三個函式指標,但是這裡只涉及其中的兩個。一個是NormalRoutine,若為非0就是指向一個實質性的核心APC函式。另一個是KernelRoutine,指向一個輔助性的核心APC函式,這個指標不會是0,否則這個KAPC結構就不會在佇列中了(注意KernelRoutine與核心模式NormalRoutine的區別)。NormalRoutine為0是一種特殊的情況,在這種情況下KernelRoutine所指的核心函式無條件地得到呼叫。但是,如果NormalRoutine非0,那麼首先得到呼叫的是KernelRoutine,而指標NormalRoutine的地址是作為引數傳下去的。KernelRoutine的執行有可能改變這個指標的值。這樣,如果執行KernelRoutine以後NormalRoutine仍為非0,那就說明需要加以執行,所以通過這個函式指標予以呼叫。不過,核心APC函式的執行是在PASSIVE_LEVEL級別上執行的,所以對NormalRoutine的呼叫前有KeLowerIrql()、後有KeRaiseIrql(),前者將CPU的執行級別調整為PASSIVE_LEVEL,後者則將其恢復為APC_LEVEL。
執行完核心APC佇列中的所有請求以後,如果呼叫引數DeliveryMode為UserMode的話,就輪到使用者APC了。我們繼續往下看: [code][KiDeliverApc()] /* Now we do the User APCs */
if ((!IsListEmpty(&Thread->ApcState.ApcListHead[UserMode])) &&
(DeliveryMode == UserMode) && (Thread->ApcState.UserApcPending == TRUE)) {
/* It's not pending anymore */
Thread->ApcState.UserApcPending = FALSE; /* Get the APC Object */
ApcListEntry = Thread->ApcState.ApcListHead[UserMode].Flink;
Apc = CONTAINING_RECORD(ApcListEntry, KAPC, ApcListEntry);
/* Save Parameters so that it's safe to free the Object in Kernel Routine*/
NormalRoutine = Apc->NormalRoutine;
KernelRoutine = Apc->KernelRoutine;
NormalContext = Apc->NormalContext;
SystemArgument1 = Apc->SystemArgument1;
SystemArgument2 = Apc->SystemArgument2;
/* Remove the APC from Queue, restore IRQL and call the APC */
RemoveEntryList(ApcListEntry);
Apc->Inserted = FALSE;
KeReleaseSpinLock(&Thread->ApcQueueLock, OldIrql);
DPRINT("Calling the Kernel Routine for for a User APC: %x\n", Apc);
KernelRoutine(Apc,
&NormalRoutine,
&NormalContext,
&SystemArgument1,
&SystemArgument2); if (NormalRoutine == NULL) {
/* Check if more User APCs are Pending */
KeTestAlertThread(UserMode);
}else {
/* Set up the Trap Frame and prepare for Execution in NTDLL.DLL */
DPRINT("Delivering a User APC: %x\n", Apc);
KiInitializeUserApc(Reserved,
TrapFrame,
NormalRoutine,
NormalContext,
SystemArgument1,
SystemArgument2);
} } else {
/* Go back to APC_LEVEL */
KeReleaseSpinLock(&Thread->ApcQueueLock, OldIrql);
}
}[/code] 當然,執行使用者APC是有條件的。首先自然是使用者APC佇列非空,同時呼叫引數DeliveryMode必須是UserMode;並且ApcState中的UserApcPending為TRUE,表示佇列中的請求確實是要求儘快加以執行的。
讀者也許已經注意到,比之核心APC佇列,對使用者APC佇列的處理有個顯著的不同,那就是對使用者APC佇列並不是通過一個while迴圈處理佇列中的所有請求,而是每次進入KiDeliverApc()只處理使用者APC佇列中的第一個請求。同樣,這裡也是隻涉及兩個函式指標,即NormalRoutine和KernelRoutine,也是先執行KernelRoutine,並且KernelRoutine可以對指標NormalRoutine作出修正。但是再往下就不同了。
首先,如果執行完KernelRoutine(所指的函式)以後指標NormalRoutine為0,這裡要執行KeTestAlertThread()。這又是跟裝置驅動有關的事(Windows術語中的Alert相當於Linux術語中的“喚醒”),我們在這裡暫不關心。
反之,如果指標NormalRoutine仍為非0,那麼這裡執行的是KiInitializeUserApc(),而不是直接呼叫NormalRoutine所指的函式,因為NormalRoutine所指的函式是在使用者空間,要等CPU回到使用者空間才能執行,這裡只是為其作好安排和準備。 [code][KiDeliverApc() > KiInitializeUserApc()] VOID
STDCALL
KiInitializeUserApc(IN PVOID Reserved,
IN PKTRAP_FRAME TrapFrame,
IN PKNORMAL_ROUTINE NormalRoutine,
IN PVOID NormalContext,
IN PVOID SystemArgument1,
IN PVOID SystemArgument2)
{
PCONTEXT Context;
PULONG Esp; . . . . . .
/*
* Save the thread's current context (in other words the registers
* that will be restored when it returns to user mode) so the
* APC dispatcher can restore them later
*/
Context = (PCONTEXT)(((PUCHAR)TrapFrame->Esp) - sizeof(CONTEXT));
RtlZeroMemory(Context, sizeof(CONTEXT));
Context->ContextFlags = CONTEXT_FULL;
Context->SegGs = TrapFrame->Gs;
Context->SegFs = TrapFrame->Fs;
Context->SegEs = TrapFrame->Es;
Context->SegDs = TrapFrame->Ds;
Context->Edi = TrapFrame->Edi;
Context->Esi = TrapFrame->Esi;
Context->Ebx = TrapFrame->Ebx;
Context->Edx = TrapFrame->Edx;
Context->Ecx = TrapFrame->Ecx;
Context->Eax = TrapFrame->Eax;
Context->Ebp = TrapFrame->Ebp;
Context->Eip = TrapFrame->Eip;
Context->SegCs = TrapFrame->Cs;
Context->EFlags = TrapFrame->Eflags;
Context->Esp = TrapFrame->Esp;
Context->SegSs = TrapFrame->Ss;
/*
* Setup the trap frame so the thread will start executing at the
* APC Dispatcher when it returns to user-mode
*/
Esp = (PULONG)(((PUCHAR)TrapFrame->Esp) -
(sizeof(CONTEXT) + (6 * sizeof(ULONG))));
Esp[0] = 0xdeadbeef;
Esp[1] = (ULONG)NormalRoutine;
Esp[2] = (ULONG)NormalContext;
Esp[3] = (ULONG)SystemArgument1;
Esp[4] = (ULONG)SystemArgument2;
Esp[5] = (ULONG)Context;
TrapFrame->Eip = (ULONG)LdrpGetSystemDllApcDispatcher();
TrapFrame->Esp = (ULONG)Esp;
}[/code] 這個函式的名字取得不好,很容易讓人把它跟前面的KeInitializeApc()相連繫,實際上卻完全是兩碼事。引數TrapFrame是由KiDeliverApc()傳下來的一個指標,指向使用者空間堆疊上的“中斷現場”。這裡要做的事情就是在原有現場的基礎上“注水”,偽造出一個新的現場,使得CPU返回使用者空間時誤認為中斷(或系統呼叫)發生於進入APC函式的前夕,從而轉向APC函式。
怎麼偽造呢?首先使使用者空間的堆疊指標Esp下移一個CONTEXT資料結構的大小,外加6個32位整數的位置(注意堆疊是由上向下伸展的)。換言之就是在使用者空間堆疊上擴充出一個CONTEXT資料結構和6個32位整數。注意,TrapFrame是在系統空間堆疊上,而TrapFrame->Esp的值是使用者空間的堆疊指標,所指向的是使用者空間堆疊。所以這裡擴充的是使用者空間堆疊。這樣,原先的使用者堆疊下方是CONTEXT資料結構Context,再往下就是那6個32位整數。然後把TrapFrame的內容儲存在這個CONTEXT資料結構中,並設定好6個32位整數,那是要作為呼叫引數傳遞的。接著就把儲存在TrapFrame中的Eip映像改成指向使用者空間的一個特殊函式,具體的地址通過LdrpGetSystemDllApcDispatcher()獲取。這樣,當CPU返回到使用者空間時,就會從這個特殊函式“繼續”執行。當然,也要調整TrapFrame中的使用者空間堆疊指標Esp。
LdrpGetSystemDllApcDispatcher()只是返回一個(核心)全域性量SystemDllApcDispatcher的值,這個值是個函式指標,指向ntdll.dll中的一個函式,是在對映ntdll.dll映像時設定好的。 [code]PVOID LdrpGetSystemDllApcDispatcher(VOID)
{
return(SystemDllApcDispatcher);
}[/code] 與全域性變數SystemDllApcDispatcher相似的函式指標有:
● SystemDllEntryPoint,指向LdrInitializeThunk()。
● SystemDllApcDispatcher,指向KiUserApcDispatcher()。
● SystemDllExceptionDispatcher,指向KiUserExceptionDispatcher()。
● SystemDllCallbackDispatcher,指向KiUserCallbackDispatcher()。
● SystemDllRaiseExceptionDispatche r,指向KiRaiseUserExceptionDispatcher()。
這些指標都是在LdrpMapSystemDll()中得到設定的。給定一個函式名的字串,就可以通過一個函式LdrGetProcedureAddress()從(已經對映的)DLL映像中獲取這個函式的地址(如果這個函式被引出的話)。
於是,CPU從KiDeliverApc()回到_KiServiceExit以後會繼續完成其返回使用者空間的行程,只是一到使用者空間就栽進了圈套,那就是KiUserApcDispatcher(),而不是回到原先的斷點上。關於原先斷點的現場資訊儲存在使用者空間堆疊上、並形成一個CONTEXT資料結構,但是“深埋”在6個32位整數的後面。而這6個32位整數的作用則為:
● Esp[0]的值為0xdeadbeef,用來模擬KiUserApcDispatcher()的返回地址。當然,這個地址是無效的,所以KiUserApcDispatcher()實際上是不會返回的。
● Esp[1]的值為NormalRoutine,在我們這個情景中指向“門戶”函式IntCallUserApc()。
● Esp[2]的值為NormalContext,在我們這個情景中是指向實際APC函式的指標。
● 餘類推。其中Esp[5]指向(使用者)堆疊上的CONTEXT資料結構。
總之,使用者堆疊上的這6個32位整數模擬了一次CPU在進入KiUserApcDispatcher()還沒有來得及執行其第一條指令之前就發生了中斷的假象,使得CPU在結束了KiDeliverApc()的執行、回到_KiServiceExit中繼續前行、並最終回到使用者空間時就進入KiUserApcDispatcher()執行其第一條指令。
另一方面,對於該執行緒原來的上下文而言,則又好像是剛回到使用者空間就發生了中斷,而KiUserApcDispatcher()則相當於中斷相應程式。 [code]VOID STDCALL
KiUserApcDispatcher(PIO_APC_ROUTINE ApcRoutine, PVOID ApcContext,
PIO_STATUS_BLOCK Iosb, ULONG Reserved, PCONTEXT Context)
{
/* Call the APC */
ApcRoutine(ApcContext, Iosb, Reserved);
/* Switch back to the interrupted context */
NtContinue(Context, 1);
}[/code] 這裡的第一個引數ApcRoutine指向IntCallUserApc(),第二個引數ApcContext指向真正的(目標)APC函式。 [code][KiUserApcDispatcher() > IntCallUserApc()] static void CALLBACK
IntCallUserApc(PVOID Function, PVOID dwData, PVOID Argument3)
{
PAPCFUNC pfnAPC = (PAPCFUNC)Function;
pfnAPC((ULONG_PTR)dwData);
}[/code] 可見,IntCallUserApc()其實並無必要,在KiUserApcDispatcher()中直接呼叫目標APC函式也無不可,這樣做只是為將來可能的修改擴充提供一些方便和靈活性。從IntCallUserApc()回到KiUserApcDispatcher(),下面緊接著是系統呼叫NtContinue()。 KiUserApcDispatcher()是不返回的。它之所以不返回,是因為對NtContinue()的呼叫不返回。正如程式碼中的註釋所述,NtContinue()的作用是切換回被中斷了的上下文,不過其實還不止於此,下面讀者就會看到它還起著迴圈執行整個使用者APC請求佇列的作用。 [code][KiUserApcDispatcher() > NtContinue()] NTSTATUS STDCALL
NtContinue (IN PCONTEXT Context, IN BOOLEAN TestAlert)
{
PKTHREAD Thread = KeGetCurrentThread();
PKTRAP_FRAME TrapFrame = Thread->TrapFrame;
PKTRAP_FRAME PrevTrapFrame = (PKTRAP_FRAME)TrapFrame->Edx;
PFX_SAVE_AREA FxSaveArea;
KIRQL oldIrql; DPRINT("NtContinue: Context: Eip=0x%x, Esp=0x%x\n", Context->Eip, Context->Esp );
PULONG Frame = 0;
__asm__("mov %%ebp, %%ebx" : "=b" (Frame) : );
. . . . . . /*
* Copy the supplied context over the register information that was saved
* on entry to kernel mode, it will then be restored on exit
* FIXME: Validate the context
*/
KeContextToTrapFrame ( Context, TrapFrame ); /* Put the floating point context into the thread's FX_SAVE_AREA
* and make sure it is reloaded when needed.
*/
FxSaveArea = (PFX_SAVE_AREA)((ULONG_PTR)Thread->InitialStack –
sizeof(FX_SAVE_AREA));
if (KiContextToFxSaveArea(FxSaveArea, Context))
{
Thread->NpxState = NPX_STATE_VALID;
KeRaiseIrql(DISPATCH_LEVEL, &oldIrql);
if (KeGetCurrentPrcb()->NpxThread == Thread)
{
KeGetCurrentPrcb()->NpxThread = NULL;
Ke386SetCr0(Ke386GetCr0() | X86_CR0_TS);
}
else
{
ASSERT((Ke386GetCr0() & X86_CR0_TS) == X86_CR0_TS);
}
KeLowerIrql(oldIrql);
} /* Restore the user context */
Thread->TrapFrame = PrevTrapFrame;
__asm__("mov %%ebx, %%esp;\n" "jmp _KiServiceExit": : "b" (TrapFrame)); return STATUS_SUCCESS; /* this doesn't actually happen */
}[/code] 注意從KiUserApcDispatcher()到NtContinue()並不是普通的函式呼叫,而是系統呼叫,這中間經歷了空間的切換,也從使用者空間堆疊切換到了系統空間堆疊。CPU進入系統呼叫空間後,在_KiSystemServicex下面的程式碼中把指向中斷現場的框架指標儲存在當前執行緒的KTHREAD資料結構的TrapFrame欄位中。這樣,很容易就可以找到系統空間堆疊上的呼叫框架。當然,現在的框架是因為系統呼叫而產生的框架;而要想回到當初、即在執行使用者空間APC函式之前的斷點,就得先恢復當初的框架。那麼當初的框架在哪裡呢?它儲存在使用者空間的堆疊上,就是前面KiInitializeUserApc()儲存的CONTEXT資料結構中。所以,這裡通過KeContextToTrapFrame()把當初儲存的資訊拷貝回來,從而恢復了當初的框架。
下面的KiContextToFxSaveArea()等語句與浮點處理器有關,我們在這裡並不關心。
最後,彙編指令“jmp _KiServiceExit”使CPU跳轉到了返回使用者空間途中的_KiServiceExit處(見前面的程式碼)。在這裡,CPU又會檢查APC請求佇列中是否有APC請求等著要執行,如果有的話又會進入KiDeliverApc()。前面講過,每次進入KiDeliverApc()只會執行一個使用者APC請求,所以如果使用者APC佇列的長度大於1的話就得迴圈著多次走過上述的路線,即:
1. 從系統呼叫、中斷、或異常返回途徑_KiServiceExit,如果APC佇列中有等待執行的APC請求,就呼叫KiDeliverApc()。
2. KiDeliverApc(),從使用者APC佇列中摘下一個APC請求。
3. 在KiInitializeUserApc()中儲存當前框架,並偽造新的框架。
4. 回到使用者空間。
5. 在KiUserApcDispatcher()中呼叫目標APC函式。
6. 通過系統呼叫NtContinue()進入系統空間。
7. 在NtContinue()中恢復當初儲存的框架。
8. 從NtContinue()返回、途徑_KiServiceExit時,如果APC佇列中還有等待執行的APC請求,就呼叫KiDeliverApc()。於是轉回上面的第二步。
這個過程一直要迴圈到APC佇列中不再有需要執行的請求。注意這裡每一次迴圈中儲存和恢復的都是同一個框架,就是原始的、開始處理APC佇列之前的那個框架,代表著原始的使用者空間程式斷點。一旦APC佇列中不再有等待執行的APC請求,在_KiServiceExit下面就不再呼叫KiDeliverApc(),於是就直接返回使用者空間,這次是返回到原始的程式斷點了。所以,系統呼叫neContinue()的作用不僅僅是切換回到被中斷了的上下文,還包括執行使用者APC佇列中的下一個APC請求。
對於KiUserApcDispatcher()而言,它對NtContinue()的呼叫是不返回的。因為在NtContinue()中CPU不是“返回”到對於KiUserApcDispatcher()的另一次呼叫、從而對另一個APC函式的呼叫;就是返回到原始的使用者空間程式斷點,這個斷點既可能是因為中斷或異常而形成的,也可能是因為系統呼叫而形成的。 理解了常規的APC請求和執行機制,我們不妨再看看啟動執行PE目標映像時函式的動態連線。以前講過,PE格式EXE映像與(除ntdll.dll外的)DLL的動態連線、包括這些DLL的裝入,是由ntdll.dll中的一個函式LdrInitializeThunk()作為APC函式執行而完成的,所以這也是對APC機制的一種變通使用。
要啟動一個EXE映像執行時,首先要建立程序,再把目標EXE映像和ntdll.dll的映像都對映到新程序的使用者空間,然後通過系統呼叫NtCreateThread()建立這個程序的第一個執行緒、或稱“主執行緒”。而LdrInitializeThunk()作為APC函式的執行,就是在NtCreateThread()中安排好的。 [code]NtCreateThread(OUT PHANDLE ThreadHandle, IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes OPTIONAL,
IN HANDLE ProcessHandle, OUT PCLIENT_ID ClientId,
IN PCONTEXT ThreadContext, IN PINITIAL_TEB InitialTeb,
IN BOOLEAN CreateSuspended)
{
HANDLE hThread; . . . . . .
. . . . . .
/*
* Queue an APC to the thread that will execute the ntdll startup
* routine.
*/
LdrInitApc = ExAllocatePool(NonPagedPool, sizeof(KAPC));
KeInitializeApc(LdrInitApc, &Thread->Tcb, OriginalApcEnvironment,
LdrInitApcKernelRoutine,
LdrInitApcRundownRoutine,
LdrpGetSystemDllEntryPoint(), UserMode, NULL);
KeInsertQueueApc(LdrInitApc, NULL, NULL, IO_NO_INCREMENT); /*
* The thread is non-alertable, so the APC we added did not set UserApcPending to TRUE.
* We must do this manually. Do NOT attempt to set the Thread to Alertable before the call,
* doing so is a blatant and erronous hack.
*/
Thread->Tcb.ApcState.UserApcPending = TRUE;
Thread->Tcb.Alerted[KernelMode] = TRUE;
. . . . . .
. . . . . .
}[/code] NeCreateThread()要做的事當然很多,但是其中很重要的一項就是安排好APC函式的執行。這裡的KeInitializeApc()和KeInsertQueueApc讀者都已經熟悉了,所以我們只關心呼叫引數中的三個函式指標,特別是其中的KernelRoutine和NormalRoutine。前者十分簡單: [code]VOID STDCALL
LdrInitApcKernelRoutine(PKAPC Apc, PKNORMAL_ROUTINE* NormalRoutine,
PVOID* NormalContext, PVOID* SystemArgument1, PVOID* SystemArgument2)
{
ExFreePool(Apc);
}[/code] 而NormalRoutine,這裡是通過LdrpGetSystemDllEntryPoint()獲取的,它只是返回全域性量SystemDllEntryPoint的值: [code]PVOID LdrpGetSystemDllEntryPoint(VOID)
{
return(SystemDllEntryPoint);
}[/code] 前面已經講到,全域性量SystemDllEntryPoint是在LdrpMapSystemDll()時得到設定的,指向已經對映到使用者空間的ntdll.dll映像中的LdrInitializeThunk()。注意這APC請求是掛在新執行緒的佇列中,而不是當前程序的佇列中。事實上,新執行緒和當前程序處於不同的程序,因而不在同一個使用者空間中。還要注意,這裡的NormalRoutine直接就是LdrInitializeThunk(),而不像前面通過QueueUserAPC()發出的APC請求那樣中間還有一層IntCallUserApc()。至於KiUserApcDispatcher(),那是由KeInitializeApc()強制加上的,正是這個函式保證了對NtContinue()的呼叫。
此後的流程本來無需細說了,但是由於情景的特殊性還是需要加一些簡要的說明。由NtCreateProcess()建立的程序並非一個可以排程執行的實體,而NtCreateThread()建立的執行緒卻是。所以,在NtCreateProcess()返回的前夕,系統中已經多了一個執行緒。這個新增執行緒的“框架”是偽造的,目的在於讓這個執行緒一開始在使用者空間執行就進入預定的程式入口。從NtCreateProcess()返回是回到當前執行緒、而不是新增執行緒,而剛才的APC請求是掛在新增執行緒的佇列中,所以在從NtCreateThread()返回的途中不會去執行這個APC請求。可是,當新增執行緒受排程執行時,首先就是按偽造的框架和堆疊模擬一個從系統呼叫返回的過程,所以也要途徑_KiServiceExit。這時候,這個APC請求就要得到執行了(由KiUserApcDispatcher()呼叫LdrInitializeThunk())。然後,在使用者空間執行完APC函式LdrInitializeThunk()以後,同樣也是通過NtContinue()回到核心中,然後又按原先的偽造框架“返回”到使用者空間,這才真正開始了新執行緒在使用者空間的執行。 最後,我們不妨比較一下APC機制和Unix/Linux的Signal機制。
Unix/Linux的Signal機制基本上是對硬體中斷機制的軟體模擬,具體表現在以下幾個方面:
1) 現代的硬體中斷機制一般都是“向量中斷”機制,而Signal機制中的Signal序號(例如SIG_KILL)就是對中斷向量序號的模擬。
2) 作為作業系統對硬體中斷機制的支援,一般都會提供“設定中斷向量”一類的核心函式,使特定序號的中斷向量指向某個中斷服務程式。而系統呼叫signal()就相當於是這一類的函式。只不過前者在核心中、一般只是供其它核心函式呼叫,而後者是系統呼叫、供使用者空間的程式呼叫。
3) 在硬體中斷機制中,“中斷向量”的設定只是為某類非同步事件、及中斷的發生做好了準備,但是並不意味著某個特定時間的發生。如果一直沒有中斷請求,那麼所設定的中斷向量就一直得不到執行,而中斷的發生只是觸發了中斷服務程式的執行。在Signal機制中,向某個程序發出“訊號”、即Signal、就相當於中斷請求。
相比之下,APC機制就不能說是對於硬體中斷機制的模擬了。首先,通過NtQueueApcThread()設定一個APC函式跟通過signal()設定一個“中斷向量”有所不同。將一個APC函式掛入APC佇列中時,對於這個函式的得到執行、以及大約在什麼時候得到執行,實際上是預知的,只是這得到執行的條件要過一回兒才會成熟。而“中斷”則不同,中斷向量的設定只是說如果發生某種中斷則如何如何,但是對於其究竟是否會發生、何時發生則常常是無法預測的。所以,從這個意義上說,APC函式只是一種推遲執行、非同步執行的函式呼叫,因此稱之為“非同步過程呼叫”確實更為貼切。
還有,signal機制的signal()所設定的“中斷服務程式”都是使用者空間的程式,而APC機制中掛入APC佇列的函式卻可以是核心函式。
但是,儘管如此,它們的(某些方面的)實質還是一樣的。“中斷”本來就是一種非同步執行的機制。再說,(使用者)APC與Signal的執行流程幾乎完全一樣,都是在從核心返回使用者空間的前夕檢查是否有這樣的函式需要加以執行,如果是就臨時修改堆疊,偏離原來的執行路線,使得返回使用者空間後進入APC函式,並且在執行完了這個函式以後仍進入核心,然後恢復原來的堆疊,再次返回使用者空間原來的斷點。這樣,對於原來的流程而言,就相當於受到了中斷。