STM32 I2C读写心率传感器MAX3010x并显示BPM全攻略
2026/9/14 5:30:01 网站建设 项目流程

简介:这套STM32F407心率传感器LCD显示DEMO例程,定位为嵌入式入门与STM32外设实战的参考工程,适合正在学习单片机开发、需要完成传感器数据采集与显示任务的开发者。工程完整覆盖GPIO初始化、传感器数据读取、滑动平均滤波、LCD驱动与通信接口配置等关键环节,并提供基于HAL库的工程文件和编译脚本,可在Keil或IAR中直接打开调试。资源包共667个文件,以.c源码、.h头文件、.s启动文件与.icf链接配置为核心,同时附带hex固件、uvprojx工程、bat批处理等辅助文件,方便直接烧录与批量编译,整体仅7.05MB,目录结构清晰,便于按模块查阅和二次开发。目前已有131人学习下载,参考价值在于能帮助读者理清从传感器输入到屏幕显示的整体数据流,掌握中断、ADC、滤波和LCD驱动的协作方式,也为其他传感器接入LCD显示提供可复用的工程范式。

1. 读写心率传感器模块,先把数据链路立起来

心率传感器模块的 DEMO 源码在 ZIP 里解开之后,最容易犯的错误不是找 BPM 显示在哪一行,而是没确认 I2C 总线上读回的第一帧数据是不是真的。这个标题背后的硬件链路通常很固定:STM32 通过 I2C 或 SPI 访问光电式心率模块(常见的是 MAX3010x 这类反射式方案),模块里同时有红光 LED、红外 LED 和光电二极管,读回的是受血管容积变化调制的原始 ADC 数据。只有把“寄存器配置、FIFO 读取、滤波和峰值检测、LCD 刷新”这条链路全部打通,LCD 上跳动的数字才算有意义。这篇文章就按这条链路拆开讲,适合手里有一个 Demo 例程但改不动参数、或者想把例程换成自己板子的人的阅读路径。

2. 模块与 STM32 的硬件链路和例程源码的目录组织

2.1 引脚、上拉与供电:I2C 链路排查的起点

先看模块端的接口定义。绝大多数心率传感器模块会引出 VIN、GND、SCL、SDA,部分带 INT 中断脚。VIN 接到 STM32 的 3.3V,不要接 5V,MAX3010x 的绝对最大额定电压是 5.5V,但长时间跑在 5V 上会发热,红光 LED 驱动电流容易偏移,ADC 读数的基线会漂。SCL、SDA 接 STM32 对应引脚,I2C 总线必须要有上拉电阻。模块底板一般已经焊好 4.7kΩ 或 10kΩ 上拉,所以排插没有裸露上拉时不用重复加。真正的坑在异种板互联:如果模块板载上拉是 10kΩ,而 STM32 的 I2C 引脚内部又没有使能上拉,加上线缆一长,时钟低电平时间会被拉长,400kHz 模式直接不工作。我一般会把 I2C 速度先降到 100kHz 验证,再升到 400kHz。

INT 脚在例程源码里经常被忽略,但它值得接到一个空闲 GPIO。心率模块的数据准备好之后会拉低 INT,配合 EXTI 中断做读取,比轮询 FIFO 寄存器省不少 CPU。如果只是 DEMO 验证,轮询就够。引脚选择上有两个思路:一是用复用功能开漏模式,GPIOB 的 PB8/PB9 是 I2C1 默认脚;二是在新板子上用任意 GPIO 加硬件 I2C 映射。坚持用硬件 I2C,不要用 GPIO 模拟 I2C 去读这个模块,因为传感器 FIFO 连续读时要维持时序一致,模拟时序在中断嵌套下容易出现 ACK 延时报错。

2.2 例程源码的目录结构:先弄清文件归属,再动手改

Demo 源码包通常不是只有一个 main.c,而是带了一整套驱动文件。解开 ZIP 后先看根目录,常见做法是分 Application、Driver、ThirdParty 三层:

Demo/ ├─ Core/ │ ├─ Inc/ │ │ ├─ main.h │ │ └─ i2c.h │ └─ Src/ │ ├─ main.c │ └─ i2c.c ├─ Drivers/ │ ├─ BSP/ │ │ ├─ MAX3010x.h │ │ └─ MAX3010x.c │ └─ LCD/ │ ├─ ssd1306.h │ └─ ssd1306.c └─ Middlewares/ └─ 算法/ ├─ hr_algorithm.h └─ hr_algorithm.c

这个结构里,Drivers/BSP是芯片级驱动,管寄存器和 FIFO;Middlewares是心率算法层,管滤波、峰峰值、BPM;Core管引脚初始化和主循环。拿到例程先不要全盘编译,先看主循环里调的是哪几个函数。如果出现max30102_read_fifo()heart_rate_get_bpm(),就顺着这两个函数把源文件找齐。

文件关心什么
MAX3010x.cI2C 读写函数、寄存器配置参数
hr_algorithm.c滑动窗口大小、阈值、BPM 输出
ssd1306.c屏幕刷新是否占用 I2C 总线
main.c初始化顺序、主循环执行频率

特别注意LCD和传感器是否挂在同一条 I2C 总线上。OLED 屏的 I2C 地址是 0x3C,MAX3010x 的默认地址是 0xAE,从设备地址不冲突,但同一个总线上两个设备都要有各自的上拉,且总线上拉电阻阻值不要再额外并联小板,否则总线电容变大。

2.3 最小化初始化:先让 I2C 扫描到设备地址

无论例程里的初始化多复杂,最终都要回到三件事:开时钟、配 GPIO、设置 I2C 时序。

