驱动程序的 “main”
我们为链接程序设定的参数:
IRQL
通常需要处理的有四种 IRQL ,他们是 “Passive” , “APC” , “Dispatch” 和 “DIRQL” 。 IRQL 越高,你能使用的 API 越少。在 MSDN 中说明了当指定的驱动入口点被调用的时候处理器将会运行于何种 IRQL 。例如, “DriverEntry” 将会在 PASSIVE_LEVEL 层被调用。
IRQL 最低级别,没有被屏蔽的中断,在这个级别上,线程执行用户模式,可以访问分页内存。
APC_LEVEL
只有 APC 级别的中断被屏蔽。这是异步程序调用( Asynchronous Procedure Calls )发生的级别。可以访问分页内存。当有 APC 发生时,处理器提升到 APC 级别,这样就能屏蔽掉其他的 APC 。驱动可以手动提升到 APC (或者其它任何级别)以便进行一些 APC 同步的操作。比如,已经在 APC 级别上时 APC 不能被使用。一些 API 函数在 APC 级别无法调用,这就是因为 APC 被禁止了,从而导致一些 I/O 完成 APC 被禁用。
DPC 以及更低级中断被屏蔽掉,不能访问分页内存,因此所有需要访问的内存必须是未分页的。如果你在 Dispatch Level 中运行,你能使用的 API 数量大大减少,因为你只能处理未分页的内存。
通常的,更高级的驱动在这个级别上不会处理 IRQL ,但是在这个级别上几乎所有的中断都被屏蔽掉了,事实上,这是 IRQL 的一个范围,也是一个使得谋个设备拥有比其它设备更高优先级的方法。
IRP
IRP 包含了一个列表,这个列表被叫做 ““IRP Stack Location” ,每一个在驱动堆栈中的驱动程序通常都有它们自己对于 IRP 的解释的 “ 子请求 ” 。这个数据结构被称为 “IO_STACK_LOCATION” 。
创建 DriverEntry
DriverEntry 的原型如下:
DriverEntry 的第一个参数是一个指针,指向一个刚被初始化的驱动程序对象,该对象就代表你的驱动程序。
下一部分是向 DriverEntry 例程发送数据。一般而言,驱动程序是会和硬件关联的。事实上,在系统中有许多种驱动,他们工作在不同的层中,并非所有的驱动都会直接面对硬件。当有需要做的事情的时候,需要维护一个驱动堆栈。最高层的驱动是和用户态进行交互的,最底层的驱动通常只是和硬件打交道,等等。这些驱动的每一个都有他们自己的驱动堆栈。堆栈中的每一个处都会将请求分解成为一个对于更底层的驱动而言更加易于处理的一个请求。
比如,以硬盘驱动为例。硬盘驱动中和用户态通讯的部分通常不会直接和硬件打交道。最高层的驱动只是简简单单的管理自己的文件系统并决定该将数据放至何处。然后它通过更底层的有可能直接和硬盘交流的驱动来读写硬盘上那些它所需要的部分。之后有可能还有其他在事实上的硬件驱动层收到请求,然后才进行物理上的扇区读写,然后再返回到高层驱动。最高层的驱动或许是将他们解释为文件数据,但是底层的驱动或许只是应付请求似的来读当前读写磁头所在位置的数据。它也许稍后会决定读哪个扇区来应答请求,然而,它对于数据是什么以及如何解释这些数据是没有任何看法的。
// {
NTSTATUS NtStatus = STATUS_SUCCESS;
UINT uiIndex = 0 ;
PDEVICE_OBJECT pDeviceObject = NULL;
UNICODE_STRING usDriverName, usDosDeviceName;
DbgPrint( " DriverEntry Called " );
RtlInitUnicodeString( & usDriverName, L " /Device/Example " );
RtlInitUnicodeString( & usDosDeviceName, L " /DosDevices/Example " );
NtStatus = IoCreateDevice(pDriverObject, 0 , & usDriverName, FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN, FALSE, & pDeviceObject);
“RtlInitUnicodeString”,这个函数初始化一个UNICODE_STRING数据结构。
USHORT Length;
USHORT MaximumLength;
PWSTR Buffer;
} UNICODE_STRING * PUNICODE_STRING;
关于UNICODE_STRING需要记住的事情就是,它们不是以NULL来结尾的,而是以数据结构中的描述“大小”的参数来决定的。
设备的名字通常类似于/Device/,通过IoCreateDevice来创建的一个字符串。对于函数IoCreateDevice而言,传入的有:一个驱动程序对象,一个指向包含有调用驱动所使用字串的Unicode字符串,如果这个驱动程序和任何设备都无关的话,那么还会有一个被成为“UNKNOWN”的驱动类型。传入一个指针,以便于接收新创建的驱动对象。第二个参数我们传0进去,这个参数是指定用来创建新设备扩展的字节数的。这是一个驱动可以由驱动开发人员来声明的,并且对于设备并非唯一的数据结构。利用这个数据结构扩展能储存数据的实例所传入信息,比如对驱动程序和创建驱动上下文等。不过在例子中,不会使用这些东西。
现在已经成功的创建了/Device/Example驱动程序,为了让驱动能够响应用户态的IRP主要请求,应当设置驱动对象。当然,在IRP栈中也存在一些子请求,这些子请求就是次要请求。
以下代码组织好了所有确定的请求:
IoCreateDevice(
IN PDRIVER_OBJECT DriverObject,
IN ULONG DeviceExtensionSize,
IN PUNICODE_STRING DeviceName OPTIONAL,
IN DEVICE_TYPE DeviceType,
IN ULONG DeviceCharacteristics,
IN BOOLEAN Exclusive,
OUT PDEVICE_OBJECT * DeviceObject
);
for (uiIndex = 0 ; uiIndex < IRP_MJ_MAXIMUM_FUNCTION; uiIndex ++ )
pDriverObject -> MajorFunction[uiIndex] = Example_UnSupportedFunction;
pDriverObject -> MajorFunction[IRP_MJ_CLOSE] = Example_Close;
pDriverObject -> MajorFunction[IRP_MJ_CREATE] = Example_Create;
pDriverObject -> MajorFunction[IRP_MJ_DEVICE_CONTROL] = Example_IoControl;
pDriverObject -> MajorFunction[IRP_MJ_READ] = Example_Read;
pDriverObject -> MajorFunction[IRP_MJ_WRITE] = USE_WRITE_FUNCTION;
我们把创建,关闭,I/O控制,读和写都组织好了。这也例程意味着什么呢?当我们和用户态的应用程序通讯的时候,这些API调用会直接把参数传入驱动!
CreateFile -> IRP_MJ_CREATE
CloseHandle -> IRP_MJ_CLEANUP & IRP_MJ_CLOSE
WriteFile -> IRP_MJ_WRITE
ReadFile-> IRP_MJ_READ
DeviceIoControl -> IRP_MJ_DEVICE_CONTROL
需要解释的是,IRP_MJ_CLOSE不是在创建句柄的进程的上下文中调用的。如果你想要进程进行相关的清理操作,你需要通过操纵IRP_MJ_CLEANUP来替代。
下一步很容易,它是驱动的卸载函数。
pDriverObject->DriverUnload = Example_Unload;
在技术上你可以忽略这个函数,但是如果你想要动态的卸载你的驱动程序,那么它一定要被列出。如果你的驱动中没有这个函数,那么系统不会允许你卸载这个驱动。
之后的代码实际上是DEVICE_OBJECT,而不是DRIVER_OBJECT。由于他们都是以“D”开头,同时又都是以“_OBJECT”结尾的,所以可能会有点难以区分,所以我们很容易混淆他们。
pDeviceObject->Flags |= IO_TYPE;
pDeviceObject->Flags &= (~DO_DEVICE_INITIALIZING);
我们仅仅是设置了标志。“IO_TYPE”其实是一个用来定义我们所想要做的I/O类型的常数(我在example.h中定义了它)。我在操作用户态写请求的部分会进行解释。
“DO_DEVICE_INITIALIZING”用来告诉I/O管理器设备正在被初始化以及不要向驱动发送任何的I/O请求。对于在“DriverEntry”的上下文中创建的设备,当“DriverEntry”完成的时候不需要清除这个标志,然而,如果你在“DriverEntry”之外的任何例程中设定了这个标志,那么你必须为任何你使用IoCreateDevice创建的设备通过手动清除这个标志,事实上这个标志是由IoCreateDevice设定的,我们在这儿清除这个标志其实只是For fun而已——因为我们并不需要这么做。
我们的驱动的最后一部分是使用我们在之前定义的Unicode字符串。“/Device/Example”和“/DosDevices/Example”。
IoCreateSymbolicLink(&usDosDeviceName, &usDriverName);
“IoCreateSymbolicLink”只做了这些事情,它在对象管理器中创建了一个“符号链接”。一个符号链接仅仅是把一个“DOS设备名”映射为“NT设备名”而已。在这个例子中,“Example”是我们的DOS设备名,“/Device/Example”是我们的NT设备名。
创建卸载例程
删除了之前创建的符号链接和被命名为/Device/Example的设备。
... {
UNICODE_STRING usDosDeviceName;
DbgPrint("Example_Unload Called ");
RtlInitUnicodeString(&usDosDeviceName, L"/DosDevices/Example");
IoDeleteSymbolicLink(&usDosDeviceName);
IoDeleteDevice(DriverObject->DeviceObject);
}
创建IRP_MJ_WRITE
如果你使用过WriteFile和ReadFile,你应当知道调用函数的时候你只是传入一个指向缓冲区来实现写入数据或是从设备中读取数据。这些参数就如我们之前解释的那样,在IRP中被传入驱动。尽管输入输出管理器在IRP传递到驱动之前有三种方法来排列数据。这也意味着数据的排列方式决定了驱动程序的读写函数需要如何分析传入的数据。
这三种方法是“直接输入输出”,“被缓冲的输入输出”,“两者皆非”。
#define IO_TYPE DO_DIRECT_IO
#define USE_WRITE_FUNCTION Example_WriteDirectIO
#endif
#ifdef __USE_BUFFERED__
#define IO_TYPE DO_BUFFERED_IO
#define USE_WRITE_FUNCTION Example_WriteBufferedIO
#endif
#ifndef IO_TYPE
#define IO_TYPE 0
#define USE_WRITE_FUNCTION Example_WriteNeither
#endif
/**/ /*
这段代码意味着,如果你在头文件中定义了“__USE_DIRECT__”,那么IO_TYPE将是 DO_DIRECT_IO并且USE_WRITE_FUNCTION指向Example_WriteDirectIO。如果你在头文件中定义了“__USE_BUFFERED__”,那么IO_TYPE将是 DO_BUFFERED_IO并且USE_WRITE_FUNCTION指向Example_WriteBufferedIO。如果你在头文件中既没有定义__USE_DIRECT__也没有定义__USE_BUFFERED__,那么IO_TYPE将被定义为0(两者皆非)并且写入函数指向Example_WriteNeither。
*/
直接I/O(Direct I/O)
调用“IoGetCurrentIrpStackLocation”,这个函数只是给我们提供我们的IO_STACK_LOCATION。在例子中,需要从它获取的参数是提供给驱动的缓冲区长度,它位于Parameters.Write.Length。
直接I/O工作的方法是提供一个“MdlAddress”,也就是一个“内存描述表”“Memory Descrīptor List”(MDL)。它是一个对用户态内存地址以及如何将其映射到物理地址的描述,我们使用“MmGetSystemAddressForMdlSafe”及Irp->MdlAddress来完成他。这个操作可以给我们一个系统的虚拟地址,我们可以使用这个虚拟地址来读内存。
... {
NTSTATUS NtStatus = STATUS_SUCCESS;
PIO_STACK_LOCATION pIoStackIrp = NULL;
PCHAR pWriteDataBuffer;
DbgPrint("Example_WriteDirectIO Called ");
/**//*
* Each time the IRP is passed down
* the driver stack a new stack location is added
* specifying certain parameters for the IRP to the driver.
*/
pIoStackIrp = IoGetCurrentIrpStackLocation(Irp);
if(pIoStackIrp)
...{
pWriteDataBuffer =
MmGetSystemAddressForMdlSafe(Irp->MdlAddress, NormalPagePriority);
if(pWriteDataBuffer)
...{
/**//*
* We need to verify that the string
* is NULL terminated. Bad things can happen
* if we access memory not valid while in the Kernel.
*/
if(Example_IsStringTerminated(pWriteDataBuffer,
pIoStackIrp->Parameters.Write.Length))
...{
DbgPrint(pWriteDataBuffer);
}
}
}
return NtStatus;
}
使用该方法的原因是某些驱动并非总是在所发请求的线程甚至是进程的上下文中来处理用户态的请求。如果你处理一个不同线程的请求,而这个线程却又运行在另外一个进程的上下文中,你也许不能正常的跨越进程分界线来读取用户态内存。如果运行两个应用程序,如果没有操作系统的支持的话,他们不能读写对方的数据。
所以,这只是简单的映射被用户态使用的物理页面到系统内存中。我们可以使用它的返回值来访问被用户态所传入的缓冲区。
这种方法通常用于较大的缓冲区因为他不需要内存复制。用户模式的缓冲区在IRP请求完成之前是被锁定的,这是使用直接输入输出的不利方面。这是唯一的不好之处,也是为什么它通常对较大缓冲区而言更有用的原因。
缓冲I/O(Buffered I/O)
这种方法的好处是传送到驱动程序中的数据可以在任何上下文中,比如另外一个位于其他进程中的线程的上下文中被访问。另外一个原因是映射内存到未分页的页面中,以便于驱动也能够在提升的IRQL中读取。
你需要在进程上下文之外访问内存的原因也许是一些驱动在SYSTEM进程中创建了线程。然后它们按照程序的要求来同步或不同步的工作。一个比你的驱动更高级别的驱动或许会做这种事情,或者由你的驱动来导致这种情况。
使用“缓冲I/O”的不好之处在于它分配了未分页的内存并且进行了复制。这总是凌驾于驱动每次的读写之上的。这也是它最好被用于较小的缓冲区的原因之一。整个用户模式的页不需要再像直接访问输入输出那样被锁定,这是它的好的方面。另外一个将它用于较大缓冲区的问题是,由于他分配了未分页内存,它也许会需要在连续的未分页内存中分配较大的一个块。
... {
NTSTATUS NtStatus = STATUS_SUCCESS;
PIO_STACK_LOCATION pIoStackIrp = NULL;
PCHAR pWriteDataBuffer;
DbgPrint("Example_WriteBufferedIO Called ");
/**//*
* Each time the IRP is passed down
* the driver stack a new stack location is added
* specifying certain parameters for the IRP to the driver.
*/
pIoStackIrp = IoGetCurrentIrpStackLocation(Irp);
if(pIoStackIrp)
...{
pWriteDataBuffer = (PCHAR)Irp->AssociatedIrp.SystemBuffer;
if(pWriteDataBuffer)
...{
/**//*
* We need to verify that the string
* is NULL terminated. Bad things can happen
* if we access memory not valid while in the Kernel.
*/
if(Example_IsStringTerminated(pWriteDataBuffer,
pIoStackIrp->Parameters.Write.Length))
...{
DbgPrint(pWriteDataBuffer);
}
}
}
return NtStatus;
}
两者皆非
在这个方法中,驱动直接访问用户模式内存。输入输出管理器不会复制数据,但是也不会锁定用户态内存中的页。它只不过将用户态缓冲区的地址交给了驱动。
... {
NTSTATUS NtStatus = STATUS_SUCCESS;
PIO_STACK_LOCATION pIoStackIrp = NULL;
PCHAR pWriteDataBuffer;
DbgPrint("Example_WriteNeither Called ");
/**//*
* Each time the IRP is passed down
* the driver stack a new stack location is added
* specifying certain parameters for the IRP to the driver.
*/
pIoStackIrp = IoGetCurrentIrpStackLocation(Irp);
if(pIoStackIrp)
...{
/**//*
* We need this in an exception handler or else we could trap.
*/
__try ...{
ProbeForRead(Irp->UserBuffer,
pIoStackIrp->Parameters.Write.Length,
TYPE_ALIGNMENT(char));
pWriteDataBuffer = Irp->UserBuffer;
if(pWriteDataBuffer)
...{
/**//*
* We need to verify that the string
* is NULL terminated. Bad things can happen
* if we access memory not valid while in the Kernel.
*/
if(Example_IsStringTerminated(pWriteDataBuffer,
pIoStackIrp->Parameters.Write.Length))
...{
DbgPrint(pWriteDataBuffer);
}
}
} __except( EXCEPTION_EXECUTE_HANDLER ) ...{
NtStatus = GetExceptionCode();
}
}
return NtStatus;
}
好处是没有数据被复制,没有内存被分配,并且也没有页被锁定到内存中。不好之处在于你必须在调用线程的上下文中处理,否则你将无法正确的访问到用户态内存。另外一个不好之处是进程自身有可能会在另一个线程中尝试去修改页的访问权限,释放内存,等等。这就是为什么你通常想要使用“ProbeForRead”和“ProbeForWrite”函数并且给所有的代码都写上异常处理。无法保证在任何时候页都是可用的,你可以简单的在你尝试读写之前,尝试确认他们是可用的。这个缓冲区位于Irp->UserBuffer。
参考资料:
http://www.codeproject.com/KB/system/driverdev.aspx