STM32F103C8T6 驱动MOAX30201心率血氧传感器(原创超详细算法解析+完整源码)

0. 原创前言

目前网上绝大多数 MAX30102/MOAX30201 的教程,基本都是丢一份源码就结束了,要么就是直接照搬官方数据手册,根本不讲底层逻辑。很多小伙伴烧完代码只能看着串口打印数据,心里完全没底:心率到底是怎么算出来的?血氧数值凭什么这么取值?滤波代码为什么要这么写?数据疯狂抖动、没手指还乱跳到底怎么解决?

这篇文章是我实物实测、亲手踩坑后整理的原创内容,基于 STM32F103C8T6 + MAX30102(MOAX30201完美兼容)整套工程,所有驱动、算法都是我自己手写优化的,没有照搬网上通用模板,所有难懂的坑我都帮大家填好了。

这篇文章的核心亮点我简单说下,都是实打实的干货:

  • 纯原创逐行拆解:从底层硬件驱动、官方核心算法到我自研的心率防抖优化,全程细讲
  • 全网超细MAX30102算法拆解:滤波、去基线、峰值检测、周期计算、血氧查表,每一步都讲透为什么这么做
  • 详细解释所有变量、缓冲区大小、阈值、窗口参数的设计初衷和适配逻辑,不是随便写的数值
  • 针对性解决新手通病:数据跳变、无手指误输出、血氧不准、心率漂移、波形异常
  • 整套工程代码可直接编译、烧录,毕业设计、小型穿戴项目直接套用

适配硬件:STM32F103C8T6 + MOAX30201(兼容MAX30102)

通信方式:软件模拟IIC(PB6/PB7)

核心难点:PPG光电容积信号处理 + 时域峰值检测 + 血氧AC/DC比值算法

1. MAX30102传感器核心物理与工程原理(超详细深挖,算法前置必备)

我发现绝大多数嵌入式小伙伴在用 MAX30102 时,基本都是停留在「初始化、读数据、跑现成算法」的表层阶段,只会用但完全不懂原理。PPG波形到底是怎么来的?血流怎么影响光强?双波长测血氧的生理依据是什么?波形基线漂移、噪声干扰到底怎么产生的?这些核心问题没人讲明白。

也正是因为不懂底层原理,大家调试时才会各种翻车:心率乱跳、血氧数值飘忽不定、抬手不识别、强光环境直接失效、波形被拉成直线等一系列奇葩问题。

所以这一节我专门从人体生理结构、光学吸收特性、传感器硬件架构、信号分层原理、干扰来源、算法工程适配全方位深挖,保证后文每一行算法代码,都能在这里找到对应的物理依据,做到知其然,更知其所以然

1.1 MAX30102硬件架构深度解析

MAX30102 是一款专门用来做人体心率、血氧检测的反射式光电传感器,和传统夹持式透射血氧模块不一样,它不需要夹住手指,只需要贴合皮肤就能采集信号,非常适配手环、指尖检测、手腕穿戴等场景,也是毕业设计和小型体征项目的常用器件。

最省心的是,这款芯片内部集成了完整的信号采集链路,不需要我们外接运放、滤波电路,大大简化了硬件设计。它的内部核心架构如下:

  • 双波长独立LED光源:660nm 可见光红光、880nm 近红外光,两路光源分时交替点亮,避免光线互相干扰
  • 高灵敏度光电接收二极管:捕捉皮肤反射回来的微弱光信号,将光信号转为模拟电流信号
  • 可编程ADC采样单元:最高8192nA量程、高精度24位采样,可捕捉极细微的血流光强波动
  • 内部温度补偿电路:抵消LED发热导致的光强漂移
  • 32级FIFO数据缓存:自动缓存采样数据,避免MCU丢点、数据断层
  • 可编程LED电流驱动:支持调节发光亮度,适配不同肤色、不同按压力度

我在工程里固定配置了 100Hz 采样率,也就是每10ms完成一次红光、红外光的交替采样,每秒能拿到100组完整数据。这个参数是我实测调试出来的,兼顾了数据实时性和波形稳定性,不会太快导致CPU占用过高,也不会太慢导致数据滞后。

1.2 人体指尖生理结构(PPG波形产生的根本)

想要彻底看懂PPG波形和后续算法,必须先搞懂人体指尖的生理结构,这是所有心率、血氧计算的根基,也是很多教程跳过的关键知识点。

人体指尖检测区域分为两层组织:

第一层:静态固定组织(皮肤、角质层、骨骼、肌肉、静态静脉血)

这类组织的厚度、透光率、光吸收率恒定不变,不会随心脏跳动发生任何变化。光线照射后产生的反射光强是固定值,对应信号中的直流DC分量

