Rotary encoder trông như biến trở xoay nhưng về nguyên lý hoàn toàn khác: không có điện trở, không có góc giới hạn, và quan trọng nhất — nó biết bạn xoay theo chiều nào. Bí quyết là 2 tín hiệu lệch pha nhau 90° (quadrature). Bài này giải thích từng bit và xây dựng code interrupt không mất nhịp.
Nguyên Lý Hoạt Động
1. Cơ Chế Encoder Cơ Học
KY-040 là encoder cơ học (mechanical incremental encoder):
Đĩa tiếp điểm bên trong:
Đĩa xoay có 20 răng dẫn điện:
┌─────────────────────────────┐
│ ████░░████░░████░░████░░ │ ← Răng dẫn (nối GND)
│ ░░░░██░░░░██░░░░██░░░░██ │
└─────────────────────────────┘
↑ ↑
A (CLK) B (DT) ← 2 tiếp điểm lệch nhau 1/4 bước
Khi đĩa xoay:
- Mỗi răng đi qua tiếp điểm A: CLK tạo 1 xung LOW
- Mỗi răng đi qua tiếp điểm B: DT tạo 1 xung LOW (lệch 90°)
Pull-up resistor: Tiếp điểm A và B thường ở trạng thái HIGH (module có pull-up). Khi răng dẫn điện chạm → kéo xuống LOW.
2. Quadrature Encoding — 2 Kênh Lệch Pha 90°
Bí mật xác định hướng xoay:
Chiều Kim Đồng Hồ (Clockwise — CW):
CLK ──┐ ┌────┐ ┌────
└────┘ └────┘
DT ────┐ ┌────┐ ┌──
└────┘ └────┘
← CLK trước, DT sau 90°
Ngược Kim Đồng Hồ (Counter-Clockwise — CCW):
CLK ──┐ ┌────┐ ┌────
└────┘ └────┘
DT ──┐ ┌────┐ ┌────
└────┘ └────┘
← DT trước, CLK sau 90°
Quy tắc đọc đơn giản cho KY-040:
- Quan sát CLK khi nó chuyển từ HIGH → LOW (falling edge)
- Lúc đó đọc DT: – DT = HIGH → đang xoay Clockwise – DT = LOW → đang xoay Counter-Clockwise
Điều này không phải quadrature hoàn hảo về mặt kỹ thuật, nhưng đây là cách hoạt động thực tế của KY-040 với pull-up resistors.
3. Vấn Đề Bounce (Rung Tiếp Điểm)
Cơ học → tiếp điểm nảy → mỗi click có thể tạo ra 5-10 xung thay vì 1. Giải pháp:
| Phương pháp | Ưu điểm | Nhược điểm |
|---|---|---|
| Phần cứng: tụ 100nF song song | Đơn giản, không cần code | Làm chậm signal |
| Phần mềm: debounce delay | Không cần linh kiện | Có thể mất nhịp nhanh |
| State machine đầy đủ | Chính xác nhất | Code phức tạp hơn |
KY-040 thường đã có tụ trên module — debounce đơn giản trong code là đủ.
4. Push Button SW
Nút nhấn ở trung tâm encoder:
- SW = HIGH bình thường (pull-up)
- SW = LOW khi nhấn (active LOW)
- Dùng để xác nhận lựa chọn trong menu, reset counter, v.v.
Thông Số Kỹ Thuật
| Thông số | Giá trị |
|---|---|
| Điện áp hoạt động | 3.3V – 5V |
| Số detent (click/vòng) | 20 |
| Xung per detent | 2 xung (CLK + DT) |
| Loại | Incremental (tương đối, không tuyệt đối) |
| Tuổi thọ | ~15,000 cycles |
| Số chân | 5 (GND, VCC, SW, DT, CLK) |
Sơ Đồ Chân (Pinout)
KY-040 — Nhìn từ mặt trước:
┌──────────────────────────────────┐
│ [Núm xoay lớn] │
│ │
└──────────────────────────────────┘
GND VCC SW DT CLK
| Chân | Ký hiệu | Mô tả |
|---|---|---|
| GND | – | Mass |
| VCC | + | Nguồn 3.3V-5V |
| SW | Button | Push button (LOW khi nhấn) |
| DT | B | Kênh B — xác định hướng |
| CLK | A | Kênh A — tạo xung khi xoay |
Kết Nối Phần Cứng
KY-040 với ESP32 DevKit V1
ESP32 DevKit V1 KY-040 Module
───────────────────── ─────────────────
3V3 ─────────────────→ VCC
GND ─────────────────→ GND
GPIO18 (Input) ────────→ CLK ← Interrupt trên falling edge
GPIO19 (Input) ────────→ DT ← Đọc khi CLK falling
GPIO5 (Input) ────────→ SW ← Pull-up nội bộ
GPIO18 và GPIO19: interrupt-capable, không phải strapping pin.
KY-040 với Arduino Uno
Arduino Uno KY-040 Module
───────────────────── ─────────────────
5V ─────────────────→ VCC
GND ─────────────────→ GND
Pin 2 (INT0) ─────────→ CLK ← Interrupt 0 (falling edge)
Pin 3 (INT1) ─────────→ DT ← Interrupt 1 hoặc digitalRead
Pin 4 (Input)─────────→ SW ← INPUT_PULLUP
Pin 2 và 3 trên Arduino Uno là 2 interrupt pins (INT0 và INT1).
Code Arduino IDE
Code Đọc Encoder Cơ Bản — Arduino Uno
/*
* Rotary Encoder KY-040 — Đọc hướng xoay cơ bản
* Board: Arduino Uno
* Kết nối: VCC→5V, GND→GND, CLK→Pin2, DT→Pin3, SW→Pin4
*
* Nguyên tắc: khi CLK falling, đọc DT để biết hướng
* DT = HIGH → Clockwise (CW)
* DT = LOW → Counter-Clockwise (CCW)
*/
const int CLK_PIN = 2; // INT0 — CLK
const int DT_PIN = 3; // DT — hướng
const int SW_PIN = 4; // Button
int counter = 0; // Vị trí hiện tại (có thể âm)
int lastCLK = HIGH; // Trạng thái CLK trước đó
void setup() {
Serial.begin(9600);
pinMode(CLK_PIN, INPUT_PULLUP); // Module đã có pull-up nhưng thêm cho chắc
pinMode(DT_PIN, INPUT_PULLUP);
pinMode(SW_PIN, INPUT_PULLUP);
lastCLK = digitalRead(CLK_PIN);
Serial.println("=== Rotary Encoder KY-040 ===");
Serial.println("Counter = 0");
}
void loop() {
int currentCLK = digitalRead(CLK_PIN);
// Phát hiện falling edge trên CLK (HIGH → LOW)
if (currentCLK == LOW && lastCLK == HIGH) {
// Đọc DT để xác định hướng
int dtVal = digitalRead(DT_PIN);
if (dtVal == HIGH) {
counter++; // Clockwise
Serial.print("CW → Counter = ");
} else {
counter--; // Counter-Clockwise
Serial.print("CCW → Counter = ");
}
Serial.println(counter);
}
lastCLK = currentCLK;
// Kiểm tra button nhấn
if (digitalRead(SW_PIN) == LOW) {
counter = 0;
Serial.println("RESET → Counter = 0");
delay(300); // Debounce button
}
}
Code Interrupt-Based — Arduino Uno (Không Mất Nhịp)
/*
* Rotary Encoder KY-040 — Interrupt-based, không bỏ sót xung
* Board: Arduino Uno
* Kết nối: VCC→5V, GND→GND, CLK→Pin2, DT→Pin3, SW→Pin4
*
* ISR trên falling edge của CLK → xử lý ngay lập tức
* Dùng volatile cho biến chia sẻ với ISR
*/
const int CLK_PIN = 2;
const int DT_PIN = 3;
const int SW_PIN = 4;
volatile int counter = 0; // Biến volatile: chia sẻ ISR và main loop
volatile bool changed = false; // Cờ báo có thay đổi
// ISR — gọi khi CLK falling edge (HIGH → LOW)
void onCLKFalling() {
// Đọc DT ngay lập tức (không delay!)
if (digitalRead(DT_PIN) == HIGH) {
counter++; // Clockwise
} else {
counter--; // Counter-Clockwise
}
changed = true;
}
void setup() {
Serial.begin(9600);
pinMode(CLK_PIN, INPUT_PULLUP);
pinMode(DT_PIN, INPUT_PULLUP);
pinMode(SW_PIN, INPUT_PULLUP);
// Attach interrupt trên CLK: trigger khi CLK falling (HIGH→LOW)
attachInterrupt(digitalPinToInterrupt(CLK_PIN), onCLKFalling, FALLING);
Serial.println("=== Encoder Interrupt Mode ===");
}
unsigned long lastBtnTime = 0;
void loop() {
// In kết quả khi có thay đổi (xử lý ngoài ISR)
if (changed) {
changed = false;
Serial.print("Counter = ");
Serial.println(counter);
}
// Button với debounce 200ms
if (digitalRead(SW_PIN) == LOW) {
if (millis() - lastBtnTime > 200) {
lastBtnTime = millis();
counter = 0;
Serial.println("RESET → 0");
}
}
}
Code Điều Chỉnh Giá Trị Trong Phạm Vi — Arduino Uno
/*
* Rotary Encoder — Điều chỉnh giá trị trong phạm vi min-max
* Ứng dụng: âm lượng 0-100, nhiệt độ 16-30°C, tốc độ PWM 0-255
* Board: Arduino Uno
*/
const int CLK_PIN = 2;
const int DT_PIN = 3;
const int SW_PIN = 4;
const int LED_PIN = 9; // LED để demo điều chỉnh độ sáng
// Phạm vi giá trị
const int VALUE_MIN = 0;
const int VALUE_MAX = 255;
const int VALUE_STEP = 5; // Mỗi click thay đổi 5 đơn vị
volatile int targetValue = 128; // Giá trị mặc định
void onCLKFalling() {
if (digitalRead(DT_PIN) == HIGH) {
targetValue = min(targetValue + VALUE_STEP, VALUE_MAX);
} else {
targetValue = max(targetValue - VALUE_STEP, VALUE_MIN);
}
}
void setup() {
Serial.begin(9600);
pinMode(CLK_PIN, INPUT_PULLUP);
pinMode(DT_PIN, INPUT_PULLUP);
pinMode(SW_PIN, INPUT_PULLUP);
pinMode(LED_PIN, OUTPUT);
attachInterrupt(digitalPinToInterrupt(CLK_PIN), onCLKFalling, FALLING);
Serial.println("Xoay để điều chỉnh độ sáng LED");
}
int lastPrintedValue = -1;
void loop() {
// Áp dụng giá trị vào LED
analogWrite(LED_PIN, targetValue);
// In khi có thay đổi
if (targetValue != lastPrintedValue) {
lastPrintedValue = targetValue;
int percent = map(targetValue, 0, 255, 0, 100);
Serial.print("Độ sáng: ");
Serial.print(percent);
Serial.println("%");
}
// Nhấn button → reset về 50%
if (digitalRead(SW_PIN) == LOW) {
targetValue = 128;
delay(300);
}
}
Code ESP32 — Encoder Điều Hướng Menu
/*
* Rotary Encoder KY-040 — ESP32, điều hướng menu đơn giản
* Board: ESP32 DevKit V1
* Kết nối: VCC→3V3, GND→GND, CLK→GPIO18, DT→GPIO19, SW→GPIO5
*/
const int CLK_PIN = 18;
const int DT_PIN = 19;
const int SW_PIN = 5;
// Menu items
const char* menuItems[] = {
"1. Đọc nhiệt độ",
"2. Điều khiển LED",
"3. WiFi settings",
"4. Thông tin hệ thống"
};
const int MENU_SIZE = 4;
volatile int menuIndex = 0; // Vị trí menu hiện tại
volatile bool menuChanged = false;
volatile bool btnPressed = false;
void IRAM_ATTR onCLKISR() {
if (digitalRead(DT_PIN) == HIGH) {
menuIndex = (menuIndex + 1) % MENU_SIZE; // Tiến — vòng lại đầu
} else {
menuIndex = (menuIndex - 1 + MENU_SIZE) % MENU_SIZE; // Lùi — vòng lại cuối
}
menuChanged = true;
}
void IRAM_ATTR onSWISR() {
btnPressed = true;
}
void printMenu() {
Serial.println("\n=== MENU ===");
for (int i = 0; i < MENU_SIZE; i++) {
if (i == menuIndex) {
Serial.print("► "); // Con trỏ
} else {
Serial.print(" ");
}
Serial.println(menuItems[i]);
}
}
void setup() {
Serial.begin(115200);
pinMode(CLK_PIN, INPUT_PULLUP);
pinMode(DT_PIN, INPUT_PULLUP);
pinMode(SW_PIN, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(CLK_PIN), onCLKISR, FALLING);
attachInterrupt(digitalPinToInterrupt(SW_PIN), onSWISR, FALLING);
printMenu(); // Hiển thị menu ban đầu
}
unsigned long lastBtnTime = 0;
void loop() {
// Cập nhật hiển thị khi menu thay đổi
if (menuChanged) {
menuChanged = false;
printMenu();
}
// Xử lý button nhấn (debounce 300ms)
if (btnPressed) {
btnPressed = false;
if (millis() - lastBtnTime > 300) {
lastBtnTime = millis();
Serial.print("\n>>> CHỌN: ");
Serial.println(menuItems[menuIndex]);
// Thực thi action tương ứng với menuIndex ở đây
}
}
}
Kết Quả Mong Đợi
=== MENU ===
► 1. Đọc nhiệt độ
2. Điều khiển LED
3. WiFi settings
4. Thông tin hệ thống
(Xoay CW)
=== MENU ===
1. Đọc nhiệt độ
► 2. Điều khiển LED
3. WiFi settings
4. Thông tin hệ thống
(Nhấn)
>>> CHỌN: 2. Điều khiển LED
Ứng Dụng Thực Tế
| Ứng dụng | Chi tiết |
|---|---|
| Điều chỉnh âm lượng | Xoay encoder thay đổi 0-100% |
| Điều hướng menu OLED | Kết hợp với SSD1306 (Bài 33) |
| Nhập số PIN/mật khẩu | Xoay chọn chữ số, nhấn xác nhận |
| Điều chỉnh nhiệt độ set point | Thermostat thủ công |
| Điều khiển tốc độ motor | Xoay tăng/giảm PWM |
Lưu Ý Khi Sử Dụng
1. ISR phải nhanh — không delay(), không Serial trong ISR
ISR chạy với interrupt tắt. Serial.print() trong ISR gây treo máy. Chỉ set cờ và thay đổi biến; xử lý in ấn ở loop().
2. Biến chia sẻ phải là volatile
Compiler có thể optimize biến không volatile → ISR thay đổi nhưng loop() không thấy. Luôn khai báo volatile cho biến chia sẻ giữa ISR và main code.
3. Encoder bounce gây đếm sai
Mỗi click thực sự tạo ra 2-5 edge giả do bounce. Giải pháp đơn giản:
- Giữ nguyên FALLING trigger (chỉ CLK falling)
- Đừng dùng CHANGE trigger (sẽ đếm double)
- Thêm tụ 100nF từ CLK xuống GND nếu vẫn bị lỗi
4. KY-040 không đọc vị trí tuyệt đối
Incremental encoder chỉ đếm số bước từ lúc bật nguồn. Mỗi lần reset mất vị trí. Nếu cần vị trí tuyệt đối → dùng absolute encoder hoặc AS5600 (magnetic encoder với I2C).


