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

驱动程序开发第一部分:驱动程序入门

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (556投票s)

2005年2月6日

29分钟阅读

viewsIcon

3344037

downloadIcon

26728

本文将介绍创建简单驱动程序的基础知识。

引言

本教程将尝试描述如何为 Windows NT 编写一个简单的设备驱动程序。互联网上有很多关于编写设备驱动程序的资源和教程,但是,与为 Windows 编写一个“hello world”GUI 程序相比,它们有些稀缺。这使得寻找开始编写设备驱动程序的信息变得更加困难。你可能会想,如果已经有一个教程,为什么还需要更多?答案是,更多信息总是更好,特别是当你刚开始理解一个概念时。从不同角度看信息总是好的。人们写作方式不同,根据他们对某个方面的熟悉程度或他们认为应该如何解释,以不同的方式描述某些信息。在这种情况下,我建议任何想要编写设备驱动程序的人不要止步于此或任何其他地方。始终寻找各种示例和代码片段,并研究其中的差异。有时会有错误和遗漏。有时会有不必要的操作,有时信息不正确或不完整。

本教程将描述如何创建一个简单的设备驱动程序,动态加载和卸载它,并最终从用户模式与它通信。

创建一个简单的设备驱动程序

什么是子系统?

在我们开始解释如何编写设备驱动程序之前,我需要定义一个起点。本文的起点是编译器。编译器和链接器以操作系统理解的格式生成二进制文件。在 Windows 中,这种格式是“PE”,即“Portable Executable”格式。在这种格式中,有一个称为子系统的概念。子系统,连同 PE 头信息中指定的其他选项,描述了如何加载可执行文件,其中也包括二进制文件的入口点。

许多人使用 VC++ IDE 只是简单地创建一个带有编译器(和链接器)命令行默认预设选项的项目。这就是为什么很多人可能不熟悉这个概念,尽管如果他们曾经编写过 Windows 应用程序,他们很可能已经在使用了。你写过控制台应用程序吗?你写过 Windows 的 GUI 应用程序吗?这些是 Windows 中不同的子系统。这两个都将生成一个带有适当子系统信息的 PE 二进制文件。这也是为什么控制台应用程序使用“main”,而 WINDOWS 应用程序使用“WinMain”的原因。当你选择这些项目时,VC++ 只是简单地创建一个带有 /SUBSYSTEM:CONSOLE 或 /SUBSYSTEM:WINDOWS 的项目。如果你不小心选择了错误的项目,你可以在链接器选项菜单中简单地更改它,而不需要创建新项目。

这一切有什么意义?驱动程序只是使用一个称为“NATIVE”的不同子系统链接的。MSDN 子系统编译器选项

驱动程序的“main”

在编译器设置好适当的选项后,最好开始考虑驱动程序的入口点。第一部分关于子系统的描述有点不准确。“NATIVE”也可以用来运行用户模式应用程序,这些应用程序定义了一个名为“NtProcessStartup”的入口点。这是在指定“NATIVE”时创建的“默认”可执行文件类型,就像链接器创建应用程序时找到“WinMain”和“main”一样。你只需使用“-entry:<函数名>”链接器选项,就可以用自己的入口点覆盖默认入口点。如果我们知道我们希望它是一个驱动程序,我们只需要编写一个参数列表和返回类型与驱动程序匹配的入口点。然后,当我们安装驱动程序并告诉系统它是一个驱动程序时,系统将加载该驱动程序。

我们使用的名称可以是任何东西。如果愿意,我们可以称之为 BufferFly()。驱动程序开发人员和微软最常用的做法是使用名称“DriverEntry”作为其初始入口点。这意味着我们将“-entry:DriverEntry”添加到链接器的命令行选项中。如果你正在使用 DDK,当你指定“DRIVER”作为要构建的可执行文件类型时,DDK 会为你完成此操作。DDK 包含一个环境,在公共的 make 文件目录中预设了选项,这使得创建应用程序更简单,因为它指定了默认选项。实际的驱动程序开发人员可以在 make 文件中覆盖这些设置,或者只是将其用作便利。这本质上就是“DriverEntry”成为驱动程序入口点某种“官方”名称的方式。

