USB Descriptor là gì?

Giải thích khái niệm và cấu trúc của USB Descriptor kèm ví dụ. Lưu ý về Boot Protocol trong Configuration Descriptor

27 phút đọc
Đọc bằng Tiếng Việt English
STM32 / Firmware cover

Bài 3/8 trong series USB Device trên STM32.

Trước khi đọc bài này

Nếu bạn chưa đọc bài 2 Transfer Types thi nên xem lại trước vì bài này dùng khái niệm Control Transfer, Interrupt Transfer và Setup Packet đã giải thích ở trong bài đó.

Bài này tập trung vào lý thuyết nền tảng như cấu trúc descriptor, cách host xử lý và các field hay gây nhầm lẫn. Descriptor thật của project trong series (VID/PID, cấu hình composite HID + CDC + Vendor) được dùng làm ví dụ minh hoạ xuyên suốt trong bài nhưng không đi sâu vào quá trình debug hay từng bug cụ thể. Những phần đó dược đặt ở các phần sau trong series và ở trang project đi kèm.

Ngay từ đầu bài tôi có nhắc tới HID. Nếu bạn là người mới chưa biết gì về nó thì có thể tham khảo ghi chú ngắn của tôi trước để hiểu khái niệm cơ bản: USB Device Class và Composite Device là gì?

Ý chính bài này

  • Host không đọc source code của firmware. Host chỉ đọc descriptor trong quá trình enumeration để quyết định loại thiết bị, driver, interface và endpoint tương ứng.
  • Descriptor có cấu trúc phân tầng: Device chứa Configuration, Configuration chứa Interface, Endpoint và các descriptor đặc thù theo class.
  • HID có thêm HID Descriptor(mô tả thiết bị) và HID Report Descriptor(mô tả dữ liệu).
  • Composite device cần đặc biệt chú ý bNumInterfaces, wTotalLength, IAD và bộ ba bDeviceClass/SubClass/Protocol.
  • Nên triển khai descriptor theo từng bước, không viết hết một lần rồi mới test.

Descriptor là gì?

Descriptor là những mảng byte mà USB device gửi cho host trong quá trình enumeration, có cấu trúc phân tầng cố định.

Sơ đồ dưới đây biểu diễn cấu trúc tổng quan của các descriptor trong một thiết bị USB:

Device Descriptor (1)
└─ Configuration Descriptor (1..n)
   ├─ Interface Descriptor (1..n)
   │  ├─ Class-specific Descriptor  (vd: HID Descriptor)
   │  └─ Endpoint Descriptor (0..n, không tính EP0)
   │
   └─ Interface Descriptor tiếp theo (nếu composite)

String Descriptor - đứng riêng, host đọc khi cần (iManufacturer, iProduct...)

HID Report Descriptor - đứng riêng, chỉ HID interface mới có,
                         đọc bằng GET_DESCRIPTOR(Report) riêng

Khi cắm USB, host và device sẽ thực thi quá trình enumration theo các chuỗi bước sau:

Host → Device: GET_DESCRIPTOR (Device Descriptor)
Device → Host: 18 byte Device Descriptor

Host → Device: GET_DESCRIPTOR (Configuration Descriptor)
Device → Host: Configuration + Interface + Endpoint + class-specific

Host phân tích descriptor để quyết định: đây là loại thiết bị gì? dùng driver nào?
có bao nhiêu endpoint? giao tiếp ra sao?

Trong CubeMX, ST middleware tạo sẵn descriptor trong usbd_desc.c. Nhưng khi muốn tùy chỉnh ví dụ thêm interface để tạo composite device, sửa HID Report Descriptor để đổi format dữ liệu, hoặc thay VID/PID để host load đúng driver thì trước hết cần phải hiểu từng field. Chỉ cần sửa sai một byte cũng có thể khiến enumeration thất bại hoặc host diễn giải sai dữ liệu.

Dưới đây là capture thật quá trình enumeration của một thiết bị HID sau khi cắm USB:

Quá trình enumeration USB thật chụp từ Wireshark/USBPcap
Capture thật quá trình enumeration cho 1 thiết bị HID.

Device Descriptor: giới thiệu tổng quát

Device Descriptor là cấu trúc đầu tiên host đọc, luôn có độ dài cố định là 18 byte. Dưới đây là mảng thật trong usbd_desc.c của project với USBD_VID = 1155 (0x0483,ST Microelectronics) và USBD_PID = 22315 (0x572B):

