ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32+MAX30102+DS18B20+OLED体征监测系统源码解析

STM32+MAX30102+DS18B20+OLED体征监测系统源码解析

简介:这套源代码是一份基于STM32单片机的智能健康监测手环毕业设计/课程设计项目,集成MAX30102心率血氧传感器、DS18B20温度传感器与OLED屏幕,可采集心率、血氧和体温数据并实时显示,同时通过串口发送到电脑端调试助手,适合嵌入式方向学生参考或在此基础上扩展二次开发。资源包为RAR压缩格式,共254个文件、约7.05MB,主要包含C源码、H头文件、Keil工程文件(.uvprojx/.uvoptx)以及编译生成的.o、.axf、.map等目标与调试文件,工程结构清晰,可在Keil中直接打开和重新编译。代码涵盖传感器驱动、外设初始化、OLED显示与串口通信模块,能帮助理解STM32从数据采集、处理到传输的完整流程;项目也便于按需求增加蓝牙、上位机等功能,作为毕业设计或课程设计的基础平台较为合适。目前已有1314人学习,是一份实用性与可扩展性兼备的健康监测参考项目。

1. 一屏看全的体征监测:这套源代码解决什么问题

把 STM32、MAX30102、DS18B20、OLED 这四样凑在一起,最常见的落地场景是做一个桌面小护士:手指往 MAX30102 上一搭,OLED 上同时出现心率、血氧饱和度、体温三个数。很多基于 STM32 的毕业设计选的就是这套组合,因为它把 I2C、单总线、显示驱动、信号处理一次全练到,硬件成本也压得住。拿到这类源代码包的人,多数不是冲着某一个算法去的,而是想要一条能直接跑通的路径:知道怎么初始化传感器、怎么把原始数据变成能看的数字、怎么让显示不拖后腿。这篇文章就沿着烧录前的核对、三路传感器的读写、数据融合和排错往下讲。适合正在做课设、准备电赛作品,或者第一次把多传感器项目拼起来的读者。

2. 为什么都是这三颗料:传感器原理与选型理由

不少人在拿到这套源码时会先问一句:MAX30102 和 DS18B20 能不能换更便宜的?能换,但换了以后代码里一半的功夫就白费了。这套组合的价值在于每颗传感器都代表一种典型的嵌入式通信方式:MAX30102 走 I2C,DS18B20 走单总线,OLED 屏也是 I2C(少数是 SPI),一个工程里同时出现两条总线、两种时序,这才是题目真正想练的东西。

2.1 MAX30102:I2C 访问的光电容积脉搏波黑匣子

MAX30102 的封装很小,内部把红光 LED、红外 LED、光电二极管和 18 位 ADC 全塞在一起,外部只需要很少的被动器件就能工作。它采集的是 PPG 信号:血液搏动时,手指内血容量周期性变化,对光的吸收也跟着变,光电二极管把透过手指的光转换成电流,ADC 再量化成数值。红光波长约 660nm,红外约 880nm,血氧饱和度靠这两路光的吸收差来推算,心率则从任意一路信号的周期性波动里提取。

从代码角度看,这颗传感器是个典型的 I2C 从设备,7 位地址 0x57,写地址 0xAE、读地址 0xAF。寄存器不多,但有几个必须搞清楚:模式配置寄存器 0x09 要写成 0x03 才同时开启心率和血氧测量;SpO2 配置寄存器 0x0A 控制 LED 脉宽和采样率,常见写成 0x27 表示 411\mu s 脉宽、100Hz 采样率;LED 电流寄存器 0x0C 和 0x0D 决定红光和红外的发射强度,写在 0x40 附近通常能拿到比较明显的脉搏波。数据不是直接读某个寄存器就完事的,它先落到内部 FIFO,再通过 0x04 到 0x07 这几个 FIFO 数据寄存器依次读出来。

这颗传感器像黑匣子的地方在于,它给你的只是 ADC 数,不是现成的心率和血氧。能不能算出正确数字,取决于你手指按得实不实、LED 电流调没调对、FIFO 有没有读空。很多人烧完代码看到屏幕上心率不是 60 多而是 140,第一反应是算法坏了,其实多半是原始信号里根本没有干净的脉搏波。