void I2C_Init_400kHz(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_I2C1_CLK_ENABLE(); gpio.Pin = GPIO_PIN_6 | GPIO_PIN_7; // PB6=SCL, PB7=SDA gpio.Mode = GPIO_MODE_AF_OD; // 复用开漏 gpio.Pull = GPIO_PULLUP; // 内部上拉兜底 gpio.Speed = GPIO_SPEED_FREQ_HIGH; gpio.Alternate = GPIO_AF4_I2C1; HAL_GPIO_Init(GPIOB, &gpio); I2C_HandleTypeDef hi2c1 = {0}; hi2c1.Instance = I2C1; hi2c1.Init.ClockSpeed = 400000; // 400kHz 快速模式 hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2; hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; HAL_I2C_Init(&hi2c1); }

这里的GPIO_AF4_I2C1是 STM32F1 系列以外的典型映射,F1 系列需要查 AF 表,PB6/PB7 在 F1 上是默认 I2C1 脚,不需要 AF 配置。开漏模式是必须的,因为 I2C 协议依赖低电平驱动的线与特性;把引脚配置成推挽输出会导致总线冲突,严重时模块发 ACK 会失败。

初始化之后不要直接进主循环,先做一次设备扫描:

uint8_t id = 0; if (HAL_I2C_Mem_Read(&hi2c1, 0xAE, 0xFF, 1, &id, 1, 100) == HAL_OK) { // 0xFF 是无效寄存器,能读到说明地址应答正常 }

这一段代码不要用来判断寄存器内容,只用来判断模块是否在线。如果 HAL_I2C_Mem_Read 返回 HAL_ERROR,问题基本出在接线、上拉、地址这三项中的一项,先检查 0xAE 是不是真的模块地址。读取成功之后再查器件 ID 寄存器,MAX30102 的 ID 寄存器地址是 0xFE,读回应为 0x15;MAX30100 没有 ID 寄存器,靠读 0x00 寄存器区分。

3. STM32 I2C 读写模块寄存器:关键寄存器与 FIFO 数据流

3.1 写寄存器失败的三个常见原因

使用 HAL 库时,写入 MAX3010x 的寄存器通常写成HAL_I2C_Mem_Write(&hi2c1, 0xAE, reg_addr, I2C_MEMADD_SIZE_8BIT, &value, 1, 100)。寄存器地址是 8 位,这是它和许多 EEPROM 最大的区别——EEPROM 的地址可能是 16 位。如果把I2C_MEMADD_SIZE_8BIT误写成I2C_MEMADD_SIZE_16BIT,从机不会回复第二个地址字节,写操作直接超时。这是例程移植时最常见的低级错误。

写完寄存器立刻读回校验也容易出错。心率模块的部分寄存器(如模式配置寄存器)内含软复位位和保留位,写入 0x00 后再读回 0x00 是正常的。但有些组合位会立即改变芯片状态,比如把模式切到 SpO2 后,读回的寄存器会带出实时状态位。做校验时应该屏蔽掉可变位,只比对有效位,不然读回不等就反复复位,越调越乱。

第三个坑是写入前没有等到芯片退出复位。MAX30102 的 0x09 寄存器 bit6 是软复位控制,写 1 后芯片会重启,立即访问下一笔寄存器会收不到 ACK。解决方案是软复位后延时 10ms 以上,再用循环读中断状态寄存器确认芯片准备好。

3.2 必须配置的寄存器:模式、采样率、LED 电流和 ADC 脉宽

在拿到例程后,最值得逐位检查的是这三类寄存器。它们直接决定读回来的数据长什么样,也决定后续算法的输入质量。

寄存器地址关键位推荐配置
模式配置0x09bit1:0=模式0x03 SpO2 模式
SpO2 配置0x0Abit4:2=采样率,bit1:0=LED脉宽采样率 100Hz,脉宽 411μs
LED 脉冲幅度0x0CRed LED 电流初始 0x24 左右
LED 脉冲幅度0x0DIR LED 电流初始 0x24 左右
中断使能0x02FIFO 几乎满中断0x40
FIFO 写指针0x04只读读回确认
FIFO 数据0x07连续读 6 字节一轮 3 个通道数据

采样率不是越高越好。100Hz 对成年人静息心率绰绰有余,心率信号的基频在 1Hz 到 3Hz 之间,奈奎斯特频率远低于 50Hz。把采样率调成 1000Hz 只会让 FIFO 更快填满,中断更频繁,CPU 更多时间花在搬数据上。LED 电流需要按肤色和透光率调整,电流太小导致信号幅值只有几十个 LSB,斜率检测拿到的峰位置抖动大;电流太大又会让接收端饱和,波形顶部削平。例程里常写的 0x24 是约 6.4mA,暗肤色可以把两路都调到 0x40(约 11mA),但不要同时把两路调满。

3.3 FIFO 读取:一次读 6 字节还是一次读 1 字节

MAX30102 的 FIFO 数据寄存器地址是 0x07,每个样本包含 Red 和 IR 两个通道,每个通道 18 位,按 3 字节存放。连续读 6 字节可以拿到一个完整样本的两通道数据。很多 Demo 例程为了省功耗在主循环里先读 0x04 寄存器拿写指针,再计算要读多少字节,这种做法在多任务环境下反而复杂。更稳的方式是直接轮询 FIFO 写指针,发现新数据就读空 FIFO:

#define MAX30102_ADDR 0xAE #define REG_FIFO_DATA 0x07 void MAX30102_Read_All_Samples(uint32_t *red_buf, uint32_t *ir_buf, uint8_t *count) { uint8_t wr_ptr = 0, rd_ptr = 0; HAL_I2C_Mem_Read(&hi2c1, MAX30102_ADDR, 0x04, 1, &wr_ptr, 1, 50); HAL_I2C_Mem_Read(&hi2c1, MAX30102_ADDR, 0x06, 1, &rd_ptr, 1, 50); uint8_t available = (wr_ptr - rd_ptr) & 0x0F; // FIFO 深度 32 if (available == 0) return; uint8_t bytes[6]; for (uint8_t i = 0; i < available; i++) { HAL_I2C_Mem_Read(&hi2c1, MAX30102_ADDR, REG_FIFO_DATA, 1, bytes, 6, 100); red_buf[i] = ((uint32_t)(bytes[0] & 0x03) << 16) | ((uint32_t)bytes[1] << 8) | bytes[2]; ir_buf[i] = ((uint32_t)(bytes[3] & 0x03) << 16) | ((uint32_t)bytes[4] << 8) | bytes[5]; } *count = available; }

这段代码里,FIFO 深度的计算方式是 MAX3010x 系列的环形缓冲设计:写指针和读指针都是 4 位,差值按位与 0x0F 得到可用样本数。每次调用最多读 16 个样本,循环里单次读 6 字节是保证同一个样本的红外和红光对齐,绝对不能拆成两次 3 字节读。数据拼装时把 high byte 的低 2 位拼到 16 位低字节的高位,这是 18 位 ADC 结果的左右拆位方式。

需要注意 HAL 库的HAL_I2C_Mem_Read每次调用都有一段起始条件和终止条件,读 6 字节时总线会有微小间隔,但这在 100Hz 采样率下完全够用。不要因为想减少调用次数就自己拼一次读 96 字节的大事务,反而容易被 FIFO 中途覆盖。读回来的数据如果发现连续多个零,大概率是 LED 电流没配上或者传感器表面没有贴合手指;发现数据全满则可能是近端饱和。

4. 从原始数据到 BPM:滤波、峰值检测和心率计算

4.1 原始 PPG 信号的特征

从 FIFO 读出来的 raw 值不是心率波形,它包含一个很大的直流分量。环境光、组织静态吸收和静脉血共同构成了这个直流基线,动脉搏动带来的交流分量通常只有直流分量的 1% 到 3%。在波形上看,raw 值围绕一个缓慢变化的基线上下波动,每个心跳对应一个特征性的上升沿和下降沿,其中下降沿更陡峭,这是由心脏舒张期动脉压快速下降引起的。

很多 Demo 例程直接把 raw 值拿去找最大值和最小值算幅值,然后套一个固定阈值判断心跳,这样做在手指固定时勉强有效,但只要传感器被挪一下,直流分量跳变会立刻触发误检。正确的第一步是滤掉直流分量。常见做法是维护一个 1 秒长度的均值窗,用当前值减去窗口均值,得到近似交流信号。这个减法不需要引入复杂的 IIR 滤波器,循环队列的均值计算在 MCU 上开销很小。

4.2 用滑动均值和阈值做峰值检测

下面是适合 STM32F103 这类资源不宽裕平台的完整片段:

#define WINDOW_SIZE 100 // 1 秒数据, 采样率 100Hz int32_t raw_buf[WINDOW_SIZE]; int32_t ac_signal; int32_t dc_sum; void DC_Remove(int32_t new_raw) { static uint8_t idx = 0; static int32_t sum = 0; sum -= raw_buf[idx]; raw_buf[idx] = new_raw; sum += new_raw; idx = (idx + 1) % WINDOW_SIZE; dc_sum = sum / WINDOW_SIZE; ac_signal = new_raw - dc_sum; }

ac_signal就是削减基线后的交流分量。峰值检测再单独开一个状态机:

typedef enum { PEAK_LOOKING_HIGH, PEAK_LOOKING_LOW } peak_state_t; int32_t peak_threshold = 50; peak_state_t state = PEAK_LOOKING_HIGH; uint8_t Detect_Peak(int32_t ac) { static int32_t local_max = 0; uint8_t event = 0; if (state == PEAK_LOOKING_HIGH) { if (ac > local_max) local_max = ac; if (local_max > peak_threshold && ac < local_max - 30) { event = 1; // 检测到一个波峰 state = PEAK_LOOKING_LOW; } } else { if (ac < local_max / 2) state = PEAK_LOOKING_HIGH; local_max = 0; } return event; }

这组阈值需要根据传感器贴合的松紧度调整。如果手指压力过大,交流信号会被压平,local_max到不了阈值;如果手指抖动,交流信号瞬时幅值会冲到很高,检测器会在同一个心跳里触发两次。先通过串口把ac_signal打印出来观察,再定阈值,比盲目相信默认值更可靠。

BPM 的计算也很直接:记录相邻两次峰值检测事件的时间戳,取 60 秒除以间隔秒数。更稳的做法是维护最近 4 个间隔的中位数。比如采样率 100Hz,用 tick 计数,两次峰之间隔了 72 个样本,BPM 就是(60 * 100) / 72 = 83。不要用平均值,因为偶尔一个早搏或误检会拉偏计算结果。

4.3 中断读取和主循环计算怎么分工

中断里只做 FIFO 搬运,不做算法。把 raw 数据放到一个环形缓冲区,主循环每次读到新数据就执行 DC 去除和峰值检测。这样调度可以避免算法卡住中断,也能让 LCD 刷新不拖累采样节奏。注意 LCD 的 I2C 写操作耗时较长,接近 1ms 到 5ms,如果和传感器的 I2C 读取共用一条总线,两个设备的访问不能重叠。

另外,心率计算里必须加一个降级保护:如果连续 5 秒没有峰值事件,说明信号质量太差,LCD 上不要显示上一次计算出来的 BPM,而应该显示丢手势状态。这是一个低成本的质量指标。很多人把例程跑通后最先遇到的问题就是手指抬起来,LCD 还停在上一次的数值上,这正是缺少这个保护逻辑。

5. LCD 显示 BPM 和后续的调试方法

5.1 屏幕刷新策略和 I2C 总线占用控制

LCD 用 OLED 模块(常见为 SSD1306 驱动,地址 0x3C)时,刷新整屏 128x64 需要写入约 1KB 数据,在 400kHz 下耗时约 20ms。如果传感器每 10ms 出一个新样本,这 20ms 的阻塞会打乱采样节奏。不要在主循环里每次都整屏刷新,把显示内容固定为一行:时间戳、BPM、信号质量。只有 BPM 数值改变时才更新对应区域。

void LCD_Update_HeartRate(uint16_t bpm, uint8_t valid) { static char prev_buf[16] = {0}; char buf[16]; if (valid) snprintf(buf, sizeof(buf), "HR:%3d", bpm); else snprintf(buf, sizeof(buf), "HR:---"); if (strcmp(buf, prev_buf) != 0) { ssd1306_GotoXY(0, 2); ssd1306_Puts(buf, &Font_7x10, 1); ssd1306_UpdateScreen(); strcpy(prev_buf, buf); } }

这段代码把屏刷新频率降到心率变化频率级别。ssd1306_UpdateScreen()只做局部行更新时也需要内部转换,但至少避免每帧全屏重写。如果屏幕刷新时传感器 FIFO 漏读了,优先做两件事:把屏幕分段区域限制在一行以内,或者把传感器的 I2C 读取放到屏幕刷新之前最靠前的位置,确保新样本不会连续被丢。

5.2 信号质量验证的三种手段

例程跑通后,先不要直接贴到手指上看数字,按下面三种方式验证:

  1. 串口观察 raw 值。在 main 循环里用串口输出raw_buf[i]ac_signal,用上位机画波形,确认波形是否有周期性。
  2. 静置测试。传感器不贴合手指时,BPM 应显示无效状态,而不是跳出一个固定值。
  3. 对比测试。把手指放上去,用另一只手的桡动脉搏动数和 LCD 显示值做一分钟对比,误差在 ±3 以内是正常范围。

实际工程里最常见的坑是手指放置位置偏移。LED 和光电二极管之间的距离决定探测深度,模块的窗口中间位置一般是最佳贴合点,偏到边缘后信号会明显变弱。按压力度也很重要,太紧会压闭毛细血管,导致交流信号消失。

5.3 参数调整的顺序和最终自检流程

如果 BPM 偏快,优先怀疑峰值检测的阈值太低,一个心跳的反射波被当成第二个峰。把阈值调高 20% 再测。如果 BPM 偶尔中断,优先检查采样率配置是否和实际代码一致,寄存器配置 100Hz 但算法窗口按 50Hz 去算,BPM 会成倍跳。逐项核对寄存器表,再调算法,避免两个维度同时乱改。

最终验证按“先看原始数据、再看交流信号、最后看显示”的顺序执行,每一步都在串口上打点确认。把一节锂电池接到供电口,保证模块和屏幕不会互相抢压降。这个流程走完,LCD 上显示的 BPM 才拿着住。复位按键按下去的瞬间,观察屏幕是否黑一下再亮,如果屏幕长时间不亮,先把 I2C 两个引脚的示波器波形抓出来,再回头看 ACK 超时日志。

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

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

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

立即咨询