☰
MDL驱动读写实战:从锁页原理到跨进程内存安全访问
2026/10/19 6:20:55 网站建设 项目流程

1. 从阻塞到崩溃:为什么读写用户缓冲区不能只靠“附加进程”

写Windows内核驱动的人,多多少少都遇到过这个场景:你拿到了一个目标进程的EPROCESS,想读它某块用户态内存里的数据。最常见的方案是先调用KeStackAttachProcess挂到目标进程上下文,然后直接用MmCopyVirtualMemory或者裸访问地址把数据搬出来,完事再KeUnstackDetachProcess。这套流程在低负载、地址固定、内存不换页的情况下确实能跑,可一旦放到真实项目里,很快就暴露出问题。

先说一个我早年踩过的坑。某次给一台外设写过滤驱动,需要在IRP下发的瞬间读取应用层传入的一块缓冲区。应用层为了性能,把缓冲区开在栈上让线程反复使用,还时不时用VirtualAlloc重新提交内存。我在驱动里觉得“附加上下文之后指针就是有效的”,直接做了次RtlCopyMemory。结果系统在某个随机时刻蓝屏,指向的地址段正好是一块被应用层释放后又被偏移量访问的页。检查转储文件后发现,缓冲区在复制过程中发生了分页错误,而我的驱动代码没有用try/except捕获,异常一路传到内核态,系统当场崩溃。

这个例子想说明的核心问题其实很简单:用户态虚拟地址在驱动眼里是“借用来的”,它背后映射的物理页面可能在任意时刻被换出、被释放、被共享甚至被恶意篡改。你如果要稳定地做“MDL驱动读写”,本质上有两件事必须同时保证:

  • 地址对应的物理页面在读写持续期间不能被换走、不能被释放;
  • 驱动访问该内存时,不能因为页面不在内存里而触发一个未处理的分页错误。

MDL(Memory 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,标准步骤通常包含四步:

  1. 分配MDL结构体:IoAllocateMdl。
  2. 锁定用户缓冲区对应的物理页面:MmProbeAndLockPages(用户模式内存)或MmBuildMdlForNonPagedPool(非分页内核池)。
  3. 将锁定的页面映射到内核虚拟地址:MmMapLockedPagesSpecifyCache。
  4. 用完后解除映射、解锁页面、释放MDL:MmUnmapLockedPages、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设为NULL,VirtualAddress传用户缓冲区的起始用户虚拟地址,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_VA,MappedSystemVa也被更新为映射地址。清理时如果直接调用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的IOCTL,I/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之前用可用的范围限制一次。一个稳妥的检查顺序是:

  1. 判断地址是否在用户态范围:((ULONG_PTR)UserAddress + Length) < MM_USER_PROBE_ADDRESS。
  2. 调用ProbeForRead或ProbeForWrite,按对齐要求做进一步探测。
  3. 在try/except块内做MmProbeAndLockPages。
  4. 映射以后访问时,对读取方向用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驱动读写引发的系统级故障都能被挡在设计阶段之外。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询