STM32F103与BMI160实战:从零搭建六轴传感器数据采集系统(附完整代码)
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 引脚 | 连接至 | 功能说明 |
|---|---|---|
| PB10 | BMI160模块 SCL | I2C1 时钟线 |
| PB11 | BMI160模块 SDA | I2C1 数据线 |
| PB8 | OLED 模块 SCL | I2C2 时钟线 (或模拟I2C) |
| PB9 | OLED 模块 SDA | I2C2 数据线 (或模拟I2C) |
| 5V | BMI160模块 VIN | 电源 (模块自带LDO降压至3.3V) |
| 3.3V | OLED 模块 VCC | 电源 (注意OLED屏电压要求) |
| GND | BMI160模块 GND | 共地 |
| GND | OLED 模块 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需要遵循一个特定的顺序,不能乱来。一个稳健的初始化流程通常包括以下几步:
- 软复位:向CMD寄存器(0x7E)写入0xB6,让传感器恢复默认状态,并等待至少1ms。
- 配置陀螺仪:先通过CMD寄存器(0x7E)写入0x15,将陀螺仪切换到正常工作模式,等待约80ms让其稳定。
- 配置加速度计:通过CMD寄存器(0x7E)写入0x11,将加速度计切换到正常工作模式,同样等待约80ms。
- 设置输出数据率(ODR)和带宽:配置ACC_CONF(0x40)和GYR_CONF(0x42)寄存器。这两个寄存器决定了数据刷新的频率和内部滤波器的带宽。例如,写入0x28代表ODR为100Hz,带宽为正常模式。
- 设置量程:配置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)) |
|---|---|---|---|
| 加速度计 | ±2g | 0x03 | 16384 |
| 加速度计 | ±4g | 0x05 | 8192 |
| 加速度计 | ±8g | 0x08 | 4096 |
| 加速度计 | ±16g | 0x0C | 2048 |
| 陀螺仪 | ±125°/s | 0x04 | 262.4 |
| 陀螺仪 | ±250°/s | 0x03 | 131.2 |
| 陀螺仪 | ±500°/s | 0x02 | 65.6 |
| 陀螺仪 | ±1000°/s | 0x01 | 32.8 |
| 陀螺仪 | ±2000°/s | 0x00 | 16.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和中断功能我们还没有用到,它们对于实现低功耗的数据批处理和事件触发非常有用,这可以作为你下一步深入探索的方向。当你把这些都跑通,再回头看最初的代码,会发现一个完整的传感器数据采集系统已然成型。
更多推荐



所有评论(0)