Windows 앱에서 USB 기기를 다루는 방법 ── 가상 COM・HID・WinUSB・전용 SDK를 고르는 법

· · USB, HID, WinUSB, 시리얼 통신, 장치 연동, Windows 개발, C#, 디바이스 드라이버

「이 장치, 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

2. 대전제 ── Windows에서 본 USB 기기는 「어떤 드라이버가 올라갔는가」가 전부

먼저 4가지 방식의 결정 트리를 한 장으로 보여드리겠습니다. 1장에서 든 공식 선정 순서(단순한 것부터)를, 실제 판단 순서로 다시 배열한 것입니다.2 각 분기의 상세는 3~6장에 대응합니다.

포트(COM과 LPT)로 보인다사람이 사용하는 인터페이스 디바이스로 보인다벤더 제작 드라이버가 올라가 있다어느 쪽도 아님 / 알 수 없는 디바이스댈 수 없다댈 수 있다충분하다부족하다(대량 데이터・독자 프로토콜)하지 않는다한다USB 기기를 앱에서 다루고 싶다장치 관리자에서 무엇으로 보이는가(먼저 실물을 꽂아 확인한다)기기의 펌웨어에 손을 댈 수 있는가(자체 설계, 또는 벤더에 의뢰 가능)필요한 대역은 인터럽트 전송으로 충분한가(기준 - 수십 KB/s 이하의 상태 통지나 명령)여러 앱이 동시에같은 기기에 접근하는가방식 A - 가상 COM 포트3장방식 B - HID4장방식 C - WinUSB5장방식 D - 벤더 SDK6장UMDF 드라이버 개발을 검토그것도 안 되면 KMDF.배포 비용은 10장

그림 1: 4가지 방식의 결정 트리. 출발점은 반드시 「장치 관리자에서 무엇으로 보이는가」

이 그림의 핵심은 두 가지입니다. 출발점이 제품 카탈로그가 아니라 실물의 장치 관리자라는 점, 그리고 아래로 갈수록 배포 비용이 늘어난다는 점입니다. 위쪽에서 해결할 수 있다면 그것이 정답이며, 아래로 내려가는 판단은 「내려갈 만한 이유가 있을」 때만 하십시오.

USB 케이블 끝에 무엇이 연결되어 있든, 앱에서 보이는 것은 그 기기 위에 올라간 드라이버가 공개하는 인터페이스뿐입니다. 이 점을 이해하지 못하면 논의가 맞물리지 않습니다.

기기를 꽂으면 Windows는 기기가 신고하는 디스크립터를 읽고, 클래스 코드와 VID/PID로부터 올릴 드라이버를 정합니다. 표준 클래스에 해당하면 Windows에 동봉된 클래스 드라이버가 자동으로 올라갑니다.1

USB-IF 클래스 코드 Windows 표준 드라이버 앱에서 보이는 형태
Audio (01h) Usbaudio.sys 오디오 디바이스
CDC (02h, 서브클래스 02h) Usbser.sys COM 포트
HID (03h) Hidclass.sys / Hidusb.sys HID 컬렉션
Image (06h) Usbscan.sys WIA 디바이스
Printer (07h) Usbprint.sys 프린터
Mass Storage (08h) Usbstor.sys 드라이브
Video (0Eh) Usbvideo.sys 카메라(UVC)
Vendor Specific (FFh) (없음) WinUSB 권장

마지막 행이 중요합니다. 벤더 독자 기기는 FFh(Vendor Specific)를 표방하는 경우가 많으며, 그 경우 Microsoft의 권장은 WinUSB입니다.1

또 하나 알아둬야 할 것이 복합 디바이스(composite device)입니다. USB 케이블 하나로 여러 기능을 가진 기기는, Usbccgp.sys가 기능별로 별도의 디바이스로 전개합니다. 「기기 1대인데 장치 관리자에 3개 나온다」는 이것이며, 예를 들어 「제어는 CDC(COM 포트), 상태 통지는 HID」라는 구성의 기기도 드물지 않습니다. 방식은 기기 단위가 아니라 기능(인터페이스) 단위로 정해집니다.

먼저 해야 할 일 - 실물을 장치 관리자로 확인한다

논의에 앞서, 실기를 꽂고 다음을 확인하십시오. 5분이면 끝나고, 이후의 판단이 전부 달라집니다.

  1. 장치 관리자의 어느 카테고리에, 어떤 이름으로 나오는가
  2. 속성 → 세부 정보 탭 → 하드웨어 ID (USB\VID_xxxx&PID_yyyy&...)
  3. 동일 → 호환 ID (USB\Class_02&SubClass_02USB\MS_COMP_WINUSB가 보이는가)
  4. 동일 → 디바이스 인스턴스 경로의 끝(시리얼 번호가 들어 있는가, &를 포함하는 생성값인가)
  5. 드라이버 탭 → 공급자 및 드라이버 파일(Microsoft 제작인가, 벤더 제작인가)

3번에서 USB\MS_COMP_WINUSB가 있다면, 그 기기는 WinUSB 디바이스로 설계된 것입니다.5 4번은 8.1절에서 사용하는, 기기의 고유 식별 가능 여부를 판단하는 재료입니다.

3. 방식 A - 가상 COM 포트 ── 가장 편하지만, 가장 착각하기 쉽다

3.1 무슨 일이 벌어지고 있는가

USB의 CDC(Communications and CDC Control) 클래스, 서브클래스 02h(ACM)를 표방하는 기기에는, Windows 표준의 Usbser.sysINF 배포 없이 자동으로 올라갑니다. 디바이스 디스크립터에서 클래스 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를 경유하더라도 변하지 않습니다. 바이트 스트림으로 받아, 버퍼에 쌓은 뒤 파서로 프레임을 잘라내는 구성으로 하십시오.

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\라는 필터에는 단 한 건도 걸리지 않습니다. Silicon Labs 등 다른 칩 벤더도 독자적인 열거자를 가질 수 있습니다.

안전한 구현은 다음 둘 중 하나입니다.

  • Ports 클래스를 전건 취득한 뒤, PNPDeviceID에 포함된 VID_xxxx/PID_yyyy(또는 VID_xxxx+PID_yyyy)를 정규식으로 뽑는다 ── 열거자 이름에 의존하지 않으므로 견고함
  • 열거자 이름을 명시적으로 허용 목록으로 만든다(USB\FTDIBUS\ 등) ── 대상 기기가 고정되어 있다면 충분함

어느 쪽이든, 자신의 대상 기기를 실제로 꽂고 이 명령을 실행해, 어떤 PNPDeviceID로 나오는지 눈으로 확인한 뒤에 필터를 작성하십시오. 열거자 이름은 기기와 드라이버의 조합에 따라 정해지므로, 탁상에서 미리 정할 수 없습니다.

C#에서는 System.ManagementManagementObjectSearcher로 같은 쿼리를 던지거나, Windows.Devices.SerialCommunication.SerialDevice.GetDeviceSelectorFromUsbVidPid(vid, pid)로 AQS 셀렉터를 만들어 DeviceInformation.FindAllAsync하는 방법이 있습니다(GetDeviceSelector 쪽은 VID/PID를 받지 않고, 인수 없이 또는 포트 이름을 넘기는 형태이므로 착각에 주의). 전자가 의존성이 적어, 데스크톱 앱에서는 다루기 쉬운 경우가 많습니다.

열거에서 SerialPort를 여는 것까지 통째로 작성하면 다음과 같습니다. 위의 PowerShell 확인을 그대로 코드로 옮긴 형태입니다(.NET 8. System.Management 패키지 참조가 필요하며, Windows 전용입니다).

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

// PNPDeviceID 에서 VID/PID를 뽑는다. 구분자가 & 와 + 두 가지임에 주의
//   USB\VID_2341&PID_0043\...      ← CDC-ACM(usbser.sys)
//   FTDIBUS\VID_0403+PID_6001+...  ← FTDI의 VCP 드라이버
private static readonly Regex VidPidPattern = new(
    @"VID[_+](?<vid>[0-9A-Fa-f]{4})[&+]PID[_+](?<pid>[0-9A-Fa-f]{4})",
    RegexOptions.IgnoreCase | RegexOptions.Compiled);

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

