STM32驱动MAX30102心率血氧传感器与OLED显示工程详解
2026/9/12 12:25:19 网站建设 项目流程

简介:一份基于STM32F103C8T6的MAX30102心率血氧检测与OLED显示完整工程,面向嵌入式学习者与可穿戴健康监测开发者,覆盖传感器驱动、I2C通信、数据处理及屏幕显示联调等关键环节。压缩包共87个文件,大小349KB,以C源文件与头文件为主(38个h、36个c),另含4个hex固件、Keil工程配置文件及清理脚本;工程基于STM32标准外设库,并单独划分OLED、MAX30102、IIC等HARDWARE模块,目录结构便于定位复用。目前已有521人学习下载。借助该工程可快速理解MAX30102的采集原理与STM32外设库用法,直接参考心率血氧算法、OLED显示驱动等核心代码,还能将现成工程迁移到智能手环、健康监测等实际项目中,是嵌入式入门和课程设计的实用参考资料。

1. 一块板子同时点亮心率传感器和屏:这套工程的真正价值在哪

一块 STM32F103C8T6 最小系统板,一个 MAX30102 模块,一块 0.96 寸 OLED,这三样东西被塞进同一个 Keil 工程时,很多人的第一反应是"抄来交课程设计"。其实这套 OLED-MAX30102 组合是一条完整的健康监测数据链路:MAX30102 通过 I2C 输出红光和红外两路 PPG 原始数据,Cortex-M3 内核负责跑心率血氧算法,再把结果推给 SSD1306 驱动的 OLED 显示。适合刚把 I2C 时序理顺、想一次性接触传感器算法和点屏驱动的开发者,因为 F103C8T6 有 72MHz 主频和 20KB SRAM,跑典型的 100 点滑动窗算法余量充足,而整个工程又把底层库、外设驱动、算法、显示分开目录,方便逐层啃。

2. 看懂工程骨架:标准外设库、启动文件与 HARDWARE 目录的职责划分

2.1 从 CORE/USER/HARDWARE 三个目录拆解 Keil MDK 工程

拿到压缩包,先别急着打开 main.c。这个工程沿用了标准外设库时代的经典布局,CORE、SYSTEM、USER、HARDWARE、STM32F10x_FWLib 各管一摊。先用一张表把目录职责理清,再去读具体文件会顺畅很多。

目录关键文件职责
COREcore_cm3.c/h、startup_stm32f10x_md.sCortex-M3 内核抽象、启动代码和向量表
SYSTEMdelay、usart、sys、systick阻塞延时、调试串口、时钟与 GPIO 快捷宏
USERmain.c、stm32f10x_conf.h、system_stm32f10x.c主逻辑、外设配置开关、系统时钟初始化
STM32F10x_FWLibsrc 和 inc 下的 stm32f10x_*.c/h标准外设库,驱动 GPIO、I2C、SPI、TIM 等寄存器
HARDWAREOLED/、max30102/、IIC/屏幕驱动、传感器驱动、I2C 时序与寄存器读写
OBJ编译中间文件和 hex输出 Demo.hex、OLED.hex 等产物,与源码无关
2.1.1 为什么需要 core_cm3.c 和启动文件

startup_stm32f10x_md.s是中等容量(Medium-density)芯片的启动文件,它决定了中断向量表、初始栈顶地址和 Reset_Handler 的流程。启动代码会先调用 SystemInit 把时钟切到 72MHz,再进入 __main 初始化 C 运行环境,最后才跳到 main。工程里同时在 CORE 目录放了 core_cm3.c,它提供 NVIC、SysTick 等内核外设的访问函数。很多初学者认为这些文件是"固定模板"直接跳过,但当你改了晶振频率或换芯片容量时,真正要动的恰恰是启动文件和 SystemInit。

2.1.2 SYSTEM 层与 USER 层怎么配合

