ESP32与MAX30102心率检测实战:从PPG原理到OLED显示完整教程
2026/9/12 20:58:00 网站建设 项目流程

1. MAX30102到底在测什么:先弄懂那颗传感器

1.1 光电容积脉搏波(PPG)的物理原理

做心率检测这个项目,我见过太多人跳过原理直接刷代码,最后数据乱跳就开始怀疑模块坏了。其实MAX30102的原理并不复杂:心脏每收缩一次,动脉血管里的血流量就会有一个脉冲式的增加。MAX30102的LED发出一束光照射到皮肤组织上,一部分光被血液、皮肤、骨骼吸收,剩下的散射回来被光电二极管接收。血液多的时候吸收的光多,反射回来的光就弱;血液少的时候反射光就强。于是光电二极管接收到的光信号会跟着心跳出现微小波动,把这个波动记录下来,就是光电容积脉搏波,简称PPG。

这跟标题里"听心跳"的比喻是一样的:耳朵听到的是声音的节律,MAX30102"看"到的是光强变化的节律,本质都是心脏泵血的周期性。你需要理解一个关键点:MAX30102并不是直接"测心率"的芯片,它只输出一路受血流调制过的光强度信号。心率数值是你在单片机里通过算法从这个信号里"算"出来的。把这两件事分开,后面遇到问题才不会乱。

1.2 模块上那些元器件分别是干嘛的

MAX30102模块上有几个关键部分:一颗集成了LED和光电二极管的MAX30102芯片、几个去耦电容,有的模块还会带一个稳压芯片和电平转换电路,方便接5V单片机。

MAX30102芯片内部集成了红光LED(660nm)、红外光LED(880nm)和一个光电二极管,加上ADC、FIFO、I2C接口、数字滤波等一堆东西。外部单片机通过I2C读写它的寄存器,就能拿到经过内部ADC量化后的光强数据。对了,模块上那个透明的窗口下面就是LED和光电二极管,手指要盖住的正是这个区域。有的模块会带一个隔离环做遮光,有的没有,后者对贴装和佩戴的要求会更高一些。

从使用角度你要记住三个核心概念:LED电流(决定发光亮度)、采样率(ADC每秒采多少次)、FIFO(内部数据缓存队列)。这三个参数直接决定了你拿到的信号长什么样。

1.3 双波长有什么用

MAX30102同时集成了红光和红外光两种LED,很多人以为测心率需要两路都用上,其实不是。测心率只用红外光就足够,因为红外光穿透更深,信号受肤色、指甲油等因素影响更小。双波长真正的用途是测血氧饱和度(SpO2):氧合血红蛋白和脱氧血红蛋白对红光和红外光的吸收率不同,通过两路信号的比值可以估算血氧浓度。

所以你在代码里会看到ledMode这个参数设置为3,代表红光和红外光同时开启。即使你现在只想测心率,我也建议保持这个模式,原因很简单:两路数据可以互相校验信号质量。比如红外光信号明显异常而红光正常,说明传感器佩戴可能有问题,或者皮肤表面状态对两种波长的吸收差异异常。用一路做主计算,另一路做辅助判断,是实际项目中很实用的做法。

2. 零基础环境搭建:IDE、板卡和接线

2.1 Arduino IDE里装好ESP32开发板

先把Arduino IDE装好,我用的是2.x版本,界面清爽,库管理器也比1.x好用。安装完之后要做两件事:配置ESP32开发板支持,以及安装MAX30102的驱动库。

ESP32开发板的支持要手动添加。打开Arduino IDE的"文件 -> 首选项 -> 附加开发板管理器网址",填入官方的ESP32开发板JSON地址。这一步很多新手会漏掉,填完之后在"开发板管理器"里搜索"esp32",安装Espressif官方维护的那一个。国内网络如果下载慢,可以切换镜像源,但注意不要用来路不明的第三方源,安全第一。

