news 2026/8/31 7:11:18

STM32+MAX30102心率血氧监测实战:从寄存器配置到OLED显示

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32+MAX30102心率血氧监测实战:从寄存器配置到OLED显示

简介:本资源是一套完整的STM32嵌入式健康监测项目源码,面向嵌入式初学者与课程设计实践者,解决心率血氧实时采集、本地OLED可视化显示及串口数据上位机同步传输的核心开发需求。项目基于STM32F1系列单片机(HAL库开发),集成MAX30102传感器I²C驱动、SSD1306 OLED显示控制、UART串口通信模块,并包含完整时序管理与数据解析逻辑,适用于智能手环、便携式体征监测等教学与原型开发场景。压缩包共249个文件,含39个C源文件(如OLED.c、stm32f10x_i2c.c)、39个头文件(.h)、50个编译中间文件(.o)、48个依赖描述(.crf)及Keil工程配置(.uvprojx/.uvoptx)、调试脚本(.bat)等,结构清晰,便于理解编译流程与模块划分。资源包大小为6.91MB,已有2497人学习下载,提供可直接编译运行的工程框架、关键外设初始化范例、传感器原始数据到生理参数的转换逻辑及串口协议封装,显著降低I²C通信调试与多任务协同实现门槛。 从收到 MAX30102 模块到把心率、血氧数值稳定地显示在 OLED 上并发送到串口调试助手,我大概折腾了两个通宵。第一晚全耗在“为什么读出来的数据全是 0”和“为什么数值在 60 到 180 之间疯狂跳”这两个问题上。第二晚把原始波形从串口拉出来看之后才明白,问题不在传感器,而在我的寄存器配置和数据处理方式上。这篇东西就是把这两晚的完整过程记录下来,包括硬件怎么接、寄存器怎么配、FIFO 数据怎么读、心率血氧算法怎么在单片机上落地,以及最终如何把数据通过串口发到上位机。项目本身不复杂,但里面有几个坑确实会让新手卡很久,适合正在做同类项目的同学参考。

1. 硬件选型和接线:为什么这套组合是入门心率血氧的最稳选项

1.1 四颗料的选择逻辑

先聊一下这套系统里每一颗芯片/模块的选择理由,因为很多同学不是不会写代码,而是被硬件选型坑了。

主控是 STM32F103C8T6,也就是大家常说的“蓝丸”核心板。它便宜、资料多、引脚够用,而且 3.3V 供电和 MAX30102 模块完全兼容。I2C、USART、OLED 驱动这些外设全部由硬件支持,不需要额外模拟,开发门槛低。如果你手头有 STM32F407、G031 或者其他系列,也能照搬,只要注意引脚复用和时钟配置的不同就行。

MAX30102 这颗传感器是 Maxim 推出的集成式脉搏血氧传感器,内部集成了红光 LED(660nm)、红外 LED(880nm)、光电检测二极管、ADC、环境光抑制、LED 驱动和数字接口。它最大的优势就是把模拟前端全部集成在模块内部,外部只需要接 I2C 两根线就能拿到数字化的光电容积脉搏波(PPG)数据,不需要自己搭跨阻放大器、滤波器和 ADC 采样电路。和上一代 MAX30100 相比,MAX30102 的功耗更低、信噪比更好,而且红光和红外光的通道一致性更好,这也是我推荐用 02 而不是 00 的原因。

OLED 用的是 0.96 寸 I2C 接口版本,主控芯片是 SSD1306,分辨率 128x64。选 I2C 版本而不是 SPI 版本,原因是省引脚——整块屏幕只需要 SCL、SDA、VCC、GND 四根线,和 MAX30102 共用一条 I2C 总线即可,连线极简。

串口部分用板载 CH340 的 USB 转 TTL 模块,或者直接用 STM32 核心板上的 USB 转串口芯片都可以。我用的是独立的 CH340 模块,原因是调试时不想和烧录口抢资源,而且独立模块可以随时换到其他板子上用。

1.2 接线表与电平匹配细节

整套系统的接线非常简单,我直接列个表:

STM32F103C8T6MAX30102 模块0.96 寸 OLEDCH340 串口模块
3.3VVINVCCVCC(可选)
GNDGNDGNDGND
PB6(I2C1_SCL)SCLSCL-
PB7(I2C1_SDA)SDASDA-
PA9(USART1_TX)--RXD
PA10(USART1_RX)--TXD

有几个细节要特别注意:

第一,所有模块必须统一由 3.3V 供电。MAX30102 的 VIN 虽然标称可以到 5V,但模块上的电平转换电路通常只是把 5V 降下来给内部用,I2C 引脚的电平还是 3.3V 逻辑。如果你用 5V 给传感器供电,同时又用 3.3V 的 STM32 去拉 I2C,虽然大多数时候能工作,但在极端情况下可能会因为电平不匹配导致通信异常。OLED 模块也是同样道理,虽然 SSD1306 支持 3.3V~5V,但 I2C 引脚的电平逻辑建议保持和 MCU 一致。

第二,I2C 上拉电阻的问题。MAX30102 模块和 OLED 模块上通常都已经焊好了 4.7kΩ 或 10kΩ 的上拉电阻到 VCC。如果你用的是淘宝上最常见的成品模块,那基本不用额外接上拉。但如果你用的是 MAX30102 裸片或者自己画的板子,那必须在 SCL 和 SDA 上各接一个 4.7kΩ 上拉电阻到 3.3V,否则 I2C 通信会时好时坏,逻辑分析仪上看起来波形就像一团毛线。

第三,共地是必须的。CH340 模块和 STM32 之间一定要共地,否则串口数据会出现乱码或者完全收不到。这个坑我踩过一次,当时把两个模块分别用两个 USB 口供电,结果 TX 线上量出来有 1V 左右的压差,串口助手收到的全是乱码。

1.3 为什么不用 MAX30100 或者 PulseSensor

很多教程还在用 MAX30100,这颗芯片有点老,I2C 地址和 MAX30102 不同(0x57 vs 0xAE),而且内部 FIFO 只有 16 个样本,红光和红外光的串扰也比较明显。相比之下 MAX30102 的 FIFO 深度同样是 16 个样本,但 ADC 分辨率更高(18 位),动态范围更好,对于手指放置位置的宽容度也更高。

PulseSensor 是另一类方案,它输出模拟信号,需要 MCU 自带的 ADC 去采样。好处是算法可以自己在时域上随便玩,坏处是电路设计和信号调理全靠自己,没有数字滤波,环境光干扰非常严重。对于“拿到就能跑、跑起来还能看”的入门项目,MAX30102 的集成度优势太大了。

2. MAX30102 驱动核心:寄存器配置与 FIFO 数据读取的完整链路

2.1 初始化时序:厂家推荐的寄存器配置顺序

MAX30102 的寄存器配置是整个项目里最容易出错的地方,但也是最套路化的地方。它不像一些 MCU 外设那样需要复杂的时钟树配置,只要按照数据手册的推荐顺序写寄存器即可。

我整理了一份标准的初始化函数流程,代码是 HAL 库风格的:

void MAX30102_Init(void) { // 1. 复位传感器 MAX30102_WriteReg(REG_MODE_CONFIG, 0x40); // 0x40 写入 RESET 位 HAL_Delay(100); // 2. 配置 FIFO MAX30102_WriteReg(REG_FIFO_CONFIG, 0x4F); // 采样平均 4 次,FIFO 满时不再写入 MAX30102_WriteReg(REG_FIFO_WR_PTR, 0x00); MAX30102_WriteReg(REG_OVF_COUNTER, 0x00); MAX30102_WriteReg(REG_FIFO_RD_PTR, 0x00); // 3. 配置 SpO2 模式 MAX30102_WriteReg(REG_SPO2_CONFIG, 0x47); // ADC 范围 4096nA,采样率 400Hz,LED 脉宽 411us // 4. 配置 LED 电流 MAX30102_WriteReg(REG_LED1_PA, 0x24); // 红光 LED 电流约 6.4mA MAX30102_WriteReg(REG_LED2_PA, 0x24); // 红外 LED 电流约 6.4mA // 5. 使能中断(可选,轮询时可不用) MAX30102_WriteReg(REG_INTR_ENABLE_1, 0xC0); // 使能 FIFO 几乎满中断 MAX30102_WriteReg(REG_INTR_ENABLE_2, 0x00); // 6. 设置为心率和血氧模式(连续采样) MAX30102_WriteReg(REG_MODE_CONFIG, 0x03); // SpO2 模式,红红外同时开 }

这里要解释几个关键寄存器为什么这么配:

REG_MODE_CONFIG(0x09):复位后芯片默认处于关机模式(SHDN=1),所以第一步要么先清掉 SHDN 位,要么直接写 0x40 复位再重新配置。数据手册要求复位后等待至少 100ms 再操作寄存器,否则可能不稳定。最后配置成 0x03,即 SpO2 模式,这个模式下红光和红外 LED 会交替点亮,ADC 交替采样。

REG_FIFO_CONFIG(0x08):高三位设置多次采样平均值(SAMPLE_AVG),0x4F 表示 4 次平均(即 000 是 1 次,001 是 2 次,010 是 4 次,以此类推)。4 次平均可以在几乎不损失实时性的情况下有效降低噪声。低两位的 FIFO_ROLLOVER_EN 设为 1,表示 FIFO 满时新数据覆盖旧数据,这样轮询读取时不容易漏掉最新数据。

REG_SPO2_CONFIG(0x0A):bit[5:0] 分别是 ADC_RGE[1:0] 和 LED_PW[1:0] 以及 SPO2_SR[2:0]。0x47 拆开来看:ADC_RGE=01(±4096nA),LED_PW=11(411μs),SPO2_SR=100(采样率 400Hz)。实际上,我把采样率降到 100Hz 就够用了,频率太高只会增加数据处理压力,心率信号本身是 0.5~4Hz 的频带。

LED 电流寄存器(0x0C 和 0x0D):这是最大的坑之一。电流太小,信号幅度低,信噪比差;电流太大,容易饱和,ADC 输出满量程导致波形削顶。0x24 是 36mA 满量程的 25% 左右,如果手指放上去之后原始波形幅度太小(峰值小于 ADC 满量程的 20%),可以适当增大到 0x40~0x60。但注意不要一上来就调到最大,否则红光通道很容易饱和。

2.2 FIFO 数据读取:三字节拼一个 18 位样本

MAX30102 内部有一个 16 样本深度的 FIFO,每个样本包含红光和红外两个通道的数据,每个通道 18 位。读 FIFO 时,从寄存器 REG_FIFO_DATA(0x11)连续读取 6 个字节,前三个字节是红光通道,后三个字节是红外通道。

关键点:不是所有 24 位都有效,每个通道的有效数据只有高 18 位。也就是说,读取三个字节后,需要把第一个字节左移 16 位,第二个字节左移 8 位,第三个字节放在最低位,然后整体右移 6 位,才能得到真正的 18 位无符号整数。

#define MAX30102_READ_BUF_SIZE 6 void MAX30102_ReadFIFO(uint32_t *red, uint32_t *ir) { uint8_t buf[MAX30102_READ_BUF_SIZE]; MAX30102_ReadRegs(REG_FIFO_DATA, buf, 6); *red = ((uint32_t)(buf[0] & 0x03) << 16) | ((uint32_t)buf[1] << 8) | buf[2]; *ir = ((uint32_t)(buf[3] & 0x03) << 16) | ((uint32_t)buf[4] << 8) | buf[5]; }

这里的 &0x03 是因为 18 位有效数据的高 2 位占用第一个字节的 bit[1:0],bit[7:2] 属于上一通道的溢出位或保留位。如果不做掩码处理,读到的高 6 位可能是无效的填充数据,导致数值偏大。

另一种更安全的方式是直接把 24 位提取出来再右移 6 位:

uint32_t raw = ((uint32_t)buf[0] << 16) | ((uint32_t)buf[1] << 8) | buf[2]; uint32_t sample = raw >> 6;

两种方式结果一样,但第二种不容易出错。

2.3 读 FIFO 的时机:轮询还是中断

MAX30102 支持两种方式判断 FIFO 里有没有新数据:轮询读取 FIFO 数据计数寄存器 REG_FIFO_DATA_COUNT(0x06),或者使用中断引脚。

我用的是轮询方式,在主循环里先读 REG_INTR_STATUS_1(0x00)检查 A_FULL 位,再读取数据。原因很简单:MAX30102 的数据产生速率是固定的(比如 100Hz),单片机的空余时间足够多,轮询完全不会阻塞其他任务。而且省掉中断引脚和中断服务程序,逻辑更清晰。

不过有一个细节:FIFO 的数据计数寄存器返回的是 FIFO 中未读样本的数量,不是当前写入位置。如果你很久没读 FIFO,计数会累计到 16 或 32(取决于配置),这时候要一鼓作气把 FIFO 里的数据全部读空,避免下次读到旧数据。所以我的读取逻辑是:

uint8_t num_avail = MAX30102_ReadReg(REG_FIFO_DATA_COUNT); if (num_avail > 0) { for (uint8_t i = 0; i < num_avail; i++) { MAX30102_ReadFIFO(&red, &ir); // 处理数据 } }

注意 REG_FIFO_DATA_COUNT 寄存器在数据手册里是 0x06,但某些模块的数据手册打印错误,写成了 0x05。以 Maxim 官方数据手册为准,0x06 才对。

2.4 模拟 I2C 和硬件 I2C 的选择

STM32F103 的硬件 I2C 外设风评很差,主要因为 bug 多、时序要求严格、出错后容易卡死。很多人建议直接用 GPIO 模拟 I2C,我这边的做法是:调试阶段用模拟 I2C,稳定运行后可以切回硬件 I2C

模拟 I2C 的好处是可控性极强,什么时候拉高拉低完全由代码决定,即使 SCL 频率不太准确也能稳定工作,而且出错后不会像硬件 I2C 那样进入 Busy 状态需要复位外设。缺点是占用 CPU 时间,不过对于 100kHz 的 I2C 时钟来说,开销完全可以忽略。

如果你坚持用硬件 I2C,有几个必须注意的点:

  • 使能 I2C 外设后,要等待 I2C_ISR 的 BUSY 位清零再开始通信。
  • 发送 START 条件后,要确认 SB 位置位;发送地址后,要确认 ADDR 置位。
  • 在读取最后一个字节之前,必须关闭 ACK,否则会多发一个 ACK 导致传感器误判通信结束。
  • 出错后不要只是清错误标志,要调 __HAL_I2C_ENABLE() 禁用再使能外设,彻底复位状态机。

我在项目代码里默认用的是软件模拟 I2C,文件只有 100 多行,稳健性比硬件 I2C 好太多。

3. 从原始光电容积波到心率血氧:算法的工程化落地

3.1 看懂 PPG 波形:原始数据长什么样

把 MAX30102 的原始数据通过串口发到电脑上,用串口调试助手的波形显示功能或者导入到 Excel 里画出来,你会看到两条交替变化的曲线,这就是光电容积脉搏波(PPG)。红光通道(Red)和红外通道(IR)的形状非常相似,但幅度不同。

PPG 波形有两个关键特征:

  • 直流分量(DC):数值相对稳定的基底部分,主要反映了皮肤、骨骼、肌肉组织对光的恒定吸收,以及静脉血的吸收。DC 值通常占据原始信号的 80%~90% 以上。
  • 交流分量(AC):叠加在 DC 之上、随心跳周期性波动的分量,主要来自动脉血随心脏搏动而产生的血管容积变化。AC 分量的幅度通常只有 DC 的 1%~5%。

心率的检测就是基于 AC 分量的周期性峰值。血氧饱和度则是基于红光和红外两路 AC/DC 比值的对比。

3.2 心率检测:差分阈值法最省事

在单片机上跑心率检测,不需要上什么深度学习或者小波变换,最实用的方法还是差分阈值法。核心思路是:找到 AC 信号的峰值,计算相邻两个峰值之间的时间间隔(即 RR 间隔),然后换算成每分钟心跳次数。

我实现的步骤是这样的:

先做一阶差分,检测波形的上升沿和下降沿:

int32_t diff = sample - prev_sample;

然后设定一个自适应阈值。阈值不能是固定的,因为不同人的手指透光性、肤色、血管粗细都不同,AC 幅度差异很大。我用的是“滑动最大值的一定比例”作为阈值:

static int32_t max_val = 0; static int32_t min_val = 0; if (sample > max_val) max_val = sample; if (sample < min_val) min_val = sample; int32_t threshold = min_val + (max_val - min_val) * 3 / 5;

当一个样本从低于阈值变成高于阈值,并且一段时间内没有触发过新的峰值,就认为检测到了一个心跳峰。

关键点:必须加一个“最短间隔”限制。如果检测到两个峰值之间的间隔小于 300ms(对应 200bpm),说明是噪声误触发,直接丢弃。这个 300ms 的窗口就是不应期,和心电检测里的算法思路一样。

计算心率的方式有两种:一是用最近 N 个 RR 间隔的平均值换算;二是用滑动窗口内峰值个数的倒数乘 60。我实测下来,平均值法更平滑,实时性略差;计数法响应快,但会出现短时抖动。我的做法是:记录最近 5 个 RR 间隔,取中位数,然后用 60000 / median_interval_ms 得到瞬时心率。

3.3 血氧饱和度:R 值查表还是公式拟合

血氧饱和度 SpO2 的计算比心率稍微复杂一点,但工程上仍然有非常成熟的简化方案。核心是计算调制比 R:

R = (AC_red / DC_red) / (AC_ir / DC_ir)

然后通过一个经验公式或者查找表把 R 值映射到 SpO2 百分比。MAX 官方提供了一个参考公式:

SpO2 = 110 - 25 × R

这个公式在 R 值处于 0.4~3.0 的范围内足够用。但如果你想要更好的精度,可以在 PC 上用标准血氧仪采集一批数据,拟合出一条二次或三次曲线。我在代码里用的是查表法:

const uint8_t spo2_lookup[] = { 100, 99, 98, 97, 96, 95, 94, 93, 92, 91, 90, 89, 88, 86, 84, 82, 80, 78, 76, 74, 72, 70, 68, 66, 64, 62, 60, 58, 56, 54 }; uint8_t SpO2_Calc(uint16_t ac_red, uint16_t dc_red, uint16_t ac_ir, uint16_t dc_ir) { float r = ((float)ac_red / dc_red) / ((float)ac_ir / dc_ir); uint8_t idx = (uint8_t)((r - 0.4f) / 0.1f); if (idx >= sizeof(spo2_lookup)) idx = sizeof(spo2_lookup) - 1; return spo2_lookup[idx]; }

查表法的优势是逻辑简单,没有浮点运算的误差积累,也方便在标准设备上校正。缺点是表的精度有限,但这对于非医疗级别的参考设备来说完全够用。

3.4 别忘了数字滤波

原始 PPG 信号里不仅有脉搏波,还有环境光噪声、运动伪影、50Hz/60Hz 工频干扰。这些噪声在时域里表现得很明显,比如波形毛刺多、基线漂移、峰值检测异常。

我在代码里加了两层滤波:

第一层是滑动平均滤波,对原始 ADC 数据做 5 点平均,可以有效去掉高频毛刺。

第二层是移动平均基线消除。由于呼吸引起的基线漂移很缓慢,会严重影响阈值判断。我维护一个 100 点的移动平均窗口,估计 DC 基线,然后把 raw - baseline 作为 AC 信号:

static int32_t baseline = 0; static int32_t sum = 0; static int32_t buf[100] = {0}; static uint8_t idx = 0; sum -= buf[idx]; buf[idx] = sample; sum += sample; idx = (idx + 1) % 100; baseline = sum / 100; int32_t ac = sample - baseline;

这个 100 点窗口在采样率 100Hz 下对应 1 秒的时间常数,既能跟踪基线的缓慢漂移,又不会把心跳的 AC 分量“吃”掉。

4. OLED 显示与串口输出的双通道实现

4.1 OLED 显示内容的组织与刷新策略

0.96 寸 OLED 的显示区域是 128x64 像素,SSD1306 驱动,显存是 1KB。在单片机上驱动 OLED 不需要自己画像素,只需要把想要显示的内容填充到显存里,然后调用刷新函数把显存发到屏幕上。

我设计的 UI 很简单:

  • 第一行显示心率和血氧数值,格式如HR: 72 bpmSpO2: 98%
  • 第二行画一个迷你滚动波形,显示最近一次 PPG 波形的趋势线。
  • 第三行显示传感器状态,比如 “Finger OK” 或 “No Finger”。

关于刷新策略有个重要的教训:OLED 的 I2C 刷新速度很慢,不要每收到一个样本就刷新一次屏幕。SSD1306 在 I2C 400kHz 模式下传输 1KB 显存大约需要 20ms,如果你以 100Hz 的频率刷新,OLED 会占用 100% 的 I2C 带宽,导致 MAX30102 数据读取被无限阻塞。

正确做法是设置一个 10Hz~20Hz 的软件定时器,也就是每 50ms~100ms 刷新一次屏幕。心率数值本身是平滑过的,20Hz 的刷新率人眼看起来已经足够流畅。

下面是精简的 OLED 显示函数:

void OLED_Display_Update(void) { char buf[16]; OLED_Clear(); sprintf(buf, "HR:%d bpm", heart_rate); OLED_ShowString(0, 0, buf, 12); sprintf(buf, "SpO2:%d%%", spo2); OLED_ShowString(0, 16, buf, 12); // 绘制微型波形 OLED_DrawWave(0, 32, ppg_waveform, WAVE_LEN); OLED_Refresh(); }

注意 sprintf 在嵌入式上会占 flash,而且浮点格式化很浪费资源,可以换成自己封装的整数转字符串函数。我这边的代码为了简洁用了 sprintf,实际项目里建议换掉。

4.2 串口协议格式:不是简单地 printf

串口调试助手能收到数据,但如果你直接printf("%d %d\n", red, ir),虽然也能看,但对于后续的 PC 端波形处理、数据记录、算法验证来说可读性就很差了。

我设计了一个简单的帧格式:

帧头(0xA5 0x5A) + 数据长度(1字节) + 数据类型(1字节) + 数据负载(N字节) + 校验(1字节)

数据类型定义成 0x01 代表原始波形数据,0x02 代表心率血氧结果。数据负载部分用大端序排列。

发送函数如下:

void UART_SendWaveform(uint16_t red, uint16_t ir) { uint8_t buf[8]; buf[0] = 0xA5; buf[1] = 0x5A; buf[2] = 4; // 数据长度 buf[3] = 0x01; // 数据类型:波形 buf[4] = (uint8_t)(red >> 8); buf[5] = (uint8_t)(red & 0xFF); buf[6] = (uint8_t)(ir >> 8); buf[7] = (uint8_t)(ir & 0xFF); HAL_UART_Transmit(&huart1, buf, 8, 10); }

使用串口调试助手时,打开 HEX 显示,就能看到完整的帧。如果想看波形,可以在 PC 端写一个小 Python 脚本读取串口数据,把红色和红外通道画出来,或者直接使用串口调试助手自带的波形显示功能。SSCOM、XCOM 这类工具都支持波形显示,选择通道格式时要对应上你发送的整数类型。

4.3 串口波特率的选择

MAX30102 的采样率我设定在 100Hz,每个样本需要发送 4 字节有效数据(红红外各 2 字节),加上帧头校验一共 8 字节,也就是每秒 800 字节,波特率 115200 完全够用。

如果你用 400Hz 采样率,每秒就是 3200 字节,115200 波特率(每秒约 11520 字节)依然没问题。但如果每个样本打包成 float 字符串(比如 “1234.5678,”),数据量翻倍,就需要更高的波特率或者降低采样率。

注意:把串口波特率调高到 921600 时,CH340 模块在长线传输下可能会出现误码,建议使用 115200,稳定性优先。

5. 实测中的坑与调优:从波形异常到数值稳定的经验记录

5.1 坑一:心率值疯狂翻倍

现象:明明心率 70,OLED 上却显示 140 或者 210。

排查过程:一开始以为是峰值检测的阈值问题,把阈值调高后依然翻倍。然后用串口把原始波形发到 PC 上,发现波形上有大量高频毛刺,波形看起来像锯齿一样。进一步检查发现,红光通道和红外通道之间存在串扰——两个通道的 LED 交替点亮时,光信号在手指内部发生了散射,导致一个通道的采样值受到了另一个通道 LED 的影响。

解决办法:把 REG_SPO2_CONFIG 里的 LED_PW 从 215μs 改成 411μs,让 ADC 在每个 LED 点亮后有更长的稳定时间;同时把 FIFO 的平均次数从 1 次改成 4 次。修改后波形明显变得平滑。

这个案例说明,调整算法参数之前,一定先看原始波形。波形是圆的还是尖的、有没有毛刺、有没有基线漂移,这些信息比任何算法调参都重要。

5.2 坑二:血氧值固定在 99%~100%

现象:不管怎么测,SpO2 一直是 99% 或 100%,有时候甚至 100%。

排查过程:用手指挡住红光 LED 再测,数值依然不变。这说明算法里算出来的 R 值非常小,接近 0.4 的查表边界。原因很可能是红光和红外的 DC 分量计算方式不对。

我当时犯的错误是直接用原始 ADC 值作为 DC,直接用相邻两个采样点的差值作为 AC,导致 AC 分量和 DC 分量不在同一个量纲上,计算出的 R 值严重偏小。正确的做法是:DC 应该是长时间平均值(至少包含数十个脉搏周期),AC 应该是在 DC 附近的峰峰值或 RMS 值。

修正后:

// 每 5 秒更新一次 DC dc_red = red_baseline; dc_ir = ir_baseline; // AC 取最近一个脉搏周期的峰峰值 ac_red = red_peak_to_peak; ac_ir = ir_peak_to_peak;

因为 AC 信号本身具备周期性,用一个完整的脉搏周期内最大值减去最小值作为 AC 幅度,比采样点差分要稳定得多。

5.3 坑三:手指放上去没反应

现象:初始化完成后,OLED 上一直显示 “No Finger”,但手指明明已经贴紧了。

这个问题的根源在于环境光干扰和 LED 电流太小。MAX30102 虽然内部有环境光消除,但如果你在强光环境下使用,比如阳光直射、白炽灯底下,环境光分量仍然可能超过内部消噪能力。

我的处理方式是:

  • 增加了一个简单的检测逻辑:当红光和红外通道的 DC 值变化量超过一定阈值时,判定为手指接触。这个阈值可以通过初始化后前几秒的基线数据自动标定。
uint8_t MAX30102_IsFingerPresent(uint32_t ir) { static uint32_t baseline = 0; static uint8_t init = 0; if (!init) { baseline = ir; init = 1; return 0; } if (ir > baseline + 5000) return 1; // 手指遮住后红外值显著增加 return 0; }
  • 适当增加 LED 电流。如果你发现手指放上去之后红外值只有几千(满量程是 262144),说明 LED 电流太小,需要把 LED 电流寄存器加到大一些。

5.4 数值稳定性调优的几条实操经验

在完成了基本功能之后,我做了几个优化,让数值稳定了很多:

第一,心率显示加滑动平均。在瞬时心率基础上,再做一次 10 点滑动平均。代价是心率变化响应变慢,但观感上非常舒服,不会出现数字来回跳 2~3 的情况。

第二,血氧显示加“有效区间”过滤。血氧值如果低于 70% 或者高于 100%,直接显示 “--”,不显示不可信的数据。因为如果 R 值计算出 80% 以下,大概率是信号质量差或者手指没放好,而不是真的血氧低。

第三,如果 MAX30102 的 FIFO 长时间没有数据,自动重新初始化。有时候 I2C 总线会因为干扰进入异常状态,导致传感器不再产生数据。我在主循环里加了一个超时计时器,如果 3 秒内没有新数据,就调用 MAX30102_Init() 重新初始化,并把错误计数通过串口打印出来。这个“自动复位”机制让系统在长时间运行时的可靠性大幅提升。

6. 后续可以怎么扩展

这套工程跑通之后,扩展的方向非常多。

如果你想做成可穿戴设备,可以把 0.96 寸 OLED 换成低功耗的墨水屏或者干脆去掉,用蓝牙模块(HC-08、JDY-31 等)把心率血氧数据发送到手机 App。串口协议这部分直接把帧格式转发给蓝牙模块就行,几乎不用改。

如果你想提高测量精度,可以在算法层面加入运动伪影消除。最常用的方法是用加速度计(MPU6050 等)检测运动状态,当运动幅度过大时暂停心率计算,而不是输出一个错误的数值。MAX30102 本身也支持 SpO2 和心率同时测量,但要排除运动干扰,只有传感器数据是不够的。

如果你想做低功耗,可以把 STM32 切换到睡眠模式,用 MAX30102 的中断引脚唤醒 MCU。MAX30102 本身的功耗很低(典型工作电流 600μA 左右),整机的功耗瓶颈主要在 MCU 和 OLED。

最后分享一个个人习惯:每次调试传感器项目,我都会先把原始波形通过串口拿到 PC 上看一眼,确认信号质量没问题,再开始写算法。跳过这一步,后面大概率会走弯路。这比任何技巧都重要。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/31 7:10:52

ClaudeCode安装及配置实操详解——AI入门必备

2026 年 AI 编程工具已从简单代码补全进化为全流程开发助手&#xff0c;呈现三大趋势&#xff1a;智能体化&#xff08;AI 能自主规划并执行复杂任务&#xff09;、全链路适配&#xff08;从需求分析到部署运维的全流程辅助&#xff09;、个性化定制&#xff08;适配个人编码习…

作者头像 李华
网站建设 2026/8/31 7:09:13

Vibe Coding零基础入门:用自然语言让AI帮你写代码

如果你是零基础选手&#xff0c;但一直想做一个属于自己的小软件&#xff0c;这篇文章会陪你完整跑通一次。所谓 Vibe Coding&#xff0c;并不是什么神秘技巧&#xff0c;它的核心玩法只有一个&#xff1a;你用自然语言描述需求&#xff0c;AI 帮你生成代码&#xff0c;你负责运…

作者头像 李华
网站建设 2026/8/31 7:07:31

酷家乐后端B卷复盘:从Java并发到系统设计的校招备考路线

拿到酷家乐2020校园招聘后端B卷的时候&#xff0c;我的第一反应是&#xff1a;这家公司是真的想通过一份试卷&#xff0c;把“会背八股的人”和“能在生产环境里扛事的人”分开。这份B卷流传出来的完整版本网络上有不少&#xff0c;但大部分帖子只贴了题目&#xff0c;没有讲透…

作者头像 李华
网站建设 2026/8/31 7:06:40

LangGraph实战指南:用状态图编排可控的Agent流程

LangGraph不是又一个模型调用库&#xff0c;它是把Agent应用流程画成状态图、再按图执行的编排框架。简单说&#xff0c;过去你用普通Python函数一步步把LLM、工具、文本处理串起来&#xff0c;现在你把每个处理步骤拆成“节点”&#xff0c;用“边”控制它们怎么流转&#xff…

作者头像 李华
网站建设 2026/8/31 7:06:02

Congestion Control System Optimization with Large Language Models

论文《Congestion Control System Optimization with Large Language Models》总结与翻译 一、文章主要内容总结 本文聚焦互联网基础设施中的拥塞控制算法优化问题,提出一种基于大型语言模型(LLMs)自动优化拥塞控制算法的新框架,核心内容如下: 研究背景与挑战:拥塞控制…

作者头像 李华
网站建设 2026/8/31 7:05:59

途虎养车秋招数据分析笔试题解析:业务驱动型考察要点

途虎养车2023秋招数据分析笔试试卷A&#xff0c;我拿到这份卷子是在去年秋招的时候。当时在牛客网上刷到有人分享&#xff0c;顺手存下来做了一套&#xff0c;说实话做完之后感触挺深的。它不是那种大厂通用算法题堆砌出来的卷子&#xff0c;而是明显的业务驱动型出题思路&…

作者头像 李华