最近在调一块STM32C5开发板,外接ST的六轴IMU传感器LSM6D3TR-C,第一步就是先把陀螺仪数据稳定读回来。这一篇是整个系列的第(1)篇,不扯别的,就聊一件事:怎么用轮询(polling)的方式把三轴陀螺仪数据读出来,并且换算成人能看的角速度值。整个过程看着简单,但里面牵扯到I2C通信、寄存器配置、补码换算这些基本功,实际跑起来踩的坑也不少。如果你正在STM32C5上驱动这颗传感器,或者手里有一颗类似的六轴IMU想先跑通,这篇文章可以直接照着做。
传感器调试最怕的不是芯片完全不工作,而是它“看似在工作”、读回来的数据却全是错的。方向搞反、量程算错、数据跳变,这些问题一旦带着往前走,后面做姿态解算、滤波、动作识别时都会被无限放大。所以这一篇我会把从接线、CubeMX配置、寄存器说明到轮询代码的每个细节都摊开讲,尽量让你一次性把数据读对。
1. 先把思路理清楚:为什么要用轮询
1.1 STM32C5和LSM6D3TR-C的组合好在哪
STM32C5是ST近两年主推的Cortex-M33内核系列,定位是主流MCU的升级方向,主频高、外设全,而且有TrustZone这类安全特性,跑传感器算法、边缘推理都够用。做IMU数据采集这种活,它的硬件I2C、SPI、DMA资源完全有余量,关键是CubeMX的代码生成支持做得比较成熟,外设初始化基本不用手写。
LSM6D3TR-C则是ST的六轴惯性传感器,里面同时集成了一个三轴陀螺仪和一个三轴加速度计,后缀的“TR”只是编带包装方式,不影响芯片本身。它支持I2C和SPI两种数字接口,供电范围是1.71V到3.6V,适合3.3V系统直连,陀螺仪的满量程可以从±125 dps一直选到±2000 dps,ODR最高能到6.6kHz以上。对于做姿态检测、运动追踪、机械振动监测这些场景,这颗芯片的性价比很高。
这一篇只读陀螺仪,加速度计那部分后面单独开一篇讲。先把一个轴的传感器数据链路跑通,再扩展到六轴,这是做传感器驱动最稳妥的路径。
1.2 轮询、中断、DMA三种方式怎么选
读取IMU数据常见有三种方式:轮询、中断、DMA。我在这篇里选轮询,不是因为它是最高级的方案,而是因为这个场景下它最合适。
轮询的逻辑就是主循环不停地去查状态寄存器里的“数据就绪”标志位,查到了就立刻去读。好处是代码简单、逻辑直观、出问题好排查,你随时知道程序执行到哪一步。坏处是MCU得一直陪着它转,不能干别的复杂任务。
中断方式需要把传感器的INT1/INT2引脚接到MCU的GPIO中断上,数据准备好时由传感器主动通知MCU,效率更高,但需要额外配置中断引脚和回调函数,调试链路变长。DMA方式效率最高,但也要等数据链路稳定了再上,不然DMA搬运回来的数据一样是错数据。
对于系列第一篇文章,目标是“把数据正确读出来”,轮询是最不容易出错的起点。先把轮询跑通,再逐步改中断、改DMA,后面甚至可以用SPI代替I2C提高传输速率,这些都有个共同的验证基础——那就是你手里这份轮询代码读出来的数据是准的。
1.3 轮询的前提:传感器有自己的数据节奏
需要特别强调的是,传感器不是MCU想读就能立刻给新数据的。LSM6D3TR-C内部有一个采样和转换的过程,陀螺仪按照你配置的ODR(输出数据速率)周期性更新内部寄存器。举个例子,如果ODR配成208Hz,那么每秒钟传感器最多产生208组新数据,约4.8ms一组。
如果我们不管这个节奏,上去就读输出寄存器,很可能读到的是上一帧的旧数据甚至半新半旧的数据。轮询的“轮”就轮在这里:一直查状态寄存器的GDA位,这个位置1说明陀螺仪寄存器里已经有新数据可以读了,再去读才不会读到过期数据。理解这一点,后面写代码时就不会把“轮询”简单地写成“循环delay+直接读”。
2. 硬件连接和工程初始化
2.1 接线列表与供电要点
STM32C5开发板与LSM6D3TR-C模块之间,这一篇用I2C接口。I2C只需要两根数据线加两根电源线,连线非常省事,特别适合模块验证阶段。我这里把实际接线整理一下:
| 模块引脚 | 开发板引脚 | 说明 |
|---|---|---|
| VCC | 3.3V | 供电,注意不要接到5V |
| GND | GND | 共地 |
| SCL | I2C1_SCL | 由CubeMX分配,比如PB8 |
| SDA | I2C1_SDA | 由CubeMX分配,比如PB9 |
| SA0 | GND或悬空 | 决定I2C地址,见下文 |
| INT1/INT2 | 暂不接 | 轮询模式下用不到 |
有三个硬件细节值得说。
第一,供电必须确认模块是3.3V版本,接到5V大概率会烧传感器。如果你用的是3.3V的模块核心板,注意板上的LDO和电平转换电路,部分便宜模块其实是5V供电的,ST芯片本身真的扛不住。
第二,SA0引脚决定了I2C从机地址。SA0接GND时,7位地址是0x6A;SA0接VCC时,7位地址是0x6B。很多现成的模块上SA0已经被设计好焊死,所以驱动里我保留了“0x6A不行就换0x6B”的排查思路,这在后面问题章节会展开。
第三,如果I2C线上有多个设备,注意总线上拉电阻。开发板的I2C接口一般已经配上了4.7k或10k上拉,但如果你的模块是自定义PCB,一定在SCL和SDA上各加一个上拉电阻,否则通信会时好时坏,波形边沿爬升缓慢,频率稍微拉高就出错。
2.2 CubeMX里的关键配置
STM32C5用STM32CubeMX生成工程,这是ST官方工具链里最省心的方式。我习惯的配置顺序是先配时钟,再配外设,因为外设时钟依赖RCC。
时钟方面,STM32C5通过HSE+PLL把系统时钟拉高,CubeMX会自动同步各外设时钟树。I2C的时钟源一般可以直接用系统给的分频,不需要手动改得很细。
I2C配置上,我直接把I2C1打开,模式选I2C,速度选Fast Mode(400kHz)。可能有人会想选Fast Mode Plus(1MHz)提升速度,但第一版驱动不建议,因为1MHz对线路寄生电容和上拉电阻更敏感,调试难度陡增。400kHz对轮询读陀螺仪来说完全够用:一次读6字节数据在400kHz下约耗时不到200微秒,远小于4.8ms的数据周期。
串口方面,把UART2对应到板载ST-Link的虚拟串口上,参数115200-8-N-1,用于printf打印数据。STM32CubeIDE默认就把printf重定向到了_write函数,我们手动补一个实现就行,这点放到下一小节。
外设全部配置好之后,Project Manager里点Generate Code,工程就出来了。接下来所有工作都在main.c和一个自己新建的lsm6d3.c/h里完成,保持代码结构清楚,后续扩展SPI模式或中断模式时也不至于把main函数塞成一大坨。
2.3 串口printf调试通道怎么通
调试IMU这类数据流,串口打印是排第一位的工具。没有printf裸看寄存器和变量太痛苦,打印一次三轴数据也就几十个字节,115200波特率下足够每秒刷一百次以上。
在STM32CubeIDE里,printf重定向只需要在usart.c或者main.c里补这样一个函数:
#include <stdio.h> int _write(int file, char *ptr, int len) { HAL_UART_Transmit(&huart2, (uint8_t *)ptr, len, 0xFFFF); return len; }这个函数的本质是:C标准库的printf最终会调用_write,我们把它接到HAL库的UART发送函数上,这样printf就能走串口输出了。注意第一参数填的其实是句柄地址,需要按你在CubeMX里实例化的句柄名来写,比如huart2。
我习惯在初始化尾部加个开场白打印,比如“LSM6D3TR-C demo start”,这样串口一打开就能确认代码在跑。后面每步初始化也分别打印状态码,出问题时从打印日志能直接定位是哪一步失败了。
3. 读懂寄存器,才是读懂这颗IMU
3.1 先验证通信:WHO_AM_I
驱动IMU的第一步不是配置量程,而是读WHO_AM_I寄存器。它相当于传感器的身份证明,地址是0x0F。LSM6D3TR-C的WHO_AM_I数值在0x69附近,具体以你手头这颗芯片数据手册的标识为准,不同封装批次可能会有差异,但同一个型号应该固定一致。
#define LSM6D3_I2C_ADDR (0x6A << 1) #define LSM6D3_WHO_AM_I 0x0F #define LSM6D3_CTRL1_XL 0x10 #define LSM6D3_CTRL2_G 0x11 #define LSM6D3_CTRL3_C 0x12 #define LSM6D3_STATUS_REG 0x1E #define LSM6D3_OUTX_L_G 0x22 #define LSM6D3_OUTX_H_G 0x23 #define LSM6D3_OUTY_L_G 0x24 #define LSM6D3_OUTY_H_G 0x25 #define LSM6D3_OUTZ_L_G 0x26 #define LSM6D3_OUTZ_H_G 0x27用HAL库的I2C读函数,一次Mem_Read就能读回来:
uint8_t who_am_i = 0; HAL_StatusTypeDef status; status = HAL_I2C_Mem_Read(&hi2c1, LSM6D3_I2C_ADDR, LSM6D3_WHO_AM_I, I2C_MEMADD_SIZE_8BIT, &who_am_i, 1, 100); if (status != HAL_OK) { printf("I2C read failed\r\n"); return 1; } if (who_am_i != LSM6D3_EXPECTED_WHO_AM_I) { printf("WHO_AM_I error: 0x%02X\r\n", who_am_i); return 2; } printf("WHO_AM_I = 0x%02X, chip OK\r\n", who_am_i);这一小段代码能拦住八成的前期问题。如果I2C读返回超时,基本是接线错、地址错、上拉缺失;如果WHO_AM_I读出来是个不认识的数值,可能是芯片供电异常、I2C总线竞争,或者买到了打磨片。注意HAL_I2C_Mem_Read的第二个参数,传的是(0x6A << 1),因为HAL内部默认7位地址模式,习惯上把7位地址左移一位传入,这点新手特别容易写错。
3.2 三个必须配置的控制寄存器
确认芯片在线之后,接下来要配置三个寄存器。
第一个是CTRL3_C(0x12),它里面有两个位影响后面的所有数据操作:BDU位和IF_INC位。BDU(Block Data Update)位我们一般直接置1,它的作用是让传感器在读取一组数据时“锁存”,防止应用程序读到高字节和低字节分别来自不同时刻的旧数据。IF_INC位置1后,连续读取时寄存器地址能自动递增,这样我可以用一次Mem_Read直接连续读出6个陀螺仪字节,省掉多次I2C事务的开销。
uint8_t ctrl3_c = 0x44; // BDU=1, IF_INC=1 HAL_I2C_Mem_Write(&hi2c1, LSM6D3_I2C_ADDR, LSM6D3_CTRL3_C, I2C_MEMADD_SIZE_8BIT, &ctrl3_c, 1, 100);0x44这个值的来历:bit6是BDU,bit2是IF_INC。所以二进制约是0100 0100,正是0x44。你也可以写成可读性更好的位操作形式,比如ctrl3_c |= (1 << 6); ctrl3_c |= (1 << 2);,效果一样。
第二个是CTRL2_G(0x11),陀螺仪的控制寄存器。它的高4位决定ODR,中间2位决定满量程(Full Scale),第1位是±125dps扩展档。我这一篇选ODR=208Hz,满量程±500dps,对应字节是0x52。
为什么选208Hz和±500dps?208Hz作为一个中等输出速率,既能观察人手转动时的动态变化,又不会产生超出串口打印能力的数据量。±500dps则覆盖了大多数手持设备的角速度范围,灵敏度17.5 mdps/LSB,比±2000dps的70 mdps/LSB细腻得多。如果要测高速旋转,比如飞盘或电机的瞬时转速,再换到±2000dps不迟。
第三个是CTRL1_XL(0x10),加速度计的控制寄存器。这一篇不读加速度计,所以直接写入0x00让它进入power-down模式,省电且减少内部噪声耦合。这里有个常见的理解误区:虽然这颗芯片是六轴IMU,但陀螺仪和加速度计在寄存器配置层面是完全独立的两个单元,只读陀螺仪时,加速度计完全可以关掉。
3.3 灵敏度与满量程:LBS怎么变成dps
陀螺仪输出的是16位有符号整数,单位是LSB(最低有效位)。把它变成物理量角速度还需要乘以一个灵敏度系数,这个系数由你选择的满量程决定。LSM6D3TR-C的灵敏度数值如下:
| 满量程 | 灵敏度(mdps/LSB) | 灵敏度(dps/LSB) |
|---|---|---|
| ±250 dps | 8.75 | 0.00875 |
| ±500 dps | 17.5 | 0.0175 |
| ±1000 dps | 35 | 0.035 |
| ±2000 dps | 70 | 0.07 |
这个表是数据手册里的固定参数,不需要自己标定。换算公式很简单:
角速度(dps) = 原始整数(LSB) × 灵敏度(dps/LSB)
举个例子,如果Z轴原始值是5600,我们的满量程是±500dps,那么实际角速度就是5600 × 0.0175 = 98 dps。这个数是带正负的,正值表示绕该轴逆时针旋转(从轴正方向看),负值表示顺时针。
最容易翻车的点是把mdps/LSB当成dps/LSB直接用。8.75 mdps/LSB如果误当dps用,算出来的数据会膨胀1000倍,打印出来都是几千“dps”,一看就是错的。我建议代码里统一用dps为单位,把灵敏度写成浮点数常量:
#define LSM6D3_GYRO_SENSITIVITY_250DPS 0.00875f #define LSM6D3_GYRO_SENSITIVITY_500DPS 0.0175f #define LSM6D3_GYRO_SENSITIVITY_1000DPS 0.035f #define LSM6D3_GYRO_SENSITIVITY_2000DPS 0.07f注意LSB是16位有符号数,范围是-32768到32767,对应两个字节的高低位组合。读寄存器时先读低字节再读高字节,拼接时用(int16_t)((hi << 8) | lo),千万不要少一个转int16_t,否则负数的符号位会出错,导致静止时数据在一个奇怪的方向上跳变。
4. 轮询读取陀螺仪:代码实现与实测
4.1 轮询的核心逻辑:别直接读数据,先看状态位
轮询读取的思路可以概括成三步:第一步读STATUS_REG,第二步检查GDA位,第三步当GDA为1时读数据。
STATUS_REG的地址是0x1E,bit0是XLDA(加速度计数据就绪),bit1是GDA(陀螺仪数据就绪)。我们只关心GDA位,也就是status & 0x02。
为什么不直接延时一个ODR周期然后读数据?因为ODR的节拍和主循环的节奏不一定对齐,而且MCU的HAL_Delay本身带有一定抖动。如果读的时候传感器还没来得及更新下一帧数据,你拿到的还是旧数据。检查GDA位是最可靠的“数据新鲜度”判断方式,这也是轮询这个动作的核心价值。
第一次写轮询驱动的人容易忽略一个问题:GDA为0时不代表出错,它只是表示“这帧数据还没准备好”。代码里不应该直接把这种情况当错误打印,而应该继续下一轮循环,或者用一个计数器统计未就绪的次数,帮助判断ODR配置是否太高。我刚调这块芯片时,把GDA为0当错误输出,串口被刷爆,还误以为传感器坏了。
4.2 轮询读取函数的完整实现
我把读取函数写在一个独立的头文件里,方便后面第二篇、第三篇继续扩展。核心读取代码如下:
uint8_t lsm6d3_read_gyro(float *gx, float *gy, float *gz) { uint8_t status = 0; uint8_t data[6] = {0}; int16_t raw_x, raw_y, raw_z; HAL_StatusTypeDef ret; // 第一步:读状态寄存器,检查GDA位 ret = HAL_I2C_Mem_Read(&hi2c1, LSM6D3_I2C_ADDR, LSM6D3_STATUS_REG, I2C_MEMADD_SIZE_8BIT, &status, 1, 100); if (ret != HAL_OK) { return 1; } if (!(status & 0x02)) { return 2; // 数据未就绪,不是错误,只是还没到时间 } // 第二步:连续读取6个字节的陀螺仪数据 ret = HAL_I2C_Mem_Read(&hi2c1, LSM6D3_I2C_ADDR, LSM6D3_OUTX_L_G, I2C_MEMADD_SIZE_8BIT, data, 6, 100); if (ret != HAL_OK) { return 3; } // 第三步:拼接16位有符号数 raw_x = (int16_t)((data[1] << 8) | data[0]); raw_y = (int16_t)((data[3] << 8) | data[2]); raw_z = (int16_t)((data[5] << 8) | data[4]); // 第四步:换算成dps *gx = (float)raw_x * LSM6D3_GYRO_SENSITIVITY_500DPS; *gy = (float)raw_y * LSM6D3_GYRO_SENSITIVITY_500DPS; *gz = (float)raw_z * LSM6D3_GYRO_SENSITIVITY_500DPS; return 0; }主循环里这样用:
while (1) { float gx = 0.0f, gy = 0.0f, gz = 0.0f; uint8_t ret = lsm6d3_read_gyro(&gx, &gy, &gz); if (ret == 0) { printf("G: %.2f %.2f %.2f dps\r\n", gx, gy, gz); } HAL_Delay(5); }主循环里加一个5ms的延时,是为了控制打印频率,防止串口被刷爆。实际上读取本身是由状态位来控制的,即使你不加延时,GDA位也会保证每次读到的都是新数据。但这颗传感器ODR配的是208Hz,每4.8ms才有一帧新数据,循环里加5ms延时和读取节奏刚好匹配,不会漏帧。
4.3 实测输出与关键数据验证
把代码烧进去,打开串口终端,应该能看到类似下面这样的输出:
G: 0.02 -0.01 0.03 dps G: 0.01 0.00 -0.02 dps G: -0.02 0.01 0.01 dps用手拿着板子绕Z轴转一下,Z轴的数值会明显变大,正值或负值取决于旋转方向,而X、Y轴数值维持在小噪声范围内。静止时三个轴的输出都应该在0 dps附近浮动,波动幅度通常不超过±0.5 dps。如果静止时某一轴稳定在几dps以上,说明该轴存在偏置,传感器内部和PCB应力都会影响这个值,软件上可以用零偏标定来补偿,这在后面滤波时再展开。
这套输出同时验证了四个关键点:I2C通信没问题、寄存器配置生效、轮询状态位逻辑正确、数据换算公式没写错。这一步跑通了,后续加加速度计、加中断、加DMA才有的放矢。
5. 踩坑记录和排查手册
5.1 通信层排查:I2C常见故障
如果串口打印停留在“I2C read failed”,说明I2C通信根本没建立起来。我的排查顺序固定是:先用万用表量模块VCC是不是3.3V,再量SCL和SDA对地电压是否在供电电压附近,然后检查接线两端是否共地,最后才怀疑软件。
I2C地址是最容易被绕进去的一环。寄存器地址和数据手册里SA0默认电平一定要核对。如果代码里用的是0x6A读不通,直接改成0x6B试一下,很多板子已经把SA0拉到了VCC。注意HAL库函数传参时地址需要左移一位,如果你用(0x6A)直接传,总线上的地址就变成了0x35,完全对不上。
上拉电阻缺失也是高频问题。示波器看SCL波形如果是缓慢的三角波而不是方波,几乎可以肯定上拉电阻有问题或者主板寄生电容太大。没有示波器时有个土办法:把I2C频率降到100kHz,如果通信恢复了,十有八九是上拉不足。
5.2 数据层排查:读到错数据的典型场景
通信正常但数据不对的情况更隐蔽,这里把我实际踩过的坑整理成一张速查表:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| WHO_AM_I是0xFF或0x00 | 从机地址错、芯片没供电 | 核对SA0电平,换地址重试 |
| 三轴数据恒定不变 | 没配置ODR,芯片处于power-down | 检查CTRL2_G,确认ODR位非0 |
| 数据量级看似正常但噪声很大 | 未开启BDU,高低字节跨帧拼接 | CTRL3_C置BDU位 |
| 静止时某轴有几十dps偏差 | 未做零偏校准,或PCB应力影响 | 温度稳定后记录零偏并补偿 |
| 转动时数据变化不跟手 | ODR频率过低,或者I2C速率过慢 | 提高ODR到416Hz以上 |
| 数值出现极大跳变毛刺 | 未检查GDA直接读数据 | 先查STATUS_REG的GDA位 |
补充一个非常容易忽视的细节:LSM6D3TR-C的输出寄存器是8位宽,但一个轴的数据是16位,分布在两个地址里。如果不开启BDU,传感器更新数据时可能正好卡在你读完低字节、还没读高字节的瞬间,那样拼出来的数会比真实值差一整个字节的权重,数据表现为偶发的野值。BDU就是为解决这个问题设计的,所以我在初始化里特意把它置位,这一步建议永远不要省。
5.3 几个实操体会
调这一套下来,有几个体会想单独说说。
第一个是面向数据调试时,打印格式一定要一眼能看懂。我的打印格式一直是G: x.xx y.yy z.zz dps,每个轴的宽度固定,这样在串口工具里能直接排版成三列,用文本模式观察数据变化非常直观。千万别打印只有裸数字没有单位,时间长了你自己都会搞混。
第二个是轮询和打印频率要分开控制。传感器的数据速率和串口的输出速率是两回事。串口打印是阻塞的,在115200波特率下打印一行三轴浮点数大约需要2到3毫秒,如果ODR很高、打印又全量输出,主循环会被打印拖慢,甚至反过来影响轮询读取的节奏。我建议初期只打印原始LSB值,验证完换算公式再改成打印dps值,打印频率也可以适当降为每10帧打1帧。
第三个是关于后续扩展的铺垫。这一篇用I2C轮询把陀螺仪数据读出来,只是整个IMU开发流程的起点。后面要加加速度计、要开中断引脚、要用FIFO缓冲,甚至换SPI接口,数据解析这部分代码的架构都可以复用。我在lsm6d3.h里已经把寄存器地址都定义成宏,后续新增功能只需要在这个文件里加宏、在.c里加函数,不用动其他模块的代码。
最后再分享一个小技巧:如果板子上有多个LED,可以在每次成功读到一帧新数据时翻转一次某个LED引脚。这样不需要串口也能从LED闪烁频率判断读取是否在正常进行,尤其在做现场调试、串口线不方便接的时候特别实用。
这块板子的陀螺仪数据链路跑通之后,我下一步打算先把加速度计读出来,然后基于两个传感器的数据做简单的姿态角解算。数据只是第一步,真正有价值的永远是数据背后的处理和判断。