65.9K
CodeProject 正在变化。 阅读更多。
Home

驱动程序开发第三部分:驱动程序上下文入门

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (64投票s)

2005年2月20日

17分钟阅读

viewsIcon

182931

downloadIcon

4181

本文将深入探讨创建简单驱动程序的基础知识。

引言

这是《编写设备驱动程序》系列的第三篇文章。第一篇文章帮助您初步了解了设备驱动程序以及用于 NT 的设备驱动程序开发框架。第二篇教程尝试演示如何使用 IOCTL 以及 Windows NT 的内存布局。在这一部分,我们将深入探讨上下文和内存池的概念。今天我们将编写的驱动程序会更有趣一些,因为它能让两个用户模式应用程序以简单的方式相互通信。我们将称之为“简陋的管道”实现。

什么是上下文?

这是一个非常笼统的问题,如果您在 Windows 下进行编程,就应该理解这个概念。无论如何,我都会进行简要回顾。上下文是用户定义的数据结构(用户是开发者),底层架构对其内容一无所知。架构的作用是为用户传递这个上下文,这样在事件驱动的架构中,您就不需要实现全局变量,也不需要尝试确定请求是针对哪个对象、实例、数据结构等发出的。

在 Windows 中,使用上下文的例子包括 `SetWindowLong` 的 `GWL_USERDATA`、`EnumWindows`、`CreateThread` 等。这些都允许您传递上下文,您的应用程序可以使用这些上下文来区分和实现多个函数实例,而只需一份函数实现。

设备上下文

如果您还记得,在第一篇文章中,我们学习了如何为驱动程序创建设备对象。驱动程序对象包含与驱动程序在内存中的物理实例相关的信息。每个驱动程序二进制文件显然只有一个驱动程序对象,它包含二进制文件的函数入口点等信息。正如我们所知,可以通过调用 `IoCreateDevice` 来创建由单个驱动程序对象处理的任意数量的设备。这就是为什么所有入口点都传入设备对象而不是驱动程序对象的原因,以便您可以确定函数是为哪个设备调用的。设备对象会指向驱动程序对象,因此您仍然可以将其关联起来。

    NtStatus = IoCreateDevice(pDriverObject, sizeof(EXAMPLE_DEVICE_CONTEXT), 
                    &usDriverName, FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN, 
                    FALSE, &pDeviceObject);

...

    /*
    * Per-Device Context, User Defined
    */
    pExampleDeviceContext = 
          (PEXAMPLE_DEVICE_CONTEXT)pDeviceObject->DeviceExtension;  

    KeInitializeMutex(&pExampleDeviceContext->kListMutex, 0);
    pExampleDeviceContext->pExampleList  = NULL;

`IoCreateDevice` 函数包含一个用于“设备扩展”大小的参数。然后可以使用它来创建设备对象的“deviceextension”成员,这代表了用户定义上下文。然后,您可以创建自己的数据结构,以便与每个设备对象一起传递和使用。如果您为单个驱动程序定义了多个设备,您可能希望在所有设备上下文中包含一个单一的共享成员作为第一个成员,这样您就可以快速确定函数是为哪个设备调用的。设备代表 `\Device\Name`。

上下文通常包含任何需要为此设备进行搜索的列表,或者用于此设备的属性和锁。一个数据示例,它对每个设备都是全局的,就是磁盘驱动器上的可用空间。如果您有三个设备,每个设备代表一个特定的磁盘驱动器映像,那么对于特定设备的属性对于该设备的每个实例都是全局的。如前所述,卷名、可用空间、已用空间等是每个设备独有的,但对于该设备的所有实例都是全局的。

资源上下文

