Hoàn thành HID keyboard thật từ sự kiện nhấn thả phím

Chuyển sự kiện nhấn thả phím thành HID report rồi gửi đến host kèm theo các lưu ý về Boot Protocol hay tap-style null report.

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

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

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

Bài 4 đã xây dựng pipeline từ keypad vật lý đến hàng đợi sự kiện. Bài này nối tiếp phần còn lại: xử lý từ event queue qua tầng HID report đến USB transport, đồng thời giải thích vì sao bước tưởng như đơn giản nhất (gọi một hàm gửi report) lại có nhiều cạm bẫy hơn.

Bài này giả định bạn đã quen với cấu trúc descriptor, cùng hai khái niệm HID Report Descriptor và Boot Protocol đã trình bày ở bài 3 USB Descriptor. Nếu bạn mới bắt đầu và chỉ muốn tra cứu nhanh các khái niệm này, hãy xem ghi chú ngắn: HID Keyboard Report Format là gì?

Ý chính của bài

  • CubeMX mặc định sẽ tạo descriptor cho mouse dù đã chọn HID Keyboard.
  • Triển khai khung composite device qua hai bước: sửa code mặc định của ST trước để xác nhận HID keyboard logic rồi mới thay bằng file composite tự viết.
  • Report 8 byte chỉ gửi trạng thái phím lên host. Firmware tuy có hỗ trợ Boot Protocol nhưng không xử lý Output report, tức không nhận lại trạng thái LED như Caps Lock.
  • USBD_HID_SendReport() không báo trạng thái truyền một cách đúng nghĩa khi endpoint đang bận. Tầng transport cần phải tự quản lý bằng biến hay flag riêng.
  • Null report ngay sau khi gửi phím (kiểu “tap-style”) là cách firmware tránh để OS chèn typematic repeat (cơ chế lặp phím tự động).

Thiết kế tổng quan

Bài này đi tiếp từ hàng đợi sự kiện đến lúc report thật sự xuất hiện trên đường truyền USB và phím nhấn được hiển thị chính xác trên host theo lưu đồ sau:

01 Queue

KeyEvent_t

Nhận sự kiện phím: ON / OFF / REPEAT / ERROR.

02 Convert

Tạo report

Lấy sự kiện, tra key_table và tạo report 8 byte.

03 Transport

Gửi report

Kiểm tra trạng thái bận trước khi gửi.

04 USB

Interrupt IN

Report 8 byte được gửi lên host sau mỗi lần.

05 Host

OS / BIOS

Đọc report theo cấu trúc đã khai báo trong descriptor.

Từ hàng đợi sự kiện đến kí tự hiển thị trên host.

Convert và Transport được tách thành hai module riêng để giảm phụ thuộc: thay đổi cách tạo report không ảnh hưởng đến cách gửi qua USB, và ngược lại.

Cấu trúc descriptor (Device, Configuration, HID Report Descriptor) đã được trình bày ở bài 3. Cấu trúc hàng đợi sự kiện KeyEvent_tkey_table đã trình bày ở bài 4 nên cũng sẽ không nhắc lại ở đây.

Source map: từ class mặc định đến composite

Cấu trúc thư mục dưới đây là toàn bộ project STM32CubeIDE thật đi kèm trong series.

Thư mục Middlewares/, Keyboard/Usb/ sẽ chứa nội dung của bài này. Các file bên trong 3 thư mục này mà không có chú thích đồng nghĩa với việc nó nằm ngoài nội dung của bài.

Phần code do CubeMX tự tạo cũng sẽ có chú thích nhưng sẽ được căn lề khác biệt.