请记住,DLL 实际上也是通过将“WINDOWS”指定为子系统来编译的,但它们还有一个额外的开关,称为 /DLL。还有一个开关也可以用于驱动程序:/DRIVER:WDM(它也在后台设置 NATIVE)以及 /DRIVER:UP,这意味着此驱动程序不能在多处理器系统上加载。

链接器构建最终的二进制文件,并根据 PE 头中的选项以及二进制文件试图加载的方式(通过加载器作为 EXE 运行、由 LoadLibrary 加载或尝试作为驱动程序加载)来定义加载系统的行为。加载系统尝试执行某种程度的验证,例如,被加载的映像确实应该以这种方式加载。在某些情况下,甚至会在二进制文件中添加启动代码,这些代码在你的入口点被调用之前执行(例如,WinMainCRTStartup 调用 WinMain 以初始化 CRT)。你的工作只是根据你希望它如何加载来编写应用程序,然后在链接器中设置正确的选项,以便它知道如何正确创建二进制文件。关于 PE 格式的详细信息有各种资源,如果你有兴趣进一步研究这个领域,应该能够找到。

我们为链接器设置的选项最终将是以下内容

/SUBSYSTEM:NATIVE /DRIVER:WDM –entry:DriverEntry

创建“DriverEntry”之前

在我们坐下来编写“DriverEntry”之前,我们需要回顾一些事情。我知道很多人只是想直接编写驱动程序并看到它工作。这在大多数编程场景中通常是这样,你通常只是获取代码,修改它,编译它,然后测试它。如果你回想刚开始学习 Windows 开发的时候,可能也是如此。你的应用程序可能没有立即正常工作,可能崩溃了,或者只是消失了。这很有趣,你可能学到了很多,但你知道,对于驱动程序来说,这种冒险有点不同。不知道该怎么做最终可能导致系统蓝屏,如果你的驱动程序在启动时加载并执行该代码,那么你现在就有问题了。希望你可以通过安全模式启动或恢复到以前的硬件配置。在这种情况下,在你编写驱动程序之前,我们需要回顾一些事情,以帮助你了解你正在做什么,然后再实际去做。

第一条经验法则是不要仅仅拿一个驱动程序并用你的一些更改编译它。如果你不了解驱动程序的工作原理或如何在环境中正确编程,你很可能会导致问题。驱动程序可能会破坏整个系统的完整性,它们可能存在不总是发生但在某些罕见情况下发生的错误。应用程序程序可能存在相同类型的行为错误,但原因不同。例如,有时你无法访问可分页内存。如果你知道虚拟内存的工作原理,你就知道操作系统会从内存中删除页面以引入所需的页面,这就是在机器内存限制下,可以运行比物理上可能更多的应用程序的方式。但是,有些时候,页面无法从磁盘读入内存。此时,那些处理内存的“驱动程序”只能访问不可分页的内存。

我这是在说什么?好吧,如果你允许一个在这种限制下运行的驱动程序访问“可分页”内存,它可能不会崩溃,因为操作系统通常会尽可能长时间地将所有页面保存在内存中。例如,如果你关闭了一个正在运行的应用程序,它可能仍然在内存中!这就是为什么这种错误可能未被检测到(除非你尝试使用驱动程序验证器等工具),并最终可能导致陷阱。当它发生时,如果你不理解这样的基本概念,你就会对问题是什么以及如何解决它感到茫然。

本文档中描述的所有内容背后都有很多概念。仅 IRQL,你就可以在 MSDN 上找到一份二十页的文档。关于 IRP 也有同样大的文档。我不会尝试重复这些信息,也不会指出每一个小细节。我将尝试给出一个基本的总结,并指导你去哪里找到更多信息。在编写驱动程序之前,至少了解这些概念的存在并理解它们背后的一些基本思想是很重要的。

什么是 IRQL?

