理解和使用NT驅動程序的執行上下文

理解Windows NT驅動程序最重要的概念之一就是驅動程序運行時所處的“執行上下文”。理解並小心地應用這個概念可以幫助你構建更快、更高效的驅動程序。

NT標準內核模式驅動程序編程中的一個重要觀念是某個特定的驅動程序函數執行時所處的“上下文”。傳統上文件系統開發者最關注這個問題,但所有類型的NT內核模式驅動程序的編寫者都能從對執行上下文的深刻理解中獲益。小心謹慎地使用執行上下文的概念能幫助構建更高性能、更低開銷的驅動程序設計。

在本文中,我們將探尋執行上下文的概念。作爲對概念的示範,本文在結尾描述了一個能讓用戶程序在內核模式下運行並擁有其中所有權限的驅動程序。在這個過程中,我們也將討論設備驅動程序中執行上下文的實際用法。

什麼是上下文?

當提及一個例程的上下文時,我們是指它的線程和進程的執行環境。在NT中,這個環境由當前的線程環境塊(TEB)和進程環境塊(PEB)建立。上下文因此包括虛擬內存的設置(告訴我們那個物理內存頁面對應那個虛擬內存地址),句柄轉換(因爲句柄是基於進程的),分派器信息,堆棧,以及通用和浮點寄存器的設置。當我們問到一個特定的內核例程運行在那個上下文時,我們實際在問,“那一個是(NT內核)分派器建立的當前線程?”因爲每一個線程只屬於一個進程,當前線程確定了當前進程。當前線程和當前進程在一起確定了唯一標識線程和進程的所有事情(句柄、虛擬內存、調度器狀態和寄存器)。

虛擬內存也許是上下文中對內核模式驅動程序編寫者最有用的一個方面。還記得NT把用戶進程映射到虛擬地址空間的低2GB,把操作系統自身的代碼映射到虛擬地址空間的高2GB嗎?當一個用戶進程中的線程執行時,它的虛擬地址範圍是0到2GB,2GB以上的所有地址均被設置爲“no access”,以此防止用戶直接訪問操作系統代碼和結構。當操作系統代碼執行時,它的虛擬地址範圍是2到4GB,而當前用戶進程(如果有的話)的地址映射到0到2GB。在NT3.51和V4.0中,映射到高2GB地址的代碼從不變化。然而,映射到低2GB地址的代碼會變化,取決於當前進程是那一個。

此外,在NT特殊的虛擬內存排布策略中,進程P內一個合法的虛擬地址X(X小於等於2GB)和內核虛擬地址X對應於相同的物理內存位置。當然,這只有在進程P是當前進程並且(也因此)進程P的物理頁面映射到操作系統的低2GB虛擬地址空間時才能成立。上面這句話的另一個說法就是,“這隻在P是當前進程時才能成立。”所以在同一個進程上下文中,用戶虛擬地址和2GB以上的內核虛擬地址指向相同的物理位置。

上下文中另一個讓內核模式驅動程序編寫者感興趣的方面是線程調度上下文。當一個線程在等待時(例如通過發出Win32函數WaitForSingleObject(…)來等待一個沒有被激發的對象),這個線程的調度上下文對象被用來存儲關於線程定義所等待的對象的信息。當發出未滿足的等待時,這個線程就從就緒隊列中被移出,只有當等待被滿足(指定的分派器對象被激發)時才被移回。

上下文也影響到句柄的使用。因爲句柄是針對一個特定的進程的,在一個進程中創建的句柄在其他進程上下文中是沒有用的。

不同類型的上下文

內核模式的例程運行在下面三種不同的上下文之一:
-系統進程上下文
-特定用戶線程(和進程)上下文
-任意用戶線程(和進程)上下文

在執行過程中,每一個內核模式驅動程序的各部分可能運行在上面三種上下文之一。例如,一個驅動程序的DriverEntry(…)函數總是運行在系統進程的上下文中。系統進程上下文無用戶上下文無關(因此沒有TEB),並且也沒有用戶進程映射到內核虛擬地址空間的低2GB中。另一方面,DPCs(例如一個驅動程序爲ISR服務的DPC或者定時器到期函數)運行在任意用戶線程的上下文中。這意味者在一個DPC的執行過程中,任何用戶線程都可以成爲“當前”線程,因此任何用戶進程都可以映射到內核虛擬地址空間的低2GB中。

