C++通过Windows SMART命令获取硬盘序列号:原理、代码与避坑指南

📅 2026/7/10 9:38:40
C++通过Windows SMART命令获取硬盘序列号:原理、代码与避坑指南
1. 项目概述最近在做一个软件授权系统需要绑定用户的硬件信息硬盘序列号自然成了首选。网上搜了一圈发现很多C获取硬盘序列号的代码要么太老要么依赖第三方库要么就是直接用wmic命令调用总觉得不够“纯粹”。作为一个喜欢刨根问底的程序员我决定自己动手通过Windows底层的SMART命令来直接和硬盘“对话”把序列号、型号、固件版本这些信息都掏出来。这不仅仅是实现一个功能更是理解操作系统如何与硬件打交道的一次绝佳实践。这个项目适合所有对Windows系统编程、硬件交互感兴趣的C开发者。无论你是想为自己的软件增加硬件绑定功能还是单纯好奇电脑里的硬盘到底藏着哪些信息这篇内容都能给你一份可以直接编译运行的代码以及背后每一步的原理详解。我会从最基本的CreateFile打开设备讲起到构造ATA命令包最后通过DeviceIoControl完成通信把整个过程掰开揉碎讲清楚。2. 核心原理Windows如何与硬盘通信在开始写代码之前我们必须搞清楚一个核心问题运行在用户模式的应用程序如何绕过文件系统直接读取物理硬盘的固件信息答案就在于Windows提供的设备接口和ATA命令协议。2.1 物理设备与符号链接在Windows中一切皆文件硬件设备也不例外。每个物理硬盘都有一个对应的设备对象系统为其创建了符号链接Symbolic Link方便应用程序访问。对于第一块硬盘这个链接就是\\.\PHYSICALDRIVE0。这里的\\.\是一个特殊的命名空间指向本地计算机的设备根目录。当我们用CreateFile打开这个路径时实际上是在请求操作系统为我们打开一个指向物理硬盘设备对象的句柄。这里的关键是权限我们必须以GENERIC_READ | GENERIC_WRITE权限打开因为后续发送的IOCTL输入/输出控制代码可能需要读写操作。同时分享模式设置为FILE_SHARE_READ | FILE_SHARE_WRITE允许其他进程同时访问。注意以读写方式打开物理驱动器是一项特权操作。在Windows Vista及更高版本上如果你的程序没有以管理员权限运行CreateFile会失败并返回ERROR_ACCESS_DENIED。这是开发这类工具时第一个要踩的坑。要么引导用户“以管理员身份运行”要么在清单文件里请求requireAdministrator权限。2.2 IOCTL与SMART/ATA命令拿到设备句柄后我们不能像读写普通文件一样去读写它。与硬盘固件通信的标准方式是发送ATA命令。Windows通过DeviceIoControl函数提供了一个安全的通道。我们向这个函数传递一个特定的IOCTL控制代码以及一个精心构造的命令数据包。这里涉及两个关键的控制代码SMART_GET_VERSION这个代码用于查询硬盘控制器是否支持SMART功能以及获取一些基本的版本信息。更重要的是它的输出结构GETVERSIONINPARAMS中包含一个bIDEDeviceMap字段这个字段的位图可以帮助我们判断目标硬盘是ATA设备还是ATAPI设备如光驱从而决定后续发送哪种IDENTIFY命令。SMART_RCV_DRIVE_DATA这个代码才是真正用来发送自定义ATA命令的“万能钥匙”。我们将包含ATA命令寄存器值如Features, Sector Count, Command等的数据结构SENDCMDINPARAMS传给它它就会帮我们把命令打包发送给硬盘驱动并取回硬盘的响应数据存放在SENDCMDOUTPARAMS结构中。2.3 IDENTIFY DEVICE命令与数据结构我们要获取的序列号、型号等信息都存储在硬盘固件的一个特定数据结构中。通过发送IDENTIFY DEVICE命令ATA设备为0xECATAPI设备为0xA1可以让硬盘将这份长达512字节的数据块返回给我们。这份数据是一个256个WORD双字节的数组每个数据都有固定的位置序列号位于第10-19个WORD。这里存储的是ASCII字符但每个WORD的两个字节是颠倒的即字节序问题。型号位于第27-46个WORD。固件版本位于第23-26个WORD。原始数据中每个WORD的高字节在前低字节在后并且每个字段都可能用空格0x20填充到固定长度。所以我们的代码需要做两件事一是正确地将两个字节拼接成一个字符二是去掉首尾多余的空格。3. 代码逐行解析与实现理解了原理我们来看代码实现。我将核心功能封装成了一个GetDiskInfo函数它接受一个驱动器索引并返回三个字符串信息。3.1 打开物理设备第一步永远是获取访问硬件的“门票”——句柄。char szFilePath[64] { 0 }; sprintf(szFilePath, \\\\\\\\.\\\\PHYSICALDRIVE%d, iDriver); HANDLE hFile CreateFileA(szFilePath, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); if (INVALID_HANDLE_VALUE hFile) { return -1; // 打开失败通常是权限不足 }这里sprintf用于动态生成设备路径。iDriver为0表示第一块物理硬盘1表示第二块以此类推。CreateFileA的ANSI版本在这里够用如果考虑Unicode兼容可以使用CreateFileW和宽字符路径。3.2 探测设备能力在发送IDENTIFY命令前最好先探探路确认设备支持我们的操作。GETVERSIONINPARAMS gvopVersionParam {0}; DWORD dwBytesReturned 0; BOOL bResult DeviceIoControl(hFile, SMART_GET_VERSION, NULL, 0, gvopVersionParam, sizeof(gvopVersionParam), dwBytesReturned, NULL); if (!bResult || gvopVersionParam.bIDEDeviceMap 0) { CloseHandle(hFile); return -2; // 设备不支持SMART或探测失败 }SMART_GET_VERSION是一个简单的查询不需要输入缓冲区。返回的gvopVersionParam.bIDEDeviceMap是一个位图它的第uiDrive位从0开始如果被置位通常表示该通道上的设备是ATAPI设备。这是我们选择发送0xEC还是0xA1命令的依据。3.3 构造并发送IDENTIFY命令这是最核心的一步我们需要填充一个SENDCMDINPARAMS结构体它描述了要发送的ATA命令。SENDCMDINPARAMS scip {0}; scip.cBufferSize IDENTIFY_BUFFER_SIZE; // 通常为512 scip.irDriveRegs.bFeaturesReg 0; scip.irDriveRegs.bSectorCountReg 1; scip.irDriveRegs.bSectorNumberReg 1; scip.irDriveRegs.bCylLowReg 0; scip.irDriveRegs.bCylHighReg 0; // 选择主设备(0xA0)或从设备(0xB0)通常主设备是0xA0 scip.irDriveRegs.bDriveHeadReg (btDriveNum 1) ? 0xB0 : 0xA0; // 命令寄存器ATA IDENTIFY (0xEC) 或 ATAPI IDENTIFY (0xA1) scip.irDriveRegs.bCommandReg btIDCmd; scip.bDriveNumber btDriveNum; BYTE btOutBuffer[sizeof(SENDCMDOUTPARAMS) IDENTIFY_BUFFER_SIZE - 1] {0}; bResult DeviceIoControl(hFile, SMART_RCV_DRIVE_DATA, scip, sizeof(scip), btOutBuffer, sizeof(btOutBuffer), dwBytesReturned, NULL);SENDCMDINPARAMS结构体中的irDriveRegs模拟了ATA协议中那些经典的寄存器Features、Sector Count、LBA地址由CylHigh、CylLow、SectorNumber组成以及Command寄存器。对于IDENTIFY DEVICE命令大多数寄存器填0或1即可关键在于bCommandReg。DeviceIoControl函数会阻塞直到硬盘驱动完成操作。返回的数据被写入btOutBuffer其开头是一个SENDCMDOUTPARAMS头后面紧跟512字节的IDENTIFY数据。3.4 解析原始数据拿到原始数据后我们需要从这512个字节中提取出可读的字符串。USHORT* pIdSector (USHORT*)(((SENDCMDOUTPARAMS*)btOutBuffer)-bBuffer); DWORD dwDiskData[256]; for (int i 0; i 256; i) { dwDiskData[i] pIdSector[i]; } // 使用ConvertToString函数提取并转换特定区间的数据 strcpy(szSerialNumber, ConvertToString(dwDiskData, 10, 19)); strcpy(szModelNumber, ConvertToString(dwDiskData, 27, 46)); strcpy(szFirmwareNumber, ConvertToString(dwDiskData, 23, 26));这里有一个关键点bBuffer是一个BYTE数组但我们将其强制转换为USHORT*指针。这是因为IDENTIFY数据是按WORD16位组织的而x86架构是Little-Endian小端序即低字节在前。硬盘返回的数据恰好也是按这个顺序存储的所以我们可以直接按USHORT读取。ConvertToString函数负责将指定索引范围的WORD数组转换为字符串。它遍历每个WORD先取高字节dwDiskData[i] / 256再取低字节dwDiskData[i] % 256依次放入字符数组。这样就把颠倒的字节序纠正了过来。3.5 字符串清理与返回转换后的字符串首尾通常会有空格填充所以需要一个Trim函数来清理。BOOL Trim(char* szStr) { // ... 实现查找第一个非空格和最后一个非空格字符的逻辑 ... // 将中间部分复制到新数组并添加字符串结束符 }Trim函数的实现是经典的“双指针”思路一个从前往后找第一个非空格字符一个从后往前找最后一个非空格字符然后将中间部分复制出来。确保返回的字符串干净整洁。最后别忘了关闭设备句柄CloseHandle(hFile)并返回成功或错误代码。4. 实战中的坑与解决方案看起来流程很清晰但在实际编码和测试中你会遇到一堆教科书上不会写的坑。下面是我踩过之后总结出来的经验。4.1 权限问题与兼容性这是最大的拦路虎。在Windows 7及以后版本即使你是Administrator组的用户默认启动的程序也没有“备份和还原”特权而直接读写物理磁盘需要这个特权。解决方案1运行时提权最简单粗暴的方法就是要求用户右键点击你的程序选择“以管理员身份运行”。对于工具类程序这可以接受。解决方案2清单文件更优雅的方式是在项目里添加一个清单文件.manifest声明需要管理员权限。?xml version1.0 encodingUTF-8 standaloneyes? assembly xmlnsurn:schemas-microsoft-com:asm.v1 manifestVersion1.0 trustInfo xmlnsurn:schemas-microsoft-com:asm.v3 security requestedPrivileges requestedExecutionLevel levelrequireAdministrator uiAccessfalse/ /requestedPrivileges /security /trustInfo /assembly在Visual Studio中你可以在项目属性 - 链接器 - 清单文件 - UAC执行级别里直接选择“requireAdministrator”。解决方案3动态提权如果你的程序大部分功能不需要管理员权限只有获取硬盘信息时需要可以尝试动态提权。但这比较复杂通常需要启动一个带参数的守护进程或服务不推荐初学者尝试。4.2 固态硬盘与NVMe的挑战上面的代码主要针对传统的SATA/AHCI或IDE接口的硬盘。如果你的电脑用的是NVMe固态硬盘\\.\PHYSICALDRIVEX这个路径依然有效但通信协议完全不同。ATA命令集对NVMe设备无效。如何判断和应对判断接口类型一个更健壮的程序应该先通过Windows Management Instrumentation (WMI) 查询Win32_DiskDrive的InterfaceType属性。如果是SCSI很可能就是NVMe在系统看来NVMe常被归类为SCSI。使用NVMe命令对于NVMe设备需要使用IOCTL_STORAGE_QUERY_PROPERTY等控制代码配合STORAGE_PROPERTY_QUERY和STORAGE_DEVICE_ID_DESCRIPTOR来获取设备唯一标识。或者更直接地使用NVMe专有的IOCTL和命令集这比ATA复杂得多。备选方案对于需要硬件绑定的场景如果遇到NVMe硬盘可以回退到使用WMI的Win32_PhysicalMedia类来获取SerialNumber属性虽然这个号可能不是厂商序列号而是Windows生成的唯一ID但作为软件绑定的依据在单机上是稳定的。实操心得如果你的软件用户群中NVMe硬盘占比很高强烈建议将WMI方案作为后备。纯IOCTL方案虽然“酷”但兼容性始终是产品首先要考虑的问题。可以设计成分级获取先尝试IOCTL获取精确ATA序列号失败则降级到WMI获取通用唯一ID。4.3 字节序与字符串处理这是一个隐蔽的坑。IDENTIFY数据中的字符串是“每个WORD内字节颠倒”存储的。我们的ConvertToString函数用除法和取模操作分离高、低字节顺序是先高后低这符合逻辑。 但你要注意有些硬盘特别是老硬盘或某些品牌返回的数据可能在某些字段的末尾不是用空格(0x20)填充而是用空字符(0x00)。如果你的Trim函数只处理空格可能会把有效字符截断。更安全的做法是在Trim函数中将判断条件从if ( ! szStr[i])改为if (szStr[i] )这样就能过滤掉所有控制字符和空格。4.4 多硬盘与驱动器编号\\.\PHYSICALDRIVE0不总是你的系统盘。在有多块硬盘的系统中这个编号取决于主板SATA端口的连接顺序、BIOS设置等具有一定的不确定性。 如果你需要获取“Windows系统所在硬盘”的序列号更可靠的做法是通过GetLogicalDrives和GetVolumeInformation找到系统盘符通常是C:。使用IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS控制代码查询该卷Volume位于哪个物理磁盘上。得到物理磁盘编号后再用我们的方法去获取信息。 这个过程涉及卷管理代码会更复杂但它确保了目标的准确性。5. 完整代码整合与优化将上述所有知识点和避坑指南融合我提供一个增强版的代码框架。这个框架包含了基本的错误处理并预留了扩展接口。#define _CRT_SECURE_NO_WARNINGS #include windows.h #include iostream #include winioctl.h #include string // 必要的结构体和常量定义通常包含在 winioctl.h 和 ntddscsi.h 中 // 如果编译提示未定义可能需要手动定义或包含更具体的头文件 #ifndef SMART_GET_VERSION #define SMART_GET_VERSION CTL_CODE(IOCTL_DISK_BASE, 0x0020, METHOD_BUFFERED, FILE_READ_ACCESS) #endif #ifndef SMART_RCV_DRIVE_DATA #define SMART_RCV_DRIVE_DATA CTL_CODE(IOCTL_DISK_BASE, 0x0022, METHOD_BUFFERED, FILE_READ_ACCESS | FILE_WRITE_ACCESS) #endif #define IDENTIFY_BUFFER_SIZE 512 const WORD IDE_ATAPI_IDENTIFY 0xA1; const WORD IDE_ATA_IDENTIFY 0xEC; // ... 此处插入之前提到的 Trim, ConvertToString, DoIdentify 函数实现 ... // 增强的GetDiskInfo函数返回更详细的错误信息 int GetDiskInfoEx(int iDriver, char* szSerialNumber, size_t serialLen, char* szModelNumber, size_t modelLen, char* szFirmwareNumber, size_t firmwareLen, DWORD* pdwLastError nullptr) { if (!szSerialNumber || !szModelNumber || !szFirmwareNumber) { if (pdwLastError) *pdwLastError ERROR_INVALID_PARAMETER; return -10; // 参数错误 } szSerialNumber[0] szModelNumber[0] szFirmwareNumber[0] \0; char szFilePath[64] { 0 }; sprintf(szFilePath, \\\\\\\\.\\\\PHYSICALDRIVE%d, iDriver); HANDLE hDevice CreateFileA(szFilePath, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); if (hDevice INVALID_HANDLE_VALUE) { if (pdwLastError) *pdwLastError GetLastError(); return -1; // CreateFile失败 } // 探测设备 GETVERSIONINPARAMS gvip { 0 }; DWORD dwRet 0; if (!DeviceIoControl(hDevice, SMART_GET_VERSION, NULL, 0, gvip, sizeof(gvip), dwRet, NULL)) { DWORD err GetLastError(); CloseHandle(hDevice); if (pdwLastError) *pdwLastError err; return -2; // IOCTL失败可能是不支持的设备 } if (gvip.bIDEDeviceMap 0) { CloseHandle(hDevice); if (pdwLastError) *pdwLastError ERROR_NOT_SUPPORTED; return -3; // 设备映射为空 } // 判断设备类型并选择命令 BYTE btDrive 0; // 通常主设备是0 DWORD dwDevMap gvip.bIDEDeviceMap; WORD wIdCmd (dwDevMap (0x10 btDrive)) ? IDE_ATAPI_IDENTIFY : IDE_ATA_IDENTIFY; SENDCMDINPARAMS scip { 0 }; BYTE btOutBuf[sizeof(SENDCMDOUTPARAMS) IDENTIFY_BUFFER_SIZE - 1] { 0 }; if (!DoIdentify(hDevice, scip, (PSENDCMDOUTPARAMS)btOutBuf, (BYTE)wIdCmd, btDrive, dwRet)) { CloseHandle(hDevice); if (pdwLastError) *pdwLastError GetLastError(); return -4; // 发送IDENTIFY命令失败 } CloseHandle(hDevice); // 解析数据 USHORT* pIdData (USHORT*)(((SENDCMDOUTPARAMS*)btOutBuf)-bBuffer); DWORD dwDiskData[256]; for (int i 0; i 256; i) dwDiskData[i] pIdData[i]; // 安全地拷贝转换后的字符串 const char* szTmp nullptr; szTmp ConvertToString(dwDiskData, 10, 19); strncpy(szSerialNumber, szTmp, serialLen - 1); szSerialNumber[serialLen - 1] \\0; szTmp ConvertToString(dwDiskData, 27, 46); strncpy(szModelNumber, szTmp, modelLen - 1); szModelNumber[modelLen - 1] \\0; szTmp ConvertToString(dwDiskData, 23, 26); strncpy(szFirmwareNumber, szTmp, firmwareLen - 1); szFirmwareNumber[firmwareLen - 1] \\0; return 0; // 成功 } int main() { std::cout 正在获取物理驱动器0的信息... std::endl; char szSerial[64] {0}; char szModel[128] {0}; char szFirmware[32] {0}; DWORD dwErr 0; int ret GetDiskInfoEx(0, szSerial, sizeof(szSerial), szModel, sizeof(szModel), szFirmware, sizeof(szFirmware), dwErr); if (ret 0) { std::cout 序列号: szSerial std::endl; std::cout 型号: szModel std::endl; std::cout 固件版本: szFirmware std::endl; } else { std::cerr 获取失败错误代码: ret , 系统错误: dwErr std::endl; // 可以根据dwErr给出更友好的提示例如ERROR_ACCESS_DENIED提示需要管理员权限 if (dwErr ERROR_ACCESS_DENIED) { std::cerr 请尝试以管理员身份运行此程序。 std::endl; } } // 可以加个循环尝试获取所有物理驱动器 // for (int i 0; i 8; i) { ... } return 0; }这个优化版本主要做了以下几点改进安全的字符串拷贝使用strncpy并手动添加终止符防止缓冲区溢出。详细的错误信息通过GetLastError()和输出参数pdwLastError返回系统错误码便于调试。更健壮的参数检查对输入指针进行非空判断。主函数中的友好提示根据错误码给出运行建议。6. 常见问题排查与调试技巧即使代码看起来没问题在实际运行中也可能遇到各种稀奇古怪的情况。这里记录几个我遇到过的典型问题及其排查思路。6.1 程序编译通过但运行返回“拒绝访问”现象CreateFile失败GetLastError()返回5 (ERROR_ACCESS_DENIED)。排查检查权限这是最常见的原因。确保程序以管理员身份运行。可以在命令行管理员模式下运行或在资源管理器中右键选择“以管理员身份运行”。检查路径确认iDriver参数是有效的。你可以先用磁盘管理工具查看你的系统有几块物理磁盘编号从0开始。尝试iDriver为0, 1, 2...检查杀毒软件某些安全软件会拦截对物理设备的直接访问尝试暂时禁用它们再测试。解决确保使用管理员权限运行。对于最终产品通过清单文件嵌入UAC请求。6.2 程序运行无错但获取的序列号是乱码或空现象DeviceIoControl调用成功但ConvertToString出来的字符串是乱码或全是空格。排查检查设备类型你查询的PHYSICALDRIVE可能不是一块普通的ATA/SCSI硬盘而是一个USB移动硬盘、SD卡读卡器或者虚拟磁盘。这些设备可能不支持IDENTIFY DEVICE命令或者返回的数据格式不同。可以在磁盘管理中查看该磁盘的“总线类型”。检查命令选择通过SMART_GET_VERSION返回的bIDEDeviceMap判断设备类型是否准确。有时判断逻辑可能有误。检查数据解析在调试器中查看btOutBuf里的原始数据。找到偏移sizeof(SENDCMDOUTPARAMS)开始的512个字节用十六进制查看器观察。序列号字段第10-19个WORD即字节偏移36-55应该能看到可识别的ASCII字符可能夹杂空格。如果全是0或0xFF说明命令未成功。检查字节序确认你的ConvertToString函数中取高字节和低字节的顺序是否正确。可以尝试交换/256和%256的顺序看看。解决针对非标准ATA设备考虑回退到WMI方案。6.3 在部分Windows 10/11新电脑上失败现象在老电脑上工作正常在新买的笔记本电脑上失败。排查NVMe硬盘新电脑普遍使用NVMe SSD。如前所述ATA命令对NVMe无效。你需要先判断接口类型。存储驱动某些厂商定制驱动或RAID模式如Intel RST可能会改变设备栈使得\\.\PHYSICALDRIVE设备不支持标准的SMART_IOCTL。可以尝试在BIOS中将SATA模式从“RAID”或“Intel RST Premium”改为“AHCI”注意这可能需要重装系统。安全启动与BitLocker在启用了BitLocker的设备上直接访问磁盘扇区可能会受到限制尽管我们的操作是只读的。通常问题不大但也是一个考虑因素。解决实现多路径获取策略。先尝试IOCTL方案如果失败再尝试通过WMI的Win32_DiskDrive获取SerialNumber注意此序列号可能与物理序列号不同。6.4 如何调试DeviceIoControl的调用调试硬件IO操作比较麻烦因为涉及内核态。这里有几个实用技巧使用GetLastError()每次CreateFile和DeviceIoControl调用后立即检查返回值并用GetLastError()获取错误码。在MSDN或网上搜索该错误码的含义。输出中间数据将gvopVersionParam.bIDEDeviceMap、构造的SENDCMDINPARAMS结构体内容以十六进制形式打印出来确保命令参数正确。使用WinDbg内核调试对于极其棘手的问题可以启用内核调试查看IRP的传递情况。但这属于高级话题对大多数应用开发来说成本太高。对比已知工具使用像CrystalDiskInfo、HDTune这类专业的硬盘信息工具它们能正确读取信息。然后用你的程序读取同一块硬盘对比结果。如果它们行你不行说明是你的命令构造或解析有问题如果它们也不行那可能是系统或硬件环境问题。最后分享一个我个人的编码习惯对于这类底层硬件操作一定要把函数设计成可重入的并且做好彻底的错误清理比如打开句柄后任何失败分支都要记得CloseHandle。因为这类代码一旦发生资源泄漏或句柄未关闭可能会影响系统中其他程序的正常运行。