装完后在"工具 -> 开发板"里找到"ESP32 Dev Module"或对应你板子的型号。这里有一个很容易踩的坑:很多开发板虽然印着不同的名称,但芯片都是ESP32-WROOM-32,选"ESP32 Dev Module"基本都能跑。如果你用的是ESP32-S3或C3,就要选对应的型号,选错会出现编译能过但烧录失败或上电开机闪退的情况。

2.2 安装MAX30102驱动库

在Arduino IDE的库管理器里搜索"MAX30105",安装"SparkFun MAX3010x Pulse and Proximity Sensor Library"。注意这个库的名字带MAX30105,但它同时支持MAX30100、MAX30101、MAX30102、MAX30105这些同系列传感器,寄存器大部分兼容。

还有另一个库叫"MAX30102 by OXullo Interworks",底层驱动更轻量,适合想自己写算法的人。零基础建议先装SparkFun的库,因为示例多、社区讨论多、排错容易。装好之后打开"文件 -> 示例 -> SparkFun MAX3010x Pulse and Proximity Sensor Library -> Example5_HeartRate",先编译烧录一次,看串口能不能输出BPM。如果这个Demo能跑通,说明环境和接线都没问题,后面改自己的逻辑会轻松很多。

2.3 接线图和供电检查

MAX30102模块引脚一般有VIN、GND、SCL、SDA,有的带INT中断输出引脚。与ESP32开发板的接线非常固定:

MAX30102模块ESP32开发板
VIN3.3V
GNDGND
SCLGPIO22
SDAGPIO21

SCL和SDA用的GPIO21和GPIO22,这是ESP32默认的I2C引脚。如果你手里的板子I2C引脚被其他外设占用,可以用Wire.begin(sda, scl)在代码里改成其他引脚,比如改成GPIO18和GPIO19。

供电是个容易出问题的环节。很多ESP32开发板的3.3V来自板载稳压器,输出电流有限。MAX30102的LED是脉冲方式点亮的,瞬时电流不小,如果供电线又细又长,LED点亮的瞬间会把电压拉低,导致I2C通信不稳定甚至模块复位。我的经验是:用尽量短的杜邦线,最好在模块的VIN和GND之间并联一个10uF电容。如果你的模块板载了稳压芯片,标称可以接5V,那也要看清楚丝印,不要盲目接5V烧掉后级电路。

2.4 上电先做I2C扫描

接好线之后不要急着跑心率Demo,先用一个I2C扫描程序确认模块有没有被系统认出来。MAX30102在I2C总线上的地址固定是0x57。扫描结果如果不是0x57,或者干脆扫不到设备,多半是接线问题、供电不稳定,或者模块本身有问题。

#include <Wire.h> void setup() { Serial.begin(115200); Wire.begin(21, 22); Serial.println("Scanning..."); for (byte address = 1; address < 127; address++) { Wire.beginTransmission(address); if (Wire.endTransmission() == 0) { Serial.print("Found I2C device at 0x"); Serial.println(address, HEX); } } } void loop() {}

扫描到0x57说明I2C链路正常。如果扫不到,先查接线,再查供电,最后换模块。我遇到过几次"模块是坏的"的判断后来发现其实是杜邦线接触不良,所以排查顺序千万别反。

3. 信号处理链路:为什么裸数据不能直接算心率

3.1 原始IR信号里都有什么

现在你已经能从串口打印IR值了,会看到几百到几千之间波动的数字。这个数字可以拆成两部分:直流分量(DC)和交流分量(AC)。

DC分量是皮肤、骨骼、静脉血等静态部分吸收光之后剩下的反射光强,数值大、变化缓慢,通常占信号绝大部分。AC分量才是动脉血搏动引起的微小波动,往往只有DC的1%到5%。心率信息全部藏在AC分量里。

可以这样理解:DC分量像房间里的背景灯光,AC分量像有人在灯光前晃动手掌造成的阴影变化。你想检测的是那个"阴影"的节奏,但背景光变化、人走动、身体晃动都会干扰这个阴影的判读。如果直接用原始IR值做峰值检测,不是不行,但阈值很难设。手指按压力度稍微一变,整个DC分量漂移了,峰值检测就全乱了。