uint8_t USBD_HID_DeviceDesc[USB_LEN_DEV_DESC] = {  0x12,                  /* bLength = 18 */  USB_DESC_TYPE_DEVICE,  /* bDescriptorType = 0x01 */  0x00, 0x02,            /* bcdUSB = 0x0200 (USB 2.0) */  0xEF,                  /* bDeviceClass: Miscellaneous - bắt buộc khi dùng IAD */  0x02,                  /* bDeviceSubClass: Common Class */  0x01,                  /* bDeviceProtocol: IAD */  USB_MAX_EP0_SIZE,      /* bMaxPacketSize0 = 64 byte, giá trị hợp lệ: 8/16/32/64 */  LOBYTE(USBD_VID), HIBYTE(USBD_VID),   /* idVendor = 0x0483 */  LOBYTE(USBD_PID), HIBYTE(USBD_PID),   /* idProduct = 0x572B */  0x00, 0x02,            /* bcdDevice = 0x0200 (firmware version 2.00) */  USBD_IDX_MFC_STR,      /* iManufacturer - index vào String Descriptor */  USBD_IDX_PRODUCT_STR,  /* iProduct */  USBD_IDX_SERIAL_STR,   /* iSerialNumber */  USBD_MAX_NUM_CONFIGURATION  /* bNumConfigurations = 1 */};

Những field thường phải chỉnh sửa:

  • bDeviceClass/SubClass/Protocol = 0xEF/0x02/0x01: Bộ ba này bắt buộc khi thiết bị dùng IAD (ví dụ composite có CDC). Nếu sai, USBView báo lỗi rất rõ:

      *!*ERROR: device class should be Multi-interface Function 0xEF
  • idVendor/idProduct: Đây là cặp định danh quan trọng nhất - host dựa vào VID/PID để chọn driver. 0x0483 là VID của ST, phù hợp cho phát triển và nội bộ. Nếu là sản phẩm thương mại thì cần mua VID riêng từ USB-IF.

  • bcdDevice: Version của firmware theo dạng BCD. 0x0200 = version 2.00. Rất hữu ích khi debug bởi nhìn vào Device Manager là biết ngay board đang chạy firmware nào.

  • iManufacturer/iProduct/iSerialNumber: Index vào String Descriptor. Trong project đi kèm thì iProduct = "STM32 USB HID 4x4 Macro Keypad". Chuỗi hiện trong Device Manager và cũng là chuỗi mà vendor_test.py in ra khi tìm thấy thiết bị (trong bài 7).

  • bMaxPacketSize0: Không phải field “trang trí”. Host luôn đọc 8 byte đầu của Device Descriptor trước, chỉ để lấy đúng giá trị này rồi mới yêu cầu toàn bộ 18 byte. Host cần biết kích thước packet tối đa của EP0 để chia các control transfer tiếp theo cho đúng. Sai field này ngay từ 8 byte đầu khiến enumeration thất bại từ rất sớm mà chẳng có thông báo gì.

Configuration Descriptor: power và số interface

Configuration Descriptor không đứng một mình, nó là phần mở đầu của một khối lớn chứa toàn bộ Interface và Endpoint Descriptor bên dưới. Khi host gửi request GET_DESCRIPTOR(Configuration), thiết bị phải trả về toàn bộ khối này trong một lần.

Dưới đây là phần mở đầu của configuration descriptor được triển khai trong project đi kèm:

/* Configuration Descriptor header - 9 byte */uint8_t USBD_CfgDesc[] = {    0x09,        /* bLength = 9 */    0x02,        /* bDescriptorType = 0x02 (Configuration) */    LOBYTE(wTotalLength), HIBYTE(wTotalLength),                 /* wTotalLength = tổng kích thước của toàn bộ khối                  * (Config + tất cả Interface + Endpoint bên dưới) */    0x03,        /* bNumInterfaces = 3 (composite: HID + CDC Comm + CDC Data) */    0x01,        /* bConfigurationValue = 1 - ID của config này */    0x00,        /* iConfiguration - string index, 0 = không có */    0x80,        /* bmAttributes: xem giải thích bên dưới */    0x32,        /* bMaxPower = 0x32 = 50 x 2mA = 100mA */    /* --- Interface Descriptors và Endpoint Descriptors tiếp theo --- */};

