RFID RC522 với ESP32: đọc mã thẻ không cần thư viện, lưu thẻ hợp lệ, mở khóa bằng relay
- 1. RFID là gì, RC522 đọc được thẻ nào
- 2. Module RC522
- 3. Linh kiện cần chuẩn bị
- 4. Sơ đồ nối dây
- 5. Đầu đọc và thẻ trao đổi những gì
- 6. Thẻ UID 7 byte
- 7. Chương trình đọc thẻ, lưu thẻ, mở khóa
- 8. Giải thích các phần chính
- 9. Kết quả trên Serial Monitor
- 10. Mã thẻ không phải mật khẩu
- 11. Xử lý lỗi thường gặp
- 12. Xem thêm
Nối RC522 với ESP32 qua SPI, hiểu REQA, chống va chạm, SELECT, HLTA, đọc UID 4 và 7 byte không cần thư viện, lưu thẻ bằng Preferences, đóng relay khi quẹt đúng thẻ.
Quẹt thẻ để mở cửa, chấm công, mở tủ đồ: phần lớn các hệ thống này dùng thẻ RFID tần số 13,56 MHz. Module RC522 đọc được loại thẻ này với giá rất rẻ, nên rất hay gặp trong đồ án và dự án tự làm. Bài này nối RC522 với ESP32 qua SPI, giải thích đầu đọc và thẻ trao đổi những byte nào, rồi viết chương trình đọc mã thẻ (UID) không cần thư viện. Chương trình đọc được cả thẻ UID 4 byte và 7 byte, lưu danh sách thẻ hợp lệ vào bộ nhớ flash và đóng relay 3 giây khi quẹt đúng thẻ.
1. RFID là gì, RC522 đọc được thẻ nào
RFID là nhận dạng bằng sóng vô tuyến. Đầu đọc phát từ trường 13,56 MHz qua cuộn anten. Thẻ không có pin, nó lấy năng lượng từ chính từ trường đó để chạy con chip bên trong rồi trả lời bằng cách thay đổi tải trên cuộn dây của thẻ. Khoảng cách đọc của module RC522 thường chỉ 2–5 cm.
RC522 dùng chip MFRC522, đọc các thẻ theo chuẩn ISO/IEC 14443A. Sau khi chọn thẻ, đầu đọc nhận một byte SAK cho biết loại thẻ:
| SAK | Loại thẻ thường gặp | UID | Ghi chú |
|---|---|---|---|
| 0x08 | MIFARE Classic 1K (thẻ trắng, móc khóa đi kèm module) | 4 byte | Thẻ ra vào, gửi xe phổ biến |
| 0x18 | MIFARE Classic 4K | 4 hoặc 7 byte | |
| 0x00 | MIFARE Ultralight, nhãn NFC NTAG213/215/216 | 7 byte | Nhãn dán, thẻ NFC giá rẻ |
| 0x20 | Thẻ ISO 14443-4: thẻ ngân hàng, điện thoại giả lập thẻ | Thường đổi mỗi lần | Không dùng làm thẻ ra vào bằng cách đọc UID |
RC522 không đọc được thẻ 125 kHz (loại thẻ dày, chỉ có số in sẵn, hay dùng ở thang máy, chấm công đời cũ). Thẻ 125 kHz cần đầu đọc riêng.
2. Module RC522

Phần lớn mặt module là cuộn anten in trên mạch. Chip MFRC522 và thạch anh 27,12 MHz nằm ở cạnh trên, cạnh hàng 8 chân:
- SDA: chân chọn chip (SS, CS) của SPI. Tên in là SDA nhưng không liên quan tới I2C.
- SCK, MOSI, MISO: ba dây SPI.
- IRQ: chân báo ngắt, bài này không dùng.
- RST: chân khởi động lại chip, mức thấp là tắt chip.
- 3.3V, GND: nguồn. Chỉ cấp 3,3 V; chip MFRC522 chịu tối đa khoảng 3,6 V.
3. Linh kiện cần chuẩn bị
- ESP32 DevKit V1 30 chân (hoặc loại 38 chân, nối theo tên GPIO).
- Module RC522 kèm thẻ trắng và thẻ móc khóa.
- Module relay 1 kênh 5 V để đóng khóa điện, chuông, đèn.
- Dây cắm test board, breadboard, dây USB nạp code.
4. Sơ đồ nối dây

