Module thời gian thực DS3231 với ESP32: đọc giờ, đặt giờ, báo thức hằng ngày, giữ giờ khi mất điện
- 1. DS3231 là gì, khác DS1307 thế nào
- 2. Module DS3231 trên thị trường
- 3. Linh kiện cần chuẩn bị
- 4. Sơ đồ nối dây
- 5. Thanh ghi và mã BCD
- 6. Chương trình đọc giờ, đặt giờ và báo thức
- 7. Giải thích các phần chính
- 8. Báo thức đi qua chân SQW
- 9. Kết quả trên Serial Monitor
- 10. Kết hợp với giờ Internet
- 11. Xử lý lỗi thường gặp
- 12. Xem thêm
DS3231 khác DS1307 thế nào, nối với ESP32 qua I2C, thanh ghi và mã BCD, chương trình đọc giờ, nhiệt độ, đặt giờ qua Serial và báo thức hằng ngày qua chân SQW, kèm bảng lỗi.
ESP32 có đồng hồ bên trong, nhưng mất điện là quên giờ, và nếu không có Wi-Fi thì không lấy lại giờ từ Internet được. Module thời gian thực (RTC) DS3231 giải quyết việc này: một con chip đếm giờ riêng, có pin cúc áo nuôi khi mất điện, sai chỉ khoảng một phút mỗi năm. Bài này nối DS3231 với ESP32, giải thích các thanh ghi và mã BCD, rồi viết chương trình đọc giờ, nhiệt độ, đặt giờ qua Serial Monitor và tạo báo thức hằng ngày qua chân SQW. Chương trình chỉ dùng thư viện Wire có sẵn.
1. DS3231 là gì, khác DS1307 thế nào
DS3231 là chip đồng hồ thời gian thực giao tiếp I2C. Điểm khác các chip RTC rẻ hơn là thạch anh nằm sẵn trong chip và được bù nhiệt độ: chip đo nhiệt độ của chính nó rồi chỉnh tần số dao động, nên giờ ít bị trôi khi trời nóng lạnh.
| Đặc điểm | DS3231 | DS1307 |
|---|---|---|
| Thạch anh | Tích hợp trong chip, có bù nhiệt | Thạch anh rời bên ngoài, không bù nhiệt |
| Độ chính xác | ±2 ppm ở 0–40 °C, khoảng ±1 phút mỗi năm | Phụ thuộc thạch anh, thường lệch vài giây mỗi ngày |
| Điện áp | 2,3–5,5 V, chạy được ở 3,3 V | 4,5–5,5 V, không hợp với ESP32 khi cấp 3,3 V |
| Báo thức | Hai báo thức, có chân ngắt | Không có |
| Cảm biến nhiệt độ | Có, bước 0,25 °C | Không có |
Với ESP32, DS3231 là lựa chọn hợp lý hơn: chạy thẳng ở 3,3 V và gần như không cần chỉnh giờ lại.
2. Module DS3231 trên thị trường

Module có hai hàng chân. Hàng 6 chân dùng để nối với vi điều khiển:
- VCC, GND: nguồn 3,3–5 V.
- SDA, SCL: I2C, địa chỉ 0x68. Module có sẵn điện trở kéo lên.
- SQW: chân ngắt báo thức hoặc phát xung vuông. Kiểu cực máng hở: chip chỉ kéo xuống thấp.
- 32K: xung 32,768 kHz. Bài này không dùng.
Hàng 4 chân ở cạnh kia nối thông với hàng 6 chân, dùng để nối tiếp module I2C khác. Trên module còn có EEPROM AT24C32 (4 KB) ở địa chỉ 0x57, đổi được bằng ba miếng hàn A0, A1, A2.
Lưu ý về pin: nhiều module loại này có sẵn mạch nạp pin gồm một điện trở và diode thủy tinh màu đỏ cạnh chip (thấy trong ảnh). Mạch này làm cho pin sạc được LIR2032, trong khi pin đi kèm thường là CR2032 không sạc được. Cấp 3,3 V từ ESP32 thì điện áp sau diode thấp hơn điện áp pin, gần như không nạp. Nếu cấp 5 V lâu dài cho module mà vẫn dùng CR2032, nên gỡ diode hoặc điện trở của mạch nạp, hoặc thay bằng pin LIR2032.
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 DS3231 kèm pin CR2032.
- Dây cắm test board, breadboard và dây USB nạp code.
4. Sơ đồ nối dây