3.2 滤波和去基线漂移

最简单有效的办法是"滑动平均 + 做差"。先算一个窗口大约1秒的滑动平均值,这个平均值近似于DC分量,然后用原始IR值减去这个平均值,剩下的就是AC分量的近似值。

更讲究的做法是设计一个带宽0.5Hz到4Hz的带通滤波器,因为人体心率范围大约对应30到240次/分钟,换算成频率就是0.5Hz到4Hz。滤波后波形干净很多,峰值更容易检测。

我在实际项目里更常用一种轻量级的"自适应基线"思路:用高通滤波器的形式实时更新基线,例如每次读到新IR值后执行:

baseline = baseline * 0.95 + ir * 0.05; ac = ir - baseline;

这样不需要维护数组,内存占用少,ESP32跑起来没有压力。系数0.95到0.98都可以试,基线跟随得越慢,AC分量越接近真实波动,但对低频漂移的抑制会弱一些。0.95差不多是1秒左右的时间常数,对心率检测来说够用。

3.3 心率算法的最简实现:峰值间隔法

心率算法有很多种,最常用的是峰值间隔法。思路很直接:检测出AC波形里的每一个脉冲峰值,记录相邻两个峰值的时间间隔,然后BPM = 60000 / 间隔毫秒数。

峰值检测不能只看"当前值比上一次大",因为波形上会带毛刺。比较稳的做法是设两个条件:当前值超过一个动态阈值,比如当前波峰幅度的60%;距离上一个峰值至少间隔300毫秒,对应最高200次/分钟的心率。两个条件同时满足,才认为检测到一个合格的心跳峰。SparkFun库的heartRate.h里checkForBeat()用的就是类似逻辑,会根据IR值的动态范围自动调整灵敏度。

需要提醒的是,峰值间隔法对单个误检很敏感:一个毛刺被当成心跳,BPM会瞬间飙高,所以通常要加一个平均窗口。代码里常见的做法是维护一个长度为4的滑动数组,存最近4次心跳间隔算出的BPM再取平均,能有效平滑偶发抖动。这个方法简单但有效,跑通之后你可以再研究更复杂的算法,比如自相关法、傅里叶变换法等,但零基础阶段先把峰值间隔法吃透就够用。

3.4 采样节奏和FIFO的坑

MAX30102内部自带ADC和FIFO,外部单片机不需要自己控制采样时刻,只需要定时把FIFO里的数据取出来。setup()里配置的sampleRate,比如100Hz,就是内部ADC的采样频率。

坑在于:如果主循环里delay(20),循环频率约50Hz,而FIFO以100Hz的速度积累数据,你每次读取拿到的时间间隔是不均匀的。对心率检测这种需要精确峰值间隔的场景,时间抖动会直接影响BPM精度。我建议主循环尽量不delay,或者最多降到5ms以内。如果要刷OLED,也改成每100ms刷新一次,而不是每读一个样本就刷一次。

4. 完整代码:从初始化到串口和OLED显示

4.1 初始化参数怎么设置

先用一个具体例子说明setup()里几个参数怎么看。SparkFun库的particleSensor.setup()有6个参数:

particleSensor.setup(60, 4, 3, 100, 411, 16384);
  • 60:LED亮度,范围0到255,亮度越大信号越强,但太大会让接收信号饱和
  • 4:采样平均次数,内部对4次ADC结果取平均
  • 3:LED模式,3表示红光+红外光都开
  • 100:采样率100Hz,心率检测够用
  • 411:LED脉冲宽度411微秒,脉宽越大信噪比越高
  • 16384:ADC满量程范围,范围越大分辨率越高,但也要小心信号太弱时量化噪声明显

