
简介面向嵌入式开发者的BMI160六轴传感器全套开发资料集成三轴加速度计与三轴陀螺仪适用于可穿戴设备、物联网终端、无人机及手机姿态检测等场景。压缩包共9个文件、整体5.24MB以中文数据手册、官方驱动源码、I2C通信代码及规格书为主PDF用于查阅电气特性、引脚定义与寄存器说明C/H文件提供可直接集成的驱动接口ZIP内含完整驱动工程MD文档补充使用指引。已有1172人学习浏览适合正在做运动识别、步数计数、倾斜检测或需要快速完成传感器驱动移植的开发者借助这套资料可省去查找与整合时间直接从初始化、数据读取到上层应用逐层落地。1. 把BMI160当“能读懂的手册”而不是黑盒从中文数据手册看这颗6轴传感器拿到 BMI160 整套资料时第一反应通常是先找现成的驱动库但真正能让你在两三个晚上把传感器跑起来的不是代码压缩包而是中文数据手册里那张寄存器映射表。BMI160 是一颗 3 轴加速度计加 3 轴陀螺仪二合一的 16 位惯性传感器支持 I2C 与 SPI 接口内部还有一块 1KB 的 FIFO 和多路中断输出。做平衡车、自稳云台、低成本 IMU 的工程师愿意选它往往不是因为性能比 MPU6050 好多少而是功耗更低、FIFO 更大并且一套寄存器手册就能同时支撑 C 和 C 两套驱动。下面这套方案以“手伸进寄存器”为主线从 C 驱动、C 封装一路推到姿态解算和电机驱动最后落在调试技巧上。2. 用C写BMI160驱动I2C/SPI端口抽象与寄存器操作2.1 先定接口把I2C/SPI读写抽象成两个函数指针写传感器驱动最容易翻车的地方是每个项目都把读写函数写死在 HAL 库里。换一块板子就得重写一遍逻辑还容易在三处拷贝里悄悄改坏。我一般的做法是先抽象出一组端口函数让 BMI160 驱动只关心“读寄存器、写寄存器、延时”不关心底下是 STM32 的 HAL 还是 ESP32 的 ESP-IDF。/* bmi160_io.h */ typedef struct { uint8_t addr; /* 7bit I2C 从机地址SPI 模式填 0 */ int (*read)(uint8_t addr, uint8_t reg, uint8_t *buf, uint16_t len); int (*write)(uint8_t addr, uint8_t reg, const uint8_t *buf, uint16_t len); void (*delay_ms)(uint32_t ms); } bmi160_io_t;调用 read/write 函数指针时driver 层不需要知道 SDA 为什么要拉高、CS 什么时候下降。addr是 BMI160 的 7 位 I2C 地址SDO 引脚接低电平时是 0x68接高电平时是 0x69用 SPI 时这个字段忽略但 read/write 函数仍要按寄存器号操作。如果走 SPI读操作要把寄存器最高位置 1也就是reg | 0x80同时 CPOL 选 0、CPHA 选 0。这个细节在中文数据手册“SPI 时序特性”一节里有但最容易漏。把端口函数连同 SPI 的片选逻辑都放在一个文件里BMI160 驱动里只出现寄存器号这样移植时只需要改一个文件。2.2 读芯片ID0x00寄存器是驱动的“红灯”上电后我永远先读芯片 ID不读 ID 就直接配置寄存器属于碰运气。BMI160 的 CHIP_ID 在0x00固定值是0xD1。这一步能同时验证 I2C 地址、接线、供电和时钟是否正常。#define BMI160_CHIP_ID_ADDR 0x00u #define BMI160_CHIP_ID_EXPECT 0xD1u int bmi160_check_id(const bmi160_io_t *io) { uint8_t id 0; int ret io-read(io-addr, BMI160_CHIP_ID_ADDR, id, 1); if (ret ! 0) { return ret; /* 总线错误先查上拉和接线 */ } return (id BMI160_CHIP_ID_EXPECT) ? 0 : -1; }这段代码的意思是一次单字节读读出的值和预期值比对。返回 0 才能继续初始化返回 -1 时不要急着改配置先检查 SDO 电平是不是和代码里的io-addr一致。常见错误是地址写 0x68实际 SDO 拉高变成了 0x69读出来全是 0xFF 或 0x00。把“读 ID”单独留给调试入口比闷头找中断配置省时间得多。2.3 初始化序列从软复位到量程和带宽BMI160 的命令寄存器在0x7E软复位命令是0xB6。初始化我习惯先软复位再配加速度计和陀螺仪的输出频率、量程最后才把两颗传感器的电源模式切到 normal。#define BMI160_CMD_ADDR 0x7Eu #define BMI160_CMD_SOFTRESET 0xB6u #define BMI160_REG_ACC_CONF 0x40u #define BMI160_REG_ACC_RANGE 0x41u #define BMI160_REG_GYR_CONF 0x42u #define BMI160_REG_GYR_RANGE 0x43u int bmi160_init(bmi160_io_t *io) { uint8_t cmd BMI160_CMD_SOFTRESET; uint8_t val; io-write(io-addr, BMI160_CMD_ADDR, cmd, 1); io-delay_ms(20); val 0x08u; /* 100Hz 输出normal mode */ io-write(io-addr, BMI160_REG_ACC_CONF, val, 1); val 0x03u; /* ±2g */ io-write(io-addr, BMI160_REG_ACC_RANGE, val, 1); val 0x08u; /* 100Hz 输出normal mode */ io-write(io-addr, BMI160_REG_GYR_CONF, val, 1); val 0x00u; /* ±2000dps */ io-write(io-addr, BMI160_REG_GYR_RANGE, val, 1); cmd 0x11u; /* ACC power: normal */ io-write(io-addr, BMI160_CMD_ADDR, cmd, 1); cmd 0x15u; /* GYR power: normal */ io-write(io-addr, BMI160_CMD_ADDR, cmd, 1); io-delay_ms(10); return 0; }ACC_CONF的高四位是输出频率0x08表示 100Hz低两位是电源模式正常模式为 00。ACC_RANGE的0x03表示 ±2g。陀螺仪侧GYR_CONF也是0x08代表 100HzGYR_RANGE的0x00表示 ±2000dps。常用量程和灵敏度关系如下寄存器数值量程灵敏度ACC_RANGE0x03±2g16384 LSB/gACC_RANGE0x05±4g8192 LSB/gACC_RANGE0x08±8g4096 LSB/gACC_RANGE0x0C±16g2048 LSB/gGYR_RANGE0x00±2000dps16.4 LSB/dpsGYR_RANGE0x01±1000dps32.8 LSB/dpsGYR_RANGE0x02±500dps65.5 LSB/dpsGYR_RANGE0x03±250dps131.2 LSB/dps写初始化代码时我还会在软复位后留 20ms而不是手册里写的最短 1ms。原因是很多板子供电稳压器上电慢复位刚发完传感器可能还没退出内部启动流程紧接着写量程会丢一两个字节。多留这段时间不会影响实时性但能少跑一轮排查。2.4 读原始数据6个字节的字节序与补码处理BMI160 的加速度数据寄存器从0x12开始陀螺仪从0x18开始都是低字节在前。一次读 6 个字节避免单独读两个字节造成高/低位不同帧。int bmi160_read_accel(bmi160_io_t *io, int16_t *ax, int16_t *ay, int16_t *az) { uint8_t raw[6]; int ret io-read(io-addr, 0x12u, raw, 6); if (ret ! 0) { return ret; } *ax (int16_t)((raw[1] 8) | raw[0]); *ay (int16_t)((raw[3] 8) | raw[2]); *az (int16_t)((raw[5] 8) | raw[4]); return 0; }代码先把低字节放在最低位高字节左移 8 位再拼起来最后强转成int16_t。BMI160 输出的是二进制补码所以 0x8000 会被正确解释为 -32768。读陀螺仪只改起始寄存器为0x18字节组合方式完全一致。转换成物理量时拿原始 LSB 除以灵敏度即可#define ACC_SCALE_2G (1.0f / 16384.0f) float ax_g *ax * ACC_SCALE_2G;这一步看起来简单但很多新手直接在中断回调里做除法CPU 开销和浮点精度都吃亏。我一般把原始量存进环形队列需要做姿态解算时再换算成 g 和 dps这样高频采样时中断里只有 memcpy。3. 把C驱动封装成C类中断回调、FIFO与IMU数据流3.1 为什么值得封装C接口的“句柄参数”在C里是thisC 驱动里的每个函数都要带bmi160_io_t *io参数写多了总觉得啰嗦。封装成 C 类之后这个句柄自然变成成员变量方法签名短一截还能用构造/析构管理上电和资源释放。class BMI160 { public: explicit BMI160(bmi160_io_t io) : io_(io) {} int begin() { return bmi160_check_id(io_); } int init() { return bmi160_init(io_); } int readAccel(int16_t *ax, int16_t *ay, int16_t *az) { return bmi160_read_accel(io_, ax, ay, az); } private: bmi160_io_t io_; };封装时尽量让 C 类直接复用 C 函数而不是把寄存器读写逻辑再抄一遍。这样驱动只有一个实现做单元测试时也能用 mock 的bmi160_io_t替换真实硬件。3.2 用枚举和常量把寄存器映射变成类型安全C 里量程参数就是裸数字传错一个 0x08 和 0x03 就要花半天查。C 里可以用enum class把量程变成编译期类型传错类型直接报编译错误。enum class AccelRange : uint8_t { G2 0x03, G4 0x05, G8 0x08, G16 0x0C, }; enum class GyroRange : uint8_t { DPS2000 0x00, DPS1000 0x01, DPS500 0x02, DPS250 0x03, }; void setAccRange(AccelRange range) { uint8_t v static_castuint8_t(range); io_.write(io_.addr, 0x41u, v, 1); }static_castuint8_t(range)把枚举值安全转成寄存器值避免reinterpret_cast这种不必要的危险写法。这样调用方只能写imu.setAccRange(AccelRange::G4)永远不可能变成imu.setAccRange(0x41)这种把寄存器地址和值混在一起的错误。3.3 中断引脚配置与回调注册BMI160 的数据就绪中断、FIFO 水位中断都可以映射到 INT1 或 INT2。用 C 封装时把硬件中断服务函数里的裸标志位包装成std::function回调会更好用。bool BMI160::enableDataReadyInterrupt() { uint8_t v 0x01; io_.write(io_.addr, 0x51u, v, 1); /* INT_EN_1: 打开 data ready */ io_.write(io_.addr, 0x54u, v, 1); /* INT_MAP_1: 映射到 INT1 */ v 0x01; /* INT_OUT_CTRL: 推挽输出高有效 */ io_.write(io_.addr, 0x55u, v, 1); return true; }这里把INT_EN_1的 bit0 置 1 使能数据就绪中断再把INT_MAP_1的 bit0 置 1 让信号从 INT1 脚出来最后把INT_OUT_CTRL配成推挽、高电平有效。不同版本的中文数据手册对这三个寄存器的位定义排版不太一样但名字很固定照着INT_EN、INT_MAP、INT_OUT_CTRL三张表定位即可。回调注册可以这样设计void BMI160::attachDataReady(std::functionvoid() cb) { dataReadyCb_ std::move(cb); } void BMI160::handleInterrupt() { if (dataReadyCb_) dataReadyCb_(); }实际项目里不要在中断处理函数里直接解析姿态而是把“有数据”这件事登记下来主循环里再批量读 FIFO。这样中断回调只有几行不会把优先级搞乱。3.4 FIFO读取一次中断读多帧把CPU占用摊平BMI160 的 FIFO 最多能存 1024 字节。加速度计加陀螺仪每帧 6 字节加 6 字节不开 header 时一帧就是 12 字节。先读 FIFO 长度寄存器再按帧数整块读比每个采样都进中断舒服得多。bool BMI160::readFifo(int16_t acc[][3], int16_t gyr[][3], size_t frameCount, size_t max) { uint8_t len[2] {0, 0}; io_.read(io_.addr, 0x3Cu, len, 2); /* FIFO_LENGTH_0 / 1 */ uint16_t bytes len[0] | (uint16_t(len[1]) 8); frameCount 0; while (bytes 12 frameCount max) { uint8_t frame[12]; io_.read(io_.addr, 0x3Eu, frame, 12); /* FIFO_DATA 连读 12 字节 */ acc[frameCount][0] (int16_t)((frame[1] 8) | frame[0]); acc[frameCount][1] (int16_t)((frame[3] 8) | frame[2]); acc[frameCount][2] (int16_t)((frame[5] 8) | frame[4]); gyr[frameCount][0] (int16_t)((frame[7] 8) | frame[6]); gyr[frameCount][1] (int16_t)((frame[9] 8) | frame[8]); gyr[frameCount][2] (int16_t)((frame[11] 8) | frame[10]); frameCount; bytes - 12; } return true; }读 FIFO 长度时一次读两个字节低字节在前组合成 16 位bytes。读 FIFO 数据时一次读 12 字节但要注意一旦在 FIFO 配置里开启了 header 模式每帧第一个字节是帧头12 字节的偏移就会发生变化。FIFO 读完不需要主动清空硬件会自动把水位降下去这也是调试时判断驱动是否健康的一个重要现象。4. 三轴姿态解算与电机驱动联动从原始数据到可用角度4.1 加速度计标定零偏和尺度因子怎么算BMI160 出厂虽然做过温度补偿但贴片应力会让加速度计出现零偏尤其是 X、Y 轴。我一般用“六面法”做一阶标定每个轴分别朝上和朝下静置采集一段时间取平均得到1g和-1g对应的原始 LSB。/* 以 X 轴为例ax_pos 存储 X 朝下的平均值ax_neg 存储 -X 朝下的平均值 */ float bias_x (ax_pos ax_neg) / 2.0f; float scale_x (ax_pos - ax_neg) / 2.0f; float ax_g (float)ax_raw - bias_x; ax_g / scale_x;道理很简单同一个轴在 ±1g 下测得ax_pos和ax_neg两者平均就是零偏差的一半就是尺度因子单位为 LSB/g。Y 轴、Z 轴同样处理。标定完成后的bias和scale可以烧进控制器参数区也可以在 C 类里做成可读写结构体方便产线校准软件直接下发。4.2 互补滤波在陀螺仪积分与加速度计重力方向之间找平衡加速度计静止时能给出准确的横滚角、俯仰角但动态下高频噪声大陀螺仪短时间非常准但积分一久就会漂移。互补滤波就是用陀螺仪算短期角度用加速度计每帧拉回一点避免角度发散。class ComplementaryFilter { public: void update(float gyro_dps, float acc_angle, float dt) { angle_ alpha_ * (angle_ gyro_dps * dt) (1.0f - alpha_) * acc_angle; } float angle() const { return angle_; } private: float angle_ 0.0f; static constexpr float alpha_ 0.98f; };alpha_取 0.98 表示 98% 信任陀螺仪积分2% 信任加速度计。dt必须和控制循环频率一致比如 100Hz 控制就是 0.01 秒。如果dt和实际运行周期对不上alpha_不变时会出现角度抖过头或跟不上的情况。更稳的做法是让alpha_根据dt动态计算但在 100Hz 固定中断里直接用常量足够。这个滤波器只适合横滚和俯仰偏航角没有绝对参照必须靠磁力计或视觉/里程计修正不是一颗 BMI160 能单独解决的。4.3 把角度输出给电机驱动TB6612和STLink/JLink调试时的数据流把 BMI160 放到两轮平衡车项目里数据流就非常清晰BMI160 产生原始 LSB姿态解算输出角度PID 根据角度和角速度算 PWMTB6612 电机驱动芯片放大 PWM 信号驱动直流电机。void controlLoop100Hz() { readFifo(acc, gyr, cnt, 8); float gyr_x_dps cvtGyro(gyr[0][0]); /* LSB 转 dps */ float acc_pitch_deg calcPitch(acc[0]); /* LSB 转 g 后算角度 */ float pitch filter.update(gyr_x_dps, acc_pitch_deg, 0.01f); float pid_out balancePid.calculate(pitch); int16_t pwm clamp(pid_out, -1000, 1000); if (pwm 0) { tb6612.setMotorA(pwm, MotorDir::FORWARD); } else { tb6612.setMotorA(-pwm, MotorDir::BACKWARD); } }关键一点是readFifo读出来的多帧数据每帧都要参与 PID 而不是只用第一帧。PID 输出的 PWM 是大油门但给 TB6612 的占空比注意不要 100% 猛拉最好限幅在 850 以内给反向刹车留出余量。调试这种联动系统时我习惯先用 STLink 或 JLink 在线调试把角度值放到 VSCode 里配好 C/C 环境后打断点查看等角度输出稳定再转到串口波形。串口用 CH340 比较多波特率设 921600 或 460800波形刷新才够密否则 100Hz 数据打印出来全是毛刺。5. 驱动调试三板斧读ID、看FIFO水位、用示波器对时序5.1 读ID失败先查器件地址和电源时序读 CHIP_ID 失败时先别怀疑芯片焊坏了。把读 ID 放进一个 while 循环每 100ms 打一次读回值观察上电瞬间有没有从 0x00 或 0xFF 跳到 0xD1。很多板子只是 I2C 地址选错了SDO 电平没有和代码对应。一个容易被忽略的细节是SDO 不能悬空悬空读到的 ID 可能不稳定必须接明确的高或低电平。while (bmi160_check_id(io) ! 0) { io.delay_ms(100); /* 等电源稳定后自动恢复 */ }5.2 通过FIFO水位判断数据是否真的在走正常运行时读 FIFO 长度寄存器两次应该能明显看到数值在增长。如果一直是 0说明传感器根本没进正常模式检查命令寄存器写入 0x11 和 0x15 后有没有读回错误位如果水位一直停在 0x4000 附近满着不动说明已经来不及消费这时要调大批量读取的max或者把输出频率从 415Hz 降回 100Hz。5.3 示波器对时序先对读CHIP_ID再对中断脉冲最后一级确认永远靠示波器。I2C 模式下抓 SDA 和 SCL第一次读0x00时应该看到地址字节、寄存器字节、再列出 8 个时钟读数据字节。SPI 模式下抓 CS、SCK 和 MISOCS 拉低后 MISO 上应该是 0xD1。数据对不上时查手册里的建立时间tSU和保持时间tHD很多问题不是逻辑写错是主控 SPI 时钟太快BMI160 跟不上。硬件接线都正常但中断不来时用示波器抓 INT1 引脚和 FIFO 水位寄存器。数据就绪中断配成电平触发就应当时常处于高电平配成边沿触发就应看到 50us 左右的脉冲宽度和主控主频有关。最快的验证方式其实是一边用串口打印 FIFO 水位一边用示波器抓 CS 拉低时的读地址字节两路信号对上了寄存器配置就没有秘密。本文还有配套的精品资源点击获取