驅動程序的分派例程執行時所處的上下文應該引起特別的注意。在許多情況下,內核模式驅動程序的分派例程運行在調用者用戶線程的上下文中。圖1顯示了爲什麼會這樣。當一個用戶線程向一個設備發出了I/O函數調用,例如通過調用Win32的ReadFile(…)函數,將產生一個系統服務請求。在Intel架構的處理器上,這樣的請求依靠通過一箇中斷門的軟中斷來實現。中斷門把處理器的當前權限級別改變到內核模式,切換內核堆棧的,然後再調用系統服務分派器。系統服務分派器接着調用操作系統內處理所請求的系統服務的函數。。對應ReadFile(…)則是I/O子系統內的NtReadFile(…)函數。NtReadFile(…)函數構造一個IRP,然後調用對應於被ReadFile(…)請求的文件句柄所引用的文件對象的驅動程序的讀分派例程。所有這些均發生在IRQL級別PASSIVE_LEVEL之上。


在上面描述的整個過程中,用戶請求沒有被調度或者排隊。所以用戶線程或進程的上下文沒有改變。在這個例子中,驅動程序的分派例程運行在發出ReadFile(…)請求的用戶線程的上下文中。這意味着當驅動程序的讀分派函數運行時,是用戶線程在執行內核模式驅動程序的代碼。

驅動程序的分派函數總是運行在發出請求的用戶線程的上下文中嗎?嗯,並非如此。內核模式驅動程序設計指南4.0版的16.4.1.1小節告訴我們,“只有最高層的NT驅動程序,例如文件系統驅動程序,可以確保它們的分派函數在用戶模式線程的上下文中被調用。”從我們的例子可以看出,這個說法並不完全精確。文件系統驅動程序(FSDs)當然是在發出請求的用戶線程的上下文中被調用。實際上,任何因用戶I/O請求而被直接調用的驅動程序,只要不是先通過另一個驅動程序,都可確保在發出請求的用戶線程的上下文中被調用。這包括了文件系統驅動程序的情況。這也意味着大多數用戶編寫的直接爲用戶應用程序提供函數的標準內核模式驅動程序,例如那些過程控制設備的驅動,它們的分派函數將在發出請求的用戶線程上下文中被調用。

實際上,驅動程序分派函數不在調用者線程的上下文中被調用唯一方式是用戶請求首先被定向到了一個更高層的驅動程序,例如文件系統驅動程序。如果高層驅動將請求傳遞給了一個系統工作線程,這將導致上下文的改變。當IRP最終傳遞到低層驅動程序時,不能保證轉發IRP的高層驅動程序運行時所處的上下文還是發出請求的用戶線程的上下文。低層驅動程序將運行在任意線程上下文中。

一般的規則是,當一個設備直接被用戶訪問而不涉及其他驅動程序時,該設備的驅動程序的分派線程總是運行在發出請求的用戶線程中。這時就有一些十分有趣的後果,使得我們能夠做一些同樣有趣的事情。

影響

分派函數運行在調用者用戶線程的上下文中的後果是什麼?嗯,有些是有用的,有些是令人討厭的。例如,讓我們假設一個驅動程序在分派函數中用ZwCreateFile(…)創建了一個文件。當同一個驅動程序試圖用ZwReadFile(..)讀取那個文件時將會失敗,除非讀取和創建是發自同一個用戶線程的上下文中。這是因爲句柄和文件對象是按線程存儲的。繼續上面的例子,如果ZwReadFile(…)請求成功發出,驅動程序可以選擇在一個和讀取操作相關的事件上等待來等待讀取操作完成。當這個等待發出後會發生什麼呢?當前用戶線程被放入等待的狀態,引用着一個事件指示對象。到此爲止,關於異步I/O請求的操作僅僅這麼些!操作系統分派器找到下一個擁有最高優先權的就緒的線程。當事件對象因ReadFile(…)請求完成而設置爲被激發的狀態後,只有當用戶線程再次成爲一個N CPU系統的N個擁有最高優先權的就緒線程之一時,驅動程序纔會運行。