stm32g0-usb-device-lab/
├─ Core/                       // HAL init, system file do CubeMX tạo
├─ Drivers/                    // CMSIS + STM32G0 HAL (vendor code)
├─ Middlewares/ST/.../Class/HID/
│  └─ usbd_hid.c                    // BƯỚC 1: sửa trực tiếp descriptor
│                                      trên class HID mặc định của ST
├─ App/
│  └─ app_main.c
├─ Hardware/
├─ Keyboard/
│  ├─ key_detect.c / .h
│  ├─ key_event_queue.c / .h
│  ├─ key_table.c / .h
│  ├─ hid_keyboard_convert.c / .h   // lấy KeyEvent_t, tap-style null report, macro
│  └─ hid_keyboard_report.c / .h    // struct report 8 byte, gán keycode
├─ Usb/
│  ├─ usbd_composite.c              // BƯỚC 2: thay hoàn toàn usbd_hid.c,
│  │                                   làm nền cho CDC và Vendor request
│  └─ usb_hid_keyboard.c / .h       // tầng transport: kiểm tra trạng thái bận trước khi gửi
├─ USB_Device/                 // App + Target, descriptor và usbd_conf do CubeMX tạo
└─ docs/
└─ tools/

Quá trình triển khai code cho tầng USB được diễn ra qua hai bước như sau:

  • Bước 1 - sửa class mặc định : sửa trực tiếp trên file HID mặc định của ST. Mục tiêu duy nhất ở bước này là xác nhận logic report 8 byte hoạt động chính xác.

  • Bước 2 - chuyển sang composite : thay toàn bộ usbd_hid.c bằng một file composite tự viết để chuẩn bị nền sẵn cho việc thêm CDC và Vendor request ở các bài sau.

Việc này nhằm giảm thiểu rủi ro lỗi sai descriptor hay sai kiến trúc class đúng với tinh thần “thay một biến số ở một thời điểm” như đã nói ở bài 3.

Cấu hình HID keyboard

CubeMX mặc định tạo ra descriptor cho mouse dù đã chọn “HID Keyboard” trong middleware config. Để host nhận diện đúng đây là bàn phím thì cần sửa đúng 3 chỗ.

1. Interface Descriptor: khai báo lại ba field sau cho đúng class:

bInterfaceClass    = 0x03  // HIDbInterfaceSubClass = 0x01  // Boot Interface SubclassbInterfaceProtocol = 0x01  // Keyboard (CubeMX để mặc định là 0x02 = Mouse)

2. HID Report Descriptor: Nội dung của mảng này được CubeMX tạo tự động là HID_MOUSE_ReportDesc (74 byte, HID_MOUSE_REPORT_DESC_SIZE = 74U) với cấu trúc 3 bit biểu thị nút bấm rồi X/Y/Wheel dạng delta tương đối, không có khái niệm “6 keycode slot” nào cả. Đây là lý do report 8 byte kiểu bàn phím gửi vào driver chuột không tạo ra ký tự gì. Trên host, HID driver phân tích report theo đúng cấu trúc trên và không tìm thấy trường nào khớp.

Cần thay bằng mảng mô tả report 8 byte của Boot Keyboard, HID_KEYBOARD_REPORT_DESC_SIZE = 45U:

static uint8_t HID_Keyboard_ReportDesc[HID_KEYBOARD_REPORT_DESC_SIZE] ={  /* Mouse cũ (74 byte): 0x05,0x01, 0x09,0x02 (Usage Mouse), ... X/Y/Wheel delta */  0x05, 0x01, 0x09, 0x06,  /* Usage Page (Generic Desktop), Usage (Keyboard) */  0xA1, 0x01,              /* Collection (Application) */  /* ... modifier byte, reserved byte, 6-byte keycode array ... */  0xC0                     /* End Collection */};

Toàn bộ mảng 45 byte thật và thay đổi so với bản gốc dành cho mouse nằm trong mục “Interface 0: HID Boot Keyboard” ở trang project Composite Device.

HID_KEYBOARD_REPORT_DESC_SIZE = 45U là độ dài của mảng HID_Keyboard_ReportDesc đã khai báo ở trên. Nếu chỉ đổi nội dung mảng mà quên cập nhật con số này (hoặc ngược lại), wDescriptorLength trong HID Descriptor lệch khỏi report descriptor thật làm host đọc thiếu hoặc thừa byte khi enumerate.