对零基础的同学,这几个参数直接用我给出的值就行,实测信号质量不错。如果你把LED亮度调到60之后IR值依然经常触顶(数值接近16384或2047),说明手指放的区域或者模块增益太强,可以适当降到40左右。

4.2 数据采集和心率计算

主循环里做的事情很简单:不断调用getIR()读红外光数据,交给checkForBeat()判断是否出现心跳峰。如果出现,就根据两次心跳的时间差算出瞬时BPM,存入环形数组,每存满4个值取一次平均。

这里有个细节:对瞬时BPM做一次合法性判断,只保留30到220之间的数值。这个范围过滤掉了大部分误检,比如手指短暂挪动产生的假峰值。有人会把范围调到30到200更保守一点,都是可以的,主要看你想适应的人群。

如果超过2秒没有检测到新的心跳峰,说明信号可能丢失了,应该在显示上归零或显示"--",否则屏幕上会停着一个老的BPM数值,让人误以为还在正常测量。这种小细节在实际体验里很重要。

4.3 用OLED显示心率

OLED我选的是0.91英寸128x32或者0.96英寸128x64,都能用。驱动库用Adafruit SSD1306,在库管理器里安装"Adafruit SSD1306"和"Adafruit GFX Library"两个库。

OLED和MAX30102都挂在I2C总线上,地址通常分别是0x3C和0x57,不冲突。在代码里先初始化MAX30102再初始化OLED,如果OLED初始化失败,程序继续跑,不影响心率采集,只是显示不了。串口依然能输出数据,这对调试很有用。

显示刷新频率建议100ms一次,太频繁反而让OLED驱动占用太多I2C总线时间,可能干扰MAX30102的数据读取。

4.4 完整代码

下面是一份可以直接复制到Arduino IDE的完整代码,集成了心率采集、串口输出和OLED显示。如果你没有OLED,把开头几行和loop里显示相关部分注释掉即可。

#include <Wire.h> #include "MAX30105.h" #include "heartRate.h" #include <Adafruit_SSD1306.h> #include <Adafruit_GFX.h> #define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 32 #define OLED_RESET -1 Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET); MAX30105 particleSensor; const byte RATE_SIZE = 4; byte rates[RATE_SIZE]; byte rateSpot = 0; long lastBeat = 0; float beatsPerMinute = 0; int beatAvg = 0; unsigned long lastDisplayUpdate = 0; void setup() { Serial.begin(115200); Wire.begin(21, 22); if (!particleSensor.begin(Wire, I2C_SPEED_FAST)) { Serial.println("MAX30102 not found, check wiring"); while (1); } particleSensor.setup(60, 4, 3, 100, 411, 16384); if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { Serial.println("OLED init failed"); } display.clearDisplay(); display.setTextSize(2); display.setTextColor(SSD1306_WHITE); display.setCursor(0, 0); display.println("BPM: --"); display.display(); } void loop() { long irValue = particleSensor.getIR(); if (checkForBeat(irValue)) { long delta = millis() - lastBeat; lastBeat = millis(); beatsPerMinute = 60.0 / (delta / 1000.0); if (beatsPerMinute > 30 && beatsPerMinute < 220) { rates[rateSpot++] = (byte)beatsPerMinute; rateSpot %= RATE_SIZE; beatAvg = 0; for (byte x = 0; x < RATE_SIZE; x++) { beatAvg += rates[x]; } beatAvg /= RATE_SIZE; } } if (millis() - lastDisplayUpdate > 100) { lastDisplayUpdate = millis(); Serial.print("IR="); Serial.print(irValue); Serial.print(", BPM="); Serial.print(beatsPerMinute); Serial.print(", Avg="); Serial.println(beatAvg); display.clearDisplay(); display.setCursor(0, 0); display.print("BPM: "); if (beatAvg > 0) { display.println(beatAvg); } else { display.println("--"); } display.display(); } }

烧录板子之前记得在"工具"里选对开发板型号和串口端口。如果烧录时报"Connecting... Failed",按住开发板上的BOOT键再点烧录,这是ESP32最常见的烧录姿势,不算问题。

