STM32C5读取LSM6D3TR-C陀螺仪:I2C轮询实战指南
2026/9/8 17:33:25 网站建设 项目流程

最近用 STM32C5 做了一套传感器数据采集的小板子,主控选的是自家生态里货量比较足的型号,传感器则用的是 LSM6D3TR-C。这颗芯片内部集成了三轴加速度计和三轴陀螺仪,是典型的 6 轴 IMU,主要面向可穿戴设备、智能家居、游戏手柄这类对成本和功耗都敏感的场景。我把“轮询获取陀螺仪数据”放在这篇系列文章的第一篇,因为它最能说明 LSM6D3TR-C 的工作流:先通过 I2C 去查状态寄存器,等陀螺仪数据就绪位置 1 后,再把 6 个字节的原始输出读回来。为了让刚入门的朋友也能直接抄作业,我会把从 CubeMX 配置到主循环代码完整讲一遍,同时把我踩过的坑一起写出来,后面中断和 DMA 版本再继续更新。

1. 项目背景与轮询方案选型

1.1 LSM6D3TR-C 究竟是个什么传感器

LSM6D3TR-C 是意法半导体 LSM6D 系列里的一个型号,内部结构可以理解成“两个传感器挤在一个封装里”:三轴加速度计加三轴陀螺仪。加速度计测的是物体受到的加速度,陀螺仪测的是绕 X、Y、Z 三个轴的角速度。两者结合起来,就可以在一定条件下估算物体姿态,这也是消费级无人机、AR 眼镜、运动手表里最常见的传感器方案。

和很多同系列芯片一样,LSM6D3TR-C 对外提供 I2C 和 SPI 两种接口,我这次选的是 I2C。它的寄存器地址是 8 位,内部功能寄存器分成好几组,比较核心的有控制寄存器、状态寄存器、数据输出寄存器、FIFO 相关寄存器。你不需要把所有寄存器都背下来,但几个关键地址必须知道,后面代码里全都会用到:

  • WHO_AM_I(0x0F):读出来的是芯片 ID,用来确认 I2C 链路是不是通了。
  • CTRL1_XL(0x10):控制加速度计的输出速率和量程。
  • CTRL2_G(0x11):控制陀螺仪的输出速率和量程。
  • CTRL3_C(0x12):控制接口、BDU 位和自动地址递增。
  • STATUS_REG(0x1E):用来查询加速度计和陀螺仪是否准备好了新数据。
  • OUTX_L_G(0x22)到 OUTZ_H_G(0x27):陀螺仪三轴原始数据,每轴两个字节,总共 6 字节。

我说这个传感器的时候,有人会拿 MPU6050 来对比。MPU6050 是老牌六轴器件,很多入门的飞控项目都在用;但从寄存器结构、低功耗表现和 FIFO 能力上看,LSM6D3TR-C 这套方案更现代一些。使用方法上两者差别不大,都是先初始化寄存器,再读输出寄存器,核心套路是一样的。

1.2 为什么选 STM32C5 这颗主控

STM32C5 是 ST 在 MCU 产品线里比较新的一代,核心基于 ARM Cortex-M33,带 FPU 和 DSP 指令集。先别听到 FPU 就觉得夸张,我用它的主要原因是后面打算直接在芯片上做简易姿态解算,浮点运算会比定点写起来省心很多,代码也更容易扩展。实际测试下来,裸机轮询读取 LSM6D3TR-C 的占用率其实很低,CPU 大部分时间是空闲的,所以完全有余力去跑滤波和解算。

另一个原因是 STM32C5 的生态很顺。STM32CubeMX 直接支持这套芯片,HAL 库生成代码很快,I2C 外设初始化不需要自己翻寄存器手册。对做传感器验证来说,关键是先把 MCU 和传感器之间的通道打通,而不是去跟底层寄存器较劲。开发板上我用的是 3.3V 供电,I2C 速度设置为 100kbps 的标准模式,这样信号质量和兼容性都比较稳。

如果换成 RK3588 接这颗传感器,流程上其实是类似的,只不过一个在裸机环境直接操作寄存器,另一个在 Linux 用户态通过 I2C 设备节点读写。平台不同,思路一致,先把 I2C 读写函数验明白,后面怎么接都跑不掉这套逻辑。

1.3 轮询和中断、DMA 怎么选

