先说我做完这台设备之后的真实感受:在串口助手里看到数字每秒钟跳一次,手指按上去波形稳定、心率停在 72、73 附近的时候,那种“数据真的被我读出来了”的成就感,比买一块智能手表强烈得多。这篇文章我就按照“从零开始”的路线,把用 STM32 和 MAX30100 做一台智能心率监测设备的完整过程拆开揉碎,重点说清楚每一步为什么要这么做。硬件选型、接线、Keli5 环境、I2C 读取、波形处理、心率算法、OLED 显示,一路到调试踩坑,全部覆盖。不管你之前只玩过 Arduino,还是刚接触 STM32 不久,只要愿意动手,这个项目都能完整跑下来。
1. 项目定位与整体设计思路
1.1 这个项目到底能做什么
这台设备的核心功能描述起来很简单:把手指放在传感器上,几秒钟之后屏幕显示出实时心率数值。但为了达到这个“简单”的目标,背后要打通一整条链路:MAX30100 传感器负责采集手指毛细血管的 PPG 光电容积脉搏波信号,STM32 通过 I2C 接口把原始数据读回来,经过滤波和算法处理得到心率值,最后显示在 OLED 屏幕上,同时通过串口上传到上位机。
选这个项目作为 STM32 入门的“第一台智能设备”,是因为它的技术跨度非常合适。你不需要搭复杂的电机驱动,也不用做 PID 闭环,但你必须学会裸机开发最基本的几件事:GPIO 操作、定时器、串口、I2C 通信、中断、数据处理。这几乎覆盖了 STM32 日常开发 80% 的核心技能。做完这个项目,后面再做温湿度计、智能台灯、遥控小车,你会发现套路基本都是相通的。
1.2 为什么用 STM32 而不是 Arduino 或者 ESP32
很多新手会纠结这个问题。Arduino 之所以简单,是因为它把寄存器细节全部封装掉了,写个Wire.read()就能读传感器。但正因为封装太好,你反而很难理解数据到底是怎么从传感器的寄存器里流到内存里的,一旦遇到 I2C 卡死、读回全 F、波形不对这类硬件问题,就毫无头绪。
STM32 的难度曲线确实更陡,尤其是环境搭建和工程配置这两步,能劝退一半的人。但只要跨过这个坎,后续逻辑和 Arduino 几乎一样,而你获得的却是对硬件的绝对掌控权。ESP32 更适合需要 WiFi 的联网项目,这个心率仪不需要联网,用 ESP32 反而增加了不必要的功耗和复杂度。从学习价值上看,STM32 是性价比最高的选择,这也是绝大多数高校课程和培训机构都用 STM32 做入门芯片的原因。
1.3 为什么选 MAX30100 传感器
MAX30100 是一颗集成了红光 LED、红外 LED、光电二极管、光接收电路、ADC 和 I2C 接口的传感器,封装很小,外围电路只需要极少的外部器件。它的工作电压范围是 1.8V 到 3.3V,可以直接由 STM32 的 3.3V 供电,逻辑电平也兼容,不用额外的电平转换电路。
更关键的是,它通过 I2C 接口输出的是已经经过内部 ADC 转换的数字量,你不需要自己搞模拟前端。比用它同系列的 MAX30102,MAX30100 多了温度传感器,虽然精度一般,但作为学习已经够用。网上关于 MAX30100 的 Arduino 驱动代码非常多,移植思路可以参考,可以大大降低开发难度。
1.4 整机架构和信号流向
整个系统的数据流大概是这样的:手指毛细血管的血流量随心跳周期变化 → MAX30100 内部的红外 LED 发出的光穿过手指,经过反射后被光电二极管接收 → 接收到的光强随血液体积变化 → 传感器内部 ADC 把这个光信号转换成数字值 → STM32 通过 I2C 定时读取这个数字值 → 在 STM32 内部做滤波和心率算法 → 把结果送往 OLED 和串口。
我在画系统框图的时候习惯把每个环节单独拆开,再标注数据格式。比如传感器读取的是 16 位无符号数,心率算法输出的是 uint8 的每分钟心跳次数,显示部分只关心这个最终结果。模块化思考的好处是,后面任何一个环节出问题,你都能快速定位到是通信问题、算法问题还是显示问题。
2. 硬件选型与接线方案
2.1 器件清单
这个项目的硬件成本可以压得很低,我在第一次搭的时候用的基本都是手头现成的东西。
| 器件 | 型号/规格 | 数量 | 说明 |
|---|---|---|---|
| 主控板 | STM32F103C8T6 最小系统板 | 1 | 64KB Flash,20KB RAM,性价比极高 |
| 传感器 | MAX30100 模块 | 1 | 注意买带引脚的,方便杜邦线连接 |
| 显示屏 | OLED 0.96寸 I2C 接口 | 1 | 4个引脚,接线简单 |
| 按键 | 轻触开关 | 1 | 用于切换显示模式 |
| 下载器 | ST-Link V2 或 J-Link | 1 | ST-Link 更通用也更便宜 |
| 电源 | USB 转 TTL + 5V 电源 | 1 | 也可以用充电宝加降压模块 |
| 其他 | 杜邦线、面包板、10kΩ 电阻 | 若干 | 用作 I2C 上拉 |
STM32F103C8T6 这个芯片可以说是 STM32 玩家人手一颗的“神板”,价格低、资料多、外设丰富,性能虽然比不上最新的 M4 系列,但跑一个心率算法绰绰有余。需要强调的是,MAX30100 模块最好选那种引出了所有引脚的版本,方便用杜邦线连接。有些模块是邮票孔封装,需要自己焊线,对新手不太友好。
2.2 引脚连接与接线图
这是整个项目最不能错的部分,接错了后面所有调试都无从谈起。我用的连接方式如下:
| MAX30100 引脚 | STM32 引脚 | 说明 |
|---|---|---|
| VIN | 3.3V | 电源正极 |
| GND | GND | 共地 |
| SCL | PB6 | I2C1 时钟线 |
| SDA | PB7 | I2C1 数据线 |
| INT | PB1 | 中断引脚,本次项目主要用于读取状态 |
OLED 模块的连接方式类似,SCL 接 PB6,SDA 接 PB7,VCC 接 3.3V,GND 接 GND。这样传感器和 OLED 共用同一组 I2C 总线,地址不同,不会冲突。MAX30100 的 7 位地址是 0x57,写入地址是 0xAE;OLED 的 7 位地址是 0x3C,写入地址是 0x78,两者互不干扰。
接线的时候有两点经验:第一,杜邦线尽量短,因为 I2C 线长了容易受干扰;第二,MAX30100 模块很可能已经自带上拉电阻,如果你发现通信不稳定,再在外挂两个 4.7kΩ 左右的电阻,一端接 SCL,一端接 SDA,然后各自接 3.3V。I2C 协议本身是开漏结构,必须要有上拉电阻才能正常工作,这一点后面还会详细讲。
2.3 电源设计与电容选择的影响
STM32 最小系统板上往往自带 AMS1117 线性稳压芯片,负责把 5V 或 USB 电压降为 3.3V。AMS1117 后端一般会放一个 10μF 的钽电容和 0.1μF 的陶瓷电容。有朋友问过“把钽电容换成陶瓷电容对 STM32 有没有影响”,我的实测结论是:AMS1117 这种线性稳压器对输出电容的 ESR(等效串联电阻)有一定要求,钽电容 ESR 一般比同容量陶瓷电容大,如果完全换成陶瓷电容,在负载瞬态变化剧烈时可能引起输出电压轻微震荡,严重时会导致 STM32 复位。
我的建议是输出端至少保留一个 10μF 的钽电容或电解电容,再并联一个 0.1μF 陶瓷电容做高频去耦。尤其是 MAX30100 内部 LED 是以脉冲方式点亮的,瞬时电流变化快,如果电源纹波太大,这些噪声会直接混进光信号数据里,导致后面的心率波形一塌糊涂。在我的项目里,我就吃过这个亏,后来在传感器电源引脚旁边加了一个 4.7μF 陶瓷电容,波形明显干净很多。
2.4 为什么要重视共地
很多新手接完线发现读数乱跳,或者干脆读不到数据,第一个想到的是代码问题,但很多时候是地线没接好。STM32、传感器、OLED,只要不是同一个电源供电,就必须把所有 GND 连到一起。数字电路里所有信号的高低电平,都是相对于公共地的电位差,地线不共,信号就是悬浮的,通信协议再对也没用。
我用面包板搭的时候,习惯把所有 GND 都接到同一条地轨,然后从 STM32 的 GND 引脚单独拉一根线到电源负极,避免电源线过长产生压降。这个小习惯后来帮我避免了好几次莫名其妙的复位和通信故障。
3. 开发环境搭建与工程初始化
3.1 软件工具链的选择
我推荐的环境是 Keil MDK 加 STM32CubeMX,这是目前 STM32 开发最主流的组合。STM32CubeMX 负责图形化配置引脚和生成初始化代码,Keil 负责编译和调试。对于新手来说,CubeMX 能避免很多手工配置寄存器时的低级错误,并且生成的代码是 HAL 库风格的,可读性强,网上资料也多。
Keil MDK 需要先安装芯片支持包,如果你是第一次用,一定要在 Pack Installer 里找到 STMicroelectronics 下面的 STM32F1 系列设备支持包并安装。否则你会遇到新建工程后找不到芯片型号,或者编译报错说device not found的情况。这一步网上很多人会忽略,但它是最常见的新手卡点。
3.2 新建工程的完整流程
用 STM32CubeMX 新建工程的步骤我尽量写详细一点:
第一步,打开 CubeMX,选择New Project,在芯片搜索框输入STM32F103C8,选中后双击进入配置界面。第二步,配置 RCC,把 HSE 设置为Crystal/Ceramic Resonator,让外部 8MHz 晶振作为系统时钟源。第三步,配置时钟树,在 Clock Configuration 页面把 HCLK 设为 72MHz。这一步要特别注意,STM32F103 最高主频就是 72MHz,如果倍频超过这个值,芯片可能直接跑飞。
接下来配置外设。在 Pinout 页面把 PB6 选为 I2C1_SCL,把 PB7 选为 I2C1_SDA。把 USART1 的 TX 引脚 PA9、RX 引脚 PA10 使能,用于串口输出调试信息。如果要用按键中断,可以把 PB1 设置为 GPIO_EXTI,并在 NVIC 里使能 EXTI1 全局中断。最后在 Project Manager 里设置生成工程的名字、选择 IDE 为 MDK-ARM,点击 Generate Code 就完成了。
3.3 时钟树配置的“为什么”
时钟树对 CAN 考 BootLoader 之外的绝大部分项目而言,像“心脏”一样重要。STM32F103 默认使用内部 8MHz HSI 时钟,但精度不高,而且外设时钟都是从这个总线上分频出去的,所以一般情况下我们都用外部晶振。CubeMX 的时钟树界面看起来挺复杂,其实只需关注几个关键点:PLL 倍频系数、AHB 分频、APB1 分频和 APB2 分频。
我习惯直接输入期望的 HCLK 值 72MHz,让 CubeMX 自动推算分频系数。需要注意的是 APB1 总线上挂着 I2C1,APB1 最高只能到 36MHz,所以分频结果通常是 APB1 等于 HCLK 的一半。这个值会直接影响后面 I2C 外设的时钟频率计算,CubeMX 会自动处理,但如果你手动改错分频系数,I2C 通信时序就会乱掉。
3.4 下载调试与烧录前的准备
Keil 工程生成后并不是马上能烧录,还需要进行几个设置。点击魔术棒图标,在Debug选项卡里选择你的下载器。如果你用的是 ST-Link,选ST-Link Debugger,J-Link 就选J-Link。然后在Settings里确认能找到芯片,并勾选Reset and Run,这样烧录完程序会自动复位运行,不用手动按复位键。
接下来是下载器与板子的接线:ST-Link 的 SWDIO 接 STM32 的 PA13,SWCLK 接 PA14,GND 接 GND,3.3V 接 3.3V。有不少新手烧录失败,报错No target connected,八成是这四根线接反了,或者 STM32 板子没有独立供电。还有一点:有的最小系统板把 PA13 和 PA14 做了其他用途,或者跳帽没插对,也会导致 SWD 识别不到芯片。
4. MAX30100 驱动:I2C 通信与寄存器配置
4.1 I2C 协议要点回顾
I2C 是一种只有两根线的串行通信协议,一根时钟线 SCL,一根数据线 SDA。主机通过改变 SCL 电平的节奏,配合 SDA 上的高低电平,向从机发送地址和寄存器地址以及数据。它的通信模式非常好理解:主机先发送起始条件,然后发送 7 位从机地址加一位读写标志位,从机应答后,主机发送要操作的寄存器地址,再写入或者读取数据。
MAX30100 是这个总线上的一个从机,地址是 0x57。在 STM32 HAL 库里面调用HAL_I2C_Mem_Read和HAL_I2C_Mem_Write时,传入的地址是 8 位地址,也就是 7 位地址左移一位,0x57 左移一位得到 0xAE。很多新手把库函数里的地址参数直接填 0x57,结果通信完全不工作,就是没搞清楚这个左移一位的机制。
4.2 MAX30100 的关键寄存器与初始化顺序
MAX30100 内部寄存器可以分为几类:中断控制、FIFO 控制、配置、数据输出和设备 ID。初始化的时候,我一般按这个顺序操作:
先通过读取设备 ID 寄存器来确认 I2C 通信正常。如果读回来的 ID 和 datasheet 不一致,说明连接有问题或者芯片没正常工作,这时候先别急着往下配置,把硬件查一遍。然后设置模式寄存器为心率模式,或者同时采集红光和红外两种数据;接着配置采样率和脉宽,采样率从 50Hz 到 1kHz 可选,我对这个项目的建议是 100Hz 就够了,再高数据量大,算法处理不过来,而且更容易混入高频噪声。
最后把 LED 电流配置调到一个合适的值。MAX30100 内部有两路 LED 驱动电流,分别控制红光 LED 和红外 LED 的亮度。电流太小信号就弱,电流太大容易饱和,输出会一直顶在最大值附近,波形直接削顶。这个参数需要根据手指按压的程度实际调试,我通常从中间值开始,然后观察原始数据幅值再微调。
4.3 驱动代码的核心实现
这里给出一段我在项目中使用的初始化代码,基于 STM32 HAL 库,核心逻辑可以直接抄过去用。
#define MAX30100_ADDR_WRITE 0xAE #define MAX30100_ADDR_READ 0xAF #define REG_MODE_CONFIG 0x06 #define REG_SPO2_CONFIG 0x07 #define REG_LED_CONFIG 0x09 #define REG_FIFO_WRITE_PTR 0x02 #define REG_FIFO_READ_PTR 0x04 #define REG_FIFO_DATA 0x05 #define REG_ID 0x0D void MAX30100_Init(void) { uint8_t id = 0; HAL_I2C_Mem_Read(&hi2c1, MAX30100_ADDR_READ, REG_ID, 1, &id, 1, 100); if (id == 0x11) { printf("MAX30100 online\r\n"); } // 复位 uint8_t temp = 0x40; HAL_I2C_Mem_Write(&hi2c1, MAX30100_ADDR_WRITE, REG_MODE_CONFIG, 1, &temp, 1, 100); HAL_Delay(10); // 模式:心率模式 temp = 0x02; HAL_I2C_Mem_Write(&hi2c1, MAX30100_ADDR_WRITE, REG_MODE_CONFIG, 1, &temp, 1, 100); // 采样率 100Hz,脉宽 1.6ms temp = 0x43; HAL_I2C_Mem_Write(&hi2c1, MAX30100_ADDR_WRITE, REG_SPO2_CONFIG, 1, &temp, 1, 100); // 红光 LED 电流 18mA,红外 LED 电流 18mA temp = 0x24; HAL_I2C_Mem_Write(&hi2c1, MAX30100_ADDR_WRITE, REG_LED_CONFIG, 1, &temp, 1, 100); }4.4 读取 FIFO 数据的两种方式
MAX30100 的采样数据存放在一个 32 字节深度的 FIFO 里,也就是最多存 32 个样本,超过就会覆盖旧数据。读取方式通常有两种:一种是轮询 FIFO 写指针和读指针,把有效数据全部读出来;另一种是配置好 FIFO 中断,当采样数据达到一定数量时触发中断,在中断服务函数里读取。
新手我建议先用轮询的方式,逻辑简单,容易调试。每次读取时先读取 FIFO 写指针,然后计算和读指针之间的差值,决定本次要读多少数据,最后把读指针往前推进。数据寄存器里每一个样本是 3 个字节,其中高 16 位是红外 ADC 数据,中间 12 位是红光 ADC 数据,读取时需要拼接处理。
我在读取时会直接采用块读的方式,把 FIFO 里的数据一次全读出来,然后按字节拼接。如果发现自己读到的值常常是 0xFFFF 或者毫无规律,多半不是读写函数的问题,而是前面寄存器的配置没有生效,或者传感器电源质量太差。检查顺序是:先确认 I2C 能读到正确的 ID,再确认模式寄存器里的值写进去之后能读回来,最后再怀疑数据本身。
5. 从原始数据到心率数值:信号处理与算法实现
5.1 读懂 PPG 波形的特征
MAX30100 输出的原始数据不是心率,而是一串随心跳波动的光强数值。心脏收缩时,手指毛细血管里血液量增加,吸收的红外光增多,反射回来的光强变弱,ADC 数值下降;心脏舒张时,血量减少,ADC 数值回升。所以它的波形大致是每隔一次心跳出现一个“谷底—山峰—谷底”的形态。
如果直接用串口把原始数据打印出来,你看到的是一串在不断上下抖动的数字。这个抖动幅度通常只在原始值的百分之几到百分之十几之间,所以不能直接拿原始值做阈值判断。很多新手第一次读数据后心想“这好像也就是一堆乱数”,原因是忘了把直流分量去掉。在数学上,这个直流分量相当于信号里的一个很大的常数偏移,后面做高通滤波或减去滑动平均值就能消掉。
5.2 信号预处理:滑动平均与高通滤波
我用的信号处理链路简单但有效:先做一次窗口长度为 5 到 7 的滑动平均,用来去除高频噪声;然后计算一个较长窗口的滑动平均值,把它作为基线,再用原始数据减去基线,得到去除直流分量的交流信号。
这个思路和调 PID 的时候先看数据波形再做滤波是类似的。很多做嵌入式的朋友习惯一上来就搞复杂的滤波器设计,比如 FIR、IIR,但在这个项目的第一版里,滑动平均已经能满足要求。它的参数也直观:窗口大了数据更平滑但实时性差,窗口小了噪声压不住。我用 5 点滑动平均处理 100Hz 采样率的数据,实测延迟在一个可接受的范围,心率值稳定。
下面是我在代码里实现的一个简化版高通效果:
#define WINDOW_SIZE 5 int32_t raw_buf[WINDOW_SIZE]; int32_t raw_sum = 0; uint8_t raw_index = 0; // 累加新样本,并减去最老的样本,得到窗口内总和 int32_t MovingAverageFilter(int32_t new_sample) { raw_sum -= raw_buf[raw_index]; raw_buf[raw_index] = new_sample; raw_sum += new_sample; raw_index++; if (raw_index >= WINDOW_SIZE) { raw_index = 0; } return raw_sum / WINDOW_SIZE; }5.3 心率计算的简化方案:阈值+过零检测
在干净的交流波形上,每两次心跳之间会跨过一次中值线,也就是信号从上方穿过中值线再到下方再上来。最简单的检测办法是:记录信号从上方穿过中值线的那一个点,认为这是一次心跳;两次穿过之间的时间差就是心跳周期,心率等于 60 秒除以这个周期。
这个方法不算最准,因为如果有干扰信号,会出现误触发或漏触发。但对静态测心率已经足够,而且算法简单到新手可以直接看懂。我在代码里维护了一个记录上一次通过中值线时间戳的变量,每检测到一次有效的穿越事件,就计算当前时间和上次时间的差值,如果差值在合理的 300ms 到 1500ms 之间(对应 40 到 200 次/分钟),就更新一次心率值。
需要注意,中值线不能是固定的,因为手指按压的力度会改变信号幅值,基线会慢慢漂移。所以中值线要动态更新,最简单的办法是使用这几十个滑动平均结果的平均值作为中值线。
5.4 串口波形辅助调试
在调试算法时,我最推荐的做法是把波形数据通过串口发到上位机,用串口绘图工具直观地看波形。数据格式可以用类似 CSV 的形式:第一列是原始值,第二列是滤波后的 AC 分量,第三列是检测到的心跳标记值。这里的心跳标记值平时为 0,检测到一次心跳就输出一个较大的数值,这样在图形里能看到一个明显的脉冲,方便判断检测是否准确。
我刚开始调的时候,心率值一直在 120 以上乱跳,通过波形图才发现是信号基线漂移导致过零检测不停误触发。后来把动态基线加进去,同时设置了一个 120ms 的最小间隔时间,两次检测之间不能小于这个时间,误触发的情况立刻少了很多。这其实很像调 PID 的过程——先看曲线,再改参数,再验证,而不是瞎猜。
6. 显示与按键交互:让设备成为“成品”
6.1 OLED 驱动的选择
OLED 模块我倾向于使用常见的 SSD1306 驱动芯片,网上有非常成熟的软件库可以移植。你只需要实现两个底层函数:写一个字节到 I2C,以及写入一个 I2C 命令。如果你的 OLED 是 0.96 寸 128x64 分辨率的,初始化序列是标准的,网上到处都能找到。
为了减少代码量,我暂时没有使用 U8g2 这种重量级图形库,而是自己写了一个极其精简的字符显示函数,只支持 ASCII 字符和数字。这样既能让项目保持简单,又能让你看到显示驱动的基本原理。后面如果想让界面更好看,可以再移植 LVGL 或者 U8g2,不过那是等核心功能稳定之后才做的事。
6.2 心率数值与状态显示
OLED 屏幕上我设计了三个区域:最上面一行显示系统状态,比如“FINGER OK”或者“PLEASE COVER”;中间区域用大号字体显示实时心率数值;最下面一行显示上一次检测到心跳的时间间隔,方便看算法是否正常。
显示逻辑放在一个简单的状态机里。默认状态下,设备每 100ms 读一次传感器数据,然后刷新屏幕。如果算法检测到连续 10 秒都没有有效的心跳,界面会提示用户重新调整手指位置。这个交互设计虽然简单,但能让设备使用体验接近真正的商品。
6.3 按键切换显示模式
我在项目中加了一个按键,用来切换显示模式:第一种模式只显示心率,第二种模式除了显示心率还把原始波形画成简单的曲线,第三种模式显示传感器温度和 LED 电流配置。按键检测我用的是外部中断加按键扫描,在中断服务函数里置一个标志位,主循环检测到标志位就切换模式。这里有一个新手容易踩的坑:按键会机械抖动,需要做消抖处理。最简单的消抖方式是在中断里加一个 10ms 到 20ms 的延时,或者记录按键按下的时间戳,判断连续稳定后才认为是一次有效按下。
7. 实测过程与大坑记录
7.1 问题一:I2C 扫描不到传感器地址
我一开始遇到的第一个问题,是设备完全无法从 MAX30100 读到数据,I2C 扫描也找不到任何地址。排查思路是一条条硬件链路往后查:先用万用表确认传感器 VIN 对地电压是否为 3.3V,然后确认 GND 是否连通,再检查 SCL 和 SDA 引脚上的电平是否被拉高。结果发现我的面包板有问题,SDA 脚接触不良,导致信号根本没发出去。
后来换了一根杜邦线,问题就消失了。这个经历让我养成了一个习惯:通信问题先怀疑硬件连接,再怀疑软件。不要一上来就改代码,浪费时间。如果四根线都没问题,再测一下上拉电阻是否有 4.7kΩ 左右的阻值。没有上拉电阻,I2C 引脚会一直处于低电平,自然无法通信。
7.2 问题二:心率数值乱跳
这个问题的典型表现是:波形能看到,心率也能算出来,但数值一会儿 70 一会儿 150。我排查后发现两个原因。第一个原因是手指按压的力度不均匀,压得太紧会把毛细血管压扁,信号消失;压得太松光通路不稳定,信号太弱。我用一个夹子固定手指之后,情况改善了很多。第二个原因是算法参数没有设好,里面的过零检测太灵敏,把一些噪声毛刺当成了心跳。
解决办法是在检测到一次过零之后,加入一个 150ms 到 200ms 的锁定时间,在这段时间内不检测第二次过零,从而避免高频误触发。同时把动态基线窗口调大一点,让基线更平滑,也能减少误检。经过这两处调整,心率值稳定在 ±3 次/分钟以内。
7.3 问题三:Keil 烧录失败和延时卡死
有朋友在移植这个项目时遇到 Keil 烧录失败,报错找不到目标芯片,或者点了下载后程序没反应。常见原因是芯片包没装对、下载器配置选错、BOOT0 跳帽没有接到 GND。STM32F103 的 BOOT0 引脚如果接高电平,芯片会进入系统存储器引导模式,这时候 SWD 下载虽然基本不受影响,但用户程序是从 Flash 启动的,如果它检测到异常可能会一直停在启动阶段,程序无法正常跑起来。
还有一个非常容易踩的坑是HAL_Delay卡死。这个函数依赖 SysTick 中断,如果你在别的地方把 SysTick 的中断优先级改动得异常,或者关掉了全局中断,HAL_Delay就会一直等不到中断,程序卡在延时函数里。在调试这个项目时,我没有碰中断优先级,但有人因为开了其他外设中断且配置不当,导致HAL_Delay失效,整块板卡死。如果遇到这种情况,检查 SysTick 优先级是否正常、是否有关中断指令没有恢复。
7.4 常见问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| I2C 扫描不到 0x57 | 接线接触不良、上拉电阻缺失、传感器供电异常 | 逐段测量电压和通断,再试 |
| 读回的 ID 不对 | 芯片型号不对、通信时序不对、地址左右移位搞错 | 确认写入地址是 0xAE,读地址 0xAF |
| 原始数据大部分是 0xFFFF | 传感器未正确上电、LED 电流没配置好 | 检查电源、复位寄存器、LED 电流寄存器 |
| 心率值持续偏高 | 过零检测误触发、窗口太小 | 加锁定时间、调大基线窗口 |
| 心率值持续偏低 | 漏检、信号幅值太小 | 增大 LED 电流、调整手指位置 |
| 波形有高频毛刺 | 电源噪声、室内光源干扰 | 加强滤波、靠近 LED 附近加电容 |
| 程序烧录后无反应 | BOOT0 跳帽配置不对、时钟配置有误 | 检查 BOOT0 和时钟树 |
HAL_Delay卡死 | SysTick 中断被关闭或优先级异常 | 检查中断配置,确保 SysTick 正常 |
8. 后续扩展与几点个人体会
这个项目做到能稳定显示心率,已经算是一个完整的嵌入式系统了。如果还想继续深入,有三个方向我认为延展性很好:一是加蓝牙模块,比如 HC-05 或者低功耗的 BLE 模块,把数据传到手机 App,这样可以积累长时间的心率趋势数据;二是换更高性能的传感器和 MCU,比如 MAX30102 搭配 STM32L4 系列,再把算法换成 FFT 或者更精确的峰值检测,可以提取血氧饱和度、呼吸率等多维指标;三是加无线数传模块,变成远程健康监测节点,配合低成本无线模块也可以做病房多点监控的雏形。
做这个项目花了我两个周末的时间,踩了不少坑,但收获非常大。我最想分享的一个体会是:嵌入式项目里 70% 的时间是在和硬件纠缠,而不是写代码。I2C 读不到数据、波形不对、数值乱跳,这些问题靠翻 datasheet 和画波形图解决,比靠改代码要快得多。还有一点就是,参数调试要有耐心,每次只改一个变量,改完立刻观察波形或者心率值的变化,不要一次动好几个地方,否则出了问题根本猜不到是哪个改动引起的。
最后分享一个小技巧:调试心率算法的时候,我习惯把传感器数据通过串口存成 CSV 文件,然后用 Excel 或者 Python 打开画一下波形,跟屏幕上看到的效果对比。这样能准确统计误检和漏检的次数,比肉眼看数字直观很多。只要把这个调试流程跑顺,后面做任何带传感器的项目,你都会有底气。