在發出請求的用戶線程上下文中運行也有一些非常有用的好處。例如,用句柄值-2(意味着“當前線程”)調用ZwSetInformationThread(…)函數將允許驅動程序改變當前線程的所有的各種各樣的屬性。類似地,用NtCurrentProcess(…)的句柄值(在ntddk.h中定義爲1)調用ZwSetInformationProcess(…)將允許驅動程序當前進程的所有特性。注意,因爲這兩個調用在內核模式發出,所以不會進行安全性堅持。也就是說這種方式有可能改變線程自身不能訪問的線程或進程屬性。

然而,在發出請求的用戶線程上下文中運行最有用的地方也許是直接訪問用戶虛擬地址的能力。例如,請考慮一個簡單的,直接被用戶程序使用的共享內存類型設備的驅動程序。我們假設在這個設備上的一個寫操作由從用戶緩衝區直接拷貝1K數據到設備的共享內存區構成,而該設備的共享內存區總是可訪問的。

這個設備的驅動程序的傳統設計可能使用帶緩衝的I/O,因爲要移動的數據量遠遠小於一個頁面的長度。也就是說,I/O Manager將在非分頁池中爲每一個寫請求分配一塊大小和用戶數據緩衝區相同的緩衝區,再從用戶緩衝區拷貝數據到這個非分頁池中的緩衝區。I/O Manager調用驅動程序的寫分派例程,在IRP裏面提供一個指向非分頁池中的緩衝區的指針(Irp->AossicatedIrp.SystemBuffer)。然後,驅動程序從非分頁池中的緩衝區拷貝數據到設備的共享內存區。這個設計效率有多高?嗯,爲完成一件事而拷貝了兩次數據,更別提I/O Manager還要爲非分頁池中的緩衝區進行共享池分配的事實。我可不願稱之爲最低開銷設計。

假設我們要增加這個設計的性能,依然使用傳統方法。我們可以讓驅動程序使用直接I/O。在這種情況下,I/O Manager找出並在內存鎖定包含用戶數據的頁面。然後I/O Manager用一個內存描述符列表(MDL)描述用戶數據緩衝區,指向這個MDL的指針在IRP裏面提供給驅動程序(Irp->MdlAddress)。現在,當驅動程序的寫分派函數得到IRP後,它需要用MDL創建一個可以用作拷貝操作數據源的系統地址。這由調用IoGetSystemAddressForMdl(…)完成,它隨後調用MmMapLockedPages(…)把 MDL中的頁面表入口映射到內核虛擬地址空間。利用IoGetSystemAddressForMdl(…)返回的內核虛擬地址,驅動程序用戶緩衝區拷貝數據到設備的共享內存區。這個設計效率有多高?嗯,比第一個設計要好。但是映射也不是一個低開銷的操作。

那麼這兩個傳統設計的替代方案是什麼?嗯,假設用戶程序直接和這個驅動程序對話,我們知道驅動程序的分派例程總是在發出請求的用戶線程的上下文中被調用。因此我們可以用“非I/O”來繞過帶緩衝的I/O和直接I/O的設計。驅動程序通過在設備對象的標誌字裏面即不指定DO_DIRECT_IO位也不指定DO_BUFFERED_IO位來指明需要使用“非I/O”。當驅動程序的寫分派函數被調用時,用戶數據緩衝區的用戶模式虛擬地址可在Irp->UserBuffer找到。因爲指向用戶空間位置的內核模式虛擬地址和指向同一位置的用戶模式虛擬地址是相同的,驅動程序可直接使用Irp->UserBuffer,從用戶數據緩衝區拷貝數據到設備的共享內存區。當然,爲預防訪問用戶緩衝區時出錯,驅動程序可將拷貝包含在一個try…except語句塊中。沒有映射,沒有重複拷貝,沒有共享池分配。就是一個直接的拷貝。沒有那些我所說的低開銷的操作。

