How to Work with USB Devices from a Windows App — Choosing Between virtual COM, HID, WinUSB, and Vendor SDKs
· Go Komura · USB, HID, WinUSB, Serial Communication, Device Integration, Windows Development, C#, Device Driver
“This device connects over USB, so we can drive it from the application, right?” — that’s the first question that comes up in equipment-integration consultations. The answer is “it depends how it connects,” and hidden inside that one sentence is a difference of tens of times in development effort. Even for the same “USB-connected device,” whether it appears as a COM port, appears as a HID, or requires a dedicated driver completely changes the code you write, how you distribute it, and the kinds of trouble you’ll hit on site.
The awkward part is that this decision has to be made before you start writing the application. Proceed on the basis of “we installed the SDK and it worked,” and it comes back to bite you later in the form of “we can’t produce a 64-bit build,” “we can’t tell the devices apart once two are connected,” or “the driver won’t install on the customer’s PC.”
This article organises the four ways of working with USB devices from a Windows application — virtual COM port, HID, WinUSB, and vendor-supplied SDK — covering the selection criteria, the implementation points that matter, and the design work all four approaches require in common.
1. The Bottom Line First
- The first thing to check is “where, and as what, does it appear in Device Manager?” Ports (COM & LPT), Human Interface Devices, Universal Serial Bus devices, a custom category — the approach is essentially decided right there (Section 2).
- Devices belonging to a standard class need no driver. Windows ships class drivers for audio, CDC, HID, mass storage, printing, and more, and matching devices work automatically. It is not recommended for vendors to write drivers for standard classes.1
- The official selection order is “simplest first.” (1) If a standard class driver works, don’t write one; (2) if it doesn’t and only a single application accesses the device, WinUSB; (3) if multiple applications access it simultaneously, a UMDF driver; (4) if even that won’t do, a KMDF driver — consider them in that order.2
- virtual COM is strongest for portability and reuse, and weakest for identification. CDC-ACM devices get usbser.sys loaded automatically on Windows 10 and later, and you can write everything with
SerialPortalone. But the COM number is not the device’s identity. An implementation that resolves it at run time from VID/PID and serial number is mandatory (Section 3).3 - HID is the dark horse: two-way communication with zero driver distribution. But collections corresponding to mice, keyboards, touch, and pens are opened exclusively by the OS and are off limits. Speed is also bound by the bandwidth of interrupt transfers (Section 4).4
- WinUSB is for “bulk transfer where speed matters” and “proprietary protocols.” Automatic installation without an INF applies only when the firmware reports the compatible ID
WINUSBvia Microsoft OS descriptors, and only on Windows 8 and later. For existing devices, or when Windows 7 or earlier is in scope, you basically need a custom INF (Section 5).5 - A vendor SDK isn’t something you “choose” — it’s something you “take on.” Bitness, threading model, lifetime, and redistribution terms are all decided by somebody else’s circumstances, so start by inventorying the SDK’s constraints as premises for your application design (Section 6).
- Whatever the approach, you design the four items yourself: unique device identification, hot-plug handling, timeouts, and power management. An application that skips these will inevitably become one that “sometimes doesn’t work” (Section 8).
- If you’re writing your own kernel-mode driver, Microsoft signing has been mandatory since Windows 10 1607. Budget for it as a distribution cost up front, including the fact that opening a Partner Center account requires an EV certificate (Section 10).6
2. The Fundamental Premise — For Windows, a USB Device Is Entirely About “Which Driver Loaded”
Whatever is on the other end of the USB cable, all the application sees is the interface exposed by the driver that loaded on top of that device. Without pinning this down, the discussion never converges.
When you plug a device in, Windows reads the descriptors the device declares and decides which driver to load from the class code and the VID/PID. If it matches a standard class, the class driver shipped with Windows loads automatically.1
| USB-IF class code | Windows in-box driver | How it appears to the application |
|---|---|---|
| Audio (01h) | Usbaudio.sys | Audio device |
| CDC (02h, subclass 02h) | Usbser.sys | COM port |
| HID (03h) | Hidclass.sys / Hidusb.sys | HID collection |
| Image (06h) | Usbscan.sys | WIA device |
| Printer (07h) | Usbprint.sys | Printer |
| Mass Storage (08h) | Usbstor.sys | Drive |
| Video (0Eh) | Usbvideo.sys | Camera (UVC) |
| Vendor Specific (FFh) | (none) | WinUSB recommended |
The last row matters. Vendor-specific devices often declare FFh (Vendor Specific), and in that case Microsoft’s recommendation is WinUSB.1
The other thing to know about is the composite device. A device that has several functions on the end of a single USB cable gets expanded by Usbccgp.sys into a separate device per function. That’s why “it’s one piece of equipment yet three entries appear in Device Manager,” and it’s not unusual to find a device configured as, say, “control over CDC (COM port), status notification over HID.” The approach is decided not per device, but per function (interface).
The First Thing to Do: Look at the Real Device in Device Manager
Before any discussion, plug the actual device in and check the following. It takes five minutes and it changes every subsequent judgement.
- Which Device Manager category it appears in, and under what name
- Properties → Details tab → Hardware Ids (
USB\VID_xxxx&PID_yyyy&...) - Same → Compatible Ids (whether
USB\Class_02&SubClass_02orUSB\MS_COMP_WINUSBis visible) - Same → the tail of the Device instance path (whether it contains a serial number, or a generated value containing
&) - Driver tab → Driver Provider and driver files (Microsoft’s, or the vendor’s)
If USB\MS_COMP_WINUSB appears under item 3, the device was designed as a WinUSB device.5 Item 4 is the material used in Section 8.1 to judge whether unique device identification is possible.
3. Approach A: virtual COM Port — the Easiest, and the Easiest to Get Wrong
3.1 What’s Actually Happening
Devices that declare the USB CDC (Communications and CDC Control) class with subclass 02h (ACM) get the in-box Usbser.sys loaded automatically, with no INF to distribute. Simply setting class 02 and subclass 02 in the device descriptor causes the standard Usbser.inf to match via the compatible ID USB\Class_02&SubClass_02.3
However, this automatic loading is Windows 10 and later behaviour.1 If Windows 8.1 or earlier is also in scope, descriptors alone aren’t enough: you have to prepare and distribute an INF that references the in-box driver (a custom INF referencing mdmcpq.inf, for instance). “It worked with no effort on Windows 10, but it shows up as an unknown device on the customer’s Windows 7 machine” comes from exactly this.
The other route is the VCP drivers supplied by USB-to-serial bridge chip vendors such as FTDI, Silicon Labs, and Prolific. These do require driver installation, but because the chip vendors also publish signed drivers through Windows Update, in practice it’s close to “plug it in and it installs.”
Either way, what the application sees is just a COM port. That’s the biggest advantage: assets, know-how, and terminal software from the RS-232 era carry over unchanged.
3.2 The Implementation Is Just SerialPort — but You Inherit the Pitfalls Too
In .NET it’s System.IO.Ports.SerialPort (on .NET 5 and later you need a reference to the System.IO.Ports package). The implementation caveats aren’t USB-specific but general serial communication ones, and they’re organised — framing, timeouts, reconnection, and logging design included — in “Serial Communication App Pitfalls.” In particular, the point that Read(buffer, 0, 16) will not necessarily read exactly 16 bytes is no different over USB. Receive it as a byte stream, accumulate it in a buffer, and cut frames out of it with a parser.
3.3 Don’t Write the COM Number into a Configuration File
This is the number one cause of virtual COM setups breaking on site.
- A COM number is nothing more than a number Windows assigned on that PC; it is not the device’s identifier
- Changing which USB port you plug into can change the number
- Connect two identical devices and the numbers alone won’t tell you which is which
- “COM3 is in use” pushes the numbering on, and ending up at COM13 or COM27 is entirely normal
The correct implementation is to look up the COM number at run time from the VID/PID (and, where available, the serial number). You can obtain it from PnP enumeration.
# List every COM port along with its hardware ID (looking without filtering first is the safe move)
Get-CimInstance Win32_PnPEntity |
Where-Object { $_.PNPClass -eq 'Ports' } |
Select-Object Name, PNPDeviceID |
Format-List
# Example output:
# Name : USB Serial Device (COM5) <- CDC-ACM (usbser.sys)
# PNPDeviceID : USB\VID_2341&PID_0043\85436323631351D0E1C1
# ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^
# VID/PID Device instance ID
#
# Name : USB Serial Port (COM7) <- FTDI's VCP driver
# PNPDeviceID : FTDIBUS\VID_0403+PID_6001+A5XK3RJTA\0000
# ^^^^^^^ the enumerator is FTDIBUS, not USB
Filtering here with something like PNPDeviceID -like 'USB\*' will cause an accident. As the example above shows, the COM port created by FTDI’s VCP driver begins with FTDIBUS\ and matches not a single entry under a USB\ filter. Other chip vendors such as Silicon Labs may have their own enumerators too.
A safe implementation is one of the following.
- Fetch all
Ports-class entries, then pick out theVID_xxxx/PID_yyyy(orVID_xxxx+PID_yyyy) contained inPNPDeviceIDwith a regular expression — robust, because it doesn’t depend on the enumerator name - Put the enumerator names on an explicit allow list (
USB\,FTDIBUS\, and so on) — sufficient when the target device is fixed
Either way, plug in your actual target device, run this command, and visually confirm what PNPDeviceID it appears with, before you write the filter. The enumerator name is determined by the combination of device and driver, so you can’t decide it on paper.
From C# you can issue the same query with ManagementObjectSearcher from System.Management, or build an AQS selector with Windows.Devices.SerialCommunication.SerialDevice.GetDeviceSelectorFromUsbVidPid(vid, pid) and call DeviceInformation.FindAllAsync (note that GetDeviceSelector does not take a VID/PID — it takes no arguments or a port name, so be careful not to mix them up). The former has fewer dependencies and is often easier to handle in a desktop application.
Slicing the number out of the trailing (COM5) in Name looks inelegant, but in practice it’s the approach that most reliably works. To do it strictly, read the PortName value under the device’s registry key.
3.4 Handles Rot on Hot-Plug
With USB-to-serial, pulling the cable makes the port itself disappear. Unplugging while SerialPort is open, and having an exception thrown from the internal receive thread that takes the whole application down with it, is a classic accident. The safe pattern is to establish the ordering when you receive the PnP removal notification, close the port first (Section 8.2).
Reconnection is not just “call Open() again.” Design it as session re-creation, covering invalidating the old session, definitively failing in-flight requests, stopping the reader/writer, backing off before reopening, and re-running the device initialisation sequence.
3.5 Suitability
| Suited to | Not suited to |
|---|---|
| Existing serial protocol assets | High throughput via a USB-UART bridge chip |
| Text command/response equipment and instruments | Tight low-latency control |
| Wanting to triage on site with terminal software | Many identical devices connected at once (identification gets expensive) |
| Developers with no driver knowledge | New development where you get to define the protocol yourself |
On throughput, please don’t lump everything together as “virtual COM, therefore slow.” In configurations that go through a USB-UART bridge chip such as an FTDI part, the baud rate of the UART on the other side is the ceiling (921.6 kbps gives roughly 92 KB/s). By contrast, on a native USB device where a microcontroller implements CDC-ACM directly, the data interface uses USB bulk transfers, so there’s no UART constraint, and a high-speed connection can reach several MB/s. In that territory, however, the overhead of the Usbser.sys and SerialPort layers starts to bite, so if you need more than a few hundred KB/s, measure on real hardware before you decide on an approach. Deciding “we need speed, therefore WinUSB” before measuring saddles you with unnecessary driver distribution.
4. Approach B: HID — Two-Way Communication with Zero Driver Distribution
4.1 HID Isn’t Only for Input Devices
HID makes people think of mice and keyboards, but as a specification it’s a general-purpose protocol for exchanging arbitrary byte sequences (reports) in both directions. Barcode readers, card readers, electronic locks, measurement units, UPSs, custom I/O boxes — devices that “don’t want to distribute a driver but do want to exchange proprietary data” declare themselves as HID because Hidclass.sys and Hidusb.sys ship with Windows and it works without distributing any INF or driver at all.1
The unit Windows works in for HID is the top-level collection (TLC). A single physical device can have several TLCs, in which case each appears as a separate device interface.4
4.2 The HIDs You Can Touch and the Ones You Can’t
This is the most important constraint. Windows opens some TLCs in exclusive mode. This is to stop other applications from intercepting the overall input state, and the Raw Input Manager (RIM) is what opens those devices exclusively.4
| Usage Page / Usage | Purpose | Access mode |
|---|---|---|
| 0x0001 / 0x0001-0x0002 | Mouse | Exclusive |
| 0x0001 / 0x0004-0x0005 | Game controller | Shared |
| 0x0001 / 0x0006-0x0007 | Keyboard, keypad | Exclusive |
| 0x000C / 0x0001 | Consumer control | Shared |
| 0x000D / 0x0001-0x0002 | Pen | Exclusive |
| 0x000D / 0x0004-0x0005 | Touchscreen, precision touchpad | Exclusive |
| 0x0020 / various | Sensor | Shared |
| 0x008C / 0x0002 | Barcode scanner | Shared (obtaining decoded data is exclusive) |
In other words, you cannot pull data directly out of a keyboard-emulation barcode reader with the HID APIs. It’s been opened exclusively as a keyboard. If you want to receive data from that sort of device “as data rather than keystrokes,” the proper approach is to switch the device itself into a HID vendor-defined TLC mode or a CDC mode via its own configuration.
Note that even for devices opened exclusively, if you open a handle without requesting read/write access you can still obtain attributes and strings via the HidD_GetXxx family.4 That’s enough for the “I only want to check whether the device is connected” use case.
4.3 Implementation — Get the Report Length Wrong and It Will Always Fail
The steps for a user-mode application are fixed. Find the HID collection with SetupDi*, open it with CreateFile, get information with HidD_*, read and write reports with ReadFile/WriteFile, and interpret reports with HidP_* — that’s all.7
// Enumerating HID devices and obtaining report lengths (P/Invoke declarations excerpted)
[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);
// Always free the buffer returned by GetPreparsedData. Forget it and it leaks on the native side
[DllImport("hid.dll")]
[return: MarshalAs(UnmanagedType.U1)]
static extern bool HidD_FreePreparsedData(IntPtr preparsedData);
[StructLayout(LayoutKind.Sequential)]
struct HIDD_ATTRIBUTES
{
public int Size; // Always set this to sizeof(HIDD_ATTRIBUTES)
public ushort VendorID;
public ushort ProductID;
public ushort VersionNumber;
}
// HIDP_CAPS must not be declared with "only the fields you use".
// HidP_GetCaps writes the full length of the native definition (USHORT x 32 = 64 bytes),
// so passing a truncated structure corrupts the stack beyond it
[StructLayout(LayoutKind.Sequential)]
struct HIDP_CAPS
{
public ushort Usage;
public ushort UsagePage;
public ushort InputReportByteLength; // Buffer length to pass to ReadFile
public ushort OutputReportByteLength; // Buffer length to pass to WriteFile
public ushort FeatureReportByteLength;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 17)]
public ushort[] Reserved; // Reserved area. Cannot be omitted
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;
}
The common accident when declaring structures is “write only the fields you use and leave the rest as a comment.” HidP_GetCaps writes the full length as defined natively (USHORT × 32 = 64 bytes), so if you pass a structure containing only the first five fields (10 bytes), the marshaller’s allocated region is overrun by 54 bytes. If you’re lucky you get an AccessViolationException; if you’re not, other variables are quietly corrupted. Declare P/Invoke structures with the same size and the same layout as the native definition, including the fields you don’t use.8
The buffer returned by HidD_GetPreparsedData is allocated on the native side, so always free it with HidD_FreePreparsedData when you’re done. In an application that re-enumerates every HID device on each hot-plug event, forgetting this quietly eats memory forever. Wrap it in try/finally, or wrap it in a SafeHandle-derived class so the leak is structurally impossible.
const int HIDP_STATUS_SUCCESS = 0x00110000;
// Always check the return value. If the device was unplugged right after enumeration, FALSE comes back
if (!HidD_GetPreparsedData(handle, out IntPtr preparsed))
{
return null; // Skip this device. preparsed is invalid, so don't touch it
}
try
{
if (HidP_GetCaps(preparsed, out HIDP_CAPS caps) != HIDP_STATUS_SUCCESS)
{
return null;
}
// Only now are caps.InputReportByteLength and friends valid
}
finally
{
HidD_FreePreparsedData(preparsed); // Free only when acquisition succeeded
}
Don’t ignore the return value of HidD_GetPreparsedData. If the device is unplugged between enumerating it and opening the handle, FALSE comes back and preparsed is not a valid pointer. You’d then pass it to HidP_GetCaps and HidD_FreePreparsedData and go on to decide report lengths from a caps whose contents are unknown. The more hot-plugging a site does, the more likely you are to hit this race, so enter the try only when acquisition succeeded, and also check the return value of HidP_GetCaps (whether it is HIDP_STATUS_SUCCESS).
The most frequent implementation failure is handling of report lengths.
- The buffer passed to
ReadFilemust be exactlyInputReportByteLength. Too short and it fails; too long and it isn’t handled correctly either - The first byte of the buffer is the report ID. If the device’s design doesn’t use report IDs, it contains 0. The actual data starts at byte 1
- Likewise, the
WriteFilebuffer is exactlyOutputReportByteLength, with the report ID at the front
Nine times out of ten, “I sent it but the device doesn’t respond” is either data shifted by the one byte of the report ID, or a buffer length that doesn’t match. When the device’s documentation says “the command is 8 bytes” and OutputReportByteLength is 9, that means 9 bytes including the report ID.
Besides WriteFile, there’s also HidD_SetOutputReport as a path for sending output reports, and the official guidance on when to use which is fixed.9
| Purpose | What to use |
|---|---|
| Sending output reports continuously | WriteFile (this is the default) |
| Setting the collection’s current state | HidD_SetOutputReport |
| Sending a Feature report | HidD_SetFeature |
What needs care is that the official documentation warns that “some devices do not support HidD_SetOutputReport and may stop responding if it is used.”9 In other words, switching to HidD_SetOutputReport “because WriteFile doesn’t work” is not an unconditionally safe alternative. Choose after checking which one the device’s specification and the vendor’s samples use, and if you do switch, verify on real hardware that responses don’t stop.
If enumerating devices is all you’re after, open them with CreateFile’s dwDesiredAccess set to 0. Exclusively opened devices can still be enumerated, and you can get the VID/PID with HidD_GetAttributes and the serial number with HidD_GetSerialNumberString.
If you’d rather not write raw P/Invoke from C#, using a library such as HidSharp is an option. However, you’ll still need to understand report lengths and report IDs, so getting through it once in the form above makes later investigations much faster. In a packaged application you can also use Windows.Devices.HumanInterfaceDevice.HidDevice, but that requires declaring DeviceCapability in the manifest.10
4.4 The Speed Ceiling
HID uses interrupt transfers. On USB 2.0 full-speed (12 Mbps) devices, the maximum packet size of an interrupt endpoint is 64 bytes, and the polling interval is whatever value in the range 1–255 ms the firmware declares. On high-speed (480 Mbps), it’s up to 1024 bytes with the interval in units of 125 µs.11
So a full-speed HID device polling at 1 ms with 64 bytes gives about 64 KB/s even in theory. Choose HID for uses that don’t fit inside that — images, waveforms, bulk log retrieval — and there’s no going back later. Conversely, for command/response exchanges of a few dozen bytes or status notifications, it’s more than enough bandwidth.
5. Approach C: WinUSB — Driving a Proprietary Protocol Directly
5.1 Where It Fits
Winusb.sys is a general-purpose USB driver supplied by Microsoft. Load it as the function driver and you can read from and write to endpoints directly through the functions exposed by the user-mode Winusb.dll. It’s the mechanism for handling a proprietary protocol without writing a driver.2
The conditions Microsoft lists for adopting WinUSB are clear.2
- A single application accesses the device
- The device has bulk, interrupt, or isochronous endpoints (isochronous requires Windows 8.1 or later)
- Windows XP SP2 or later is the target
Conversely, WinUSB cannot be used for devices that need simultaneous access from multiple applications. That’s UMDF driver territory.
| Capability | WinUSB | UMDF | KMDF |
|---|---|---|---|
| Simultaneous access from multiple applications | No | Yes | Yes |
| Bulk, interrupt, and control transfers | Yes | Yes | Yes |
| Isochronous transfers | Yes (8.1 and later) | No | Yes |
| Stacking filter drivers | No | No | Yes |
| Selective suspend | Yes | Yes | Yes |
5.2 The Conditions Under Which “No INF Required” Holds
This is the most misunderstood part of any WinUSB explanation. Winusb.sys loads automatically without an INF only when the device’s firmware carries Microsoft OS descriptors and reports WINUSB as its compatible ID.5
What’s more, this automatic matching only works on Windows 8 and later. The in-box Winusb.inf gained support for the compatible ID USB\MS_COMP_WINUSB in Windows 8; before that, a custom INF specifying the hardware ID was mandatory.5 As noted in Section 5.1, WinUSB itself runs on Windows XP SP2 and later, but “works from XP” and “installs without an INF” are different claims. If Windows 7 or earlier is in scope, plan on distributing an INF even if you have implemented OS descriptors. (On Windows 7 and earlier it can still match if an updated Winusb.inf arrived via Windows Update, but you can’t build a distribution plan on that.)
Concretely, the device side needs the following implementation. Note that there are two lineages: version 1.0 (WCID) and 2.0.
- Microsoft OS 1.0 descriptors (all versions)
- Carry an OS string descriptor at string index
0xEEthat returns a vendor code - Set
compatibleIDtoWINUSBin the extended compat ID OS feature descriptor (per function, for a composite device)
- Carry an OS string descriptor at string index
- Microsoft OS 2.0 descriptors (Windows 8.1 and later)
- Announce the location of the descriptor set through the platform capability descriptor in the BOS descriptor. The
0xEEstring descriptor is not used - Within that descriptor set, place a compatible ID feature descriptor reporting
WINUSB. Merely announcing through BOS that “a set exists” will not getWinusb.sysselected. What decides the binding is, as in 1.0, the compatible ID
It was defined to resolve the constraints and reliability problems of 1.0, so for new firmware designs this is the first choice12
- Announce the location of the descriptor set through the platform capability descriptor in the BOS descriptor. The
Registering a device interface GUID plays a different role from the above. That is the GUID by which an application finds the device, whereas what decides whether Winusb.sys can bind is the compatible ID. The GUID is on the discovery side. Even so, without a registered custom GUID it’s hard to build device discovery on the application side, so in practice they’re implemented together.
What needs care here is that the registry property name exists in both singular and plural forms.13
| Name | Type | Where it’s used |
|---|---|---|
DeviceInterfaceGUID |
String (REG_SZ) | The Microsoft OS 1.0 extended properties descriptor specifies this name with a wPropertyNameLength of 40 bytes5 |
DeviceInterfaceGUIDs |
Multi-string (REG_MULTI_SZ) | The standard form for custom INFs. Microsoft’s own samples also use the plural: HKR,,DeviceInterfaceGUIDs,0x10000,"{...}"13 |
When describing Winusb.sys’s behaviour, the official documentation uses the plural: “it reads the DeviceInterfaceGUIDs registry key and registers the device interface with the GUID specified there.”13 The procedure for adding the entry to the registry by hand likewise offers both: place either the string DeviceInterfaceGUID or the multi-string DeviceInterfaceGUIDs under Device Parameters.13
If you’re using the OS 2.0 registry property feature descriptor, check the property name and data type against the specification (in practice the plural plus REG_MULTI_SZ is the common choice). Carry the 1.0 singular form over into the 2.0 route unchanged and you may end up writing to a location Windows doesn’t consult, producing the state “the binding works but the application can’t find it.”
The reliable way to verify this is to plug the device in and then look at the registry. Check HKLM\SYSTEM\CurrentControlSet\Enum\USB\<hardware ID>\<instance ID>\Device Parameters for the intended name, type, and value, and you’ll know the facts regardless of how the descriptors were interpreted.
Furthermore, when writing an INF, use the setup class USBDevice ({88BAE032-5A81-49f0-BC3D-A4FF138216D6}). The USB class is documented as being exclusively for host controllers, hubs, and composite devices, and using it for a custom device is explicitly stated to invite reliability and performance problems.5
If you can’t touch the firmware of an existing device, you’ll be preparing and distributing your own custom INF that specifies the hardware ID. At that point you have a configuration where “the installer installs a driver,” and the distribution discussion (Section 10) begins. Even an INF that doesn’t contain a single byte of your own .sys and merely references Microsoft’s winusb.sys will not install on real-world Windows without a signed catalogue. It isn’t a case of “just write one INF and you’re done,” so read Section 10 before you estimate the effort.
Swapping the driver over to WinUSB with a tool such as Zadig during development is a legitimate verification technique, but it’s an operation that removes the vendor’s driver. Don’t make it your production distribution mechanism — it stops other applications from using the same device.
5.3 Implementation Points
// WinUSB initialisation (error handling omitted)
HANDLE h = CreateFile(devicePath,
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
NULL, OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, // asynchronous is essentially mandatory
NULL);
WINUSB_INTERFACE_HANDLE usb;
WinUsb_Initialize(h, &usb);
// Always set a timeout on reads (the default is to wait indefinitely)
ULONG timeoutMs = 1000;
WinUsb_SetPipePolicy(usb, bulkInPipeId, PIPE_TRANSFER_TIMEOUT,
sizeof(timeoutMs), &timeoutMs);
// When asynchronous, pass NULL for LengthTransferred and get the transfer length after completion
WinUsb_ReadPipe(usb, bulkInPipeId, buffer, bufferLength, NULL, &overlapped);
// -> return value FALSE / GetLastError() == ERROR_IO_PENDING means it's in progress
ULONG transferred = 0;
WinUsb_GetOverlappedResult(usb, &overlapped, &transferred, TRUE); // only now is the value valid
// --- Cleanup. This runs on every hot-plug, so anything leaked accumulates on every reconnect ---
CancelIoEx(h, NULL); // stop in-flight I/O,
WinUsb_GetOverlappedResult(usb, &overlapped, &transferred, TRUE); // reap completion first,
WinUsb_Free(usb); // free the interface handle,
CloseHandle(h); // then close the file handle
There are four points that matter in the field.
- Open with
FILE_FLAG_OVERLAPPEDand run asynchronously. With synchronous I/O, the moment the device goes silent the whole thread freezes - Always set
PIPE_TRANSFER_TIMEOUT. By default, reads never return - In asynchronous mode, don’t pass a pointer for
LengthTransferred. The official documentation states explicitly that “ifOverlappedis non-NULL,LengthTransferredmay be NULL,” and that if you do pass a non-NULL value, the value at the momentWinUsb_ReadPipereturns is meaningless until the operation completes. Get the transfer length withWinUsb_GetOverlappedResult.14 Passing the address of a local variable isn’t just meaningless — if that variable’s design lets it go out of scope, it becomes a dangling pointer - Always pair
WinUsb_FreeandCloseHandle. Every successfulWinUsb_Initializeallocates an interface handle. If, as in Section 8.2, you design the session to be re-created on every hot-plug event, anything you forget to free piles up on every reconnect. Make sure cleanup is reached not only on the success path but also on paths that fail partway through initialisation (for example,WinUsb_Initializesucceeded but pipe configuration failed) by keeping cleanup in a single place. The order is “stop in-flight I/O withCancelIoEx→ reap completion withWinUsb_GetOverlappedResult→WinUsb_Free→CloseHandle.” Close the handle before reaping completion and you free a buffer the kernel is still touching - Prevent multiple instances of the application. WinUSB doesn’t support simultaneous access from multiple applications, so put single-instance enforcement (a named mutex, for example) into the specification
To use it from C#, libusb’s Windows backend is implemented on top of WinUSB, so a wrapper such as LibUsbDotNet is an option. In a packaged application you can also use Windows.Devices.Usb.UsbDevice, but it carries an explicit restriction: it cannot access the Audio, HID, Image, Printer, Mass Storage, Smart Card, Audio/Video, or Wireless Controller device classes.15
6. Approach D: Vendor SDKs and Dedicated Drivers — Not Chosen, but Taken On
Industrial cameras, measurement instruments, POS peripherals, fingerprint and vein authentication, dedicated I/O boards — for these, the vendor supplies driver and SDK as a set, and in practice no other usage is possible. Approach D is less a choice than a precondition that was settled the moment the device was chosen.
Precisely for that reason, inventorying the SDK’s constraints at the device selection stage is the design work. Here are the items to check.
| Item to check | What happens if you miss it |
|---|---|
| Whether both 32-bit and 64-bit are supported | A 64-bit application can’t call a 32-bit-only DLL and you need process separation |
| API form (C DLL / COM / .NET) | It changes the calling convention and marshalling design. COM brings threading model constraints |
| Threading constraints (STA required, which thread callbacks arrive on) | It blocks the UI thread, or deadlocks |
| Redistributables and distribution terms | You can’t bundle it in the installer and manual installation is needed at the customer site |
| Signing status of the bundled driver | It can’t be installed on newer Windows 11 builds or on equipment PCs |
| Supported OSes and support lifetime | An OS upgrade means rebuilding the whole application |
| Whether multiple simultaneous connections are supported, and how they’re identified | It falls apart the moment you connect a second unit |
| Whether demo application source is available | Investigation costs for undocumented behaviour go through the roof |
On the implementation side, the greatest defence is not scattering the SDK throughout the application. Confine SDK calls behind a single thin abstraction layer (an interface) and write the application proper against that abstraction. Do this and the impact of a device model change, an SDK major version upgrade, or switching vendors is confined to one place, and you can also write unit tests without the hardware.
If you need to use a 32-bit-only SDK from a 64-bit application, the standard move is to push it out into a separate process and connect over inter-process communication. Via COM, “A Worked Example of a COM Bridge for Calling a 64-bit DLL from a 32-bit App” is the same idea in the opposite direction, and how to call native DLLs in the first place is organised in “Calling Native DLLs from C#: C++/CLI Wrapper vs P/Invoke.” For managing child process lifetimes, see “A Checklist for Safely Handling Child Processes in Windows Apps.”
7. Decision Table for the Four Approaches
| Aspect | virtual COM | HID | WinUSB | Vendor SDK |
|---|---|---|---|---|
| Driver distribution | Not needed (CDC) / standard (VCP) | Not needed | Conditionally not needed; often an INF is required | Required |
| Implementation difficulty | Low | Medium | Medium to high | Depends on the SDK (wildly variable) |
| Throughput | Low to medium (device-dependent; measure it) | Low | High | High |
| Latency | Medium | Medium (depends on polling interval) | Low | Low |
| Simultaneous use by multiple applications | No (the port is exclusive) | Yes (for shared TLCs) | No | Depends on the SDK |
| Unique device identification | Needs implementing (COM number won’t do) | Possible via VID/PID/serial | Possible via device path | Depends on the SDK |
| Ease of on-site triage | High (terminal software) | Medium | Low | Low |
| Dependence on device firmware | Small | Small | Large (OS descriptors) | Total |
| Suited to | Command/response equipment and instruments | Status notification, small commands | Large data volumes, proprietary protocols | Cameras, instruments, specialised equipment |
The order of decisions goes like this.
- Does the device already appear as a COM port / HID / standard class? → If so, use that
- It doesn’t, but you can change the firmware → decide between HID (small data) and WinUSB (large data) based on bandwidth
- You can’t change the firmware and a vendor SDK exists → use the SDK, but do the Section 6 checks first
- None of the above applies and simultaneous access from multiple applications is required → consider developing a UMDF driver2
8. Four Things You Design Yourself, Whatever the Approach
Once the approach is settled, you still have to build the following four things yourself. Equipment-integration applications that “sometimes don’t work” are almost certainly missing one of them.
8.1 Unique Device Identification — Grab It by ID, Not by Number
What may go into configuration is VID/PID + serial number, or the device interface path. COM numbers and the ordering in Device Manager are not identifiers.
Whether a serial number exists is visible from the last element of the device instance path.
USB\VID_2341&PID_0043\85436323631351D0E1C1 <- the device reports a serial number (invariant when moved)
USB\VID_0403&PID_6001\5&1a2b3c4d&0&2 <- it doesn't (a value Windows generated from the connection location)
For composite devices, VID/PID + serial number is not enough. As Section 2 explained, one device can have several functions, and in that case every function shares the same VID, PID, and serial number. On a device with “two CDCs, one for control and one for maintenance” or “two HID top-level collections,” you’ll find multiple candidates with identical values for that trio, and which one you open comes down to luck. Include something that distinguishes the function in your key.
USB\VID_1234&PID_5678&MI_00\7&2a3b4c5d&0&0000 <- function 0 (for example, the control CDC)
USB\VID_1234&PID_5678&MI_02\7&2a3b4c5d&0&0002 <- function 2 (for example, the maintenance CDC)
same VID/PID, same parent device ^ only MI_xx (the USB interface number) differs
In practice, build the key in one of the following ways.
- Include the USB interface number (
MI_xx) — the standard way to distinguish CDC/WinUSB functions on a composite device - For HID, use Usage Page + Usage as well — this is how you tell multiple top-level collections on the same device apart (
UsagePage/UsagefromHidP_GetCaps) - Store the device interface path itself — the string returned by enumeration is unique per function, so keying on it is the most reliable option
If you’re in a position to define the device specification yourself, a firmware specification that assigns a distinct interface number to each function and always reports a serial number makes the identification logic on the software side dramatically simpler.
The latter contains &, and its value changes when you plug into a different port.
However, do not apply this test to the child nodes of a composite device. On a composite device, the tail of the instance ID of the child PDOs enumerated as CDC functions or HID collections is a value generated by Usbccgp.sys, and it contains & even when the physical device does report a serial number. Looking only at the child’s path and concluding “no serial number” is wrong. The serial number lives on the parent USB device node, so walk up to the parent with CM_Get_Parent (or DEVPKEY_Device_Parent) and look at that instance ID.
USB\VID_1234&PID_5678\SN0001234 <- parent (the physical device). The serial number is here
└ USB\VID_1234&PID_5678&MI_00\7&2a3b... <- child. Usbccgp-generated, so it contains & (don't use it for the test)
If you end up choosing a device without a serial number for an operation that connects several identical units, the only remaining means of identification is “which USB port it’s plugged into.” In that case, design all the way down to fixing the hub ports and putting labels on as an operational procedure — this is a part technology can’t solve, so you need to notice it at the device selection stage.
8.2 Following Hot-Plug — Don’t Poll
You often see implementations that re-enumerate every second on a Timer, but Windows has a notification mechanism.
- Windows 8 and later: register
CM_NOTIFY_FILTER_TYPE_DEVICEINTERFACE(detecting arrival and removal) andCM_NOTIFY_FILTER_TYPE_DEVICEHANDLE(detecting that a device you hold an open handle to has gone) withCM_Register_Notification16 - Windows 7 and earlier also in scope: register
DBT_DEVTYP_DEVICEINTERFACEwithRegisterDeviceNotificationand handleWM_DEVICECHANGE17
There are two caveats you can’t skip in the implementation.16
CM_Register_Notificationdoes not notify you about “interfaces that already exist at registration time.” Register first, then enumerate the existing ones withCM_Get_Device_Interface_List. Do it in the other order and you’ll miss devices plugged in during the gap- In exchange, build on the assumption that duplicates will appear. An interface enabled after registration but before enumeration appears in both the arrival notification and the list. Feed both straight into arrival handling and you’ll create two sessions for the same device, so the second exclusive open fails or an already-established state is overwritten. Always deduplicate by holding a set keyed on the device interface path and ignoring paths you already know (clear entries from this set on removal)
- Don’t do anything that can block inside the callback. Push work involving I/O onto another thread. Wait here and the whole processing of PnP events backs up
In a packaged application, or any configuration where you can use the WinRT APIs, DeviceWatcher expresses the same thing concisely.
// Watch for serial devices with a specific 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();
And the important thing is not to make PnP notification the “only entry point.” When the cable is pulled, in-flight I/O can complete with a removal or cancellation error before, or at the same time as, the notification. The ordering “close the handle the instant the notification arrives” only holds when the notification arrives first, so an implementation that relies on it alone still carries the “unplug it and it crashes” behaviour from Section 3.4.
The correct shape is to have two entry points into ending the session.
- On every read and write completion path, treat removal-class errors (
ERROR_DEVICE_NOT_CONNECTED/ERROR_DEVICE_REMOVED/ERROR_GEN_FAILURE, or the correspondingIOExceptionin .NET) as “the device is gone” and enter session teardown from there - Treat PnP notification as a supplementary signal. It’s needed to catch the case where the device is unplugged while idle with no I/O running, but on its own it isn’t enough
The case you absolutely must distinguish here is “I cancelled it myself.” Aborting on a response timeout, cleaning up at application shutdown, an interruption from a user action — use CancelIoEx, a CancellationToken, or Dispose for these and you get ERROR_OPERATION_ABORTED, OperationCanceledException, or ObjectDisposedException despite everything working correctly. Judge those unconditionally as “the device is gone” and you build a particularly nasty loop in which the device stays connected but you throw away the session and reconnect on every timeout.
// Distinguish "I stopped it myself" from "the device is gone" by state
catch (OperationCanceledException) when (_shutdown.IsCancellationRequested)
{
// Self-cancellation. The session isn't broken, so don't tear it down
}
catch (Exception ex) when (IsDeviceGone(ex))
{
TearDownSession(); // Idempotent. Doesn't run twice when also called from PnP notification
}
The exception type or error code alone isn’t enough to decide. You can only triage it by cross-referencing against the state of “am I currently requesting cancellation?” (a cancellation token, a shutdown flag). Put another way, if you have a self-cancellation path, you need to keep that fact somewhere visible from the I/O layer.
So that entering from either path leads to the same cleanup, consolidate session teardown into a single idempotent operation and make sure a double call can’t break it (for example, set a flag with Interlocked.Exchange and let only the first caller through). Exceptions thrown by I/O against a rotten handle often come flying from places that are hard to catch — which is exactly why you need a design that catches them at the source and turns them into a state transition.
8.3 Timeouts and Reconnection — “One Timeout” Isn’t Enough
I/O on a USB device produces the same symptom — “it doesn’t come back” — whether it was unplugged, lost power, or the firmware hung. Keep separate timeouts for separate meanings.
| Timeout | Applies to | Rough value |
|---|---|---|
| Open timeout | Until the device is open | Order of seconds |
| Response timeout | From issuing a command until the response completes | The device spec’s worst case × a safety factor |
| Inter-byte timeout | The rest of a frame doesn’t arrive | Calculated from the line speed |
| Reconnect backoff | The wait between reopen attempts | Exponential backoff + a cap |
And treat a timeout not as “insurance for when things are slow” but as “a rule that advances a state transition.” It only becomes a design once you’ve decided which state you move to on timeout, how in-flight requests are failed, and what you put on the UI. How to present it on the UI is covered in “Best Practices for Checking and Displaying External Device State” — please don’t settle for the single word “Connected.”
8.4 Power Management — The Real Cause of “It’s Slow to Respond Even Though Nothing Was Unplugged”
USB selective suspend is the mechanism that puts an idle device into a low-power state. Because waking takes time, it’s the culprit behind symptoms like “only the very first response is slow” and “leave it alone for a while and it drops the first command.”
- With
Usbser.sys(virtual COM) it’s disabled by default, and you enable and configure it via the registry valueIdleUsbSelectiveSuspendPolicy3 - With WinUSB, you control it through the extended properties OS feature descriptor (or the INF) using
DeviceIdleEnabled,DefaultIdleTimeout,UserSetDeviceIdleEnabled, and so on5
The first thing to check on site is the “Allow the computer to turn off this device to save power” checkbox in the properties of the device in question (and of the USB Root Hub) in Device Manager. On equipment PCs there really are faults that clearing that box alone fixes. Including laptop power-saving settings, do your verification on the same power plan as production.
9. Estimating Performance and Latency
Putting the required bandwidth and latency into numbers at the approach-selection stage eliminates backtracking.
| Transfer type | Approaches that use it | Characteristics |
|---|---|---|
| Control transfer | All approaches (used internally) | For configuration and small commands. No bandwidth guarantee |
| Interrupt transfer | HID, WinUSB | Periodic polling. Low latency but small capacity |
| Bulk transfer | WinUSB, mass storage | For large volumes. No bandwidth guarantee; uses whatever is free |
| Isochronous transfer | UVC (cameras), UAC (audio), WinUSB (8.1 and later) | Guaranteed bandwidth, no retransmission |
A USB 2.0 interrupt endpoint is up to 64 bytes per packet with a polling interval of 1–255 ms at full speed, and up to 1024 bytes with intervals in units of 125 µs at high speed.11 If you’re choosing HID, check that your required bandwidth has at least an order of magnitude of headroom against those limits.
One more thing: Windows is a general-purpose OS, so it offers no latency guarantee. Meeting a requirement such as “it must always respond on a 10 ms cycle” at the application layer is a stretch. If cyclic control is genuinely necessary, design it so the cycle is confined to the microcontroller in the device and the PC sticks to commanding and monitoring. This dividing line is covered in detail in “A Practical Guide to Getting as Close to Soft Real-Time as Possible on Ordinary Windows.”
10. Distribution and Operations — The Moment You Ship a Driver, the Costs Change
Between the “no driver needed” approaches (standard class, HID, WinUSB devices) and the “ship a driver” approaches lies a discontinuity not in development cost but in distribution and maintenance cost.
- “I’m not writing my own
.sys, so signing isn’t required” is wrong. In PnP device installation, a driver package will not be staged into the Driver Store unless its catalogue file is signed.18 This requirement is independent of what’s inside the package, so even an INF that merely references Microsoft’swinusb.sys, as in Section 5.2, requires a step that generates and signs a catalogue (.cat). Estimate it as “write one INF and ship it and it works” and you’ll find out only when the customer’s machine rejects it with “the driver for this device is not signed.” Catalogue signing means either WHQL release signing or signing with a third-party release certificate (SPC).18 - Kernel-mode driver signing. Since Windows 10 version 1607, a new kernel-mode driver won’t load unless Microsoft has signed it via the Dev Portal (Partner Center). Opening a Partner Center account requires an EV code signing certificate.6 This is a requirement at a different layer from the catalogue signing above, and if you include a kernel-mode binary, you have to satisfy both.
- There are two signing routes, and their reach differs. HLK tested / dashboard signed, which requires passing the HLK tests, is valid from Windows Vista through Windows Server editions, and Microsoft recommends it as the route to take. The other, attestation signing, doesn’t require HLK testing but is only valid on Windows 10 desktop and later (it isn’t accepted on Windows 7/8.1 or on Windows Server 2016 and later). On top of that, it can’t be distributed to the general public via Windows Update, and it doesn’t make the driver Windows Certified. Microsoft’s own documentation positions it as “for testing purposes.”19 Using attestation signing for a home-grown driver shipped in your own installer is widely done in practice, but adopt it only after you’ve written the restriction to Windows 10/11 desktop into your supported-OS table. If the equipment PC runs Windows Server or an older LTSC, this route isn’t available to you in the first place.
- Don’t count on the exception conditions. Cross-signed drivers still work if Secure Boot is disabled, or if the certificate was issued before 29 July 2015, and so on — but a distribution plan built on that will fall apart within a few years.6
- Installer design. An installer containing a driver requires administrator privileges, and you’ll also need to verify silent installation. How to choose the distribution method itself is covered in “Choosing a Windows App Distribution Method,” and how to tell when administrator privileges are required is summarised in “When Do You Actually Need Administrator Privileges on Windows?.”
- On equipment PCs, drivers and OS updates conflict. When installing a vendor driver on an LTSC-configured equipment PC, decide the OS build pinning policy and the driver update policy as a set. “Which Windows Should You Put on an Industrial PC?” is a useful reference.
As a design decision, “if there’s an approach that avoids shipping a driver, take it even if the implementation is somewhat more tedious” is almost always the right answer. That single point is the whole reason HID is quietly strong.
11. Common Failures and What to Do About Them
| Symptom | Likely cause | What to do |
|---|---|---|
| Works on the dev machine but not at the customer site | The COM number is hard-coded in configuration | Resolve it at run time from VID/PID and serial (8.1) |
| Misbehaves once a second device is connected | The device doesn’t report a serial number | Revisit device selection. Failing that, fix the port and use labels |
| One device, but it grabs a different partner every time | A composite device keyed without distinguishing the function | Include MI_xx, HID Usage, or the device interface path in the key (8.1) |
| The app crashes when the cable is pulled | I/O against a rotten handle; cleanup that relies on PnP notification alone | Treat removal-class errors on I/O completion paths as end-of-session too (8.2) |
| Only the first response is slow / gets dropped | Waking from selective suspend | Check and disable the power management setting (8.4) |
| The device doesn’t respond to HID output | The data is shifted by the report ID | Buffer length exactly OutputReportByteLength, report ID first (4.3) |
| No HID data can be read at all | The target is a TLC the OS opens exclusively (keyboard, etc.) | Switch the device’s mode. For enumeration only, open with access 0 (4.2) |
| WinUSB reads never return | PIPE_TRANSFER_TIMEOUT not set |
Set the timeout in the pipe policy (5.3) |
| It reconnects on every timeout | Self-cancellation mistaken for device disconnection | Triage by cross-referencing state such as a cancellation token (8.2) |
| It gets gradually heavier with repeated hot-plugging | Missing WinUsb_Free/CloseHandle |
Consolidate cleanup in one place and reach it on failure paths too (5.3) |
| Unrelated variables get corrupted after a P/Invoke call | A structure declared only partway through | Declare every field with the same size and layout as the native definition (4.3) |
| The driver won’t install at the customer site | Unsigned catalogue (irrespective of whether you wrote a .sys) |
Include .cat generation and signing in the distribution plan (Section 10) |
| Launching two instances makes one of them fail | WinUSB doesn’t allow simultaneous access | Prevent multiple instances, or funnel everything through a resident service |
| The SDK can’t be loaded in a 64-bit build | A 32-bit-only DLL | Separate it into another process and connect over IPC (Section 6) |
| No way to tell whether the traffic is really getting through | No means of observation | A USB protocol analyser, a usbmon-equivalent trace, an implemented communication log |
That last row is easy to dismiss but important. Having, from day one, a means of triaging “which side is at fault (the application or the device)” dramatically shortens the period during which the cause is unknown. What makes the virtual COM approach strong on site is that terminal software gives everyone a triage tool. If you choose HID or WinUSB, build the logs and the test CLI that take its place yourself.
12. Summary
- How you work with a USB device is determined not by the device itself but by which driver loaded on top of it. Start by looking at the hardware ID, compatible ID, and device instance path in Device Manager.
- The official selection order is “simplest first”: standard class driver → WinUSB (single application) → UMDF (multiple applications) → KMDF. Writing your own driver is the last resort.
- virtual COM is easy to implement and easy to triage on site, but the COM number is not an identifier. Resolve it at run time from VID/PID and serial number. Throughput differs by an order of magnitude depending on whether it’s a USB-UART bridge or native CDC, so don’t assume — measure.
- HID is a strong option offering two-way communication with zero driver distribution, but collections corresponding to mice, keyboards, touch, and pens are opened exclusively by the OS and are off limits, and the bandwidth of interrupt transfers is your ceiling.
- WinUSB is for large data volumes and proprietary protocols. However, “no INF required” only holds for devices with OS descriptors on Windows 8 and later, and simultaneous access from multiple applications is impossible. For new firmware, OS 2.0 descriptors are the first choice.
- A vendor SDK is a precondition, not an option. Inventory the bitness, threading constraints, redistribution terms, and support lifetime at the device selection stage, and wrap the SDK in a thin abstraction layer on the application side.
- Whatever the approach, you design these four yourself: unique identification, hot-plug handling, layered timeouts, and power management. This is where “sometimes it doesn’t work” comes from. For composite devices, push identification down to the function level, and catch disconnection from both PnP notification and I/O errors.
- If you ship a driver package, catalogue signing is required even without your own
.sys. If it contains a kernel-mode binary, you additionally need Microsoft signing under 1607 and later (and an EV certificate). Attestation signing skips HLK but is limited to Windows 10 desktop and later, so check it against your supported-OS table before choosing it. If there’s an approach that avoids shipping a driver, take it — in practice that’s almost always the right answer.
Related Articles
- Serial Communication App Pitfalls - Through Reconnection and Log Design
- Best Practices for Checking and Displaying External Device State - Designing Beyond a Single ‘Connected’
- Safely Calling Win32 APIs from C# — A Practical P/Invoke Guide
- Calling Native DLLs from C#: C++/CLI Wrapper vs P/Invoke
- A Practical Guide to Getting as Close to Soft Real-Time as Possible on Ordinary Windows
- Choosing a Windows App Distribution Method - MSI/MSIX/ClickOnce/xcopy/Custom Updater
- Which Windows Should You Put on an Industrial PC? — A Practical Guide to Windows IoT Enterprise / LTSC
- A Practical Guide to Process Monitor (ProcMon) — Pinpointing “Settings Not Applied” and “ACCESS DENIED” in 10 Minutes
Related Consulting Areas
KomuraSoft LLC handles integration design between USB-connected equipment or instruments and Windows applications, wrapping and 64-bit migration of existing SDKs, and root-cause investigation and remediation for equipment-integration applications that become unstable on hot-plug or reconnection.
- Windows Application Development
- Technical Consulting & Design Review
- Bug Investigation & Root-Cause Analysis
- Contact Us
References
-
Microsoft Learn, USB device class drivers included in Windows. The list of USB class drivers shipped with Windows (Usbaudio.sys / Usbser.sys / Hidclass.sys and Hidusb.sys / Usbscan.sys / Usbprint.sys / Usbstor.sys / Usbvideo.sys, and so on); that vendors should not write drivers for supported device classes; that WinUSB (Winusb.sys) is recommended for unclassified classes including Vendor Specific (FFh); that for composite devices Usbccgp.sys generates a PDO per function; and the distinction between the setup class USBDevice ({88BAE032-5A81-49f0-BC3D-A4FF138216D6}) and the USB class. The statement on the CDC (02h) row that “in Windows 10, Usbser.inf automatically loads Usbser.sys as the function driver,” and the route of handling subclass 02h (ACM) with a custom INF referencing mdmcpq.inf, also come from the same page. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Choose a driver model for developing a USB client driver. The “start with the simplest approach” selection order (standard class driver → WinUSB → UMDF → KMDF); that WinUSB suits access from a single application, bulk/interrupt/isochronous endpoints, and targeting Windows XP SP2 and later; that WinUSB cannot be used for simultaneous access from multiple applications; and the WinUSB / UMDF / KMDF capability comparison table (isochronous transfers supported by WinUSB from Windows 8.1, not supported by UMDF). ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, USB serial driver (Usbser.sys). That setting class 02 and subclass 02 in the device descriptor causes the standard Usbser.inf to match via the compatible ID (USB\Class_02&SubClass_02) so that Usbser.sys loads automatically without distributing a custom INF; that a subclass other than 02 is not loaded automatically; that CDC devices can be communicated with from the Windows.Devices.SerialCommunication namespace; and that selective suspend is disabled by default and can be configured via the registry value IdleUsbSelectiveSuspendPolicy. ↩ ↩2 ↩3
-
Microsoft Learn, HID Architecture. That the HID class driver (hidclass.sys) abstracts between HID clients and transports; the list of top-level collections Windows supports and their access modes (mouse, keyboard, pen, touchscreen, and precision touchpad exclusive; game controller, sensor, barcode scanner, and others shared); that for security reasons the Raw Input Manager (RIM) opens those devices exclusively; and that even for exclusively opened devices, opening a handle without requesting read/write access allows information to be obtained via HidD_GetXxx. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, WinUSB Device. That a WinUSB device is a USB device whose firmware reports WINUSB as its compatible ID via Microsoft OS feature descriptors, so that Winusb.sys loads without a custom INF; that before Windows 8 there was no automatic matching on compatible ID and a custom INF was mandatory (Windows 8’s in-box Winusb.inf gained support for USB\MS_COMP_WINUSB, and an updated INF is provided via Windows Update for earlier versions); the mechanism of the OS string descriptor at string index 0xEE and the vendor code; that compatibleID is set to WINUSB in the extended compat ID descriptor; that registering a DeviceInterfaceGUID via the extended properties descriptor lets applications discover and operate the device; that the setup class USBDevice ({88BAE032-5A81-49f0-BC3D-A4FF138216D6}) should be used rather than the USB class; and the power management settings DeviceIdleEnabled / DefaultIdleTimeout / UserSetDeviceIdleEnabled / SystemWakeEnabled. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, Driver Signing Policy. That from Windows 10 version 1607, new kernel-mode drivers not signed via the Dev Portal will not load; that an EV code signing certificate is required to register with the Windows Hardware Dev Center program; and the exception conditions under which cross-signed drivers are still permitted (upgrading to 1607, Secure Boot disabled, an end-entity certificate issued before 29 July 2015). ↩ ↩2 ↩3
-
Microsoft Learn, Opening HID collections. The sequence by which a user-mode application identifies HID collections with the SetupDi* functions, opens them with CreateFile, obtains preparsed data and information with HidD_Xxx, reads input reports with ReadFile and sends output reports with WriteFile, and interprets reports with HidP_Xxx. ↩
-
Microsoft Learn, HIDP_CAPS structure (hidpi.h). The complete definition of the structure (Usage / UsagePage / InputReportByteLength / OutputReportByteLength / FeatureReportByteLength / Reserved[17] / NumberLinkCollectionNodes and the ten Number members that follow, USHORT × 32 in total), and that each report length is a value that includes the one byte of the report ID. ↩
-
Microsoft Learn, Sending HID Reports. That a user-mode application should use WriteFile to send output reports continuously; that the HidD_SetXxx family (HidD_SetOutputReport / HidD_SetFeature) can also send output and feature reports but that HidD_SetXxx should be used only for setting the collection’s current state; and the warning that “some devices do not support HidD_SetOutputReport and may stop responding if this routine is used.” Also, from HidD_SetOutputReport function, that ReportBufferLength is determined by HIDP_CAPS’s OutputReportByteLength, and that the first byte should be 0 when report IDs are not used. ↩ ↩2
-
Microsoft Learn, HidDevice Class (Windows.Devices.HumanInterfaceDevice). That HidDevice represents a device corresponding to a top-level collection; the flow of building an AQS selector from usagePage / usageId / vendorId / productId with GetDeviceSelector and opening it with FromIdAsync; and that applications accessing HID devices through this class must include the appropriate DeviceCapability data in the manifest’s Capabilities node. ↩
-
USB Implementers Forum, Universal Serial Bus Specification Revision 2.0. That the maximum packet size of an interrupt endpoint is 64 bytes at full speed and 1024 bytes at high speed; that the polling interval (bInterval) is 1–255 milliseconds at full speed and is expressed at high speed as 2^(bInterval-1) in units of 125 microseconds (section 9.6.6 Endpoint); and the bandwidth characteristics of the control, bulk, interrupt, and isochronous transfer types. ↩ ↩2
-
Microsoft Learn, Microsoft OS 2.0 Descriptors Specification. That version 2.0 of the Microsoft OS descriptors was defined to resolve the constraints and reliability problems of version 1.0, and that the target operating systems are Windows 10 and Windows 8.1 Preview. ↩
-
Microsoft Learn, WinUSB (Winusb.sys) Installation for Developers. That you set the GUID by adding either the string entry
DeviceInterfaceGUIDor the multi-string entryDeviceInterfaceGUIDsunder the device’sDevice Parameterskey; that a custom INF’sAddRegis written asHKR,,DeviceInterfaceGUIDs,0x10000,"{...}"(0x10000 = REG_MULTI_SZ); that “when Winusb.sys is loaded as the function driver it reads theDeviceInterfaceGUIDsregistry value and represents the device interface with the specified GUID” and that “each time it loads, Winusb.sys registers a device interface with the device interface class specified under theDeviceInterfaceGUIDskey”; that the user-mode side enumerates registered interfaces withSetupDiGetClassDevsbefore passing one toWinUsb_Initialize; and that a driver package requires a signed catalogue file. ↩ ↩2 ↩3 ↩4 -
Microsoft Learn, WinUsb_ReadPipe function (winusb.h). That specifying Overlapped makes the function return immediately and the operation run asynchronously; that in that case GetLastError returns ERROR_IO_PENDING and success or failure is checked with WinUsb_GetOverlappedResult; that when asynchronous (Overlapped non-NULL) LengthTransferred may be set to NULL; that even when a non-NULL LengthTransferred is passed, the value at the point the function returns is meaningless until the overlapped operation completes and the actual number of bytes read must be obtained with WinUsb_GetOverlappedResult; and that for synchronous calls (Overlapped NULL) LengthTransferred must be non-NULL. ↩
-
Microsoft Learn, Windows.Devices.Usb Namespace. That this namespace targets WinUSB devices handled by the in-box winusb.sys (compatible ID USB\MS_COMP_WINUSB); that it cannot access the Audio (0x01) / HID (0x03) / Image (0x06) / Printer (0x07) / Mass Storage (0x08) / Smart Card (0x0B) / Audio-Video (0x10) / Wireless Controller (0xE0) device classes; that the usb device capability must be declared in the manifest and that from Windows 10 version 1809 specifying VendorId/ProductId is no longer required; and that device stacks including upper or lower filter drivers are generally inaccessible. ↩
-
Microsoft Learn, CM_Register_Notification function (cfgmgr32.h). That it is available on Windows 8 and later and that RegisterDeviceNotification should be used when targeting Windows 7 or earlier; the filter types CM_NOTIFY_FILTER_TYPE_DEVICEINTERFACE / DEVICEHANDLE / DEVICEINSTANCE; that PnP events should be processed as quickly as possible and that anything that can block, such as I/O, should be done asynchronously on another thread; and that this function does not notify about existing device interfaces, so CM_Get_Device_Interface_List must be called after registering, with interfaces enabled in between appearing in both the notification and the list. ↩ ↩2
-
Microsoft Learn, RegisterDeviceNotificationW function (winuser.h). That it is the registration function by which an application receives device notifications, returning a device notification handle on success and NULL on failure. Also, from WM_DEVICECHANGE message and DBT_DEVICEARRIVAL event, that WM_DEVICECHANGE is broadcast with wParam set to DBT_DEVICEARRIVAL when a device or piece of media has been inserted and becomes available. ↩
-
Microsoft Learn, PnP Device Installation Signing Requirements. That a driver package must meet the signing requirements to be staged into the Driver Store; that to be considered “signed” for PnP device installation the driver package’s catalogue file must be signed with WHQL or a third-party release certificate (SPC, a commercial release certificate); that the signing requirements for loading a kernel-mode driver binary are imposed separately from this; that on 64-bit Windows the kernel-mode code signing policy requires WHQL or SPC signing; and that some editions, such as Windows 10 in S mode, accept only WHQL-signed catalogues. ↩ ↩2
-
Microsoft Learn, Driver Signing Options. That dashboard-signed drivers that have passed the HLK tests work on Windows Vista and later including Windows Server editions, and are the recommended approach because they can be signed for all OS versions; that attestation signing is positioned as “for testing purposes only” and requires no HLK testing; that attestation-signed drivers cannot be published to Windows Update for the general public; that they are only valid on Windows 10 desktop and later; that targeting earlier versions of Windows requires submitting HLK/HCK test logs; that Windows Server 2016 and later do not accept attestation signing submissions and load only HLK-passing drivers; and that attestation signing requires an EV certificate while not making the driver Windows Certified. ↩
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Serial Communication App Pitfalls - Through Reconnection and Log Design
The serial communication app pitfalls you want to avoid in device integration and instrument control, organized from a practical perspect...
Windows App Outsourcing and Contract Development: What to Sort Out Before You Ask
Before commissioning Windows app outsourcing or contract development, here is how to sort out existing software modification, device inte...
Code Design for Business Systems — Deciding Product and Customer Codes, and Check Digits
A practical guide to deciding the code scheme for a business system, including product and customer codes. Covers a decision table for me...
Versioning Your Business App's Database Schema — Migration Practices to Prevent 'Every Customer Has a Different DB'
A practical guide to versioning the database schema of a business app whose databases are scattered across customer sites. Covers PRAGMA ...
CI/CD for WinForms / WPF Apps in Practice — Automating from Build to Signing and Distribution with GitHub Actions
A practical guide to setting up CI/CD for WinForms / WPF apps with GitHub Actions. Covers a minimal YAML for build+test on windows-latest...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Frequently Asked Questions
Common questions about the topic of this article.
- I want to use a USB device from my application. Do I have to write a driver myself?
- In most cases, no. Microsoft's official guidance explicitly says to start with the simplest approach and only move to a more complex one when you have to. If the device belongs to a standard USB class (CDC, HID, mass storage, and so on), a Windows in-box class driver loads automatically and no driver is needed. If it doesn't belong to a standard class and only one application will access it, you can use WinUSB (winusb.sys) as the function driver as-is. Only when multiple applications need to access it simultaneously do you move to a UMDF driver, and only if that won't work, a KMDF driver. Writing your own driver is the last resort.
- What is the single biggest weakness of the virtual COM port (USB serial) approach?
- That the COM port number is not the device's identity. Even for the same device, plugging it into a different USB port can change the COM number, and if you connect several you can't tell which is which from the number alone. Writing "COM3" into a configuration file is an operational practice that will break on site, guaranteed. In practice the correct answer is to look up the mapping between VID/PID plus serial number and the COM number at run time, from sources such as Win32_PnPEntity, and open that. On top of that, serial communication is a byte stream, so message boundaries aren't guaranteed and you need a separate parser that accumulates into a receive buffer and then cuts out frames.
- I hear the HID approach is easy because it needs no driver. What should I watch out for?
- Three things. The first is speed: HID uses interrupt transfers, so it isn't suited to sustained transfer of large volumes of data. The second is report length: the buffer you pass to ReadFile must be exactly the InputReportByteLength returned by HidP_GetCaps, and the first byte is the report ID. Getting this wrong is the classic bug where nothing reads or the app crashes. The third is exclusivity: top-level collections corresponding to mice, keyboards, touchscreens, and pens are opened exclusively by the Windows Raw Input Manager, so an application can't read from or write to them. That said, if you open a handle without requesting read/write access, you can still obtain information via the HidD_GetXxx family.
- I want to use WinUSB. Can I apply it to an existing device as-is?
- Often not. winusb.sys loads automatically without an INF file only when the device's firmware carries Microsoft OS descriptors and reports WINUSB as its compatible ID — a "WinUSB device" — and only on Windows 8 and later. The in-box Winusb.inf only started matching on the compatible ID in Windows 8, so if Windows 7 or earlier is also in scope, distributing an INF is a given. If your existing device doesn't qualify as a WinUSB device, you'll also have to prepare and distribute your own custom INF that specifies the hardware ID. Swapping the driver with a tool such as Zadig during development is useful for verification, but it removes the vendor's driver, so don't make it your production distribution mechanism. If the firmware can be changed, having OS descriptors added is by far the cleanest solution, and for a new design the OS 2.0 descriptors announced via BOS (Windows 8.1 and later) are the first choice.
- How should an application follow USB devices being plugged and unplugged?
- Subscribe to PnP notifications rather than polling. For a desktop application on Windows 8 or later, the standard approach is CM_Register_Notification with a device interface filter; if Windows 7 or earlier is also in scope, use RegisterDeviceNotification and WM_DEVICECHANGE. One caveat: CM_Register_Notification does not notify you about interfaces that already exist at registration time, so register first and then enumerate the existing ones with CM_Get_Device_Interface_List (do it the other way round and you'll miss some). Also, doing anything inside the callback that can block — I/O, for instance — is dangerous, so hand it off to another thread. On top of that, the important thing is not to make PnP notification your only entry point. An I/O operation in flight can complete with a removal or cancellation error before, or at the same time as, the notification. Treat removal-class errors as end-of-session on every read and write completion path, and combine PnP notification in as a supplementary signal that catches disconnections while you're idle.
Author Profile
Profile page for the article author.
Go Komura
Representative of KomuraSoft LLC
Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.
Public links