Windows核心-7-IRP和派遣函式
IRP以及派遣函式是Windows中非常重要的概念。IRP 是I/O Request Pocket的簡稱,意思是I/O操作的請求包,Windows中所有User和Kernel之間的交流都會被封裝成一個IRP結構體,然後不同的IRP會被派遣到不同的派遣函式裡面,通過派遣函式來實現I/O操作。
IRP
typedef struct _IRP {
CSHORT Type;
USHORT Size;
PMDL MdlAddress;
ULONG Flags;
union {
struct _IRP *MasterIrp;
__volatile LONG IrpCount;
PVOID SystemBuffer;
} AssociatedIrp;
LIST_ENTRY ThreadListEntry;
IO_STATUS_BLOCK IoStatus;
KPROCESSOR_MODE RequestorMode;
BOOLEAN PendingReturned;
CHAR StackCount;
CHAR CurrentLocation;
BOOLEAN Cancel;
KIRQL CancelIrql;
CCHAR ApcEnvironment;
UCHAR AllocationFlags;
union {
PIO_STATUS_BLOCK UserIosb;
PVOID IoRingContext;
};
PKEVENT UserEvent;
union {
struct {
union {
PIO_APC_ROUTINE UserApcRoutine;
PVOID IssuingProcess;
};
union {
PVOID UserApcContext;
#if ...
_IORING_OBJECT *IoRing;
#else
struct _IORING_OBJECT *IoRing;
#endif
};
} AsynchronousParameters;
LARGE_INTEGER AllocationSize;
} Overlay;
__volatile PDRIVER_CANCEL CancelRoutine;
PVOID UserBuffer;
union {
struct {
union {
KDEVICE_QUEUE_ENTRY DeviceQueueEntry;
struct {
PVOID DriverContext[4];
};
};
PETHREAD Thread;
PCHAR AuxiliaryBuffer;
struct {
LIST_ENTRY ListEntry;
union {
struct _IO_STACK_LOCATION *CurrentStackLocation;
ULONG PacketType;
};
};
PFILE_OBJECT OriginalFileObject;
} Overlay;
KAPC Apc;
PVOID CompletionKey;
} Tail;
} IRP;
IRP這種機制類似於Windows的訊息機制,驅動在接受到IRP之後會根據IRP的不同型別分配給不同型別的派遣函式來處理IRP。
IRP不是單獨的,只要建立了IRP就會跟著建立IRP的I/O棧,有一個棧是給核心驅動用的:
驅動需要呼叫IoGetCurrentIrpStackLocation函式來獲取內驅驅動對應的I/O棧。
例如:
auto stack = IoGetCurrentIrpStackLocation(Irp)
該API返回一個IO_STACK_LOCATION 結構體:
typedef struct _IO_STACK_LOCATION {
UCHAR MajorFunction;
UCHAR MinorFunction;
UCHAR Flags;
UCHAR Control;
union {
...
...
...
...
...
} Parameters;
PDEVICE_OBJECT DeviceObject;
PFILE_OBJECT FileObject;
PIO_COMPLETION_ROUTINE CompletionRoutine;
PVOID Context;
} IO_STACK_LOCATION, *PIO_STACK_LOCATION;
這個結構體有兩個重要的屬性,分別是MajorFunction和MinorFunction,分別記錄了IRP的主型別和子型別,作業系統根據MajorFunction來將IRP派遣到不同的派遣函式裡面去處理,而還可以繼續利用MinorFunction來判斷更多的內容,基本上MajorFunction用的比較多。
設定IRP和派遣函式
在DriverEntry驅動物件裡面有一個函式指標陣列MajorFunction就是來記錄派遣函式的地址,來讓派遣函式和IRP一一對應,這個數組裡面採用巨集定義來將每個 IRP和派遣函式通過陣列的索引來一一對應:
#define IRP_MJ_CREATE 0x00
#define IRP_MJ_CREATE_NAMED_PIPE 0x01
#define IRP_MJ_CLOSE 0x02
#define IRP_MJ_READ 0x03
#define IRP_MJ_WRITE 0x04
#define IRP_MJ_QUERY_INFORMATION 0x05
#define IRP_MJ_SET_INFORMATION 0x06
#define IRP_MJ_QUERY_EA 0x07
#define IRP_MJ_SET_EA 0x08
#define IRP_MJ_FLUSH_BUFFERS 0x09
#define IRP_MJ_QUERY_VOLUME_INFORMATION 0x0a
#define IRP_MJ_SET_VOLUME_INFORMATION 0x0b
#define IRP_MJ_DIRECTORY_CONTROL 0x0c
#define IRP_MJ_FILE_SYSTEM_CONTROL 0x0d
#define IRP_MJ_DEVICE_CONTROL 0x0e
#define IRP_MJ_INTERNAL_DEVICE_CONTROL 0x0f
#define IRP_MJ_SHUTDOWN 0x10
#define IRP_MJ_LOCK_CONTROL 0x11
#define IRP_MJ_CLEANUP 0x12
#define IRP_MJ_CREATE_MAILSLOT 0x13
#define IRP_MJ_QUERY_SECURITY 0x14
#define IRP_MJ_SET_SECURITY 0x15
#define IRP_MJ_POWER 0x16
#define IRP_MJ_SYSTEM_CONTROL 0x17
#define IRP_MJ_DEVICE_CHANGE 0x18
#define IRP_MJ_QUERY_QUOTA 0x19
#define IRP_MJ_SET_QUOTA 0x1a
#define IRP_MJ_PNP 0x1b
這些每一個都有特定的意思,比較常用的有:
IRP_MJ_CREATE //建立 和CreateFile對應
IRP_MJ_READ //讀取 和ReadFile對應
IRP_MJ_WRITE //寫入 和WriteFile對應
IRP_MJ_CLOSE //關閉 和CloseFile對應
所有的派遣函式都有一個原型:
typedef NTSTATUS DRIVER_DISPATCH (
_In_ PDEVICE_OBJECT DeviceObject,
_Inout_ PIRP Irp);
//typedef可以省去,名字自己取
IRP傳遞流程
I/O系統是以裝置物件為中心,而不是以驅動物件為中心的。IRP可以在裝置物件中傳來傳去:
但是不管是在怎麼傳遞,最後都必須把這個IRP請求結束,給它完成。
完成必備操作:
NTSTATUS SysMonRead(PDEVICE_OBJECT, PIRP Irp) {
Irp->IoStatus.Status = status;//設定狀態
Irp->IoStatus.Information = count;//統計處理的位元組數
IoCompleteRequest(Irp, IO_NO_INCREMENT);//完成IO操作的必須返回函式
return status;
}
IoCompleteRequest這個API需要返回一個IRP大家應該沒問題,但是第二個引數就比較複雜了,第二個引數是返回之後的執行緒級,這是因為一個執行緒在執行IRP的時候會等待。
這裡以ReadFile為例,ReadFile函式會呼叫ntdll中的NtReadFile函式,然後ntdll中的NtReadFile函式又呼叫核心的NtReadFile函式,然後核心的NtReadFile函式建立關於Read這個型別的IO_MJ_READ型別的IRP再將它傳送到核心的對應IRP的派遣函式裡面,然後等待IRP對應派遣函式執行完之後再返回。所以需要設定當返回後執行緒又重新執行了的執行緒級。
User操作裝置物件
前面我們說了,User只能通過符號連結來操作裝置物件進行I/O互動,所以User只需要在User的API下面把通常使用的檔案路徑改成符號連結就好了。
比如:
CreateFile(L"\\\\.\\test", GENERIC_READ, 0, nullptr, OPEN_EXISTING, 0, nullptr)
注意C語言是有轉義字元的,所以這個第一個引數看起來怪怪的。
讀寫方式
IRP和派遣函式是用來進行I/O操作的,I/O操作就是讀寫,所以很自然的有了這個讀寫方式的環境。
一般讀寫方式有三種:緩衝I/O,直接I/O,和其它方式。三種方式對應的Flags分別是DO_BUFFERED_IO、DO_DIRECT_IO和0。都在DeviceObject裝置物件裡面新增。
例如:
DeviceObject->Flags |= 0;
注意:並不是直接賦值
緩衝I/O
讀寫操作通常是WriteFile和ReadFile這類API,這類API會要求新增緩衝區指標和緩衝區大小作為函式引數,然後WriteFile/ReadFile將這段記憶體的資料傳遞給驅動程式。由於這段緩衝區是使用者模式的記憶體地址,所以直接使用會非常危險,因為Windows是多程序作業系統,有可能在你用的時候就被別的程序改了,所以直接使用非常危險。
而緩衝I/O的原理就是,在核心模式下開闢一個緩衝空間來儲存該緩衝區內容,然後讀取的時候先放到核心緩衝區裡面,再來進行復制和賦值操作。
比如:當呼叫ReadFile/WriteFile時,作業系統提供核心模式下的一段地址來存放User在WriteFile裡面配置的Buffer,然後當IRP請求結束,核心區域的Buffer就會被拷貝到User/Kernel裡面。
1 I/O 管理器從非分頁池中分配一個與使用者緩衝區大小相同的緩衝區。它將指向這個新緩衝區的指標儲存在 IRP 的 AssociatedIrp->SystemBuffer 成員中。 (緩衝區大小可以在當前 I/O 堆疊位置的 Parameters.Read.Length 或 Parameters.Write.Length 中找到。)
2 對於寫請求,I/O 管理器將使用者的緩衝區複製到系統緩衝區。
3 現在才呼叫驅動程式的排程例程。驅動程式可以直接使用系統緩衝區指標而無需任何檢查,因為緩衝區在系統空間中(它的地址是絕對的 - 從任何程序上下文中都一樣),並且在任何 IRQL 中,因為緩衝區是從非分頁池分配的,所以它不能被調出。
4 一旦驅動完成IRP(IoCompleteRequest),I/O管理器(對於讀請求)將系統緩衝區複製回用戶的緩衝區(複製的大小由IRP中設定的IoStatus.Information欄位決定)司機)。
5 最後,I/O 管理器釋放系統緩衝區。
圖文講解一下ReadFile的整體流程:(WriteFile以此類推)
這裡是User下一個Buffer緩衝區:
作業系統知道是緩衝I/O後開闢了一個核心緩衝區,然後由WriteFile/ReadFile建立的Irp->AssociatedIrp.SystemBuffer來儲存記錄
然後驅動訪問系統空間往裡面寫東西
作業系統將系統空間的內容拷貝到User緩衝區裡。
拷貝完了,釋放系統空間記憶體。
派遣函式中可以通過Irp的Parameters.Read.Length來知道User請求多少位元組,也可以通過Parameters.Write.Length來知道要寫入多少位元組,但是真正執行了多少是通過Irp的IoStatus.Information欄位來返回,所以WriteFile/ReadFile函式中各個引數是意義就可以解釋通透了。
但是這樣的缺點是會造成記憶體空間開銷,因為核心空間是公有的大家都得用,而且由於大量資料的複製也會影響效率,所以針對比較小的內容採用緩衝I/O可以採取這種辦法,別的還是用直接I/O吧。
//緩衝I/O的例子:
NTSTATUS SysMonRead(PDEVICE_OBJECT, PIRP Irp) {
auto status = STATUS_SUCCESS;
auto stack = IoGetCurrentIrpStackLocation(Irp);//得到當前的IRP棧
auto ulReadLength = stack->Parameters.Read.Length;//得到要讀取的位元組數
//完成IRP操作
Irp->IoStatus.Status = status;//設定IRP完成狀態
Irp->IoStatus.Information = ulReadLength;//設定實際完成的IRP位元組數
memset(Irp->AssociatedIrp.SystemBuffer, 0xAA, ulReadLength);//拷貝內容到系統地址空間
IoCompleteRequest(Irp, IO_NO_INCREMENT);//完成IRP操作
return status;
}
User下的就不用寫了吧,hh,偷個懶。
直接I/O
直接I/O用了另一個辦法來規避風險。
1 I/O 管理器確保使用者的緩衝區有效。
2 將其對映到實體記憶體中,然後將緩衝區鎖定在記憶體中,因此在另行通知之前無法將其調出。這解決了緩衝區訪問的問題之一——不會發生頁面錯誤,因此在任何 IRQL 中訪問緩衝區都是安全的。
3 I/O 管理器構建記憶體描述符列表 (MDL),這是一種知道緩衝區如何對映到 RAM 的資料結構。該資料結構的地址儲存在 IRP 的 MdlAddress 欄位中。
4 此時,驅動程式呼叫派遣函式。因為使用者的緩衝區被鎖定在 RAM 中,不能從任意執行緒訪問。所以當驅動程式需要訪問緩衝區時,它必須呼叫將同一使用者緩衝區對映到系統地址的函式。由於該地址被鎖進了系統中所以,該地址在任何程序上下文中都是有效的。所以本質上,我們得到了到同一個緩衝區的兩個對映。一個來自原始地址(僅在請求者程序的上下文中有效),另一個來自系統空間,始終有效。要呼叫的 API 是 MmGetSystemAddressForMdlSafe,傳遞由 I/O 管理器構建的 MDL。返回值是系統地址。
5 User或Kernel進行緩衝區修改:
6結束: 一旦驅動程式完成請求,I/O 管理器刪除第二個對映(到系統空間),釋放 MDL 並解鎖使用者緩衝區,因此它可以像任何其他使用者模式記憶體一樣正常分頁。
這裡全程沒有用到拷貝,完完全全就是通過地址對映User->記憶體,然後Kernel修改記憶體等同於直接修改User的緩衝區。
直接I/O在使用者態下:
直接I/O作業系統會在User下用MDL這個資料結構來儲存相關資訊:
大小儲存在MDL->ByteCount裡,然後這段虛擬記憶體的首地址在MDL->StartVa裡,實際緩衝區的首地址相當於起始地址的偏移地址在ByteOffset裡,DDK裡面封裝了幾個巨集來方便用
#define MmGetMdlByteCount(Mdl) ((Mdl)->MyteCount)
#define MmGetMdlByteOffset(Mdl) ((Mdl)->ByteOffset)
#define MmGetMdlVirtualAddress(Mdl) (PVOID)((PCHAR)(Mdl)->StartVa)+(Mdl)->ByteOffset)
直接I/O在Kernel下
Kernel下比較簡單粗暴直接用了一個巨集來得到MDL在核心模式下的地址對映
MmGetSystemAddressForMdlSafe();
然後通過前面緩衝I/O用到的一些Read的length或者Write的Length直接通過這個地址拷貝就行了。
直接I/O例子
NTSTATUS TestRead(IN PDEVICE_OBJECT pDevObj,
IN PIRP pIrp)
{
KdPrint(("Enter HelloDDKRead\n"));
PDEVICE_EXTENSION pDevExt = (PDEVICE_EXTENSION)pDevObj->DeviceExtension;
NTSTATUS status = STATUS_SUCCESS;
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(pIrp);
ULONG ulReadLength = stack->Parameters.Read.Length;
KdPrint(("ulReadLength:%d\n",ulReadLength));
ULONG mdl_length = MmGetMdlByteCount(pIrp->MdlAddress);
PVOID mdl_address = MmGetMdlVirtualAddress(pIrp->MdlAddress);
ULONG mdl_offset = MmGetMdlByteOffset(pIrp->MdlAddress);
KdPrint(("mdl_address:0X%08X\n",mdl_address));
KdPrint(("mdl_length:%d\n",mdl_length));
KdPrint(("mdl_offset:%d\n",mdl_offset));
if (mdl_length!=ulReadLength)
{
pIrp->IoStatus.Information = 0;
status = STATUS_UNSUCCESSFUL;
}else
{
//ÓÃMmGetSystemAddressForMdlSafe
PVOID kernel_address = MmGetSystemAddressForMdlSafe(pIrp->MdlAddress,NormalPagePriority);
KdPrint(("kernel_address:0X%08X\n",kernel_address));
memset(kernel_address,0XAA,ulReadLength);
pIrp->IoStatus.Information = ulReadLength; // bytes xfered
}
pIrp->IoStatus.Status = status;
IoCompleteRequest( pIrp, IO_NO_INCREMENT );
KdPrint(("Leave TestRead\n"));
return status;
}
其它
其它的I/O讀寫方式用得非常少,而且很麻煩,這裡就不介紹了。
IO裝置控制操作
除了常用的ReadFile,WriteFile,CreateFile,CloseFile這類操作外,還可以通過另一個API DeviceIoControl來操作裝置。DeviceIoControl會建立一個IRP_MJ_DEVICE_CONTROL型別的IRP,別的和其它的IRP以及派遣函式是一樣的。
DeviceIoControl和驅動互動
DeviceIoControl除了可以被用來讀寫還可以用在其它操作上。
BOOL DeviceIoControl(
HANDLE hDevice, //裝置物件控制代碼
DWORD dwIoControlCode, //控制碼
LPVOID lpInBuffer, //輸入緩衝區
DWORD nInBufferSize, //輸入緩衝區大小
LPVOID lpOutBuffer, //輸出緩衝區
DWORD nOutBufferSize, //輸出緩衝區大小
LPDWORD lpBytesReturned, //實際返回位元組數 這個對應這Irp->IoStatus.Information
LPOVERLAPPED lpOverlapped //是否OVERLAP操作
);
dwIoControlCode是I/O控制碼,也叫IOCTL值。
Windows有一些內建的I/O控制碼可以選用:(在官方文件上可以獲取:DeviceIoControl function (ioapiset.h) - Win32 apps | Microsoft Docs
同樣的我們也可以自己定義,Windows提供了控制碼的定義規定:
在ddk標頭檔案裡面有一個巨集定義方便我們使用:
#define CTL_CODE( DeviceType, Function, Method, Access ) (((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method))
DeviceType(31-16):裝置物件的型別,這個和IoCreateDevice時設定的裝置物件型別一樣。
Access(15-14):訪問許可權,如果沒有特殊要求,一般採用FILE_ANY_ACCESS
Funciton:0X000~0X7FF由微軟保留,0x800~0xFFF由程式設計師自己定義
Method:操作模式,以下四種之一:
1 METHOD_BUFFERED: 使用緩衝區方式操作
2 METHOD_IN_DIRECT: 使用直接寫方式操作
3 METHOD_OUT_DIRECT: 使用直接讀方式操作
4 METHOD_NEITHER: 使用其它方式操作
緩衝記憶體模式IOCTL
DeviceIoControl的緩衝讀取和前面的緩衝I/O有一點不一樣,前面的流程都一樣,都是複製到系統程序的緩衝區裡面,然後這個緩衝區地址可以由Irp->AssociatedIrp.SystemBuffer來獲取。不一樣的是DeviceIoControl會傳入輸入和輸出兩個緩衝區,但是兩個緩衝區對應的是一個地址,因為如果是輸入就是輸出到Kernel裡,Kernel可以進行操作後,再把這個緩衝區修改瞭然後作為輸出,輸出到User裡。
首先定義一個自己的IOCTL碼
#define IOCTL_TEST CTL_CODE(FILE_DEVICE_UNKNOWN,0X800,METHOD_BUFFERED,FILE_ANY_ACCESS)
在核心狀態下要使用IOCTL需要新增ntddk標頭檔案,在User下需要新增winioctl.h標頭檔案
NTSTATUS DeviceIoControl(PDEVICE_OBJECT pDevObj, PIRP pIrp)
{
auto stack = IoGetCurrentIrpStackLocation(pIrp);
auto status = STATUS_SUCCESS;
//得到輸入緩衝區大小
auto cbIn = stack->Parameters.DeviceIoControl.InputBufferLength;
//得到輸出緩衝區大小
auto cbOut = stack->Parameters.DeviceIoControl.OutputBufferLength;
//得到IOCTL控制碼
auto code = stack->Parameters.DeviceIoControl.IoControlCode;
ULONG info = 0;
switch (code)
{
case IOCTL_TEST1:
{
//處理輸入給核心的緩衝區內容
UCHAR* InputBuffer = (UCHAR*)pIrp->AssociatedIrp.SystemBuffer;
for (ULONG i = 0; i < cbIn; i++)
{
KdPrint(("%x\n", InputBuffer[i]));
}
//處理輸出給User的緩衝區內容
UCHAR* OutputBuffer = (UCHAR*)pIrp->AssociatedIrp.SystemBuffer;
memset(OutputBuffer, 0xAA, cbOut);
info = cbOut;
break;
}
default:
{
status = STATUS_INVALID_VARIANT;
break;
}
}
pIrp->IoStatus.Information = info;
pIrp->IoStatus.Status = status;
IoCompleteRequest(pIrp, IO_NO_INCREMENT);
}
直接記憶體模式IOCTL
直接記憶體模式IOCTL也和直接I/O有稍許區別,直接IOCTL中的輸入緩衝區就是前面的緩衝記憶體模式下的緩衝區,直接開闢一個系統緩衝區,但是輸出緩衝區就是前面的直接I/O,通過地址對映來得到的地址。所以直接記憶體模式的IOCTL需要分兩種來處理,一種是輸入緩衝區當緩衝I/O處理,另一種是輸出緩衝區當直接I/O來處理。
這裡需要說明一下直接模式的
METHOD_IN_DIRECT: 使用直接寫方式操作
METHOD_OUT_DIRECT: 使用直接讀方式操作
這兩種方式的區別,在呼叫DeviceIoControl的時候是會指定開啟的模式是隻讀還是隻寫,還是可讀可寫,就對應著這兩種,如果是隻讀,那麼只有METHOD_IN_DIRECT編寫的IOCTL控制程式碼才會識別才會有用,以此類推。
直接記憶體模式IOCTL的例子:
1 先建立IOCTL
#define IOCTL_TEST1 CTL_CODE(FILE_DEVICE_UNKNOWN,0X801,METHOD_IN_DIRECT,FILE_ANY_ACCESS)
2 編寫對應IOCTL的派遣函式
NTSTATUS DeviceIoControl(PDEVICE_OBJECT pDevObj, PIRP pIrp)
{
auto stack = IoGetCurrentIrpStackLocation(pIrp);
auto status = STATUS_SUCCESS;
//得到輸入緩衝區大小
auto cbIn = stack->Parameters.DeviceIoControl.InputBufferLength;
//得到輸出緩衝區大小
auto cbOut = stack->Parameters.DeviceIoControl.OutputBufferLength;
//得到IOCTL控制碼
auto code = stack->Parameters.DeviceIoControl.IoControlCode;
ULONG info = 0;
switch (code)
{
case IOCTL_TEST1:
{
//處理輸入給核心的緩衝區內容
UCHAR* InputBuffer = (UCHAR*)pIrp->AssociatedIrp.SystemBuffer;
for (ULONG i = 0; i < cbIn; i++)
{
KdPrint(("%x\n", InputBuffer[i]));
}
//處理輸出給User的緩衝區內容
UCHAR* OutputBuffer = (UCHAR*)pIrp->AssociatedIrp.SystemBuffer;
memset(OutputBuffer, 0xAA, cbOut);
info = cbOut;
break;
}
case IOCTL_TEST2:
{
//當是IOCTL_TEST2的IOCTL時的程式碼邏輯
auto InputBuffer = (UCHAR*)pIrp->AssociatedIrp.SystemBuffer;
for (int i = 0; i < cbIn; i++)
{
KdPrint(("%X\n", InputBuffer[i]));
}
auto OutputBuffer = (UCHAR*)MmGetSystemAddressForMdlSafe(pIrp->MdlAddress, NormalPagePriority);
memset(OutputBuffer, 0xAA, cbOut);
info = cbOut;
break;
}
default:
{
status = STATUS_INVALID_VARIANT;
break;
}
}
pIrp->IoStatus.Information = info;
pIrp->IoStatus.Status = status;
IoCompleteRequest(pIrp, IO_NO_INCREMENT);
}
其它記憶體模式IOCTL
其它模式用的很少,而且很麻煩,這裡也不介紹了。
總結
不管是DeviceIoControl還是ReadFile,WriteFile其實都是用作User和Kernel互動的API,其中的IRP是自帶的資料結構體,裡面儲存了要互動的東西的資訊。DeviceIoControl更像輸入東西給Kernel讓Kernel通過根據輸入的內容和控制碼來執行命令的一個東西,通過I/O來控制Kernel執行一些程式碼流程;而ReadFille/WriteFile可能用得更多的是在單純的互動資料上面。