资讯详情 MDL驱动读写实战:从锁页原理到跨进程内存安全访问
📅 2026/10/19 6:21:15
1. 从阻塞到崩溃为什么读写用户缓冲区不能只靠“附加进程”写Windows内核驱动的人多多少少都遇到过这个场景你拿到了一个目标进程的EPROCESS想读它某块用户态内存里的数据。最常见的方案是先调用KeStackAttachProcess挂到目标进程上下文然后直接用MmCopyVirtualMemory或者裸访问地址把数据搬出来完事再KeUnstackDetachProcess。这套流程在低负载、地址固定、内存不换页的情况下确实能跑可一旦放到真实项目里很快就暴露出问题。先说一个我早年踩过的坑。某次给一台外设写过滤驱动需要在IRP下发的瞬间读取应用层传入的一块缓冲区。应用层为了性能把缓冲区开在栈上让线程反复使用还时不时用VirtualAlloc重新提交内存。我在驱动里觉得“附加上下文之后指针就是有效的”直接做了次RtlCopyMemory。结果系统在某个随机时刻蓝屏指向的地址段正好是一块被应用层释放后又被偏移量访问的页。检查转储文件后发现缓冲区在复制过程中发生了分页错误而我的驱动代码没有用try/except捕获异常一路传到内核态系统当场崩溃。这个例子想说明的核心问题其实很简单用户态虚拟地址在驱动眼里是“借用来的”它背后映射的物理页面可能在任意时刻被换出、被释放、被共享甚至被恶意篡改。你如果要稳定地做“MDL驱动读写”本质上有两件事必须同时保证地址对应的物理页面在读写持续期间不能被换走、不能被释放驱动访问该内存时不能因为页面不在内存里而触发一个未处理的分页错误。MDLMemory Descriptor List内存描述符列表就是为此设计的。它在内存管理器里充当“物理页面的冻结清单”锁定页面的同时把虚拟地址段对应的物理页信息记录成一张表。后续驱动通过这张表去映射并访问内存就不会再受进程上下文切换、虚拟地址失效或页面换出的影响。可以说MDL提供的是“基于物理页的稳定访问通道”而不是“基于虚拟指针的临时借道”。换个生活化的类比普通附加进程的读写方式等于你找邻居借了把钥匙进门搬东西邻居随时可能换锁、搬家提前没跟你打招呼。MDL则是你把要搬的那些东西先贴了封条、登记在册让管理员专门看着期间谁也不能动搬完再解封。哪怕邻居的门牌号变了东西本身的物理位置也已经在你掌控之中。写驱动时如果不理解这层差异就会在“共享内存跨进程”“DMA传输”“大缓冲区实时处理”这些场景里反复撞墙。理解了MDL为什么存在之后下一件事就是看懂它到底是怎么工作的。2. 结构拆解_MDL里面到底存了什么以及构建它的完整步骤很多教材会把MDL直接说成“物理内存描述链表”听起来抽象。实际上MDL就是一个带链表结构的头部加一个可变长物理页号数组。在内核里它的定义大致如下typedef struct _MDL { CSHORT Size; // 本MDL结构体总大小 CSHORT MdlFlags; // 标志位如MDL_PAGES_LOCKED、MDL_MAPPED_TO_SYSTEM_VA struct _EPROCESS *Process; PVOID MappedSystemVa; PVOID StartVa; // 起始虚拟地址页对齐 ULONG ByteCount; // 需要锁定的字节数 ULONG ByteOffset; // 起始虚拟地址在页内的偏移 PFN_NUMBER *MdlPfnArray[1]; // 变长物理页号数组 } MDL, *PMDL;这段结构信息量很大。拿一个例子说明应用层要锁定地址0x402A000、长度0x2500字节的内存。MdlPfnArray里的第一个PFN对应的就是0x402000这一页StartVa做了页对齐ByteOffset等于0xA000的低12位也就是0xA000 0xFFF后续按页粒度依次记录物理页号。驱动从头到尾真正拿到的不是原始虚拟地址而是一串物理页号再由MmMapLockedPagesSpecifyCache等函数把这串物理页重新映射到内核虚拟地址空间里访问。构建一个用于读写的MDL标准步骤通常包含四步分配MDL结构体IoAllocateMdl。锁定用户缓冲区对应的物理页面MmProbeAndLockPages用户模式内存或MmBuildMdlForNonPagedPool非分页内核池。将锁定的页面映射到内核虚拟地址MmMapLockedPagesSpecifyCache。用完后解除映射、解锁页面、释放MDLMmUnmapLockedPages、MmUnlockPages、IoFreeMdl。这里有一个新手经常弄混的点不是所有MDL都一定需要MmMapLockedPagesSpecifyCache。如果驱动执行在发起者的进程上下文中并且只是临时访问也可以直接用GetMdlVirtualAddress配合MmGetSystemAddressForMdlSafe来拿系统地址。但一旦涉及跨进程访问或者驱动中的延迟过程调用DPC级别环境就必须先映射到系统地址空间否则指针在切换上下文后立刻失效。再说IoAllocateMdl的细节PMDL IoAllocateMdl( __in_opt PVOID VirtualAddress, __in ULONG Length, __in BOOLEAN SecondaryBuffer, __in BOOLEAN ChargeQuota, __in_opt PIRP Irp );最常见的调用方式是把Irp设为NULLVirtualAddress传用户缓冲区的起始用户虚拟地址Length传要处理长度。SecondaryBuffer通常填FALSE只有表示后续随IRP继续追加缓冲区时才会填TRUE。IoAllocateMdl分配的结构体本身在非分页池里Length超过一小段时还会额外分配MDL里PFN数组占用的扩展内存。如果Length超过一个MDL默认可以描述的容量一般是32个物理页函数内部会把MDL连接成链表也就是需要遍历Mdl-Next来做完整处理。锁页时MmProbeAndLockPages有三个核心参数NTSTATUS MmProbeAndLockPages( IN OUT PMDL MemoryDescriptorList, IN KPROCESSOR_MODE AccessMode, IN LOCK_OPERATION Operation );AccessMode决定用什么样的权限模式去探测地址。Operation则告诉内存管理器这个MDL后续要做什么样的操作LockOperationRead表示只读锁定LockOperationWrite表示读写锁定。选错Operation在严格模式下会导致后续真正写内存时出现访问冲突。驱动里面一定要根据自己实际要做的操作选锁定模式不要图省事都填LockOperationWrite。还有一点容易被忽略MmProbeAndLockPages内部会尝试访问用户缓冲区并处理访问违规。如果传入的是一个非法地址比如已经被应用层释放的指针这里会返回STATUS_ACCESS_VIOLATION之类的错误。部分开发者在进入Probe之前忘了包一层try/except一旦用户指针是无效地址驱动又会吃一个未处理的内核异常。规范写法是先做ProbeForRead/ProbeForWrite再做ProbeAndLockPages或者至少确保异常过滤处理到位。构建并锁定好MDL之后映射到内核空间的方法选择也有讲究。理论上可以这么调PVOID MmMapLockedPagesSpecifyCache( __in PMDL Mdl, __in KPROCESSOR_MODE AccessMode, __in CACHING_LEVEL CacheType, __inout_opt PVOID RequestedAddress, __in ULONG Priority, __in ULONG Flags );AccessMode传KernelMode表示映射后的页面对内核可见且不再做访问检查传UserMode则仍会保留用户态访问属性。CacheType一般用MmCached但如果这块内存后续要送给DMA引擎做硬件读写就要根据硬件对缓存一致性的要求来选择MmNonCached或MmWriteCombined。RequestedAddress多数情况填NULL让系统决定映射地址。Priority与内存分配优先级相关非紧急场景用NormalPagePriority即可。直接把构建MDL、锁页、映射、访问、清理串起来的完整代码我会放到下一节展示。之所以先把结构和过程单列出来是因为后面排查各种疑难杂症时你几乎都要回到这几步里去核对其中的标志位和调用顺序。3. 完整实现一个带异常保护和跨进程功能的MDL读写函数实例下面这套代码是一个我在模拟项目X里整理过的、相对完整的“MDL读写指定进程内存”的函数。它兼具读和写两个方向做了一定程度的异常保护和长度检查。你可以把它当作模板再根据自己驱动的IRP路径、同步模型去裁剪。typedef enum _MDL_RW_DIRECTION { MdlReadDirection, MdlWriteDirection } MDL_RW_DIRECTION; NTSTATUS MdlReadWriteProcessMemory( PEPROCESS TargetProcess, PVOID UserAddress, PVOID KernelBuffer, SIZE_T Length, MDL_RW_DIRECTION Direction) { NTSTATUS status STATUS_SUCCESS; PMDL mdl NULL; PVOID mappedVa NULL; KAPC_STATE apcState; BOOLEAN attached FALSE; BOOLEAN pagesLocked FALSE; BOOLEAN pagesMapped FALSE; LOCK_OPERATION lockOp Direction MdlReadDirection ? LockOperationRead : LockOperationWrite; if (TargetProcess NULL || UserAddress NULL || KernelBuffer NULL || Length 0) { return STATUS_INVALID_PARAMETER; } // 附加到目标进程上下文以便安全探测与锁定用户地址 KeStackAttachProcess(TargetProcess, apcState); attached TRUE; __try { if (Direction MdlReadDirection) { ProbeForRead(UserAddress, Length, 1); } else { ProbeForWrite(UserAddress, Length, 1); } mdl IoAllocateMdl(UserAddress, (ULONG)Length, FALSE, FALSE, NULL); if (mdl NULL) { status STATUS_INSUFFICIENT_RESOURCES; __leave; } MmProbeAndLockPages(mdl, UserMode, lockOp); pagesLocked TRUE; mappedVa MmMapLockedPagesSpecifyCache( mdl, KernelMode, MmCached, NULL, NormalPagePriority, MdlMappingNoExecute); if (mappedVa NULL) { status STATUS_INSUFFICIENT_RESOURCES; __leave; } pagesMapped TRUE; if (Direction MdlReadDirection) { RtlCopyMemory(KernelBuffer, mappedVa, Length); } else { RtlCopyMemory(mappedVa, KernelBuffer, Length); } status STATUS_SUCCESS; } __except (EXCEPTION_EXECUTE_HANDLER) { status GetExceptionCode(); } __finally { if (pagesMapped mappedVa ! NULL) { MmUnmapLockedPages(mappedVa, mdl); } if (pagesLocked mdl ! NULL) { MmUnlockPages(mdl); } if (mdl ! NULL) { IoFreeMdl(mdl); } if (attached) { KeUnstackDetachProcess(apcState); } } return status; }代码里面有几个细节值得单独拿出来说。首先是ProbeForRead/ProbeForWrite。这一对函数不只是检查地址读过一遍它们还会验证地址处于用户模式范围内、长度没有溢出、以及“按1字节对齐”是否满足缓冲区的可探测条件。传入的Length是SIZE_T而IoAllocateMdl内部用的Length是ULONG所以上游调用时最好做一层长度上限判断防止截断。其次是MdlMappingNoExecute标志。Windows 8以上内核里映射内核虚拟地址时可以指定这个标志来禁止在该区域执行代码。对于只做数据读写的场景加上它能降低潜在的代码注入面这也是近年驱动安全审查里常常要求的一项。再次是RtlCopyMemory之后要不要修改MdlFlags里的MDL_MAPPED_TO_SYSTEM_VA。如果你在调用MmMapLockedPagesSpecifyCache成功之后去调试会看到MDL的MdlFlags被自动置上了MDL_MAPPED_TO_SYSTEM_VAMappedSystemVa也被更新为映射地址。清理时如果直接调用MmUnlockPages而忘了MmUnmapLockedPages释放顺序就会错乱。反过来映射完成但实际上没有访问同样必须走Unmap然后在Unlock顺序不能反。很多内存池泄漏乃至蓝屏就是这么来的。还有一个经常被笔试和实操同时问到的点为什么例程里要把KeStackAttachProcess放在ProbeAndLockPages之前原因是MmProbeAndLockPages对用户地址的探测依赖当前进程的页表。只有挂到目标进程上下文系统才能用目标进程的CR3去解析用户地址对应的物理页面。如果没附加就去Probe得到的状态很可能是访问冲突或者更危险地探测了错误进程的地址空间。所以这个顺序是强约束不是代码风格偏好。不过这里有个反直觉但很实用的经验如果函数内部还要做更加耗时的操作比如把映射区域的字节做摘要计算、再进行后续的分页写入那就可以考虑不把所有事情都放在附加进程的区域内完成。因为KeStackAttachProcess会改变当前线程的地址空间并提升在目标进程环境下的执行代价长时间挂在一个进程身上会影响调度。更妥善的做法是先通过附加完成锁定和映射然后立即KeUnstackDetachProcess这时MDL已经拿到了物理页映射到系统空间后访问不再依赖进程上下文。后续大量数据操作全部基于mappedVa进行即可。所以上面的代码其实还可以优化成两个阶段附加进程只做Probe、Lock、Map中途就解挂真正的数据拷贝在解挂之后完成。我把拷贝放在附加区间内只是为了示例紧凑真实项目里建议拆开原因就在上下文开销上。你可以自己改一下结构阶段一返回映射地址和MDL阶段二使用映射地址阶段三统一清理。这样既提升了执行效率也降低了对目标进程的调度干扰。4. 不止是跨进程MDL驱动读写在IRP读写路径和DMA传输里的正宗用法很多初学者一提到“MDL驱动读写”第一反应就只想到跨进程内存操作。实际上MDL在驱动I/O路径里出现得更频繁尤其是IRP_MJ_READ/IRP_MJ_WRITE的处理过程。要给外部设备传数据驱动通常会涉及三种I/O方式I/O方式系统做法驱动侧拿到的数据形态适用场景BUFFERED_IO系统复制用户缓冲区到系统缓冲区驱动直接访问Irp-AssociatedIrp.SystemBuffer数据量小、延迟容忍度高、要频繁做合法性检查的场景DIRECT_IO系统为用户缓冲区构建MDL驱动访问Irp-MdlAddress拿MDL映射地址或者物理地址数据量较大、需要DMA、希望减少一次拷贝的场景NEITHER_IO系统不复制也不构建MDL驱动拿用户虚拟地址自行决定锁定方式对性能和灵活性要求最高的自定义设备也最容易写错在DIRECT_IO方式里系统在I/O管理器层面已经帮你分配好了包含用户缓冲区的MDL。你只需要在StartIo或者队列处理里读取Irp-MdlAddress然后用MmGetSystemAddressForMdlSafe拿到系统映射地址。这个函数的定义其实是一个宏加封装PVOID MmGetSystemAddressForMdlSafe( IN PMDL Mdl, IN MM_PAGE_PRIORITY Priority );它内部会检查MDL是否已经映射过如果没映射就调用MmMapLockedPagesSpecifyCache再映射一次。调用后得到的系统虚拟地址可以直接用于软件的读写。值得一提的是对于支持直接I/O的磁盘驱动、网卡驱动它们往往不依赖系统地址而是直接遍历MDL里的PFN数组把物理页号转成散列表交给DMA引擎。这里的逻辑是硬件设备只认物理地址不认虚拟地址。MDL的价值就变成了把用户态分散的虚拟页翻译成硬件能用的物理页集合。带DMA的驱动实现时通常有类似如下的处理PHYSICAL_ADDRESS MdlPhysicalAddress; PMDL mdl Irp-MdlAddress; // 拿MDL对应的起始物理地址 MdlPhysicalAddress MmGetMdlPhysicalAddress(mdl); // 对于散列不连续的大缓冲区准备散列表 ULONG pageCount ADDRESS_AND_SIZE_TO_SPAN_PAGES( MmGetMdlVirtualAddress(mdl), MmGetMdlByteCount(mdl) );有些驱动会直接用MmGetMdlByteCount替代IoGetMdlLength它们本质上都是取MDL描述的字节数。但我更建议统一用IoGetMdlLength因为它带上了IRP完整性判断并且语义更清晰。讲到DMA就不得不提缓存一致性问题。内存页有时候在CPU的Cache里硬件设备通过总线读物理内存时不一定能看到最新的内容。对于支持总线主控DMA的设备驱动要在开始传输前调用KeFlushIoBuffers或依据MmCacheFlags判断是否需要FLUSH。如果你在MmMapLockedPagesSpecifyCache里选了MmCached数据通路大概率经过Cache那么DMA传输前刷新Cache是不可省略的。这里翻过车的案例我见了不少症状往往是设备收到的数据和应用层看到的数据偶尔不一致时好时坏最后定位到Cache刷新被漏掉了。MDL在IRP读写路径里的使用还有一个“隐性要求”区分DIRECT_IO下用户缓冲区的权限。如果应用层用FILE_READ_DATA打开设备DIRECT_IO构建的MDL默认只允许读锁定如果应用层又发了写请求驱动要检查IRP的RequestedMode和MDL的标志。另外当设备在用户没有以写权限打开的情况下尝试写回内存I/O管理器会在新版本系统的验证机制上直接拦截。所以不光是驱动要守规矩应用层打开设备的方式也必须同步约束。对于METHOD_NEITHER的IOCTLI/O管理器不会再为你准备MDL。这种模式下IOCTL参数里的Type3InputBuffer和UserBuffer是原始的用户虚拟地址驱动必须在自己的分发函数里根据情况构建MDL。具体做法就是第三节展示的流程先判断地址位于用户态地址范围低于MM_USER_PROBE_ADDRESS再附加进程、Probe、Lock、Map。注意NEITHER模式下地址很容易被恶意传入一个指向内核空间的伪地址如果不加Probe而直接IoAllocateMdl并锁页后果很严重。内核空间的地址走MmProbeAndLockPages也一样可能触发异常因为访问模式是UserMode。保险的做法是先用用户地址范围宏检测再做Probe再从IoAllocateMdl开始。到这里你会发现MDL在驱动读写里的定位几乎是“万能钥匙”既能做跨进程自由读写又能服务DMA还能在非缓冲I/O路径下保住内存稳定性。它也因此成了许多高吞吐驱动项目的核心依赖。5. 踩坑记录一次由错误释放顺序引发的死锁排查过程理论讲再多不如一次真实排查来得刻骨铭心。某次我在给某跨平台系统适配一套自研存储设备驱动时收到测试机频繁“卡死加掉盘”的反馈。表面上看读写速度正常但随机间隔几分钟系统就出现明显IO停顿设备管理器里设备状态变成“故障”只能重启恢复。先临时用WinDbg抓系统状态发现卡死时DPC队列里堆了一堆超时的I/O请求包。继续敲!analyze -v发现关联的锁是某个MDL相关的非分页池。这时候我意识的重点不再是设备硬件而是驱动里MDL释放的时序。排查代码后发现一个很低级的错误在完成例程里我为了省事把MmUnmapLockedPages、MmUnlockPages和IoFreeMdl三者合并到了一个统一的cleanup函数里但该函数在DPC及其后续完成路径中存在一个分支部分路径对同一个MDL执行了两次清理。第一次调用IoFreeMdl把MDL结构体释放回池中第二次走另一个路径时又读取已经释放的Mdl-Next直接读到了一个被回收的指针内容随后在内核池管理器里触发一致性检查锁越占越多最后死锁。修复方式很简单每个MDL在分配时通过Mdl-Next区分主MDL和附属MDL只用主MDL结构体携带一个清理状态位或者在IRP完成回调里集中做“只clean一次”的标志判断。这个坑更大的启发是MDL的生命周期管理和IRP生命周期紧密耦合。IRP一旦完成伴随它的MDL就必须立即释放否则I/O管理器会在后续复用IRP时出现双重释放。反过来如果一个异步操作用到了MDL但完成回调因为某种原因永远不跑这就会导致MDL一直占着内存页未解锁页帧逐渐被锁死最终触发系统严重问题。还有一次踩在MmUnlockPages和MmUnmapLockedPages顺序上的教训。一个同事在测试时把顺序写反先MmUnlockPages再Unmap结果释放了页锁之后再去解除映射Unmap函数尝试触碰的页已经被标记为可换出随即触发一次缺页异常。因为Unmap执行在内核态的非分页代码上下文里异常直接升级成 bugcheck。后来我养成了一个习惯无论代码怎么写清理顺序一律固定为Unmap、Unlock、Free并且在每个函数前面加ASSERT判断当前是否还有映射标志检查构建时在Driver Verifier里把这几个API的验证全部打开。这件事也顺带让我重新理解了Driver Verifier的价值。平时调试驱动时开启Driver Verifier的“Force IRQL checking”和“Pool tracking”选项能非常早地暴露出MDL释放顺序、锁定页未解锁、错误调用级别等问题。特别是针对IoAllocateMdl和MmProbeAndLockPages打开“Mdl checking”以后系统会在释放时校验MDL标志位一旦发现异常直接断点比事后挖dump省时间得多。除了释放顺序MDL还容易出现“过度锁定”问题。有一种场景驱动为了给上层应用提供共享内存映射把用户缓冲区锁定后长期不释放。如果应用层分配了几百MB内存这个锁定会让对应物理页始终在内存里无法被换出。轻则系统内存压力增大重则导致其他进程的内存申请被阻塞。写这种长期锁页功能前要仔细评估业务风险。如果只是短时读写不要设计成持锁状态。把上面这些经历浓缩成一句话就是MDL用对了是宝用错了是坑。它涉及的内存锁、非线性映射、IRQL沟通、物理页生命周期管理每一个环节都有足以让系统崩溃的隐蔽副作用。与其说我是在写MDL读写不如说是在写对Windows内存管理机制的敬畏心。6. 优化进阶从MDL读写到零拷贝策略的取舍心得把基础读写跑通后很容易让人产生一个冲动既然MDL能直接锁定用户缓冲区的物理页那我是不是可以完全不用拷贝直接拿着映射地址当作设备数据的“零拷贝”通道答案是能但有前提而且前提比你想象的多。零拷贝在驱动读写的含义是避免系统缓冲区和用户缓冲区之间额外的一次复制。MDL本身就提供了这种可能因为DIRECT_IO下系统已经创建了用户缓冲区的MDL驱动和硬件可以直接访问这些物理页。但零拷贝不等于没有成本。它的成本转移到了内存锁定和管理复杂度上具体体现在以下几点。第一可用的物理页连续性。用户态分配的大块虚拟连续内存物理页极大概率是散列的。DMA引擎如果要求连续物理地址你就必须用散列/聚集列表来适配。这种适配本身需要额外分配和计算数据量小时反而不如复制一次划算。第二锁定带来的内存压力。零拷贝意味着用户缓冲区必须全程锁定也就是它不能被换出。对一个大而活跃的缓冲区来说这会挤占系统的可回收内存。在内存紧张的环境中这种锁定反而会拖慢整体吞吐因为系统需要拼命腾出更多可换出页面来平衡锁定区域。第三对齐和边界问题。硬件设备、总线协议多半有自己的对齐要求。比如某个外设要求传输起始地址按扇区字节对齐长度也必须是区块大小整数倍。用户态缓冲区不可能保证都满足。这种情况下MDL虽然能锁定页面却无法替你把一个小尾巴补齐。常见的解决方案是“大块走MDL首尾走小缓冲”的分段策略。我的习惯是超过4KB且对齐良好的传输用MDL零拷贝小于4KB或者边界不齐的就老老实实走系统缓冲复制。实测统计下来这种混合策略比单纯某一侧方案的吞吐高出不少。第四取消和超时处理。零拷贝的传输一开硬件可能正在访问这些物理页。如果这时用户线程退出虚拟地址空间即将销毁MDL锁定机制理论上能保着物理页不被释放但如果驱动没有正确处理取消请求就可能让用户线程已经消失后硬件依然在写一块即将失效的关联缓冲区。所以零拷贝读写对取消例程、超时处理的要求明显更高。我在实现这类功能时会在请求下发之前就登记取消回调并在互锁标志位里记录传输状态完成例程里再做一次状态机收敛。整个过程像是一套小型的传输协议栈比单纯RtlCopyMemory复杂得多。把MDL读写做成零拷贝后建议用性能计数器做AB对比。一般来讲数据块小于某阈值时MDL锁定加映射的开销高于复制开销超过阈值以后省掉复制的优势才明显体现。这个阈值受平台和硬件影响很大。在我接触的某图像处理Demo里阈值大约在8KB到16KB之间。你可以用ETW事件或内核性能计数器记录每笔IO的映射耗时和拷贝耗时找出属于你自己环境的最佳临界点。7. 第一次用MDL时最容易忽略的权限边界和安全习惯最后这一点必须单独写因为MDL读写涉及用户缓冲区锁定天然就踩在权限与安全边界上。如果处理不好轻则功能被系统拒之门外重则留下可以被利用的漏洞。第一个习惯永远先假设用户传进来的指针是不可信的。不管是IOCTL参数、IRP里的UserBuffer还是应用层直接指定的地址都要在进入Probe之前用可用的范围限制一次。一个稳妥的检查顺序是判断地址是否在用户态范围((ULONG_PTR)UserAddress Length) MM_USER_PROBE_ADDRESS。调用ProbeForRead或ProbeForWrite按对齐要求做进一步探测。在try/except块内做MmProbeAndLockPages。映射以后访问时对读取方向用RtlCopyMemory即可写入方向也要确认MDL确实允许写锁定。第二步容易踩的细节是ProbeForRead只检查范围不会检查“这块内存当前是否真的可读”因为可读性本来就要由真正的访问来完成。所以不要以为Probe过了就万事大吉。第二个习惯看清楚谁构建了这个MDL。如果是I/O管理器在DIRECT_IO下构建的MDL它的Process字段通常指向发起请求的进程MdlFlags会带有MDL_SOURCE_IS_NONPAGED_POOL之类标志也可能是MDL_ALLOCATED_FIXED_SIZE。这些标志决定了你能做什么。比如MDL_PAGES_LOCKED表明页面已锁定你可以直接访问如果它都没被锁定你却跳过MmProbeAndLockPages去映射后续访问极有可能出问题。第三个习惯注意跨进程场景下对目标进程的引用计数。KeStackAttachProcess本身假设传进来的EPROCESS是有效的。如果你是通过PsLookupProcessByProcessId拿到的引用那么在整个MDL读写期间必须保证这个引用不被释放。尤其当你把MDL读写函数做成异步、在系统工作线程里执行时要防止目标进程先退出、EPROCESS被回收。稳妥的方式是在附加进程期间立刻完成Probe和Lock把EPROCESS引用的持有时间压缩到最短。第四个习惯核心数据如果涉及安全边界驱动不要替应用层做决定。比如某个IOCTL是“锁页并返回系统映射地址给应用层”这等于把内核映射地址泄露给用户程序严格说这条路径本身就是不安全的。现代Windows里把MmMapLockedPages映射为UserMode时系统会检查调用者是否有SeLockMemoryPrivilege等特权的背景。大多数合规的驱动都会在内核侧直接完成数据的读写而不会把映射地址直接交给应用层去碰。我现在写任何涉及MDL的分发函数都会在代码注释里留清楚“谁创建、谁映射、谁清理、什么条件下可以访问”这四件事。原因不是代码风格洁癖而是MDL太容易在权限、锁、引用三种边界上产生交互问题。早年间在某个项目中就曾因为在一个共享缓冲区上同时创建了两个MDL并叠加映射导致同一物理页被两个相互信任的驱动组件分别修改产生竞态。后来改为只创建一份MDL并且统一通过引用计数管理访问者问题才彻底消失。说回文章开头那个蓝屏教训。现在如果再让我评审类似代码我会直接问三件事用户地址做过完整探测吗MDL的锁页标志确认了吗清理路径在取消、超时、完成三种情况下都只走一次吗这三个问题问完绝大多数由MDL驱动读写引发的系统级故障都能被挡在设计阶段之外。