Cảm biến nhịp tim MAX30102 với ESP32: tín hiệu PPG, đếm nhịp không cần thư viện và giới hạn của SpO2
Nguyên lý PPG, nối MAX30102 với ESP32, cấu hình thanh ghi, lọc tín hiệu và đếm nhịp tim không cần thư viện, tỉ số R của SpO2 và vì sao kết quả tự làm chỉ để học.
MAX30102 là cảm biến quang học nhỏ có hai đèn (đỏ và hồng ngoại) cùng một photodiode, dùng trong máy đo nhịp tim kẹp ngón và đồng hồ thông minh. Đặt ngón tay lên cảm biến, nó đo lượng ánh sáng dội lại; lượng máu trong ngón tay thay đổi theo mỗi nhịp tim nên ánh sáng cũng thay đổi theo. Bài này giải thích tín hiệu PPG, cách nối MAX30102 với ESP32, cách cấu hình các thanh ghi, cách lọc tín hiệu để đếm nhịp tim mà không cần thư viện, và vì sao con số SpO2 từ mạch tự làm chỉ để học.
Lưu ý: mạch trong bài là đồ học tập về xử lý tín hiệu. Kết quả không dùng để chẩn đoán, theo dõi sức khỏe hay thay cho máy đo y tế.
1. Tín hiệu PPG: đo nhịp tim bằng ánh sáng

PPG (photoplethysmogram) là tín hiệu ánh sáng thay đổi theo lượng máu trong mô. Ánh sáng chiếu vào ngón tay bị da, mô, xương và máu hấp thụ một phần. Hầu hết những thứ này không đổi theo thời gian, tạo nên phần DC (một chiều). Riêng máu động mạch tăng lên mỗi khi tim co bóp rồi giảm dần, tạo nên phần AC (xoay chiều) nhỏ, chỉ khoảng 0,5–2% tổng ánh sáng nhận được.
Vì phần AC quá nhỏ, cảm biến cần bộ chuyển đổi tương tự – số (ADC) có độ phân giải cao. MAX30102 có sẵn ADC 18 bit, mạch khử ánh sáng môi trường và bộ đệm FIFO 32 mẫu, giao tiếp I2C ở địa chỉ 0x57.
2. 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.
- Module MAX30102 (thường là bo màu tím hoặc xanh, có 5–7 chân).
- Dây cắm; một đoạn dây chun hoặc băng dính dán xé để giữ ngón tay đè nhẹ lên cảm biến.
- Muốn hiện nhịp tim không cần máy tính: thêm màn hình OLED 0.96 inch I2C, nối chung SDA, SCL với MAX30102.
3. Nối MAX30102 với ESP32

| MAX30102 | ESP32 DevKit V1 | Ghi chú |
|---|---|---|
| VIN | 3V3 | Module có mạch ổn áp 1,8 V cho lõi chip |
| SCL | D22 | I2C, chạy 400 kHz |
| SDA | D21 | |
| INT | để trống | Báo có dữ liệu mới; bài đọc FIFO theo vòng lặp nên không cần |
| GND | GND |
Lỗi hay gặp với module màu tím: trên một số bo, điện trở kéo lên của SDA, SCL nối về đường 1,8 V. Mức 1,8 V thấp hơn mức ESP32 coi chắc chắn là mức cao, nên lúc đọc được lúc không. Để module yên (chưa chạy chương trình), đo SDA so với GND: thấy khoảng 1,8 V thì chuyển cầu hàn trên module sang 3,3 V (nếu bo có), hoặc thêm hai điện trở 4,7 kΩ từ SDA và SCL lên 3V3.
4. Cấu hình MAX30102 qua thanh ghi
MAX30102 được điều khiển bằng cách ghi vào thanh ghi qua I2C, giống như nhiều cảm biến khác (xem bài I2C là gì: đọc ghi thanh ghi trên ESP32). Chương trình ghi các giá trị sau:
| Thanh ghi | Giá trị | Ý nghĩa |
|---|---|---|
| 0xFF (mã linh kiện) | đọc ra 0x15 | Kiểm tra đúng là MAX30102 |
| 0x09 (chế độ) | 0x40, rồi 0x03 | Đặt lại toàn bộ; sau đó chọn chế độ SpO2 (đo cả đèn đỏ và hồng ngoại) |
| 0x04, 0x05, 0x06 | 0 | Đưa con trỏ ghi, bộ đếm tràn, con trỏ đọc của FIFO về 0 |
| 0x08 (cấu hình FIFO) | 0x50 | Lấy trung bình 4 mẫu; FIFO đầy thì ghi đè mẫu cũ |
| 0x0A (cấu hình đo) | 0x2F | Dải ADC 4096 nA, 400 mẫu/giây, độ rộng xung đèn 411 µs (18 bit) |
| 0x0C, 0x0D (dòng đèn) | 0x24 | Đèn đỏ và hồng ngoại 7,2 mA (mỗi bước 0,2 mA) |
400 mẫu/giây lấy trung bình 4 mẫu thì FIFO nhận 100 mẫu/giây, đủ cho tín hiệu nhịp tim (thường dưới 3 Hz) mà vẫn giảm được nhiễu. Mỗi mẫu trong FIFO dài 6 byte: 3 byte đèn đỏ rồi 3 byte hồng ngoại, mỗi số lấy 18 bit thấp. Số mẫu chưa đọc bằng (con trỏ ghi − con trỏ đọc) lấy 5 bit thấp, vì FIFO chỉ có 32 ô.
5. Từ số đo thô tới nhịp tim