IRQL 被称为“Interrupt ReQuest Level”(中断请求级别)。处理器将在特定的 IRQL 级别执行线程中的代码。处理器的 IRQL 本质上有助于确定该线程如何被中断。线程只能被需要在同一处理器上以更高 IRQL 运行的代码中断。需要相同或更低 IRQL 的中断被屏蔽,因此只有需要更高 IRQL 的中断才能进行处理。在多处理器系统中,每个处理器以其自己的 IRQL 独立运行。

通常会处理四个 IRQL 级别:“Passive”、“APC”、“Dispatch”和“DIRQL”。MSDN 中记录的内核 API 通常会附带一个说明,指定使用该 API 所需运行的 IRQL 级别。IRQL 越高,可用的 API 就越少。MSDN 上的文档定义了当调用驱动程序的特定入口点时,处理器将运行在哪个 IRQL 级别。例如,“DriverEntry”将在 PASSIVE_LEVEL 调用。

PASSIVE_LEVEL

这是最低的 IRQL。没有中断被屏蔽,这是用户模式下执行的线程运行的级别。可分页内存可访问。

APC_LEVEL

在运行在此级别的处理器中,只有 APC 级别中断被屏蔽。这是发生异步过程调用的级别。可分页内存仍然可访问。当发生 APC 时,处理器被提升到 APC 级别。这反过来也阻止了其他 APC 的发生。驱动程序可以手动将其 IRQL 提升到 APC(或任何其他级别),以便与 APC 进行同步,例如,因为如果你已经处于 APC 级别,APC 就无法被调用。有些 API 在 APC 级别不能被调用,因为 APC 被禁用,这反过来可能会禁用一些 I/O 完成 APC。

DISPATCH_LEVEL

在此级别运行的处理器已将 DPC 级别及以下中断屏蔽。不可访问可分页内存,因此所有访问的内存必须是非分页的。如果运行在 Dispatch 级别,可使用的 API 会大大减少,因为只能处理非分页内存。

DIRQL (设备 IRQL)

通常,更高级别的驱动程序不处理此级别的 IRQL,但此级别或更低级别的所有中断都会被屏蔽且不会发生。这实际上是一个 IRQL 范围,这是一种确定哪些设备优先于其他设备的方法。

在这个驱动程序中,我们基本上只会在 PASSIVE_LEVEL 工作,所以我们不必担心陷阱。但是,如果你打算继续编写设备驱动程序,了解 IRQL 是什么很有必要。

有关 IRQL 和线程调度的更多信息,请参阅以下文档,另一个很好的信息来源是此处

什么是 IRP?

“IRP”被称为“I/O 请求包”,它在驱动程序堆栈中从一个驱动程序传递到另一个驱动程序。这是一个数据结构,允许驱动程序之间进行通信,并请求驱动程序完成工作。I/O 管理器或另一个驱动程序可能会创建 IRP 并将其传递给你的驱动程序。IRP 包含有关正在请求的操作的信息。

IRP 数据结构的描述可以在此处找到。

IRP 的描述和使用可以很容易地从简单到复杂,所以我们只一般性地描述 IRP 对你意味着什么。MSDN 上有一篇文章详细描述(大约二十页)了 IRP 到底是什么以及如何处理它们。该文章可以在此处找到。

IRP 还将包含一个“子请求”列表,也称为“IRP 堆栈位置”。设备堆栈中的每个驱动程序通常都有自己的“子请求”,用于解释 IRP。这个数据结构就是“IO_STACK_LOCATION”,并在 MSDN 上有描述。

为了类比 IRP 和 IO_STACK_LOCATION,假设你有三个人从事不同的工作,例如木工、管道工和焊工。如果他们要建造一所房子,他们可以有一个共同的总体设计,以及一套共同的工具,比如他们的工具箱。这包括电钻等。所有这些共同的工具和建造房子的总体设计将是 IRP。他们每个人都有一个需要完成的独立部分才能实现这一点,例如,管道工需要关于管道放在哪里的计划,他有多少管道等等。这些可以解释为 IO_STACK_LOCATION,因为他的具体工作是铺设管道。木匠可以建造房屋的框架,而这些细节将包含在他的 IO_STACK_LOCATION 中。因此,虽然整个 IRP 是建造房屋的请求,但堆栈中的每个人都有自己的工作,由 IO_STACK_LOCATION 定义,以实现这一点。一旦每个人都完成了自己的工作,他们就完成了 IRP。

