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

LSM6DSOX:从六轴读数到连续采集

手腕抬起、翻转、晃动时,传感器究竟给出什么数?先分清加速度与角速度,再按现有初始化函数启动测量,读懂六个数的排列和单位,最后让芯片替我们保存连续数据。

前置:MPU6050 寄存器读写使用仓库现有驱动先理解读数,再组织采集
本篇数据手册LSM6DSOX 官方数据手册,DS12814 第 4 版:第 7 页看芯片方向,第 10 页查换算系数,第 54~56 页查测量配置,第 68~71 页看输出排列,第 35~37 页和第 73~74 页查缓存队列。双线通信的地址、读写函数和按位操作沿用前篇。

第 1 节 · 六轴数据,分别告诉我们什么?

LSM6DSOX 内部有一个三轴加速度计和一个三轴陀螺仪。三个轴就是芯片规定的 X、Y、Z 三个方向;加速度计给出沿这些方向的测量值,陀螺仪给出绕这些方向旋转的快慢。合起来一共六个数,称为六轴数据。

器件三个数本篇使用的单位观察什么
加速度计ax、ay、azg,标准重力加速度的倍数加速、减速、振动,以及静止时重力在各轴上的分量
陀螺仪gx、gy、gz度/秒绕各轴旋转的方向与快慢

放在桌上没动,为什么加速度还有数?

加速度计也会反映静态重力的影响。板子平放、芯片的 Z 轴竖直时,通常会看到 Z 轴约为正或负 1 g,其余两轴接近零;把板子翻面,Z 轴读数的符号会改变。翻转后,重力在各轴上的分量随姿态改变;静止时三个分量合成的大小通常仍接近 1 g。

因此,“静止”不能写成“三个加速度都为零”。缓慢倾斜板子时,接近 1 g 的分量会逐渐分到不同轴上。它还不是一个现成的倾斜角度,需要后续根据这些分量计算姿态。实际读数存在噪声和零偏,不会每次都恰好等于理想值。

转了 90 度,陀螺仪会一直输出 90 吗?

陀螺仪测的是角速度。每秒转过 90 度,对应约 90 度/秒;转到新位置后停下来,读数就应回到接近零。若想估计转过多少角度,需要把角速度按时间累积,不能把某一次读数直接当作角度。

拿到数据后,可以先静放、翻面,再绕一个方向缓慢转动,观察对应的变化。芯片坐标由封装决定,板子印的方向和表盘方向可能不同,实际对应关系查手册的封装方向图。这些六轴原始量是后续动作判断的输入。

第 2 节 · 按初始化顺序,让器件开始测量

打开 LSM6DSOX.c,找到 LSM6DSOX_Init()。它接收已经配置好的总线管理对象地址,成功返回 0,失败返回非零。沿着执行顺序,看每一步在解决什么问题:

  1. 记住总线,并清除旧的初始化状态。保存传入对象,检查它是否为空,再选择用于普通测量配置的寄存器组。这颗芯片有多组寄存器,同一个地址在不同组里可能代表不同内容;这里先确保后续访问普通测量这一组。
  2. 确认器件身份。读取 0x0F,预期值为 0x6C。手表上地址选择脚接地,七位设备地址为 0x6A;驱动按前篇约定传入左移后的 0xD4。地址、内部位置、读出的身份值各有用途。
  3. 复位,并等待复位真正完成。向 0x12 写 0x01,发出软件复位请求;随后反复读取,等待复位位自动清零。代码限制尝试次数,每次成功读取后才判断,避免接线出错时永久等待。复位尚未结束就继续配置,新设置可能又被清掉。
  4. 设置加速度测量。向 0x10 写 0x4C,选择每秒 104 次、每轴 ±8 g;再向 0x17 写 0,建立保留静态重力分量的输出配置。采样率决定多久产生新数,量程决定可测的范围和原始整数的换算系数。
  5. 设置角速度测量。向 0x11 同样写 0x4C,含义却是每秒 104 次、每轴 ±2000 度/秒。接着在 0x15 选择滤波参数,在 0x13 接通对应滤波路径,减弱过快变化的成分。参数与路径要配套设置。
  6. 配置连续数据的暂存。设置缓存队列水位为 52 条,加速度和角速度都按每秒 104 条写入,再开启连续保存模式。芯片先保存记录,STM32 稍后来取;一条装什么、52 条代表多少,在第 4 节集中说明。
  7. 安排读数一致性和读取通知。在 0x12 开启一轴高低字节的更新保护和连续读地址递增,同时把中断输出设为推挽、高有效;随后在 0x0D 开启队列水位通知。记录够一批时,芯片会通过 INT1 引脚提醒 STM32。
  8. 全部完成后才报告成功。设置初始化成功标志并返回 0。中间某次通信失败就返回错误,调用者应先处理失败,不能接着把旧数据当作新测量。

