联合采集与重采样:从 104 Hz 到 100 Hz
LSM6DSOX 每秒产生 104 对加速度和角速度数据,MAX30102 每秒产生 100 组红光和红外数据。由于两颗芯片的可选档位不同,这是以每秒 100 次为目标时最接近的一组配置。下面先由运动传感器的队列水位安排取数,再把 26 对六轴数据重采样为 25 对。
基础查阅:uint8_t、uint32_t 与整数范围 · 队列、入队出队与循环下标。下面沿用前两篇已经讲过的传感器缓存队列。
1 · 先确定采样率,再由队列水位通知取数
先明确两颗器件分别产生什么、多久产生一份:
- LSM6DSOX 运动传感器:加速度计和陀螺仪都设为每秒 104 次,每秒得到 104 对加速度和角速度数据。
- MAX30102 光学传感器:设为每秒 100 次,每秒得到 100 组红光和红外数据。
为什么没有把它们都设成每秒 100 次?
项目希望两路数据最终都按每秒 100 次处理,但采样率只能从芯片规定的档位中选择。两颗芯片在这个目标附近提供的档位并不相同:
| 传感器 | 目标附近的可选档位 单位:次/秒 | 本项目选择 |
|---|---|---|
| LSM6DSOX | 52、104、208 | 104 次/秒 |
| MAX30102 | 50、100、200 | 100 次/秒 |
光学传感器可以直接选每秒 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 来读取。
这些设置已经由前篇的初始化完成,可以对照下面四处看整个连接关系:
| 位置 | 当前设置 | 起什么作用 |
|---|---|---|
| 运动传感器的队列水位寄存器(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 的两路输入采样率设置。
3 · 线性插值:估计目标采样时刻的数值
先只看加速度 X 轴,用 x[0]、x[1]、x[2]… 表示原始数值。为了便于画图,把这一批第一个点的时刻定为 0。原始点每隔 1/104 秒出现一次,新序列则需要每隔 1/100 秒出现一个点:
目标第 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 | 插值方式 |
|---|---|---|---|
| 0 | 0 毫秒 | 0 | 1 × x[0] + 0 × x[1] |
| 1 | 10 毫秒 | 1.04 | 0.96 × x[1] + 0.04 × x[2] |
| 12 | 120 毫秒 | 12.48 | 0.52 × x[12] + 0.48 × x[13] |
| 24 | 240 毫秒 | 24.96 | 0.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,用后一次读取的红光去配前一次残留的红外。读到多少组、是否丢过数据、属于哪一段时间,都应在交给上层处理之前确定。