第 9 章 设备驱动 --- 9.9 磁盘的设备驱动堆叠
本节剖析 Windows 磁盘 I/O 的设备驱动堆叠(device stack)架构。 磁盘 I/O 是 Windows 中最复杂的驱动堆叠之一,自下而上涉及 端口驱动 → 类驱动 → 卷管理 → 文件系统 多层。ReactOS 的 drivers/storage/(file:///d:/reactos/drivers/storage/) 目录实现了这些层次。理解磁盘堆叠的关键是把握 PDO/FDO 嵌套关系、IRP_MJ_SCSI 内部 IRP、SRB(SCSI Request Block)转发链。
概述
磁盘 I/O 的设备栈远比一般字符设备复杂,它需要协调 硬件(磁盘控制器、磁盘)→ 端口驱动(port driver)→ 类驱动(class driver)→ 卷管理 → 文件系统 五层。
磁盘设备栈分层
+------------------------+ 顶层(应用)
| 文件系统 (fastfat/ntfs) | 接收 NtReadFile/NtWriteFile
+------------------------+
| 卷管理 (volmgr) | 装配 Volume,IOCTL_VOLUME_*
+------------------------+
| 分区管理 (partmgr) | IOCTL_DISK_*, Partition0/1/2...
+------------------------+
| 类驱动 (classpnp/disk) | 接收 IRP_MJ_READ/WRITE/IOCTL
+------------------------+
| 端口驱动 (scsiport/ | IRP_MJ_SCSI (内部 IRP)
| storport/atapi) |
+------------------------+
| HAL (PCI 中断/DMA) |
+------------------------+
| 硬件控制器(HBA) |
+------------------------+
每层只处理它关心的 IRP 类别,不关心的就直接下传(IoCallDriver)。
本节内容概览
- 9.9.0 框架图
- 9.9.1 总线驱动与 PDO 创建
- 9.9.2 端口驱动(scsiport/storport)
- 9.9.3 类驱动(classpnp.sys / disk.sys)
- 9.9.4 分区管理(partmgr.sys)
- 9.9.5 卷管理(volmgr.sys / mountmgr)
- 9.9.6 文件系统驱动
- 9.9.7 IRP_MJ_SCSI 内部 IRP 路径
- 9.9.8 总结与代码索引
学习目标
- 理解磁盘 I/O 的五层设备栈
- 掌握 IRP_MJ_SCSI 与 SRB 的关系
- 知道 classpnp 与 disk 的协作
- 区分类驱动与端口驱动
涉及的内核子系统
| 子系统 | 头文件/源文件 | 核心作用 |
|---|---|---|
| 类驱动 classpnp | drivers/storage/class/classpnp/(file:///d:/reactos/drivers/storage/class/classpnp/) | 共用类驱动框架 |
| 类驱动 disk | drivers/storage/class/disk/disk.c(file:///d:/reactos/drivers/storage/class/disk/disk.c) | 磁盘类驱动 |
| 分区管理 | drivers/storage/partmgr/partmgr.c(file:///d:/reactos/drivers/storage/partmgr/partmgr.c) | PartMgrAddDevice、分区枚举 |
| 端口驱动 scsiport | drivers/storage/port/scsiport/scsiport.c(file:///d:/reactos/drivers/storage/port/scsiport/scsiport.c) | SCSI 端口驱动 |
| 端口驱动 storport | drivers/storage/port/storport/storport.c(file:///d:/reactos/drivers/storage/port/storport/storport.c) | 新式存储端口驱动 |
| 端口驱动 atapi | drivers/storage/ide/atapi/atapi.c(file:///d:/reactos/drivers/storage/ide/atapi/atapi.c) | IDE/ATAPI 端口驱动 |
| 卷管理 | drivers/storage/mountmgr/mountmgr.c(file:///d:/reactos/drivers/storage/mountmgr/mountmgr.c) | 卷挂载管理 |
磁盘设备栈的设计哲学
为什么需要如此复杂的五层架构?这是一种典型的**分层抽象(layered abstraction)**设计模式。每一层都隐藏了下层的复杂性,向上层提供更高层次的接口:
- 硬件层面对的是寄存器、中断、DMA 通道等物理细节
- 端口驱动将这些细节抽象为统一的 SCSI 命令接口
- 类驱动将 SCSI 命令进一步抽象为读写/IOCTL 接口
- 卷管理在逻辑分区层面管理空间分配
- 文件系统则提供文件名、目录结构等最高层抽象
这种分层设计的核心价值在于解耦------任意一层都可以独立替换而不影响其他层。例如,更换磁盘控制器(从 IDE 到 AHCI)只需要更换最底层的端口驱动,上层的类驱动和文件系统不需要任何修改。
为什么 1:为什么磁盘设备栈需要五层架构而不是一层搞定?
如果使用单层驱动直接管理硬件,虽然看似简单,但在实际工程中会带来灾难性的后果:
- 代码爆炸:每种硬件控制器都需要独立的驱动实现完整的 I/O 栈------从 DMA 编程到文件系统解析。以 Windows 为例,如果将所有功能塞入一层,需要为每个 SCSI 控制器、IDE 控制器、AHCI 控制器、NVMe 控制器分别实现文件系统支持,代码量将膨胀数十倍。
- 可移植性灾难:文件系统逻辑与硬件控制逻辑混合在一起后,更换硬盘控制器就意味着要重写文件系统代码。反之,分层后任意一层的变化都被隔离在该层内部,不影响相邻层。
- 调试复杂度:五层架构中每层只需关注自己的职责,调试时可以逐层隔离问题。端口驱动出问题只影响 SRB 提交,类驱动出问题只影响 IRP 转换,而不会出现需要同时理解 DMA 页表和文件分配表才能调试的噩梦。
因此,五层架构并不是过度设计,而是操作系统工程中经过数十年演化形成的最优解。
9.9.0 框架图
用户态 NtReadFile/WriteFile
|
v
+-------------------+
| I/O Manager | (ntoskrnl/io/iomgr/)
+-------------------+
|
v
+-------------------+
| 顶层设备 | 命名空间:\Device\HarddiskVolumeX
| 卷设备 (FS) | VPB->DeviceObject
+-------------------+
|
v
+-------------------+
| 卷/分区设备 | \Device\HarddiskN\PartitionM
| (volmgr/partmgr) | IOCTL_DISK_*
+-------------------+
|
v
+-------------------+
| 类驱动 disk.sys | PDO 来自 partmgr
| FDO | IRP_MJ_READ/WRITE
+-------------------+
|
v
+-------------------+
| 端口驱动 | scsiport.sys / storport.sys
| scsiport/ | IRP_MJ_SCSI
| atapi/pciidex |
+-------------------+
|
v
+-------------------+
| PCI/ACPI 驱动 | 中断处理、DMA 控制器
+-------------------+
|
v
+-------------------+
| 硬件 | HBA(Host Bus Adapter)
+-------------------+
9.9.1 总线驱动与 PDO 创建
最底层是 PCI/ACPI 总线驱动 ,它枚举 PCI 设备并创建 PDO。例如,IDE 控制器在 PCI 总线上的设备 ID 被 PCI 驱动识别,调用 IoCreateDevice 创建 PDO ,附带设备扩展(FDO_EXTENSION)记录 BAR、IRQ 等。
磁盘设备的 PnP 路径:
PCI 总线 (pci.sys) -> 创建 PCI IDE/SATA/AHCI 控制器的 PDO
|
v
atapi.sys / pciidex.sys / storahci.sys -> 创建 子 PDO(表示连接的磁盘)
|
v
classpnp.sys / disk.sys -> 在 AddDevice 中创建 FDO 并附加到 PDO
|
v
partmgr.sys -> 在 AddDevice 中创建 自身 FDO 附加到 disk
每一层都把上层(FDO)的 IRP 转发到下层(PDO),并在返回时执行完成例程。
关键代码(atapi.c)
c
// atapi 端口驱动注册类驱动回调
Status = AtapiRegisterController(DriverObject, ...);
为什么 8:为什么 PnP IRP 需要先下后上的传播方式?
PnP IRP 如 IRP_MN_START_DEVICE 采用先下后上 的传播路径,这是因为硬件资源的初始化和释放必须从底层开始。以磁盘设备为例,IRP_MN_START_DEVICE 到达设备栈时:
- 顶层(partmgr)先将 IRP 转发给下层(disk)
- disk 再转发给 scsiport
- scsiport 再转发给 atapi(PDO)
- atapi 在 PDO 层真正与硬件交互:分配 I/O 端口范围、设置中断向量、启用 DMA 通道
- 完成这些硬件级操作后,IRP 沿完成链向上返回,每层此时才使用已准备好的硬件资源
如果顺序颠倒------顶层先初始化再逐层向下------会导致上层在硬件尚未就绪时就尝试访问设备,造成系统崩溃。这种"先下后上"的策略是 PnP 系统的核心设计原则,在 ReactOS 的 PnP 管理器(ntoskrnl/pnp/pnpdrv.c)中得到了严格实施。
9.9.2 端口驱动(scsiport / storport)
端口驱动(port driver)位于 类驱动与硬件之间,负责:
- SRB 的构造和提交 :把上层发来的
IRP_MJ_SCSI转换为SRB(SCSI Request Block) - 中断处理 :在
HwInterrupt中处理硬件中断 - DMA 编程 :使用
AllocateCommonBuffer分配 DMA 一致性内存 - 超时管理 :
ScsiPortIoTimer监控 SRB 超时
ScsiPort 入口点
c
NTSTATUS NTAPI
ScsiPortInitialize(IN PVOID Argument1, // DriverObject
IN PVOID Argument2, // RegistryPath
IN struct _HW_INITIALIZATION_DATA *HwInitData,
IN PVOID HwContext);
Miniport 驱动调用 ScsiPortInitialize 注册一组回调(HwFindAdapter、HwInitialize、HwStartIo、HwInterrupt)。
Storport(替代方案)
Windows XP 之后引入 storport,提供 更优的队列管理 (并行 SCSI 命令)和 Message-Signaled Interrupt (MSI) 支持。ReactOS 的 storport/(file:///d:/reactos/drivers/storage/port/storport/) 是框架实现,Miniport 驱动 storahci(AHCI)使用它。
IRP_MJ_SCSI 路径
c
// 类驱动 disk 创建 IRP_MJ_SCSI
IoBuildDeviceIoControlRequest(IOCTL_SCSI_EXECUTE_IN, TargetDevice, ...);
IRP 的 Parameters.Scsi.Srb 字段指向 SRB:
c
typedef struct _SCSI_REQUEST_BLOCK {
USHORT Length;
UCHAR Function; // SRB_FUNCTION_EXECUTE_SCSI 等
UCHAR SrbStatus;
UCHAR ScsiStatus;
UCHAR PathId;
UCHAR TargetId;
UCHAR Lun;
UCHAR QueueTag;
UCHAR QueueAction; // 队列深度
UCHAR Reserved;
ULONG DataTransferLength;
PVOID DataBuffer; // 数据缓冲(系统内存或 MDL)
ULONG SenseInfoBufferLength;
PVOID SenseInfoBuffer;
struct _SCSI_REQUEST_BLOCK *NextSrb;
PVOID OriginalRequest;
PVOID SrbExtension; // Miniport 私有
// ... 端口驱动扩展
} SCSI_REQUEST_BLOCK, *PSCSI_REQUEST_BLOCK;
为什么 3:为什么端口驱动使用 SRB 而不是直接使用 IRP?
SRB 并不替代 IRP,而是作为 IRP 的有效载荷存在。使用 SRB 而非直接在 IRP 中编码 SCSI 参数的原因有三:
- SCSI 命令的复杂性 :一条 SCSI 命令包含操作码(OP Code)、逻辑块地址(LBA)、传输长度、控制字节等大量参数。将这些参数嵌入 IRP 的
Parameters联合体不够灵活,而 SRB 可以精确描述 SCSI 协议的所有细节,包括 CDB(命令描述块)、Sense 缓冲区和数据缓冲区。 - 队列管理需求 :现代存储设备支持多个未完成的 I/O 请求(NCQ、TCQ)。SRB 通过
NextSrb指针形成链表,端口驱动可以管理多个待处理的 SCSI 命令。每个 IRP 只能携带一个 SRB,但端口驱动可以在内部维护一个 SRB 链表,实现 I/O 合并和排队。 - Miniport 接口标准化 :SRB 是端口驱动与 Miniport 驱动之间的标准化接口。无论底层是 ATA、SATA 还是 SCSI 控制器,Miniport 驱动都通过处理 SRB 来完成 I/O。这种抽象使得微软能够用
storport.sys替换scsiport.sys,而所有现有 Miniport 驱动只需重新编译即可迁移。在 ReactOS 中,SpiBuildSrb函数(scsiport.c(file:///d:/reactos/drivers/storage/port/scsiport/scsiport.c))负责从 IRP 中提取参数并填充 SRB。
9.9.3 类驱动(classpnp.sys / disk.sys)
类驱动是 类特定 的驱动,它把通用的 IRP_MJ_READ/WRITE 翻译为 IRP_MJ_SCSI,并处理 缓存、几何信息、SMART、IOCTL_DISK_* 等。
disk.sys 入口
c
NTSTATUS NTAPI
DriverEntry(IN PDRIVER_OBJECT DriverObject, IN PUNICODE_STRING RegistryPath)
{
CLASS_INIT_DATA InitializationData = { 0 };
InitializationData.InitializationDataSize = sizeof(CLASS_INIT_DATA);
InitializationData.FdoData.DeviceExtensionSize = FUNCTIONAL_EXTENSION_SIZE;
// ... 填写回调
return ClassInitialize(DriverObject, RegistryPath, &InitializationData);
}
ClassInitialize 由 classpnp.sys 导出。它注册了 AddDevice 回调(ClassAddDevice),PnP 触发时创建 FDO。
classpnp 通用 AddDevice
ClassAddDevice(classpnp/class.c(file:///d:/reactos/drivers/storage/class/classpnp/class.c)):
- 接收总线驱动传来的 PDO(来自 scsiport)
- 创建 FDO,
FdoData.DeviceExtensionSize由类驱动指定 - 调用
IoAttachDeviceToDeviceStack把 FDO 附加到 PDO - 设置
Flags |= DO_POWER_PAGABLE - 清
DO_DEVICE_INITIALIZING标志
IRP_MJ_READ 处理
c
// ClassReadWrite 内核函数
NTSTATUS ClassReadWrite(IN PDEVICE_OBJECT Fdo, IN PIRP Irp) {
PIO_STACK_LOCATION IoStack = IoGetCurrentIrpStackLocation(Irp);
PDEVICE_EXTENSION Ext = (PDEVICE_EXTENSION)Fdo->DeviceExtension;
// 1. 检查分区表
// 2. 构造 SRB (SRB_FUNCTION_READ/WRITE)
// 3. IoCallDriver(LowerDevice, Irp) // 转发到下层
}
为什么 2:为什么类驱动要将 IRP_MJ_READ/WRITE 转换为 IRP_MJ_SCSI?
类驱动处于"文件系统 ↔ 端口驱动"的中间层,承担着协议转换的关键角色:
- 上层视角 :文件系统驱动只关心逻辑块读写,它发送
IRP_MJ_READ/WRITE,参数是起始扇区号和传输长度。 - 下层视角 :端口驱动只理解 SCSI 命令,它需要
SCSIOP_READ/SCSIOP_WRITE命令描述块(CDB),包含操作码、LBA、传输长度等 SCSI 协议参数。
类驱动的 ClassReadWrite 函数完成这一转换:它从 IRP 栈的 Parameters.Read.ByteOffset 提取 LBA,构造 SCSI CDB,填充 SRB,然后将原始 IRP 重新打包为 IRP_MJ_SCSI(通过 IRP_MJ_INTERNAL_DEVICE_CONTROL + IOCTL_SCSI_EXECUTE_IN)发送到下层。
这种转换的核心价值在于接口隔离------文件系统不需要了解磁盘接口协议(SCSI/ATA/NVMe),端口驱动也不需要理解文件系统的缓存策略或扇区大小。当磁盘从 512 字节扇区切换到 4K 扇区时,类驱动负责在逻辑 LBA 和物理 LBA 之间做映射,上下层都不受影响。
9.9.4 分区管理(partmgr.sys)
partmgr.sys 位于 disk 之上,文件系统之下,主要职责:
- 磁盘签名读取 :通过
IOCTL_DISK_GET_DRIVE_LAYOUT_EX获得 MBR/GPT 分区表 - 创建分区 PDO :
\Device\Harddisk0\Partition0(整个磁盘)和Partition1..N - 卷/分区 IOCTL 分发 :把
IOCTL_VOLUME_*和IOCTL_MOUNTMGR_*转发到合适层
PartMgrAddDevice
c
// partmgr.c
static
NTSTATUS
PartMgrAddDevice(
_In_ PDRIVER_OBJECT DriverObject,
_In_ PDEVICE_OBJECT Pdo)
{
// 1. 创建设备对象 \Device\HarddiskN
// 2. 附加到 disk.sys 的 FDO
// 3. 调用 IoInvalidateDeviceRelations 通知 PnP
// 4. PartMgrCreatePdo 列出所有分区
}
关键 IOCTL
| IOCTL | 处理者 | 作用 |
|---|---|---|
IOCTL_DISK_GET_DRIVE_LAYOUT_EX |
disk | 读取分区表 |
IOCTL_DISK_GET_PARTITION_INFO_EX |
partmgr/disk | 单分区信息 |
IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS |
partmgr | 卷的磁盘盘区 |
IOCTL_MOUNTMGR_QUERY_DEFAULT |
mountmgr | 查询默认卷 |
为什么 4:为什么分区管理需要为每个分区创建独立的 PDO?
partmgr 的核心职责是将物理磁盘的扇区空间划分为逻辑分区,并为每个分区创建独立的 PDO。这背后的设计考量包括:
- 独立挂载不同文件系统:同一个磁盘的不同分区可以挂载不同的文件系统。例如,Partition1 是 FAT32(C: 盘),Partition2 是 NTFS(D: 盘)。如果没有独立的分区 PDO,文件系统驱动无法区分同一物理磁盘上的不同分区,整个磁盘只能使用一个文件系统。
- 独立的 PnP 管理粒度 :每个分区 PDO 可以独立响应 PnP 事件。动态磁盘的分区扩展、收缩、删除都需要在 PDO 级别进行管理。当一个分区被删除时,只有该分区的 PDO 收到
IRP_MN_REMOVE_DEVICE,其他分区不受影响。 - 独立的电源与安全策略:某些系统支持对单个分区设置独立的电源策略(如 Spin-down 超时)和访问权限(如 BitLocker 加密每个分区独立)。
在 ReactOS 的 PartMgrCreatePdo 实现中,每个分区 PDO 的设备名称为 \Device\HarddiskN\PartitionM,其设备扩展中记录了分区的起始偏移和大小。这些信息通过 IOCTL_DISK_GET_DRIVE_LAYOUT_EX 从 disk 类驱动获取,该 IOCTL 由 disk 驱动负责读取 MBR 或 GPT 分区表并返回给 partmgr。
9.9.5 卷管理(volmgr.sys / mountmgr)
ReactOS 的 partmgr 兼任 volmgr 角色(volmgr 尚未独立)。它的功能:
- 卷设备对象 :当
IOCTL_DISK_GET_DRIVE_LAYOUT_EX显示有有效分区时,partmgr 为每个分区创建 PDO - 卷符号链接 :在 mountmgr 中创建
\DosDevices\C:→\Device\HarddiskVolume1 - 挂载协调 :mountmgr 接收
IOCTL_MOUNTMGR_CHECK_DLNP决定哪个盘符对应哪个卷
MountMgr 关键函数
c
// mountmgr.c
NTSTATUS MountMgrCheckDes MountMgrCheckDLNP(
IN PIO_STACK_LOCATION IrpSp, OUT PVOID OutData);
挂载管理器协议(Mount Manager Protocol)见 MSDN:
IOCTL_MOUNTMGR_CREATE_POINTIOCTL_MOUNTMGR_DELETE_POINTSIOCTL_MOUNTMGR_QUERY_POINTSIOCTL_MOUNTMGR_SET_AUTO_MOUNT
设备名空间
\Device\Harddisk0 <- 物理磁盘 0
\Device\Harddisk0\Partition0 <- 整个磁盘(无文件系统)
\Device\Harddisk0\Partition1 <- 第一分区
\Device\HarddiskVolume1 <- 卷管理器创建的卷对象
\DosDevices\C: <- DOS 盘符
\??\C: <- 内部 NT 视图
为什么 7:为什么 ReactOS 将 volmgr 合并到 partmgr 中?
这是 ReactOS 存储栈与 Windows 的一个重要差异。在 Windows 中,volmgr.sys 负责创建卷设备对象(\Device\HarddiskVolumeX)并管理简单卷、跨区卷等逻辑卷。ReactOS 选择将 volmgr 的功能合并到 partmgr 中,原因如下:
- 开发优先级:ReactOS 的目标是与 Windows Server 2003 兼容,而 Server 2003 的卷管理功能相对简单。动态磁盘、镜像卷、RAID-5 卷等高级功能在 ReactOS 的早期阶段不是必须的。将 volmgr 合并到 partmgr 减少了需要独立开发和测试的驱动数量。
- 消除层间通信开销 :合并后 partmgr 直接创建卷 PDO,磁盘 I/O 路径减少了一层设备栈。在 Windows 中,IRP 需要穿越 partmgr → volmgr 两个模块,每多一层就多一次
IoCallDriver和完成例程的开销。ReactOS 的合并将 IRP 路径缩短了至少一层。 - 代码复用:partmgr 已具备读取分区表的能力,合并 volmgr 后可以在同一个设备扩展中管理分区信息和卷信息,无需跨模块通信。
然而,这种合并也有代价:ReactOS 目前不支持 Windows 的动态磁盘管理功能(如跨区卷、镜像卷、RAID-5 卷),因为 volmgr 的核心------卷的在线扩展和容错------需要独立的卷管理器实现。从 partmgr.c 源码注释可以看到:"Here it acts like both partition and volume manager, because volmgr.sys does not (yet) exist in ReactOS。"
9.9.6 文件系统驱动
文件系统驱动(fastfat、ntfs、cdfs)位于设备栈的 最顶层 。它通过 IoCreateDevice 创建 卷设备对象(VOD):
c
// fastfat 的 VCB 创建
PDEVICE_OBJECT VolumeDevice;
IoCreateDevice(DriverObject, sizeof(VCB), &VolumeName,
FILE_DEVICE_DISK, ...,
FALSE, &VolumeDevice);
Vpb->DeviceObject = VolumeDevice 把 FS 卷设备与卷管理器创建的卷设备链接起来。
挂载流程
IopMountVolume (ntoskrnl/io/iomgr/volume.c)
|
v
IoCallDriver(Vpb->DeviceObject, Irp) // IRP_MJ_FILE_SYSTEM_CONTROL with MOUNT
|
v
FS Driver: FatMountVolume / NtfsMountVolume
|
v
填充 VCB, 创建 CCB
|
v
返回 STATUS_SUCCESS
为什么 5:为什么文件系统挂载需要 VPB 这个中间结构?
VPB(Volume Parameter Block)是 DEVICE_OBJECT 结构体的内嵌字段,它在文件系统驱动与卷管理器之间扮演着桥梁角色。VPB 存在的根本原因在于:
- 文件系统的可替换性 :同一个卷设备可以被不同的文件系统挂载。VPB 作为间接层,使得卷设备对象可以动态关联到任意文件系统卷设备。例如,一个 U 盘插入时先被 fastfat 尝试挂载,如果 fastfat 返回
STATUS_UNRECOGNIZED_VOLUME,系统可以继续尝试 ntfs 或 cdfs------每次尝试都通过 VPB 重新建立关联,不影响卷设备对象本身。 - I/O 路径的快速路由 :当应用程序打开
C:\file.txt时,I/O 管理器通过\DosDevices\C:定位到卷设备对象,然后通过Vpb->DeviceObject立即找到文件系统设备对象,无需遍历挂载点数据库。这个指针直接指向文件系统驱动的卷设备,I/O 请求可以一步直达。 - 卸载与重载的支持 :VPB 的状态字段(
VpbFlags)记录了卷的当前状态------是否已挂载(VPB_MOUNTED)、是否已锁定(VPB_LOCKED)、是否为原始卷(VPB_RAW_MOUNT)。当卷被卸载时,Vpb->DeviceObject被置空,文件系统驱动安全地清理资源。下次访问时,I/O 管理器检测到 VPB 未挂载,自动触发重新挂载。
在 ReactOS 的 IopMountVolume 实现中,挂载流程遍历已注册的文件系统驱动列表,依次尝试挂载。第一个成功的文件系统驱动通过 IoMountVolume 将自身的卷设备对象注册到 VPB 中。如果所有文件系统都失败,I/O 管理器会提示"未格式化的磁盘"并挂载 RAW 文件系统。
9.9.7 IRP_MJ_SCSI 内部 IRP 路径
类驱动通过 IOCTL_SCSI_EXECUTE_IN 构造 内部 IRP(Irp->Flags |= IRP_DEFER_IO_COMPLETION),并把它传递给端口驱动。
完整路径
用户 NtReadFile -> IoCallDriver(vol, Irp)
|
v
file system (fastfat)
| 缓存未命中
v
IoCallDriver(disk, Irp)
|
v
disk.sys (classpnp)
| 构造 IRP_MJ_SCSI
v
IoCallDriver(scsiport, Irp)
|
v
scsiport.sys
| SpiBuildSrb -> 构造 SRB
v
IoCallDriver(atapi, Irp) [IRP_MJ_SCSI]
|
v
atapi.sys
| 编程 ATA 控制器
v
HalStartInterruptTransfer
|
v
中断到来 -> ISR -> DPC -> 完成 IRP
|
v
沿完成例程链向上返回
每一层都注册了 IoSetCompletionRoutine,形成 完成例程链(completion routine chain)。
为什么 6:为什么完成例程要从底层向上层传播?
完成例程链的传播方向(从底层到上层)由 I/O 操作的固有顺序决定:
- 数据流向决定顺序:底层驱动最先完成与硬件的交互,此时数据已从磁盘读取到缓冲区,或已从缓冲区写入磁盘。完成例程从底层开始执行,每个上层完成例程处理的数据都是下层已经处理完毕的------这是典型的"后进先出"(LIFO)模式。如果从上到下执行,上层完成例程会在数据尚未就绪时访问缓冲区,导致数据不一致。
- 资源释放的顺序依赖:DMA 缓冲区的解除映射、中断资源的释放必须从底层开始。例如,底层先释放 DMA 通道和取消 MDL 映射,然后上层才能安全地释放与 DMA 相关的高层数据结构。顺序颠倒在多核系统上会导致竞争条件。
- 错误恢复机制 :如果某一层返回错误状态(如
STATUS_UNSUCCESSFUL),上层的完成例程根据SL_INVOKE_ON_ERROR标志被调用,可以执行重试或降级处理。例如,classpnp 的ClassInterpretSenseInfo在收到NOT_READY感觉数据时,在完成例程中自动构造REQUEST_SENSE命令重新发送。如果传播方向颠倒,底层错误会被底层完成例程自行消化,上层完全不知情。
为什么 10:为什么设备栈之间使用 IoCallDriver 而不是直接函数调用?
设备栈的各层之间通过 IoCallDriver 发送 IRP,而不是直接函数调用,这背后的设计思想是控制反转(IoC)和可插拔性:
- 过滤驱动的透明插入 :Windows 的过滤驱动(filter driver)可以透明地插入设备栈的任意两层之间。例如,磁盘加密驱动可以插入在 classpnp 与端口驱动之间,对写入数据自动加密,对读取数据自动解密。如果使用直接函数调用,过滤驱动无法拦截 I/O 请求------它不知道应该在哪两个函数之间插入自己。而 IRP 机制使得过滤驱动只需注册
AddDevice、调用IoAttachDeviceToDeviceStack附加到栈中,即可处理、修改甚至拦截任意 IRP。 - 完成例程的自动管理 :
IoCallDriver不仅负责将 IRP 传递到下层,还自动管理 IRP 的StackLocation索引和完成例程链。每次IoCallDriver调用都会推进栈位置,下次完成时自动从正确的位置开始回调。直接函数调用要求每层手动管理完成回调队列,代码复杂度呈指数级增长。 - 统一的异步/同步模型 :
IoCallDriver的返回值统一为NTSTATUS:如果下层同步完成返回STATUS_SUCCESS,如果异步处理返回STATUS_PENDING。上层驱动不需要关心下层是同步还是异步实现------这种统一接口简化了每层的实现。例如,scsiport 的StartIo队列可以挂起 IRP 等待 SRB 完成,而上面的 disk 驱动完全不需要感知这种异步性。
深入剖析:磁盘设备栈的内部机制
VPB(卷参数块)的核心角色
在磁盘设备栈中,VPB(Volume Parameter Block) 是连接文件系统驱动与下层卷管理设备的关键纽带。VPB 是 DEVICE_OBJECT 结构体内嵌的一个字段(Vpb),它指向一个 VPB 结构体。当文件系统成功挂载一个卷时,I/O 管理器会创建文件系统的卷设备对象(VOD),并将 Vpb->DeviceObject 指向该 VOD。这样,当应用层对 C:\path\file.txt 发起 I/O 请求时,I/O 管理器通过 DOS 设备名 \DosDevices\C: 找到卷设备对象,再通过 VPB 定位到文件系统驱动的设备对象,从而将 IRP 发送到正确的文件系统驱动。
在 ReactOS 的实现中,VPB 的创建和关联发生在 ntoskrnl/io/iomgr/volume.c 的 IopMountVolume 函数中。该函数遍历所有已注册的文件系统驱动(通过 IoRegisterFileSystem 注册的驱动链表),依次向每个文件系统发送 IRP_MJ_FILE_SYSTEM_CONTROL(带有 FSCTL_MOUNT_VOLUME 控制码)。第一个成功返回 STATUS_SUCCESS 的文件系统驱动就"拥有"了这个卷。ReactOS 中 fastfat 和 ntfs 是主要的文件系统驱动,它们各自实现了 FatMountVolume 和 NtfsMountVolume 函数来完成挂载过程。
IRP 完成例程链的深层机制
磁盘 I/O 路径上的每一层驱动都通过 IoSetCompletionRoutine 注册完成回调,形成一条完成例程链。这条链的工作机制比表面看起来更为复杂:
-
完成例程的调用条件 :每个完成例程可以设置三个条件标志------
SL_INVOKE_ON_SUCCESS、SL_INVOKE_ON_ERROR、SL_INVOKE_ON_CANCEL。只有当 IRP 的完成状态匹配设定的条件时,完成例程才会被调用。 -
完成例程的返回值 :完成例程返回
STATUS_MORE_PROCESSING_REQUIRED表示"我处理完了,不要再往上走了";返回其他状态则继续向上传播。这个机制允许某一层"截获"IRP 的完成过程,例如用于异步重试或错误恢复。 -
ReactOS 中的实现细节 :在
ntoskrnl/io/iomgr/iocomp.c中,IopCompleteRequest函数负责遍历 IRP 的IoStackLocation数组,从当前栈位置向上逐一检查并完成每一层的回调。由于每个IO_STACK_LOCATION中的CompletionRoutine和Context字段是在IoCallDriver之前通过IoSetCompletionRoutine设置的,因此完成链与发送链是严格对称的。 -
完成例程与 IRP 重用 :在某些场景下(如 SCSI 超时重试),端口驱动会在完成例程中截获 IRP,重置 SRB 状态后重新发送到下层。这种模式在
classpnp的ClassInterpretSenseInfo函数中尤为常见------当磁盘返回NOT_READY感觉数据时,classpnp 会自动构造REQUEST_SENSE命令并重新发送。
设备栈中的 PnP 传播路径
PnP IRP(如 IRP_MN_START_DEVICE、IRP_MN_REMOVE_DEVICE)在设备栈中的传播方式与普通 I/O IRP 不同。PnP IRP 遵循先下后上的传播规则:
- 向下传播 :PnP 管理器(
ntoskrnl/pnp/pnpdrv.c)将 PnP IRP 发送到设备栈的顶层 设备对象,然后每层驱动在处理完毕后必须将 IRP 传递给下层(通过PoCallDriver或IoCallDriver),直到到达最底层的 PDO。 - 向上传播:当底层 PDO 完成 IRP 后,完成结果沿着完成例程链逐层向上传播,每层的完成例程可以在此时执行自己的后处理逻辑。
在 ReactOS 的磁盘栈中,这意味着 partmgr.sys 的 IRP_MN_START_DEVICE 处理流程是:partmgr 先做自身的初始化(如读取分区表),然后将 IRP 传递给 disk.sys 的 FDO,disk 再传递给 scsiport.sys,最终到达 atapi 或 storahci 的 PDO。每一层在"向下传递"之前完成自己的初始化工作。
与 Windows 实现的对比分析
ReactOS 的磁盘设备栈设计力求与 Windows Server 2003 保持二进制兼容,但在以下方面存在差异:
-
volmgr.sys 的缺失 :在 Windows 中,
volmgr.sys负责基本卷管理(简单卷),volsnap.sys负责卷影 copy。ReactOS 目前将这两者的功能合并到了partmgr.sys中。从 ReactOS 的partmgr.c源码注释可以看到:"Here is acts like both partition and volume manager, because volmgr.sys does not (yet) exist in ReactOS."这意味着 ReactOS 暂时不支持动态磁盘、跨区卷等高级卷管理功能。 -
classpnp 的版本差异 :ReactOS 的 classpnp 来源于 Windows 10 的 classpnp.sys(从
#if (NTDDI_VERSION >= NTDDI_WIN8)条件编译可以看到),而磁盘类驱动 disk.sys 也相应地使用了较新的接口。但 ReactOS 的 scsiport 则是独立实现的,这导致了一些兼容性问题------例如STORAGE_ADAPTER_DESCRIPTOR_WIN8结构体在 scsiport 中需要特殊定义才能与 classpnp 兼容。 -
storport 的成熟度:Windows 的 storport 已经发展到支持 NVMe、SCSI 等现代协议,而 ReactOS 的 storport 实现相对初级。storahci(AHCI Miniport)虽然能够工作,但在多队列深度、NCQ(Native Command Queuing)等高级特性上还有差距。
调试技巧与常见问题
-
设备栈查看 :使用 WinDbg 的
!devstack命令可以查看某个设备对象的完整栈。例如!devstack \Device\Harddisk0\Partition1可以显示从文件系统到端口驱动的完整设备栈。 -
IRP 跟踪 :使用
!irp <irp_address>可以查看 IRP 的当前栈位置、完成状态和已完成的层数。在调试磁盘 I/O 挂起问题时,检查 IRP 是否卡在某一层的完成例程中非常有用。 -
常见挂起场景 :磁盘 I/O 挂起通常发生在以下情况------SRB 超时但端口驱动未正确处理(
ScsiPortIoTimer未触发);完成例程返回了错误的状态码导致 IRP 不再向上传播;DMA 映射失败导致 SRB 无法启动。 -
ReactOS 特有的调试输出 :ReactOS 的存储栈启用了
DPRINT宏进行调试输出。设置HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Debug Print Filter中对应组件的级别为 0xFF,可以在调试串口看到详细的 IRP 流转日志。
为什么 9:为什么磁盘 I/O 超时处理如此复杂?
磁盘 I/O 的超时处理是存储栈中最容易出错的环节之一。其复杂性源于多个因素的叠加:
- 硬件的不可预测性:磁盘是机电设备,其响应时间受多种因素影响------寻道时间、旋转延迟、总线争用、坏道重映射等。一个正常的磁盘在重负载下可能需要数秒才能完成一个 I/O。因此,超时阈值的设定本身就是一种权衡:太短会导致误判(正常慢速 I/O 被中止),太长会导致系统长时间挂起。
- 三级恢复链 :当 SRB 超时时,端口驱动的
ScsiPortIoTimer触发超时回调。恢复过程通常分三步执行:先发送SRB_FUNCTION_ABORT_COMMAND中止当前命令;如果失败,发送SRB_FUNCTION_RESET_DEVICE重置目标设备;如果仍然失败,发送SRB_FUNCTION_RESET_BUS重置整个 SCSI 总线。每一步都涉及新的 SRB 提交和等待,且每一步都可能再次超时,形成递归式的超时处理。 - IRP 完整性的保护 :超时恢复过程中,原始 IRP 不能被简单地丢弃。类驱动的完成例程(如
ClassInterpretSenseInfo)需要在超时后处理 SCSI 感觉数据(Sense Data),判断设备是否返回了NOT_READY或UNIT_ATTENTION,并决定是否自动重试。如果在三级恢复过程中 IRP 被意外完成,应用程序可能收到错误的 I/O 结果(如读取到不完整的数据)。 - 电源状态转换的干扰 :磁盘在待机状态(
PowerDeviceD1/D2)下对任何 I/O 的响应时间会显著增加。端口驱动需要区分"正常的电源恢复延迟"和"真正的硬件超时",避免在电源状态转换期间误报超时。ReactOS 的 scsiport 通过检查 SRB 时间戳和电源状态标志来实现这一区分。
在 ReactOS 的 scsiport 中,超时处理由 SpiProcessTimeout 函数实现,它遍历所有未完成的 SRB 链表,检查每个 SRB 的时间戳,对超时的 SRB 依次执行中止→重置设备→重置总线的三级恢复策略。理解这一机制对于调试 ReactOS 的磁盘 I/O 挂起问题至关重要。
9.9.8 总结
磁盘设备栈的 关键要点:
- 五层架构:硬件 → 总线 → 端口 → 类 → 卷管理 → 文件系统
- IRP_MJ_SCSI 内部 IRP:类驱动通过 IOCTL_SCSI_EXECUTE 构造
- SRB:SCSI Request Block,端口驱动与 Miniport 的接口
- 完成例程链 :每层都通过
IoSetCompletionRoutine注册完成处理 - partmgr 与 volmgr:ReactOS 中 partmgr 兼任两者
- mountmgr 协议:卷挂载与盘符分配
下一节 9.9.1 将详细剖析类驱动 disk.sys。
本章代码索引
| 文件 | 内容 |
|---|---|
| disk.c(file:///d:/reactos/drivers/storage/class/disk/disk.c) | 类驱动 disk.sys 入口 |
| disk.h(file:///d:/reactos/drivers/storage/class/disk/disk.h) | disk 设备扩展定义 |
| pnp.c(file:///d:/reactos/drivers/storage/class/disk/pnp.c) | disk PnP 处理 |
| class.c(file:///d:/reactos/drivers/storage/class/classpnp/class.c) | classpnp 框架 |
| classp.h(file:///d:/reactos/drivers/storage/class/classpnp/classp.h) | classpnp 头文件 |
| partmgr.c(file:///d:/reactos/drivers/storage/partmgr/partmgr.c) | 分区管理器 |
| partmgr.h(file:///d:/reactos/drivers/storage/partmgr/partmgr.h) | partmgr 头文件 |
| scsiport.c(file:///d:/reactos/drivers/storage/port/scsiport/scsiport.c) | SCSI 端口驱动 |
| storport.c(file:///d:/reactos/drivers/storage/port/storport/storport.c) | 新式存储端口驱动 |
| atapi.c(file:///d:/reactos/drivers/storage/ide/atapi/atapi.c) | ATAPI 端口驱动 |
| mountmgr.c(file:///d:/reactos/drivers/storage/mountmgr/mountmgr.c) | 卷挂载管理器 |