MAX30102心率血氧模块开发实战:STM32驱动、滤波算法与OLED显示全解析

📅 发布时间:2026/10/15 16:29:14
MAX30102心率血氧模块开发实战:STM32驱动、滤波算法与OLED显示全解析
简介面向STM32F103开发者的MAX30102心率模块完整代码包基于HAL库编写集成OLED显示与MAX30102血氧心率读取功能适合嵌入式入门、可穿戴健康监测或毕业设计参考。代码为可直接使用的完整工程设置一次读取模式再次读取需复位MCU实测数值较精准项目包含OLED显示驱动、MAX30102传感器初始化与数据读取、HAL库外设配置等模块引脚改动已在代码中标注使用前需先核对。压缩包共180个文件大小6.65MB其中60个.h头文件与22个.c源文件构成主体逻辑uvprojx/uvoptx为Keil工程配置hex/axf为编译输出另有8个.bak备份文件便于回退ioc文件可对应CubeMX配置从预览可见还包含了HAL库标准框架文件整体目录符合常规STM32工程组织方式。已有1388人学习下载。读取数值期间建议尽量减少触碰MAX30102排针以免采样变慢整体工程结构清晰适合快速移植到同类开发板也可作为学习HAL库分层编写的参考。1. 这套资源解决什么问题MAX30102 心率模块不是点个灯STM32 心率模块做到能显示数字不难难的是让数字稳定跟住心跳。MAX30102 加上 OLED 和 HAL 库这套组合代码量不大但 I2C 时序、FIFO 深度、滤波阈值、OLED 刷新任何一个环节出错屏幕上跳出来的“72”就会变成玄学数字。这篇笔记拆的是一份可直接烧录的 STM32F103 完整工程覆盖 MAX30102 驱动、心率与血氧算法、OLED 显示和主循环组织给两类人看刚接触这类传感器的新手和想把点灯式 Demo 改成可交付逻辑的熟手。读完你会清楚每个配置寄存器为什么填这个值也明白心率数值不稳定时该从哪查起。2. MAX30102 驱动与 I2C 选型阻塞读 FIFO 是稳妥起点2.1 接线与上拉先把硬件底子打好MAX30102 模块常见引脚有 VIN、GND、SCL、SDA、INT部分板子还引出 PGND。VIN 接 3.3V千万别图省事接 5V模块上虽然有电平转换但长时间 5V 供电会把传感器本体烧掉。I2C 用 STM32F103 的 I2C1PB6 接 SCL、PB7 接 SDAINT 脚可以接 PA0 并配置成 EXTI 下降沿触发也可以不接因为代码里用定时轮询读 FIFO 就够了。SCL 和 SDA 上必须要有上拉电阻。很多模块板上已经焊了 2.2k 或 4.7k如果你用的是自己画的板子忘了上拉会出现一个很典型的怪现象初始化偶尔成功但连续读几次就卡死。I2C 引脚在 CubeMX 里配置成开漏输出开漏模式下芯片本身不主动拉高全靠上拉电阻所以外部 4.7k 到 3.3V 是底线。引脚连接目标说明VIN3.3V禁止接 5VGNDGND与 STM32 共地SCLPB6I2C1 时钟SDAPB7I2C1 数据INTPA0可选内部中断引脚轮询方案可悬空2.2 寄存器初始化采样率、量程与 LED 电流MAX30102 的 I2C 设备地址是 0xAE7 位地址 0x57 左移一位OLED 的地址是 0x3C两者可以挂同一条总线。初始化顺序很关键先软复位等芯片稳定再写 FIFO 配置、模式配置、SPO2 配置和 LED 电流。下面这段是代码包里的核心初始化函数。#define MAX30102_ADDR 0xAE #define REG_FIFO_CONFIG 0x0A #define REG_MODE_CONFIG 0x0B #define REG_SPO2_CONFIG 0x0C #define REG_LED1_PA 0x0D #define REG_LED2_PA 0x0E uint8_t max30102_init(void) { uint8_t reg; // 软复位 reg 0x40; HAL_I2C_Mem_Write(hi2c1, MAX30102_ADDR, REG_MODE_CONFIG, 1, reg, 1, 100); HAL_Delay(100); // FIFO 配置采样平均 4 次FIFO 翻滚使能接近满阈值设 15 reg 0x4F; HAL_I2C_Mem_Write(hi2c1, MAX30102_ADDR, REG_FIFO_CONFIG, 1, reg, 1, 100); // 模式RED IR 双通道 reg 0x03; HAL_I2C_Mem_Write(hi2c1, MAX30102_ADDR, REG_MODE_CONFIG, 1, reg, 1, 100); // SPO2411us 脉宽4096nA 量程采样率 400Hz reg 0xC3; HAL_I2C_Mem_Write(hi2c1, MAX30102_ADDR, REG_SPO2_CONFIG, 1, reg, 1, 100); // LED 电流RED 和 IR 都设 0x24约 7.2mA reg 0x24; HAL_I2C_Mem_Write(hi2c1, MAX30102_ADDR, REG_LED1_PA, 1, reg, 1, 100); HAL_I2C_Mem_Write(hi2c1, MAX30102_ADDR, REG_LED2_PA, 1, reg, 1, 100); return 0; }逻辑说明先通过 MODE_CONFIG 写 0x40 触发芯片软复位等 100ms 让内部稳压器和时钟稳定。FIFO_CONFIG 的 0x4F 拆开看是0100 1111高三位010表示每 4 个原始采样取平均这会直接降低有效采样率但同时抑制高频噪声bit1 置 1 开启 FIFO 翻滚老数据满了自动覆盖省去了手动清 FIFO 的麻烦低四位1111表示 FIFO 剩余空间小于等于 15 个样本时触发中断标志轮询时读这个标志判断数据是否 ready。SPO2_CONFIG 填 0xC3二进制1100 0011其中 bit6-5 为11表示 LED 脉冲宽度 411usbit4-2 为000表示 ADC 满量程 4096nAbit1-0 为01表示采样率 400Hz。注意这里采样率和 FIFO 平均是配合关系400Hz 原始采样除以 4 次平均实际输出到算法的有效采样率是 100Hz。LED 电流 0x24 对应约 7.2mA透射式手指夹持场景下这个值比较中庸。电流太小信号 AC 分量太弱电流太大容易饱和白皮和深色皮肤需要的电流也不一样后面避坑章节会细说。2.3 批量读 FIFO用深度换 I2C 频率MAX30102 内部 FIFO 深度是 32 个样本每个样本包含红和红外各 3 字节共 6 字节。读 FIFO 数据寄存器时芯片会自动递增读指针所以可以一次连续读多个样本减少 I2C 通信次数。100Hz 有效采样率下每 10ms 才产生一个新样本完全没必要用中断方式逐样本读。#define REG_FIFO_DATA 0x09 uint8_t max30102_read_samples(uint32_t *red, uint32_t *ir, uint8_t n) { uint8_t buf[6 * 3]; uint8_t i; if (n 3) n 3; HAL_I2C_Mem_Read(hi2c1, MAX30102_ADDR, REG_FIFO_DATA, 1, buf, 6 * n, 50); for (i 0; i n; i) { red[i] ((uint32_t)buf[i * 6] 16) | ((uint32_t)buf[i * 6 1] 8) | buf[i * 6 2]; ir[i] ((uint32_t)buf[i * 6 3] 16) | ((uint32_t)buf[i * 6 4] 8) | buf[i * 6 5]; } return 0; }参数说明函数限制单次最多读 3 个样本是因为主循环 10ms 周期内实际只需要读 1 个读 3 个是为了处理前期 FIFO 里积压的数据。每个样本 18 位有效数据左移 16 位和 8 位再或运算把三字节拼成 uint32_t。如果你的代码里发现读出来的数值始终只有低 8 位在变大概率是字节拼接顺序弄反了MAX30102 是大端序输出先高字节后低字节。阻塞模式下HAL_I2C_Mem_Read超时设 50ms正常情况下 6 字节传输在 100kHz 总线时钟下耗时不到 1ms50ms 足够覆盖总线异常情况又不会卡死主循环。2.4 中断和 DMA 的选择边界这套代码默认用阻塞读但工程里也留了中断方式的接口。要明确一个边界MAX30102 的数据率低FIFO 又有 32 样本深度CPU 负载几乎可以忽略。阻塞读最大的好处是逻辑简单不会踩 HAL 库 I2C 中断回调状态机的坑。DMA 方案在这个场景里收益不大。有人为了让主循环不等待把 FIFO 读取改成 DMA结果反而引入两个新问题一是 DMA 传输期间 I2C 总线被占用OLED 刷新同一总线时会等待二是 DMA 中断和定时器中断优先级没配好偶发丢样本。如果你非要上 DMA优先级建议 DMA 高于定时器并且 DMA 回调里只用标志位通知主循环不要直接在回调里处理数据。3. 从原始波形到心率数值去直流、滤波与脉搏检测3.1 原始数据不能直接用DC 分量与 AC 分量MAX30102 读出来的 18 位原始值是光电容积脉搏波反映的是血液容积变化对红外光和红光吸收率的影响。静止按压手指时直流分量占了绝大部分这部分对应组织、静脉血和皮肤对光的恒定吸收我们真正关心的是叠加在直流上的微小交流分量它随动脉搏动周期性变化幅度通常只有直流的 1% 到 3%。直接把原始值拿去做阈值检测是新手最容易犯的错。不同人手指粗细、肤色、按压力度不同原始值的直流基线能差出几万个 LSB一个固定阈值根本无法适应结果就是 A同学测着正常B同学一放上去心率就乱跳。先去掉直流把信号拉回零轴附近后面所有处理才有意义。3.2 一阶高通去直流与移动平均工程里用的是最务实的一阶高通加移动平均组合不引入复杂滤波器STM32F103 的主频跑这套足够。#define ALPHA_HP 0.9f int16_t dc_remove(int32_t raw) { static int32_t prev_raw 0; static int32_t filtered 0; filtered raw - prev_raw (int32_t)(ALPHA_HP * filtered); prev_raw raw; return (int16_t)filtered; } int16_t moving_average(int16_t input) { static int16_t buf[5]; static uint8_t idx 0; int32_t sum 0; uint8_t i; buf[idx] input; idx (idx 1) % 5; for (i 0; i 5; i) { sum buf[i]; } return (int16_t)(sum / 5); }逻辑说明高通滤波器的公式是y[n] x[n] - x[n-1] alpha * y[n-1]alpha 越大截止频率越低。0.9 对应大约 0.16Hz 的截止频率也就是说不超过 10 秒的缓慢基线漂移都会被抑制而正常心率 0.8Hz 以上的频段基本不受影响。移动平均窗口取 5 个点在 100Hz 有效采样率下对应 50ms 窗口能压掉 20Hz 以上的高频毛刺。这两级滤波过后信号从原始的一两万 LSB 变成正负几百的平整波形用串口导出来能看到明显的周期性起伏。如果滤波后的波形还有明显台阶先检查是不是手指按压力度在变或者 LED 电流设得过大进入非线性区。3.3 脉搏峰检测与 250ms 消抖基线拉平之后检测脉搏峰的最简单方法是设一个动态阈值当信号从下往上穿越阈值时记一次心跳。动态阈值比固定阈值稳得多我习惯取最近 1 秒内波形最大值的 60% 作为阈值。typedef struct { int16_t peak; int16_t threshold; uint8_t above; uint32_t last_beat_tick; uint16_t bpm; uint8_t beat_count; uint32_t window_start; } pulse_detector_t; void pulse_detect(pulse_detector_t *pd, int16_t signal, uint32_t now) { if (signal pd-peak) { pd-peak signal; pd-threshold (int16_t)(pd-peak * 0.6f); } if (signal pd-threshold) { pd-above 1; } else if (pd-above) { pd-above 0; if (now - pd-last_beat_tick 250) { uint32_t interval now - pd-last_beat_tick; pd-bpm (uint16_t)(60000 / interval); pd-last_beat_tick now; pd-beat_count; } pd-peak 0; } }参数说明峰值在信号高于阈值期间持续更新一旦信号回落到阈值以下且之前已经拉高过就认为完成了一个脉搏周期这时把 60000 除以毫秒间隔得到瞬时心率。250ms 消抖是硬门槛防止波形上有两个挨得很近的毛刺被当成两次心跳正常心率上限按 240bpm 算对应间隔 250ms所以这个值不会漏掉真实心跳。瞬时心率波动很大是正常的呼吸、手指微小移动都会影响一次心跳的间隔。显示到 OLED 上之前建议再套一个滑动平均取最近 5 次心跳间隔的平均值作为显示值这样屏幕上不会跳得让人心慌。3.4 血氧的 R 值计算思路血氧计算用的是红光和红外两个通道的交流直流比核心公式是 R 值等于(AC_red / DC_red) / (AC_ir / DC_ir)。红光的交流分量对血氧饱和度敏感红外光的交流分量主要反映血流灌注两者比值和 SpO2 之间有近似线性关系。工程里用了一个经验拟合式SpO2 110 - 25 * R这只是个演示精度不能当医疗设备用。工程代码里 R 值的计算窗口取了 4 秒每 1 秒滑动一次。计算过程中需要分别统计红光和红外通道在窗口内的峰值和谷值差就是 AC平均值就是 DC。真正要关注的坑是红外通道信号质量差时R 值会异常偏大或偏小输出血氧数值直接飞出 90% 到 100% 的合理区间。所以代码里加了数据有效性判断IR 通道的 AC 幅度低于阈值时SpO2 显示--而不是硬给一个离谱数字。4. OLED 显示与主循环局部刷新与时基管理4.1 OLED 地址与初始化顺序OLED 和 MAX30102 共用 I2C1 总线0.96 寸 SSD1306 的常见 I2C 地址是 0x3C某些板子上通过电阻可以改成 0x3D。初始化顺序上OLED 要先复位后延时再发初始化命令序列我见过不少人一上电立刻发命令结果 OLED 静默无响应其实只是芯片还在上电复位中。#define OLED_ADDR 0x3C void oled_init(void) { // 模块 RES 脚如果接到 GPIO先拉低 10ms 再拉高 // 这里假设 RES 接在 PB1 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_RESET); HAL_Delay(10); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_SET); HAL_Delay(100); // SSD1306 初始化命令序列 uint8_t init_cmds[] { 0xAE, 0xD5, 0x80, 0xA8, 0x3F, 0xD3, 0x00, 0x40, 0x8D, 0x14, 0x20, 0x02, 0xA1, 0xC8, 0xDA, 0x12, 0x81, 0xCF, 0xD9, 0xF1, 0xDB, 0x40, 0xA4, 0xA6, 0xAF }; HAL_I2C_Mem_Write(hi2c1, OLED_ADDR, 0x00, 1, init_cmds, sizeof(init_cmds), 100); OLED_Clear(); }逻辑说明SSD1306 的命令写在控制字节 0x00 后面数据写在控制字节 0x40 后面。这段命令序列按顺序完成关闭显示、设置时钟分频、设置 multiplex ratio、设置显示偏移、开电荷泵、设置内存寻址模式为页寻址、左右上下翻转、设置对比度、开显示。如果你用现成的 ssd1306 库这段不用手写但理解每个命令的作用对后续排查黑屏有帮助。4.2 主循环的时基拆分主循环的核心是时间片隔离。10ms 读一次传感器并喂给滤波和脉搏检测500ms 刷新一次 OLED。这样传感器采样是准实时同步的显示刷新又不会因为频繁操作 I2C 总线挤占采样窗口。uint32_t last_sample 0; uint32_t last_display 0; uint32_t red_raw 0, ir_raw 0; int16_t filtered_signal 0; pulse_detector_t detector {0}; while (1) { uint32_t now HAL_GetTick(); if (now - last_sample 10) { last_sample now; max30102_read_samples(red_raw, ir_raw, 1); filtered_signal moving_average(dc_remove(ir_raw)); pulse_detect(detector, filtered_signal, now); update_spo2(red_raw, ir_raw); } if (now - last_display 500) { last_display now; oled_show_hr_bpm(detector.bpm); oled_show_spo2(get_spo2_value()); } }参数说明采样用 IR 通道做心率检测因为红外光穿透深度更深信号幅度通常比红光稳定。两个时间片互相独立就算 OLED 刷新期间 I2C 总线抖动最坏情况丢一次显示更新不会影响采样。判断条件里的是为了防止 HAL_GetTick 偶尔被定时器中断延迟导致跳变实际运行时如果主循环被压满整体周期会有微小漂移但 100Hz 采样下 1ms 以内的漂移不影响心率计算。4.3 局部刷新避免整屏闪烁OLED 整屏清空再重绘肉眼看就是闪烁而且频繁刷整屏会占用 I2C 总线时间。工程里的做法是只在数值变化区域重绘数字。心率显示框固定在一个矩形区域每次刷新先清掉这个区域再画新的数字周围的心电图图标和文字保持不变。void oled_update_number(uint8_t page, uint8_t x, uint8_t *str) { oled_clear_area(page, x, x 28); oled_show_string(page, x, str); }页寻址模式下一个 128x64 的 OLED 被分成 8 页每页 8 像素高。这里的page参数指定数字在第几页x是起始列x 28是结束列清屏只清这 28 列避免把旁边图标抹掉。实际效果是心率数字每秒刷新两次但其他区域纹丝不动视觉上稳定很多。4.4 工程目录怎么划分完整代码包的结构分成三个目录Driver 层放 MAX30102 和 OLED 的底层读写Algorithm 层放心率和血氧处理App 层放主循环和参数配置。这样划分的好处是你想换 OLED 驱动库、或者改用中断方式读 FIFO只动 Driver 层算法和主循环一行不用改。代码包里你也会看到配置文件里预留了USE_DMA_READ宏默认注释掉这就是给想折腾 DMA 的人留的口子。5. 避坑清单I2C 卡死、白屏与心率乱跳的排查5.1 I2C 卡在 HAL_BUSY读 FIFO 全 0现象程序运行几秒后HAL_I2C_Mem_Read一直返回 HAL_BUSY复位后正常过一会儿又卡死。读完 FIFO 全是 0。原因最常见两条。一是 SCL/SDA 上拉电阻缺失总线空闲时电平不稳定偶尔进入错误状态二是 OLED 和 MAX30102 同时操作总线时前一次传输没等释放就发起了下一次。HAL 库的 I2C 状态机在异常时序下会卡在 BUSY不复位外设根本释放不了。解决先查硬件上拉4.7k 到 3.3V 不能省。软件层面每次读写前用__HAL_I2C_GET_FLAG判断总线是否空闲在一个循环里等待如果连续等待 10ms 仍忙就对 I2C 外设执行一次软件复位清掉 BUSY 状态。工程代码里封装了i2c_busy_wait()和i2c_recover()两个小函数专门处理这种卡死。5.2 手指放上去没波形拿开反而有现象手指按压传感器时波形幅度极小甚至没有手指离开反而读到明显的起伏。原因MAX30102 是反射式传感器它的红 LED 和红外 LED 发出的光穿透皮肤后一部分被血液吸收一部分被组织漫反射回来被光电二极管接收。手指按压太紧会把毛细血管压扁血流被挤走AC 分量消失按压太松外部环境光直接进入传感器读到的全是噪声。很多人以为按得越紧信号越好实际是轻轻搭在上面才有波形。解决让手指轻轻覆盖传感器窗口不要用力压。代码层面用 IR 通道的 AC 幅度做置信度判断幅度低于 20 LSB 时在 OLED 上提示“松手”比用户自己瞎琢磨强。这属于穿戴设备里典型的机械设计问题代码能缓解但解决不了根本。5.3 心率数值从 40 跳到 140现象显示数值剧烈波动一会儿 40 一会儿 140看起来完全没有规律。原因滤波参数和阈值不匹配。常见两种一是滤波后信号里残留低频基线漂移导致峰值检测器把基线抬升误判成心跳二是峰值检测里没有消抖两个相邻毛刺被算成两次心跳间隔减半心率直接翻倍。解决先通过串口把滤波后的信号波形导出来用上位机看是噪声大还是基线漂移。噪声大就加大移动平均窗口到 7 或 9基线漂移就调高 alpha 到 0.95。峰值检测的 250ms 消抖是底线不要为了响应快而调小。显示层的 5 次滑动平均也建议保留瞬时心率本来就不该直接给人看。5.4 OLED 黑屏但 MAX30102 正常现象MAX30102 数据读取正常心率算得出来但 OLED 一直黑屏I2C 读写 OLED 也不报错。原因典型的初始化时序问题。SSD1306 需要上电后至少等 100ms 才能接收命令而且 RES 引脚如果悬空内部上电复位不一定可靠。另一个地址坑是有些 0.96 寸 OLED 板子地址是 0x3D 而不是 0x3C代码里写死 0x3C 就会导致所有命令被忽略。解决把 RES 引脚用 GPIO 控制初始化时拉低 10ms 再拉高上电到发命令之间至少延时 100ms。如果手里有逻辑分析仪抓一下 OLED 的 ACK 信号地址不对时从机根本不会拉低 SDA一眼就能看出来。5.5 LED 电流调大后数据饱和现象LED 电流从 0x18 调到 0x30 后读到的原始值持续在 65535 附近滤波后波形变成平头。原因光电二极管接收到的光强超过了 ADC 量程。MAX30102 的 ADC 量程在 SPO2_CONFIG 里设了 4096nALED 电流越大反射到接收端的光电流越大超过量程后数据直接封顶。解决两种改法优先把 SPO2_CONFIG 的 ADC 量程从 4096nA 调到 8192nA改 b4-2 为001如果量程调大后 AC 幅度变小再适当减小 LED 电流。一般肤色偏深的人需要更亮的 LED但量程也要同步调大这两个参数必须配套调。判断标准是手指放上去后原始值在 20000 到 60000 之间晃动既不饱和也不太弱。6. 进阶验证把原始波形导出再做判断6.1 用一条 printf 串口看原始波形很多心率乱跳的问题靠看 OLED 上的数字根本定位不了。数字跳 140 的时候你没法判断是滤波没过关还是检测逻辑出错但波形一出来全都清楚。工程代码里预留了 UART1 的 printf 重定向打开DEBUG_LOG宏后主循环每次采样会把红和红外原始值发出来。int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 10); return ch; } // 打开 DEBUG_LOG 宏后采样函数末尾追加一句 printf(%d,%d\r\n, (int)red_raw, (int)ir_raw);用串口助手或任何波形显示工具接收就能看到两条不断起伏的曲线。正常信号应该是幅度稳定的周期波像正弦波带着点毛刺如果波形是一条直线说明手指没放好或者 LED 电流太小如果波形高低起伏但节奏不规则说明按压在动需要固定手指。6.2 稳定性验收的三个指标我一般用三个指标判断这套代码能不能交付。第一个是心率数值在静息状态下 20 秒内波动不超过 3 到 5 下第二个是波形幅度在 10 秒内没有明显的缓慢升降说明基线漂移被压住了第三个是手指挪开再放回后心率能在 3 秒内重新锁定而不是变成乱码。这三个都过了代码才算是能给别人用的状态。从那以后我每次调 MAX30102 都是先把原始波形导出确认信号质量达标再调算法参数绝不会直接盯着 OLED 数字去猜问题。这个习惯帮我排掉了至少一半的疑难杂症。希望这篇笔记能让你少走几步弯路如果卡在某个环节把波形打出来看看答案往往就在那条曲线上祝顺利。本文还有配套的精品资源点击获取