SysRestoreDriver:基于写时重定向的 NTFS 卷过滤还原驱动设计与实现

〇、前言

这是一个 Windows 内核态卷过滤驱动,做的事情是:开启保护后,系统盘上任何写入都会被悄悄重定向到空闲扇区。重启之后,磁盘自动回到保护开启时的样子。 类似网吧/教室那种还原卡,只是纯软件实现,没有任何硬件介入。

核心机制叫"写时重定向"(copy-on-write redirection),关键的取巧之处是:$Bitmap 本身也被驱动重定向了,所以它在物理磁盘上永远是保护开启那一刻的快照——这意味着不用自己维护空闲扇区表,直接读 $Bitmap 就行。

没有第三方依赖,纯 WDK,Apache License 2.0。配套有一个 MFC 用户态管理程序 QHEngineUI,把 .sys.inf 作为资源嵌入 exe,一键安装、勾选要保护的卷、开启/关闭保护。

本文记录设计取舍、几次大的方案推翻、写完后跑 CrystalDiskMark 才发现的真正瓶颈、以及一些我事后觉得有意思的小坑。

github开源链接:SysRestoreDriver(Apache License 2.0)


一、项目定位

适用场景就那几样:网吧 / 教室 / 图书馆 / 展会样机、本地软件试用沙箱(装一堆软件、改注册表、删系统文件,重启即清)、给家里老人小孩的机器防误操作。

仅支持 NTFS。目前在 Win10 22H2 和 Win11 23H2 上实测通过,包括"正常写入后重启"、“异常断电模拟后重启”、"开关保护重启循环"三组场景。Win7 / Win8 / Win8.1 理论兼容,但没测。


二、驱动在设备栈中的位置

设备栈位置

选择卷过滤而不是文件过滤(minifilter),原因很直接:minifilter 拦不到 NTFS 元数据的写入$Bitmap$MFT 这些元数据被 NTFS 内部更新时不会产生文件级 IRP,minifilter 完全无感。而卷过滤位于 NTFS 文件系统的下层,所有块级写入都要经过——这恰恰是还原驱动需要的"全覆盖"。

驱动注册为 Volume Upper Filter(FilterClass = Volume),系统挂载任何 NTFS 卷时,自动 attach 一个过滤设备上去。每个卷独立判断是否开启保护:根目录有 _qh_protect_state.data 且首字节 = 1 才激活,否则一律透传。


三、核心机制:写时重定向

写时重定向机制

机制本身两句话能讲明白:

  1. 用户写扇区 X → 驱动从空闲扇区池里挑一个 Y → 实际写到 Y,内存里记一笔 X → Y
  2. 后续读 X → 查内存表,得到 Y → 实际读 Y → 应用看到的是新数据

但真正让这套设计成立的,是第三件事$Bitmap 自己也被驱动重定向。

这个观察看着不起眼,但它解决了所有难题。NTFS 看到的空闲扇区表(也就是 $Bitmap),从保护开启那一刻起就再也不会被真实改写——所有对它的写入都被重定向到别处了。所以物理磁盘上的 $Bitmap,就是保护开启时刻的快照。重启之后,驱动重新挂载、内存表全部丢失、NTFS 重新读盘——读到的就是那张快照,整个文件系统瞬间回到保护开启那一刻。

那些重定向期间被分配到的目标扇区(Y、W、…),数据虽然还物理存在于磁盘上,但 NTFS 的 $Bitmap 把它们标为空闲,后续写入会自然覆盖。无需任何"提交"或"清理"步骤。


四、关键设计决策

这一节是整篇的重点。每个决策都讲:最初想法 → 演变过程 → 最终方案 → 变化原因。少数地方我花了不短的时间才想明白,过程值得记录。

4.1 位图管理:从"自己维护"到"复用 NTFS $Bitmap"

最初的设想是:自己维护一张空闲扇区位图。200GB 的 C 盘按 512B 扇区算,约 4 亿位,需要 35MB 内存——这个量级勉强能接受。但如果是 2TB 卷呢?350MB。20TB 呢?3.5GB。内存条不够的用户立马蓝屏。