这里的高低字节保护,解决的是“低字节刚读完,高字节却已更新成下一次测量”的问题;它不会替我们保留之前产生的多份历史数据。连续保存由队列负责。

实际接线沿用工程:时钟线接 PB6,数据线接 PB3,中断输出 INT1 接 PB4。使用双线通信时,芯片的接口选择脚 CS 保持高电平,地址选择脚 SA0 的接法决定设备地址。配置定义见手册第 54~56 页,板上连线以项目硬件资料为准。

在程序中调用一次初始化

main.c 包含 LSM6DSOX.h 和 i2c.h,在生成的总线初始化完成之后、主循环之前调用:

uint8_t init_result = LSM6DSOX_Init(&hi2c1);

先确认 init_result 为 0,再开始后面的读取。基本初始化已经同时启动测量和队列保存;不需要为每个例子重复调用它。片内决策树的加载放在另一篇,本篇先取得原始六轴数据。

第 3 节 · 六个数怎样从字节变成带单位的读数?

先看当前输出寄存器的排列:一个轴占两个字节,低字节在前,高字节在后。三轴角速度紧接三轴加速度,从 0x22 开始连续读十二字节,就能取出这六个数。

读到数组的位置寄存器地址含义
raw[0]、raw[1]0x22、0x23X 轴角速度,低字节、高字节
raw[2]、raw[3]0x24、0x25Y 轴角速度
raw[4]、raw[5]0x26、0x27Z 轴角速度
raw[6]、raw[7]0x28、0x29X 轴加速度
raw[8]、raw[9]0x2A、0x2BY 轴加速度
raw[10]、raw[11]0x2C、0x2DZ 轴加速度

先确认有新数据,再取出完整字节

驱动已有三个内部读写函数:reg_write() 写一个字节,reg_read() 读一个字节,reg_read_mul() 从指定地址连续读取多个字节。它们返回 1 表示通信成功,读取内容存进传入的变量或数组。这些函数只在 LSM6DSOX.c 内使用,应用调用后面介绍的公开批量接口。

状态寄存器 0x1E 的最低两位,分别说明是否有新的加速度、角速度。两位都为 1 时,再从 0x22 连读十二字节。下面只展开其中 X 轴的两项,其他轴按上表相同方式拼接:

在 LSM6DSOX.c 中对照阅读的片段 · 初始化成功后

uint8_t state = 0, raw[12];
if (reg_read(0x1E, &state) && (state & 0x03) == 0x03) {
    if (reg_read_mul(0x22, raw, 12)) {
        int16_t gx = (int16_t)(((uint16_t)raw[1] << 8) | raw[0]);
        int16_t ax = (int16_t)(((uint16_t)raw[7] << 8) | raw[6]);
        /* 只在读取成功的分支里使用这次结果。 */
    }
}

例如 X 轴加速度低字节为 0x00、高字节为 0x10,合起来是 0x1000,即 4096;低字节为 0x00、高字节为 0xF0,合成 0xF000,按 16 位有符号数解释是 −4096。这样才能保留正负方向。MPU6050 的高字节在前,本器件的低字节在前,不能直接沿用旧驱动的数组顺序。

4096 代表多大加速度?

原始整数还需要乘以当前量程的换算系数。手册表 2 给出:±8 g 时,每增加一个计数,增加约 0.000244 g;±2000 度/秒时,每个计数对应 0.070 度/秒。

加速度 = 原始值 × 0.000244 g
例:4096 → 约 0.999 g;−4096 → 约 −0.999 g

角速度 = 原始值 × 0.070 度/秒
例:1000 → 70 度/秒

换量程后,换算系数也要一起变。采样率表示一秒产生多少份数据,换算系数表示每个整数对应多大的物理量,两者各自决定不同的事情。系数对照手册第 10 页表 2,字节排列对照第 68~71 页。

这种直接读输出寄存器的方法,适合先观察方向和换算。程序若忙了一会儿才来读取,这里的数已经被后来的测量更新了。要保存中间那一段,就需要下一节的队列。

