在 Windows 应用中处理 USB 设备的方法 ── 虚拟 COM、HID、WinUSB、专用 SDK 该如何选择

· · USB, HID, WinUSB, 串口通信, 设备联动, Windows开发, C#, 设备驱动程序

「这台设备是用 USB 连接的,从应用里应该能直接操作吧?」── 这是设备联动咨询中最先冒出来的问题。答案是「取决于连接方式」,这一句话背后,隐藏着相差几十倍的开发工时。同样是「USB 连接的设备」,是显示为 COM 端口、还是显示为 HID、又或者需要专用驱动程序,写出来的代码、分发方式,乃至现场会遇到的故障种类,都会完全不同。

麻烦的是,这个判断必须在开始写应用之前就完成。如果按「先装个 SDK,能跑就行」的方式推进,之后就会以「无法生成 64 位版本」「接两台设备就无法识别」「客户的电脑装不上驱动程序」这类形式反噬回来。

本文将整理从 Windows 应用处理 USB 设备的四种方式 ── 虚拟 COM 端口、HID、WinUSB、供应商提供的 SDK ── 包括选型标准、实现要点,以及四种方式都需要的通用设计。

1. 先说结论

  • 首先要确认的是「在设备管理器中,它出现在哪里、显示为什么」。端口(COM 和 LPT)、人体学输入设备、通用串行总线设备、独立类别 ── 到这一步,方式基本就定下来了(第 2 章)。
  • 属于标准类的设备不需要驱动程序。Windows 标准自带音频、CDC、HID、大容量存储、打印等类驱动程序,符合的设备会自动工作。不建议厂商为标准类另行编写驱动程序。1
  • 官方的选型顺序是「从简单的开始」。(1) 能用标准类驱动程序就不要自己写,(2) 不能用且只由单一应用访问就用 WinUSB,(3) 多个应用需要同时访问就用 UMDF 驱动程序,(4) 还是不行才用 KMDF 驱动程序 ── 按这个顺序依次考虑。2
  • 虚拟 COM 在移植和复用上最强,但识别能力最弱。CDC-ACM 设备在 Windows 10 及以后会自动加载 usbser.sys,只用 SerialPort 就能写。但COM 号并不是设备的 ID。必须实现从 VID/PID、序列号在运行时解析对应端口(第 3 章)。3
  • HID 是一个隐藏的强力选项,无需分发驱动程序即可实现双向通信。但相当于鼠标、键盘、触摸、笔的集合,会被操作系统独占打开,无法触碰。速度也受限于中断传输的带宽(第 4 章)。4
  • WinUSB 适合「需要批量传输速度」「独有协议」的场景。能不带 INF 自动安装的前提是,固件通过 Microsoft OS 描述符报告兼容 ID WINUSB,且系统是 Windows 8 及以后。如果是现有设备或需要覆盖 Windows 7 及更早版本,基本上都需要自定义 INF(第 5 章)。5
  • 供应商 SDK 不是「选择」而是「承受」的对象。bitness(32 位还是 64 位)、线程模型、生命周期、再分发条件,全都由对方决定,因此要先把 SDK 的约束当成应用设计的前提条件梳理清楚(第 6 章)。
  • 无论选哪种方式,设备的唯一识别、拔插追踪、超时、电源管理这四点都要自己设计。省略了这些的应用,几乎必然会变成「偶尔不工作」(第 8 章)。
  • 如果要自行开发内核模式驱动程序,Windows 10 1607 及以后必须经过 Microsoft 签名。包括 Partner Center 开户需要 EV 证书这一点在内,请提前把它计入分发成本(第 10 章)。6

2. 大前提 ── 从 Windows 的视角看 USB 设备,一切都取决于「加载了哪种驱动程序」

先用一张图给出四种方式的决策树。这是把第 1 章列出的官方选型顺序(从简单的开始)按实际判断顺序重新排列的结果。2 各分支的细节分别对应第 3~6 章。

显示为端口-COM和LPT-显示为人体学输入设备已加载厂商驱动程序都不是-未知设备不能改能改够用不够-大量数据/独有协议不需要需要想从应用中操作 USB 设备在设备管理器中显示为什么-先插上实物确认-设备固件是否可以改动-自研设计,或可委托厂商-所需带宽用中断传输是否够用-参考 数十 KB/s 以下的状态通知或命令-是否有多个应用同时访问同一设备方式 A - 虚拟 COM 端口第3章方式 B - HID第4章方式 C - WinUSB第5章方式 D - 供应商 SDK第6章考虑开发 UMDF 驱动程序不行再用 KMDF。分发成本见第10章

图1:四种方式的决策树。起点必须是「在设备管理器中显示为什么」

这张图的要点有两个。起点不是产品目录,而是实物在设备管理器中的表现,以及越往下走,分发成本越高。如果能在靠上的层级解决,那就是正解;只有「有理由往下走」时,才应该往下判断。

无论 USB 线的另一端连着什么,应用能看到的只是该设备上加载的驱动程序所公开的接口而已。抓住这一点,讨论才不会跑偏。

设备插入后,Windows 会读取设备申报的描述符,根据类代码和 VID/PID 决定加载哪种驱动程序。如果属于标准类,Windows 自带的类驱动程序会自动加载。1

USB-IF 类代码 Windows 标准驱动程序 应用可见的形态
Audio (01h) Usbaudio.sys 音频设备
CDC (02h,子类02h) Usbser.sys COM 端口
HID (03h) Hidclass.sys / Hidusb.sys HID 集合
Image (06h) Usbscan.sys WIA 设备
Printer (07h) Usbprint.sys 打印机
Mass Storage (08h) Usbstor.sys 驱动器
Video (0Eh) Usbvideo.sys 摄像头(UVC)
Vendor Specific (FFh) (无) 建议使用 WinUSB

最后一行很重要。厂商自定义设备大多会声明为 FFh(Vendor Specific),这种情况下 Microsoft 的建议是使用 WinUSB1

还有一点需要了解,就是复合设备(composite device)。一根 USB 线连接的设备如果具备多种功能,Usbccgp.sys 会把每个功能展开为独立的设备。「明明是一台设备,设备管理器里却出现了三个」,原因就在这里。例如「控制走 CDC(COM 端口),状态通知走 HID」这种构成的设备并不少见。方式不是按设备决定,而是按功能(接口)来决定的。

第一步:先在设备管理器中看实物