2.2 DS18B20:一根线的测温时序课

DS18B20 走的是单总线(1-Wire),数据线和供电可以共用一根线,再加上一根地,就够跑了。但省线的代价是时序要求严格:所有读写都靠主机在总线上制造特定宽度的低电平脉冲,从机用不同的响应时间来区分 0 和 1。代码里最常见的是 GPIO 模拟时序,因为 STM32 没有原生单总线外设,HAL 库也不管这件事。

整个读温度过程分三段:复位、发 ROM 命令、发功能命令。复位时主机拉低总线 480\mu s 以上然后释放,从机会在 60 到 240\mu s 后把总线拉低给出存在脉冲;接下来写 0xCC 跳过 ROM 匹配(只挂一颗传感器时用),写 0x44 启动温度转换,再等一段时间;最后重新复位,写 0xBE 读暂存器,连续读两个字节得到温度原始值。温度分辨率默认 12 位,转换时间最长 750ms,这个等待时间是很多人没耐心等的,但跳过它读到的永远是上次的结果。

如果工程里挂了不止一颗 DS18B20,0xCC 就行不通了,得先发 0x33 读每颗传感器的 64 位 ROM 序列号,再通过 0x55 匹配指定那颗。题目里既然只打包了一颗,那 0xCC 就是够用的路径。DS18B20 的实物长得很像三极管,封装上印着型号,没有字的那面是背面,引脚顺序别记反,经常有人把电源和地焊反,一上电传感器直接发热报废。

时序实现要靠微秒级延时。标准库时代大家都用 SysTick 或者空循环数数,HAL 库下建议用 DWT 或者定时器做延时,因为空循环在不同主频和优化等级下误差很大。很多翻车现场不是逻辑错,是延时不准:复位总线的低电平只拉了 200\mu s,感应不到存在脉冲,read 到的全是 0xFF。

2.3 OLED 和主控:显示刷新与采集调度的关系

0.96 寸 128x64 的 OLED,驱动芯片几乎都是 SSD1306,接口最常见是 I2C,地址 0x3C 或 0x3D。它内部自带一块 1KB 的 GDDRAM,你要显示什么,就是往这块显存里写字节。写进去之后屏自己刷新,MCU 不用管保持显示这件事。这意味着刷屏的代价全部花在 I2C 传输上:全屏更新一次要传 8 页 x 128 列,差不多 1KB 数据,在 400kHz 的 I2C 下大约 20ms 上下,听起来不多,但如果每 100ms 刷一次全屏,I2C 总线就快被占满了,MAX30102 的 FIFO 读取会被反复延后。

常见做法是用局部刷新:哪块区域的数据变了,就只改那块。比如心率和血氧数字每秒变一次,那就每秒只更新对应坐标上的数字;温度和显示字样基本不变,只在变化时重画。标题里的显示器是 OLED,很多人会顺带研究 HAL 库驱动 OLED 代码,里面最核心的不是画点函数,而是初始化命令序列和坐标到显存地址的换算。SSD1306 的显存按 8 像素一页组织,y 坐标要除以 8 得出页地址,再用较低位决定字节内位置,这个换算错一格,显示的就是乱码或者错位。

主控选型上,F103C8T6 是这套方案最常见的选择,因为 64KB Flash 够放下 HAL 库、两个传感器的驱动和显示字库,20KB RAM 也够开采样数组。C8T6 没有硬件 I2C 的坑多数人早就绕开了,直接 GPIO 模拟 I2C 做 OLED 反而更省心。时钟树配置上,一般用外部 8MHz 晶振倍频到 72MHz 给内核,I2C 外设时钟从 APB1 来。如果用的是国产替代芯片,引脚兼容但 Flash 容量可能不同,烧录前要在 Keil5 里确认芯片型号和包版本,免得代码里地址越界。

3. 把源码包跑成实物:工程结构、初始化与最小可运行步骤

源码包拿到手,第一步不是打开 main.c 从头读,而是先把工程结构和引脚映射核对清楚。这类多传感器项目最怕的就是引脚对不上,代码写得再漂亮,PB6 和 PB7 接错了线,屏幕上就永远是白板一块。

3.1 拿到项目先核对这三处,别急着烧录

