问题现象
在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只读设置流程应该是三步:
对应的核心代码实现:
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缓存。这不是一个可选的优化,而是一个必须的处理步骤——缺少它,磁盘设备层和文件系统层的只读状态就会处于未对齐的状态,导致写入行为与预期不符。
