Reactos 第 9 章 设备驱动 — 9.12 MDL

第 9 章 设备驱动 --- 9.12 MDL

本节剖析 Windows NT 中 MDL(Memory Descriptor List,内存描述符列表)的概念、结构和用法。 MDL 是描述 物理页数组 的数据结构,直接 I/ODMA零拷贝网络 都依赖它。ReactOS 的 MDL 实现在 ntoskrnl/mm/ARM3/mdlsup.c(file:///d:/reactos/ntoskrnl/mm/ARM3/mdlsup.c) 和 ntoskrnl/mm/mdl.c(file:///d:/reactos/ntoskrnl/mm/mdl.c)。理解 MDL 的关键是把握 虚拟地址到物理页数组的映射、锁页、映射 三步流程。


概述

MDL 的核心价值是 DMA直接 I/O。设备直接读写物理内存,需要驱动告诉它:

  1. 缓冲区在哪几个物理页上
  2. 这些页是否锁定(pinned)
  3. 缓存属性(cached、write-through、write-combined、uncached)

IRP->MdlAddress 是 I/O 路径中 MDL 的标准位置。

MDL 解决的问题

MDL 存在的根本原因是 虚拟地址与物理地址的鸿沟。用户态程序通过虚拟地址访问缓冲区,这些虚拟地址对应的物理页面可能分散在内存的不同位置,甚至可能被换出到磁盘。然而 DMA 控制器和硬件设备只认识物理地址------它们不经过 MMU,直接通过系统总线访问物理内存。MDL 正是连接这两个世界的桥梁:它将一段虚拟地址范围"翻译"为一组物理页帧号(PFN),并通过锁定机制保证这些页面在 I/O 期间不会被换出或释放。

MDL 的标准三阶段生命周期

每个 MDL 在使用过程中都遵循一套严格的 三段式生命周期

  1. 分配阶段IoAllocateMdl):在非分页池中分配 MDL 结构体,并在其后预留足够的空间容纳 PFN 数组。分配时指定虚拟基地址和长度,内核自动计算需要多少个 PFN 条目。

  2. 锁定/填充阶段:根据缓冲区的来源决定采用哪条路径------

    • 用户缓冲区:MmProbeAndLockPages 探测地址合法性、处理缺页、锁定物理页、填充 PFN 数组;
    • 非分页池:MmBuildMdlForNonPagedPool 直接查物理地址填充 PFN 数组,无需锁页。
  3. 映射与使用阶段MmGetSystemAddressForMdlSafe 将锁定的物理页映射到系统地址空间,驱动获得一个可以在内核态安全访问的虚拟地址。DMA 驱动则从 PFN 数组中提取物理地址,构造散点-聚集列表交给设备。

这三个阶段对应三种释放操作:MmUnmapLockedPages(解除映射)→ MmUnlockPages(解锁物理页)→ IoFreeMdl(释放 MDL 结构)。顺序不可颠倒------必须先解除映射再解锁,否则解锁后物理页可能被回收,映射的虚拟地址变成悬空指针。

MDL 在三种 I/O 模式中的角色

IRP 的三种 I/O 模式对 MDL 的处理方式不同,这直接影响了驱动开发者的编程模型:

  • DO_DIRECT_IO :I/O 管理器在 NtReadFile/NtWriteFile 中自动为应用层缓冲区分配 MDL、锁定页面,并将 MDL 挂载到 Irp->MdlAddress。驱动直接从 Irp->MdlAddress 读取 MDL,无需自行处理分配和锁定。这是块设备和网络设备最常用的模式。

  • DO_BUFFERED_IO :I/O 管理器在系统空间中分配一个与用户缓冲区大小相同的临时缓冲区(Irp->AssociatedIrp.SystemBuffer),并在 IRP 完成时自动将数据拷贝回用户缓冲区。这种模式下驱动不需要接触 MDL,适用于小数据量的控制操作。

  • NEITHER 模式 :I/O 管理器不做任何处理,驱动自行决定如何处理用户缓冲区------可以自己调用 IoAllocateMdl + MmProbeAndLockPages 构造 MDL,也可以直接访问用户地址(但需要自己处理探测和异常保护)。

一个典型的 MDL 使用场景

假设用户程序分配了一个 10KB 的缓冲区并调用 ReadFile 从磁盘读取数据。这个缓冲区在虚拟地址上是连续的(比如起始于 0x1000),但其对应的物理页可能分散在 物理页 5物理页 1023物理页 47 上。磁盘控制器无法理解这种"虚拟连续而物理分散"的布局。MDL 捕获了这三个物理页的帧号,驱动程序将 PFN 数组转换为 PRDT(Physical Region Descriptor Table)后,磁盘控制器可以通过 PRDT 的散点-聚集能力完成 DMA 传输,将数据直接写入三个不连续的物理页面中------整个过程对应用层完全透明。

本节内容概览

  • 9.12.0 框架图
  • 9.12.1 MDL 数据结构
  • 9.12.2 IoAllocateMdlIoFreeMdl
  • 9.12.3 MmBuildMdlForNonPagedPool / MmProbeAndLockPages
  • 9.12.4 MmGetSystemAddressForMdlSafe 映射
  • 9.12.5 MmUnmapLockedPages / MmUnlockPages
  • 9.12.6 IRP 中的 MDL
  • 9.12.7 DMA 与 MDL
  • 9.12.8 总结与代码索引

学习目标

  • 理解 MDL 描述的物理页数组结构
  • 掌握 IoAllocateMdl + MmBuildMdlForNonPagedPool 的协作
  • 知道 DIRECT_IO 与 BUFFERED_IO 在 MDL 上的区别
  • 跟踪 MDL 在 IRP 中的流动

涉及的内核子系统