wTotalLength phải bằng đúng tổng kích thước của toàn bộ chuỗi descriptor. Thực tế khi host đọc Configuration Descriptor, nó thường làm hai bước: đọc 9 byte đầu lấy wTotalLength rồi dựa vào giá trị này để request toàn bộ Config + Interface + Endpoint + class-specific descriptors. Vì vậy sai giá trị này là host cắt ngắn dữ liệu hoặc đọc vượt ranh giới sang vùng không phải descriptor hay phân tích sai toàn bộ cấu trúc descriptor.

bNumInterfaces phải đếm đúng số interface thật sự có trong descriptor. Nếu số interface khai báo không khớp với số interface thực tế thì host có thể bỏ qua interface cuối hoặc phân tích sai cấu trúc của thiết bị composite.

Vì sao sai một byte cũng làm hỏng toàn bộ chuỗi?

Host không biết trước cấu trúc chuỗi descriptor. Nó đọc tuần tự theo các bước sau:

  1. Đọc bLength của descriptor hiện tại
  2. Nhảy đúng số byte đó để tới descriptor kế tiếp
  3. Đọc bDescriptorType để biết loại descriptor (Interface / Endpoint)
  4. Lặp lại từ bước 1 cho đến khi kết thúc

Nếu một descriptor ở giữa có bLength sai sẽ làm host nhảy lệch offset dẫn đến toàn bộ phần descriptor phía sau bị đọc sai chứ không chỉ đơn thuần là bị cắt ngắn ở cuối.

bmAttributes:

Đây là giá trị hay bị đặt sai khi khai báo descriptor. Nó thường là 1 trong 3 giá trị sau:

  • 0x80 = Bus-powered, không hỗ trợ remote wakeup (lấy nguồn từ dây VCC của USB)
  • 0xC0 = Self-powered (thiết bị có nguồn riêng ngoài USB)
  • 0xA0 = Bus-powered + Remote Wakeup

Trong series này, thiết bị khai báo bus-powered (0x80). Nếu khai Self-powered 0xC0 nhưng thực tế vẫn lấy nguồn từ USB thì không gây lỗi chức năng. Thiết bị vẫn chạy nhưng host có thể tính sai ngân sách dòng điện cấp cho các thiết bị khác trên cùng bus.

bMaxPower:

Đây là giá trị cực kì quan trọng cho thiết bị lấy nguồn từ USB (bus-powered).

Nó là lượng dòng điện thiết bị cần từ host với đơn vị là 2mA/LSB. Ví dụ trong project đi kèm có khai báo bMaxPower = 0x32 tức là thiết bị cần 100mA. Công thức tính như sau:

0x32 = 50 x 2mA = 100mA

Nếu board chỉ lấy nguồn từ USB thì cần phải khai báo đúng.

  • Khai báo quá thấp so với dòng thực tế tiêu thụ có thể khiến board thiếu điện khi hoạt động.
  • Khai báo quá cao thì một số host có thể từ chối enumerate nếu ngân sách điện của port không đủ cấp.

Interface Descriptor: mô tả chức năng

Mỗi Interface Descriptor mô tả một chức năng của device. Bộ ba bInterfaceClass/SubClass/Protocol là quan trọng nhất bởi host dùng ba giá trị này để chọn đúng class driver:

Giá trị Class Driver Chú thích
0x02 / 0x02 / 0x01 CDC ACM (Virtual COM Port) Abstract Control Model + AT Commands. Windows/Linux bind cdc_acm driver. Dùng trong project đi kèm.
0x03 / 0x00 / 0x00 HID - không subclass, không protocol Custom HID, không phải keyboard/mouse chuẩn.
0x03 / 0x01 / 0x01 HID Boot Keyboard Keyboard chuẩn, BIOS/UEFI dùng được trước khi OS load driver. Interface 0 của project đi kèm.
0x03 / 0x01 / 0x02 HID Boot Mouse Mouse chuẩn, đây cũng là descriptor mặc định CubeMX sinh ra kể cả khi chọn HID Keyboard.
0x08 / 0x06 / 0x50 Mass Storage (MSC) SCSI + Bulk Only Transport - USB drive. Không dùng trong project đi kèm.
0xFF / 0x00 / 0x00 Vendor Specific Không có class driver, cần cài WinUSB/libusbK qua Zadig (bài 7). Dùng trong project đi kèm.

Project đi kèm có dùng ba loại là: HID Boot Keyboard, CDC ACM, và Vendor Specific. Không có Mass Storage hay HID Mouse thật trong composite descriptor cuối cùng. Hai cái này được liệt kê để tham chiếu đầy đủ theo chuẩn USB.