这部分信号完全是固定干扰,没有任何有效体征信息,也是我们后续算法必须做基线去漂移、直流滤波的核心原因,不滤除干净,波形会全程偏移、数据完全不准。

第二层:动态脉动组织(动脉血流)

人体动脉血管随心脏节律周期性收缩与舒张:

  • 心脏收缩( systole ):血液泵入末梢血管,指尖血管容积变大、血液浓度升高,光线被血液吸收更多,反射光强变弱
  • 心脏舒张( diastole ):血液回流,指尖血管容积变小、血液浓度降低,光线吸收减少,反射光强变强

心脏每跳动一次,指尖反射光强就完成一次「减弱—增强」的周期性波动,这个周期性波动信号就是交流AC分量,也就是真正的PPG心跳波形。

简单总结核心逻辑:PPG波形的一个完整周期,就对应一次心跳周期;波形的波动幅度大小,对应指尖血流的灌注量。算法就是依靠识别波形周期算心率、识别波形幅值比例算血氧,所有代码逻辑都是围绕这个生理规律展开的。

1.3 双波长光学吸收原理(血氧检测核心生理学逻辑)

这里肯定有很多小伙伴疑惑:为什么测血氧非要红光+红外光搭配?只用一种光线难道不行吗?

答案很明确:完全不行。单一光线只能检测血流的跳动变化,根本区分不出血液的含氧量,自然算不出血氧值。

人体血液中负责携带氧气的物质是血红蛋白,分为两种形态,二者对不同波长光线的吸收特性完全相反、互为互补,这是血氧检测的唯一物理依据:

  • 脱氧血红蛋白 Hb(缺氧血液):对660nm红光吸收率极高,对880nm红外光吸收率极低

血液越缺氧 → 红光被吸收越多 → 红光反射波形波动越大

血液含氧充足 → 红光被吸收越少 → 红光反射波形波动越小

而红外光不受含氧量影响,无论血液缺氧与否,红外光的吸收波动基本一致,仅反映血管容积变化,作为基准参考信号

所以大家记住这个核心分工:红外光专职测心跳(捕捉血流周期),红光专职测血氧(判断血液含氧状态),双光配合才能精准解算出血氧饱和度。

这里重点说一句:MAX30102 采回来的原始数据,绝对不是干净的心跳信号!它是环境噪声、直流基线、心跳交流信号三层信号叠加的混合波形,杂质非常多,不处理根本没法用。

1.4.1 高频噪声(必须滤除)

主要来源:室内灯光闪烁、电源纹波干扰、手指轻微晃动、ADC采样自带毛刺。特点是变化极快、毫无规律,这也是我们后续做四级移动平均滤波、汉明窗降噪的核心处理对象。

1.4.2 直流基线DC(必须去除)

主要来源:皮肤骨骼的固定透光值、环境基础亮度、LED静态发光基线。特点是变化缓慢,会让整个波形整体上浮或者下沉,也就是行业常说的基线漂移,必须通过一阶递归基线滤波(ALPHA 0.995)【大家可以自己查看一下这种方法,百度一下就可以,AI也行】彻底消除。

1.4.3 交流分量AC(唯一有效信号)

来源:心脏跳动带来的血流周期性波动。特征是低频、周期性、规律波动,是心率、血氧计算的唯一有效依据。

一句话闭环所有算法逻辑:我们后续所有的滤波、去基线、降噪操作,目的只有一个:剥离所有干扰噪声和无效基线,提纯出干净的AC心跳信号。后面会讲如何最终算心率。

1.5 为什么必须使用 AC/DC 比值?(解决行业核心痛点)

这是血氧算法里最绕、新手最难理解的知识点,我掰开揉碎了通俗讲:

很多人试过直接读光强数值算血氧,结果数据乱得离谱,原因很简单:光强数值受太多外界因素干扰,根本不具备参考价值!

  • 手指按压轻重(压得越紧,血流越少,光强越高)
  • 皮肤肤色深浅(黑皮肤吸收光更多)
  • 环境光线强弱(室内强光、暗光)

但重点来了:以上所有干扰,只会改变红光、红外光的整体基线DC值,完全不会改变两种光线的AC/DC比值关系,这也是比值法能抗干扰的核心底气。

  • LED灯光强弱偏差

我简单拆分一下有效和无效信号:

DC:受干扰的整体基线(无效)

AC:纯粹的血流波动幅值(有效,与血氧强相关)

通过公式 Ratio = (Red_AC / Red_DC) / (IR_AC / IR_DC)

通过这个比值公式,就能彻底抵消硬件误差、环境干扰、人为操作误差,只保留和血液含氧量强相关的真实数据,这也是官方算法坚持用比值法、不用直接光强计算的根本原因。