“轮询获取陀螺仪数据”里的“轮询”,指的是 CPU 主动、反复去查传感器状态寄存器,等数据就绪后再读数据。和中断、DMA 相比,轮询最大的优点是逻辑简单,代码里没有回调函数,没有中断优先级配置,也不需要考虑 DMA 缓冲区的生命周期。对刚上手传感器的开发者来说,轮询是理解传感器时序最快的方式。

但轮询也有它的代价:如果传感器输出速率特别高,比如 ODR 设成 6.6kHz,那 CPU 会持续被状态查询占用,没时间干别的事。所以我这次把陀螺仪 ODR 设置成 104Hz,这个速率对姿态显示来讲足够,CPU 绝大部分时间还是在主循环里空跑。

这里顺带说一个容易被坑的点:在实际项目里,轮询不只是这一种形式。有人会把传感器的 INT 引脚接到 MCU 的 IO 口,然后在主循环里查询这个引脚电平,这也算“轮询状态”。甚至一些通信系统里也有类似问题,比如有人用西门子 1200PLC 做 Modbus 轮询读取时,发现读取频率如果设置不合理,高频率请求会互相覆盖其他数据。本质原因和传感器轮询是一样的——在你读取旧数据之前,新数据已经产生,数据帧被覆盖了。所以设计轮询逻辑时,必须把“数据就绪”作为一个判断条件,不能盲目地定时去读。

2. 硬件准备与工程初始化

2.1 接线和地址设置

拿到 LSM6D3TR-C 之后,先别急着写代码,把硬件连接确认清楚最重要。我这块板子上,LSM6D3TR-C 的 VDD 和 VDDIO 都接 3.3V,GND 公共接地。I2C 的 SCL 和 SDA 分别接到 STM32C5 的 I2C1 引脚上,同时各加一个 4.7kΩ 上拉电阻到 VDDIO。很多新手会忽略上拉电阻,导致 I2C 波形上沿太慢,通信时好时坏,尤其数据量大时特别容易出问题。

LSM6D3TR-C 的 I2C 地址取决于 SDO/SA0 引脚的电平。SDO 接地时,7 位地址是 0x6A,左移一位的 8 位写地址是 0xD4;SDO 接高电平时,7 位地址变成 0x6B,8 位地址是 0xD6。我这里把 SDO 拉低,所以后边代码里定义设备地址用的 7 位 0x6A,传给 HAL 函数时左移一位。

接线表整理如下:

信号LSM6D3TR-CSTM32C5备注
VDD3.3V3.3V传感器主电源
VDDIO3.3V3.3VIO 电平参考
GNDGNDGND共地
SCLSCLPB6I2C1_SCL
SDASDAPB7I2C1_SDA
SDOGNDGND设置 I2C 地址
CS3.3V3.3V选择 I2C 模式

CS 引脚在 I2C 模式下必须接高。这个是我刚上手时忽略的问题,CS 悬空的时候 I2C 一直没反应,后来查数据手册才意识到问题,接上拉后立刻正常了。

2.2 在 STM32CubeMX 里配置 I2C

用 STM32CubeMX 新建 STM32C5 工程时,我做了几步配置:

  1. 选择芯片型号或开发板后,先把时钟树配好。系统时钟直接跑最高主频也行,不过为了功耗考虑,我用了 64MHz 的外部晶振倍频方案。
  2. 打开 I2C1 外设,Mode 选 I2C,Speed Mode 选 Standard Mode,I2C Clock Speed 设置为 100000Hz。这里特意用标准模式,一方面兼容 LSM6D3TR-C 的上电时序,另一方面总线上如果还挂其他传感器,100kHz 对走线的容错率更高。
  3. 打开 UART2 用于调试,后面打印陀螺仪数据要用人机对话。波特率设成 115200,不对,直接叫 115200。
  4. 生成工程,代码框架里会带上 MX_I2C1_Init() 和 MX_USART2_UART_Init()。

CubeMX 生成的 I2C 初始化基本不用改,只是我会把超时时间从 100ms 调大一点,因为第一次上电时传感器可能需要时间来稳定,如果是 10 万毫秒这种悬殊配置,完全没必要。重点是确认 GPIO 复用正确,别把 SCL 和 SDA 配成普通推挽输出口。

2.3 传感器寄存器初始化顺序

LSM6D3TR-C 上电后默认是低功耗模式,输出数据还没准备好,必须先写控制寄存器。我在初始化函数里按这个顺序操作:

void LSM6D3_Init(void) { uint8_t id = 0; LSM6D3_ReadRegs(LSM6D3_WHO_AM_I, &id, 1); if (id != 0x6A) { while(1); } LSM6D3_WriteReg(LSM6D3_CTRL3_C, 0x44); LSM6D3_WriteReg(LSM6D3_CTRL2_G, 0x55); LSM6D3_WriteReg(LSM6D3_CTRL1_XL, 0x50); }

第一行读 WHO_AM_I,如果读回来不是 0x6A,说明接线有问题、地址配置错了,或者你的传感器批次 ID 不一样。这里我直接让程序死循环,是为了调试时一眼看出初始化没过。

写 CTRL3_C 时我设置了两个关键位:

  • BDU(Block Data Update)位,置 1。这个位的意思是“数据未读完之前,寄存器内容不更新”。如果不设置它,当 MCU 在读数据的间隙传感器更新了寄存器,就可能出现高字节是旧数据、低字节是新数据的撕裂情况,陀螺仪输出值会莫名其妙跳变。
  • IF_INC 位,置 1。这个位让 I2C 支持自动地址递增,后面一次读 6 字节时才不需要逐个地址操作。

CTRL2_G 写 0x55,表示陀螺仪 ODR 为 104Hz,满量程为 2000dps。CTRL1_XL 写 0x50,表示加速度计 ODR 为 104Hz,满量程为 4g。这个配置对我要做的静态和慢速姿态演示完全够用。

3. 轮询读取陀螺仪的核心代码

3.1 I2C 寄存器的读写封装

STM32C5 的 HAL 库里,I2C 读写用起来很直接。我封装了两个函数,一个是写单个寄存器,一个是连续读多个寄存器:

void LSM6D3_WriteReg(uint8_t reg, uint8_t data) { HAL_I2C_Mem_Write(&hi2c1, LSM6D3_DEV_ADDR, reg, I2C_MEMADD_SIZE_8BIT, &data, 1, 100); } void LSM6D3_ReadRegs(uint8_t reg, uint8_t *buf, uint16_t len) { HAL_I2C_Mem_Read(&hi2c1, LSM6D3_DEV_ADDR, reg, I2C_MEMADD_SIZE_8BIT, buf, len, 100); }

HAL_I2C_Mem_Write 和 HAL_I2C_Mem_Read 是专门针对寄存器型器件的封装,函数内部会先发送寄存器地址,再写或读数据。这里要注意设备地址参数,HAL 库要求传入 8 位地址,也就是 7 位地址左移一位,所以我宏定义里写的是:

#define LSM6D3_DEV_ADDR (0x6A << 1) #define LSM6D3_WHO_AM_I 0x0F #define LSM6D3_CTRL3_C 0x12 #define LSM6D3_CTRL2_G 0x11 #define LSM6D3_CTRL1_XL 0x10 #define LSM6D3_STATUS 0x1E #define LSM6D3_OUTX_L_G 0x22

如果你用的是 SPI 模式,地址就不用左移,但 I2C 模式下这个细节最容易翻车。

3.2 数据就绪位判断与超时保护

轮询读取的核心逻辑不是直接读 OUTX_L_G,而是先读 STATUS_REG,判断陀螺仪数据是否已经准备好。LSM6D3TR-C 的 STATUS_REG 里,bit0 是加速度计数据就绪位,bit1 是陀螺仪数据就绪位。所以代码里要检查(status & 0x02)

必须加超时保护。如果不加,只要传感器一旦配置错误或者总线异常,这个 while 循环就会永远卡死,MCU 连看门狗的机会都没有。我一般给 1000 次查询作为上限,查询时间大约几毫秒,足够覆盖 104Hz 对应的 9.6ms 周期:

int LSM6D3_ReadGyro(int16_t *gyro) { uint8_t status = 0; uint8_t buf[6]; uint32_t timeout = 1000; do { LSM6D3_ReadRegs(LSM6D3_STATUS, &status, 1); if (--timeout == 0) { return -1; } } while ((status & 0x02) == 0); LSM6D3_ReadRegs(LSM6D3_OUTX_L_G, buf, 6); gyro[0] = (int16_t)((buf[1] << 8) | buf[0]); gyro[1] = (int16_t)((buf[3] << 8) | buf[2]); gyro[2] = (int16_t)((buf[5] << 8) | buf[4]); return 0; }

