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

联合采集与重采样:从 104 Hz 到 100 Hz

LSM6DSOX 每秒产生 104 对加速度和角速度数据,MAX30102 每秒产生 100 组红光和红外数据。由于两颗芯片的可选档位不同,这是以每秒 100 次为目标时最接近的一组配置。下面先由运动传感器的队列水位安排取数,再把 26 对六轴数据重采样为 25 对。

前置:双线通信基础与上一篇实战数据手册与仓库原有驱动先独立实验,再对应项目
本篇完成后已经能组织两颗传感器采集。可以继续阅读片内决策树扩展,也可先去 时钟主线,以后再回来部署片内模型。

基础查阅:uint8_t、uint32_t 与整数范围 · 队列、入队出队与循环下标。下面沿用前两篇已经讲过的传感器缓存队列。

1 · 先确定采样率,再由队列水位通知取数

先明确两颗器件分别产生什么、多久产生一份:

  • LSM6DSOX 运动传感器:加速度计和陀螺仪都设为每秒 104 次,每秒得到 104 对加速度和角速度数据。
  • MAX30102 光学传感器:设为每秒 100 次,每秒得到 100 组红光和红外数据。

为什么没有把它们都设成每秒 100 次?

项目希望两路数据最终都按每秒 100 次处理,但采样率只能从芯片规定的档位中选择。两颗芯片在这个目标附近提供的档位并不相同:

传感器目标附近的可选档位
单位:次/秒
本项目选择
LSM6DSOX52、104、208104 次/秒
MAX3010250、100、200100 次/秒

光学传感器可以直接选每秒 100 次,运动传感器却没有这一档,只能选择最接近的每秒 104 次。因此,围绕每秒 100 次这个目标,104 与 100 已经是最接近的一组可用配置。这个差别来自芯片的档位限制,后面再通过重采样处理两者每秒相差的 4 次。

可选档位见 LSM6DSOX 数据手册的加速度、角速度采样率表,第 54~55 页和 MAX30102 数据手册的采样率表,第 19 页。

前两篇已经能分别读取两颗传感器。把它们放进同一个程序后,首先要决定:什么时候来取这一批数据?项目让 LSM6DSOX 的缓存队列负责发出这个通知,STM32 收到后,在同一轮处理里依次读取两颗器件。

两颗传感器各自把新数据放进芯片内部的缓存队列。STM32 通过共用的第 1 路双线通信总线依次读取;两颗器件的地址分别为 0x6A、0x57,沿用前篇的驱动即可。

① 先规定:运动传感器累积多少数据时提醒?

在缓存队列里预先设置一个数量,未读记录达到这个数量就产生通知。这个数量叫水位,英文为 watermark,也常叫“水印”。水位可以低于缓存队列的总容量,目的是提前来取,给后续读取留出时间。

本项目希望每批有 26 对六轴数据。这里一对包括一组三轴加速度和一组三轴角速度;它们在运动传感器的缓存队列中分别占一条记录,所以水位寄存器应写:

26 对六轴 × 每对 2 条记录 = 52 条队列记录
FIFO_WATERMARK = 52

说“等够 26 对”是在数六轴数据,写寄存器时则是在数缓存队列记录。若直接把寄存器水位写成 26,这个配置下只积累约 13 对就会通知,读取会提前一半。

② 到水位后,是哪根线发生变化?

LSM6DSOX 有一个专门输出事件通知的引脚,叫 INT1。驱动把缓存队列水位事件接到这个引脚,并配置为高电平有效:缓存队列未到水位时为低,积累到 52 条记录时变高。

电路把 运动传感器的 INT1 接到 STM32 的 PB4。STM32 将 PB4 配置为上升沿外部中断:检测到这根线从低电平变为高电平,就进入第 4 路外部中断的处理函数。数据线和时钟线负责后面的双线通信数据传输,INT1 负责提醒 STM32 来读取。

两颗传感器各自采样;运动传感器积累26对即52条记录时,INT1从低变高,使STM32 PB4触发第 4 路外部中断中断;中断设置标志,主循环依次读取两个缓存队列,之后才重采样
先由缓存队列数量触发通知,再由程序取数,最后处理采样率的差别。图中用主循环展示裸机流程。

