磁盘、分区和卷的对象名与符号链接
一般地,如果略去复杂的过滤驱动,读写文件时控制流会依次经过文件系统驱动(ntfs.sys或fastfat.sys等)、卷管理驱动(volmgr.sys)、分区管理驱动(partmgr.sys)、磁盘类驱动(disk.sys和classpnp.sys)、存储设备端口驱动(storport.sys、scsiport.sys和ataport.sys三者之一)和小端口驱动(storachi.sys、stornvme.sys或usbstor.sys等)等。系统启动或插入移动存储设备时会创建一系列设备对象和符号链接,有多个驱动参与了创建这些东西的过程:
- disk.sys创建磁盘的功能设备对象(functional device object, FDO),其对象名形如
\Device\Harddisk*\DR*
,其中“*”是一个数字,比如\Device\Harddisk1\DR3
,这类设备对象的DEVICE_OBJECT.DeviceExtension
域是FUNCTION_DEVICE_EXTENSION
结构的指针,由classpnp.sys维护。然后在目录\Device\Harddisk*\
下创建符号链接Partition0
指向该DR*
;创建名称形如\DosDevices\Disk{GUID}
的符号链接,比如Disk{5ad84865-b2e7-5970-a28c-456e76d01240}
,以及\DosDevices\PhysicalDrive*
,也指向该DR*
。详情可参考disk!DiskCreateFdo
和disk!DiskCreateGuidSymbolicLink
的源码。 - 卷管理驱动volmgr.sys创建分区的设备对象,名字形如
\Device\HarddiskVolume*
,其DEVICE_OBJECT.DeviceExtension
域是结构VM_VOLUME_EXTENSION
的指针(可惜pdb中没有定义);然后创建指向该设备对象的符号链接,名称形如\Device\Harddisk*\Partition*
、\Device\Harddisk*Partition*
和\DosDevices\HarddiskVolume*
,详情见volmgr!VmpCreateLegacyNameLinks
函数的源码。 - 挂载点管理驱动mountmgr.sys创建指向分区设备对象的符号链接,形如
\DosDevices\X:
、\DosDevices\Volume{GUID}
,前者称为drive letter name,后者称为unique volume name,例如\DosDevices\C:
和\DosDevices\Volume{7ad0241e-da75-412d-848c-8c61b4fe5a15}
,我们平时口头常说的C盘、D盘,其实就是drive letter name的体现。 - 存储设备端口驱动创建磁盘的物理设备对象(physical device object, PDO),Windows中有三个存储设备端口驱动。
(1) SCSI端口驱动(scsiport.sys),它创建适配器设备(adapter device)和逻辑单元设备(logical unit device)两种,名称分别形如\Device\Scsi\XXX
和\Device\Scsi\XXXPort*Path*Target*Lun*
,其中“XXX”表示小端口驱动的服务名,“*”是数字,比如\Device\Scsi\vmscsi
和\Device\Scsi\vmscsiPort0Path0Target0Lun0
。这两种设备的DEVICE_OBJECT.DeviceExtension
域分别是ADAPTER_EXTENSION
和LOGICAL_UNIT_EXTENSION
结构的指针。详见scsiport!SpCreateAdapter
和scsiport!SpCreateLogicalUnit
的源码。
(2) ATA端口驱动(ataport.sys),在vista引入,它创建PDO和FDO两种设备,名称分别形如\Device\Ide\IdeDeviceP*T*L*-*
和\Device\Ide\IdePort*
,其中“*”是一个数字。MSDN中提到该端口驱动未来可能会淘汰掉。详见ataport!DeviceAllocatePdo
和ataport!ChannelAddDevice
的源码。
(3) Storport驱动(storport.sys),在server 2003引入,并且MSDN推荐使用该端口驱动而不是以上两种(意思就是鼓励开发者编写存储设备小端口驱动时采用storport的模型)。本机(Windows 10 20H2)就直接不加载前两者,全使用storport。Storport.sys创建适配器设备(adapter device)和单元设备(unit device),DEVICE_OBJECT.DeviceExtension
域分别是RAID_ADAPTER_EXTENSION
和RAID_UNIT_EXTENSION
结构的指针。两种其对象名形如\Device\XXXXXXXX
和\Device\RaidPort*
,其中“XXXXXXXX”是8位十六进制数,比如000000F3。这个数字怎么确定的呢?storport调用nt!IoCreateDevice
时,给DeviceCharacteristics
参数加上了FILE_AUTOGENERATED_DEVICE_NAME
标志,于是IoCreateDevice
函数便会将设备名定为\Device\XXXXXXXX
,其中该十六进制数来自IopUniqueDeviceObjectNumber
变量。
我们一般使用Win32 API CreateFile
来打开文件,并传入形如C:\dir1\dir2\file
的文件全路径名,这样的全路径名称为DOS路径名(DOS path name)。该函数会调用RtlDosPathNameToRelativeNtPathName_U_WithStatus
函数将对象管理器能够识别的NT路径名。比如C:\dir1\dir2\file
就会转换为\??\C:\dir1\dir2\file
。实际上,\DosDevices
是指向\GLOBAL??
的符号链接,同时对象管理器会将\??
解析为\GLOBAL??
。CreateFile
函数拿着NT路径名调用NtCreateFile
,后者进入内核之后控制流走到IopCreateFile -> ObOpenObjectByName -> ObpLookupObjectName
,ObpLookupObjectName
解析该路径名,进入根目录,进入GLOBAL??
目录,发现C:
是一个符号链接,指向\Device\HarddiskVolume3
(举例子,不一定是3),然后再次返回根目录,进入Device
目录,发现HarddiskVolume3
是一个设备对象,于是把路径名交给设备对象的名称解析例程——I/O管理器的IopParseDevice
函数来解析,该函数构造一个IRP,发给DEVICE_OBJECT.Vpb.DeviceObject
,这是文件系统驱动的设备,所以剩余的路径就由文件系统驱动来解析。
打开设备对象
网上的代码常使用形如\\.\PhysicalDrive*
的路径名打开物理磁盘,该路径会被转换成\DosDevices\PhysicalDrive*
,这当然没有问题。但如果你想用CreateFile
函数直接打开设备对象\Device\Harddisk*\DR*
,可以把DOS路径名写成\\.\GLOBALROOT\Device\Harddisk*\DR*
,这是因为\GLOBAL??
目录下有一个名为GLOBALROOT
的符号链接,指向对象根目录。当然也可以用NtOpenFile
直接打开设备对象。
// 这些结构体和函数的定义,MSDN和WDK中都有
// NtOpenFile函数位于ntdll.dll中,链接时要包含它的静态库
HANDLE hDrive;
IO_STATUS_BLOCK iosb;
UNICODE_STRING uname;
NTSTATUS status;
OBJECT_ATTRIBUTES oa;
// 以\Device\Harddisk1\DR3为例
RtlInitUnicodeString(&uname, L"\\Device\\Harddisk1\\DR3");
// oa.Attributes = OBJ_CASE_INSENSITIVE: 路径名不区分大小写
InitializeObjectAttributes(&oa, &uname, OBJ_CASE_INSENSITIVE, 0, 0);
// FILE_SYNCHRONOUS_IO_NONALERT: 后续的每一次I/O完成后,函数才返回(需要指定SYNCHRONIZE权限)
// FILE_NO_INTERMEDIATE_BUFFERING: 就查个序列号,又不读写磁盘,就不用缓存了
// FILE_NON_DIRECTORY_FILE: 这是设备对象,不是文件夹,文件系统不要多虑了
// 关于FILE_READ_DATA和FILE_WRITE_DATA,请参阅下面
status = NtOpenFile(
&hDrive,
FILE_READ_DATA | FILE_WRITE_DATA | SYNCHRONIZE,
&oa, &iosb,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
FILE_SYNCHRONOUS_IO_NONALERT | FILE_NO_INTERMEDIATE_BUFFERING | FILE_NON_DIRECTORY_FILE
);
// 返回值是NTSTATUS,具体代表什么意思可以去ntstatus.h里查,MSDN上也有索引
if (!NT_SUCCESS(status)) {
printf_s("[%d] NtOpenFile failed, status = %x\n", __LINE__, status);
return;
}
// 执行到这里,hDrive就是\\Device\\Harddisk1\\DR3的句柄
使用Win32 API CreateFile
打开设备对象的代码略去,思路是类似的。
这里要注意打开文件设置的DesiredAccess,后续进行DeviceIoControl时,大部分control code都使用CTL_CODE
宏来定义
#define CTL_CODE(DeviceType, Function, Method, Access) ( \
((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method))
例如
#define IOCTL_MOUNTMGR_QUERY_POINTS \
CTL_CODE(MOUNTMGRCONTROLTYPE, 2, METHOD_BUFFERED, FILE_ANY_ACCESS)
#define IOCTL_SCSI_PASS_THROUGH_DIRECT \
CTL_CODE(IOCTL_SCSI_BASE, 0x0405, METHOD_BUFFERED, FILE_READ_ACCESS | FILE_WRITE_ACCESS)
注意CTL_CODE
宏的第四个参数,即control code第14、15位,如果指定了FILE_READ_ACCESS
(第14位为1),打开文件时就需要加上FILE_READ_DATA
权限;如果指定了FILE_WRITE_ACCESS
(第15位为1),打开文件时就需要加上FILE_WRITE_DATA
权限。
最通用的获取方法
IOCTL_STORAGE_QUERY_PROPERTY
这个控制码可以获取存储设备的很多信息,详情参考MSDN。但它有两个缺点,一是获取的信息不如指定协议的时候全面,二是不知道为什么,对支持ATA协议的设备,获取到的序列号与特定方法总是不一样,对支持NVMe协议的设备,获取到的是其存储单元的EUI64/GUID而不是控制器序列号。
// 打开设备对象的过程略去,得到句柄hDrive
STORAGE_PROPERTY_QUERY spq;
// 空间给多一点,就不用调用两次DeviceIoControl了
PSTORAGE_DEVICE_DESCRIPTOR psdd = (PSTORAGE_DEVICE_DESCRIPTOR)malloc(1024);
if (!psdd) {
CloseHandle(hDrive);
return;
}
spq.PropertyId = StorageDeviceProperty;
spq.QueryType = PropertyStandardQuery;
BOOL ret = DeviceIoControl(
hDrive, IOCTL_STORAGE_QUERY_PROPERTY,
&spq, sizeof(STORAGE_PROPERTY_QUERY),
psdd, 1024, 0, 0
);
CloseHandle(hDrive);
if (!ret) {
printf_s("[%d] DeviceIoControl: %x\n", __LINE__, GetLastError());
free(psdd);
return;
}
// Product ID, Serial Number, Vendor ID, Product Revision不一定全都存在
PSTR str;
if (psdd->ProductIdOffset) {
str = (PCHAR)psdd + psdd->ProductIdOffset;
// 虽然这些字符串都以空字符结尾,但可能以空格开头,把它们trim掉
while(*str && *str == ' ') str++;
printf_s("Product ID: %s\n", str);
}
if (psdd->SerialNumberOffset) {
str = (PCHAR)psdd + psdd->SerialNumberOffset;
while(*str && *str == ' ') str++;
printf_s("Serial Number: %s\n", str);
}
if (psdd->VendorIdOffset) {
str = (PCHAR)psdd + psdd->VendorIdOffset;
while(*str && *str == ' ') str++;
printf_s("Vendor ID: %s\n", str);
}
if (psdd->ProductRevisionOffset) {
str = (PCHAR)psdd + psdd->ProductRevisionOffset;
while(*str && *str == ' ') str++;
printf_s("Product Revision: %s\n", str);
}
free(psdd);
使用SCSI INQUIRY命令获取硬盘基本信息
直通控制码IOCTL_SCSI_PASS_THROUGH_DIRECT
可用于发送大部分SCSI命令,并且scsiport和storport都支持。在使用该控制码时需要包含ntddscsi.h
头文件,并且需要填充SCSI_PASS_THROUGH_DIRECT
结构
typedef struct _SCSI_PASS_THROUGH_DIRECT {
USHORT Length;
UCHAR ScsiStatus;
UCHAR PathId;
UCHAR TargetId;
UCHAR Lun;
UCHAR CdbLength;
UCHAR SenseInfoLength;
UCHAR DataIn;
ULONG DataTransferLength;
ULONG TimeOutValue;
PVOID DataBuffer;
ULONG SenseInfoOffset;
UCHAR Cdb[16];
}SCSI_PASS_THROUGH_DIRECT, *PSCSI_PASS_THROUGH_DIRECT;
网上有些代码使用的控制码是IOCTL_SCSI_PASS_THROUGH
,填充的结构是SCSI_PASS_THROUGH_WITH_BUFFERS
,其实效果是相同的,只不过“with buffers”意味着缓冲区要跟在SCSI_PASS_THROUGH结构体后面。无论使用哪种控制码,其中的CDB域都是关键,我们要在里面填写具体的命令参数。但MSDN并没有说明应该怎么填。SCSI具体有那些命令,具体怎么填写命令参数,可在T10的SCSI Primary Commands1和SCSI Block Commands2中查询。其中有一条primary command称为INQUIRY,可以获取SCSI设备的相关信息。下图显示了INQUIRY命令的CDB应该怎么填(只用填前6个字节)。
如果将其中的EVPD位设为0,就代表进行的是标准查询,这时PAGE CODE也要设为0,ALLOCATION LENGTH设为接收数据的缓冲区的长度(够用即可,不一定要和SCSI_PASS_THROUGH_DIRECT.DataTransferLength相同)。
判断SCSI直通操作是否成功,除了看GetLastError值,还要看SCSI_PASS_THROUGH_DIRECT.ScsiStatus,具体可能是哪些值,在scsi.h
中有定义,这里贴心地写出来。
#define SCSISTAT_GOOD 0x00
#define SCSISTAT_CHECK_CONDITION 0x02
#define SCSISTAT_CONDITION_MET 0x04
#define SCSISTAT_BUSY 0x08
#define SCSISTAT_INTERMEDIATE 0x10
#define SCSISTAT_INTERMEDIATE_COND_MET 0x14
#define SCSISTAT_RESERVATION_CONFLICT 0x18
#define SCSISTAT_COMMAND_TERMINATED 0x22
#define SCSISTAT_QUEUE_FULL 0x28
GetLastError() == ERROR_SUCCESS && ScsiStatus == SCSISTAT_GOOD
才表示SCSI直通是成功的
因为有的时候后者错误而前者照样显示成功。当INQUIRY成功时,它返回的数据结构如下图。
我们需要的是其中的Product Identification、Product Revision Level、Vendor Specific和Version Descriptor四个域。落实到代码,头文件scsi.h
已经帮我们定义了需要用到的所有结构,于是我们可以这么写:
// 打开设备对象的过程略去,得到句柄hDrive
SCSI_PASS_THROUGH_DIRECT sptd;
PINQUIRYDATA data;
PVOID buffer = malloc(256);
memset(buffer, 0, 256);
memset(&sptd, 0, sizeof(SCSI_PASS_THROUGH_DIRECT));
// SenseInfoOffset和SenseInfoLength域都不用管
// PathId、TargetId和Lun用于定位,这里设为0
sptd.Length = sizeof(SCSI_PASS_THROUGH_DIRECT);
sptd.CdbLength = 6;
sptd.DataIn = SCSI_IOCTL_DATA_IN;
sptd.DataTransferLength = 256;
sptd.TimeOutValue = 2;
sptd.DataBuffer = buffer;
PCDB cdb = (PCDB)sptd.Cdb;
cdb->CDB6INQUIRY3.OperationCode = SCSIOP_INQUIRY;
// 这个AllocationLength写得太大太小都会导致错误,经实验发现200尚未出错
cdb->CDB6INQUIRY3.AllocationLength = (UCHAR)200;
BOOL ret = DeviceIoControl(
hDrive, IOCTL_SCSI_PASS_THROUGH_DIRECT,
&sptd, sizeof(SCSI_PASS_THROUGH_DIRECT),
&sptd, sizeof(SCSI_PASS_THROUGH_DIRECT),
0, 0 // 自信点,malloc的空间足够了,不用管lpBytesReturned
);
if (!ret || sptd.ScsiStatus) {
printf_s("[%d] DeviceIoControl: %x\n", __LINE__, GetLastError());
free(buffer);
// 别的错误处理,比如return
}
char i, str[41];
WORD ver;
// 这些字符串以空格结尾,把它们trim掉
for(i = 15; data->ProductId[i] == ' ' && i >= 0; i--);
strncpy_s(str, 41, data->ProductId, i+1);
printf_s("Product ID: %s\n", str);
for(i = 7; data->VendorId[i] == ' ' && i >= 0; i--);
strncpy_s(str, 41, data->VendorId, i+1);
printf_s("Vendor ID: %s\n", str);
for(i = 20; data->VendorSpecific[i] == ' ' && i >= 0; i--);
strncpy_s(str, 41, data->VendorSpecific, i+1);
printf_s("Vendor Specific: %s\n", str);
for(i = 3; data->ProductRevisionLevel[i] == ' ' && i >= 0; i--);
strncpy_s(str, 41, data->ProductRevisionLevel, i+1);
printf_s("Production Revision Level: %s\n", str);
// VersionDesciptors是8个大端32位数组成的数组,需要逐个将它们翻转过来,
// 使用REVERSE_BYTES_SHORT宏即可
printf_s("Version Descriptors:");
for(i = 0; i < 8; i++) {
if (!data->VersionDescriptors[i]) continue;
REVERSE_BYTES_SHORT(&ver, &data->VersionDescriptors[i]);
printf_s(" %04x", ver);
}
putchar('\n');
free(buffer);
INQUIRYDATA
结构在MSDN中有介绍,这里不赘述,但MSDN没有说明VersionDescriptor
域是做什么的。这个域是8个32位数,每个数都表明该设备遵循/兼容哪些标准,具体某个数对应哪个标准,在SCSI Primary Commands1中可以查得到。著名的硬盘信息查看软件CrystalDiskInfo使用WMI,最终也通过INQUIRY命令获取这项信息。
例如,对我的Seagate Basic移动硬盘执行上述代码,得到输出
Product ID: Basic
Vendor ID: Seagate
Vendor Specific: ?\v?Basic
Production Revision Level: 0712
Version Descriptors: 1747 0460 04c0
VersionDescriptors
这三个值查表为:
Version Descriptor | 对应的标准 |
---|---|
0x0460 | SPC-4 (no version claimed) |
0x04C0 | SBC-3 (no version claimed) |
0x1747 | UAS T10/2095-D revision 04 |
SPC全称SCSI Primary Commands,SBC全称SCSI Block Commands,UAS全称USB Attached SCSI,这些标准文档都可以在T10上找到。CrystalDiskMark的Interface一栏也给出了UASP的结果。
SCSI INQUIRY获取序列号
上文的标准查询返回的信息中,唯独缺了序列号,因此需要补救一下。将CDB的EVPD设为1,即可查询设备的关键产品数据(vital product data, VPD),而EVPD全称就是Enable VPD。当EVPD设为1之后,PAGE CODE一项也要跟着设为特定的值,以查询不同的VPD,PAGE CODE和可以设定的值如下图。
其中0x80就是序列号。当然,不一定所有设备都支持查询0x80的PAGE,但实验发现手头的所有存储设备都支持。而缓冲区中的数据的格式如下图(当然scsi.h
又帮我们贴心地定义了VPD_SERIAL_NUMBER_PAGE
结构)
落实到代码上,我们可以这么写(注意下面是怎么填写CDB的):
// 打开设备对象的过程略去,得到句柄hDrive
// 记得#include <scsi.h>
SCSI_PASS_THROUGH_DIRECT sptd;
PVOID buffer = malloc(256);
memset(buffer, 0, 256);
memset(&sptd, 0, sizeof(SCSI_PASS_THROUGH_DIRECT));
sptd.Length = sizeof(SCSI_PASS_THROUGH_DIRECT);
sptd.CdbLength = 6;
sptd.DataIn = SCSI_IOCTL_DATA_IN;
sptd.DataTransferLength = 256;
sptd.TimeOutValue = 2;
sptd.DataBuffer = buffer;
PCDB cdb = (PCDB)sptd.Cdb;
cdb->CDB6INQUIRY3.OperationCode = SCSIOP_INQUIRY;
cdb->CDB6INQUIRY3.EnableVitalProductData = CDB_INQUIRY_EVPD;
cdb->CDB6INQUIRY3.PageCode = VPD_SERIAL_NUMBER;
cdb->CDB6INQUIRY3.AllocationLength = (UCHAR)200;
BOOL ret = DeviceIoControl(
hDrive, IOCTL_SCSI_PASS_THROUGH_DIRECT,
&sptd, sizeof(SCSI_PASS_THROUGH_DIRECT),
&sptd, sizeof(SCSI_PASS_THROUGH_DIRECT),
0, 0
);
if (!ret) {
printf_s("[%d] DeviceIoControl: %d\n", __LINE__, GetLastError());
free(buffer);
// 别的错误处理,比如return
}
PVPD_SERIAL_NUMBER_PAGE page = (PVPD_SERIAL_NUMBER_PAGE)buffer;
printf_s("Serial Number: %s\n", page->SerialNumber);
free(buffer);
针对NVMe协议硬盘获取基本信息
针对NVMe协议,Windows的小端口驱动是stornvme.sys,该驱动使用storport框架,所以有关于NVMe协议的I/O Control的流程都在storport.sys里完成。MSDN中似乎未提供针对NVMe的控制码,只能使用IOCTL_STORAGE_PROTOCOL_COMMAND
发送NVMe命令,思路同SCSI和ATA的直通控制码,但MSDN提到该控制码只用于发送厂商定制的命令,而对于一般的命令则不行。好在IOCTL_STORAGE_QUERY_PROPERTY
控制码可以针对NVMe协议查询设备信息。
与之前的方法不同的是,这次STORAGE_QUERY_PROPERTY.PropertyId
设为StorageAdapterProtocolSpecificProperty
,而STORAGE_QUERY_PROPERTY.AdditionalParameters
是STORAGE_PROTOCOL_SPECIFIC_DATA
结构,该结构后面紧跟着缓冲区。
实测当
STORAGE_QUERY_PROPERTY.PropertyId
为StorageDeviceProtocolSpecificProperty
时,返回的缓冲区中也包含设备的序列号,但处在一个非常奇怪的位置,以至于NVMe Base Specification 2.0中都查不到可能的结构,这应该是NVMe设备厂商秘制的小代码。此外,在这种情况下,稍微改一下数据格式,可以使用IOCTL_SCSI_MINIPORT
控制码来代替IOCTL_STORAGE_QUERY_PROPERTY
,得到的结果是一样的。反汇编storport.sys可以发现PropertyId
无论是adapter还是device,都调用了storport!RaBuildQueryProtocolSpecificPropertyBufferForMiniport
构造了一个IOCTL_SCSI_MINIPORT
才需要的缓冲区,但adapter这条线接着调用RaidAdapterSendSrbIoControlSynchronously
,device这条线调用RaidUnitSendSrbIoControlSynchronously
,两者构造的SRB和XRB有些不同,但最后BuildIo时都进入stornvme,调用stornvme!IoctlToNVMe -> IoctlQueryProtocolInfoProcess -> QueryProtocolInfoIdentifyData
分配DMA缓冲区,然后StartIo。
这种查询硬盘信息的方法对应执行NVMe中的IDENTIFY CONTROLLER命令,缓冲区里的数据结构在NVMe Base Specification3中可以查到,这是一个大小为4kB、内容非常丰富的结构,我们关心的型号、序列号都在比较靠前的地方,如下图。
头文件nvme.h
又贴心地帮我们将这个结构定义为了NVME_IDENTIFY_CONTROLLER_DATA
,可以直接用,落实到代码上,可以这么写:
// 打开设备对象的过程略去,得到句柄hDrive
// 记得#include <nvme.h>
ULONG bufferLength =
FIELD_OFFSET(STORAGE_PROPERTY_QUERY, AdditionalParameters) +
sizeof(STORAGE_PROTOCOL_SPECIFIC_DATA) +
sizeof(NVME_IDENTIFY_CONTROLLER_DATA);
PUCHAR buffer = malloc(bufferLength);
memset(buffer, 0, bufferLength);
PSTORAGE_PROPERTY_QUERY query = (PSTORAGE_PROPERTY_QUERY)buffer;
PSTORAGE_PROTOCOL_SPECIFIC_DATA protocolData = (PSTORAGE_PROTOCOL_SPECIFIC_DATA)query->AdditionalParameters;
query->PropertyId = StorageDeviceProtocolSpecificProperty;
query->QueryType = PropertyStandardQuery;
protocolData->ProtocolType = ProtocolTypeNvme;
protocolData->DataType = NVMeDataTypeIdentify;
protocolData->ProtocolDataRequestValue = NVME_IDENTIFY_CNS_CONTROLLER;
protocolData->ProtocolDataRequestSubValue = 0;
protocolData->ProtocolDataOffset = sizeof(STORAGE_PROTOCOL_SPECIFIC_DATA);
protocolData->ProtocolDataLength = sizeof(NVME_IDENTIFY_CONTROLLER_DATA);
BOOL ret = DeviceIoControl(
hDrive, IOCTL_STORAGE_QUERY_PROPERTY,
buffer, bufferLength,
buffer, bufferLength,
0, 0
);
if (!ret) {
printf_s("[%d] DeviceIoControl Error: %d", __LINE__, GetLastError());
free(buffer);
// 其他错误处理比如return
}
// 头文件nvme.h中有相当多的部分是未文档化的,有的指令发下去后都不知道会返回什么结果,所以要进行详细检查
PSTORAGE_PROTOCOL_DATA_DESCRIPTOR protocolDataDescr = (PSTORAGE_PROTOCOL_DATA_DESCRIPTOR)buffer;
if ((protocolDataDescr->Version != sizeof(STORAGE_PROTOCOL_DATA_DESCRIPTOR)) ||
(protocolDataDescr->Size != sizeof(STORAGE_PROTOCOL_DATA_DESCRIPTOR))) {
printf("Identify controller: invalid header.\n");
free(buffer);
return;
}
protocolData = &protocolDataDescr->ProtocolSpecificData;
if ((protocolData->ProtocolDataOffset < sizeof(STORAGE_PROTOCOL_SPECIFIC_DATA)) ||
(protocolData->ProtocolDataLength < sizeof(NVME_IDENTIFY_CONTROLLER_DATA))) {
printf("Identify controller: invalid offset/length.\n");
free(buffer);
// 其他错误处理比如return
}
PNVME_IDENTIFY_CONTROLLER_DATA data = (PNVME_IDENTIFY_CONTROLLER_DATA)((PCHAR)protocolData + protocolData->ProtocolDataOffset);
if ((data->VID == 0) || (data->NN == 0)) {
printf("Identify controller: invalid data.\n");
free(buffer);
// 其他错误处理比如return
}
CHAR i, str[41];
for(i = 39; data->MN[i] == ' ' && i >= 0; i--);
strncpy_s(str, 41, data->MN, i+1);
printf_s("Model Number: %s\n", str);
for(i = 19; data->SN[i] == ' ' && i >= 0; i--);
strncpy_s(str, 41, data->SN, i+1);
printf_s("Serial Number: %s\n", str);
for(i = 7; data->FR[i] == ' ' && i >= 0; i--);
strncpy_s(str, 41, data->FR, i+1);
printf_s("Firmware Revision: %s\n", str);
free(buffer);
当然这个结构只用来查询这几条信息有点浪费了,还有很多有意思的信息,比如TNVMCAP
域就是该硬盘的容量。
需要注意的是,使用IDENTIFY CONTROLLER获取到的硬盘序列号和之前的Standard Query或SCSI INQUIRY的都不一样,因为IDENTIFY CONTROLLER获得的是控制器的序列号,而Standard Query或SCSI INQUIRY获取的是单元序列号4。单元序列号一般是硬盘的NGUID(128位整数)或EUI64(64位整数),并采用下划线分十六进制记法,最后加上一个点,比如EUI64:0x0123456789ABCDEF
记为0123_4567_89AB_CDEF.
,NGUID:0x0123456789ABCDEF0123456789ABCDEF
记为0123_4567_89AB_CDEF_0123_4567_89AB_CDEF.
。
针对SAT协议获取硬盘基本信息
SAT全称SCSI/ATA Translation,具体来说,这种方法采用IOCTL_SCSI_PASS_THROUGH_DIRECT
控制码代替IOCTL_ATA_PASS_THROUGH
控制码,发送ATA直通请求。至于为什么不直接发送IOCTL_ATA_PASS_THROUGH
,是因为有的设备/驱动不支持。反正我这里试了一下是失败的。SCSI/ATA翻译的标准文档在T10上有5。
想让借助SCSI直通发送ATA直通请求,就要按照下图填写CDB:
其中COMMAND域是具体的ATA/ATAPI命令号,这里要发送的是IDENTIFY DEVICE命令,命令号为0xEC,具体有什么ATA/ATAPI命令,这些命令应该怎么填写参数,可参考T13的文档ATA/ATAPI Command Set6,IDENTIFY DEVICE应该按下图填写参数:
由图可知,我们可以将CDB的LBA填0(COUNT和FEATURE在SAT中有用)。
PROTOCOL域,因为IDENTIFY DEVICE是PIO Data-In类型的命令,填4;
OFF_LINE域,因为IDENTIFY不会导致总线状态不确定,填0;
CK_COND域,只要不出错ScsiStatus就应该为0,填0;
T_DIR域,数据传送方向是设备到应用,填1;
BYTE_BLOCK, T_TYPE, T_LENGTH, COUNT, FEATURES域按下图填,填1,0,2,1,0或1,0,1,0,1,T_LENGTH决定COUNT或FEATURES哪个应该填1。
关于DEVICE域,ATA/ATAPI的IDENTIFY DEVICE命令的只要求注意第4位,但SAT忽略这一位,所以填0即可。
那么SAT返回的缓冲区是什么结构的呢?其实结构和网上流传的使用DFP_RECEIVE_DRIVE_DATA / SMART_RCV_DRIVE_DATA
控制码查询硬盘信息时返回的所谓IDSECTOR
结构是一样的。这个结构长512字节,内容非常丰富,以至于ATA/ATAPI文档上需要19页才能表示出来,同NVMe,型号、序列号之类的信息在比较靠前的地方。
在WDK10中,这个结构放在头文件ata.h
中,命名为IDENTIFY_DEVICE_DATA
,所以不应该再使用流传的IDSECTOR
结构。
在给出代码之前,还要注意一点,ATA该结构中的字符串是逐字(WORD)颠倒,即正常顺序字符串是"01234567"
会被颠倒成"10325476"
,应当逐字反转回来,这里写一个extractString
用来逐字反转字符串。
void extractString(PUCHAR InString, UCHAR MaxLength, PSTR OutString)
{
PWORD p;
UCHAR i, *s, *e;
for(i = 0, p = (PWORD)InString; i < MaxLength; i+=2, p++)
*p = (*p << 8) | (*p >> 8);
for(e = &InString[MaxLength-1]; *e == ' ' && e >= InString; e--);
for(s = InString; *s == ' ' && s < e; s++);
strncpy_s(OutString, MaxLength+1, s, e-s+1);
return;
};
最后落实到代码上,可以这么写:
// 打开设备对象的过程略去,得到句柄hDrive
// 记得#include <ata.h>
SCSI_PASS_THROUGH_DIRECT sptd;
IDENTIFY_DEVICE_DATA sector;
memset(&sptd, 0, sizeof(SCSI_PASS_THROUGH_DIRECT));
memset(§or, 0, 512);
sptd.Length = sizeof(SCSI_PASS_THROUGH_DIRECT);
sptd.TargetId = 0;
sptd.CdbLength = 12;
sptd.DataIn = SCSI_IOCTL_DATA_IN;
sptd.DataTransferLength = IDENTIFY_BUFFER_SIZE;
sptd.TimeOutValue = 2;
sptd.DataBuffer = §or;
sptd.Cdb[0] = SCSIOP_ATA_PASSTHROUGH12;
sptd.Cdb[1] = 0x8; // 00001000b, MULTIPLE_COUNT(5:7)=0, PROTOCOL(1:4)=0100b=4, RESERVED(0)=0
sptd.Cdb[2] = 0xD; // 00001110b, OFF_LINE(6:7)=0, CK_COND(5)=0, T_TYPE(4)=0, T_DIR(3)=1, BYTE_BLOCK(2)=1, T_LENGTH(0:1)=01b=1
sptd.Cdb[3] = 1; // FEATURES(0:7)=1
sptd.Cdb[9] = IDE_COMMAND_IDENTIFY;
BOOL ret = DeviceIoControl(
hDrive, IOCTL_SCSI_PASS_THROUGH_DIRECT,
&sptd, sizeof(SCSI_PASS_THROUGH_DIRECT),
&sptd, sizeof(SCSI_PASS_THROUGH_DIRECT),
0, 0
);
if (!ret) {
printf_s("[%d] DeviceIoControl Error: %d", __LINE__, GetLastError());
// 其他错误处理比如return
}
CHAR str[41];
extractString(sector.ModelNumber, 40, str);
printf("Model Number: %s\n", str);
extractString(sector.SerialNumber, 20, str);
printf("Serial Number: %s\n", str);
extractString(sector.FirmwareRevision, 8, str);
printf("Firmware Revision: %s\n", str);
需要注意的是,尽管SAT标准规定SCSI INQUIRY和ATA IDENTIFY DEVICE获取到的硬盘序列号应当相同,但实践发现往往是不同的!!!
网上流传的获取硬盘序列号的代码解析
网上曾经流传一份代码,使用DFP_RECEIVE_DRIVE_DATA / SMART_RCV_DRIVE_DATA
控制码获取硬盘序列号,程序的核心部分是DoIdentify
函数:
BOOL DoIdentify(
HANDLE hPhysicalDriveIOCTL,
PSENDCMDINPARAMS pSCIP,
PSENDCMDOUTPARAMS pSCOP,
BYTE bIDCmd, BYTE bDriveNum,
PDWORD lpcbBytesReturned
)
{
// Set up data structures for IDENTIFY command.
pSCIP->cBufferSize = IDENTIFY_BUFFER_SIZE;
pSCIP->irDriveRegs.bFeaturesReg = 0;
pSCIP->irDriveRegs.bSectorCountReg = 1;
pSCIP->irDriveRegs.bSectorNumberReg = 1;
pSCIP->irDriveRegs.bCylLowReg = 0;
pSCIP->irDriveRegs.bCylHighReg = 0;
// calc the drive number.
pSCIP->irDriveRegs.bDriveHeadReg = 0xA0 | ( ( bDriveNum & 1 ) << 4 );
// The command can either be IDE identify or ATAPI identify.
pSCIP->irDriveRegs.bCommandReg = bIDCmd;
pSCIP->bDriveNumber = bDriveNum;
pSCIP->cBufferSize = IDENTIFY_BUFFER_SIZE;
return DeviceIoControl(
hPhysicalDriveIOCTL, DFP_RECEIVE_DRIVE_DATA,
(LPVOID) pSCIP, sizeof (SENDCMDINPARAMS) - 1,
(LPVOID) pSCOP, sizeof (SENDCMDOUTPARAMS) + IDENTIFY_BUFFER_SIZE - 1,
lpcbBytesReturned, NULL
);
}
不知道最早这份代码什么时候写出来的,但至少在XP泄露的源码里,nt\admin\wmi\wbem\providers\win32provider\cimwin32a\physicalmedia.cpp
的中的GetIdentifyData
函数就是这么做的。这个程序打开\\.\PhysicalDrive*
,然后发送DFP_RECEIVE_DRIVE_DATA
控制码。从disk.sys的源码里我们可以看到,当disk收到该控制码后,调用disk!DiskIoctlSmartReceiveDriveData
函数,构造一个SRB_IO_CONTROL
结构
typedef struct _SRB_IO_CONTROL {
ULONG HeaderLength; // sizeof(SRB_IO_CONTROL)
UCHAR Signature[8]; // 'SCSIDISK'
ULONG Timeout; // fdoExtension->TimeoutValue
ULONG ControlCode; // IOCTL_SCSI_MINIPORT_IDENTIFY
ULONG ReturnCode;
ULONG Length; // IDENTIFY_BUFFER_SIZE + sizeof(SENDCMDOUTPARAMS)
} SRB_IO_CONTROL, *PSRB_IO_CONTROL;
然后发送IOCTL_SCSI_MINIPORT
给下层驱动。这个控制码最终交给小端口驱动,但目前为止,stornvme不再认IOCTL_SCSI_MINIPORT_IDENTIFY
,usbstor和uaspstor不认IOCTL_SCSI_MINIPORT
,所以在20多年后的今天,用这种方法查询到硬盘序列号的成功率比原来低了不少。