2026/10/14 18:53:29

MAX30102心率血氧波形显示:STM32从I2C到滤波算法的完整链路

MAX30102心率血氧波形显示:STM32从I2C到滤波算法的完整链路 简介面向使用STM32单片机进行嵌入式开发的工程师与学习者这份心率血氧波形显示工程把MAX30102传感器采集到的心率、血氧相关数据在0.96英寸单色OLED屏幕上实时绘制成波形重点解决脉搏显示这一应用需求适合拥有一定单片机基础、正在学习RT-Thread实时操作系统或希望了解传感器驱动集成方法的开发者。工程代码基于RT-Thread任务调度模型组织覆盖I2C通信读取MAX30102原始数据、信号滤波处理、OLED刷新输出等关键环节并通过B站演示视频公开了实际运行效果便于边看边对照。资源包采用了RAR压缩格式整体体积约为65.5MB需要说明的是工程内没有随包附带RT-Thread完整源码使用者需要自行添加相应组件或者利用SCONS工具完成构建。目前已有3999人学习/下载对于想快速搭建心率显示原型、梳理传感器到屏幕完整数据链路或借鉴RT-Thread外设接入写法的开发者这套资源提供了可运行的显示工程、驱动衔接思路和构建注意事项能明显缩短从零移植与联调所花费的时间对课程设计、毕业设计或快速原型验证都是较直接的参考资料。1. MAX30102心率血氧波形显示为什么要拿原始数据自己拼一次很多人在某宝买来 MAX30102 模块满怀期待地接上 STM32读了两天寄存器最终得到的却是一条平直线或者心率数字毫无规律地乱跳。真正可用的“心率血氧波形显示”并不是插上模块就能出来的在传感器和你之间隔着 I2C 配置、FIFO 读取、数字滤波、峰值检测、血氧比例换算这几道坎。这个资源把这几层全部串成一条可以照抄的链路从 STM32 通过 I2C 驱动 MAX30102到把红光和红外的原始 ADC 值变成稳定的 PPG 波形再输出心率和血氧两个可读数字。适合正在做健康监测类项目、手里有传感器但还没跑通完整算法的人也适合想快速移植到其他单片机平台看效果的嵌入式开发者。2. 硬件连接与 I2C 通信让 MAX30102 在 STM32 上先输出稳定数据2.1 引脚连接与两个容易忽视的硬件前提MAX30102 的 I2C 从机地址是 0x57HAL 库在HAL_I2C_Mem_Read里传入的是 7 位地址左移 1 位后的 8 位地址。标准接法是 VDD 接 3.3VGND 共地SDA 和 SCL 分别接到 STM32 的 I2C 引脚。一个非常容易踩的硬件坑是上拉电阻。MAX30102 的 SDA 和 SCL 是开漏输出总线必须外接上拉电阻才能正常拉高。模块厂商一般会在小板上集成 4.7kΩ 上拉但如果买的是裸芯片或者自己画板忘了加电阻的结果就是总线电位一直起不来读回来的数据全是 0xFF看起来像传感器坏了。INT 引脚是另一个认识偏差。MAX30102 的 INT 引脚是中断输出很多人在意它是不是必须接 MCU 的外部中断。实际上如果你在主循环里用轮询方式定时读 FIFOINT 完全可以悬空。我在项目里通常把 INT 接到普通 GPIO配成下降沿触发只在调试时看事件频率平时读数据不依赖它。这样做是为了避免在中断服务函数里做 I2C 读操作I2C 读取的耗时不稳定放在中断里会拖慢整个系统的调度。供电方面需要区分 VDD 和 LED_VDD。LED_VDD 是内部 LED 驱动回路的供电输入脉冲导通时电流可以达到几十毫安如果直接和 MCU 共用同一路 3.3V LDO会造成电源轨纹波而这个纹波会叠加到 PPG 信号上波形上表现为周期性的尖刺。我一般会单独走一路 3.3V或者在模块附近放一颗 10μF 电容做瞬态储能。这样 ADC 采到的信号才尽量只含光学信息。2.2 初始化寄存器的顺序与参数选择上电后不要急着读数据先把一组关键寄存器配好。下面这段初始化代码来自我常用的 STM32 HAL 工程先读芯片 ID 确认 I2C 通路正常然后按模式、采样配置、LED 电流、FIFO、中断的先后顺序初始化。#define MAX30102_ADDR 0x57 uint8_t max30102_init(I2C_HandleTypeDef *hi2c) { uint8_t data 0; // 读 PART_ID固定值 0x15用于确认 I2C 通路和芯片供电 HAL_I2C_Mem_Read(hi2c, MAX30102_ADDR 1, 0xFF, I2C_MEMADD_SIZE_8BIT, data, 1, 100); if (data ! 0x15) return 1; // 0x09 模式配置0x03 表示红光和红外 LED 都使能SpO2 双波长模式 data 0x03; HAL_I2C_Mem_Write(hi2c, MAX30102_ADDR 1, 0x09, I2C_MEMADD_SIZE_8BIT, data, 1, 100); // 0x0A SpO2 配置bit61 使能ADC 量程 4096采样率 100HzLED 脉宽 411us data 0x49; HAL_I2C_Mem_Write(hi2c, MAX30102_ADDR 1, 0x0A, I2C_MEMADD_SIZE_8BIT, data, 1, 100); // 0x0C / 0x0D红光和红外 LED 电流0x33 对应约 7.1mA data 0x33; HAL_I2C_Mem_Write(hi2c, MAX30102_ADDR 1, 0x0C, I2C_MEMADD_SIZE_8BIT, data, 1, 100); HAL_I2C_Mem_Write(hi2c, MAX30102_ADDR 1, 0x0D, I2C_MEMADD_SIZE_8BIT, data, 1, 100); // 0x08 FIFO 配置1 次采样不平均FIFO 回滚使能剩余 1 个空间触发中断 data 0x11; HAL_I2C_Mem_Write(hi2c, MAX30102_ADDR 1, 0x08, I2C_MEMADD_SIZE_8BIT, data, 1, 100); // 0x01 清中断状态 data 0xFF; HAL_I2C_Mem_Write(hi2c, MAX30102_ADDR 1, 0x01, I2C_MEMADD_SIZE_8BIT, data, 1, 100); return 0; }几个关键参数说明一下。采样率 100Hz 是心率血氧项目里很稳的选择心跳能量主要集中在 0.5Hz 到 4Hz100Hz 既够还原波形也不会让 FIFO 每秒堆积大量数据。0x0A 寄存器里0x49的位拆解是bit6 使能 SpO2bit5:4 是 01对应 ADC 量程 4096nAbit2:0 是 001对应 100Hz。LED 电流 7.1mA 算中间值肤色深或环境光强时可以往上调到 0x66但电流越大发热越明显发热会引起传感器接触面温度变化导致 DC 分量缓慢漂移。FIFO 配置里的0x11高三位 SMP_AVE 是 000表示不做采样平均保证主循环拿到的每帧数据都来自同一时刻的光强变化。bit4 置 1 开启 FIFO 回滚溢出时自动丢旧数据避免读指针和写指针错位。bit1:0 是 01表示 FIFO 只剩 1 个空位时触发中断方便你感知数据吞吐。2.3 从 FIFO 读出红光和红外数据流初始化完成后主循环每 10ms 做一次 FIFO 读取。MAX30102 的数据寄存器是 0x07一次读回 6 个字节排列顺序是红光 ADC 高字节、中字节、低字节红外 ADC 高字节、中字节、低字节。芯片内部 ADC 是 18 位所以拼接之后要做一个掩码操作。typedef struct { uint32_t red; uint32_t ir; } ppg_sample_t; uint8_t max30102_read_fifo(I2C_HandleTypeDef *hi2c, ppg_sample_t *sample) { uint8_t buf[6]; if (HAL_I2C_Mem_Read(hi2c, MAX30102_ADDR 1, 0x07, I2C_MEMADD_SIZE_8BIT, buf, 6, 100) ! HAL_OK) { return 1; } // 18bit 拼接每个通道占 3 字节其中第 3 字节只保留低 2 位 sample-red ((uint32_t)buf[0] 16) | ((uint32_t)buf[1] 8) | buf[2]; sample-ir ((uint32_t)buf[3] 16) | ((uint32_t)buf[4] 8) | buf[5]; sample-red 0x03FFFF; sample-ir 0x03FFFF; return 0; }去掉掩码的后果是最大读值能到 0xFFFFFF超出真实量程后续波形缩放和 AC/DC 比例都会被高位伪噪声干扰。读取时还要注意 FIFO 指针如果写指针减读指针大于 1说明积压了多帧数据这时应该连续读多次直到差值为 0避免旧数据滞留。100Hz 采样率下每 10ms 读一次只要主循环不长时间卡死FIFO 一般不会溢出。提示有些模块的 I2C 地址是 0x57有些是 0x58 或 0x59取决于模块上地址引脚有没有被拉高。初始化之前写一段 I2C 地址扫描代码把 0x50 到 0x5F 都读一遍能帮你快速定位地址问题。3. 从原始 ADC 到可显示的 PPG 波形滤波、传输与数据链路3.1 PPG 信号的长相与三大噪声来源当红光和红外光穿过手指时接收端的光强信号由两部分组成。DC 分量来自皮肤、肌肉、静脉血等静态组织数值可能占 ADC 量程的 70% 到 90%AC 分量来自动脉血随心跳产生的周期性搏动这个比例常常只有 0.1% 到 1%。直接把原始 ADC 值打印到串口看到的只会是一条接近常数的粗线。要让脉搏波形“浮出来”必须把 DC 减去把 AC 放大。噪声来源主要有三个。第一个是环境光尤其是室内照明里的 50Hz/100Hz 工频成分会直接叠加到光信号上。MAX30102 硬件做了脉冲调制和采样保持能抑制一部分环境光但残余干扰依然存在所以软件上需要滤波。第二个是运动伪迹手指轻微移动或按压压力变化产生的信号幅度能大过脉搏波本身。第三个是电源纹波上一章说的 LED_VDD 供电问题最直接的影响就是 ADC 参考电压抖动波形上表现为细密毛刺。3.2 主循环的数据链路设计在 STM32F103 这类 72MHz 主频的 MCU 上我习惯把任务拆成三个时间片每 10ms 读一次 FIFO把原始样本存入环形缓冲区每 200ms 做一次滤波和特征计算每 500ms 刷新一次显示。这样读 FIFO 和跑算法不互相抢占不会因为算法耗时导致传感器数据积压。如果只想在 PC 上看到波形最快的路径是用串口把数据发给虚拟示波器。发送帧格式建议固定如下uint8_t tx_buf[8]; tx_buf[0] 0xAA; // 帧头用于上位机同步 tx_buf[1] (sample.red 16) 0xFF; tx_buf[2] (sample.red 8) 0xFF; tx_buf[3] sample.red 0xFF; tx_buf[4] (sample.ir 16) 0xFF; tx_buf[5] (sample.ir 8) 0xFF; tx_buf[6] sample.ir 0xFF; tx_buf[7] tx_buf[1] ^ tx_buf[2] ^ tx_buf[3] ^ tx_buf[4] ^ tx_buf[5] ^ tx_buf[6]; HAL_UART_Transmit(huart1, tx_buf, 8, 50);帧头0xAA用来告诉上位机从哪个字节开始解析校验位用 6 个数据字节的异或简单可靠。115200 波特率下每帧耗时不到 1 毫秒每 10ms 发一帧完全不影响主循环。上位机用串口助手抓包或者写一个简单的 Python 脚本读串口解析都可以。关键是先把波形可视化再考虑心率血氧算法。3.3 软件滤波移动平均与一阶低通的配合滤波是把波形从毛糙变平滑最直接的手段。我通常做两级第一级是窗口长度为 5 的移动平均用于抹掉单个采样点的随机毛刺第二级是一阶低通用于进一步压高频抖动。#define WIN_LEN 5 uint32_t history[WIN_LEN] {0}; uint8_t idx 0; uint32_t sum 0; // 移动平均滤波去掉随机噪声 sum - history[idx]; history[idx] raw; sum history[idx]; idx (idx 1) % WIN_LEN; uint32_t filtered sum / WIN_LEN;移动平均窗口不要超过 5100Hz 采样率下窗口 5 等价于 50ms 时间窗已经会轻微削掉脉搏波的上升沿。如果拉到 10波形圆钝峰值检测时机偏移心率容易差 5 到 10 次。移动平均之后接一阶低通float alpha 0.6f; static float lpf_out 0; lpf_out alpha * lpf_out (1.0f - alpha) * filtered;alpha越大平滑力度越强但延迟越大。0.6 是我的常用折中值。调试时把这个系数定义成全局变量用串口指令实时改观察波形的响应。注意滤波后的序列才是进算法的数据原始值直接进峰值检测很容易把毛刺认成心跳。4. 心率与血氧计算从波形里提取两个可读数字4.1 峰植检测算心率最短间隔与自适应阈值心率计算是在脉搏波序列里找到每个心动周期的主峰。可选方案有峰值检测、FFT 频谱分析、自相关分析。在 STM32F103 上做 FFT 不划算时域自适应峰值检测是性价比最高的。核心逻辑是用滑动平均值做基线把阈值设为基线的 1.8 倍只有超过阈值且距上一次有效心搏超过 250ms 的点才被认可。#define MIN_INTERVAL_MS 250 // 对应最高240bpm的周期限制 uint32_t last_beat_ms 0; float sum_avg 0; uint16_t avg_value 0; uint8_t beat_detected 0; // 每收到一个滤波后的样本执行一次 sum_avg sum_avg * 0.9f filtered * 0.1f; // 滑动均值跟随基线 avg_value (uint16_t)sum_avg; if (filtered avg_value * 1.8f filtered 3000) { uint32_t now_ms HAL_GetTick(); if (now_ms - last_beat_ms MIN_INTERVAL_MS) { beat_detected 1; last_beat_ms now_ms; } else { beat_detected 0; } } else { beat_detected 0; }为什么阈值不用固定值因为手指按压力度、环境光亮度变化PPG 波形的峰峰幅度会大幅浮动。固定阈值在信号弱时漏检在信号强时把噪声尖峰也算进来。滑动均值乘以 1.8 倍阈值自动跟随信号幅值。filtered 3000是无手指放置时的底噪门槛具体数值可以先采样看空闲 ADC 输出再设成其两倍以上。得到一组 RR 间期后心率用滑动平均计算uint32_t rr_sum 0; uint16_t rr_count 0; uint32_t last_rr_ms 0; if (beat_detected last_rr_ms ! 0) { uint32_t rr_ms now_ms - last_rr_ms; if (rr_ms 300 rr_ms 2000) { // 只接受30bpm~200bpm rr_sum rr_ms; rr_count; if (rr_count 5) { uint16_t heart_rate 60000 / (rr_sum / rr_count); rr_sum 0; rr_count 0; } } } last_rr_ms now_ms;RR 间期限定在 300ms 到 2000ms低于 300ms 的判定为噪声高于 2000ms 的判定为漏检。每 5 次有效心跳求一次平均心率数值更稳但响应时间约 3 秒。想加快响应可以改为 3 次平均但噪声大时数字抖动会明显。4.2 血氧计算R 值与经验公式的局限血氧饱和度 SpO2 的估算基于氧合血红蛋白和还原血红蛋白对红光与红外光吸收系数的差异。在两个波长下分别计算 AC 与 DC 的比值再算 R 值最后用经验公式映射到 SpO2。float dc_red 0, dc_ir 0; float ac_red_sum 0, ac_ir_sum 0; uint16_t sample_count 0; // 每个滤波后的样本都更新 dc_red dc_red * 0.97f red_filtered * 0.03f; dc_ir dc_ir * 0.97f ir_filtered * 0.03f; ac_red_sum (red_filtered - dc_red) * (red_filtered - dc_red); ac_ir_sum (ir_filtered - dc_ir) * (ir_filtered - dc_ir); sample_count; if (sample_count 100) { // 约1秒窗口 float ac_red sqrt(ac_red_sum / sample_count); float ac_ir sqrt(ac_ir_sum / sample_count); float r (ac_red / dc_red) / (ac_ir / dc_ir); float spo2 110.0f - 25.0f * r; if (spo2 100) spo2 100; if (spo2 70) spo2 70; ac_red_sum ac_ir_sum 0; sample_count 0; }R 值一般在 0.4 到 2.0 之间R 越大血氧越低。110 - 25R是一个宽泛线性近似只在 R 处于 0.4 到 1 之间时比较可靠超出范围只能做趋势参考。工业设备会针对不同肤色和传感器做多点标定再用查表插值。如果只是做原型验证线性公式够用别把数值当成医疗级结果。注意AC 分量不能用峰谷差来算因为运动会直接把噪声幅度并进去。这里用窗口内标准差近似 AC 的均方根值对噪声更稳健。窗口 1 秒能覆盖 2 到 3 个脉搏周期算出的波动幅度相对稳定。4.3 显示刷新节奏与 OLED 的协作方式把心率和血氧显示在 OLED 上时刷新频率要克制。OLED 的 I2C 速率一般最高 400kHz刷一屏需要几毫秒如果每 50ms 刷一次主循环接近一半时间耗在显示上。我建议数值区 500ms 刷新一次波形区 100ms 刷新一次。波形绘制时要先把 DC 分量减掉再把 AC 分量放大到屏幕高度。直接画原始 ADC 值会导致波形上下压缩成一根平线因为 DC 占了数值的绝大部分。5. 避坑指南MAX30102 波形显示项目中五个最常见的故障5.1 读回来的数据长时间是同一个固定值现象初始化返回成功读 FIFO 也返回 HAL_OK但串口打印的数据全是 0x7FFFF 或者全 0波形是直线。原因I2C 通路看似正常但传感器没进入工作状态。常见情况是模式寄存器被写成 0x00LED 全部关闭或者初始化顺序不对寄存器被后续配置覆盖。解决先读 0xFF 寄存器的 PART_ID确认返回 0x15。再读 0x09 看模式是否为 0x03读 0x0C 和 0x0D 看 LED 电流配置是否还在。如果 PART_ID 正常但 0x09 写不进去基本是 I2C 地址传参问题把 HAL 的 8 位地址和I2C_MEMADD_SIZE_8BIT参数逐项核对。5.2 波形每隔几秒出现大幅跳变现象波形大体正常但周期性出现一个大尖峰跳变后基线漂移持续一阵才恢复。原因FIFO 溢出。MAX30102 的 FIFO 深度是 32每帧数据占 6 字节。当主循环忙于刷屏或者做其他阻塞操作超过 20ms 没有读 FIFO新数据会把旧数据顶掉读回来的一帧混着不同时刻的采样波形看起来就像断裂。解决读取前先检查 FIFO 写指针 0x04 和读指针 0x05 的差值。如果差值大于 5先把 FIFO 读空再进入正常流程。同时把 0x08 寄存器的回滚位打开溢出后从最新数据开始。更硬核的做法是把 FIFO 读取放进定时器中断或 DMA 传输完成回调让读取变成硬实时任务。5.3 心率忽高忽低峰值检测到了很多抖动现象波形肉眼看着有规律但心率一会儿 85 一会儿 130偶尔还冒出 999。原因固定阈值脱离实际信号基线信号幅值变化时把噪声尖峰误判成脉搏波。另一个常见原因是滤波没有压掉工频分量50Hz 或 100Hz 的纹波远高于心跳频率峰值检测会把每个纹波峰都当成一次心跳算出的心率直接翻倍。解决峰值检测必须加最小间隔限制并把阈值系数从 1.2 倍提高到 1.8 倍。宁可漏检几次也不要误检。波形上的工频纹波可以用一阶低通进一步压掉alpha 取 0.7 以上让 10Hz 以上的分量快速衰减。5.4 血氧值恒定在 90 以下或一直 99现象SpO2 显示值不随手指状态变化要么长期低于 90要么永远 99。原因R 值计算对 AC 和 DC 的提取质量非常敏感。手指按压过紧时 DC 增大、AC 被压小R 值失真。另一种常见错误是 AC 用最大值减最小值来求运动噪声会混进来导致 R 偏大、SpO2 偏低。解决手指要轻放不要用力压传感器。AC 必须用窗口标准差估算不能简单取峰谷差。同时给 SpO2 输出加一个长度为 5 的中值滤波取最近五次的中间值能有效消除单次异常计算。正常成人 SpO2 应在 95 到 100 之间持续出现 90 以下时优先怀疑算法而不是被测对象血氧真的异常。5.5 OLED 和传感器抢同一根 I2C 总线波形卡顿现象接上 OLED 后波形每隔几百毫秒卡顿一次心率算法偶尔把卡顿后的突跳识别成心跳。原因OLED 和 MAX30102 共用 I2C 总线时两边都是阻塞式通信。OLED 刷一屏要传输几百字节这段期间 MAX30102 的 FIFO 无人读取数据积压直到溢出。解决优先把 OLED 放到另一路 I2C 总线上STM32F103 一般有 I2C1 和 I2C2让传感器和显示互不干扰。如果硬件已经固定共用总线就降低 OLED 刷新频率并在每次 OLED 刷新前先把 FIFO 里的所有数据读空。调试点上OLED 刷新前临时关闭串口打印也能明显改善波形卡顿。6. 进阶用数据回放验证算法把 MAX30102 从“演示”做到“可控”波形能出、数值能跳只完成了一半。另一半是确认算法在各种边界条件下依然稳定手指轻放和按压的区别、环境光变化、手部轻微晃动这些情况都要有可预期的表现。我在一个模拟项目里栽过跟头直接改滤波参数就上板测试波形看着平滑了但心率偶尔还是会跳。后来重新分析原始数据才发现那是运动伪迹造成的滤波参数只是暂时掩盖了问题。从那以后我每次调 MAX30102 都强制走一遍回放流程通过串口把原始 ADC 数据录到 PC存成 CSV在 PC 上用 Python 或者类似数值工具离线跑同一套滤波和算法调整参数观察输出确认参数有效后再移植回 STM32。这样算法问题和硬件问题不再混杂在一起排查效率高得多。import serial ser serial.Serial(COM10, 115200, timeout1) buf b data [] while len(data) 1000: buf ser.read(200) idx buf.find(b\xAA) if idx 0 and len(buf) idx 8: frame buf[idx:idx8] if frame[1] ^ frame[2] ^ frame[3] ^ frame[4] ^ frame[5] ^ frame[6] frame[7]: red (frame[1] 16) | (frame[2] 8) | frame[3] ir (frame[4] 16) | (frame[5] 8) | frame[6] data.append((red 0x3FFFF, ir 0x3FFFF)) buf buf[idx8:]这段脚本只做串口解析和帧校验拿到数组后可以先画原始波形标记峰值位置再对比滤波前后的差别。回放验证的另一个好处是可以把某一段的心率计算过程打印出来看到底是漏检还是误检这类问题在 MCU 上很难直观定位。还有一个小习惯值得养成把滤波系数、阈值系数、窗口长度、LED 电流全部放进一个结构体通过串口命令实时修改。这样在硬件上调参时不用反复编译烧录发一条命令就能看到波形和数值的变化。参数确定后再固化到默认配置里后续重新编译也只是改几行初始值。项目做到最后所谓“MAX30102 心率血氧波形显示”考验的其实不是传感器本身而是数据链路上每个环节的可靠性I2C 初始化有没有写对、FIFO 有没有按时排空、滤波参数和信号频率是否匹配、峰值检测有没有被噪声干扰。资源里这些环节都做成了可运行的工程骨架你需要做的只是用自己的手指和一组原始数据把链路完整走一遍。如果你的波形稳定、心率误差在正负 5 次以内、血氧数值在正常区间说明这套方案已经被你掌握住了。希望帮到你。本文还有配套的精品资源点击获取