| DS3231 | ESP32 DevKit V1 |
|---|---|
| VCC | 3V3 |
| GND | GND |
| SDA | D21 (GPIO21) |
| SCL | D22 (GPIO22) |
| SQW | D27 (GPIO27) |
| 32K | Không nối |
Đã có màn hình OLED hay LCD I2C thì nối chung hai dây SDA, SCL: mỗi thiết bị có địa chỉ riêng. Cách quét địa chỉ và nối nhiều thiết bị có trong bài giao tiếp I2C trên ESP32.
5. Thanh ghi và mã BCD

DS3231 không lưu giờ bằng số nhị phân thường mà bằng BCD: mỗi nửa byte là một chữ số thập phân. Ví dụ 30 phút được lưu thành 0x30 (nửa trên 3, nửa dưới 0). Đọc nhầm thành số nhị phân sẽ ra 48. Chương trình dùng hai hàm đổi: sangBcd() khi ghi và tuBcd() khi đọc.
Đọc hay ghi DS3231 luôn theo cùng một kiểu: gửi số thứ tự thanh ghi, rồi đọc hoặc ghi liên tiếp. Chip tự tăng địa chỉ sau mỗi byte, nên đọc 7 byte từ 0x00 là lấy đủ giây, phút, giờ, thứ, ngày, tháng, năm trong một lần.
6. Chương trình đọc giờ, đặt giờ và báo thức
Chọn bo ESP32 Dev Module trong Arduino IDE 2 rồi nạp chương trình. Bài dùng gói esp32 phiên bản 3.3.10, chỉ cần thư viện Wire có sẵn.
// PYWORLD.VN - Module thời gian thực DS3231 với ESP32: đọc giờ, nhiệt độ, đặt giờ qua Serial, báo thức hằng ngày
// Nối VCC 3V3, GND, SDA D21, SCL D22, SQW D27 (chân báo thức kiểu cực máng hở, cần điện trở kéo lên)
// Gõ trên Serial Monitor: T2026-10-25 20:30:00 để đặt giờ, A06:30:00 để đặt giờ báo thức hằng ngày
#include <Wire.h>
const uint8_t DIA_CHI_DS3231 = 0x68;
const int CHAN_SQW = 27;
// Thanh ghi của DS3231
const uint8_t TG_GIAY = 0x00; // 0x00-0x06: giây, phút, giờ, thứ, ngày, tháng, năm (mã BCD)
const uint8_t TG_BAO_THUC_1 = 0x07; // 0x07-0x0A: báo thức 1 (giây, phút, giờ, ngày)
const uint8_t TG_DIEU_KHIEN = 0x0E;
const uint8_t TG_TRANG_THAI = 0x0F;
const uint8_t TG_NHIET_DO = 0x11;
const char *TEN_THU[] = {"", "Chu nhat", "Thu hai", "Thu ba", "Thu tu", "Thu nam", "Thu sau", "Thu bay"};
struct ThoiGian {
uint16_t nam;
uint8_t thang, ngay, gio, phut, giay, thu; // thu: 1 = Chủ nhật ... 7 = Thứ bảy
};
volatile bool coBaoThuc = false;
void IRAM_ATTR ngatBaoThuc() { coBaoThuc = true; }
uint8_t sangBcd(uint8_t v) { return ((v / 10) << 4) | (v % 10); }
uint8_t tuBcd(uint8_t v) { return (v >> 4) * 10 + (v & 0x0F); }
bool ghiThanhGhi(uint8_t thanhGhi, const uint8_t *du, size_t n) {
Wire.beginTransmission(DIA_CHI_DS3231);
Wire.write(thanhGhi);
Wire.write(du, n);
return Wire.endTransmission() == 0;
}
bool docThanhGhi(uint8_t thanhGhi, uint8_t *du, size_t n) {
Wire.beginTransmission(DIA_CHI_DS3231);
Wire.write(thanhGhi);
if (Wire.endTransmission(false) != 0) return false; // giữ bus, đọc tiếp ngay (repeated START)
if (Wire.requestFrom(DIA_CHI_DS3231, (uint8_t)n) != n) return false;
for (size_t i = 0; i < n; i++) du[i] = Wire.read();
return true;
}
// Thứ trong tuần theo thuật toán Sakamoto: 1 = Chủ nhật ... 7 = Thứ bảy.
uint8_t tinhThu(uint16_t y, uint8_t m, uint8_t d) {
static const uint8_t t[] = {0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4};
if (m < 3) y -= 1;
return (y + y / 4 - y / 100 + y / 400 + t[m - 1] + d) % 7 + 1;
}
bool datGio(const ThoiGian &tg) {
uint8_t du[7] = {sangBcd(tg.giay), sangBcd(tg.phut), sangBcd(tg.gio), // bit 6 của giờ = 0: chế độ 24 giờ
tinhThu(tg.nam, tg.thang, tg.ngay), sangBcd(tg.ngay), sangBcd(tg.thang),
sangBcd(tg.nam - 2000)};
if (!ghiThanhGhi(TG_GIAY, du, 7)) return false;
uint8_t trangThai;
if (!docThanhGhi(TG_TRANG_THAI, &trangThai, 1)) return false;
trangThai &= ~0x80; // xóa cờ OSF: giờ đã hợp lệ
return ghiThanhGhi(TG_TRANG_THAI, &trangThai, 1);
}
bool docGio(ThoiGian &tg) {
uint8_t du[7];
if (!docThanhGhi(TG_GIAY, du, 7)) return false;
tg.giay = tuBcd(du[0] & 0x7F);
tg.phut = tuBcd(du[1] & 0x7F);
tg.gio = tuBcd(du[2] & 0x3F);
tg.thu = du[3] & 0x07;
tg.ngay = tuBcd(du[4] & 0x3F);
tg.thang = tuBcd(du[5] & 0x1F);
tg.nam = 2000 + tuBcd(du[6]);
return true;
}
// Nhiệt độ bên trong chip, bước 0,25 độ C (dùng để bù sai số thạch anh).
float docNhietDo() {
uint8_t du[2];
if (!docThanhGhi(TG_NHIET_DO, du, 2)) return NAN;
return (int8_t)du[0] + (du[1] >> 6) * 0.25f;
}
// Báo thức 1 khớp giờ:phút:giây, bỏ qua ngày (bit A1M4 = 1) -> kêu mỗi ngày một lần.
bool datBaoThuc(uint8_t gio, uint8_t phut, uint8_t giay) {
uint8_t du[4] = {sangBcd(giay), sangBcd(phut), sangBcd(gio), 0x80};
if (!ghiThanhGhi(TG_BAO_THUC_1, du, 4)) return false;
uint8_t dieuKhien;
if (!docThanhGhi(TG_DIEU_KHIEN, &dieuKhien, 1)) return false;
dieuKhien |= 0x04 | 0x01; // INTCN = 1: chân SQW làm chân ngắt; A1IE = 1
return ghiThanhGhi(TG_DIEU_KHIEN, &dieuKhien, 1);
}
// Xóa cờ A1F để chân SQW nhả về mức cao, sẵn sàng cho lần báo sau.
void xoaCoBaoThuc() {
uint8_t trangThai;
if (docThanhGhi(TG_TRANG_THAI, &trangThai, 1)) {
trangThai &= ~0x01;
ghiThanhGhi(TG_TRANG_THAI, &trangThai, 1);
}
}
// Lấy giờ lúc biên dịch (__DATE__ "Oct 25 2026", __TIME__ "20:30:00") làm giờ ban đầu.
ThoiGian gioBienDich() {
static const char thangChu[] = "JanFebMarAprMayJunJulAugSepOctNovDec";
char t[4] = {__DATE__[0], __DATE__[1], __DATE__[2], 0};
ThoiGian tg;
tg.thang = (strstr(thangChu, t) - thangChu) / 3 + 1;
tg.ngay = atoi(__DATE__ + 4);
tg.nam = atoi(__DATE__ + 7);
tg.gio = atoi(__TIME__);
tg.phut = atoi(__TIME__ + 3);
tg.giay = atoi(__TIME__ + 6);
return tg;
}
void xuLyLenh(const String &lenh) {
ThoiGian tg;
unsigned n, th, ng, g, p, s;
if (lenh.startsWith("T") && sscanf(lenh.c_str() + 1, "%u-%u-%u %u:%u:%u", &n, &th, &ng, &g, &p, &s) == 6 &&
n >= 2000 && n <= 2099 && th >= 1 && th <= 12 && ng >= 1 && ng <= 31 && g < 24 && p < 60 && s < 60) {
tg = {(uint16_t)n, (uint8_t)th, (uint8_t)ng, (uint8_t)g, (uint8_t)p, (uint8_t)s, 0};
Serial.println(datGio(tg) ? "Da dat gio" : "Loi I2C khi dat gio");
} else if (lenh.startsWith("A") && sscanf(lenh.c_str() + 1, "%u:%u:%u", &g, &p, &s) == 3 && g < 24 && p < 60 && s < 60) {
Serial.println(datBaoThuc(g, p, s) ? "Da dat bao thuc hang ngay" : "Loi I2C khi dat bao thuc");
} else {
Serial.println("Lenh khong hop le. Vi du: T2026-10-25 20:30:00 hoac A06:30:00");
}
}
void setup() {
Serial.begin(115200);
delay(500);
Wire.begin(21, 22); // SDA = GPIO21, SCL = GPIO22
pinMode(CHAN_SQW, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(CHAN_SQW), ngatBaoThuc, FALLING);
Wire.beginTransmission(DIA_CHI_DS3231);
if (Wire.endTransmission() != 0) {
Serial.println("Khong thay DS3231 o dia chi 0x68: kiem tra SDA, SCL, nguon");
return;
}
uint8_t trangThai = 0;
docThanhGhi(TG_TRANG_THAI, &trangThai, 1);
if (trangThai & 0x80) { // OSF = 1: dao động từng dừng (lần đầu, hết pin) -> giờ không đáng tin
Serial.println("DS3231 mat gio (co OSF): lay gio luc bien dich");
datGio(gioBienDich());
}
xoaCoBaoThuc();
Serial.println("Go T2026-10-25 20:30:00 de dat gio, A06:30:00 de dat bao thuc");
}
void loop() {
static uint8_t giayTruoc = 255;
static String lenh;
while (Serial.available()) {
char c = Serial.read();
if (c == '\n' || c == '\r') {
if (lenh.length()) xuLyLenh(lenh);
lenh = "";
} else if (lenh.length() < 40) {
lenh += c;
}
}
ThoiGian tg;
if (docGio(tg) && tg.giay != giayTruoc) { // in mỗi khi sang giây mới
giayTruoc = tg.giay;
Serial.printf("%s %02u/%02u/%04u %02u:%02u:%02u | %.2f C\n", TEN_THU[tg.thu], tg.ngay, tg.thang, tg.nam,
tg.gio, tg.phut, tg.giay, docNhietDo());
}
if (coBaoThuc) {
coBaoThuc = false;
xoaCoBaoThuc();
Serial.println(">>> BAO THUC <<<");
}
delay(50);
}
Cách dùng sau khi nạp, trên Serial Monitor (115200, chọn kiểu xuống dòng Newline):
- Lần đầu, module mới hoặc vừa thay pin có cờ OSF: chương trình tự lấy giờ lúc biên dịch. Giờ này chậm vài giây so với thực tế.
- Muốn đặt đúng giờ, gõ
T2026-10-25 20:30:00rồi Enter. Thứ trong tuần được tính tự động. - Đặt báo thức hằng ngày: gõ
A06:30:00.
7. Giải thích các phần chính
docThanhGhi()gửi số thanh ghi bằngendTransmission(false)để giữ bus, rồi đọc tiếp ngay (tín hiệu START lặp lại). Cách này tránh thiết bị khác chen vào giữa hai bước.- Cờ OSF (bit 7 thanh ghi 0x0F) bằng 1 khi bộ dao động từng dừng: module mới, hết pin, tháo pin lúc mất điện. Thấy cờ này thì giờ không đáng tin. Đặt giờ xong,
datGio()xóa cờ. tinhThu()tính thứ từ ngày tháng theo thuật toán Sakamoto. DS3231 chỉ đếm thứ từ 1 đến 7, không biết ngày nào là thứ mấy, nên phải ghi đúng khi đặt giờ. Chương trình quy ước 1 là Chủ nhật.- Báo thức 1 có 4 thanh ghi: giây, phút, giờ, ngày. Bit 7 của mỗi thanh ghi là bit "bỏ qua". Đặt bit 7 của thanh ghi ngày bằng 1 nghĩa là chỉ so giờ:phút:giây, nên báo thức kêu mỗi ngày một lần.
- Thanh ghi điều khiển 0x0E: bit INTCN = 1 biến chân SQW thành chân ngắt, bit A1IE = 1 bật báo thức 1.
- Hàm ngắt
ngatBaoThuc()chỉ bật cờcoBaoThuc. Việc đọc I2C, in Serial, xóa cờ A1F làm trongloop(). Hàm ngắt nên ngắn như vậy; chi tiết ở bài ngắt GPIO trên ESP32. docNhietDo()đọc nhiệt độ của chính con chip, bước 0,25 °C. Chip dùng số này để bù thạch anh. Nó gần với nhiệt độ phòng nhưng không thay được cảm biến nhiệt độ chuyên dụng.
8. Báo thức đi qua chân SQW

