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.

10 phút đọc
STM32 cover

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ật

Lý 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.

CubeIDE Watch window quan sát sScanBuffer và rawState khi nhấn phím
Breakpoint trong KeyDetect_Run: rawState đổi, debounce xác nhận sau 2 mẫu, KEY_EVENT_ON được đẩy vào queue - toàn bộ verify được trước khi có USB thật.

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:

  1. Latch cờ sSimultaneousErrorActive
  2. Thả hết phím đang active (KeyDetect_ReleaseAllKeys)
  3. Đẩy KEY_EVENT_ERROR với KEY_LOC_ERROR_ROLLOVER vào queue
  4. 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.

MilestoneEvidence
lab-05Build OK, cấu trúc source Hardware layer
lab-06Build OK; video debug breakpoint trong KeyDetect_Run: timing 5ms, debounce, simultaneous error (youtu.be/SdR-xoFYoiA)
lab-07Build 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_SPECIAL mớ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ị.

Góp ý

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.

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.