第 4 节 · 用队列保存连续数据,再按批取走

每秒 104 次,大约每 9.6 毫秒产生一对六轴数据。假设刷新屏幕花了几十毫秒,回头读当前输出,只能看到后来的值。解决办法是在芯片内部先把产生的记录排起来,等 STM32 有空再按顺序取走。

先认识队列:先来的数据,先处理

暂时放下寄存器,假设某个时刻先后收到三个数:10、20、30。程序还没来得及处理它们,就先按到达顺序存起来。等程序有空,先拿 10,再拿 20,最后拿 30。这样保存待处理数据,并从最早的一份开始取出的组织方式,叫队列。

把新数据放在队列末端,叫入队;把最早的数据取出并从待处理内容中移除,叫出队。下一份要取出的数据所在的一端叫队头,最新入队的数据所在的一端叫队尾。例如 10、20、30 都在等待时,队头是 10,队尾是 30:

队列一次出队和一次入队的变化10、20、30先后入队,队头是10,队尾是30。取出10后剩20、30,再放入40后顺序为20、30、40;下一次仍先取20。先后放入 10、20、30出队:取走 10入队:在末尾加入 401020302030203040队头队尾队头队尾队头队尾10 已离开待处理队列下一次仍先取 20
方框表示等待处理的数据。出队以后,10 不再等待处理;后面到来的 40 排在 30 后面。

队列规定了先来先处理的顺序。接下来用数组保存这些数据,看看数组前面腾出的空位应该怎样再次利用。

数组还没满,为什么却写不进去了?

假设数组只有四个位置,下标为 0、1、2、3。用读下标记录下一份要取的数据在哪里,用写下标记录下一份数据要写在哪里;再用待处理数量记录还有几份数据没有取走。先采用一个最直接的规则:每次入队或出队,相应的下标都只向后移动一格。

只向后移动时,数组前面有空位,末尾却写不进去四格数组先放入10、20、30、40;取走10、20后,读下标为2,写下标为4。前面两个位置空闲,但4号位置不属于数组,50无法按只向后写的规则入队。只向后移动:前面腾出了空位,末尾却写不进去① 依次放入四个数0 号101 号202 号303 号40读下标写下标 = 4数组之外② 取走 10、200 号空闲1 号空闲2 号303 号40读下标写下标 = 4数组之外③ 还想放入 50:前面有两个空位,但没有 4 号位置。待处理数量只有 2;按“只向后写”的规则,仍然无法入队。
虚线框是已经出队的空位,橙色虚线标出数组边界。读下标移动了,写下标却不能继续向后,才需要考虑怎样复用前面的空间。

问题出在这里:写下标已经越过数组末尾,读下标却也向后移动过,前面明明还有两个空位。如果只允许继续向后写,就无法再入队。这里的“空闲”表示原来的数据已经出队,不要求把内存里的旧数值清成零;图中的 4 只表示写到了边界,不能真的访问数组的 4 号位置。

把剩余数据搬到前面可以解决,但要多做搬运

一种办法是把 30 从 2 号搬到 0 号,把 40 从 3 号搬到 1 号,再把读下标设为 0、写下标设为 2。这样 50 就能写入 2 号位置,前面的空间也重新用上了。

但这次要额外移动两个数。如果队列里还剩两千个数,就要移动两千个数。整理一次数组所需的工作量,会随剩余数据量增加。把剩余数量记为 n,这种搬移的时间复杂度就是 O(n)。我们只是想再放入一个数,却可能要先搬动整段已有数据。

循环队列:直接利用前面已经空出来的位置

能不能让 30、40 留在原位,把 50 直接写到已经空闲的 0 号位置?可以。为此,把下标移动规则改成:走过最后一个位置以后,回到 0 号位置继续。这就是把数组首尾接起来使用的循环队列,也常称环形缓冲区。数组本身没有移动,改变的是下一次访问的位置。

回到刚才的例子。采用这个规则后,40 写入 3 号位置时,写下标就回到 0;随后取走 10、20,读下标移到 2。接着放入 50、60:

循环队列复用空位,3号之后回到0号左图为取走10、20后的循环队列:0号和1号空闲,2号是30,3号是40,读下标2,写下标0。右图为再放入50、60后:0号50,1号60,2号30,3号40,读写下标都是2,待处理数量4。箭头按0、1、2、3、0循环,逻辑读取顺序30、40、50、60。循环使用四个位置:走过 3 号,就回到 0 号① 取走 10、20 后② 再放入 50、600 号空闲1 号空闲2 号303 号403 → 0下标回绕写下标 = 0读下标 = 2;数量 = 20 号501 号602 号303 号403 → 0下标回绕50、60 写入原来的空位读 / 写下标 = 2;数量 = 430、40 没有搬动;下一次从 2 号位置开始读读取顺序:30 → 40 → 50 → 60
圆环表示下标的访问顺序,数组在内存中的排列没有改变。橙色箭头表示从 3 号回到 0 号;右图已经满了,下标相等时要结合数量判断。

有效数据的先后顺序仍是 30 → 40 → 50 → 60。读取时,从读下标 2 开始,依次经过 2、3、0、1。读下标和写下标都遵循同一条回绕规则:在这个四格数组里,3 的下一个位置是 0。这就是后面所说的“指针回绕”:循环队列通过这样的回绕,反复利用已经出队的位置,避免为了腾空间而整体搬移数据。

每次入队或出队,只处理当前一个数据,再更新下标和数量;无论队列长短,都不用搬动其余数据。这种固定工作量的时间复杂度记为 O(1)。这个四格数组只是演示循环队列,并不代表传感器缓存的实际容量。

回绕也需要配合空、满判断。右图中读写下标都是 2,数量为 4,说明已经满了;如果再依次取走四个数,读下标绕一圈又回到 2,写下标仍为 2,但数量为 0,说明已经空了。因此,不能只凭两个下标相等就认定为空。本例用待处理数量区分;具体芯片可能提供计数或状态标志,需要按手册判断。

只有空闲位置才能继续存入而不丢失数据。队列真的满了以后,需要确定是停止接收新数据,还是覆盖最旧的数据;选择后者,就意味着一部分历史数据没有被读取。

手册中的“读指针、写指针”,在这里表示读写位置。读指针标记下一份从哪里取,写指针标记下一份往哪里放。上面的数组下标是在演示这些位置怎样移动;具体芯片是否把位置提供为可读写寄存器,要分别查看手册。

回到传感器:谁把测量数据放进队列?

前面用数组解释了队列怎样保存数据。LSM6DSOX 内部也有一块保存测量记录的空间,由芯片自己的电路管理;手册把这块先进先出的缓存称为 FIFO,下面称缓存队列。开启保存后,新测量由传感器自动放进队列,STM32 稍后通过总线读取。这就接上了本节开头的问题:主控忙着刷新屏幕时,传感器仍能先把这段时间产生的记录存下来。

STM32 不必像操作演示数组那样,自己修改传感器内部的读写下标。传感器提供查询数量、取出记录的寄存器接口;程序读完一条记录,芯片会推进内部读取位置,下一次再从出口读取时,就能取到后续记录。

先看队列保存的内容。当前项目关闭压缩,只保存加速度和角速度;一条记录由一个字节的类型标签,加六个字节的数据组成。标签说明这一条来自哪种测量。

这一条的类型标签后的六个字节能得到什么
加速度X 低、高;Y 低、高;Z 低、高三个加速度原始值
角速度X 低、高;Y 低、高;Z 低、高三个角速度原始值

所以,一条记录不是完整六轴;取得一条加速度和一条角速度记录,才组成一对六轴。初次读队列时,也不能假定第一条一定是加速度,应该先看标签。

沿着现有批量函数,看一轮怎样取出

知道一条记录的格式后,就能沿着现有的 LSM6DSOX_GetFifoBatch() 看读取过程。它做的事情是:查询还有多少条未读记录,依次取走,再把加速度与角速度整理成一组六个数。这里读取的是先前保存的测量,和第 3 节直接读取当前输出寄存器的用途不同。

函数先查询 0x3A、0x3B,得到当前未读记录的数量,对应前面演示中的“待处理数量”。随后循环执行下面几步:

  1. 读标签。从 0x78 读取标签字节;高五位表示类型,代码用 tag >> 3 取出。当前未压缩角速度的类型号为 1,加速度为 2。
  2. 读这一条的数据。从 0x79 读取六字节,按上一节的低、高字节顺序拼成三个带符号整数。这里同样的拼接方法,只是字节来自缓存队列。
  3. 按类型暂存。加速度放入 cur_acc,角速度放入 cur_gyr,用两个标志记住已经拿到哪一种。
  4. 两种都有,交出一对。将各自三个数放入调用者数组,实际取得对数加一,再清掉两个标志,继续等待下一对。达到本次最多读取对数或没有更多记录时结束。