我们将要构建的设备驱动程序不会那么复杂,基本上将是堆栈中唯一的驱动程序。

应避免的事项

你需要避免很多陷阱,但它们大多与我们的简单驱动程序无关。然而,为了更好地了解情况,这里列出了在驱动程序开发中需要“避免的事项”。

创建 DriverEntry 例程

有太多要解释的,但是,我认为是时候我们简单地开始开发驱动程序并边做边解释了。如果不实际做任何事情,很难消化理论甚至代码应该如何工作。你需要一些实践经验,这样你才能将这些想法从空中带入现实。

DriverEntry 的原型如下。

NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegistryPath);

DRIVER_OBJECT 是一个用于表示此驱动程序的数据结构。DriverEntry 例程将使用它填充驱动程序的其他入口点,以处理特定的 I/O 请求。此对象还包含一个指向 DEVICE_OBJECT 的指针,DEVICE_OBJECT 是一个表示特定设备的数据结构。单个驱动程序实际上可以声明自己处理多个设备,因此,DRIVER_OBJECT 维护一个指向此特定驱动程序服务请求的所有设备的链表指针。我们将只创建一个设备。

“注册表路径”是一个字符串,指向注册表中存储驱动程序信息的位置。驱动程序可以使用此位置存储特定于驱动程序的信息。

下一步是实际将内容放入 DriverEntry 例程。我们要做的第一件事是创建设备。你可能会想我们如何创建设备以及应该创建什么类型的设备。这通常是因为驱动程序通常与硬件相关联,但事实并非如此。有各种不同类型的驱动程序在不同级别运行,并非所有驱动程序都直接与硬件交互。通常,你维护一个驱动程序堆栈,每个驱动程序都有一个特定的工作。最高级别驱动程序是与用户模式通信的驱动程序,最低级别驱动程序通常只与其他驱动程序和硬件通信。有网络驱动程序、显示驱动程序、文件系统驱动程序等,每个都有自己的驱动程序堆栈。堆栈中的每个位置都将请求分解为更通用或更简单的请求,供较低级别驱动程序服务。最高级别驱动程序是那些自身与用户模式通信的驱动程序,除非它们是具有特定框架的特殊设备(如显示驱动程序),否则它们可以像其他驱动程序一样通用,只是它们实现了不同类型的操作。

以硬盘驱动器为例。与用户模式通信的驱动程序不直接与硬件通信。高级驱动程序只是管理文件系统本身以及放置事物的位置。然后它将要从磁盘读取或写入的位置告知低级驱动程序,低级驱动程序可能直接与硬件通信,也可能不直接通信。可能还有另一个层将该请求传递给实际的硬件驱动程序,然后硬件驱动程序物理地从磁盘读取或写入特定扇区,然后将其返回给更高级别。最高级别可能将它们解释为文件数据,但最低级别驱动程序可能只是“愚蠢的”,只管理基于读/写磁头在磁盘上位置的扇区读取请求。然后它可以确定要服务的扇区读取请求,但是,它不知道数据是什么,也不解释它。

让我们看看“DriverEntry”的第一部分。