/// <summary>지정한 VID/PID의 COM 포트 이름을 열거한다(열거자 이름에 의존하지 않음).</summary>
static IEnumerable<(string PortName, string PnpDeviceId)> FindComPorts(ushort vid, ushort pid)
{
    // PNPClass = 'Ports' 로 전건을 가져온다. USB\ 로 필터링하면 FTDIBUS\ 를 놓친다
    using var searcher = new ManagementObjectSearcher(
        "SELECT Name, PNPDeviceID FROM Win32_PnPEntity WHERE PNPClass = 'Ports'");

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

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

            // "USB 시리얼 디바이스 (COM5)" 의 (COM5) 를 꺼낸다
            var com = ComNamePattern.Match(name);
            if (!com.Success) { continue; }

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

// 디바이스 인스턴스 경로의 어디에 시리얼이 들어 있는지는 열거자에 따라 다르다.
//   USB\VID_2341&PID_0043\85436323631351D0E1C1  마지막 요소가 시리얼
//   FTDIBUS\VID_0403+PID_6001+A5XK3RJTA\0000    중간 요소에 묻혀 있고,
//                                               끝은 \0000. 게다가 FTDI는
//                                               포트를 나타내는 문자 1개를 뒤에 붙인다
// 「마지막 요소와 일치하는가」만으로 작성하면 FTDI의 VCP는 단 한 건도 걸리지 않는다.
static bool MatchesSerial(string pnpDeviceId, string serial)
{
    foreach (var part in pnpDeviceId.Split('\\'))
    {
        if (part.Equals(serial, StringComparison.OrdinalIgnoreCase)) { return true; }

        // FTDI 형식 VID_xxxx+PID_xxxx+<시리얼><포트 문자> 의 마지막 필드
        var fields = part.Split('+');
        if (fields.Length < 3) { continue; }
        var tail = fields[fields.Length - 1];
        if (tail.Equals(serial, StringComparison.OrdinalIgnoreCase)) { return true; }
        if (tail.Length == serial.Length + 1 &&
            tail.StartsWith(serial, StringComparison.OrdinalIgnoreCase)) { return true; }
    }
    return false;
}

// 사용하는 쪽: 여는 것은 「매칭한 결과」이지, 설정 파일의 COM 번호가 아니다
var candidates = FindComPorts(0x2341, 0x0043).ToList();
if (candidates.Count == 0) { throw new InvalidOperationException("대상 기기를 찾을 수 없습니다."); }

// 대수와 관계없이, 반드시 시리얼로 좁힌다. 1대만 발견되더라도, 그것이
// 목표 개체라는 보장은 없다(목표 개체가 빠져 있고, 다른 개체만 꽂혀
// 있는 상태도 있다). WMI의 열거 순서는 기기의 동일성을 보장하지 않으므로,
// candidates[0] 를 그대로 사용하면 「옆 개체를 조작하고 있었다」는 사고가 된다.
var wanted = config.DeviceSerial;   // 설정이나 인수로 「어느 개체인가」를 받는다
candidates = candidates.Where(c => MatchesSerial(c.PnpDeviceId, wanted)).ToList();

if (candidates.Count != 1)
{
    throw new InvalidOperationException(
        $"시리얼 '{wanted}' 로 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처럼 중간 요소에 파묻힌 형식에서는 단 한 건도 일치하지 않게 됩니다. 위 코드에서는 이 두 가지를 MatchesSerial에 가둬 두었습니다.

또한 키로 삼을 수 있다면 디바이스 인스턴스 경로를 통째로 설정에 갖는 편이 확실합니다(8.1절). 시리얼을 추출하는 처리 자체가 필요 없어집니다.

Name의 끝에 있는 (COM5)에서 번호를 잘라내는 것은 무례해 보이지만, 실무에서는 가장 확실하게 동작하는 방법입니다. 엄밀하게 하려면 디바이스의 레지스트리 키 아래의 PortName 값을 읽습니다.

3.4 착탈로 핸들이 썩는다

USB-시리얼은 케이블을 뽑으면 포트 자체가 사라집니다. SerialPort를 연 채로 뽑으면, 내부의 수신 스레드에서 예외가 튀어 앱째로 죽는 사고가 고전적으로 알려져 있습니다. PnP의 제거 통지를 받으면 먼저 포트를 Close한다는 순서를 만들어 두는 것이 안전합니다(8.2절).

재접속은 「Open()을 다시 하는」 것만으로는 부족합니다. 이전 세션의 무효화, 처리 중 요청의 실패 확정, 리더/라이터의 정지, 백오프 후의 재오픈, 장치 초기화 시퀀스의 재실행까지 포함한 세션 재생성으로 설계하십시오.

3.5 적합/부적합

적합 부적합
기존 시리얼 프로토콜 자산이 있음 USB-UART 변환 칩 경유의 높은 처리량
텍스트 명령 응답형 장치・계측기 지연이 엄격한 저지연 제어
현장에서 터미널 소프트웨어로 원인 분리를 하고 싶음 같은 기종의 다수 동시 연결(식별 구현이 무거워짐)
개발자에게 드라이버 지식이 없음 프로토콜 설계를 이쪽에서 결정할 수 있는 신규 개발

처리량에 대해서는, 「가상 COM이니까 느리다」고 일괄적으로 취급하지 마십시오. FTDI 등의 USB-UART 변환 칩을 끼우는 구성에서는, 변환 대상 UART의 보 레이트가 상한이 됩니다(921.6kbps라면 약 92KB/s). 반면 마이크로컨트롤러가 직접 CDC-ACM을 구현한 네이티브 USB 기기에서는, 데이터 인터페이스가 USB의 벌크 전송이므로 UART의 제약이 없고, 하이 스피드 연결이라면 수 MB/s급이 나오는 경우도 있습니다. 다만 그 영역에서는 Usbser.sysSerialPort 계층의 오버헤드가 영향을 미치므로, 필요 대역이 수백 KB/s를 넘는다면 실기로 실측한 뒤 방식을 결정하는 것이 정답입니다. 측정하지 않은 단계에서 「속도가 필요하니까 WinUSB」라고 단정하면, 불필요한 드라이버 배포를 짊어지게 됩니다.

4. 방식 B - HID ── 드라이버 배포 없이 양방향 통신이 가능하다

4.1 HID는 입력 기기만을 위한 것이 아니다

HID라고 하면 마우스와 키보드를 연상하지만, 규격상으로는 임의의 바이트 열(리포트)을 양방향으로 주고받을 수 있는 범용 프로토콜입니다. 바코드 리더, 카드 리더, 전자 자물쇠, 계측 유닛, UPS, 독자 I/O 박스 ── 「드라이버를 배포하고 싶지 않지만 독자 데이터를 주고받고 싶다」는 기기가 HID를 표방하는 것은, Windows에 Hidclass.sysHidusb.sys가 표준 탑재되어 있어, INF도 드라이버도 전혀 배포하지 않고 동작하기 때문입니다.1

Windows에서 본 HID의 단위는 최상위 컬렉션(TLC)입니다. 하나의 물리 디바이스가 여러 TLC를 가질 수 있으며, 그 경우 각각 별도의 디바이스 인터페이스로 보입니다.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_GetCapsHidD_FreePreparsedData에 넘기고, 내용을 알 수 없는 caps로 리포트 길이를 결정하게 됩니다. 착탈이 잦은 현장일수록 자주 밟는 경합이므로, try에 들어가는 것은 취득에 성공했을 때뿐으로 하고, HidP_GetCaps의 반환값(HIDP_STATUS_SUCCESS인지 아닌지)도 확인합니다.

구현에서 가장 많은 실패가 리포트 길이의 처리입니다.

  • ReadFile에 넘기는 버퍼는 InputReportByteLength정확히 같게 합니다. 짧으면 실패하고, 길어도 제대로 처리되지 않습니다
  • 버퍼의 맨 앞 1바이트는 리포트 ID입니다. 기기가 리포트 ID를 사용하지 않는 설계라면 0이 들어갑니다. 실제 데이터는 바이트 1부터입니다
  • 마찬가지로 WriteFile의 버퍼는 OutputReportByteLength와 정확히 같게 하고, 맨 앞에 리포트 ID를 둡니다

「보냈는데 기기가 반응하지 않는다」의 90%는, 리포트 ID 1바이트만큼 데이터가 어긋나 있거나, 버퍼 길이가 맞지 않는 경우입니다. 기기 문서에 「명령은 8바이트」라고 적혀 있을 때, OutputReportByteLength가 9라면 리포트 ID를 포함해 9바이트라는 뜻입니다.

출력 리포트의 송신 경로에는 WriteFile 외에 HidD_SetOutputReport도 있으며, 사용 구분이 공식적으로 정해져 있습니다.9

용도 사용할 것
출력 리포트를 지속적으로 보낸다 WriteFile(이쪽이 기본)
컬렉션의 현재 상태를 설정한다 HidD_SetOutputReport
기능(Feature) 리포트를 보낸다 HidD_SetFeature

주의가 필요한 것은, 공식 문서가 「일부 디바이스는 HidD_SetOutputReport를 지원하지 않으며, 사용하면 응답이 없어질 수 있다」고 경고하고 있다는 점입니다.9 즉 「WriteFile이 안 통하니까 HidD_SetOutputReport로 바꾼다」는 전환은, 무조건 안전한 대체가 아닙니다. 기기의 사양서와 벤더 샘플이 어느 쪽을 쓰는지 확인한 뒤 선택하고, 전환할 경우에는 실기에서 응답이 멈추지 않는지까지 확인하십시오.

디바이스 열거만이 목적이라면, CreateFiledwDesiredAccess를 0으로 두고 여십시오. 배타적으로 열려 있는 디바이스도 열거할 수 있으며, HidD_GetAttributes로 VID/PID를, HidD_GetSerialNumberString으로 시리얼 번호를 취득할 수 있습니다.

C#에서 순수 P/Invoke를 작성하고 싶지 않다면, HidSharp 같은 라이브러리를 쓰는 선택지도 있습니다. 다만 리포트 길이와 리포트 ID의 처리는 결국 이해해야 하므로, 처음 한 번은 위와 같은 형태로 통과시켜 두면 이후의 조사가 빨라집니다. 패키징된 앱이라면 Windows.Devices.HumanInterfaceDevice.HidDevice도 사용할 수 있지만, 매니페스트에 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가 호환 ID USB\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 두 계통이 있다는 점에 주의하십시오.

  • Microsoft OS 1.0 기술자(전 버전 공통)
    1. 문자열 인덱스 0xEE에 OS 문자열 디스크립터를 갖고, 벤더 코드를 반환한다
    2. 확장 호환 ID OS 기능 디스크립터에서 compatibleIDWINUSB를 설정한다(복합 디바이스라면 기능별로)
  • Microsoft OS 2.0 기술자(Windows 8.1 이후)
    1. BOS 디스크립터의 플랫폼 기능 디스크립터에서 디스크립터 세트의 소재를 통지한다. 0xEE 문자열 디스크립터는 사용하지 않습니다
    2. 그 디스크립터 세트 안에 호환 ID 기능 디스크립터를 두고, WINUSB를 보고한다. BOS로 「세트가 있다」고 알리는 것만으로는 Winusb.sys가 선택되지 않습니다. 바인딩을 결정하는 것은, 1.0과 마찬가지로 호환 ID 쪽입니다

    1.0의 제약과 신뢰성 문제를 해소하기 위해 제정된 것으로, 신규 설계 펌웨어라면 이쪽이 최우선 후보입니다12

디바이스 인터페이스 GUID의 등록은, 위와는 역할이 다릅니다. 이것은 앱이 기기를 찾기 위한 GUID이며, Winusb.sys가 바인딩할 수 있는지 여부를 결정하는 것은 호환 ID 쪽입니다. GUID는 발견(discovery) 쪽입니다. 다만 독자 GUID가 등록되어 있지 않으면 앱 쪽의 기기 탐색을 구성하기 어려우므로, 실무상으로는 세트로 구현합니다.

여기서 주의해야 할 것이, 레지스트리상의 속성 이름에 단수형과 복수형 두 종류가 있다는 점입니다.13

이름 타입 사용되는 상황
DeviceInterfaceGUID 문자열(REG_SZ) Microsoft OS 1.0의 확장 속성 디스크립터는 이 이름을 wPropertyNameLength 40바이트로 지정한다5
DeviceInterfaceGUIDs 다중 문자열(REG_MULTI_SZ) 커스텀 INF의 표준 형태. Microsoft의 샘플도 HKR,,DeviceInterfaceGUIDs,0x10000,"{...}"와 같이 복수형을 사용한다13

공식 문서는 Winusb.sys의 동작을 설명할 때 「레지스트리의 DeviceInterfaceGUIDs 키를 읽고, 거기에 지정된 GUID로 디바이스 인터페이스를 등록한다」고 복수형으로 적고 있습니다.13 수동으로 레지스트리에 추가하는 절차에서도, 문자열의 DeviceInterfaceGUID 또는 다중 문자열의 DeviceInterfaceGUIDs 중 하나Device Parameters 아래에 두라고 양쪽 다 안내되어 있습니다.13

OS 2.0의 레지스트리 속성 기능 디스크립터를 사용하는 경우는, 속성 이름과 데이터 타입을 사양서에서 확인하십시오(구현에서는 복수형 + REG_MULTI_SZ가 일반적입니다). 1.0의 단수형을 그대로 2.0 경로에 가져가면, Windows가 참조하지 않는 곳에 기록하고 있어 「바인딩은 되어 있는데 앱에서 찾을 수 없다」는 상태가 될 수 있습니다.

확실한 검증법은, 기기를 꽂은 뒤 레지스트리를 보는 것입니다. HKLM\SYSTEM\CurrentControlSet\Enum\USB\<하드웨어 ID>\<인스턴스 ID>\Device Parameters에, 의도한 이름・타입・값으로 들어 있는지 확인하면, 디스크립터의 해석이 어떻든 사실을 알 수 있습니다.

또한, INF를 작성하는 경우는 셋업 클래스로 USBDevice({88BAE032-5A81-49f0-BC3D-A4FF138216D6})를 사용합니다. USB 클래스는 호스트 컨트롤러와 허브, 복합 디바이스 전용이며, 독자 기기에 사용하면 신뢰성과 성능 문제를 일으킨다고 명기되어 있습니다.5

기존 기기의 펌웨어에 손댈 수 없는 경우는, 하드웨어 ID를 지정한 커스텀 INF를 직접 준비해 배포하게 됩니다. 이 시점에서 「인스톨러가 드라이버를 설치한다」는 구성이 되며, 배포에 관한 이야기(10장)가 발생합니다. 독자 .sys를 한 바이트도 작성하지 않고, Microsoft의 winusb.sys를 참조하기만 하는 INF라도, 서명된 카탈로그를 붙이지 않으면 실제 운용의 Windows에는 설치되지 않습니다. 「INF를 한 장 작성하면 끝」이 아니므로, 10장을 읽은 뒤 공수를 산정하십시오.

개발 중에 Zadig 같은 도구로 드라이버를 WinUSB로 교체해 검증하는 것은 유효한 방법이지만, 이것은 벤더의 드라이버를 떼어내는 조작입니다. 실제 배포 수단으로 삼지 마십시오. 다른 앱이 같은 기기를 사용할 수 없게 됩니다.

5.3 구현의 요령

// WinUSB 초기화(에러 처리는 생략)
HANDLE h = CreateFile(devicePath,
                      GENERIC_READ | GENERIC_WRITE,
                      FILE_SHARE_READ | FILE_SHARE_WRITE,
                      NULL, OPEN_EXISTING,
                      FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, // 비동기는 필수급
                      NULL);

WINUSB_INTERFACE_HANDLE usb;
WinUsb_Initialize(h, &usb);

// 읽기에 반드시 타임아웃을 설정한다(기본값은 무기한 대기)
ULONG timeoutMs = 1000;
WinUsb_SetPipePolicy(usb, bulkInPipeId, PIPE_TRANSFER_TIMEOUT,
                     sizeof(timeoutMs), &timeoutMs);

// 비동기일 때는 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의 착오, 이미 무효가 된 핸들 ── 어느 것이든 흔히 일어납니다. 이때 보류 중인 작업은 하나도 없으므로, 그대로 WinUsb_GetOverlappedResult를 호출하는 것은 「시작하지 않은 전송의 완료」를 기다리는 조작이 됩니다. 원래의 에러 코드는 덮어써져 사라지고, 남는 것은 「0바이트로 완료됐다」거나 전혀 다른 에러입니다. 원인 규명이 시작되기 전에 원인이 사라집니다. 위 코드에서 started를 이어서 들고 있는 것은 이 때문이며, 마무리 쪽의 회수도 같은 조건으로 감쌉니다14
  • WinUsb_GetOverlappedResult의 반환값도 확인한다. 요청이 접수된 뒤라도, 타임아웃(위에서 설정한 PIPE_TRANSFER_TIMEOUT), CancelIoEx, 전송 중 제거로, 이 함수는 FALSE를 반환합니다. 이때 transferred는 「전송된 길이」가 아닙니다. 반환값을 확인하지 않고 사용하면, 초기화한 0이 그대로 「빈 패킷을 받았다」로 하류로 흘러가, 세션을 다시 만들어야 할 에러가 뭉개집니다. 게다가 증상이 「가끔 아무것도 오지 않는 기기」이므로, 원인에 도달하기까지가 깁니다. FALSE라면 GetLastError()그 자리에서 가져옵니다(사이에 다른 API를 하나만 끼워도 덮어써집니다)
  • 비동기에서는 LengthTransferred에 포인터를 넘기지 않는다. 공식 문서는 「Overlapped가 NULL이 아니면 LengthTransferred는 NULL이어도 된다」「NULL이 아닌 값을 넘긴 경우, WinUsb_ReadPipe에서 돌아온 시점의 값은 작업이 완료될 때까지 무의미」라고 명기하고 있습니다. 전송 길이는 WinUsb_GetOverlappedResult로 취득합니다.14 로컬 변수의 주소를 넘기는 것은, 값이 무의미할 뿐 아니라, 그 변수가 스코프를 벗어나는 설계라면 댕글링 포인터가 됩니다
  • WinUsb_FreeCloseHandle을 반드시 짝지어 둔다. WinUsb_Initialize가 성공할 때마다 인터페이스 핸들이 확보됩니다. 8.2절대로 착탈 때마다 세션을 다시 만드는 설계로 하면, 해제를 빠뜨린 만큼이 재접속 때마다 쌓입니다. 성공 경로뿐 아니라, 초기화 도중에 실패한 경로(WinUsb_Initialize는 성공했지만 파이프 설정에서 실패한 경우 등)에서도 반드시 지나가도록, 마무리는 한 곳에 모으십시오. 순서는 「처리 중인 I/O를 CancelIoEx로 멈춘다 → WinUsb_GetOverlappedResult로 완료를 회수한다 → WinUsb_FreeCloseHandle」입니다. 완료를 회수하기 전에 핸들을 닫으면, 커널이 아직 만지고 있는 버퍼를 해제해 버립니다
  • 앱의 중복 실행을 막는다. WinUSB는 여러 앱의 동시 접근을 지원하지 않으므로, 이중 실행 방지(이름 있는 뮤텍스 등)를 사양에 넣어 둡니다

C#에서 사용하려면, libusb의 Windows 백엔드가 WinUSB 위에 구현되어 있으므로, LibUsbDotNet 같은 래퍼가 선택지가 됩니다. 패키징된 앱에서는 Windows.Devices.Usb.UsbDevice도 사용할 수 있지만, Audio・HID・Image・Printer・Mass Storage・Smart Card・Audio/Video・Wireless Controller의 각 디바이스 클래스에는 접근할 수 없다는 명시적인 제한이 있습니다.15

6. 방식 D - 벤더 제공 SDK・전용 드라이버 ── 고르는 것이 아니라 떠안는 것

산업용 카메라, 계측기, POS 주변기기, 지문・정맥 인증, 전용 I/O 보드 ── 이런 것들은 벤더가 드라이버와 SDK를 세트로 제공하며, 그 외의 사용 방법이 사실상 불가능합니다. 방식 D는 선택지라기보다, 기기를 고른 시점에 정해져 있는 전제 조건입니다.

그렇기에, 기기 선정 단계에서 SDK의 제약을 파악해 두는 것이 그대로 설계가 됩니다. 확인해야 할 항목을 들겠습니다.

확인 항목 놓쳤을 때 일어나는 일
32bit/64bit 양쪽 대응 여부 64bit 앱에서 32bit 전용 DLL을 호출할 수 없어, 프로세스 분리가 필요해짐
API 형태(C DLL / COM / .NET) 호출 방식과 마셜링 설계가 달라짐. COM이라면 스레드 모델 제약이 붙음
스레드 제약(STA 필수, 콜백 스레드) UI 스레드를 막거나, 데드락에 빠짐
재배포 가능물과 배포 조건 인스톨러에 동봉할 수 없어, 고객사에서 수동 설치가 필요해짐
동봉 드라이버의 서명 상태 Windows 11의 새 빌드나 장치 PC에서 설치할 수 없음
지원 OS와 유지보수 기한 OS 갱신 시 앱째로 다시 만들어야 함
여러 대 동시 연결 가능 여부와 식별 방법 2대째를 연결한 시점에 무너짐
데모 앱 소스 유무 사양 불명 동작의 조사 비용이 급증함

표의 첫째 행인 bitness(비트 폭)란, SDK의 DLL이 32bit판으로 빌드되었는지 64bit판인지를 의미합니다. 이것이 실무에 직결되는 이유는, Windows의 프로세스는 32bit와 64bit 코드를 같은 프로세스 안에 혼재시킬 수 없기 때문입니다. 32bit 전용 DLL만 제공되는 SDK는, x64 빌드 앱에서 직접 호출할 수 없습니다(BadImageFormatException이나 LoadLibrary의 실패가 됩니다). 회피하려면, 앱 전체를 x86으로 빌드하거나, SDK를 호출하는 부분만 32bit의 별도 프로세스로 몰아내 프로세스 간 통신으로 연결해야 합니다. 어느 쪽이든 앱 구조에 관련된 결정이므로, 기기 선정 단계에서 알고 있어야 하는 항목입니다.

구현 측면에서는, SDK를 앱 전체에 직접 흩뿌리지 않는 것이 최대의 방어입니다. SDK 호출을 하나의 얇은 추상 계층(인터페이스) 뒤에 가두고, 앱 본체는 그 추상에 대해 작성합니다. 이렇게 해두면, 기기의 모델 변경・SDK의 메이저 버전업・벤더 교체의 영향이 한 곳으로 끝나며, 기기 없이도 단위 테스트를 작성할 수 있게 됩니다.

32bit 전용 SDK를 64bit 앱에서 사용해야 하는 경우는, 별도 프로세스로 몰아내 프로세스 간 통신으로 연결하는 것이 정석입니다. COM을 경유한다면 「32bit 앱에서 64bit DLL을 호출하는 방법 - COM 브리지가 도움이 되는 케이스 스터디」가 반대 방향의 같은 사고방식이며, 네이티브 DLL 호출 방식 자체는 「C#에서 네이티브 DLL을 쓴다면 C++/CLI 래퍼가 유력한 이유 - P/Invoke와 비교해 정리」에 정리해 두었습니다. 자식 프로세스의 생존 관리는 「Windows 앱에서 자식 프로세스를 안전하게 다루기 위한 체크리스트 - Job Object, 종료 전파, 표준 입출력, watchdog의 베스트 프랙티스」를 참조하십시오.

7. 4가지 방식의 판단표

관점 가상 COM HID WinUSB 벤더 SDK
드라이버 배포 불필요(CDC) / 표준적(VCP) 불필요 조건부 불필요, 대부분 INF 필요 필요
구현 난이도 낮음 중간 중간~높음 SDK에 따라 다름(천차만별)
처리량 낮음~중간(기기 의존・실측 필요) 낮음 높음 높음
지연 중간 중간(폴링 간격 의존) 낮음 낮음
여러 앱의 동시 이용 불가(포트 배타) 가능(공유 TLC라면) 불가 SDK에 따라 다름
기기의 고유 식별 구현 필요(COM 번호는 불가) VID/PID/시리얼로 가능 디바이스 경로로 가능 SDK에 따라 다름
현장에서의 원인 분리 용이성 높음(터미널 소프트웨어) 중간 낮음 낮음
기기 펌웨어에 대한 의존 작음 작음 (OS 기술자) 전부
적합한 용도 명령 응답형 장치・계측기 상태 통지・작은 명령 대량 데이터・독자 프로토콜 카메라・계측기・전용기

판단 순서는 다음과 같습니다.

  1. 기기가 이미 COM 포트/HID/표준 클래스로 보이는가 → 보인다면 그것을 사용한다
  2. 보이지 않지만, 펌웨어에 손을 댈 수 있다 → HID(작은 데이터)인지 WinUSB(큰 데이터)인지를 대역으로 결정한다
  3. 펌웨어에 손댈 수 없고, 벤더 SDK가 있다 → SDK를 사용한다. 다만 6장의 점검을 먼저 한다
  4. 어느 것도 해당하지 않고, 여러 앱에서의 동시 접근이 필요하다 → UMDF 드라이버 개발을 검토한다2

8. 어느 방식이든 직접 설계해야 하는 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장대로 하나의 기기가 여러 기능을 가질 수 있으며, 그 경우 각 기능이 같은 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_GetCapsUsagePage/Usage)
  • 디바이스 인터페이스 경로를 그대로 저장한다 ── 열거로 얻는 문자열은 기능 단위로 고유하므로, 이것을 키로 삼는 것이 가장 확실합니다

자사에서 기기 사양을 결정할 수 있는 입장이라면, 기능마다 다른 인터페이스 번호를 할당하고, 시리얼 번호를 반드시 보고하는 펌웨어 사양으로 해 두면, 소프트웨어 쪽 식별 로직이 극적으로 단순해집니다.

후자는 &를 포함하며, 꽂는 포트가 바뀌면 값도 바뀝니다.

다만 이 판정을 복합 디바이스의 자식 노드에 적용해서는 안 됩니다. 복합 디바이스에서는, CDC 기능이나 HID 컬렉션으로 열거되는 자식 PDO의 인스턴스 ID 끝은 Usbccgp.sys가 생성한 값이 되며, 물리 디바이스가 시리얼 번호를 보고하고 있어도 &를 포함합니다. 자식의 경로만 보고 「시리얼 번호 없음」이라고 판정하는 것은 오류입니다. 시리얼 번호는 부모의 USB 디바이스 노드 쪽에 있으므로, CM_Get_Parent(또는 DEVPKEY_Device_Parent)로 부모까지 거슬러 올라간 뒤, 그 인스턴스 ID를 보십시오.

USB\VID_1234&PID_5678\SN0001234           ← 부모(물리 디바이스). 여기에 시리얼 번호가 있음
 └ USB\VID_1234&PID_5678&MI_00\7&2a3b…    ← 자식. Usbccgp가 생성한 값이므로 & 를 포함(판정에 사용하지 않음)

같은 기종을 여러 대 연결하는 운용에서 시리얼 번호가 없는 기기를 선택해 버리면, 식별 수단이 「어느 USB 포트에 꽂는가」밖에 남지 않습니다. 그 경우는 허브의 포트를 고정하고, 운용 절차로서 라벨을 붙이는 것까지 포함해 설계합니다 ── 이것은 기술로 해결할 수 없는 부분이므로, 기기 선정 단계에서 깨달아야 합니다.

8.2 착탈에 대한 추적 ── 폴링하지 않는다

Timer로 1초마다 다시 열거하는 구현을 자주 보지만, Windows에는 통지 구조가 있습니다.

  • Windows 8 이후: CM_Register_NotificationCM_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로 기존 항목을 열거합니다. 순서를 반대로 하면, 그 틈에 꽂힌 기기를 놓칩니다
  • 대신, 중복이 발생하는 것을 전제로 구성합니다. 등록 후・열거 전에 활성화된 인터페이스는, 도착 통지와 목록 양쪽에 나타납니다. 양쪽을 그대로 도착 처리로 흘리면, 같은 기기의 세션을 이중으로 생성해, 두 번째 배타 오픈이 실패하거나, 확립된 상태를 덮어쓰거나 합니다. 디바이스 인터페이스 경로를 키로 한 집합을 갖고, 이미 아는 경로는 무시하는 중복 제거를 반드시 넣으십시오(이 집합은 제거 시에 지웁니다)
  • 콜백 안에서 블로킹될 수 있는 처리를 하지 않는다. 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가 돌고 있지 않은 대기 중에 뽑힌 경우를 잡기 위해 필요하지만, 이것만으로는 부족하다

여기서 반드시 구별해야 하는 것이 「스스로 취소한」 경우입니다. 응답 타임아웃에 의한 중단, 앱 종료 시 마무리, 사용자 조작에 의한 중단 ── 이런 경우에 CancelIoExCancellationTokenDispose를 사용하면, 정상 동작인데도 ERROR_OPERATION_ABORTEDOperationCanceledExceptionObjectDisposedException이 발생합니다. 이것들을 무조건 「기기가 사라졌다」로 판정하면, 기기는 연결된 채인데 타임아웃 때마다 세션을 버리고 재접속하는 성질이 나쁜 루프가 만들어집니다.

// 「스스로 멈춘 것인지, 기기가 사라진 것인지」를 상태로 판별한다.
// 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 쪽도, OperationCanceledExceptionObjectDisposedException을 무조건 「기기가 사라졌다」로 판정하지 않도록 작성하십시오.

어느 경로로 들어와도 같은 마무리가 되도록, 세션 파기는 멱등한 하나의 처리로 모으고, 이중 호출로 망가지지 않도록 해 둡니다(Interlocked.Exchange로 플래그를 세워 먼저 도착한 1회만 실행하는 등). 썩은 핸들에 대한 I/O가 던지는 예외는, 종종 잡기 어려운 곳에서 날아옵니다 ── 그렇기에, 예외의 발생원 쪽에서 잡아 상태 전이로 연결하는 설계가 필요합니다.

8.3 타임아웃과 재접속 ── 「타임아웃 1개」로는 부족하다

USB 기기의 I/O는, 뽑혔다・전원이 꺼졌다・펌웨어가 멈췄다, 어느 경우든 「돌아오지 않는다」는 같은 증상이 됩니다. 타임아웃은 의미별로 나누어 갖습니다.

타임아웃 대상 기준
오픈 타임아웃 기기를 열 때까지 초 단위
응답 타임아웃 명령 발행부터 응답 완료까지 기기 사양의 최악값 × 안전율
바이트 간 타임아웃 프레임 도중에 뒷부분이 오지 않음 통신 속도로부터 산출
재접속 백오프 재오픈의 대기 간격 지수 백오프 + 상한

그리고 타임아웃은 「느릴 때의 보험」이 아니라 「상태 전이를 진행하는 규칙」으로 취급하십시오. 타임아웃됐을 때 어느 상태로 이동하는지, 처리 중인 요청을 어떻게 실패시킬지, UI에 무엇을 표시할지까지 정해야 비로소 설계가 됩니다. UI 표시 방법은 「외부 기기의 상태 확인과 표시의 베스트 프랙티스」에서 다루고 있습니다 ── 「연결 중」이라는 한마디로 끝내지 마십시오.

8.4 전원 관리 ── 「뽑지 않았는데 반응이 느리다」의 정체

USB의 셀렉티브 서스펜드는, 유휴 상태의 기기를 저소비 전력 상태로 넣는 구조입니다. 복귀에 시간이 걸리므로, 「처음 1회만 응답이 느리다」「잠시 방치하면 첫 번째 명령을 놓친다」는 증상의 범인이 됩니다.

  • Usbser.sys(가상 COM)에서는 기본값으로 비활성화되어 있으며, 레지스트리의 IdleUsbSelectiveSuspendPolicy로 활성화・설정합니다3
  • WinUSB에서는, 확장 속성 OS 기능 디스크립터(또는 INF)의 DeviceIdleEnabledDefaultIdleTimeoutUserSetDeviceIdleEnabled 등으로 제어합니다5

현장에서 먼저 확인할 것은, 장치 관리자의 해당 디바이스(및 USB 루트 허브) 속성에 있는 「전원 절약을 위해 컴퓨터가 이 디바이스의 전원을 끌 수 있도록 허용」 체크박스입니다. 장치 PC에서는, 이것을 해제하는 것만으로 해결되는 결함이 실제로 있습니다. 노트북의 절전 설정까지 포함해, 검증은 실제 운용과 같은 전원 관리 옵션으로 진행하십시오.

9. 성능과 지연의 견적

방식 선정 단계에서, 필요한 대역과 지연을 숫자로 만들어 두면 나중에 되돌아갈 일이 없어집니다.

전송 타입 사용하는 방식 특징
제어 전송 전 방식(내부에서 사용) 설정・작은 명령용. 대역 보장 없음
인터럽트 전송 HID, WinUSB 정기 폴링. 저지연이지만 소용량
벌크 전송 WinUSB, 마스 스토리지 대용량용. 대역 보장 없이, 남는 자리를 사용
아이소크로너스 전송 UVC(카메라), UAC(오디오), WinUSB(8.1 이후) 대역 보장 있음, 재전송 없음

USB 2.0의 인터럽트 엔드포인트는, 풀 스피드에서 최대 64바이트/패킷・1~255ms의 폴링 간격, 하이 스피드에서 최대 1024바이트・125µs 단위의 간격입니다.11 HID를 선택한다면, 이 상한에 대해 필요 대역이 한 자릿수 이상 여유가 있는지 확인하십시오.

또 하나, Windows는 범용 OS이므로 지연에 보장이 없습니다. 「10ms 주기로 반드시 응답한다」와 같은 요구를 앱 계층에서 만족시키는 것은 무리가 있습니다. 주기 제어가 본질적으로 필요하다면, 기기 쪽 마이크로컨트롤러에 가두고 PC는 지시와 감시에만 전념하는 설계로 하십시오. 이 경계는 「일반 Windows에서 소프트 리얼타임을 가능한 한 실현하기 위한 실전 가이드」에서 자세히 다루고 있습니다.

10. 배포와 운용 ── 드라이버를 배포하는 순간 비용이 달라진다

「드라이버 불필요」한 방식(표준 클래스・HID・WinUSB 디바이스)과, 「드라이버를 배포하는」 방식 사이에는, 개발 비용이 아니라 배포・유지보수 비용의 단층이 있습니다.

  • 「직접 .sys를 작성하지 않으니 서명은 불필요」는 잘못입니다. PnP의 디바이스 설치에서는, 드라이버 패키지의 카탈로그 파일에 서명이 없으면 Driver Store에 스테이징되지 않습니다.18 이것은 패키지 내용과 무관한 요건이므로, 5.2절처럼 Microsoft의 winusb.sys를 참조하기만 하는 INF라도, 카탈로그(.cat)를 생성해 서명하는 공정이 필요합니다. 「INF를 한 장 작성해 배포하면 동작한다」고 산정하면, 고객사에서 「이 디바이스의 드라이버는 서명되지 않았습니다」라고 거부당한 뒤에야 알아차리게 됩니다. 카탈로그 서명은, WHQL 릴리스 서명이나 서드파티의 릴리스 인증서(SPC)에 의한 서명입니다.18
  • 커널 모드 드라이버의 서명. Windows 10 버전 1607 이후, 신규 커널 모드 드라이버는 Dev Portal(Partner Center)을 통해 Microsoft에 서명받지 않으면 로드되지 않습니다. Partner Center 계정 개설에는 EV 코드 서명 인증서가 필요합니다.6 위의 카탈로그 서명과는 다른 레이어의 요건으로, 커널 모드 바이너리를 포함한다면 양쪽 다 충족해야 합니다.
  • 서명 경로는 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 앱의 배포 방식을 어떻게 고를까 - MSI / MSIX / ClickOnce / xcopy / 자체 updater의 판단표」에, 관리자 권한이 필요한 조건을 구별하는 법은 「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의 누락 마무리를 한 곳에 모으고, 실패 경로에서도 반드시 지나가게 함(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 표와 대조한 뒤 선택합니다. 드라이버를 배포하지 않아도 되는 방식이 있다면, 그것을 선택하는 것이 실무상 거의 항상 정답입니다.

관련 글

관련 상담 영역

합동회사 코무라소프트에서는 USB 연결 장치・계측기와 Windows 앱의 연동 설계, 기존 SDK의 래핑과 64bit화, 착탈이나 재접속으로 불안정해지는 장치 연동 앱의 원인 조사와 개수를 다루고 있습니다.

참고 링크

  1. Microsoft Learn, USB device class drivers included in Windows. Windows가 표준으로 동봉하는 USB 클래스 드라이버 목록(Usbaudio.sys / Usbser.sys / Hidclass.sys・Hidusb.sys / Usbscan.sys / Usbprint.sys / Usbstor.sys / Usbvideo.sys 등), 지원 대상 디바이스 클래스에는 벤더가 드라이버를 작성해서는 안 된다는 점, Vendor Specific(FFh)을 포함한 미분류 클래스에서는 WinUSB(Winusb.sys)가 권장된다는 점, 복합 디바이스에서는 Usbccgp.sys가 기능별로 PDO를 생성한다는 점, 셋업 클래스 USBDevice({88BAE032-5A81-49f0-BC3D-A4FF138216D6})와 USB 클래스의 구분 사용에 대해. CDC(02h) 행에 있는 「Windows 10에서는, Usbser.inf가 Usbser.sys를 기능 드라이버로 자동 로드한다」는 기술, 그리고 서브클래스 02h(ACM)를 mdmcpq.inf를 참조하는 커스텀 INF로 다루는 경로에 대해서도 같은 페이지에 근거한다.  2 3 4 5

  2. Microsoft Learn, Choose a driver model for developing a USB client driver. 「가장 단순한 방법부터 시작한다」는 선정 순서(표준 클래스 드라이버 → WinUSB → UMDF → KMDF), WinUSB가 적합한 것은 단일 앱으로부터의 접근・벌크/인터럽트/아이소크로너스 엔드포인트・Windows XP SP2 이후를 대상으로 하는 경우라는 점, 여러 앱의 동시 접근에는 WinUSB를 사용할 수 없다는 점, WinUSB / UMDF / KMDF의 기능 비교표(아이소크로너스 전송은 Windows 8.1 이후에서 WinUSB가 지원, UMDF는 미지원)에 대해.  2 3 4 5

  3. Microsoft Learn, USB serial driver (Usbser.sys). 디바이스 디스크립터에서 클래스 02・서브클래스 02를 설정하면 호환 ID(USB\Class_02&SubClass_02)로 표준 Usbser.inf가 매칭되어, 독자 INF 배포 없이 Usbser.sys가 자동으로 로드된다는 점, 서브클래스가 02가 아니면 자동 로드되지 않는다는 점, Windows.Devices.SerialCommunication 네임스페이스로 CDC 디바이스와 통신할 수 있다는 점, 셀렉티브 서스펜드가 기본값으로 비활성화되어 있으며 레지스트리의 IdleUsbSelectiveSuspendPolicy로 설정할 수 있다는 점에 대해.  2 3

  4. Microsoft Learn, HID Architecture. HID 클래스 드라이버(hidclass.sys)가 HID 클라이언트와 트랜스포트 사이를 추상화한다는 점, Windows가 지원하는 최상위 컬렉션 목록과 접근 모드(마우스・키보드・펜・터치스크린・고정밀 터치패드는 배타, 게임 컨트롤러・센서・바코드 스캐너 등은 공유), 보안상의 이유로 Raw Input Manager(RIM)가 이런 디바이스들을 배타적으로 연다는 점, 배타적으로 열려 있어도 읽기/쓰기 권한을 요청하지 않고 핸들을 열면 HidD_GetXxx로 정보 취득이 가능하다는 점에 대해.  2 3 4

  5. Microsoft Learn, WinUSB Device. WinUSB 디바이스란 펌웨어가 Microsoft OS 기능 기술자로 호환 ID로서 WINUSB를 보고하는 USB 디바이스이며, 커스텀 INF 없이 Winusb.sys가 로드된다는 점, Windows 8 이전에는 호환 ID에 의한 자동 매칭이 존재하지 않아 커스텀 INF가 필수였다는 점(Windows 8에서 표준 탑재된 Winusb.inf가 USB\MS_COMP_WINUSB에 대응했고, 그 이전 버전용으로는 갱신판 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

  6. Microsoft Learn, Driver Signing Policy. Windows 10 버전 1607 이후, Dev Portal에서 서명되지 않은 신규 커널 모드 드라이버는 로드되지 않는다는 점, Windows Hardware Dev Center 프로그램 등록에 EV 코드 서명 인증서가 필요하다는 점, 교차 서명 드라이버가 허용되는 예외 조건(1607로의 업그레이드, 보안 부팅 비활성화, 2015년 7월 29일 이전에 발급된 종단 인증서)에 대해.  2 3 4

  7. Microsoft Learn, Opening HID collections. 사용자 모드 앱이 SetupDi* 함수로 HID 컬렉션을 특정하고, CreateFile로 열고, HidD_Xxx로 프리파스드 데이터와 정보를 취득하고, ReadFile로 입력 리포트를 읽고 WriteFile로 출력 리포트를 보내고, HidP_Xxx로 리포트를 해석하는 일련의 절차에 대해. 

  8. Microsoft Learn, HIDP_CAPS structure (hidpi.h). 구조체의 완전한 정의(Usage / UsagePage / InputReportByteLength / OutputReportByteLength / FeatureReportByteLength / Reserved[17] / NumberLinkCollectionNodes 이하 10개의 Number 계열 멤버, 합계 USHORT×32), 그리고 각 리포트 길이가 리포트 ID의 1바이트를 포함한 값이라는 점에 대해. 

  9. Microsoft Learn, Sending HID Reports. 사용자 모드 앱이 출력 리포트를 지속적으로 보내려면 WriteFile을 사용한다는 점, HidD_SetXxx 계열 루틴(HidD_SetOutputReport / HidD_SetFeature)으로도 출력 리포트나 기능 리포트를 보낼 수 있지만, HidD_SetXxx는 컬렉션의 현재 상태를 설정하는 용도로 한정해 사용해야 한다는 점, 「일부 디바이스는 HidD_SetOutputReport를 지원하지 않으며, 이 루틴을 사용하면 응답이 없어질 수 있다」고 경고되어 있다는 점에 대해. 아울러 HidD_SetOutputReport function의, ReportBufferLength가 HIDP_CAPS의 OutputReportByteLength로 정해진다는 점, 리포트 ID를 사용하지 않는 경우는 맨 앞 바이트를 0으로 한다는 점에 대해.  2

  10. Microsoft Learn, HidDevice Class (Windows.Devices.HumanInterfaceDevice). HidDevice가 최상위 컬렉션에 대응하는 디바이스를 나타낸다는 점, GetDeviceSelector로 usagePage / usageId / vendorId / productId에서 AQS 셀렉터를 만들어 FromIdAsync로 여는 흐름, 이 클래스로 HID 디바이스에 접근하는 앱은 매니페스트의 Capabilities 노드에 고유한 DeviceCapability 데이터를 포함해야 한다는 점에 대해. 

  11. USB Implementers Forum, Universal Serial Bus Specification Revision 2.0. 인터럽트 엔드포인트의 최대 패킷 길이가 풀 스피드에서 64바이트・하이 스피드에서 1024바이트라는 점, 폴링 간격(bInterval)이 풀 스피드에서는 1~255밀리초, 하이 스피드에서는 125마이크로초를 단위로 하는 2^(bInterval-1)로 표현된다는 점(섹션 9.6.6 Endpoint), 그리고 제어・벌크・인터럽트・아이소크로너스 각 전송 타입의 대역 특성에 대해.  2

  12. Microsoft Learn, Microsoft OS 2.0 Descriptors Specification. Microsoft OS 기술자의 버전 2.0이 버전 1.0의 제약과 신뢰성 문제를 해소하기 위해 제정되었다는 점, 대상 OS가 Windows 10 및 Windows 8.1 Preview라는 점에 대해. 

  13. Microsoft Learn, WinUSB (Winusb.sys) Installation for Developers. 디바이스의 Device Parameters 키 아래에 문자열 항목 DeviceInterfaceGUID 또는 다중 문자열 항목 DeviceInterfaceGUIDs를 추가해 GUID를 설정하는 것, 커스텀 INF의 AddReg에서는 HKR,,DeviceInterfaceGUIDs,0x10000,"{...}"(0x10000 = REG_MULTI_SZ)와 같이 기술하는 것, 「Winusb.sys가 기능 드라이버로 로드되면 레지스트리 값 DeviceInterfaceGUIDs 키를 읽고, 지정된 GUID로 디바이스 인터페이스를 나타낸다」「Winusb.sys는 로드될 때마다 DeviceInterfaceGUIDs 키 아래에 지정된 디바이스 인터페이스 클래스로 디바이스 인터페이스를 등록한다」는 것, 사용자 모드 쪽은 SetupDiGetClassDevs로 등록된 인터페이스를 열거한 뒤 WinUsb_Initialize에 넘긴다는 것, 그리고 드라이버 패키지에는 서명된 카탈로그 파일이 필요하다는 것에 대해.  2 3 4

  14. Microsoft Learn, WinUsb_ReadPipe function (winusb.h). Overlapped를 지정하면 함수가 즉시 돌아와 작업이 비동기로 실행된다는 점, 그 경우 GetLastError가 ERROR_IO_PENDING을 반환하며 WinUsb_GetOverlappedResult로 성패를 확인한다는 점, 비동기(Overlapped가 NULL이 아님)에서는 LengthTransferred에 NULL을 설정해도 된다는 점, LengthTransferred에 NULL이 아닌 값을 넘긴 경우에도 함수에서 돌아온 시점의 값은 오버랩 작업이 완료될 때까지 무의미(meaningless)하며, 실제 읽은 바이트 수는 WinUsb_GetOverlappedResult로 취득해야 한다는 점, 동기 호출(Overlapped가 NULL)에서는 LengthTransferred를 NULL이 아니게 해야 한다는 점에 대해.  2

  15. Microsoft Learn, Windows.Devices.Usb Namespace. 이 네임스페이스가 대상으로 하는 것은 표준 탑재된 winusb.sys가 다루는 WinUSB 디바이스(호환 ID USB\MS_COMP_WINUSB)라는 점, Audio(0x01) / HID(0x03) / Image(0x06) / Printer(0x07) / Mass Storage(0x08) / Smart Card(0x0B) / Audio/Video(0x10) / Wireless Controller(0xE0)의 각 디바이스 클래스에는 접근할 수 없다는 점, 매니페스트에 usb 디바이스 기능 선언이 필요하며 Windows 10 버전 1809 이후는 VendorId/ProductId 지정이 불필요해졌다는 점, 상위/하위 필터 드라이버를 포함한 디바이스 스택은 일반적으로 접근할 수 없다는 점에 대해. 

  16. Microsoft Learn, CM_Register_Notification function (cfgmgr32.h). Windows 8 이후에서 사용 가능하며 Windows 7 이전을 대상으로 하는 경우는 RegisterDeviceNotification을 사용한다는 점, CM_NOTIFY_FILTER_TYPE_DEVICEINTERFACE / DEVICEHANDLE / DEVICEINSTANCE의 각 필터 타입, PnP 이벤트는 가능한 한 빠르게 처리하고 I/O 등 블로킹될 수 있는 처리는 별도 스레드에서 비동기로 수행해야 한다는 점, 이 함수는 기존 디바이스 인터페이스를 통지하지 않으므로 등록 후에 CM_Get_Device_Interface_List를 호출해야 하며, 그 사이에 활성화된 인터페이스는 통지와 목록 양쪽에 나타난다는 점에 대해.  2

  17. Microsoft Learn, RegisterDeviceNotificationW function (winuser.h). 애플리케이션이 디바이스 통지를 받기 위한 등록 함수라는 점, 성공 시 디바이스 통지 핸들을 반환하고 실패 시 NULL을 반환한다는 점에 대해. 아울러 WM_DEVICECHANGE messageDBT_DEVICEARRIVAL event의, 디바이스나 미디어가 삽입되어 사용 가능해졌을 때 wParam을 DBT_DEVICEARRIVAL로 하여 WM_DEVICECHANGE가 브로드캐스트된다는 점에 대해. 

  18. Microsoft Learn, PnP Device Installation Signing Requirements. 드라이버 패키지를 Driver Store에 스테이징하려면 서명 요건을 충족해야 한다는 점, PnP의 디바이스 설치에서 「서명됨」으로 간주되려면 드라이버 패키지의 카탈로그 파일이 WHQL 또는 서드파티의 릴리스 인증서(SPC・상용 릴리스 인증서)로 서명되어 있어야 한다는 점, 커널 모드 드라이버의 바이너리를 로드하기 위한 서명 요건은 이것과 별도로 부과된다는 점, 64bit판 Windows에서는 커널 모드 코드 서명 정책에 의해 WHQL 또는 SPC에 의한 서명이 요구된다는 점, Windows 10 in S mode 등 일부 에디션에서는 WHQL 서명 카탈로그만 허용된다는 점에 대해.  2

  19. 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가 되지는 않는다는 점에 대해. 

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

USB 기기를 앱에서 사용하고 싶은데, 드라이버를 직접 작성해야 하나요?
대부분의 경우 필요하지 않습니다. Microsoft의 공식 가이드라인도 「가장 단순한 방법부터 시작해, 필요할 때만 복잡한 방법으로 넘어간다」라고 명시하고 있습니다. 기기가 USB의 표준 클래스(CDC・HID・마스 스토리지 등)에 속한다면 Windows 표준의 클래스 드라이버가 자동으로 올라가므로 드라이버가 필요 없습니다. 표준 클래스에 속하지 않고, 하나의 앱에서만 접근한다면 WinUSB(winusb.sys)를 그대로 기능 드라이버로 쓸 수 있습니다. 여러 앱이 동시에 접근해야 할 때 비로소 UMDF 드라이버, 그것도 안 되면 KMDF 드라이버라는 순서가 됩니다. 자사에서 드라이버를 직접 작성하는 것은 최후의 수단입니다.
가상 COM 포트(USB 시리얼) 방식의 가장 큰 약점은 무엇인가요?
COM 포트 번호가 기기의 ID가 아니라는 점입니다. 같은 기기라도 꽂는 USB 포트를 바꾸면 COM 번호가 바뀔 수 있고, 여러 대를 연결하면 어느 것이 어느 것인지 번호만으로는 구별할 수 없습니다. 설정 파일에 「COM3」이라고 적어두는 운용은 현장에서 반드시 깨집니다. 실무에서는 Win32_PnPEntity 등에서 VID/PID・시리얼 번호와 COM 번호의 대응을 실행 시점에 매칭해 여는 것이 정답입니다. 게다가 시리얼 통신은 바이트 스트림이므로 메시지 경계가 보장되지 않아, 수신 버퍼에 쌓은 뒤 프레임을 잘라내는 파서가 별도로 필요합니다.
HID 방식은 드라이버가 필요 없어 손쉽다고 하는데, 무엇에 주의해야 하나요?
세 가지가 있습니다. 첫째는 속도로, HID는 인터럽트 전송을 사용하므로 대량 데이터의 연속 전송에는 맞지 않습니다. 둘째는 리포트 길이로, ReadFile에 넘기는 버퍼는 HidP_GetCaps가 반환하는 InputReportByteLength와 정확히 같아야 하며, 맨 앞 1바이트는 리포트 ID입니다. 여기를 틀리면 읽지 못하거나 앱이 죽는 전형적인 결함이 됩니다. 셋째는 배타 제어로, 마우스・키보드・터치스크린・펜에 해당하는 최상위 컬렉션은 Windows의 Raw Input Manager가 배타적으로 열기 때문에 앱에서는 읽고 쓸 수 없습니다. 다만 읽기/쓰기 권한을 요청하지 않고 핸들을 열면 HidD_GetXxx 계열로 정보 취득은 가능합니다.
WinUSB를 사용하고 싶은데, 기존 기기에 그대로 적용할 수 있나요?
적용할 수 없는 경우가 많습니다. INF 파일 없이 winusb.sys가 자동으로 올라가는 것은, 기기의 펌웨어가 Microsoft OS 기술자를 갖고 있고 호환 ID로 WINUSB를 보고하는 「WinUSB 디바이스」를, Windows 8 이후에서 사용하는 경우뿐입니다. 표준 탑재된 Winusb.inf가 호환 ID에 대응한 것이 Windows 8이므로, Windows 7 이전도 대상이라면 INF 배포가 전제가 됩니다. 기존 기기가 WinUSB 디바이스에 해당하지 않는 경우에도, 하드웨어 ID를 지정한 커스텀 INF를 직접 준비해 배포・설치해야 합니다. 개발 중에 Zadig 같은 도구로 드라이버를 교체하는 것은 검증으로는 유효하지만, 벤더의 드라이버를 떼어내는 행위이므로 실제 배포 수단으로 삼지 마십시오. 펌웨어에 손을 댈 수 있다면 OS 기술자를 추가하는 것이 가장 깔끔한 해결책이며, 신규 설계라면 BOS를 통해 통지하는 OS 2.0 기술자(Windows 8.1 이후)가 최우선 후보가 됩니다.
USB 기기의 착탈에 앱이 추적하려면 어떻게 해야 하나요?
폴링이 아니라 PnP 통지를 구독합니다. Windows 8 이후의 데스크톱 앱이라면 CM_Register_Notification에 디바이스 인터페이스 필터를 지정하는 것이 표준적이며, Windows 7 이전도 대상이라면 RegisterDeviceNotification과 WM_DEVICECHANGE를 사용합니다. 주의할 점으로, CM_Register_Notification은 등록 시점에 이미 존재하는 인터페이스는 통지하지 않으므로, 등록 후에 CM_Get_Device_Interface_List로 기존 항목을 열거하는 순서로 진행합니다(순서를 반대로 하면 누락됩니다). 또한 콜백 안에서 I/O 등 블로킹될 수 있는 처리를 하면 위험하므로 별도 스레드로 넘기십시오. 그리고 중요한 것은, PnP 통지를 유일한 진입점으로 삼지 않는 것입니다. 처리 중인 I/O는 통지보다 먼저, 혹은 동시에, 삭제나 취소 오류로 완료될 수 있습니다. 모든 읽기/쓰기의 완료 경로에서 삭제 계열 오류를 세션 종료로 취급하고, PnP 통지는 대기 중의 연결 해제를 잡아내는 보조 신호로 조합하십시오.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기