SYSTEM 目录是典型的"工程保姆"层:delay.c 提供 us 和 ms 级阻塞延时,MAX30102 软件 I2C 的时序就是靠 delay_us 撑出来的;usart.c 把串口 1 重定向到 printf,调试算法参数时可以直接打印原始波形幅值;sys.c 里的 GPIO 快捷宏和时钟使能函数,让 HARDWARE 层写起来更简洁。USER/main.c 则是唯一需要按业务改写的文件,它把 HARDWARE 里的 OLED 和 MAX30102 驱动串成一个完整流程,所以看代码时先定位 main 里的 while(1),再往回找函数实现。

2.2 HARDWARE 里驱动文件的依赖关系

HARDWARE 目录下有三个子模块,但命名有点随性:OLED 目录存的是 SSD1306 的驱动和字库,max30102 目录放的是算法封装,而真正的 I2C 底层读写却和 MAX30102 驱动一起放在 IIC 目录下。这种耦合在资源包里很常见,读代码时要注意依赖方向。实际调用链是 main.c -> max30102/max30102.h 里的配置函数 -> IIC/max30102.c 的寄存器读写函数,底层最终操作的是 GPIO 模拟的 SCL 和 SDA 引脚。OLED 驱动并不依赖 MAX30102,它只负责把心率和血氧值渲染到屏幕上,两者通过全局变量或函数返回值交接数据。

2.3 keilkill.bat:清理中间文件而不影响源码

压缩包里有个keilkill.bat,看起来不起眼,但它是从别人手里接手工程时最该先跑的命令。批处理内容通常是删除 OBJ 目录下的 .o、.d、.crf、.axf 等编译产物,以及工程目录里的 .bak 和 .dep 文件,只保留源码和 .uvprojx。

@echo off del /s /q OBJ\*.o OBJ\*.d OBJ\*.crf OBJ\*.axf OBJ\*.htm 2>nul del /s /q *.bak *.dep 2>nul del /s /q Listings\*.lst 2>nul echo [OK] Keil intermediate files removed.

如果在 Keil 的 Options for Target -> User 里把这段批处理挂到 After Build/Rebuild 的 User Command 中,每次编译后会自动清掉中间文件,分享工程包时体积会从几十 MB 缩到几 MB,也能避免本机绝对路径泄露到项目文件里。注意 2>nul 的作用是屏蔽"找不到文件"的报错,批处理内部删除不存在的目录时不会中断后续指令。

3. MAX30102 寄存器读写:软件 I2C 时序与 FIFO 数据通路

3.1 先看关键寄存器,再动手配传感器

MAX30102 的核心寄存器不多,初始化时要关心的就是模式、采样率、LED 电流和 FIFO 配置。下面这张表列的是我在调试时最常读写的几个地址,全部为 8 位寄存器,通过 I2C 访问。

地址寄存器名作用常用值
0x01中断状态读 FIFO 几乎满、温度就绪等标志读后清零
0x02中断使能打开或屏蔽中断0xC0 只开温度
0x08FIFO 写指针指示传感器写入位置读,只读
0x09FIFO 溢出计数样本被覆盖的次数读后清零
0x0AFIFO 读指针主机读取位置读,只读
0x0BFIFO 数据寄存器连续读样本数据6 字节一包
0x0C模式配置心跳模式/血氧模式/多 LED 模式0x03 SpO2 模式
0x0DSpO2 配置ADC 量程、采样率、LED 脉宽0x27
0x0E/0x0FLED1/LED2 电流红光与红外发射电流0x24 左右

初始化时我一般这样配置:先软复位芯片,等 10ms 后再写模式寄存器,否则寄存器写入可能不生效。配置顺序是模式 -> SpO2 参数 -> LED 电流 -> FIFO 配置。

MAX30102_Write_Reg(REG_MODE_CONFIG, 0x03); // SpO2 模式,红红外同时工作 MAX30102_Write_Reg(REG_SPO2_CONFIG, 0x27); // ADC 满量程 4096nA,采样率 50Hz,脉宽 411us MAX30102_Write_Reg(REG_LED1_PA, 0x24); // 红光电流约 6.4mA MAX30102_Write_Reg(REG_LED2_PA, 0x24); // 红外电流约 6.4mA MAX30102_Write_Reg(REG_FIFO_CONFIG, 0x4F); // FIFO 平均 4 次,开启滚动