| RC522 | ESP32 | Relay | ESP32 |
|---|---|---|---|
| SDA | D5 | IN | D26 |
| SCK | D18 | VCC | VIN (5 V khi cắm USB) |
| MOSI | D23 | GND | GND |
| MISO | D19 | ||
| RST | D27 | ||
| 3.3V | 3V3 | ||
| GND | GND |
Dây SPI nên ngắn dưới 20 cm. Đặt module xa vật kim loại lớn, vì kim loại làm giảm khoảng cách đọc. Ý nghĩa bốn dây SPI có trong bài SPI là gì.
5. Đầu đọc và thẻ trao đổi những gì

RC522 chỉ là bộ phát và thu sóng. Việc nói chuyện với thẻ do chương trình trên ESP32 làm, theo bốn bước:
- REQA (một byte 0x26, chỉ gửi 7 bit): hỏi "có thẻ nào đang chờ không". Thẻ trong vùng đọc trả lời 2 byte ATQA.
- Chống va chạm (0x93 0x20): thẻ trả về 4 byte UID và 1 byte BCC. BCC là XOR của 4 byte UID, dùng để kiểm tra. Nếu có hai thẻ cùng lúc, các bit khác nhau va chạm và cần hỏi lại.
- SELECT (0x93 0x70, UID, BCC, 2 byte CRC): chọn đúng thẻ đó. Thẻ trả về SAK cho biết loại thẻ.
- HLTA (0x50 0x00 và CRC): cho thẻ ngủ. Thẻ không trả lời lệnh REQA nữa cho tới khi được đưa ra khỏi vùng đọc, nhờ vậy mỗi lần quẹt chỉ đọc một lần.
CRC ở đây là CRC_A của chuẩn ISO 14443, tính từ mọi byte đứng trước. Chương trình tự tính trong hàm crcA().
6. Thẻ UID 7 byte