Số đo hồng ngoại khi có ngón tay vào khoảng hàng trăm nghìn, còn phần thay đổi theo nhịp tim chỉ vài trăm. Chương trình xử lý từng mẫu qua các bước:
- Phát hiện ngón tay: IR dưới 50.000 coi như không có ngón tay. Vừa đặt ngón tay vào thì mọi bộ lọc bắt đầu lại.
- Bỏ phần DC: trung bình trượt chậm
dcIr += (ir − dcIr) / 64theo dõi phần không đổi; lấydcIr − irđược phần AC. Dấu được đảo vì khi tim co bóp, máu nhiều hơn, ánh sáng về ít hơn: đảo dấu để đỉnh tín hiệu trùng lúc tim co bóp. - Lọc thông thấp:
locIr += (ac − locIr) × 0,25làm mượt, bớt nhiễu nhanh. - Đếm nhịp có hai ngưỡng: biên độ đỉnh
dinhgiảm dần theo thời gian và được kéo lên mỗi khi tín hiệu cao hơn. Một nhịp được đếm khi tín hiệu vượt 0,6 × dinh, và phải xuống dưới 0 rồi mới đếm nhịp tiếp theo. Mỗi nhịp PPG có một khấc phụ nhỏ sau đỉnh chính; ngưỡng 0,6 cao hơn khấc này nên không bị đếm đôi. - Loại khoảng vô lý: hai nhịp cách nhau dưới 0,3 giây (trên 200 nhịp/phút) coi là nhiễu; trên 2 giây (dưới 30 nhịp/phút) thì không dùng khoảng đó.
- Tính nhịp tim: 60 chia cho khoảng cách trung bình của 4 nhịp gần nhất (tính bằng giây).
Chương trình chờ 2 giây cho bộ lọc ổn định và cần đủ 5 nhịp, nên kết quả đầu tiên có sau khoảng 6–7 giây từ lúc đặt ngón tay.
6. Tỉ số R và SpO2: nguyên lý và giới hạn

SpO2 là tỉ lệ hemoglobin trong máu động mạch đang mang oxy. Hemoglobin mang oxy hấp thụ ít ánh sáng đỏ hơn hemoglobin không mang oxy, còn với ánh sáng hồng ngoại thì ngược lại. Vì vậy so độ dao động của hai màu đèn cho biết tỉ lệ này:
R = (AC đỏ / DC đỏ) ÷ (AC hồng ngoại / DC hồng ngoại)
Chia cho DC để loại ảnh hưởng của độ dày ngón tay và độ sáng đèn. Chương trình tính AC bằng độ lệch chuẩn của tín hiệu đã lọc trong mỗi nhịp (có trừ giá trị trung bình để phần trôi chậm còn sót không làm lệch R), lấy trung bình 4 nhịp, rồi đổi R ra SpO2 bằng công thức kinh nghiệm dùng trong mã mẫu của hãng: SpO2 = −45,060 R² + 30,354 R + 94,845.
Giới hạn cần biết:
- Công thức này chỉ đúng với cảm biến và vỏ đã được hiệu chuẩn trên người thật, so với máy đo chuẩn. Module bán rời chưa qua bước đó.
- R lệch 0,05 đã làm kết quả đổi 1–3%. Cử động, ấn ngón tay mạnh, tay lạnh, ánh sáng lọt vào đều làm R lệch nhiều hơn thế.
- Máy đo kẹp ngón dùng trong y tế có vỏ che sáng, lò xo giữ lực ép ổn định, thuật toán chống nhiễu và được kiểm định.