于是又退一步:把位图写到磁盘扇区里,需要时局部读取。但这立刻引出另一个问题——位图自己也是写操作的对象,每次更新位图都要触发新的 IRP,多次扇区读写会把性能打到地板上。

转折点是反向思考的一句话:既然 $Bitmap 也被我重定向了,它在磁盘上就永远是保护开启时刻的状态。那它本身就是一张天然的、不会被污染的空闲扇区位图——为什么要再自己造一张?

所以最终方案是:驱动启动时把 $Bitmap 的簇级位图从磁盘读出来,在内存里展开成扇区级 SectorBitmap。1TB 卷约 256MB,从 NonPagedPool 分配(IRP 派遣可能在 DISPATCH_LEVEL 触达,必须不可分页)。

这一步本质上是"借力打力"——NTFS 自己已经在维护一份高度优化的空闲扇区数据结构,去复用它,而不是再造一份。

4.2 位图实现:基于 RTL_BITMAP 的薄包装

SectorBitmap 用 Microsoft 公开的 RTL_BITMAP 系列 API 做了一层薄包装 QH_BITMAP。选 RTL_BITMAP 的几个理由:

  • 代码量极小Create / Set / Test / FindNextClear 全部直接转发给 RtlSetBit / RtlClearBit / RtlCheckBit / RtlFindClearBits。后者自带 wrap-around 语义——找不到清零位时会自动从位图头部再扫一遍,省掉上层一段"找不到则再来一次"的兜底代码。
  • 容量足够RTL_BITMAPULONG 索引,单张位图上限约 4G 位。512B 扇区下对应约 2TB 卷,4KB 扇区下约 16TB,覆盖绝大多数系统盘 + 数据盘场景。若未来需要超大卷,可以在 QH_BITMAP 层内部再加稀疏分槽,上层接口不变。
  • 官方 API,行为可信。位操作本身极容易写错,能用官方实现就用官方实现。

4.3 偏移记录表的数据结构:从 khashl 到 RTL_GENERIC_TABLE

(原始扇区 → 重定向目标扇区) 这张映射表的数据结构,最初执着于"必须 O(1) 查找的哈希表"——毕竟读写 IRP 路径上每个扇区都要查一次。

第一步尝试把 klib 的 khashl.h 移植到内核态。klib 是一套头文件唯一的轻量 C 库,理论上"应该"能移植,毕竟它不依赖 libc。我花了不少时间适配(替换内存分配、去除标准库依赖),跑起来后却频繁卡死——具体原因到最后也没完全定位,但稳定性问题在内核里是绝对的红线。

退而求其次,改用 RTL_GENERIC_TABLE——基于 Splay 树,O(log N) 查找。两个理由:

  • Splay 树有"热点旋根"的局部性优化。频繁访问的节点会被旋转到根附近,实际负载下单次查找接近 O(1)。
  • Microsoft 官方 API,行为有保证。考虑到这个表的查找在 IRP 慢路径上每个扇区都要查一次,“快但偶尔崩"远不如"略慢但永远稳”。

剥离 klib 还有个意外收益——许可证清洁度。klib 是 MIT,并不冲突,但少一个第三方依赖等于少一份审计成本。最终代码库零第三方依赖,纯 WDK。

未来计划在稳定之后自实现 per-bucket 锁的开链哈希表,但那是后话。

4.4 映射粒度:从簇级到扇区级

最初按簇做映射:原始簇 → 重定向簇。这是直觉上最自然的选择——NTFS 的最小分配单元就是簇,元数据全部是按簇组织的。

实际跑起来才意识到一个问题。NTFS 的簇默认 4KB(8 个 512B 扇区)。考虑一个簇里只有部分扇区被使用的场景——比如 _ _ * _ * * _ _* 表示被使用,_ 表示空闲)。如果按簇重定向,写其中任一扇区都要:

  1. 把原簇 8 个扇区全部读上来
  2. 修改其中要改的几个扇区
  3. 把整个 8 个扇区写到重定向目标簇

