在 Windows 應用程式中處理 USB 裝置的方法 ── 虛擬 COM・HID・WinUSB・專用 SDK 的選擇方式
· Go Komura · USB, HID, WinUSB, 序列通訊, 設備整合, Windows 開發, C#, 裝置驅動程式
「這台裝置是USB連接的,應用程式應該可以直接操作它吧?」── 這是設備整合諮詢中最先出現的問題。答案是「要看連接方式而定」,這一句話裡就藏著相差數十倍的開發工時。同樣是「USB連接的裝置」,依看起來是COM埠、是HID,還是需要專用驅動程式而不同,要寫的程式碼、發布方式,乃至於現場會發生的問題種類,都截然不同。
麻煩的是,這個判斷必須在開始寫應用程式之前就完成。若先用「總之先裝上SDK,能動就好」的方式往下走,之後就會以「無法製作64bit組建」「連接兩台裝置就無法識別」「客戶端電腦裝不上驅動程式」的形式反撲回來。
本文將整理從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是完全不必發布驅動程式就能雙向通訊的隱藏首選。不過相當於滑鼠・鍵盤・觸控・觸控筆的集合,會被OS以獨佔方式開啟,因此碰不到。速度也受限於中斷傳輸的頻寬(第4章)。4
- WinUSB適合「需要巨量傳輸速度」「自訂協定」的場景。能不用INF自動安裝,僅限韌體具備Microsoft OS描述元、並回報相容ID
WINUSB的裝置,且在Windows 8以後使用的情況。若對象是既有裝置或Windows 7以前的系統,基本上都需要自訂INF(第5章)。5 - 廠商SDK不是「選擇」,而是「承接」下來的東西。bitness(32bit版還是64bit版)・執行緒模型・生命週期・再發布條件,全都由對方決定,因此要先把SDK的限制當作應用程式設計的前提條件,一開始就全部列出來(第6章)。
- 無論選哪種方式,裝置的唯一識別・拔插追蹤・逾時・電源管理這四點都要自己設計。省略這些的應用程式,一定會變成「偶爾不動」(第8章)。
- 若要自行開發核心模式驅動程式,Windows 10 1607以後必須經過Microsoft簽署。包含開設Partner Center帳戶需要EV憑證這點在內,請事先將其估算為發布成本(第10章)。6
2. 大前提 ── 從Windows的角度看,USB裝置的一切都取決於「載入了哪個驅動程式」
首先,用一張圖來呈現四種方式的決策樹。這是把第1章列出的官方選定順序(從最單純的開始),重新按照實際判斷的順序排列而成。2 各分支的細節對應第3~6章。
flowchart TD
S["想從應用程式操作USB裝置"]
Q1["在裝置管理員中被視為什麼<br/>先插入實機確認"]
Q2["裝置的韌體是否可以修改<br/>自家設計,或可委託廠商"]
Q3["所需頻寬是否中斷傳輸就夠<br/>參考基準-數十KB/s以下的狀態通知或指令"]
Q4["是否有多個應用程式<br/>同時存取同一裝置"]
A1["方式A‧虛擬COM埠<br/>第3章"]
A2["方式B‧HID<br/>第4章"]
A3["方式C‧WinUSB<br/>第5章"]
A4["方式D‧廠商SDK<br/>第6章"]
A5["考慮開發UMDF驅動程式<br/>不行的話用KMDF。<br/>發布成本見第10章"]
S --> Q1
Q1 -->|"看起來像連接埠,COM和LPT"| A1
Q1 -->|"看起來像人體介面裝置"| A2
Q1 -->|"已載入廠商製驅動程式"| A4
Q1 -->|"都不是/不明裝置"| Q2
Q2 -->|"不能改"| A4
Q2 -->|"可以改"| Q3
Q3 -->|"夠用"| A2
Q3 -->|"不夠,大量資料・自訂協定"| Q4
Q4 -->|"不會"| A3
Q4 -->|"會"| A5
圖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推薦的方案是WinUSB。1
還有一件事必須知道,就是複合裝置(composite device)。一條USB線的另一端若擁有多個功能,Usbccgp.sys會將每個功能展開為各自獨立的裝置。「明明是一台裝置,裝置管理員卻出現三個項目」就是這個原因,例如「控制走CDC(COM埠),狀態通知走HID」這種構成的裝置並不少見。方式並不是以裝置為單位決定,而是以功能(介面)為單位決定的。
第一步:用裝置管理員檢視實機
在展開討論之前,請先插上實機確認以下項目。5分鐘就能完成,之後的判斷會完全改變。
- 出現在裝置管理員的哪個分類、以什麼名稱顯示
- 內容 → 詳細資料索引標籤 → 硬體ID (
USB\VID_xxxx&PID_yyyy&...) - 同上 → 相容ID (是否看得到
USB\Class_02&SubClass_02或USB\MS_COMP_WINUSB) - 同上 → 裝置執行個體路徑的結尾(是否含有序號,或是含
&的產生值) - 驅動程式索引標籤 → 提供者與驅動程式檔案(是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,就能透過USB\Class_02&SubClass_02這個相容ID,讓標準的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.Management的ManagementObjectSearcher發出相同的查詢,或是用Windows.Devices.SerialCommunication.SerialDevice.GetDeviceSelectorFromUsbVidPid(vid, pid)建立AQS選擇器後呼叫DeviceInformation.FindAllAsync。(GetDeviceSelector不接受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
// 還會在後面加上代表埠的1個字元
// 只用「是否與結尾元素一致」來寫,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("找不到目標裝置。"); }
// 不論找到幾台,都一定要用序號篩選。即使只找到1台,也不代表
// 那就是目標的那一台(有可能目標的那台沒插,而插著的是另一台)。
// 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)的陷阱在於,只找到1台時看起來不需要確認。但如果目標的那台沒插上、只插著另一台,候選雖然只有1個,內容卻是別的東西。(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.sys與SerialPort層的額外負擔會開始起作用,因此若所需頻寬超過數百KB/s,正確做法是先在實機上實測,再決定方式。在還沒實測的階段就認定「因為需要速度所以選WinUSB」,只會白白背上不必要的驅動程式發布成本。
4. 方式B:HID ── 不必發布驅動程式就能雙向通訊
4.1 HID不只是給輸入裝置用的
一提到HID,容易聯想到滑鼠和鍵盤,但就規格而言,它是一種能雙向交換任意位元組序列(報告)的通用協定。條碼掃描器、卡片讀卡機、電子鎖、量測單元、UPS、自訂I/O盒 ── 這些「不想發布驅動程式,卻想交換自訂資料」的裝置之所以宣告為HID,是因為Windows標準內建了Hidclass.sys與Hidusb.sys,完全不需要發布INF或驅動程式就能動作。1
從Windows的角度看,HID的單位是頂層集合(Top Level Collection, 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_GetCaps和HidD_FreePreparsedData,就會用內容不明的caps來決定報告長度。拔插越頻繁的現場,越容易踩到這個競爭條件,因此請只在取得成功時才進入try,並且也要確認HidP_GetCaps的回傳值(是否為HIDP_STATUS_SUCCESS)。
實作上最常見的失敗是報告長度的處理。
- 傳給
ReadFile的緩衝區,長度要剛好等於InputReportByteLength。太短會失敗,太長也不會被正確處理 - 緩衝區的開頭第1個位元組是報告ID。如果裝置設計上不使用報告ID,這裡會是0。真正的資料從位元組1開始
- 同樣地,
WriteFile的緩衝區也要剛好等於OutputReportByteLength,開頭要放上報告ID
「送出去了裝置卻沒反應」,九成都是資料因報告ID的1個位元組而錯位,或是緩衝區長度不對。裝置文件上寫「指令是8位元組」時,如果OutputReportByteLength是9,就代表含報告ID在內共9位元組。
輸出報告的傳送路徑除了WriteFile之外還有HidD_SetOutputReport,兩者的使用時機官方有明確規定。9
| 用途 | 使用的函式 |
|---|---|
| 持續傳送輸出報告 | WriteFile(這是基本做法) |
| 設定集合的目前狀態 | HidD_SetOutputReport |
| 傳送功能(Feature)報告 | HidD_SetFeature |
需要注意的是,官方文件警告「部分裝置不支援HidD_SetOutputReport,使用它可能導致裝置失去回應」。9 也就是說,「WriteFile沒作用就改用HidD_SetOutputReport」這種切換方式,並不是無條件安全的替代方案。請先確認裝置的規格書和廠商的範例程式用的是哪一種,選定後若要切換,也請務必在實機上確認不會導致無回應。
若只是想列舉裝置,請把CreateFile的dwDesiredAccess設為0再開啟。這樣即使是以獨佔方式開啟的裝置也能列舉,並可用HidD_GetAttributes取得VID/PID、用HidD_GetSerialNumberString取得序號。
若不想在C#中直接寫原生的P/Invoke,也可以選擇HidSharp之類的函式庫。不過報告長度與報告ID的處理終究還是得理解,所以第一次先照上面的方式走一遍,之後查問題會比較快。若是打包型應用程式,也可以使用Windows.Devices.HumanInterfaceDevice.HidDevice,但需要在應用程式清單中宣告DeviceCapability。10
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回報WINUSB的情況。5
而且,這種自動比對只在Windows 8以後才生效。標準內建的Winusb.inf是在Windows 8才支援相容IDUSB\MS_COMP_WINUSB,在此之前必須使用指定硬體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描述元(所有版本共通)
- 在字串索引
0xEE擁有OS字串描述元,並回報廠商代碼 - 在擴充相容ID OS功能描述元中,把
compatibleID設為WINUSB(複合裝置則每個功能都要設定)
- 在字串索引
- Microsoft OS 2.0描述元(Windows 8.1以後)
- 在BOS描述元的平台功能描述元中,通知描述元集合的所在位置。不使用
0xEE的字串描述元 - 在該描述元集合中放入相容ID功能描述元,回報
WINUSB。光靠BOS通知「有集合存在」,並不會選中Winusb.sys。決定綁定與否的,和1.0一樣是相容ID
這是為了解決1.0的限制與可靠性問題而制定的,若是全新設計的韌體,這會是第一選擇12
- 在BOS描述元的平台功能描述元中,通知描述元集合的所在位置。不使用
裝置介面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);
// 非同步時,LengthTransferred 傳 NULL,完成後再取得傳輸長度。
// 一定要檢查回傳值。若為 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的回傳值再等待。若FALSE且GetLastError()回傳的不是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_Free和CloseHandle一定要成對出現。每次WinUsb_Initialize成功,就會配置一個介面控制代碼。如8.2節所述,若設計成每次拔插都重建工作階段,忘記釋放的部分就會在每次重新連線時不斷累積。不只是成功路徑,在初始化過程中失敗的路徑(例如WinUsb_Initialize成功、但管道設定失敗)也要確實走到收尾處理,請把收尾整合在同一個地方。順序是「用CancelIoEx停止處理中的I/O → 用WinUsb_GetOverlappedResult回收完成情形 →WinUsb_Free→CloseHandle」。若在回收完成之前就關閉控制代碼,就會釋放掉核心仍在存取的緩衝區- 防止應用程式重複啟動。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的限制列出來,本身就是一種設計。以下是應該確認的項目。
| 確認項目 | 疏忽時會發生什麼事 |
|---|---|
| 是否同時支援32bit/64bit | 64bit應用程式無法呼叫32bit專用DLL,需要另外做行程分離 |
| API的形態(C DLL/COM/.NET) | 呼叫方式和封送處理設計會不同。若是COM,還會多一層執行緒模型的限制 |
| 執行緒限制(是否要求STA、回呼所在的執行緒) | 卡住UI執行緒,或發生死結 |
| 可再發布的元件與發布條件 | 無法內建於安裝程式中,必須在客戶端手動安裝 |
| 隨附驅動程式的簽署狀態 | 無法安裝在Windows 11的新組建或裝置用電腦上 |
| 支援的作業系統與維護期限 | OS更新時整個應用程式必須重做 |
| 是否支援多台同時連接與其識別方式 | 一插上第二台就會出問題 |
| 是否有展示應用程式的原始碼 | 未知行為的調查成本會大幅上升 |
表格第一行的bitness(位元寬度),指的是SDK的DLL是以32bit版還是64bit版建置的。這一點會直接影響實務,原因在於Windows的行程無法在同一個行程內混用32bit和64bit的程式碼。若SDK只提供32bit專用DLL,就無法從x64建置的應用程式直接呼叫(會變成BadImageFormatException或LoadLibrary失敗)。要繞過這個問題,可以把整個應用程式都以x86建置,或是把呼叫SDK的部分獨立成一個32bit的子行程,再用行程間通訊串連。這兩種做法都涉及應用程式的結構,因此必須在選定裝置的階段就先確定。
在實作面,不要把SDK直接散布到整個應用程式中,這是最大的防禦手段。把SDK呼叫封裝進一個薄的抽象層(介面)背後,應用程式本體只針對這個抽象層寫程式。這樣一來,裝置型號變更・SDK的主要版本升級・更換廠商所帶來的影響就能收斂在一處,也能寫出不需要實體裝置的單元測試。
若必須從64bit應用程式使用32bit專用SDK,慣用做法是把它獨立成子行程、用行程間通訊串連。若走COM,「從32bit應用程式呼叫64bit DLL的COM橋接實例」是相反方向、但相同思路的案例;原生DLL的呼叫方式本身,整理在「從C#呼叫原生DLL:C++/CLI包裝vs. P/Invoke」中。子行程的存活管理,請參考「子行程的安全處理」。
7. 四種方式的判斷表
| 觀點 | 虛擬COM | HID | WinUSB | 廠商SDK |
|---|---|---|---|---|
| 驅動程式發布 | 不需要(CDC)/標準做法(VCP) | 不需要 | 有條件不需要,多數需要INF | 需要 |
| 實作難度 | 低 | 中 | 中~高 | 依SDK而定(參差不齊) |
| 吞吐量 | 低~中(依裝置而定,需實測) | 低 | 高 | 高 |
| 延遲 | 中 | 中(依輪詢間隔而定) | 低 | 低 |
| 多應用程式同時使用 | 不可(埠獨佔) | 可(共用TLC的話) | 不可 | 依SDK而定 |
| 裝置的唯一識別 | 需要自行實作(不可用COM編號) | 可用VID/PID/序號 | 可用裝置路徑 | 依SDK而定 |
| 現場排查的容易度 | 高(終端機軟體) | 中 | 低 | 低 |
| 對裝置韌體的依賴 | 小 | 小 | 大(OS描述元) | 全部依賴 |
| 適合的用途 | 指令應答型的裝置・量測儀器 | 狀態通知・小型指令 | 大量資料・自訂協定 | 攝影機・量測儀器・專用機 |
判斷的順序如下。
- 裝置是否已經以COM埠/HID/標準類別的身分出現 → 若是,就直接使用
- 沒有以上述身分出現,但韌體可以修改 → 依頻寬決定用HID(小型資料)還是WinUSB(大量資料)
- 韌體無法修改,但有廠商SDK → 使用SDK。不過要先做完第6章的確認
- 以上皆不符合,且需要多個應用程式同時存取 → 考慮開發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_GetCaps的UsagePage/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未運作、處於等待狀態時被拔除的情況,光靠它本身是不夠的
這裡必須明確區分出「自己取消的」情況。應答逾時的中止、應用程式結束時的收尾、使用者操作導致的中斷 ── 這些情況下使用CancelIoEx・CancellationToken・Dispose,即使是正常運作,也會出現ERROR_OPERATION_ABORTED・OperationCanceledException・ObjectDisposedException。若把這些無條件判定為「裝置消失了」,就會做出裝置明明還連接著,卻每次逾時就丟棄工作階段、重新連線這種性質惡劣的迴圈。
// 用狀態判斷「是自己中止的,還是裝置消失了」。
// operationCts 是這一次讀寫專用的 CancellationTokenSource。
// 應答逾時和使用者操作的中斷,都用這個來取消
catch (OperationCanceledException ex) when (IsSelfCancelled(ex, operationCts.Token))
{
// 自己取消的。工作階段沒有損壞,不銷毀
}
catch (ObjectDisposedException) when (_shutdown.IsCancellationRequested)
{
// 結束處理已經關閉控制代碼之後,飛出去的I/O才回來的情況
}
catch (Exception ex) when (IsDeviceGone(ex))
{
TearDownSession(); // 冪等。即使從PnP通知呼叫也不會重複執行
}
// 判斷「現在是不是自己正在要求取消」,要用實際取消的
// token 來比對
private bool IsSelfCancelled(OperationCanceledException ex, CancellationToken operation) =>
ex.CancellationToken == operation ||
ex.CancellationToken == _shutdown.Token ||
operation.IsCancellationRequested ||
_shutdown.IsCancellationRequested;
只看應用程式整體的關閉是不夠的。應答逾時或使用者操作導致的中斷,是用這一次操作專用的token來取消的。這時_shutdown並未被設定,因此只以_shutdown.IsCancellationRequested為條件的catch會被直接穿過。穿過去的OperationCanceledException,也不符合接下來的IsDeviceGone(不能把自己取消判定為「裝置消失」),於是就這樣一路穿到最下面,把整個I/O迴圈一起帶垮。這是一種每次逾時,接收執行緒就死掉一次的壞法。
判斷基準光靠例外型別或錯誤碼是不夠的。必須和取消方所使用的token互相比對,才能真正切分開來。反過來說,若程式中存在自己取消的路徑,就必須把這個事實放在I/O層看得到的地方。IsDeviceGone那一側,也請不要把OperationCanceledException和ObjectDisposedException無條件判定為「裝置消失了」。
不論從哪個路徑進來,都要走到相同的收尾,因此請把工作階段的銷毀整合成一個冪等的處理,確保重複呼叫也不會壞掉(例如用Interlocked.Exchange設定旗標,只讓最先進來的那次真正執行)。對著已失效控制代碼的I/O所拋出的例外,經常來自難以攔截的地方 ── 正因如此,才需要在例外的發生源那一端接住,並連結到狀態轉換的設計。
8.3 逾時與重新連線 ──「一個逾時」是不夠的
USB裝置的I/O,不論是被拔掉、斷電,還是韌體當掉,症狀都同樣是「沒有回應」。逾時要依意義分開設定。
| 逾時 | 對象 | 參考基準 |
|---|---|---|
| 開啟逾時 | 直到裝置開啟為止 | 秒級 |
| 應答逾時 | 從發出指令到應答完成 | 裝置規格的最差值 × 安全係數 |
| 位元組間逾時 | 訊框中途沒有後續資料 | 依通訊速度計算 |
| 重新連線退避 | 重新開啟前的等待間隔 | 指數退避 + 上限 |
而且逾時不是「應付變慢時的保險」,而是「推進狀態轉換的規則」,請這樣看待它。逾時發生時要轉移到哪個狀態、處理中的要求要如何確定失敗、UI要顯示什麼,都要決定清楚,才算是完整的設計。UI的呈現方式在「外部設備狀態確認與顯示的最佳實務」中有詳細說明 ── 請不要只用一句「連線中」帶過。
8.4 電源管理 ──「明明沒拔卻反應遲鈍」的真相
USB的選擇性暫止,是把閒置狀態的裝置切換到低耗電狀態的機制。由於恢復需要時間,「只有第一次回應特別慢」「放置一段時間後,第一個指令會被漏接」這類症狀,元兇往往就是它。
Usbser.sys(虛擬COM)預設是停用的,可透過登錄檔的IdleUsbSelectiveSuspendPolicy啟用・設定3- WinUSB則透過擴充屬性OS功能描述元(或INF)中的
DeviceIdleEnabled・DefaultIdleTimeout・UserSetDeviceIdleEnabled等控制
現場首先要確認的,是裝置管理員中該裝置(以及USB根集線器)內容裡的「允許電腦關閉這個裝置以節省電源」核取方塊。在裝置用電腦上,實際上有不少故障只要取消勾選這裡就解決了。連同筆電的省電設定在內,驗證請在與正式環境相同的電源方案下進行。
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節那樣,INF只是參照Microsoft的winusb.sys,也需要產生並簽署目錄(.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桌面版,並對照支援OS清單後再採用。若裝置用電腦是Windows Server或較舊的LTSC,這條路徑從一開始就選不了。
- 不要指望例外條件。在安全開機停用、或使用2015年7月29日以前發行的憑證簽署等情況下,交叉簽署的驅動程式仍可運作,但若把發布計畫建立在這個前提上,幾年內就會破功。6
- 在開始寫程式之前,先確認費用與所需時間。若要發布核心模式驅動程式,取得EV程式碼簽署憑證與開設Partner Center帳戶就是前置工程。6 金額和所需期間會依認證機構・時期・自家公司登記資訊的齊備程度而不同,請不要憑其他公司的案例數字判斷,務必以自家公司的名義實際確認以下三項:(1)EV憑證的年費(向多家認證機構取得報價)、(2)EV憑證所必須的組織實在性審查所需期間、(3)開設Partner Center帳戶所需期間。這是程序上而非技術上的時間,若不與開發排程平行推進,就會出現程式碼明明已經完成、卻無法發布的窘境。
- 安裝程式的設計。內含驅動程式的安裝程式需要系統管理員權限,也需要驗證靜默安裝。發布方式本身的選擇方法,整理在「Windows應用程式發布方式怎麼選」中;判斷何時需要系統管理員權限,則整理在「Windows什麼時候需要系統管理員權限」中。
- 在裝置用電腦上,驅動程式和OS更新會互相衝突。若要在LTSC架構的裝置用電腦上安裝廠商驅動程式,請把OS組建的固定與驅動程式的更新方針一併決定好。「工業用電腦該安裝哪一種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一筆資料都讀不到 | 對象是被OS獨佔開啟的TLC(鍵盤等) | 切換裝置模式。若只是列舉,用權限0開啟即可(4.2) |
| WinUSB的Read沒有返回 | 未設定PIPE_TRANSFER_TIMEOUT |
在管道原則中設定逾時(5.3) |
| 每次逾時都重新連線 | 把自己取消誤判為裝置斷線 | 依取消token等狀態比對後再判斷(8.2) |
| 反覆拔插後逐漸變重 | WinUsb_Free/CloseHandle漏呼叫 |
把收尾整合在一處,失敗路徑也要確實走到(5.3) |
| P/Invoke呼叫之後,無關的變數壞掉 | 結構只宣告到一半 | 以與原生端相同的大小・排列宣告全部欄位(4.3) |
| 客戶端裝不上驅動程式 | 目錄未簽署(與是否自製.sys無關) |
把.cat的產生與簽署納入發布計畫(第10章) |
| 開兩個應用程式,其中一個失敗 | WinUSB不支援同時存取 | 防止重複啟動,或整合到常駐服務 |
| 64bit組建讀不到SDK | 32bit專用DLL | 分離到子行程用IPC串連(第6章) |
| 不知道通訊內容是否真的送達 | 沒有觀察手段 | USB通訊協定分析儀、相當於usbmon的追蹤、實作通訊記錄檔 |
最後一行常被輕忽,但很重要。一開始就準備好能切分「是應用程式的問題還是裝置的問題」的手段,能大幅縮短原因不明的時間。虛擬COM方式在現場之所以強,就是因為有終端機軟體這種人人都能用的排查工具。若選擇HID或WinUSB,請自行準備能取代它的記錄檔和測試用CLI。
12. 總結
- USB裝置的處理方式,取決於它上面載入了哪個驅動程式,而不是裝置本身。請從在裝置管理員檢視硬體ID・相容ID・裝置執行個體路徑開始。
- 官方的選定順序是「從最單純的方式開始」。標準類別驅動程式 → WinUSB(單一應用程式) → UMDF(多個應用程式) → KMDF。自製驅動程式是最後的手段。
- 虛擬COM在實作和現場排查上很輕鬆,但COM編號不是識別碼。請在執行期從VID/PID・序號解析。吞吐量會因是USB-UART轉換還是原生CDC而相差一個數量級,請不要憑感覺判斷,務必實測。
- HID是不需要發布驅動程式就能雙向通訊的有力選項,但相當於滑鼠・鍵盤・觸控・觸控筆的集合會被OS獨佔開啟而碰不到,速度的上限則是中斷傳輸的頻寬。
- WinUSB適合大量資料和自訂協定。不過不需要INF這件事,僅在裝置具備OS描述元、且於Windows 8以後使用時才成立,且無法支援多個應用程式同時存取。若是全新韌體,OS 2.0描述元是首選。
- 廠商SDK不是選項,而是前提條件。請在裝置選型階段就把bitness・執行緒限制・再發布條件・維護期限列出來,應用程式端則用薄的抽象層包住SDK。
- 不論選哪種方式,唯一識別・拔插追蹤・多層逾時・電源管理這四點都要自己設計。這裡正是「偶爾不動」的發生源頭。複合裝置的識別要細到功能層級,斷線則要同時從PnP通知和I/O錯誤兩邊捕捉。
- 若要發布驅動程式套件,即使沒有自製的
.sys,也需要目錄的簽署。若包含核心模式的二進位檔案,還需要1607以後的Microsoft簽署(以及EV憑證)。attestation signing不需要HLK,但僅限Windows 10桌面版以後,請先對照支援OS清單再選擇。若有不必發布驅動程式的方式可選,就選那一種,這在實務上幾乎永遠是正解。
相關文章
- 序列通訊應用的陷阱 - 先釐清 1 byte 單位、逾時、流控、重連、USB 轉換、UI 凍結
- 外部設備狀態檢查與顯示的最佳實務 - 別只用『連線中』就交差的設計
- 在 C# 中安全呼叫 Win32 API — P/Invoke 實務指南(DllImport / LibraryImport / CsWin32)
- 要在 C# 中使用原生 DLL,C++/CLI 包裝是有力選項的理由 - 與 P/Invoke 的比較整理
- Windows 軟即時實戰指南 - 為了減少延遲的檢查清單
- Windows 應用發布方式怎麼選 - MSI / MSIX / ClickOnce / xcopy / 自訂 updater 的判斷表
- 工業用電腦該安裝哪一種Windows ── Windows IoT Enterprise / LTSC 實戰指南
- Process Monitor(ProcMon)實戰指南 ── 在10分鐘內找出「設定未被讀取」「ACCESS DENIED」的原因
相關諮詢領域
合同會社小村軟體處理USB連接的裝置・量測儀器與Windows應用程式的整合設計、既有SDK的包裝與64bit化,以及拔插或重新連線後不穩定的裝置整合應用程式的原因調查與改善。
參考連結
-
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
-
Microsoft Learn, Choose a driver model for developing a USB client driver。關於「從最單純的方法開始」的選定順序(標準類別驅動程式 → WinUSB → UMDF → KMDF)、WinUSB適合單一應用程式存取・巨量/中斷/等時端點・對象為Windows XP SP2以後的情況、多個應用程式同時存取無法使用WinUSB、WinUSB/UMDF/KMDF的功能比較表(等時傳輸在Windows 8.1以後才由WinUSB支援,UMDF不支援)等說明。 ↩ ↩2 ↩3 ↩4 ↩5
-
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
-
Microsoft Learn, HID Architecture。關於HID類別驅動程式(hidclass.sys)在HID用戶端與傳輸層之間做抽象化;Windows支援的頂層集合清單與存取模式(滑鼠・鍵盤・觸控筆・觸控螢幕・高精度觸控板為獨佔,遊戲控制器・感測器・條碼掃描器等為共用);基於安全性理由,Raw Input Manager(RIM)以獨佔方式開啟這些裝置;即使是獨佔開啟的裝置,只要開啟控制代碼時不要求讀寫權限,仍可用HidD_GetXxx取得資訊等說明。 ↩ ↩2 ↩3 ↩4
-
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
-
Microsoft Learn, Driver Signing Policy。關於Windows 10版本1607以後,未經Dev Portal簽署的新核心模式驅動程式無法載入;註冊Windows Hardware Dev Center計畫需要EV程式碼簽署憑證;允許交叉簽署驅動程式的例外條件(升級到1607、安全開機停用、2015年7月29日以前發行的終端憑證)等說明。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Opening HID collections。關於使用者模式應用程式以SetupDi*函式找出HID集合、用CreateFile開啟、以HidD_Xxx取得已解析資料與資訊、用ReadFile讀取輸入報告並以WriteFile傳送輸出報告、以HidP_Xxx解析報告的一連串步驟說明。 ↩
-
Microsoft Learn, HIDP_CAPS structure (hidpi.h)。關於結構的完整定義(Usage/UsagePage/InputReportByteLength/OutputReportByteLength/FeatureReportByteLength/Reserved[17]/NumberLinkCollectionNodes以下10個Number系成員,合計USHORT×32),以及各報告長度均含報告ID的1個位元組在內等說明。 ↩
-
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
-
Microsoft Learn, HidDevice Class (Windows.Devices.HumanInterfaceDevice)。關於HidDevice代表對應於頂層集合的裝置;用GetDeviceSelector從usagePage/usageId/vendorId/productId建立AQS選擇器,再以FromIdAsync開啟的流程;使用這個類別存取HID裝置的應用程式,需要在應用程式清單的Capabilities節點中加入特定的DeviceCapability資料等說明。 ↩
-
USB Implementers Forum, Universal Serial Bus Specification Revision 2.0。關於中斷端點的最大封包長度,全速為64位元組・高速為1024位元組;輪詢間隔(bInterval)在全速下為1~255毫秒,高速下以125微秒為單位、以2^(bInterval-1)表示(第9.6.6節 Endpoint);以及控制・巨量・中斷・等時各傳輸類型的頻寬特性等說明。 ↩ ↩2
-
Microsoft Learn, Microsoft OS 2.0 Descriptors Specification。關於Microsoft OS描述元2.0版是為了解決1.0版的限制與可靠性問題而制定的,適用的作業系統為Windows 10及Windows 8.1 Preview等說明。 ↩
-
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 -
Microsoft Learn, WinUsb_ReadPipe function (winusb.h)。關於指定Overlapped時函式會立即返回、操作以非同步方式執行,此時GetLastError會回傳ERROR_IO_PENDING,須以WinUsb_GetOverlappedResult確認成敗;非同步(Overlapped非NULL)時LengthTransferred可設為NULL;若LengthTransferred傳入非NULL,函式返回當下的值在重疊操作完成前是沒有意義(meaningless)的,實際的讀取位元組數須透過WinUsb_GetOverlappedResult取得;同步呼叫(Overlapped為NULL)時LengthTransferred必須為非NULL等說明。 ↩ ↩2
-
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;一般無法存取包含上/下層篩選驅動程式的裝置堆疊等說明。 ↩
-
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
-
Microsoft Learn, RegisterDeviceNotificationW function (winuser.h)。關於這是應用程式用來接收裝置通知的登錄函式,成功時回傳裝置通知控制代碼、失敗時回傳NULL等說明。另外一併參考WM_DEVICECHANGE message及DBT_DEVICEARRIVAL event,關於裝置或媒體插入並可供使用時,會以wParam為DBT_DEVICEARRIVAL廣播WM_DEVICECHANGE的說明。 ↩
-
Microsoft Learn, PnP Device Installation Signing Requirements。關於驅動程式套件要暫存到Driver Store,必須符合簽署要求;在PnP裝置安裝中,要被視為「已簽署」,驅動程式套件的目錄檔案必須以WHQL或第三方發行憑證(SPC・商用發行憑證)簽署;核心模式驅動程式二進位檔案載入所需的簽署要求是另外課予的;64bit版Windows因核心模式程式碼簽署原則而要求WHQL或SPC簽署;Windows 10 in S mode等部分版本只接受WHQL簽署的目錄等說明。 ↩ ↩2
-
Microsoft Learn, Driver Signing Options。關於通過HLK測試的dashboard簽署驅動程式,可在包含Windows Vista及Windows Server版本在內的作業系統上運作,因為能為所有OS版本簽署而被列為建議方法;attestation signing定位為「僅供測試用途(for testing purposes only)」,不需要HLK測試;attestation簽署的驅動程式無法透過Windows Update向一般使用者公開;僅在Windows 10桌面版以後有效;若對象包含更早的Windows,需要提交HLK/HCK測試記錄;Windows Server 2016以後不接受attestation簽署的提交,只會載入通過HLK的驅動程式;attestation簽署需要EV憑證,且即使取得簽署也不會成為Windows Certified等說明。 ↩
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
序列通訊應用的陷阱 - 先釐清 1 byte 單位、逾時、流控、重連、USB 轉換、UI 凍結
從設備整合與儀器控制的實作現場出發,整理序列通訊應用最容易踩到的陷阱。把訊息邊界、逾時語意、流控線設定、single writer、session 重連與 hex dump 日誌一一拆開,幫助讀者把「偶爾才壞」的 byte 序列處理改造成可預測且容易調查的結構。
委外・委託開發 Windows 應用程式前該整理的事項
在委外・委託開發 Windows 應用程式之前,整理既有軟體改版、設備整合、COM/ActiveX、發布與更新、維護等應留意的重點。
在 C#・PowerShell 中使用 WMI/CIM ── 硬體資訊取得・處理程序監控・遠端查詢的實務指南
取得 PC 序號、監控磁碟可用空間、偵測處理程序啟動,這些定番需求的標準答案就是 WMI/CIM。本文解說 Get-CimInstance 等 CIM Cmdlet 的用法、從舊版 Get-WmiObject 的遷移方式、C# 的 System.Management 與 C...
為業務應用程式的 DB 結構做版本管理 ── 防止「各客戶端 DB 不一致」的遷移實踐
為分散在各客戶端的業務應用程式 DB 結構做版本管理的實務指南。整理 PRAGMA user_version 與前進遷移的 C# 實作、EF Core Migrations・DbUp・自行實作的判斷表,一直到兩階段發佈。
WinForms / WPF 應用程式的 CI/CD 實務 ── 用 GitHub Actions 把從建置到簽章・發布全部自動化
本文整理用 GitHub Actions 為 WinForms / WPF 應用程式建置 CI/CD 的實務指南,內容涵蓋在 windows-latest 上進行建置+測試的最小 YAML、以標籤驅動的版本編號、透過 signtool 整合簽章,以及依 MSI/MSIX/C...
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
常見問題
整理諮詢這個主題時常見的問題。
- 我想從應用程式使用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,而且開頭第1個位元組是報告ID。這裡搞錯,就會變成讀不到資料、或直接當掉的典型故障。第三是獨佔控制,相當於滑鼠・鍵盤・觸控螢幕・觸控筆的頂層集合,會被Windows的Raw Input Manager以獨佔方式開啟,應用程式無法讀寫。不過,只要開啟控制代碼時不要求讀寫權限,仍然可以透過HidD_GetXxx系列函式取得資訊。
- 我想使用WinUSB,能直接套用在既有裝置上嗎?
- 多數情況下不行。不需要INF檔案就能自動載入winusb.sys,僅限於裝置韌體具備Microsoft OS描述元、並以相容ID回報WINUSB的「WinUSB裝置」,而且是在Windows 8以後使用的情況。標準內建的Winusb.inf是在Windows 8才支援相容ID,因此若也要涵蓋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通知則作為輔助訊號,用來補足等待期間的斷線情形。