.NET Win32设置只读未对齐,导致NTFS文件系统识别异常

问题现象

在Windows平台上,通过Win32 API IOCTL_DISK_SET_DISK_ATTRIBUTES 将磁盘设置为只读后,出现了意料之外的行为:磁盘属性面板显示已只读,但NTFS文件系统仍允许写入;或者反过来,磁盘已取消只读,但NTFS仍拒绝写入。这种”磁盘层”与”文件系统层”状态不一致的现象,在iSCSI磁盘热插拔场景下尤为突出——同一LUN路径上先后挂载不同磁盘,前一块盘的只读状态会”残留”给后一块盘。

表面上看,只读设置已经成功返回,Get-Disk 也确认为只读,但实际写入操作却不受控制。问题的根源在于Windows磁盘架构的分层设计。

Windows磁盘只读的两层架构与刷新机制

Windows的磁盘只读实际上分为两层:

  • 磁盘设备驱动层:通过 IOCTL_DISK_SET_DISK_ATTRIBUTES 设置,直接修改磁盘设备对象的属性。这一层是”物理级”的,一旦设置成功,所有对该磁盘设备的I/O请求都会被拦截。
  • NTFS文件系统驱动层:NTFS在内存中维护一个VCB(Volume Control Block)结构,缓存了磁盘的只读状态。文件系统的读写判断依赖的是VCB中的缓存值,而非实时去查询磁盘设备属性。

关键问题在于:NTFS不会主动轮询磁盘属性的变化。当通过Win32 IOCTL修改了磁盘只读属性后,NTFS的VCB缓存仍然是旧值,直到某种事件触发它重新加载。

能触发NTFS重新加载磁盘属性的事件有四种:

触发方式 作用层级 副作用
磁盘Offline/Online 磁盘设备层 卷会短暂不可用,可能导致I/O错误
卷Dismount/Mount 文件系统层 卷短暂不可用,文件句柄失效
FSCTL_LOCK_VOLUME / FSCTL_UNLOCK_VOLUME 文件系统层 短暂独占,但影响最小
PnP设备事件 即插即用层 不受控,依赖硬件事件

其中,FSCTL_LOCK_VOLUME + FSCTL_UNLOCK_VOLUME 是最轻量的方案。FSCTL_LOCK_VOLUME 会强制NTFS刷新脏数据并获得独占访问权,随后的 FSCTL_UNLOCK_VOLUME 释放锁时,NTFS会重新从磁盘设备层读取最新属性并更新VCB缓存。这个过程不需要卸载卷,也不需要脱机磁盘,对业务的影响最小。

这也就解释了为什么 PowerShell Set-Disk -ReadOnly $true 看起来总能正确生效——它底层调用的是WMI的 MSFT_Disk.SetAttributes 方法(由storagewmi.dll实现),内部大概率封装了Lock/Unlock或者等效的属性刷新逻辑。而直接调用Win32 IOCTL的开发者,则需要自行处理这一层同步。

解决方案:Lock → SetReadOnly → Unlock

基于上述原理,正确的Win32只读设置流程应该是三步:

FSCTL_LOCK_VOLUME → 强制NTFS刷新脏数据,获得独占访问 ↓ IOCTL_DISK_SET_DISK_ATTRIBUTES → 设置磁盘只读属性 ↓ FSCTL_UNLOCK_VOLUME → 释放锁,NTFS重新加载最新属性到VCB

对应的核心代码实现:

 1 public static async Task<OperateResult> SetReadOnlyAsync(
 2     string volumeGuid, int diskNumber, bool isReadOnly, int timeoutSeconds = 30)
 3 {
 4     // Step 1: Lock卷 — 触发NTFS刷新脏数据 + 获取独占访问
 5     var lockResult = await LockVolumeAsync(volumeGuid, timeoutSeconds);
 6     if (!lockResult.IsResultOk || lockResult.Data == IntPtr.Zero)
 7         return OperateResult.ToError($"Lock volume failed: {lockResult.Message}");
 8 
 9     // Step 2: 设置只读 — 修改磁盘设备层属性
10     var setReadOnlyResult = await Task.Run(
11         () => SetReadOnly(diskNumber, isReadOnly)
12     ).TimeOutAsync(TimeSpan.FromSeconds(timeoutSeconds));
13 
14     if (!setReadOnlyResult.Success)
15     {
16         await UnlockVolumeAsync(lockResult.Data);
17         return setReadOnlyResult;
18     }
19 
20     // Step 3: Unlock卷 — NTFS重新加载磁盘属性到VCB缓存
21     var unlockResult = await UnlockVolumeAsync(lockResult.Data);
22     if (!unlockResult.Success)
23         return OperateResult.ToError(
24             $"SetReadOnly ok but Unlock failed: {unlockResult.Message}");
25 
26     return OperateResult.ToSuccess();
27 }

其中 LockVolume 通过卷GUID直接打开设备(如 \\?\Volume{xxx}),调用 FSCTL_LOCK_VOLUME。如果遇到 ACCESS_DENIED (0x05) 错误,说明有其他进程持有该卷上的文件句柄,需要加入重试逻辑(建议5次重试,间隔500ms):

 1 public OperateResult<IntPtr> LockVolumeByGuid(string volumeGuid)
 2 {
 3     string devicePath = volumeGuid.TrimEnd('\\');
 4     IntPtr hVolume = CreateFile(devicePath,
 5         GENERIC_READ | GENERIC_WRITE,
 6         FILE_SHARE_READ | FILE_SHARE_WRITE,
 7         IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero);
 8 
 9     if (hVolume == INVALID_HANDLE_VALUE)
10         return OperateResult<IntPtr>.ToWin32Error("CreateFile failed");
11 
12     if (DeviceIoControl(hVolume, FSCTL_LOCK_VOLUME, ...))
13         return OperateResult<IntPtr>.ToSuccess(hVolume);
14 
15     int err = Marshal.GetLastWin32Error();
16     CloseHandle(hVolume);
17     return OperateResult<IntPtr>.ToWin32Error($"FSCTL_LOCK_VOLUME failed after", err);
18 }

此外,在磁盘挂载流程中,操作顺序也很关键。应该在分配挂载点(AddAccessPath)之前完成只读设置,因为一旦挂载路径暴露给系统,第三方软件(如杀毒、索引)可能立即打开卷上的文件,导致后续Lock失败。推荐的挂载操作顺序为:

取消只读 → 扩容 → 设置磁盘标签 → 设置只读 → 分配挂载点

总结来说,直接使用Win32 IOCTL操作磁盘只读时,必须搭配 FSCTL_LOCK_VOLUME / FSCTL_UNLOCK_VOLUME 来同步NTFS文件系统的VCB缓存。这不是一个可选的优化,而是一个必须的处理步骤——缺少它,磁盘设备层和文件系统层的只读状态就会处于未对齐的状态,导致写入行为与预期不符。

文章摘自:https://www.cnblogs.com/kybs0/p/20305667