0x27 后面 5 位拆开看是 SAMPLE_RATE=100 和 LED_PW=1,组合出来的实际采样率约 50Hz,每个样本由 4 次 ADC 平均得到。LED 电流改成 0xFF 能把亮度推到最大,肤色较深或手指按压不均匀时会有明显改善,但功耗和发热同时上升,传感器贴近皮肤部位会感到微热。

3.2 用 GPIO 模拟 I2C,绕开硬件 I2C 的坑

工程把 max30102.c 放在 IIC 目录下,说明驱动作者选择了软件模拟 I2C。这是 STM32F1 标准外设库时期常见的做法,硬件 I2C 在中断和事件标志配合不好的时候容易卡死在忙状态,而软件模拟时序透明、可移植,调试时用示波器点一下 GPIO 就能看到电平变化。GPIO 初始化时要注意 SCL 和 SDA 都要配成开漏输出,并在外部或内部上拉。

void IIC_Start(void) { SDA_Output_Mode(); SDA_HIGH(); SCL_HIGH(); delay_us(4); SDA_LOW(); delay_us(4); SCL_LOW(); } uint8_t IIC_Write_Byte(uint8_t data) { uint8_t i, ack; for (i = 0; i < 8; i++) { SCL_LOW(); if (data & 0x80) SDA_HIGH(); else SDA_LOW(); data <<= 1; delay_us(2); SCL_HIGH(); delay_us(2); } SCL_LOW(); SDA_Input_Mode(); delay_us(2); SCL_HIGH(); delay_us(2); ack = SDA_READ(); SCL_LOW(); SDA_Output_Mode(); return ack; }

IIC_Start 在 SCL 高电平时拉低 SDA,这是 I2C 总线的起始条件,随后把 SCL 拉低准备传数据。IIC_Write_Byte 从高位开始逐位输出,每一位都要在 SCL 低电平时更新 SDA,再拉高 SCL 让从机采样。发送完 8 位后释放 SDA 并产生第 9 个时钟,从机通过拉低 SDA 返回应答信号。这里的 delay_us(2) 对应 400kHz 左右的总线速率,你改用不同主频的芯片时优先调这个延时,I2C 时序裕量不足会出现寄存器读回全是 0xFF 的典型症状。

MAX30102 的 7 位设备地址是 0x57,写地址是 0xAE,读地址是 0xAF。读写单个寄存器的套路是先发设备地址和寄存器地址,写数据时连续发两字节,读数据时在寄存器地址后重新发起 Start 条件,再发读地址。

