更新履歴(9件・最終更新 2026年08月02日)
この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。
- 記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
- 外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
- WinUSBの読み取り例で、`WinUsb_GetOverlappedResult`の戻り値を見ずに`transferred`を使っていたのを直しました。要求が受け付けられたあとでも、`PIPE_TRANSFER_TIMEOUT`によるタイムアウト、`CancelIoEx`、転送中の取り外しでこの関数は`FALSE`を返し、そのとき`transferred`は転送できた長さではありません。戻り値を見ずに使うと、初期化した0がそのまま「空のパケットを受け取った」として下流へ流れ、セッションを作り直すべきエラーが握り潰されます。戻り値で分岐し、`FALSE`なら`GetLastError()`をその場で取るようにしました。後始末側の回収も、まだ回収していない要求だけに限定しています。
- WinUSBの読み取り例で、`WinUsb_ReadPipe`の戻り値を見ずに`WinUsb_GetOverlappedResult`を呼んでいたのを直しました。取り外し・パイプIDの取り違え・無効なハンドルでは`FALSE`かつ`ERROR_IO_PENDING`以外が返り、このとき保留中の操作は1つも存在しません。そのまま完了を待つと、元のエラーコードが「0バイトで完了」や別のエラーに化けて原因が消えます。戻り値を見て分岐し、後始末側の回収も同じ条件で囲むようにしました。あわせて、箇条書きの件数が実際の項目数と合っていなかったのも直しています。
- 自己キャンセルの判定がアプリ全体のシャットダウンしか見ていませんでした。応答タイムアウトやユーザー操作による中断はその1回の操作専用のトークンでキャンセルするため`_shutdown`は立っておらず、`catch`を素通りします。素通りした`OperationCanceledException`は「機器が消えた」の判定にも当てはまらないので、そのまま下まで抜けてI/Oループごと落とします。記事自身がタイムアウトとユーザー操作を自己キャンセルの経路として挙げていたので、実際にキャンセルしたトークンと突き合わせて判定する形に直しました。
- `RegisterDeviceNotification`の戻り値を検査するようにしました。失敗しても例外は飛ばずNULLが返るだけなので、見ないと「起動には成功したのに`WM_DEVICECHANGE`が一度も来ない」アプリになります。`FreeHGlobal`を挟む前に`GetLastWin32Error`を取り、`Win32Exception`にして投げます。
- シリアルでの絞り込みを「複数見つかったとき」の分岐の中に置いていたのを、台数にかかわらず必ず通すよう直しました。目的の号機が外れていて別の号機だけが挿さっていると、候補は1台でも中身は別物です。あわせて、シリアルの探し方が`USB\`形式にしか合っておらず、`FTDIBUS\VID_0403+PID_6001+A5XK3RJTA\0000`のように真ん中の要素へ埋め込む形式では1件も一致しなかったのを直しました。
- 同型機が複数見つかったときの分岐が、注記だけで結局1台目を開いてしまう書き方になっていたので、シリアル番号で1台に特定し、特定できなければ例外にする形に直しました。
- 接続方式を選ぶための決定木を2章に追加しました。あわせてデスクトップアプリでの着脱検知の実装例2種、VID/PIDからCOM番号を得てポートを開くまでの通し例、32bit/64bitの別が実務に効く理由の説明を加えています。
- 初版公開
この記事を引用する(DOI: 10.5281/zenodo.21590192)
この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。
小村 豪(2026)「WindowsアプリでUSB機器を扱う方法 ── 仮想COM・HID・WinUSB・専用SDKの選び方」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21590192 https://staging.comcomponent.com/blog/windows-usb-device-handling-guide/
- DOI(最新版)
- 10.5281/zenodo.21590192
- DOI(この版)
- 10.5281/zenodo.21733181
「この装置、USBでつながるのでアプリから叩けますよね?」── 装置連携の相談で最初に出てくる質問です。答えは「つなぎ方によります」で、この一言に開発工数の数十倍の差が隠れています。同じ「USB接続の機器」でも、COMポートとして見えるのか、HIDとして見えるのか、専用ドライバーが要るのかで、書くコードも配布方法も、現場で起きるトラブルの種類もまるで別物になります。
厄介なのは、この判断がアプリを書き始める前に必要なことです。「とりあえずSDKを入れて動いた」で進めると、後になって「64bitビルドができない」「装置を2台つないだら識別できない」「客先のPCでドライバーが入らない」という形で跳ね返ってきます。
この記事では、WindowsアプリからUSB機器を扱う4つの方式 ── 仮想COMポート・HID・WinUSB・ベンダー提供SDK ── を、選定基準と実装上の勘所、そして4方式に共通して必要になる設計まで含めて整理します。
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章)。
- どの方式でも、機器の一意識別・抜き差し追従・タイムアウト・電源管理の4点は自分で設計します。ここを省いたアプリは、必ず「たまに動かない」になります(8章)。
- カーネルモードドライバーを自作するなら、Windows 10 1607以降はMicrosoftによる署名が必須です。Partner Centerのアカウント開設にEV証明書が要る点も含め、配布コストとして事前に見積もってください(10章)。6
この記事の知識マップ
この記事は、WindowsアプリからUSB機器を扱う4つの接続方式(仮想COMポート・HID・WinUSB・ベンダー提供SDK)を、機器がデバイスマネージャーでどのUSBデバイスクラスとして見えるかを起点に選ぶ考え方を扱う。標準クラスドライバーで足りなければWinUSB、複数アプリの同時アクセスが必要ならUMDFドライバー、それでも無理ならKMDFドライバーという順で検討するのが公式の選定順序であり、複合デバイスやHIDの排他コレクションなど機器側の制約が識別・実装方法を左右する。どの方式でも機器の一意識別とPnP通知による抜き差し追従を自分で設計する必要があり、機器切断によるハンドル無効化への対処が欠かせない。ドライバーを配布する場合は、自作の.sysがなくてもカタログ署名が必要で、カーネルモードドライバーを含むならさらにMicrosoftによる署名とEVコード署名証明書が要る。
flowchart LR
accTitle: WindowsでのUSB機器接続方式の知識マップ
accDescr: USB接続方式の選定がデバイスマネージャー上のUSBデバイスクラスの確認を起点とし、仮想COMポート・HID・WinUSB・ベンダーSDKの4方式それぞれが機器の一意識別・PnP通知・署名要件とどう結び付くかを示す図。
usb_connectivity_method_selection["USB接続方式の選定"]
usb_device_class["USBデバイスクラス"]
winusb["WinUSB方式"]
umdf_driver["UMDFドライバー"]
kmdf_driver["KMDFドライバー"]
composite_usb_device["複合デバイス"]
virtual_com_port["仮想COMポート方式"]
hid["HID方式"]
winusb_os_descriptor["Microsoft OS記述子(WinUSBの互換ID)"]
concurrent_multi_app_access["複数アプリからの同時アクセス"]
usb_device_unique_identification["機器の一意識別"]
exclusive_hid_collection["排他HIDコレクション"]
overlapped_io["Overlapped I/O"]
getoverlappedresult["GetOverlappedResult"]
cancelioex["CancelIoEx"]
usb_pnp_notification["USB機器のPnP通知"]
usb_handle_invalidation["機器切断によるハンドルの無効化"]
windows_forms["Windows Forms"]
usb_selective_suspend["USBセレクティブサスペンド"]
driver_catalog_signing["ドライバーパッケージのカタログ署名"]
kernel_driver_signing["カーネルモードドライバー署名(Windows 10 1607以降)"]
ev_certificate["EVコード署名証明書"]
vendor_usb_sdk["ベンダー提供SDK・専用ドライバー方式"]
bitness_match_requirement["bitness一致要件"]
usb_connectivity_method_selection -->|"前提とする"| usb_device_class
winusb -->|"より先に行うべき"| umdf_driver
umdf_driver -->|"より先に行うべき"| kmdf_driver
composite_usb_device -->|"利用する"| usb_device_class
virtual_com_port -->|"利用する"| usb_device_class
hid -->|"利用する"| usb_device_class
winusb -.->|"利用する"| usb_device_class
winusb -.->|"前提とする"| winusb_os_descriptor
winusb -->|"両立しない"| concurrent_multi_app_access
umdf_driver -->|"推奨される対応"| concurrent_multi_app_access
virtual_com_port -.->|"前提とする"| usb_device_unique_identification
composite_usb_device -.->|"前提とする"| usb_device_unique_identification
hid -.->|"前提とする"| usb_device_unique_identification
winusb -.->|"前提とする"| usb_device_unique_identification
hid -->|"両立しない"| exclusive_hid_collection
winusb -.->|"利用する"| overlapped_io
winusb -.->|"利用する"| getoverlappedresult
winusb -->|"利用する"| cancelioex
usb_pnp_notification -.->|"軽減する"| usb_handle_invalidation
windows_forms -.->|"利用する"| usb_pnp_notification
usb_selective_suspend -.->|"で構成できる"| winusb_os_descriptor
winusb -.->|"前提とする"| driver_catalog_signing
kmdf_driver -->|"前提とする"| kernel_driver_signing
kernel_driver_signing -->|"前提とする"| ev_certificate
vendor_usb_sdk -.->|"利用する"| usb_device_class
vendor_usb_sdk -.->|"前提とする"| bitness_match_requirement
virtual_com_port -.->|"前提とする"| usb_pnp_notification
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全27件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle
2. 大前提 ── Windowsから見たUSB機器は「どのドライバーが載ったか」がすべて
先に、4方式の決定木を1枚で示します。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: 4方式の決定木。起点は必ず「デバイスマネージャーで何として見えているか」
この図の要点は2つです。起点が製品カタログではなく現物のデバイスマネージャーであること、そして下に行くほど配布コストが増えることです。上のほうで解決できるならそれが正解で、下に降りる判断は「降りるだけの理由がある」ときだけにしてください。
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)です。1本のUSBケーブルの先で複数の機能を持つ機器は、Usbccgp.sysが機能ごとに別々のデバイスとして展開します。「1台の装置なのにデバイスマネージャーに3つ出てくる」のはこれで、たとえば「制御は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方式で現場を壊す原因の第1位が、これです。
- COM番号はWindowsがそのPCで割り当てた番号にすぎず、機器の識別子ではありません
- 挿すUSBポートを変えると番号が変わることがあります
- 同型機を2台つなぐと、どちらがどちらか番号だけでは分かりません
- 「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\のフィルターには1件も引っかかりません。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は1件も引っかからない。
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}' で1台に特定できません(該当 {candidates.Count} 台)。");
}
using var port = new SerialPort(candidates[0].PortName, 115200)
{
ReadTimeout = 1000,
WriteTimeout = 1000,
};
port.Open();
ポイントは4つです。(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のように真ん中の要素へ埋め込む形式で1件も一致しなくなります。上のコードでは、この2つを 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の単位はトップレベルコレクション(TLC)です。1つの物理デバイスが複数の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バイト)だけの構造体を渡すと、マーシャラーが確保した領域を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を置きます
「送ったのに機器が反応しない」の9割は、レポート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の扱いは結局理解する必要があるので、最初の1回は上の形で通しておくと後の調査が速くなります。パッケージ化されたアプリなら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が互換IDUSB\MS_COMP_WINUSBに対応したのがWindows 8で、それ以前はハードウェアIDを指定したカスタムINFが必須でした。5 5.1節のとおりWinUSB自体はWindows XP SP2以降で動きますが、「XPから動く」と「INFなしで入る」は別の話です。Windows 7以前も対象なら、OS記述子を実装していてもINFを配布する前提で計画してください(Windows 7以前でも、更新版Winusb.infがWindows Update経由で入っていればマッチしますが、それを配布計画の前提にはできません)。
具体的には、機器側に次の実装が必要です。バージョン1.0(WCID)と2.0の2系統がある点に注意してください。
- 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が登録されていなければアプリ側の機器探索が組み立てにくいので、実務上はセットで実装します。
ここで注意すべきなのが、レジストリ上のプロパティ名が単数形と複数形の2種類あることです。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を1バイトも書かず、Microsoftのwinusb.sysを参照するだけのINFでも、署名済みカタログを付けなければ実運用のWindowsには入りません。「INFを1枚書けば済む」ではないので、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 かつ 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); // ファイルハンドルを閉じる
現場で効くポイントは7つです。
FILE_FLAG_OVERLAPPEDで開いて非同期で回す。同期I/Oにすると、機器が黙ったときにスレッドごと固まりますPIPE_TRANSFER_TIMEOUTを必ず設定する。既定では読み取りが返ってきませんWinUsb_ReadPipeの戻り値を見てから待つ。FALSEかつGetLastError()がERROR_IO_PENDING以外のときは、要求そのものが受け付けられていません。抜き差しの直後、パイプIDの取り違え、既に無効になったハンドル ── どれも普通に起きます。このとき保留中の操作は1つも無いので、そのままWinUsb_GetOverlappedResultを呼ぶのは「開始していない転送の完了」を待つ操作になります。元のエラーコードは上書きされて消え、残るのは「0バイトで完了した」か、まったく別のエラーです。原因究明が始まる前に原因が消えます。上のコードでstartedを持ち回っているのはこのためで、後始末側の回収も同じ条件で囲みます14WinUsb_GetOverlappedResultの戻り値も見る。要求が受け付けられたあとでも、タイムアウト(上で設定したPIPE_TRANSFER_TIMEOUT)、CancelIoEx、転送中の取り外しで、この関数はFALSEを返します。このときtransferredは「転送できた長さ」ではありません。戻り値を見ずに使うと、初期化した 0 がそのまま「空のパケットを受け取った」として下流へ流れ、セッションを作り直すべきエラーが握り潰されます。しかも症状は「たまに何も来ない機器」なので、原因にたどり着くまでが長い。FALSEならGetLastError()をその場で取ります(間に別のAPIを1つ挟むだけで上書きされます)- 非同期では
LengthTransferredにポインターを渡さない。公式ドキュメントは「Overlappedが非NULLならLengthTransferredはNULLでよい」「非NULLを渡した場合、WinUsb_ReadPipeから戻った時点の値は操作が完了するまで無意味」と明記しています。転送長はWinUsb_GetOverlappedResultで取得します。14 ローカル変数のアドレスを渡すのは、値が無意味なだけでなく、その変数がスコープを抜ける設計だとダングリングポインターになります WinUsb_FreeとCloseHandleを必ず対にする。WinUsb_Initializeが成功するたびにインターフェースハンドルが確保されます。8.2節のとおり抜き差しのたびにセッションを作り直す設計にすると、解放を書き忘れたぶんが再接続のたびに積み上がります。成功経路だけでなく、初期化途中で失敗した経路(WinUsb_Initializeは成功したがパイプ設定で失敗した、など)でも必ず通るように、後始末は1箇所にまとめてください。順序は「処理中のI/OをCancelIoExで止める →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の新しいビルドや装置PCでインストールできない |
| 対応OSと保守期限 | OS更改でアプリごと作り直しになる |
| 複数台同時接続の可否と識別方法 | 2台目をつないだ時点で破綻する |
| デモアプリのソース有無 | 仕様不明な挙動の調査コストが跳ね上がる |
表の1行目のbitness(ビット幅)は、SDKのDLLが32bit版としてビルドされているか64bit版かという意味です。ここが実務に直結するのは、Windowsのプロセスは32bitと64bitのコードを同じプロセス内に混在させられないためです。32bit専用DLLしか提供されないSDKは、x64ビルドのアプリから直接呼び出せません(BadImageFormatExceptionやLoadLibraryの失敗になります)。回避するには、アプリ全体をx86でビルドするか、SDKを呼ぶ部分だけを32bitの別プロセスに追い出してプロセス間通信でつなぐことになります。どちらもアプリの構造に関わる決定なので、機器選定の段階で分かっていなければならない項目です。
実装面では、SDKを直接アプリ全体にばらまかないことが最大の防御です。SDK呼び出しを1つの薄い抽象層(インターフェース)の裏に閉じ込め、アプリ本体はその抽象に対して書きます。こうしておくと、機器の型番変更・SDKのメジャーバージョンアップ・ベンダー乗り換えの影響が1箇所で済み、機器なしでの単体テストも書けるようになります。
32bit専用SDKを64bitアプリから使う必要が出た場合は、別プロセスに追い出してプロセス間通信でつなぐのが定石です。COM経由なら「32bitアプリから64bit DLLを呼ぶCOMブリッジ実例」が逆方向の同じ考え方で、ネイティブDLLの呼び出し方そのものは「C#からネイティブDLLを呼ぶ:C++/CLIラッパー vs P/Invoke」に整理してあります。子プロセスの生存管理は「子プロセスの安全な扱い」を参照してください。
7. 4方式の判断表
| 観点 | 仮想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. どの方式でも自分で設計する4つのこと
方式が決まっても、以下の4つは自分で作る必要があります。「たまに動かない」装置連携アプリは、ほぼ確実にこのどれかが欠けています。
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章のとおり1台の機器が複数の機能を持つことがあり、その場合は各機能が同じVID・PID・シリアル番号を共有します。「制御用と保守用でCDCが2本」「HIDのトップレベルコレクションが2つ」という機器では、この3点セットが同じ値になる相手が複数見つかり、どちらを開くかは運任せになります。機能を区別する要素まで含めて鍵にしてください。
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で1秒ごとに列挙し直す実装をよく見かけますが、Windowsには通知の仕組みがあります。
- Windows 8以降:
CM_Register_NotificationにCM_NOTIFY_FILTER_TYPE_DEVICEINTERFACE(到着・削除の検知)とCM_NOTIFY_FILTER_TYPE_DEVICEHANDLE(開いているハンドルの機器が消えたことの検知)を登録します16 - Windows 7以前も対象:
RegisterDeviceNotificationでDBT_DEVTYP_DEVICEINTERFACEを登録し、WM_DEVICECHANGEを処理します17
実装で外せない注意点が2つあります。16
CM_Register_Notificationは「登録時点で既に存在するインターフェース」を通知しません。先に登録し、その後でCM_Get_Device_Interface_Listにより既存分を列挙します。順序を逆にすると、その隙間に挿された機器を取りこぼします- その代わり、重複が出ることを前提に組みます。登録後・列挙前に有効化されたインターフェースは、到着通知と一覧の両方に現れます。両方をそのまま到着処理へ流すと、同じ機器のセッションを二重に生成し、2回目の排他オープンが失敗したり、確立済みの状態を上書きしたりします。デバイスインターフェースパスをキーにした集合を持ち、既知のパスは無視するという重複排除を必ず入れてください(この集合は取り外し時に消します)
- コールバック内でブロックし得る処理をしない。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)では、次の2パターンのどちらかになります。
パターン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節の「抜いたら落ちる」を残したままになります。
正しい形は、セッション終了の入口を2つ持つことです。
- すべての読み書きの完了パスで、削除系のエラー(
ERROR_DEVICE_NOT_CONNECTED/ERROR_DEVICE_REMOVED/ERROR_GEN_FAILURE、.NETなら該当するIOException)を「機器が消えた」として扱い、そこからセッション破棄に入る - PnP通知は補助の信号として扱う。I/Oが動いていない待機中に抜かれたケースを拾うために必要ですが、これ単独では足りません
ここで必ず区別しなければならないのが「自分でキャンセルした」ケースです。応答タイムアウトでの打ち切り、アプリ終了時の後始末、ユーザー操作による中断 ── これらでCancelIoEx・CancellationToken・Disposeを使うと、正常動作なのにERROR_OPERATION_ABORTED・OperationCanceledException・ObjectDisposedExceptionが出ます。これらを無条件に「機器が消えた」と判定すると、機器はつながったままなのにタイムアウトのたびにセッションを捨てて再接続するという、性質の悪いループができあがります。
// 「自分で止めたのか、機器が消えたのか」を状態で判別する。
// operationCts は、この1回の読み書き用のトークンソース。
// 応答タイムアウトとユーザー操作の中断はこちらでキャンセルする
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;
アプリ全体のシャットダウンだけを見るのでは足りません。応答タイムアウトやユーザー操作による中断は、その1回の操作専用のトークンでキャンセルします。このとき _shutdown は立っていないので、_shutdown.IsCancellationRequested だけを条件にした catch は素通りします。素通りした OperationCanceledException は、次の IsDeviceGone にも当てはまらず(自己キャンセルを「機器が消えた」と判定してはいけません)、そのまま下まで抜けてI/Oループごと落とします。タイムアウトのたびに受信スレッドが死ぬ、という壊れ方です。
判定の基準は例外の型やエラーコードだけでは足りません。キャンセルした側のトークンと突き合わせて初めて切り分けられます。逆に言うと、自己キャンセルの経路を持つなら、その事実をI/O層から見える場所に置いておく必要があります。IsDeviceGone の側も、OperationCanceledException と ObjectDisposedException を無条件に「機器が消えた」と判定しないよう書いてください。
どちらの経路から入っても同じ後始末になるよう、セッション破棄は冪等な1つの処理にまとめ、二重呼び出しで壊れないようにしておきます(Interlocked.Exchangeでフラグを立てて先着1回だけ実行する、など)。腐ったハンドルに対するI/Oが投げる例外は、しばしばキャッチしにくい場所から飛んできます ── だからこそ、例外の発生源側で握って状態遷移につなげる設計が要ります。
8.3 タイムアウトと再接続 ── 「1個のタイムアウト」では足りない
USB機器のI/Oは、抜けた・電源が落ちた・ファームがハングした、のいずれでも「返ってこない」という同じ症状になります。タイムアウトは意味ごとに分けて持ちます。
| タイムアウト | 対象 | 目安 |
|---|---|---|
| オープンタイムアウト | 機器を開くまで | 秒オーダー |
| 応答タイムアウト | コマンド発行から応答完了まで | 機器仕様の最悪値 × 安全率 |
| バイト間タイムアウト | フレーム途中で続きが来ない | 通信速度から算出 |
| 再接続バックオフ | 再オープンの待機間隔 | 指数バックオフ + 上限 |
そしてタイムアウトは「遅いときの保険」ではなく「状態遷移を進めるルール」として扱ってください。タイムアウトしたときにどの状態に移るのか、処理中のリクエストをどう失敗させるのか、UIに何を出すのかまで決めて初めて設計になります。UIへの出し方は「外部機器の状態の確認と表示のベストプラクティス」で扱っています ── 「接続中」の一言で済ませないでください。
8.4 電源管理 ── 「抜いてないのに反応が遅い」の正体
USBのセレクティブサスペンドは、アイドル状態の機器を低消費電力状態に入れる仕組みです。復帰に時間がかかるため、「最初の1回だけ応答が遅い」「しばらく放置すると1回目のコマンドを取りこぼす」という症状の犯人になります。
Usbser.sys(仮想COM)では既定で無効で、レジストリのIdleUsbSelectiveSuspendPolicyで有効化・設定します3- WinUSBでは、拡張プロパティOS機能記述子(またはINF)の
DeviceIdleEnabled・DefaultIdleTimeout・UserSetDeviceIdleEnabledなどで制御します5
現場でまず確認するのは、デバイスマネージャーの当該デバイス(およびUSBルートハブ)のプロパティにある「電力の節約のために、コンピューターでこのデバイスの電源をオフにできるようにする」チェックボックスです。装置PCでは、ここを外すだけで直る不具合が実際にあります。ノートPCの省電力設定込みで、検証は本番と同じ電源プランで行ってください。
9. 性能とレイテンシの見積もり
方式選定の段階で、必要な帯域とレイテンシを数字にしておくと後戻りがなくなります。
| 転送タイプ | 使う方式 | 特徴 |
|---|---|---|
| 制御転送 | 全方式(内部で使用) | 設定・小さなコマンド向け。帯域保証なし |
| 割り込み転送 | HID、WinUSB | 定期ポーリング。低レイテンシだが小容量 |
| バルク転送 | WinUSB、マスストレージ | 大容量向け。帯域の保証はなく、空きを使う |
| アイソクロナス転送 | UVC(カメラ)、UAC(音声)、WinUSB(8.1以降) | 帯域保証あり、再送なし |
USB 2.0の割り込みエンドポイントは、フルスピードで最大64バイト/パケット・1〜255msのポーリング間隔、ハイスピードで最大1024バイト・125µs単位の間隔です。11 HIDを選ぶなら、この上限に対して必要帯域が1桁以上余っているかを確認してください。
もうひとつ、Windowsは汎用OSなのでレイテンシに保証がありません。「10ms周期で必ず応答する」といった要件をアプリ層で満たすのは無理があります。周期制御が本質的に必要なら、機器側のマイコンに閉じ込めてPCは指令と監視に徹する設計にしてください。この線引きは「普通のWindowsでソフトリアルタイムをできるだけ実現するための実践ガイド」で詳しく扱っています。
10. 配布と運用 ── ドライバーを配る瞬間にコストが変わる
「ドライバー不要」の方式(標準クラス・HID・WinUSBデバイス)と、「ドライバーを配る」方式のあいだには、開発コストではなく配布・保守コストの断層があります。
- 「自分で
.sysを書かないから署名は不要」は誤りです。PnPのデバイスインストールでは、ドライバーパッケージのカタログファイルに署名がなければDriver Storeにステージされません。18 これはパッケージの中身によらない要件なので、5.2節のようにMicrosoftのwinusb.sysを参照するだけのINFでも、カタログ(.cat)を生成して署名する工程が必要です。「INFを1枚書いて配れば動く」と見積もると、客先で「このデバイスのドライバーは署名されていません」と拒否されてから気づくことになります。カタログの署名は、WHQLリリース署名か、サードパーティのリリース証明書(SPC)による署名です。18 - カーネルモードドライバーの署名。Windows 10 バージョン1607以降、新規のカーネルモードドライバーはDev Portal(Partner Center)経由でMicrosoftに署名してもらわないとロードされません。Partner Centerのアカウント開設にはEV コード署名証明書が必要です。6 上のカタログ署名とは別レイヤーの要件で、カーネルモードのバイナリを含むなら両方満たす必要があります。
- 署名の経路は2つあり、適用範囲が違います。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を使う運用は現に広く行われていますが、対象OSがWindows 10/11デスクトップに限られることを、対応OS表に落とし込んでから採用してください。装置PCがWindows Serverや古いLTSCなら、この経路は最初から選べません。
- 例外条件は当てにしない。セキュアブートが無効、または2015年7月29日より前に発行された証明書で署名されている等の場合はクロス署名ドライバーも動きますが、これを前提にした配布計画は数年で破綻します。6
- 費用と期間を、コードを書き始める前に問い合わせる。カーネルモードドライバーを配るなら、EVコード署名証明書の取得と、Partner Centerのアカウント開設が前提工程になります。6 金額と所要期間は認証局・時期・自社の登記情報の整い方で変わるため、他社事例の数字を当てにせず、(1)EV証明書の年額(複数の認証局から見積もりを取る)、(2)EV証明書に必須の組織実在性審査に要する期間、(3)Partner Centerのアカウント開設に要する期間の3つを、自社の名義で実際に確認してください。ここは技術ではなく手続きの時間なので、開発スケジュールと並行して進めないと、コードが完成しているのに配布できないという止まり方をします。
- インストーラーの設計。ドライバーを含むインストーラーは管理者権限が要り、サイレントインストールの検証も必要になります。配布方式そのものの選び方は「Windowsアプリ配布方式の選び方」に、管理者権限が要る条件の見分け方は「Windowsの管理者特権が必要になるのはいつなのか」にまとめています。
- 装置PCではドライバーとOS更新が競合します。LTSC構成の装置PCにベンダードライバーを入れる場合、OSのビルド固定とドライバーの更新方針をセットで決めてください。「産業用PCにはどのWindowsを入れるべきか」が参考になります。
設計判断としては、「ドライバーを配らずに済む方式があるなら、多少実装が面倒でもそちらを選ぶ」がほぼ常に正解です。HIDが地味に強いのはこの一点に尽きます。
11. よくある失敗と対処
| 症状 | ありがちな原因 | 対処 |
|---|---|---|
| 開発機で動くが客先で動かない | COM番号を設定にハードコードしている | VID/PID・シリアルから実行時に解決する(8.1) |
| 2台目をつないだら誤動作 | 機器がシリアル番号を報告していない | 機器選定を見直す。無理ならポート固定+ラベル運用 |
| 1台の機器なのに掴む相手が毎回変わる | 複合デバイスで、機能を区別せず鍵にしている | MI_xxやHIDのUsage、デバイスインターフェースパスまで含めて鍵にする(8.1) |
| ケーブルを抜くとアプリが落ちる | 腐ったハンドルへのI/O、PnP通知だけに頼った後始末 | I/O完了パスでも削除系エラーをセッション終了として扱う(8.2) |
| 最初の1回だけ応答が遅い/取りこぼす | セレクティブサスペンドからの復帰 | 電源管理の設定を確認・無効化(8.4) |
| HIDで送信しても機器が無反応 | レポートIDのぶんデータがずれている | バッファ長はOutputReportByteLengthちょうど、先頭はレポートID(4.3) |
| HIDでデータが1件も読めない | 対象がOSに排他で開かれるTLC(キーボード等) | 機器のモードを切り替える。列挙だけならアクセス権0で開く(4.2) |
| WinUSBのReadが返ってこない | PIPE_TRANSFER_TIMEOUT未設定 |
パイプポリシーでタイムアウトを設定(5.3) |
| タイムアウトのたびに再接続してしまう | 自己キャンセルを機器切断と誤判定 | キャンセルトークン等の状態と突き合わせて切り分ける(8.2) |
| 抜き差しを繰り返すと徐々に重くなる | WinUsb_Free/CloseHandleの漏れ |
後始末を1箇所にまとめ、失敗経路でも必ず通す(5.3) |
| P/Invoke呼び出しの後で無関係な変数が壊れる | 構造体を途中まで切って宣言している | ネイティブと同じサイズ・並びで全フィールド宣言(4.3) |
| 客先でドライバーが入らない | カタログ未署名(自作.sysの有無は無関係) |
.catの生成と署名を配布計画に含める(10章) |
| アプリを2つ起動すると片方が失敗 | 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を包みます。
- 方式が何であれ、一意識別・抜き差し追従・多層のタイムアウト・電源管理の4点は自分で設計します。ここが「たまに動かない」の発生源です。複合デバイスでは機能単位まで識別を降ろし、切断はPnP通知とI/Oエラーの両方から拾います。
- ドライバーパッケージを配るなら、自作の
.sysがなくてもカタログの署名が必要です。カーネルモードのバイナリを含むなら、さらに1607以降のMicrosoft署名(とEV証明書)が要ります。attestation signingはHLK不要な代わりにWindows 10デスクトップ以降限定なので、対応OS表と突き合わせてから選びます。ドライバーを配らずに済む方式があるなら、それを選ぶのが実務上ほぼ常に正解です。
関連記事
- シリアル通信アプリの落とし穴 - 再接続とログ設計まで
- 外部機器の状態の確認と表示のベストプラクティス - 「接続中」だけで済ませない設計
- C#からWin32 APIを安全に呼ぶ ── P/Invoke実務ガイド
- C#からネイティブDLLを呼ぶ:C++/CLIラッパー vs P/Invoke
- 普通のWindowsでソフトリアルタイムをできるだけ実現するための実践ガイド
- Windowsアプリ配布方式の選び方 - MSI/MSIX/ClickOnce/xcopy/独自更新
- 産業用PCにはどのWindowsを入れるべきか ── Windows IoT Enterprise / LTSC 実践ガイド
- Process Monitor(ProcMon)実践ガイド ── 「設定が読まれない」「ACCESS DENIED」を10分で特定する
関連する相談領域
合同会社小村ソフトでは、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を機能ドライバーとして自動的にロードする」という記述、およびサブクラス02h(ACM)をmdmcpq.infを参照するカスタムINFで扱う経路についても同ページによる。 ↩ ↩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に対応し、それ以前のバージョン向けには更新版INFがWindows Update経由で提供されること)、文字列インデックス0xEEのOS文字列ディスクリプタとベンダーコードの仕組み、拡張互換ID記述子でcompatibleIDにWINUSBを設定すること、拡張プロパティ記述子でDeviceInterfaceGUIDを登録するとアプリが機器を発見・操作できるようになること、セットアップクラスにUSBDevice({88BAE032-5A81-49f0-BC3D-A4FF138216D6})を使いUSBクラスは使わないこと、DeviceIdleEnabled / DefaultIdleTimeout / UserSetDeviceIdleEnabled / SystemWakeEnabled による電源管理設定について。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
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の制約と信頼性の問題を解消するために策定されたものであること、対象OSが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で動作し、全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にはならないことについて。 ↩
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
シリアル通信アプリの落とし穴 - 再接続とログ設計まで
装置連携や計測器制御で避けたいシリアル通信アプリの落とし穴を、フレーミング、タイムアウト、RTS/CTS、DTR/RTS、再接続、ログ設計まで実務目線で整理します。
WMI/CIMをC#・PowerShellから使う ── ハードウェア情報取得・プロセス監視・リモート照会の実務ガイド
PCのシリアル番号取得、ディスク空き監視、プロセス起動検知の定番がWMI/CIMです。Get-CimInstance等のCIMコマンドレットの使い方と旧Get-WmiObjectからの移行、C#のSystem.ManagementとCIM APIの使い分け、実例レシピと落と...
業務システムのコード設計 ── 商品コード・顧客コードの決め方とチェックディジット
商品コード・顧客コードなど業務システムのコード体系を決める実践ガイド。有意コードと無意味連番の判断表、JAN・Luhn等のチェックディジット算式とC#実装、Excelの0落ち対策、桁あふれと移行まで整理します。
業務アプリのDBスキーマをバージョン管理する ── 「客先ごとにDBが違う」を防ぐマイグレーションの実践
客先ごとに分散する業務アプリのDBスキーマをバージョン管理する実践ガイド。PRAGMA user_versionと前進マイグレーションのC#実装、EF Core Migrations・DbUp・自前実装の判断表、2段階リリースまで整理します。
WinForms / WPFアプリのCI/CD実践 ── GitHub Actionsでビルドから署名・配布まで自動化する
WinForms / WPFアプリのCI/CDをGitHub Actionsで組む実務ガイド。windows-latestでのビルド+テストの最小YAML、タグ駆動のバージョン採番、signtoolによる署名の組み込み、MSI/MSIX/ClickOnce/xcopy別のC...
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- USB機器をアプリから使いたいのですが、ドライバーは自分で書く必要がありますか?
- ほとんどの場合、必要ありません。Microsoftの公式ガイドラインも「最も単純な方法から始めて、必要になったときだけ複雑な方法へ進む」と明示しています。機器がUSBの標準クラス(CDC・HID・マスストレージなど)に属していればWindows標準のクラスドライバーが自動で載るのでドライバーは不要です。標準クラスに属さず、かつ1つのアプリからだけアクセスするならWinUSB(winusb.sys)をそのまま機能ドライバーとして使えます。複数のアプリが同時にアクセスする必要が出て初めてUMDFドライバー、それも無理ならKMDFドライバー、という順序になります。自社でドライバーを書くのは最後の手段です。
- 仮想COMポート(USBシリアル)方式のいちばんの弱点は何ですか?
- COMポート番号が機器のIDではないことです。同じ機器でも挿すUSBポートを変えればCOM番号は変わり得ますし、複数台つなげばどれがどれか番号だけでは判別できません。設定ファイルに「COM3」と書く運用は、現場で必ず壊れます。実務では、Win32_PnPEntityなどからVID/PID・シリアル番号とCOM番号の対応を実行時に引き当てて開くのが正解です。加えて、シリアル通信はバイトストリームなのでメッセージ境界が保証されず、受信バッファに蓄積してからフレームを切り出すparserが別途必要になります。
- HID方式はドライバー不要で手軽と聞きますが、何に注意すべきですか?
- 3点あります。1つめは速度で、HIDは割り込み転送を使うため、大量データの連続転送には向きません。2つめはレポート長で、ReadFileに渡すバッファはHidP_GetCapsが返すInputReportByteLengthちょうどである必要があり、先頭1バイトはレポートIDです。ここを間違えると読めない・落ちるという定番の不具合になります。3つめは排他制御で、マウス・キーボード・タッチスクリーン・ペンに相当するトップレベルコレクションは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通知は待機中の切断を拾う補助の信号として組み合わせてください。