每次小写入都多一次完整的簇读 IO。CDM 跑下来性能直接砍半,这是灾难性的。还原驱动的卖点本来就是"开了保护不会显著变慢",如果开了之后性能砍半,这个产品的使用价值就没了。

改成扇区级映射后,IRP 范围有多少扇区就操作多少扇区,精确匹配,无谓拷贝。代价是偏移表条目变多——按簇 1 条记录变成按扇区最多 8 条——但 Splay 树的 O(log N) 查找完全吃得消,内存增长也可控。

4.5 保护状态持久化方案的设计权衡

这是整个项目里我反复推翻自己最多的一个点。问题本身简单:驱动启动时怎么知道"上次有没有开启保护"? 但因为驱动会重定向所有写入,常规的"在某处存个标志位"全都行不通。

推翻一:注册表

第一个直觉是注册表。用户态在 HKLM\SYSTEM\...\Services\SysRestoreDriver\ 下记一个值,驱动启动时读。

问题瞬间冒出来:用户在保护开启状态下关闭 D 盘保护,他对注册表的写入会被驱动重定向。也就是说,他把注册表里的"D 盘开启保护"删了,但这个删除操作实际写到了某个空闲扇区。重启之后,物理磁盘上的注册表 hive 文件根本没被改过——D 盘依然被认为是开启保护的。

试图绕过:能不能"暂停重定向",让那次注册表写入直通磁盘,然后恢复重定向?理论上可以,但要解决的问题一堆:

  • 注册表的写入可能被文件系统缓存延后
  • 暂停期间如果有其他注册表写入并发怎么办
  • 注册表 hive 文件在磁盘上的物理位置怎么算

每一个都不是小工程。这条路放弃。

推翻二:再写一个 minifilter 通信

退而想:写第二个驱动,一个文件过滤系统(minifilter),它能感知文件级 IO。在用户态修改注册表时,minifilter 拦截到、计算这条注册表项落在哪个物理扇区、通过共享内存通知卷过滤驱动"这个扇区放行不要重定向"。

听起来不错,写起来全是雷:

  • minifilter 算物理扇区需要打开文件 → 查 extent → 算 LCN → 算物理偏移,这事情本身就容易出错
  • 一个扇区 512B,可能装多条记录,万一并发写到同一扇区其他位置怎么办
  • 跨驱动通信的同步原语,又是一坨复杂度

而且核心矛盾没解决:这么复杂的机制,仅仅是为了存"开 / 关"这一个 bit。这就跟为了拧一颗螺丝去造一台机床一样。

推翻三:注册表 + 自定义扇区

又想:用户态软件安装时,在 NTFS 空闲簇里挑一个扇区,把那个扇区号写注册表。这个扇区由驱动直接读写,不经过文件系统。

问题更隐蔽,等想清楚已经晚了:NTFS 不知道这个扇区被驱动私自占用了。下次启动时,文件系统可能已经把这个扇区分配给某个文件、写了新数据,驱动按记录的扇区号去读,读到的是文件内容——保护状态判断完全错乱。

最终方案:ProtectRanges + 卷根目录状态文件

最后想通的方案,反而是最朴素的:在卷根目录创建一个固定大小的隐藏文件 _qh_protect_state.data,1MB 大小,首字节存 1/0 表示开 / 关。

关键设计:这个文件的所有物理扇区被驱动加入 ProtectRanges(直写放行扇区区间表),读写时直通真实磁盘,绕过重定向。

ProtectRanges 是一个 init 阶段一次性填充、之后只读的静态小数组(嵌入 DEVICE_EXTENSION,上限 8 段,实际仅占 3-4 段),IRP 路径上无锁线性扫描。它的填充流程:

  1. UI 开启保护时创建状态文件,写首字节 1
  2. 驱动启动时通过 FSCTL_GET_RETRIEVAL_POINTERS 拿到文件的所有 extent
  3. 把这些 extent 对应的扇区追加到 ProtectRanges