先看工程结构。一个典型的 STM32 工程,不管是标准库还是 HAL 库,通常长这样:

Project/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── max30102.h │ │ ├── ds18b20.h │ │ └── oled.h │ └── Src/ │ ├── main.c │ ├── max30102.c │ ├── ds18b20.c │ └── oled.c ├── Drivers/ │ └── STM32F1xx_HAL_Driver/ ├── MDK-ARM/ │ └── Project.uvprojx └── ioc/ └── Project.ioc

拿到后第一件事,打开 .ioc 文件(如果是 CubeMX 工程)或者直接看 GPIO 初始化代码,确认三个传感器的引脚分配。常见分配是:MAX30102 连在 I2C1 的 PB6/PB7,OLED 也用 I2C1 或者用软件 I2C 挂在别的 IO 上,DS18B20 单独占一个 GPIO,比如 PA0。第二件事,确认 Keil5 里的芯片型号和宏定义。F103 系列要检查 STM32F10X_HD 还是 STM32F10X_MD,标准库工程经常因为宏不对导致编译报错或者外设时钟打不开。芯片包没装的话,在 Keil5 的 Pack Installer 里按芯片型号补装,不然打开工程会提示 Device 不存在。

第三件事,看延时函数用的什么实现。HAL 库工程里如果直接用 HAL_Delay(1) 来做微秒级延时,那是一定翻车的,因为 HAL_Delay 的最小粒度是 1ms。正常源码包里会自己写 delay_us,要么用 SysTick,要么用 DWT。没有这个函数的话,后边 DS18B20 的时序怎么调都没用。这块花五分钟核对,比烧录后对着逻辑分析仪猜半天省事得多。

3.2 MAX30102 初始化与 FIFO 读取代码

MAX30102 的驱动核心就两个动作:写寄存器初始化,读 FIFO 拿原始数据。用 HAL 库写 I2C 发送接收,代码很直接:

#define MAX30102_ADDR_W 0xAE #define MAX30102_ADDR_R 0xAF #define MAX30102_REG_FIFO_DATA 0x05 static int32_t max30102_write_reg(uint8_t reg, uint8_t val) { uint8_t buf[2]; buf[0] = reg; buf[1] = val; return HAL_I2C_Master_Transmit(&hi2c1, MAX30102_ADDR_W, buf, 2, 100); } static int32_t max30102_read_fifo(uint8_t *buf, uint8_t len) { return HAL_I2C_Mem_Read(&hi2c1, MAX30102_ADDR_R, MAX30102_REG_FIFO_DATA, I2C_MEMADD_SIZE_8BIT, buf, len, 100); }

初始化序列放在 max30102_init 里,常见顺序是:先关中断、写模式寄存器、写 SpO2 配置、设置 LED 电流、清 FIFO,最后再开中断:

void max30102_init(void) { max30102_write_reg(0x02, 0x40); // 中断使能寄存器,只开 FIFO 几乎满中断 max30102_write_reg(0x01, 0x00); // 关所有中断标志 max30102_write_reg(0x09, 0x03); // 模式:心率 + 血氧同时测 max30102_write_reg(0x0A, 0x27); // SpO2 配置:100Hz 采样,411us 脉宽 max30102_write_reg(0x0C, 0x40); // 红光 LED 电流,约 8mA 量级 max30102_write_reg(0x0D, 0x40); // 红外 LED 电流 max30102_write_reg(0x0E, 0x01); // 禁止多点采样,单点模式 max30102_write_reg(0x04, 0x00); // FIFO 写指针归零 max30102_write_reg(0x05, 0x00); // FIFO 读指针归零 max30102_write_reg(0x06, 0x00); // FIFO 溢出计数器清零 }

说明一下几个关键参数。0x09 写成 0x03 是双 LED 模式,如果只测心率,可以写 0x02,这样红外 LED 不工作,省一点电流。0x0A 的低三位是采样率控制,0x27 对应 100Hz;这个频率对脉搏波足够了,不必开满 400Hz,否则 FIFO 很快堆满,主循环一卡就读溢出。LED 电流寄存器 0x40 不是绝对数值,它由两个字段拼成,高 5 位是电流档位,低 3 位是微调,0x40 表示约 8mA 附近。实际按手指肤色和遮挡程度,可以整体往上调,但别超过 0x7F,太亮会饱和,波形顶部削平,算法反而算不准。

FIFO 读取时每次读几个字节有讲究。MAX30102 的每个样本是 6 字节:先是红光三字节,再是红外三字节,每路取低 18 位有效。典型做法是一次读 6 个字节组一个样本,或者一次读 30 字节拿 5 个样本。代码里要注意的是,FIFO 数据寄存器是 0x05,寄存器 0x04 是写指针;有人把读地址写成 0x04,会把指针值当数据读出来,那波形自然是乱的。

3.3 DS18B20 的读时序与温度换算

DS18B20 驱动是这套代码里最考验延时的地方。用 GPIO 模拟时,先把引脚配置成开漏输出并外接 4.7k 上拉,或者直接在代码里切换输入输出模式。一个常见的读温度函数骨架如下:

uint8_t ds18b20_reset(void) { uint8_t presence = 1; DS18B20_GPIO_MODE_OUTPUT(); DS18B20_PIN_LOW(); delay_us(500); // 主机拉低至少 480us DS18B20_PIN_HIGH(); DS18B20_GPIO_MODE_INPUT(); delay_us(70); // 等从机响应窗口 presence = DS18B20_PIN_READ(); delay_us(410); // 补齐存在脉冲后的时间 return presence; // 返回 0 表示检测到设备 } uint8_t ds18b20_read_byte(void) { uint8_t val = 0; for (int i = 0; i < 8; i++) { val >>= 1; DS18B20_GPIO_MODE_OUTPUT(); DS18B20_PIN_LOW(); delay_us(2); DS18B20_PIN_HIGH(); DS18B20_GPIO_MODE_INPUT(); delay_us(10); if (DS18B20_PIN_READ()) { val |= 0x80; } delay_us(50); } return val; } float ds18b20_get_temp(void) { uint8_t lsb, msb; int16_t raw; if (ds18b20_reset()) { return 85.0f; // 复位失败,返回一个明显异常的值 } ds18b20_write_byte(0xCC); // 跳过 ROM ds18b20_write_byte(0x44); // 启动温度转换 while (!ds18b20_read_bit()); // 等待高温就绪信号,最多约 750ms ds18b20_reset(); ds18b20_write_byte(0xCC); ds18b20_write_byte(0xBE); // 读暂存器 lsb = ds18b20_read_byte(); msb = ds18b20_read_byte(); raw = (int16_t)((msb << 8) | lsb); return raw * 0.0625f; // 12 位分辨率,每一位代表 0.0625 度 }

这里有个容易踩的细节:温度转换结束后,DS18B20 会把总线拉低表示转换完成,所以读转换状态用的是读一位,而不是固定 delay 750ms。写成固定 delay 也能用,但要浪费 750ms,而且主线在等,FIFO 数据会越堆越多。用读一位的方式,常规温度下转换时间大约是 100 到 200ms,速度上来不少。原始温度值是有符号数,负温度下 lsb 和 msb 拼成的 16 位值直接用 int16_t 接收再乘 0.0625 就行,不需要额外处理补码。

延时函数建议直接用 DWT 实现,因为 SysTick 在 HAL 库下已经被 HAL_Delay 占用了。主频 72MHz 时,DWT 的 CYCCNT 每计数一次是 1/72 微秒,用两个时刻的差值就能算准微秒延时,不会受中断影响,这是单总线时序最省心的量法。

3.4 OLED 显示配置命令:HAL 库驱动与界面布局

OLED 这部分有两种写法:硬件 I2C 用 HAL 库,软件 I2C 用 GPIO 模拟。用 HAL 库驱动 OLED 代码时,核心是初始化命令序列和写显存两个函数:

static void oled_write_cmd(uint8_t cmd) { uint8_t buf[2] = {0x00, cmd}; // 控制字节 0x00 表示后续是命令 HAL_I2C_Master_Transmit(&hi2c1, 0x3C << 1, buf, 2, 100); } static void oled_write_data(uint8_t data) { uint8_t buf[2] = {0x40, data}; // 控制字节 0x40 表示后续是数据 HAL_I2C_Master_Transmit(&hi2c1, 0x3C << 1, buf, 2, 100); } void oled_init(void) { oled_write_cmd(0xAE); // 关闭显示 oled_write_cmd(0x20); // 设置内存寻址模式 oled_write_cmd(0x00); // 水平寻址模式,坐标递增最直观 oled_write_cmd(0xC0); // 扫描方向,从左上角开始 oled_write_cmd(0x8D); // 电荷泵设置 oled_write_cmd(0x14); // 开启电荷泵,这一步漏了屏会不亮 oled_write_cmd(0xAF); // 打开显示 oled_clear(); }

初始化命令里最容易被漏掉的是 0x8D 和 0x14 这对电荷泵命令。SSD1306 内部升压电路不开启时,即使电源供电正常,屏幕也是一片黑。另外 0x20 之后必须跟一个参数指定寻址模式,有人说「我初始化没写这条也能显示」,那是因为模块默认值恰好是水平寻址,但不写总归是隐患。

写数据时控制字节是 0x40,和命令的 0x00 不一样。很多人因为只改了命令没改数据段,写显存变成了写命令,结果屏幕花得一塌糊涂。显存写入要先把列地址和页地址设置到位,再连续写入字节。128x64 的屏分 8 页,每页 128 字节,页地址寄存器 0xB0 到 0xB7,列地址低四位由 0x00 到 0x0F 指定,高四位由 0x10 到 0x1F 指定。写一个中文字模就是把这个字对应的 16x16 像素点拆成 4 页写入,坐标换算有一行算错就整体偏移。

界面布局上,一行放一个数据比较合适,比如第一行写 HR,第二行写 SpO2,第三行写 Temp,第四行留空或者放个小状态。每秒刷新一次数值就好,不要每 100ms 重画一次,OLED 虽然响应快,但 I2C 带宽和 MCU 时间都耗不起。

4. 三路数据合成一屏:采样、滤波、心率血氧算法与刷新节奏

传感器驱动通了,屏幕上开始出数,但这只是第一步。MAX30102 读出来的是波动值,DS18B20 读出来的是 0.0625 度的原始温度,OLED 要显示的是人能一眼看懂的心率、血氧、体温。中间这一步换算和滤波,决定了这套源代码是好用还是只能演示。

4.1 心率与血氧的数据换算逻辑

心率的最常见算法是时域峰值检测:从红外信号里找脉搏波的波峰,两个相邻波峰的时间间隔就是一个心动周期,60 除以这个间隔就是每分钟心跳数。工程上不会直接数波峰,而是先维护一个滑动窗口,窗内数据做基线漂移消除,再用阈值和最小间距双重筛选峰,避免把噪声当心跳。

#define PPG_WINDOW 200 // 200 个样本,100Hz 采样下对应 2 秒 static uint32_t ir_buf[PPG_WINDOW]; static uint32_t red_buf[PPG_WINDOW]; static int16_t find_peaks_and_calc_hr(uint32_t *buf, uint32_t len) { int32_t baseline = 0; for (uint32_t i = 0; i < len; i++) { baseline += buf[i]; } baseline /= len; // 滑动窗口均值,当作基线 int16_t peaks = 0; uint16_t last_peak = 0; uint16_t min_interval = (uint16_t)(100 * 60 / 200); // 心率上限 200BPM 对应的最小间隔 for (uint32_t i = 2; i < len - 2; i++) { if (buf[i] > (uint32_t)(baseline * 1.1f)) { // 左右各比 5 个点高,才算一个峰 if (buf[i] > buf[i-1] && buf[i] > buf[i-2] && buf[i] > buf[i+1] && buf[i] > buf[i+2]) { if (peaks == 0 || (i - last_peak) >= min_interval) { peaks++; last_peak = i; } } } } return peaks; } static float calc_spo2(uint32_t *red, uint32_t *ir, uint32_t len) { int32_t ac_r = 0, dc_r = 0, ac_i = 0, dc_i = 0; for (uint32_t i = 0; i < len; i++) { dc_r += red[i]; dc_i += ir[i]; } dc_r /= len; dc_i /= len; for (uint32_t i = 0; i < len; i++) { ac_r += abs((int32_t)red[i] - dc_r); ac_i += abs((int32_t)ir[i] - dc_i); } ac_r /= len; ac_i /= len; if (ac_i == 0 || dc_r == 0 || dc_i == 0) { return 97.0f; // 数据异常时给一个保守值 } float r = ((float)ac_r / dc_r) / ((float)ac_i / dc_i); float spo2 = 110.0f - 25.0f * r; // 经验公式,只适合工程演示 if (spo2 > 100.0f) spo2 = 100.0f; if (spo2 < 70.0f) spo2 = 70.0f; return spo2; }

说明一下上面这段的关键参数。窗口长度 200 对应 2 秒数据,心率 60 时一个周期约 60 个样本,2 秒内能抓到两个峰,够算一次;窗口太短峰数不够,太长则心率变化被平均掉,反应迟钝。min_interval 这里限制最大心率 200BPM,如果被测者心率真超过这个值,会被漏掉,但对普通场景足够,还能干掉大量抖动噪声。峰检测用左右各两个点做局部最大,这比单一阈值可靠得多。

血氧公式用的是行业里最常见的经验式:先算 AC 和 DC 比值 R,再套一次性拟合公式。这个公式在中低血氧段误差不小,医用级不可能这么干,但作为课设和便携设备演示完全够。代码里对 R 做除零保护是必要的,因为手指拿开瞬间 ADC 值会掉到很低,不保护直接除零,程序就飞了。

4.2 采样窗口和显示刷新率怎么搭配

MAX30102 在 100Hz 采样率下,每秒往 FIFO 里塞 100 组六字节。如果主循环每 10ms 读一次 FIFO,一次读 6 个样本绰绰有余。但 OLED 刷屏如果占了 20ms,这轮循环就把 FIFO 读晚了,下轮读的时候 FIFO 已经堆了几十组。堆太多不是不行,只是延迟变大,数据显示滞后半秒,手一抖波形全糊。

我一般把主循环拆成两段节奏:高频段每 10ms 读一次 FIFO,把样本塞进环形缓冲区;低频段每 500ms 算一次心率和血氧,每 1s 刷新一次 OLED。用 STM32 的定时器触发读取比主循环死等要稳,比如用定时器 2 做 10ms 中断,中断里只做 I2C 读和缓冲区写入,主循环只做算法和显示。这样显示再慢也不影响采样连续性。

缓冲区用环形结构比滑动数组省心,因为读取是持续的,环形缓冲区天然处理新旧数据交替:

#define RING_SIZE 600 // 6 秒数据,足够算法窗口用 static uint32_t ring_ir[RING_SIZE]; static volatile uint16_t ring_head = 0; void on_timer_10ms(void) { uint8_t raw[6]; uint32_t ir_val, red_val; if (max30102_read_fifo(raw, 6) == HAL_OK) { ir_val = ((uint32_t)(raw[3] & 0x03) << 16) | ((uint32_t)raw[4] << 8) | raw[5]; red_val = ((uint32_t)(raw[0] & 0x03) << 16) | ((uint32_t)raw[1] << 8) | raw[2]; ring_ir[ring_head] = ir_val; ring_red[ring_head] = red_val; ring_head = (ring_head + 1) % RING_SIZE; } }

每 10ms 读一组样本,一组样本是六字节,正好对应一次测量。这里把 18 位数据拼回来的方式要注意:每个通道的高两位在控制的字节里,所以 raw[0] 只取低两位再左移 16 位。有些人直接 raw[0] << 16,把状态位也拼进数据里,波形会出现周期性的毛刺。FIFO 读是否成功要判返回值,I2C 在总线上被 OLED 占用时可能超时,不判断就继续取数,拿到的可能是半截旧数据。

4.3 把原始数据从串口导出来看波形

OLED 上显示的是算法结果,而算法有没有算对,得看原始波形。很多人调心率算法时盯着 OLED 数字猜,这效率太低。正确的做法是让原始数据从串口出来,在电脑上画成波形再调参数。标题里如果配套代码原本就带串口打印,那直接用;没有的话,自己加一段很简单:

#include "stdio.h" #ifdef __GNUC__ int _write(int fd, char *p, int len) // 配合 GCC 工具链的 printf 重定向 { (void)fd; return HAL_UART_Transmit(&huart1, (uint8_t *)p, len, 100) == HAL_OK ? len : 0; } #endif void debug_dump(uint16_t idx) { printf("%lu,%lu\r\n", ring_red[idx], ring_ir[idx]); }

串口输出用 CSV 格式,一列红光一列红外,这样 Excel 或者 Python 脚本都能直接读。USB 虚拟串口方式也可行,STM32 的 USB 虚拟串口发送数据本质上就是把 CDC 的收发缓冲区接上,注意别在中断里直接 printf,会卡死。打印速率不用太快,每 20ms 打一行,每秒 50 行,波特率 115200 毫无压力。

导出的数据能看到三种典型形态:第一种是明显起伏的脉搏波,波峰间隔均匀,这种波形算法随便写都能算对;第二种是平线,说明手指没放好或者 LED 电流太小;第三种是整体浮动的慢波,那是手指压力变化带来的基线漂移,算法里加一个高通滤波或者用滑动均值做基线扣除就解决了。没有串口就调不出这个问题,这是所有读 MAX30102 的代码里最值得先做的事。

5. 避坑指南:五个必翻车的点与排查流程

这套组合的翻车现场我来来回回见过太多,从 OLED 花屏到温度卡在 85,再到期期艾艾的心率数字,基本都是几个固定原因。

5.1 OLED 花屏不是屏的问题

现象:屏幕能亮,但显示内容是乱的,要么全是乱点,要么字符错位,有时刷几下又正常。密码门锁 OLED 屏花屏那些案例,多数也是同一个病根。

原因:最常的是 I2C 速率过高,400kHz 下 OLED 模块上的走线和上拉电容导致时序变形;其次是初始化序列少了电荷泵开启命令,屏幕处于欠压状态;还有就是显存写入时列地址和页地址没有成对设置,写了一半跳到别的页去了。

解决:先把 I2C 速率降到 100kHz 试一下,如果花屏消失,就是速率问题。初始化序列补上 0x8D 0x14 这对命令。再检查每次写显存前是否都调用了设置坐标函数,SSD1306 在连续写一页后列地址会自动回绕,跨页写必须重新设置地址。最后看供电,OLED 和传感器共用一颗 LDO 时,MAX30102 的 LED 一亮会拉低电压,给 OLED 屏的 VCC 加一个 10\mu F 电容能解决大部分闪烁和花屏。

5.2 MAX30102 一直读到 0xFF

现象:初始化不报错,但读 FIFO 永远拿到 0xFFFFFF 或 0x000000,换传感器也一样。

原因:I2C 地址写错是最典型的,有人把 7 位地址 0x57 直接当 8 位地址传给 HAL 函数,读写地址没左移,从机地址自然对不上。第二个原因是通信引脚缺上拉,STM32 的 I2C1 引脚在开漏模式下没有外部上拉电阻时,总线电平拉不高,传输直接失败。第三个原因是 LED 电流太小或者手指没放实,传感器能初始化但采不到有效信号。

解决:确认 HAL_I2C_Master_Transmit 的地址参数是 0xAE(8 位写地址),I2C 的 Mem_Read 用 0xAF。用示波器或者逻辑分析仪看 SCL 上有没有正常的时钟,没有就查上拉。手指放上去后用串口打印 LED 电流调大的原始值,如果还是全零,查 FIFO 写指针寄存器 0x04,它一直不动就说明传感器根本没在采数,重点查模式寄存器 0x09 是不是 0x03。

5.3 DS18B20 显示 85℃

现象:OLED 温度永远是 85.0,或者开机 85 过一阵变成别的大数,但和实际温度明显不符。

原因:85℃ 是 DS18B20 上电复位后暂存器里的默认值。程序读到 85 只有一种可能,就是启动转换的命令没被执行,读的时候传感器还在用上电默认值。典型原因有三个:复位时序不合格导致存在脉冲没被正确识别,程序把复位失败当成成功了;写 0x44 时写时序的时隙长度不对,命令没写进去;还有从机没等到转换完成就发起了读,读到的还是旧暂存器。

解决:用逻辑分析仪抓整个单总线过程,重点看是否存在脉冲。没有存在脉冲,先查外接 4.7k 上拉电阻和 GPIO 模式切换,确认拉低后释放时引脚真的变成了输入态。存在脉冲有了但温度还是 85,就用读位函数去等转换完成信号,代码里写成 while 等待比固定 delay 更直接。另外注意,DS18B20 供电电压低于 3.3V 时转换时间会变长,等位超时要适当放宽到 1s。

5.4 心率血氧乱跳

现象:屏幕显示心率从 70 一下蹦到 150,血氧在 92 到 99 之间反复横跳,数据看着吓人。

原因:多数是原始信号质量差,不是算法问题。手指按压位置不对、力度忽大忽小、LED 电流太大导致 PPG 波峰削顶,都会让峰检测抓到假峰。还有一个常见原因是窗口太短,心率低的时候 2 秒窗口里只有两三个峰,稍微抖一下峰值就翻了。

解决:第一步先看串口波形,确认原始信号是平滑的周期性波形。第二步给算法加上峰间隔限制,心率 30 到 200 之间的间隔才算有效,低于 30 就丢弃重算。第三步是降速,把采样率从 100Hz 降到 50Hz,对脉搏波完全够用,还能减少 LED 发热和功耗。手指固定方式比算法更重要,给 MAX30102 做个指套或者用胶带固定比整天调参数管用。

5.5 I2C 总线被拉死的排查

现象:程序跑一会儿,OLED 再也不刷新,MAX30102 也读不到,整个系统像是卡住,复位后又正常,过一段时间又死。

原因:HAL 库的 I2C 传输函数是阻塞的,超时时间到了才返回错误。当 SCL 或 SDA 被从设备拉低时,主机的传输会一直等,看起来就是死机。OLED 和 MAX30102 共用一条 I2C 总线时,任何一个设备的时序出错都可能把总线锁住。还有一种情况是中断里调用了 HAL_I2C_Master_Transmit,和主循环的调用冲突了。

解决:检查是不是中断和主循环同时操作 I2C,如果是,把读 FIFO 的 I2C 调用放到主循环,或者加互斥标志。I2C 死锁后用软件复位总线,把 SCL 翻转九次让从设备释放总线,这个办法治标,长期看还是要查哪个设备把 SCL 或 SDA 长时间拉低。用 100kHz 低速运行这套双设备总线,大多数玄学死机都能消失。

6. 用逻辑分析仪和串口数据把「能亮」变成「能用」

到这一步,你的板子已经能显示三个数据,剩下的功夫都花在「验证」而不是「新功能」上。我建议按这个顺序做一轮自测:先用逻辑分析仪抓 DS18B20 的完整时序,确认复位、存在脉冲、读时隙三段波形都符合手册;然后用串口导出一段 10 秒的 MAX30102 原始数据,自己手动数一下波峰,和算法算出的心率对一下;最后给 OLED 做一个渐变刷新测试,连续显示 20 分钟,看会不会花屏或者堆积错误。这三个验证过了,这套源代码才算真正在你手上落地。

进阶改动从低功耗做起性价比最高。MAX30102 的 LED 是耗电大户,把采样率降到 25Hz,只在需要测量时点亮 LED,平时寄存器模式切到待机,整机功耗能掉一大截。DS18B20 可以改成隔 5 秒才发起一次转换,体温本来就不是毫秒级变化的量。OLED 则把显示内容分级,空闲时只显示大字,需要时才刷新次要信息。这样改完之后,一块 18650 电池可以用非常久,而这套代码的骨架不用动。

再往后可以做数据记录。环形缓冲区里的原始值定时写入 Flash 或者 SD 卡,攒一天的数据导出,在电脑上画出血氧趋势线和体温曲线,对睡眠监测这种方向就是现成的起点。心率算法也可以换思路,从时域峰值检测换成自相关或者频域 FFT,代码结构和脉率估计会更扎实。我现在拿到这类源码的习惯是:先抓时序,再看波形,最后才动手改参数,顺序错了就是玄学调参现场。这条路走通一遍,再回头看 I2C 和单总线就不是黑匣子了。希望这些拆解能帮到你,少走我当年绕过的弯路。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进