排查这种问题,大多数情况下都不是函数签名写得不对,而是调用方对“磁盘设备”这个特殊对象的理解不够。ReadFile返回FALSE,GetLastError()等于87,对应的是ERROR_INVALID_PARAMETER,翻译过来就是“参数错误”。但这个“参数”指的不是你在代码里看到的五个参数,而是Windows存储栈底层在处理这次I/O时需要满足的一组隐形条件。
这次要解决的就是这个问题。本文会从错误产生的原因讲起,拆解磁盘直读场景下必须遵守的几条规则,再给出完整的排查流程和可复现的代码写法。
1. 问题现象:ReadFile读物理磁盘失败,返回87
1.1 错误码87到底是谁报出来的
先还原一下出错现场。代码大概长这样:
HANDLE hDisk = CreateFileA("\\\\.\\PhysicalDrive0", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); BYTE buf[512] = { 0 }; DWORD bytesRead = 0; BOOL ok = ReadFile(hDisk, buf, 512, &bytesRead, NULL); if (!ok) { DWORD err = GetLastError(); // err == 87 }看到错误码87,第一反应通常是:缓冲区、句柄、长度都传了,怎么还参数错误?这个“参数”到底是什么?
在Windows内部,错误码87最终来自内核态返回的NTSTATUS状态码,映射到应用层就变成了ERROR_INVALID_PARAMETER。这句话的真正意思是:I/O请求包(IRP)在下发到存储驱动时,驱动拒绝了这个请求,理由是参数不合法。不是你的函数调用语法有问题,而是你的I/O请求在驱动眼中是一个“不合规”的请求。
磁盘设备跟普通文件不一样。普通文件通过文件系统层帮你处理了很多细节,比如缓存、偏移换算、字节粒度访问。物理磁盘这种设备对象,用户在应用层直接发ReadFile,请求几乎不经过文件系统转换,一路直达存储驱动。存储驱动按照磁盘物理模型来处理请求,它要求这次I/O必须符合“整扇区、整块、对齐”的基本规则,否则直接拒绝。
1.2 同一个87,不同的来源
需要先说清楚一个前提:不是所有“读磁盘报87”都是ReadFile造成的。实际排查中,至少有三种情况会产生同样的错误码:
| 报错API | 常见原因 |
|---|---|
| CreateFile | 设备路径不存在、打开标志组合非法 |
| DeviceIoControl | IOCTL码不匹配、输入/输出缓冲区大小错误 |
| ReadFile / WriteFile | 缓冲区地址、字节偏移、传输字节数未按扇区对齐 |
很多人拿着DeviceIoControl失败的错误码,跑去查ReadFile对齐问题,方向就偏了。所以第一步不是去改对齐,而是确认到底哪条API返回的87。如果是DeviceIoControl返回87,多半是IOCTL控制码的参数结构大小不对,比如传了sizeof(DISK_GEOMETRY_EX)却用老版本的IOCTL_DISK_GET_DRIVE_GEOMETRY去接收,缓冲区尺寸不匹配,驱动直接拒绝。如果是ReadFile返回87,那大概率就是本文要讲的存储对齐规则被违反了。
2. 根因剖析:Windows磁盘I/O的对齐三要素
2.1 一次ReadFile在存储栈里经历了什么
要理解为什么会有对齐限制,得先知道一次磁盘读取请求在内核里是怎么流动的。
应用层调用ReadFile之后,I/O管理器会构造一个IRP_MJ_READ请求,把它发送到设备对象栈。对PhysicalDrive设备来说,请求经过卷管理驱动、分区驱动,最后到达磁盘驱动和端口驱动。到了端口驱动这一层,请求会被转换成一个或者多个SCSI/ATA命令,发给硬盘控制器。
SCSI/ATA命令里对读写操作有一个非常关键的要求:它只能按照“逻辑块”为单位操作。一个逻辑块就是一个扇区,早期固定512字节,现在也有4096字节的4Kn盘。控制器根本不认识“从第100字节开始读300字节”这种描述,它只认识“从第N个扇区开始,读M个扇区”。如果应用层送下来的请求是“字节偏移100,长度300”,驱动必须要么自己做一个很昂贵的读改写/拼装操作,要么干脆拒绝。Windows存储栈选择的是后者——拒绝不满足条件的请求,返回STATUS_INVALID_PARAMETER。
用仓库搬运来类比就很形象:磁盘把数据按固定尺寸的箱子存放,搬运工只接受“从第几号箱子开始,搬几个箱子”这种指令。你要求他“从箱子的左半边开始,搬三分之二箱”,在这个规则下就是没有合法解释,直接拒单。
2.2 对齐三要素:缓冲区地址、字节偏移、传输长度
在ReadFile读取物理磁盘时,所有导致87的对齐问题都可以归到三类:
缓冲区地址对齐。传给ReadFile的接收缓冲区,起始地址必须是扇区大小的整数倍。这个要求来自I/O管理器构造MDL(内存描述符表)后的物理页映射,存储驱动会检查缓冲区起始地址是否落在扇区边界上。用new[]或者malloc分配的堆内存,默认对齐通常只有16字节,如果扇区是512字节,地址落在512边界上的概率只有1/32,所以经常会偶发失败。推荐用VirtualAlloc或者_aligned_malloc来分配,VirtualAlloc分配的内存起始地址在64KB边界上,天然满足扇区对齐要求。
字节偏移对齐。这里的偏移指“从设备起始位置算起的绝对字节偏移”。如果直接传OVERLAPPED结构,对应OV.Offset和OV.OffsetHigh组成的64位偏移值;如果用SetFilePointerEx设置文件指针,就是当前文件指针位置。这个偏移值也必须是扇区大小的整数倍。很多人的逻辑是:我要读一个扇区,扇区大小512,指定偏移0开始没问题;但当他尝试读第0扇区之后的某个字节区间时,偏移随手填了一个512中间的值,87就来了。
传输长度对齐。这个最容易被忽略。nNumberOfBytesToRead这个参数,在磁盘直读场景下也必须是扇区大小的整数倍。想只读300个字节?对不起,不行。哪怕缓冲区地址和偏移都对齐了,长度不对齐照样返回87。正确做法是读一个整扇区(或者多个扇区),然后在应用层从Buffer里截取你真正需要的部分。
2.3 缓冲I/O和非缓冲I/O的误区
有人会问:我打开物理磁盘时没有指定FILE_FLAG_NO_BUFFERING,为什么还有对齐要求?这里有个很普遍的误解。
FILE_FLAG_NO_BUFFERING的意思是绕过系统缓存,这是针对普通文件而言的。物理磁盘设备本身不是一个“带文件系统缓存”的对象,应用层直接打开\.\PhysicalDrive0时,I/O路径上没有文件系统缓存来帮你缓冲和拼装。即使你不在CreateFile里加FILE_FLAG_NO_BUFFERING,底层最终处理请求的磁盘驱动仍然按“整扇区直接I/O”模型工作。换句话说,对物理磁盘的ReadFile,对齐要求是硬性存在的,跟你在不在CreateFile里写FILE_FLAG_NO_BUFFERING关系不大。
这里给一个保守建议:如果在写磁盘直读工具,干脆在CreateFile里明确加上FILE_FLAG_NO_BUFFERING,同时严格遵守对齐三要素。这样行为更可控,出问题时原因也更集中。
2.4 扇区大小是个变量:512e和4Kn
对齐规则里提到的“扇区大小”,并不总等于512。
传统硬盘逻辑扇区512字节。后来2TB以上的盘普遍采用高级格式化,物理扇区4096字节,但通过固件模拟成512字节的逻辑扇区对外提供服务,这就是512e。在512e盘上,逻辑扇区仍然是512,底层收到的命令仍然是512字节块,所以应用层按512对齐没问题。
但4Kn盘不一样,逻辑扇区本身就是4096字节。这种盘上,你按512字节对齐去读,就算地址、偏移、长度都是512的倍数,底层还是会拒绝,因为你没按4K边界来。所以在写直读代码时,不要硬编码512,要通过IOCTL向设备查询真实扇区大小。
3. 排查与修复实操路线
3.1 先做减法:确认错误来自哪一步
排查第一步,先把问题范围缩小。建议在代码里分段检查返回值,不要一口气把CreateFile、SetFilePointerEx、ReadFile连在一起写,出了错就区分不了。
- CreateFile返回INVALID_HANDLE_VALUE时,GetLastError通常是5(拒绝访问)、32(共享冲突)、2(文件不存在)等,不是87。
- SetFilePointerEx失败时,常见错误码是ERROR_NEGATIVE_SEEK(131),也不会是87。
- ReadFile失败且GetLastError是87时,才轮到对齐三要素背锅。
如果CreateFile都返回87,请检查路径字符串是不是写成了“\\.\PhysicalDrive0”,以及设备是否存在。路径字符串多一个或少一个转义字符,都会导致打开失败。
3.2 分步排查清单:照着做就能定位
确定是ReadFile返回87之后,按下面顺序一项一项核对:
- 用DeviceIoControl查询磁盘的扇区大小,不要猜512还是4096。代码如下:
DISK_GEOMETRY geometry = { 0 }; DWORD bytesReturned = 0; BOOL ret = DeviceIoControl(hDisk, IOCTL_DISK_GET_DRIVE_GEOMETRY, NULL, 0, &geometry, sizeof(geometry), &bytesReturned, NULL); if (!ret) { // 处理DeviceIoControl失败 } DWORD sectorSize = geometry.BytesPerSector;- 检查缓冲区地址是否符合对齐要求。写一行临时日志打印取模结果:
printf("buffer addr mod sector = %zu\n", (size_t)buffer % sectorSize);只有取模结果为0才是合格的缓冲区地址。
检查偏移对齐。如果用了OVERLAPPED,打印OV.Offset和OV.OffsetHigh拼出来的64位值对sectorSize取模;如果用了SetFilePointerEx,打印文件指针对sectorSize取模。
检查传输长度。确认nNumberOfBytesToRead是sectorSize的整数倍,并且大于0。
如果以上都满足还报87,把磁盘型号和扇区大小信息一起记录下来,考虑是不是4Kn盘或者特殊虚拟磁盘设备。
3.3 完整示例:一个扇区对齐读取函数
下面给出一个亲测可用的基础版本,封装了扇区大小查询和对齐读取的完整流程:
#include <windows.h> #include <winioctl.h> #include <ntdddisk.h> #include <cstdio> #include <cstring> #include <cstdint> static bool ReadPhysicalSector(HANDLE hDisk, unsigned long long sectorIndex, DWORD sectorSize, BYTE* outSector) { if (hDisk == INVALID_HANDLE_VALUE || outSector == NULL) { return false; } // 接收缓冲区用VirtualAlloc,天然满足扇区对齐 DWORD allocSize = sectorSize; BYTE* buffer = (BYTE*)VirtualAlloc(NULL, allocSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (buffer == NULL) { return false; } LARGE_INTEGER offset; offset.QuadPart = (LONGLONG)(sectorIndex * (unsigned long long)sectorSize); if (!SetFilePointerEx(hDisk, offset, NULL, FILE_BEGIN)) { VirtualFree(buffer, 0, MEM_RELEASE); return false; } DWORD bytesRead = 0; BOOL ok = ReadFile(hDisk, buffer, allocSize, &bytesRead, NULL); if (ok) { memcpy(outSector, buffer, sectorSize); } else { DWORD err = GetLastError(); // 四个关键值全部打出来,一眼就能看出谁没对齐 printf("err=%u, bufferMod=%Iu, offsetMod=%I64u, sizeMod=%u, sectorSize=%u\n", err, (size_t)buffer % sectorSize, offset.QuadPart % sectorSize, allocSize % sectorSize, sectorSize); } VirtualFree(buffer, 0, MEM_RELEASE); return ok == TRUE; }打开物理磁盘时,建议这样写:
HANDLE hDisk = CreateFileA("\\\\.\\PhysicalDrive0", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, FILE_FLAG_NO_BUFFERING, NULL);注意FILE_FLAG_NO_BUFFERING打开后,读取长度如果不过扇区对齐,会直接触发87。上面示例里allocSize等于sectorSize,长度天然满足。如果你的业务需要一次读多个扇区,把allocSize改成sectorSize的N倍即可。
代码里那句日志是排查利器。真出问题时,四个取模值里哪一项不为0,就是哪一项没对齐。绝大多数情况下,你看到的会是bufferMod不为0,这是因为堆内存对齐不可控导致的偶发失败;也有一部分是sizeMod不为0,比如有人为了省内存只读256字节,结果也是87。
3.4 异步形态下更容易踩坑
如果用OVERLAPPED异步方式读取,代码会比同步态复杂一些。很多人的错误在于ReadFile返回FALSE时,只看GetLastError直接判定失败。
异步ReadFile返回FALSE,GetLastError等于997是正常情况,这表示操作还在后台继续。真正的成败要看GetOverlappedResult的返回值。如果GetOverlappedResult返回FALSE,GetLastError才表示最终的I/O结果,这时候才可能出现87。
异步态下对齐三要素跟同步态完全一致,只是出错时机被推迟了。我见过一个案例:开发者在CreateFile里忘了加FILE_FLAG_OVERLAPPED,却传了非空OVERLAPPED结构给ReadFile,行为变得不可预知,时不时返回87。按照文档约定,只有以FILE_FLAG_OVERLAPPED方式打开的句柄,才能配合OVERLAPPED结构使用。不要钻“好像能工作”的空子。
4. 高频报错对照表与避坑心得
4.1 一张对照表帮你看懂返回值
做磁盘工具开发,我建议把下面这张表贴在代码注释最上方:
| GetLastError | 含义 | 常见触发原因 |
|---|---|---|
| 5 | 拒绝访问 | 权限不足,未以管理员身份运行 |
| 32 | 共享冲突 | 打开磁盘时未指定FILE_SHARE_READ / FILE_SHARE_WRITE |
| 87 | 参数错误 | 对齐三要素不满足,或DeviceIoControl结构大小有误 |
| 997 | I/O挂起 | 异步ReadFile未结束,应等待GetOverlappedResult |
| 2 | 文件不存在 | \.\PhysicalDriveN里的N超出了实际磁盘数 |
这张表能省很多时间。遇到87先检查对齐;遇到5先检查权限;遇到32先看ShareMode。别一上来就怀疑是Windows自身bug。
4.2 避坑:PhysicalDrive和卷路径的偏移不是一回事
不少人读物理磁盘报错修好之后,又发现读出来的数据不对劲。这里还有个隐藏坑:\.\PhysicalDrive0和\.\C:是两类完全不同的设备视图。
\.\C:是卷设备,ReadFile的偏移0对应的是卷的起始扇区,不是物理硬盘的0号扇区。\.\PhysicalDrive0是整块物理磁盘,偏移0对应硬盘第一个扇区,也就是我们常说的MBR或者GPT头所在位置。
如果硬盘上有两个分区,第一个分区通常从偏移2048扇区开始。你要读第一个分区里的某个扇区,用PhysicalDrive0时逻辑偏移要加上2048个扇区;用卷路径时直接从0开始。两个视角混着用,读出来的数据完全对不上,但程序不一定报错。所以定位到这个阶段时,先确认自己打开的是哪一种设备视图。
4.3 关于“以前没事”的三种解释
排查中经常会听到一种反馈:“我这个代码以前一直好的,什么都没改,怎么现在报87了。”
根据我踩过的坑,绝大多数“以前没事”都可以归结为三种情况:
第一种是缓冲区地址靠运气。用堆内存做缓冲区,16字节对齐下碰512扇区,有1/32的机会地址落在512边界上。也就是说地址不对齐时有31/32的概率报87,但确实存在1/32的运气路径能够成功。以前跑通的那几次,可能就是运气好。
第二种是磁盘换了。原来用512字节逻辑扇区的盘,程序按512对齐写死了,加了FILE_FLAG_NO_BUFFERING也没事。后来换了4Kn盘,扇区大小变成4096,所有按512对齐的逻辑瞬间全部踩雷。
第三种是调用路径变了。以前走的是文件系统路径,中间有缓存层帮你收拾各种非对齐请求;现在换成物理磁盘直读路径,缓存层被绕开,底层对齐需求直接暴露出来了。从代码改动上看可能什么都没变,但运行时路径已经完全不一样。
顺着这三个方向去查,大多数“莫名其妙”的87都能给出合理解释。
真正稳妥的做法是:把这套对齐逻辑封装成工具函数,每次打开设备后动态查询扇区大小,再用VirtualAlloc分配缓冲区,偏移和长度都先取模校验再发起读取。把这个流程做成习惯,这类问题就不会再回来找你了。