获取硬盘型号、序列号等信息的正确姿势

磁盘、分区和卷的对象名与符号链接

一般地,如果略去复杂的过滤驱动,读写文件时控制流会依次经过文件系统驱动(ntfs.sys或fastfat.sys等)、卷管理驱动(volmgr.sys)、分区管理驱动(partmgr.sys)、磁盘类驱动(disk.sys和classpnp.sys)、存储设备端口驱动(storport.sys、scsiport.sys和ataport.sys三者之一)和小端口驱动(storachi.sys、stornvme.sys或usbstor.sys等)等。系统启动或插入移动存储设备时会创建一系列设备对象和符号链接,有多个驱动参与了创建这些东西的过程:

  1. 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!DiskCreateFdodisk!DiskCreateGuidSymbolicLink的源码。
  2. 卷管理驱动volmgr.sys创建分区的设备对象,名字形如\Device\HarddiskVolume*,其DEVICE_OBJECT.DeviceExtension域是结构VM_VOLUME_EXTENSION的指针(可惜pdb中没有定义);然后创建指向该设备对象的符号链接,名称形如\Device\Harddisk*\Partition*\Device\Harddisk*Partition*\DosDevices\HarddiskVolume*,详情见volmgr!VmpCreateLegacyNameLinks函数的源码。
  3. 挂载点管理驱动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的体现。
  4. 存储设备端口驱动创建磁盘的物理设备对象(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_EXTENSIONLOGICAL_UNIT_EXTENSION结构的指针。详见scsiport!SpCreateAdapterscsiport!SpCreateLogicalUnit的源码。
    (2) ATA端口驱动(ataport.sys),在vista引入,它创建PDO和FDO两种设备,名称分别形如\Device\Ide\IdeDeviceP*T*L*-*\Device\Ide\IdePort*,其中“*”是一个数字。MSDN中提到该端口驱动未来可能会淘汰掉。详见ataport!DeviceAllocatePdoataport!ChannelAddDevice的源码。
    (3) Storport驱动(storport.sys),在server 2003引入,并且MSDN推荐使用该端口驱动而不是以上两种(意思就是鼓励开发者编写存储设备小端口驱动时采用storport的模型)。本机(Windows 10 20H2)就直接不加载前两者,全使用storport。Storport.sys创建适配器设备(adapter device)和单元设备(unit device),DEVICE_OBJECT.DeviceExtension域分别是RAID_ADAPTER_EXTENSIONRAID_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 -> ObpLookupObjectNameObpLookupObjectName解析该路径名,进入根目录,进入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对应的标准
0x0460SPC-4 (no version claimed)
0x04C0SBC-3 (no version claimed)
0x1747UAS 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.AdditionalParametersSTORAGE_PROTOCOL_SPECIFIC_DATA结构,该结构后面紧跟着缓冲区。

实测当STORAGE_QUERY_PROPERTY.PropertyIdStorageDeviceProtocolSpecificProperty时,返回的缓冲区中也包含设备的序列号,但处在一个非常奇怪的位置,以至于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:
ATA直通命令

其中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(&sector, 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 = &sector;

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多年后的今天,用这种方法查询到硬盘序列号的成功率比原来低了不少。


  1. SCSI Primary Commands - 5 (SPC-5) ↩︎ ↩︎

  2. SCSI Block Commands - 4 (SBC-4) ↩︎

  3. NVM Express Base Specification 2.0b ↩︎

  4. NVMe/SCSI Translation Reference Revision 1.5 ↩︎

  5. SCSI/ATA Translation - 3 (SAT-3) ↩︎

  6. ATA/ATAPI Command Set - 4 (ACS-4 r14) ↩︎

  • 3
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值