Endpoint Descriptor: kênh dữ liệu

Mỗi endpoint (ngoài EP0) đều cần một Endpoint Descriptor. Nó mô tả nơi và cách thông tin được trao đổi giữa host và device theo từng chức năng.

Ví dụ dưới đây là endpoint descriptor cho thiết bị HID Boot Keyboard.

/* Endpoint Descriptor - 7 byte */0x07,        /* bLength = 7 */0x05,        /* bDescriptorType = 0x05 (Endpoint) */0x81,        /* bEndpointAddress - xem chú thích bên dưới */0x03,        /* bmAttributes: Transfer Type - đã giải thích ở bài 2 - 0x03 = Interrupt */0x40, 0x00,  /* wMaxPacketSize = 0x0040 = 64 byte */0x0A,        /* bInterval = 10ms polling - cách đọc field này đã giải thích ở bài 2 */

Field dễ bị hiểu nhầm nhất là bEndpointAddress. Cấu trúc bit của field này như sau:

bEndpointAddress (8-bit) : Address and direction of endpoint
Direction [7] Hướng truyền dữ liệu
0
OUT - Host → Device
1
IN - Device → Host
Reserved [6:4] Luôn bằng 0, không dùng
Endpoint Number [3:0] Số hiệu endpoint, giá trị 0-15

bit 7 sẽ quyết định hướng truyền dữ liệu từ góc nhìn của device. Ví dụ:

  • 0x81 = EP1 IN (device gửi lên host).
  • 0x01 = EP1 OUT (host gửi xuống device).

Hai endpoint cùng số nhưng khác direction này (EP1 IN và EP1 OUT) là hai endpoint riêng biệt.

Địa chỉ endpoint không được trùng nhau trong cùng configuration. STM32 Full Speed có tối đa 8 endpoint (EP0 + EP1-EP7), mỗi endpoint có thể IN, OUT hoặc cả hai tùy phần cứng.

HID Descriptor: đặc trưng của HID class

HID interface cần thêm một descriptor đặc biệt nằm giữa Interface Descriptor và Endpoint Descriptor:

/* HID Descriptor - 9 byte (minimum) */0x09,        /* bLength = 9 */0x21,        /* bDescriptorType = 0x21 (HID) */0x11, 0x01,  /* bcdHID = 0x0111 - HID spec version 1.11 */0x00,        /* bCountryCode = 0 (not localized) */0x01,        /* bNumDescriptors = 1 - số Report Descriptor */0x22,        /* bDescriptorType = 0x22 (Report Descriptor) */LOBYTE(HID_REPORT_DESC_SIZE), HIBYTE(HID_REPORT_DESC_SIZE),  /* wDescriptorLength */

Host đọc HID Descriptor này để biết Report Descriptor dài bao nhiêu, sau đó gửi thêm GET_DESCRIPTOR(Report) để lấy Report Descriptor thật sự.

Thứ tự trong Configuration Descriptor phải đúng:

Interface Descriptor (HID)
HID Descriptor          ← nằm ngay sau Interface, trước Endpoint
Endpoint Descriptor (Interrupt IN)

Nếu đặt sai thứ tự, host phân tích sai hoặc không nhận được HID Report Descriptor.

wDescriptorLength

wDescriptorLength phải khớp đúng kích thước mảng report descriptor thật. Nếu sai là host đọc thừa hoặc thiếu byte, phân tích sai cấu trúc report hoặc từ chối thiết bị ngay trong quá trình enumeration.

bCountryCode

bCountryCode cho biết thiết bị HID có được thiết kế cho một layout bàn phím địa phương cụ thể hay không. Giá trị 0x00 nghĩa là not localized, tức là thiết bị không khai báo một country hay layout đặc biệt nào.

Với HID Keyboard, firmware không gửi trực tiếp ký tự như A, @, #, ~, mà gửi Usage ID trong Usage Page 0x07 (Keyboard/Keypad). Sau đó hệ điều hành sẽ dựa vào keyboard layout đang được người dùng chọn (ví dụ US, Japanese, Vietnamese, German…) để ánh xạ Usage ID đó thành ký tự hiển thị trên màn hình.

