这块内容,适合那些已经在写基础 Windows 驱动、想进一步把“读和写”搞透彻的开发者。一个很常见的现象是:你在用户态打开一个设备句柄,ReadFile / WriteFile 传了几 MB 数据,结果驱动侧拿到用户缓冲区指针后要么直接访问失败,要么数据对不上。问题根源大多是缓冲区访问方式选错了,而 MDL(Memory Descriptor List,内存描述符列表)恰恰是解决这类问题的标准工具。这篇文章我就以“实现 MDL 驱动读写”为主线,把从 MDL 原理、相关 API 到驱动读写例程的完整写法,以及我踩过的坑都梳理清楚。
1. MDL驱动读写到底在解决什么问题
1.1 什么是MDL驱动读写
先把这个名词拆开理解。MDL 是 Windows 内核里用来描述一块虚拟内存对应的物理内存页集合的数据结构。它记录了起始虚拟地址、长度、所属进程、物理页面数组等信息。所谓“MDL 驱动读写”,指的是驱动在处理 IRP_MJ_READ 和 IRP_MJ_WRITE 的过程中,通过 I/O 管理器传递过来的 MDL,或者自己手动构造的 MDL,来完成用户态缓冲区与内核态缓冲区之间的数据传输。
这里要强调一点:我们不是要绕过什么系统机制,而是要“正确地”读写用户态缓冲区。如果驱动在 IRP 派发函数里直接拿用户态虚拟地址去访问,当进程上下文切换、页面被换出、或者缓冲区被分页时,就会出现不可预期的崩溃。MDL 的价值就在于,它先把用户态缓冲区锁在物理内存里,然后建立一个内核地址映射,驱动在映射后的地址上做读写,安全且高效。
1.2 为什么MDL比直接访问用户缓冲区安全
我在刚接触驱动开发时也犯过“直接访问”的错误。比如用户态传一个字符串指针给驱动,驱动直接 RtlCopyMemory 过去,运气好时能读到,运气不好就是一个蓝屏 0x50。原因很简单:用户态虚拟地址只有在当前进程上下文里才有意义。IRP 到达驱动时,对应的用户线程可能已经被调度出去,或者缓冲区所在页面还没有在物理内存中(被换出到页面文件),又或者页面权限不允许写,这些都会导致非法访问。
而 MDL 方案从设计上规避了这类问题。内核首先把用户地址范围锁定(ProbeAndLock),保证被锁定的页面常驻物理内存;随后通过映射(MapLockedPages)得到一个系统内存地址,驱动只操作这个系统地址。整个过程由内存管理器统一管理,用户缓冲区既不会在 I/O 过程中被换出,也不会因为进程切换而失效。
打个比方,直接把用户指针拿来用,就像街道上没有红绿灯,车辆横冲直撞;用 MDL,等于先修了一条从用户缓冲区到内核缓冲区的专用公路,并且把这段路封起来了,只有你的驱动能“通行”。
1.3 三种I/O模式与MDL出现的时机
Windows 驱动里常见的 I/O 模式有三种,搞清楚它们能避免 90% 的缓冲区访问错误。
| I/O 模式 | 设备对象标志 | 缓冲区获取方式 | 数据拷贝开销 | 适用场景 |
|---|---|---|---|---|
| Buffered I/O | DO_BUFFERED_IO | Irp->AssociatedIrp.SystemBuffer | 有,需要系统拷贝 | 小型控制命令 |
| Direct I/O | DO_DIRECT_IO | Irp->MdlAddress | 无系统拷贝,但需映射 | 大数据块读写 |
| Neither I/O | 两者都未设置 | Type3InputBuffer或跳过 UserBuffer | 由驱动自行处理 | 特殊协议或需要灵活性 |
Direct I/O 模式下,I/O 管理器会自动把用户缓冲区锁页并构造 MDL,挂到 IRP 的 MdlAddress 字段上。这就是“MDL 驱动读写”最常用的场景。Buffered I/O 虽然写代码简单,但进行大块数据拷贝时,额外多了一次内存拷贝,性能会明显下降。Neither I/O 灵活性最高,但责任也最大,驱动必须自己完成校验和锁页,否则风险极高。
如果你设计的设备主要用于文件传输、批量数据处理,Direct I/O 配合 MDL 是标准方案。如果你的驱动还提供一些 IOCTL 控制命令,那么这些命令通常走 Buffered I/O,而读写走 Direct I/O,两者并不冲突,设备对象可以同时支持。
2. 核心API原理解析
2.1 MDL结构
MDL 的结构定义在 wdm.h 中,关键是这几个字段:
- Next:指向下一个 MDL,链表结构
- Size:当前 MDL 结构体总大小,包括后面跟随的物理页数组
- MdlFlags:标志位,比如 MDL_MAPPED_TO_SYSTEM_VA
- Process:所属进程的 EPROCESS,如果是内核缓冲则为 NULL
- MappedSystemVa:已经映射到系统空间的虚拟地址
- StartVa:起始虚拟地址,通常按页对齐
- ByteCount:锁定区间的长度
- ByteOffset:从开始页偏移到缓冲区起始的偏移量
实际访问时,驱动一般不需要手算物理页数组,直接调用系统提供的宏或函数即可。理解 MdlFlags 和 ByteOffset 可以帮助你排查映射出错的问题。
2.2 关键函数分工
围绕 MDL 的 API 主要分成三组,每一组负责一个阶段。
分配与释放:
PMDL IoAllocateMdl( PVOID VirtualAddress, ULONG Length, BOOLEAN SecondaryBuffer, BOOLEAN ChargeQuota, PIRP Irp ); VOID IoFreeMdl(PMDL Mdl);锁定与解锁:
void MmProbeAndLockPages( PMDL Mdl, KPROCESSOR_MODE AccessMode, LOCK_OPERATION Operation ); void MmUnlockPages(PMDL Mdl);映射与取消映射:
PVOID MmMapLockedPagesSpecifyCache( PMDL Mdl, KPROCESSOR_MODE AccessMode, CACHE_TYPE CacheType, PVOID RequestedAddress, ULONG Priority, ULONG Flags ); void MmUnmapLockedPages(PVOID MappedAddress, PMDL Mdl);另外还有一个高频宏:
PVOID MmGetSystemAddressForMdlSafe(PMDL Mdl, MM_PAGE_PRIORITY Priority);它等价于“如果当前 MDL 尚未映射,则映射;如果已经映射,则直接返回已有系统地址”。在 Direct I/O 处理例程里,我几乎总是优先用这个宏,因为它不会因为内存不足抛异常,而是返回 NULL,便于统一错误处理。
这里有一个很重要的点:MmProbeAndLockPages 必须在 try/except 结构中调用,因为它会对无法锁定的用户地址、越界访问等异常做 SEH 处理。如果直接调用而不包裹 except,出现问题时会直接把系统打崩。
2.3 手动构建MDL的完整流程
手动构建 MDL 一般用于 Neither I/O 设备或者特殊内存访问场景。整个流程可以写成下面几步:
- 检查用户传入的虚拟地址和长度是否有效,长度不能超过驱动允许的单次传输上限。
- 调用 IoAllocateMdl(用户地址, 长度, FALSE, TRUE, NULL) 分配 MDL。
- 在 __try/__except 中调用 MmProbeAndLockPages,AccessMode 使用 UserMode,Operation 根据你是读用户缓冲区还是写用户缓冲区来选择 IoReadAccess / IoWriteAccess / IoModifyAccess。
- 调用 MmMapLockedPagesSpecifyCache 得到系统地址。
- 读写数据。
- 调用 MmUnmapLockedPages 解除映射。
- 调用 MmUnlockPages 解锁页面。
- 调用 IoFreeMdl 释放 MDL。
这个流程里最容易犯错的点在于:AccessMode 用错。如果是从内核模式入队请求,却传了 UserMode,会因权限判断失败导致锁定失败。相对地,在普通 IRP 派发例程中处理用户请求,必须用 UserMode,因为系统需要按用户模式规则校验缓冲区可访问性。
3. 完整实现:MDL读写驱动的编码
下面我用一个模拟设备驱动来演示完整实现。这个设备不真正访问硬件,而是维护一块内部缓冲区,支持用户态写入和读回。设备对象设置为 Direct I/O,这样 IRP 读写请求会直接带 MDL。另外加一个 METHOD_NEITHER 的 IOCTL,演示手动构建 MDL 访问用户指定内存区域。
3.1 创建设备对象并设置直接I/O
驱动入口里分配设备对象、设置分发函数:
NTSTATUS CreateDevice(PDRIVER_OBJECT DriverObject) { UNICODE_STRING deviceName = RTL_CONSTANT_STRING(L"\\Device\\MdlDemo"); UNICODE_STRING symLink = RTL_CONSTANT_STRING(L"\\??\\MdlDemo"); PDEVICE_OBJECT deviceObject = NULL; NTSTATUS status; status = IoCreateDevice(DriverObject, 0, &deviceName, FILE_DEVICE_UNKNOWN, 0, FALSE, &deviceObject); if (!NT_SUCCESS(status)) { return status; } // 设置为直接I/O,读写请求会由I/O管理器构建MDL deviceObject->Flags &= ~DO_BUFFERED_IO; deviceObject->Flags |= DO_DIRECT_IO; status = IoCreateSymbolicLink(&symLink, &deviceName); if (!NT_SUCCESS(status)) { IoDeleteDevice(deviceObject); return status; } DriverObject->MajorFunction[IRP_MJ_CREATE] = MdlDemoCreateClose; DriverObject->MajorFunction[IRP_MJ_CLOSE] = MdlDemoCreateClose; DriverObject->MajorFunction[IRP_MJ_READ] = MdlDemoRead; DriverObject->MajorFunction[IRP_MJ_WRITE] = MdlDemoWrite; DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = MdlDemoIoctl; return STATUS_SUCCESS; }有一个细节:IoCreateDevice 返回后,设备对象的 Flags 默认是既不设 Buffered I/O 也不设 Direct I/O。第一反应可能是直接设 DO_DIRECT_IO,但从严谨角度讲,最好先清掉 DO_BUFFERED_IO 再置位,防止初始化路径里某些意外标志残留。实际驱动里尽可能用“先清后置”的方式写。
3.2 读例程:从内部缓冲区拷贝到MDL映射地址
NTSTATUS MdlDemoRead(PDEVICE_OBJECT DeviceObject, PIRP Irp) { PIO_STACK_LOCATION ioStack = IoGetCurrentIrpStackLocation(Irp); ULONG length = ioStack->Parameters.Read.Length; NTSTATUS status = STATUS_SUCCESS; if (length == 0) { Irp->IoStatus.Status = STATUS_SUCCESS; Irp->IoStatus.Information = 0; IoCompleteRequest(Irp, IO_NO_INCREMENT); return STATUS_SUCCESS; } PMDL mdl = Irp->MdlAddress; if (mdl == NULL) { status = STATUS_INVALID_DEVICE_REQUEST; } else { PVOID mappedBuffer = MmGetSystemAddressForMdlSafe(mdl, NormalPagePriority); if (mappedBuffer == NULL) { status = STATUS_INSUFFICIENT_RESOURCES; } else { ULONG copyLength = length; if (copyLength > g_internalSize) { copyLength = g_internalSize; } RtlCopyMemory(mappedBuffer, g_internalBuffer, copyLength); Irp->IoStatus.Information = copyLength; } } if (!NT_SUCCESS(status)) { Irp->IoStatus.Information = 0; } Irp->IoStatus.Status = status; IoCompleteRequest(Irp, IO_NO_INCREMENT); return status; }这里没有直接通过 MDL 里的 StartVa 之类的字段去手动计算映射地址,而是用 MmGetSystemAddressForMdlSafe。它兼顾了两种状态:某些情况下 MDL 已经映射过(例如同一个 MDL 被多次利用),该宏会直接返回已有映射地址;如果未映射,则自动映射。这样代码更健壮。
读例程中访问的是“系统映射地址”,而不是用户地址,所以完全不担心进程上下文切换问题。
3.3 写例程:把MDL映射地址的内容拷贝到内部缓冲区
NTSTATUS MdlDemoWrite(PDEVICE_OBJECT DeviceObject, PIRP Irp) { PIO_STACK_LOCATION ioStack = IoGetCurrentIrpStackLocation(Irp); ULONG length = ioStack->Parameters.Write.Length; NTSTATUS status = STATUS_SUCCESS; if (length == 0) { Irp->IoStatus.Status = STATUS_SUCCESS; Irp->IoStatus.Information = 0; IoCompleteRequest(Irp, IO_NO_INCREMENT); return STATUS_SUCCESS; } PMDL mdl = Irp->MdlAddress; if (mdl == NULL) { status = STATUS_INVALID_DEVICE_REQUEST; } else { PVOID mappedBuffer = MmGetSystemAddressForMdlSafe(mdl, NormalPagePriority); if (mappedBuffer == NULL) { status = STATUS_INSUFFICIENT_RESOURCES; } else { ULONG copyLength = length; if (copyLength > INTERNAL_BUFFER_SIZE) { copyLength = INTERNAL_BUFFER_SIZE; } RtlCopyMemory(g_internalBuffer, mappedBuffer, copyLength); g_internalSize = copyLength; Irp->IoStatus.Information = copyLength; } } if (!NT_SUCCESS(status)) { Irp->IoStatus.Information = 0; } Irp->IoStatus.Status = status; IoCompleteRequest(Irp, IO_NO_INCREMENT); return status; }写例程和读例程是对称的。唯一需要注意的是:Write 的 Information 字段表示“实际写入的字节数”,用户态 WriteFile 会把它作为实际写入大小返回。如果驱动要求长度必须对齐扇区或必须达到某个最小值,应该在完成 IRP 前校验,而不是默默截断。这里为了示例清晰,用了截断处理,但真实驱动中应当根据协议明确定义。
3.4 IOCTL手动MDL路径
为了演示手动构 MDL,我增加一个 METHOD_NEITHER 的 IOCTL。它的作用是:用户传入一个缓冲区地址和长度,驱动通过手动 MDL 锁定并映射这块缓冲区,然后把驱动内部数据拷贝到用户指定缓冲区中。
#define IOCTL_MDL_DEMO_COPY CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_NEITHER, FILE_ANY_ACCESS) NTSTATUS MdlDemoIoctl(PDEVICE_OBJECT DeviceObject, PIRP Irp) { PIO_STACK_LOCATION ioStack = IoGetCurrentIrpStackLocation(Irp); NTSTATUS status = STATUS_SUCCESS; if (ioStack->Parameters.DeviceIoControl.IoControlCode == IOCTL_MDL_DEMO_COPY) { PVOID userBuffer = ioStack->Parameters.DeviceIoControl.Type3InputBuffer; ULONG userLength = ioStack->Parameters.DeviceIoControl.InputBufferLength; PMDL mdl = NULL; PVOID mappedBuffer = NULL; if (userBuffer == NULL || userLength == 0 || userLength > INTERNAL_BUFFER_SIZE) { status = STATUS_INVALID_PARAMETER; goto complete; } mdl = IoAllocateMdl(userBuffer, userLength, FALSE, TRUE, NULL); if (mdl == NULL) { status = STATUS_INSUFFICIENT_RESOURCES; goto complete; } __try { MmProbeAndLockPages(mdl, UserMode, IoReadAccess); } __except (EXCEPTION_EXECUTE_HANDLER) { status = STATUS_INVALID_USER_BUFFER; goto cleanup_mdl; } mappedBuffer = MmMapLockedPagesSpecifyCache(mdl, UserMode, MmCached, NULL, HighPagePriority, NormalPagePriority); if (mappedBuffer == NULL) { status = STATUS_INSUFFICIENT_RESOURCES; goto unlock_pages; } RtlCopyMemory(mappedBuffer, g_internalBuffer, userLength); status = STATUS_SUCCESS; MmUnmapLockedPages(mappedBuffer, mdl); unlock_pages: MmUnlockPages(mdl); cleanup_mdl: IoFreeMdl(mdl); } else { status = STATUS_INVALID_DEVICE_REQUEST; } complete: Irp->IoStatus.Status = status; Irp->IoStatus.Information = 0; IoCompleteRequest(Irp, IO_NO_INCREMENT); return status; }这里选择 IoReadAccess,是因为我们要往用户缓冲区里写数据,系统需要用“读取用户缓冲区”的权限校验来锁定页面。如果是想读取用户缓冲区的数据,则应该用 IoWriteAccess 或者 IoModifyAccess。这是新手最容易混的点。
METHOD_NEITHER 路径下,I/O 管理器不会为你锁定缓冲区,也不会创建 MDL,因此驱动必须具备完整的校验能力。如果你不愿意承担这个责任,MEMORY 方法就优先选 Buffered I/O 或 Direct I/O。
3.5 驱动入口与卸载
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) { NTSTATUS status = CreateDevice(DriverObject); if (!NT_SUCCESS(status)) { return status; } return STATUS_SUCCESS; } VOID DriverUnload(PDRIVER_OBJECT DriverObject) { UNICODE_STRING symLink = RTL_CONSTANT_STRING(L"\\??\\MdlDemo"); IoDeleteSymbolicLink(&symLink); IoDeleteDevice(DriverObject->DeviceObject); }别忘了在 DriverEntry 里给 DriverObject->DriverUnload 赋值:
DriverObject->DriverUnload = DriverUnload;卸载时先删符号链接,再删设备对象,顺序不能反。某些驱动会因为对象树里存在符号链接而导致删除失败。
4. 用户态测试与实测要点
4.1 测试客户端
用户态测试程序可以用 C 或者 PowerShell。C 程序最直接:
#include <windows.h> #include <stdio.h> int main() { HANDLE hDevice = CreateFileW(L"\\\\.\\MdlDemo", GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); if (hDevice == INVALID_HANDLE_VALUE) { printf("open failed: %lu\n", GetLastError()); return 1; } char buffer[4096] = "hello mddl driver write"; DWORD written = 0; BOOL ok = WriteFile(hDevice, buffer, 4096, &written, NULL); if (!ok) { printf("write failed: %lu\n", GetLastError()); } else { printf("write success: %lu bytes\n", written); } char readBuffer[4096] = {0}; DWORD readLen = 0; ok = ReadFile(hDevice, readBuffer, 4096, &readLen, NULL); if (!ok) { printf("read failed: %lu\n", GetLastError()); } else { printf("read success: %lu bytes: %s\n", readLen, readBuffer); } // 测试 METHOD_NEITHER IOCTL char outBuf[4096] = {0}; DWORD retLen = 0; ok = DeviceIoControl(hDevice, IOCTL_MDL_DEMO_COPY, outBuf, sizeof(outBuf), NULL, 0, &retLen, NULL); if (!ok) { printf("ioctl failed: %lu\n", GetLastError()); } else { printf("ioctl copy ok: %s\n", outBuf); } CloseHandle(hDevice); return 0; }这里 UseCodeIOCTL 的传参需要注意:因为 IOCTL 是 METHOD_NEITHER,InputBuffer 会被 I/O 管理器作为 Type3InputBuffer 直接传进去,驱动锁定的是这个传入地址。而用户态传入的地址必须是可读写的普通用户堆栈或堆地址,不能是只读常量区,否则 MmProbeAndLockPages 会触发异常。
4.2 实测中的注意事项
第一,测试前先确认驱动已正确加载、设备符号链接已经创建。直接用 CreateFile 打开失败时,常见的错误码是 ERROR_FILE_NOT_FOUND(2),这种大多是符号链接路径不对,或者设备没有创建成功。
第二,读写长度不要一次搞太大。虽然 MDL 理论上能处理大缓冲区,但系统有内存锁定额度和 MDL 长度限制。单次 I/O 过长会触发资源不足。建议先以 4KB、16KB 起步,确认数据一致后再加压。
第三,用 WinDbg 附加内核调试。我在驱动开发中一直保持着“改了驱动代码必须挂内核调试器”的习惯。读写例程中一旦出现 STATUS_INVALID_USER_BUFFER 或缓冲区内容异常,调试器里看 pMdl->ByteCount、pMdl->ByteOffset 和映射后的缓冲区内容,很快就能定位问题。特别是 MmGetSystemAddressForMdlSafe 返回 NULL 时,要优先检查 MDL 是否真的是由 I/O 管理器分配的 MDL,还是你在某个错误字段上强行构造的。
第四,注意 BuildNumber 和系统版本差异。不同 Windows 版本对 MDL 物理页数组和映射算法的内部实现有差异,但公开 API 是稳定的。你只要不手动解析 MDL 内部字段,基本可以做到一套代码跨版本运行。
5. 常见问题与排查技巧实录
5.1 错误速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| WriteFile 返回错误 87 | IRP 请求长度或缓冲区类型不正确 | 检查 IOCTL 控制码使用方法,确认输入输出缓冲区长度 |
| ReadFile 返回错误 1 | 功能错误,读例程中 Information 字段设置不对 | 检查 IRP 完成状态和 Information 是否等于实际拷贝长度 |
| Blue screen 0x50 | 直接访问未锁定的用户地址 | 确认是否在 Direct I/O 下使用 MdlAddress;不要在无 MDL 时直接访问 Type3InputBuffer |
| MmMapLockedPagesSpecifyCache 返回 NULL | 内存不足或锁页配额超限 | 缩短单次传输长度,检查是否有驱动未解锁的 MDL 泄漏 |
| MmProbeAndLockPages 抛异常 | 用户地址非法、越界,或缓冲区是只读却用 IoWriteAccess | 用 try/except 包住,并检查操作类型 AccessMode、Operation |
| 驱动卸载失败 | 有未释放的 MDL 或设备对象冲突 | 检查所有路径是否成对调用 MmUnmapLockedPages / MmUnlockPages / IoFreeMdl |
5.2 独家避坑心得
我在最初实现这个驱动时,遇到过一个非常隐蔽的问题:WriteFile 传一个小缓冲区时,I/O 管理器不会为 MDL 分配完整的物理页面数组,ByteCount 可能小于请求长度?实际情况是,I/O 管理器对 Direct I/O 的处理是完整锁页并填充 MDL,如果出现异常往往是驱动自己改动设备对象标志导致 I/O 路径混乱。
还有一个容易忽略的点是调用 MmMapLockedPagesSpecifyCache 时 CacheType 到底是选 MmCached 还是 MmNonCached。对于普通 RAM 缓冲区,用 MmCached 没问题;如果驱动之后要做 DMA 到设备,要结合硬件能力选择缓存类型,并且注意是否需要在 DMA 前做缓存刷新。示例项目只是内存数据拷贝,不需要特别处理。
另一条经验是:MDL 映射出来的系统地址,在驱动自身的 IRQL 环境下访问有讲究。读写例程通常运行在 PASSIVE_LEVEL,这时候安全。如果你在 DISPATCH_LEVEL 访问 MDL 映射缓冲区,需要用 MmGetSystemAddressForMdlSafe 并且系统会直接帮你映射;但要真正读写数据,仍要注意此时不能调用需要分页的例程,也不能发生页面错误。所以在设计时尽量把耗时拷贝放到 PASSIVE_LEVEL 的工作线程里做,不要在派发例程中硬扛。
最后一个大坑:MDL 的“锁页”会占用系统锁页配额,大量驱动频繁申请大 MDL 却不释放,系统最终会因为内存锁定配额耗尽而出现分配失败。所以驱动里一旦出现 MmGetSystemAddressForMdlSafe 返回 NULL,不要反复重试拿长缓冲区,要检查是否在某条错误分支中遗漏了 MmUnlockPages。建议把所有分配和释放过程封装成函数,并在所有出口统一释放,而不是零零散散写在各个分支里。
如果要做性能压测,用 MmGetSystemAddressForMdlSafe 映射一次后,可以连续多次读写映射地址,完成后再统一解除映射。但是注意,Direct I/O 的 IRP 应该在 IoCompleteRequest 之前解除映射吗?实际上,Direct I/O 设备的 IRP 中的 MDL 由 I/O 管理器在请求完成后统一解锁,驱动不需要也不能主动 MmUnlockPages。这条也是新手最常问的:为什么手动 MDL 里要主动解锁,Direct I/O 里却不用?因为 I/O 管理器已经接管了 MDL 的完整生命周期,驱动只负责访问,不负责收尾。
5.3 关于使用ManualMode 的额外建议
如果你真的需要在 METHOD_NEITHER 下处理大量用户缓冲区,建议对每个缓冲区都走 IoAllocateMdl、MmProbeAndLockPages、MmMapLockedPagesSpecifyCache 的完整链路。不要尝试用 ProbeForRead 或 ProbeForWrite 之后就直接访问用户指针,那样仍然有进程切换风险。ProbeForRead 只验证地址可访问性,并不把页面锁定在物理内存中,所以脱离 MDL 直接访问是不可靠的。
另外,对于设备对象同时开放读写和 IOCTL 的情况,建议把 IOCTL 控制码的读写权限设置清楚。METHOD_NEITHER 要求驱动自带校验逻辑,因此对你的数据结构和边界检查要求更高。如果只是想快速实现控制命令,用 METHOD_BUFFERED 加 SystemBuffer 更稳妥,这样既避免手动 MDL 的复杂度,也避免安全漏洞。
6. 最后再分享一个小技巧
在我的实际开发中,有一招对调试 MDL 读写特别有效:在内核调试器里直接判断 MDL 是否完整,使用 dt _MDL 展开当前 IRP 的 MdlAddress,会看到 ByteCount 和 StartVa。如果 ByteCount 和请求长度对不上,说明 I/O 管理器构造 MDL 时出了问题,多半是设备对象标志被改坏了,或者请求路径异常。如果 ByteCount 是对的,但映射后的地址和用户态看到的地址相差很远,这是正常现象,映射地址是系统地址,不能用来反向比较。
另外,调试时多打 KdPrint 日志,记录请求长度、MDL 地址、映射地址和拷贝长度。一次设备读写请求往往涉及多个页面,日志可以帮助你快速判断数据是否被截断、是否存在越界。我给这类日志里加上了请求序号,这样在多线程并发读写时也能追踪到具体的 IRP 生命周期。
MDL 驱动读写这件事,说到底是让驱动学会“正规地”访问用户态内存。一旦你掌握了 Direct I/O 下的自动 MDL 和 METHOD_NEITHER 下的手动 MDL,后面再做 FIFO、虚拟磁盘、过滤驱动里的缓冲区处理,思路都会清晰很多。