NTSTATUS DriverEntry(PDRIVER_OBJECT  pDriverObject, PUNICODE_STRING  pRegistryPath)
{
    NTSTATUS NtStatus = STATUS_SUCCESS;
    UINT uiIndex = 0;
    PDEVICE_OBJECT pDeviceObject = NULL;
    UNICODE_STRING usDriverName, usDosDeviceName;

    DbgPrint("DriverEntry Called \r\n");

    RtlInitUnicodeString(&usDriverName, L"\\Device\\Example");
    RtlInitUnicodeString(&usDosDeviceName, L"\\DosDevices\\Example"); 

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

你首先会注意到 DbgPrint 函数。它的工作方式与“printf”相同,它将消息打印到调试器或调试输出窗口。你可以从 www.sysinternals.com 获取一个名为“DBGVIEW”的工具,所有这些消息中的信息都将显示出来。

你将注意到我们使用了名为“RtlInitUnicodeString”的函数,它基本上初始化了一个 UNICODE_STRING 数据结构。这个数据结构基本上包含三个条目。第一个是当前 Unicode 字符串的大小,第二个是 Unicode 字符串可以达到的最大大小,第三个是指向 Unicode 字符串的指针。它用于描述 Unicode 字符串,并常用于驱动程序中。关于 UNICODE_STRING 要记住的一件事是,由于结构中有一个大小参数,它们需要以 NULL 终止!这给驱动程序开发新手带来了问题,因为他们假设 UNICODE_STRINGNULL 终止,结果导致驱动程序蓝屏。传入你驱动程序的大多数 Unicode 字符串都不会以 NULL 终止,所以你需要注意这一点。

设备像其他任何东西一样都有名称。它们通常命名为 \Device\<某个名称>,这就是我们为了传递给 IoCreateDevice 而创建的字符串。第二个字符串“\DosDevices\Example”,我们稍后再讨论,因为它尚未在驱动程序中使用。对于 IoCreateDevice,我们传入驱动程序对象,一个指向我们希望调用驱动程序的 Unicode 字符串的指针,并传入一个驱动程序类型“UNKNOWN”,因为它不与任何特定类型的设备关联,我们还传入一个指针以接收新创建的设备对象。参数在“IoCreateDevice”中有更详细的解释。

我们传入的第二个参数是 0,它表示为设备扩展创建的字节数。这基本上是驱动程序编写者可以定义的数据结构,它对于该设备是唯一的。这就是你如何扩展传递到设备的信息并创建设备上下文等以存储实例数据的方式。在此示例中,我们不会使用它。

现在我们已经成功创建了 \Device\Example 设备驱动程序,我们需要设置 Driver Object,以便在收到某些请求时调用我们的驱动程序。这些请求称为 IRP 主要请求。还有次要请求,它们是这些请求的子请求,可以在 IRP 的堆栈位置中找到。

以下代码填充了某些请求

        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;

我们填充了 CreateCloseIoControlReadWrite。它们指的是什么?当与用户模式应用程序通信时,某些 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

所以正如你所见,当用户模式应用程序使用这些函数时,它会调用你的驱动程序。你可能会想为什么用户模式 API 说“文件”而它实际上并不表示“文件”。这是正确的,这些 API 可以与任何向用户模式公开的设备通信,它们不仅仅用于访问文件。在本文的最后一部分,我们将编写一个用户模式应用程序来与我们的驱动程序通信,它将简单地执行 CreateFileWriteFileCloseHandle。就这么简单。USE_WRITE_FUNCTION 是一个我稍后会解释的常量。

下一段代码很简单,是驱动程序卸载函数。

        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”完成,I/O 管理器将清除此标志。但是,如果你在 DriverEntry 之外的任何函数中创建设备,则需要手动清除你使用 IoCreateDevice 创建的任何设备的此标志。此标志实际上是由 IoCreateDevice 函数设置的。我们在这里只是为了好玩而清除了它,尽管我们没有被要求这样做。

我们驱动程序的最后一部分是使用我们上面定义的两个 Unicode 字符串:“\Device\Example”和“\DosDevices\Example”。

IoCreateSymbolicLink(&usDosDeviceName, &usDriverName);

IoCreateSymbolicLink”就是如此,它在对象管理器中创建一个“符号链接”。要查看对象管理器,你可以下载我的工具“QuickView”,或者访问 www.sysinternals.com 并下载“WINOBJ”。符号链接只是将“DOS 设备名称”映射到“NT 设备名称”。在此示例中,“Example”是我们的 DOS 设备名称,“\Device\Example”是我们的 NT 设备名称。

从这个角度看,不同的厂商有不同的驱动程序,每个驱动程序都必须有自己的名称。你不能有两个驱动程序具有相同的 NT 设备名称。假设你有一个内存棒,它可以在系统中显示为一个新的驱动器号,例如 E:。如果你移除这个内存棒,然后将网络驱动器映射到 E:。应用程序可以以相同的方式与 E: 通信,它们不关心 E: 是 CD ROM、软盘、内存棒还是网络驱动器。这怎么可能呢?嗯,驱动程序需要能够解释请求,并要么在内部处理它们(例如网络重定向器),要么将其传递给适当的硬件驱动程序。这是通过符号链接完成的。E: 就是一个符号链接。网络映射驱动器可能将 E: 映射到 \Device\NetworkRedirector,而内存棒可能将 E: 映射到 \Device\FujiMemoryStick,例如。

应用程序就是这样使用一个通常定义的名称来编写的,这个名称可以抽象地指向任何能够处理请求的设备驱动程序。这里没有规则,我们实际上可以将 \Device\Example 映射到 E:。我们可以做任何我们想做的事情,但最终,应用程序会尝试按照设备驱动程序需要响应和行动的方式使用设备。这意味着支持这些设备常用的 IOCTL,因为应用程序会尝试使用它们。COM1、COM2 等都是这方面的例子。COM1 是一个 DOS 名称,它被映射到一个处理串行请求的驱动程序的 NT 设备名称。这甚至不需要是一个真实的物理串行端口!

因此,我们将“Example”定义为一个指向“\Device\Example”的 DOS 设备。在“与用户模式通信”部分,我们将学习更多关于如何使用此映射的信息。

创建卸载例程

我们将要查看的下一段代码是卸载例程。为了能够动态卸载设备驱动程序,这是必需的。这一节会小一些,因为没有太多要解释的。

VOID Example_Unload(PDRIVER_OBJECT  DriverObject)
{    
    
    UNICODE_STRING usDosDeviceName;
    
    DbgPrint("Example_Unload Called \r\n");
    
    RtlInitUnicodeString(&usDosDeviceName, L"\\DosDevices\\Example");
    IoDeleteSymbolicLink(&usDosDeviceName);

    IoDeleteDevice(DriverObject->DeviceObject);
}

你可以在卸载例程中做任何你想做的事情。这个卸载例程非常简单,它只是删除了我们创建的符号链接,然后删除了我们创建的唯一设备 \Device\Example

创建 IRP_MJ_WRITE

其余函数应该是自解释的,因为它们什么也不做。这就是为什么我只选择解释“写入”例程。如果这篇文章受到欢迎,我可能会写一个关于实现 IO 控制函数的第二个教程。

如果你使用过 WriteFileReadFile,你就会知道你只需传递一个数据缓冲区来向设备写入数据或从设备读取数据。这些参数如前所述通过 IRP 发送给设备。但故事还有更多,因为 I/O 管理器实际上有三种不同的方法来编组这些数据,然后才将 IRP 交给驱动程序。这也意味着数据如何编组决定了驱动程序的 Read 和 Write 函数如何解释数据。

这三种方法是“直接 I/O”、“缓冲 I/O”和“两者都不是”。

#ifdef __USE_DIRECT__
#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_IOUSE_WRITE_FUNCTION 现在是 Example_WriteDirectIO。如果你在头文件中定义“__USE_BUFFERED__”,那么 IO_TYPE 现在是 DO_BUFFERED_IOUSE_WRITE_FUNCTION 现在是 Example_WriteBufferedIO。如果你没有定义 __USE_DIRECT____USE_BUFFERED__,那么 IO_TYPE 被定义为 0(两者都不是),写入函数是 Example_WriteNeither

我们现在将逐一介绍每种 I/O 类型。

直接 I/O

我将做的第一件事是简单地向你展示处理直接 I/O 的代码。

NTSTATUS Example_WriteDirectIO(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
    NTSTATUS NtStatus = STATUS_SUCCESS;
    PIO_STACK_LOCATION pIoStackIrp = NULL;
    PCHAR pWriteDataBuffer;

    DbgPrint("Example_WriteDirectIO Called \r\n");
    
    /*
     * 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!

我们做的第一件事是调用“IoGetCurrentIrpStackLocation”,它简单地为我们提供了 IO_STACK_LOCATION。在我们的例子中,我们唯一需要的参数是提供给驱动程序的缓冲区的长度,即 Parameters.Write.Length

缓冲 I/O 的工作方式是,它为你提供一个“MdlAddress”,它是一个“内存描述符列表”。这是用户模式地址及其如何映射到物理地址的描述。然后我们调用的函数是“MmGetSystemAddressForMdlSafe”,我们使用 Irp->MdlAddress 来完成此操作。此操作将为我们提供一个系统虚拟地址,然后我们可以使用该地址读取内存。

这背后的原因是,有些驱动程序并不总是在发出请求的线程甚至进程的上下文中处理用户模式请求。如果你在另一个进程上下文中运行的不同线程中处理请求,你将无法跨进程边界读取用户模式内存。你应该已经知道这一点,因为你运行两个应用程序时,它们无法在没有操作系统支持的情况下相互读/写。

所以,这只是简单地将用户模式进程使用的物理页面映射到系统内存中。然后我们可以使用返回的地址来访问从用户模式传递下来的缓冲区。

这种方法通常用于较大的缓冲区,因为它不需要复制内存。用户模式缓冲区被锁定在内存中直到 IRP 完成,这是使用直接 I/O 的缺点。这是唯一的缺点,这就是为什么它通常对较大的缓冲区更有用。

缓冲 I/O

我将做的第一件事是简单地向你展示处理缓冲 I/O 的代码。

NTSTATUS Example_WriteBufferedIO(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
    NTSTATUS NtStatus = STATUS_SUCCESS;
    PIO_STACK_LOCATION pIoStackIrp = NULL;
    PCHAR pWriteDataBuffer;

    DbgPrint("Example_WriteBufferedIO Called \r\n");
    
    /*
     * 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;
}

如上所述,其思想是向驱动程序传递数据,这些数据可以从任何上下文访问,例如另一个进程中的另一个线程。另一个原因是为了将内存映射为非分页,以便驱动程序也可以在提升的 IRQL 级别读取它。

你可能需要在当前进程上下文之外访问内存的原因是,某些驱动程序会在 SYSTEM 进程中创建线程。然后它们会异步或同步地将工作延迟到这个进程。比你的驱动程序更高级别的驱动程序可能会这样做,或者你的驱动程序本身可能会这样做。

使用“缓冲 I/O”的缺点是它分配非分页内存并执行复制。这现在是处理每次读写到驱动程序的开销。这是它最适合小型缓冲区的原因之一。整个用户模式页面不需要像直接 I/O 那样锁定在内存中,这是它的优点。对于大型缓冲区使用它的另一个问题是,由于它分配非分页内存,它需要分配一大块连续的非分页内存。

既不缓冲也不直接

我将做的第一件事是向你展示处理非缓冲非直接 I/O 的代码。

NTSTATUS Example_WriteNeither(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
    NTSTATUS NtStatus = STATUS_SUCCESS;
    PIO_STACK_LOCATION pIoStackIrp = NULL;
    PCHAR pWriteDataBuffer;

    DbgPrint("Example_WriteNeither Called \r\n");
    
    /*
     * 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;
}

在此方法中,驱动程序直接访问用户模式地址。I/O 管理器不复制数据,不将用户模式页面锁定在内存中,它只是将用户模式地址缓冲区提供给驱动程序。

这种方法的优点是没有数据被复制,没有内存被分配,也没有页面被锁定在内存中。缺点是,你必须在调用线程的上下文中处理此请求,这样才能访问正确进程的用户模式地址空间。另一个缺点是,进程本身可能会尝试更改页面的访问权限、释放内存等,在另一个线程上。这就是为什么你通常希望使用“ProbeForRead”和“ProbeForWrite”函数并将所有代码封装在异常处理程序中。无法保证页面在任何时候都可能无效,你只需尝试确保它们有效,然后再尝试读取或写入。此缓冲区存储在 Irp->UserBuffer 中。

这些 #pragma 是什么?

你看到的这些指令只是让链接器知道将代码放入哪个段以及在页面上设置哪些选项。“DriverEntry”例如被设置为“INIT”,这是一个可丢弃的页面。这是因为你只需要在初始化期间使用该函数。

家庭作业!

你的家庭作业是为每种 I/O 处理类型创建 Read 例程。你可以使用 Write 例程作为参考来找出你需要做什么。

动态加载和卸载驱动程序

很多教程会去解释注册表,但我这次选择不这样做。有一个简单的用户模式 API 可以用来加载和卸载驱动程序,而无需做任何其他事情。我们现在就用这个。

int _cdecl main(void)
{
    HANDLE hSCManager;
    HANDLE hService;
    SERVICE_STATUS ss;

    hSCManager = OpenSCManager(NULL, NULL, SC_MANAGER_CREATE_SERVICE);
    
    printf("Load Driver\n");

    if(hSCManager)
    {
        printf("Create Service\n");

        hService = CreateService(hSCManager, "Example", 
                                 "Example Driver", 
                                  SERVICE_START | DELETE | SERVICE_STOP, 
                                  SERVICE_KERNEL_DRIVER,
                                  SERVICE_DEMAND_START, 
                                  SERVICE_ERROR_IGNORE, 
                                  "C:\\example.sys", 
                                  NULL, NULL, NULL, NULL, NULL);

        if(!hService)
        {
            hService = OpenService(hSCManager, "Example", 
                       SERVICE_START | DELETE | SERVICE_STOP);
        }

        if(hService)
        {
            printf("Start Service\n");

            StartService(hService, 0, NULL);
            printf("Press Enter to close service\r\n");
            getchar();
            ControlService(hService, SERVICE_CONTROL_STOP, &ss);

            DeleteService(hService);

            CloseServiceHandle(hService);
            
        }

        CloseServiceHandle(hSCManager);
    }
    
    return 0;
}

此代码将加载并启动驱动程序。我们以“SERVICE_DEMAND_START”方式加载驱动程序,这意味着此驱动程序必须手动启动。它不会在启动时自动启动,这样我们就可以测试它,如果出现蓝屏,我们可以在不进入安全模式的情况下修复问题。

这个程序将简单地暂停。然后你可以在另一个窗口中运行与服务通信的应用程序。上面的代码应该很容易理解,你需要将驱动程序复制到 C:\example.sys 才能使用它。如果服务创建失败,它知道它已经创建,并打开它。然后我们启动服务并暂停。一旦你按下 Enter,我们停止服务,从服务列表中删除它,然后退出。这是非常简单的代码,你可以修改它以满足你的目的。

与设备驱动程序通信

以下是与驱动程序通信的代码。

int _cdecl main(void)
{
    HANDLE hFile;
    DWORD dwReturn;

    hFile = CreateFile("\\\\.\\Example", 
            GENERIC_READ | GENERIC_WRITE, 0, NULL, 
            OPEN_EXISTING, 0, NULL);

    if(hFile)
    {
        WriteFile(hFile, "Hello from user mode!", 
                  sizeof("Hello from user mode!"), &dwReturn, NULL); 
        CloseHandle(hFile);
    }
    
    return 0;
}

这可能比你想象的要简单。如果你使用三种不同的 I/O 方法编译驱动程序三次,从用户模式发送的消息应该会打印在 DBGVIEW 中。正如你所注意到的,你只需使用 \\.\<DosName> 打开 DOS 设备名称。你甚至可以使用相同的方法打开 \Device\<Nt Device Name>。然后你将创建一个设备的句柄,然后你可以调用 WriteFileReadFileCloseHandleDeviceIoControl!如果你想尝试,只需执行操作并使用 DbgPrint 来显示你的驱动程序中正在执行的代码。

结论

本文展示了一个简单的示例,说明如何创建驱动程序、安装它以及通过简单的用户模式应用程序访问它。你可以使用相关的源文件进行更改和实验。如果你希望编写驱动程序,最好阅读许多驱动程序的基本概念,特别是本教程中链接的一些概念。

© . All rights reserved.