3. Setup handler: sau khi đọc xong HID Descriptor, host sẽ gửi GET_DESCRIPTOR để lấy Report descriptor. Handler phải trỏ đúng vào mảng vừa khai báo ở bước 2 chứ không phải mảng report descriptor cũ của chuột. Trong project đi kèm thì code phần này đặt trong USBD_HID_Setup() của file usbd_hid.c:

case USB_REQ_GET_DESCRIPTOR:  if ((req->wValue >> 8) == HID_REPORT_DESC)  {    len  = MIN(HID_KEYBOARD_REPORT_DESC_SIZE, req->wLength);    pbuf = HID_Keyboard_ReportDesc;  }  /* ... */

Bản composite sau này ở bước 2 (usbd_composite.c, Composite_Setup()) dùng lại đúng logic này, chỉ thay đổi tên hàm và tên biến.

Thiếu bất kỳ bước nào trong ba bước trên đều dẫn đến cùng một triệu chứng: descriptor “trông đúng” khi đọc code nhưng host vẫn hiểu sai. Nếu chỉ sửa bước 1 mà quên bước 2 hoặc 3, bInterfaceProtocol báo đúng là Keyboard nhưng Report Descriptor thật sự trả về vẫn là của chuột. Host chọn đúng tên driver nhưng phân tích sai cấu trúc report khiến phím bấm không tạo ra bất kì ký tự nào hoặc tạo ra chuyển động vô nghĩa của con trỏ.

Format report 8 byte

Format cố định dành cho bàn phím theo tài liệu của HID được biểu diễn bằng cấu trúc sau:

typedef struct __attribute__((packed)) {  uint8_t modifier;     /* Bit mask: LCtrl=0, LShift=1, LAlt=2, LGui=3, ... */  uint8_t reserved;     /* Luôn 0x00 */  uint8_t keycode[6];   /* Tối đa 6 phím cùng lúc */} HID_KeyboardReport_t;

modifier là một bitmask 8 bit, mỗi bit ứng với đúng một phím điều khiển (Ctrl, Shift, Alt, GUI ở cả hai bên trái/phải), độc lập với 6 slot keycode phía sau. Nó là một bitmask nên cùng lúc có thể bật nhiều bit chứ không phải chỉ một bit đơn lẻ bất kì.

Bảng đầy đủ 8 bit modifier và cách đọc bitmask này nằm ở note HID Keyboard Report Format là gì?.

Ví dụ:

  • Giữ Left Shift trong lúc gõ ‘a’ thì modifier = 0x02 (bit 1) và keycode[0] = 0x04 (usage của ‘a’). Report sẽ là :
02 00 04 00 00 00 00 00
  • Giữ đồng thời Left Ctrl và Left Alt thì modifier = 0x01 | 0x04 = 0x05. Khi này report sẽ là:
05 00 00 00 00 00 00 00
  • Giữ mỗi phím “1” thì keycode[0] = 0x1E (usage của ‘1/!’). Report sẽ là:
00 00 1E 00 00 00 00 00

Vì sao không nhận Output report cho LED

Bàn phím layout chuẩn 101 phím thường có 3 đèn LED: Num Lock, Caps Lock, Scroll Lock. Ba phím này bản thân chúng là phím bình thường, có usage riêng và xuất hiện trong Input report giống bất kỳ phím nào khác khi nhấn. Nhưng trạng thái bật/tắt của 3 đèn LED lại là Output report đi từ host xuống device chứ không phải Input report từ device lên host. Cả hai chiều IN-OUT này đều được khai báo trong cùng một HID Report Descriptor.

Chiều Output report này thường sẽ dùng 1 byte để biểu thị. Trong đó có 3 bit được dùng để thể hiện trạng thái LED và 5 bit padding không sử dụng.

