1. 错误87的现场还原:一个看起来很正常的ReadFile突然翻车
ReadFile读取物理磁盘时给我甩了个参数错误(87),这是我第一次做物理盘镜像工具时遇到的事。需求其实不复杂:打开\\.\PhysicalDrive0,跳过文件系统,直接把整块盘的数据读出来备份。CreateFile成功、DeviceIoControl查几何信息也成功,偏偏到了ReadFile这一步,返回FALSE,GetLastError一查是87——ERROR_INVALID_PARAMETER。望着代码里那几个参数,句柄是好的,缓冲区也new出来了,长度512字节,怎么想都不该报参数错误。
后来吃了几次亏才明白,物理磁盘的ReadFile和普通文件的ReadFile,虽然函数签名一模一样,底层对参数的要求完全是两套逻辑。这篇文章就把我从报错到定位、最后彻底解决问题的全过程拆开讲一遍,给还在被错误87卡住的朋友一条可以照着走的路。内容主要面向Windows平台做磁盘取证、数据恢复、底层备份工具开发的开发者;如果你只是想读普通文件,这篇文章的方法可以作为背景知识看看。
1.1 先确认87到底是谁的锅
GetLastError返回87,对应ERROR_INVALID_PARAMETER,意思是调用方传给函数的某个参数不被接受。放在ReadFile上,候选参数就五样:设备句柄、数据缓冲区、要读的字节数、实际读到的字节数、OVERLAPPED结构。看起来很简单,但物理磁盘这类设备句柄,Windows存储驱动栈对“合法参数”的定义跟文件系统完全不同。
这里有一个特别容易踩的坑:项目里常有人在前一步刚调完DeviceIoControl,紧接着就去调ReadFile,随后检查GetLastError,结果拿到的是DeviceIoControl失败留下的87,而不是ReadFile自己的。所以排查的第一步,先把错误码来源钉死。最简单的做法是在ReadFile前主动SetLastError(0),在ReadFile返回FALSE之后立刻取一次GetLastError,并且保证DeviceIoControl那一段的返回值先被单独记录。
1.2 典型的报错现场长什么样
我自己遇到的报错现场很典型,当时的参数是这样的:
- 句柄:
CreateFileA("\\\\.\\PhysicalDrive0", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL),成功。 - 扇区信息:
DeviceIoControl(IOCTL_DISK_GET_DRIVE_GEOMETRY_EX)成功,返回的BytesPerSector是512。 - 缓冲区:
new char[512],拿到一个普通堆地址。 - 请求:
ReadFile(hDisk, buffer, 512, &bytesRead, NULL)。
结果就是FALSE + 87。换成4096再试,这台机器上成功了;换一块4Kn盘,还是512,依然87。更迷惑的是,同一块4Kn盘,用现成的镜像工具能读。这基本可以锁定了问题不是权限、不是句柄,而是ReadFile的参数细节不符合物理磁盘驱动对扇区对齐的要求。
| 观测项 | 结果 | 线索 |
|---|---|---|
| CreateFile | 成功 | 打开方式中的低级错误可以排除 |
| IOCTL查询几何信息 | 成功 | 管理员权限和基本设备访问正常 |
| ReadFile(512, 普通new缓冲区) | FALSE, 87 | 参数层的拒绝 |
| ReadFile(4096, 普通new缓冲区) | 时好时坏 | 与扇区大小、地址对齐有关 |
| 第三方镜像工具读取同一块盘 | 成功 | 硬件本身可用,排除介质问题 |
如果你也遇到过类似的“时好时坏”,别急着怀疑人品,大概率就是下面要说的扇区对齐和缓冲区对齐问题。
2. 正本清源:ReadFile对物理磁盘的对齐要求不是玄学
2.1 磁盘I/O的“格子”模型
物理磁盘本质上是一个按固定大小逻辑块寻址的设备,ATA/SCSI命令天然要求起始LBA和传输长度必须是逻辑块大小的整数倍。Windows存储驱动栈接到ReadFile后,会把用户请求封装成IRP,再往下找适配器、端口驱动,最终变成一条READ命令。如果用户给的起始位置或长度没有落在逻辑块的边界上,驱动连IRP都构造不完整,直接向上返回STATUS_INVALID_PARAMETER,也就是用户态看到的87。
可以这么理解:磁盘是一个只有整齐格子的快递柜,每个格子大小固定,存取必须整格整格来。你非要从格子的1/4处取半个格子的东西,柜子只能回你“操作参数无效”。
2.2 扇区大小不是想当然的512
我做这个工具那会儿,市面上还有不少教程默认512字节。传统HDD确实大多是512B逻辑扇区,但两种盘很容易翻车:
- 4Kn盘:原生4KB逻辑扇区,512字节请求不合法,直接87。
- 512e盘:物理4K但模拟512逻辑扇区,512请求能过,但每次写跨物理扇区边界时性能极差。
- 某些虚拟磁盘、U盘主控可能返回512或4096,不能从产品参数猜。
所以读取长度绝不能拍脑袋,必须向设备查询。用DeviceIoControl(hDisk, IOCTL_DISK_GET_DRIVE_GEOMETRY_EX, ...),从返回的DISK_GEOMETRY_EX.Geometry.BytesPerSector拿逻辑扇区大小;想拿物理扇区大小做更优对齐,可以用IOCTL_STORAGE_QUERY_PROPERTY查询STORAGE_ACCESS_ALIGNMENT_DESCRIPTOR。
| 磁盘类型 | 逻辑扇区大小 | 512B读请求 | 4096B读请求 |
|---|---|---|---|
| 传统HDD | 512 | 成功 | 成功 |
| 512e盘 | 512 | 成功 | 成功,性能通常更好 |
| 4Kn盘 | 4096 | 多数被拒,87 | 成功 |
| 部分虚拟化磁盘 | 512 | 成功 | 成功 |
注意:个别主控面对非对齐长度会选择静默补齐,让错误现象“时有时无”。千万不要依赖这种兼容性行为,必须主动对齐。
2.3 缓冲区地址对齐的硬性约束
长度对齐只解决一半问题。使用FILE_FLAG_NO_BUFFERING打开设备时,用户缓冲区地址本身必须按扇区大小对齐。这是官方明确写的。原因在于,无缓冲IO下系统不会帮你做用户缓冲和内核缓冲之间的拷贝,驱动要直接把用户内存页锁在物理内存里,生成MDL交给设备DMA。DMA要求内存页对齐到扇区边界;如果地址不对齐,底层会直接拒绝本次请求。
很多新手栽在这里:buffer用new[]分配,得到的是类似0x0145F930这种地址,不是4KB倍数,于是无论长度怎么改都87。VirtualAlloc分配的内存在页边界上,天然满足扇区对齐,所以更稳。
即便是没有指定FILE_FLAG_NO_BUFFERING,在对物理磁盘句柄做ReadFile时,最稳妥也最省心的做法就是始终按无缓冲的思想来处理:缓冲区地址按扇区/页对齐、长度按扇区倍数、偏移也按扇区倍数。这样既满足无缓冲模式下的硬性要求,又避开了某些存储驱动在普通缓冲模式下对MDL锁页的额外校验。实践中最常见的结果就是:malloc不保证对齐,所以在某些盘、某些驱动版本上时好时坏。
2.4 为什么同样代码有的盘成功有的失败
把上面几件事组合起来,前面碰到的“玄学”就完全说得通了:
- 传统512盘 + 512长度 + 恰好页对齐的堆地址:成功。
- 4Kn盘 + 512长度:起始和长度不在逻辑块边界,必87。
- 普通
new地址未页对齐 + 请求跨页:可能87。 VirtualAlloc+ 扇区大小整数倍 + 扇区对齐偏移:全通过。
所以这段经验可以浓缩成一句话:对物理磁盘做ReadFile,不要把它当普通文件,要把它当成一块“格子柜”,长度、地址、偏移三个维度都必须落在格子上。
3. 藏在打开方式里的坑:句柄权限、共享模式和先调DeviceIoControl的误导
3.1 CreateFile打开物理磁盘的正确姿势
打开物理盘的标准写法是这样:
HANDLE hDisk = CreateFileA( "\\\\.\\PhysicalDrive0", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, FILE_FLAG_NO_BUFFERING | FILE_FLAG_OVERLAPPED, NULL );这里有两个容易忽略的点。
第一,物理盘句柄需要管理员权限。权限不足时典型错误是5(拒绝访问),而不是87。看到87先别怀疑打开方式,但也不能完全不排查打开方式。
第二,共享模式一般要用FILE_SHARE_READ | FILE_SHARE_WRITE。因为系统里已经有卷被文件系统占用,物理盘句柄仍然能打开,靠的就是共享模式允许其他句柄同时访问。如果只给FILE_SHARE_READ,磁盘正在被卷或杀毒软件独占时,CreateFile可能直接失败。反过来,如果你把共享模式设成0,等于对整个物理盘上锁,其他程序包括系统卷都可能会被影响,这种操作是自找麻烦。
3.2 共享模式影响的是句柄打开而不是ReadFile
这句话的意思是:打开失败时,GetLastError一般不是87,而是5或32。所以很多“87问题”文章里让你改CreateFile参数,其实并不能直接解决87,最多是解决了另一层隐患。
真正要记住的是:用FILE_FLAG_NO_BUFFERING打开物理盘后,文件系统过滤驱动也可能参与判断。某些安全软件会拦截物理盘读取,返回一个不预期的状态码。如果对齐、偏移都检查过仍然87,可以考虑在干净环境里复现,或者观察是不是只有装了特定安全软件的那台机器才出问题。
3.3 DeviceIoControl查询阶段已经把错误码污染了
这是个非常隐蔽的坑。很多人读物理盘之前会先用DeviceIoControl咨询几何参数,比如:
DeviceIoControl(hDisk, IOCTL_DISK_GET_DRIVE_GEOMETRY_EX, ...);如果这个调用本身失败了,而且失败原因同样是87——比如STORAGE_PROPERTY_QUERY结构字段填错、输出缓冲区给太小——那紧接着的ReadFile即使成功,也可能因为GetLastError没有更新而让你误判。更常见的是:ReadFile确实失败,但你在日志里看到的87其实是DeviceIoControl留下的。
如果代码里各个API共用一个错误码变量,那更要命。我的建议是:每个系统调用后立刻把GetLastError保存到独立变量,并且按“哪一步出错就停在哪一步”的方式排查,别让错误串台。
3.4 物理盘句柄不等于卷句柄
\\.\PhysicalDrive0代表整个物理设备,\\.\C:代表某个卷。对物理盘ReadFile读出的扇区偏移是相对物理盘起始的,与文件系统的簇、文件偏移没有任何关系。
这一点看起来简单,实际操作中经常让人绕进去:想从物理盘里解析某个文件,不能直接用文件偏移去读物理盘,必须先解析MBR/GPT分区表,再定位文件系统元数据。本文讨论的是最外层扇区读取,但如果你后续要做文件级恢复,一定要把“物理扇区→分区→文件系统簇”这条链路单独理清。
4. 想读指定扇区?OVERLAPPED偏移、大磁盘寻址和对齐内存一个都不能少
4.1 为什么建议用OVERLAPPED
读一个扇区用同步ReadFile也能工作,但做整盘镜像通常要读大量扇区,而且磁盘容量动辄数TB,光靠SetFilePointer加同步读很容易写出性能很差的代码。
用OVERLAPPED的典型准备代码:
OVERLAPPED ov = {0}; ov.hEvent = CreateEvent(NULL, TRUE, FALSE, NULL); LARGE_INTEGER offset; offset.QuadPart = (LONGLONG)lba * sectorSize; // lba是扇区号 ov.Offset = offset.LowPart; ov.OffsetHigh = offset.HighPart;特别提醒:lba * sectorSize这个乘法一定要用64位整数,很多人在32位环境里用DWORD乘,结果地址绕回2GB以内,读出来的数据完全不对,或者直接87。
4.2 偏移不对齐也会被拒绝
我在排查中做过这个实验:从偏移1开始读512字节,返回87;把偏移改成0或512的整数倍,一切正常。原因和长度一样——底层ATA/SCSI命令要求起始LBA是整数,偏移一旦落在扇区中间,驱动栈直接判定参数不合法。
所以哪怕你只是想跳过磁盘头部的某几个字节,也必须先对扇区大小取整,再把偏移设为扇区整数倍。多读一点数据,然后在用户态的内存里裁剪,千万不要试图让ReadFile帮你做非对齐读取。
4.3 ERROR_IO_PENDING不是错误
这是异步IO最常见的新手坎。使用OVERLAPPED后,ReadFile返回FALSE并不代表失败,你必须检查GetLastError:
- 如果等于
ERROR_IO_PENDING,说明请求已经进入排队,稍后会完成。 - 如果等于87或者其他错误码,才是真失败。
正确等待方式:
BOOL ok = ReadFile(hDisk, buffer, bytesToRead, NULL, &ov); if (!ok && GetLastError() == ERROR_IO_PENDING) { if (WaitForSingleObject(ov.hEvent, 10000) == WAIT_OBJECT_0) { DWORD bytesRead = 0; ok = GetOverlappedResult(hDisk, &ov, &bytesRead, FALSE); if (ok) { // bytesRead才是真正读到的字节数 } } }事件对象建议在每次发起ReadFile前ResetEvent一次,避免上一次信号残留导致WaitForSingleObject立刻返回,然后GetOverlappedResult读到的是一个过期结果。
4.4 分配对齐缓冲区的几种方式对比
| 分配方式 | 对齐保证 | 是否推荐 |
|---|---|---|
malloc/new char[] | 通常16字节,不保证页对齐 | 不推荐 |
_aligned_malloc(len, 4096) | 可指定4096对齐 | 可用,记得配对_aligned_free |
VirtualAlloc(NULL, len, MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE) | 页对齐 | 最推荐 |
VirtualAlloc返回的地址天然落在页边界上,也就是4KB对齐。对于绝大多数磁盘扇区(512/4096)都满足要求。如果你遇到的是8K甚至更大逻辑扇区的企业级设备,页对齐依然是它的整数倍,所以兼容性最好。
还有一个细节:缓冲区大小也要是扇区整数倍。比如用VirtualAlloc分配了4096字节,一次ReadFile就传4096;不要只传512,否则还是会被驱动拒掉。
5. 从复现到修复:完整排查链路与验证实验
5.1 把问题从“玄学”变成可复现的实验
我建议把所有变量固定下来,做一个控制变量的实验矩阵。下面是我当时的一组数据,磁盘是4Kn逻辑扇区的SATA SSD,读取目标为LBA 0:
| 实验 | 长度 | 缓冲区分配 | 偏移 | 结果 | 结论 |
|---|---|---|---|---|---|
| 1 | 512 | new char[] | 0 | FALSE 87 | 长度不是4K扇区整数倍 |
| 2 | 4096 | _aligned_malloc(4096, 16) | 0 | FALSE 87 | 地址只有16字节对齐,不满足页/扇区对齐 |
| 3 | 4096 | VirtualAlloc | 0 | TRUE | 地址页对齐后通过 |
| 4 | 8192 | VirtualAlloc | 0 | TRUE | 扇区整数倍任意长度都可以 |
| 5 | 4096 | VirtualAlloc | 512 | FALSE 87 | 偏移未对齐 |
| 6 | 4096 | VirtualAlloc | 4096 | TRUE | 偏移对齐后正常 |
实验2不是每次都失败,因为_aligned_malloc(4096, 16)如果分配的地址末尾刚好凑上4096倍数,还是会成功。这种“碰运气”正是很多人觉得物理盘读取时好时坏的原因。通过这样的实验矩阵,你就能快速定位到底是长度、地址还是偏移的问题。
5.2 一套可以直接照做的逐级排查顺序
- 在ReadFile前调用
SetLastError(0),ReadFile返回FALSE后立刻取GetLastError,确认这个87确实来自ReadFile。 - 查询逻辑扇区大小:优先
IOCTL_DISK_GET_DRIVE_GEOMETRY_EX,取Geometry.BytesPerSector。 - 把所有读取长度改成扇区大小的整数倍,建议一次读64KB(128个512B扇区)起步。
- 把缓冲区改成
VirtualAlloc分配,并确认长度也是扇区整数倍。 - 如果要指定偏移,用OVERLAPPED,并让
offset.QuadPart等于lba * sectorSize。 - 如果仍然87,回头检查DeviceIoControl是否先失败、CreateFile是否真的成功、句柄是否被过滤驱动拦截。可以借助进程监视工具观察句柄上的访问掩码。
每一步都尽量打印一句话日志,比如“打开句柄成功”“扇区大小=4096”“ReadFile失败,code=87”。这点日志成本很低,却能避免错误码串台。
5.3 不同错误码别混为一谈
| 错误码 | 含义 | 常见场景 |
|---|---|---|
| 5 | 拒绝访问 | 权限不够,或句柄没有所需访问权 |
| 21 | 设备未就绪 | 移动介质未就绪、光驱没放盘 |
| 23 | 数据错误(CRC) | 磁盘物理坏道,读取数据校验失败 |
| 32 | 共享冲突 | 句柄打开时共享模式不对 |
| 87 | 参数错误 | 长度、地址、偏移未对齐,或参数本身不合法 |
| 997 | IO_PENDING | 异步IO正常排队,不是错误 |
把错误码和本文讨论的“对齐”问题分开看,能节省大量排查时间。遇到997千万别当87处理。
6. 可以直接抄的C++修复模板和最终几点经验
6.1 完整模板
#include <windows.h> #include <winioctl.h> #include <cstdio> bool QuerySectorSize(HANDLE hDisk, DWORD& sectorSize) { DISK_GEOMETRY_EX geom = {0}; DWORD returned = 0; if (DeviceIoControl(hDisk, IOCTL_DISK_GET_DRIVE_GEOMETRY_EX, nullptr, 0, &geom, sizeof(geom), &returned, nullptr)) { sectorSize = geom.Geometry.BytesPerSector; return true; } return false; } int main() { const char* path = "\\\\.\\PhysicalDrive0"; HANDLE hDisk = CreateFileA( path, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_FLAG_NO_BUFFERING | FILE_FLAG_OVERLAPPED, nullptr ); if (hDisk == INVALID_HANDLE_VALUE) { printf("CreateFile failed, code=%lu\n", GetLastError()); return 1; } DWORD sectorSize = 0; if (!QuerySectorSize(hDisk, sectorSize) || sectorSize == 0) { printf("Query geometry failed, use default 512\n"); sectorSize = 512; } const DWORD sectorsPerIo = 128; const DWORD bytesToRead = sectorSize * sectorsPerIo; char* buffer = (char*)VirtualAlloc( nullptr, bytesToRead, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE ); if (!buffer) { printf("VirtualAlloc failed, code=%lu\n", GetLastError()); CloseHandle(hDisk); return 1; } OVERLAPPED ov = {0}; ov.hEvent = CreateEventA(nullptr, TRUE, FALSE, nullptr); if (!ov.hEvent) { printf("CreateEvent failed, code=%lu\n", GetLastError()); VirtualFree(buffer, 0, MEM_RELEASE); CloseHandle(hDisk); return 1; } ULONGLONG totalRead = 0; LARGE_INTEGER offset; offset.QuadPart = 0; while (offset.QuadPart < 0x10000000000ULL) { // 示例:最多读到1TB ResetEvent(ov.hEvent); ov.Offset = offset.LowPart; ov.OffsetHigh = offset.HighPart; DWORD bytesRead = 0; BOOL ok = ReadFile(hDisk, buffer, bytesToRead, nullptr, &ov); if (!ok && GetLastError() == ERROR_IO_PENDING) { WaitForSingleObject(ov.hEvent, 10000); ok = GetOverlappedResult(hDisk, &ov, &bytesRead, FALSE); } if (!ok) { printf("ReadFile failed at offset=%lld, code=%lu\n", offset.QuadPart, GetLastError()); break; } if (bytesRead == 0) { break; } totalRead += bytesRead; // 这里把 buffer 里的 bytesRead 字节写入镜像文件 offset.QuadPart += bytesRead; if (bytesRead < bytesToRead) { break; // 已读到磁盘末尾 } } printf("total read: %llu bytes\n", totalRead); CloseHandle(ov.hEvent); VirtualFree(buffer, 0, MEM_RELEASE); CloseHandle(hDisk); return 0; }这个模板直接跑通后,你可以在“这里把buffer写入镜像文件”的位置落盘,或者改成回调函数处理每个扇区块。
6.2 模板里的几个重要决策
为什么用FILE_FLAG_NO_BUFFERING | FILE_FLAG_OVERLAPPED?前者让对齐规则由我们完全掌控,后者便于后续做并发读取。两者叠加是物理盘读取的常见组合。
为什么一次读128个扇区而不是1个?因为单扇区读取的IO次数太多,性能很差;再往上加也不建议,64KB到1MB之间是比较常见的折中。反正只要保持扇区整数倍,长度随便选。
为什么句柄只给GENERIC_READ?这个工具的职责就是只读镜像,没必要请求写权限。某些DeviceIoControl控制码可能需要额外权限,那就再按需追加GENERIC_WRITE,但从最小权限原则出发,只读任务别上来就全开。
提示:把程序拷到别的机器测试时,记得提醒对方用管理员权限运行,否则很可能在CreateFile阶段就返回5。
6.3 最后几点经验
我这套工具做完后,后续又写过几个类似的扇区级读取小程序,基本再没被87卡住过。核心就三条:先查扇区、再上VirtualAlloc、偏移长度一起对齐。只要这三条都满足,错误87基本不会再来打卡。
还有一个经验是:物理盘读取虽然能拿到原始扇区,但读到系统盘时,文件系统正在写数据,你拿到的扇区内容可能是“半新半旧”。对数据恢复和取证场景,最稳的做法是把盘接到只读环境,或者配合卷影拷贝机制,不要在操作系统正常运行时直接拿物理盘数据充当一致性快照。
如果你只是想读某个文件内容,就别走物理盘这条路了,老老实实用CreateFile打开文件路径、ReadFile读文件,简单又安全。物理盘ReadFile是给底层任务准备的,不是给普通文件读取准备的。这两条路分清楚,能省下很多莫名其妙的调试时间。