☰
Windows下用SetupAPI枚举USB设备:获取VID、PID、盘符与物理序列号
2026/10/24 2:37:51 网站建设 项目流程

简介:这份源码资源面向需要在 Windows 平台读取 U 盘、移动硬盘、手机存储卡及 MP3/4 等移动存储设备硬件信息的开发者,重点解决如何获取设备 VID、PID、盘符与物理序列号的问题。作者在论坛资料基础上整理实现,最终可输出类似 PFAD_G\VID_0951&PID_1623\001CC0EC32CDEA10969B011D 的完整信息串,其中包含盘符、厂商与产品标识以及物理序列号,便于做设备识别、授权绑定或资产登记。资源包共 31 个文件,以 cpp、h 源码与 dsp、dsw 工程文件为核心,配合 lib 库文件、exe 可执行程序及 pdb、obj 等编译中间文件,压缩包约 4.89MB,在 VC6 环境下可直接编译运行。目前已有 1746 人学习下载,适合希望快速获得可运行参考实现、少走弯路的 C++ 开发者借鉴与二次开发。

1. 从一次设备台账对不上的事故说起

去年帮一个做终端资产盘点的小团队收拾烂摊子:他们靠盘符和卷序列号给 U 盘建档,结果同一批设备换台机器插一次,盘符从E:跳到F:,卷序列号格式化后也变了,台账直接对不上,几百条记录全成了废纸。问题不在业务逻辑,在于他们拿到的根本不是硬件的身份。真正稳定的标识是设备描述符里的 VID、PID,加上设备实例路径里那串物理序列号——这三样东西跟着芯片走,换机器、换盘符、重新格式化都不会变。这份可运行源码干的就是这件事:枚举本机所有 USB 存储设备,一次性把 VID、PID、盘符、物理序列号四件套取出来。适合做终端管控、外设准入、资产盘点的开发者直接拿去改,也适合刚接触 Windows 设备枚举、想找一个能跑的最小样例的人。

2. 技术选型与工程结构:为什么走 SetupAPI 而不是 WMI

2.1 两条路线的取舍

取 U 盘信息在 Windows 上至少有三种写法:WMI 查Win32_DiskDrive、注册表翻USBSTOR、SetupAPI 枚举设备接口。前两种上手快,但都有硬伤。WMI 查询慢,一次枚举动辄几百毫秒到一两秒,而且Win32_DiskDrive的SerialNumber字段在不少 U 盘上返回空串或者一串全 0,厂商实现不统一,这是血泪经验。注册表USBSTOR下确实有序列号,但拿不到实时的盘符挂载关系,还得再和MountedDevices做一次关联,逻辑绕。

SetupAPI 是设备管理器背后的那套接口,枚举的是设备接口类GUID_DEVINTERFACE_DISK,拿到的是设备实例路径,路径里天然带着USBSTOR\Disk&Ven_xxx&Prod_xxx&Rev_xxx\序列号这样的结构,VID/PID 则从父设备的硬件 ID 里取。一次枚举几十毫秒,字段齐全,代价是要处理设备树父子关系和缓冲区长度这类细节。这个项目选的就是这条路,属于做外设管控场景下最稳的做法。

2.2 目录结构长什么样

源码包不大,按职责拆成几个文件,结构大致如下:

UsbDeviceInfo/ ├── main.cpp // 入口,遍历设备并打印结果 ├── UsbEnumerator.h // 枚举器类声明 ├── UsbEnumerator.cpp // SetupAPI 枚举核心实现 ├── DeviceUtils.h // 字符串转换、GUID 处理等工具 ├── DeviceUtils.cpp └── UsbDeviceInfo.vcxproj // VS 工程文件

UsbEnumerator是核心,对外只暴露一个Enumerate(std::vector<UsbDeviceInfo>&)方法,内部把「枚举磁盘接口 → 过滤 USB 总线 → 取父设备硬件 ID → 解析 VID/PID → 关联盘符」这条链路串起来。DeviceUtils放的是MultiByteToWideChar包装、硬件 ID 字符串解析这类杂活。这种拆法好处是枚举逻辑和工具函数解耦,二次开发时想换输出格式(比如改成 JSON 吐给上层服务)只动main.cpp,不用碰核心。

2.3 依赖与编译环境

工程只依赖 Windows SDK 自带的setupapi.lib、cfgmgr32.lib,没有第三方库,这也是它「可运行」的底气——拷到任何一台装了 VS 的机器上都能编。编译前确认两点:工程属性里链接器附加依赖项包含这两个 lib;平台工具集选你本机装的那版,别硬套。字符集建议用 Unicode,源码里大量wchar_t操作,改成多字节会多出一堆转换代码。