1.6 嵌入式查表法替代浮点公式的底层原因

血氧标准物理计算公式为二次函数:

SpO2 = -45.060 * Ratio * Ratio + 30.354 * Ratio + 94.845

但我们用的 STM32F103 是 Cortex-M3 内核,没有硬件浮点运算单元,直接实时套公式计算,会出现两个致命问题:

  • 运算速度极慢,占用大量CPU资源
  • 浮点精度丢失严重,极易出现数据溢出、血氧跳变、数值错乱

所以官方直接提前算好所有比值对应的血氧数值,做成 uch_spo2_table 数组。我们单片机直接查表取值,以空间换时间、以查表换精度,这是低端单片机实现高精度血氧检测的最优解,也是我工程里沿用这个方案的原因。

很多人疑惑我为什么选500点缓冲区、100Hz采样率,其实这个参数不是随便写的,是根据人体心率范围适配出来的最优参数:

本工程采用 100Hz 采样率(10ms/点),500点缓冲区对应 5秒连续波形数据,参数设计经过严格生理学适配:

  • 人体静息心率区间:60~180次/分钟
  • 最低心率60次/分,单周期1000ms,5秒可采集5个完整波形
  • 最高心率180次/分,单周期333ms,5秒可采集15个完整波形

5秒的滑动窗口,能稳定捕获多组完整心跳周期,足够算法完成降噪、拟合、基线收敛和数据统计,完美避开单点干扰、单周期异常导致的数据错误,极大提升了整套算法的稳定性。

最后说下无效数据判断的逻辑,也就是大家常看到的无手指输出-999的原理:

当传感器无手指贴合时,光线直接透射、无反射,光电接收管接收光量极低,原始采样数据远小于50000。这里大家搭配源码看,效果会更好。

当手指贴合时,皮肤遮挡光线、形成反射回路,血流波动带来光强大幅提升,数据稳定大于50000。

我设置了50000的阈值做人体接触检测,没放手指直接终止算法、输出-999,杜绝空数据、噪声数据参与运算,避免出现离谱的错误数值。

2. 硬件接线(本人工程实际管脚)

MOAX30201引脚

STM32F103C8T6引脚

功能说明

VIN

5V

模块供电

GND

GND

共地

SCL

PB6

IIC时钟线

SDA

PB7

IIC数据线

INT

PB5(悬空可用)

中断引脚,本工程未使用

IRO、RO

NC

悬空

本工程采用软件模拟IIC,兼容性极强,无需硬件IIC配置,不占用硬件资源。

3. 整体工程运行流程(核心框架)

我整套代码的运行逻辑是一套完整的闭环流水线,所有数据读取、缓存、算法处理逻辑都是我实测优化过的,没有冗余代码,流程非常清晰:

硬件初始化 → 传感器软复位 → 寄存器参数配置 → 500点缓冲区预填充 → 滑动窗口实时更新数据 → 信号预处理(去基线+多级滤波)→ 波形校正+阈值自适应 → 峰值检测 → 心率计算+防抖优化 → 血氧AC/DC比值计算+查表输出 → 有效标志位输出

下面分 驱动层 和 算法核心层(重点超详细拆解) 讲解。

4. 驱动层代码逐段解析(本人原版代码)

4.1 全局缓冲区变量设计(算法核心基础)

这几个变量是整个传感器运行的核心,所有算法全部依赖这500点缓冲区:

c
uint32_t aun_ir_buffer[500];    // 红外LED原始PPG数据
int32_t n_ir_buffer_length;     
uint32_t aun_red_buffer[500];   // 红光LED原始PPG数据
int32_t n_sp02;                 // 最终血氧结果
int8_t ch_spo2_valid;           // 血氧有效标志
int32_t n_heart_rate;           // 最终心率结果
int8_t  ch_hr_valid;            // 心率有效标志

设计原理(原创讲解):

这里简单说下设计思路:传感器100Hz采样,10ms一个数据点,500个点刚好覆盖5秒的连续波形。心率和血氧绝对不能靠单点数据判断,很容易受干扰出错,必须依靠连续波形的周期和幅值特征计算,所以5秒滑动窗口是保证算法稳定的关键。

心率、血氧绝对不能靠单点数据计算,必须依靠连续波形的周期性、波动幅度,所以必须维持5s滑动窗口数据。

4.2 IIC底层读写函数

我自己封装的这两个IIC读写函数,在标准寄存器读写的基础上多加了容错处理,一旦总线报错就强制停止传输,完美解决IIC总线卡死、数据阻塞的问题,稳定性比通用模板好很多。

核心作用:配置传感器工作模式、读取FIFO原始数据、读取中断状态。

