ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MFC HID拔插检测:监听U盘与鼠标插拔事件

MFC HID拔插检测:监听U盘与鼠标插拔事件 简介一份面向C/MFC开发者的Win32 HID设备拔插检测完整工程基于VS2008实现USB鼠标、U盘等输入与存储设备的实时监听解决硬件接入移除无法及时感知的问题。工程通过注册设备接口回调并调用SetupDi系列API帮助开发者掌握Windows USB设备管理机制与HID通信流程。压缩包共33个文件包含h/cpp源文件、vcproj工程配置、rc资源描述、编译生成的exe可执行程序及pdb调试符号等整体约21.41MB目录结构清晰可直接打开工程对照学习便于二次开发与调试。目前已有327人学习下载。项目重点演示了SetupDiGetClassDevs与SetupDiEnumDeviceInterfaces遍历设备信息集、CreateFile打开设备、HidD_GetAttributes查询设备属性、HidP_GetCaps读取设备能力等关键接口并针对鼠标输入报告与U盘读写场景给出回调处理框架资源内还包含调试阶段的pdb/ilk文件可配合源码跟踪设备事件触发后的完整API调用链路。对于需要实时监控硬件状态、开发自定义USB管理工具或深入理解底层设备交互的开发者是一份可复用性很强的参考模板。1. HID 拔插检测一个 MFC 工程就能盯住 U 盘和鼠标工控机上接了个 U 盘授权狗拔了再插上位机没有任何反应重启软件才恢复。这个问题我调了整整一个下午最后发现问题不在业务代码而在 Windows 的插拔事件根本没送进我的窗口过程。HID 拔插检测这件事Windows 确实把消息发出来了但前提是你得用对注册方式、监听对设备类 GUID否则 WM_DEVICECHANGE 就是块敲门砖门根本不会开。这份 MFC 资源做的就是把这套链路完整封装起来用 Win32 API HID 接口监听 USB 设备插拔识别 U 盘、鼠标、键盘等 HID 设备的接入和移除并在 UI 上反馈设备名称、VID/PID 和插拔时间。适合用 MFC 做上位机、桌面工具、产测软件的开发者也适合刚刚接触设备通知机制、想知道这套消息链路怎么走通的人。2. 事件驱动还是轮询为什么 HID 监听要选 WM_DEVICECHANGE很多人接到「检测 U 盘插入」这个需求时第一反应是定时枚举一遍所有盘符。这招在资源管理器里看盘符够用但在产测软件或授权校验场景下完全不行轮询周期短了 CPU 占用难看长了就漏掉瞬时插拔。而且 GetLogicalDrives 只能看到分区鼠标这种没有盘符的 HID 设备根本不在枚举范围内。2.1 轮询的致命缺陷看不到无盘符设备如果只依赖 GetLogicalDrives 或 GetVolumeInformation你检测到的只有 MSCMass Storage Class设备也就是 U 盘、移动硬盘这类会挂载分区的设备。鼠标、键盘、蓝牙手柄、HID 加密狗都没有盘符轮询这条路对它们是死路。另一个问题是时序。设备插入到分区挂载之间有一段延迟轮询在这个窗口期可能返回「未就绪」。你以为设备没插上其实它正在被系统初始化。事件驱动则不同系统在设备栈准备好后主动通知你时序上更接近设备真正可用的时刻。2.2 注册设备通知必须指定设备类 GUIDWindows 原生支持设备插拔广播系统会向所有顶层窗口发送 WM_DEVICECHANGE 消息。但前提是窗口要先调用 RegisterDeviceNotification 注册感兴趣的设备类。这就是大多数工程收不到插拔消息的头号原因你写了消息处理函数但窗口根本没注册。#include setupapi.h #include dbt.h #include hidclass.h #pragma comment(lib, setupapi.lib) GUID hidGuid; HidD_GetHidGuid(hidGuid); HDEVNOTIFY hNotify RegisterDeviceNotification( hWnd, hidGuid, DEVICE_NOTIFY_WINDOW_HANDLE );HidD_GetHidGuid 从 HID 类驱动里取出设备接口 GUID这个值在 Windows 上是固定不变的 {4d1e55b2-f16f-11cf-88cb-001111000030}。RegisterDeviceNotification 第一个参数是接收消息的窗口句柄第二个参数本质是 GUID 指针第三个参数 DEVICE_NOTIFY_WINDOW_HANDLE 表示走窗口消息通道要求 hWnd 必须是有效顶层窗口。注册成功后系统在设备接入和移除时会向该窗口投递 WM_DEVICECHANGE。注意这里有一个隐藏约束窗口过程必须在主线程消息循环里正常运转如果窗口被模态对话框阻塞或线程卡死消息会积压在队列里表现就是「插拔没反应」。2.3 消息链路从 DBT_DEVICEARRIVAL 到设备路径收到 WM_DEVICECHANGE 后wParam 是事件类型。插入是 DBT_DEVICEARRIVAL (0x8000)移除是 DBT_DEVICEREMOVECOMPLETE (0x8004)。这两个事件之间还有一个 DBT_DEVNODES_CHANGED那个属于设备树变化不是真正的拔插行为需要区分开。lParam 指向一个 DEV_BROADCAST_HDR 结构根据 dbch_devicetype 字段可以判断消息里带的是接口信息还是句柄信息。设备接口通知的类型是 DBT_DEVTYP_DEVICEINTERFACE数据结构是 DEV_BROADCAST_DEVICEINTERFACE里面有一个 dbcc_name 字段这就是设备的完整路径形如\\?\hid#vid_093apid_2510#71a3d4c5e00000#{4d1e55b2-f16f-11cf-88cb-001111000030}。3. 把 HID 拔插检测落地成 MFC 类编译环境与核心代码资源包里的核心是一个 HIDMonitor 类外加一个 MFC 对话框演示工程。工程配置要求 VS2015 及以上字符集选「多字节字符集」。如果你用的是 VS2019 或 VS2022打开工程后需要在项目属性里确认平台工具集匹配顺手把 Windows SDK 版本选成你本机已安装的那个。3.1 资源包结构与编译环境包内文件大致分成三块HIDMonitor.h/.cpp 是封装好的监控类核心逻辑全在这里DemoDlg 是一套完整的对话框界面实时显示插拔日志、设备名、VID/PID还有一份调用示例 main.cpp 告诉你非 MFC 控制台程序怎么复用这个类。编译前需要做的三件事在 stdafx.h 里加上 setupapi.h 和 hidclass.h 头文件链接 setupapi.lib 和 hid.lib确认项目字符集不是 Unicode否则 dbcc_name 拿回来的是宽字节字符串字符集混用会导致设备路径出现乱码。3.2 消息拦截与设备信息读取HIDMonitor 的工作过程分三步注册设备通知、拦截 WM_DEVICECHANGE、枚举设备路径并提取 VID/PID。注册部分前面已经写过消息拦截是关键因为 MFC 的消息映射器不会自动把 WM_DEVICECHANGE 分发给对话框类需要手动映射。BEGIN_MESSAGE_MAP(CDemoDlg, CDialogEx) ON_MESSAGE(WM_DEVICECHANGE, CDemoDlg::OnDeviceChange) END_MESSAGE_MAP() LRESULT CDemoDlg::OnDeviceChange(WPARAM wParam, LPARAM lParam) { if (wParam ! DBT_DEVICEARRIVAL wParam ! DBT_DEVICEREMOVECOMPLETE) return TRUE; PDEV_BROADCAST_HDR pHeader (PDEV_BROADCAST_HDR)lParam; if (pHeader-dbch_devicetype ! DBT_DEVTYP_DEVICEINTERFACE) return TRUE; PDEV_BROADCAST_DEVICEINTERFACE pInfo (PDEV_BROADCAST_DEVICEINTERFACE)pHeader; if (pInfo-dbcc_classguid hidGuid) { ProcessHidDevice(pInfo-dbcc_name, wParam); } return TRUE; }ON_MESSAGE 这种映射方式绕过了 MFC 的类向导直接把消息路由到指定函数返回值用 LRESULT 而不是 BOOL这是 Windows 窗口过程的约定。函数开头先滤掉不关心的事件类型再检查设备接口类型最后比对 GUID只有 HID 接口的设备才继续处理。如果你只关心特定品牌设备在这里就加过滤条件。ProcessHidDevice 函数内部要用 CreateFile 打开这个设备路径然后调用 HidD_GetAttributes 拿 VID/PID。void CDemoDlg::ProcessHidDevice(LPCTSTR strPath, WPARAM wParam) { HANDLE hDevice CreateFile( strPath, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL); if (hDevice INVALID_HANDLE_VALUE) return; HIDD_ATTRIBUTES attrs; attrs.Size sizeof(HIDD_ATTRIBUTES); if (HidD_GetAttributes(hDevice, attrs)) { CString strMsg; strMsg.Format(_T(VID0x%04X PID0x%04X %s), attrs.VendorID, attrs.ProductID, wParam DBT_DEVICEARRIVAL ? _T(插入) : _T(拔出)); m_listLog.InsertItem(0, strMsg); } CloseHandle(hDevice); }CreateFile 打开设备路径和打开文件走的是同一个 API但注意两个参数dwShareMode 必须给 FILE_SHARE_READ | FILE_SHARE_WRITE否则多进程访问设备时会冲突dwCreationDisposition 用 OPEN_EXISTING设备必须已存在不能新建。设备路径来自系统长度可能超过 MAX_PATH所以调用 CreateFile 前不要对路径做任何字符串缩短处理。VID/PID 是 16 位整数格式化时用 %04X 补零显示这样查驱动时一眼能看出厂商 ID。3.3 多线程窗口的坑消息回调在哪个线程HIDMonitor 类本身可以在工作线程中创建窗口和消息循环但有一个前置条件那条线程必须自己启动消息泵。如果你在业务线程里直接 new 一个窗口对象却没有人调用消息循环插拔消息永远不会被分发。常见的工程做法是让 HIDMonitor 依附主窗口在工作线程收到设备插拔相关业务时用 PostMessage 而不是 SendMessage 通知主界面刷新。SendMessage 是同步的工作线程会阻塞等待主界面处理完如果主界面此时弹了个模态框整个线程链路就卡住了。PostMessage 是异步投递不会阻塞发送方代价是消息到达时间不确定但对 UI 刷新场景完全够用。4. U 盘和鼠标的识别过滤VID/PID 匹配与设备分类策略设备路径和 VID/PID 都拿到了下一个问题是怎么知道插进来的是 U 盘还是鼠标主控芯片是否在白名单里这套 MFC 资源给了一种很实用的策略按设备接口 GUID 和 VID/PID 双重过滤。4.1 按接口 GUID 区分设备类型HID 接口 GUID 监听到的设备是广义 HID 设备包括鼠标、键盘、触摸板、手柄、部分加密狗。但传统的 U 盘走的是 USB Mass Storage 接口不是 HID 接口它的设备接口 GUID 是 USB 类 GUID {a5dcbf10-6530-11d2-901f-00c04fb951ed}。所以如果你的目标是同时监控 U 盘和鼠标需要注册两个 GUID。GUID usbGuid; GUID hidGuid; HidD_GetHidGuid(hidGuid); // {a5dcbf10-6530-11d2-901f-00c04fb951ed} usbGuid GUID_DEVINTERFACE_USB_DEVICE; RegisterDeviceNotification(hWnd, hidGuid, DEVICE_NOTIFY_WINDOW_HANDLE); RegisterDeviceNotification(hWnd, usbGuid, DEVICE_NOTIFY_WINDOW_HANDLE);这里有个容易搞混的地方USB 类 GUID 监听到的是所有 USB 设备包括 U 盘、鼠标、USB 转串口等范围太宽。通常的做法是先用 USB 类 GUID 做粗筛拿到设备路径后用 SetupAPI 枚举该路径的父设备或接口信息再判断设备功能类型。如果嫌繁琐更直接的办法是对 HID GUID 监听结果进一步按 UsagePage/Usage 区分HID 鼠标的 UsagePage 是 0x01、Usage 是 0x02HID 键盘是 UsagePage 0x01、Usage 0x06。这个字段在 HIDP_CAPS 结构里可以通过 HidP_GetCaps 拿到。4.2 U 盘不全是 HID 设备标题里写的是 HID 拔插检测但很多做产测的人真正要监控的是 U 盘。U 盘主控有两条路大多数走 USB Mass Storage 类少数加密 U 盘走厂商自定义的 HID 通道。所以监控 U 盘的正确姿势是同时挂 USB 类 GUID 和 HID GUID然后在消息处理里分支判断。如果是走 MSC 路径的 U 盘事件通知只告诉你「USB 设备插入了」但它是不是一个 U 盘需要额外判断盘符是否存在。一个常见的处理流程是收到 DBT_DEVICEARRIVAL 后延时 500 毫秒再用 GetLogicalDrives 枚举盘符对比前后两次枚举结果新增的盘符就是 U 盘挂载出的卷。DWORD dwDrivesBefore GetLogicalDrives(); // 等待系统完成挂载 Sleep(500); DWORD dwDrivesAfter GetLogicalDrives(); DWORD dwNewDrives dwDrivesAfter (~dwDrivesBefore); if (dwNewDrives ! 0) { // 遍历每个 bit找到新增盘符 for (int i 0; i 26; i) { if (dwNewDrives (1 i)) { CString szDrive; szDrive.Format(_T(%c:\\), _T(A) i); // 检测到新盘符 } } }GetLogicalDrives 返回一个 DWORD 位掩码bit 0 对应 A 盘、bit 1 对应 B 盘依此类推。Sleep(500) 是为了等系统完成卷挂载时间太短会漏掉刚插入还没就绪的 U 盘太长则影响响应速度。这个延时值可以根据目标设备类型调整普通 U 盘 300 到 800 毫秒都行老式机械移动硬盘可能需要更久。4.3 白名单机制只放行指定 VID/PID资源包内预置了一份设备白名单表格式是 VID 加 PID 的逗号分隔对。这个机制的价值在于区分「U 盘插拔了」和「我们授权的 U 盘插拔了」两个诉求。不用白名单时系统里插个鼠标都会被记一条日志日志量一大就淹没了真正的授权设备信号。; 白名单配置文件 device.ini ; 格式 VID0xxxx, PID0xxxx 04710x0839,0x0850 ; 飞利浦 09300x6544,0x6545 ; 东芝 07810x5583,0x5581 ; 闪迪解析时按行读取等号左边是厂商名或备注右边是 VID/PID 列表。匹配逻辑是先用 HidD_GetAttributes 拿到设备 VID/PID再遍历白名单比对命中才触发回调事件。注意 VID 和 PID 都是十六进制数值文件里写 0x 前缀是为了可读性代码里用 _tcstoul 转换时字符串基数的参数给 16不要默认传 10否则 0x0930 会解析失败变成 0。5. HID 拔插检测避坑记录收不到消息、句柄泄漏与瞬时插拔这套链路踩过的坑不少每一类几乎都对应一类业务事故。我把常见的几条记录列在这里按现象、原因、解决的方式展开。坑一插拔完全收不到任何消息现象程序运行后无论插拔 U 盘还是鼠标日志区都没有记录。原因绝大部分情况是忘了调用 RegisterDeviceNotification或者窗口句柄传了一个空值。其次可能注册时 GUID 参数传的是 HID 设备类 GUID{745a17a0-74d3-11d0-b6fe-00a0c90f57da}这个 GUID 是设备安装类不是设备接口类两者注册后收到的消息来源不同。RegisterDeviceNotification 要求的 GUID 是设备接口 GUID必须是 HidD_GetHidGuid 返回的那一个。解决检查注册返回值 HDEVNOTIFY 是否非 NULL同时用 Spy 挂到目标窗口上观察 WM_DEVICECHANGE 是否到达。如果 Spy 能看到消息但程序收不到检查 ON_MESSAGE 映射是否声明在 BEGIN_MESSAGE_MAP 里。如果消息根本没到达窗口确认 hWnd 是顶层窗口而不是子窗口控件句柄。坑二收到插入消息但打开设备失败现象日志显示设备插入但 VID/PID 一直拿不到CreateFile 返回 INVALID_HANDLE_VALUE 且 GetLastError 是 5拒绝访问。原因设备插入消息到达时驱动栈可能还没完全初始化完毕。HID 设备的接口路径虽然已经出现在消息里但设备对象还不能接受打开操作。另外有些 HID 设备只允许单进程独占访问如果杀毒软件或系统服务已经打开该设备独占打开就会失败。解决在 CreateFile 前做一个短延时重试机制比如 50ms 间隔最多重试 5 次。共享模式参数给 FILE_SHARE_READ | FILE_SHARE_WRITE 已经是常规操作如果设备本身属性不允许共享打开就接受这个失败并在日志里标注获取失败原因不要无限重试导致线程挂死。坑三拔插速度太快消息在队列里被合并现象快速插拔三次 U 盘日志里只记录了一次插入和一次拔出。原因WM_DEVICECHANGE 属于系统广播消息消息队列里同一窗口的同类消息不会无限堆积连续到达的同类消息可能被合并。这属于 Windows 消息机制的正常行为不是 Bug。解决关键业务不要依赖消息次数。如果必须感知到每一次插拔动作需要配合轮询枚举设备路径做兜底对账。常见方案是收到插入消息后主动枚举一次当前全部 HID 设备路径和上一次枚举结果做差集这个差集就是新增设备删除的部分就是已移除设备。枚举函数用 SetupDiGetClassDevs SetupDiEnumDeviceInterfaces。坑四拔掉设备后句柄没释放下次插入同名设备打不开现象第一次插入正常识别拔出后再插入CreateFile 返回错误码 32另一个程序正在使用此文件。原因ProcessHidDevice 里 CreateFile 成功打开设备但后续流程在某处提前 return 了CloseHandle 没执行。比如 HidD_GetAttributes 返回 FALSE 时直接走到 return设备句柄泄漏在堆里。拔掉设备后系统释放了内核对象但进程内的句柄表仍占着位置下次同路径设备接入时打开就会冲突。解决所有打开设备函数里用 RAII 或者 try-finally 保证 CloseHandle 一定被执行。MFC 工程里标准做法是把 CloseHandle 写在函数内所有 return 路径之前或者封装一层 CAmAutoHandle 类在析构里关闭。写完代码后拔插设备 50 次用任务管理器的句柄数观察是否持续增长。坑五消息处理里直接操作 UI结果界面卡死现象拔出鼠标后界面卡住无响应CPU 占用飙高。原因OnDeviceChange 是在窗口过程里执行的本身就是 UI 线程。如果在这里写大量文件操作、网络请求或 Sleep 等待窗口的消息泵被阻塞界面自然就死了。更严重的是拔鼠标这种操作会连续触发多个 WM_DEVICECHANGE如果每个事件都做重操作消息队列会积压。解决OnDeviceChange 只做记录和投递把重活放到 PostMessage 触发的自定义消息处理函数里。必要时开一个工作线程做设备路径枚举和 VID/PID 提取完成后用 PostMessage 回传结果给界面。这里必须用 PostMessage不能用 SendMessage跨线程 SendMessage 会导致工作线程阻塞等待 UI 线程响应拔插风暴来临时可能死锁。6. 进阶验证批量拔插自检脚本与可靠性对账写完了不验证等于没写。我习惯给插拔检测功能配一套自检脚本用 Windows 的硬件移除和重新扫描机制做循环测试把检测可靠性量化出来。这比手工一根根拔插 U 盘靠谱得多还能在交付前发现句柄泄漏和消息丢失问题。echo off setlocal enabledelayedexpansion set COUNT0 set MAX30 :loop if %COUNT% GEQ %MAX% goto end devcon.exe disable USB\VID_093APID_2510* timeout /t 2 /nobreak nul devcon.exe enable USB\VID_093APID_2510* timeout /t 2 /nobreak nul set /a COUNTCOUNT1 echo round !COUNT! done goto loop :end echo all test rounds finisheddevcon 是 Windows 驱动工具包里的命令行程序disable 相当于逻辑拔出enable 相当于重新接入。因为这个动作是驱动层操作设备路径不会变正好用来验证「同一路径设备反复插拔」场景。执行一轮脚本后回到程序日志里核对插入与拔出记录次数是否等于 MAX 值。如果发现少收消息大概率问题出在 devcon disable 的命令匹配模式上。USB\VID_093APID_2510*这种方式只匹配硬件 ID 前缀但有些设备在 disable 后重新 enable 时设备实例路径会重新生成此时程序代码如果仍按旧路径比对就会显示为拔出后没有新的插入事件。对策是枚举时不要缓存设备路径做长期键值只用它拿 VID/PID设备唯一性靠实例 ID 来保证。还有一种更狠的验证方式是直接调用 SetupAPI 的 CM_Query_And_Remove_SubTree 强制卸载设备树节点这是模拟用户物理拔出的最接近方式。但在桌面环境容易把系统 USB 总线节点卸载掉建议只在测试机上用。从那以后我每次交付 HID 插拔检测功能都把批量插拔自检脚本和程序一起打包进测试报告。跑完 30 轮插拔再交付基本没在客户现场因为「收不到拔插消息」翻过车。这套方法你也可以直接用在自己接手的项目上希望帮到你。本文还有配套的精品资源点击获取
返回列表