Thiết kế input pipeline trước tầng USB
Giải thích thiết kế tạo input từ keypad qua các tầng scan, debounce, key policy, event queue và những đánh đổi trước khi tạo HID report.
Bài 4/8 trong series USB Device trên STM32. Bài này không phải USB, nó giải thích phần firmware nội bộ phía sau bàn phím. Nếu bạn chỉ quan tâm đến USB, có thể đọc lướt qua rồi đi đến bài 5.
Trước khi đọc bài này
Bài trước nói về descriptor, tức cách host nhận diện một USB device. Nhưng với một HID keyboard, descriptor chỉ cho host biết đây là bàn phím, chứ không trả lời câu hỏi firmware lấy sự kiện phím từ đâu.
Bài này bắt đầu đi vào triển khai thật phần nằm trước tầng USB: cách firmware đọc keypad 4x4, lọc nhiễu tiếp điểm, xử lý nhấn giữ và nhấn đồng thời, tạo sự kiện phím ổn định để tầng HID phía sau sử dụng. Đây không phải bài hướng dẫn GPIO và cũng không đi sâu vào HID report. Trong bài chỉ dùng vài đoạn code ngắn trong Github repo để minh họa các quyết định thiết kế, không phải giải thích chi tiết từng dòng.
Bài 5 sẽ nhắc lại khái niệm hàng đợi sự kiện phím từ bài này mà không giải thích lại. Nếu bỏ qua bài này và đọc thẳng bài 5, một vài đoạn đầu sẽ gặp các thuật ngữ lạ.
Ý chính của bài
- Input pipeline được chia thành nhiều tầng nhỏ: scan keypad lấy tín hiệu thô, lọc dội phím và xử lý trạng thái phím, tạo hàng đợi sự kiện rồi mới đến tầng HID.
- Timer interrupt không thực hiện scan trực tiếp. ISR chỉ tạo yêu cầu scan, phần xử lý thật nằm ở main loop.
- Tín hiệu GPIO thô không được đưa thẳng lên HID. Nó phải qua bước ổn định để loại dội tiếp điểm và nhiễu.
- Keypad không diode không phù hợp cho multi-key vật lý. Firmware chọn cách khóa input khi phát hiện nhấn đồng thời, thay vì cố lọc các “phím ma” do dòng điện chạy ngược.
- Hàng đợi sự kiện là ranh giới giữa input layer và HID layer, giúp tầng HID không cần biết chi tiết GPIO hay các xử lý phím bên dưới.
Vì sao cần input pipeline?
Một HID keyboard report chỉ là kết quả cuối. Trước khi tạo được report đó, firmware phải giải quyết nhiều vấn đề không thuộc USB như:
- GPIO đọc được chỉ là mức điện áp thô tại thời điểm đọc.
- Tiếp điểm cơ khí có bounce - dội (đóng/mở/rung) nhiều lần trước khi ổn định.
- Keypad ma trận không diode có thể bị ghosting (phím ma) khi nhấn nhiều phím cùng lúc.
- Tầng USB không nên phụ thuộc trực tiếp vào thời điểm scan GPIO.
- Các hành vi như macro key hoặc lặp phím cần chính sách riêng, không nên nhét vào driver USB.
Vì vậy firmware cần một pipeline trung gian để biến tín hiệu phím từ phần cứng thành sự kiện phần mềm ổn định trước khi tầng HID sử dụng.
Thiết kế tổng quan
Lưu đồ dưới đây sẽ thể hiện khái quát các tầng xử lý và quá trình chuyển đổi tín hiệu cũng như sự kiện nhấn/thả phím vật lý thành sự kiện phần mềm.
Keypad matrix
Hàng và cột, tín hiệu phím từ GPIOB.
rawState
Bitmask 16 bit: bit N thể hiện keyLoc N đang được nhấn.
Stable state
Chỉ chấp nhận thay đổi khi tín hiệu ổn định qua hai mẫu liên tiếp.
Repeat / Error
Nhấn giữ, nhấn đồng thời, hoặc yêu cầu thả toàn bộ.
KeyEvent_t
ON / OFF / REPEAT / ERROR.
Tầng HID
Bài 5 sẽ chuyển sự kiện thành HID report.
Lưu ý: bài này chỉ dừng lại ở tầng queue tức tạo sự kiện phím rồi đặt vào hàng đợi. Việc chuyển đổi sự kiện phím thành HID report sẽ được triển khai ở bài sau.
Source map: trách nhiệm mỗi module
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 Hardware/ và Keyboard/ sẽ chứa nội dung của bài này. Các file bên trong 2 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 và sẽ được nói chi tiết ở các bài sau trong quá trình triển khai tầng USB.
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 USB Device Library (vendor code)
├─ App/
│ └─ app_main.c // main loop: scan scheduler, KeyDetect_Run, HID convert
├─ Hardware/
│ ├─ keypad.c / keypad.h // đọc GPIO thô, trả về rawState 16 bit
│ └─ scan_scheduler.c / .h // nhận tick TIM6, tạo scan request cho main loop
├─ Keyboard/
│ ├─ key_detect.c / .h // debounce, phát hiện ON/OFF/REPEAT, simultaneous error
│ ├─ key_event_queue.c / .h // giữ KeyEvent_t cho tầng HID phía sau
│ ├─ key_table.c / .h // tách keyLoc vật lý khỏi ý nghĩa phím/macro
│ ├─ hid_keyboard_convert.c / .h
│ └─ hid_keyboard_report.c / .h
├─ Usb/
├─ USB_Device/ // App + Target, descriptor và usbd_conf do CubeMX tạo
└─ docs/
└─ tools/ Các đoạn code trong bài này chỉ giữ ở mức rút gọn để minh hoạ liên kết giữa các module. Code đầy đủ nằm trong repo stm32g0-usb-device-lab.
Quy ước chung cho project
Module keypad 4x4 dùng ngoài thực tế in sẵn nhãn S1 đến S16 trên mặt phím. Đây là
cách nhà sản xuất đánh số công tắc, không liên quan gì đến thứ tự quét ma trận của firmware.
Series này và code trong repo không dùng nhãn S1-S16 mà gọi theo keyLoc (0-15),
đánh số theo đúng thứ tự quét. Công thức tính như sau:
keyLoc = số hàng x 4 + số cột
Đây là quy ước dùng xuyên suốt cả series từ cách gọi phím cho đến hiển thị trên demo video. Tham khảo hình bên dưới.
Bảng dưới đây là toàn bộ 16 phím thật của project, theo đúng định nghĩa của struct sKeyTable
trong source file key_table.c:
| Hàng quét | keyLoc | Kí tự được gán |
|---|---|---|
| Row 0 | 0 - 1 - 2 - 3 | Phím số 1, 2, 3, 4 |
| Row 1 | 4 - 5 - 6 - 7 | Phím chữ A, B, C, D |
| Row 2 | 8 - 9 - 10 - 11 | Enter, Space, Backspace, Tab |
| Row 3 | 12 - 13 - 14 - 15 | Macro Ctrl+C, Ctrl+V, Ctrl+S, Alt+Tab |
Ba hàng đầu (keyLoc 0-11) là các phím thường. Mỗi lần nhấn chỉ gửi đúng một keycode và có thể lặp lại khi giữ.
Bốn phím ở hàng cuối (keyLoc 12-15) là phím macro kích hoạt một lần. Nhấn một lần sẽ gửi ngay cả tổ hợp phím, và không lặp lại khi giữ. Lý do và cách tạo tổ hợp này nằm ở tầng HID và sẽ được trình bày trong bài 5. Bài này chỉ nói về vị trí vật lý của chúng trong ma trận.
Quyết định 1: raw scan chỉ trả về bitmask 16 bit
Tầng GPIO không nên biết gì về debounce, macro hay HID. Nhiệm vụ của nó chỉ là đọc
keypad và trả về một rawState 16 bit - trạng thái scan tức thì chưa qua xử lý.
Mỗi bit trong biến sẽ biểu thị trạng thái cho 1 phím riêng(0: thả , 1: nhấn).
Quy ước biểu thị như sau:
bit 0 = keyLoc 0
bit 1 = keyLoc 1
...
bit 15 = keyLoc 15
Ví dụ về cách đọc hay kiểm tra trạng thái phím sẽ như sau:
| Dữ liệu | Ý nghĩa |
|---|---|
| rawState = 0x0000 | Không có phím nào đang được nhấn |
| rawState & (1 << 4) | keyLoc 4 đang được đọc là nhấn |
| rawState có nhiều bit 1 | Có nhiều phím đang được nhấn cùng lúc |
Nếu bit N bằng 1, nghĩa là tại thời điểm scan đó, keyLoc N đang được nhấn. Tầng này không quyết định đó có phải phím hợp lệ hay chưa, nó chỉ cung cấp dữ liệu thô.
/* * KEYPAD_ROW_NUM = 4 * KEYPAD_COL_NUM = 4 *//* Rút gọn từ Keypad_ReadRaw() - xem bản đầy đủ trong repo */uint16_t state = 0;for (row = 0; row < KEYPAD_ROW_NUM; row++) { KEYPAD_GPIO->BSRR = (uint32_t)sRowPins[row] << 16U; /* kéo row xuống LOW */ for (col = 0; col < KEYPAD_COL_NUM; col++) { if ((KEYPAD_GPIO->IDR & sColPins[col]) == 0U) { /* check tín hiệu LOW của col */ state |= (1U << (row * KEYPAD_COL_NUM + col)); /* cập nhật trạng thái matrix */ } } KEYPAD_GPIO->BSRR = (uint32_t)sRowPins[row]; /* trả row về HIGH */}STM32G0B1 dùng thanh ghi BSRR/IDR trực tiếp thay vì HAL_GPIO_WritePin/ReadPin cho
vòng scan này nhằm tăng tốc độ xử lý tránh overhead hoặc phá vỡ scan timming.
Chi tiết bit layout hay cách ứng dụng của thanh ghi BSRR và IDR đã được tổng hợp ở note STM32 GPIO Register Access là gì? nên tôi sẽ không trình bày lại ở đây.
Quyết định 2: Timer ISR chỉ tạo request, không scan trực tiếp
Cách dễ nhất là gọi thẳng KeyDetect_Run() trong TIM6 ISR. Nhưng làm vậy khiến ISR trở
nên dài vì hàm này phải quét GPIO, ổn định tín hiệu phím, xử lý nhấn giữ và đẩy sự kiện
vào hàng đợi. Nếu sau này USB interrupt hoặc CDC/Vendor cũng cần CPU, một ISR dài như vậy
sẽ làm hệ thống khó đoán và khó kiểm soát nhịp thời gian hơn.
Vì vậy mà tôi đã lựa chọn thiết kế như sau:
Ưu và nhược điểm của thiết kế được trình bày trong bảng sau:
| Lựa chọn | Ưu điểm | Nhược điểm |
|---|---|---|
| Scan trực tiếp trong ISR | Nhịp scan đều tuyệt đối, code đơn giản hơn | ISR dài, khó mở rộng, có thể trễ ngắt ưu tiên cao hơn như USB |
| ISR chỉ tạo request (đang dùng) | ISR ngắn, dễ kiểm soát, không cản ngắt khác | Vòng lặp chính không được block quá lâu, cần cơ chế giảm tải khi bị chặn |
Điểm quan trọng: firmmware không chỉ quét keypad, nó còn có USB interrupt, HID Interrupt IN transfer, và sau này là CDC/Vendor. Giữ ISR ngắn ngay từ đầu giúp kiến trúc dễ mở rộng khi firmware lớn dần chứ không chỉ cho bài toán keypad.
Số yêu cầu quét được giới hạn ở 10. Nếu vòng lặp chính bận quá 10 x 5 ms = 50 ms, các tick tiếp theo sẽ bị bỏ qua thay vì để bộ đếm tăng vô hạn. Chấp nhận bỏ vài chu kỳ quét hiếm khi xảy ra để tránh hệ thống rối loạn khi vòng lặp chính đang xử lý USB transfer.
Quyết định 3: debounce 2 mẫu liên tiếp
Tín hiệu thô từ GPIO không đáng tin ngay lập tức vì tiếp điểm cơ khí thường rung trong vài mili-giây. Firmware chỉ xác nhận một thay đổi khi cùng một trạng thái xuất hiện ở hai lần quét liên tiếp. Với chu kỳ quét 5 ms:
t = 0ms raw = 0
t = 5ms raw = 1 mới thấy nhấn, chưa xác nhận
t = 10ms raw = 1 ổn định 2 mẫu → KEY_EVENT_ON
Thả phím cũng cần 2 mẫu liên tiếp ở trạng thái không nhấn:
t = 0ms raw = 1
t = 5ms raw = 0 mới thấy thả, chưa xác nhận
t = 10ms raw = 0 ổn định 2 mẫu → KEY_EVENT_OFF
| Số mẫu | Thời gian chờ | Đánh đổi |
|---|---|---|
| 1 mẫu | 0ms (không lọc) | Không có gì để so sánh, thực chất tín hiệu không ổn định |
| 2 mẫu (đang dùng) | 10ms | Đủ lọc rung tiếp điểm thực tế, độ trễ cảm nhận phím thấp |
| 3 mẫu | 15ms | An toàn hơn với tiếp điểm rung lâu bất thường nhưng chậm thêm 5ms mỗi lần nhấn |
Quyết định 4: keypad không cố hỗ trợ multi-key
Keypad 4x4 trong project không có diode chống phím ma. Khi nhiều phím được nhấn cùng lúc, firmware không thể luôn phân biệt đâu là phím thật và đâu là phím ma sinh ra do dòng điện lan ngược qua các phím đã đóng lân cận khiến việc quét đọc nhầm một phím không hề được nhấn.
| Cách xử lý | Ưu điểm | Nhược điểm |
|---|---|---|
| Cố lọc phím ma bằng phần mềm | Có vẻ hỗ trợ nhiều phím hơn | Phức tạp, không đáng tin cậy trên ma trận không diode |
| Khoá input khi phát hiện nhấn đồng thời (đang dùng) | Đơn giản, dễ chứng minh, an toàn | Không hỗ trợ nhấn nhiều phím vật lý cùng lúc |
Chính sách này bảo thủ có chủ đích: firmware không chờ tín hiệu ổn định rồi mới kiểm tra nhấn đồng thời. Ngay khi một lần quét thô (trước khi thực hiện debounce) đếm được từ hai phím trở lên, luồng xử lý chuyển sang trạng thái lỗi ngay lập tức, xóa toàn bộ phím đang ở trạng thái nhấn và khoá input cho đến khi người dùng thực sự thả hết phím. Firmware sẽ không cố phân tích tổ hợp phím để đoán đâu là phím ma.
Macro như Ctrl+C hay Alt+Tab không yêu cầu nhấn nhiều phím vật lý cùng lúc, chúng được tạo ở tầng HID phía sau bằng chuỗi report gửi tuần tự nên không mâu thuẫn với chính sách khoá multi-key ở tầng input này.
Quyết định 5: dùng event queue thay vì gọi HID trực tiếp
Cách đơn giản nhất là sau khi phát hiện phím thì gọi thẳng hàm gửi HID report. Tuy nhiên cách
này làm tầng input phụ thuộc vào USB transport. Nếu endpoint đang bận - ở trạng thái busy thì
sẽ gây lỗi mất phím nhấn và logic debounce phía dưới cũng bị ảnh hưởng theo.
Vì vậy mà tôi thiết kế một lớp trung gian ở giữa:
KeyDetect → tạo KeyEvent_t → push vào KeyEventQueue
Tầng HID → pop KeyEvent_t → chuyển thành HID report → gửi qua USB
KeyEvent_t là cầu nối giữa hai tầng. Nó cung cấp thông tin về sự kiện phím nào được nhấn, thả, lặp lại
hay các phím nào đang được nhấn đồng thời gây ra lỗi
typedef struct { KeyEventType_t type; /* ON / OFF / REPEAT / ERROR */ uint8_t keyLoc; /* 0-15, vị trí phím trong ma trận */} KeyEvent_t;Nhờ vậy tầng input không cần biết USB endpoint có đang busy hay không, và tầng HID
không cần biết GPIO scan, debounce hay ghosting được xử lý ra sao. Queue có kích thước
32 phần tử, đủ rộng cho pipeline hiện tại và vẫn nhỏ để kiểm soát trên MCU.
Chi tiết head/tail và cách quay vòng chỉ số cho queue tôi đã ghi lại trong ghi chú Ring Buffer trong Embedded C là gì?.
Cũng cần nói đến là cả KeyDetect_Run (bên push) lẫn tầng HID (bên pop) đều chạy trong main
loop nên không có ISR nào chạm vào queue. TIM6 ISR chỉ tạo scan request như ở Quyết định 2,
không push event trực tiếp. Vì vậy queue hiện tại không cần lock hay critical section.
Giả sử sau này cho ISR hoặc USB callback ghi trực tiếp vào queue thì phần này sẽ phải được
bảo vệ bằng critical section hoặc thiết kế theo kiểu producer-consumer rõ hơn.
Key table: tách vị trí vật lý khỏi ý nghĩa phím
KeyEvent_t chỉ mang keyLoc, không mang ý nghĩa “phím nào”. keyLoc không tự biết nó
tương ứng với phím 1, phím Enter hay macro Ctrl+C. Bảng ánh xạ đầy đủ đã liệt kê ở phần
“Quy ước chung cho project” phía trên.
Việc ánh xạ này nằm ở một module tra cứu riêng: key_table.c. Đây là cấu trúc dữ
liệu giúp tầng HID phía sau chuyển sự kiện thành HID report chính xác.
Cấu trúc bảng ánh xạ như sau:
typedef struct { KeyKind_t kind; /* NORMAL / MACRO / SPECIAL */ uint8_t modifier; /* Ctrl/Shift/Alt/GUI, dùng ở bài 5 */ uint8_t usage; /* HID usage, dùng ở bài 5 */ MacroId_t macroId; /* dùng ở bài 5 nếu kind = MACRO */ uint8_t repeatEnable; /* 1 = cho phép repeat khi giữ phím */} KeyTableEntry_t;Tách thành một module riêng thay vì gắn cứng ý nghĩa vào từng keyLoc mang lại lợi ích rõ
ràng cho việc đổi layout keypad.
Ví dụ đổi keyLoc 12 từ Ctrl+C sang Ctrl+Z thì chỉ cần sửa một dòng trong bảng, không đụng đến tầng scan, debounce hay event queue. Hơn nữa những tầng đó cũng không biết và không cần biết ý nghĩa của từng phím.
Chi tiết các trường trong KeyTableEntry_t (HID usage, modifier, macro) thuộc về tầng HID và
sẽ được trình bày ở bài 5.
Kiểm tra pipeline bằng debugger
Bài này dừng trước tầng USB transport nên chỉ có thể kiểm tra logic bằng cách quan
sát biến nội bộ thông qua debugger. Các biến cần quan sát gồm có: rawState,
stable state, sKeyStatus, event type, số phần tử trong queue.
Các biến static có thể xác nhận thông qua Live Expression trong CubeIDE, còn các
biến local thì cần đặt breakpoint để quan sát tại từng thời điểm thông qua Watch Windows.
Debug Video
Debug với STLink và CubeIDE: Khởi tạo biến, buffer, flag. Quan sát thay đổi của chúng khi nhấn/thả phím.
Những đánh đổi trong thiết kế
| Quyết định | Lấy được gì | Đánh đổi |
|---|---|---|
| Raw scan chỉ trả bitmask | Tầng GPIO đơn giản, không phụ thuộc HID | Tầng sau phải tự xử lý debounce/policy |
| ISR chỉ tạo request | ISR ngắn, ít ảnh hưởng USB interrupt | Main loop không được block quá lâu |
| Debounce 2 mẫu | Phản hồi nhanh, dễ debug | Không xử lý bounce quá cực đoan |
| Khoá input khi multi-key | An toàn trên keypad không diode | Không hỗ trợ tổ hợp vật lý |
| Event queue | Tách input khỏi HID transport, không cần lock | Thêm buffer và logic queue |
| Key table | Dễ đổi layout/macro | Thêm một lớp mapping |
Bài này dừng ở đâu?
Sau bài này, firmware đã có nguồn input ổn định mỗi khi thực hiện nhấn phím kiểu:
KEY_EVENT_ON, keyLoc = 4
KEY_EVENT_OFF, keyLoc = 4
KEY_EVENT_REPEAT, keyLoc = 1
KEY_EVENT_ERROR
Tuy nhiên những event này chưa phải USB data, chúng chỉ là input sạch đã qua xử lý. Trong bài tiếp theo tôi sẽ dùng chính các event này để tạo HID keyboard report 8 byte, xử lý key down/null report, macro sequence và gửi dữ liệu lên host qua Interrupt IN endpoint.
Hơn nữa tôi cũng sẽ dùng Wireshark và USBView để xem các dữ liệu thật sự đi qua USB như nào, kiểm tra xem nó có hoạt động chính xác giống với thiết kế hay không.
Tóm tắt
Pipeline xử lý phím có 3 tầng rõ ràng với trách nhiệm riêng biệt:
- Hardware (
keypad.c): đọc GPIO thô, không biết gì về debounce hay event - Detection (
key_detect.c): debounce, phát hiện thay đổi, repeat, xử lý lỗi - Queue (
key_event_queue.c): buffer trung gian tách tầng scan khỏi tầng HID
Mỗi quyết định thiết kế trong bài (scan trả về bitmask thô, ISR chỉ tạo request, 2 mẫu debounce, khoá input thay vì lọc ghost, dùng event queue thay vì gọi HID trực tiếp) đều đánh đổi một thứ để lấy một thứ khác và không có lựa chọn nào đúng tuyệt đối.
Thiết kế phân tầng này cho phép kiểm thử từng phần độc lập. Ví dụ bài 5 có thể kiểm tra logic tạo HID report chỉ bằng debugger mà chưa cần USB thật vì cấu trúc tầng dưới cung cấp sự kiện còn tầng trên tiêu thụ, hoàn toàn không lệ thuộc vào nhau.
Tài liệu tham khảo
- STM32G0B1 Reference Manual RM0444 GPIO BSRR/IDR, TIM6
- USB HID Usage Tables 1.5 keycode mapping
- STM32 GPIO Register Access là gì? - BSRR/IDR chi tiết, so sánh với HAL
- Debounce Algorithm là gì? - các kỹ thuật debounce khác nhau
- Ring Buffer trong Embedded C là gì? - head/tail, ISR producer, DMA circular
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ị.