4.3 FIFO数据读取函数

MAX30102的FIFO数据格式是固定的,每次输出6字节数据,分工很明确:

  • 第0、1、2字节:红光24位原始数据
  • 第3、4、5字节:红外24位原始数据
  •  aun_red_buffer[i]  = (((uint32_t)temp[0] & 0x03) << 16) | ((uint32_t)temp[1] << 8) | temp[2];
        aun_ir_buffer[i]   = (((uint32_t)temp[3] & 0x03) << 16) | ((uint32_t)temp[4] << 8) | temp[5];

代码里通过位运算拼接出24位原始数据,再用 0x03FFFF 掩码过滤无效高位,剔除冗余数据,保证读回来的每一组数据都是干净有效的。这里配合代码看更好。

4.4 传感器初始化(本人优化参数)

传感器初始化参数直接决定了波形质量,原厂默认参数漏洞很多,很容易出现LED过曝、波形拉直、数据溢出的问题,我针对性微调了寄存器参数,适配指尖测量场景:

c
max30102_Bus_Write(REG_SPO2_CONFIG, 0x33);    // ADC 8192nA、100Hz采样
max30102_Bus_Write(REG_LED1_PA, 0x20);        // 红光电流适配
max30102_Bus_Write(REG_LED2_PA, 0x18);        // 红外防过曝电流

原厂LED电流偏大,光线过强,贴合手指后很容易出现波形饱和失真。我适当降低了红外LED的驱动电流,实测波形起伏均匀、无失真、无饱和,适配日常静态测量场景。

4.5 滑动窗口数据更新(本人核心设计)

这是我个人很满意的一个优化点,采用滑动覆盖更新的方式,代替传统清空重填的逻辑,保证波形全程连续:

c
// 旧数据前移,丢弃最早100个点
for(i=100; i<500; i++)
{
    aun_red_buffer[i-100] = aun_red_buffer[i];
    aun_ir_buffer[i-100]  = aun_ir_buffer[i];
}
// 填充最新100个实时数据
for(i=400; i<500; i++)
{
    max30102_FIFO_ReadBytes(REG_FIFO_DATA, temp);
    aun_red_buffer[i]  = (((uint32_t)temp[0] & 0x03) << 16) | ((uint32_t)temp[1] << 8) | temp[2];
    aun_ir_buffer[i]   = (((uint32_t)temp[3] & 0x03) << 16) | ((uint32_t)temp[4] << 8) | temp[5];
    aun_red_buffer[i] &= 0x03FFFF;
    aun_ir_buffer[i]  &= 0x03FFFF;
}

原理详解:

每次都清空缓冲区重填,会导致波形断层,算法瞬间丢失数据,出现心率跳变、血氧闪烁的问题。滑动更新可以全程保留有效波形,只淘汰最旧数据、补充最新数据,算法运行全程稳定无断层。

5. 核心重点:MOAX30201心率血氧算法 超详细原创逐段解析

algorithm.c 是整套工程的核心灵魂,也是网上教程最敷衍、最缺细节的部分。我这里逐模块、逐公式、逐原理拆解每一行算法代码,把所有隐藏逻辑讲透。

5.1 算法整体流水线(总结)

指尖检测 → 基线去漂移(直流滤波)→ 多级平滑滤波 → 汉明窗降噪+波形矫正 → 自适应阈值峰值检测 → 心率周期计算 + 自研防抖滤波 → 波形谷值精准定位 → AC/DC分量提取 → 血氧比值计算 + 查表输出

5.2 静态滤波系数设计

c
#define ALPHA 0.995f
static float dc_estimate = 0;

这里给大家讲下我选这个参数的思路:

实际测量中,手指轻微晃动、环境光线微弱变化、肢体微动,都会导致PPG波形整体偏移,也就是基线漂移。不处理,有效心跳波形会被基线淹没,数据直接失效。

采用一阶递归低通滤波实时跟踪基线:

公式:dc_new = a * dc_old + (1 - a) * data

a = 0.995 这个数值是我实测筛选的最优值,无限接近1,基线更新速度极慢,只能跟随缓慢的环境漂移,完美保留心跳的快速周期性波动,降噪不丢有效信号。

5.3 第一步:智能指尖检测(防无效数据)

c
for( i=0; i<n_ir_buffer_length; i++){
    if(pun_ir_buffer[i] >= 50000){  
        has_finger = 1;
        break;
    }
}
if(has_finger == 0){
    *pn_heart_rate = -999;
    *pch_hr_valid = 0;
    return;
}

原理非常直白:

无手指贴合时,传感器没有有效反射光路,接收光量极低,原始数据远小于50000;手指贴合后形成稳定反射回路,血流波动会让数据大幅抬升。