比如标签字节为 0x10,右移三位得到 2,这条就是加速度;其余低位还包含计数、校验信息,不能把整个标签直接当类型号。遇到不使用的类型,也需要按格式取走相应记录,不能只跳过标签,把后续数据误当作下一个标签。

当前配置没有把温度和时间戳加入队列。接口虽然留了温度参数,也不能据此认为每批一定有新的温度;本篇调用时传 NULL。队列格式和状态对照手册第 35~37 页、第 73~74 页及标签输出寄存器说明。

怎样知道该来取了?初始化里的水位 52 是什么意思?

现在已经会取队列里的数据了,还剩一个问题:什么时候来取?可以在主循环里反复查询数量,也可以让传感器在攒够一批时,通过引脚发出通知。本项目采用后一种办法,因此需要先规定“够一批”是多少。

水位就是预先规定“积累多少条未读记录时提醒读取”的数量。本项目希望每批取 26 对六轴,一对占两条记录,所以水位为 26 × 2=52 条。两类数据各以每秒 104 条入队,名义上约 250 毫秒积累一批。

第 2 节的初始化已经配置好数量、入队频率和连续保存模式,并把水位通知接到传感器的 INT1 引脚。按当前高电平有效的设置,记录还不够时,这根引脚为低电平;未读数量达到 52 条时,它变为高电平。INT1 接到 STM32 的 PB4,于是 STM32 就能利用这个低到高的变化触发外部中断,得知“该来取这一批了”。

通知发出后,数据仍在传感器队列里,需要程序通过总线把它们读走。读到未读数量低于 52 条,水位条件解除,引脚恢复低电平;后续测量继续入队,攒够下一批又会发出通知。怎样配置这一中断,并在同一轮读取另一颗光学传感器,留到联合采集篇集中说明。

水位低于队列总容量,提供的是“该来取了”的提醒。连续模式下,队列满了会用新数据覆盖最旧的数据;程序长期取不及时,历史数据仍会丢失。当前驱动没有完整向调用者报告队列溢出,持续采集还应检查状态并记录缺失。

最后,在主循环调用现有接口

LSM6DSOX_GetFifoBatch(acceleration, gyroscope, temperature, maximum) 的前两个参数接收加速度、角速度数组,第三个是可选温度输出,最后一个限制最多读取多少对;返回这次实际取得的对数。两份数组各按 X、Y、Z 连续排列,最多 26 对就各准备 26×3 个元素。

第 2 节的初始化在主循环前执行一次。下面数组也在循环外准备好:

int16_t acceleration[26 * 3];
int16_t gyroscope[26 * 3];

主循环先用轮询调用,确认能取到数据。init_result 沿用初始化的返回值,成功才读取;count 大于零才使用这一批数组:

if (init_result == 0) {
    uint8_t count = LSM6DSOX_GetFifoBatch(
        acceleration, gyroscope, NULL, 26);
    if (count > 0) {
        float first_ax = acceleration[0] * 0.000244f;
        float first_gx = gyroscope[0] * 0.070f;
        /* 这批有效元素仅为下标 0 到 count*3-1。 */
    }
}

返回 18 就只取得了 18 对,后面预留的位置不属于新数据。返回 0 可能没有数据,也可能通信失败,应结合通信状态排查。程序中的批量读取,只是把先前排队的测量取走;它不会让传感器在读取瞬间重新测量 26 次。

把连续采集连起来看

传感器产生测量 → 自动把记录放进队列 → 积累到水位,INT1 变高通知主控 → 主控批量读取 → 未读数量降到水位以下,INT1 恢复低电平 → 继续积累下一批。

上面的主循环例子先用轮询验证批量读取;在联合采集篇,再把读取时机接到水位中断上。这两种安排使用的是同一个队列读取接口。现在,从测量、保存到通知和取出,各自由谁完成就能对应起来了。

六轴数据可以描述手腕怎样运动。手表还要知道心跳和血氧,可下一颗传感器实际给出的却只是红光和红外光的读数:光为什么会随心跳变化,两个光学数值又怎样用于估计血氧?下一篇从这些问题进入 MAX30102,继续使用已经学过的寄存器读写与队列知识。