7. Chương trình
Chương trình chỉ dùng thư viện Wire có sẵn trong gói esp32. Đặt VE_DO_THI = true để in tín hiệu đã lọc và ngưỡng cho Serial Plotter của Arduino IDE, rất tiện để thấy ngón tay đặt đã ổn chưa.
// PYWORLD.VN - Cảm biến MAX30102 với ESP32: đọc tín hiệu PPG, đếm nhịp tim, tính tỉ số R của hai đèn đỏ và hồng ngoại, không cần thư viện
// Nối: VIN 3V3, GND, SDA D21, SCL D22 (địa chỉ I2C 0x57). Đặt đầu ngón tay nhẹ và giữ yên trên mặt cảm biến
// Chỉ để học nguyên lý đo: không dùng kết quả để chẩn đoán hay theo dõi sức khỏe
#include <Wire.h>
const uint8_t DIA_CHI = 0x57;
const bool VE_DO_THI = false; // true: in tín hiệu cho Serial Plotter; false: in kết quả mỗi giây
const uint32_t NGUONG_NGON_TAY = 50000; // IR thấp hơn mức này: không có ngón tay trên cảm biến
const int TAN_SO_MAU = 100; // 400 mẫu/giây, lấy trung bình 4 mẫu → 100 mẫu/giây
const uint8_t TG_FIFO_GHI = 0x04, TG_FIFO_TRAN = 0x05, TG_FIFO_DOC = 0x06, TG_FIFO_DATA = 0x07;
const uint8_t TG_FIFO_CAU_HINH = 0x08, TG_CHE_DO = 0x09, TG_SPO2_CAU_HINH = 0x0A;
const uint8_t TG_LED_DO = 0x0C, TG_LED_IR = 0x0D, TG_PART_ID = 0xFF;
bool ghi(uint8_t tg, uint8_t giaTri) {
Wire.beginTransmission(DIA_CHI);
Wire.write(tg);
Wire.write(giaTri);
return Wire.endTransmission() == 0;
}
int doc(uint8_t tg) {
Wire.beginTransmission(DIA_CHI);
Wire.write(tg);
if (Wire.endTransmission(false) != 0 || Wire.requestFrom(DIA_CHI, (uint8_t)1) != 1) return -1;
return Wire.read();
}
bool khoiDong() {
if (doc(TG_PART_ID) != 0x15) return false; // MAX30102 có mã linh kiện 0x15
ghi(TG_CHE_DO, 0x40); // đặt lại toàn bộ thanh ghi
delay(10);
ghi(TG_FIFO_GHI, 0);
ghi(TG_FIFO_TRAN, 0);
ghi(TG_FIFO_DOC, 0);
ghi(TG_FIFO_CAU_HINH, 0x50); // trung bình 4 mẫu, FIFO đầy thì ghi đè mẫu cũ
ghi(TG_SPO2_CAU_HINH, 0x2F); // dải 4096 nA, 400 mẫu/giây, xung 411 µs (18 bit)
ghi(TG_LED_DO, 0x24); // dòng đèn đỏ 7,2 mA (mỗi bước 0,2 mA)
ghi(TG_LED_IR, 0x24); // dòng đèn hồng ngoại 7,2 mA
return ghi(TG_CHE_DO, 0x03); // chế độ SpO2: đo cả đèn đỏ và hồng ngoại
}
// Mỗi mẫu trong FIFO 6 byte: 3 byte đèn đỏ rồi 3 byte hồng ngoại, mỗi số 18 bit.
bool docMau(uint32_t &doDo, uint32_t &hongNgoai) {
Wire.beginTransmission(DIA_CHI);
Wire.write(TG_FIFO_DATA);
if (Wire.endTransmission(false) != 0 || Wire.requestFrom(DIA_CHI, (uint8_t)6) != 6) return false;
uint8_t b[6];
for (int i = 0; i < 6; i++) b[i] = Wire.read();
doDo = (((uint32_t)b[0] << 16) | (b[1] << 8) | b[2]) & 0x3FFFF;
hongNgoai = (((uint32_t)b[3] << 16) | (b[4] << 8) | b[5]) & 0x3FFFF;
return true;
}
// ---- Xử lý tín hiệu ----
float dcIr, dcDo, locIr, locDo, dinh; // thành phần một chiều, tín hiệu đã lọc, biên độ đỉnh gần đây
bool coNgon = false, duoiNguong = true;
uint32_t soMau = 0, mauBatDau = 0, mauNhipTruoc = 0;
float khoangNhip[4], tiSoR[4]; // 4 nhịp gần nhất
int soNhip = 0, viTri = 0;
double tongIr, tongIr2, tongDo, tongDo2; // tổng và tổng bình phương phần xoay chiều trong một nhịp
uint32_t soMauTrongNhip;
// Độ lệch chuẩn trong một nhịp: trừ giá trị trung bình để phần trôi chậm còn sót không làm lệch tỉ số R.
float doLech(double tong, double tong2, uint32_t n) {
double tb = tong / n;
return sqrt(tong2 / n - tb * tb);
}
void xuLyMau(uint32_t doDo, uint32_t hongNgoai) {
soMau++;
if (hongNgoai < NGUONG_NGON_TAY) {
coNgon = false;
return;
}
if (!coNgon) { // vừa đặt ngón tay: bắt đầu lại từ đầu
coNgon = true;
dcIr = hongNgoai;
dcDo = doDo;
locIr = locDo = dinh = 0;
duoiNguong = true;
soNhip = viTri = 0;
mauBatDau = soMau;
mauNhipTruoc = 0;
tongIr = tongIr2 = tongDo = tongDo2 = 0;
soMauTrongNhip = 0;
}
dcIr += (hongNgoai - dcIr) / 64.0f; // trung bình trượt chậm: phần một chiều (mô, máu tĩnh mạch)
dcDo += (doDo - dcDo) / 64.0f;
// Tim co bóp, máu động mạch nhiều hơn nên ánh sáng về ít hơn: đảo dấu để đỉnh tín hiệu là lúc tim co bóp
locIr += ((dcIr - hongNgoai) - locIr) * 0.25f; // lọc thông thấp, bớt nhiễu nhanh
locDo += ((dcDo - doDo) - locDo) * 0.25f;
dinh *= 0.995f; // biên độ đỉnh giảm dần (còn một nửa sau khoảng 1,4 giây)
if (locIr > dinh) dinh = locIr;
tongIr += locIr;
tongIr2 += (double)locIr * locIr;
tongDo += locDo;
tongDo2 += (double)locDo * locDo;
soMauTrongNhip++;
if (soMau - mauBatDau < 2 * TAN_SO_MAU) return; // chờ 2 giây cho bộ lọc ổn định
float nguong = 0.6f * dinh; // ngưỡng trên cao hơn khấc phụ sau mỗi nhịp
if (duoiNguong && locIr > nguong) { // tín hiệu vượt ngưỡng trên: một nhịp mới
uint32_t khoang = soMau - mauNhipTruoc;
if (mauNhipTruoc != 0 && khoang < TAN_SO_MAU * 3 / 10) return; // dưới 0,3 giây: nhiễu, bỏ qua
duoiNguong = false;
if (mauNhipTruoc != 0 && khoang <= 2 * TAN_SO_MAU) { // 0,3–2 giây: 30–200 nhịp/phút
float acDo = doLech(tongDo, tongDo2, soMauTrongNhip), acIr = doLech(tongIr, tongIr2, soMauTrongNhip);
khoangNhip[viTri] = khoang;
tiSoR[viTri] = (acDo / dcDo) / (acIr / dcIr);
viTri = (viTri + 1) % 4;
if (soNhip < 4) soNhip++;
}
mauNhipTruoc = soMau;
tongIr = tongIr2 = tongDo = tongDo2 = 0;
soMauTrongNhip = 0;
} else if (!duoiNguong && locIr < 0) {
duoiNguong = true; // phải xuống dưới 0 mới đếm nhịp tiếp theo
}
if (VE_DO_THI) Serial.printf("ir:%.0f,nguong:%.0f\n", locIr, nguong);
}
void inKetQua(uint32_t hongNgoai) {
if (!coNgon) {
Serial.printf("Dat ngon tay len cam bien (IR = %lu)\n", (unsigned long)hongNgoai);
} else if (soNhip < 4) {
Serial.printf("Dang do, giu yen ngon tay... (IR = %lu)\n", (unsigned long)hongNgoai);
} else {
float tongKhoang = 0, tongR = 0;
for (int i = 0; i < 4; i++) {
tongKhoang += khoangNhip[i];
tongR += tiSoR[i];
}
float nhipPhut = 60.0f * TAN_SO_MAU / (tongKhoang / 4);
float r = tongR / 4;
float spo2 = -45.060f * r * r + 30.354f * r + 94.845f; // công thức kinh nghiệm, cần hiệu chuẩn
Serial.printf("IR %lu | nhip tim %.0f lan/phut | R %.2f | SpO2 uoc tinh %.0f%% (chi de hoc)\n",
(unsigned long)hongNgoai, nhipPhut, r, spo2 > 100 ? 100 : spo2);
}
}
void setup() {
Serial.begin(115200);
Wire.begin(21, 22);
Wire.setClock(400000);
while (!khoiDong()) {
Serial.println("Khong thay MAX30102 o dia chi 0x57: kiem tra SDA, SCL, nguon 3V3");
delay(2000);
}
if (!VE_DO_THI) Serial.println("MAX30102 san sang. Chi de hoc nguyen ly, khong dung cho y te");
}
void loop() {
int ghiP = doc(TG_FIFO_GHI), docP = doc(TG_FIFO_DOC);
if (ghiP < 0 || docP < 0) return;
int soMoi = (ghiP - docP) & 0x1F; // FIFO 32 mẫu: số mẫu chưa đọc
uint32_t doDo = 0, hongNgoai = 0;
for (int i = 0; i < soMoi; i++) {
if (!docMau(doDo, hongNgoai)) return;
xuLyMau(doDo, hongNgoai);
if (!VE_DO_THI && soMau % TAN_SO_MAU == 0) inKetQua(hongNgoai);
}
}
khoiDong()kiểm tra mã linh kiện 0x15 trước khi cấu hình; sai mã thì báo lỗi và thử lại mỗi 2 giây.loop()đọc hai con trỏ FIFO để biết có bao nhiêu mẫu mới, đọc hết rồi đưa từng mẫu vàoxuLyMau(). Cách này không bỏ sót mẫu dù vòng lặp chạy chậm, miễn là chưa quá 32 mẫu (0,32 giây).doLech()tính độ lệch chuẩn từ tổng và tổng bình phương, không cần lưu cả nhịp vào mảng.
8. Kết quả mong đợi

