Bàn phím 4x4 và còi với ESP32: quét ma trận, chống dội, khóa mật khẩu và âm báo không cần thư viện
Cách quét bàn phím ma trận 4x4, chống dội phím, nối còi thụ động qua transistor, chương trình khóa mật khẩu có đổi mật khẩu, khóa tạm khi nhập sai và âm báo không dùng delay.
Bàn phím màng 4×4 có 16 phím nhưng chỉ cần 8 dây, nhờ cách nối thành ma trận hàng – cột. Ghép với một chiếc còi nhỏ là đủ làm khóa cửa mật khẩu, bảng nhập số cho máy đếm, hay bảng điều khiển cho đồ chơi học tập. Bài này giải thích cách quét ma trận, cách chống dội phím, cách nối còi qua transistor, rồi viết chương trình khóa mật khẩu có đổi mật khẩu, khóa tạm khi nhập sai và âm báo không làm chương trình bị dừng, không cần thư viện ngoài.
1. Bàn phím ma trận quét thế nào

Mỗi phím là một công tắc nối một hàng với một cột. Chương trình làm như sau:
- 4 chân cột đặt
INPUT_PULLUP: khi không có gì kéo xuống, cột đọc ra 1. - 4 chân hàng là ngõ ra, bình thường ở mức 1. Lần lượt kéo một hàng xuống 0.
- Trong lúc hàng đó ở 0, đọc 4 cột. Cột nào ra 0 thì phím ở chỗ giao của hàng đó với cột đó đang được bấm.
- Trả hàng về 1 rồi chuyển sang hàng kế tiếp.
Cả 4 hàng quét xong trong vài chục micro giây, nhanh hơn nhiều so với tay bấm. Khi bấm cùng lúc ba phím ở ba góc của một hình chữ nhật, phím ở góc thứ tư cũng có vẻ như đang bấm (hiện tượng "phím ma"). Chương trình chỉ nhận một phím một lúc nên không bị ảnh hưởng; bàn phím cần nhận nhiều phím cùng lúc phải thêm diode cho từng phím.
2. Chống dội phím

Tiếp điểm của phím không đóng gọn một lần mà nảy lên xuống vài mili giây. Nếu đọc liên tục, một lần bấm có thể bị đếm thành hai, ba lần. Chương trình quét 10 ms một lần và chỉ chấp nhận kết quả khi 3 lần quét liền nhau giống nhau (30 ms). Ngoài ra nó chỉ báo phím đúng lúc phím vừa được bấm xuống; giữ phím lâu cũng chỉ ra một ký tự.
Cách quét theo thời gian (dùng millis()) thay vì delay() giúp vòng lặp vẫn làm được việc khác, như phát âm báo hay đếm giờ mở khóa.
3. Linh kiện cần chuẩn bị
- ESP32 DevKit V1 30 chân (hoặc loại 38 chân) và dây USB nạp code.
- Bàn phím màng 4×4 (dây dẹt 8 sợi).
- Còi thụ động (loại không tự kêu khi cấp điện một chiều), transistor NPN S8050 hoặc 2N2222, điện trở 1 kΩ, diode 1N4148.
- Breadboard và dây cắm.
- Muốn đóng mở khóa điện thật: module relay và khóa chốt điện (khóa từ) đúng điện áp của nó.
4. Nối bàn phím và còi với ESP32

| Bàn phím | R1 | R2 | R3 | R4 | C1 | C2 | C3 | C4 |
|---|---|---|---|---|---|---|---|---|
| ESP32 | D13 | D14 | D27 | D26 | D25 | D33 | D32 | D4 |
Thứ tự 8 sợi của bàn phím màng thường là R1 R2 R3 R4 C1 C2 C3 C4 tính từ trái khi nhìn mặt phím, nhưng không phải hãng nào cũng vậy. Dùng đồng hồ vạn năng để kiểm tra: giữ phím 1, sợi R1 và sợi C1 phải thông nhau; giữ phím D, sợi R4 và C4 thông nhau. Bài tránh các chân GPIO12 (ảnh hưởng điện áp bộ nhớ flash lúc khởi động) và GPIO34–39 (chỉ nhận vào, không có điện trở kéo lên).
5. Mạch còi và âm báo

