SMARTWATCH 学习手册有 C 基础 · 嵌入式从零开始
双线通信项目实战 / 02

MAX30102:红光和红外怎样算出心率、血氧?

手表背面发出的光,怎样变成屏幕上的心率和血氧百分比?MAX30102 交给程序的,是一组组红光、红外读数。先看这些数字为什么随心跳变化,再顺着仓库代码看它们怎样变成两个结果。

前置:寄存器读写与缓存队列沿用仓库驱动和算法先理解测量,再启动采集
本篇数据手册MAX30102 官方数据手册:第 8 页查引脚,第 13~19 页查数据排列和采样配置。光学原理与血氧换算另见厂家说明 《血氧测量指南》。寄存器读写、地址左移和队列基础沿用前篇,需要时回看即可。

第 1 节 · 它看见的是光,为什么能反映心跳?

MAX30102 内部有红光发光管、红外发光管和光电接收器。贴近皮肤时,光进入组织,经过吸收、散射,其中一部分回到同侧的接收器。接收器将收到的光转成电信号,芯片里的转换电路再把它变成整数,程序就能读到。

心脏每次泵血,局部动脉血容量都会随脉搏变化,组织吸收的光也跟着变化。因此,接收到的信号中除了一个较大的背景水平,还叠加着随脉搏起伏的小波动。把连续读数画出来,就能看到重复的波形。这个利用光观察血容量变化的方法,叫光电容积描记法。

把测量过程串成一句话发光 → 光经过皮肤和血液 → 接收返回的光 → 得到连续整数 → 从起伏中找脉搏。传感器提供光学读数,STM32 上的算法负责计算心率和血氧。

当前配置下,芯片在一个采样周期内先后测量红光、红外,每秒产生 100 组;每组包括一个红光值和一个红外值。比如某一组为 20000 和 50000,它们表示两次光学接收的数字结果,单凭这两个瞬时值还无法确定心率或血氧。需要观察一段连续记录怎样变化。

同一段波形,可以回答两个不同的问题

  • 心率:脉搏多久重复一次?看波形在时间轴上的间隔。
  • 血氧:红光和红外随脉搏变化的相对程度有什么不同?比较两路波形,再进行标定换算。

两路发光和接收在同一周期内依次进行,属于同一组,但并非同一瞬间。读到的整数还会受接触、发光强度和运动影响,接下来先从一段稳定的波形理解计算。

第 2 节 · 心率怎么算?先量两次脉搏之间的时间

假设连续红外读数呈现下面这样的重复起伏。先找两个相邻、属于不同脉搏的波峰,再量它们相隔多久。每次起伏的方向可能随测量和处理方式变化,重点是找到同一种重复特征;仓库的心率算法使用红外读数中的局部峰值。

从两个波峰之间的样本数计算脉搏间隔教学示意波形,第100个与第180个样本的波峰相隔80个采样间隔。每秒100次采样,间隔为0.8秒,换算每分钟75次。红外光学读数随时间变化(示意)第 100 个样本第 180 个样本相隔 80 个采样间隔 → 0.8 秒 → 每分钟 75 次
图中只演示间隔计算,曲线和数值不是设备实测。

每秒采样 100 次,相邻两点间隔为 0.01 秒。两个波峰在样本编号 100 和 180,编号差为 80,所以:

一次脉搏的间隔 = 80 ÷ 100 = 0.8 秒
每分钟脉搏次数 = 60 ÷ 0.8 = 75
合起来:每分钟脉搏次数 = 60 × 采样率 ÷ 相邻峰值的编号差

程序不必真的等一分钟再数次数。只要量出相邻脉搏的时间,就能换算。反过来,采样率填错,时间也会算错:同样相隔 80 个采样间隔,若误填成每秒 50 次,就会被当成 1.6 秒。

程序怎样找到波峰?

在 hr_detector.c 中,程序每收到一个红外读数,就与前一个比较。原先一直上升,现在开始下降,说明刚才经过一个局部峰值。但小噪声也会形成峰,所以还需要筛选:

  1. 保留最近约两秒的红外数据,找到最大值和最小值,用两者的中间水平作为当前阈值。
  2. 局部峰值高于阈值,并且距离上一个峰的间隔在允许范围内,才作为候选脉搏。
  3. 用间隔计算每分钟次数,检查范围,再对最近几次结果取平均,减小显示跳动。

这里的平均发生在几次已经算出的心率之间。当前代码直接保存原始红外读数,并没有先实现完整的波形滤波。晃动也可能制造假峰,信号质量判断会影响是否采用结果。稳定佩戴、连续取数之后,才有条件检查算法表现。

厂家也用相邻脉搏特征的时间间隔解释心率计算,见心率计算说明。

第 3 节 · 血氧怎么算?为什么需要两种光?