有人会问:为什么不能定时 10ms 读一次输出寄存器,非要去查状态位?因为在高速模式下,如果你一直读,传感器可能会覆盖数据;在低速模式下,提前读会拿到旧数据。状态位就是传感器在 SAY:这次的数据已经准备好了,你赶紧来拿。这也是“状态轮询”这个名字的由来。

3.3 主循环里的完整调用流程

主循环里我写得比较精简:

int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_I2C1_Init(); MX_USART2_UART_Init(); LSM6D3_Init(); int16_t gyro_raw[3]; float gyro_dps[3]; while (1) { if (LSM6D3_ReadGyro(gyro_raw) == 0) { gyro_dps[0] = gyro_raw[0] * 0.070f; gyro_dps[1] = gyro_raw[1] * 0.070f; gyro_dps[2] = gyro_raw[2] * 0.070f; printf("GX:%.2f GY:%.2f GZ:%.2f\r\n", gyro_dps[0], gyro_dps[1], gyro_dps[2]); } } }

实际运行效果是,串口助手以 104Hz 的频率不断输出三轴角速度。板子静止时,三个值都会在 0 dps 附近小幅波动;用手转动板子,对应轴向的值会立刻变化。如果你看到数值不变化,先回去查初始化顺序;如果数值始终是一个固定的大值,多半是数据拼接时高低字节顺序搞反了。

4. 数据处理:角速度换算与误差校正

4.1 怎么把原始值换算成角速度

LSM6D3TR-C 内部 ADC 是 16 位,输出寄存器里存放的是补码形式的原始值。满量程设置为 2000dps 时,-32768 对应 -2000dps,32767 对应约 +2000dps。换算公式很简单:

角速度(dps) = 原始值 * 2000 / 32768

实际测试中,我更习惯用封装好的灵敏系数,这颗传感器在 2000dps 量程下,典型灵敏度是 70mdps/LSB。也就是说,原始值每变化 1,对应角速度变化 0.070 dps。代码里直接乘以 0.070f 就可以:

gyro_dps[0] = gyro_raw[0] * 0.070f;

如果换成了 250dps 量程,那灵敏度就变成 8.75mdps/LSB,系数就要改成 0.00875f。这个数值千万别想当然,得去数据手册的“Sensitivity”表格里查,不同量程对应的系数差距很大。

4.2 零漂、Z轴补偿和姿态解算的关系

陀螺仪不是完美器件,静止时读数不会正好是 0。因为温度、机械应力和硅片工艺的原因,输出值会有零漂。我做 Z 轴补偿的时候,先把传感器水平静置,连续采样 200 帧陀螺仪数据求平均,把这个平均值作为零偏记录下来,以后每次读到的角速度都把它减掉:

static float gyro_offset[3]; void Gyro_CalibZero(void) { int32_t sum[3] = {0,0,0}; int16_t gyro[3]; for (int i = 0; i < 200; i++) { if (LSM6D3_ReadGyro(gyro) == 0) { sum[0] += gyro[0]; sum[1] += gyro[1]; sum[2] += gyro[2]; } HAL_Delay(2); } gyro_offset[0] = (float)sum[0] / 200.0f * 0.070f; gyro_offset[1] = (float)sum[1] / 200.0f * 0.070f; gyro_offset[2] = (float)sum[2] / 200.0f * 0.070f; }

这里说的 Z 轴补偿,其实包括两层意思:一是校正绕 Z 轴的零偏,二是保证 Z 轴和重力方向对齐,让姿态解算初始角度为 0。如果只是做角速度测量,补偿零偏就够了;如果拿去做姿态解算,还需要加速度计参与初始化。很多人一上来就想拿陀螺仪积分算角度,结果越漂越厉害,根因就是没有先做零偏校准。

从这份原始角速度到最终姿态,中间还差一个姿态解算的过程,可以选欧拉角、方向余弦矩阵或者四元数。四元数在嵌入式里用得最多,因为它没有万向锁问题,计算量也可控。这篇文章暂时不会展开,先把干净的角速度数据拿到手,这是第一步也是最重要的一步。

4.3 用串口和虚拟示波器观察数据

数据拿到手后,光看串口数字很难直观感受角度变化。我习惯把数据用文本协议发出去,然后在 PC 上用虚拟示波器软件查看波形。串口打印代码我一般用重定向 printf,如果你用的是 STM32CubeIDE,直接在 usart.c 文件里加上:

int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart2, (uint8_t *)&ch, 1, 100); return ch; }

发送格式保持一行一条,字段用逗号或者空格分隔,这样解析最简单:

GX:-0.05 GY:0.10 GZ:0.02

把开发板水平放在桌面上,然后快速转动板子,你会看到对应轴的波形出现一个脉冲。静止不动时,三个轴的波形都在零线附近小幅抖动。抖动幅度如果到了 ±2dps 以上,就要留意是不是供电纹波太大,或者传感器附近有振动源。

5. 常见问题与排坑记录

5.1 常见问题速查表

我把这套开发过程中最容易遇到的问题整理成一个表,方便你直接对照排查:

现象可能原因解决办法
WHO_AM_I 读不对I2C 地址错误、SDO 电平不对、接线虚焊确认 SDO 接地,地址是 0x6A,检查 SCL/SDA 是否反
WHO_AM_I 读不到值芯片供电异常、CS 悬空、I2C 未初始化给 CS 接 3.3V,确认 VDD 和 VDDIO 有电
GDA 位一直为 0陀螺仪没有配置 ODR确认写入了 CTRL2_G,而不是只写了 CTRL1_XL
数据能读但跳变得厉害寄存器撕裂、电源纹波太大打开 BDU 位,增加 0.1uF 去耦电容
数据像一个固定大数不变高低字节拼接顺序错误检查 buf[1]<<8 还是 buf[0]<<8,And确认数据是补码
上电立刻读的陀螺仪数值不稳芯片内部还在自校准初始化后丢弃前 10 帧,或 HAL_Delay(50) 再读
多次调用占 CPU 高轮询里每次循环都做 I2C 查询先查状态位再读,避免高频空转

这部分是我最想让你收藏的地方。因为这些坑,尤其是 I2C 地址和 BDU 位,不看经验分享的话真的会卡你一两天。

5.2 我这轮开发踩过的坑

第一个坑是 I2C 通信时的上拉电阻。我最初直接用开发板内部上拉,其实部分 STM32 的引脚内部上拉只有几十千欧,用作 I2C 外接总线时驱动能力不够。换了 4.7kΩ 外部上拉后,通信一下子稳定了,再也没出现“读十次失败一次”的问题。

第二个坑是 BDU 位。我第一版代码没设置 BDU,转动板子的速度比较快时,偶尔会出现某个轴的角度值突然跳到 +1800 然后又跳回来,看起来像毛刺。后来仔细读了数据手册,发现是数据撕裂问题,设置 BDU 后毛刺明显消失。

第三个坑和上电时序有关。一开始我在初始化后立刻开始轮询,读到的第一个数经常是一个很大的异常值。这不是芯片坏了,而是传感器刚切换 ODR 时,内部数字滤波还没稳定。我在初始化结束后加了 50ms 延时并丢弃前 10 帧,马上就干净了。

还有一次比较典型的情况,我为了调试方便把 ODR 设为 1.6kHz,主循环轮询也能跑,但我同时在串口打印,导致打印耗时太长,状态位已经被下一次采样覆盖。虽然逻辑上没错,但每隔一段时间会丢失一帧。这种场景下就该考虑 FIFO 或者切换到 SPI,轮询不是不能用,而是要把打印这类阻塞任务从主循环里拿出来。

5.3 轮询方式在真实项目中的边界

最后说一句实在话:轮询获取陀螺仪数据,适合前期验证和低速率场景。我在这篇里设置的 104Hz ODR 属于比较保守的值,轮询完全扛得住。可一旦 ODR 开始往 1kHz 以上走,或者系统里还要跑 LVGL、无线协议栈这些重任务,轮询就会成为瓶颈。到那时候你应该切换到中断模式,或者直接把传感器数据接到 FIFO 和 DMA 上面,让传感器先进 FIFO,再由 DMA 定期搬运,CPU 只负责消费结果。

但不管你选轮询还是中断,Sensor 初始化、寄存器配置、单位换算这些底层逻辑是一模一样的。所以把这一篇完整吃透,后面再升级中断或者 DMA 版本,就是单纯的工程技巧问题,不用再回头啃数据手册。

我在实际开发中还有个体会:很多同学一上来就想实现姿态解算,结果连原始角速度都没打出来就开始调四元数,最后定位问题耗时特别久。正确的路径应该是先打印原始数据,确认传感器通路;再做零偏校准和单位换算;最后才进入滤波和姿态融合。先把这层地基打牢,后面再做 LSM6D3TR-C 的中断方式获取、陀螺仪温度补偿、FIFO 批量读取,都会顺很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询