Khi giờ trùng với giờ báo thức, DS3231 đặt cờ A1F và kéo SQW xuống thấp. Chân này giữ mức thấp cho tới khi chương trình xóa A1F. Vì thế quên xóa cờ là lỗi hay gặp: báo thức chỉ kêu được một lần.
Chân SQW còn dùng được để đánh thức ESP32 khỏi Deep Sleep. Thiết bị chạy pin có thể ngủ cả ngày, đến giờ hẹn thì thức dậy đo và gửi dữ liệu. Xem cách làm trong bài Deep Sleep trên ESP32, với nguồn đánh thức là chân GPIO27 mức thấp.
9. Kết quả trên Serial Monitor

10. Kết hợp với giờ Internet
Thiết bị có Wi-Fi nên lấy giờ chuẩn qua NTP khi có mạng rồi ghi vào DS3231. Khi mất mạng hay mất điện, DS3231 vẫn giữ giờ đúng. Cách lấy giờ NTP và đặt múi giờ Việt Nam (UTC+7) có trong bài đồng hồ Internet với ESP32, NTP và OLED. Sau khi lấy được giờ NTP, gọi datGio() với giờ đó, mỗi ngày một lần là đủ.
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 thay DS3231 o dia chi 0x68" | Đảo SDA với SCL, cắm lỏng, chưa cấp nguồn | Kiểm tra lại dây; chạy chương trình quét I2C, phải thấy 0x68 và 0x57 |
| Mỗi lần cắm điện lại báo "mat gio" | Chưa lắp pin, pin hết, tiếp xúc hộc pin kém | Đo pin phải trên 2,8 V; uốn nhẹ lá kẹp pin |
| Giờ đúng nhưng thứ sai | Đặt giờ bằng chương trình khác ghi thứ theo quy ước khác (1 = Thứ hai) | Đặt lại giờ bằng lệnh T… của chương trình này |
| Phút, giây nhảy số lạ (ví dụ 48 thay cho 30) | Đọc thanh ghi như số nhị phân, quên đổi BCD | Dùng tuBcd() khi đọc, sangBcd() khi ghi |
| Báo thức chỉ kêu một lần | Không xóa cờ A1F nên SQW giữ mức thấp | Gọi xoaCoBaoThuc() sau mỗi lần báo |
| Báo thức không kêu | Chưa bật INTCN, A1IE; chân SQW thiếu điện trở kéo lên | Kiểm tra thanh ghi 0x0E; giữ INPUT_PULLUP cho GPIO27 |
12. Xem thêm
- Module thẻ nhớ Micro SD với ESP32: ghi dữ liệu kèm ngày giờ lấy từ DS3231.
- LCD 2004 I2C với ESP32: làm đồng hồ để bàn, chung dây I2C với DS3231.
- Lưu dữ liệu bằng Preferences: nhớ giờ báo thức đã đặt khi mất điện.
- Module thẻ nhớ Micro SD và màn hình LCD I2C của shop.
Xem thêm