Vì vậy bCountryCode không nên được hiểu là “chỉ cần đổi field này thì host sẽ tự đổi layout bàn phím”. Trong đa số hệ điều hành hiện đại, layout thực tế vẫn do OS/input method quyết định. Field này chủ yếu dùng để khai báo rằng phần cứng bàn phím được localize cho một quốc gia/layout cụ thể. Với một macro keypad hoặc HID keyboard đơn giản như project đi kèm, để bCountryCode = 0x00 là lựa chọn an toàn và phổ biến.

IAD (Interface Association Descriptor)

IAD cần thiết khi composite device có CDC trên Windows. Nó nhóm hai interface CDC (Comm và Data) lại thành một chức năng duy nhất để Windows nhận đúng driver và tạo cổng COM.

Dưới đây là IAD descriptor nằm trong Configuration Descriptor mô tả CDC class:

0x08,  /* bLength */0x0B,  /* bDescriptorType = IAD */0x01,  /* bFirstInterface = 1 (IF1 = CDC Comm) */0x02,  /* bInterfaceCount = 2 (IF1 + IF2) */0x02,  /* bFunctionClass = CDC */0x02,  /* bFunctionSubClass = ACM */0x01,  /* bFunctionProtocol = AT Commands */0x00,

Ngoài ra như đã trình bày trước đó thì trong Device descriptor cũng cần khai báo bộ ba bDeviceClass/SubClass/Protocol cho chính xác. Thiếu IAD hoặc bDeviceClass không phải 0xEF thì Windows không nhận ra hai interface CDC thuộc cùng một chức năng, không gán đúng driver và không tạo cổng COM cho thiết bị.

Phần này chỉ giải thích vai trò của IAD trong descriptor. Cách đưa IAD vào composite descriptor thật, cập nhật wTotalLength/bNumInterfaces, và các bug thật gặp phải khi viết composite class nằm ở trang project Composite Device.

HID Report Descriptor: ngôn ngữ mô tả data

Đây là phần phức tạp và thú vị nhất của HID. Report Descriptor không mô tả hardware, nó mô tả cấu trúc của data packet mà device gửi/nhận. Host đọc Report Descriptor một lần khi enumerate, sau đó biết cách interpret mọi byte trong mọi report về sau mà không cần driver riêng cho từng thiết bị.

Cú pháp: Item format

Mỗi item trong Report Descriptor dài 1-5 byte:

Byte 0: Tag (bit 7..4) | Type (bit 3..2) | Size (bit 1..0)
  Size: 00=0 byte, 01=1 byte, 10=2 byte, 11=4 byte
Byte 1..n: Data (nếu Size > 0)

Ví dụ: 0x05, 0x01

  • 0x05 = tag=0000 (Usage Page), type=01 (Global), size=01 (1 byte data)
  • 0x01 = value = 0x01 (Generic Desktop Controls)

Các item quan trọng

Global items - áp dụng cho tất cả field sau đó cho đến khi thay đổi:

0x05, xx  → Usage Page        : namespace cho Usage values
0x15, xx  → Logical Minimum   : giá trị nhỏ nhất data field có thể có
0x25, xx  → Logical Maximum   : giá trị lớn nhất
0x75, xx  → Report Size       : số bit của một field
0x95, xx  → Report Count      : số field
0x85, xx  → Report ID         : ID đặt ở byte đầu report (nếu dùng)

Local items - chỉ áp dụng cho item kế tiếp:

0x09, xx  → Usage             : ý nghĩa cụ thể của field
0x19, xx  → Usage Minimum     : dùng khi có range
0x29, xx  → Usage Maximum

Main items - tạo field thực sự:

0x81, xx  → Input      : field trong report gửi từ device lên host (IN)
0x91, xx  → Output     : field trong report gửi từ host xuống device (OUT)
0xA1, xx  → Collection : nhóm các field lại
0xC0      → End Collection

Data byte của Input/Output:

0x02 = Data, Variable, Absolute
  Data     : có thể thay đổi (≠ Constant)
  Variable : mỗi field là một giá trị riêng (≠ Array)
  Absolute : giá trị tuyệt đối (≠ Relative)
0x03 = Constant (padding - không dùng, chỉ để align byte)
0x06 = Data, Variable, Relative (dùng cho delta movement như chuột)

Usage Page - namespace cho Usage

Usage Page giống namespace trong lập trình. Cùng một Usage value (ví dụ 0x30) có ý nghĩa khác nhau tùy Usage Page:

Usage Page 0x01 (Generic Desktop): 0x30 = X axis
Usage Page 0x09 (Button):          0x30 = Button 48

Các Usage Page hay dùng:

0x01 = Generic Desktop Controls (X/Y/Z, mouse, keyboard, joystick)
0x07 = Keyboard/Keypad
0x08 = LEDs
0x09 = Buttons
0x0D = Digitizer (touchscreen, pen)
0xFF = Vendor Defined (custom, không có nghĩa chuẩn)

Bảng keycode đầy đủ của Usage Page 0x07 (a-z, số, phím chức năng…) dài hơn nhiều so với phạm vi bài này - xem HID Usage Tables là gì? để tra cứu khi cần.

Collection - nhóm field có liên quan

0xA1, 0x01  → Collection (Application) : nhóm top-level, mỗi HID device cần ít nhất 1
0xA1, 0x02  → Collection (Logical)     : nhóm logic trong Application
0xA1, 0x00  → Collection (Physical)    : nhóm dữ liệu từ cùng vị trí vật lý
0xC0        → End Collection

Ví dụ đọc HID Mouse Report Descriptor từng byte

Mouse là ví dụ cổ điển nhất để thấy cú pháp trên hoạt động ra sao. Đây là Report descriptor mặc dịnh cho USB HID device mà CubeMx sinh ra:

static uint8_t HID_MOUSE_ReportDesc[] = {    0x05, 0x01,  /* Usage Page (Generic Desktop Controls)     */    0x09, 0x02,  /* Usage (Mouse)                             */    0xA1, 0x01,  /* Collection (Application)                  */    0x09, 0x01,  /*   Usage (Pointer)                         */    0xA1, 0x00,  /*   Collection (Physical)                   */    0x05, 0x09,  /*     Usage Page (Button)                   */    0x19, 0x01,  /*     Usage Minimum (Button 1)              */    0x29, 0x03,  /*     Usage Maximum (Button 3)              */    0x15, 0x00,  /*     Logical Minimum (0) - button released */    0x25, 0x01,  /*     Logical Maximum (1) - button pressed  */    0x95, 0x03,  /*     Report Count (3) - 3 buttons          */    0x75, 0x01,  /*     Report Size (1) - 1 bit per button    */    0x81, 0x02,  /*     Input (Data, Variable, Absolute)      */                 /*     → 3 bit: [btn3][btn2][btn1]           */    0x95, 0x01,  /*     Report Count (1)                      */    0x75, 0x05,  /*     Report Size (5) - 5 bit padding       */    0x81, 0x03,  /*     Input (Constant) - padding to byte    */                 /*     → 5 bit: [0][0][0][0][0]              */    0x05, 0x01,  /*     Usage Page (Generic Desktop)          */    0x09, 0x30,  /*     Usage (X)                             */    0x09, 0x31,  /*     Usage (Y)                             */    0x09, 0x38,  /*     Usage (Wheel)                         */    0x15, 0x81,  /*     Logical Minimum (-127)                */    0x25, 0x7F,  /*     Logical Maximum (127)                 */    0x75, 0x08,  /*     Report Size (8) - 8 bit per axis      */    0x95, 0x03,  /*     Report Count (3) - X, Y, Wheel        */    0x81, 0x06,  /*     Input (Data, Variable, Relative)      */                 /*     → 3 byte: X delta, Y delta, Wheel     */    0xC0,        /*   End Collection (Physical)               */    0xC0,        /* End Collection (Application)              */};

Report packet sẽ có cấu trúc như sau:

Byte 0: [pad5][btn3][btn2][btn1]  ← 3 bit nút bấm + 5 bit padding
Byte 1: X movement (-127..+127)   ← signed delta
Byte 2: Y movement (-127..+127)
Byte 3: Wheel (-127..+127)

Tổng 4 byte / report. Host phân tích đúng cấu trúc này chỉ nhờ đọc descriptor, không cần code parser riêng cho từng loại chuột.

Boot Keyboard: có format Boot Protocol cố định

Ở ví dụ Mouse phía trên, host phải đọc Report Descriptor để biết report gồm những field nào: bit nào là button, byte nào là X/Y/Wheel. Với Boot Keyboard thì cần hiểu khác một chút.

HID Boot Keyboard vẫn cần có Report Descriptor hợp lệ. OS vẫn có thể đọc Report Descriptor đó khi chạy ở Report Protocol. Nhưng vì keyboard này khai báo là Boot Keyboard, nó còn phải hỗ trợ thêm một format report cố định theo Boot Protocol.

Format Boot Keyboard IN report là 8 byte:

Byte 0: modifier bitmap
Byte 1: reserved (0x00)
Byte 2: keycode[0]
Byte 3: keycode[1]
Byte 4: keycode[2]
Byte 5: keycode[3]
Byte 6: keycode[4]
Byte 7: keycode[5]

Trong đó Byte 0 là các phím modifier:

Modifier byte (Byte 0) (8-bit) : Trạng thái các phím modifier, mỗi bit là một phím riêng
Right GUI [7] Windows/Cmd phải
Right Alt [6] Alt phải
Right Shift [5] Shift phải
Right Ctrl [4] Ctrl phải
Left GUI [3] Windows/Cmd trái
Left Alt [2] Alt trái
Left Shift [1] Shift trái
Left Ctrl [0] Ctrl trái

Mỗi bit là một phím modifier riêng biệt, 1 = đang nhấn, 0 = không nhấn. Nhiều modifier có thể cùng 1 một lúc (ví dụ Ctrl+Shift).

Byte 2-7 chứa tối đa 6 keycode đang được nhấn cùng lúc. Vì vậy Boot Keyboard report thường được gọi là 8-byte keyboard report hoặc 6KRO report. Khi nhả phím, firmware cần gửi một report rỗng, tức là toàn bộ 8 byte bằng 0x00, để host biết phím đã được release.

Điểm quan trọng là Boot Protocol không cho firmware tự ý đổi format này. Lý do là BIOS/UEFI hoặc firmware trước khi OS load driver đầy đủ thường không parse một HID Report Descriptor phức tạp. Nó chỉ cần biết: nếu đây là Boot Keyboard, cứ đọc report 8 byte theo format cố định ở trên.

Host có thể chuyển giữa hai chế độ bằng HID class request SET_PROTOCOL:

wValue = 0  → Boot Protocol
wValue = 1  → Report Protocol

Điểm dễ nhầm: bInterfaceSubClass = 0x01bInterfaceProtocol trong descriptor không phải là protocol đang chạy. Chúng chỉ khai báo rằng interface có hỗ trợ Boot Protocol, giống như một lời quảng cáo khả năng. Protocol nào đang thật sự active là trạng thái runtime riêng, đổi qua lại bằng control transfer SET_PROTOCOL, và firmware phải tự lưu biến trạng thái để trả lời đúng khi host hỏi lại bằng GET_PROTOCOL.

Theo spec, mặc định ngay sau enumerate là Report Protocol. Trong thực tế:

  • BIOS/UEFI (chưa có driver HID đầy đủ) thường chủ động gửi SET_PROTOCOL(0) lúc POST để chuyển sang Boot Protocol - một số BIOS cũ hơn còn không gửi gì cả mà mặc định coi thiết bị đang ở Boot Protocol luôn, đây là hành vi legacy phổ biến dù không hoàn toàn đúng spec.
  • Khi OS load driver HID đầy đủ, OS thường gửi SET_PROTOCOL(1) để chuyển về Report Protocol và bắt đầu parse Report Descriptor riêng của thiết bị.

Vì vậy trong firmware, nếu muốn làm đúng chuẩn, device nên lưu trạng thái protocol hiện tại và trả report tương ứng. Với project keypad đơn giản này, Report Descriptor cũng mô tả đúng format keyboard 8 byte, nên cùng một buffer report có thể dùng cho cả hai chế độ. Tuy vậy, firmware vẫn nên xử lý đúng request SET_PROTOCOL, vì host có quyền gửi request này.

Trong Interface Descriptor, bộ ba sau cho host biết đây là HID Boot Keyboard:

bInterfaceClass    = 0x03  // HID
bInterfaceSubClass = 0x01  // Boot Interface Subclass
bInterfaceProtocol = 0x01  // Keyboard

Chỗ này cũng là nơi CubeMX dễ sinh sai. Nếu bInterfaceProtocol = 0x02, host sẽ hiểu interface này là Boot Mouse, không phải Boot Keyboard. Đây chính là lỗi descriptor đã gặp ở milestone lab-08.

Cấu trúc report 8 byte đầy đủ với ví dụ modifier/keycode thật của project này nằm ở bài 5 HID Keyboard Baseline.

Triển khai descriptor theo từng bước

