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實例函數,該函數被稱為驅動程序的一個操作。
一些常見的函數代碼和意義:
MajorFunction數組 | 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結構的實例對象。沒有設備對象,驅動就沒有辦法連接。表示一個驅動程序至少應該創建一個設備對象來方便和User交互。
typedef struct _DEVICE_OBJECT { CSHORT Type; USHORT Size; LONG ReferenceCount; struct _DRIVER_OBJECT *DriverObject; struct _DEVICE_OBJECT *NextDevice; struct _DEVICE_OBJECT *AttachedDevice; struct _IRP *CurrentIrp; PIO_TIMER Timer; ULONG Flags; ULONG Characteristics; __volatile PVPB Vpb; PVOID DeviceExtension; DEVICE_TYPE DeviceType; CCHAR StackSize; union { LIST_ENTRY ListEntry; WAIT_CONTEXT_BLOCK Wcb; } Queue; ULONG AlignmentRequirement; KDEVICE_QUEUE DeviceQueue; KDPC Dpc; ULONG ActiveThreadCount; PSECURITY_DESCRIPTOR SecurityDescriptor; KEVENT DeviceLock; USHORT SectorSize; USHORT Spare1; struct _DEVOBJ_EXTENSION *DeviceObjectExtension; PVOID Reserved; } DEVICE_OBJECT, *PDEVICE_OBJECT;
創建設備對象需要用到API:
NTSTATUS IoCreateDevice( PDRIVER_OBJECT DriverObject,//設備對象綁定的驅動對象 ULONG DeviceExtensionSize,//設備對象的大小,采用0用默認的就好 PUNICODE_STRING DeviceName,//設備對象的名稱 DEVICE_TYPE DeviceType,//設備對象的類型,沒有指定什么設備的對象就用FILE_DEVICE_UNKNOWN就好 ULONG DeviceCharacteristics,//設備對象的特征,默認用0就行 BOOLEAN Exclusive,//True表示在內核模式下使用,一般都是True PDEVICE_OBJECT *DeviceObject//接受設備對象的指針 ); //創建設備對象的例子 PDEVICE_OBJECT DeviceObject = nullptr; UNICODE_STRING devName = RTL_CONSTANT_STRING(L"\\Device\\test"); status = IoCreateDevice(DriverObject, 0, &devName, FILE_DEVICE_UNKNOWN, 0, TRUE, &DeviceObject);
那么如何利用設備對象來進行交互呢?其實很多時候你都用到了只是你不知道,在Windows下的和文件相關的內容都是和設備對象進行交互了,比如:CreateFile,ReadFile,WriteFile這些操作Windows文件的API。
比如說:CreateFile
HANDLE CreateFileA(
LPCSTR lpFileName,
DWORD dwDesiredAccess,
DWORD dwShareMode,
LPSECURITY_ATTRIBUTES lpSecurityAttributes,
DWORD dwCreationDisposition,
DWORD dwFlagsAndAttributes,
HANDLE hTemplateFile
);
這里的第一個參數,lpFileName就是設備對象了,只是說一般我們在User模式下是用的符號鏈接,符號鏈接可以想象成一種快捷方式,相當於設備對象的一個別名,專門用來給User下使用設備對象准備的。User模式下識別設備只能通過符號鏈接或者接口設備來使用,但是接口設備一般用的很少。設備對象的名稱只能被Kernel模式的驅動識別,而符號鏈接可以被Kernel和User識別。比如說常用的C盤、D盤就是符號鏈接。所謂的C盤指的是名為"C:"的符號鏈接,而真實的設備對象是 \Device\HarddiskVolume1。注意設備對象的名稱只能用\Device\開頭。而符號對象在內核模式下是以 \??\或者是\DosDevices\開頭的比如前面例子里的:\Device\test。在User模式下就是以 \.\開頭的比如說 ”\.\C: “。
一個創建設備對象和建立符號鏈接的例子:
PDEVICE_OBJECT DeviceObject = nullptr; UNICODE_STRING symLink = RTL_CONSTANT_STRING(L"\\??\\test"); UNICODE_STRING devName = RTL_CONSTANT_STRING(L"\\Device\\test"); IoCreateDevice(DriverObject, 0, &devName, FILE_DEVICE_UNKNOWN, 0, TRUE, &DeviceObject); IoCreateSymbolicLink(&symLink, &devName);
驅動程序使用IoCreateDevice函數來創建設備對象,該函數初始化並分配一個設備對象結構並把指針給調用這,設備對象實例存儲在DRIVER_OBJECT結構的DeviceObject成員中。如果創建多個對象就會形參一個單項鏈表:
總結