资讯动态

STM32F103通过SPI驱动BMI160六轴传感器:从初始化到数据换算

发布时间:2026/10/13 12:25:38 来源:尧图企业网站定制
简介面向 STM32F103 嵌入式开发者的 BMI160 驱动移植示例重点解决使用 SPI 接口读取加速度计与陀螺仪数据时的实时性和资源占用问题。方案将 FIFO 批量读取与 INT1 中断机制结合起来避免在主循环中频繁查询传感器状态适合对功耗和采集效率有要求的姿态检测与运动监测场景。压缩包共 81 个文件包含 37 个头文件与 34 个 C 源文件可组成完整 Keil 工程另有工程配置、启动汇编、调试日志及数据记录文件便于直接打开编译和对照分析。整体约 309KB结构紧凑。目前已有 1909 人学习。资料里不仅演示了标准外设库下的 SPI 读写封装、FIFO 与中断处理流程还给出日志输出和数据解析思路代码按驱动、用户、库文件分层组织目录清晰既适合初学者理解 BMI160 的初始化与数据流也方便有经验者快速移植到其他 STM32 项目。1. 按下 stm32f103-spi-bmi160-demo 的启动键前先把这四件事想明白stm32f103-spi-bmi160-demo-20201126.rar 这个压缩包光看文件名就知道它是干什么的STM32F103 通过 SPI 总线读取 BMI160 六轴加速度计和陀螺仪数据的示例工程打包日期是 2020 年 11 月 26 日。它解决的是一类非常典型的问题——很多刚接触 IMU 的开发者第一步都会去网上找一个类似的例程烧进板子后发现要么芯片 ID 读不出来要么加速度数据是一串不停跳动的负值。这篇文章不打算假装手里有一份所谓官方源码而是顺着这个 Demo 必然要走的四条技术路线把 SPI 初始化、BMI160 寄存器配置、突发读取和物理量换算完整讲透。适合谁看刚把 BMI160 模块焊到转接板上、正对着逻辑分析仪发愁的人以及已经在用 I2C 读 IMU、但想在提高采样率或挂载更多外设时切换到 SPI 的熟手。2. 总线选型和接线SPI 方案比 I2C 好在哪里2.1 为什么 SPI 在这个场景下比 I2C 更值得选BMI160 同时支持 I2C 和 SPI很多 Demo 默认给的引脚定义也是 I2C 版本所以除非你明确知道自己要上 SPI否则很容易被带偏。我一般会建议只要 MCU 引脚够用IMU 通信就优先走 SPI。原因要从三个层面看第一I2C 是一条半双工、开漏加外部上拉的总线挂载的每一个从机都有可能通过时钟拉伸把整条总线拖慢。开发者为了省两根引脚往往会把 IMU、OLED、气压计全挂到同一条 I2C 上结果就是当 OLED 刷新时IMU 的中断响应出现几十微秒的抖动。对于 100Hz 的姿态解算这种抖动可能不那么致命但当你把 ODR 提到 400Hz 以上丢包就会明显变多。第二SPI 是四线全双工靠 CS 片选信号区分从机不存在从机地址冲突的问题。BMI160 在 SPI 模式下的最高时钟是 10MHz而 STM32F103 的 SPI1 挂在 APB2 总线上72MHz 主频经过分频后很容易就能跑到 9MHz这个带宽读六轴原始数据绰绰有余。第三SPI 通信是明确的主动发起、主动结束主机完全控制时序不会出现 I2C 那种总线被某个异常从机锁死、SCL 拉不回来的“玄学”故障。下面是 I2C 与 SPI 在 IMU 读取场景下的小对比对比项I2CSPI本 Demo 采用通信速率上限F103 硬件 I2C 最高 400kHzBMI160 快速 SPI 最大 10MHz线数2 根SCL/SDA4 根SCK/MOSI/MISO/CS多从机方式地址区分存在地址冲突CS 片选互不干扰总线异常排查时钟拉伸、总线锁死较难定位时序完全在主机掌控中采样率扩展性600Hz 以上力不从心直接读原始数据非常轻松2.2 BMI160 模块引脚定义与 F103 的最小接线图现在市面上的 BMI160 模块引脚定义大同小异六轴版本一般引出了 VCC、GND、SDI、SDO、SCLK、CS部分模块还带 INT1 和 INT2 中断引脚。接线时要把传感器的 SDI 接到 STM32 的 MOSISDO 接到 MISOSCLK 对 SCLKCS 接任意一个普通 GPIO 即可不一定要用硬件 NSS。我一般会选择 SPI1 作为默认总线因为它挂在 APB2 上速度比挂 APB1 的 SPI2/SPI3 更快而且引脚在 PA4-PA7排布规整。具体接法如下BMI160 引脚对应 SPI 信号STM32F103 引脚备注VCC电源正3.3V注意模块是否带 LDO部分裸片内核电压是 1.8VGND电源地GND与 MCU 共地必须可靠连接SDIMOSIPA7SPI1_MOSI主出从入SDOMISOPA6SPI1_MISO主入从出模块内部可能为开漏需外部上拉SCLKSPI 时钟PA5SPI1_SCK由主机产生CS片选PA4普通 GPIO软件控制拉低选中INT1中断输出PB1可选低功耗/中断读取时使用本 Demo 可悬空这里有个容易翻车的细节有些 BMI160 模块把 SDO 引脚同时用作 I2C 地址选择在 SPI 模式下这个引脚是 MISO 信号。少数模块的 SDO 是开漏输出外部没有带上拉电阻MISO 读回来会一直为低芯片 ID 读到 0x00。遇到这种模块直接在 SDO 和 3.3V 之间加一个 10kΩ 上拉电阻就能解决。2.3 SPI 控制器初始化代码模式 0、9MHz、软件 CS 一个都不能错先给出一份基于标准外设库的 SPI1 初始化代码这是整个 Demo 的地基void SPI1_Init(void) { GPIO_InitTypeDef gpio; SPI_InitTypeDef spi; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE); // PA5SCK, PA6MISO, PA7MOSI, 全部复用推挽输出 gpio.GPIO_Pin GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7; gpio.GPIO_Mode GPIO_Mode_AF_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); // PA4CS, 普通推挽输出, 默认拉高 gpio.GPIO_Pin GPIO_Pin_4; gpio.GPIO_Mode GPIO_Mode_Out_PP; GPIO_Init(GPIOA, gpio); GPIO_SetBits(GPIOA, GPIO_Pin_4); spi.SPI_Direction SPI_Direction_2Lines_FullDuplex; spi.SPI_Mode SPI_Mode_Master; spi.SPI_DataSize SPI_DataSize_8b; spi.SPI_CPOL SPI_CPOL_Low; // 时钟空闲为低 spi.SPI_CPHA SPI_CPHA_1Edge; // 第一个边沿采样 spi.SPI_NSS SPI_NSS_Soft; // 软件管理片选 spi.SPI_BaudRatePrescaler SPI_BaudRatePrescaler_8; // 72/89MHz spi.SPI_FirstBit SPI_FirstBit_MSB; SPI_Init(SPI1, spi); SPI_Cmd(SPI1, ENABLE); }每项参数都值得核对一遍SPI_CPOL_Low 配合 SPI_CPHA_1Edge 是 SPI Mode 0这是 BMI160 最常用的时序模式SPI_BaudRatePrescaler_8 把 72MHz 的 APB2 时钟分频到 9MHz既低于 BMI160 的 10MHz 上限又留出了信号上升沿的裕量SPI_NSS_Soft 则确保硬件 NSS 不会干扰我们手动控制的 CS。如果上面这段代码运行时 SCK 没有波形优先检查 RCC 时钟有没有打开、GPIO 是否配置成复用推挽。SPI1 对应 GPIOA 的时钟使能非常容易被漏掉尤其是从 SPI2 例程移植过来时很多人只改了引脚忘了改时钟。3. 初始化序列从乱码到稳定读出加速度和陀螺仪3.1 先读芯片 ID 再谈配置0xD1 是 SPI 是否打通的第一个信号SPI 初始化完成后第一件要做的事不是急着一口气写完所有配置寄存器而是先读 BMI160 的芯片 ID寄存器地址是 0x00。芯片 ID 的期望值是 0xD1这也是判断 SPI 通信是否完全打通的硬指标。读芯片 ID 的代码分为两步先封装一个单字节读函数再调用它读 0x00 寄存器。BMI160 在 SPI 模式下地址字节的最高位代表方向1 是读0 是写。这个细节非常关键尤其当你从一个简单的读函数开始往上搭代码时一旦把这位置反后面所有写配置的操作都会静默失败。uint8_t BMI160_ReadReg(uint8_t reg) { uint8_t val; GPIO_ResetBits(GPIOA, GPIO_Pin_4); // CS 拉低 SPI1_Exchange(reg | 0x80); // 第7位置1表示读操作 val SPI1_Exchange(0x00); // 发送任意字节同时接收数据 GPIO_SetBits(GPIOA, GPIO_Pin_4); // CS 拉高 return val; }SPI1_Exchange 是基础的单字节收发函数它的逻辑是等待发送缓冲区为空然后发一个字节再等待接收缓冲区非空最后取回收到的字节。整个过程是全双工的发 0x00 只是为了产生时钟把传感器的数据从 MISO 线上带回来。读取芯片 ID 后的判断逻辑可以这样写uint8_t id BMI160_ReadReg(0x00); if (id ! 0xD1) { // 通信异常排查接线和 SPI 模式 return 1; }注意 CS 在发送地址字节和接收数据字节期间必须保持低电平整个读操作是一段连续的片选周期不能把地址发送和数据接收拆成两次独立片选。这个问题在有些人的代码里表现为ID 偶尔能读到 0xD1但数据不稳定就是因为 CS 电平切换的时机不对。3.2 量程、输出速率与电源模式三个寄存器的顺序和值BMI160 上电后默认处于挂起模式所有轴输出都是无效数据。如果跳过模式切换直接读加速度寄存器你大概率拿到一屏 0x0000 或 0x7FFF 这样的满量程极值。初始化时建议按这个顺序操作先软复位再配置加速度计量程和输出速率再配置陀螺仪量程和输出速率最后切换工作模式。我用标准外设库实现了一个简单的初始化函数配合上一节的单字节读写封装uint8_t BMI160_Init(void) { uint8_t id; // 1. 软复位向命令寄存器写入 0xB6 BMI160_WriteReg(0x7E, 0xB6); delay_ms(50); // 软复位需要足够等待时间 // 2. 确认 SPI 通信正常 id BMI160_ReadReg(0x00); if (id ! 0xD1) return 0xFF; // 3. 加速度计配置100Hz ODR±2g 量程 BMI160_WriteReg(0x40, 0x05); // ACC_CONF: bit7-4 配置输出速率 BMI160_WriteReg(0x41, 0x03); // ACC_RANGE: 0x03 表示 ±2g // 4. 陀螺仪配置100Hz ODR±2000dps 量程 BMI160_WriteReg(0x42, 0x05); // GYR_CONF: 100Hz BMI160_WriteReg(0x43, 0x00); // GYR_RANGE: 0x00 表示 ±2000dps // 5. 切换为正常模式 BMI160_WriteReg(0x7E, 0x11); delay_ms(10); return 0x00; }先解释第 5 步。BMI160 的命令寄存器 0x7E 写入 0x11 是“进入正常模式”写入 0xB6 是“软复位”写入 0x10 是“进入低功耗模式”。命令寄存器不能像普通寄存器那样回读因此判断命令是否生效可以通过读取 0x03 电源模式状态寄存器来完成正常模式下它应该返回 0x11。然后说量程选择。0x41 寄存器配置加速度计量程0x03 代表 ±2g它的编码不是常规的连续递增0x00 反而是 ±4g。这种非直觉的编码是 BMI160 系列的特色如果你直接照搬别的 IMU 的量程配置很容易把 ±2g 写成 ±4g后续所有换算出来的物理量都跟着错一半。陀螺仪的 0x43 寄存器则是线性编码0x00 是 ±2000dps0x01 是 ±1000dps以此类推。关于 ODR 值0x40 和 0x42 的 bit7-4 用于配置输出速率0x05 对应 100Hz。如果你的产品需要更高速率可以改成 0x06200Hz或 0x07400Hz但改完之后要同步检查主循环里读取耗时是否跟得上否则会出现数据覆盖。3.3 软复位后别急着写配置时序藏在日期戳里Demo 包名里的 20201126说明这是 2020 年 11 月 26 日打包整理的版本。这类例程的发布时间点其实侧面反映了一个客观事实当年大家踩过的坑今天依然存在尤其是软复位后的时序问题。软复位命令 0xB6 写入后BMI160 内部需要时间完成寄存器复位过早读取芯片 ID 或者写入新配置命令很有可能会被丢弃。我建议软复位后至少等待 50ms而不是数据手册上的最小值。这里给出两条经验第一不要在上电后立刻初始化 BMI160。STM32F103 上电后SPI 外设和 GPIO 的初始化需要时间传感器自身的电源电压也没有完全抬升。合理的顺序是系统延时 100ms然后初始化 SPI再跑 BMI160_Init。有些 Demo 为了省时间把 delay 放在 SPI 初始化之前结果就是第一次初始化失败、第二次才成功。第二正常模式切换命令 0x11 写入后同样需要等待 10ms 左右让传感器内部完成模式切换。读取状态寄存器 0x1B 时如果 acc 和 gyr 就绪位始终为 0多半也是因为模式切换还没有完成。4. 数据读取与换算把寄存器里的 16 位补码变成 g 和 dps4.1 突发读取 6 字节与数据就绪位配合BMI160 的加速度数据寄存器从 0x0A 开始顺序是 X 轴低字节、X 轴高字节、Y 轴低字节、Y 轴高字节、Z 轴低字节、Z 轴高字节。陀螺仪数据从 0x10 开始布局完全相同。数据寄存器是 16 位有符号数低字节在前高字节在后。逐个寄存器去读当然可以但更高效的做法是突发读取。SPI 模式下BMI160 支持地址自增连续读只要发送一次起始地址后续每来一个时钟就输出一个字节数据。读取代码封装如下static void BMI160_ReadBytes(uint8_t reg, uint8_t *buf, uint8_t len) { GPIO_ResetBits(GPIOA, GPIO_Pin_4); // CS 拉低开始一段连续读 SPI1_Exchange(reg | 0x80); // 发送起始地址读标志置1 for (uint8_t i 0; i len; i) { buf[i] SPI1_Exchange(0x00); // 连续接收 len 个字节 } GPIO_SetBits(GPIOA, GPIO_Pin_4); // CS 拉高结束本次通信 }read 函数的逻辑很直接CS 低电平期间完成地址发送和数据接收地址自增完全由传感器内部管理主机不需要挨个寄存器发地址。如果 len 超过寄存器连续区域边界传感器会回绕所以读取注意长度不要越界。读取加速度和陀螺仪的函数可以共用一个底层读函数void BMI160_ReadAccel(int16_t *ax, int16_t *ay, int16_t *az) { uint8_t buf[6]; BMI160_ReadBytes(0x0A, buf, 6); *ax (int16_t)((buf[1] 8) | buf[0]); // 低字节在前 *ay (int16_t)((buf[3] 8) | buf[2]); *az (int16_t)((buf[5] 8) | buf[4]); } void BMI160_ReadGyro(int16_t *gx, int16_t *gy, int16_t *gz) { uint8_t buf[6]; BMI160_ReadBytes(0x10, buf, 6); *gx (int16_t)((buf[1] 8) | buf[0]); *gy (int16_t)((buf[3] 8) | buf[2]); *gz (int16_t)((buf[5] 8) | buf[4]); }读数据之前还可以先读一次状态寄存器 0x1Bbit0 是加速度计数据就绪位bit1 是陀螺仪数据就绪位。如果两个位都为 1再执行突发读这样可以避免读到尚未完全更新的半组数据。4.2 15 位有符号数如何换算成物理量量程与缩放系数表原始数据是 16 位有符号数但 BMI160 实际有效数据只占 15 位最高位永远是符号位。换算成物理量时需要用量程对应的灵敏度系数去除原始值。加速度计的量程和灵敏度关系如下加速度量程灵敏度LSB/g±2g16384±4g8192±8g4096±16g2048陀螺仪的量程和灵敏度关系如下陀螺量程灵敏度LSB/dps±2000dps16.4±1000dps32.8±500dps65.5±250dps131.1±125dps262.4换算代码用浮点数直接算虽然 STM32F103 没有硬件浮点单元但只做除法运算100Hz 频率下 CPU 占用可以接受#define ACC_SCALE (1.0f / 16384.0f) // ±2g 对应的灵敏度 #define GYR_SCALE (1.0f / 16.4f) // ±2000dps 对应的灵敏度 int16_t ax_raw, ay_raw, az_raw; int16_t gx_raw, gy_raw, gz_raw; float ax_g, ay_g, az_g; float gx_dps, gy_dps, gz_dps; BMI160_ReadAccel(ax_raw, ay_raw, az_raw); BMI160_ReadGyro(gx_raw, gy_raw, gz_raw); ax_g ax_raw * ACC_SCALE; ay_g ay_raw * ACC_SCALE; az_g az_raw * ACC_SCALE; gx_dps gx_raw * GYR_SCALE; gy_dps gy_raw * GYR_SCALE; gz_dps gz_raw * GYR_SCALE;示例中的 ACC_SCALE 对应 ±2g 量程如果你在初始化时把量程改成了 ±16g这里的灵敏度就要同步改成 2048换算因子跟着量程走。这个呼应关系是 Demo 里最容易漏改的地方。4.3 用串口打印验证的最小实现把换算结果打到串口上是最直观的验证手段。ST官方的标准外设库工程默认不带 printf 重定向需要手动实现 fputc 函数#include stdio.h int fputc(int ch, FILE *f) { USART_SendData(USART1, (uint8_t)ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); return ch; }串口初始化为 115200-8-N-1然后在主循环里延时 10ms打印一次数据while (1) { BMI160_ReadAccel(ax_raw, ay_raw, az_raw); BMI160_ReadGyro(gx_raw, gy_raw, gz_raw); ax_g ax_raw * ACC_SCALE; ay_g ay_raw * ACC_SCALE; az_g az_raw * ACC_SCALE; printf(ACC: %.3f %.3f %.3f g\r\n, ax_g, ay_g, az_g); delay_ms(10); }如果传感器水平放置理论上 Z 轴读数接近 1gX 轴和 Y 轴接近 0g。如果 Z 轴读到 -1g说明模块安装方向反了在代码里对 Z 轴取反即可。如果三个轴都跳成很大或很小的值回头看量程配置和灵敏度是否匹配。5. 避坑SPI 调通后依然会翻车的 4 个真实场景5.1 芯片 ID 读到 0xFF但接线检查了好几遍都没问题现象反复检查 SDI、SDO、SCLK、CS 四根线都接对了SPI 参数也对着 Mode 0 设置的但读 0x00 寄存器始终返回 0xFF。原因这一类问题八成出在 CS 片选时序上。有些例程用 GPIO 模拟 CS但拉低和拉高之间没有加合适的延时导致传感器还没准备好接收地址CS 就已经被释放。另一种常见原因是 MISO 引脚被复用错PA6 同时连接着其他外设两个输出互相打架。解决先用逻辑分析仪抓 CS 和 SCK 波形确认 CS 拉低后至少有几十微秒的稳定时间再发送地址。如果波形正常单独把 PA6 配置改成普通输入上拉手动测一下高电平能否被拉起来。还有一个操作建议把 SPI 时钟频率临时降到 2.25MHz即预分频 32排除信号质量导致的采样失败。很多从 18MHz 配置迁过来的例程就是卡在这一步。5.2 芯片 ID 正常但加速度数据全是 0x7FFF 或 0x8000现象SPI 通信已经打通芯片 ID 能读到 0xD1但读取加速度寄存器时返回值不是 0x0000 就是接近满量程的 0x7FFF。原因传感器还处于挂起模式。BMI160 上电后如果只配置了 ACC_CONF 和 ACC_RANGE没有往命令寄存器写入 0x11加速度计的输出级不会真正开始工作。当年我踩这个坑时还很认真地检查了量程配置和灵敏度系数最后发现是初始化顺序漏了模式切换这一步。解决在初始化代码的最后补上BMI160_WriteReg(0x7E, 0x11);然后等待 10ms。如果写入后依然没有输出看一下 0x03 寄存器的 PMC 状态它返回 0x11 才说明进入了正常模式。如果写入命令寄存器之后回读 0x03 还是 0x00可以再执行一次软复位然后重新配置这个重复初始化流程在 demo 类代码里很常见。5.3 陀螺仪静止时输出漂移很大甚至每秒偏几度现象把模块放在桌面上完全不动陀螺仪 Z 轴输出始终在 -5dps 到 5dps 之间缓慢变化采集一段数据求平均也不是零。原因陀螺仪的零漂主要来自两个地方一个是传感器出厂时的零偏未校准另一个是电源纹波和参考电压噪声。±2000dps 量程下灵敏度只有 16.4 LSB/dps主电源上有 50mV 的纹波反映到输出上就是 1-2dps 的抖动。解决先区分是噪声还是真实漂移。静止放置 30 秒采集 100 组数据求平均得到的均值就是静态零偏。在代码里把每次读数减去这个零偏值是最简单也最有效的校准方法。如果零偏随时间变化明显检查给 BMI160 供电的 LDO 是不是和电机驱动共用了电源必要时串联一个磁珠隔离高频噪声。5.4 数据偶尔出现一个明显跳变的离群点频率没有规律现象串口打印的数据流里绝大部分时间数值连续但每隔几秒会出现一个突然偏离正常范围的点持续一帧后又恢复正常。原因多任务环境下的并发访问问题。如果主循环里读取 IMU 数据和 OLED 刷新、按键扫描共用代码段某一帧数据可能在读取了一半时被中断打断导致低字节和高字节来自不同的采样周期。另外如果串口打印函数不带超时机制发送被阻塞时也可能错过下一次数据就绪造成读指针错位。解决把突发读取的代码包进临界区最简单的方式是在读取前后加上__disable_irq()和__enable_irq()。但临界区持续时间尽量控制在几十微秒内避免影响中断响应。更好的做法是优先使用 BMI160 的硬件 FIFO 和数据就绪中断让传感器自己缓存数据主循环在读到的瞬间一次性取出这样就算被打断也不会丢中间字节。6. 让 Demo 从“能出数据”走向“能顶产品”验证与中断升级方案Demo 跑通只是起点。我习惯在项目定型前做一次完整的静态验证用数据说明这套驱动是可靠的而不是看着波形图觉得差不多。验证方法很简单把模块固定好水平静止放置采集 5 秒数据然后看三个核心指标。第一加速度计合向量。把三轴读数平方和开根号数值应该在 0.98g 到 1.02g 之间。如果差得远优先检查灵敏度系数写没写错。第二加速度计静止标准差。5 秒内 500 个样本的标准差应该小于 0.005g超过这个值就说明电源噪声偏大或量程设置过小导致溢出。第三陀螺仪静止零漂均值。静止条件下 500 个样本的均值应在 ±0.5dps 以内超出就要考虑执行零偏扣除。这三项结论打印成一行表格我通常还会保存 100 组原始 bin 文件方便后续算法调试时回放。再往深处走Demo 里那种“主循环读一次打印一次”的结构只适合验证不适合产品。两个方向值得改一是打开 BMI160 的硬件 FIFO寄存器 0x46 把 FIFO 配置成加速度计和陀螺仪同时写入再配合水印中断让 MCU 攒一批数据一次性读取降低唤醒频率二是把 0x15 中断映射寄存器配置到 INT1 引脚用外部 EXTI 触发读取避开轮询状态寄存器的死等。这两个改动做完主循环里的阻塞读取就能彻底去掉整个系统的实时性才真正立得住。回看这个 2020 年的 Demo它的价值不在于代码多优雅而在于它把 BMI160 的 SPI 初始化路径完整走了一遍。从那之后我再接任何一颗 SPI 接口的新传感器都会先写一个 30 行的底层读写封装再单独写初始化脚本验证 ID最后才动业务逻辑。这个习惯让我少踩了很多“接线明明没问题但就是不通”的坑。希望这份复盘对你也有同样的帮助。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