Mở đầu: nút nhấn "ma ám" — một cú bấm, nhiều lần đếm
Bài 3 để lại một lời hẹn: bộ đếm nút nhấn dùng polling đơn giản "chưa xử lý chống dội". Giờ là lúc trả nợ
lời hẹn đó. Lắp một bộ đếm nút nhấn thật lên board, chỉ đếm cạnh lên (0→1) mỗi khi
gpioaReadPin đổi từ 0 sang 1 — về lý thuyết, mỗi lần bấm phải ra đúng +1. Chạy thử: bấm 1 cái
thật nhanh, dứt khoát — bộ đếm nhảy lên 2, có khi 3, đôi khi còn hơn. Không phải tay bạn run, không phải
phần mềm có bug logic — đó là dội (bounce): một hiện tượng CƠ HỌC hoàn toàn thật, xảy ra
ở mọi công tắc/nút nhấn có tiếp điểm kim loại trên đời.
Bài này mổ xẻ đúng cơ chế vật lý gây ra dội, so sánh 2 cách chống dội (delay — dễ mà dở, và lấy mẫu định kỳ — đúng nhưng cần tinh chỉnh), rồi lên hẳn một cấp trừu tượng cao hơn: máy trạng thái hữu hạn (FSM) — xương sống của mọi firmware không chạy RTOS. Cuối bài, bạn sẽ tự mắt thấy 2 bộ đếm chạy song song trên CÙNG một tín hiệu dội y hệt: một cái đếm loạn, một cái luôn đúng.
1. Dội (bounce) — sự thật vật lý của tiếp điểm
Bên trong một nút nhấn cơ học, hai lá kim loại chạm vào nhau khi bạn bấm. Ở cấp độ micro-giây, lá kim loại KHÔNG chạm vào nhau một cách "sạch sẽ" — nó nảy lên nảy xuống nhiều lần (rung cơ học của chính tấm kim loại + lực đàn hồi), tạo ra hàng chục lần chạm/rời chạm cực nhanh trong khoảng 1–10 mili-giây trước khi thật sự "yên vị" ở trạng thái tiếp xúc ổn định. Nhìn qua oscilloscope, tín hiệu không phải một cạnh lên sạch mà là một chuỗi răng cưa hỗn loạn trước khi ổn định:
Minh hoạ dạng sóng dội: vài lần nảy hỗn loạn (xanh/xám xen kẽ) trước khi tín hiệu thật sự ổn định ở mức cao (toàn xanh). Demo thật ở Mục 6 tái hiện đúng kiểu tín hiệu này.
Đây KHÔNG phải lỗi thiết kế mạch hay lỗi firmware — nó là đặc tính cơ học không thể loại bỏ hoàn toàn của mọi tiếp điểm kim loại, chỉ có thể được lọc sạch bằng phần mềm (hoặc phần cứng, ngoài phạm vi bài này). Việc lọc đó gọi là debounce.
2. Debounce bằng delay() — dễ mà dở
Cách đơn giản nhất ai cũng nghĩ tới đầu tiên: phát hiện cạnh lên xong thì chờ một khoảng cố định (ví dụ 50ms) trước khi đọc lại, coi như "chờ cho dội lắng xuống":
if (button_edge_detected()) {
delay(50); // "cho doi lang xuong" - nhung CPU dong bang 50ms
if (button_still_pressed()) {
count++; // 50ms la con so CHON MU, khong theo dac tinh nut that
}
}
delay() chặn
CPU — phản bội toàn bộ bài học non-blocking của Bài 4, trong
50ms đó chương trình không đọc được nút khác, không cập nhật được LED nào cả. (2) hằng
số 50 được chọn MÙ — nút nhấn khác nhau (bàn phím cơ, công tắc micro, nút to trên tủ điện)
có thời gian dội khác nhau hoàn toàn (thường 1–10ms, nhưng có loại tới 20-30ms); chọn quá ngắn thì vẫn
dính dội, chọn quá dài thì bỏ lỡ những lần nhấn nhanh liên tiếp thật sự.
3. Debounce bằng lấy mẫu định kỳ — đúng tinh thần non-blocking
Cách chuẩn firmware thương mại: đọc chân nút mỗi tick (ví dụ mỗi 5ms, dùng đúng
millis() của Bài 4, KHÔNG chặn CPU), và chỉ công nhận trạng thái mới sau khi thấy
N mẫu liên tiếp đều cùng một mức logic. N và chu kỳ lấy mẫu T được chọn theo đặc tính
thật của nút (thường đọc từ datasheet, hoặc đo bằng oscilloscope như Mục 1) — ví dụ N=5, T=5ms nghĩa là
cần 25ms ổn định liên tục mới công nhận:
uint8_t stable_count = 0;
uint8_t last_stable_state = 0; // 0 = tha, 1 = nhan
void on_tick_5ms(void) { // goi moi 5ms, KHONG blocking
uint8_t raw = read_button_pin();
if (raw == last_stable_state) {
stable_count = 0; // giong trang thai cu - khong co gi moi
return;
}
stable_count++;
if (stable_count >= 5) { // 5 mau lien tiep = 25ms on dinh
last_stable_state = raw; // CONG NHAN trang thai moi
stable_count = 0;
if (raw == 1) count++; // canh len da loc sach
}
}
Cách này đã ĐÚNG và không blocking — nhưng cách viết bằng biến đếm rời rạc như trên dễ rối khi số trạng thái tăng lên (ví dụ cần phân biệt "đang chờ xác nhận nhấn" khác với "đang chờ xác nhận thả"). Mục 4 nâng cấp đúng ý tưởng này lên một cấu trúc rõ ràng hơn hẳn.
4. Máy trạng thái hữu hạn — xương sống của firmware không-RTOS
FSM
là cách firmware chuyên nghiệp diễn đạt "hệ thống đang ở đâu và sẽ đi về đâu": một enum liệt
kê MỌI trạng thái có thể, cùng một switch định nghĩa CHÍNH XÁC điều gì xảy ra ở mỗi trạng
thái và khi nào chuyển sang trạng thái khác. Với nút nhấn, 4 trạng thái là đủ:
| Trạng thái | Ý nghĩa | Chuyển đi khi nào |
|---|---|---|
RELEASED |
Đang thả, ổn định | Thấy 1 mẫu pressed → MAYBE_PRESSED |
MAYBE_PRESSED |
Ứng viên "có thể vừa nhấn", đang đếm mẫu ổn định |
Đủ N mẫu pressed liên tiếp → PRESSED (phát sự kiện); dội về released → huỷ, quay lại
RELEASED
|
PRESSED |
Đang nhấn, ổn định | Thấy 1 mẫu released → MAYBE_RELEASED |
MAYBE_RELEASED |
Ứng viên "có thể vừa thả", đang đếm mẫu ổn định |
Đủ N mẫu released liên tiếp → RELEASED (phát sự kiện); dội về pressed → huỷ, quay lại
PRESSED
|
typedef enum { RELEASED, MAYBE_PRESSED, PRESSED, MAYBE_RELEASED } btn_state_t;
btn_state_t state = RELEASED;
uint8_t counter = 0;
const uint8_t N = 5; // so mau on dinh can thiet
// Goi moi tick (vd 5ms). Tra ve: 1=vua PRESS, 2=vua RELEASE, 0=khong co gi moi.
uint8_t button_fsm_sample(uint8_t raw_pressed) {
switch (state) {
case RELEASED:
if (raw_pressed) { state = MAYBE_PRESSED; counter = 1; }
return 0;
case MAYBE_PRESSED:
if (!raw_pressed) { state = RELEASED; counter = 0; return 0; } // doi - huy ung vien
if (++counter >= N) { state = PRESSED; counter = 0; return 1; } // SU KIEN: pressed
return 0;
case PRESSED:
if (!raw_pressed) { state = MAYBE_RELEASED; counter = 1; }
return 0;
case MAYBE_RELEASED:
if (raw_pressed) { state = PRESSED; counter = 0; return 0; } // doi - huy ung vien
if (++counter >= N) { state = RELEASED; counter = 0; return 2; } // SU KIEN: released
return 0;
}
return 0;
}
Điểm mấu chốt: sự kiện (cạnh đã lọc sạch — "vừa nhấn", "vừa thả") được tách hoàn toàn
khỏi hành động (toggle LED, tăng biến đếm...). Hàm button_fsm_sample chỉ trả
lời "có sự kiện gì không", còn quyết định làm gì với sự kiện đó là việc của code gọi nó — kiến trúc này mở
rộng cực dễ (mở rộng nhấn-giữ ở Mục 6 chỉ cần thêm 1 trạng thái, không đụng gì tới logic hiện có).
enum/switch rõ ràng, lập trình viên thường "vá" từng trường
hợp bằng cờ boolean rải rác (waitingForRelease, justPressed,
maybeDebouncing...) và if lồng nhau ngày càng sâu. Kết quả: sau vài lần thêm tính năng
(long-press, double-click...), không ai — kể cả người viết — có thể vẽ lại được sơ đồ trạng thái đang
chạy, và không thể viết test cho từng trạng thái vì chúng không tồn tại như một khái niệm rõ ràng trong
code, chỉ là tổ hợp cờ ẩn. FSM buộc bạn liệt kê MỌI trạng thái ngay từ đầu — không có "trạng thái ẩn"
nào lọt lưới.
5. VMCU: ButtonFSM đã có mặt
Bài này không cần thêm ngoại vi mới cho VMCU — tận dụng nguyên vẹn GPIOA (Bài 2-3) và tick (Bài 4). Thay
vào đó, VMCU thêm một lớp logic firmware sống ngoài class VMCU (đúng như
code C thật sống ngoài bản thân con chip): ButtonFSM triển khai chính xác 4 trạng thái ở Mục
4, cùng simulateBouncedPress() — một hàm MÔ PHỎNG dội cho mục đích demo/self-test (không có
trên phần cứng thật, lá kim loại thật nảy hỗn loạn chứ không theo mã có sẵn).
class ButtonFSM {
constructor(stableSamplesNeeded = 5) {
this.stableSamplesNeeded = stableSamplesNeeded;
this.state = BTN_STATE_RELEASED;
this.counter = 0;
}
sample(rawPressed) {
switch (this.state) {
case BTN_STATE_RELEASED:
if (rawPressed) { this.state = BTN_STATE_MAYBE_PRESSED; this.counter = 1; }
return null;
case BTN_STATE_MAYBE_PRESSED:
if (!rawPressed) { this.state = BTN_STATE_RELEASED; this.counter = 0; return null; }
if (++this.counter >= this.stableSamplesNeeded) {
this.state = BTN_STATE_PRESSED; this.counter = 0; return 'pressed';
}
return null;
// ... PRESSED / MAYBE_RELEASED doi xung tuong tu
}
}
}
Số đo thật từ self-test (node vmcu.js): mô phỏng đúng 1 lần nhấn-rồi-thả với 3 lần dội khi
nhấn VÀ 3 lần dội khi thả (26 mẫu thô tổng cộng), đưa CÙNG chuỗi đó vào cả 2 bộ đếm:
| Bộ đếm | Kết quả trên 1 lần nhấn có dội |
|---|---|
| Đếm thô (đếm mọi cạnh lên, không debounce) | 3 cạnh lên giả — cho đúng 1 lần nhấn thật |
ButtonFSM (N=5 mẫu ổn định) |
Đúng 1 sự kiện 'pressed' + đúng 1 sự kiện
'released'
|
| Dội liên tục không bao giờ ổn định đủ N mẫu (10 mẫu xen kẽ 1/0) | 0 sự kiện nào cả — FSM đúng đắn từ chối công nhận |
PRESSED ổn định, việc tiếp tục nhận mẫu
pressed=true (giữ nút bao lâu cũng vậy) KHÔNG kích hoạt lại nhánh
MAYBE_PRESSED — vì switch chỉ xử lý chuyển trạng thái ở nhánh
PRESSED khi thấy !raw_pressed. Đây chính là nền tảng để mở rộng phát hiện
nhấn-giữ (long-press) ở Mục 6: chỉ cần thêm MỘT trạng thái mới
LONG_PRESSED, chuyển vào từ PRESSED khi đã đứng yên đủ lâu (ví dụ 1 giây, đo
bằng millis() của Bài 4) — không cần đụng vào bất kỳ nhánh nào khác. FSM "nở" thêm tính
năng mà không phá vỡ logic cũ, đúng như lời hứa ở Mục 4.
6. Thực hành: hai bộ đếm chạy song song trên cùng 1 tín hiệu dội
Demo dưới đây tạo ra một chuỗi mẫu thô mô phỏng dội thật (giống hệt cách self-test làm), phát từng mẫu một
theo thời gian thực vào 2 bộ đếm cùng lúc: một cái ngây thơ (đếm mọi cạnh lên), một cái
chạy qua ButtonFSM. Sơ đồ 4 trạng thái bên dưới tô sáng đúng trạng thái FSM đang đứng theo
từng mẫu:
Tín hiệu thô theo thời gian (oscilloscope):
Bấm nút nhiều lần liên tiếp — bộ đếm "Thô" sẽ tăng nhảy loạn (2-3 mỗi lần bấm vì dội), còn bộ đếm "FSM" luôn tăng đúng chính xác +1 cho mỗi lần bấm thật, bất kể dội bên trong tín hiệu.
// Dung o tab "Xem truoc" khi bam nut: ca 2 bo dem chay tren CUNG 1 tin hieu tho
uint8_t raw_prev = 0;
uint32_t raw_count = 0; // NGAY THO - dem moi canh len, khong loc gi ca
btn_state_t fsm_state = RELEASED;
uint32_t fsm_count = 0; // qua ButtonFSM (Muc 4) - da loc doi
void on_tick(uint8_t raw) {
if (raw && !raw_prev) raw_count++; // dem tho: moi canh len la +1, ke ca canh gia do doi
raw_prev = raw;
uint8_t event = button_fsm_sample(raw); // 1 = pressed that
if (event == 1) fsm_count++; // fsm: chi +1 khi da xac nhan on dinh
}
import { ButtonFSM, simulateBouncedPress } from './vmcu.js';
const fsm = new ButtonFSM(5);
let rawCount = 0, fsmCount = 0, prevRaw = false;
function onPressClicked() {
const samples = [...simulateBouncedPress(true, 3, 10), ...simulateBouncedPress(false, 3, 10)];
samples.forEach((raw, i) => {
setTimeout(() => {
if (raw && !prevRaw) rawCount++; // dem tho
prevRaw = raw;
const ev = fsm.sample(raw); // fsm debounce
if (ev === 'pressed') fsmCount++;
render(raw);
}, i * 40); // phat tung mau theo thoi gian thuc, 40ms/mau
});
}
Tóm lược
- ✅ Dội (bounce) là hiện tượng cơ học thật của tiếp điểm kim loại (1–10ms hỗn loạn trước khi ổn định) — không phải bug phần mềm, không thể loại bỏ hoàn toàn ở phần cứng, chỉ lọc được bằng debounce phần mềm.
- ✅ Debounce bằng delay() hoạt động nhưng có 2 vấn đề: quay lại blocking (phản bội Bài 4) và hằng số thời gian chọn mù.
- ✅ Debounce bằng lấy mẫu định kỳ (N mẫu ổn định liên tiếp, không blocking) là cách đúng — nhưng viết bằng biến đếm rời rạc dễ rối khi thêm trạng thái.
-
✅ Máy trạng thái hữu hạn (FSM): enum + switch, tách sự kiện khỏi hành động — verified:
đếm thô ra 3 cạnh giả cho 1 nhấn thật có dội, FSM luôn đúng 1 sự kiện
pressed+ 1 sự kiệnreleased, và không phát sinh sự kiện nào khi dội không bao giờ ổn định đủ. - ✅ Mở rộng (long-press, double-click...) chỉ cần thêm trạng thái mới, không đụng logic cũ — đây là lý do FSM là xương sống chuẩn của firmware không-RTOS.
Trắc nghiệm ôn tập
Câu 1
Nếu chỉ đếm số cạnh lên (0→1) trực tiếp trên tín hiệu thô đọc từ chân nút nhấn, tại sao MỘT lần nhấn thật có thể bị đếm ra NHIỀU HƠN 1?
Câu 2
Debounce bằng cách gọi delay(50) ngay sau khi phát hiện cạnh có vấn đề gì?
Câu 3
Trong FSM 4 trạng thái, vì sao cần trạng thái trung gian MAYBE_PRESSED thay vì chuyển thẳng
RELEASED → PRESSED ngay khi thấy 1 mẫu pressed?
Câu 4
Muốn mở rộng FSM để phát hiện thêm nhấn-giữ (long-press), cách tiếp cận đúng tinh thần FSM là gì?
Tải file code thực hành minh họa bài học
File JavaScript VMCU — engine MCU ảo dùng xuyên suốt cả 16 bài, Bài 5 vừa thêm
ButtonFSM (chống dội chuẩn firmware) và simulateBouncedPress (mô phỏng dội cho
demo/self-test), kèm self-test đối chiếu đúng mọi hành vi trong bài (chạy node vmcu.js,
không cần cài thêm gì):
Bình luận