血液中的血红蛋白负责携带氧。已经结合氧的血红蛋白,与尚未结合氧的血红蛋白,对不同波长的光具有不同吸收特性。红光和红外提供两种观察角度,比较它们的变化,可以估算动脉血氧饱和度,也就是血红蛋白结合氧的比例。

先把每条波形分成“背景”和“脉动”

一条红光记录可能始终在两万左右,上面叠加几百的起伏。较慢变化的背景包含组织、其他血液成分和光路的影响;随脉搏变化的小部分,则反映动脉血容量的变化。红外也有自己的背景与起伏。

仅比较红光原始值除以红外原始值,会把发光强度、光路等差异混进去。先分别计算“脉动幅度占本路背景多少”,再比较这两个相对变化量。用一段心跳内的示例数值算一遍:

同一段心跳背景水平脉动幅度脉动幅度 ÷ 背景
红光200002000.01,即 1%
红外5000010000.02,即 2%

将两路的相对变化再相除,把得到的数记作 R:

R = (红光脉动幅度 ÷ 红光背景水平)
    ÷ (红外脉动幅度 ÷ 红外背景水平)
  = 0.01 ÷ 0.02 = 0.5

R 是一个用于换算的比值,0.5 还不表示血氧是 50%。前面取得的是两种光的变化关系,接下来需要一条经过标定的曲线,把这个关系映射为血氧百分比。测量原理和这种归一化方法见厂家《血氧测量指南》。

仓库怎样从波形取得这些量?

打开 spo2.h,沿着下面几步读即可,不必先通读整个实现:

  1. 收集连续数据。当前每秒 100 组,保存约四秒红光、红外数据;逐步跟踪各自背景,并记录扣除背景后的波动。
  2. 找一段段心跳。在红外波形里寻找局部低点,也叫谷值,用相邻谷值划出一次心跳,检查间隔是否合理。
  3. 计算每一段的相对变化。在两端谷值之间连出基线,计算峰值高出这条线多少,作为脉动幅度;结合本路背景估计,算出这一段的 R。当前实现用区间峰值和所跟踪背景近似估计背景水平。
  4. 综合几段结果。丢掉过弱或异常的片段,将剩余几个 R 排序后取中间位置的值,减少个别异常片段的影响,再换算血氧。

最后的换算,具体是什么?

当前代码在 R 小于 1 时采用下面的经验曲线。代入刚才的示例:

血氧估计值 = −45.060 × R² + 30.354 × R + 94.845
R = 0.5 时,计算结果约为 98.76%

R 不小于 1 时,当前代码改用 110 − 25 × R,随后还做范围处理。它们是仓库采用的估算规则;光学结构、佩戴和人群差异会影响换算关系,不能把这些系数当作这块手表已经完成标定的证明。厂家明确说明,MAX30102 没有一条适用于所有设备的固定换算曲线,见血氧比值与标定说明。

现在再回答“两个值就能算吗?”需要连续的两路波形。心率利用重复间隔,血氧利用两路各自的相对脉动幅度,再结合标定曲线;单独一组红光、红外数字还缺少这些信息。

第 4 节 · 要拿到这段波形,初始化按什么顺序做?

现在已经知道算法需要连续的红光、红外数据,再看 MAX30102.c 的 MAX30102_Init() 怎样启动测量。参数是配置好的通信总线管理对象地址,返回 0 表示成功,非零表示失败。下面按这个函数的实际执行顺序阅读。

寄存器读写沿用前篇:reg_read() 读一个字节,reg_write() 写一个字节。这里关注每一步要完成的事情,位的具体取值可按手册查表。

  1. 记住使用哪条总线。保存传入的管理对象;若为空就报错。后面的所有读取和写入都使用它。
  2. 确认找到了正确器件。从身份寄存器 0xFF 读一个字节,预期为 0x15。通信地址仍使用 0x57 << 1,含义沿用双线通信基础篇。
  3. 复位,回到已知起点。向 0x09 写 0x40,再反复读取,目标是等复位位自动清零。软件复位也会复位队列的相关位置,因此新采集从明确状态开始。
  4. 在配置阶段暂不开始测量。源码接着向 0x09 写 0x00,稍后才开启双通道。注意手册把模式 000 列为“不要使用”,因此不能把这条过渡写入当作通用待机方法;需要关闭测量时,应使用手册规定的关断控制。
  5. 安排数据暂存方式。向 0x08 写 0x10:不把多组测量平均成一组,队列满后允许覆盖旧数据。这样每秒 100 次测量按约 100 组保存;覆盖意味着旧数据会丢失,需要及时读取。
  6. 确定测得多快、每次测多久。向 0x0A 写 0x27,选择每秒 100 组、411 微秒发光脉宽和 18 位输出,同时设置接收侧 4096 纳安的满量程范围。采样率决定时间间隔,脉宽决定一次接收积分多久,接收范围决定多大的信号达到数字上限。
  7. 设置两路发光强度。向 0x0C、0x0D 分别写 0x1F,设置红光、红外发光电流,典型值各约 6.2 毫安。两路分别设置;增大发光强度会影响信号和耗电,也可能使接收饱和。
  8. 开始红光和红外测量。向 0x09 写 0x03,启用两路发光、接收。手册将它称为血氧测量模式,但这个模式仍输出原始光学数据。
  9. 等待一个采样周期。当前代码等待 10 毫秒,给首次采样留出时间。这段等待不会把两颗传感器的内部时钟锁在一起。
  10. 选择怎样通知读取。向 0x02 写 0,关闭这里的采集通知。项目由运动传感器的队列水位统一提醒,之后依次读取两颗器件,具体协作在下一篇联合采集讲解。