Nhãn NFC và thẻ Ultralight có UID 7 byte. Ở cấp 1, thẻ trả về byte 0x88 (cờ nối tầng) cùng 3 byte đầu. Chương trình SELECT cấp 1 rồi hỏi tiếp cấp 2 bằng mã 0x95 để lấy 4 byte còn lại. Bỏ qua bước này thì mọi nhãn NFC cùng lô sẽ có "mã" bắt đầu bằng 88 và dễ trùng nhau.
7. Chương trình đọc thẻ, lưu thẻ, mở khóa
Chọn bo ESP32 Dev Module rồi nạp. Bài dùng gói esp32 phiên bản 3.3.10, chỉ cần SPI và Preferences có sẵn, không cần thư viện MFRC522.
// PYWORLD.VN - Đọc mã thẻ RFID bằng module RC522 (MFRC522) và ESP32, không cần thư viện; thẻ hợp lệ thì đóng relay 3 giây
// RC522: SDA(SS) D5, SCK D18, MOSI D23, MISO D19, RST D27, 3.3V vào 3V3 (không cấp 5 V); relay IN vào D26
// Giữ nút BOOT trong lúc quẹt thẻ để thêm hoặc xóa thẻ khỏi danh sách (lưu bằng Preferences, giữ khi mất điện)
#include <SPI.h>
#include <Preferences.h>
const int CHAN_SS = 5;
const int CHAN_RST = 27;
const int CHAN_RELAY = 26;
const int CHAN_BOOT = 0;
const bool RELAY_KICH_MUC_THAP = true; // module relay phổ biến đóng khi IN ở mức thấp
const uint32_t THOI_GIAN_MO_MS = 3000;
const int SO_THE_TOI_DA = 10;
// Thanh ghi và lệnh của MFRC522 (theo datasheet NXP)
enum : uint8_t {
CommandReg = 0x01, ComIrqReg = 0x04, DivIrqReg = 0x05, ErrorReg = 0x06, FIFODataReg = 0x09, FIFOLevelReg = 0x0A,
ControlReg = 0x0C, BitFramingReg = 0x0D, CollReg = 0x0E, ModeReg = 0x11, TxModeReg = 0x12, RxModeReg = 0x13,
TxControlReg = 0x14, TxASKReg = 0x15, ModWidthReg = 0x24, TModeReg = 0x2A, TPrescalerReg = 0x2B,
TReloadRegH = 0x2C, TReloadRegL = 0x2D, VersionReg = 0x37
};
enum : uint8_t { LENH_NGHI = 0x00, LENH_TRANSCEIVE = 0x0C, LENH_SOFT_RESET = 0x0F };
SPISettings caiDatSpi(4000000, MSBFIRST, SPI_MODE0);
Preferences boNho;
uint8_t danhSach[SO_THE_TOI_DA][7];
uint8_t doDaiThe[SO_THE_TOI_DA];
int soThe = 0;
void ghi(uint8_t tg, uint8_t giaTri) {
SPI.beginTransaction(caiDatSpi);
digitalWrite(CHAN_SS, LOW);
SPI.transfer((tg << 1) & 0x7E); // bit 7 = 0: ghi
SPI.transfer(giaTri);
digitalWrite(CHAN_SS, HIGH);
SPI.endTransaction();
}
uint8_t doc(uint8_t tg) {
SPI.beginTransaction(caiDatSpi);
digitalWrite(CHAN_SS, LOW);
SPI.transfer(((tg << 1) & 0x7E) | 0x80); // bit 7 = 1: đọc
uint8_t v = SPI.transfer(0);
digitalWrite(CHAN_SS, HIGH);
SPI.endTransaction();
return v;
}
void datBit(uint8_t tg, uint8_t mask) { ghi(tg, doc(tg) | mask); }
void xoaBit(uint8_t tg, uint8_t mask) { ghi(tg, doc(tg) & ~mask); }
// CRC_A theo ISO/IEC 14443-3 (giá trị đầu 0x6363), byte thấp gửi trước.
uint16_t crcA(const uint8_t *du, size_t n) {
uint16_t crc = 0x6363;
for (size_t i = 0; i < n; i++) {
uint8_t c = du[i] ^ (uint8_t)crc;
c ^= (uint8_t)(c << 4);
crc = (crc >> 8) ^ ((uint16_t)c << 8) ^ ((uint16_t)c << 3) ^ (c >> 4);
}
return crc;
}
void khoiDongRc522() {
digitalWrite(CHAN_RST, LOW);
delayMicroseconds(10);
digitalWrite(CHAN_RST, HIGH);
delay(50);
ghi(CommandReg, LENH_SOFT_RESET);
delay(50);
ghi(TxModeReg, 0x00);
ghi(RxModeReg, 0x00);
ghi(ModWidthReg, 0x26);
ghi(TModeReg, 0x80); // timer tự chạy khi gửi xong
ghi(TPrescalerReg, 0xA9); // 13,56 MHz / (2 × 169 + 1) ≈ 40 kHz, mỗi nhịp 25 us
ghi(TReloadRegH, 0x03);
ghi(TReloadRegL, 0xE8); // 1000 nhịp = 25 ms chờ thẻ trả lời
ghi(TxASKReg, 0x40); // điều chế ASK 100 % theo ISO 14443A
ghi(ModeReg, 0x3D); // giá trị đầu CRC 0x6363
datBit(TxControlReg, 0x03); // bật hai chân phát ra anten
}
// Gửi `n` byte (byte cuối chỉ gửi `bitCuoi` bit nếu khác 0) và nhận trả lời. Trả về số byte nhận, -1 nếu lỗi/hết giờ.
int traoDoi(const uint8_t *gui, uint8_t n, uint8_t *nhan, uint8_t toiDa, uint8_t bitCuoi = 0) {
ghi(CommandReg, LENH_NGHI);
ghi(ComIrqReg, 0x7F); // xóa các cờ ngắt
ghi(FIFOLevelReg, 0x80); // xóa bộ đệm FIFO
for (uint8_t i = 0; i < n; i++) ghi(FIFODataReg, gui[i]);
ghi(BitFramingReg, bitCuoi);
ghi(CommandReg, LENH_TRANSCEIVE);
datBit(BitFramingReg, 0x80); // StartSend
uint32_t batDau = millis();
while (true) {
uint8_t irq = doc(ComIrqReg);
if (irq & 0x30) break; // RxIRq hoặc IdleIRq: đã nhận xong
if ((irq & 0x01) || millis() - batDau > 40) return -1; // TimerIRq: thẻ không trả lời
}
xoaBit(BitFramingReg, 0x80);
if (doc(ErrorReg) & 0x13) return -1; // tràn bộ đệm, lỗi chẵn lẻ, lỗi giao thức
uint8_t soByte = doc(FIFOLevelReg);
if (soByte > toiDa) return -1;
for (uint8_t i = 0; i < soByte; i++) nhan[i] = doc(FIFODataReg);
return soByte;
}
// Chọn thẻ ở một cấp: chống va chạm lấy 4 byte + BCC, rồi SELECT để nhận SAK.
bool chonCap(uint8_t maCap, uint8_t uid4[4], uint8_t &sak) {
uint8_t gui[9] = {maCap, 0x20};
uint8_t nhan[5];
if (traoDoi(gui, 2, nhan, 5) != 5) return false;
if ((nhan[0] ^ nhan[1] ^ nhan[2] ^ nhan[3]) != nhan[4]) return false; // BCC sai
gui[1] = 0x70;
memcpy(gui + 2, nhan, 5);
uint16_t crc = crcA(gui, 7);
gui[7] = crc & 0xFF;
gui[8] = crc >> 8;
uint8_t traLoi[3];
if (traoDoi(gui, 9, traLoi, 3) != 3) return false;
memcpy(uid4, nhan, 4);
sak = traLoi[0];
return true;
}
// Tìm thẻ mới trong vùng đọc. Trả về độ dài UID (4 hoặc 7 byte), 0 nếu không có thẻ.
uint8_t docUid(uint8_t uid[7], uint8_t &sak) {
xoaBit(CollReg, 0x80);
uint8_t reqa = 0x26, atqa[2];
if (traoDoi(&reqa, 1, atqa, 2, 7) != 2) return 0; // REQA chỉ có 7 bit
uint8_t phan[4];
if (!chonCap(0x93, phan, sak)) return 0;
if (phan[0] != 0x88) { // không có cờ nối tầng: UID 4 byte
memcpy(uid, phan, 4);
return 4;
}
memcpy(uid, phan + 1, 3); // 0x88 + 3 byte đầu, cấp 2 cho 4 byte còn lại
if (!chonCap(0x95, phan, sak)) return 0;
memcpy(uid + 3, phan, 4);
return 7;
}
void choTheNghi() { // HLTA: thẻ ngủ tới khi ra khỏi vùng đọc, không bị đọc lặp
uint8_t gui[4] = {0x50, 0x00};
uint16_t crc = crcA(gui, 2);
gui[2] = crc & 0xFF;
gui[3] = crc >> 8;
uint8_t bo[1];
traoDoi(gui, 4, bo, 1); // thẻ không trả lời là đúng
}
int timThe(const uint8_t *uid, uint8_t n) {
for (int i = 0; i < soThe; i++)
if (doDaiThe[i] == n && memcmp(danhSach[i], uid, n) == 0) return i;
return -1;
}
void luuDanhSach() {
boNho.putInt("so", soThe);
boNho.putBytes("uid", danhSach, sizeof(danhSach));
boNho.putBytes("dai", doDaiThe, sizeof(doDaiThe));
}
void relay(bool dong) {
digitalWrite(CHAN_RELAY, (dong == RELAY_KICH_MUC_THAP) ? LOW : HIGH);
}
void setup() {
Serial.begin(115200);
pinMode(CHAN_SS, OUTPUT);
digitalWrite(CHAN_SS, HIGH);
pinMode(CHAN_RST, OUTPUT);
pinMode(CHAN_BOOT, INPUT_PULLUP);
pinMode(CHAN_RELAY, OUTPUT);
relay(false);
SPI.begin(18, 19, 23, CHAN_SS); // SCK, MISO, MOSI, SS
khoiDongRc522();
uint8_t ban = doc(VersionReg);
if (ban == 0x00 || ban == 0xFF) {
Serial.println("Khong doc duoc RC522: kiem tra day SPI, nguon 3.3V");
} else {
Serial.printf("RC522 phien ban 0x%02X\n", ban);
}
boNho.begin("rfid", false);
soThe = boNho.getInt("so", 0);
boNho.getBytes("uid", danhSach, sizeof(danhSach));
boNho.getBytes("dai", doDaiThe, sizeof(doDaiThe));
Serial.printf("Da luu %d the. Giu BOOT khi quet de them/xoa the\n", soThe);
}
void loop() {
static uint32_t lucMo = 0;
if (lucMo && millis() - lucMo > THOI_GIAN_MO_MS) {
relay(false);
lucMo = 0;
}
uint8_t uid[7], sak;
uint8_t n = docUid(uid, sak);
if (!n) {
delay(50);
return;
}
choTheNghi();
char chuoi[22];
for (uint8_t i = 0; i < n; i++) sprintf(chuoi + i * 3, "%02X%s", uid[i], i + 1 < n ? ":" : "");
int viTri = timThe(uid, n);
if (digitalRead(CHAN_BOOT) == LOW) { // chế độ quản lý thẻ
if (viTri >= 0) {
for (int i = viTri; i < soThe - 1; i++) {
memcpy(danhSach[i], danhSach[i + 1], 7);
doDaiThe[i] = doDaiThe[i + 1];
}
soThe--;
Serial.printf("Da XOA the %s, con %d the\n", chuoi, soThe);
} else if (soThe < SO_THE_TOI_DA) {
memcpy(danhSach[soThe], uid, n);
doDaiThe[soThe++] = n;
Serial.printf("Da THEM the %s, co %d the\n", chuoi, soThe);
} else {
Serial.println("Danh sach day, xoa bot the truoc");
}
luuDanhSach();
return;
}
if (viTri >= 0) {
Serial.printf("The %s (SAK 0x%02X): HOP LE, mo khoa %lu ms\n", chuoi, sak, (unsigned long)THOI_GIAN_MO_MS);
relay(true);
lucMo = millis();
} else {
Serial.printf("The %s (SAK 0x%02X): KHONG HOP LE\n", chuoi, sak);
}
}
8. Giải thích các phần chính
- Byte địa chỉ SPI: MFRC522 nhận địa chỉ thanh ghi dịch trái 1 bit, bit 7 bằng 1 là đọc, bằng 0 là ghi. Hàm
ghi()vàdoc()làm đúng việc này. khoiDongRc522(): reset chip, đặt bộ hẹn giờ trong chip chạy 40 kHz và đếm 1000 nhịp, tức 25 ms chờ thẻ trả lời. Sau đó bật điều chế ASK 100 % của chuẩn 14443A và bật anten.traoDoi(): đổ byte vào bộ đệm FIFO 64 byte của chip, ra lệnh Transceive, chờ cờ nhận xong hoặc hết giờ rồi đọc bộ đệm. Thanh ghi BitFramingReg quy định byte cuối gửi mấy bit; REQA chỉ gửi 7 bit.chonCap(): chống va chạm rồi SELECT ở một cấp, kiểm tra BCC trước khi chọn.docUid(): gọi cấp 1, thấy 0x88 thì gọi tiếp cấp 2. Kết quả là UID 4 hoặc 7 byte.- Danh sách thẻ lưu bằng Preferences vào bộ nhớ flash, mất điện vẫn còn. Giữ nút BOOT trên mạch trong lúc quẹt: thẻ chưa có thì được thêm, thẻ đã có thì bị xóa. Cách dùng Preferences có trong bài lưu dữ liệu vào flash của ESP32.
- Relay đóng 3 giây rồi tự nhả, đo bằng
millis()nên vẫn đọc được thẻ khác trong lúc đó. Module relay phổ biến đóng khi chân IN ở mức thấp; module của bạn đóng ở mức cao thì đổiRELAY_KICH_MUC_THAPthànhfalse.
9. Kết quả trên Serial Monitor