5. 实测排错:为什么你测出来的是乱跳的数字

5.1 佩戴方式决定信号质量

代码跑起来之后,很多人第一次看到串口输出的BPM就开始头疼,数字在50到180之间乱跳。别急,先检查自己的佩戴方式。

传感器的窗口要完全贴合手指指腹,通常用食指或中指。按压力度不能太重,轻轻搭上去就行,压太紧会把毛细血管压扁,反而没有搏动信号。手指也不能晃动,一点微小的抖动都会在光信号里被放大。更妙的是,你越试图保持不动,手越容易抖,这是肌肉紧张造成的。我的经验是靠坐在椅子上把手臂搁在桌面或腿上,手腕放松,几次呼吸之后信号会慢慢稳定下来。

还有一个容易被忽略的点:环境光。MAX30102的抗环境光能力比老型号好一些,但强光直射依然会干扰。在一个明亮窗户旁边测量效果就明显变差,把传感器稍微遮一下会好很多。

5.2 用串口绘图器看波形

Arduino IDE自带的串口绘图器是个调试神器。把串口监视器关掉,打开"工具 -> 串口绘图器",如果代码里打印的是"IR=12345"这种格式,显示效果不好。最好是单独打印纯数值,比如:

Serial.println(irValue);

这样绘图器会画出一条连续的曲线。把手指放上去,你应该能看出一条随着心跳起伏的波形,每次心跳对应一个明显的凸起。如果波形是一条直线,说明信号太弱或者传感器窗口没有对准;如果波形上下乱跳没有规律,说明身体晃动或者环境光干扰太严重;如果波形顶到天花板,就是信号饱和了,调低LED亮度。

看波形比看BPM数字更能训练你对信号质量的感觉。BPM是一个算法处理后的结果,中间任何一环出问题都会让它失真,而原始波形是最接近真相的。我调试时永远先看波形,再看数字。

5.3 常见问题排查表

把过去几年被问到最多的问题整理成一张排查表,按优先级排序:

现象可能原因解决办法
串口无输出板子没选对、串口端口不对检查开发板型号和端口,按住BOOT烧录
I2C扫描不到0x57接线错误、供电不足、模块损坏先查接线,换短杜邦线,并联10uF电容
IR数值恒定不变手指没贴合、LED亮度太低重新放置手指,调高LED亮度到100试试
IR数值触顶信号饱和降低LED亮度到30以下
BPM乱跳手指抖动、环境光干扰、信号太弱手放稳,遮挡环境光,观察波形
屏幕显示一直--心率算法没检测到峰看串口绘图器波形,确认峰是否明显
刚上电BPM很高滤波和平均窗口还没稳定等5秒左右再读数,别一放上去就看数字

这是我自己的血泪总结。顺便说一句,ESP32开发板便宜,但MAX30102模块市场上有各种质量层次,如果你把该检查的都检查了一遍还是不行,换一块模块往往比继续折腾更省时间。

5.4 还能怎么进阶

这个项目跑通之后你是不会想停在这里的,几个值得继续深入的方向:

把数据通过WiFi上传到手机App或物联网平台,做远程心率监测,这是ESP32最擅长的活之一。给传感器加一个手指夹或腕带式的固定结构,从桌面实验变成可穿戴原型。把血氧算法加上,MAX30102的双波长硬件就物尽其用了。数据积累后做HRV心率变异性分析,这个就比较专业了,但很有研究价值。还可以把平均窗口算法换成卡尔曼滤波或小波去噪,让数值更平滑,同时处理运动伪迹。

最后分享一个我调试这类传感器的小习惯:不要一上电就把手指放上去,先让模块空跑几秒,让FIFO和滤波状态稳定下来,再把手放上去,等大约5秒让波形进入稳定状态,这时候看到的心率值才有参考意义。这个习惯帮我省掉了很多"误报"时间,也希望你的MAX30102第一次出数就是准的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询