STM32 I2C读写心率传感器MAX3010x并显示BPM全攻略

STM32 I2C读写心率传感器MAX3010x并显示BPM全攻略 简介这套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不要接 5VMAX3010x 的绝对最大额定电压是 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是芯片级驱动管寄存器和 FIFOMiddlewares是心率算法层管滤波、峰峰值、BPMCore管引脚初始化和主循环。拿到例程先不要全盘编译先看主循环里调的是哪几个函数。如果出现max30102_read_fifo()或heart_rate_get_bpm()就顺着这两个函数把源文件找齐。文件关心什么MAX3010x.cI2C 读写函数、寄存器配置参数hr_algorithm.c滑动窗口大小、阈值、BPM 输出ssd1306.c屏幕刷新是否占用 I2C 总线main.c初始化顺序、主循环执行频率特别注意LCD和传感器是否挂在同一条 I2C 总线上。OLED 屏的 I2C 地址是 0x3CMAX3010x 的默认地址是 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; // PB6SCL, PB7SDA 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读回应为 0x15MAX30100 没有 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:0LED脉宽采样率 100Hz脉宽 411μsLED 脉冲幅度0x0CRed LED 电流初始 0x24 左右LED 脉冲幅度0x0DIR LED 电流初始 0x24 左右中断使能0x02FIFO 几乎满中断0x40FIFO 写指针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 信号质量验证的三种手段例程跑通后先不要直接贴到手指上看数字按下面三种方式验证串口观察 raw 值。在 main 循环里用串口输出raw_buf[i]或ac_signal用上位机画波形确认波形是否有周期性。静置测试。传感器不贴合手指时BPM 应显示无效状态而不是跳出一个固定值。对比测试。把手指放上去用另一只手的桡动脉搏动数和 LCD 显示值做一分钟对比误差在 ±3 以内是正常范围。实际工程里最常见的坑是手指放置位置偏移。LED 和光电二极管之间的距离决定探测深度模块的窗口中间位置一般是最佳贴合点偏到边缘后信号会明显变弱。按压力度也很重要太紧会压闭毛细血管导致交流信号消失。5.3 参数调整的顺序和最终自检流程如果 BPM 偏快优先怀疑峰值检测的阈值太低一个心跳的反射波被当成第二个峰。把阈值调高 20% 再测。如果 BPM 偶尔中断优先检查采样率配置是否和实际代码一致寄存器配置 100Hz 但算法窗口按 50Hz 去算BPM 会成倍跳。逐项核对寄存器表再调算法避免两个维度同时乱改。最终验证按“先看原始数据、再看交流信号、最后看显示”的顺序执行每一步都在串口上打点确认。把一节锂电池接到供电口保证模块和屏幕不会互相抢压降。这个流程走完LCD 上显示的 BPM 才拿着住。复位按键按下去的瞬间观察屏幕是否黑一下再亮如果屏幕长时间不亮先把 I2C 两个引脚的示波器波形抓出来再回头看 ACK 超时日志。本文还有配套的精品资源点击获取