Dòng đầu in phiên bản chip đọc từ thanh ghi VersionReg: 0x91, 0x92 là chip chính hãng; một số module dùng chip tương thích cho 0x12, 0x88 hoặc 0xB2 và vẫn chạy bình thường. Đọc ra 0x00 hoặc 0xFF là ESP32 không nói chuyện được với chip.
10. Mã thẻ không phải mật khẩu
UID chỉ là số nhận dạng, ai cũng đọc được, và có loại thẻ "đổi được UID" bán rất rẻ để chép mã của thẻ khác. Vì vậy khóa chỉ so UID hợp với tủ đồ, cổng phòng thí nghiệm, đồ án. Cửa cần an toàn thật thì dùng thẻ có mã hóa như MIFARE DESFire, hoặc kết hợp thêm mã PIN như bài dự án khóa cửa RFID và bàn phím trong cùng loạt.
Relay đóng cắt khóa điện 12 V hay thiết bị 220 V thì đọc kỹ bài ESP32 điều khiển module relay an toàn trước khi đấu: phần điện lưới phải có vỏ cách điện, cầu chì, và người có chuyên môn đấu nối.
11. Xử lý lỗi thường gặp
| Hiện tượng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
| "Khong doc duoc RC522" (phiên bản 0x00 hoặc 0xFF) | Sai dây SPI, quên nối RST, hàn chân module chưa ăn thiếc | Kiểm tra từng dây theo bảng; hàn lại hàng chân module |
| Đọc được phiên bản nhưng không thấy thẻ | Thẻ 125 kHz, đặt thẻ xa quá 3 cm, module nằm trên mặt kim loại | Dùng thẻ 13,56 MHz đi kèm; áp thẻ sát giữa cuộn anten |
| Chip nóng, chạy lúc được lúc không | Cấp 5 V vào chân 3.3V | Chỉ cấp 3,3 V; chip có thể đã hỏng nếu cấp 5 V lâu |
| Mã thẻ NFC bắt đầu bằng 88 | Chương trình chỉ đọc cấp 1 | Đọc thêm cấp 2 như hàm docUid() |
| Quẹt một lần in nhiều dòng | Không gửi HLTA, thẻ cứ trả lời REQA | Gọi choTheNghi() sau khi đọc |
| ESP32 tự khởi động lại khi relay đóng | Cuộn relay làm sụt nguồn | Cấp nguồn 5 V riêng cho relay, nối chung GND; xem bài relay làm ESP32 bị reset |
12. Xem thêm
- ESP32 làm web server điều khiển relay: thêm nút mở cửa từ điện thoại.
- MQTT với ESP32: gửi lịch sử quẹt thẻ về máy chủ.
- Tổng quan các chuẩn giao tiếp: SPI, I2C, UART khác nhau thế nào.
Xem thêm