但是使用“非I/O”有一個不利之處。如果用戶傳遞了一個對驅動程序合法卻對用戶進程非法的緩衝區指針給驅動程序會發生什麼?try…excpet語句塊無法捕獲這個問題。例如,一個指向者被用戶進程映射爲只讀,但是可以在內核模式下讀/寫的內存的指針。在這種情況下,驅動程序的移動操作將簡單地把數據放在用戶程序看來是隻讀的地方!這是個問題嗎?嗯,這取決於驅動程序和應用程序。只有你才能決定這個設計的回報是否值得冒潛在的風險。

限制

最後用一個例子演示運行在發出請求的用戶線程的上下文中的驅動程序的許多可能性。這個例子將演示當驅動程序運行時,所發生的是運行在內核模式下的調用者用戶進程的上下文中。我們編寫了一個名叫SwitchStack的僞設備。因爲是一個僞設備,它不與任何硬件相關。這個驅動程序支持創建,關閉和一個使用METHOD_NEITHER的IOCTL操作。當用戶程序發出這個IOCTL時,提供一個void類型的指針作爲IOCTL的輸入緩衝區,以及一個函數指針(參數爲一個void類型的指針並返回void)作爲IOCTL的輸出緩衝區。當處理這個IOCTL時,驅動程序調用指定的用戶函數,將PVOID作爲上下文變量傳遞。在用戶地址空間的結果函數將在內核模式下執行。

依照NT的設計,很少有回調函數不能做的事。它能發出Win32函數調用,彈出對話框和執行文件I/O。唯一不同的是,這個用戶程序將運行在內核模式下,使用內核堆棧。當一個應用程序運行在內核模式下時,它不受權限和配額限制,不受保護檢查。因爲在內核模式下執行的所有函數都擁有IOPL,這個用戶程序甚至可以發出IN和OUT指令(當然是在Intel架構的系統上)。你的想像力(外加一點常識)只受到驅動程序所能做到的事情的類型的限制。

//++
// SwitchStackDispatchIoctl
//
// This is the dispatch routine which processes
// Device I/O Control functions sent to this device
//
// Inputs:
// DeviceObject Pointer to a Device Object
// Irp Pointer to an I/O Request Packet
//
// Returns:
// NSTATUS Completion status of IRP
//
//--
NTSTATUS
SwitchStackDispatchIoctl(IN PDEVICE_OBJECT, DeviceObject, IN PIRP Irp)
{
PIO_STACK_LOCATION Ios;
NTSTATUS Status;
//
// Get a pointer to current I/O Stack Location
//
Ios = IoGetCurrentIrpStackLocation(Irp);
//
// Make sure this is a valid IOCTL for us...
//
if(Ios->Parameters.DeviceIoControl.IoControlCode!=IOCTL_SWITCH_STACKS)
{
Status = STATUS_INVALID_PARAMETER;
}
else
{
//
// Get the pointer to the function to call
//
VOID (*UserFunctToCall)(PULONG) = Irp->UserBuffer;
//
// And the argument to pass
//
PVOID UserArg;
UserArg = Ios->Parameters.DeviceIoControl.Type3InputBuffer;
//
// Call user's function with the parameter
//
(VOID)(*UserFunctToCall)((UserArg));
Status = STATUS_SUCCESS;
}
Irp->IoStatus.Status = Status;
Irp->IoStatus.Information = 0;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return(Status);
}

上面是驅動程序的DispatchIoCtl函數。這個驅動程序在標準的Win32系統服務調用中被調用,如下所示:
DeviceIoControl (hDriver,(DWORD) IOCTL_SWITCH_STACKS,
&UserData,
sizeof(PVOID),
&OriginalWinMain,
sizeof(PVOID),
&cbReturned,
設計這個例子當然並非鼓勵你編寫運行在內核模式下的的程序。但是,這個例子所作的事說明了當你的驅動程序運行時,它的確是運行在一個普通的Win32程序的上下文中,帶有所有的變量,隊列,windows句柄,諸如此類。唯一的不同是運行在內核模式,使用內核堆棧。

總結

到這兒就搞定了。理解上下文將是有用的工具,它可幫助你避免一些討厭的問題。當然它可以讓你寫出一些非常酷的驅動程序。讓我們期待這對你有所幫助。祝你編寫驅動快樂!
發佈了5 篇原創文章 · 獲贊 5 · 訪問量 5萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章