26、存储与磁盘驱动:存储栈、SCSI/ATA命令、磁盘分区、卷管理、加密与压缩
存储驱动这块,说实话是Windows内核里最「硬核」的部分之一。我早年做存储虚拟化的时候,踩过的坑能绕办公室三圈。今天咱们就把存储栈从上到下捋一遍,重点聊聊SCSI/ATA命令怎么下发、分区表怎么解析、卷管理怎么玩,以及加密压缩那些事儿。
核心要点:存储驱动不是单一驱动,而是一整套分层架构。每一层都有明确的职责,搞懂这个栈,你才能写出稳定高效的存储驱动。
26.1 存储栈的整体架构
Windows的存储栈,说白了就是一层套一层的「俄罗斯套娃」。从上往下看:
- 文件系统驱动(如NTFS、FAT32):把文件操作翻译成卷操作
- 卷管理器(如VolMgr、VolSnap):管理卷的挂载、快照、镜像
- 分区管理器(PartMgr):解析MBR/GPT分区表
- 磁盘类驱动(Disk.sys):把卷操作翻译成磁盘操作
- 端口驱动(如StorPort.sys):与硬件控制器通信
- 微型端口驱动(厂商提供):直接操作硬件寄存器
我个人习惯把存储栈想象成「快递系统」:文件系统是下单的人,卷管理器是分拣中心,分区管理器是地址解析,磁盘类驱动是快递员,端口驱动是运输车,微型端口驱动就是那最后一百米的送货小哥。
26.2 SCSI/ATA命令:驱动与磁盘的「对话语言」
磁盘驱动跟硬件打交道,靠的就是SCSI命令和ATA命令。你想想看,上层发一个「读扇区100到200」的请求,到了端口驱动层,就得翻译成硬件能懂的SCSI CDB(命令描述块)或者ATA寄存器命令。
SCSI命令结构
SCSI命令的核心是CDB,长度通常是6、10、12或16字节。我当年调试一个存储阵列的驱动,发现SCSI命令超时,最后定位到是CDB里的LBA字段填错了——高位字节没清零,导致磁盘寻址到了火星上。
// SCSI READ(10) 命令的CDB结构
typedef struct _SCSI_READ10_CDB {
UCHAR OperationCode; // 0x28 = READ(10)
UCHAR ServiceAction : 5; // 通常为0
UCHAR Reserved1 : 3;
UCHAR LogicalBlockAddress[4]; // 起始LBA,大端序
UCHAR GroupNumber : 5;
UCHAR Reserved2 : 3;
UCHAR TransferLength[2]; // 要读取的扇区数
UCHAR Control; // 控制字节
} SCSI_READ10_CDB, *PSCSI_READ10_CDB;
避坑指南:我曾经在写SCSI命令下发时,忘了把LBA从主机字节序转成大端序。结果读前256个扇区没问题,一超过256就乱套。后来花了整整两天才找到这个bug——嗯,字节序问题永远是存储驱动的头号杀手。
ATA命令的差异
ATA命令跟SCSI不太一样。ATA走的是寄存器模型,你得往任务文件寄存器里写命令参数,然后触发命令执行。Windows通过ATA PASS-THROUGH指令来封装ATA命令,本质上还是包在SCSI命令里发下去的。
// ATA PASS-THROUGH 命令结构(简化版)
typedef struct _ATA_PASS_THROUGH_DIRECT {
USHORT Length; // 本结构体长度
USHORT AtaFlags; // 方向标志等
UCHAR PathId; // 路径ID
UCHAR TargetId; // 目标ID
UCHAR Lun; // LUN
UCHAR ReservedAsUchar;
ULONG DataTransferLength; // 数据传输长度
UCHAR CurrentTaskFile[8]; // 任务文件寄存器值
UCHAR PreviousTaskFile[8];
PVOID DataBuffer; // 数据缓冲区
} ATA_PASS_THROUGH_DIRECT, *PATA_PASS_THROUGH_DIRECT;
26.3 磁盘分区:MBR与GPT的博弈
分区表这事儿,说简单也简单,说复杂也复杂。MBR(主引导记录)是古董级的存在,但至今还在用。GPT(GUID分区表)是后来者,支持更大的磁盘和更多分区。
| 特性 | MBR | GPT |
|---|---|---|
| 最大磁盘容量 | 2TB | 9.4ZB(理论) |
| 最大分区数 | 4个主分区(或3主+1扩展) | 128个(Windows限制) |
| 备份分区表 | 无 | 有(磁盘末尾备份) |
| CRC校验 | 无 | 有 |
| 引导方式 | BIOS + 活动分区 | UEFI + ESP分区 |
在驱动层面,PartMgr负责解析分区表。它通过IOCTL_DISK_GET_PARTITION_INFO等接口向上层报告分区信息。我记得有一次,客户反馈磁盘分区识别错误,查了半天发现是GPT的备份分区表被破坏了,但主分区表完好——PartMgr默认只读主分区表,除非主表校验失败才去读备份。这个设计其实挺合理的,但如果你手动改了分区表,就得注意同步更新备份。
26.4 卷管理:从分区到逻辑卷的升华
卷管理器是存储栈里的「魔术师」。它能把多个物理分区组合成一个逻辑卷,还能做镜像、条带化、快照。Windows的卷管理器主要有两个:
- VolMgr:基础卷管理器,处理简单卷、跨区卷、带区卷
- VolSnap:快照卷管理器,支持卷影复制(VSS)
卷管理的核心数据结构是卷参数块(VPB)。每个挂载的卷都有一个VPB,它连接了文件系统卷设备和底层存储设备。我当年写一个卷过滤驱动时,就是通过拦截VPB的创建和销毁来实现卷级别的加密。
// VPB结构体(简化)
typedef struct _VPB {
CSHORT Type;
CSHORT Size;
USHORT Flags; // VPB_MOUNTED, VPB_LOCKED等
USHORT VolumeLabelLength;
struct _DEVICE_OBJECT *DeviceObject; // 文件系统卷设备
struct _DEVICE_OBJECT *RealDevice; // 底层存储设备
ULONG SerialNumber;
ULONG ReferenceCount;
WCHAR VolumeLabel[32];
} VPB, *PVPB;
注意:卷挂载和卸载是有严格顺序的。我曾经在卷卸载时没有等待所有引用释放,直接删除了设备对象,结果导致蓝屏。正确的做法是:先锁定卷(FSCTL_LOCK_VOLUME),再卸载文件系统,最后删除设备对象。
26.5 加密与压缩:数据保护的最后一公里
存储加密和压缩,通常是在卷过滤驱动里实现的。Windows自带的BitLocker就是全卷加密的典型例子。加密驱动位于文件系统之下、磁盘类驱动之上,这样对上层完全透明。
加密驱动的实现思路
加密驱动本质上是一个过滤驱动,它附着在卷设备上。当写请求下来时,它先加密数据再往下传;读请求上来时,它先解密数据再往上送。
// 加密写请求的简化处理
NTSTATUS EncryptWrite(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
PIO_STACK_LOCATION irpSp = IoGetCurrentIrpStackLocation(Irp);
PMDL mdl = irpSp->Parameters.Write.MdlAddress;
ULONG length = irpSp->Parameters.Write.Length;
// 锁定用户缓冲区
PVOID buffer = MmGetSystemAddressForMdlSafe(mdl, NormalPagePriority);
if (!buffer) {
return STATUS_INSUFFICIENT_RESOURCES;
}
// 对每个扇区进行加密(AES-256示例)
for (ULONG offset = 0; offset < length; offset += SECTOR_SIZE) {
EncryptAes256((PUCHAR)buffer + offset, SECTOR_SIZE, &encryptionKey);
}
// 传递给下层驱动
return IoCallDriver(DeviceObject->AttachedDevice, Irp);
}
压缩驱动的不同之处
压缩比加密更麻烦。因为压缩后的数据长度会变,你不能简单地原地替换。通常的做法是:
- 写请求:压缩数据,然后分配新的缓冲区存放压缩后的数据,再下发
- 读请求:读取原始数据,解压后返回给上层
- 需要维护一个「压缩映射表」,记录每个逻辑扇区对应的物理扇区位置
我个人觉得,压缩驱动比加密驱动难写得多。加密是固定长度变换,压缩是变长变换,这会导致磁盘碎片和性能问题。我在项目中做过一个实时压缩驱动,最后因为性能太差被砍掉了——压缩率上去了,但写入延迟增加了3倍,用户受不了。
经验之谈:如果你要在驱动里做加密,建议用硬件加速(如AES-NI指令集)。软件加密在高速NVMe磁盘上会成为瓶颈。我测试过,纯软件AES-256加密,在PCIe 4.0的NVMe盘上,吞吐量直接腰斩。
26.6 实战:写一个简单的卷过滤驱动
说了这么多理论,咱们来点实际的。下面是一个卷过滤驱动的骨架,它拦截IRP_MJ_READ和IRP_MJ_WRITE,打印访问的扇区号。
NTSTATUS FilterDispatchReadWrite(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
PIO_STACK_LOCATION irpSp = IoGetCurrentIrpStackLocation(Irp);
PDEVICE_EXTENSION devExt = (PDEVICE_EXTENSION)DeviceObject->DeviceExtension;
if (irpSp->MajorFunction == IRP_MJ_READ) {
LARGE_INTEGER byteOffset = irpSp->Parameters.Read.ByteOffset;
ULONG length = irpSp->Parameters.Read.Length;
ULONG sector = (ULONG)(byteOffset.QuadPart / 512);
DbgPrint("[Filter] 读扇区: %u, 长度: %u 字节\n", sector, length);
}
else if (irpSp->MajorFunction == IRP_MJ_WRITE) {
LARGE_INTEGER byteOffset = irpSp->Parameters.Write.ByteOffset;
ULONG length = irpSp->Parameters.Write.Length;
ULONG sector = (ULONG)(byteOffset.QuadPart / 512);
DbgPrint("[Filter] 写扇区: %u, 长度: %u 字节\n", sector, length);
}
// 传递给下层驱动
IoSkipCurrentIrpStackLocation(Irp);
return IoCallDriver(devExt->LowerDeviceObject, Irp);
}
这个驱动虽然简单,但它是所有卷过滤驱动的基础。加密、压缩、快照、去重,都是在类似这样的骨架里添加具体逻辑。
26.7 总结与避坑清单
存储驱动开发,说白了就是跟数据打交道。数据怎么来、怎么去、怎么存、怎么保护,每一步都有坑。我整理了一份避坑清单,都是血泪教训:
- 字节序问题:SCSI命令里的LBA和传输长度都是大端序,别忘了转换
- 对齐问题:存储请求的缓冲区必须按扇区对齐(512字节或4KB)
- 引用计数:卷设备对象的引用计数必须正确管理,否则卸载时会蓝屏
- IRP完成例程:如果你在过滤驱动里修改了IRP,一定要正确设置完成例程
- 电源管理:磁盘休眠时下发IO请求会导致超时,记得处理IRP_MJ_POWER
- PnP事件:磁盘热插拔时,你的过滤驱动必须能优雅地卸载和重载
嗯,存储驱动这块内容确实不少。但只要你把存储栈的每一层搞明白,知道数据是怎么从上往下流、再从下往上流的,写驱动就不会抓瞎。我当年也是从一脸懵逼到慢慢摸清门道的——多写、多调试、多读WDK示例,慢慢就熟了。
公众号:蓝海资料掘金营,微信deep3321