Cách đặt ngón tay quyết định chất lượng tín hiệu:
- Đặt đầu ngón tay (phần thịt, không phải móng) phủ kín mặt cảm biến, ấn nhẹ. Ấn mạnh làm máu bị ép ra khỏi mao mạch, tín hiệu AC nhỏ hẳn đi.
- Giữ yên tay và ngồi yên. Cử động tạo dao động lớn hơn nhiều lần tín hiệu nhịp tim.
- Tránh đèn chiếu thẳng vào cảm biến; tay lạnh thì xoa cho ấm trước khi đo.
- Dùng dây chun hoặc băng dán giữ ngón tay với lực đều sẽ ổn định hơn tự giữ bằng tay.
9. 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 MAX30102 o dia chi 0x57" | Đảo SDA/SCL; kéo lên 1,8 V; module khác loại (MAX30100 có mã khác) | Quét I2C; kiểm tra mức SDA; đọc chữ trên chip |
| Luôn "Dat ngon tay len cam bien" dù đã đặt | IR thấp hơn 50.000: đèn yếu, ngón tay chưa phủ kín | Đặt lại ngón tay; tăng dòng đèn (0x0C, 0x0D) lên 0x30–0x3F |
| IR gần 262.143 và không đổi | ADC bão hòa: đèn quá sáng | Giảm dòng đèn hoặc chọn dải ADC 8192 nA |
| Nhịp tim gấp đôi thực tế | Khấc phụ bị đếm thành nhịp; nhiễu do cử động | Giữ yên; xem Serial Plotter, tăng hệ số ngưỡng từ 0,6 lên 0,7 |
| Nhịp tim nhảy lung tung, R thay đổi nhiều | Cử động, ấn quá mạnh, ánh sáng lọt vào | Ấn nhẹ, che sáng, cố định ngón tay bằng dây chun |
| Mãi "Dang do" không ra kết quả | Tín hiệu quá yếu, không vượt ngưỡng đều | Bật VE_DO_THI xem dạng sóng; xoa ấm tay; đổi ngón khác |
10. Xem thêm
- Giao tiếp I2C trên ESP32: quét địa chỉ và nối nhiều thiết bị: nối MAX30102 chung đường I2C với màn hình OLED.
- OLED 0.96 hay 1.3 inch, SSD1306 hay SH1106: chọn màn hình để hiện nhịp tim.
- PID giữ tốc độ động cơ DC trên ESP32: thêm một ví dụ dùng Serial Plotter để xem tín hiệu.
- ESP32 đọc MPU-6050 bằng bộ lọc bù: một cách lọc tín hiệu cảm biến khác.
Xem thêm