STM32G0 USB Lab: Key Input Pipeline
Keypad 4x4 matrix trên STM32G0B1RE: quét bằng BSRR/IDR trực tiếp, TIM6 5ms, debounce 2 mẫu, event queue và chính sách simultaneous-error cho ma trận không diode.
Kết quả đạt được
- Quét matrix bằng GPIOB BSRR/IDR, không dùng HAL_GPIO
- TIM6 5ms tạo nhịp quét ổn định qua interrupt, ISR chỉ tạo request
- Debounce 2 mẫu liên tiếp, window 10ms
- Chính sách simultaneous-error: latch cho đến khi thả hết phím
- key_table.c tách vị trí vật lý khỏi ý nghĩa phím, 3 loại key
- Ring buffer 32 sự kiện tách tầng scan khỏi tầng HID
Vai trò trong series
Project page này mô tả tầng thấp nhất trong stm32g0-usb-device-lab: phần đọc phần cứng và chuyển tín hiệu GPIO thô thành sự kiện phím có thể dùng được. Tầng này hoàn toàn độc lập với USB - nó không biết gì về HID report hay endpoint.
Tương ứng với milestone lab-05 đến lab-07 trong repo.
Phần cứng
Board: NUCLEO-G0B1RE Keypad: ma trận 4x4, 16 phím, không có diode chống ghost Kết nối: tất cả 8 chân trên GPIOB (PB0-PB7)
Row 0-3 : PB0-PB3 (output push-pull, idle HIGH)Col 0-3 : PB4-PB7 (input pull-up)Scan timer: TIM6, prescaler 47, period 4999 → tick 5ms.
Cấu trúc thư mục
stm32g0-usb-device-lab/
├─ Hardware/
│ ├─ keypad.c / .h - đọc GPIO thô qua BSRR/IDR, 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 - ring buffer 32 sự kiện
└─ key_table.c / .h - tách keyLoc vật lý khỏi ý nghĩa phím/macro Mỗi tầng có API riêng, không biết gì về tầng bên dưới. Thiết kế này cho phép test từng tầng độc lập với debugger mà không cần USB hay HID hoạt động.
Quá trình triển khai: lab-05 đến lab-07
Tầng input được xây và verify độc lập với USB, theo đúng thứ tự từ thấp lên cao - mỗi bước xác nhận xong bằng debugger trước khi làm bước kế tiếp:
lab-05 Hardware layer (keypad.c, scan_scheduler.c) → verify: build OK, cấu trúc source đúnglab-06 Detection layer (key_detect.c: debounce, repeat, simultaneous-error) → verify: breakpoint trong KeyDetect_Run, quan sát rawState/sScanBufferlab-07 Queue + key_table (key_event_queue.c, key_table.c) → verify: watch sReport.bytes[] qua debugger, chưa cần USB thậtLý do verify bằng debugger thay vì cắm USB thử ngay: ở giai đoạn này chưa có tầng HID hay USB transport nào tồn tại, nên “đúng” chỉ có thể kiểm chứng bằng cách đọc trực tiếp biến nội bộ. Cách này cũng tách rủi ro rõ ràng - nếu sau này USB không hoạt động, đã biết chắc tầng input không phải nguyên nhân vì đã verify riêng từ trước.
Scan ma trận bằng BSRR/IDR
Thay vì dùng HAL_GPIO_WritePin/ReadPin, code dùng trực tiếp thanh ghi BSRR
và IDR của GPIOB.
uint16_t Keypad_ReadRaw(void){ uint16_t state = 0U; for (uint8_t row = 0; row < KEYPAD_ROW_NUM; row++) { /* Kéo Row xuống thấp: BSRR bit [31:16] = reset field */ KEYPAD_GPIO->BSRR = (uint32_t)sRowPins[row] << 16U; __NOP(); __NOP(); __NOP(); __NOP(); for (uint8_t col = 0; col < KEYPAD_COL_NUM; col++) { if ((KEYPAD_GPIO->IDR & (uint32_t)sColPins[col]) == 0U) state |= (uint16_t)(1U << (row * KEYPAD_COL_NUM + col)); } /* Trả Row về cao: BSRR bit [15:0] = set field */ KEYPAD_GPIO->BSRR = (uint32_t)sRowPins[row]; } return state;}Lý do dùng BSRR/IDR trực tiếp: BSRR là atomic write (không cần read-modify-write như ODR), không có rủi ro race condition với interrupt. IDR là read đơn giản. Cả hai nhanh hơn và code rõ ràng hơn chuỗi HAL calls khi đã hiểu cấu trúc thanh ghi.
Scan scheduler: ISR chỉ tạo request, không scan trực tiếp
Keypad_ReadRaw không chạy trực tiếp trong ngắt TIM6. ISR chỉ làm đúng một việc: tăng
một bộ đếm request đang chờ.
/* app_main.c - ngắt TIM6 mỗi 5ms */void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim){ if (htim->Instance == TIM6) { ScanScheduler_OnTimerTick(); }}/* scan_scheduler.c */void ScanScheduler_OnTimerTick(void){ sScanTickTotal++; if (sScanRequestCount < SCAN_REQUEST_MAX_COUNT) { sScanRequestCount++; }}Main loop mới thật sự gọi KeyDetect_Run (hàm gọi Keypad_ReadRaw bên trong):
/* app_main.c - main loop */while (ScanScheduler_TakeRequest() != 0U){ KeyDetect_Run();}ScanScheduler_TakeRequest tiêu thụ đúng một request mỗi lần gọi, tắt/mở ngắt quanh
đoạn đọc-sửa-ghi bộ đếm để tránh race với chính ISR đang tăng nó:
uint8_t ScanScheduler_TakeRequest(void){ uint8_t hasRequest = 0U; uint32_t primask = __get_PRIMASK(); __disable_irq(); if (sScanRequestCount > 0U) { sScanRequestCount--; hasRequest = 1U; } if (primask == 0U) { __enable_irq(); } return hasRequest;}Lý do tách ISR khỏi phần xử lý thật: KeyDetect_Run phải quét cả ma trận
(Keypad_ReadRaw có 4 vòng lặp con và vài __NOP() delay), rồi debounce, rồi có thể
đẩy sự kiện vào queue - tổng thời gian chạy không nhỏ và không cố định. Nếu chạy thẳng
trong ngắt, TIM6 sẽ chiếm CPU lâu hơn cần thiết và có thể trễ các ngắt ưu tiên cao hơn
như USB. ISR tối giản, main loop xử lý thật là nguyên tắc chung khi viết firmware
bare-metal.
SCAN_REQUEST_MAX_COUNT = 10 giới hạn số request tồn đọng tối đa. Nếu main loop bị
chặn hơn 10 x 5ms = 50ms, các tick tiếp theo bị bỏ qua thay vì để bộ đếm tăng vô hạn -
backpressure có chủ đích, chấp nhận bỏ vài chu kỳ scan hiếm khi xảy ra thay vì để hệ
thống rối loạn lúc main loop tạm bận việc khác như xử lý USB transfer.
Debounce 2 mẫu liên tiếp
static uint16_t sScanBuffer[4]; /* [0] mới nhất, [3] cũ nhất *//* Phím ổn định nhấn: có mặt trong 2 mẫu gần nhất liên tiếp */uint16_t stableOn = sScanBuffer[0] & sScanBuffer[1];/* Phím ổn định thả: vắng mặt trong 2 mẫu gần nhất liên tiếp */uint16_t stableOff = ~(sScanBuffer[0] | sScanBuffer[1]) & KEYPAD_KEY_MASK;2 mẫu x 5ms = 10ms debounce window. Tiếp điểm cơ học thường rung trong 1-10ms - 10ms lọc sạch mà không làm chậm cảm nhận phím.
Buffer khai báo 4 slot nhưng chỉ 2 slot đầu được dùng - dấu vết của hướng thiết kế ban đầu nhắm tới N-key rollover (dùng lịch sử sâu hơn để phân biệt tổ hợp phím phức tạp), bị bỏ vì ma trận không diode khiến hướng đó không khả thi. Giữ nguyên 4 slot vì không ảnh hưởng gì, không phải bug hay sơ suất bỏ quên.
Chính sách simultaneous-error
Ma trận không diode không thể phân biệt phím thật và ghost key khi nhấn đồng thời nhiều phím. Thay vì cố gắng lọc ghost (không đáng tin trên phần cứng này), firmware dùng chính sách an toàn hơn:
Khi một lần quét thô (trước debounce) phát hiện ≥2 phím cùng lúc:
- Latch cờ
sSimultaneousErrorActive - Thả hết phím đang active (
KeyDetect_ReleaseAllKeys) - Đẩy
KEY_EVENT_ERRORvớiKEY_LOC_ERROR_ROLLOVERvào queue - Chặn toàn bộ input cho đến khi tất cả phím vật lý được thả
Đây là đánh đổi có chủ đích: mất khả năng nhấn tổ hợp thật để đổi lấy độ tin cậy. Macro multi-key (Ctrl+C, Alt+Tab) được xử lý ở tầng HID Convert bằng cách gửi tuần tự - không nhấn đồng thời thật.
Key repeat
#define KEY_REPEAT_INTERVAL_TICKS 40U /* 40 x 5ms = 200ms */Phím giữ 200ms sẽ phát KEY_EVENT_REPEAT. Mỗi phím có counter riêng trong
sRepeatTick[], reset về 0 khi phím được thả.
key_table.c: tách vị trí vật lý khỏi ý nghĩa phím
KeyEvent_t ra khỏi queue chỉ mang keyLoc (0-15), không mang ý nghĩa “phím nào”.
Việc ánh xạ nằm ở module riêng key_table.c, dùng đúng 16 dòng cho 16 vị trí:
{ KEY_KIND_NORMAL, HID_MOD_NONE, HID_USAGE_1, MACRO_NONE, 1U }, /* keyLoc 0 *//* ... row 0: 1,2,3,4 | row 1: A,B,C,D | row 2: Enter,Space,Backspace,Tab ... */{ KEY_KIND_MACRO, HID_MOD_NONE, 0x00U, MACRO_CTRL_C, 0U }, /* keyLoc 12 *//* ... row 3: macro Ctrl+C, Ctrl+V, Ctrl+S, Alt+Tab */key_table.h định nghĩa đúng 3 loại key:
typedef enum { KEY_KIND_NORMAL = 0, // single keystroke: modifier + usage KEY_KIND_MACRO, // multi-step sequence identified by macroId KEY_KIND_SPECIAL // reserved for layer switch, media keys, etc.} KeyKind_t;Bảng thật hiện chỉ dùng 2 trong 3 loại: KEY_KIND_NORMAL cho 12 phím đầu (số, chữ,
Enter/Space/Backspace/Tab), KEY_KIND_MACRO cho 4 phím cuối. KEY_KIND_SPECIAL không
có entry nào dùng - đây là chỗ chừa sẵn trong enum theo đúng comment gốc trong header
(“reserved for layer switch, media keys, etc.”), không phải logic đã hoàn thiện.
Tách bằng module riêng thay vì gắn cứng ý nghĩa vào từng keyLoc mang lại lợi ích cụ
thể: đổi layout keypad chỉ cần sửa một dòng trong bảng, không đụng đến tầng scan,
debounce, hay event queue phía trên - những tầng đó không biết và không cần biết ý
nghĩa của từng phím.
Bug gặp phải
TIM6 NVIC không được bật (lab-02). Build OK, chạy thử không thấy lỗi. Nhưng khi
debug thì ScanScheduler_OnTimerTick không bao giờ được gọi - timer interrupt không
bắn dù HAL_TIM_Base_Start_IT đã được gọi trong main.c.
Nguyên nhân: CubeMX không tự bật NVIC cho TIM6 khi generate code. Phải vào CubeMX, tab
NVIC, tích TIM6_DAC_LPTIM1_IRQn rồi generate lại. Phát hiện bằng cách đối chiếu với
project cũ đã chạy đúng.
Bài học: “build OK” không đủ. Cần verify từng phần bằng breakpoint hoặc logic analyzer trước khi kết luận đúng.
Evidence
Tham chiếu: assets/evidence/ trong repo stm32g0-usb-device-lab.
| Milestone | Evidence |
|---|---|
lab-05 | Build OK, cấu trúc source Hardware layer |
lab-06 | Build OK; video debug breakpoint trong KeyDetect_Run: timing 5ms, debounce, simultaneous error (youtu.be/SdR-xoFYoiA) |
lab-07 | Build OK; video watch sReport.bytes[] trong debugger khi nhấn phím - verify HID report layer trước khi có USB (youtu.be/aOZO3ETZRBc) |
Xem thêm: bài 4 Key Input Pipeline, bài 5 HID Keyboard Baseline (lab-07 bao gồm HID report layer, bước cuối trước USB transport)
Giới hạn hiện tại
- Không hỗ trợ nhấn nhiều phím vật lý cùng lúc (NKRO) - ma trận không diode nên chính sách simultaneous-error khoá input thay vì cố phân biệt phím thật/phím ma.
- Layout vật lý (row/col pin, số lượng phím) cố định lúc biên dịch qua
sRowPins/sColPins- đổi phần cứng keypad cần sửa code, không cấu hình runtime được. - Debounce window (2 mẫu, 10ms) và repeat interval (200ms) là hằng số biên dịch, không điều chỉnh được lúc chạy hoặc qua vendor command.
KEY_KIND_SPECIALmới chỉ là chỗ chừa sẵn trong enum, chưa có entry hay logic xử lý nào thật sự dùng đến (xem phần key_table.c phía trên).
Video demo
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ị.
Nội dung liên quan
Một số bài viết, ghi chú hoặc project có liên quan đến nội dung bạn vừa đọc.
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.
Debounce Algorithm là gì?
Tại sao phím cơ học bounce và cách xử lý bằng counter-based debounce, timestamp debounce, scan buffer và state machine trong firmware.
STM32 GPIO Register Access là gì?
Truy cập GPIO bằng BSRR và IDR thay vì HAL_GPIO_WritePin/ReadPin: tại sao, khi nào phù hợp, và cách áp dụng cho matrix scan row-by-row trên STM32.
Tiếp tục xem các project embedded
Các project thực chiến giúp biến ghi chú kỹ thuật thành kinh nghiệm.