Cách an toàn nhất để viết descriptor không phải là viết hết một lần cho đủ số interface cần có rồi mới cắm thử. Khi có quá nhiều interface như vậy thì rất khó để tìm ra lỗi một cách nhanh chóng và chính xác. Thay vào đó, cách tiếp cận thêm từng interface một sẽ tốn thêm chút thời gian nhưng cho hiệu suất debug tốt hơn nhiều. Sau khi hoàn thiện một interface, xác nhận lại bằng cách cắm USB và xem descriptor qua USBView, rồi mới thêm interface tiếp theo:

Bước 1: Device đơn giản, 1 interface       → xác nhận enumerate OK
Bước 2: Thêm interface thứ 2 (vd. CDC)     → xác nhận bNumInterfaces, IAD nếu cần
Bước 3: Thêm interface thứ 3 trở đi        → xác nhận wTotalLength mới, endpoint mới

Lý do phải làm từng bước là do khi descriptor sai, đôi khi host không thông báo lỗi một cách rõ ràng hoặc thường chỉ đưa các thông báo lỗi chung chung kiểu “Unknown USB device”. Một số lỗi khác (sai bNumInterfaces, sai vị trí HID Descriptor, sai IAD…) vẫn cho phép enumerate nhưng chỉ gây lỗi khi driver cố sử dụng interface đó. Nếu bạn vừa thêm một interface, vừa sửa wTotalLength/bNumInterfaces và nhiều chỗ khác cùng lúc, phạm vi nghi ngờ khi debug sẽ rất rộng. Ngược lại, nếu mỗi lần chỉ thêm đúng một interface, lỗi mới xuất hiện gần như chắc chắn nằm trong phần vừa thêm.

Điều này cũng có nghĩa là descriptor hiếm khi “xong” ngay từ lần đầu. Mỗi lần thêm interface mới, wTotalLengthbNumInterfaces đều phải cập nhật lại. Các nguyên tắc đã giải thích ở trên (đọc tuần tự theo bLength, vai trò của IAD, thứ tự HID Descriptor phải nằm trước Endpoint,…) đều được áp dụng lại y như cũ, chỉ thay đổi số liệu theo cấu trúc mới.

Tóm tắt

Descriptor quyết định tất cả. Host không biết gì về source code, không đoán ý firmware. Nó chỉ đọc tuần tự chuỗi byte tự mô tả do firmware cung cấp, từ Device Descriptor xuống tới Report Descriptor rồi dựa vào đó phân tích và xác nhận thiết bị được kết nối. Cấu trúc phân tầng cố định (Device chứa Configuration, Configuration chứa Interface và Endpoint) cùng cách host đọc tuần tự theo bLength/bDescriptorType là nền tảng bắt buộc phải nắm trước khi đi tiếp các bài sau trong series.

Với HID, có thêm một lớp mô tả riêng gọi là HID Report Descriptor. Lớp này giúp host biết cách đọc từng byte trong report. Mỗi bit và mỗi byte đều được mô tả rõ ràng trong đó. Đây là cơ chế chung áp dụng cho mọi thiết bị HID.

Nếu thiết bị khai báo là Boot Keyboard hoặc Boot Mouse, nó phải hỗ trợ thêm một định dạng cố định thứ hai gọi là Boot Protocol. Định dạng này được dùng trong các môi trường chưa có driver HID đầy đủ chẳng hạn như BIOS hoặc UEFI. Khi ở những môi trường này, host không thể đọc và hiểu HID Report Descriptor. Vì vậy nó chỉ dựa vào cấu trúc cố định của Boot Protocol để lấy dữ liệu phím hoặc chuột.

HID Report Descriptor và Boot Protocol tồn tại song song. Chúng không thay thế nhau và rất dễ bị nhầm lẫn vì đều mô tả report của cùng một thiết bị. Tuy nhiên, chúng phục vụ hai tình huống hoàn toàn khác nhau. HID Report Descriptor dành cho host đã có khả năng đọc và hiểu descriptor, còn Boot Protocol dành cho host chưa có khả năng đó.

Những khái niệm trong bài này như IAD, thứ tự HID Descriptor phải nằm trước Endpoint, cách tính wTotalLength, hay bDeviceClass của composite sẽ được dùng lại nhiều lần trong các bài sau khi bước vào triển khai thật, nơi có ví dụ cụ thể và kết quả đo được từ project

Bài tiếp theo trình bày key input pipeline, phần firmware nội bộ phía sau bàn phím, trước khi đến tầng USB transport.

Tài liệu tham khảo

Bài viết này hữu ích với bạn?

Chia sẻ, góp ý, hoặc ủng hộ nếu bạn thấy nội dung này có giá trị.

Góp ý