在讨论之前,请先插上实机确认以下内容。只需 5 分钟,但之后的所有判断都会因此改变。

  1. 在设备管理器的哪个类别下,显示为什么名称
  2. 属性 → 详细信息标签 → 硬件 IDUSB\VID_xxxx&PID_yyyy&...
  3. 同上 → 兼容 ID(能否看到 USB\Class_02&SubClass_02USB\MS_COMP_WINUSB
  4. 同上 → 设备实例路径末尾(是否包含序列号,还是含 & 的生成值)
  5. 驱动程序标签 → 提供程序和驱动程序文件(是 Microsoft 制作还是厂商制作)

如果第 3 项中能看到 USB\MS_COMP_WINUSB,说明该设备是按 WinUSB 设备设计的。5 第 4 项在 8.1 节会用到,是判断能否唯一识别设备的依据。

3. 方式 A:虚拟 COM 端口 ── 最省事,也最容易搞混

3.1 背后发生了什么

对于声明 USB 的 CDC(Communications and CDC Control)类、子类 02h(ACM)的设备,Windows 标准的 Usbser.sys无需分发 INF、自动加载。只要在设备描述符中设置类 02、子类 02,兼容 ID USB\Class_02&SubClass_02 就会匹配到标准的 Usbser.inf,机制就是这样。3

不过,这种自动加载是 Windows 10 及以后的行为1 如果 Windows 8.1 及更早版本也在覆盖范围内,仅靠描述符是不够的,需要准备并分发引用标准驱动程序的 INF(例如引用 mdmcpq.inf 的自定义 INF)。「Windows 10 上什么都没做就能用,但客户的 Windows 7 机器上却变成未知设备」,原因就在这里。

另一条路径是 FTDI、Silicon Labs、Prolific 等USB-串口转换芯片厂商提供的 VCP 驱动程序。这条路径需要安装驱动程序,但由于芯片厂商把已签名的驱动程序也放上了 Windows Update,实务上基本处于「插上就能装」的状态。

无论哪种情况,应用看到的都只是一个普通的 COM 端口。这是最大的优势,RS-232 时代的资产、经验、测试用终端软件都可以直接沿用。

3.2 实现只需 SerialPort,但也继承了同样的坑

.NET 下用 System.IO.Ports.SerialPort(.NET 5 及以后需要引用 System.IO.Ports 包)。实现上的注意事项并非 USB 特有,而是串口通信通用的问题,包括帧划分、超时、重连、日志设计,都整理在《串口通信应用的陷阱》中。尤其是Read(buffer, 0, 16) 不代表一定能刚好读到 16 字节这一点,即便走 USB 也没有变化。应当当作字节流来接收,先积累到缓冲区,再由 parser 切出帧。

3.3 不要把 COM 号写进配置文件

虚拟 COM 方式在现场出问题的头号原因,就是这个。

  • COM 号只是 Windows 在这台电脑上分配的编号,不是设备的标识符
  • 更换插入的 USB 端口,编号可能会变
  • 同型号设备接两台,仅凭编号无法分辨谁是谁
  • 「COM3 正在使用中」会导致编号跳跃,出现 COM13、COM27 这类情况也很常见

正确的实现方式是,在运行时根据 VID/PID(以及序列号,如果有的话)解析出 COM 号。这些信息可以从 PnP 的枚举中获取。

# 把 COM 端口连同硬件 ID 一并全部列出(先不做筛选地看一遍比较安全)
Get-CimInstance Win32_PnPEntity |
  Where-Object { $_.PNPClass -eq 'Ports' } |
  Select-Object Name, PNPDeviceID |
  Format-List

# 输出示例:
# Name        : USB シリアル デバイス (COM5)          ← CDC-ACM(usbser.sys)
# PNPDeviceID : USB\VID_2341&PID_0043\85436323631351D0E1C1
#                    ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^
#                    VID/PID            设备实例ID
#
# Name        : USB Serial Port (COM7)               ← FTDI的VCP驱动程序
# PNPDeviceID : FTDIBUS\VID_0403+PID_6001+A5XK3RJTA\0000
#               ^^^^^^^ 枚举符不是 USB 而是 FTDIBUS

在这里用 PNPDeviceID -like 'USB\*' 做筛选会出事。如上面的示例所示,FTDI 的 VCP 驱动程序生成的 COM 端口以 FTDIBUS\ 开头,一条都不会USB\ 过滤器命中。Silicon Labs 等其他芯片厂商也可能拥有各自独立的枚举符。

安全的实现方式是以下两种之一。

  • 先全量获取 Ports 类,再用正则表达式提取 PNPDeviceID 中的 VID_xxxx/PID_yyyy(或 VID_xxxx+PID_yyyy ── 不依赖枚举符名称,更稳健
  • 把枚举符名称显式列入白名单USB\FTDIBUS\ 等) ── 如果目标设备固定,这样就足够了

无论哪种方式,都请先实际插上你的目标设备,跑一遍上面这条命令,用肉眼确认它以什么样的 PNPDeviceID 出现,之后再写筛选逻辑。枚举符名称是由设备和驱动程序的组合决定的,不能在纸面上凭空断定。

在 C# 里,可以用 System.ManagementManagementObjectSearcher 发出同样的查询,也可以用 Windows.Devices.SerialCommunication.SerialDevice.GetDeviceSelectorFromUsbVidPid(vid, pid) 生成 AQS 选择器后调用 DeviceInformation.FindAllAsyncGetDeviceSelector 这一个重载不接受 VID/PID,而是不带参数或传端口名,注意不要弄混)。前者依赖更少,在桌面应用中往往更好用。

把从枚举到打开 SerialPort 的完整流程写出来,就是下面这样。这是把上面 PowerShell 里的确认过程直接落地成代码(.NET 8,需要引用 System.Management 包,且仅限 Windows)。

using System.Globalization;
using System.IO.Ports;
using System.Management;
using System.Text.RegularExpressions;

// 从 PNPDeviceID 中提取 VID/PID。注意分隔符既有 & 也有 +
//   USB\VID_2341&PID_0043\...      ← CDC-ACM(usbser.sys)
//   FTDIBUS\VID_0403+PID_6001+...  ← FTDI的VCP驱动程序
private static readonly Regex VidPidPattern = new(
    @"VID[_+](?<vid>[0-9A-Fa-f]{4})[&+]PID[_+](?<pid>[0-9A-Fa-f]{4})",
    RegexOptions.IgnoreCase | RegexOptions.Compiled);

private static readonly Regex ComNamePattern = new(@"\((?<com>COM\d+)\)", RegexOptions.Compiled);

/// <summary>枚举指定VID/PID的COM端口名(不依赖枚举符名称)。</summary>
static IEnumerable<(string PortName, string PnpDeviceId)> FindComPorts(ushort vid, ushort pid)
{
    // PNPClass = 'Ports' 全量获取。用 USB\ 筛选会漏掉 FTDIBUS\
    using var searcher = new ManagementObjectSearcher(
        "SELECT Name, PNPDeviceID FROM Win32_PnPEntity WHERE PNPClass = 'Ports'");

    foreach (var device in searcher.Get().Cast<ManagementObject>())
    {
        using (device)
        {
            var name = device["Name"] as string;
            var pnpId = device["PNPDeviceID"] as string;
            if (name is null || pnpId is null) { continue; }

            var ids = VidPidPattern.Match(pnpId);
            if (!ids.Success) { continue; }
            if (ushort.Parse(ids.Groups["vid"].Value, NumberStyles.HexNumber) != vid) { continue; }
            if (ushort.Parse(ids.Groups["pid"].Value, NumberStyles.HexNumber) != pid) { continue; }

            // 从 "USB シリアル デバイス (COM5)" 中取出 (COM5)
            var com = ComNamePattern.Match(name);
            if (!com.Success) { continue; }

            yield return (com.Groups["com"].Value, pnpId);
        }
    }
}

// 序列号在设备实例路径中的位置,因枚举符不同而不同。
//   USB\VID_2341&PID_0043\85436323631351D0E1C1  末尾元素就是序列号
//   FTDIBUS\VID_0403+PID_6001+A5XK3RJTA\0000    埋在中间元素里,
//                                               末尾是 \0000。而且FTDI
//                                               还会在后面加一个表示端口的字符
// 如果只按「是否与末尾元素一致」来写,FTDI的VCP将一条都匹配不到。
static bool MatchesSerial(string pnpDeviceId, string serial)
{
    foreach (var part in pnpDeviceId.Split('\\'))
    {
        if (part.Equals(serial, StringComparison.OrdinalIgnoreCase)) { return true; }

        // FTDI格式 VID_xxxx+PID_xxxx+<序列号><端口字符> 的最后一个字段
        var fields = part.Split('+');
        if (fields.Length < 3) { continue; }
        var tail = fields[fields.Length - 1];
        if (tail.Equals(serial, StringComparison.OrdinalIgnoreCase)) { return true; }
        if (tail.Length == serial.Length + 1 &&
            tail.StartsWith(serial, StringComparison.OrdinalIgnoreCase)) { return true; }
    }
    return false;
}

// 调用方: 打开的是"解析出的结果",而不是配置文件里的COM号
var candidates = FindComPorts(0x2341, 0x0043).ToList();
if (candidates.Count == 0) { throw new InvalidOperationException("未找到目标设备。"); }

// 无论找到几台,都必须用序列号做筛选。即使只找到一台,也不代表
// 那就是目标机(有可能目标机不在,而只插着别的一台)。WMI 的枚举顺序
// 并不保证设备的同一性,如果直接拿 candidates[0],就会发生
// "操作了隔壁那台设备"的事故。
var wanted = config.DeviceSerial;   // 从配置或参数接收"是哪一台"
candidates = candidates.Where(c => MatchesSerial(c.PnpDeviceId, wanted)).ToList();

if (candidates.Count != 1)
{
    throw new InvalidOperationException(
        $"无法根据序列号 '{wanted}' 确定唯一一台设备(命中 {candidates.Count} 台)。");
}

using var port = new SerialPort(candidates[0].PortName, 115200)
{
    ReadTimeout = 1000,
    WriteTimeout = 1000,
};
port.Open();

要点有四个。(1) 先用 PNPClass = 'Ports' 全量获取,再按 VID/PID 筛选(不要用 USB\ 筛选),(2) 要打开的端口名每次都从这个枚举结果中解析(不要在配置文件里写死 COM3),(3) 按序列号筛选不是「找到多台时」才做,而是每次都要过一遍(4) 序列号的查找方式不要依赖枚举符(键的构造方式见 8.1 节)。

(3) 和 (4) 只要弄错任何一个,症状都会一样,都是「操作了隔壁那台设备」。(3) 的陷阱在于,只找到一台时看起来似乎不需要再确认。但如果目标机不在,而只插着别的一台,候选虽然只有一台,内容却是别的设备。(4) 的问题是,如果只看 USB\ 就断定「序列号是末尾元素」,那么在 FTDIBUS\VID_0403+PID_6001+A5XK3RJTA\0000 这类把序列号埋在中间元素的格式下,就会一条都匹配不上。上面的代码把这两点都封装进了 MatchesSerial

另外,如果能用作键,把整个设备实例路径直接存进配置会更可靠(8.1 节)。这样就不再需要单独提取序列号的处理了。

Name 末尾的 (COM5) 里切出编号看起来不太规范,但实务上是最可靠的做法。要做得严谨一些,可以读取设备注册表键下的 PortName 值。

3.4 拔插会让句柄失效

USB 串口一旦拔掉线缆,端口本身就会消失。如果打开着 SerialPort 就拔线,内部的接收线程会抛出异常,把整个应用带崩,这是一个经典的事故。收到 PnP 的移除通知后,首先 Close 端口,建立起这样的顺序是安全的(8.2 节)。

重连不能靠「重新执行一次 Open()」来解决。请把它设计成一次完整的会话重建,包括旧会话失效化、把处理中的请求标记为失败、停止读写线程、退避后重新打开、以及重新执行设备初始化序列。

3.5 适用与不适用

适用 不适用
已有既存的串口通信协议资产 经由 USB-UART 转换芯片的高吞吐场景
文本命令应答型的设备、仪器 对低延迟要求苛刻的控制
希望在现场用终端软件做排查 同型号设备大量同时连接(识别实现会变重)
开发者不具备驱动程序知识 可以由己方自主决定协议设计的新开发项目

关于吞吐量,请不要一概而论地认为「虚拟 COM 就是慢」。在夹着 FTDI 等 USB-UART 转换芯片的架构中,转换后的 UART 波特率就是上限(921.6kbps 时约 92KB/s)。而在单片机直接实现 CDC-ACM 的原生 USB 设备中,数据接口走的是 USB 的批量传输,没有 UART 的限制,高速连接下也可能达到数 MB/s 级别。不过在那个量级上,Usbser.sysSerialPort 层的开销会开始显现,因此如果所需带宽超过数百 KB/s,正确的做法是先在实机上实测,再决定方式。在还没测过的阶段就断定「要速度所以用 WinUSB」,会白白背上不必要的驱动程序分发负担。

4. 方式 B:HID ── 无需分发驱动程序即可实现双向通信

4.1 HID 不只是给输入设备用的

一提到 HID,容易联想到鼠标和键盘,但就规格而言,它是一种可以双向收发任意字节序列(报告)的通用协议。条码阅读器、读卡器、电子锁、测量单元、UPS、独有的 I/O 盒 ── 「不想分发驱动程序、但又想收发自定义数据」的设备之所以会声明为 HID,是因为 Windows 标准自带了 Hidclass.sysHidusb.sys完全不用分发 INF 或驱动程序就能工作1

从 Windows 的视角看,HID 的单位是顶级集合(TLC)。一台物理设备可能拥有多个 TLC,此时每个 TLC 会作为独立的设备接口出现。4

4.2 能碰的 HID 与不能碰的 HID

这是最重要的限制。Windows 会以独占模式打开部分 TLC。目的是防止其他应用抢夺全局输入状态,Raw Input Manager(RIM) 会以独占方式打开这些设备。4

Usage Page / Usage 用途 访问模式
0x0001 / 0x0001-0x0002 鼠标 独占
0x0001 / 0x0004-0x0005 游戏手柄 共享
0x0001 / 0x0006-0x0007 键盘・小键盘 独占
0x000C / 0x0001 消费者控制 共享
0x000D / 0x0001-0x0002 独占
0x000D / 0x0004-0x0005 触摸屏・高精度触控板 独占
0x0020 / 各种 传感器 共享
0x008C / 0x0002 条码扫描器 共享(解码数据的获取为独占)

也就是说,试图从模拟键盘输入的条码阅读器上用 HID API 直接取数据,是取不到的。因为它是以键盘身份被独占打开的。这类设备如果想「不当作按键输入、而是当作数据接收」,正规做法是在设备一侧切换到 HID 的厂商自定义 TLC 模式或 CDC 模式。

需要说明的是,即便设备以独占方式被打开,只要在打开句柄时不要求读写权限,仍然可以通过 HidD_GetXxx 系列函数获取属性和字符串。4 「只想确认设备是否连接」这类用途,用这种方式就够了。

4.3 实现 ── 报告长度一旦搞错必定失败

用户模式应用的步骤是固定的:用 SetupDi* 查找 HID 集合,用 CreateFile 打开,用 HidD_* 获取信息,用 ReadFile/WriteFile 读写报告,用 HidP_* 解析报告 ── 就这些。7

// HID设备的枚举,以及报告长度的获取(P/Invoke声明摘录)
[DllImport("hid.dll")]
static extern void HidD_GetHidGuid(out Guid hidGuid);

[DllImport("hid.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.U1)]
static extern bool HidD_GetAttributes(SafeFileHandle device, ref HIDD_ATTRIBUTES attributes);

[DllImport("hid.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.U1)]
static extern bool HidD_GetPreparsedData(SafeFileHandle device, out IntPtr preparsedData);

[DllImport("hid.dll")]
static extern int HidP_GetCaps(IntPtr preparsedData, out HIDP_CAPS capabilities);

// GetPreparsedData 返回的缓冲区必须释放。忘记的话会在原生一侧泄漏
[DllImport("hid.dll")]
[return: MarshalAs(UnmanagedType.U1)]
static extern bool HidD_FreePreparsedData(IntPtr preparsedData);

[StructLayout(LayoutKind.Sequential)]
struct HIDD_ATTRIBUTES
{
    public int Size;              // 必须设置为 sizeof(HIDD_ATTRIBUTES)
    public ushort VendorID;
    public ushort ProductID;
    public ushort VersionNumber;
}

// HIDP_CAPS 不能只按"用到的字段"来声明。
// HidP_GetCaps 会写入原生定义的完整长度(USHORT×32 = 64字节),
// 传入一个中途截断的结构体,会破坏其后的栈内存
[StructLayout(LayoutKind.Sequential)]
struct HIDP_CAPS
{
    public ushort Usage;
    public ushort UsagePage;
    public ushort InputReportByteLength;    // 传给 ReadFile 的缓冲区长度
    public ushort OutputReportByteLength;   // 传给 WriteFile 的缓冲区长度
    public ushort FeatureReportByteLength;

    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 17)]
    public ushort[] Reserved;               // 保留区域。不可省略

    public ushort NumberLinkCollectionNodes;
    public ushort NumberInputButtonCaps;
    public ushort NumberInputValueCaps;
    public ushort NumberInputDataIndices;
    public ushort NumberOutputButtonCaps;
    public ushort NumberOutputValueCaps;
    public ushort NumberOutputDataIndices;
    public ushort NumberFeatureButtonCaps;
    public ushort NumberFeatureValueCaps;
    public ushort NumberFeatureDataIndices;
}

结构体声明中常见的一个事故,就是「只写用到的字段,剩下的用注释敷衍」HidP_GetCaps 会按原生定义写入完整长度(USHORT×32 = 64字节),如果只传入前 5 个字段(10字节)的结构体,marshaller 分配的区域会被溢出写入多达 54 字节。运气好的话是 AccessViolationException,运气不好则会悄悄破坏其他变量。P/Invoke 的结构体,请连同不使用的字段一起,按与原生完全相同的大小和排列来声明。8

HidD_GetPreparsedData 返回的缓冲区是在原生一侧分配的,用完后必须用 HidD_FreePreparsedData 释放。如果应用会在每次拔插时重新枚举全部 HID 设备,一旦忘记释放,就会悄悄地持续吃掉内存。请用 try/finally 包裹,或者用 SafeHandle 派生类包装,从结构上防止漏释放。

const int HIDP_STATUS_SUCCESS = 0x00110000;

// 务必检查返回值。刚枚举完就被拔出的话会返回 FALSE
if (!HidD_GetPreparsedData(handle, out IntPtr preparsed))
{
    return null;   // 跳过这个设备。preparsed 无效,不要碰
}

try
{
    if (HidP_GetCaps(preparsed, out HIDP_CAPS caps) != HIDP_STATUS_SUCCESS)
    {
        return null;
    }
    // 到这里 caps.InputReportByteLength 等字段才有效
}
finally
{
    HidD_FreePreparsedData(preparsed);   // 只在获取成功时释放
}

请不要忽略 HidD_GetPreparsedData 的返回值。如果在枚举完成到打开句柄之间设备被拔出,会返回 FALSE,此时 preparsed 不是有效指针。把它传给 HidP_GetCapsHidD_FreePreparsedData,再用内容不明的 caps 去决定报告长度,就是这么发生的。拔插越频繁的现场越容易踩到这种竞态,因此只有在获取成功时才进入 try,并且要检查 HidP_GetCaps 的返回值(是否为 HIDP_STATUS_SUCCESS)。

实现中最常见的失败是报告长度的处理

  • 传给 ReadFile 的缓冲区要刚好等于 InputReportByteLength。短了会失败,长了也不会被正确处理
  • 缓冲区的第一个字节是报告 ID。如果设备设计上不使用报告 ID,这里会填 0。实际数据从字节 1 开始
  • 同理,WriteFile 的缓冲区要刚好等于 OutputReportByteLength,开头放报告 ID

「发了数据但设备没反应」的九成原因,要么是因为报告 ID 占的那一个字节导致数据错位,要么是缓冲区长度不对。当设备文档写着「命令是 8 字节」,而 OutputReportByteLength 是 9 时,意味着算上报告 ID 一共是 9 字节

发送输出报告的路径除了 WriteFile,还有 HidD_SetOutputReport,两者的使用场景在官方文档中已有明确划分。9

用途 使用的函数
持续发送输出报告 WriteFile(这是基本方式)
设置集合的当前状态 HidD_SetOutputReport
发送功能(Feature)报告 HidD_SetFeature

需要注意的是,官方文档明确警告「部分设备不支持 HidD_SetOutputReport,使用它可能导致设备不再响应」9 也就是说,「WriteFile 不起作用就换成 HidD_SetOutputReport」这种切换,并不是一个无条件安全的替代方案。应先确认设备规格书和厂商示例代码使用的是哪一个再做选择,如果要切换,也务必在实机上确认切换后不会导致设备停止响应。

如果目的只是枚举设备,请把 CreateFiledwDesiredAccess 设为 0 打开。这样即使是被独占打开的设备也能枚举到,还可以用 HidD_GetAttributes 获取 VID/PID,用 HidD_GetSerialNumberString 获取序列号。

如果不想在 C# 里直接写原生 P/Invoke,也可以选择使用 HidSharp 之类的库。不过报告长度和报告 ID 的处理终究需要理解,第一次先按上面的方式走一遍,之后排查问题会更快。打包应用还可以使用 Windows.Devices.HumanInterfaceDevice.HidDevice,但需要在清单中声明 DeviceCapability10

4.4 速度的上限

HID 使用中断传输。在 USB 2.0 全速(12Mbps)设备上,中断端点的最大包长为 64 字节,轮询间隔为固件申报的 1~255ms 范围内的值。高速(480Mbps)下最大为 1024 字节,间隔以 125µs 为单位。11

也就是说,对于全速的 HID 设备,1ms 轮询、64 字节的组合,理论值也只有大约 64KB/s。图像、波形、日志一次性抓取这类用不了这个带宽的场景,如果选了 HID,之后就无法挽回了。反过来,如果只是几十字节的命令应答或状态通知,这个带宽绰绰有余。

5. 方式 C:WinUSB ── 直接裸操作自有协议

5.1 定位

Winusb.sys 是 Microsoft 提供的通用 USB 驱动程序,把它作为功能驱动程序加载后,用户模式的 Winusb.dll 公开的函数就可以直接读写端点。这是不写驱动程序也能处理自有协议的机制。2

官方给出的 WinUSB 采用条件很明确。2

  • 访问设备的是单一应用
  • 拥有批量、中断、等时端点(等时端点需 Windows 8.1 及以后)
  • 面向 Windows XP SP2 及以后的系统

反过来,需要多个应用同时访问的设备,不能用 WinUSB。那属于 UMDF 驱动程序的领域。

功能 WinUSB UMDF KMDF
多应用同时访问 不可
批量・中断・控制传输
等时传输 可(8.1及以后) 不可
过滤驱动程序叠加 不可 不可
选择性挂起

5.2 「无需 INF」成立的条件

关于 WinUSB 最容易被误解的地方就在这里。能不带 INF、自动加载 Winusb.sys 的前提是,设备固件带有 Microsoft OS 描述符,并以兼容 ID 的形式报告 WINUSB5

而且,这种自动匹配只在 Windows 8 及以后生效。标准自带的 Winusb.inf 支持兼容 ID USB\MS_COMP_WINUSB 是从 Windows 8 开始的,在此之前必须使用指定硬件 ID 的自定义 INF。5 如 5.1 节所述,WinUSB 本身在 Windows XP SP2 及以后就能运行,但「能从 XP 运行」和「无需 INF 即可安装」是两回事。如果 Windows 7 及更早版本也在覆盖范围内,即便实现了 OS 描述符,也应以分发 INF 为前提来规划(虽然在 Windows 7 及更早版本上,如果通过 Windows Update 装上了更新版的 Winusb.inf 也能匹配,但不能把这一点当作分发计划的前提)。

具体来说,设备一侧需要以下实现。请注意存在 1.0(WCID) 和 2.0 两个版本

  • Microsoft OS 1.0 描述符(所有版本通用)
    1. 在字符串索引 0xEE 处提供 OS 字符串描述符,返回厂商代码
    2. 在扩展兼容 ID OS 功能描述符中,把 compatibleID 设为 WINUSB(复合设备则按功能分别设置)
  • Microsoft OS 2.0 描述符(Windows 8.1 及以后)
    1. 在 BOS 描述符的平台功能描述符中,通知描述符集的所在位置。不使用 0xEE 的字符串描述符
    2. 在该描述符集中放置兼容 ID 功能描述符,报告 WINUSB。仅靠 BOS 通知「有描述符集」,并不会因此选中 Winusb.sys。决定能否绑定的,和 1.0 一样,是兼容 ID 这一侧

    这一版是为了解决 1.0 的限制和可靠性问题而制定的,如果是全新设计的固件,应首选这一版12

设备接口 GUID 的注册,扮演的是与上述不同的角色。这是应用用来发现设备的 GUID,决定 Winusb.sys 能否绑定的仍然是兼容 ID。GUID 属于发现(discovery)一侧。不过,如果没有注册独有的 GUID,应用一侧的设备探索就很难搭建,因此实务上通常会一并实现。

这里需要注意,注册表中的属性名存在单数和复数两种形式13

名称 类型 使用场景
DeviceInterfaceGUID 字符串(REG_SZ) Microsoft OS 1.0 的扩展属性描述符以 wPropertyNameLength 40字节的形式指定这个名称5
DeviceInterfaceGUIDs 多字符串(REG_MULTI_SZ) 自定义 INF 的标准形式。Microsoft 的示例也使用复数形式 HKR,,DeviceInterfaceGUIDs,0x10000,"{...}"13

官方文档在说明 Winusb.sys 行为时,用的是复数形式:「读取注册表的 DeviceInterfaceGUIDs 键,并以其中指定的 GUID 注册设备接口」13 手动向注册表添加的操作步骤中,也同时给出了字符串形式的 DeviceInterfaceGUID 或多字符串形式的 DeviceInterfaceGUIDs这两种选项,放在 Device Parameters 之下。13

如果使用 OS 2.0 的注册表属性功能描述符,请务必在规格书中确认属性名和数据类型(实现上以复数形式加 REG_MULTI_SZ 更常见)。如果把 1.0 的单数形式原样带入 2.0 的路径,就有可能写入了 Windows 不会读取的位置,出现「明明绑定成功了,但应用却找不到」的状态。

最可靠的验证方法是插上设备后直接查看注册表。只要在 HKLM\SYSTEM\CurrentControlSet\Enum\USB\<硬件ID>\<实例ID>\Device Parameters 下确认是否以预期的名称、类型、值写入,无论描述符如何解释,都能看到事实真相。

此外,如果要编写 INF,安装类需要使用 USBDevice({88BAE032-5A81-49f0-BC3D-A4FF138216D6})。USB 类专用于主控制器、集线器和复合设备,用在独有设备上会带来可靠性和性能问题,官方文档对此有明确说明。5

如果现有设备的固件无法改动,就需要自行准备指定硬件 ID 的自定义 INF 并进行分发。到这一步,就变成了「安装程序需要安装驱动程序」的架构,第 10 章讨论的分发问题也会随之出现。即使不写一个字节的自有 .sys,只是引用 Microsoft 的 winusb.sys 的 INF,如果不附带已签名的目录,在正式使用的 Windows 上也无法安装。这不是「写一份 INF 就完事」的问题,请先读完第 10 章再估算工时。

开发过程中用 Zadig 之类的工具把驱动程序替换成 WinUSB 进行验证,是有效的手段,但这是在移除厂商驱动程序。不要把它当作正式分发的手段,否则其他应用将无法再使用同一设备。

5.3 实现要点

// WinUSB 的初始化(省略错误处理)
HANDLE h = CreateFile(devicePath,
                      GENERIC_READ | GENERIC_WRITE,
                      FILE_SHARE_READ | FILE_SHARE_WRITE,
                      NULL, OPEN_EXISTING,
                      FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, // 异步几乎是必需的
                      NULL);

WINUSB_INTERFACE_HANDLE usb;
WinUsb_Initialize(h, &usb);

// 必须为读取设置超时(默认是无限期等待)
ULONG timeoutMs = 1000;
WinUsb_SetPipePolicy(usb, bulkInPipeId, PIPE_TRANSFER_TIMEOUT,
                     sizeof(timeoutMs), &timeoutMs);

// 异步时把 NULL 传给 LengthTransferred,完成后再取转发长度。
// 务必检查返回值。如果 FALSE 且 GetLastError() 不是 ERROR_IO_PENDING,
// 此时失败已经确定,也不存在待处理的操作
BOOL started = WinUsb_ReadPipe(usb, bulkInPipeId, buffer, bufferLength, NULL, &overlapped);
if (!started && GetLastError() == ERROR_IO_PENDING) {
    started = TRUE;   // 正在执行。完成情况在下面等待
}

ULONG transferred = 0;
BOOL  collected   = FALSE;   // 是否已回收 OVERLAPPED 的结果(不论成败)
BOOL  ok          = FALSE;   // 读取是否成功

if (started) {
    // 无论是同步完成还是走 ERROR_IO_PENDING,结果都在这里接收。
    // 这里同样必须检查返回值。超时、取消、拔出都会返回 FALSE,
    // 此时 transferred 不是"实际传输的长度"
    ok = WinUsb_GetOverlappedResult(usb, &overlapped, &transferred, TRUE);
    collected = TRUE;
    if (!ok) {
        ReportError(GetLastError());   // 立即获取。中间只要插入一次别的API调用就会被覆盖
    }
} else {
    ReportError(GetLastError());   // 拔出、管道ID错误、句柄已失效等
}

if (ok) {
    Consume(buffer, transferred);   // transferred 只有走到这里才有效
}

// --- 后续处理。每次拔插都会经过这里,遗漏的话每次重连都会累积 ---
// 实际应用中上面的读取会放在循环里,所以到这里时可能
// 处于"还有尚未回收的请求"的状态
CancelIoEx(h, NULL);                                  // 停止处理中的I/O,
if (started && !collected) {                          // 只回收尚未回收的部分
    WinUsb_GetOverlappedResult(usb, &overlapped, &transferred, TRUE); // 先回收完成结果
}
WinUsb_Free(usb);                                     // 释放接口句柄
CloseHandle(h);                                       // 关闭文件句柄

现场有效的要点有七个。

  • FILE_FLAG_OVERLAPPED 打开,走异步。如果用同步 I/O,设备一旦不响应,整个线程都会卡死
  • 务必设置 PIPE_TRANSFER_TIMEOUT默认情况下读取不会返回
  • 先看 WinUsb_ReadPipe 的返回值再等待。FALSEGetLastError() 不是 ERROR_IO_PENDING 时,说明请求本身没有被接受。拔插刚发生、管道 ID 搞错、句柄已经失效 ── 这些情况都很常见。此时一个待处理的操作都不存在,如果直接调用 WinUsb_GetOverlappedResult,就相当于在等待一个”未开始的传输”的完成。原始错误码会被覆盖消失,剩下的是”以0字节完成”或完全不相关的另一个错误。原因还没开始查,就已经消失了。上面的代码之所以要一直携带 started,就是为了这个,后续处理中的回收也用同样的条件包裹14
  • WinUsb_GetOverlappedResult 的返回值也要检查。即便请求已被接受,超时(上面设置的 PIPE_TRANSFER_TIMEOUT)、CancelIoEx、传输过程中的拔出,都会让这个函数返回 FALSE此时 transferred 不是”实际传输的长度”。如果不看返回值就直接使用,初始化的 0 就会原样作为”收到了空包”流向下游,本该重建会话的错误就这样被吞掉了。而且症状是”设备偶尔什么都不返回”,要追到原因需要很长时间。FALSE 时要当场GetLastError()(中间只要插入一次别的API调用就会被覆盖)
  • 异步时不要给 LengthTransferred 传指针。官方文档明确写着「如果 Overlapped 非 NULL,LengthTransferred 可以为 NULL」「如果传入非 NULL,WinUsb_ReadPipe 返回时的值在操作完成之前是没有意义的」。转发长度要通过 WinUsb_GetOverlappedResult 获取。14 传入局部变量的地址,不仅值没有意义,如果该变量的作用域会提前结束,还会变成悬空指针
  • WinUsb_FreeCloseHandle 必须成对出现。每次 WinUsb_Initialize 成功,都会分配一个接口句柄。按 8.2 节的做法,把每次拔插都设计成重建会话,那么漏写的释放会在每次重连时不断累积。不仅是成功路径,初始化过程中失败的路径(比如 WinUsb_Initialize 成功了,但设置管道时失败)也必须经过这里,所以后续处理请统一收拢到一处。顺序是「用 CancelIoEx 停止处理中的 I/O → 用 WinUsb_GetOverlappedResult 回收完成结果 → WinUsb_FreeCloseHandle」。如果在回收完成结果之前就关闭句柄,会释放掉内核可能还在操作的缓冲区
  • 防止应用多开。WinUSB 不支持多个应用同时访问,因此应在规格中加入防止重复启动的机制(如命名互斥体)

如果想从 C# 使用,libusb 的 Windows 后端就是基于 WinUSB 实现的,因此 LibUsbDotNet 之类的包装库也是一个选项。打包应用还可以使用 Windows.Devices.Usb.UsbDevice,但有明确限制:无法访问 Audio、HID、Image、Printer、Mass Storage、Smart Card、Audio/Video、Wireless Controller 各设备类15

6. 方式 D:供应商提供的 SDK・专用驱动程序 ── 不是选择,而是承受

工业相机、测量仪器、POS 外设、指纹・静脉认证、专用 I/O 板 ── 这些设备通常由厂商把驱动程序和 SDK 打包提供,基本没有其他用法。方式 D 与其说是一个选项,不如说是选定该设备那一刻起就已经确定的前提条件。

正因如此,在设备选型阶段就把 SDK 的约束梳理清楚,这本身就是设计工作的一部分。以下是需要确认的项目。

确认项目 遗漏后会发生什么
是否同时支持32位/64位 64位应用无法调用32位专用DLL,需要进行进程隔离
API形态(C DLL / COM / .NET) 调用方式和封送设计会随之改变。若是COM则会带来线程模型的限制
线程约束(是否必须STA、回调所在线程) 阻塞UI线程,或发生死锁
再分发物与分发条件 无法内置到安装程序里,需要客户手动安装
附带驱动程序的签名状态 在Windows 11新版本或装置PC上无法安装
支持的操作系统与维护期限 操作系统更替时应用需要整体重做
是否支持多台同时连接及识别方式 接入第二台设备的那一刻就会崩溃
是否提供带源码的演示应用 规格不明的行为,调查成本会飙升

表格第一行的 bitness(位宽),指的是 SDK 的 DLL 是按 32 位版本还是 64 位版本编译的。这一点之所以直接影响实务,是因为Windows 的进程无法在同一进程内混用 32 位和 64 位代码。只提供 32 位专用 DLL 的 SDK,无法被 x64 构建的应用直接调用(会出现 BadImageFormatExceptionLoadLibrary 失败)。要绕开这一点,要么把整个应用按 x86 构建,要么把调用 SDK 的部分单独放进一个 32 位子进程,通过进程间通信连接。这两种方案都涉及应用结构层面的决定,因此必须在设备选型阶段就已经明确

在实现层面,不要把 SDK 直接撒得到处都是,这是最大的防御手段。把 SDK 调用封装到一个薄的抽象层(接口)背后,应用本体只针对这个抽象来编写。这样一来,设备型号变更、SDK 大版本升级、更换厂商带来的影响就都能收拢到一处,也能够编写不依赖实机的单元测试。

如果需要在 64 位应用中使用 32 位专用 SDK,把它放进单独进程、通过进程间通信连接是常规做法。走 COM 的话,《32 位应用调用 64 位 DLL 的 COM 桥接实例》给出的是方向相反但思路相同的案例,原生 DLL 调用方式本身整理在《从 C# 调用原生 DLL:C++/CLI 包装器 vs P/Invoke》中。子进程的生存管理请参考《Windows 应用安全处理子进程的清单》。

7. 四种方式的判断表

观点 虚拟COM HID WinUSB 供应商SDK
驱动程序分发 不需要(CDC)/标准提供(VCP) 不需要 有条件不需要,多数需要INF 需要
实现难度 中~高 视SDK而定(参差不齐)
吞吐量 低~中(依赖设备・需实测)
延迟 中(依赖轮询间隔)
多应用同时使用 不可(端口独占) 可(若为共享TLC) 不可 视SDK而定
设备唯一识别 需要自行实现(COM号不可用) 可通过VID/PID/序列号 可通过设备路径 视SDK而定
现场排查便利性 (终端软件)
对设备固件的依赖 (OS描述符) 全部
适用场景 命令应答型的设备・仪器 状态通知・小命令 大量数据・自有协议 相机・仪器・专用设备

判断顺序如下。

  1. 设备是否已经显示为COM端口/HID/标准类 → 如果是,直接用
  2. 未显示为以上任何一种,但固件可以改动 → 根据带宽在HID(小数据)和WinUSB(大数据)之间选择
  3. 固件无法改动,但厂商有SDK → 使用SDK。不过要先做第6章的检查
  4. 以上都不符合,且需要多应用同时访问 → 考虑开发UMDF驱动程序2

8. 无论哪种方式都要自己设计的四件事

即使方式已经定下来,以下四点仍然需要自己实现。「偶尔不工作」的设备联动应用,几乎必然是缺了这几项中的某一项。

8.1 设备的唯一识别 ── 用ID而非编号来定位

配置中可以写入的是VID/PID + 序列号,或者设备接口路径。COM号和设备管理器中的排列顺序都不是标识符。

序列号是否存在,可以从设备实例路径的最后一个元素判断出来。

USB\VID_2341&PID_0043\85436323631351D0E1C1   ← 设备报告了序列号(移动位置也不变)
USB\VID_0403&PID_6001\5&1a2b3c4d&0&2         ← 没有报告(Windows按接入位置生成的值)

对于复合设备,仅靠VID/PID + 序列号是不够的。如第2章所述,一台设备可能拥有多个功能,此时各个功能会共享相同的VID、PID、序列号。「控制口和维护口各有一路CDC」「HID有两个顶级集合」这类设备,就会出现这三项组合相同的多个候选,打开哪一个全凭运气。必须把区分功能的要素也纳入键中。

USB\VID_1234&PID_5678&MI_00\7&2a3b4c5d&0&0000   ← 功能0(比如控制用CDC)
USB\VID_1234&PID_5678&MI_02\7&2a3b4c5d&0&0002   ← 功能2(比如维护用CDC)
       相同的VID/PID・相同的父设备 ↑ 只有MI_xx(USB接口编号)不同

实务上构造键的方式有以下几种。

  • 包含USB接口编号(MI_xx) ── 区分复合设备CDC/WinUSB功能的标准方法
  • HID可并用Usage Page + Usage ── 同一设备的多个顶级集合可以据此判别(HidP_GetCapsUsagePage/Usage)
  • 直接保存设备接口路径 ── 枚举得到的字符串在功能级别是唯一的,用它做键最可靠

如果能自主决定设备规格,把固件设计成为每个功能分配不同的接口编号、并始终报告序列号,软件一侧的识别逻辑会因此大幅简化。

后者(未报告序列号的情况)会包含 &,且更换插入的端口后值也会改变

不过不能把这个判断规则套用到复合设备的子节点上。在复合设备中,作为CDC功能或HID集合枚举出来的子PDO,其实例ID末尾是由Usbccgp.sys生成的值,即使物理设备本身报告了序列号,子节点也会包含&只看子节点的路径就判定「没有序列号」是错误的。序列号在父级USB设备节点上,需要通过CM_Get_Parent(或DEVPKEY_Device_Parent)追溯到父节点,再查看它的实例ID。

USB\VID_1234&PID_5678\SN0001234           ← 父级(物理设备)。序列号在这里
 └ USB\VID_1234&PID_5678&MI_00\7&2a3b…    ← 子节点。是Usbccgp生成的值,包含 &(不用于判断)

如果在同型号设备多台并用的场景中,选用了不报告序列号的设备,识别手段就只剩下「插在哪个USB端口」了。这种情况下,需要固定集线器端口,并把在运维流程中贴标签也纳入设计 ── 这是技术无法解决的部分,需要在设备选型阶段就意识到。

8.2 追踪拔插 ── 不要轮询

经常能看到用 Timer 每秒重新枚举一次的实现,但 Windows 本身提供了通知机制。

  • Windows 8及以后: 向 CM_Register_Notification 注册 CM_NOTIFY_FILTER_TYPE_DEVICEINTERFACE(检测到达・移除)和 CM_NOTIFY_FILTER_TYPE_DEVICEHANDLE(检测已打开句柄对应的设备消失)16
  • 需要覆盖Windows 7及更早版本: 用 RegisterDeviceNotification 注册 DBT_DEVTYP_DEVICEINTERFACE,并处理 WM_DEVICECHANGE17

实现上有两个绕不开的注意点。16

  • CM_Register_Notification 不会通知「注册时已经存在的接口」。要先注册,然后再用 CM_Get_Device_Interface_List 枚举既有部分。顺序反过来的话,会漏掉这段间隙中插入的设备
  • 反过来,要以会出现重复为前提来设计。注册之后、枚举之前被启用的接口,会同时出现在到达通知和枚举列表中。如果把两者都原样送入到达处理逻辑,就会给同一台设备重复创建会话,导致第二次的独占打开失败,或者覆盖已经建立好的状态。必须持有一个以设备接口路径为键的集合,对已知路径做去重(这个集合在设备移除时清除对应项)
  • 不要在回调中执行可能阻塞的处理。涉及I/O的处理要转交给其他线程。在这里等待,会堵塞整个PnP事件的处理

如果是打包应用,或者能使用WinRT API的架构,DeviceWatcher 可以用更简洁的方式实现同样的功能。

// 监视特定VID/PID的串口设备(WinRT)
string selector = SerialDevice.GetDeviceSelectorFromUsbVidPid(0x2341, 0x0043);
DeviceWatcher watcher = DeviceInformation.CreateWatcher(selector);

watcher.Added   += (s, info) => OnDeviceArrived(info.Id);
watcher.Removed += (s, info) => OnDeviceRemoved(info.Id);
watcher.Start();

在无法使用WinRT的传统桌面应用(WinForms / WPF)中,会是以下两种模式之一。

模式1: RegisterDeviceNotification + WM_DEVICECHANGE(推荐)

针对窗口句柄注册设备接口通知,在 WndProc 中接收。WinForms下入口是重写 WndProc,WPF下则是 HwndSource.AddHook

// WinForms 的示例。WPF 下同样的处理写在 HwndSource.AddHook 里
const int WM_DEVICECHANGE          = 0x0219;
const int DBT_DEVICEARRIVAL        = 0x8000;
const int DBT_DEVICEREMOVECOMPLETE = 0x8004;
const int DBT_DEVTYP_DEVICEINTERFACE = 0x00000005;
const int DEVICE_NOTIFY_WINDOW_HANDLE = 0x00000000;

// USB设备的接口类 GUID
static readonly Guid GUID_DEVINTERFACE_USB_DEVICE =
    new("A5DCBF10-6530-11D2-901F-00C04FB951ED");

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct DEV_BROADCAST_DEVICEINTERFACE
{
    public int dbcc_size;
    public int dbcc_devicetype;
    public int dbcc_reserved;
    public Guid dbcc_classguid;
    [MarshalAs(UnmanagedType.ByValArray, SizeConst = 1)]
    public char[] dbcc_name;
}

[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
static extern IntPtr RegisterDeviceNotification(IntPtr hRecipient, IntPtr filter, int flags);

[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
static extern bool UnregisterDeviceNotification(IntPtr handle);

private IntPtr _notification = IntPtr.Zero;

protected override void OnHandleCreated(EventArgs e)
{
    base.OnHandleCreated(e);

    var filter = new DEV_BROADCAST_DEVICEINTERFACE
    {
        dbcc_size       = Marshal.SizeOf<DEV_BROADCAST_DEVICEINTERFACE>(),
        dbcc_devicetype = DBT_DEVTYP_DEVICEINTERFACE,
        dbcc_classguid  = GUID_DEVINTERFACE_USB_DEVICE,
        dbcc_name       = new char[1],
    };

    IntPtr buffer = Marshal.AllocHGlobal(filter.dbcc_size);
    int error;
    try
    {
        Marshal.StructureToPtr(filter, buffer, fDeleteOld: false);
        _notification = RegisterDeviceNotification(Handle, buffer, DEVICE_NOTIFY_WINDOW_HANDLE);
        error = Marshal.GetLastWin32Error();   // 在 FreeHGlobal 之前获取
    }
    finally
    {
        Marshal.FreeHGlobal(buffer);   // 注册时会被复制,所以这里可以释放
    }

    // 失败时不会抛出异常,只会返回 NULL。不检查这里的话,
    // 就会变成"启动成功了,但WM_DEVICECHANGE一次都没来"的应用,
    // 要花很长时间才能意识到"不跟随拔插"的原因出在通知注册上
    if (_notification == IntPtr.Zero)
    {
        // Win32Exception 来自 System.ComponentModel
        throw new Win32Exception(error, "设备通知注册失败。");
    }

    // 先注册再枚举既有设备。反过来会漏掉间隙中插入的设备(必须做去重)
    ScanExistingDevices();
}

protected override void WndProc(ref Message m)
{
    if (m.Msg == WM_DEVICECHANGE)
    {
        switch ((int)m.WParam)
        {
            case DBT_DEVICEARRIVAL:
                // 这里只设置标志。I/O放到别的线程去做
                QueueRescan();
                break;
            case DBT_DEVICEREMOVECOMPLETE:
                QueueRescan();
                break;
        }
    }
    base.WndProc(ref m);
}

protected override void OnHandleDestroyed(EventArgs e)
{
    if (_notification != IntPtr.Zero)
    {
        UnregisterDeviceNotification(_notification);
        _notification = IntPtr.Zero;
    }
    base.OnHandleDestroyed(e);
}

也能看到只处理 WM_DEVICECHANGE、却不注册的实现,但那种情况下收到的主要是 DBT_DEVNODES_CHANGED(只表示”设备树发生了变化”的事件),无法知道具体是哪台设备。结果每次都要重新全量枚举,如果想针对特定目标,还是应该按上面的方式注册。

模式2: WMI的实例创建・删除事件

对于没有窗口的服务或控制台应用,订阅WMI事件是比较轻量的方式。

using System.Management;

// WITHIN 2 表示"每2秒轮询一次"。值越小负载越高
var arrival = new ManagementEventWatcher(new WqlEventQuery(
    "SELECT * FROM __InstanceCreationEvent WITHIN 2 " +
    "WHERE TargetInstance ISA 'Win32_PnPEntity'"));

var removal = new ManagementEventWatcher(new WqlEventQuery(
    "SELECT * FROM __InstanceDeletionEvent WITHIN 2 " +
    "WHERE TargetInstance ISA 'Win32_PnPEntity'"));

arrival.EventArrived += (s, e) =>
{
    var target = (ManagementBaseObject)e.NewEvent["TargetInstance"];
    var pnpId = target["PNPDeviceID"] as string;   // 在这里判断VID/PID
    QueueRescan();
};
removal.EventArrived += (s, e) => QueueRescan();

arrival.Start();
removal.Start();

不过这个WMI查询本质上是轮询。WITHIN 2 意味着检测最多会延迟2秒,间隔缩得越短,WMI的负载就越高。想要对拔插立即响应的应用应选择模式1,WMI则适合”常驻服务,可以接受几秒延迟”的场景。

需要说明的是,无论用哪种模式,QueueRescan 的内容都是通用的。通知终究只是”有什么发生了变化”的信号,实际连接着什么设备,要重新枚举才能确认。如果按通知种类分别写不同的处理逻辑,只会让下面要说的重复和遗漏问题变得更多。

而且重要的是,不要把PnP通知当作”唯一的入口”。线缆被拔出时,处理中的I/O有可能在通知到来之前,或者与通知同时,就以删除・取消类的错误完成。「收到通知后第一时间关闭句柄」这种顺序,只有在通知先到达时才成立,如果只依赖它,3.4节中”一拔就崩”的问题依然会残留。

正确的形态是,拥有两个会话结束的入口

  • 所有读写的完成路径上,把删除类错误(ERROR_DEVICE_NOT_CONNECTED / ERROR_DEVICE_REMOVED / ERROR_GEN_FAILURE,.NET下对应的IOException)当作”设备消失了”来处理,并由此进入会话销毁
  • PnP通知作为辅助信号。用来捕捉I/O未在运行的等待期间被拔出的情况,但仅靠它是不够的

这里必须区分”自己主动取消”的情况。响应超时的中止、应用退出时的收尾、用户操作导致的中断 ── 在这些场景下使用 CancelIoExCancellationTokenDispose会正常地抛出 ERROR_OPERATION_ABORTEDOperationCanceledExceptionObjectDisposedException如果无条件把这些判定为”设备消失了”,就会形成一个恶性循环:设备明明还连着,却每次超时都要销毁会话再重连。

// 通过状态判断"是自己停止的,还是设备消失了"。
// operationCts 是本次读写专用的令牌源。
// 响应超时和用户操作中断都用这个来取消
catch (OperationCanceledException ex) when (IsSelfCancelled(ex, operationCts.Token))
{
    // 自我取消。会话未损坏,不销毁
}
catch (ObjectDisposedException) when (_shutdown.IsCancellationRequested)
{
    // 结束处理关闭句柄之后,飞出的I/O才返回的情况
}
catch (Exception ex) when (IsDeviceGone(ex))
{
    TearDownSession();   // 幂等。即使从PnP通知调用也不会重复执行
}

// 判断"当前是否正是自己在请求取消",要与实际取消所用的
// 令牌进行比对
private bool IsSelfCancelled(OperationCanceledException ex, CancellationToken operation) =>
    ex.CancellationToken == operation ||
    ex.CancellationToken == _shutdown.Token ||
    operation.IsCancellationRequested ||
    _shutdown.IsCancellationRequested;

只看应用整体的关闭状态是不够的。响应超时和用户操作导致的中断,用的是那一次操作专属的令牌来取消。这时 _shutdown 并未置位,如果 catch 只以 _shutdown.IsCancellationRequested 为条件,就会直接放行。放行的 OperationCanceledException 又不符合接下来的 IsDeviceGone(不能把自我取消判定为”设备消失了”),于是就这样一路穿透到底,把整个I/O循环带崩。表现出来就是每次超时接收线程都会死掉。

判断的依据不能只靠异常类型或错误码。只有和发起取消一方所用的令牌进行比对,才能真正区分开来。反过来说,如果存在自我取消的路径,就必须把这个事实放在I/O层能看到的地方。IsDeviceGone 一侧也要注意,不要无条件把 OperationCanceledExceptionObjectDisposedException 判定为”设备消失了”。

无论从哪条路径进入,都应该走向同样的收尾,把会话销毁归并为一个幂等的处理,即使被重复调用也不会出问题(比如用 Interlocked.Exchange 立标志位,只让最先到达的一次真正执行)。针对已损坏句柄的I/O所抛出的异常,往往从很难捕获的地方冒出来 ── 正因如此,才需要在异常发生的源头就接住它,并驱动状态迁移。

8.3 超时与重连 ── “一个超时”是不够的

USB设备的I/O,无论是被拔出、断电、还是固件挂死,表现出来的症状都是同一种”没有返回”。超时应该按含义分开设置。

超时 对象 参考值
打开超时 打开设备所需的时间 秒级
响应超时 从发出命令到应答完成 设备规格的最坏值 × 安全系数
字节间超时 帧中途后续数据不来 根据通信速率计算
重连退避 重新打开之间的等待间隔 指数退避 + 上限

而且应该把超时当作”推进状态迁移的规则”来对待,而不是”响应慢时的保险”。超时后要迁移到哪个状态、处理中的请求如何标记为失败、要在界面上显示什么,都决定清楚,才算是完成了设计。界面上的呈现方式在《外部设备状态检查与显示的最佳实践》中有讨论 ── 不要只用一句”连接中”敷衍了事。

8.4 电源管理 ── “明明没拔线却反应变慢”的真相

USB的选择性挂起(selective suspend),是把处于空闲状态的设备切换到低功耗状态的机制。由于恢复需要时间,“只有第一次响应很慢”“放置一段时间后第一条命令会丢失”这类症状,罪魁祸首往往就是它。

  • Usbser.sys(虚拟COM)默认为禁用,可通过注册表的 IdleUsbSelectiveSuspendPolicy 启用并配置3
  • WinUSB通过扩展属性OS功能描述符(或INF)中的 DeviceIdleEnabledDefaultIdleTimeoutUserSetDeviceIdleEnabled 等进行控制5

现场首先要确认的,是设备管理器中该设备(以及USB根集线器)属性里的“允许计算机关闭此设备以节约电源”复选框。在装置PC上,实际存在只要取消勾选这一项就能解决的故障。连同笔记本电脑的省电设置在内,验证请务必在与生产环境相同的电源方案下进行

9. 性能与延迟的估算

在方式选型阶段,把所需的带宽和延迟量化出来,可以避免之后走回头路。

传输类型 使用的方式 特点
控制传输 所有方式(内部使用) 适合设置・小命令。无带宽保证
中断传输 HID、WinUSB 定期轮询。低延迟但容量小
批量传输 WinUSB、大容量存储 适合大容量。无带宽保证,使用空闲带宽
等时传输 UVC(摄像头)、UAC(音频)、WinUSB(8.1及以后) 有带宽保证,不重传

USB 2.0的中断端点,全速下每包最大64字节・轮询间隔1~255ms,高速下最大1024字节・间隔以125µs为单位。11 如果要选HID,请确认所需带宽相对这个上限是否留有一个数量级以上的余量。

还有一点,Windows是通用操作系统,对延迟没有保证。「必须每10ms周期性响应」这类要求,在应用层是无法满足的。如果本质上需要周期性控制,应把它封闭在设备一侧的单片机中,PC只负责下达指令和监控。这个划分方式在《在普通 Windows 上尽量做到软实时的实践指南》中有详细讨论。

10. 分发与运维 ── 一旦要分发驱动程序,成本就会变

“不需要驱动程序”的方式(标准类、HID、WinUSB设备)与”需要分发驱动程序”的方式之间,存在的不是开发成本的差异,而是分发・维护成本上的断层。

  • “自己不写.sys所以不需要签名”是错误的。在PnP的设备安装流程中,驱动程序包的目录文件如果没有签名,就无法暂存到Driver Store。18 这是一个不依赖包内容的要求,因此即便是像5.2节那样只引用Microsoft的winusb.sys的INF,也需要生成目录(.cat)并签名这道工序。如果按”写一份INF就能分发出去”来估算,就会在客户现场因”此设备的驱动程序未经过签名”被拒绝安装后才发现问题。目录签名可以是WHQL发布签名,也可以是第三方发布证书(SPC)签名。18
  • 内核模式驱动程序的签名。Windows 10 版本1607及以后,新的内核模式驱动程序必须经过 Dev Portal(Partner Center)提交给Microsoft签名后才能加载。开通Partner Center账户需要EV代码签名证书6 这是与上面的目录签名不同层面的要求,如果包含内核模式的二进制文件,两者都要满足。
  • 签名有两条路径,适用范围不同。通过了HLK测试的HLK tested / dashboard signed,从Windows Vista到Windows Server都有效,Microsoft推荐使用这条路径。另一条attestation signing不需要HLK测试,但只在Windows 10桌面版及以后有效(Windows 7/8.1或Windows Server 2016及以后不接受)。而且不能通过Windows Update向普通用户分发,也不会成为Windows认证(Windows Certified)。Microsoft的文档也把它定位为”用于测试目的”。19 用attestation signing给自家安装程序分发自研驱动程序的做法虽然目前被广泛使用,但请先把目标操作系统仅限Windows 10/11桌面版这一点落实到支持系统表里,再决定是否采用。如果装置PC跑的是Windows Server或较旧的LTSC,这条路径从一开始就不可选。
  • 不要依赖例外条件。在安全启动被禁用,或使用2015年7月29日之前签发的证书签名等情况下,交叉签名的驱动程序也能运行,但把这类条件作为分发计划的前提,几年内就会失效。6
  • 在开始写代码之前,先把费用和周期问一清楚。如果要分发内核模式驱动程序,获取EV代码签名证书和开通Partner Center账户是前置工序。6 金额和所需时间会因认证机构、时期、自家登记信息的完备程度而不同,不要拿别的公司的案例数字当参考,请以自家名义实际确认以下三项:(1) EV证书的年费(向多家认证机构询价)、(2) EV证书必需的组织真实性审查所需时间、(3) 开通Partner Center账户所需时间。这里花费的是手续上的时间而非技术上的时间,如果不与开发进度并行推进,就会陷入”代码写完了却无法分发”的停滞。
  • 安装程序的设计。包含驱动程序的安装程序需要管理员权限,也需要验证静默安装。分发方式本身的选型在《Windows 应用发布方式怎么选》中有整理,判断何时需要管理员权限的方法则整理在《Windows 什么时候需要管理员权限》中。
  • 装置PC上,驱动程序和操作系统更新会互相冲突。在LTSC架构的装置PC上安装厂商驱动程序时,请把操作系统版本固定策略和驱动程序更新策略一并定下来。《产业用 PC 应该安装哪种 Windows》可以作为参考。

从设计判断上说,”如果存在不需要分发驱动程序的方式,即便实现上多花点功夫,也应该选它”,这一点几乎总是正解。HID之所以低调却强大,原因就在这一点上。

11. 常见故障与应对

症状 常见原因 应对
在开发机上能用,客户现场不能用 把COM号写死在配置里 在运行时根据VID/PID・序列号解析(8.1)
接上第二台就出现误动作 设备未报告序列号 重新审视设备选型。不行的话固定端口+贴标签运维
明明是同一台设备,每次抓到的对象却不一样 复合设备未按功能区分就用作键 MI_xx或HID的Usage、设备接口路径也纳入键(8.1)
一拔线应用就崩溃 对已损坏句柄进行I/O,收尾只依赖PnP通知 在I/O完成路径上也把删除类错误当作会话结束处理(8.2)
只有第一次响应很慢/会丢失 从选择性挂起中恢复 检查并禁用电源管理设置(8.4)
HID发送后设备无响应 报告ID占用的那一字节导致数据错位 缓冲区长度刚好等于OutputReportByteLength,开头放报告ID(4.3)
HID一条数据都读不到 目标是被操作系统独占打开的TLC(键盘等) 切换设备模式。仅枚举的话用访问权限0打开(4.2)
WinUSB的Read不返回 未设置PIPE_TRANSFER_TIMEOUT 通过管道策略设置超时(5.3)
每次超时都会重连 把自我取消误判为设备断开 结合取消令牌等状态进行区分(8.2)
反复拔插后逐渐变卡 WinUsb_Free/CloseHandle遗漏 把收尾归并到一处,失败路径也必须经过(5.3)
P/Invoke调用之后无关变量被破坏 结构体只声明到一半 按与原生相同的大小和排列声明全部字段(4.3)
客户现场无法安装驱动程序 目录未签名(与是否自研.sys无关) .cat的生成与签名纳入分发计划(第10章)
同时启动两个应用会有一个失败 WinUSB不支持同时访问 防止重复启动,或统一收拢到常驻服务
64位构建下SDK无法加载 32位专用DLL 分离到单独进程,用IPC连接(第6章)
无法确认通信内容是否真的送达 缺乏观测手段 USB协议分析仪、相当于usbmon的追踪、实现通信日志

最后一行容易被忽视,但很重要。一开始就具备”能分清是应用的问题还是设备的问题”的手段,能大幅缩短原因不明的时间。虚拟COM方式在现场之所以强,是因为有终端软件这种人人都能用的排查工具。如果选择HID或WinUSB,请自己准备与之对应的日志和测试用CLI工具。

12. 总结

  • USB设备的处理方式,不是由设备本身决定,而是由其上加载了哪种驱动程序决定的。要从在设备管理器中查看硬件ID、兼容ID、设备实例路径开始。
  • 官方的选型顺序是”从简单的开始”。标准类驱动程序 → WinUSB(单一应用) → UMDF(多应用) → KMDF。自研驱动程序是最后的手段。
  • 虚拟COM实现和现场排查都很轻松,但COM号不是标识符。请在运行时根据VID/PID・序列号来解析。吞吐量会因USB-UART转换还是原生CDC而相差一个数量级,不要凭空断定,请实测。
  • HID是无需分发驱动程序即可实现双向通信的有力选项,但相当于鼠标、键盘、触摸、笔的集合会被操作系统独占打开而无法触碰,中断传输的带宽就是上限。
  • WinUSB适合大量数据和自有协议。但“无需INF”只有在使用带OS描述符的设备、且系统是Windows 8及以后时才成立,也不支持多应用同时访问。全新固件的首选是OS 2.0描述符。
  • 供应商SDK不是选项,而是前提条件。请在设备选型阶段就把bitness・线程约束・再分发条件・维护期限梳理清楚,应用一侧用薄抽象层把SDK包起来。
  • 无论选哪种方式,唯一识别、拔插追踪、多层超时、电源管理这四点都要自己设计。这里正是”偶尔不工作”的发生源头。复合设备要把识别下沉到功能级别,断开连接要同时从PnP通知和I/O错误两条路径捕捉。
  • 如果要分发驱动程序包,即使没有自研的.sys,也需要目录签名。如果包含内核模式的二进制文件,还需要1607及以后的Microsoft签名(和EV证书)。attestation signing不需要HLK,但仅限Windows 10桌面版及以后,请对照支持系统表再做选择。如果存在不需要分发驱动程序的方式,就选它,这在实务上几乎总是正解。

相关文章

相关咨询领域

合同会社小村软件承接 USB 连接装置・测量仪器与 Windows 应用的联动设计、既有 SDK 的封装与 64 位化,以及拔插或重连后变得不稳定的设备联动应用的原因调查与改进。

参考链接

  1. Microsoft Learn, USB device class drivers included in Windows。关于 Windows 标准自带的 USB 类驱动程序列表(Usbaudio.sys / Usbser.sys / Hidclass.sys・Hidusb.sys / Usbscan.sys / Usbprint.sys / Usbstor.sys / Usbvideo.sys 等)、支持的设备类不建议由厂商自行编写驱动程序、Vendor Specific(FFh)等未分类的类建议使用 WinUSB(Winusb.sys)、复合设备由 Usbccgp.sys 按功能生成 PDO、安装类 USBDevice({88BAE032-5A81-49f0-BC3D-A4FF138216D6}) 与 USB 类的使用区别等内容。CDC(02h)一行中”在 Windows 10 上,Usbser.inf 会把 Usbser.sys 作为功能驱动程序自动加载”的记载,以及用引用 mdmcpq.inf 的自定义 INF 来处理子类 02h(ACM) 的路径,均来自该页面。  2 3 4 5

  2. Microsoft Learn, Choose a driver model for developing a USB client driver。关于”从最简单的方法开始”的选型顺序(标准类驱动程序 → WinUSB → UMDF → KMDF)、WinUSB 适用于单一应用访问・批量/中断/等时端点・面向 Windows XP SP2 及以后系统的场景、WinUSB 不能用于多应用同时访问、WinUSB / UMDF / KMDF 的功能对比表(等时传输由 WinUSB 从 Windows 8.1 起支持,UMDF 不支持)。  2 3 4 5

  3. Microsoft Learn, USB serial driver (Usbser.sys)。关于在设备描述符中设置类 02、子类 02 后,兼容 ID(USB\Class_02&SubClass_02) 会匹配标准的 Usbser.inf,无需分发独立 INF 即可自动加载 Usbser.sys;子类非 02 时不会自动加载;可通过 Windows.Devices.SerialCommunication 命名空间与 CDC 设备通信;选择性挂起默认禁用,可通过注册表的 IdleUsbSelectiveSuspendPolicy 配置。  2 3

  4. Microsoft Learn, HID Architecture。关于 HID 类驱动程序(hidclass.sys) 抽象了 HID 客户端与传输层之间的关系;Windows 支持的顶级集合列表及其访问模式(鼠标、键盘、笔、触摸屏、高精度触控板为独占,游戏手柄、传感器、条码扫描器等为共享);出于安全原因,Raw Input Manager(RIM) 会以独占方式打开这些设备;即使以独占方式打开,只要不要求读写权限打开句柄,仍可通过 HidD_GetXxx 获取信息。  2 3 4

  5. Microsoft Learn, WinUSB Device。关于 WinUSB 设备是指固件通过 Microsoft OS 功能描述符以兼容 ID 形式报告 WINUSB 的 USB 设备,无需自定义 INF 即可加载 Winusb.sys;Windows 8 之前不存在基于兼容 ID 的自动匹配,必须使用自定义 INF(Windows 8 起标准自带的 Winusb.inf 支持 USB\MS_COMP_WINUSB,更早版本可通过 Windows Update 获取更新版 INF);字符串索引 0xEE 处的 OS 字符串描述符与厂商代码机制;在扩展兼容 ID 描述符中将 compatibleID 设为 WINUSB;通过扩展属性描述符注册 DeviceInterfaceGUID 后应用即可发现并操作设备;安装类应使用 USBDevice({88BAE032-5A81-49f0-BC3D-A4FF138216D6}) 而非 USB 类;以及 DeviceIdleEnabled / DefaultIdleTimeout / UserSetDeviceIdleEnabled / SystemWakeEnabled 等电源管理设置。  2 3 4 5 6 7

  6. Microsoft Learn, Driver Signing Policy。关于 Windows 10 版本 1607 及以后,未经 Dev Portal 签名的新内核模式驱动程序无法加载;注册 Windows Hardware Dev Center 项目需要 EV 代码签名证书;交叉签名驱动程序被允许的例外条件(升级到 1607、安全启动禁用、2015年7月29日之前签发的终端证书)。  2 3 4

  7. Microsoft Learn, Opening HID collections。关于用户模式应用通过 SetupDi* 函数定位 HID 集合,用 CreateFile 打开,用 HidD_Xxx 获取预解析数据和信息,用 ReadFile 读取输入报告、WriteFile 发送输出报告,用 HidP_Xxx 解析报告的一整套流程。 

  8. Microsoft Learn, HIDP_CAPS structure (hidpi.h)。关于结构体的完整定义(Usage / UsagePage / InputReportByteLength / OutputReportByteLength / FeatureReportByteLength / Reserved[17] / NumberLinkCollectionNodes 以下 10 个 Number 系成员,合计 USHORT×32),以及各报告长度均为包含报告 ID 1 字节的值。 

  9. Microsoft Learn, Sending HID Reports。关于用户模式应用持续发送输出报告应使用 WriteFile;HidD_SetXxx 系列函数(HidD_SetOutputReport / HidD_SetFeature) 也能发送输出报告或功能报告,但 HidD_SetXxx 应仅限用于设置集合当前状态的场景;文档警告”部分设备不支持 HidD_SetOutputReport,使用该函数可能导致设备不再响应”。以及 HidD_SetOutputReport function 中,ReportBufferLength 由 HIDP_CAPS 的 OutputReportByteLength 决定,不使用报告 ID 时首字节应为 0。  2

  10. Microsoft Learn, HidDevice Class (Windows.Devices.HumanInterfaceDevice)。关于 HidDevice 表示与顶级集合对应的设备;通过 GetDeviceSelector 用 usagePage / usageId / vendorId / productId 生成 AQS 选择器后用 FromIdAsync 打开的流程;使用该类访问 HID 设备的应用需要在清单的 Capabilities 节点中包含特定的 DeviceCapability 数据。 

  11. USB Implementers Forum, Universal Serial Bus Specification Revision 2.0。关于中断端点的最大包长在全速下为 64 字节、高速下为 1024 字节;轮询间隔(bInterval) 在全速下以 1~255 毫秒为单位,高速下以 125 微秒为单位、按 2^(bInterval-1) 表示(9.6.6 节 Endpoint);以及控制・批量・中断・等时各传输类型的带宽特性。  2

  12. Microsoft Learn, Microsoft OS 2.0 Descriptors Specification。关于 Microsoft OS 描述符 2.0 版是为解决 1.0 版的限制和可靠性问题而制定的,支持的操作系统为 Windows 10 和 Windows 8.1 Preview。 

  13. Microsoft Learn, WinUSB (Winusb.sys) Installation for Developers。关于在设备的 Device Parameters 键下添加字符串项 DeviceInterfaceGUID 或多字符串项 DeviceInterfaceGUIDs 来设置 GUID;自定义 INF 的 AddReg 写法为 HKR,,DeviceInterfaceGUIDs,0x10000,"{...}"(0x10000 = REG_MULTI_SZ);”Winusb.sys 作为功能驱动程序加载时会读取注册表值 DeviceInterfaceGUIDs 键,并以指定的 GUID 表示设备接口”、”Winusb.sys 每次加载都会在 DeviceInterfaceGUIDs 键下按指定的设备接口类注册设备接口”;用户模式一侧用 SetupDiGetClassDevs 枚举已注册的接口后再传给 WinUsb_Initialize;以及驱动程序包需要已签名的目录文件。  2 3 4

  14. Microsoft Learn, WinUsb_ReadPipe function (winusb.h)。关于指定 Overlapped 时函数会立即返回、操作异步执行,此时 GetLastError 返回 ERROR_IO_PENDING,需用 WinUsb_GetOverlappedResult 确认成败;异步(Overlapped 非 NULL)时 LengthTransferred 可设为 NULL;若传入非 NULL 的 LengthTransferred,函数返回时的值在重叠操作完成之前是没有意义的(meaningless),实际读取字节数需通过 WinUsb_GetOverlappedResult 获取;同步调用(Overlapped 为 NULL)时 LengthTransferred 必须为非 NULL。  2

  15. Microsoft Learn, Windows.Devices.Usb Namespace。关于该命名空间面向的是标准自带 winusb.sys 处理的 WinUSB 设备(兼容 ID USB\MS_COMP_WINUSB);无法访问 Audio(0x01) / HID(0x03) / Image(0x06) / Printer(0x07) / Mass Storage(0x08) / Smart Card(0x0B) / Audio/Video(0x10) / Wireless Controller(0xE0) 各设备类;需要在清单中声明 usb 设备功能,Windows 10 版本 1809 起不再需要指定 VendorId/ProductId;一般无法访问包含上层/下层过滤驱动程序的设备栈。 

  16. Microsoft Learn, CM_Register_Notification function (cfgmgr32.h)。关于该函数自 Windows 8 起可用,若需覆盖 Windows 7 及更早版本应使用 RegisterDeviceNotification;CM_NOTIFY_FILTER_TYPE_DEVICEINTERFACE / DEVICEHANDLE / DEVICEINSTANCE 各过滤类型;PnP 事件应尽快处理,I/O 等可能阻塞的处理应在其他线程异步执行;本函数不会通知既有的设备接口,因此注册后需调用 CM_Get_Device_Interface_List,期间被启用的接口会同时出现在通知和列表中。  2

  17. Microsoft Learn, RegisterDeviceNotificationW function (winuser.h)。关于该函数是应用程序用于接收设备通知的注册函数,成功时返回设备通知句柄,失败时返回 NULL。以及 WM_DEVICECHANGE messageDBT_DEVICEARRIVAL event 中,设备或媒体插入并可用时会以 wParam 为 DBT_DEVICEARRIVAL 广播 WM_DEVICECHANGE 的说明。 

  18. Microsoft Learn, PnP Device Installation Signing Requirements。关于把驱动程序包暂存到 Driver Store 必须满足签名要求;PnP 设备安装中被视为”已签名”要求驱动程序包的目录文件经过 WHQL 或第三方发布证书(SPC・商用发布证书)签名;内核模式驱动程序二进制文件的加载签名要求是另外单独课加的;64位 Windows 上内核模式代码签名策略要求 WHQL 或 SPC 签名;Windows 10 in S mode 等部分版本只接受 WHQL 签名的目录。  2

  19. Microsoft Learn, Driver Signing Options。关于通过 HLK 测试的 dashboard 签名驱动程序在 Windows Vista 及 Windows Server 各版本及以后均有效,可对所有操作系统版本签名,因此是 Microsoft 推荐的方式;attestation signing 被定位为”仅用于测试目的”,不需要 HLK 测试;attestation 签名的驱动程序无法通过 Windows Update 向普通用户发布;仅在 Windows 10 桌面版及以后有效;面向更早版本的 Windows 需要提交 HLK/HCK 测试日志;Windows Server 2016 及以后不接受 attestation 签名提交,只加载通过 HLK 的驱动程序;attestation signing 需要 EV 证书,即便签名成功也不会成为 Windows Certified。 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

我想在应用里使用 USB 设备,需要自己写驱动程序吗?
大多数情况下不需要。Microsoft 的官方指南也明确指出「从最简单的方法开始,只有在确有必要时才转向更复杂的方法」。如果设备属于 USB 的标准类(CDC、HID、大容量存储等),Windows 标准的类驱动程序会自动加载,不需要驱动程序。如果不属于标准类,且只由一个应用访问,那么可以直接把 WinUSB(winusb.sys)当作功能驱动程序使用。只有在需要多个应用同时访问时,才轮到 UMDF 驱动程序,如果还是不行,再考虑 KMDF 驱动程序。自己编写驱动程序是最后的手段。
虚拟 COM 端口(USB 串口)方式最大的弱点是什么?
COM 端口号并不是设备的 ID。即使是同一台设备,换一个 USB 端口插入,COM 号也可能改变;同时接入多台设备时,仅凭编号根本无法判断哪个是哪个。在配置文件里写死「COM3」这种做法,在现场必定会出问题。正确的做法是在运行时通过 Win32_PnPEntity 等方式,根据 VID/PID、序列号动态解析出对应的 COM 号后再打开。此外,串口通信是字节流,不保证消息边界,因此还需要另外实现一个先在接收缓冲区中积累数据、再从中切出帧的 parser。
听说 HID 方式不需要驱动程序、用起来很方便,需要注意什么?
有三点。第一是速度,HID 使用中断传输,不适合大量数据的连续传输。第二是报告长度,传给 ReadFile 的缓冲区必须与 HidP_GetCaps 返回的 InputReportByteLength 完全一致,且开头第一个字节是报告 ID。这里搞错,就是「读不到数据、直接崩溃」这类典型故障的根源。第三是互斥控制,相当于鼠标、键盘、触摸屏、笔的顶级集合(Top-Level Collection)会被 Windows 的 Raw Input Manager 以独占方式打开,应用无法读写。不过,如果打开句柄时不要求读写权限,仍然可以通过 HidD_GetXxx 系列函数获取信息。
我想使用 WinUSB,能直接用在现有设备上吗?
多数情况下不能。不需要 INF 文件、由 winusb.sys 自动加载的前提是,设备固件带有 Microsoft OS 描述符,并以兼容 ID 的形式报告 WINUSB,也就是「WinUSB 设备」,而且只在 Windows 8 及以后的系统上才成立。标准自带的 Winusb.inf 支持这个兼容 ID 是从 Windows 8 开始的,所以如果还要覆盖 Windows 7 及更早版本,就必须以分发 INF 为前提。如果现有设备不属于 WinUSB 设备,也需要自行准备指定硬件 ID 的自定义 INF 并进行分发和安装。开发过程中用 Zadig 之类的工具替换驱动程序作为验证手段是有效的,但这属于移除厂商驱动程序的操作,不应作为正式发布的手段。如果能对固件动手脚,最干净的解决方式是让固件追加 OS 描述符;如果是全新设计,首选是通过 BOS 通知的 OS 2.0 描述符(Windows 8.1 及以后)。
要让应用能跟上 USB 设备的拔插,应该怎么做?
不要用轮询,而是订阅 PnP 通知。Windows 8 及以后的桌面应用,标准做法是给 CM_Register_Notification 指定设备接口过滤器;如果还要覆盖 Windows 7 及更早版本,则使用 RegisterDeviceNotification 和 WM_DEVICECHANGE。需要注意的是,CM_Register_Notification 不会通知注册时已经存在的接口,所以要先注册,再用 CM_Get_Device_Interface_List 枚举既有部分(顺序反过来会漏掉设备)。另外,在回调中执行 I/O 之类可能阻塞的处理很危险,应转交给其他线程处理。更重要的是,不要把 PnP 通知当作唯一的入口。处理中的 I/O 有可能在通知到来之前,或与通知同时,就以删除或取消类的错误完成。应当在所有读写的完成路径上,把删除类错误当作会话结束来处理,PnP 通知只作为捕捉等待期间断开连接的辅助信号来组合使用。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表