通过这个阈值判断,直接拦截无效数据,没手指就停止运算、输出-999,彻底杜绝乱输出的问题。

5.4 第二步:去除基线漂移,提取纯心跳AC信号

c
for (k = 0; k < n_ir_buffer_length; k++) {
    dc_estimate = ALPHA * dc_estimate + (1.0f - ALPHA) * (float)pun_ir_buffer[k];
    an_x[k] = (int32_t)((float)pun_ir_buffer[k] - dc_estimate);
}

我给大家直白翻译这段代码的核心:

an_x[k] = 原始数据 - 动态基线

执行完这段代码后,数组里剩下的就是纯纯的心跳交流信号,彻底剔除了光线、温度、手势带来的整体基线偏移,波形干净了很多。

5.5 第三步:四级移动平均滤波(降噪)

c
for(k=0; k< BUFFER_SIZE-MA4_SIZE; k++){
    n_denom= ( an_x[k]+an_x[k+1]+ an_x[k+2]+ an_x[k+3]);
    an_x[k]=  n_denom/(int32_t)4;
}

原始PPG波形的高频毛刺特别多,肉眼看波形很杂乱。四点滑动平均滤波是轻量化、高效的降噪方式,既能抹平高频噪声毛刺,又不会过滤掉心跳的低频有效波动,非常适配单片机实时运算。

原始PPG噪声极大,存在高频毛刺。4点滑动平均,平滑波形,保留心跳低频特征,剔除高频噪声。

5.6 第四步:差分求导 + 二级平滑

c
for( k=0; k<BUFFER_SIZE-MA4_SIZE-1;  k++)
    an_dx[k]= (an_x[k+1]- an_x[k]);

for(k=0; k< BUFFER_SIZE-MA4_SIZE-2; k++){
    an_dx[k] =  ( an_dx[k]+an_dx[k+1])/2 ;
}

这里是很多教程跳过的核心精髓!直接在原始波形上找峰值,特别容易被噪声干扰,出现误判、多判峰值的情况,所以我采用一阶差分求导的方式预处理波形:

直接对原始波形找峰值极易误判,所以对波形一阶差分求导

依靠差分过零点找峰值,抗干扰能力比原始波形找峰强十倍,心率检测的精准度直接拉满。

  • 波形峰值 → 导数由正变负(过零点)
  • 波形下降 → 导数为负

5.7 第五步:汉明窗滤波+波形翻转(算法精髓)

c
const uint16_t auw_hamm[31]={ 41,276,512,276,41 };
for ( i=0 ; i<BUFFER_SIZE-HAMMING_SIZE-MA4_SIZE-2 ;i++){
    s= 0;
    for( k=i; k<i+ HAMMING_SIZE ;k++){
        s -= an_dx[k] *auw_hamm[k-i] ;
    }
    an_dx[i]= s/ (int32_t)1146;
}

这段是整套算法的精髓优化点,我详细拆解一下:

这里使用5阶汉明窗做加权平滑,是信号处理里非常经典的时域降噪手段,能进一步滤除残留杂波,让波形更规整。大家可以搜一下汉明窗这种是怎么用的,百度一下。

重点看代码里的负号,这一步实现了波形翻转

原本PPG波形是上凸的波峰,翻转后把波谷转为波峰,全程只用一套峰值检测函数就能识别心跳周期,代码逻辑更统一、稳定性更高,不用单独写谷值检测逻辑。

最后除以窗函数总权重1146,还原波形原始幅值,避免加权运算导致波形失真、幅值异常。

5.8 第六步:直流偏置矫正 + 自适应阈值

这是我额外优化的细节,也是提升适配性的关键:

计算整体波形直流偏移,将波形拉回0轴;削平所有负波,只保留正向有效波动;统计正向波形平均高度,作为动态峰值阈值。

最大的优势就是阈值自适应,不管是不同肤色、不同按压力度、不同光线环境,都能自动适配波形高度,不用手动修改参数,通用性直接拉满。

5.9 第七步:峰值定位 + 峰间时间差计算 + 多周期均值心率计算(核心重点拓展)

前面我们完成了波形去基线、多级降噪、差分求导、汉明窗矫正一系列预处理,目的就是为了精准找到每一个心跳对应的真实波峰。心率计算的核心根本不是读波形高度,而是计算相邻有效波峰之间的时间间隔,再通过多组间隔取平均,消除单次周期的偶然误差,最终算出稳定、精准的心率数值。这也是工业级心率检测的核心逻辑。

结合我们之前的差分求导波形,我详细拆解整套核心计算逻辑,全程对应代码运行流程:

5.9.1 导数过零点精准定位有效波峰

经过差分求导和波形翻转后的波形,峰值识别逻辑变得极其精准且抗干扰:原始PPG波形的真实心跳波峰,会对应差分波形的正负过零点。简单来说,波形上升阶段导数为正、到达峰值瞬间导数归零、波形下降阶段导数为负。

算法中 `maxim_find_peaks` 函数的核心工作,就是遍历整条处理干净的差分波形,精准捕捉所有正向转负向的过零点位置,记录每一个波峰对应的数组下标位置,存储在峰值坐标数组 `an_dx_peak_locs` 中。

同时搭配自适应阈值约束,过滤掉微小抖动产生的伪峰值,只保留符合人体心跳波形幅值特征的有效波峰,彻底杜绝噪声导致的误峰、杂峰识别问题。

5.9.2 波峰间时间距离(周期)计算原理

我们工程固定采样率为100Hz,单次采样间隔固定为 10ms,也就是波形数组中,相邻两个数组下标对应的真实时间间隔就是10ms。这是后续所有心率计算的基准常量。

算法遍历所有识别到的有效波峰坐标,依次计算后一个波峰下标 - 前一个波峰下标,得到的差值就是两个心跳之间的采样点数,再乘以固定时间10ms,就能算出单次心跳的真实周期时长。

举个通俗例子:两个相邻波峰下标差值为100,就代表两次心跳间隔为 100×10ms=1000ms,对应心率为60次/分钟。

5.9.3 多周期累加求平均(算法稳定性核心)

这是很多简易教程忽略的关键!只靠单组峰间周期计算心率,极易受肢体微动、微弱环境干扰、单次波形畸变影响,导致心率数值忽高忽低、跳动严重,数据完全不实用。

我们的工程采用多有效周期累加、整体求平均的方案:

首先遍历所有波峰坐标,累加每一组相邻波峰的采样间隔差值,得到所有心跳周期的总采样点数;再用总点数除以有效周期组数(有效波峰数量-1),最终得到平均单心跳周期采样点数

通过多周期平均,能直接抹平单次心跳波形的微小畸变、瞬时干扰带来的计算误差,让心率周期数据无限趋近于真实人体心率,稳定性大幅提升。

5.9.4 最终心率换算公式详解

结合固定10ms采样周期,推导最终心率计算公式:

单次平均心跳真实周期 = 平均峰间隔点数 × 10ms

心率代表每分钟心跳次数,1分钟=60000ms,因此:

HR = 60000 / (平均间隔点数 * 10) = 6000 / 平均间隔点数

对应核心代码逻辑,每一步都有明确物理与数学依据:

c
// 1. 查找所有有效心跳波峰,返回峰值坐标与有效峰值数量
maxim_find_peaks(an_dx_peak_locs, &n_npks, an_dx, final_len, n_th1 * 3, 50, 10);
// 2. 累加所有相邻波峰的采样间隔点数
for(int i=1;i<n_npks;i++)
{
    n_peak_interval_sum += (an_dx_peak_locs[i] - an_dx_peak_locs[i-1]);
}
// 3. 计算多周期平均单心跳间隔点数
n_peak_interval_sum = n_peak_interval_sum/(n_npks-1);
// 4. 代入公式换算出每分钟心率值
*pn_heart_rate=(int32_t)(6000/n_peak_interval_sum);

我把整套计算逻辑通俗总结一下,方便大家理解:

1、固定采样参数:采样频率100Hz,单个数据点时间间隔为 10ms,是心率换算的基准;

2、峰值定位:依靠波形差分过零点精准找峰,规避原始波形噪声干扰;

3、间隔计算:通过相邻波峰的数组下标差值,得到每一次心跳的周期采样点数;

4、均值优化:多组心跳周期累加平均,消除单次波形误差,提升数据稳定性;

5、公式换算:通过固定公式将采样间隔,转化为标准的每分钟心跳次数。

这也是为什么本工程心率数据,比网上单周期计算的代码更稳、几乎无跳变的核心原因。

采样周期 = 10ms

平均峰间隔 = N 个10ms单位

心率公式:HR = (60 * 1000) / (间隔 * 10) = 6000 / 间隔

5.10 自研心率防抖算法(本人原创优化)

原版官方算法算出来的心率数据跳动非常严重,忽高忽低,肉眼看着很不美观。我专门写了一套分级防抖滤波算法,针对性平滑数据:

  • 新旧心率差值>10:微调±3
  • 差值>8:微调±2
  • 差值>5:微调±1
  • 差值≤5:直接跟随

实测效果非常明显,彻底解决了原生算法心率漂移、数值乱跳的问题,输出数据非常平稳,适合直接屏幕显示。