这是一个新概念,但您可以使用更长的字符串打开一个设备来指定由设备本身管理的一个特定资源。在文件系统的例子中,您实际上会指定一个文件名和文件路径。例如,您可以使用 `\Device\A\Program Files\myfile.txt` 来打开设备。然后,驱动程序可能希望分配一个对于打开此特定资源的每个进程都是全局的上下文。在文件系统的例子中,可能对于一个文件实例是全局的项包括某些缓存项,如文件大小、文件属性等。这些对于每个文件都是唯一的,但对于该文件的所有实例句柄都是共享的。

    {   /*
         * We want to use the unicode string that was used to open the driver to
         * identify "pipe contexts" and match up applications that open the same name
         * we do this by keeping a global list of all open instances in the device
         * extension context.
         * We then use reference counting so we only remove an instance from the list
         * after all instances have been deleted.  We also put this in the FsContext
         * of the IRP so all IRP's will be returned with this so we can easily use
         * the context without searching for it.
         */
         if(RtlCompareUnicodeString(&pExampleList->usPipeName, 
                                    &pFileObject->FileName, TRUE) == 0)
         {
             bNeedsToCreate = FALSE;
             pExampleList->uiRefCount++;
             pFileObject->FsContext = (PVOID)pExampleList;

实例上下文

这是您可能创建的最独特的上下文。它对于系统中创建的每个句柄都是唯一的。因此,如果进程 1 和进程 2 都打开了同一文件的同一个句柄,尽管它们的资源上下文可能相同,但它们的实例上下文将是唯一的。每个实例可能独有的项目的简单例子是文件指针。尽管两个进程打开了同一个文件,但它们可能不是从同一位置读取文件。这意味着对该文件每个打开的实例句柄都必须维护自己的上下文数据,以记住每个特定句柄当前正在读取的文件位置。

实例上下文和任何上下文都可以随时指向资源上下文和设备上下文,就像设备对象指向驱动程序对象一样。这些可以在需要时使用,以避免需要使用查找表和搜索列表来查找正确的上下文。

全局概览

下图概述了上面刚刚描述的大图景和关系。这应该有助于您可视化如何在驱动程序中构建关系。上下文关系可以按您想要的任何方式构建,这只是一个示例。您甚至可以创建上面提到的三种上下文之外的具有您自己定义的范围的上下文。

我们的实现

我们驱动程序将使用的实现是拥有设备上下文和资源上下文。我们不需要实例上下文来完成我们正在做的事情。

我们首先使用 `IoCreateDevice` 创建一个设备扩展。这个数据结构将用于维护资源上下文列表,因此所有对“Create”的调用都可以与正确的资源上下文关联。

我们的第二个实现是简单地创建资源上下文。在创建时,我们首先尝试搜索列表以确定资源是否已存在。如果存在,我们将简单地增加引用计数并将其与该句柄实例关联。如果不存在,我们则创建一个新的并将其添加到列表中。

Close 操作则相反。我们将简单地减少引用计数,如果它达到 0,我们就从列表中删除资源上下文并删除它。

IRP 的 `IO_STACK_LOCATION`(如果存在)提供了一个指向 FILE_OBJECT 的指针,我们可以将其用作句柄实例。它包含两个我们可以用来存储上下文的字段,我们只使用其中一个来存储我们的资源上下文。如果我们选择这样做,我们也可以使用它们来存储我们的实例上下文。某些驱动程序可能有规则并将其用于不同的目的,但我们是在任何框架之外开发此驱动程序的,并且没有其他驱动程序可以与之通信。这意味着我们可以自由地做任何我们想做的事情,但如果您选择实现特定类别的驱动程序,您可能需要确保您可以使用什么。

要关联资源,我们只需使用传入的设备字符串名称。然后,我们将一个新字符串追加到设备名称的末尾以创建不同的资源。如果两个应用程序随后打开相同的资源字符串,它们将被关联并共享相同的资源上下文。我们创建的这个资源上下文只是维护它自己的锁定和一个循环缓冲区。这个位于内核内存中的循环缓冲区可以从任何进程访问。因此,我们可以从一个进程复制内存并将其提供给另一个进程。

内存池

在这个驱动程序中,我们终于开始分配内存。在驱动程序中,分配称为“内存池”,您从特定的内存池中分配内存。在用户模式下,您从堆中分配内存。从这个意义上说,它们本质上是相同的。有一个管理器会跟踪这些分配并为您提供内存。然而,在用户模式下,虽然可以有多个堆,但它们本质上是相同类型的内存。此外,在用户模式下,每个进程使用的堆集只能由该进程访问。两个进程不共享同一个堆。

    pExampleList = (PEXAMPLE_LIST)ExAllocatePoolWithTag(NonPagedPool, 
                             sizeof(EXAMPLE_LIST), EXAMPLE_POOL_TAG);

    if(pExampleList)
    {

在内核中,情况略有不同。基本上有两种基本类型的内存池:分页内存池和非分页内存池。分页内存池本质上是可以分页到磁盘的内存,并且只能在 IRQL < `DISPATCH_LEVEL` 时使用,如第一篇教程中所述。非分页内存则不同;您可以随时随地访问它,因为它永远不会被分页到磁盘。有一些需要注意的事项,您不希望消耗过多的非分页内存池,原因很明显,您会耗尽物理内存。

内存池也在所有驱动程序之间共享。鉴于这种情况,您可以做一些事情来帮助调试内存池问题,那就是指定一个“内存池标签”。这是一个四字节标识符,放在您分配的内存的内存池头中。这样,如果您覆盖了内存边界,并且文件系统驱动程序突然崩溃,您就可以在访问无效内存之前查看池中的内存,并注意到您的驱动程序可能损坏了下一个池条目。这与用户模式下的概念相同,您甚至可以在那里启用堆标记。您通常会想到一个唯一的名称来标识您驱动程序的内存。此字符串通常也会反向写入,以便在调试器中使用时正向显示。由于调试器会以 `DWORD` 的形式转储内存,高地址内存会先显示。

在我们的驱动程序中,我们从非分页内存池分配,因为我们的数据结构中有一个 `KMUTEX`。我们可以将它单独分配并在此处维护一个指针,但为了简单起见,我们只有一个分配。`KMUTEX` 对象必须位于非分页内存中。

内核互斥体

在这篇文章中,我们开始创建您可能已经在用户模式下熟悉的了对象。互斥体在内核中的实际作用与您在用户模式下使用时相同。事实上,每个进程都有一个称为“句柄表”,它只是用户模式句柄和内核对象之间的映射。当您在用户模式下创建互斥体时,实际上会在内核中创建一个互斥体对象,这正是我们今天所要创建的。

我们需要建立的一个区别是,我们在内核中创建的互斥体句柄实际上是内核使用的数据结构,并且它必须是非分页内存。等待互斥体的参数比我们习惯的要复杂一些。

您可以通过单击链接找到 MSDN 中关于 KeWaitForMutexObject 的文档。文档中提到这实际上只是一个宏,实际上是 `KeWaitForSingleObject`。

那么,参数是什么意思?这些选项在 MSDN 上有解释,但这里基本上是摘要。

第一个很明显,它是实际的互斥体对象。第二个参数有点奇怪,它是 `UserRequest` 或 `Executive`。`UserRequest` 基本上意味着等待是用户发起的,而 `Executive` 意味着等待是调度器发起的。这只是一个信息字段,如果进程查询线程等待的原因,就会返回此信息。它实际上不会影响 API 的行为。

下一组选项指定等待模式。您的选项是 `KernelMode` 或 `UserMode`。驱动程序基本上会在此参数中使用“`KernelMode`”。如果您在 `UserMode` 下进行等待,您的堆栈可能会被分页出去,因此您将无法通过堆栈传递参数。

第三个参数是“`Alertable`”,它指定线程在等待时是否可被提醒。如果为 true,则可以传递 APC,等待会被中断。API 将返回 APC 状态。

最后一个参数是超时时间,它是一个 LARGE_INTEGER。如果您想设置一个等待,代码将如下所示

 LARGE_INTEGER TimeOut;
 
 TimeOut.QuadPart = 10000000L;
 TimeOut.QuadPart *= NumberOfSeconds;
 TimeOut.QuartPart = -(TimeOut.QuartPart);

超时值是相对时间,所以它必须是负数。

我们的实现尝试了一种简单的方法,指定 `KernelMode`、不可提醒,并且没有超时。

    NtStatus = KeWaitForMutexObject(&pExampleDeviceContext->kListMutex, 
                                   Executive, KernelMode, FALSE, NULL);

    if(NT_SUCCESS(NtStatus))
    {

您可以在 MSDN 的 此位置 找到关于互斥体如何工作的详细信息。

简陋的管道实现

该项目代表了一个非常简单的实现。在本节中,我们将评估驱动程序的运行方式以及一些可以改进实现的地方。我们还将介绍如何使用示例驱动程序。

安全

很简单,根本没有!驱动程序本身根本不设置任何安全措施,因此我们基本上不关心谁被允许读取或写入任何缓冲区。因为我们不在乎,所以任何进程都可以使用此 IPC 来通信,而不管用户或他们的权限如何。

那么问题就来了,这真的重要吗?我不是安全专家,但对我来说,这完全取决于。如果您打算让任何人使用它,那么您可能不希望实施安全措施。如果您认为用户希望强制只允许 SYSTEM 进程,或者不允许跨用户 IPC,那么这是需要考虑的事情。两者都有其理由。另一种可能是您不关心用户,而是希望只有两个特定的进程可以通信,而没有其他进程。在这种情况下,您可能希望设置某种类型的注册或安全措施,以便只允许适当的进程打开句柄,然后应用程序决定其管道的安全措施。您也可以采用不使用名称而是进行每个实例处理的模型。在这种情况下,可能需要将句柄复制到其他进程,例如。

循环缓冲区

循环缓冲区是一个简单的实现,它从不阻塞读取或写入,并且会简单地忽略额外的数据。缓冲区大小也不可配置,因此应用程序只能使用我们硬编码的值。

这有必要是这样吗?绝对不是,正如我们在第二部分中所见,我们可以创建自己的 IOCTL 来向驱动程序发出请求。可以实现 IOCTL 来对驱动程序进行某些配置,例如缓冲区的大小。另一部分是处理。一些循环缓冲区实际上会开始回绕并用新数据覆盖旧数据。这可以是一个标志,表明您是否希望它忽略新数据或用新数据覆盖现有数据。

循环缓冲区实现不是驱动程序特定的,因此我将不详细介绍其实现。

示例的图形化流程

这是此示例流程的简单说明。`CreateFile()` API 将使用符号链接器“Example”引用此对象。I/O 管理器会将 DOS 设备名称映射到 NT 设备“`\Device\Example`”,并附加我们在此名称之后的任何字符串(例如,“`\TestPipe`”)。我们得到由设备管理器创建的 IRP,然后我们将首先使用设备字符串查找我们是否已创建资源上下文。如果已创建,则在添加引用后,我们只需使用 I/O 堆栈位置的 `FileObject` 来放置我们的资源上下文。如果未创建,则需要先创建它。

Sample image

不过,作为快速参考,`FILE_OBJECT` 实际上只会包含额外的“`\TestPipe`”。这是一个例子

kd> dt _FILE_OBJECT ff6f3ac0
   +0x000 Type             : 5
   +0x002 Size             : 112
   +0x004 DeviceObject     : 0x80deea48 
   +0x008 Vpb              : (null) 
   +0x00c FsContext        : (null) 
   +0x010 FsContext2       : (null) 
   +0x014 SectionObjectPointer : (null) 
   +0x018 PrivateCacheMap  : (null) 
   +0x01c FinalStatus      : 0
   +0x020 RelatedFileObject : (null) 
   +0x024 LockOperation    : 0 ''
   +0x025 DeletePending    : 0 ''
   +0x026 ReadAccess       : 0 ''
   +0x027 WriteAccess      : 0 ''
   +0x028 DeleteAccess     : 0 ''
   +0x029 SharedRead       : 0 ''
   +0x02a SharedWrite      : 0 ''
   +0x02b SharedDelete     : 0 ''
   +0x02c Flags            : 2
   +0x030 FileName         : _UNICODE_STRING "\HELLO"
   +0x038 CurrentByteOffset : _LARGE_INTEGER 0x0
   +0x040 Waiters          : 0
   +0x044 Busy             : 0
   +0x048 LastLock         : (null) 
   +0x04c Lock             : _KEVENT
   +0x05c Event            : _KEVENT
   +0x06c CompletionContext : (null)

这是 `ReadFile` 操作如何工作的简单说明。由于我们将自己的上下文关联到了 `FILE_OBJECT`,因此我们无需执行查找,并且可以在执行读取时直接访问相应的循环缓冲区。

Sample image

这是 `WriteFile` 操作如何工作的简单说明。由于我们将自己的上下文关联到了 `FILE_OBJECT`,因此我们无需执行查找,并且可以在执行写入时直接访问相应的循环缓冲区。

Sample image

关闭句柄时,我们将简单地减少资源上下文的引用。如果上下文现在为 0,我们将从全局列表中删除它。如果不是,我们将只执行其他操作。需要记住的一件事是,这是一个简单的示例,我们实际上正在处理 `IRP_MJ_CLOSE` 而不是 `IRP_MJ_CLEANUP`。这段代码可以放在其中任何一个中,因为我们所做的操作不与用户模式应用程序交互。但是,如果我们正在释放应该在应用程序上下文中完成的资源,我们需要将其移到 `IRP_MJ_CLEANUP` 中。由于 IRP_MJ_CLOSE 不保证在进程上下文中运行,因此这个示例更多地展示了 `IRP_MJ_CLEANUP` 可能如何发生。

Sample image

尽管 MSDN 指出 `IRP_MJ_CLOSE` 不在进程上下文中调用,但这并不意味着总是如此。下面的堆栈跟踪显示它在应用程序的上下文中被调用。如果您在调试时发现此情况并认为可以忽略 MSDN 上的警告,我建议您三思。文档中之所以这样写,是有原因的,即使它并不总是按此行为。还有另一方面,即使它不按此行为,也不能保证未来不会发生变化,因为它们就是这样记录的。这是一个普遍陈述,您不能看到某事物的一种行为并期望它总是如此。有一篇关于处理 IRP 的文档,详细介绍了 `IRP_MJ_CLOSE` 和 `IRP_MJ_CLEANUP` 的行为,位于 此位置

THREAD ff556020  Cid 0aa4.0b1c  Teb: 7ffde000 
                 Win32Thread: 00000000 RUNNING on processor 0
        IRP List:
            ffa1b6b0: (0006,0094) Flags: 00000404  Mdl: 00000000
        Not impersonating
        DeviceMap                 e13b0d20
        Owning Process            ff57d5c8       Image:         usedriver3.exe
        Wait Start TickCount      26769661       Ticks: 0
        Context Switch Count      33             
        UserTime                  00:00:00.0000
        KernelTime                00:00:00.0015
        Start Address kernel32!BaseProcessStartThunk (0x77e4f35f)
*** WARNING: Unable to verify checksum for usedriver3.exe
*** ERROR: Module load completed but symbols could not be loaded for usedriver3.exe
        Win32 Start Address usedriver3 (0x00401172)
        Stack Init faa12000 Current faa11c4c Base faa12000 Limit faa0f000 Call 0
        Priority 10 BasePriority 8 PriorityDecrement 2
        ChildEBP RetAddr  
        faa11c70 804e0e0d example!Example_Close (FPO: [2,0,2]) 
                          (CONV: stdcall) [.\functions.c @ 275]
        faa11c80 80578ce9 nt!IofCallDriver+0x3f (FPO: [0,0,0])
        faa11cb8 8057337c nt!IopDeleteFile+0x138 (FPO: [Non-Fpo])
        faa11cd4 804e4499 nt!ObpRemoveObjectRoutine+0xde (FPO: [Non-Fpo])
        faa11cf0 8057681a nt!ObfDereferenceObject+0x4b (FPO: [EBP 0xfaa11d08] [0,0,0])
        faa11d08 8057687c nt!ObpCloseHandleTableEntry+0x137 (FPO: [Non-Fpo])
        faa11d4c 805768c3 nt!ObpCloseHandle+0x80 (FPO: [Non-Fpo])
        faa11d58 804e7a8c nt!NtClose+0x17 (FPO: [1,0,0])
        faa11d58 7ffe0304 nt!KiSystemService+0xcb (FPO: [0,0] TrapFrame @ faa11d64)
        0012fe24 77f42397 SharedUserData!SystemCallStub+0x4 (FPO: [0,0,0])
        0012fe28 77e41cb3 ntdll!ZwClose+0xc (FPO: [1,0,0])
        0012fe30 0040110d kernel32!CloseHandle+0x55 (FPO: [1,0,0])
WARNING: Stack unwind information not available. Following frames may be wrong.
        0012ff4c 00401255 usedriver3+0x110d
        0012ffc0 77e4f38c usedriver3+0x1255
        0012fff0 00000000 kernel32!BaseProcessStart+0x23 (FPO: [Non-Fpo])

使用示例

示例分为两个新的用户模式进程:`usedriver2` 和 `usedriver3`。`userdriver2` 允许您输入数据并将其发送到驱动程序。`userdriver3` 源代码允许您按 Enter 键从驱动程序读取数据。显然,如果它读取多个字符串,按照当前的实现方式,您只会看到第一个字符串。

有一个参数需要提供,那就是要打开的资源名称。这是一个任意名称,它只是允许驱动程序将两个句柄实例绑定在一起,以便多个应用程序可以同时共享数据!“`usedriver 2 HELLO`” “`usedriver3 HELLO`” “`userdriver2 Temp`” “`usedriver3 Temp`” 将打开 `\Device\Example\HELLO` 和 `\Device\Example\Temp`,相应的版本将与具有相同句柄的应用程序通信。当前实现创建资源时是 **不区分大小写** 的。更改它非常简单,`RtlCompareUnicodeString` 函数的最后一个参数指定是区分大小写还是不区分大小写地比较字符串。

构建示例

这是我之前没有详细介绍的内容。这些文章中包含的项目可以使用 ZIP 文件中的目录结构进行解压缩。项目中有“makefile”,因此您只需执行“`nmake clean`”然后“`nmake`”即可构建这些二进制文件。

makefile 可能需要更改以指向您的 DDK 的位置(您可以从 Microsoft 订购,只需支付运费和处理费)。这些 makefile 指向 `C:\NTDDK\xxx`,您可以将其更改为您的位置。如果您的路径中没有 nmake,您可能需要确保在命令提示符中设置了 Visual Studio 环境。您可以转到 Visual Studio 的二进制文件目录,然后运行“`VCVARS32.BAT`”。

当它尝试使用“rebase”时可能会出现错误。这些 makefile 只是从其他项目中复制过来的,所以 rebase 实际上是不必要的。它实际上只用于剥离调试符号。可以通过从 makefile 中删除 rebase 序列或在 `BIN` 目录中创建 `SYMBOLS` 目录来修复此错误。rebase 抱怨的原因仅仅是因为目录不存在。

结论

在本文中,我们进一步了解了用户模式和内核模式的交互以及如何创建非常简单的 IPC。我们学习了如何在设备驱动程序中创建上下文,以及如何在内核中分配内存和使用同步对象。

© . All rights reserved.