这些设置已经由前篇的初始化完成,可以对照下面四处看整个连接关系:

位置当前设置起什么作用
运动传感器的队列水位寄存器(0x07、0x08)水位 52规定多少条未读记录才通知
运动传感器的中断输出控制寄存器(0x0D)0x08,位 3 置 1把缓存队列水位事件输出到 INT1
板上连线INT1 → PB4把传感器输出的电平送到 STM32 输入脚
STM32 的引脚与中断配置PB4 上升沿;使能第 4 路外部中断线变高时执行中断处理

水位单位与输出规则可对照 LSM6DSOX 手册队列水位寄存器(0x07、0x08),第 48 页、中断输出控制寄存器(0x0D),第 52 页。项目设置见 LSM6DSOX.c 和 gpio.c。

③ 中断先记下通知,主循环再读取两个缓存队列

沿用中断章的办法,留一个全局标志 sensor_ready,初值为 0。STM32U5 的驱动库上升沿回调是 HAL_GPIO_EXTI_Rising_Callback(),参数 GPIO_Pin 表示哪一条引脚线触发。这次收到 PB4 的通知,就将标志设为 1:

裸机实验 · 放在全局变量区和用户函数区

volatile uint8_t sensor_ready = 0;

void HAL_GPIO_EXTI_Rising_Callback(uint16_t GPIO_Pin)
{
    if (GPIO_Pin == GPIO_PIN_4) sensor_ready = 1;
}

主循环发现标志为 1,先把它清为 0,再开始这一轮读取:先调用 LSM6DSOX_GetFifoBatch() 取得 26 对;确认取得完整一批后,再调用 MAX30102_GetFifoBatch() 读取最多 25 组红光、红外。两函数的参数和返回数量沿用前两篇,第 4 节会把具体调用串起来。中断回调本身只设标志,双线通信传输放在主循环里进行。

读走运动传感器的记录后,缓存队列未读数量降到水位以下,INT1 就回到低电平。后来又累积到水位,才会再次出现低到高的变化。若一直没把数量读到水位以下,INT1 可能持续为高,也就没有新的上升沿。因此读取后还要查看剩余数量,不能只清 STM32 的中断标志就等待下一次通知。水位状态见手册队列状态寄存器(0x3B),第 74 页。

④ 运动传感器攒够这一批时,光学传感器为什么对应 25 组?

运动传感器按 104 Hz 产生数据,积累 26 对对应的名义时间是 26 ÷ 104 = 0.25 秒,也就是 250 毫秒。在同样的时间里,光学传感器按 100 Hz 采样,得到 100 × 0.25 = 25 组:

从一次取数到下一次取数运动传感器:104 对/秒光学传感器:100 组/秒
约 250 毫秒26 对六轴,即 52 条记录25 组红光+红外
四个这样的批次,约 1 秒104 对原始六轴100 组光学数据

这就是“一次运动传感器通知,取两个缓存队列”的数量依据。以上按名义采样率计算;器件启动先后、时钟偏差和 STM32 读取延迟会影响实际积压数量,所以每轮仍以两个读取函数的返回值为准。后面先解释为什么选择这种通知方式,再处理 26 与 25 的差别。

2 · 为什么只开一路通知、选择运动传感器、没有用定时器?

为什么只开一路采集通知?

现在一次通知已经会让程序读取两个缓存队列。如果两颗器件各自都通知,而每次通知都启动完整的一轮读取,就可能围绕同一批数据唤醒两次:第一次已经取走了,第二次来时下一批还没积累够,还得另外判断、合并两路通知。

本项目把这件事统一为:运动传感器通知一次,程序依次取两路数据。因此 MAX30102 的本次采样就绪、缓存队列将满等采集通知不启用。关闭这些通知后,光学传感器仍持续采样、保存缓存队列,也仍能响应双线通信读取。两颗器件照常工作,只是由一处负责提醒 STM32。