讲完最核心的心率计算,接下来补齐整篇文章缺失的血氧完整计算流程,这也是网上绝大多数教程含糊带过、只给代码不讲逻辑的地方。血氧计算和心率计算是两套独立算法,心率看**波形周期**,血氧看**波形幅值比例**,全程依靠AC/DC分量比值解算,下面我从零开始逐步骤拆解完整计算逻辑。

前面原理部分我们讲过:血氧不能靠单一光强判断,必须依靠红光、红外光的AC/DC比值计算。心率算法只需要用到红外光波形,而血氧算法必须同时依赖红光660nm + 红外880nm两路PPG波形数据,缺一不可。

整套血氧计算流水线分为:波形周期分割 → DC直流基线提取 → AC波动幅值提取 → Ratio比值运算 → 数值限幅矫正 → 官方查表输出血氧六个核心步骤,每一步都有明确工程意义。

5.11.1 精准分割单个心跳周期(血氧计算前提)

血氧饱和度的计算精度,高度依赖单周期波形的完整性,不能使用整段滑动窗口数据混叠计算,否则会出现多周期波形叠加、幅值均值失真,最终导致血氧数值偏差、跳动。因此在正式提取AC/DC分量前,必须先精准分割出独立、完整、无重叠的单心跳PPG周期。

本工程依托前文心率算法识别出的有效波峰坐标序列完成周期分割:以相邻两个有效波峰为起止区间,截取中间完整的一段波形,即为一次标准的心跳周期波形。该分割方式严格贴合人体心动周期,完美包含心脏收缩、舒张的全部波形特征。

同时,为了保证红光、红外两路波形数据完全匹配、时序对齐,算法会同步对双路缓冲区做相同区间截取,确保红光、红外取自同一时刻、同一心跳周期的波形数据,彻底杜绝时序错位导致的Ratio比值计算错误,为后续精准提取AC、DC分量打下基础。

5.11.2 DC直流分量提取(静态基线)

DC分量就是我们前面滤波剥离掉的静态基线值,代表皮肤、骨骼、固定静脉血、环境光带来的固定透光量,属于无体征信息的基线数据,但却是血氧计算的必备分母参数。

在单个完整心跳周期内,分别对红光缓冲区、红外光缓冲区的数据做均值运算,得到稳定的直流基线:

Red_DC = 单周期红光原始数据总和 / 单周期采样点数

IR_DC = 单周期红外原始数据总和 / 单周期采样点数

这里不取瞬时值、取周期均值,是我实测优化的细节:可以彻底抹平单数据点的采样毛刺,让DC基线更加稳定,避免比值跳变。

5.11.3 AC交流分量提取(有效血流波动幅值)

AC分量是单个心跳周期内,波形最大值 - 波形最小值的差值,代表纯血流波动的幅度,是和血液含氧量强相关的唯一有效信号。

在已经分割好的单周期波形内,分别检索红光、红外波形的峰值和谷值,计算波动幅值:

Red_AC = Red_max - Red_min

IR_AC = IR_max - IR_min

这里大家可以结合前面的生理原理理解:血液含氧度的变化,只会影响AC波动幅值的大小,不会影响DC基线,这也是AC分量为核心有效数据的原因。

5.11.4 核心Ratio比值计算(血氧算法灵魂)

这一步是血氧计算的核心,也是抵消所有干扰的关键。我们分别对两路光线做「归一化处理」,再计算比值,彻底消除按压力度、肤色、环境光、硬件偏差的影响。

第一步:分别计算两路光线的归一化波动系数

Ratio_Red = Red_AC / Red_DC

Ratio_IR = IR_AC / IR_DC

第二步:计算最终血氧特征比值Ratio

Ratio = Ratio_Red / Ratio_IR = (Red_AC / Red_DC) / (IR_AC / IR_DC)

通俗说:这个最终Ratio数值,就是剥离了所有干扰后,纯粹反映血液缺氧程度的物理参数

5.11.5 Ratio数值限幅矫正(防报错、防溢出)

实测中手指轻微晃动、波形畸变,Ratio值会出现极端异常值,直接查表会报错误血氧(比如低于80、数值乱跳)。所以我加了人工限幅矫正

实测中如果手指轻微晃动、肢体微动、波形短暂畸变,会导致计算出的Ratio比值出现极端异常值。如果不做限制,异常比值会索引到错误的血氧表格数据,出现血氧低于80、数值乱跳、断崖式波动等问题。为保证工程稳定性,这里增加人工限幅矫正机制,严格锁定有效比值区间:

  • Ratio < 0.4,强制修正为0.4(血氧偏高极值)
  • Ratio > 1.3,强制修正为1.3(血氧偏低极值)

