简介:这份资源面向使用VS2008与MFC进行Windows底层开发的C++程序员,聚焦HID设备(USB鼠标、U盘等)的拔插检测这一典型场景。包内提供完整的MFC工程源码,演示如何注册设备接口回调、监听USB设备事件、识别GUID_DEVINTERFACE_HID接口类,并针对鼠标与U盘分别执行输入报告处理或文件读写操作,帮助读者掌握设备树与setupapi相关API的实战用法。资源共33个文件,以h头文件、cpp源文件、obj与pdb调试文件、rc资源脚本、vcproj工程文件及exe可执行程序为主,另含sln解决方案与ReadMe说明,压缩包约21.41MB,工程结构完整可直接编译运行。目前已有327人学习下载,适合需要实时监控硬件状态、补齐Windows设备管理底层知识的开发者参考,可据此快速搭建自己的HID插拔监听模块并理解设备枚举与回调机制。
1. 从一次“鼠标拔了界面还亮着”说起:Win32/MFC 下的 HID 拔插检测到底在做什么
做过工控上位机的人大概率都遇到过这个场景:设备明明已经从 USB 口拔掉了,界面上的“已连接”指示灯还绿着,操作员点一下按钮,程序直接卡死或者弹出一串看不懂的异常。这类问题的根子,往往不在业务逻辑,而在 USB HID 设备的拔插检测没做对。Win32 平台下用 MFC 写上位机,处理 USB 设备插拔检测这件事,说简单也简单——系统本来就会给你发消息;说难也难——消息类型有好几种,注册方式、窗口句柄、线程模型稍微错一点,就是收不到或者收到一堆重复通知。这篇笔记就围绕 Win32_MFC 环境下 HID 拔插检测这条线,把设备枚举、消息注册、WM_DEVICECHANGE 处理、HID 句柄失效判断、U 盘和鼠标这类不同设备的差异,一步步拆开讲清楚。适合正在用 MFC 做 USB 设备管理、需要让界面实时反映设备在线状态的一线开发者,也适合刚接手老 MFC 工程、被“设备拔了程序没反应”折磨过的人。
2. 先搞清楚 Windows 怎么通知你设备动了:WM_DEVICECHANGE 与设备接口注册
2.1 为什么轮询 EnumHIDDevice 是个坏主意
很多人的第一反应是开个定时器,每隔几百毫秒调一次SetupDiGetClassDevs枚举一遍 HID 设备,对比前后列表差异。这个做法能跑通,但代价不小:每次枚举都要走一遍设备安装类 GUID 查询,设备多了以后 CPU 占用肉眼可见,而且轮询间隔和响应实时性天然矛盾——间隔短了费资源,间隔长了用户拔掉设备后界面要愣一下才更新。更麻烦的是,轮询只能告诉你“设备列表变了”,没法告诉你“哪个设备走了、哪个设备来了”,你还得自己维护一份句柄快照做 diff,代码越写越厚。
Windows 本身提供了一套设备事件通知机制,核心就是WM_DEVICECHANGE消息。当系统检测到设备到达、移除、媒体插入等事件时,会向顶层窗口广播这个消息。MFC 的CWnd派生类只要在消息映射里加上ON_WM_DEVICECHANGE(),就能在OnDeviceChange虚函数里收到通知。这是最省资源、最贴近系统语义的做法,也是我一般会优先选的路子。
2.2 注册设备通知:RegisterDeviceNotification 的正确姿势
光有WM_DEVICECHANGE还不够。默认情况下,窗口只能收到一部分全局设备事件,比如卷(U 盘)的插拔。对于 HID 这类设备接口,你需要主动调用RegisterDeviceNotification注册感兴趣的设备接口类 GUID,系统才会把对应的DBT_DEVICEARRIVAL和DBT_DEVICEREMOVECOMPLETE发到你的窗口。
下面是一段可以直接抄的注册代码,放在对话框初始化或者主窗口OnInitDialog里:
// 在对话框类头文件中声明 HDEVNOTIFY m_hDevNotify = nullptr; // OnInitDialog 或窗口创建后调用 BOOL CMyDlg::RegisterHidNotification() { // 1. 获取 HID 设备接口类 GUID GUID hidGuid; HidD_GetHidGuid(&hidGuid); // 2. 填充 DEV_BROADCAST_DEVICEINTERFACE 结构 DEV_BROADCAST_DEVICEINTERFACE dbdi = { 0 }; dbdi.dbcc_size = sizeof(DEV_BROADCAST_DEVICEINTERFACE); dbdi.dbcc_devicetype = DBT_DEVTYP_DEVICEINTERFACE; dbdi.dbcc_classguid = hidGuid; // 3. 注册,窗口句柄用当前对话框的 m_hWnd m_hDevNotify = RegisterDeviceNotification( m_hWnd, // 接收通知的窗口 &dbdi, // 设备接口描述 DEVICE_NOTIFY_WINDOW_HANDLE // 以窗口消息形式投递 ); if (m_hDevNotify == nullptr) { DWORD err = GetLastError(); TRACE(_T("RegisterDeviceNotification failed: %lu\n"), err); return FALSE; } return TRUE; }这段代码的逻辑很直白:先问系统要 HID 类的 GUID,再告诉系统“我这个窗口关心这个类别的设备接口变化”,系统返回一个HDEVNOTIFY句柄。参数里DEVICE_NOTIFY_WINDOW_HANDLE表示通知以窗口消息形式发到m_hWnd,这也是 MFC 对话框程序最常用的方式。如果你是在服务或者无窗口线程里做,就得换成DEVICE_NOTIFY_SERVICE_HANDLE或者DEVICE_NOTIFY_CALLBACK,那是另一套写法。
提示:
RegisterDeviceNotification注册的是“设备接口类”,不是某个具体 VID/PID。也就是说,你注册 HID 类之后,任何 HID 设备的插拔都会通知你,具体是哪个设备需要自己在OnDeviceChange里解析。
2.3 OnDeviceChange 里到底该处理哪些事件
注册成功后,OnDeviceChange会被频繁调用。事件类型由nEventType参数给出,常见的有:
| 事件常量 | 含义 | 典型场景 |
|---|---|---|
DBT_DEVICEARRIVAL | 设备到达 | 插入 U 盘、插上 HID 设备 |
DBT_DEVICEREMOVECOMPLETE | 设备移除完成 | 拔掉 U 盘、拔掉 HID 设备 |
DBT_DEVICEQUERYREMOVE | 询问是否允许移除 | 系统准备弹出设备 |
DBT_DEVICEREMOVEPENDING | 即将移除 | 移除前的最后通知 |
DBT_DEVNODES_CHANGED | 设备节点变化 | 设备树有变动,信息较模糊 |
对于 HID 拔插检测,真正要盯的是DBT_DEVICEARRIVAL和DBT_DEVICEREMOVECOMPLETE。DBT_DEVNODES_CHANGED虽然也会在插拔时触发,但它不携带具体设备信息,只能当“有变化了,去刷新一下”的信号用,不能作为精确判断依据。
BOOL CMyDlg::OnDeviceChange(UINT nEventType, DWORD_PTR dwData) { switch (nEventType) { case DBT_DEVICEARRIVAL: { PDEV_BROADCAST_HDR pHdr = (PDEV_BROADCAST_HDR)dwData; if (pHdr && pHdr->dbch_devicetype == DBT_DEVTYP_DEVICEINTERFACE) { PDEV_BROADCAST_DEVICEINTERFACE pDevInf = (PDEV_BROADCAST_DEVICEINTERFACE)pHdr; // pDevInf->dbcc_name 里是设备接口路径,可用来匹配具体设备 TRACE(_T("Device arrived: %s\n"), pDevInf->dbcc_name); PostMessage(WM_APP_DEVICE_ARRIVED, 0, (LPARAM)_tcsdup(pDevInf->dbcc_name)); } break; } case DBT_DEVICEREMOVECOMPLETE: { PDEV_BROADCAST_HDR pHdr = (PDEV_BROADCAST_HDR)dwData; if (pHdr && pHdr->dbch_devicetype == DBT_DEVTYP_DEVICEINTERFACE) { PDEV_BROADCAST_DEVICEINTERFACE pDevInf = (PDEV_BROADCAST_DEVICEINTERFACE)pHdr; TRACE(_T("Device removed: %s\n"), pDevInf->dbcc_name); PostMessage(WM_APP_DEVICE_REMOVED, 0, (LPARAM)_tcsdup(pDevInf->dbcc_name)); } break; } default: break; } return TRUE; }这里有个细节值得说:dwData指向的结构体类型取决于dbch_devicetype。对于设备接口类事件,它是DEV_BROADCAST_DEVICEINTERFACE,里面的dbcc_name是设备接口路径字符串,形如\\?\HID#VID_1234&PID_5678#...。你可以用这个路径去匹配自己关心的设备。注意dbcc_name的生命周期只在消息处理期间有效,如果要异步处理,必须自己拷贝一份,上面代码里用_tcsdup就是干这个的,用完记得free。
3. 从设备路径到 HID 句柄:枚举、打开与失效判断
3.1 用 SetupAPI 枚举 HID 设备并匹配 VID/PID
收到到达通知后,下一步是找到这个设备并打开它。常见做法是用 SetupAPI 枚举 HID 类设备,逐个读取设备路径和硬件 ID,匹配你关心的 VID/PID。下面这段代码封装了一个枚举函数:
#include <setupapi.h> #include <hidsdi.h> #pragma comment(lib, "setupapi.lib") #pragma comment(lib, "hid.lib") struct HidDeviceInfo { CString strPath; USHORT vid; USHORT pid; }; BOOL EnumHidDevices(std::vector<HidDeviceInfo>& outVec) { GUID hidGuid; HidD_GetHidGuid(&hidGuid); HDEVINFO hDevInfo = SetupDiGetClassDevs( &hidGuid, nullptr, nullptr, DIGCF_PRESENT | DIGCF_DEVICEINTERFACE); if (hDevInfo == INVALID_HANDLE_VALUE) return FALSE; SP_DEVICE_INTERFACE_DATA did = { 0 }; did.cbSize = sizeof(did); for (DWORD i = 0; SetupDiEnumDeviceInterfaces(hDevInfo, nullptr, &hidGuid, i, &did); ++i) { DWORD needed = 0; SetupDiGetDeviceInterfaceDetail(hDevInfo, &did, nullptr, 0, &needed, nullptr); if (needed == 0) continue; std::vector<BYTE> buf(needed); PSP_DEVICE_INTERFACE_DETAIL_DATA pDetail = (PSP_DEVICE_INTERFACE_DETAIL_DATA)buf.data(); pDetail->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA); if (SetupDiGetDeviceInterfaceDetail(hDevInfo, &did, pDetail, needed, nullptr, nullptr)) { HidDeviceInfo info; info.strPath = pDetail->DevicePath; // 从路径中解析 VID/PID,路径格式含 VID_xxxx&PID_xxxx CString strPath(info.strPath); int nVid = strPath.Find(_T("VID_")); int nPid = strPath.Find(_T("PID_")); if (nVid >= 0 && nPid >= 0) { info.vid = (USHORT)_tcstoul(strPath.Mid(nVid + 4, 4), nullptr, 16); info.pid = (USHORT)_tcstoul(strPath.Mid(nPid + 4, 4), nullptr, 16); outVec.push_back(info); } } } SetupDiDestroyDeviceInfoList(hDevInfo); return TRUE; }逻辑说明:SetupDiGetClassDevs拿到 HID 类设备信息集,SetupDiEnumDeviceInterfaces逐个枚举接口,SetupDiGetDeviceInterfaceDetail取出设备路径。路径里天然包含VID_和PID_字段,直接字符串解析就能拿到厂商号和产品号,不用额外查属性。参数上DIGCF_PRESENT表示只要当前在位的设备,DIGCF_DEVICEINTERFACE表示按接口枚举,这两个标志组合是 HID 枚举的标准用法。
3.2 CreateFile 打开设备与异步读写
拿到设备路径后,用CreateFile打开。HID 设备路径可以直接传给CreateFile,但要注意共享模式和异步标志:
HANDLE OpenHidDevice(const CString& strPath) { HANDLE h = CreateFile( strPath, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, // HID 设备通常需要共享 nullptr, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, // 异步,避免 ReadFile 阻塞界面 nullptr ); if (h == INVALID_HANDLE_VALUE) { DWORD err = GetLastError(); TRACE(_T("OpenHidDevice failed: %lu\n"), err); } return h; }参数里FILE_SHARE_READ | FILE_SHARE_WRITE很关键,HID 设备往往被系统输入栈也持有,不共享会打开失败。FILE_FLAG_OVERLAPPED建议加上,否则ReadFile在没数据时会一直阻塞,界面直接假死。打开之后可以用HidD_GetAttributes再确认一次 VID/PID,用HidD_GetPreparsedData和HidP_GetCaps拿到输入输出报告长度,这些是后续读写报告的基础。
3.3 设备拔掉后句柄会怎样:失效判断与清理
设备被拔掉后,之前打开的句柄不会自动变成INVALID_HANDLE_VALUE,但任何对该句柄的读写都会失败,GetLastError通常返回ERROR_DEVICE_NOT_CONNECTED(1167)或者ERROR_INVALID_HANDLE。所以判断设备是否还在,不能只看句柄是否为空,而要在读写失败时检查错误码,或者干脆在收到DBT_DEVICEREMOVECOMPLETE时主动关闭句柄并置空。
我一般会在设备管理类里维护一个状态结构:
struct DeviceContext { HANDLE hDevice = INVALID_HANDLE_VALUE; CString strPath; USHORT vid = 0; USHORT pid = 0; bool bOnline = false; }; // 移除事件处理 void OnDeviceRemoved(const CString& strRemovedPath) { for (auto& ctx : m_vecDevices) { if (ctx.strPath.CompareNoCase(strRemovedPath) == 0) { if (ctx.hDevice != INVALID_HANDLE_VALUE) { CancelIo(ctx.hDevice); // 取消未完成的异步 IO CloseHandle(ctx.hDevice); ctx.hDevice = INVALID_HANDLE_VALUE; } ctx.bOnline = false; break; } } UpdateUiState(); // 刷新界面指示灯 }CancelIo这一步容易被忽略。如果之前投递了异步读,设备拔掉后那个 IRP 可能还挂着,不取消就关句柄会导致资源泄漏或者回调里访问已释放内存。先CancelIo再CloseHandle是稳妥顺序。
4. U 盘、鼠标、自定义 HID 设备:不同设备的检测差异与避坑
4.1 U 盘走的是卷通知,不是 HID 接口通知
U 盘插拔和 HID 设备插拔在 Windows 消息层面走的是不同分支。U 盘属于存储卷设备,系统会发送DBT_DEVTYP_VOLUME类型的广播,dwData指向DEV_BROADCAST_VOLUME,里面dbcv_unitmask是一个位掩码,表示哪个盘符受影响。如果你只注册了 HID 接口通知,U 盘插拔是收不到DBT_DEVTYP_DEVICEINTERFACE事件的。反过来,如果你只处理卷通知,HID 设备插拔也收不到。
常见做法是两种都注册:HID 接口通知用RegisterDeviceNotification注册 HID GUID,卷通知则不需要额外注册,顶层窗口默认就能收到DBT_DEVTYP_VOLUME。在OnDeviceChange里根据dbch_devicetype分流处理即可。
4.2 鼠标键盘这类系统独占设备,打开句柄可能失败
鼠标和键盘是 HID 设备,但它们被系统输入栈独占。你用CreateFile去打开鼠标的 HID 接口,很可能返回ERROR_ACCESS_DENIED。这不是代码写错了,是系统不允许普通应用直接读鼠标原始报告。如果你只是想做“鼠标拔插检测”,不需要打开句柄,靠WM_DEVICECHANGE里的设备路径匹配就够了。只有需要读自定义 HID 报告的设备(比如自己做的采集板),才需要真正打开句柄读写。
4.3 避坑与排查:五个真实踩过的坑
坑一:注册了通知但收不到任何消息。现象:RegisterDeviceNotification返回非空,但插拔设备时OnDeviceChange不触发。 原因:消息映射里漏了ON_WM_DEVICECHANGE(),或者窗口不是顶层窗口。WM_DEVICECHANGE只发给顶层窗口,子窗口、控件收不到。 解决:确认消息映射宏存在,确认m_hWnd是顶层对话框或主框架窗口。如果是子窗口,把注册句柄换成顶层窗口的。
坑二:插拔一次收到多次通知。现象:拔一个设备,DBT_DEVICEREMOVECOMPLETE来了三四次。 原因:一个物理 HID 设备可能暴露多个设备接口(比如复合设备有多个 HID 接口),每个接口移除都会发一次通知。另外DBT_DEVNODES_CHANGED也会混进来。 解决:在OnDeviceChange里只处理DBT_DEVTYP_DEVICEINTERFACE且路径匹配你关心的 VID/PID,用集合去重,不要每次通知都全量刷新。
坑三:设备拔掉后界面卡死。现象:拔掉设备瞬间程序无响应。 原因:在OnDeviceChange里同步调用了ReadFile或者CloseHandle时底层驱动阻塞。WM_DEVICECHANGE是在系统广播线程里同步投递的,处理函数里做耗时操作会拖住整个消息循环。 解决:OnDeviceChange里只做轻量记录,用PostMessage把实际处理抛回主线程消息队列,异步执行。
坑四:重复插拔后句柄泄漏。现象:反复插拔几十次后,程序打开设备失败,GetLastError返回句柄不足。 原因:移除事件里只置了bOnline = false,没有CloseHandle,旧句柄一直占着。 解决:移除事件里必须CancelIo+CloseHandle,并把句柄置为INVALID_HANDLE_VALUE。到达事件里打开新句柄前先检查旧句柄是否已关闭。
坑五:dbcc_name字符串用完就没了。现象:在OnDeviceChange里保存了dbcc_name指针,稍后访问变成乱码。 原因:dwData指向的内存只在消息处理期间有效,消息返回后系统就回收了。 解决:需要保留就立刻拷贝,用CString或者_tcsdup,不要存裸指针。
5. 让检测更稳的几个进阶技巧:异步 IO、设备路径匹配与状态机
5.1 用异步 ReadFile + 事件对象做设备在线心跳
对于需要持续读报告的自定义 HID 设备,光靠插拔消息还不够——有时候设备没拔但固件挂了,消息层面不会通知你。我一般会加一层心跳:用异步ReadFile挂一个读请求,配合WaitForSingleObject等事件,超时没数据就认为设备异常。
// 异步读一次报告 BOOL AsyncReadHid(HANDLE hDev, OVERLAPPED& ov, BYTE* pBuf, DWORD dwLen) { ZeroMemory(&ov, sizeof(ov)); ov.hEvent = CreateEvent(nullptr, TRUE, FALSE, nullptr); BOOL bRet = ReadFile(hDev, pBuf, dwLen, nullptr, &ov); if (!bRet && GetLastError() != ERROR_IO_PENDING) { CloseHandle(ov.hEvent); return FALSE; } return TRUE; } // 等待完成,超时 2 秒 DWORD WaitHidRead(OVERLAPPED& ov, DWORD dwTimeoutMs) { DWORD dwWait = WaitForSingleObject(ov.hEvent, dwTimeoutMs); if (dwWait == WAIT_OBJECT_0) { DWORD dwBytes = 0; if (GetOverlappedResult(ov.hEvent ? /* 句柄 */ nullptr : nullptr, &ov, &dwBytes, FALSE)) return dwBytes; } return 0; }参数上,超时时间根据设备上报周期定,一般取上报周期的 3 到 5 倍。超时后不要立刻关句柄,先CancelIo再重试一次,连续多次超时才判定离线。这样能过滤掉偶发的调度延迟。
5.2 设备路径匹配的两种策略:精确匹配与 VID/PID 匹配
dbcc_name给出的设备路径是完整接口路径,包含实例 ID 和接口 GUID。精确匹配就是拿这个完整字符串和枚举出来的路径做CompareNoCase,优点是准,缺点是设备换一个 USB 口路径就变了。VID/PID 匹配是从路径里解析出厂商号和产品号,只匹配这两个字段,优点是换口也能认出来,缺点是同型号多设备会混淆。
我的习惯是:单设备场景用 VID/PID 匹配,多设备场景用“VID/PID + 序列号”匹配。序列号可以通过HidD_GetSerialNumberString从已打开的句柄里读,但注意读序列号需要先打开设备,而打开设备又需要先匹配路径,所以顺序上先用 VID/PID 粗筛,打开后再用序列号精筛。
5.3 用状态机管理设备生命周期,避免界面状态错乱
设备状态不是简单的在线/离线两态,中间还有“正在打开”“打开失败”“正在关闭”等过渡态。我一般用一个简单状态机:
| 状态 | 触发事件 | 下一状态 | 动作 |
|---|---|---|---|
Offline | 收到到达通知 | Opening | 枚举路径,尝试打开 |
Opening | 打开成功 | Online | 启动异步读,刷新 UI |
Opening | 打开失败 | Offline | 记录错误,延迟重试 |
Online | 收到移除通知 | Closing | CancelIo,关闭句柄 |
Online | 读超时多次 | Closing | 主动关闭,标记异常 |
Closing | 句柄关闭完成 | Offline | 刷新 UI,清理资源 |
状态机的好处是每个状态只关心自己的合法迁移,不会出现“已经离线了还在读数据”这种逻辑错乱。实现上用一个枚举加一个switch就够,不需要上什么框架。
5.4 一个容易忽略的细节:UnregisterDeviceNotification 的调用时机
程序退出或者窗口销毁时,必须调用UnregisterDeviceNotification(m_hDevNotify),否则系统还持有你的窗口句柄,窗口销毁后设备事件投递过来就是野指针。我一般放在OnDestroy里,和CloseHandle设备句柄一起做清理。顺序是先注销通知,再关设备句柄,最后清理其他资源。这个顺序反了,可能在关句柄过程中又收到移除通知,导致重复关闭。
写 MFC 的 USB 设备管理,最深的教训就是:系统消息永远比你想象的更“异步”,任何在消息处理函数里做重活、存裸指针、假设顺序的写法,迟早会在现场翻车。把通知注册、异步 IO、状态机这三件事做扎实,剩下的就是耐心处理边界。希望帮到你。
本文还有配套的精品资源,点击获取