驱动程序开发第二部分:IOCTL 实现入门






4.96/5 (124投票s)
2005 年 2 月 13 日
17分钟阅读

603871

9480
本文将深入探讨创建简单驱动程序的基础知识。
引言
这是“编写设备驱动程序”系列教程的第二部分。该主题似乎引起了广泛的兴趣,因此本文将接续第一部分的内容。这些文章的主要重点将是逐步积累编写设备驱动程序所需的知识。在本文中,我们将基于第一部分使用的相同示例源代码。在本文中,我们将扩展该代码,以包含读取功能,处理输入/输出控制(也称为 IOCTL),并进一步了解 IRP。
常见问题解答。
在开始本文之前,这里列出了一些常见问题,我们可以进行解答。
在哪里可以获取 DDK?
Microsoft 允许 MSDN 订阅者从其网站下载 DDK。如果您不订阅,有时他们会允许公众在一定时期内公开下载新的 DDK。在本文撰写之时,没有 DDK 可供下载,因此如果您不是订阅者,可以要求他们以邮寄和处理费用为您邮寄 DDK CD。您可以在此处订购 DDK。
我的驱动程序可以包含 windows.h 吗?
您不能将 Windows SDK 头文件与 Windows DDK 头文件混合使用。它们包含的定义会发生冲突,您将难以编译代码。有时,有些用户模式应用程序喜欢包含 DDK 的一部分。这些应用程序通常需要从 DDK 或 SDK 中移除它们想要定义的类型,并将它们直接放在自己的文件中。另一种常用的方法是将文件分开,以便分别使用 DDK 和 SDK,这样每个 .C 文件都可以包含相应的头文件而不会发生冲突。
我能像这样实现“x”类型的驱动程序吗?
这是 Windows 中大多数驱动程序构建的通用框架。驱动程序不一定需要实现硬件,正如在第一个教程中所述,通常存在一个驱动程序堆栈。如果您想实现特定类型的驱动程序,这是理解驱动程序一般工作原理的起点。区别在于您如何向系统声明您的设备,实现哪些 IOCTL,您的驱动程序下方与哪些驱动程序通信,以及您需要实现的任何附加组件,例如支持驱动程序甚至用户模式组件。如果您想实现特定类型的驱动程序,您需要查阅 MSDN、DDK 以及其他地方关于该特定驱动程序的信息。有时还存在其他框架,它们实际上封装了我们在这里所做的很多工作,从而使编写起来更加容易。
驱动程序可以使用 C 或 C++ 运行时吗?
您应该避免在驱动程序中使用这些,而是使用等效的内核模式 API。内核运行时库还包含一个关于安全字符串函数的子主题。在内核模式下编程时,您可能需要注意一些陷阱,如果您从未查找过真正的内核 API,您可能永远不会意识到这些陷阱,因为您从未阅读过“备注”部分。内核 API 还告诉您可以使用每个函数的 IRQL。避免使用标准运行时库会更安全,也符合您的最佳利益,因为这可以节省您追踪 bug 和在代码中犯简单常见错误的时间。
实现 ReadFile
第一篇文章将此留作家庭作业,因此即使您尚未完成家庭作业,这里也是答案。正如我们之前讨论过的,有三种类型的 I/O:直接 I/O、缓冲 I/O 和无缓冲 I/O。我在示例驱动程序中实现了这三种类型。区别在于,我们不是从内存读取,而是写入内存。我不会解释所有三种 I/O 类型,因为它们是相同的。我将解释我添加的新功能:返回值!
在 WriteFile
实现中,我们无需担心返回值。正确的实现应始终告知用户模式应用程序“写入”了多少数据,但我当时为了简单起见省略了此细节。这对于“ReadFile
”的实现至关重要,不仅是为了正确告知用户模式应用程序,也是为了让 I/O 管理器知道!
如果您还记得“缓冲 I/O”的工作原理,例如,会创建另一个位置的内存缓冲区,然后将用户模式内存复制过去。如果我们想从驱动程序读取数据,I/O 管理器需要知道需要从这个临时缓冲区复制多少内存到真正的用户模式内存位置!如果我们不这样做,将不会复制任何内存,用户模式应用程序将收不到任何数据!
NTSTATUS Example_ReadDirectIO(PDEVICE_OBJECT DeviceObject, PIRP Irp) { NTSTATUS NtStatus = STATUS_BUFFER_TOO_SMALL; PIO_STACK_LOCATION pIoStackIrp = NULL; PCHAR pReturnData = "Example_ReadDirectIO - Hello from the Kernel!"; UINT dwDataSize = sizeof("Example_ReadDirectIO - Hello from the Kernel!"); UINT dwDataRead = 0; PCHAR pReadDataBuffer; DbgPrint("Example_ReadDirectIO 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 && Irp->MdlAddress) { pReadDataBuffer = MmGetSystemAddressForMdlSafe(Irp->MdlAddress, NormalPagePriority); if(pReadDataBuffer && pIoStackIrp->Parameters.Read.Length >= dwDataSize) { /* * We use "RtlCopyMemory" in the kernel instead * of memcpy. * RtlCopyMemory *IS* memcpy, however it's best * to use the * wrapper in case this changes in the future. */ RtlCopyMemory(pReadDataBuffer, pReturnData, dwDataSize); dwDataRead = dwDataSize; NtStatus = STATUS_SUCCESS; } }
实现返回值
返回值是通过 IRP 的 IO_STATUS_BLOCK
实现的。它包含一些数据成员,这些成员的使用方式因所实现的 الرئيسية 函数而异。在我们实现的 الرئيسية 函数中,“Status”等于返回代码,“Information”包含读取或写入的字节数。查看新代码,您还会注意到我们现在正在调用“IoCompleteRequest
”。这是什么意思?
驱动程序在完成 IRP 后始终会调用 IoCompleteRequest
。我们之前没有这样做是因为 I/O 管理器通常会在大多数情况下为我们完成此操作。但是,在必要时由驱动程序完成 IRP 是正确的做法。此位置包含一篇关于“IRP 处理”的文档,可以提供更多信息。
Irp->IoStatus.Status = NtStatus;
Irp->IoStatus.Information = dwDataRead;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return NtStatus;
}
IoCompleteRequest
的第二个参数指定了要给等待此 IRP 完成的线程的优先级提升。例如,也许线程等待网络操作已经很长时间了。此提升有助于调度程序比它可能在未提升的情况下进入就绪队列时更快地重新运行该线程。简单来说,它基本上是一个帮助程序,用于通知调度程序重新运行等待此 I/O 的线程。
更严格的参数验证和错误检查
与之前相比,现在的代码实现了一些额外的错误检查和参数验证。这是您需要确保的一点:用户模式应用程序不应该能够将无效的内存位置等发送到驱动程序并导致系统蓝屏。驱动程序实现还应该在返回给用户模式驱动程序的错误方面做得更好,而不是总是只返回“STATUS_SUCCESS
”。我们需要告知用户模式进程是否需要发送更多数据,或者尝试准确确定错误所在。您喜欢可以调用 GetLastError
来查看它们为何失败的 API,或者使用返回值来确定如何修复您的代码。如果您的驱动程序总是只返回“失败”或者更好的“成功”,那么就很难知道如何让您的应用程序与驱动程序正确工作。
输入/输出控制 (IOCTL)
IOCTL 更常用于驱动程序和应用程序之间的通信,而不是简单地读写数据。通常,驱动程序会导出一些 IOCTL 并定义用于此通信的数据结构。通常,这些数据结构不应包含指针,因为 I/O 管理器无法解释这些结构。所有数据都应包含在同一个块中。如果您想创建指针,可以执行一些操作,例如创建指向静态数据末尾之后数据块的偏移量,这样驱动程序就可以轻松找到该信息。但是,如果您还记得,驱动程序确实有能力读取用户模式数据,只要它处于进程上下文中。因此,可以实现指向内存的指针,驱动程序需要复制页面或锁定内存中的页面(基本上从驱动程序本身内部实现缓冲 I/O 或直接 I/O,这是可以做到的)。用户模式进程将使用“DeviceIoControl
”API 来执行此通信。
定义 IOCTL
我们首先要做的是定义应用程序和驱动程序之间使用的 IOCTL 代码。我将在这里总结 MSDN 上这篇文章。首先,为了将 IOCTL 与用户模式中的内容相关联,您可以将其视为 Windows 消息。它只是一个驱动程序用来使用预定义的输入和输出值实现某些请求功能的数值。但是,此数值比 Windows 消息有更多内容。IOCTL 定义了发出 IOCTL 所需的访问权限,以及在驱动程序和应用程序之间传输数据时要使用的方法。
IOCTL 是一个 32 位数字。最低的两位定义了“传输类型”,它可以是 METHOD_OUT_DIRECT
、METHOD_IN_DIRECT
、METHOD_BUFFERED
或 METHOD_NEITHER
。
接下来的几位(从 2 到 13)定义了“函数代码”。最高位被称为“自定义位”。它用于区分用户定义的 IOCTL 和系统定义的 IOCTL。这意味着函数代码 0x800 及以上是自定义定义的,类似于 Windows 消息的 WM_USER
。
接下来的两位定义了发出 IOCTL 所需的访问权限。I/O 管理器可以通过这种方式拒绝 IOCTL 请求,如果句柄未以正确的访问权限打开。访问类型例如是 FILE_READ_DATA
和 FILE_WRITE_DATA
。
最后的几位代表 IOCTL 所编写的设备类型。最高位再次代表用户定义的值。
有一个宏可以用来快速定义我们的 IOCTL,那就是“CTL_CODE
”。我在“public.h”中使用了它来定义四个 IOCTL,它们实现了不同类型的访问传输方法。
/* * IOCTL's are defined by the following bit layout. * [Common |Device Type|Required Access|Custom|Function Code|Transfer Type] * 31 30 16 15 14 13 12 2 1 0 * * Common - 1 bit. This is set for user-defined * device types. * Device Type - This is the type of device the IOCTL * belongs to. This can be user defined * (Common bit set). This must match the * device type of the device object. * Required Access - FILE_READ_DATA, FILE_WRITE_DATA, etc. * This is the required access for the * device. * Custom - 1 bit. This is set for user-defined * IOCTL's. This is used in the same * manner as "WM_USER". * Function Code - This is the function code that the * system or the user defined (custom * bit set) * Transfer Type - METHOD_IN_DIRECT, METHOD_OUT_DIRECT, * METHOD_NEITHER, METHOD_BUFFERED, This * the data transfer method to be used. * */ #define IOCTL_EXAMPLE_SAMPLE_DIRECT_IN_IO \ CTL_CODE(FILE_DEVICE_UNKNOWN, \ 0x800, \ METHOD_IN_DIRECT, \ FILE_READ_DATA | FILE_WRITE_DATA) #define IOCTL_EXAMPLE_SAMPLE_DIRECT_OUT_IO \ CTL_CODE(FILE_DEVICE_UNKNOWN, \ 0x801, \ METHOD_OUT_DIRECT, \ FILE_READ_DATA | FILE_WRITE_DATA) #define IOCTL_EXAMPLE_SAMPLE_BUFFERED_IO \ CTL_CODE(FILE_DEVICE_UNKNOWN, \ 0x802, \ METHOD_BUFFERED, \ FILE_READ_DATA | FILE_WRITE_DATA) #define IOCTL_EXAMPLE_SAMPLE_NEITHER_IO \ CTL_CODE(FILE_DEVICE_UNKNOWN, \ 0x803, \ METHOD_NEITHER, \ FILE_READ_DATA | FILE_WRITE_DATA)
上面的内容显示了我们如何定义我们的 IOCTL。
实现 IOCTL
首先需要发生的是一个 switch
语句,它将 IOCTL 分发到相应的实现。这本质上与 Windows 过程分发 Windows 消息的方式相同。但是,没有“def IOCTL proc”这样的东西!
IO_STACK_LOCATION
的“Parameters.DeviceIoControl.IoControlCode
”包含正在调用的 IOCTL 代码。以下代码本质上是一个 switch
语句,它将每个 IOCTL 分发到其实现。
NTSTATUS Example_IoControl(PDEVICE_OBJECT DeviceObject, PIRP Irp) { NTSTATUS NtStatus = STATUS_NOT_SUPPORTED; PIO_STACK_LOCATION pIoStackIrp = NULL; UINT dwDataWritten = 0; DbgPrint("Example_IoControl Called \r\n"); pIoStackIrp = IoGetCurrentIrpStackLocation(Irp); if(pIoStackIrp) /* Should Never Be NULL! */ { switch(pIoStackIrp->Parameters.DeviceIoControl.IoControlCode) { case IOCTL_EXAMPLE_SAMPLE_DIRECT_IN_IO: NtStatus = Example_HandleSampleIoctl_DirectInIo(Irp, pIoStackIrp, &dwDataWritten); break; case IOCTL_EXAMPLE_SAMPLE_DIRECT_OUT_IO: NtStatus = Example_HandleSampleIoctl_DirectOutIo(Irp, pIoStackIrp, &dwDataWritten); break; case IOCTL_EXAMPLE_SAMPLE_BUFFERED_IO: NtStatus = Example_HandleSampleIoctl_BufferedIo(Irp, pIoStackIrp, &dwDataWritten); break; case IOCTL_EXAMPLE_SAMPLE_NEITHER_IO: NtStatus = Example_HandleSampleIoctl_NeitherIo(Irp, pIoStackIrp, &dwDataWritten); break; } } Irp->IoStatus.Status = NtStatus; Irp->IoStatus.Information = dwDataWritten; IoCompleteRequest(Irp, IO_NO_INCREMENT); return NtStatus; }
如果您理解 ReadFile
和 WriteFile
的实现,那么这些 IOCTL 实际上是将两者合并在一个调用中。这显然不必是这种情况,IOCTL 可以只用于读取数据、只用于写入数据,或者根本不发送任何数据,而只是通知或指示驱动程序执行某个操作。
METHOD_x_DIRECT
METHOD_IN_DIRECT
和 METHOD_OUT_DIRECT
可以同时进行解释。它们基本上是相同的。INPUT 缓冲区使用“BUFFERED”实现进行传递。输出缓冲区使用 MdlAddress
进行传递,如 Read/Write 实现中所述。 “IN”和“OUT”之间的区别在于,“IN”可以使用输出缓冲区来传递数据!“OUT”仅用于返回数据。我们中的驱动程序示例不使用“IN”实现来传递数据,并且实际上示例中的“OUT”和“IN”实现是相同的。鉴于这种情况,我将仅向您展示“OUT”实现。
NTSTATUS Example_HandleSampleIoctl_DirectOutIo(PIRP Irp, PIO_STACK_LOCATION pIoStackIrp, UINT *pdwDataWritten) { NTSTATUS NtStatus = STATUS_UNSUCCESSFUL; PCHAR pInputBuffer; PCHAR pOutputBuffer; UINT dwDataRead = 0, dwDataWritten = 0; PCHAR pReturnData = "IOCTL - Direct Out I/O From Kernel!"; UINT dwDataSize = sizeof("IOCTL - Direct Out I/O From Kernel!"); DbgPrint("Example_HandleSampleIoctl_DirectOutIo Called \r\n"); /* * METHOD_OUT_DIRECT * * Input Buffer = Irp->AssociatedIrp.SystemBuffer * Ouput Buffer = Irp->MdlAddress * * Input Size = Parameters.DeviceIoControl.InputBufferLength * Output Size = Parameters.DeviceIoControl.OutputBufferLength * * What's the difference between METHOD_IN_DIRECT && METHOD_OUT_DIRECT? * * The function which we implemented METHOD_IN_DIRECT * is actually *WRONG*!!!! We are using the output buffer * as an output buffer! The difference is that METHOD_IN_DIRECT creates * an MDL for the outputbuffer with * *READ* access so the user mode application * can send large amounts of data to the driver for reading. * * METHOD_OUT_DIRECT creates an MDL * for the outputbuffer with *WRITE* access so the user mode * application can recieve large amounts of data from the driver! * * In both cases, the Input buffer is in the same place, * the SystemBuffer. There is a lot * of consfusion as people do think that * the MdlAddress contains the input buffer and this * is not true in either case. */ pInputBuffer = Irp->AssociatedIrp.SystemBuffer; pOutputBuffer = NULL; if(Irp->MdlAddress) { pOutputBuffer = MmGetSystemAddressForMdlSafe(Irp->MdlAddress, NormalPagePriority); } if(pInputBuffer && pOutputBuffer) { /* * 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(pInputBuffer, pIoStackIrp->Parameters.DeviceIoControl.InputBufferLength, &dwDataRead)) { DbgPrint("UserModeMessage = '%s'", pInputBuffer); DbgPrint("%i >= %i", pIoStackIrp->Parameters.DeviceIoControl.OutputBufferLength, dwDataSize); if(pIoStackIrp-> Parameters.DeviceIoControl.OutputBufferLength >= dwDataSize) { /* * We use "RtlCopyMemory" in the kernel instead of memcpy. * RtlCopyMemory *IS* memcpy, however it's best to use the * wrapper in case this changes in the future. */ RtlCopyMemory(pOutputBuffer, pReturnData, dwDataSize); *pdwDataWritten = dwDataSize; NtStatus = STATUS_SUCCESS; } else { *pdwDataWritten = dwDataSize; NtStatus = STATUS_BUFFER_TOO_SMALL; } } } return NtStatus; }
作为家庭作业,请尝试更改“IN”方法以使其正常工作。通过输出缓冲区传递输入数据并显示它。
METHOD_BUFFERED
METHOD_BUFFERED
实现的功能与 Read 和 Write 实现基本相同。会分配一个缓冲区,并将数据从该缓冲区复制。缓冲区的创建大小取输入或输出缓冲区的较大者。然后将读取缓冲区复制到这个新缓冲区中。在返回之前,您只需将返回数据复制到同一个缓冲区中。返回值放入 IO_STATUS_BLOCK
中,I/O 管理器将数据复制到输出缓冲区。
NTSTATUS Example_HandleSampleIoctl_BufferedIo(PIRP Irp, PIO_STACK_LOCATION pIoStackIrp, UINT *pdwDataWritten) { NTSTATUS NtStatus = STATUS_UNSUCCESSFUL; PCHAR pInputBuffer; PCHAR pOutputBuffer; UINT dwDataRead = 0, dwDataWritten = 0; PCHAR pReturnData = "IOCTL - Buffered I/O From Kernel!"; UINT dwDataSize = sizeof("IOCTL - Buffered I/O From Kernel!"); DbgPrint("Example_HandleSampleIoctl_BufferedIo Called \r\n"); /* * METHOD_BUFFERED * * Input Buffer = Irp->AssociatedIrp.SystemBuffer * Ouput Buffer = Irp->AssociatedIrp.SystemBuffer * * Input Size = Parameters.DeviceIoControl.InputBufferLength * Output Size = Parameters.DeviceIoControl.OutputBufferLength * * Since they both use the same location * so the "buffer" allocated by the I/O * manager is the size of the larger value (Output vs. Input) */ pInputBuffer = Irp->AssociatedIrp.SystemBuffer; pOutputBuffer = Irp->AssociatedIrp.SystemBuffer; if(pInputBuffer && pOutputBuffer) { /* * 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(pInputBuffer, pIoStackIrp->Parameters.DeviceIoControl.InputBufferLength, &dwDataRead)) { DbgPrint("UserModeMessage = '%s'", pInputBuffer); DbgPrint("%i >= %i", pIoStackIrp->Parameters.DeviceIoControl.OutputBufferLength, dwDataSize); if(pIoStackIrp->Parameters.DeviceIoControl.OutputBufferLength >= dwDataSize) { /* * We use "RtlCopyMemory" in the kernel instead of memcpy. * RtlCopyMemory *IS* memcpy, however it's best to use the * wrapper in case this changes in the future. */ RtlCopyMemory(pOutputBuffer, pReturnData, dwDataSize); *pdwDataWritten = dwDataSize; NtStatus = STATUS_SUCCESS; } else { *pdwDataWritten = dwDataSize; NtStatus = STATUS_BUFFER_TOO_SMALL; } } } return NtStatus; }
METHOD_NEITHER
这与实现任何 I/O 相同。原始的用户模式缓冲区被传递到驱动程序。
NTSTATUS Example_HandleSampleIoctl_NeitherIo(PIRP Irp, PIO_STACK_LOCATION pIoStackIrp, UINT *pdwDataWritten) { NTSTATUS NtStatus = STATUS_UNSUCCESSFUL; PCHAR pInputBuffer; PCHAR pOutputBuffer; UINT dwDataRead = 0, dwDataWritten = 0; PCHAR pReturnData = "IOCTL - Neither I/O From Kernel!"; UINT dwDataSize = sizeof("IOCTL - Neither I/O From Kernel!"); DbgPrint("Example_HandleSampleIoctl_NeitherIo Called \r\n"); /* * METHOD_NEITHER * * Input Buffer = Parameters.DeviceIoControl.Type3InputBuffer * Ouput Buffer = Irp->UserBuffer * * Input Size = Parameters.DeviceIoControl.InputBufferLength * Output Size = Parameters.DeviceIoControl.OutputBufferLength * */ pInputBuffer = pIoStackIrp->Parameters.DeviceIoControl.Type3InputBuffer; pOutputBuffer = Irp->UserBuffer; if(pInputBuffer && pOutputBuffer) { /* * We need this in an exception handler or else we could trap. */ __try { ProbeForRead(pInputBuffer, pIoStackIrp->Parameters.DeviceIoControl.InputBufferLength, TYPE_ALIGNMENT(char)); /* * 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(pInputBuffer, pIoStackIrp->Parameters.DeviceIoControl.InputBufferLength, &dwDataRead)) { DbgPrint("UserModeMessage = '%s'", pInputBuffer); ProbeForWrite(pOutputBuffer, pIoStackIrp->Parameters.DeviceIoControl.OutputBufferLength, TYPE_ALIGNMENT(char)); if(pIoStackIrp-> Parameters.DeviceIoControl.OutputBufferLength >= dwDataSize) { /* * We use "RtlCopyMemory" * in the kernel instead of memcpy. * RtlCopyMemory *IS* memcpy, * however it's best to use the * wrapper in case this changes in the future. */ RtlCopyMemory(pOutputBuffer, pReturnData, dwDataSize); *pdwDataWritten = dwDataSize; NtStatus = STATUS_SUCCESS; } else { *pdwDataWritten = dwDataSize; NtStatus = STATUS_BUFFER_TOO_SMALL; } } } __except( EXCEPTION_EXECUTE_HANDLER ) { NtStatus = GetExceptionCode(); } } return NtStatus; }
调用 DeviceIoControl
这是一个非常简单的实现。
ZeroMemory(szTemp, sizeof(szTemp)); DeviceIoControl(hFile, IOCTL_EXAMPLE_SAMPLE_DIRECT_IN_IO, "** Hello from User Mode Direct IN I/O", sizeof("** Hello from User Mode Direct IN I/O"), szTemp, sizeof(szTemp), &dwReturn, NULL); printf(szTemp); printf("\n"); ZeroMemory(szTemp, sizeof(szTemp)); DeviceIoControl(hFile, IOCTL_EXAMPLE_SAMPLE_DIRECT_OUT_IO, "** Hello from User Mode Direct OUT I/O", sizeof("** Hello from User Mode Direct OUT I/O"), szTemp, sizeof(szTemp), &dwReturn, NULL); printf(szTemp); printf("\n"); ZeroMemory(szTemp, sizeof(szTemp)); DeviceIoControl(hFile, IOCTL_EXAMPLE_SAMPLE_BUFFERED_IO, "** Hello from User Mode Buffered I/O", sizeof("** Hello from User Mode Buffered I/O"), szTemp, sizeof(szTemp), &dwReturn, NULL); printf(szTemp); printf("\n"); ZeroMemory(szTemp, sizeof(szTemp)); DeviceIoControl(hFile, IOCTL_EXAMPLE_SAMPLE_NEITHER_IO, "** Hello from User Mode Neither I/O", sizeof("** Hello from User Mode Neither I/O"), szTemp, sizeof(szTemp), &dwReturn, NULL); printf(szTemp); printf("\n");
系统内存布局
现在是时候看看 Windows 内存布局的样子了。为了展示这是如何工作的,我们首先需要展示 Intel 处理器如何实现虚拟内存。我将解释一般的实现,因为有几种实现方式。这基本上称为“虚拟地址转换”。以下摘自我一直在编写的另一篇关于调试的文档。
虚拟地址转换
在保护模式下,所有段寄存器都成为“选择器”。为了更熟悉 x86 的工作原理,我们将概述分页机制,而不是详细介绍。这不是一本系统编程指南。
CPU 中还有其他寄存器指向“描述符表”。这些表定义了我们不详细介绍的某些系统属性。相反,我们将讨论将“虚拟”地址转换为物理地址的过程。描述符表可以定义一个偏移量,该偏移量然后添加到虚拟地址。如果未启用分页,一旦您将这两个地址相加,您就会得到物理地址。如果启用了分页,您将获得一个“线性”地址,然后使用页表将其转换为物理地址。
有一个称为“页地址扩展”(Page Address Extensions)的分页机制,最初在 Pentium Pro 中引入。此机制允许页表引用多达 36 位地址。但是,偏移量仍然是 32 位,因此虽然您可以访问高达 36 位的物理 RAM,但一次只能访问 4 GB,而无需重新加载页表。我们在这里讨论的不是这个分页机制,但它非常相似。
正常的 32 位分页使用以下方法完成。有一个指向页目录表(Page Directory Table)基址的 CPU 寄存器,称为 CR3。下面的图显示了分页机制的工作原理。请注意,物理页的位置不必与虚拟地址或与前一个页表条目线性相关。蓝色线条包含在示例转换中,黑色线条是页表可能如何设置的进一步示例。
“页目录表”包含指向“页表项”(Page Table Entries)结构的条目。 “页表项”中的条目指向物理 RAM 中的一个页的开头。虽然 Windows 和大多数其他操作系统使用 4k 页,但 CPU 实际上可以支持 4k 和 2MB 页。
如果页定义为 4k,则整个过程可以分为以下步骤:
- 选择器指向描述符表项。
- 描述符表项的“基址偏移量”与虚拟地址的偏移量相加,创建线性地址。
- 线性地址的位 31-22 索引到 CR3 指向的“页目录表”。
- “页目录表”中的条目指向“页表项”的基址,然后使用索引到此表的位 21-12 来检索“页表项”。
- “页表项”除了包含有关地址是否分页到磁盘的信息外,还指向物理内存中页的基址。
- 线性地址的剩余位(位 11-0)将添加到物理页的开头,以创建最终的物理地址。
Windows 实现
如果您大致忽略描述符表的实现,那么地址转换应该很容易理解。地址被分成几部分,用于索引内存表,最终指向物理页的位置。最后一个索引直接索引到该物理页。
Windows 实际上实现了三个独立的虚拟地址范围层。第一个是用户模式地址。这些地址基本上是每个进程唯一的。也就是说,每个进程在该范围内都有自己的内存地址。当然,存在一些优化,例如不同的页表指向相同的物理内存位置,以便共享代码而不复制本质上是静态的内存。
第二个地址范围是会话空间中的地址。如果您使用过“快速用户切换”(Fast User Switching)或“终端服务”(Terminal Services),您就知道每个用户基本上都有自己的桌面。有些驱动程序运行在所谓的“会话空间”(Session Space)中,这是每个会话唯一的内存。在此内存中有显示驱动程序、win32k.sys 和一些打印机驱动程序。这也是 Windows 不会跨越会话的原因,即您无法执行“FindWindow
”并看到另一个用户桌面上显示的窗口。
最后一个是被称为“系统空间”(System Space)的地址范围。这是在整个系统共享且可以从任何地方访问的内存。这就是我们的驱动程序所在的位置,也是大多数驱动程序所在的位置。
那么,会发生什么?每次线程切换时,CR3 都会被重新加载,指向该线程可访问的页表。实现方式是每个进程都有自己的页目录指针,并将其加载到 CR3 中。这就是 Windows 如何隔离每个进程的方法,它们都有自己的目录指针。这种目录指针的实现方式是同一会话中的进程映射相同的会话空间,并且系统中的所有进程都映射系统内存。唯一为每个进程唯一实现的内存范围基本上是用户模式地址范围。
/PAE 开关
这称为“物理地址扩展”(Physical Address Extensions)。它基本上意味着操作系统可以将 36 位物理内存映射到 32 位。这并不意味着您可以一次访问超过 4 GB 的内存,而是意味着较高的内存地址可以映射到 32 位,这意味着进程可以访问它。这也意味着操作系统可以利用此能力来使用具有超过 4 GB 物理内存的机器。因此,虽然一个进程可能无法访问超过 4 GB,但操作系统可以以一种方式管理内存,使其能够同时将更多页面保留在内存中。
还有一些特殊的 API 供应用程序使用来管理内存本身并使用超过 4GB 的内存。这些称为“AWE”或地址窗口扩展(Address Windowing Extensions)。您可以在此 URL 找到有关这些的更多信息:MSDN。
/3GB 开关
有一个您可能听说过的开关,称为 /3GB 开关。它基本上允许用户模式拥有 3 GB 的地址空间。通常,4 GB 的范围被分成两部分。用户模式有 2 GB 的地址空间,内核模式有 2 GB 的地址空间。这基本上意味着用户模式地址没有最高位(位 31),而内核模式地址有位 31。这意味着 0x78000000 是用户模式地址,而 0x82000000 是内核模式地址。设置 /3GB 开关将允许用户模式进程维护更多内存,但内核将拥有更少内存。这有利有弊。
这样做的主要优点如下:
- 需要大量内存的应用程序在知道如何利用这一点的情况下可以更好地运行。如果它们使用用户模式内存缓存数据,那么与磁盘的交换将减少。
这样做的主要缺点如下:
- 内核模式内存不足,因此需要大量内核内存的应用程序或操作将无法执行。
- 检查最高位(位 31)并使用它来区分内核模式内存和用户模式内存的应用程序和驱动程序将无法正常工作。
结论
在本文中,我们学习了更多关于与用户模式进程通信的知识。我们学习了如何实现 ReadFile
和 DeviceIoControl
API。我们还学习了如何完成 IRP 并向用户模式返回状态。我们还学习了如何创建 IOCTL,最后,我们看到了 Windows 中内存的映射方式。
在下一篇文章中,我们可能会利用我们刚刚学到的信息来实现一些更有趣的东西:使用驱动程序在两个进程之间进行通信!