Còi điện từ có điện trở cuộn dây chỉ vài chục ohm, cần dòng lớn hơn mức một chân GPIO nên cấp. Transistor NPN làm công tắc: GPIO23 qua 1 kΩ vào base, còi nối từ 5 V xuống collector. Diode 1N4148 mắc ngược song song với còi để dập điện áp cảm ứng của cuộn dây mỗi lần ngắt. Với còi áp điện (đĩa gốm mỏng), dòng rất nhỏ và không cần diode.
Còi thụ động chỉ kêu khi nhận xung đúng tần số; ESP32 tạo xung bằng khối LEDC: ledcAttach(23, 2000, 10) gắn chân, ledcWriteTone(23, tần số) đổi nốt, ledcWriteTone(23, 0) tắt (chân ở mức 0, transistor ngắt). Xem thêm về LEDC trong bài PWM trên ESP32.
Mỗi âm báo là một dãy nốt (tần số, thời gian). Hàm capNhatCoi() được gọi liên tục trong loop() và tự chuyển sang nốt kế tiếp khi hết giờ, nên trong lúc còi kêu bàn phím vẫn được quét.
6. Chương trình khóa mật khẩu
Cách dùng:
- Nhập mật khẩu (mặc định
1234) rồi bấm#. Đúng: âm báo đi lên, đèn GPIO2 sáng 3 giây (chỗ này nối relay để mở khóa thật). Sai: âm trầm dài. - Bấm
*để xóa phần đang nhập. - Sai 3 lần liền: bàn phím bị khóa 30 giây, mọi phím chỉ phát âm báo sai.
- Đổi mật khẩu: bấm
A, nhập mật khẩu cũ +#, mật khẩu mới (4–8 số) +#, nhập lại +#. Mật khẩu mới được lưu vào flash bằng thư viện Preferences (xem bài lưu dữ liệu khi mất điện), mất điện không mất.
// PYWORLD.VN - Bàn phím ma trận 4x4 và còi thụ động với ESP32: quét phím chống dội, nhập mật khẩu, đổi mật khẩu, âm báo không chặn chương trình
// Hàng R1-R4 vào GPIO13, 14, 27, 26; cột C1-C4 vào GPIO25, 33, 32, 4. Còi qua transistor NPN ở GPIO23. Đèn GPIO2 sáng 3 giây khi mở khóa
// Nhập mật khẩu rồi bấm #, bấm * để xóa. Bấm A để đổi mật khẩu. Mật khẩu ban đầu 1234, lưu trong flash
#include <Preferences.h>
const int HANG[4] = {13, 14, 27, 26};
const int COT[4] = {25, 33, 32, 4};
const char PHIM[4][4] = {
{'1', '2', '3', 'A'},
{'4', '5', '6', 'B'},
{'7', '8', '9', 'C'},
{'*', '0', '#', 'D'},
};
const int CHAN_COI = 23, CHAN_KHOA = 2;
const uint32_t THOI_GIAN_MO_MS = 3000;
const int SAI_TOI_DA = 3;
const uint32_t KHOA_TAM_MS = 30000; // nhập sai 3 lần: khóa bàn phím 30 giây
struct Not { uint16_t tanSo, ms; }; // một nốt của còi; tần số 0 = nghỉ (khai báo trước mọi hàm)
// ---- Quét phím ----
char quetMaTran() { // trả về phím đang được bấm, 0 nếu không có
char ketQua = 0;
for (int h = 0; h < 4; h++) {
digitalWrite(HANG[h], LOW); // chỉ một hàng ở mức thấp tại một thời điểm
delayMicroseconds(10); // chờ đường dây ổn định
for (int c = 0; c < 4; c++) {
if (digitalRead(COT[c]) == LOW && ketQua == 0) ketQua = PHIM[h][c];
}
digitalWrite(HANG[h], HIGH);
}
return ketQua;
}
// Quét 10 ms một lần; một phím phải giống nhau 3 lần liền (30 ms) mới được nhận. Trả về phím vừa bấm xuống.
char docPhim() {
static uint32_t lanQuet = 0;
static char ungVien = 0, onDinh = 0;
static int soLan = 0;
if (millis() - lanQuet < 10) return 0;
lanQuet = millis();
char p = quetMaTran();
if (p == ungVien) {
if (soLan < 3) soLan++;
} else {
ungVien = p;
soLan = 1;
}
if (soLan == 3 && ungVien != onDinh) {
onDinh = ungVien;
return onDinh; // chỉ báo lúc bấm xuống; giữ phím không lặp lại
}
return 0;
}
// ---- Còi: phát một dãy nốt mà không dùng delay() ----
const Not BIP[] = {{2000, 40}};
const Not DUNG[] = {{1319, 90}, {0, 30}, {1760, 90}, {0, 30}, {2637, 160}};
const Not SAI[] = {{330, 400}};
const Not KHOA[] = {{330, 150}, {0, 80}, {330, 150}, {0, 80}, {330, 400}};
const Not *dangPhat = nullptr;
int soNot = 0, viTriNot = 0;
uint32_t batDauNot = 0;
void phat(const Not *day, int n) {
dangPhat = day;
soNot = n;
viTriNot = 0;
batDauNot = millis();
ledcWriteTone(CHAN_COI, day[0].tanSo);
}
void capNhatCoi() {
if (!dangPhat || millis() - batDauNot < dangPhat[viTriNot].ms) return;
if (++viTriNot >= soNot) {
ledcWriteTone(CHAN_COI, 0); // hết dãy: tắt còi (chân ở mức thấp, transistor ngắt)
dangPhat = nullptr;
return;
}
batDauNot = millis();
ledcWriteTone(CHAN_COI, dangPhat[viTriNot].tanSo);
}
// ---- Mật khẩu ----
Preferences boNho;
String matKhau, nhap, matKhauMoi;
enum CheDo { NHAP_MK, DOI_NHAP_CU, DOI_NHAP_MOI, DOI_NHAP_LAI } cheDo = NHAP_MK;
int soLanSai = 0;
uint32_t khoaDen = 0, moDen = 0;
void inLoiNhac() {
const char *nhac[] = {"Nhap mat khau", "Doi mat khau: nhap mat khau cu", "Nhap mat khau moi (4-8 so)", "Nhap lai mat khau moi"};
Serial.printf("%s, roi bam #\n", nhac[cheDo]);
}
void xuLyXacNhan() {
if (cheDo == NHAP_MK || cheDo == DOI_NHAP_CU) {
if (nhap == matKhau) {
soLanSai = 0;
phat(DUNG, 5);
if (cheDo == NHAP_MK) {
Serial.println("Dung mat khau: mo khoa 3 giay");
digitalWrite(CHAN_KHOA, HIGH);
moDen = millis() + THOI_GIAN_MO_MS;
} else {
cheDo = DOI_NHAP_MOI;
}
} else if (++soLanSai >= SAI_TOI_DA) {
Serial.println("Sai 3 lan: khoa ban phim 30 giay");
phat(KHOA, 5);
khoaDen = millis() + KHOA_TAM_MS;
soLanSai = 0;
cheDo = NHAP_MK;
} else {
Serial.printf("Sai mat khau (%d/%d)\n", soLanSai, SAI_TOI_DA);
phat(SAI, 1);
}
} else if (cheDo == DOI_NHAP_MOI) {
if (nhap.length() < 4 || nhap.length() > 8) {
Serial.println("Mat khau moi phai co 4-8 so");
phat(SAI, 1);
} else {
matKhauMoi = nhap;
cheDo = DOI_NHAP_LAI;
phat(BIP, 1);
}
} else { // DOI_NHAP_LAI
if (nhap == matKhauMoi) {
matKhau = matKhauMoi;
boNho.putString("mk", matKhau);
Serial.println("Da doi mat khau");
phat(DUNG, 5);
} else {
Serial.println("Hai lan nhap khong giong nhau, giu mat khau cu");
phat(SAI, 1);
}
cheDo = NHAP_MK;
}
nhap = "";
inLoiNhac();
}
void setup() {
Serial.begin(115200);
for (int i = 0; i < 4; i++) {
pinMode(HANG[i], OUTPUT);
digitalWrite(HANG[i], HIGH);
pinMode(COT[i], INPUT_PULLUP); // cột nghỉ ở mức cao, bấm phím thì bị hàng đang thấp kéo xuống
}
pinMode(CHAN_KHOA, OUTPUT);
ledcAttach(CHAN_COI, 2000, 10);
ledcWriteTone(CHAN_COI, 0);
boNho.begin("khoa", false);
matKhau = boNho.getString("mk", "1234");
inLoiNhac();
}
void loop() {
capNhatCoi();
if (moDen && (int32_t)(millis() - moDen) >= 0) {
digitalWrite(CHAN_KHOA, LOW);
moDen = 0;
Serial.println("Da khoa lai");
}
char p = docPhim();
if (!p) return;
if (khoaDen && (int32_t)(millis() - khoaDen) < 0) {
phat(SAI, 1); // đang bị khóa tạm: không nhận phím
return;
}
khoaDen = 0;
phat(BIP, 1);
if (p >= '0' && p <= '9') {
if (nhap.length() < 8) nhap += p;
Serial.print("Da nhap: ");
for (unsigned i = 0; i < nhap.length(); i++) Serial.print('*'); // không in mật khẩu ra màn hình
Serial.println();
} else if (p == '*') {
nhap = "";
Serial.println("Da xoa");
} else if (p == '#') {
xuLyXacNhan();
} else if (p == 'A' && cheDo == NHAP_MK) {
cheDo = DOI_NHAP_CU;
nhap = "";
inLoiNhac();
}
}
quetMaTran()quét 4 hàng, trả về phím đầu tiên tìm thấy;docPhim()làm phần chống dội và chỉ báo lúc bấm xuống.struct Notđược khai báo ngay đầu chương trình, trước mọi hàm. Trình dịch Arduino tự chèn khai báo các hàm lên đầu tệp; nếu kiểuNotnằm sau, khai báo củaphat()sẽ báo lỗi "does not name a type".- Serial Monitor chỉ in dấu
*, không in mật khẩu đang nhập. (int32_t)(millis() − moDen) >= 0so thời gian theo cách vẫn đúng khimillis()quay vòng sau khoảng 49 ngày.
7. Kết quả mong đợi

