Windows核心開發-3-核心程式設計基礎
這裡會深入講解kernel核心的API、結構體、和一些定義。考察程式碼在核心驅動中執行的機制。最後把所有知識合在一起寫一個有用的驅動。
本章學習要點:
1:通用核心程式設計指南
2:debug和release版本的區別
3:核心API
4:函式和錯誤程式碼
5:字串
6:動態記憶體分配
7:核心驅動物件
8:裝置物件
1 核心程式設計注意事項
核心程式設計依賴於WDK(Windows Driver Kit)Windows驅動工具包,這個東西存放了大量標頭檔案和第三方庫。核心的API由C構成,本質上核心開發和使用者態開發非常相似,但是還是有一些不同,比如:
User Mode | Kernel Mode | |
---|---|---|
Unhandled Exception未處理異常 | 未處理異常會導致程序崩潰 | 未處理異常會導致系統崩潰 |
Termination 終止 | 當一個程序中止時,會自動釋放記憶體和資源。 | 當一個驅動解除安裝時,如果沒有釋放掉它執行時所用的所有內容,都會導致洩露,只有重啟後會自動解決。 |
return values 返回值 | 函式的返回值錯誤有時可以忽略 | 永遠不要忽略任何錯誤 |
IRQL中斷請求級別 | 在PASSIVE_LEVEL(0)級別,級別比較低 | 可能是在DISPATCH_LEVEL(2)或更高級別 |
Bad coding壞程式碼 | 通常用在程序裡面處理 | 影響可以擴大到整個系統 |
Testing and Debugging測試和除錯 | 通常都在主機測試和除錯 | 必須在其他機器上進行除錯 |
Libraries | 可以使用絕大部分的C/C++庫(例如stl這中) | 絕大部分不能用 |
Exception Handleing異常的控制代碼 | 可以用C/C++裡面的異常也可以使用SEH(Windows中的) | 只能用SEH |
C++ Usage | 完全支援C++的用法 | 不支援C++ |
1.1 Unhandled Exceptions未處理的異常
在使用者態下寫的程式出現了異常就直接結束程序就完事了,但是如果在核心態這種問題會導致系統崩潰出現藍屏。
其實藍屏也是一種保護機制表示如果繼續往下執行就會造成很嚴重的後果。
1.2 Termination終止
當一個User的程序被關閉時,不管怎麼關閉的,都不會導致任何的洩露和系統問題。
但是如果是驅動程式就不一樣了,如果驅動程式正常關閉但是unload函式裡面沒有釋放前面保留的內容和資料就會導致洩露,只有在重啟後才會解決該問題。
1.3 return value返回值:
在user下的開發中,忽略返回值是經常乾的事情,比如有時候嫌麻煩就直接用void隨便怎麼返回。但是在核心下忽略返回值是一個非常危險的情況,應該避免這樣的情況出現,所以核心程式設計中有一點千萬記住,就是 始終檢查核心API返回值
1.4 IRQL 中斷請求級別
IRQL在核心開發中是一個非常重要的概念,在User的程式碼執行下它始終為0,在kernel下也經常為0,但是也可以不是0,也就是說kernel下這個級別可以提升。
高於0的IRQL後面再提。
1.5 C++ Usage用法
在User下,C++已經完美支援呼叫Windows API了。在核心中C++用得比較少,但是有一些使用資源的用法較弱( Resource Acquisition Is Initialization 資源獲取即初始化)RALL用法很常用,可以防止資源洩露。
C++是完美支援核心的,但是由於核心中沒有C++的執行示例,所以有一些C++的操作無法實行:
1 new和delete:
new和delete都是從user態的堆裡面來獲取資源,這顯然對kernel沒用。kernel的API更接近於C語言的malloc和free這樣的操作,當然要實現像user態的各種C++特性後面也會提到如何實現。
2 不會呼叫沒有預設建構函式的全域性變數。解決辦法:
A: 開闢建構函式,但是建構函式裡面沒有實際程式碼,只是呼叫init()函式,再在init函式裡寫好了。
B:只把指標作為全域性變數,利用指標來動態建立
3:C++中的異常長處理不支援(try,catch,throw),因為Kernel只支援SEH
4:不支援C++標準庫
驅動用純C來寫沒有任何問題,但是也可以採用C/C++。
1.6 Testing and Debugging測試和除錯
通常開發user下的程式,直接在本機搞就好。如果是除錯通常是將程序附加到偵錯程式上(如vs 2019)。
而在kernel下不行,為的是防止BSOD藍屏出現在開發者的電腦裡,通常是將另一臺虛擬機器弄來測試和除錯,因為除錯的斷點打在系統上,直接會讓系統停下來無法執行。
2 構建Debug和Release版本的區別
和在User下開發很型別,Debug版本更適合除錯,而Release版本利用編譯器來優化生成儘可能高效的程式碼。但是還是有一些區別的,有一些核心文件用Checked和Free版本來形容Debug和Release,如果看到了不要驚慌。
從編譯器的角度來看,Debug版本下會有一些巨集定義,會巨集定義DBG來區別Debug和release如果設定為1表示是debug。這個其實導致的最重要的就是Kdprint可以使用了,在debug版本下Kdprint會呼叫dbgprint來輸出資訊,但是在release就會忽略掉kdprint這個函式。
3 The Kernel API 核心API
寫的核心驅動程式可以使用已經存在的一些核心元件中提供的API,這個函式被稱為核心API。大多數的API由核心模組本身NtOskrnl.exe實現,但是有的也是來自別的模組(例如hal.dll)。
核心API的內部是一大堆C函式,大多數的函式的字首表明了實現該函式的核心元件。
以下是常見的Kernel核心API:
Prefix字首 | Meaning意義 | Example 示例函式 |
---|---|---|
Ex | 通用的執行函式 | ExAllocatePool |
Ke | 通用的核心函式 | KeAcquireSpinLock |
Mm | 記憶體管理函式 | MmProbeAndLockPages |
Rtl | 通用的庫函式 | RtlInitUnicodeString |
FsRtl | 檔案系統呼叫庫 | FsRtlGetFileSize |
Flt | 檔案系統過濾庫 | FltCreateFile |
Ob | 物件管理的操作函式 | ObReferenceObject |
Io | I/O裝置的管理 | IoCompleteRequest |
Se | 安全函式 | SeAccessCheck |
Ps | 有關程序結構的函式 | PsLookupProcessByProcessId |
Po | 電池管理函式 | PoSetSystemState |
Wmi | Windows管理工具 | WmiTraceMessage |
Zw | 本機API打包器 | ZwCreateFile |
Hal | 硬體抽象層相關函式 | HalExamineMBR |
Cm | 登錄檔相關函式 | CmRegisterCallBackEx |
4 Functions and Error Code 函式和錯誤程式碼
大部分的核心程式碼都會有返回值來表示是否操作成功,返回值的型別被定義為NTSTATUS,是一個32位的有符號數,返回值STATUS_SUCCESS(0)表示成功,返回負數表示失敗,具體的失敗型別可以通過ntstatus.h裡面檢視巨集定義來確定失敗型別。
大多數程式碼並不關係錯誤的根本原因,只需要知道是否是負數就行,對於這種只需要關心最高有效位是否為負就好。
這個可以用NT_SUCCESS巨集來確定是否為負。例如:
NTSTATUS Test(PRTL_OSVERSIONINFOW lp)
{
NTSTATUS status = AnyFuncion(lp);
if (NT_SUCCESS(status))
{
KdPrint(("Error occurred: 0x%08x\n", status));
return status;
}
return STATUS_SUCCESS;
}
5 strings 字串
大部分情況下核心採用unicode指標的形式來使用字串(wchar_t* 或者WCHAR)但是很多函式期待用UNICODE_STRING。
Unicode可以大致看作為UTF-16,意味著每個字元有2個位元組。這是核心的內部組成字串的方式。
UNICODE_STRING型別標識一個字串可以知道它的長度和最大長度。它的簡單定義如下:
typedef struct _UNICODE_STRING {
USHORT Length;
USHORT MaximumLength;
PWCH Buffer;//wchar的指標
} UNICODE_STRING;
typedef UNICODE_STRING *PUNICODE_STRING;
typedef const UNICODE_STRING *PCUNICODE_STRING;
UNICODE_STRING是以位元組而不是字元為單位,並且不包括UNICODE-NULL終結符,如果終結符存在,則MaximumLength是字串可以增長到的最大位元組數,而無需重新分配記憶體。操作UNICODE_STRING字串通常是用一組專門處理該字串的Rtl函式來完成。
以下是一部分操作UNICODE_STRING字串函式:
Function函式 | Description描述 |
---|---|
RtlInitUnicodeString | 基於C系列的字串指標初始化UNICODE_STRING字串,設定Buffer,計算Length長度,然後把MaximumLength設定為相同的值。它不分配記憶體,只是把現有的初始化。 |
RtlCopyUnicodeString | 把UNICODE_STRING字串拷貝給另一個UNICODE_STRING字串,拷貝的字串必須在拷貝前就開闢好空間,設定好內部的MaximumLength欄位 |
RtlCompareUnicodeString | 比較UNICODE_STRING字串(大於小於或等於),還可以指定是否區分大小寫 |
RtlEqualUnicodeString | 比較兩個UNICODE_STRING是否相等,區分大小寫。 |
RtlAppendUnicodeStringToString | 將一個UNICODE_STRING附加到另一個UNICODE_STRING後面。 |
RtlAppendUnicodeToString | 將一個UNICODE_STRING附加到C樣式字串上。 |
核心中還有一些函式可以處理C系列的字串,為了方便C的執行庫中也在核心裡實現了一些常用的字串如:wcscpy、wcscat、wcslen、wcscpy_s、wcschr、strcpy、strcpy_s 等。
6 Dynamic Memory Allocation動態記憶體分配
核心的棧空間非常小,所以任何大的記憶體卡都應該動態分配。
核心提供了兩個通用的記憶體池來給驅動使用:
A:Paged pool頁面池:如果需要可以被分頁的記憶體池
B: Non Paged Pool 非頁面池:保留在RAM中永遠不會被分頁的記憶體池。
很明顯地可以看出來Non Paged Pool非頁面池更好,因為它不會導致頁錯誤,但是使用該區域要謹慎使用,比較普通的情況還是使用Paged pool頁面池比較好。
POOL_TYPE這個列舉變量表示記憶體池的型別,該列舉類儲存了很多種記憶體池,但是隻有三種可以用:PagedPool頁面池,NonPagePool非頁面池,NonPagePoolNx(非頁面池且沒有執行許可權)。
處理記憶體池最有用的函式:
Function | Description |
---|---|
ExAllocatePool | 這個函式過時了 |
ExAllocatePoolWithTag | 從指定標籤的記憶體池中分配記憶體 |
ExAllocatePoolWithQuotaTag | 從指定標籤的記憶體池分配記憶體,並分配當前程序的記憶體池配額。 |
ExFreePool | 釋放分配的記憶體,該函式自動釋放不用管是什麼型別的。 |
一些函式中的tag引數允許用4位元組的值來標記分配的記憶體,通常這個值由4個ASCII字元組成,用來在邏輯上表示驅動程式或驅動程式的某些部分。這些標記常用來表示記憶體是否洩露(如果再解除安裝驅動後仍有任何標記該驅動程式的標記分配記憶體就表示有洩露)。
可以使用一些工具來檢視這個標記的tag: Poolmon WDK tool, or PoolMonX tool (downloadable from http://www.github.com/zodiacon/AllTools).
以下程式碼是對分配記憶體給字串,然後字串複製登錄檔內容給DriverEntry,然後再在unload例項程式中釋放該字串:
#include<ntddk.h>
#define DRIVER_TAG 'dcba' //定義一個標籤,由於小位元組序,在PoolMan中看到的是abcd
UNICODE_STRING g_RegistryPath;//定義一個UNICODE_STRING字串
void SampleUnload(_In_ PDRIVER_OBJECT DriverObject)
{
UNREFERENCED_PARAMETER(DriverObject);//防止這個引數沒有被使用而報錯。
ExFreePool(g_RegistryPath.Buffer);//釋放申請的記憶體
KdPrint(("Sample driver Unload called\n"));
}
extern"C"
NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath)
{
DriverObject->DriverUnload = SampleUnload;//定義Unload函式地址
g_RegistryPath.Buffer = (WCHAR*)ExAllocatePoolWithTag(PagedPool, RegistryPath->Length, DRIVER_TAG);
//分配一個字串,記憶體池型別是PagedPool,頁面記憶體池
//長度是登錄檔的長度,分配好的記憶體的標籤欄的內容的DRIVER_TAG
if (g_RegistryPath.Length == 0)
{
KdPrint(("Failed to allocate memory\n"));
return STATUS_INSUFFICIENT_RESOURCES;
}
g_RegistryPath.MaximumLength = RegistryPath->Length;//將最大值賦值為它的長度防止洩露
RtlCopyUnicodeString(&g_RegistryPath, (PCUNICODE_STRING)RegistryPath);//把登錄檔的內容複製給g_Registrty
KdPrint(("Copied registry path: %wZ\n", &g_RegistryPath));//%wZ是給UNICODE_STRING輸出的標準格式符。
return STATUS_SUCCESS;
}
這個自己拿去除錯就好了。
7 Lists連結串列
核心中的許多內部結構都採用迴圈雙向連結串列。
所有的List都用以下型別的結構構建:
typedef struct _LIST_ENTRY {
struct _LIST_ENTRY *Flink;
struct _LIST_ENTRY *Blink;
} LIST_ENTRY, *PLIST_ENTRY;
比如:
如果你要構建自己的雙向連結串列你可以採取這種格式:
struct MyDataItem {
// some data members
LIST_ENTRY Link;//這個就是前面的連結串列指標結構體
// more data members
};
當真正執行程式碼來跑的時候,我們會有一個表頭,存在某一個變數裡面,因為這個表頭的Windows自己定義的所以我們無法強行把它轉換變成別的,但是Windows提供了一個巨集定義幫助我們處理,我們在使用連結串列時只能把頭指標繼續執行Link裡面的資料,那麼我們要取整個結構體的資料怎麼辦呢?Windows提供了巨集定義來幫助我們:
MyDataItem* GetItem(LIST_ENTRY* pEntry) {
return CONTAINING_RECORD(pEntry, MyDataItem, Link);
}
這個返回值就是一個我們自己定義的MyDataItem結構體的指標了,就可以用它來處理了。
以下是常用的迴圈雙向連結串列函式:
Function | Description |
---|---|
InitializeListHead | 初始化一個列表頭來建立一個空連結串列,前面指標互相只向後前指標 |
InsertHeadList | 在連結串列最前面插入 |
InsertTailList | 在連結串列最後面插入 |
IsListEmpty | 判斷連結串列是否為空 |
RemoveHeadList | 刪除頭部節點 |
RemoveTailList | 刪除尾部節點 |
RemoveEntryList | 刪除特定內容 |
ExInterlockedInsertHeadList | 使用指定的自旋鎖原子地在列表的頭部插入一個專案。 |
ExInterlockedInsertTailList | 使用指定的自旋鎖原子地在列表的尾部插入一個專案。 |
ExInterlockedRemoveHeadList | 使用指定的自旋鎖原子地在列表的頭部刪除一個專案。 |
注:自旋鎖原子(specified spinlock)後面講
8 The Driver Object驅動物件
前面的DriverEntry函式的第一個引數其實就是一個驅動物件。驅動物件在WDK標頭檔案中定義,被稱為半文件化的結構體DRIVER_OBJECT。半文件化的意思是一部分內容可以查得到有文件記錄而另一部分沒有。該結構體由核心自己來分配並且部分初始化,然後提供給DriverEntry,由編寫的驅動程式來進一步初始化該結構體,來指示驅動程式支援的操作。
前面寫的各種demo裡面由一個unload例項函式,該函式被稱為驅動程式的一個操作。
驅動程式要執行的另外一個重要操作就是初始化操作,該操作被稱為排程例項(Dispatch Routines)。
一些常見的函式程式碼和意義:
Major Function主要函式 | Descript |
---|---|
IRP_MJ_CREATE(0) | 建立操作,通常為 CreateFile 或 ZwCreateFile 呼叫。 |
IRP_MJ_CLOSE(0) | 關閉操作,通常由CloseFile或ZwCloseFile呼叫。 |
IRP_MJ_READ(3) | 讀操作,通常被ReadFile、ZwReadFile和其類似的讀取API呼叫 |
IRP_MJ_WRITE(4) | 寫操作,通常被WriteFile、ZwWriteFile和其類似的API呼叫 |
IRP_MJ_DEVICE_CONTROL(14) | 對驅動程式的通用呼叫,由於 DeviceIoControl 或 ZwDeviceIoControlFile 呼叫而呼叫。 |
IRP_MJ_INTERNAL_DEVICE_CONTROL(15) | 與前一個類似,但僅適用於核心模式呼叫者。 |
IRP_MJ_PNP(31) | 即插即用回撥由即插即用管理器呼叫。 通常對基於硬體的驅動程式或 過濾這些驅動程式。 |
IRP_MJ_POWER(22) | 電源管理器呼叫的電源回撥。 通常對基於硬體的驅動程式或此類驅動程式的過濾器很感興趣。 |
在最開始的時候,MajorFunction函式陣列由核心初始化,執行核心內部的例項IopInvalidDeviceRequest,這個例項函式會返回一個失敗,表示所有的都沒有呼叫。這就意味著我們的驅動程式只需要寫自己需要的操作就好了,別的不用管都保留為預設值也就是沒有。但是如果我們沒有寫任何的排程就表示我們的驅動程式無法通訊也就是無法被使用起來。一個驅動程式要實用i起來必須至少支援IRP_MJ_CREATE和IRP_MJ_CLOSE操作,這將允許為驅動程式開啟一個裝置物件的控制代碼。
9 Device Objects裝置物件
客戶端和驅動程式對話的實際端點是裝置物件,裝置物件也是一個半文件化的DEVICE_OBJECT結構的例項物件。沒有裝置物件,驅動就沒有辦法連線。表示一個驅動程式至少應該建立一個裝置物件來方便和client互動。
CreateFile和它的變化函式都有一個接受檔名的引數,但是實際上這裡指向的其實是裝置物件的名稱,CreateFile這裡的File其實指的是檔案物件,開啟一個檔案或裝置的會建立一個核心結構FILE_OBJECT的例項,也是一個半文件化的結構。準確的來說CreateFile接受了一個符號連結,符號連結可以理解為檔案的快捷方式,該符號連結知道如何指向另一個核心物件的核心物件。所有的符號連結名字都不一樣,都儲存在Object Manager directory物件管理字典裡。但是可以通過WinObj檢視GLOBAL??目錄下的內容檢視。
一些常見的檔案目錄,像什麼C://其實就是I/O系統為了調IoRegisterDeviceInterface API 基於硬體的驅動程式來生成的。
大多數符號連結在??目錄(Winobj是GLOBAL??)下是指向Device目錄下的內部裝置名字,User呼叫者不能直接訪問此目錄中的名稱,但是可以通過核心使用IoGetDeviceObjectPointer API 來訪問。
驅動程式使用IoCreateDevice函式來建立裝置物件,該函式初始化並分配一個裝置物件結構並把指標給呼叫這,裝置物件例項儲存在DRIVER_OBJECT結構的DeviceObject成員中。如果建立多個物件就會形參一個單項鍊表:
總結
一些核心程式設計的注意事項,以及比較重要的概念字串,動態記憶體分配,連結串列,驅動物件和裝置物件的理解,這些一時間也記不完背不完,只能說後面慢慢用慢慢記了。