简介:这份资源是面向嵌入式开发者的博世BMI160三轴陀螺仪自写驱动参考代码,针对STM32F030平台与BMI160之间的I2C通信集成场景,适合已具备一定单片机外设开发基础、希望快速打通传感器数据读取链路的中级开发者。压缩包共3个文件,以2个h头文件和1个c源文件为主,头文件承担寄存器宏定义、参数配置与函数原型声明,源文件实现初始化、寄存器读写与数据读取等核心逻辑,整体约6KB,体量轻便易于移植阅读。目前已有1887人学习下载,具备一定的参考热度。驱动虽未覆盖中断处理、节能模式等完整功能,但清晰呈现了BMI160与STM32F030交互的基本框架,读者可据此理解传感器配置流程、I2C时序组织与数据解析方式,并在此基础上自行补充数据融合、姿态解算或低功耗管理,作为项目起步的脚手架较为实用。
1. 从一颗贴片传感器说起:BMI160 驱动到底解决什么问题
焊过六轴传感器的人大概都有过这种体验:芯片焊上去,I2C 地址能扫到,读出来却全是 0xFF 或者纹丝不动的固定值,示波器上看波形也没毛病。问题往往不在硬件,而在驱动——BMI160 这颗陀螺仪加加速度计的组合芯片,上电后默认处于 suspend 状态,不手动唤醒、不配置输出数据率、不设置量程,它就是一个安静的哑巴。这份「博世 BMI160 自写驱动」要解决的,就是把一颗裸芯片从「能通信」推到「能稳定输出可用的角速度和加速度数据」这一段路。它适合正在做姿态解算、平衡车、云台、可穿戴设备原型的开发者,尤其是那些不想被现成库的黑匣子绑住、想自己掌控寄存器读写节奏的人。陀螺仪 BMI160 的资料不算少,但真正能照着跑通、还能改的驱动代码,得自己一行行抠出来。
2. 驱动架构拆解:从总线抽象到数据就绪的完整链路
2.1 分层设计:为什么把 I2C 读写单独抽出来
自写驱动最容易翻车的地方,是把寄存器操作和总线时序揉在一起。今天用 I2C,明天换 SPI,代码就得大改。合格的做法是先把总线读写抽成一层薄薄的接口,上层只认「读寄存器」「写寄存器」两个动作。常见做法是定义一个函数指针结构体,把平台相关的读写实现挂进去,驱动核心不关心底下是硬件 I2C 还是软件模拟。
/* 总线抽象层:驱动核心只依赖这两个函数指针 */ typedef struct { int8_t (*read_reg)(uint8_t dev_addr, uint8_t reg, uint8_t *data, uint16_t len); int8_t (*write_reg)(uint8_t dev_addr, uint8_t reg, const uint8_t *data, uint16_t len); void (*delay_ms)(uint32_t ms); } bmi160_bus_t; /* 驱动句柄:把总线和设备状态绑在一起 */ typedef struct { bmi160_bus_t bus; uint8_t dev_addr; /* I2C 7 位地址,SDO 接地为 0x68,接 VCC 为 0x69 */ uint8_t chip_id; /* 应为 0xD1,用于上电自检 */ } bmi160_dev_t;这段代码的关键在于read_reg和write_reg的签名统一成了「设备地址 + 寄存器地址 + 缓冲区 + 长度」。参数dev_addr是 7 位地址,实际发起传输时左移一位再或上读写位,这一步留给平台实现去做,驱动核心不掺和。delay_ms单独抽出来是因为 BMI160 从 suspend 唤醒到第一次有效输出之间有固定的启动时间,裸机环境用忙等,RTOS 环境换成vTaskDelay,接口不变。我一般会在bmi160_dev_t里再塞一个uint8_t fifo_en标志,后面做 FIFO 批量读取时判断用,这里先省略。
2.2 上电初始化序列:顺序错了就永远读不到数据
BMI160 的初始化不是「写一个配置寄存器就完事」,它有一条隐含的顺序:先软复位,等复位完成,再切到正常模式,然后配加速度计和陀螺仪的输出数据率与量程,最后使能数据就绪中断或轮询。顺序颠倒,比如在 suspend 状态下配 ODR,配置会被吞掉。
int8_t bmi160_init(bmi160_dev_t *dev) { uint8_t id = 0; /* 1. 读 chip id,确认通信正常且芯片型号对得上 */ if (dev->bus.read_reg(dev->dev_addr, BMI160_REG_CHIP_ID, &id, 1) != 0) return -1; if (id != 0xD1) return -2; dev->chip_id = id; /* 2. 软复位,命令 0xB6 写入 CMD 寄存器 */ uint8_t cmd = 0xB6; dev->bus.write_reg(dev->dev_addr, BMI160_REG_CMD, &cmd, 1); dev->bus.delay_ms(50); /* 复位典型耗时约 30ms,留余量 */ /* 3. 切到正常模式:写 0x11 到 CMD,退出 suspend */ cmd = 0x11; dev->bus.write_reg(dev->dev_addr, BMI160_REG_CMD, &cmd, 1); dev->bus.delay_ms(5); /* 4. 配加速度计:ODR 100Hz,量程 ±4g */ uint8_t acc_conf = (0x08 << 4) | 0x05; /* ODR=100Hz, range=±4g */ dev->bus.write_reg(dev->dev_addr, BMI160_REG_ACC_CONF, &acc_conf, 1); /* 5. 配陀螺仪:ODR 100Hz,量程 ±500dps */ uint8_t gyr_conf = (0x08 << 4) | 0x04; dev->bus.write_reg(dev->dev_addr, BMI160_REG_GYR_CONF, &gyr_conf, 1); dev->bus.delay_ms(10); /* 等第一组数据稳定 */ return 0; }逐段说清楚:第一步读CHIP_ID是血泪经验,很多「读不到数据」的根因其实是地址配错或者芯片根本没焊好,先确认 0xD1 能读出来,能省掉后面一半的排查时间。第二步软复位写 0xB6,复位后所有寄存器回到默认值,所以必须在复位之后再写配置,顺序不能反。第三步的 0x11 是命令寄存器里「加速度计和陀螺仪都进正常模式」的编码,写完之后芯片才开始采样。第四、五步的acc_conf和gyr_conf高四位是 ODR 编码,低四位是量程编码,具体编码值要对着数据手册的表查,我这里用的是 100Hz 和 ±4g、±500dps 这组常用组合。参数改起来很简单,换 ODR 就改高四位,换量程就改低四位,但量程一改,后面换算物理量的灵敏度系数也得跟着改,这是新手最容易漏的一环。
2.3 数据读取:轮询状态位还是等中断
读数据有两条路:轮询STATUS寄存器的数据就绪位,或者配中断引脚让芯片主动通知。原型阶段我一般先用轮询,简单、好调试;产品阶段再切中断,省 CPU。
int8_t bmi160_read_sensor(bmi160_dev_t *dev, float *acc, float *gyr) { uint8_t status = 0; uint8_t buf[12]; /* 轮询数据就绪位:bit7 加速度计,bit6 陀螺仪 */ dev->bus.read_reg(dev->dev_addr, BMI160_REG_STATUS, &status, 1); if (!(status & 0xC0)) return 1; /* 数据还没准备好,直接返回 */ /* 一次性连读 12 字节:加速度 6 字节 + 陀螺仪 6 字节,小端 */ dev->bus.read_reg(dev->dev_addr, BMI160_REG_DATA_ACC_X_L, buf, 12); int16_t ax = (int16_t)(buf[1] << 8 | buf[0]); int16_t ay = (int16_t)(buf[3] << 8 | buf[2]); int16_t az = (int16_t)(buf[5] << 8 | buf[4]); int16_t gx = (int16_t)(buf[7] << 8 | buf[6]); int16_t gy = (int16_t)(buf[9] << 8 | buf[8]); int16_t gz = (int16_t)(buf[11] << 8 | buf[10]); /* 按量程换算:±4g 对应 8192 LSB/g,±500dps 对应 65.6 LSB/dps */ acc[0] = ax / 8192.0f; acc[1] = ay / 8192.0f; acc[2] = az / 8192.0f; gyr[0] = gx / 65.6f; gyr[1] = gy / 65.6f; gyr[2] = gz / 65.6f; return 0; }这里有两个细节值得展开。一是连读 12 字节,BMI160 的加速度和陀螺仪数据寄存器在地址空间上是连续的,一次突发读能保证两组数据来自同一采样时刻,分开读会有时间偏差,做姿态融合时这个偏差会变成噪声。二是小端拼接,buf[1] << 8 | buf[0]这种写法看着朴素,但buf[1]是uint8_t,左移 8 位前必须先转int16_t,否则在 16 位 int 的平台上会溢出,这是踩过的坑。换算系数 8192 和 65.6 分别对应 ±4g 和 ±500dps 量程,量程一改这两个数就得重算,建议做成宏或者查表,别散落在代码里。
3. 把驱动跑起来:从裸机到 RTOS 的移植步骤
3.1 平台适配层怎么写:以常见 MCU 的 I2C 为例
驱动核心不碰硬件,那硬件相关的部分就得有人填。适配层的任务就三件事:把read_reg和write_reg映射到具体的 I2C 收发,把delay_ms映射到系统延时,把设备地址填对。
/* 平台适配:以某 MCU 的硬件 I2C 为例,函数名按实际 HAL 替换 */ static int8_t platform_i2c_read(uint8_t addr, uint8_t reg, uint8_t *data, uint16_t len) { /* 先写寄存器地址,再读数据,这是 I2C 读寄存器的标准两步 */ if (hal_i2c_write(addr << 1, ®, 1, 100) != 0) return -1; if (hal_i2c_read((addr << 1) | 1, data, len, 100) != 0) return -1; return 0; } static int8_t platform_i2c_write(uint8_t addr, uint8_t reg, const uint8_t *data, uint16_t len) { uint8_t tmp[8]; if (len > sizeof(tmp) - 1) return -1; tmp[0] = reg; for (uint16_t i = 0; i < len; i++) tmp[i + 1] = data[i]; return hal_i2c_write(addr << 1, tmp, len + 1, 100) == 0 ? 0 : -1; } void bmi160_platform_bind(bmi160_dev_t *dev, uint8_t sdo_level) { dev->bus.read_reg = platform_i2c_read; dev->bus.write_reg = platform_i2c_write; dev->bus.delay_ms = hal_delay; dev->dev_addr = sdo_level ? 0x69 : 0x68; }platform_i2c_read里的两步是 I2C 读寄存器的固定套路:先发寄存器地址(写方向),再发起读。很多 HAL 库提供了「写地址再读」的复合传输函数,有的话直接用,没有就按上面这样拆两步,中间不要插入 stop 条件,否则部分芯片会丢状态。platform_i2c_write把寄存器地址和数据拼进一个缓冲区一次发出去,避免两次传输之间被其他中断打断。bmi160_platform_bind的sdo_level参数对应芯片 SDO 引脚的电平,接地是 0x68,接高是 0x69,硬件设计定了这个值就定了,绑定时传对即可。
3.2 最小可运行示例:初始化、读一次、打印
把上面的零件拼起来,一个能验证驱动是否工作的最小程序大概长这样。裸机上跑,串口打印,不依赖任何 RTOS。
int main(void) { bmi160_dev_t dev; float acc[3], gyr[3]; hal_init(); /* 时钟、I2C、串口初始化 */ bmi160_platform_bind(&dev, 0); /* SDO 接地,地址 0x68 */ if (bmi160_init(&dev) != 0) { printf("bmi160 init failed\r\n"); while (1); } printf("bmi160 ready, chip id=0x%02X\r\n", dev.chip_id); while (1) { if (bmi160_read_sensor(&dev, acc, gyr) == 0) { printf("acc: %.3f %.3f %.3f g\r\n", acc[0], acc[1], acc[2]); printf("gyr: %.2f %.2f %.2f dps\r\n", gyr[0], gyr[1], gyr[2]); } hal_delay(10); /* 100Hz 采样,10ms 一轮 */ } }跑通这个程序,判断标准很直接:静止平放时,acc的某一轴应该接近 1g(具体哪一轴取决于芯片贴装方向),另外两轴接近 0;gyr三轴接近 0,有零点几 dps 的漂移是正常的。如果acc三轴全是 0 或者全是固定值,回到第 2 章查初始化顺序;如果数值在跳但量级明显不对,查换算系数和量程配置是否匹配。hal_delay(10)对应 100Hz 的 ODR,采样频率高于 ODR 会读到重复数据,低于 ODR 会丢数据,两者对齐最省心。
3.3 移植到 RTOS:把轮询换成任务加信号量
裸机轮询在简单场景够用,但一旦系统里还有屏幕刷新、通信协议栈,10ms 一次的忙等就会拖累整体节奏。移植到 RTOS 的思路是把读数据放进独立任务,用中断引脚触发信号量,任务阻塞等待,数据就绪才被唤醒。
/* 中断服务函数里释放信号量,注意用 FromISR 版本 */ void exti_isr(void) { BaseType_t woken = pdFALSE; xSemaphoreGiveFromISR(g_bmi_sem, &woken); portYIELD_FROM_ISR(woken); } void bmi160_task(void *arg) { bmi160_dev_t *dev = (bmi160_dev_t *)arg; float acc[3], gyr[3]; for (;;) { /* 等中断信号,超时 100ms 防止中断丢失后任务卡死 */ if (xSemaphoreTake(g_bmi_sem, pdMS_TO_TICKS(100)) == pdTRUE) { bmi160_read_sensor(dev, acc, gyr); /* 这里接姿态解算或上报队列 */ } } }中断配置这一步驱动核心不管,得在平台层把 BMI160 的 INT1 或 INT2 引脚配成数据就绪输出,再接到 MCU 的外部中断上。xSemaphoreTake的超时参数是后悔药,中断线受干扰丢一次信号,没有超时任务就永久阻塞,加了超时至少能自己恢复。任务优先级别设太高,传感器数据 100Hz 的节奏很温和,设成中等优先级即可,把高优先级留给真正实时的控制环。
4. 避坑与排查:那些让驱动「看起来正常其实不对」的细节
4.1 现象:能读到 chip id,但数据永远是 0
原因多半是初始化顺序里漏了退出 suspend 那一步,或者命令写错了寄存器。BMI160 的 CMD 寄存器是只写的,写完不会回读确认,写错了也没有报错。解决办法是在bmi160_init里每写一个关键命令后,回读STATUS或ERR_REG寄存器,确认命令被接受。ERR_REG里如果有error位,说明上一条命令被拒绝,通常是当前模式不允许该操作。
4.2 现象:数据在跳,但静止时加速度模长明显不是 1g
先查量程配置和换算系数是否配对。配了 ±8g 却用 ±4g 的 8192 去换算,结果会差一倍。再查数据拼接的字节序,BMI160 是小端,buf[1] << 8 | buf[0]的顺序写反了,数值会变成完全无意义的跳变。最后确认芯片贴装方向,加速度的 XYZ 轴定义和芯片丝印方向绑定,板子竖着装和横着装,哪一轴是 1g 完全不同,这不是驱动的问题,是机械安装的问题。
4.3 现象:I2C 通信偶发失败,重试就好
偶发失败常见于上拉电阻偏大或者总线电容超标,波形上升沿变缓,高速率下采样出错。先把 I2C 速率降到 100kHz 验证,如果降速后稳定,就是硬件层面的信号完整性问题,换小阻值上拉或者缩短走线。软件层面可以在platform_i2c_read里加一次重试,但重试是掩盖问题不是解决问题,量产前还是要把硬件调好。
4.4 现象:FIFO 模式下读出来的数据错位
FIFO 读取必须按「读长度寄存器 → 按长度读数据」的顺序来,长度寄存器的值和实际读出的字节数对不上,就会错位。另一个坑是 FIFO 水印配置,水印设得比单次读取量大,会一直等不到中断;设得太小,中断太频繁拖累 CPU。常见做法是水印设成单组数据长度的整数倍,比如 12 字节一组,水印设 36 字节,一次读三组。
5. 二次开发:把驱动改成你自己的传感器中间件
驱动跑通只是起点,真正落地时你大概率要在这上面加东西。我的习惯是先加一层「传感器中间件」,把原始数据、量程切换、零偏校准、数据滤波都收进去,上层应用只拿处理好的物理量。具体做法是定义一个bmi160_mw_t结构体,里面放驱动句柄、当前量程、零偏数组和一个环形缓冲区,对外只暴露mw_read()和mw_set_range()两个函数。
零偏校准值得单独说。陀螺仪静止时输出不为零是常态,几百 LSB 的零偏很常见。校准方法很简单:上电后让设备静止两秒,采 200 组数据求平均,存进零偏数组,后续每次读数减去这个平均值。代码上就是在中间件初始化里加一个mw_calibrate(),内部循环调用bmi160_read_sensor累加求均值。注意校准期间设备必须真的静止,产线上可以用夹具保证,开发阶段就手动放平别碰。
量程切换是另一个高频需求。低速场景用 ±2g 省分辨率,跌落检测用 ±16g 防饱和,切换时不仅要改寄存器,还要同步更新换算系数和零偏(不同量程下零偏的 LSB 值不同)。我一般把量程和对应的灵敏度系数做成一张表:
| 量程 | 灵敏度 (LSB/g 或 LSB/dps) | 适用场景 |
|---|---|---|
| ±2g | 16384 | 静态倾角、低动态 |
| ±4g | 8192 | 通用运动检测 |
| ±8g | 4096 | 较强冲击 |
| ±16g | 2048 | 跌落、碰撞检测 |
| ±500dps | 65.6 | 常规姿态 |
| ±2000dps | 16.4 | 快速旋转 |
验证改造是否成功,别只看串口打印的数字顺不顺眼。我的做法是写一个简单的验证脚本:设备静止放 30 秒,统计三轴陀螺仪的标准差,标准差应该在小数点后两位的量级;再绕单轴匀速转 90 度,对角速度积分,积分结果应该接近 90 度,偏差超过 5% 就说明零偏或标度因数有问题。这套验证流程我从第一次做云台项目翻车之后就固定下来了——当时没做积分验证,上机才发现角度漂得离谱,回头查是零偏没扣干净。从那以后我每次改完驱动,都强制走一遍「静止统计 + 积分验证」,希望帮到你。
本文还有配套的精品资源,点击获取