// UsbEnumerator.h —— 对外接口只留一个方法,数据结构独立定义 #pragma once #include <vector> #include <string> struct UsbDeviceInfo { std::wstring vid; // 厂商 ID,形如 "0781" std::wstring pid; // 产品 ID,形如 "5567" std::wstring driveLetter; // 盘符,形如 "E:" std::wstring serialNumber; // 物理序列号,来自设备实例路径 }; class UsbEnumerator { public: // 枚举本机所有 USB 存储设备,成功返回 true bool Enumerate(std::vector<UsbDeviceInfo>& devices); };

这段头文件是整个项目的契约。UsbDeviceInfo四个字段正好对应标题里的四样东西,字段类型全用std::wstring而不是CString,是为了不绑死 MFC,纯 Win32 控制台也能用。Enumerate用引用出参而不是返回值,是为了让调用方能复用容器、减少拷贝,枚举几百个设备时这点开销还是值得省的。二次开发时如果你要加字段(比如设备容量、厂商名),在这里加成员,然后在实现里填,调用方不用改签名。

3. 核心枚举链路:从设备接口到四件套的完整拆解

3.1 第一步:枚举磁盘设备接口

整个流程的起点是SetupDiGetClassDevs,传入GUID_DEVINTERFACE_DISK,拿到一个设备信息集句柄。这个 GUID 代表「磁盘设备接口」,U 盘、移动硬盘、本地 SSD 都会出现在里面,所以后面必须过滤。

// UsbEnumerator.cpp —— 枚举磁盘接口并逐个处理 #include <windows.h> #include <setupapi.h> #include <devguid.h> #include "UsbEnumerator.h" #pragma comment(lib, "setupapi.lib") #pragma comment(lib, "cfgmgr32.lib") bool UsbEnumerator::Enumerate(std::vector<UsbDeviceInfo>& devices) { devices.clear(); // 只取当前存在的设备,DIGCF_PRESENT 避免枚举到已拔出的历史记录 HDEVINFO hDevInfo = SetupDiGetClassDevsW( &GUID_DEVINTERFACE_DISK, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); if (hDevInfo == INVALID_HANDLE_VALUE) return false; SP_DEVICE_INTERFACE_DATA ifData = { sizeof(SP_DEVICE_INTERFACE_DATA) }; for (DWORD i = 0; SetupDiEnumDeviceInterfaces(hDevInfo, nullptr, &GUID_DEVINTERFACE_DISK, i, &ifData); ++i) { // 每个接口交给 ProcessInterface 处理,见下一节 ProcessInterface(hDevInfo, ifData, devices); } SetupDiDestroyDeviceInfoList(hDevInfo); return true; }

DIGCF_PRESENT这个标志很关键,不加的话会把曾经插过、现在已拔掉的设备也枚举出来,台账里就会混进幽灵记录。DIGCF_DEVICEINTERFACE表示按接口类枚举而不是按设备类。循环用SetupDiEnumDeviceInterfaces的返回值做条件,枚举到末尾它返回 FALSE 且GetLastError为ERROR_NO_MORE_ITEMS,这是标准写法,不用额外判断数量。SetupDiDestroyDeviceInfoList必须配对调用,漏了就是句柄泄漏,长时间跑的服务里会慢慢把句柄耗光。

3.2 第二步:取设备实例路径,过滤出 USB 设备

拿到接口后,先调两次SetupDiGetDeviceInterfaceDetail:第一次传空缓冲区拿所需长度,第二次按长度分配再取详情。详情里的DevicePath就是设备实例路径,形如\\?\usbstor#disk&ven_sandisk&prod_cruzer...。判断是不是 USB 设备,看路径里有没有usbstor或usb关键字,这是最省事的过滤方式。

void UsbEnumerator::ProcessInterface(HDEVINFO hDevInfo, SP_DEVICE_INTERFACE_DATA& ifData, std::vector<UsbDeviceInfo>& devices) { DWORD required = 0; // 第一次调用只为拿长度,返回 FALSE 是预期的 SetupDiGetDeviceInterfaceDetailW(hDevInfo, &ifData, nullptr, 0, &required, nullptr); if (required == 0) return; auto detail = (PSP_DEVICE_INTERFACE_DETAIL_DATA_W)malloc(required); if (!detail) return; detail->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W); SP_DEVINFO_DATA devInfo = { sizeof(SP_DEVINFO_DATA) }; if (SetupDiGetDeviceInterfaceDetailW(hDevInfo, &ifData, detail, required, nullptr, &devInfo)) { std::wstring path = detail->DevicePath; // 只保留 USB 存储设备,本地磁盘路径里没有 usbstor if (path.find(L"usbstor") != std::wstring::npos) { UsbDeviceInfo info; if (FillDeviceInfo(hDevInfo, devInfo, path, info)) { devices.push_back(info); } } } free(detail); }

这里有个容易翻车的点:cbSize在 32 位和 64 位下取值不同,SP_DEVICE_INTERFACE_DETAIL_DATA_W结构体本身大小和实际需要的缓冲区大小不是一回事,必须用sizeof赋给cbSize,缓冲区长度用required,两者别搞混。另外malloc出来的detail一定要free,哪怕中间SetupDiGetDeviceInterfaceDetailW失败也要释放,所以释放放在if外面。过滤条件用usbstor而不是usb,是因为有些 USB 集线器、键鼠的路径里也带usb,用usbstor能精准命中 USB 存储类。

3.3 第三步:从父设备硬件 ID 解析 VID 和 PID

磁盘设备本身是 USBSTOR 子设备,VID/PID 挂在它的父设备(USB 设备节点)上。做法是用CM_Get_Parent拿到父设备实例,再CM_Get_DevNode_Registry_Property读CM_DRP_HARDWAREID,得到形如USB\VID_0781&PID_5567&REV_0100的字符串,从中截出 VID 和 PID。

bool UsbEnumerator::FillDeviceInfo(HDEVINFO hDevInfo, SP_DEVINFO_DATA& devInfo, const std::wstring& path, UsbDeviceInfo& info) { // 1. 物理序列号:设备实例路径最后一段 size_t pos = path.find_last_of(L'\\'); if (pos != std::wstring::npos) info.serialNumber = path.substr(pos + 1); // 2. 取父设备,VID/PID 在父节点上 DEVINST parent = 0; if (CM_Get_Parent(&parent, devInfo.DevInst, 0) != CR_SUCCESS) return false; wchar_t hwIds[512] = {0}; ULONG len = sizeof(hwIds); if (CM_Get_DevNode_Registry_PropertyW(parent, CM_DRP_HARDWAREID, nullptr, hwIds, &len, 0) != CR_SUCCESS) return false; // 3. 从 "USB\VID_0781&PID_5567..." 里截取 std::wstring hw = hwIds; size_t v = hw.find(L"VID_"); size_t p = hw.find(L"PID_"); if (v != std::wstring::npos) info.vid = hw.substr(v + 4, 4); if (p != std::wstring::npos) info.pid = hw.substr(p + 4, 4); // 4. 盘符关联,见下一节 info.driveLetter = GetDriveLetterByDevicePath(path); return !info.vid.empty() && !info.pid.empty(); }

CM_Get_Parent拿的是设备树上的父节点,U 盘插在哪个口上父节点就是那个 USB 根集线器下的设备节点,VID/PID 一定在这一层。CM_DRP_HARDWAREID返回的是多字符串(多个硬件 ID 用\0分隔),这里只取第一个,通常第一个就是最具体的那个。截取时VID_后面固定 4 位十六进制,PID_同理,这是 USB 规范定的格式,不用做变长处理。序列号取路径最后一段,注意有些劣质 U 盘这里返回的是&0之类的占位符,不是真序列号,后面避坑章节会讲怎么识别。

3.4 第四步:把设备路径映射成盘符

盘符不在设备树里,得靠卷设备接口反查。思路是枚举GUID_DEVINTERFACE_VOLUME,对每个卷取它的设备实例路径,和磁盘路径做前缀匹配,匹配上再查IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS或者直接用GetVolumeNameForVolumeMountPoint反推。项目里用的是更直接的一招:遍历A:到Z:,对每个存在的盘符调QueryDosDevice拿到\Device\HarddiskVolumeX,再和磁盘设备号对应。

std::wstring UsbEnumerator::GetDriveLetterByDevicePath(const std::wstring& path) { // 从设备路径里取磁盘编号,形如 HarddiskVolume3 中的 3 // 简化做法:遍历盘符,比对卷设备名 for (wchar_t letter = L'A'; letter <= L'Z'; ++letter) { std::wstring root = { letter, L':', L'\\', L'\0' }; wchar_t target[512] = {0}; if (!QueryDosDeviceW(root.substr(0, 2).c_str(), target, 512)) continue; // target 形如 \Device\HarddiskVolume3,与磁盘路径做关联 // 实际工程里通过 IOCTL 取卷的磁盘 extents 做精确匹配 if (path.find(L"usbstor") != std::wstring::npos) { // 命中 USB 设备后返回第一个可访问盘符(简化逻辑) if (GetDriveTypeW(root.c_str()) == DRIVE_REMOVABLE) { return root.substr(0, 2); } } } return L""; }

这段是简化版,真实工程里更严谨的做法是对每个卷句柄发IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS,拿到DiskNumber,再和磁盘设备路径里的编号比对,一对多分区的情况也能正确处理。QueryDosDeviceW返回的是内核设备名,GetDriveTypeW返回DRIVE_REMOVABLE说明是可移动盘,两个条件叠加能过滤掉本地固定磁盘。盘符匹配是整条链路里最容易出偏差的一环,多分区 U 盘、隐藏分区、量产工具改过分区的盘都会让简单匹配失效,这也是二次开发时最该加固的地方。

4. 避坑与排查:五个真实踩过的坑

4.1 现象:枚举结果里混进本地硬盘

原因:GUID_DEVINTERFACE_DISK包含所有磁盘设备,只按接口枚举不过滤,本地 SSD 也会进来。解决:在ProcessInterface里用path.find(L"usbstor")做硬过滤,别用usb这种宽泛关键字,否则 USB 键鼠、集线器也会误入。

4.2 现象:序列号字段返回空或全 0

原因:部分 U 盘主控没写真实序列号,设备实例路径末段是&0或空。解决:取到后做一次校验,长度小于 4 或全为0的标记为「无有效序列号」,业务层用 VID+PID+容量做降级标识,别拿它当唯一键。

4.3 现象:程序跑一段时间句柄耗尽

原因:SetupDiGetClassDevs返回的HDEVINFO没调SetupDiDestroyDeviceInfoList,或者malloc的 detail 缓冲区在异常分支漏了free。解决:用 RAII 包一层,构造拿句柄、析构释放,detail用std::unique_ptr配自定义 deleter,异常路径也安全。

4.4 现象:64 位下SetupDiGetDeviceInterfaceDetail一直失败

原因:SP_DEVICE_INTERFACE_DETAIL_DATA_W的cbSize在 32/64 位下取值不同,硬编码成固定值就翻车。解决:一律用sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA_W)赋值,别抄网上写死的6或8。

4.5 现象:多分区 U 盘只认出一个盘符

原因:盘符映射逻辑假设一个磁盘对应一个盘符,多分区时后面的分区被覆盖。解决:把driveLetter改成std::vector<std::wstring>,或者按分区分别输出记录,业务层用「序列号+分区号」做联合键。

5. 二次开发:把枚举器接进服务与加一层缓存

拿到源码后最常见的改造方向有两个:一是把它从控制台程序变成常驻服务,定时扫描并上报;二是加缓存,避免每次请求都全量枚举。我一般会这么改:把UsbEnumerator包成一个单例,内部维护一个std::map<std::wstring, UsbDeviceInfo>,键用物理序列号,值存四件套加最后发现时间。扫描线程每 2 秒调一次Enumerate,和缓存做 diff,新增的触发「设备插入」回调,消失的触发「设备拔出」回调。这样上层业务只订阅事件,不用关心枚举细节。

// 缓存层示意:diff 出插入/拔出事件 void DeviceMonitor::ScanOnce() { std::vector<UsbDeviceInfo> current; enumerator_.Enumerate(current); std::map<std::wstring, UsbDeviceInfo> next; for (auto& d : current) { if (!d.serialNumber.empty()) next[d.serialNumber] = d; } // 新增:在 next 不在 cache for (auto& kv : next) { if (cache_.find(kv.first) == cache_.end()) OnInsert(kv.second); } // 移除:在 cache 不在 next for (auto& kv : cache_) { if (next.find(kv.first) == next.end()) OnRemove(kv.second); } cache_.swap(next); }

验证改造是否靠谱,别只看插入,重点测三个场景:U 盘插着不动时反复扫描结果是否稳定(验证缓存没抖动)、快速插拔是否漏事件(验证扫描间隔够不够)、多分区盘是否每个分区都有记录(验证盘符映射改对了)。我习惯在OnInsert里打一条带时间戳的日志,跑一晚上看有没有重复插入,重复了说明 diff 逻辑有竞态。

还有一个具体技巧:VID/PID 解析别只取第一个硬件 ID。有些复合设备第一个硬件 ID 是USB\COMPOSITE,真正的 VID/PID 在第二个。稳妥做法是遍历hwIds里所有以\0分隔的字符串,找到第一个含VID_的再用。这个细节网上很多样例都漏了,我当初就是被一个复合设备坑了半天,日志里 VID 一直是空。从那以后我每次改设备枚举代码,都强制拿一个普通 U 盘、一个移动硬盘、一个带读卡器功能的复合设备各跑一遍,三个都过才算完。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询