BÀI 35 – DEBOUNCE NÚT NHẤN + EXTI + TIMER TRÊN STM32F103
Đây là bài rất quan trọng vì từ bài này chúng ta bắt đầu kết hợp nhiều ngoại vi với nhau:
GPIO Input
↓
EXTI
↓
NVIC
↓
Timer
↓
Debounce
↓
Ứng dụng thực tế
Sau bài này, bạn sẽ hiểu tại sao một nút nhấn cơ khí nhấn 1 lần nhưng vi điều khiển có thể tưởng rằng bạn nhấn 5–10 lần.
1. Vấn đề: tại sao nút nhấn bị “dội”?
Ở bài 33, chúng ta dùng:
PA0 = Button
PC13 = LED
và:
PA0 → EXTI0
Khi nhấn nút:
HIGH → LOW
EXTI phát hiện cạnh xuống.
Về lý thuyết:
1 lần nhấn
↓
1 interrupt
Nhưng thực tế nút cơ khí không chuyển trạng thái sạch như vậy.
2. Tín hiệu thực tế của nút nhấn
Khi bạn nhấn nút, tiếp điểm kim loại có thể rung trong vài mili-giây.
Thay vì:
HIGH ────────────────
│
└──────── LOW
ta có thể nhận được:
HIGH ─────┐
└─ LOW
┌─ HIGH
└─ LOW
┌─ HIGH
└──────── LOW
Tức là:
HIGH → LOW → HIGH → LOW → HIGH → LOW
trong một khoảng thời gian rất ngắn.
Đây gọi là:
Mechanical Bounce – hiện tượng dội tiếp điểm cơ khí.
3. Tại sao EXTI lại bị ảnh hưởng?
EXTI rất nhanh.
Nếu chúng ta cấu hình:
EXTI->FTSR |= (1 << 0);
thì mỗi cạnh HIGH → LOW đều có thể tạo interrupt.
Ví dụ một lần nhấn thực tế:
1 lần nhấn
│
▼
HIGH ────────┐
└─ LOW ← interrupt 1
┌─ HIGH
└─ LOW ← interrupt 2
┌─ HIGH
└─ LOW ← interrupt 3
CPU có thể nhận:
3 interrupt
trong khi người dùng chỉ nhấn:
1 lần
4. Hậu quả
Nếu ISR của chúng ta là:
void EXTI0_IRQHandler(void)
{
if (EXTI->PR & (1 << 0))
{
GPIOC->ODR ^= (1 << 13);
EXTI->PR = (1 << 0);
}
}
thì:
Nhấn 1 lần
↓
3 interrupt
↓
LED đảo 3 lần
↓
LED cuối cùng có thể quay về trạng thái cũ
Bạn sẽ thấy:
“Tôi nhấn nút nhưng LED lúc được lúc không.”
Đây không phải lỗi EXTI.
Đó là bounce.
5. Debounce là gì?
Debounce nghĩa là:
Lọc hiện tượng dội của nút nhấn để biến nhiều chuyển trạng thái rất nhanh thành một sự kiện nhấn hợp lệ duy nhất.
Ví dụ:
Tín hiệu thật:
HIGH LOW HIGH LOW HIGH LOW LOW LOW LOW
└──── bounce ────┘
Debounce:
HIGH ───────────────── LOW
↑
1 lần nhấn
6. Có những phương pháp debounce nào?
Có nhiều cách.
Cách 1 – Delay
delay_ms(20);
Dễ nhưng không tốt trong hệ thống lớn.
Cách 2 – Polling
Đọc nút định kỳ:
10 ms
20 ms
30 ms
40 ms
...
Cách 3 – EXTI + Timer
Đây là phương pháp chúng ta học hôm nay.
Nút nhấn
↓
EXTI
↓
không xử lý ngay
↓
Timer đếm khoảng 20 ms
↓
đọc lại nút
↓
nếu vẫn LOW
↓
xác nhận nhấn
Đây là cách rất hay vì:
EXTI phát hiện sự kiện, Timer tạo khoảng thời gian xác nhận.
7. Ý tưởng cốt lõi của bài 35
Chúng ta không làm:
Button
↓
EXTI
↓
LED
mà làm:
Button
↓
EXTI
↓
"có khả năng vừa nhấn"
↓
Start Timer
↓
20 ms
↓
Timer Interrupt
↓
đọc lại GPIO
↓
vẫn LOW?
↓
YES
↓
Xác nhận nhấn
Đây là kiến trúc rất quan trọng.
8. Tại sao dùng Timer?
Giả sử thời gian debounce là:
20 ms
Khi EXTI xảy ra:
t = 0 ms
chúng ta chưa biết đây có phải nhấn thật hay bounce.
Ta chờ:
t = 20 ms
rồi đọc lại.
Nếu vẫn:
PA0 = LOW
thì xác nhận:
BUTTON PRESSED
9. Sơ đồ hoạt động
┌─────────────┐
│ BUTTON │
└──────┬──────┘
│
▼
PA0
│
▼
EXTI0
│
▼
NVIC
│
▼
EXTI0_IRQHandler
│
│
Start Timer
│
▼
20 ms delay
│
▼
TIM_IRQHandler
│
▼
Đọc lại PA0
/ \
LOW HIGH
│ │
▼ ▼
Nhấn thật Bounce
│
▼
Xử lý nút
10. Chúng ta sẽ dùng Timer nào?
Với STM32F103C8T6, chúng ta có:
TIM1
TIM2
TIM3
TIM4
Trong bài này chúng ta dùng:
TIM2
vì nó rất phù hợp để học Timer interrupt.
11. Timer hoạt động như thế nào?
Timer có một bộ đếm:
CNT
Nó tăng theo clock.
Ví dụ:
CNT = 0
CNT = 1
CNT = 2
CNT = 3
...
Khi:
CNT == ARR
Timer tạo sự kiện update.
Thanh ghi:
TIM2->CNT
TIM2->PSC
TIM2->ARR
12. Ba thanh ghi Timer cần nhớ
PSC
TIM2->PSC
Prescaler.
Dùng để chia clock.
ARR
TIM2->ARR
Auto Reload Register.
Xác định giá trị đếm tối đa.
CNT
TIM2->CNT
Counter.
Giá trị hiện tại của bộ đếm.
13. Công thức Timer
Tần số Timer:
[
f_{timer} = \frac{f_{clock}}{PSC+1}
]
Thời gian một lần đếm:
[
T_{tick} = \frac{PSC+1}{f_{clock}}
]
Thời gian overflow:
[
T = \frac{(PSC+1)(ARR+1)}{f_{clock}}
]
14. Ví dụ tạo tick 1 ms
Giả sử Timer clock:
72 MHz
Ta muốn:
1 tick = 1 ms
Chọn:
PSC = 7199
Khi đó:
[
72,MHz / 7200 = 10,kHz
]
Một tick:
[
1/10000 = 100\mu s
]
Nếu:
ARR = 9
thì:
[
10 × 100\mu s = 1ms
]
Vậy:
TIM2->PSC = 7199;
TIM2->ARR = 9;
sẽ tạo update event khoảng:
1 ms
15. Tạo debounce 20 ms
Chúng ta có thể cho Timer chạy với chu kỳ:
1 ms
Sau đó đếm:
20 lần
Ví dụ:
volatile uint8_t debounce_counter = 0;
Timer:
1 ms
↓
counter = 1
↓
1 ms
↓
counter = 2
...
↓
20 ms
↓
counter = 20
16. Nhưng có cách đơn giản hơn
Trong bài đầu tiên, chúng ta có thể cấu hình:
Timer = 20 ms
mỗi khi EXTI xảy ra:
Start TIM2
Timer hết 20 ms:
TIM2_IRQHandler()
sau đó:
đọc PA0
Đây là cách dễ hiểu nhất để học.
17. Trạng thái hệ thống
Chúng ta sẽ dùng biến:
volatile uint8_t debounce_active = 0;
Ý nghĩa:
0 = không debounce
1 = đang chờ debounce
Khi EXTI xảy ra:
if (debounce_active == 0)
{
debounce_active = 1;
Start_Timer();
}
Nếu bounce tiếp tục xảy ra:
debounce_active == 1
thì bỏ qua.
18. Đây là điểm rất hay
Ví dụ:
t = 0 ms
EXTI
↓
debounce_active = 1
↓
Timer chạy
Sau đó:
t = 2 ms
bounce → EXTI
Nhưng:
debounce_active == 1
nên:
Bỏ qua
Tiếp tục:
t = 5 ms
bounce → EXTI
vẫn:
Bỏ qua
Đến:
t = 20 ms
Timer kiểm tra trạng thái thật.
19. Code cấu hình GPIO
Trước tiên:
void GPIO_Init(void)
{
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
RCC->APB2ENR |= RCC_APB2ENR_IOPCEN;
RCC->APB2ENR |= RCC_APB2ENR_AFIOEN;
/* PA0 Input Pull-up */
GPIOA->CRL &= ~(0xF << 0);
GPIOA->CRL |= (0x8 << 0);
GPIOA->ODR |= (1 << 0);
/* PC13 Output Push-Pull */
GPIOC->CRH &= ~(0xF << 20);
GPIOC->CRH |= (0x2 << 20);
}
20. Cấu hình EXTI
void EXTI_Init(void)
{
EXTI->IMR |= (1 << 0);
/* Falling Edge */
EXTI->FTSR |= (1 << 0);
NVIC_SetPriority(EXTI0_IRQn, 1);
NVIC_EnableIRQ(EXTI0_IRQn);
}
Vì:
PA0 Pull-up
nên khi nhấn:
HIGH → LOW
do đó dùng:
FTSR
21. Cấu hình TIM2
Bây giờ phần quan trọng.
Chúng ta cần bật clock:
RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
Sau đó:
TIM2->PSC = 7199;
TIM2->ARR = 199;
Với clock 72 MHz:
72 MHz / 7200 = 10 kHz
Mỗi tick:
100 µs
200 tick:
20 ms
Vậy:
PSC = 7199
ARR = 199
→ khoảng:
20 ms
22. Cho phép Timer tạo interrupt
Thanh ghi:
TIM2->DIER
Dùng:
DMA/Interrupt Enable Register
Cho phép update interrupt:
TIM2->DIER |= TIM_DIER_UIE;
23. Bật TIM2 trong NVIC
NVIC_SetPriority(TIM2_IRQn, 2);
NVIC_EnableIRQ(TIM2_IRQn);
Chúng ta đặt:
EXTI0 = priority 1
TIM2 = priority 2
Như vậy EXTI có mức ưu tiên cao hơn Timer.
24. Nhưng chưa Start Timer
Đây là điểm quan trọng.
Nếu chúng ta:
TIM2->CR1 |= TIM_CR1_CEN;
ngay từ đầu:
Timer chạy liên tục
không phù hợp với debounce hiện tại.
Chúng ta muốn:
Không có nút nhấn
→ Timer STOP
Có nút nhấn
→ Timer START
20 ms
→ Timer STOP
25. Hàm Start Timer
void Timer2_Start(void)
{
TIM2->CNT = 0;
TIM2->SR &= ~TIM_SR_UIF;
TIM2->CR1 |= TIM_CR1_CEN;
}
26. Hàm Stop Timer
void Timer2_Stop(void)
{
TIM2->CR1 &= ~TIM_CR1_CEN;
}
27. EXTI ISR
Bây giờ chúng ta viết:
volatile uint8_t debounce_active = 0;
ISR:
void EXTI0_IRQHandler(void)
{
if (EXTI->PR & (1 << 0))
{
if (debounce_active == 0)
{
debounce_active = 1;
Timer2_Start();
}
EXTI->PR = (1 << 0);
}
}
Chú ý:
Không đảo LED ở đây.
EXTI chỉ nói:
“Có khả năng nút vừa được nhấn.”
28. Timer ISR
Sau khoảng 20 ms:
void TIM2_IRQHandler(void)
{
if (TIM2->SR & TIM_SR_UIF)
{
TIM2->SR &= ~TIM_SR_UIF;
Timer2_Stop();
debounce_active = 0;
if ((GPIOA->IDR & (1 << 0)) == 0)
{
GPIOC->ODR ^= (1 << 13);
}
}
}
Đây mới là nơi xác nhận.
29. Logic cực kỳ quan trọng
Chúng ta có:
EXTI
↓
Phát hiện thay đổi
↓
Start Timer
↓
20 ms
↓
Timer interrupt
↓
Đọc PA0
↓
PA0 vẫn LOW?
├── YES → Nhấn thật
└── NO → Bounce
Đây là debounce bằng xác nhận trạng thái sau một khoảng thời gian.
30. Code hoàn chỉnh
#include "stm32f10x.h"
volatile uint8_t debounce_active = 0;
void GPIO_Init(void)
{
/* GPIOA + GPIOC + AFIO */
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
RCC->APB2ENR |= RCC_APB2ENR_IOPCEN;
RCC->APB2ENR |= RCC_APB2ENR_AFIOEN;
/* PA0 = Input Pull-up */
GPIOA->CRL &= ~(0xF << 0);
GPIOA->CRL |= (0x8 << 0);
GPIOA->ODR |= (1 << 0);
/* PC13 = Output Push-Pull 2 MHz */
GPIOC->CRH &= ~(0xF << 20);
GPIOC->CRH |= (0x2 << 20);
}
void EXTI_Init(void)
{
/* PA0 -> EXTI0 */
EXTI->IMR |= (1 << 0);
/* Falling Edge */
EXTI->FTSR |= (1 << 0);
NVIC_SetPriority(EXTI0_IRQn, 1);
NVIC_EnableIRQ(EXTI0_IRQn);
}
void TIM2_Init(void)
{
/* Enable TIM2 clock */
RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
/*
72 MHz / 7200 = 10 kHz
1 tick = 100 us
ARR = 199
200 ticks = 20 ms
*/
TIM2->PSC = 7199;
TIM2->ARR = 199;
TIM2->CNT = 0;
/* Update interrupt enable */
TIM2->DIER |= TIM_DIER_UIE;
NVIC_SetPriority(TIM2_IRQn, 2);
NVIC_EnableIRQ(TIM2_IRQn);
/* Timer initially stopped */
TIM2->CR1 &= ~TIM_CR1_CEN;
}
void Timer2_Start(void)
{
TIM2->CNT = 0;
TIM2->SR &= ~TIM_SR_UIF;
TIM2->CR1 |= TIM_CR1_CEN;
}
void Timer2_Stop(void)
{
TIM2->CR1 &= ~TIM_CR1_CEN;
}
void EXTI0_IRQHandler(void)
{
if (EXTI->PR & (1 << 0))
{
if (debounce_active == 0)
{
debounce_active = 1;
Timer2_Start();
}
/* Clear EXTI pending */
EXTI->PR = (1 << 0);
}
}
void TIM2_IRQHandler(void)
{
if (TIM2->SR & TIM_SR_UIF)
{
/* Clear Timer flag */
TIM2->SR &= ~TIM_SR_UIF;
/* Stop Timer */
Timer2_Stop();
/* End debounce */
debounce_active = 0;
/*
Confirm button state
PA0 = LOW → valid press
*/
if ((GPIOA->IDR & (1 << 0)) == 0)
{
GPIOC->ODR ^= (1 << 13);
}
}
}
int main(void)
{
GPIO_Init();
EXTI_Init();
TIM2_Init();
while (1)
{
}
}
31. Hãy chú ý volatile
Chúng ta khai báo:
volatile uint8_t debounce_active = 0;
Tại sao?
Bởi vì biến này được sử dụng giữa:
main
EXTI ISR
Timer ISR
Interrupt có thể thay đổi giá trị của nó bất kỳ lúc nào.
Vì vậy:
volatile
là phù hợp.
32. Tại sao không dùng delay_ms(20)?
Một người mới học có thể viết:
void EXTI0_IRQHandler(void)
{
delay_ms(20);
if (...)
{
GPIOC->ODR ^= (1 << 13);
}
}
Có thể hoạt động trong một project rất đơn giản, nhưng không phải cách chúng ta muốn xây dựng firmware tốt.
Vấn đề:
CPU
↓
EXTI ISR
↓
delay 20 ms
↓
CPU bị giữ trong ISR
Trong 20 ms đó, hệ thống khó đáp ứng tốt các interrupt/tác vụ khác.
33. Cách Timer tốt hơn
Chúng ta làm:
EXTI ISR
↓
Start Timer
↓
Thoát ISR ngay
↓
CPU làm việc khác
↓
20 ms
↓
Timer ISR
↓
Kiểm tra nút
Điểm quan trọng:
Không giữ CPU trong EXTI ISR để chờ debounce.
Đây là tư duy firmware rất quan trọng.
34. Nhưng code trên vẫn còn một điểm cần cải thiện
Bạn có thể hỏi:
“Nếu nút vẫn đang được giữ, sau 20 ms Timer kiểm tra thấy LOW rồi đảo LED. Vậy khi giữ nút lâu thì sao?”
Trong cấu hình chỉ dùng Falling Edge, chúng ta sẽ không liên tục nhận Falling Edge chỉ vì giữ nút LOW.
Vì vậy:
Nhấn
↓
LOW
↓
20 ms
↓
xác nhận
↓
LED đổi
Sau đó giữ nút:
LOW LOW LOW LOW LOW
không tạo thêm Falling Edge.
Đây là điều chúng ta mong muốn.
35. Khi nhả nút
Nhả nút:
LOW → HIGH
Vì chúng ta chỉ dùng:
FTSR
nên:
LOW → HIGH
không tạo interrupt.
Lần nhấn tiếp theo:
HIGH → LOW
lại tạo Falling Edge.
Do đó:
Nhấn 1 → LED đổi
Nhả
Nhấn 2 → LED đổi
Nhả
Nhấn 3 → LED đổi
36. Tại sao 20 ms?
Không phải con số bắt buộc.
Có thể:
5 ms
10 ms
20 ms
30 ms
50 ms
Tùy nút.
Thông thường:
10–20 ms
là khoảng thời gian rất hay dùng cho nút nhấn cơ khí.
Trong bài học, chúng ta dùng:
20 ms
để dễ quan sát và hiểu nguyên lý.
37. Một tư duy rất quan trọng
Bạn vừa học được mô hình:
EVENT
↓
EXTI
↓
TIMER
↓
WAIT
↓
VERIFY
↓
ACTION
Đây là một pattern rất phổ biến trong embedded programming.
Không chỉ dùng cho nút nhấn.
Nó còn có thể dùng cho:
- cảm biến;
- tín hiệu công tắc;
- encoder;
- tín hiệu ngoài;
- giao tiếp thiết bị;
- phát hiện trạng thái ổn định.
38. So sánh 3 cách debounce
| Phương pháp | Ưu điểm | Nhược điểm |
|---|---|---|
| Delay | Rất dễ | Block CPU |
| Polling | Đơn giản | Phải đọc liên tục |
| EXTI + Timer | Không block ISR | Phức tạp hơn |
Với firmware thực tế, EXTI + Timer là một kiến trúc rất đáng biết.
39. Một lỗi rất quan trọng với Timer
Trong:
TIM2_IRQHandler()
chúng ta phải xử lý:
TIM2->SR
Ví dụ:
if (TIM2->SR & TIM_SR_UIF)
{
TIM2->SR &= ~TIM_SR_UIF;
}
Nếu quên xử lý cờ:
UIF vẫn = 1
có thể dẫn đến interrupt tiếp tục được xử lý.
40. Kiến trúc hoàn chỉnh chúng ta vừa xây dựng
Bạn hãy ghi nhớ sơ đồ này:
BUTTON
│
▼
PA0
│
▼
EXTI0
│
▼
NVIC
│
▼
EXTI0_IRQHandler
│
▼
Start TIM2
│
▼
CPU tiếp tục
làm việc khác
│
│
20 ms
│
▼
TIM2_IRQHandler
│
▼
Read PA0
/ \
LOW HIGH
│ │
▼ ▼
Nhấn thật Bounce
│
▼
Toggle LED
41. Đây là bước tiến lớn so với Bài 32
Bạn đã đi qua:
Bài 22
GPIO cơ bản
↓
Bài 23
GPIO Input
↓
Bài 24–30
GPIO Register
↓
Bài 31
GPIO Driver
↓
Bài 32
Pull-up / Pull-down
↓
Bài 33
EXTI
↓
Bài 34
NVIC
↓
Bài 35
EXTI + Timer + Debounce
Bạn đang bắt đầu chuyển từ:
lập trình từng thanh ghi riêng lẻ
sang:
thiết kế một hệ thống firmware gồm nhiều peripheral phối hợp với nhau.
42. Bài tập thực hành cho Blue Pill
Với board STM32F103C8T6 của bạn, hãy làm:
PA0 ───── Button ───── GND
PC13 ───── LED onboard
Bài tập 1
Nhấn nút một lần:
LED đổi trạng thái đúng 1 lần
Bài tập 2
Nhấn giữ nút 3 giây:
LED chỉ đổi 1 lần
Bài tập 3
Nhấn nhanh 10 lần:
LED phải đổi 10 lần
Bài tập 4
Thay:
TIM2->ARR = 199;
bằng:
TIM2->ARR = 99;
Bạn đang giảm debounce từ khoảng:
20 ms → 10 ms
Quan sát xem nút còn hoạt động ổn định không.
43. Kiến thức cần nhớ sau Bài 35
Nếu chỉ cần nhớ 7 dòng, hãy nhớ:
1. Nút cơ khí bị bounce.
2. EXTI có thể nhìn bounce thành nhiều interrupt.
3. Không nên delay lâu trong ISR.
4. EXTI dùng để phát hiện sự kiện.
5. Timer dùng để tạo khoảng thời gian debounce.
6. Sau khoảng thời gian đó phải đọc lại GPIO.
7. ISR nên ngắn và nhanh.
Và công thức tư duy:
EXTI = "Có chuyện xảy ra!"
Timer = "Hãy chờ một khoảng thời gian!"
GPIO = "Kiểm tra xem chuyện đó có thật không!"
ISR = "Xử lý thật nhanh!"
Bài 36 – tiếp theo
Sau Bài 35, bài rất hợp lý tiếp theo là:
BÀI 36 – TIMER INTERRUPT TRÊN STM32F103: TỰ TẠO HỆ THỐNG millis() 1 ms
Ở bài đó, chúng ta sẽ không dùng delay(), mà tự xây dựng một bộ đếm thời gian kiểu:
volatile uint32_t millis = 0;
để có thể viết:
if (millis - last_time >= 1000)
{
// 1 giây đã trôi qua
}
Đây là bước rất quan trọng để bạn tiến tới viết firmware STM32 theo kiểu non-blocking, sau này cực kỳ hữu ích khi bạn làm robot, stepper 28BYJ-48, cảm biến, UART, LCD và nhiều tác vụ chạy đồng thời.