由此达成的几个不变量:

  • 文件由 NTFS 真实管理,所以 NTFS 不会重新分配这些扇区给别人
  • 文件的扇区在 ProtectRanges,UI 写首字节时直通真实磁盘,重启后驱动能读到真实值
  • 驱动初始化期间Initialized == FALSE),所有 IRP 走默认透传,ZwReadFile 读到的是真实磁盘内容
  • 文件大小 1MB,强制 NTFS 走非驻留存储——驻留属性的阈值约 700B,如果文件太小数据会直接塞进 MFT 记录,那 ProtectRanges 标的"文件扇区"会落到 MFT 内部,整个机制崩塌

完全不依赖注册表。

一个连带的好处:解决 NTFS dirty flag 问题

ProtectRanges 的机制不只用于状态文件。还有一个隐蔽的坑:NTFS 的 $Volume 元数据(MFT 记录 #3)维护着 dirty flag,干净关机时 NTFS 会清这个 flag。如果这个写入被重定向,物理磁盘上的 dirty bit 永远停在保护开启那一刻。几次重启后,Windows 累计判定"未干净关机",会触发 WinRE 修复模式

解决方法很对称:把 MFT #3 主区与 $MFTMirr 的对应镜像两个扇区也加入 ProtectRanges。一共两段,再加状态文件的一两段,总数不超过 4 段,远在 8 段上限之内。

一个反向决策:为什么不把 pagefile.sys / hiberfil.sys 加进去

早期版本试过把 pagefile.sys / hiberfil.sys / bootstat.dat 也加入直写放行——理由直觉上很合理,这些都是系统关键文件,被拦截会带来麻烦。

测试时却没人察觉到一个关键问题:这些文件可能在保护期间被用户态动态扩缩容pagefile.sys 尤其严重,Windows 在内存压力下会自动伸缩页面文件大小。新分配的扇区不在已快照的 ProtectRanges 里,写入会被驱动重定向 → 系统后续读 pagefile 时读到错位数据 → 蓝屏或更糟

所以最后的规则很简单:ProtectRanges 只收录扇区位置在保护期间永远不变的实体。状态文件 1MB 固定不动,MFT 元数据物理位置由文件系统固定分配——这两类是安全的。其他系统文件一概不放,宁可它们走重定向。

4.6 驱动初始化时机:从 IOCTL_VOLUME_ONLINE 到 BootDriverReinitialization

最初的初始化点选在 IOCTL_VOLUME_ONLINE 控制码处理函数里——这个 IOCTL 是 mount manager 在卷上线时发的,听起来就是"卷已经上线,可以初始化了"。

实测下来发现一个严重问题:IOCTL_VOLUME_ONLINE 到达时,NTFS 不一定已经挂载完毕。状态文件需要通过 ZwCreateFile 打开 → 必须等 NTFS 就绪 → 此时打开会失败。

研究后改用 IoRegisterBootDriverReinitialization 注册的回调。这个回调由系统保证在所有 Boot 驱动加载完成后调用,意味着 NTFS 必然已经挂载——是文件操作的最早安全时机。

对于动态插拔介质(U 盘等),仍然走 IOCTL_VOLUME_ONLINE 路径执行初始化(因为它们不在 Boot 驱动列表里),但要等 BootReinitDone == 1 后才允许激活保护,防止系统盘还没初始化完就先启动 U 盘的保护。

4.7 文件删除导致空间萎缩问题的权衡

写完核心机制后才意识到一个隐患。考虑这个场景:

  1. 保护开启,空闲扇区 Z,原始扇区 X 被写入 → 驱动重定向 X → Z
  2. NTFS 仍然认为 Z 空闲,把 Z 分配给某个新文件 → 写入 Z 触发驱动重定向 Z → W
  3. 这个文件后来被删除,NTFS 把 Z 在 $Bitmap(重定向视图)里标为空闲
  4. 但驱动并不知道"Z 又空闲了"——它仍然把 Z 视为 X 的重定向目标占用着
  5. 时间长了,越来越多 Z 这样的扇区"脱离空闲池",重定向空间不断萎缩

理论上这是个 bug。最稳的解法是写一个 minifilter 拦截文件删除 IRP,拿到文件的 extent 表,发自定义 IOCTL 通知卷过滤驱动"这些扇区释放了,可以从偏移表里删掉"。

权衡之后当前版本不处理。理由:

  • 触发这个问题需要对已经被重定向保护的数据持续大量修改同时配合大量删除操作。真实场景里保护期间的写入量本身就有限
  • 多写一个 minifilter 引入跨驱动通信,复杂度和不稳定性风险大幅上升
  • 目标场景是网吧 / 教室——这些机器单次保护期内写入量小、生命周期短(每天重启)

这是已知限制,写在 README 里。如果未来真有用户反馈空间耗尽,我也不打算再补 minifilter了,到时候添加一个定时自检空闲剩余空间(检查$Bitmap),然后更新偏移表,让出空闲空间的工作项即可,但是需要注意的是这个工作项可能会有性能问题,因为它 遍历 + 删除 偏移表和空闲扇区记录表项,所以必须在被保护磁盘空间即将耗尽时调用。


五、核心数据结构

5.1 扇区位图 + 直写放行区间表

结构 含义 来源
SectorBitmapQH_BITMAP 薄包装) 扇区级占用位图(0=空闲, 1=占用) $Bitmap 簇级位图展开
ProtectRanges[8](嵌入 DEVICE_EXTENSION 直写放行扇区区间(状态文件 + $Volume MFT#3 主/镜) QHPopulateProtectRanges 初始化时填充,IRP 路径无锁只读

5.2 偏移记录表(重定向映射表)

  • 实现RTL_GENERIC_TABLE(Splay 树,O(log N) 查找)
  • :原始扇区索引 → :重定向后的扇区索引
  • 用途:读 / 写路径直接查表。命中即取目标扇区,未命中即按原位置处理

5.3 设备扩展核心字段

typedef struct _QH_DEVICE_EXTENSION
{
    BOOLEAN              Initialized;              // 是否已激活保护
    PDEVICE_OBJECT       LowerDeviceObject;        // 下层设备
    PDEVICE_OBJECT       PhysicalDeviceObject;     // PDO
    QH_NTFS_BOOT_SECTOR  NtfsBootSector;           // 卷的引导扇区
    PQH_BITMAP           SectorBitmap;             // 扇区占用位图
    QH_PROTECT_RANGE     ProtectRanges[8];         // 直写放行区间(无锁)
    ULONG                ProtectRangeCount;
    ULONG                LastScanIndex;            // 加速 FindClearBits 起点提示
    FAST_MUTEX           BitmapMutex;
    PQH_OFFSET_HASH      OffsetHash;               // 偏移记录表
    FAST_MUTEX           OffsetHashMutex;
    KSPIN_LOCK           PendingIrpQueueLock;
    LIST_ENTRY           PendingIrpQueue;
    PIO_WORKITEM         IoWorkItem;
    // ...
} QH_DEVICE_EXTENSION;

注意 ProtectRanges静态嵌入而不是指针——它在 init 阶段一次性填充、之后只读,IRP 路径上的 QHIsSectorProtected 完全无锁,省掉一对 mutex 切换。


六、写 IRP 处理流程

写IRP处理流程

写路径是整个驱动里最复杂的部分。按流程图节点逐步讲解:

步骤 1-3:早期过滤

  • 未初始化 → 透传到下层设备
  • 当前 IRQL > PASSIVE_LEVEL → 排入工作项队列稍后处理(ZwCreateFile 等 NTFS 文件 API 要求 PASSIVE_LEVEL
  • 引导扇区写保护:偏移 < BytesPerSector 直接返回 STATUS_ACCESS_DENIED。理由——引导扇区被改了就启动不了,没有任何合法理由让保护期间的应用程序去碰它

步骤 4:范围短路(fast-path)

这是一个性能关键的优化。绝大多数写 IRP 要么整段命中 ProtectRanges(状态文件 / MFT 元数据),要么整段落在空闲扇区上(新文件分配)。这两种情况都不需要重定向,直接透传就行。

慢路径每次进入要 qhalloc(Length) 分配一个 1MiB 的缓冲区 + RtlCopyMemory 拷贝用户数据——这两步开销很大。fast-path 用 RtlAreBitsClearQHAreSectorsProtected 两次扫描判断整段是否符合条件,省掉这一大块开销。

实测净收益 +5% 写性能。听起来不多,但代码只有 30 行,风险也小,留下了。

步骤 5-6:慢路径分类处理

每个扇区分三种情况:

  1. 命中 ProtectRanges → 写原始位置(状态文件、MFT 元数据直通)
  2. SectorBitmap 显示空闲 → 标位 + 写原始位置(这扇区 NTFS 视图里就是空的,重启后 $Bitmap 复原即丢弃)
  3. 已占用扇区 → 在 OffsetHashMutex原子完成"查表 → 命中即写目标 / 未命中则分配新空闲扇区 + 写哈希表"

第 3 种情况的并发安全很关键。如果把"查 hash"和"分配 + 写 hash"拆到两段不同的锁里,两个并发写同一扇区的 IRP 会各自判定"未命中"、各自分一个新扇区、各自写 hash、后写的覆盖前写的——结果一份数据落在前一个扇区永久泄露,hash 表只指向后一个扇区,前一个 IRP 的数据永久丢失

锁顺序约定:OffsetHashMutex 外,BitmapMutex 内。全代码库无反向嵌套,无死锁风险。

步骤 7:合并连续 IO

逐扇区生成的物理写入如果在磁盘上连续,合并成一次大 IO 下发。LastScanIndex 的提示策略让连续写拿到的重定向目标也是连续的,所以这一步通常能把 IO 数量降到接近 1。


七、读 IRP 处理流程

读IRP处理流程

读路径比写路径简单一截:

与写路径的关键差异

  • 没有引导扇区保护——读引导扇区合法
  • 没有"首次重定向"分支——读不创建新映射,未命中就是按原位置读
  • 慢路径只有 3 类分支ProtectRanges 命中 / OffsetHash 命中 / OffsetHash 未命中
  • 末尾多一步 RtlCopyMemory:把从独立缓冲区读到的数据拷回原 IRP 缓冲区(用独立缓冲区是为了避免原始 MDL 与自建 IRP 的 MDL 同时锁定同一物理页,避免 PFN_LIST_CORRUPT)

读路径有个 fast-path:快速扫描整段范围,只要有任一扇区没有重定向,就走慢路径。其实更严格地说,只有"整段无重定向"才能直接透传。但这个扫描本身要逐扇区查 OffsetHash,在 SEQ Q1 这种大块顺序读的场景下,1MiB / 4KiB = 256 个扇区每个都要查一次 Splay 树——这是读路径性能损失的主要来源(SEQ Q1 约 −41%)。

权衡之后还是保留了这个 fast-path,因为绝大多数顺序读都是无重定向的,不能让所有读都走慢路径分配独立缓冲区。


八、重启还原的机制

机制本身在第三节已经讲过,这里补一点细节性的观察。

重启之后会发生这几件事,按顺序:

  1. 驱动重新挂载,内存中的 SectorBitmapProtectRangesOffsetHash 全部丢失(这些都是 NonPagedPool 分配的,关机即销毁)
  2. NTFS 重新挂载卷,重新从磁盘读取 $Bitmap$MFT$Volume 等元数据
  3. 由于这些元数据自保护开启起从未被真实修改过(写入全被驱动重定向了),NTFS 读到的就是保护开启那一刻的快照
  4. 整个文件系统视图回到那一刻——文件、目录、空闲空间都一致

那些重定向期间被分配出去的目标扇区,物理上仍然在磁盘上保留着数据。但因为 $Bitmap 把它们标为空闲,NTFS 后续的写入会自然覆盖它们。无需任何主动的"清理"或"提交"步骤——这正是 COW redirect 相比 COW copy 优雅的地方。


九、性能数据

在 Win10 22H2 / NVMe SSD / 60GB 系统盘上用 CrystalDiskMark 8.0.4 实测(1 GiB × 5 轮):

项目 保护关闭 保护开启 变化
SEQ Q8 1MiB Read 1753 MB/s 1510 MB/s −14%
SEQ Q1 1MiB Read 1170 MB/s 685 MB/s −41%
RND Q32 4KiB Read 18.3 MB/s 18.5 MB/s +1%(噪声)
RND Q1 4KiB Read 11.2 MB/s 10.8 MB/s −4%
SEQ Q8 1MiB Write 673 MB/s 436 MB/s −35%
SEQ Q1 1MiB Write 916 MB/s 373 MB/s −59%
RND Q32 4KiB Write 19.2 MB/s 10.1 MB/s −47%
RND Q1 4KiB Write 10.9 MB/s 10.2 MB/s −6%

几个观察:

  • 读路径:低队列深度顺序读损失最大(SEQ Q1 约 −41%),瓶颈在逐扇区查 Splay 树。高队列深度因为并发掩盖了单次查询延迟,损失收敛到 −14%
  • 写路径:顺序大块写约 −35%,瓶颈在防御性的 1MiB qhalloc + memcpy + 每段 IO 的同步等待
  • 小 IO 几乎无损:Q1 4KiB 读写均 < 6%

没对比同类商业方案(Deep Freeze / PowerShadow 等)。这些厂商不公布基准数据,第三方独立评测也极少,没有可信参照。如果读者手头有可复现的对比数据,欢迎反馈。


十、性能优化探索记录

这一节是写完核心功能后调优阶段的真实记录。试过的几条路里只有一条留下了,其他全撤了——记录一下,免得有同样想法的人重走一遍。

10.1 留下的:写入口范围短路(+5%)

就是第六节讲的 fast-path。写 IRP 一进来先用 RtlAreBitsClear + QHAreSectorsProtected 看一眼整段是不是要么整段在 ProtectRanges 里,要么整段空闲。命中其一就直接透传,省掉 1MiB qhalloc + memcpy 和慢路径的逐扇区循环。

实测只有 +5%,比想象的少很多——大多数 CDM 测试场景下整段空闲的命中率其实不高(CDM 会先把测试文件预分配好,第二轮起所有扇区都已经被占用了,走的全是慢路径)。但代码简单、风险低、留下。

10.2 试过但撤回的

读路径范围短路:想法跟写路径对称——整段都没重定向就直接透传。结果 SEQ Q1 Read 反而退化 5–11%,没完全搞清楚原因,可能是 QHHashGet 大范围扫一遍比按需逐扇区还慢?读本来损失就只有几个百分点,不值得为这点不确定性引入修改,撤了。

慢路径锁合并:每个扇区原本要抢 3-4 次 mutex(BitmapMutex 测占用 → 释放 → OffsetHashMutex 查 hash → 释放 → …),合并成一次大临界区。理论上应该有提升,结果 CDM 测下来完全没变化。FAST_MUTEX 在这台 NVMe 上太便宜了,锁切换根本不是瓶颈。徒增代码复杂度,撤。

异步 IO 流水线:这是花时间最多的一条。写了批次结构、完成例程、引用计数、槽位限流(MAX_INFLIGHT=16),目标是让一个写 IRP 里的多个段并发跑——理论上能把总延迟从 sum(每段) 压到 max(每段)

实测 +5%。为啥这么少?因为 LastScanIndex 让连续写拿到的重定向目标也是连续的,IO 合并之后通常就剩 1 段,根本没东西可以并发。CDM 这种"预分配 + 反复重写同一段"的负载特别坑这个优化。

不是说这条路死了。"解压一堆小文件"这种天然多段的负载可能还有用,但要专门做稳定性测试才敢上,CDM 测不出来就先撤了。

10.3 几个事后想明白的事

CDM SEQ 写不能代表所有写负载。它的特点决定了控制流优化基本没用:1MiB 大块 → IO 合并几乎完美;文件预分配 + 5 轮重写 → 第二轮起全走慢路径;重定向目标连续 → 物理上还是顺序写。

真正的瓶颈不在控制流。剩下的延迟里大头是:

  • 防御性的 1MiB qhalloc + memcpy
  • 慢路径下发 IO 时 IoCallDriver + KeWaitForSingleObject 同步等待

这两个不动,光优化锁和分支没意义。

防御性缓冲区不能动。试过去掉那个 qhalloc + RtlCopyMemory,直接把父 IRP 的 MDL 系统地址传给下层——蓝屏。这条路堵死了,别再试。

10.4 还没试的方向

留给以后或者社区贡献者:

  • per-bucket 锁的开链哈希表替掉 Splay 树。理论上对并发写更友好,但 CDM 单线程测不出区别
  • ERESOURCE 替掉 FAST_MUTEX,让位图支持多读并发,Q8 多线程读可能受益
  • 真正的 IRP 流水线异步模型——工程量很大,没 WinDbg 这种调试设备就别开始

十一、已知限制与未来规划

限制

  • 仅支持 NTFS。FAT32 / exFAT 暂不支持(解析不了 $Bitmap
  • 未处理文件删除导致的空间萎缩(详见 4.7 节)
  • 状态文件可被用户手动删除——删除等同关闭保护,目前未做防删监控
  • Splay 树是临时方案,查找性能非最优

规划

  • 自实现高性能内核态哈希表替换 RTL_GENERIC_TABLE
  • minifilter 监控状态文件防误删
  • 评估对 FAT32 / exFAT 的支持

十二、源码与协议

  • GitHubhttps://github.com/qhencoder/SysRestoreDriver
  • 协议:Apache License 2.0——允许商用、闭源集成、修改后再分发。无传递性依赖,集成审计零摩擦
  • 反馈:欢迎通过 GitHub Issues 提交问题、反馈与 PR

附:作者后记

写这个驱动之前,我在用户态写了 10 年 C++。这是第一次写 Windows 内核驱动。

最大的反差感是:用户态写错了,崩溃个进程;内核态写错了,崩溃整台机器。第一个 bug 花了大半天定位——栈申请几个 WCHAR[2048],立刻蓝屏。后来才知道内核栈默认就 12KB,几个大数组就爆。

第二件让我吐血的事,是写完整个项目后才发现:Windows 有官方的驱动样例集合,挂在 https://github.com/microsoft/Windows-driver-samples。早知道有这个,至少能少绕一大圈。

第三件是,整个开发过程没用 WinDbg——单纯因为不知道有这个工具。所有的内核态调试就是"出蓝屏 → 盯着代码看 → 查资料 → 改 → 再装一次"。一个 bug 平均要这样跑十几次。等项目写完才知道双机调试是怎么回事,多少有些哭笑不得。

第四件,网上几乎没有此类驱动的开源实现可供参考。商业的 Deep Freeze、PowerShadow、影子卫士、冰点还原都是闭源的,技术细节藏得很深。写到中段一度查资料查到绝望——$Bitmap 的格式、MFT 的非驻留属性 data run 解码、FSCTL_GET_RETRIEVAL_POINTERS 的输出语义……这些资料散在 MSDN 角落、Reactos 源码、十几年前的论坛帖子里,每一个都要拼接才能用。

如果这篇文章和这个项目能让后来想做类似驱动的人少踩几个坑,那写出来就值得了。

如有问题或建议,欢迎在 GitHub 仓库 下提 issue。


Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