这样读初始化,就能看到完整顺序:确认器件 → 复位 → 配置暂存、采样与亮度 → 开始测量 → 安排读取通知。具体设置见手册第 17~20 页。实验中先确认初始化成功,再读取波形;复位完成的判断应以实际读到复位位清零为准。

第 5 节 · 连续读出两路数据,交给现有算法

队列和循环位置的概念在前篇队列基础已经讲过。这里沿用同一个思路:芯片持续采样,STM32 按先后顺序批量取出。MAX30102 的队列最多存 32 组,每组红光、红外各三个字节,一组共六字节。

器件的读写位置编号仍用于判断有多少组待取。区别只在取数入口:从 0x07 连续读取六字节得到一组,读取更多组则是更多个六字节。它是一个固定的数据出口,芯片内部依次交出队列内容;不是读取 0x07 后面的六个配置寄存器。数据排列见手册第 14~16 页。

现有 MAX30102_GetFifoBatch(red, infrared, maximum) 接收红光数组、红外数组和最多读取组数,返回实际取得多少组。下面两个数组各能放 25 个值,返回 20 就只使用前 20 组:

uint32_t red[25], infrared[25];
uint8_t count = MAX30102_GetFifoBatch(red, infrared, 25);

驱动内部将每路的三个字节合成 18 位无符号整数。例如 01 23 45 合成 0x012345,即 74565。使用 32 位变量才能装下全部有效位;拼接和按位操作沿用前篇,不再另写一套单组读取。一次传输失败后也不能继续使用数组里的旧值。

算法为什么要保留自己的状态?

心率要记住上一个峰的位置,血氧要保留最近几秒的波形,所以算法不能每读一批就全部清零。仓库已经提供两种状态类型:hr_state_t 保存心率计算进度,spo2_state_t 保存血氧计算进度。包含 hr_detector.h、spo2.h 后,在采集循环之外创建它们,只初始化一次:

hr_detector_init() 的第二个参数是实际采样率,spo2_init() 清空血氧计算状态;当前血氧算法按每秒 100 组设置。

hr_state_t heart_rate;
spo2_state_t oxygen;
hr_detector_init(&heart_rate, 100.0f);
spo2_init(&oxygen);

每取得一组,hr_detector_update() 输入一个红外值;spo2_feed() 输入同一组的红光和红外值,最后一个参数是当前毫秒时间,用于控制计算和结果有效期。HAL_GetTick() 提供这个时间,它不是这组历史数据的精确采样时间戳。两个入口按顺序处理所有有效样本:

for (uint8_t i = 0; i < count; i++) {
    hr_detector_update(&heart_rate, (int32_t)infrared[i]);
    spo2_feed(&oxygen, red[i], infrared[i], HAL_GetTick());
}

心率函数返回 1 时表示有新结果,可以读取 heart_rate.bpm_smoothed,单位是次/分钟。血氧用 spo2_is_valid(&oxygen) 判断当前是否已有有效结果,再用 spo2_get(&oxygen) 取百分比。血氧需先积累连续波形,当前实现约收满四秒后才具备开始分析的条件,之后约每两秒尝试一次。

没有新样本时,仍需周期调用 spo2_expire(&oxygen, HAL_GetTick()),让长时间未更新的结果失效。队列读取不足、信号过弱或动作打断连续波形时,要结合实际数量和有效状态处理。代码中的“有效”表示通过当前算法检查,准确程度仍需设备实测。

完整调用可对照 main.c。初次观察波形,使用串口记录连续红光、红外值:稳定接触时看周期起伏,移动时看波形怎样受扰动,再对照结果。仓库血氧页面中的小曲线目前是绘制的示意波形,检查真实采样应查看原始数组或采集记录。

这篇的完整路线光经过组织形成连续读数 → 从重复间隔算心率 → 从两路相对脉动幅度估算血氧。初始化负责启动这种测量,驱动负责按顺序交出数据,算法负责从波形中提取结果。两颗传感器怎样在同一轮读取,接着看下一篇。