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
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ộ babDeviceClass/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:
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.0x0483là 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.pyin 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:
- Đọc
bLengthcủa descriptor hiện tại - Nhảy đúng số byte đó để tới descriptor kế tiếp
- Đọc
bDescriptorTypeđể biết loại descriptor (Interface / Endpoint) - 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:
Direction [7] Hướng truyền dữ liệu
-
0 - OUT - Host → Device
-
1 - IN - Device → Host
[6:4] Luôn bằng 0, không dùng [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:
[7] Windows/Cmd phải [6] Alt phải [5] Shift phải [4] Ctrl phải [3] Windows/Cmd trái [2] Alt trái [1] Shift trái [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 = 0x01 và bInterfaceProtocol 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, wTotalLength và bNumInterfaces đề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
- USB 2.0 Specification Section 9: Device Framework, descriptor structure
- USB in a NutShell, Chapter 5: Descriptors
- HID Class Specification 1.11 - HID Descriptor, Report Descriptor format
- HID Usage Tables 1.5 - Usage Page, Usage values
- STM32G0B1 Reference Manual RM0444 USB DRD_FS, PMA layout
- USB Device Tree Viewer - parse và hiển thị descriptor
- HID Usage Tables là gì? - tra cứu Usage ID, keycode đầy đủ
- HID Keyboard Report Format là gì? - modifier byte, keycode array, bCountryCode
- USB Enumeration Process là gì? - flow 7 bước enumeration đầy đủ
- USB Descriptor Overview là gì? - tra cứu nhanh cấu trúc phân tầng
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ị.