该区间完全覆盖人体正常静息血氧90%~100%对应的所有Ratio范围,既能拦截外界干扰产生的异常极值,又不会截断正常有效数据,是兼顾精度与稳定性的工程级优化方案。

5.11.6 查表映射输出最终血氧值

前面原理讲过,STM32F1无硬件浮点,直接套二次公式计算速度慢、精度差、容易溢出。所以工程采用比值映射查表法

将矫正后的Ratio值,按照固定比例映射为血氧查表数组 uch_spo2_table 的索引,直接索引取值输出最终血氧结果。

表格是官方通过标准血氧公式,预计算所有有效Ratio对应的标准血氧值得到的,完全贴合人体真实体征数据,精度比单片机实时浮点计算更高、运行速度更快。

5.11.7 血氧数据有效性判定

和心率校验逻辑对应,只有同时满足:手指贴合有效、波形AC幅值正常、Ratio在有效区间,才会置位 ch_spo2_valid = 1,输出有效血氧数据;否则屏蔽数据,不更新数值,避免异常乱跳。

5.12 血氧计算核心代码对应(逐行匹配原理)

c
// 1. 提取单周期红光、红外AC/DC分量
Red_DC = Red_Data_Mean;   // 红光周期均值基线
IR_DC  = IR_Data_Mean;    // 红外周期均值基线
Red_AC = Red_Max - Red_Min;
IR_AC  = IR_Max - IR_Min;

// 2. 计算归一化比值
float R_Red = (float)Red_AC / Red_DC;
float R_IR  = (float)IR_AC / IR_DC;
float Ratio = R_Red / R_IR;

// 3. 工程限幅矫正,过滤异常值
if(Ratio < 0.4f) Ratio = 0.4f;
if(Ratio > 1.3f) Ratio = 1.3f;

// 4. 比值映射查表,输出最终血氧
int index = (int)((Ratio - 0.4f) * 100);
n_sp02 = uch_spo2_table[index];

// 5. 数据有效标志位
ch_spo2_valid = 1;

5.13 血氧数值波动优化小技巧(实测有效)

很多小伙伴血氧数值偶尔跳1~2个点,属于正常现象,我这里简单说下优化思路:可以采用连续5次血氧结果取均值的轻量化平滑处理,不会牺牲响应速度,还能让血氧数值极致平稳,非常适合屏幕实时显示。

脱氧血红蛋白、含氧血红蛋白对红光、红外光的吸收率完全不同。通过计算红光AC/DC 与 红外AC/DC的比值,即可推算血氧浓度。

算法整体总结流程:

  1. 心率算法:依靠红外波形 → 差分找峰 → 峰间时间差 → 多周期平均 → 换算心率(看波形周期)
  1. 血氧算法:依靠红+红外双波形 → 提取单周期AC/DC → 计算Ratio比值 → 限幅矫正 → 查表输出血氧(看波形幅值比例)

很多人疑惑为什么不直接套公式计算,非要查表?

还是因为STM32F1的浮点短板,实时浮点运算精度差、容易溢出失真。查表法不用复杂运算,速度快、精度稳,是嵌入式单片机做血氧检测的最优方案。

6. 完整调用示例(可直接运行)

c
int32_t n_heart_rate = 0;
int32_t n_sp02 = 0;

max30102_init();

while(1)
{
    max30102_Read_Data(&n_heart_rate, &n_sp02);           
    printf("heart:%d, sp02:%d\r\n", n_heart_rate, n_sp02);
    delay_ms(100);
}

我整理了大家调试时最常遇到的问题,都是我实测踩坑总结的排错经验:

  • 一直输出-999:指尖未贴合、光线过强、LED电流配置不当、IIC通信异常
  • 血氧跳动大:未静止测量、波形AC分量过小、缓冲区未填满
  • 心率漂移:开启本人自研防抖后基本解决
  • 无波形:检查5V供电、IIC上拉、寄存器初始化参数

8. 原创总结

整篇文章是我基于实物调试、踩坑、优化后的原创总结,完整拆解了 MAX30102硬件驱动、PPG信号全套预处理、心率峰值检测逻辑、血氧比值查表算法、自研防抖优化 全流程。

和网上照搬手册、堆砌代码的教程不一样,我全程讲透底层逻辑:为什么要滤波、为什么要差分求导、为什么要用汉明窗、为什么500点窗口最稳、血氧比值法的物理意义、心率防抖的设计思路

整套工程代码无需修改,可直接编译烧录,适配STM32F103C8T6,完全满足课程设计、毕业设计、小型智能穿戴设备的开发需求。

视频精讲可搜哔哩哔哩   质点电子团队   同样有本源码。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