uint8_t MAX30102_Read_Reg(uint8_t reg) { uint8_t val; IIC_Start(); IIC_Write_Byte(0xAE); IIC_Write_Byte(reg); IIC_Start(); IIC_Write_Byte(0xAF); val = IIC_Read_Byte(0); // 最后一个字节返回 NACK IIC_Stop(); return val; }

参数 0 传给 IIC_Read_Byte 表示读到最后一个字节时不要回 ACK,而是回 NACK 告诉从机传输结束。读回来的值就是目标寄存器的当前内容,调试时可以用它打印 FIFO 写指针来判断传感器是否在正常出数。

3.3 从 FIFO 连续读 6 字节的节奏

MAX30102 每个样本包含红光和红外两组 18 位数据,按 3 字节一组左对齐存放在 FIFO 中,所以一次有效读取是 6 字节。读取前要看写指针和读指针的差值,差值就是可读样本数。

uint8_t regs[6]; uint32_t red, ir; MAX30102_Read_N_Bytes(0x0B, regs, 6); red = (regs[0] << 16) | (regs[1] << 8) | regs[2]; ir = (regs[3] << 16) | (regs[4] << 8) | regs[5]; red >>= 3; ir >>= 3;

19 位 ADC 数据实际只用到高 18 位,左对齐存放后低位补零,所以右移 3 位得到原始值。读取数量建议控制在 write_ptr - read_ptr 的范围内,如果一次读得太多会把尚未更新的数据也读走,屏幕上心率值会周期性地出现毛刺。FIFO 溢出标志置位时,应该先把溢出计数寄存器读一遍清零,再丢弃当前数据重新对准读写指针。

4. 心率与血氧计算:从原始 PPG 波形到 SpO2 估算

4.1 PPG 波形里的 AC 分量和 DC 分量决定了什么

MAX30102 输出的是光电容积脉搏波,当动脉血随心脏搏动时,血管容积周期性变化,透射或反射的光强也随之波动。波形中的直流分量 DC 代表组织、静脉血和恒定吸收,交流分量 AC 则来自动脉血的脉冲变化。心率就是 AC 波形的峰值间隔,而血氧饱和度 SpO2 依赖红光与红外两路 AC/DC 比值的数学关系。

计算 SpO2 的经典公式是 R = (AC_red / DC_red) / (AC_ir / DC_ir),然后对照经验拟合表把 R 值映射成血氧百分比。工程里 max30102 目录下的 algorithm.c 和 algorithm.h 就是用来封装这段计算的,它吞进去的是缓冲数组,吐出来的是 heart_rate 和 spo2 两个结果,底层做了带通滤波、峰值检测和查表。使用库算法时,必须保证喂给它的数据是按时间顺序均匀采样的,不然峰值间隔计算全乱。

4.2 algorithm.c 里通常走了哪几步

虽然资源包里的 algorithm.c 源码可以自己展开,但多数移植案例走的都是类似流程:先对红外数据做带通滤波,中心频率放在 0.8Hz 到 3.5Hz 对应 48 到 210 次每分钟的心率范围;然后做阈值检测找到波峰;最后用最近 4 到 8 个峰值的平均间隔换算心率。血氧部分则是提取红外交替中红光波形的 AC 和 DC,算出 R 后查表。

void MAX30102_Process(uint32_t *ir_buf, uint32_t *red_buf, uint8_t len) { uint8_t finger_on = 0; uint32_t hr = 0; uint8_t spo2 = 0; finger_on = MAX30102_Check_Finger(ir_buf, len); if (finger_on) { algorithm_one_iteration(ir_buf, red_buf, len, &hr, &spo2); HeartRate = hr; SpO2 = spo2; } }

这里把"手指有没有放上去"单独拆出来,是因为 MAX30102 检测到手指离开时 IR 值会掉到接近满量程或直接饱和,此时强行计算出的心率毫无意义。检查逻辑通常是看红外数据的近期最大值和最小值之差,当差值小于某个阈值时判定无指脉动。把这段逻辑放在算法入口之前,能省下大量无效计算。

4.3 在目标板上组织数据流水线

4.3.1 定时器节拍与采样缓冲

为了按固定频率拿到样本,我一般用一个 10ms 或 20ms 的 SysTick 中断置一个采样标志位,主循环检测到标志后去读 MAX30102 的 FIFO。注意不要在中断里直接跑 I2C 读取,软件 I2C 的延时函数在中断上下文里容易破坏其他时序。20ms 对应 50Hz 采样率,正好和 MAX30102 配置的采样率一致,缓冲数组开 100 个点就是 2 秒的波形窗口。

#define SAMPLE_WINDOW 100 uint32_t ir_wave[SAMPLE_WINDOW]; uint32_t red_wave[SAMPLE_WINDOW]; uint8_t wave_index = 0; void Update_Wave_Buffer(uint32_t ir, uint32_t red) { ir_wave[wave_index] = ir; red_wave[wave_index] = red; wave_index = (wave_index + 1) % SAMPLE_WINDOW; }

环形缓冲的好处是每次新样本只需覆盖最旧的位置,算法计算时按起始位置到末尾顺序取数。如果直接用数组整体平移,100 个点的 memmove 虽然不慢,但在低优先级的后台循环里会拉长其他任务的响应时间。wave_index 到 99 回绕后,算法的数据窗口自然就变成了最近 2 秒的样本。

4.3.2 移动平均滤波压制工频干扰

MAX30102 的原始波形里往往叠加着 50Hz 环境光干扰和手指颤动,算法自带的带通滤波器能处理大部分,但屏幕数值跳动明显时可以在进算法前加一层滑动平均。5 点平均会带来约 1 个采样周期的相位滞后,对 50Hz 采样下的心率计算完全可以接受。

#define AVG_LEN 5 uint32_t Moving_Average(uint32_t new_value, uint32_t *buf) { static uint8_t idx = 0; uint32_t sum = 0; buf[idx] = new_value; idx = (idx + 1) % AVG_LEN; for (uint8_t i = 0; i < AVG_LEN; i++) sum += buf[i]; return sum / AVG_LEN; }

这个函数返回的是过去 5 个样本的平均,每次拿到新样本后调用一次。滤波后的波形比原始数据平滑,但代价是对快速心率变化的响应变慢,比如从静息到运动瞬间,心率显示会有一到两秒的爬坡过程,这是正常现象。如果发现波形被削得太平,说明平均窗口太长,可以把 AVG_LEN 改回 3。

4.4 参数调优边界:采样率、LED 电流与 FIFO 平均

这几组参数相互牵制,调的时候要有优先级。先看波形是否饱和,再调采样率,最后才动滤波。

参数推荐起点调大调小
采样率50Hz波形更细、算法更稳,但中断更频繁功耗降低,峰值检测易丢点
LED 电流0x24 约 6.4mA信号幅值大,肤色深有效波形过小,AC 分量不明显
FIFO 平均4 次噪声更低,但输出延迟加大实时性更好,波形毛刺多
缓冲长度100 点算法窗口更长,抗瞬时干扰窗口太短心率跳动大

输入端已经平均 4 次,软件端再做 5 点平均,等于等效 20 次平均,低频呼吸引起的基线漂移会被保留,而高频噪声基本消失。如果 OLED 上心率还是乱跳,不要急着继续加滤波,先看 Infra-Red 原始波形的峰值间距是否均匀,问题更可能出在 FIFO 读取时序或手指接触压力上。

5. OLED 显示驱动与刷新策略:在 128x64 上稳定显示心率血氧

5.1 SSD1306 控制器的页寻址与显存

OLED 模块用的是 SSD1306 驱动芯片,内部集成 128x64 像素的 1KB GDDRAM,分成 8 页,每页 8 行像素。写显示数据时是按"列地址 + 页地址"定位的,理解这个模型对画波形图很关键。常见 12 号字体一个字符占 8 行 12 列,刚好对应每页 12 字节空间,所以 OLED_ShowString 这类函数可以按页为单位快速定位到字模。

初始化时如果忘了设置页地址模式,默认的页寻址方式会让连续写字节自动换页,写满一页后跳到下一页,画竖线时会看到横条纹。工程里 oled.c 的 OLED_Init 一般会把显示偏移、对比度和寻址模式一起配好,不要跳过初始化直接调显示函数。

5.2 oled.c 里的核心绘制函数

驱动文件主要提供清屏、画点、显示字符串和数字四类接口。使用固定字体时,关键在于传入的 x 坐标必须是 6 或 12 的倍数,否则字模会截断。下面这段是主循环里最常用的显示组合。

OLED_ShowString(0, 0, "HR:", 12); OLED_ShowNum(24, 0, HeartRate, 3, 12); OLED_ShowString(0, 2, "SpO2:", 12); OLED_ShowNum(40, 2, SpO2, 3, 12);

第一个参数是列坐标,单位是像素;第二个参数是页坐标,0 到 7 分别对应从上往下的 8 行像素块。12 表示使用 12 号字体,这个参数最终决定取模索引步长。把数值显示放在字符后面的固定位置,可以避免数字位数变化时整行左移。HeartRate 的格式是 unsigned int,如果你算法结果里带了小数,必须先强转成整数再用 OLED_ShowNum 显示。

5.3 与传感器采集并行的刷新方案

MAX30102 数据是持续产生的,OLED 刷新却是突发任务,最忌讳的是在采样中断里直接刷屏。一块 128x64 OLED 全屏刷新要写 1024 字节显存,即使 I2C 跑 400kHz 也要超过 20ms,而 50Hz 采样周期正好是 20ms,全屏刷新会把采样节奏拖垮,FIFO 溢出后心率出现周期性断点。

常见做法是主循环里分两个时间片:每 20ms 响应采样标志,只读数据和更新环形缓冲;每 100ms 刷新数字区域,每 500ms 画一次波形。通过累加计数器实现,不引入额外定时器。

uint32_t tick_100ms = 0; uint32_t tick_500ms = 0; while (1) { if (sample_flag) { sample_flag = 0; Read_FIFO_And_Update_Wave(); if (wave_ready) MAX30102_Process(ir_wave, red_wave, SAMPLE_WINDOW); if (++tick_100ms >= 5) { tick_100ms = 0; Update_OLED_Number(); } if (++tick_500ms >= 25) { tick_500ms = 0; OLED_Draw_Wave(ir_wave); } } }

Read_FIFO_And_Update_Wave 里只做 I2C 读取和缓冲写入,MAX30102_Process 则放在采样完成后,因为算法要遍历 100 个点的数组,耗时可能达到数毫秒。OLED 数字刷新只重写数值区域,不调用 OLED_Clear,清屏操作代价高且会造成视觉闪烁。画波形时也只更新当前点和覆盖一列像素,避免整屏重写。

需要注意的是,OLED 和 MAX30102 共用一条 I2C 总线时,两者地址不同,不会冲突,但如果 OLED 驱动的 I2C 函数和 MAX30102 的 IIC 函数各自维护独立的 Start 和 Stop 时序,混用时要保证每次传输完整结束,不能让总线处于半启动状态。最稳妥的做法是把底层 I2C 读写统一成同一组函数,上层模块只传设备地址。

6. 从 Demo.hex 到自己的板子:下载调试与常见问题定位

6.1 工程在 Keil 里的下载配置

STM32F103C8T6 属于中等容量产品,Flash 为 64KB,和 F103ZE 的 512KB 启动文件不同。如果直接用 Demo.hex 下载到自己的 C8T6 板,先确认 Keil 的 Device 选项里选的是 STM32F103C8,Debug 设置里 Flash Download 算法选择 STM32F10x Med-density Flash。很多从 F103ZE 工程改来的资源包在这两步没对齐,下载时提示 "No algorithm found" 或 "Cannot access target" 基本就是这个问题。

6.2 三个高频问题与定位手段

现象可能原因定位方法
I2C 卡死,SDA 一直被拉低软件 I2C 缺少停止条件,或 GPIO 漏配开漏在 IIC_Stop 后加短延时,量 SCL/SDA 静态电平
OLED 花屏或显示乱码初始化时序不对、I2C 地址错、对比度过低先单独跑 OLED 清屏测试,再显示固定字符
心率值周期性跳变FIFO 溢出、算法窗口未对齐、手指压力不稳打印 FIFO 写指针,看是否持续增长

碰到 I2C 卡死,最快的方法是给 SCL 连续翻转 9 个脉冲,让从机复位内部位计数器,同时把总线上残留的 ACK 状态清掉。OLED 花屏时检查模块地址是 0x78 还是 0x7A,部分模块的地址选择电阻不一样,驱动里写错一个 bit 就会白屏。

6.3 用 OLED 打印寄存器值做现场调试

板子上如果没有串口转 USB 模块,OLED 本身就是调试输出窗口。把寄存器读取结果格式化成字符串显示,比 debugger 观察变量更直观,特别是需要确认 MAX30102 是否真的在出数时。

char dbg_buf[16]; uint8_t wr_ptr = MAX30102_Read_Reg(0x04); uint8_t rd_ptr = MAX30102_Read_Reg(0x06); sprintf(dbg_buf, "W=%d R=%d", wr_ptr, rd_ptr); OLED_ShowString(0, 6, dbg_buf, 12);

把这段放在主循环最前面,手指放上去后观察 W 和 R 是否都在变化,如果 W 一直不动,说明 I2C 配置或传感器供电有问题;如果 W 增长而 R 不动,则是读指针没有跟着读操作更新。定位完问题后把调试代码删除,再把 OLED 刷新逻辑恢复到只显示心率和血氧,就可以继续做实际测量了。

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

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

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

立即咨询