Khóa mật khẩu trên bàn phím chỉ là một lớp bảo vệ đơn giản: người đứng sau có thể nhìn thấy mật khẩu, và mạch đặt ngoài cửa có thể bị tháo ra để nối tắt relay. Khi dùng thật, đặt ESP32 và relay ở phía trong cửa, chỉ đưa bàn phím ra ngoài.
8. Xử lý lỗi thường gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| Bấm phím 1 ra 4, bấm 2 ra 5… | Hàng nối lệch thứ tự | Đo lại thứ tự sợi; sửa mảng HANG |
| Bấm phím 1 ra A, phím 2 ra 3… | Cột nối lệch hoặc đảo | Sửa mảng COT theo đúng thứ tự đo được |
| Cả một hàng hoặc một cột không ăn | Dây cắm lỏng ở đầu dây dẹt | Cắm lại; dùng dây cắm đực cắm sâu vào đầu cái |
| Một lần bấm ra hai ký tự | Phím dội lâu hơn 30 ms (phím cũ, bẩn) | Tăng số lần quét giống nhau từ 3 lên 5 |
| Còi không kêu, chỉ nghe tạch | Dùng còi chủ động (tự kêu khi cấp điện) | Thay còi thụ động, hoặc chỉ bật tắt còi chủ động bằng mức 1/0 |
| ESP32 khởi động lại khi còi kêu | Nối còi thẳng vào GPIO, sụt áp | Dùng mạch transistor như hình 4 |
9. Xem thêm
- ESP32 điều khiển module relay an toàn: nối khóa chốt điện vào chân GPIO2.
- Đàn piano trái cây với ESP32: thêm một cách phát nốt nhạc bằng còi.
- Công tắc, nút nhấn, cầu chì và relay: điện trở kéo lên và cách nối nút nhấn.
Xem thêm