Project đi kèm sẽ không triển khai chiều output report bởi trong 16 phím của keypad không cài đặt HID_USAGE_CAPS_LOCK, _NUM_LOCK, hay _SCROLL_LOCK. Chính vì vậy mà sẽ không tạo ra bất kì trạng thái khóa phím nào, tức là không cần thiết phải đồng bộ. Ngoài ra thì việc mắc thêm LED ngoài cũng không quá cần thiết bởi nó không liên quan đến USB.

Về Boot Protocol, usbd_composite.c có xử lý thật hai request SET_PROTOCOLGET_PROTOCOL mà HID class yêu cầu:

case HID_REQ_SET_PROTOCOL:  hcomp->hidProtocol = (uint32_t)(req->wValue);  break;case HID_REQ_GET_PROTOCOL:  (void)USBD_CtlSendData(pdev, (uint8_t *)&hcomp->hidProtocol, 1U);  break;

Firmware lưu lại giá trị protocol host yêu cầu và trả lời đúng khi được hỏi, nhưng không rẽ nhánh logic build report theo giá trị đó. Lý do đã trình bày ở bài 3: Report Descriptor của thiết bị này vốn dĩ đã mô tả đúng format Boot Keyboard 8 byte cố định nên cùng một buffer report dùng được cho cả Boot Protocol lẫn Report Protocol.

Ba loại phím trong key_table.c

Bài 4 đã giới thiệu struct KeyTableEntry_t với field kind, nhưng chưa đi vào chi tiết. Tôi dùng nó để phân biệt ba loại phím khác nhau:

typedef enum {  KEY_KIND_NORMAL = 0,   // phím đơn: modifier + usage  KEY_KIND_MACRO,        // phím dạng macro với chuỗi nhiều bước, nhận diện bằng macroId  KEY_KIND_SPECIAL       // chừa sẵn cho layer switch, media keys,...} KeyKind_t;

Bảng sKeyTable trong key_table.c hiện cũng chỉ dùng 2 trong 3 loại là:

  // Kind          // Modifier   // Usage     // MacroId    // Repeat{ KEY_KIND_NORMAL, HID_MOD_NONE, HID_USAGE_1, MACRO_NONE,   1U },  /* keyLoc 0->11*//* ... */{ KEY_KIND_MACRO,  HID_MOD_NONE, 0x00U,       MACRO_CTRL_C, 0U },  /* keyLoc 12->15 *//* ... */

KEY_KIND_NORMAL áp dụng cho 12 phím đầu (số, chữ, Enter/Space/Backspace/Tab). Hàm build report sẽ đọc thẳng field usage và gửi một keycode.

KEY_KIND_MACRO áp dụng cho 4 phím cuối. Field usage sẽ không được sử dụng. Thay vào đó macroId trỏ tới chuỗi report định nghĩa sẵn sẽ tạo report tuần tự theo nhều nhiều bước.

KEY_KIND_SPECIAL không có entry nào dùng trong bảng hiện tại. Đây là chỗ chừa sẵn trong enum, không phải logic đã hoàn thiện. Comment gốc trong header có ghi rõ hướng dự kiến: “layer switch, media keys, etc.”. Ví dụ minh hoạ cho hướng này (chưa triển khai) là một entry KEY_KIND_SPECIAL có thể đại diện cho phím chuyển layer, khi đó sẽ không build report bình thường mà đổi bộ ánh xạ sKeyTable đang dùng để 16 phím vật lý cùng lúc mang một ý nghĩa khác hay một layout khác.

Debug với STLink và CubeIDE

Debug với STLink và CubeIDE: Quá trình chuyển hóa sự kiện nhấn phím thành HID report.

Xử lý phím thường (normal key)

Phần trên đã trả lời câu hỏi “phím này tra ra usage nào” thông qua struct KeyTableEntry_t. Phần còn lại cần làm rõ hai điểm: usage đó lấy từ đâu ra, và nó biến thành report 8 byte như thế nào.

Usage lấy từ đâu?

Mỗi phím trên bàn phím có đúng một usage cố định theo chuẩn USB HID Usage Tables do USB-IF định nghĩa. Chúng giống nhau trên mọi thiết bị HID Keyboard bất kể hãng nào. 12 phím thường được sử dụng trong project cũng tuân theo quy chuẩn này:

PhímUsage (hex)
1 / 2 / 3 / 40x1E / 0x1F / 0x20 / 0x21
A / B / C / D0x04 / 0x05 / 0x06 / 0x07
Enter0x28
Space0x2C
Backspace0x2A
Tab0x2B

Bảng đầy đủ toàn bộ usage (a-z, số, dấu câu, numpad, phím khoá, function key…) nằm ở note HID Usage Tables là gì?.

Bảng tra cứu trong key_table.c chỉ chọn ra đúng 12 giá trị cần dùng từ bảng chuẩn này.

Usage biến thành report như thế nào?

Với KEY_KIND_NORMAL thì giá trị của entry->usage sẽ đi thẳng vào keycode[0], còn giá trị của entry->modifier sẽ đi thẳng vào byte đầu của report buffer.

Ví dụ cụ thể với keyLoc = 4 (phím chữ A theo layout đã chọn ở bài 4):

KeyTable_Get(keyLoc=4)
  → { KEY_KIND_NORMAL, modifier=HID_MOD_NONE (0x00), usage=HID_USAGE_A (0x04) }

      modifier = 0x00, keycode[0] = 0x04

report bytes: 00 00 04 00 00 00 00 00

Đây là điểm hai tầng thật sự gặp nhau: tầng input (phím vật lý → sự kiện) và tầng HID (usage theo chuẩn HID). Cầu nối giữa hai tầng chính là bảng tra cứu key_table.

Tap-style null report

Thông thường nếu firmware chỉ gửi key-down report thì host sẽ giữ trạng thái phím đó. OS sẽ áp dụng typematic repeat theo tốc độ riêng của nó (thường 500ms delay, 30ms/lần). Việc này về cơ bản không sai nhưng hãy tưởng tượng nếu firmware crash hoặc bị treo ở giữa chừng thì sao? Host sẽ luôn nghĩ phím đang được giữ và tiếp tục gửi 1 chuỗi lặp giống nhau lên màn hình mà người dùng không thể nào dừng lại được.

Để hạn chế rủi ro này cũng như để firmware có thể kiểm soát tốc độ repeat thì có một cách là gửi ngay null report sau mỗi key-down report.

Theo cách này, một lần gửi phím của firmware sẽ chia làm 2 bước:

  1. Gửi key-down report (phím nhấn).
  2. Gửi null report (phím thả).

Pesudocode cho logic này như sau:

BẮT ĐẦU

    NẾU 1: cần gửi báo cáo rỗng THÌ
        Tạo NULL REPORT

        NẾU 2: gửi thành công THÌ
            Tắt cờ tạo báo cáo rỗng
        KẾT THÚC NẾU 2

        KẾT THÚC

    KẾT THÚC NẾU 1


    NẾU 3: có phím được nhấn hoặc lặp THÌ
        Tạo báo cáo theo phím

        NẾU 4: gửi thành công THÌ
            Bật cờ tạo báo cáo rỗng
        KẾT THÚC NẾU 4
    KẾT THÚC NẾU 3

KẾT THÚC

Lưu ý ở tầng Transport

Hàm USBD_HID_SendReport() của ST có hành vi không rõ ràng: khi endpoint đang bận, hàm vẫn có thể trả về USBD_OK dù không gửi gì. Không thể tin hoàn toàn vào return value đơn thuần.

Giải pháp là nên tự quản lý busy flag và có cơ chế rollback khi gửi thất bại:

/* Rút gọn từ usb_hid_keyboard.c - xem bản đầy đủ trong repo */static volatile bool sTxBusy = false;bool UsbHidKeyboard_SendReport(const HID_KeyboardReport_t *report){  if (sTxBusy) return false;  /* drop, caller thử lại lần sau */  sTxBusy = true;  if (USBD_HID_SendReport(&hUsbDeviceFS, (uint8_t *)report,                           sizeof(HID_KeyboardReport_t)) != USBD_OK)  {    sTxBusy = false;  /* rollback - quan trọng */    return false;  }  return true;}/* Gọi từ Composite_DataIn khi EP1 IN complete */void UsbHidKeyboard_OnTxCplt(void){  sTxBusy = false;}

Rollback là bắt buộc. Nếu USBD_HID_SendReport thất bại mà không reset sTxBusy về false thì firmware giữ trạng thái busy mãi và không gửi được gì nữa dù endpoint phần cứng đã idle.

Rollback khi gửi thất bại

UsbHidKeyboard_SendReport trả về false khi bận, nhưng đó chỉ là một lần gọi thất bại. Điều quan trọng là bên gọi (HidKeyboardConvert_Run) xử lý thất bại đó ra sao. Một cơ chế rollback đơn giản là giữ nguyên event trong queue khi bắt đầu gửi và chỉ Pop sau khi đã gửi thành công.

Kết hợp với tap-style null report, code sẽ được triển khai như sau:

/* Rút gọn từ hid_keyboard_convert.c */void HidKeyboardConvert_Run(void){  if (sNeedNullReport) {    /* ... build null report ... */    if (UsbHidKeyboard_SendReport(...)) {      sNeedNullReport = false; /* xóa cờ nếu gửi thành công */    }    return;   /* gửi thất bại thì giữ nguyên cờ, thử lại lần sau */  }  if (!KeyEventQueue_Peek(&event)) return;   /* xem, chưa lấy ra */  /* ... build report từ event ... */  if (UsbHidKeyboard_SendReport(...))  {    (void)KeyEventQueue_Pop(NULL);   /* chỉ lấy ra khỏi queue khi gửi thành công */    sNeedNullReport = true;  }  /* Nếu gửi thất bại: không Pop, sự kiện vẫn còn nguyên trong queue,    HidKeyboardConvert_Run lần gọi sau sẽ Peek lại đúng sự kiện đó và thử gửi lại. */}

Nguyên tắc này áp dụng nhất quán cho mọi loại report cần gửi:

Loại sự kiện Khi gửi thành công Khi gửi thất bại
Key-down thường, ErrorRollOver Pop khỏi queue, đặt cờ null report Không Pop, giữ nguyên trạng thái, thử lại y hệt ở lần gọi kế tiếp
Macro Tăng macro step để tạo report tiếp theo Không Pop, giữ nguyên trạng thái, thử lại y hệt ở lần gọi kế tiếp
Null report Tắt cờ null report Giữ cờ null report, thử gửi lại null report ở lần gọi kế tiếp

Ngược lại, sự kiện không tạo ra report nào (KEY_EVENT_OFF, keyLoc không có trong key_table, hoặc KEY_KIND_SPECIAL chưa triển khai) sẽ luôn bị Pop ngay lập tức, không phụ thuộc trạng thái gửi. Lý do là vì không có gì để thử lại.

Kết quả của thiết kế này là dù endpoint bận bao lâu, không có key-down, macro step, hay null report nào bị bỏ rơi, chỉ đơn giản là chờ đến lần HidKeyboardConvert_Run tiếp theo khi endpoint rảnh và thử lại.

Xử lý phím macro

Một số phím trên keypad 4x4 được map thành macro với chuỗi nhiều bước: Ctrl+C, Ctrl+V, Ctrl+S, Alt+Tab. Mỗi macro là một chuỗi report được gửi tuần tự. Về cơ bản thì macro cũng là một phím bình thường, nhưng thay vì gửi một report duy nhất thì sẽ gửi nhiều report theo từng bước. Mỗi bước có thể là key-down hoặc null report.

Ví dụ 2 macro được biểu thị các bước bằng code C như sau:

typedef struct {  uint8_t modifier;  uint8_t keycode;} MacroStep_t;/* Macro: Ctrl+C */static const MacroStep_t kMacroCtrlC[] ={  { HID_MOD_LEFT_CTRL , HID_USAGE_C      },  /* Step1: key-down */  { 0x00,               0x00             },  /* Step2: null report */};/* Tương tự với Macro: "Hello" */static const MacroStep_t kMacroHello[] ={  { HID_MOD_LEFT_SHIFT, HID_USAGE_H      }, /* H */  { 0x00,               0x00             },  { 0x00,               HID_USAGE_E      }, /* e */  { 0x00,               0x00             },  { 0x00,               HID_USAGE_L      }, /* l */  { 0x00,               0x00             },  { 0x00,               HID_USAGE_L      }, /* l */  { 0x00,               0x00             },  { 0x00,               HID_USAGE_O      }, /* o */  { 0x00,               0x00             },};

State machine trong hid_keyboard_convert.c lưu giữ 2 biến sMacroIdsMacroStep:

  • sMacroId xác định macro nào đang chạy (Ctrl+C, Ctrl+V,…)
  • sMacroStep xác định bước hiện tại trong macro.

sMacroStep sẽ được reset về 0 khi 1 phím macro được nhấn và tăng sau mỗi lần UsbHidKeyboard_SendReport gửi thành công. Nếu lần này gửi thất bại (endpoint bận) thì giữ nguyên giá trị hiện tại và thử lại trong lần gọi kế tiếp.

Demo video

Đây là mốc đầu tiên thực sự chạy được trong cả chuối series. Sau khi cắn USB vào máy tính, host đã nhận diện đúng là thiết bị HID keyboard và hiển thị chính xác các ký tự khi nhấn phím. Video demo dưới đây quay song song 3 khung hình (Wireshark bắt gói USB, Notepad hiện ký tự, và camera tay bấm phím thật) để đối chiếu trực tiếp giữa thao tác tay, report HID gửi đi, và ký tự hiển thị. Có đủ 16 phím, giữ phím để thấy key repeat, 4 tổ hợp phím macro (Ctrl+C/V/S, Alt+Tab) chạy đúng chuỗi nhiều bước và nhấn đồng thời 2 phím để kích hoạt ErrorRollOver.

Demo HID keyboard thật trên host

USBView và Wireshark chi tiết theo từng byte nằm ở trang project Composite Device.

Tóm tắt

Bài 5 hoàn thiện nửa sau của pipeline bàn phím: từ sự kiện trong hàng đợi đến lúc report 8 byte thật sự được gửi lên host. Trọng tâm của bài là đảm bảo không sự kiện nào bị rơi mất dù endpoint USB có bận. Mọi report chỉ Pop khỏi queue sau khi gửi thành công, còn null report kiểu tap-style và từng bước của macro đều tuân theo cùng một nguyên tắc rollback.

Bài cũng chỉ ra ba điểm bắt buộc để host nhận diện đúng HID Keyboard: sửa Interface Descriptor, thay Report Descriptor 45 byte của Boot Keyboard và trả về đúng mảng descriptor trong handler GET_DESCRIPTOR. Khi ba phần này khớp nhau, host mới phân tích report theo đúng cấu trúc và hiển thị chính xác ký tự.

Ở tầng convert, bài giải thích cách tra usage từ bảng chuẩn HID và cách ghép modifier với keycode để hình thành report 8 byte. Ở tầng transport, bài cho thấy vì sao không thể tin vào USBD_HID_SendReport() của ST và cần tự quản lý busy flag cùng rollback khi gửi thất bại.

Cuối cùng, bài triển khai state machine cho macro nhiều bước (Ctrl+C/V/S, Alt+Tab), xử lý đúng từng report mà không bỏ sót bước nào.

Đây cũng là mốc đầu tiên trong series mà thiết bị HID keyboard chạy hoàn chỉnh: host nhận diện đúng, report gửi đúng, và ký tự hiển thị chính xác. Bài tiếp theo sẽ thêm CDC log channel để quan sát firmware hoạt động mà không cần debugger gắn vào board.

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 ý