Windows內核開發-3-內核編程基礎


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調度實例,這個是一個函數指針數組,位於DRIVER_OBJECT的MajorFunction成員中,這一組操作指定驅動程序支持哪些特定操作,例如:創建、讀取、寫入等。這些函數指針的前綴是由IRP_MJ_開始的。

一些常見的函數代碼和意義:

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成員中。如果創建多個對象就會形參一個單項鏈表:

 

 

總結

一些內核編程的注意事項,以及比較重要的概念字符串,動態內存分配,鏈表,驅動對象和設備對象的理解,這些一時間也記不完背不完,只能說后面慢慢用慢慢記了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM