STM32F103与BMI160实战:从零搭建六轴传感器数据采集系统(附完整代码)

如果你刚开始接触嵌入式开发,手头正好有一块STM32F103开发板和BMI160六轴传感器模块,想快速搭建一个能实时显示运动数据的系统,却卡在I2C通信、数据解析这些环节,那这篇文章就是为你准备的。我会带你一步步走通整个流程,从硬件连接到软件调试,把那些容易踩的坑都提前标出来。我们不止于“点亮”,更要理解每个寄存器配置背后的意义,并最终实现一个稳定、可复用的数据采集框架。你会发现,驱动一个复杂的传感器,其实核心就是和它“对话”的规则搞清楚。

1. 项目蓝图:硬件选型与系统架构设计

在动手写代码之前,花点时间规划整个系统是值得的。这次我们构建的是一个典型的嵌入式传感器数据采集节点。核心控制器是大家熟悉的STM32F103C8T6,也就是常说的“蓝桥杯”或“最小系统板”核心。它基于Cortex-M3内核,主频72MHz,拥有足够的计算能力来处理传感器数据流,并且其丰富的外设(特别是硬件I2C)能让我们事半功倍。

传感器方面,BMI160来自博世,是一颗将3轴加速度计和3轴陀螺仪集成在单一封装内的6轴惯性测量单元(IMU)。它的几个关键特性决定了我们的设计思路:

  • 低功耗与高性能:全速运行时典型电流仅925µA,非常适合电池供电的移动或穿戴设备。
  • 灵活的接口:支持I2C和SPI。为了简化布线并降低对IO口的需求,我们选择I2C接口。
  • 内置FIFO与中断:这允许传感器在微控制器休眠时持续采集数据并缓存,达到极致的省电效果,也为实时响应运动事件提供了硬件基础。
  • 宽电压范围:核心电压(VDD)1.71V-3.6V,IO口电压(VDDIO)1.2V-3.6V,兼容性很强。

为了直观展示数据,我们加入一块0.96英寸的OLED显示屏(通常使用SSD1306驱动芯片),通过I2C接口与STM32连接。这样,整个系统的数据流就清晰了:BMI160感知物理运动,通过I2C将原始数据传输给STM32;STM32解析、换算这些数据,再通过另一个I2C通道将结果实时刷新到OLED屏幕上。

整个系统的硬件连接关系,可以用下面这个简表来概括:

STM32F103C8T6 引脚连接至功能说明
PB10BMI160模块 SCLI2C1 时钟线
PB11BMI160模块 SDAI2C1 数据线
PB8OLED 模块 SCLI2C2 时钟线 (或模拟I2C)
PB9OLED 模块 SDAI2C2 数据线 (或模拟I2C)
5VBMI160模块 VIN电源 (模块自带LDO降压至3.3V)
3.3VOLED 模块 VCC电源 (注意OLED屏电压要求)
GNDBMI160模块 GND共地
GNDOLED 模块 GND共地

提示:务必确保所有设备的电源地(GND)连接在一起,这是I2C总线正常通信的基础。如果OLED屏是3.3V供电,请直接从STM32的3.3V引脚取电。

2. 打通任督二脉:STM32的硬件I2C驱动精讲

很多初学者在I2C通信上栽跟头,问题往往出在时序和应答(ACK)的处理上。STM32的硬件I2C外设可以帮我们规避大部分时序问题,但配置和使用也需要遵循其特定的流程。这里我们不使用常见的“模拟I2C”(GPIO模拟时序),而是直接配置硬件I2C1来与BMI160通信,这更稳定、更节省CPU资源。

首先,我们需要初始化I2C1。关键参数包括时钟速度(标准模式100kHz或快速模式400kHz)、自身地址(主模式通常设为0)、应答使能、以及时钟延展等。BMI160支持快速模式,我们可以设置为400kHz以提升数据吞吐率。

// I2C1 初始化示例 (使用STM32标准外设库)
void I2C1_Configuration(void) {
    GPIO_InitTypeDef GPIO_InitStructure;
    I2C_InitTypeDef I2C_InitStructure;

    // 1. 使能时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE);

    // 2. 配置GPIO: PB10(SCL), PB11(SDA) 为复用开漏输出
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD; // 复用开漏
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOB, &GPIO_InitStructure);

    // 3. 配置I2C1
    I2C_InitStructure.I2C_Mode = I2C_Mode_I2C;
    I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2;
    I2C_InitStructure.I2C_OwnAddress1 = 0; // 主设备地址,可任意设置
    I2C_InitStructure.I2C_Ack = I2C_Ack_Enable;
    I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;
    I2C_InitStructure.I2C_ClockSpeed = 400000; // 400kHz

    I2C_Init(I2C1, &I2C_InitStructure);
    I2C_Cmd(I2C1, ENABLE);
}

配置好硬件,接下来就是实现最核心的读写函数。这里需要处理启动、发送设备地址、等待标志位、发送/接收数据、停止等一系列状态。下面是一个向指定寄存器写入一个字节的函数:

/**
  * @brief  通过I2C1向BMI160指定寄存器写入一个字节
  * @param  reg_addr: 寄存器地址
  * @param  data: 要写入的数据
  * @retval 成功返回0,失败返回非0
  */
uint8_t BMI160_WriteReg(uint8_t reg_addr, uint8_t data) {
    // 1. 发送起始条件
    I2C_GenerateSTART(I2C1, ENABLE);
    while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT));

    // 2. 发送设备地址 + 写标志 (BMI160地址为0x69,左移一位后,最低位为0表示写)
    I2C_Send7bitAddress(I2C1, BMI160_I2C_ADDR, I2C_Direction_Transmitter);
    while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED));

    // 3. 发送寄存器地址
    I2C_SendData(I2C1, reg_addr);
    while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED));

    // 4. 发送数据
    I2C_SendData(I2C1, data);
    while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED));

    // 5. 发送停止条件
    I2C_GenerateSTOP(I2C1, ENABLE);

    return 0; // 简化处理,实际应加入超时和错误判断
}

读函数稍微复杂一点,因为它涉及一个“重复起始”条件。流程是:先以写模式发送设备地址和寄存器地址,然后不发送停止信号,而是发送一个重复起始信号,再以读模式发送设备地址,开始接收数据。

/**
  * @brief  通过I2C1从BMI160指定寄存器读取一个字节
  * @param  reg_addr: 寄存器地址
  * @retval 读取到的数据
  */
uint8_t BMI160_ReadReg(uint8_t reg_addr) {
    uint8_t data = 0;

    // 1. 起始 + 发送设备地址(写) + 发送寄存器地址 (流程同写操作前半部分)
    I2C_GenerateSTART(I2C1, ENABLE);
    while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT));
    I2C_Send7bitAddress(I2C1, BMI160_I2C_ADDR, I2C_Direction_Transmitter);
    while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED));
    I2C_SendData(I2C1, reg_addr);
    while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED));

    // 2. 发送重复起始条件
    I2C_GenerateSTART(I2C1, ENABLE);
    while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT));

    // 3. 发送设备地址 + 读标志
    I2C_Send7bitAddress(I2C1, BMI160_I2C_ADDR, I2C_Direction_Receiver);
    while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED));

    // 4. 准备接收一个字节,并在接收后发送NACK和停止信号
    // 关闭应答(准备接收最后一个字节)
    I2C_AcknowledgeConfig(I2C1, DISABLE);
    // 清除EV6标志,准备接收数据
    (void)I2C1->SR1;
    (void)I2C1->SR2;

    // 等待数据接收完成
    while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_RECEIVED));
    data = I2C_ReceiveData(I2C1);

    // 5. 发送停止条件
    I2C_GenerateSTOP(I2C1, ENABLE);
    // 重新使能应答,为后续可能的多字节读取做准备
    I2C_AcknowledgeConfig(I2C1, ENABLE);

    return data;
}

注意:上述代码为了清晰省略了超时判断。在实际产品代码中,务必在每个while等待循环中加入超时机制,防止程序因I2C总线故障而卡死。例如,可以计数SysTick滴答数,超过一定时间仍未等到事件则跳出并返回错误码。

3. 与BMI160对话:寄存器配置与传感器初始化

有了可靠的I2C读写基础,我们就可以开始配置BMI160了。和大多数传感器一样,操作BMI160本质上就是读写其内部寄存器。首先,我们必须确认通信链路是通的,最直接的方法是读取其芯片ID寄存器(地址0x00)。BMI160的芯片ID固定为0xD1。如果读回来的值不对,请立即检查硬件连接、上拉电阻、电源和I2C地址(模块上SDO引脚的电平决定了地址是0x68还是0x69)。

初始化BMI160需要遵循一个特定的顺序,不能乱来。一个稳健的初始化流程通常包括以下几步:

  1. 软复位:向CMD寄存器(0x7E)写入0xB6,让传感器恢复默认状态,并等待至少1ms。
  2. 配置陀螺仪:先通过CMD寄存器(0x7E)写入0x15,将陀螺仪切换到正常工作模式,等待约80ms让其稳定。
  3. 配置加速度计:通过CMD寄存器(0x7E)写入0x11,将加速度计切换到正常工作模式,同样等待约80ms。
  4. 设置输出数据率(ODR)和带宽:配置ACC_CONF(0x40)和GYR_CONF(0x42)寄存器。这两个寄存器决定了数据刷新的频率和内部滤波器的带宽。例如,写入0x28代表ODR为100Hz,带宽为正常模式。
  5. 设置量程:配置ACC_RANGE(0x41)和GYR_RANGE(0x43)寄存器。量程决定了传感器能测量的最大值,也影响了数据的精度(灵敏度)。量程越大,能测量的物理量范围越大,但相同的数字变化代表的物理量变化也越大,分辨率相对变低。

下面是一个具体的初始化函数实现:

#define BMI160_I2C_ADDR_WRITE  (0x69 << 1)      // 假设SDO接高,写地址
#define BMI160_I2C_ADDR_READ   ((0x69 << 1) | 1) // 读地址
#define BMI160_REG_CHIP_ID     0x00
#define BMI160_REG_CMD         0x7E
#define BMI160_REG_ACC_CONF    0x40
#define BMI160_REG_ACC_RANGE   0x41
#define BMI160_REG_GYR_CONF    0x42
#define BMI160_REG_GYR_RANGE   0x43

void BMI160_Init(void) {
    uint8_t chip_id = 0;

    // 1. 读取芯片ID,验证通信
    chip_id = BMI160_ReadReg(BMI160_REG_CHIP_ID);
    if(chip_id != 0xD1) {
        // 通信失败处理,例如通过串口打印错误或点亮LED
        while(1); // 死循环,便于调试
    }

    // 2. 软复位
    BMI160_WriteReg(BMI160_REG_CMD, 0xB6);
    Delay_ms(5); // 短暂延时

    // 3. 启动陀螺仪 (进入正常模式)
    BMI160_WriteReg(BMI160_REG_CMD, 0x15);
    Delay_ms(80); // 等待陀螺仪启动稳定

    // 4. 启动加速度计 (进入正常模式)
    BMI160_WriteReg(BMI160_REG_CMD, 0x11);
    Delay_ms(80); // 等待加速度计启动稳定

    // 5. 配置加速度计和陀螺仪的ODR与带宽 (例如:100Hz, 正常带宽)
    BMI160_WriteReg(BMI160_REG_ACC_CONF, 0x28); // acc_us = 0, acc_bwp = 2, acc_odr = 8 (100Hz)
    BMI160_WriteReg(BMI160_REG_GYR_CONF, 0x28); // gyr_bwp = 2, gyr_odr = 8 (100Hz)

    // 6. 配置量程
    BMI160_WriteReg(BMI160_REG_ACC_RANGE, 0x0C); // 加速度计量程 ±16g
    BMI160_WriteReg(BMI160_REG_GYR_RANGE, 0x00); // 陀螺仪量程 ±2000°/s

    Delay_ms(50); // 最终稳定等待
}

这里有几个细节值得深究:

  • CMD寄存器的特殊性:它是一个命令寄存器,写入特定值会触发内部状态机执行相应操作。关键点在于,每次写入命令后,必须等待足够的时间让命令执行完成,才能进行下一次写入或读取操作。数据手册中会给出每个命令的最短执行时间(如软复位后需要至少2ms,传感器模式切换需要几十ms)。
  • ODR与带宽的权衡:输出数据率越高,数据越“新鲜”,但功耗也越高,并且可能引入更多高频噪声。带宽滤波器可以平滑数据,但会引入相位延迟。对于姿态估计等应用,需要根据具体算法选择合适的参数。
  • 量程的选择:如果你的应用是计步或手势识别,加速度可能不会超过±4g,选择±4g量程可以获得更高的分辨率。如果是无人机或机器人,可能会经历更大的加速度,就需要选择±8g或±16g。陀螺仪同理。

4. 数据的获取、解析与可视化

初始化成功后,BMI160就会按照设定的ODR持续测量,并将数据更新到其数据寄存器中。加速度和陀螺仪的原始数据各占6个字节(XYZ轴各2字节),分别存放在地址0x04~0x0D(加速度)和0x0E~0x13(陀螺仪)的寄存器中。为了提高效率并保证数据的时间同步性,强烈建议使用连续读取(Multi-Byte Read)功能,一次性将这12个字节读出来。

/**
  * @brief  一次性读取BMI160的六轴原始数据 (12字节)
  * @param  ax, ay, az: 指向加速度原始数据(int16_t)的指针
  * @param  gx, gy, gz: 指向陀螺仪原始数据(int16_t)的指针
  * @retval 无
  */
void BMI160_Get6AxisRaw(int16_t *ax, int16_t *ay, int16_t *az,
                        int16_t *gx, int16_t *gy, int16_t *gz) {
    uint8_t buf[12];
    // 从陀螺仪数据LSB寄存器开始连续读取12字节
    BMI160_ReadMulti(BMI160_REG_GYR_LSB, buf, 12);

    // BMI160数据为小端格式,低字节在前
    *gx = (int16_t)((buf[1] << 8) | buf[0]);
    *gy = (int16_t)((buf[3] << 8) | buf[2]);
    *gz = (int16_t)((buf[5] << 8) | buf[4]);
    *ax = (int16_t)((buf[7] << 8) | buf[6]);
    *ay = (int16_t)((buf[9] << 8) | buf[8]);
    *az = (int16_t)((buf[11] << 8) | buf[10]);
}

读到的原始数据是有符号的16位整数,需要根据之前设置的量程,除以对应的灵敏度(LSB/g 或 LSB/(°/s)),才能转换为有物理意义的单位。不同量程对应的灵敏度是固定的,如下表所示:

传感器量程选择寄存器配置值灵敏度 (LSB/g 或 LSB/(°/s))
加速度计±2g0x0316384
加速度计±4g0x058192
加速度计±8g0x084096
加速度计±16g0x0C2048
陀螺仪±125°/s0x04262.4
陀螺仪±250°/s0x03131.2
陀螺仪±500°/s0x0265.6
陀螺仪±1000°/s0x0132.8
陀螺仪±2000°/s0x0016.4

换算公式很简单:

  • 加速度:accel_g = raw_accel / sensitivity (单位:g) 若要转换为国际单位m/s²,则:accel_m_s2 = accel_g * 9.80665
  • 陀螺仪:gyro_dps = raw_gyro / sensitivity (单位:°/s) 若要转换为弧度制rad/s,则:gyro_rad_s = gyro_dps * (π / 180)

在代码中,我们可以这样实现换算:

// 假设我们初始化时设置了加速度计量程为±16g,陀螺仪为±2000°/s
#define ACCEL_SENSITIVITY_16G  2048.0f
#define GYRO_SENSITIVITY_2000DPS  16.4f

void BMI160_ConvertData(int16_t ax_raw, int16_t ay_raw, int16_t az_raw,
                        int16_t gx_raw, int16_t gy_raw, int16_t gz_raw,
                        float *ax_ms2, float *ay_ms2, float *az_ms2,
                        float *gx_dps, float *gy_dps, float *gz_dps) {
    // 转换为g单位
    float ax_g = (float)ax_raw / ACCEL_SENSITIVITY_16G;
    float ay_g = (float)ay_raw / ACCEL_SENSITIVITY_16G;
    float az_g = (float)az_raw / ACCEL_SENSITIVITY_16G;

    // 转换为 m/s²
    *ax_ms2 = ax_g * 9.80665f;
    *ay_ms2 = ay_g * 9.80665f;
    *az_ms2 = az_g * 9.80665f;

    // 转换为 °/s
    *gx_dps = (float)gx_raw / GYRO_SENSITIVITY_2000DPS;
    *gy_dps = (float)gy_raw / GYRO_SENSITIVITY_2000DPS;
    *gz_dps = (float)gz_raw / GYRO_SENSITIVITY_2000DPS;
}

最后,为了直观看到数据,我们将换算后的值显示在OLED屏幕上。这里需要另一个I2C驱动(可以是硬件I2C2,也可以是模拟I2C)来驱动SSD1306。在主循环中,我们周期性地读取、换算、显示数据。为了增加交互性,可以设计一个按键,用于切换显示原始数据还是换算后的物理量。

// main.c 主循环示例
int main(void) {
    // 初始化各模块
    OLED_Init();
    BMI160_Init();
    Key_Init(); // 初始化一个按键,用于切换显示模式

    uint8_t display_mode = 0; // 0:显示原始值, 1:显示物理量
    int16_t raw_accel[3], raw_gyro[3];
    float accel_ms2[3], gyro_dps[3];

    while(1) {
        // 1. 读取原始数据
        BMI160_Get6AxisRaw(&raw_accel[0], &raw_accel[1], &raw_accel[2],
                           &raw_gyro[0], &raw_gyro[1], &raw_gyro[2]);

        // 2. 检查按键,切换显示模式
        if(Key_Scan() == 1) {
            display_mode = !display_mode;
            OLED_Clear(); // 清屏
        }

        // 3. 根据模式显示
        if(display_mode == 0) {
            // 显示原始16进制或十进制值
            OLED_ShowSignedNum(2, 1, raw_accel[0], 6);
            OLED_ShowSignedNum(3, 1, raw_accel[1], 6);
            OLED_ShowSignedNum(4, 1, raw_accel[2], 6);
            // ... 显示陀螺仪原始值
        } else {
            // 换算为物理量并显示
            BMI160_ConvertData(raw_accel[0], raw_accel[1], raw_accel[2],
                               raw_gyro[0], raw_gyro[1], raw_gyro[2],
                               &accel_ms2[0], &accel_ms2[1], &accel_ms2[2],
                               &gyro_dps[0], &gyro_dps[1], &gyro_dps[2]);

            OLED_ShowFloat(2, 1, accel_ms2[0], 2, 2); // 显示两位整数,两位小数
            OLED_ShowFloat(3, 1, accel_ms2[1], 2, 2);
            OLED_ShowFloat(4, 1, accel_ms2[2], 2, 2);
            // ... 显示陀螺仪物理量
        }

        Delay_ms(50); // 控制刷新率,避免OLED刷新过快
    }
}

5. 超越基础:数据校准与滤波初步

拿到数据并显示出来,只是第一步。你会发现,即使传感器静止不动,加速度计的Z轴读数可能不是完美的1g(9.8 m/s²),其他轴也不是0;陀螺仪在静止时也会有微小的输出(零偏)。这些误差来源于传感器的制造偏差、温度漂移和电路噪声。为了获得更精确的数据,校准是必不可少的。

加速度计校准相对简单,主要校准零偏和灵敏度。一个常用的方法是六面校准法:将传感器依次静止放置于六个正交面上(±X, ±Y, ±Z面朝下),记录每个轴在重力方向上的输出。通过这六组数据,可以计算出每个轴的零偏和比例因子。

陀螺仪校准主要是校准零偏。将传感器绝对静止放置一段时间,采集大量数据并求平均值,这个平均值就是各轴的零偏。在后续使用中,将读到的原始值减去这个零偏即可。

在校准之后,数据中仍然会存在高频随机噪声,这时就需要引入滤波。对于嵌入式系统,最常用且有效的是一阶低通滤波,它计算简单,能很好地平滑数据。

// 一阶低通滤波器结构体
typedef struct {
    float alpha; // 滤波系数 (0 < alpha <= 1), 越小滤波越强,滞后越大
    float prev_value; // 上一次滤波后的值
} LowPassFilter_t;

// 初始化滤波器
void LowPassFilter_Init(LowPassFilter_t *filter, float alpha, float init_value) {
    filter->alpha = alpha;
    filter->prev_value = init_value;
}

// 执行一次滤波
float LowPassFilter_Update(LowPassFilter_t *filter, float new_value) {
    float filtered_value;
    filtered_value = filter->alpha * new_value + (1.0f - filter->alpha) * filter->prev_value;
    filter->prev_value = filtered_value;
    return filtered_value;
}

// 在主循环中使用
LowPassFilter_t accel_filter[3], gyro_filter[3];
// ... 初始化滤波器,alpha可取0.1~0.3

// 读取并换算数据后,进行滤波
accel_ms2[0] = LowPassFilter_Update(&accel_filter[0], accel_ms2[0]);
// ... 对其他轴同理

对于更复杂的应用,如姿态解算(获取俯仰角、横滚角),则需要将加速度计和陀螺仪的数据进行融合。加速度计在静态或慢速运动时测量倾角很准,但对振动敏感;陀螺仪测量角速度积分得到角度,短时间内很准,但存在积分漂移。常用的融合算法有互补滤波和卡尔曼滤波。互补滤波实现简单,资源消耗小,在很多场合下效果已经足够好。其核心思想是利用高通滤波器提取陀螺仪的高频信号(快速变化),利用低通滤波器提取加速度计的低频信号(长期稳定),再将两者加权相加。

// 简易互补滤波姿态估算(俯仰角pitch和横滚角roll)伪代码
float pitch = 0, roll = 0; // 以弧度为单位的角度
float gyro_y, gyro_x; // 陀螺仪Y轴和X轴的角速度 (rad/s)
float accel_x, accel_y, accel_z; // 加速度计三轴数据 (已单位化)

// 从加速度计计算倾斜角(忽略动态加速度干扰)
float accel_pitch = atan2(-accel_x, sqrt(accel_y*accel_y + accel_z*accel_z));
float accel_roll = atan2(accel_y, accel_z);

// 互补滤波系数,通常取0.98左右
float alpha = 0.98;
float dt = 0.01; // 采样周期,例如100Hz对应0.01s

// 主循环中更新
pitch = alpha * (pitch + gyro_y * dt) + (1 - alpha) * accel_pitch;
roll = alpha * (roll + gyro_x * dt) + (1 - alpha) * accel_roll;

在实际项目中,我习惯将原始数据读取、单位换算、滤波、甚至初步的姿态解算都封装成一个独立的传感器处理任务或模块。这样,主程序只需要调用一个接口,就能获取到稳定、校准过的、甚至带有姿态角的数据,整个系统的架构会清晰很多。BMI160内置的FIFO和中断功能我们还没有用到,它们对于实现低功耗的数据批处理和事件触发非常有用,这可以作为你下一步深入探索的方向。当你把这些都跑通,再回头看最初的代码,会发现一个完整的传感器数据采集系统已然成型。

更多推荐