子系统 头文件/源文件 核心作用
MDL 主体 ntoskrnl/mm/ARM3/mdlsup.c(file:///d:/reactos/ntoskrnl/mm/ARM3/mdlsup.c) MDL 分配、锁定、映射
MDL 旧路径 ntoskrnl/mm/mdl.c(file:///d:/reactos/ntoskrnl/mm/mdl.c) MDL 实现(旧实现)
I/O MDL ntoskrnl/io/iomgr/irp.c(file:///d:/reactos/ntoskrnl/io/iomgr/irp.c) IoAllocateMdlIoFreeMdl
内存管理 ntoskrnl/mm/(file:///d:/reactos/ntoskrnl/mm/) 物理页管理
NDIS MDL drivers/network/ndis/ndis/buffer.c(file:///d:/reactos/drivers/network/ndis/ndis/buffer.c) NDIS_BUFFER 即 MDL
Storport drivers/storage/port/scsiport/(file:///d:/reactos/drivers/storage/port/scsiport/) 构造 PRDT 用 MDL

9.12.0 框架图

复制代码
用户缓冲区
    |
    v
+----------------------+
| 用户 VA 范围          |  VirtualAddress, ByteCount
+----------------------+
    |  IoAllocateMdl + MmBuildMdlForNonPagedPool
    |  MmProbeAndLockPages
    v
+----------------------+
| MDL (内核对象)        |  描述物理页数组
+----------------------+
    |  PFN_ARRAY: pfn0, pfn1, pfn2, ...
    |  MappedSystemVa: 内核映射地址
    |  StartVa: 原始 VA
    |  ByteCount: 长度
    v
+----------------------+
| 物理页(已锁定)      |  在工作集中
+----------------------+
    |
    v
+----------------------+
| 设备 DMA / 直接 I/O  |
+----------------------+

9.12.1 MDL 数据结构

MDL(也称 NTFS_MDL)是 ntoskrnl 内部结构。NDIS 中对应的类型是 NDIS_BUFFER(结构相同),所以 NDIS 函数也可操作 MDL。

c 复制代码
typedef struct _MDL {
    struct _MDL *Next;              // 链表(IRP->MdlAddress 是链头)
    CSHORT Size;                    // 此结构大小
    CSHORT MdlFlags;                // 标志
    PVOID StartVa;                  // 原始 VA(用户/内核)
    ULONG ByteCount;                // 字节数
    PVOID MappedSystemVa;           // 内核映射 VA(如果已映射)
    PVOID Process;                  // 所属进程(用户 MDL)
    PPFN_NUMBER PfnArray;           // 物理页帧号数组
} MDL, *PMDL;

关键字段

字段 含义
Next 链接到下一个 MDL(IRP 中可能有多个 MDL)
Size 此 MDL 结构的大小(含 PfnArray)
MdlFlags MDL_MAPPED_TO_SYSTEM_VAMDL_PAGES_LOCKED
StartVa 原始虚拟地址(用户或内核)
ByteCount 字节数
MappedSystemVa 如果映射到系统地址空间,这是系统 VA
Process 拥有此 MDL 的进程(仅用户 MDL)
PfnArray 物理页帧号数组,紧跟在 MDL 结构之后

MdlFlags 标志

标志 含义
MDL_MAPPED_TO_SYSTEM_VA MDL 已映射到系统 VA
MDL_PAGES_LOCKED 物理页已锁定(工作集固定)
MDL_SOURCE_IS_NONPAGED_POOL 来自非分页池(无需锁页)
MDL_PARTIAL 部分 MDL(IRP 中子栈创建)
MDL_IO_SPACE 物理页帧号其实是 IO 端口
MDL_PARENT_MAPPED_SYSTEM_VA 子 MDL 与父共享 MappedSystemVa

PFN 数组

PfnArray 紧跟在 MDL 结构后:

复制代码
MDL 结构
+---------+---------+---------+----------+ ... 
| Header  | MdlFlags| StartVa | ByteCount|
+---------+---------+---------+----------+ ...
| MappedSystemVa |   Process   | PfnArray
+----------------+-------------+----------+------
|   PFN[0]    |   PFN[1]    |   PFN[2]  | ...
+-------------+-------------+------------+------

页帧号是 PFN_NUMBER(ULONG),表示物理页号(页大小 4KB,物理地址 = PFN << PAGE_SHIFT)。


9.12.2 IoAllocateMdlIoFreeMdl

IoAllocateMdl

c 复制代码
PMDL IoAllocateMdl(
    IN PVOID VirtualAddress,         // 虚拟地址
    IN ULONG Length,                 // 字节数
    IN BOOLEAN SecondaryBuffer,      // TRUE: 这是 IRP 内部子 MDL
    IN BOOLEAN ChargeQuota,          // TRUE: 配额收费(用户 MDL)
    IN PIRP Irp                      // 如果非 NULL,链接到 IRP->MdlAddress
);

实现(irp.c(file:///d:/reactos/ntoskrnl/io/iomgr/irp.c)):

c 复制代码
PMDL IoAllocateMdl(PVOID VirtualAddress, ULONG Length, BOOLEAN SecondaryBuffer,
                   BOOLEAN ChargeQuota, PIRP Irp)
{
    PMDL Mdl;
    ULONG Size;

    // 1. 计算 PFN 数量
    Size = ADDRESS_AND_SIZE_TO_SPAN_PAGES(VirtualAddress, Length);

    // 2. 分配 MDL(包含 PFN 数组空间)
    Mdl = ExAllocatePoolWithTag(NonPagedPool,
                                sizeof(MDL) + Size * sizeof(PFN_NUMBER),
                                ' ldM');

    // 3. 初始化
    RtlZeroMemory(Mdl, sizeof(MDL) + Size * sizeof(PFN_NUMBER));
    Mdl->StartVa = VirtualAddress;
    Mdl->ByteCount = Length;
    Mdl->Size = (CSHORT)(sizeof(MDL) + Size * sizeof(PFN_NUMBER));

    if (ChargeQuota) {
        PsChargeProcessPoolQuota(Process, ..., ' ldM');
        Mdl->Process = Process;
    }

    // 4. 如果是 IRP 子 MDL,链接到 Irp->MdlAddress
    if (Irp) {
        Mdl->Next = Irp->MdlAddress;
        Irp->MdlAddress = Mdl;
    }

    return Mdl;
}

IoFreeMdl

c 复制代码
VOID IoFreeMdl(IN PMDL Mdl)
{
    if (Mdl->MdlFlags & MDL_MAPPED_TO_SYSTEM_VA) {
        MmUnmapLockedPages(Mdl->MappedSystemVa, Mdl);
    }
    if (Mdl->MdlFlags & MDL_PAGES_LOCKED) {
        MmUnlockPages(Mdl);
    }
    if (Mdl->Process) {
        PsReturnProcessPoolQuota(Mdl->Process, ..., ' ldM');
    }
    ExFreePoolWithTag(Mdl, ' ldM');
}

9.12.1b MDL 设计背景与深层原理

为什么需要 MDL?

在操作系统中,用户态程序和内核态驱动之间存在根本性的地址空间隔离。用户缓冲区使用进程的虚拟地址空间,这些页面可能被换出到磁盘(paged out),也可能在 I/O 进行过程中被移动或释放。然而,DMA 控制器和硬件设备只认识物理地址,它们不经过 CPU 的 MMU(内存管理单元),直接通过系统总线访问物理内存。

这就产生了一个核心矛盾:设备需要物理地址,但驱动程序接收的是虚拟地址。MDL 就是解决这个矛盾的桥梁------它将一段虚拟地址范围"翻译"为一组物理页帧号(PFN),并且通过锁定机制保证这些页面在 I/O 操作期间不会被换出或移动。

从设计哲学的角度看,MDL 实际上是 Windows NT 内核对 scatter/gather I/O 概念的抽象。在早期的 Unix 系统中,scatter/gather I/O 通过 readv/writev 系统调用实现,允许多个不连续的缓冲区在一次系统调用中完成传输。Windows NT 的 MDL 更进一步,不仅描述了缓冲区的虚拟地址布局,还完成了物理页的锁定和系统空间的映射,为 DMA 操作提供了完整的硬件级描述。

MDL 在 ReactOS 中的实现架构

ReactOS 的 MDL 实现分为两个层次:

  1. ARM3 内存管理器层ntoskrnl/mm/ARM3/mdlsup.c):这是主要的 MDL 实现,负责物理页的锁定、解锁、系统空间映射等核心操作。ARM3(ARM Memory Manager 3)是 ReactOS 从 Windows Research Kernel(WRK)借鉴并扩展的内存管理子系统,它实现了完整的分页内存管理。

  2. 旧路径兼容层ntoskrnl/mm/mdl.c):保留了早期 ReactOS 开发阶段的 MDL 实现,用于兼容某些特殊的内部调用路径。

这种双层架构反映了 ReactOS 的开发历史------早期使用简单的 MDL 实现,后来引入 ARM3 内存管理器后,将 MDL 的核心功能迁移到了更完善的 ARM3 框架中。

MDL 与 PFN 数据库的交互

MDL 的锁页操作深度依赖 ReactOS 的 PFN 数据库 (Page Frame Number Database)。PFN 数据库是内存管理器的核心数据结构,它为系统中的每一个物理页维护一个 MMPFN 结构,记录该页的状态(空闲、活跃、备用、修改等)、引用计数、所属进程等信息。

MmProbeAndLockPages 锁定一个页面时,它实际上做了以下工作:

  1. 遍历 PTE(Page Table Entry) :从 StartVa 开始,通过 MiAddressToPte 获取每一页对应的 PTE。在 x86 架构上,这意味着遍历页目录(PDE)和页表(PTE)两级结构;在 x86-64 上则需要四级页表遍历(PXE → PPE → PDE → PTE)。

  2. 处理缺页 :如果某一页的 PTE 标记为无效(PTE.u.Hard.Valid == 0),说明该页已被换出到页面文件。此时 MmProbeAndLockPages 会调用 MmAccessFault 触发缺页异常处理,将页面从磁盘读回物理内存。ReactOS 代码中传递了一个特殊的 TrapInformation0xBADBADA3BADBADA3,用于告知缺页处理例程这是来自 MDL 锁页的上下文,而非真正的硬件异常。

  3. 增加 PFN 引用计数 :每一页对应的 MMPFN 结构有一个 u3.e2.ReferenceCount 字段。锁页操作增加这个计数,使得内存管理器的页面替换算法(如修改页面写回、LRU 淘汰)不会选中这些页面。

  4. 处理写时复制(Copy-on-Write) :如果操作是 IoWriteAccessIoModifyAccess,而 PTE 标记为写时复制(MI_IS_PAGE_COPY_ON_WRITE),则需要触发一个写保护异常,让内存管理器为该页创建一个私有副本,然后才能安全地锁定。

非分页池 vs 用户缓冲区:两条锁页路径

MmBuildMdlForNonPagedPoolMmProbeAndLockPages 代表了 MDL 填充的两条截然不同的路径:

非分页池路径MmBuildMdlForNonPagedPool):非分页池(NonPagedPool)中的内存保证始终驻留在物理内存中,不会被换出。因此这条路径无需缺页处理,只需通过 MmGetPhysicalAddress 查询每一页的物理地址,然后填充 PFN 数组即可。这条路径的速度很快,因为它不需要获取任何锁或操作 PFN 数据库。

用户缓冲区路径MmProbeAndLockPages):用户态缓冲区可能跨越多个页面,且每个页面都可能被换出。这条路径需要:

  • 首先探测 (Probe):通过实际读取(*(volatile CHAR*)Address)和 ProbeForWriteChar 验证每一页的可访问性,确保不会在内核模式下触发意外的访问违规。
  • 然后锁定 (Lock):获取进程工作集锁(MiLockProcessWorkingSet)或 PFN 锁(MiAcquirePfnLock),逐页增加引用计数。

ReactOS 代码中有一个重要的优化细节:对于内核模式地址(Base > MM_HIGHEST_USER_ADDRESS),不需要关联进程(Mdl->Process = NULL),且操作强制降级为 IoReadAccess,因为内核空间的页面不会涉及写时复制。

MDL 映射到系统空间的内幕

当驱动需要以 PIO(Programmed I/O)方式访问用户缓冲区的数据时,它调用 MmGetSystemAddressForMdlSafe 将锁定的物理页映射到系统地址空间。这个函数的实现核心是 MiMapLockedPagesInSystemSpace(在 mdlsup.c 中实现),其工作流程如下:

  1. 分配系统 VA 范围:在系统地址空间中找到一个足够大的虚拟地址范围来容纳所有页面。ReactOS 使用一个特殊的 VA 分配器来管理系统空间中的映射区域。

  2. 创建 VAD(Virtual Address Descriptor) :为这段映射创建一个 MMVAD_LONG 结构,类型为 VadDevicePhysicalMemory,标记为私有内存。这个 VAD 被插入到进程的 VAD 树中,使得虚拟内存管理器知道这段地址空间已被占用。

  3. 填充 PTE :遍历 MDL 的 PFN 数组,为每一个物理页在系统空间中创建有效的 PTE。PTE 的缓存属性由 MiPlatformCacheAttributes 表决定,该表根据平台(x86、ARM 等)和请求的缓存类型(Cached、WriteThrough、NonCached、WriteCombined)生成正确的 PTE 位模式。

  4. 刷新 TLB :新填充的 PTE 需要使 TLB(Translation Lookaside Buffer)生效。ReactOS 通过 MiFlushTlb 确保后续的访问能使用新的映射。

这个过程的一个重要限制是:系统空间中的映射 VA 是不连续的虚拟地址对应可能不连续的物理页。也就是说,即使原始用户缓冲区在虚拟地址上是连续的,映射后的系统 VA 对应的物理页也可能是分散的。这正是 MDL 名称中"Descriptor List"的含义------它描述的是一组可能分散的物理页。

缓存属性与硬件一致性

MDL 的缓存属性对硬件 I/O 的正确性至关重要。考虑以下场景:

  • DMA 写入 :设备将数据写入物理内存。如果 CPU 缓存中有这些页面的旧数据(脏缓存行),CPU 后续读取时会得到旧数据而非设备写入的新数据。解决方案是使用 MmNonCachedMmWriteCombined,或者在 DMA 完成后调用 MmFlushInvalidateCache(如果可用)。

  • DMA 读取 :CPU 先将数据写入缓冲区,然后设备从物理内存读取。如果 CPU 的写操作只更新了缓存而未写回内存(write-back cache),设备会读到旧数据。解决方案是使用 MmNonCached 或在 DMA 开始前调用 KeFlushIoBuffers

ReactOS 的 MmGetSystemAddressForMdlSafe 默认使用 MmCached 缓存类型。对于需要特定缓存属性的设备(如显卡帧缓冲区通常使用 WriteCombined),驱动应使用 MmMapIoSpaceMmGetSystemAddressForMdlSafeEx 显式指定缓存类型。

MDL 链与 IRP 中的多缓冲区

在实际的 I/O 操作中,一个 IRP 可能需要描述多个缓冲区。例如,SCSI 存储驱动可能需要一个数据缓冲区和一个 Sense 信息缓冲区。IRP->MdlAddress 指向一个 MDL 链表,通过 MDL->Next 字段链接多个 MDL:

复制代码
IRP->MdlAddress → MDL1 (数据缓冲区) → MDL2 (Sense缓冲区) → NULL

IoCompleteRequest 在完成 IRP 时会遍历这个链表,对每个 MDL 调用 MmUnlockPages 解锁物理页,然后调用 IoFreeMdl 释放 MDL 结构。这种链式设计允许一个 IRP 携带任意数量的缓冲区描述,而无需修改 IRP 结构本身。

调试 MDL 相关问题的技巧

在 ReactOS 开发中,MDL 相关的 bug 通常表现为以下几种形式:

  1. Bugcheck MEMORY_MANAGEMENT :通常是因为 MDL 被重复释放、或在解锁后仍然访问映射的 VA。ReactOS 的 MmUnlockPages 会检查 MDL_PAGES_LOCKED 标志,如果标志已被清除,则静默返回。

  2. Bugcheck DRIVER_CORRUPTED_EXPOOL :PFN 数组越界写入,通常是因为 ADDRESS_AND_SIZE_TO_SPAN_PAGES 计算错误,导致分配的 MDL 空间不足以容纳所有 PFN。

  3. 数据损坏:缓存一致性问题。表现为 DMA 完成后数据不正确,或 CPU 写入后设备读到旧数据。在 QEMU 等模拟器中可能不会出现(因为模拟器的缓存行为不同),需要在真实硬件上验证。

  4. 资源泄漏 :忘记调用 MmUnmapLockedPagesMmUnlockPages,导致系统空间 VA 耗尽或物理页被永久锁定。ReactOS 提供了 MmTrackLockedPages 全局变量(在 mdlsup.c 中定义),启用后可以跟踪所有锁定的页面。


9.12.3 MmBuildMdlForNonPagedPool / MmProbeAndLockPages

MmBuildMdlForNonPagedPool

非分页池的虚拟地址已经保证驻留,所以无需锁页,只需填充 PFN 数组。

c 复制代码
VOID MmBuildMdlForNonPagedPool(IN OUT PMDL Mdl)
{
    PVOID BaseVa = Mdl->StartVa;
    ULONG PageCount = ADDRESS_AND_SIZE_TO_SPAN_PAGES(BaseVa, Mdl->ByteCount);
    PPFN_NUMBER PfnArray = Mdl + 1;  // 紧跟 MDL 结构
    PFN_NUMBER Pfn;

    Mdl->MdlFlags |= MDL_PAGES_LOCKED | MDL_SOURCE_IS_NONPAGED_POOL;

    // 填充 PFN 数组
    for (ULONG i = 0; i < PageCount; i++) {
        Pfn = MmGetPhysicalAddress((PCHAR)BaseVa + i * PAGE_SIZE).QuadPart >> PAGE_SHIFT;
        PfnArray[i] = Pfn;
    }
}

MmProbeAndLockPages(用户态缓冲区)

用户缓冲区 可能换出到磁盘,必须先 探测锁定

c 复制代码
NTSTATUS MmProbeAndLockPages(
    IN OUT PMDL Mdl,
    IN KPROCESSOR_MODE AccessMode,  // UserMode / KernelMode
    IN LOCK_OPERATION Operation     // IoReadAccess / IoWriteAccess / IoModifyAccess
);

实现(mdlsup.c(file:///d:/reactos/ntoskrnl/mm/ARM3/mdlsup.c)):

  1. 探测ProbeForWriteProbeForRead 检查 VA 范围合法
  2. 逐页解析 PFN :使用 MiAddressToPteMiGetPfnEntry
  3. 锁定:增加 PFN 的 reference count,防止换出

锁页操作

c 复制代码
// 1. 用户 MDL 路径(IRP_MJ_READ)
IoAllocateMdl(UserBuffer, Length, FALSE, TRUE, Irp);
MmProbeAndLockPages(Irp->MdlAddress, UserMode, IoWriteAccess);

9.12.4 MmGetSystemAddressForMdlSafe 映射

用户缓冲区的 MDL 锁定后,需要 映射到系统地址空间(内核 VA),驱动才能安全访问。

c 复制代码
PVOID MmGetSystemAddressForMdlSafe(
    IN PMDL Mdl,
    IN MM_PAGE_PRIORITY Priority  // LowPagePriority, NormalPagePriority
);

实现:

c 复制代码
PVOID MmGetSystemAddressForMdlSafe(PMDL Mdl, MM_PAGE_PRIORITY Priority)
{
    if (Mdl->MdlFlags & MDL_MAPPED_TO_SYSTEM_VA) {
        return Mdl->MappedSystemVa;
    }
    if (Mdl->MdlFlags & MDL_SOURCE_IS_NONPAGED_POOL) {
        Mdl->MappedSystemVa = Mdl->StartVa;
        Mdl->MdlFlags |= MDL_MAPPED_TO_SYSTEM_VA;
        return Mdl->StartVa;
    }
    // 分配系统 VA 并填充 PTE
    return MiMapLockedPagesInSystemSpace(Mdl, Priority);
}

映射模型

缓冲区类型 映射策略
非分页池 直接返回 StartVa
用户缓冲区 通过 MiMapLockedPagesInSystemSpace 映射
I/O 空间 通过 MmMapIoSpace 映射到特殊 VA

缓存属性

MmGetSystemAddressForMdlSafe 默认使用 cached 内存。某些场景需要 uncached 或 write-combined:

c 复制代码
PVOID MmGetSystemAddressForMdlSafeEx(
    PMDL Mdl,
    MM_PAGE_PRIORITY Priority,
    PVOID StartVa,                    // 指定 VA(可选)
    MEMORY_CACHING_TYPE CacheType     // MmCached, MmWriteThrough, MmNonCached, MmWriteCombined
);

9.12.5 MmUnmapLockedPages / MmUnlockPages

MmUnlockPages

c 复制代码
VOID MmUnlockPages(IN PMDL Mdl)
{
    if (!(Mdl->MdlFlags & MDL_PAGES_LOCKED)) return;
    Mdl->MdlFlags &= ~MDL_PAGES_LOCKED;

    if (Mdl->MdlFlags & MDL_SOURCE_IS_NONPAGED_POOL) {
        // 非分页池无需解锁
        return;
    }

    // 用户缓冲区:逐页解锁
    PPFN_NUMBER PfnArray = Mdl + 1;
    ULONG PageCount = ...;
    for (i = 0; i < PageCount; i++) {
        MiUnpinPfn(PfnArray[i]);
    }
}

MmUnmapLockedPages

c 复制代码
VOID MmUnmapLockedPages(IN PVOID SystemVa, IN PMDL Mdl)
{
    if (!(Mdl->MdlFlags & MDL_MAPPED_TO_SYSTEM_VA)) return;
    Mdl->MdlFlags &= ~MDL_MAPPED_TO_SYSTEM_VA;

    if (Mdl->MdlFlags & MDL_SOURCE_IS_NONPAGED_POOL) {
        Mdl->MappedSystemVa = NULL;
        return;
    }

    // 解除 PTE 映射
    MiUnmapLockedPagesInSystemSpace(SystemVa, Mdl);
}

9.12.6 IRP 中的 MDL

IRP->MdlAddress 是 IRP 主缓冲区的 MDL。它的填充时机取决于 设备标志

DIRECT_IO 设备

c 复制代码
// DeviceObject->Flags |= DO_DIRECT_IO;

NTSTATUS DispatchRead(IN PDEVICE_OBJECT DeviceObject, IN PIRP Irp)
{
    PIO_STACK_LOCATION IoStack = IoGetCurrentIrpStackLocation(Irp);

    // I/O 管理器已经为用户缓冲区构造了 MDL
    // Irp->MdlAddress 已经指向该 MDL
    if (!Irp->MdlAddress) {
        return STATUS_INVALID_PARAMETER;
    }

    // 设备可使用 Irp->MdlAddress 直接 DMA
    // 或使用 MmGetSystemAddressForMdlSafe 获取内核 VA
}

BUFFERED_IO 设备

c 复制代码
// DeviceObject->Flags |= DO_BUFFERED_IO;

NTSTATUS DispatchRead(IN PDEVICE_OBJECT DeviceObject, IN PIRP Irp)
{
    // I/O 管理器分配了内核缓冲:Irp->AssociatedIrp.SystemBuffer
    // 驱动读写 SystemBuffer,无需 MDL
    PVOID SystemBuffer = Irp->AssociatedIrp.SystemBuffer;
}

NEITHER IO 设备

c 复制代码
// DeviceObject->Flags 默认

// 驱动自行处理用户缓冲区
NTSTATUS DispatchRead(...)
{
    PIO_STACK_LOCATION IoStack = IoGetCurrentIrpStackLocation(Irp);
    PVOID UserBuffer = IoStack->Parameters.Read.Buffer;

    // 驱动须自行探针
    ProbeForRead(UserBuffer, IoStack->Parameters.Read.Length, 1);

    // 自行构造 MDL
    PMDL Mdl = IoAllocateMdl(UserBuffer, Length, FALSE, TRUE, Irp);
    MmProbeAndLockPages(Mdl, UserMode, IoReadAccess);
}

IoBuildPartialMdl

驱动栈中下层驱动可以基于上层 MDL 创建 partial MDL(子范围):

c 复制代码
VOID IoBuildPartialMdl(
    IN PMDL SourceMdl,         // 父 MDL
    IN OUT PMDL TargetMdl,     // 目标(已分配)
    IN PVOID VirtualAddress,    // 子 VA
    IN ULONG Length             // 子长度
);

常用于 forwarded IRP 中下层只需要部分缓冲区。


9.12.7 DMA 与 MDL

DMA 控制器直接访问物理内存,驱动需要:

  1. 构造 MDL 描述缓冲区
  2. 调用 IoGetDmaAdapter 获取 DMA 适配器对象
  3. IoMapTransfer 设置 DMA 控制器

IoGetDmaAdapter

c 复制代码
PDMA_ADAPTER IoGetDmaAdapter(
    IN PDEVICE_OBJECT Pdo,         // 设备 PDO
    IN PDEVICE_DESCRIPTION DeviceDescription,
    IN OUT PULONG NumberOfMapRegisters
);

返回的 PDMA_ADAPTER 提供 DmaOperations 表。

IoMapTransfer

c 复制代码
NTSTATUS IoMapTransfer(
    IN PDMA_ADAPTER DmaAdapter,
    IN PMDL Mdl,
    IN PVOID MapRegisterBase,
    IN OUT PIO_STATUS_BLOCK IoStatus,
    IN PVOID CurrentVa,            // 当前虚拟地址(连续映射)
    IN PULONG Length,              // 长度
    IN BOOLEAN WriteToDevice       // 方向
);

Storport 中的 MDL

Storport 端口驱动把 IRP->MdlAddress 转换为 PRDT(Physical Region Descriptor Table):

c 复制代码
// 假设 StorAHCI miniport
PVOID AhciGetDataBuffer(PMDL Mdl) {
    PPFN_NUMBER PfnArray = Mdl + 1;
    ULONG Length = Mdl->ByteCount;

    // 构造 PRDT 项:每个 PRDT 描述一个物理页
    for (each PFN in PfnArray) {
        PrdtEntry.BaseAddress = Pfn << PAGE_SHIFT;
        PrdtEntry.ByteCount = min(Length, PAGE_SIZE);
    }
}

9.12.8 总结

关键要点

  1. MDL 描述物理页数组:PfnArray 紧跟 MDL 结构
  2. 三步流程 :分配 (IoAllocateMdl) → 锁页 (MmProbeAndLockPages/MmBuildMdlForNonPagedPool) → 映射 (MmGetSystemAddressForMdlSafe)
  3. 三阶段释放 :解除映射 (MmUnmapLockedPages) → 解锁 (MmUnlockPages) → 释放 (IoFreeMdl)
  4. IRP 三种 IO 模式
    • DO_DIRECT_IO:使用 Irp->MdlAddress
    • DO_BUFFERED_IO:使用 Irp->AssociatedIrp.SystemBuffer
    • 默认:驱动自行处理
  5. DMA 路径IoGetDmaAdapter + IoMapTransfer
  6. 缓存属性:Cached、WriteThrough、NonCached、WriteCombined

下一节 9.13 将剖析同步 I/O 与异步 I/O。


9.12.9 设计哲学问答

Q1:为什么 MDL 使用物理页帧号(PFN)数组而不是直接在内核中重新映射虚拟地址?

A设备需要的是物理地址,不是虚拟地址

DMA 控制器和硬件设备不经过 CPU 的 MMU,它们直接通过系统总线访问物理内存。因此设备需要的是物理地址列表,而非虚拟地址映射。MDL 的 PFN 数组提供了设备所要求的物理页编号列表。如果 MDL 只做虚拟地址重新映射(即仅仅把用户 VA 对应的 PTE 改为内核 VA 对应的 PTE),设备仍然无法知道物理地址在哪。

更重要的是,MDL 的 PFN 数组可以描述 不连续的物理页。用户缓冲区在虚拟地址上是连续的(一个 VA 范围),但其对应的物理页可能是分散的(物理页可能被分页到内存的不同位置)。MDL 通过 PFN 数组记录每一页的物理帧号,设备驱动可以从数组中提取每个物理页的地址,构造散点-聚集列表(Scatter-Gather List)或 PRDT。

c 复制代码
// PFN 数组 → 物理地址列表
for (i = 0; i < PageCount; i++) {
    PHYSICAL_ADDRESS PhysAddr;
    PhysAddr.QuadPart = (LONGLONG)PfnArray[i] << PAGE_SHIFT;
    // 设备使用 PhysAddr 进行 DMA
    Prdt[i].BaseAddress = PhysAddr;
    Prdt[i].ByteCount = PAGE_SIZE;
}

Q2:为什么 MmProbeAndLockPages 需要区分 UserMode 和 KernelMode 两个访问模式?

A安全边界决定探测行为

AccessMode 参数(UserMode / KernelMode)决定了探测(Probe)阶段的安全检查策略:

  • UserMode 探测 :使用 ProbeForReadProbeForWrite 对用户态缓冲区进行严格的边界检查。这些函数验证整个 VA 范围完全位于用户地址空间内(<= MM_HIGHEST_USER_ADDRESS),并且调用者确实拥有该范围的访问权限。如果用户提供了内核地址或无效地址,ProbeForRead/ProbeForWrite 会立即抛出异常,防止驱动无意中暴露内核内存数据。

  • KernelMode 探测 :跳过边界检查,因为内核模式驱动可以访问任何地址空间(用户和内核)。MmProbeAndLockPages 在 KernelMode 下直接进入锁页阶段,不再调用 ProbeForRead/ProbeForWrite

这种区分的安全意义在于:如果驱动误传了用户不可访问的地址(如内核栈地址),UserMode 探测会捕获这个错误并返回 STATUS_ACCESS_VIOLATION,而不会锁定无关的物理页。从 ReactOS 的 mdlsup.c 可以看到,AccessMode 在锁页路径中通过 Mdl->MdlFlagsIrp->RequestorMode 向下传播,最终决定是否进行异常处理。

Q3:为什么 MDL 的 PfnArray 紧跟在结构体末尾(使用柔性数组布局)而不是使用独立的指针?

A缓存局部性 + 单次分配

MDL 使用一次分配、连续布局的设计:

c 复制代码
// 单次分配,连续内存
Mdl = ExAllocatePoolWithTag(NonPagedPool,
                            sizeof(MDL) + PageCount * sizeof(PFN_NUMBER),
                            ' ldM');
// PfnArray = (PPFN_NUMBER)(Mdl + 1);  // 紧跟在 MDL 结构之后

这种设计带来两个关键优势:

  1. 缓存局部性 :MDL 结构体和 PFN 数组位于同一段连续内存中,CPU 缓存可以一次加载整个 MDL。如果 PFN 数组使用指针分配在独立内存中,访问 Mdl->PfnArray[i] 需要两次内存访问(先读指针,再读指针指向的内存),破坏缓存局部性。
  2. 单次分配/释放 :分配时一次 ExAllocatePoolWithTag,释放时一次 ExFreePoolWithTag,减少内存管理的开销和碎片。如果 PFN 数组是指针,需要分别分配和释放 MDL 头和 PFN 数组,增加了内存泄漏的风险。

这种"结构体尾随数组"(struct tail array)的布局在 NT 内核中非常常见,类似的还有 FSCTL_QUERY_ALLOCATED_RANGES 的返回结构、ACE(Access Control Entry)的 SID 附加等。

Q4:为什么 IoAllocateMdl 使用 ADDRESS_AND_SIZE_TO_SPAN_PAGES 计算页数而不是直接用 Length >> PAGE_SHIFT

A缓冲区起始地址可能不在页边界上

ADDRESS_AND_SIZE_TO_SPAN_PAGES(Va, Length) 宏计算从 Va 开始、长度为 Length 的缓冲区跨越了多少个物理页。它与简单的 Length / PAGE_SIZE 的区别在于:

c 复制代码
// 场景:Va = 0x1234, Length = 0x1000 (4KB)
// 简单除法:0x1000 / 0x1000 = 1 页     ← 错误!
// 实际跨越:页0 [0x1000-0x1FFF] 和页1 [0x2000-0x2FFF] = 2 页

// ADDRESS_AND_SIZE_TO_SPAN_PAGES 的正确计算公式:
#define ADDRESS_AND_SIZE_TO_SPAN_PAGES(Va, Size) \
    ((ULONG)(((ULONG_PTR)((Va) & (PAGE_SIZE - 1)) + (Size) + (PAGE_SIZE - 1)) >> PAGE_SHIFT))

该宏将起始地址的页内偏移(Va & (PAGE_SIZE - 1))考虑在内,计算实际占用的物理页数。如果使用 Length >> PAGE_SHIFT,当缓冲区跨越页边界时会分配太少的 PFN 空间,导致 MmBuildMdlForNonPagedPoolMmProbeAndLockPages 写入 PFN 数组时越界,触发 Bugcheck DRIVER_CORRUPTED_EXPOOL

Q5:为什么 MmBuildMdlForNonPagedPool 直接标记 MDL_PAGES_LOCKED 而不实际锁页?

A非分页池的内存永远驻留在物理内存中,锁页操作是冗余的

非分页池(NonPagedPool)是在系统初始化时分配的专用内存池,它的物理页永远不会被换出到磁盘。页交换器(Page Swapper)在遍历工作集时明确跳过了非分页池的页面。因此,对这些页面调用 MiUnpinPfn 增加 PFN 引用计数是无意义的------这些页面本就不可能被释放。

MmBuildMdlForNonPagedPool 做的事情很简单:

  1. 设置 MDL_PAGES_LOCKED 标志(使得后续 MmUnlockPages 看到该标志后跳过解锁操作);
  2. 设置 MDL_SOURCE_IS_NONPAGED_POOL 标志(使得后续 MmUnmapLockedPages 知道不需要解除 PTE 映射);
  3. 逐页调用 MmGetPhysicalAddress 获取物理帧号填充 PFN 数组。

这种设计避免了不必要的原子操作(PFN 引用计数增减需要获取 PFN 锁),提高了性能。从 ReactOS 的 mdlsup.c 可以看到,MmUnlockPages 对非分页池 MDL 直接返回,不做任何操作。

Q6:为什么存在 MmGetSystemAddressForMdlSafeMmGetSystemAddressForMdlSafeEx 两个版本?

A默认缓存属性 vs 自定义缓存属性

MmGetSystemAddressForMdlSafe 是通用版本,适用于大部分驱动场景。它默认使用 MmCached(写回缓存)的缓存属性,这对绝大多数数据传输是合适的------CPU 写入后可以被缓存,提高后续读性能。

MmGetSystemAddressForMdlSafeEx 是扩展版本,允许调用者指定自定义缓存属性:

缓存类型 适用场景
MmCached 通用数据缓冲区(默认)
MmWriteThrough 需要保证 CPU 写入立即可见(如某些网卡)
MmNonCached 设备共享内存(如显卡帧缓冲区、DMA 缓冲区)
MmWriteCombined 显卡帧缓冲区(允许写入合并,提高性能)

分离两个函数的原因是:安全性 。默认版本使用 MmCached 对大多数驱动是正确的选择,但某些设备(特别是显卡和存储控制器)需要特定的缓存属性才能正确工作。Ex 版本让驱动显式指定缓存属性,但驱动开发者必须确认硬件对缓存属性的要求。如果不清楚硬件的具体需求,使用默认版本更为安全。

Q7:为什么需要 IoBuildPartialMdl 而不是让下层驱动直接使用完整的 MDL?

A缓冲区分割与驱动隔离

在一个典型的设备栈中,上层的 MDL 可能描述了一个很大的缓冲区(如 64KB),但下层驱动(如端口驱动)可能只关心其中的一部分。IoBuildPartialMdl 允许上层驱动从完整的 MDL 中创建子 MDL,指向原始缓冲区的子范围:

c 复制代码
// 上层:完整的 64KB 缓冲区
PMDL FullMdl = Irp->MdlAddress;

// 下层:只处理 4KB 的子范围
PMDL PartialMdl = IoAllocateMdl(NULL, 4096, FALSE, FALSE, NULL);
IoBuildPartialMdl(FullMdl, PartialMdl, Offset, 4096);

这种分割的合理性在于:

  1. 驱动隔离:下层驱动不应该知道、也不应该访问上层驱动的完整缓冲区。Partial MDL 限制了可见范围,防止下层驱动意外读取写入上层缓冲区外的内存。
  2. 物理地址连续性假设:某些旧硬件要求 DMA 缓冲区在物理地址上是连续的。Partial MDL 允许驱动从虚拟地址连续的完整缓冲区中选择一个物理地址可能连续的子范围。
  3. 引用计数正确性 :Partial MDL 共享父 MDL 的物理页引用,MmUnlockPages 不会重复解锁,避免了 PFN 引用计数错误。

Q8:为什么 DO_DIRECT_IO 设备由 I/O 管理器自动构造 MDL,而 NEITHER 模式需要驱动自己构造?

A便利性与灵活性的权衡

DO_DIRECT_IO 模式下,I/O 管理器在 NtReadFile/NtWriteFile 路径(ntoskrnl/io/iomgr/io.c(file:///d:/reactos/ntoskrnl/io/iomgr/io.c))中自动为应用层缓冲区分配 MDL、锁定页面,并将 MDL 挂载到 Irp->MdlAddress。驱动开发者只需读取 Irp->MdlAddress 即可使用:

c 复制代码
// DIRECT_IO:三行代码完成 MDL 操作
PMDL Mdl = Irp->MdlAddress;
PVOID Buf = MmGetSystemAddressForMdlSafe(Mdl, NormalPagePriority);
// 使用 Buf 进行 DMA 或 PIO

NEITHER 模式将 MDL 的构造责任完全交给驱动,驱动必须自行调用 IoAllocateMdlMmProbeAndLockPagesMmGetSystemAddressForMdlSafe,并确保在 IRP 完成后正确释放。这种模式适用于需要精细控制 MDL 行为的驱动------例如,某些存储驱动需要在 MDL 锁页时指定特定的访问权限或缓存属性。

选择 DIRE CT_IO 还是 NEITHER 本质上是一个"谁来做"的决策:DIRE CT_IO 将通用情况交给 I/O 管理器,简化驱动开发;NEITHER 将控制权交给驱动,适用于需要特殊处理的设备。

Q9:为什么 MDL 需要区分 MDL_IO_SPACE 和普通物理页?

A物理内存 vs. I/O 端口空间的本质区别

MDL_IO_SPACE 标志表示 MDL 的 PFN 数组中的"物理页"实际上不是真正的 RAM 物理页,而是 I/O 空间地址(如 PCIe MMIO 区域、设备内存映射)。这两者有本质区别:

维度 普通物理页 MDL_IO_SPACE
分配来源 物理内存(RAM) PCI/PCIe BAR 映射
可缓存性 可缓存 通常不可缓存
PFN 数据库 有对应的 MMPFN 条目 无对应的 MMPFN
锁页 增加 PFN 引用计数 不需要锁页
访问方式 CPU 正常读写 可能需要特殊指令

MmProbeAndLockPages 遇到 MDL_IO_SPACE 标志时,它不会调用 MiGetPfnEntry 或增加 PFN 引用计数,因为这些地址对应的 PFN 不在 PFN 数据库中。典型的场景是设备驱动将设备 MMIO 区域映射到系统地址空间中,然后通过 MDL 传递给其他驱动。

这种区分确保了 MDL 的统一接口可以同时处理真正的物理内存和 I/O 空间地址,而 PFN 数据库不需要为 MMIO 区域创建占位条目。

Q10:为什么 ReactOS 存在 ARM3 和旧路径两套 MDL 实现?

A开发演化与渐进式替换

ReactOS 的 MDL 实现分为两层,反映了项目的开发历史:

  • 旧路径ntoskrnl/mm/mdl.c):是 ReactOS 早期开发的 MDL 实现,当时 ReactOS 还没有完整的 ARM3 内存管理器。这套实现代码简单、功能基础,能够满足基本的 MDL 操作需求,但在多核性能、缓存一致性、PFN 数据库交互等方面不够完善。

  • ARM3 路径ntoskrnl/mm/ARM3/mdlsup.c):是 ReactOS 从 Windows Research Kernel(WRK)借鉴并适配的 ARM3(Advanced RISC Machine 3 代内存管理器)的一部分。它实现了完整的 MDL 操作,包括 PFN 数据库的引用计数管理、写时复制处理、系统空间映射的 VAD 管理等高级功能。

双层架构的合理性在于:

  1. 逐步迁移:ReactOS 的开发不是从零全部重写,而是逐步替换。旧路径代码在早期可以工作,ARM3 实现成熟后逐步接管核心功能。
  2. 兼容性保障:某些内部调用路径可能依赖于旧路径的实现细节,保留旧路径代码可以防止回归。
  3. 调试比较:两套实现可以用于交叉验证。当 ARM3 路径出现 bug 时,对比旧路径行为有助于定位问题根源。

随着 ReactOS 的发展,大部分 MDL 操作已经迁移到 ARM3 路径,旧路径代码正逐步被淘汰。


本章代码索引

文件 内容
mdlsup.c(file:///d:/reactos/ntoskrnl/mm/ARM3/mdlsup.c) MDL 分配、锁定、映射主实现
mdl.c(file:///d:/reactos/ntoskrnl/mm/mdl.c) MDL 旧实现(兼容路径)
irp.c(file:///d:/reactos/ntoskrnl/io/iomgr/irp.c) IoAllocateMdlIoFreeMdlIoBuildPartialMdl
io.c(file:///d:/reactos/ntoskrnl/io/iomgr/io.c) IoReadFileIoWriteFile
iotypes.h(file:///d:/reactos/sdk/include/xdk/iotypes.h) MDL 结构定义
miarm.h(file:///d:/reactos/ntoskrnl/mm/ARM3/miarm.h) 内存管理内部宏
buffer.c(file:///d:/reactos/drivers/network/ndis/ndis/buffer.c) NDIS 缓冲区(MDL)
相关推荐
daly5201 小时前
Notepad++怎么下载?2026最新版Notepad++安装教程(Windows免费文本编辑器)
windows·notepad++·notepad
冰帆<2 小时前
[特殊字符] 深度起底:突破火山引擎 Ark-Helper 的 Linux 底层环境死锁,顺手魔改一份 Windows 一键安装脚本!
linux·windows·火山引擎
良枫2 小时前
自进化 agent:核心模块一任务规划器 Planner
java·服务器·windows
可乐要加冰^-^3 小时前
云雀文档下载
windows·git·github·石墨文档
caimouse3 小时前
Reactos 第 9 章 设备驱动 — 9.9 磁盘的设备驱动堆叠
windows·嵌入式硬件
阿维的博客日记3 小时前
Windows自由切换jdk版本
java·windows
山峰哥5 小时前
VBA数据结构之争:Dictionary vs Collection,性能差3倍!
服务器·数据结构·数据库·windows·sql·算法·哈希算法
caimouse14 小时前
Reactos 第 8 章 结构化异常处理 — 8.2 系统空间的结构化异常处理
windows
caimouse14 小时前
Reactos 第 7 章 视窗报文 — 7.3 Win32k 的用户空间回调机制
windows