“只开一路”指两颗传感器中只有一颗负责通知取数。传感器通知“有数据可读”,与通信外设通知“一次传输完成”,是两件事。

为什么偏偏让运动传感器来通知?光学传感器不行吗?

这一轮运动数据处理需要完整的 26 对六轴。使用运动传感器的水位通知,可以直接知道所需的加速度和角速度数据数量已经达到;板子上它的 INT1 也已经接到 PB4,沿用这条连接就能驱动整轮读取。根据前面的 250 毫秒计算,再到光学传感器取出相应的一批。

光学传感器也可以担任通知源。它同样有可配置的缓存队列通知,可以设置成积累 25 组时提醒;这时程序再查询运动传感器是否已经有 26 对。这里选运动传感器,是把整轮处理建立在“运动数据已够一批”这个事件上。光学传感器的阈值设置可以对照数据手册队列将满阈值,第 17 页。

为什么不用定时器每隔 250 毫秒来读?

STM32 内部有定时器。可以让它每隔 250 毫秒发出中断,再由程序读取两个缓存队列;这也是一种可用方案。但定时器到时,只说明 STM32 计的 250 毫秒已经过去,还需要查询缓存队列,确认传感器实际积累了多少数据。

运动传感器的水位通知直接说明缓存队列里已经有这一批数据。本项目已有这个事件,就用它安排取数,省去再用定时器单独计一个读取周期。两者的区别是“到时间后检查数量”和“数量达到后通知”。后面的定时器章节会讲怎样设置定时周期。

无论用运动传感器、光学传感器还是定时器提醒,通知都只是决定 STM32 何时来取数。运动传感器的 INT1 接到的是 STM32 PB4,光学传感器仍按自己的内部时钟采样;换成定时器也不会自动让两个采样时钟变成同一个。

两路数据取出来之后,才遇到 26 与 25 的差别

现在一轮已经拿到 26 对加速度和角速度数据,以及名义上 25 组红光和红外数据。项目的活动识别模型要求六轴输入按 100 Hz 排列,也就是相邻两点相隔 10 毫秒。运动传感器原始数据为 104 Hz,相邻两点实际约隔 9.615 毫秒,直接交过去会让模型对时间长度的理解发生变化。

所以,要把同样约 250 毫秒内的 26 对原始六轴数据,转换为按 100 Hz 排列的 25 对。输入与输出采样率不同,称为重采样;这次输出率更低,也属于降采样。项目采用线性插值,下一节从一个轴、两个相邻点开始讲它怎么算。红光和红外数据已经按 100 Hz 采集,保留原来的数据。

原始加速度还供睡眠特征使用,仍按 104 Hz 保存在 acBuf;活动识别模型使用转换后的 100 Hz 六轴数据。具体输入要求见 算法输入说明和 sleep_features.c 的两路输入采样率设置。

原始六轴数据与重采样数据分别进入不同算法104 Hz 加速度原始数据进入睡眠特征,104 Hz 六轴数据经26到25线性插值变成100 Hz进入活动识别,100 Hz 红光和红外数据直接进入心率血氧和睡眠特征。加速度和角速度104 Hz · 每批 26 对线性插值:26 → 25输出 100 Hz 六轴活动识别睡眠特征红光和红外 100 Hz心率、血氧处理原始加速度保持 104 Hz红光、红外保留原始采样率
同一份原始数据可以服务于不同处理链。每条链都要清楚自己接收的采样率与数据排列。

3 · 线性插值:估计目标采样时刻的数值

先只看加速度 X 轴,用 x[0]、x[1]、x[2]… 表示原始数值。为了便于画图,把这一批第一个点的时刻定为 0。原始点每隔 1/104 秒出现一次,新序列则需要每隔 1/100 秒出现一个点:

104 Hz 原始时刻与100 Hz目标时刻第一个原始点和目标点均在零时刻。第二个目标点在10毫秒,落在9.615毫秒的原始点1与19.231毫秒的原始点2之间,位置为1.04。放大这一批开头:横轴代表时间原始 104 Hz目标 100 Hzx[0]x[1]x[2]x[3]0 毫秒9.615 毫秒19.231 毫秒28.846 毫秒0 毫秒10 毫秒20 毫秒30 毫秒目标 y[1] 落在 x[1] 与 x[2] 之间,更靠近 x[1]
图中画的是数据对应的采样时刻。STM32 稍后批量读取这些点,不会改变它们原本的先后间隔。

目标第 1 点在 10 毫秒,原始第 1 点在约 9.615 毫秒。它们很接近,但不完全重合。最简单的估计办法是:把相邻两个原始点连成一条直线,在目标时刻读出这条线的高度。这就是线性插值。离前一点近,就多参考前一点;离后一点近,就多参考后一点。

目标点编号为 i,它的时刻是 i/100 秒。将这个时刻乘以原始采样率 104,就得到它在原始数组中的位置:

pos = (i / 100) * 104
    = i * 26 / 25
    = 1.04 * i

pos 可以有小数。整数部分告诉我们左边是哪一个原始点,小数部分告诉我们向右走了多少。例如 pos=1.04,就是从 x[1] 向 x[2] 走了 4%。于是:

y[1] = x[1] * 0.96 + x[2] * 0.04

比如实际读到 x[1]=1000、x[2]=1500,直线总共上升 500,走过其中 4%,高度就上升 20,所以估计结果为 1020。这个数仍然使用原始加速度的计数单位;插值只改变数据对应的时间位置,没有把加速度变成角速度,也没有改变量程换算系数。稍后将计数换算成 g 时,仍使用前一篇确定的灵敏度。

一般情况下,记左侧下标为 k,向右的比例为 alpha,就有 y = x[k] × (1-alpha) + x[k+1] × alpha。两项权重相加始终为 1。本项目的 i 只从 0 到 24,因此 pos=i+0.04i 的整数部分恰好一直是 i,可以直接使用 x[i] 和 x[i+1]。这项简化仅适用于当前比例与下标范围。

目标下标 i目标时刻原始位置 pos插值方式
00 毫秒01 × x[0] + 0 × x[1]
110 毫秒1.040.96 × x[1] + 0.04 × x[2]
12120 毫秒12.480.52 × x[12] + 0.48 × x[13]
24240 毫秒24.960.04 × x[24] + 0.96 × x[25]

直接丢掉第 26 个点,会留下间隔仍约为 9.615 毫秒的 25 个点。若每批都这样拼接,批次交界处又出现较大的时间间隔。插值的目的,是按新的均匀时间网格取得 25 个数,保留波形随时间变化的含义。

也不要为了让首尾两个输入点都原样保留,把位置写成 i*25/24。那样会把 25 个输出点铺在原始第 0 点到第 25 点之间,得到的间隔约为 10.016 毫秒;它是一种“首尾位置固定”的重新分配,与这里需要的 10 毫秒时间间隔不同。先确定输出点应该出现的时刻,再推导数组位置,比例就不会只凭点数猜出来。

4 · 对照仓库,把一个轴的方法用于六个轴

打开 main.c 的 DownSampleFrom26To25()。先看四个参数:acc_in 是 26 组三轴加速度,gyr_in 是 26 组三轴角速度;acc_out 与 gyr_out 分别存放转换后的 25 组三轴数据。输入每个数组需要 78 个 int16_t,输出每个需要 75 个。输入与输出使用不同数组,避免写出的结果覆盖后面还要读取的原始点。

每组三轴按 X、Y、Z 连续存放。所以第 i 组的起点是 i*3;组内轴编号 j 从 0 到 2,实际下标就是 i*3+j。有了这个排列规则,再读函数就能把数组下标与前面的插值公式对应起来:

static void DownSampleFrom26To25(int16_t *acc_in, int16_t *gyr_in,
                                 int16_t *acc_out, int16_t *gyr_out)
{
    for (uint8_t i = 0; i < 25; i++)
    {
        float pos = i * 26.0f / 25.0f;
        float weight = pos - (float)i;

        for (uint8_t j = 0; j < 3; j++)
        {
            int16_t v0 = acc_in[i * 3 + j];
            int16_t v1 = acc_in[(i + 1) * 3 + j];
            acc_out[i * 3 + j] =
                (int16_t)(v0 * (1.0f - weight) + v1 * weight);
        }
        for (uint8_t j = 0; j < 3; j++)
        {
            int16_t v0 = gyr_in[i * 3 + j];
            int16_t v1 = gyr_in[(i + 1) * 3 + j];
            gyr_out[i * 3 + j] =
                (int16_t)(v0 * (1.0f - weight) + v1 * weight);
        }
    }
}

26.0f、25.0f 让计算保留小数;weight 就是前面说的向右比例。两个内层循环分别处理三轴加速度和三轴角速度,所有轴共享相同的目标时刻。最后转回 int16_t 时会舍去小数部分,所以手算与实际整数输出可能相差一个计数左右。

仓库把输出数组命名为 MovingAverage_ac、MovingAverage_gy。读代码时要根据运算判断:这里没有滑动窗口求平均,实际执行的是线性插值重采样。变量名是现有代码的命名,不应据此把这个函数理解成移动平均滤波。

只有完整的输入,才能运行这次转换

LSM6DSOX_GetFifoBatch() 的返回值表示实际读到多少对六轴,最后一个参数是本次最多读取多少对。MAX30102_GetFifoBatch() 同样返回实际读到多少组红光、红外。下面省略后续算法,只保留仓库中与采集和转换有关的顺序:

先在主循环外准备好这些数组。sensor_ready 使用第 1 节的全局标志,由 PB4 上升沿中断设为 1。

int16_t acBuf[26 * 3], gyBuf[26 * 3], tempBuf;
int16_t MovingAverage_ac[25 * 3], MovingAverage_gy[25 * 3];
uint32_t PPG_red_buf[25], PPG_ir_buf[25];

主循环中的一轮处理如下:先确认收到通知并清除软件标志,再依次读取六轴数据、红光和红外数据,最后转换六轴数据。到这里才调用刚讲完的重采样函数:

if (sensor_ready) {
    sensor_ready = 0;
    uint8_t imu_count = LSM6DSOX_GetFifoBatch(acBuf, gyBuf, &tempBuf, 26);
    if (imu_count == 26) {
        uint8_t ppg_count = MAX30102_GetFifoBatch(PPG_red_buf, PPG_ir_buf, 25);
        DownSampleFrom26To25(acBuf, gyBuf, MovingAverage_ac, MovingAverage_gy);
        /* 六轴输出有 25 对;红光和红外仅使用 ppg_count 组。 */
    }
}

这一段展示正常一批的处理顺序。若主循环忙了一段时间,缓存队列可能已经积压多于一批;读后要检查剩余数量,若运动传感器仍在水位以上,就继续读取积压,等它回到水位以下,才能等到下一次上升沿。当前仓库把“中断设标志、主循环取数”对应为“中断发出通知、Sensor_Task 取数”,信号量的写法留到实时操作系统章节。

假如加速度和角速度数据只读到 18 对,数组后面仍可能残留上一次的内容,此时不能运行需要 26 对的转换。红光和红外数据只返回 20 组,就只处理这 20 组;不能因为数组容量是 25,便继续处理后面 5 组旧数。数组容量说明能装多少,返回数量说明这一次实际取得多少。这也是驱动函数完成后,调用者仍然需要判断返回值的原因。

当前项目遇到不完整的六轴数据批次,会跳过这轮后续处理。这个策略能避免直接使用残留数值,但也意味着数据流出现缺口。连续窗口算法需要知道缺口,不能把随后收到的点假装接在完整等间隔序列后面;更完整的缺失窗口处理会在算法章节继续展开。

红光和红外两路数据还有一个配对要求:同一个下标的红光和红外来自同一组缓存队列数据。若只取得 20 组,就同时把这两路的有效范围限制为前 20 组。不要为凑满 25,用后一次读取的红光去配前一次残留的红外。读到多少组、是否丢过数据、属于哪一段时间,都应在交给上层处理之前确定。