
玩 STM32 的人迟早会撞上 MPU6050 这枚芯片不管是做遥控小车、两轮自平衡还是手势识别它几乎是姿态检测领域默认的入门选项。而今天要聊的组合是 STM32F103 HAL 库 软件 I2C MPU6050 DMP 库这个搭配我前前后后用过好几轮踩过的坑可以绕开发板一圈。适合刚把点灯玩明白、想获取真实传感器数据或者卡在硬件 I2C 上死活读不出数据的同学参考。很多人上来就习惯性打开 CubeMX 的 I2C 外设但真到了 MPU6050 这里硬件 I2C 并不总是省事。这篇文章我会把软件 I2C 的时序拆开揉碎从 GPIO 模拟开始到寄存器读写再到把 InvenSense 官方 DMP 库移植进 Keil 工程每个环节都给完整思路和不绕路的做法。看完你应该能自己复现一套稳定的 MPU6050 读取方案并且知道 DMP 库怎么跟自己的代码融合而不是复制完一堆报错不知道怎么收拾。1. 为什么我最后选了软件I2C1.1 硬件I2C在F103上的那些坑STM32F103 的硬件 I2C 外设在圈内口碑一直比较微妙尤其搭配早期版本的 HAL 库或者标准库时经常会遇到总线锁死、状态寄存器异常的问题。我自己就碰到过上电后第一次读 MPU6050 的 WHO_AM_I 正常紧接着第二次读就卡在 HAL_I2C_Mem_Read 里出不来必须复位才能恢复。硬件 I2C 的坑主要来源于它的状态机设计。F103 的 I2C 外设跟后来 F4、F7 系列不太一样中断标志位、错误标志位一旦出现需要按特定顺序清理否则总线状态一直不对。HAL 库虽然在封装层做了很多处理但底层的行为方式没有变再加上硬件 I2C 引脚通常是 PB6/PB7 这种固定映射一旦跟别的外设冲突改引脚也是个麻烦事。1.2 软件I2C到底赢在哪软件 I2C 说白了就是用两个 GPIO 引脚通过翻转电平和控制时序模拟出 I2C 协议。它的优势在于灵活想用哪两个引脚都行CubeMX 里随便配置两个普通的 GPIO 输出就行完全绕开了硬件外设的固定引脚映射。时序方面软件的时钟完全由延时函数控制。I2C 标准模式是 100kHz快速模式 400kHzMPU6050 的数据输出速率一般也就是几百赫兹就算像 DMP 那样输出 100Hz 甚至 200Hz 的四元数I2C 总线上的数据量也完全不是瓶颈。软件模拟跑出来的总线速率足够用而且时序稳定可控出问题排查更容易。功耗和 CPU 占用确实比硬件 I2C要高但对于 F103 这种资源不算紧张的单片机来说软件 I2C 操作本身占用的时间非常短读完一组 14 字节数据也就在毫秒级以内。工程如果对功耗极其敏感可以想办法用 DMA 或者睡眠降低功耗但做姿态检测、平衡车这类项目软件 I2C 的这点开销完全可以忽略。1.3 方案对比与结论对比项硬件 I2C软件 I2C引脚选择固定映射F103 一般是 PB6/PB7任意 GPIO灵活配置调试难度状态机复杂报错容易卡死时序透明逻辑一目了然性能速度快可达 400kHz 以上受延时限制但 MPU6050 场景足够代码复用依赖 HAL 库版本换芯片可能变化几乎通用移植到 F4/GD32 只改宏理解协议封装较深不利于理解底层学习 I2C 时序的最佳方式结论很直接对于 STM32F103 MPU6050 这种组合软件 I2C 是性价比最高的选择。既避开了硬件外设的坑又能在实现过程中彻底搞清楚 I2C 协议的每一个细节。后面的内容全部基于软件 I2C 展开代码也都是在 HAL 库环境下写的。2. MPU6050基础知识与关键寄存器2.1 这个芯片到底能干什么MPU6050 内部集成了三轴陀螺仪和三轴加速度计陀螺仪用来测角速度加速度计用来测线加速度。单独看这两个数据其实很难直接得到稳定的姿态角因为陀螺仪积分会漂移加速度计又容易受震动干扰。所以 InvenSense 在这颗芯片里塞了一个 DMPDigital Motion Processor数字运动处理器。DMP 在硬件层面做姿态解算把加速度计和陀螺仪的数据融合成四元数、欧拉角直接把结果交给单片机读取。单片机不用自己跑卡尔曼滤波或者互补滤波姿态解算的负担大幅减轻。这也是我做这类项目坚持用 DMP 的原因尤其是在 F103 这种主频不高的芯片上DMP 能省下大量算力。2.2 设备地址要搞对MPU6050 的 I2C 设备地址由 AD0 引脚决定。AD0 接地时7 位地址是 0x688 位写地址是 0xD0读地址是 0xD1AD0 接高电平时7 位地址变成 0x69。大多数模块默认 AD0 接地所以 0xD0/0xD1 这个组合最常见。这里有一个很多人踩过的坑软件 I2C 发送的设备地址到底用 7 位还是 8 位。如果底层发送函数只接受 7 位地址那 0x68 就是对的如果函数设计者直接把读写位拼进去了那就要给 0xD0 或者 0xD1。后面 DMP 库移植时这个问题更是重灾区我会在第 5 章专门说明。2.3 需要记牢的几个寄存器MPU6050 的寄存器很多但实际开发中常用的就几个。理解这几个寄存器的含义比背一整本手册更有用。寄存器地址名称作用0x6BPWR_MGMT_1电源管理必须清零 SLEEP 位才能唤醒芯片0x19SMPLRT_DIV采样率分频器配置陀螺仪输出速率0x1ACONFIG配置 DLPF 低通滤波器滤除高频噪声0x1BGYRO_CONFIG陀螺仪量程选择±250/500/1000/2000 dps0x1CACCEL_CONFIG加速度计量程选择±2/4/8/16g0x3BACCEL_XOUT_H加速度计数据起始地址共 6 字节0x43GYRO_XOUT_H陀螺仪数据起始地址共 6 字节0x75WHO_AM_I设备 ID正常值 0x68用于验证通信数据寄存器都是 16 位有符号整数大端格式高位在前。读取时要手动把高 8 位和低 8 位拼在一起而且要注意转换成 int16_t不能直接用 uint16_t否则负数数据解析会出错。一个实用判断读取 WHO_AM_I 寄存器如果得到 0x68说明 I2C 通信链路和芯片地址都正确可以放心继续调试如果读出 0xFF 或者 0x00基本就是接线或者 SDA/SCL 时序有问题不用急着怀疑芯片坏了。3. 软件I2C底层实现从GPIO到收发字节3.1 引脚选择与初始化软件 I2C 在 STM32F103 上只需要两个引脚。我习惯用 PB6 做 SCLPB7 做 SDA这样命名好记也和部分模块的丝印对应。CubeMX 里把这两个引脚全部配置为 GPIO_OutputSDA 建议配置为开漏输出。这里说一下为什么 SDA 用开漏而 SCL 用推挽。I2C 总线是线与结构所有设备的 SDA 都是开漏输出加外部上拉电阻。开漏模式下引脚输出 1 时实际上是释放总线靠外部上拉把电平拉高输出 0 时引脚直接拉低。这样多个设备挂在同一根线上不会互相打架。SCL 始终由主机驱动不存在总线冲突问题所以用推挽输出驱动能力更强。如果手头模块没有上拉电阻或者自己做板子没有加上拉SDA 用开漏模式读到的永远是低电平时序完全乱。解决办法是在两个引脚上各接一个 4.7kΩ 左右的上拉电阻到 3.3V。买的 MPU6050 模块一般都已经集成上拉但这点一定要心里有数。3.2 底层宏定义与延时代码里用宏把引脚操作定义好后续读写函数就非常干净。我用的是直接操作 GPIO 数据寄存器的方式比反复调用 HAL_GPIO_WritePin 快很多时序也更好控制。#define MPU_SCL_PORT GPIOB #define MPU_SCL_PIN GPIO_PIN_6 #define MPU_SDA_PORT GPIOB #define MPU_SDA_PIN GPIO_PIN_7 #define SCL_H() MPU_SCL_PORT-BSRR MPU_SCL_PIN #define SCL_L() MPU_SCL_PORT-BRR MPU_SCL_PIN #define SDA_H() MPU_SDA_PORT-BSRR MPU_SDA_PIN #define SDA_L() MPU_SDA_PORT-BRR MPU_SDA_PIN #define SDA_READ() ((MPU_SDA_PORT-IDR MPU_SDA_PIN) ? 1 : 0)注意SDA 配置为开漏输出后SDA_H() 实际上是释放引脚而不是强推高所以读引脚前必须先执行 SDA_H()。而 SCL 是推挽输出SCL_H() 和 SCL_L() 都是直接驱动。延时函数推荐用 DWT 实现精度高且不影响中断响应。DWT 是 Cortex-M3 内核自带的调试观察点计数器F103 上可以直接用初始化只需要两行代码static void dwt_init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; } static void i2c_delay_us(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000); while ((DWT-CYCCNT - start) ticks); }搞定了 DWT 之后一个完整的起始信号加 8 位数据加应答整个时序周期都可以精确控制。如果嫌 DWT 麻烦用几层嵌套的空循环也能凑合但换编译器优化等级后延时变化很大我建议一步到位用 DWT。3.3 起始、停止和应答时序I2C 协议的起始信号SCL 保持高电平时SDA 从高电平跳变到低电平。停止信号相反SCL 保持高电平时SDA 从低电平跳变到高电平。这两个时序是 I2C 通信的起止标志必须严格遵守。static void i2c_start(void) { SDA_H(); SCL_H(); i2c_delay_us(5); SDA_L(); i2c_delay_us(5); SCL_L(); } static void i2c_stop(void) { SDA_L(); SCL_H(); i2c_delay_us(5); SDA_H(); i2c_delay_us(5); }应答信号的理解是这一步的关键。主机每发送完一个字节第 9 个时钟周期要释放 SDA然后从设备会主动将 SDA 拉低表示收到了数据。主机接收完一个字节后则是主机来决定是否发送应答发送应答表示还要继续读下一个字节发送非应答表示读完了从设备可以释放总线。3.4 字节读写实现发送一个字节的方法很直观从最高位开始依次把每一位放到 SDA 上每个位在 SCL 高电平期间数据必须保持稳定。判定 ACK 就发生在第 9 个时钟。static uint8_t i2c_write_byte(uint8_t data) { uint8_t i; for (i 0; i 8; i) { if (data 0x80) { SDA_H(); } else { SDA_L(); } data 1; SCL_H(); i2c_delay_us(2); SCL_L(); i2c_delay_us(2); } SDA_H(); // 释放总线准备读取从机应答 SCL_H(); i2c_delay_us(2); uint8_t ack SDA_READ(); // 1: NACK, 0: ACK SCL_L(); i2c_delay_us(2); return ack; }读取一个字节的逻辑正好相反。主机释放 SDA然后在每个 SCL 高电平期间去采样 SDA 的电平8 次采样组装成一个字节。读完最后一个字节后主机要发送 NACK 通知从机停止发送。static uint8_t i2c_read_byte(void) { uint8_t i, data 0; SDA_H(); // 释放 SDA交由从机控制 for (i 0; i 8; i) { SCL_H(); i2c_delay_us(2); data 1; if (SDA_READ()) { data | 0x01; } SCL_L(); i2c_delay_us(2); } return data; }这两个函数写完之后软件 I2C 的底层就齐全了。接下来只要在这个基础上封装寄存器读写函数就能跟 MPU6050 通信。调试时如果发现读回的数据不对优先检查底层时序的延时是不是太短。SCL 高电平保持时间至少要有 2~3 微秒太短了从机跟不上。4. 读写MPU6050初始化与数据解析4.1 寄存器读写封装有了字节收发底层接下来封装两个核心接口写单字节和读多字节。这两个函数会贯穿整个项目包括后续 DMP 库移植所以接口设计要考虑通用性。写单字节的流程是起始信号 → 发送设备写地址 → 等待 ACK → 发送寄存器地址 → 等待 ACK → 发送数据 → 等待 ACK → 停止信号。uint8_t MPU_Write_Byte(uint8_t reg, uint8_t data) { i2c_start(); if (i2c_write_byte(MPU_ADDR_W)) { i2c_stop(); return 1; } if (i2c_write_byte(reg)) { i2c_stop(); return 1; } if (i2c_write_byte(data)) { i2c_stop(); return 1; } i2c_stop(); return 0; }读多字节略微复杂因为要先写寄存器地址再重新发起一个起始信号即重复起始然后把设备地址改成读方向。连续读取时前面的字节要发 ACK最后一个字节发 NACK。uint8_t MPU_Read_Len(uint8_t reg, uint8_t *buf, uint8_t len) { uint8_t i; i2c_start(); if (i2c_write_byte(MPU_ADDR_W)) { i2c_stop(); return 1; } if (i2c_write_byte(reg)) { i2c_stop(); return 1; } i2c_start(); if (i2c_write_byte(MPU_ADDR_R)) { i2c_stop(); return 1; } for (i 0; i len - 1; i) { buf[i] i2c_read_byte(); i2c_send_ack(); // 非最后一字节主机回 ACK } buf[len - 1] i2c_read_byte(); i2c_send_nack(); // 最后一字节主机回 NACK i2c_stop(); return 0; }封装完成后读写任何寄存器都变成了一行代码的事。后面 DMP 库移植的时候只需要把这两个函数接到 InvenSense 那套接口里所有底层细节就都对上了。4.2 初始化配置详解MPU6050 的初始化大致分四步唤醒芯片、配置采样率、配置 DLPF、配置量程。顺序其实不重要但有几个关键点必须注意。先看完整的初始化函数void MPU6050_Init(void) { // 唤醒芯片清零 SLEEP 位 MPU_Write_Byte(0x6B, 0x00); // 采样率分频陀螺仪输出速率 1kHz MPU_Write_Byte(0x19, 0x07); // 配置 DLPF数字低通滤波开启 MPU_Write_Byte(0x1A, 0x01); // 陀螺仪量程 ±2000dps MPU_Write_Byte(0x1B, 0x18); // 加速度计量程 ±2g MPU_Write_Byte(0x1C, 0x00); }0x6B 寄存器清零是最关键的一步。芯片上电后默认处于睡眠模式如果不清除 SLEEP 位读出来的加速度和陀螺仪数据永远是 0。很多人调了半天读到的数据全是 0问题就出在这里。0x1B 寄存器写 0x18对应陀螺仪量程 ±2000dps满量程对应的灵敏度是 16.4 LSB/(deg/s)。这里可以根据项目需要改但量程越大灵敏度数值越小数据分辨率越低。平衡车这类场景 ±2000dps 够用手势识别要求更高的分辨率可以用 ±250dps对应的灵敏度是 131 LSB/(deg/s)。4.3 读取原始数据并转换为物理量读取六轴数据的核心就是连续读寄存器。加速度计从 0x3B 开始陀螺仪从 0x43 开始都是每轴 2 字节一共 6 字节。我习惯分两组各读 6 字节而不是一次读 14 字节这样逻辑更清晰。typedef struct { int16_t accel_x; int16_t accel_y; int16_t accel_z; int16_t gyro_x; int16_t gyro_y; int16_t gyro_z; } MPU6050_Data_t; void MPU6050_ReadRaw(MPU6050_Data_t *data) { uint8_t buf[6]; MPU_Read_Len(0x3B, buf, 6); >int i2c_write(unsigned char slave_addr, unsigned char reg_addr, unsigned char length, unsigned char const *data) { if (length 1) { MPU_Write_Byte(reg_addr, data[0]); } else { // 如果长度大于1连续写 uint8_t i; for (i 0; i length; i) { MPU_Write_Byte(reg_addr i, data[i]); } } return 0; } int i2c_read(unsigned char slave_addr, unsigned char reg_addr, unsigned char length, unsigned char *data) { MPU_Read_Len(reg_addr, data, length); return 0; }这里最容易翻车的是地址类型。inv_mpu.c 中传入的 slave_addr 是 8 位带读写位的地址也就是 0xD0 这种值。如果我们的 MPU_Write_Byte 内部用的宏是 0xD0那直接透传没有问题但如果你把 MPU_ADDR_W 定义成了 0x687 位地址这里就必须做转换。最简单的做法是在移植文件头部统一把 MPU_ADDR_W 和 MPU_ADDR_R 定义清楚同时在 i2c_write 函数入口加一个断言打印一眼地址值能省去后面大量排查时间。延时和日志函数也建议处理一下。inv_mpu.c 里用到了 delay_ms 和 log 相关函数在裸机工程中如果没实现会出现链接错误。直接在文件顶部把 log 相关的宏全部注释掉把 delay_ms 映射到自己的 HAL_Delay一步到位。#define delay_ms HAL_Delay #undef log_e #define log_e(...)5.3 添加文件到Keil工程与编译配置把上述文件添加进 Keil 工程时需要注意两点一是源文件要在左侧工程树中真实存在二是头文件的包含路径要配置到 C/C 选项卡的 Include Paths 中。很多 L6218E 链接错误都是因为源文件虽然放在了文件夹里但没有手动 Add Existing Files 到工程树导致编译时根本没生成对应的 .o 文件。链接阶段自然找不到符号定义。还有一个容易被忽略的点Keil 工程树里的文件默认是参与了编译的但如果之前的开发者不小心取消了某个文件前面的勾选也会出现类似问题。编译时如果报 DMP 相关文件的语法错误多半是源码里用了 GNU C 的扩展语法比如在表达式中间声明变量。Keil 默认的 C90 模式会报错把 C/C 选项卡里的 C99 Mode 勾上就可以解决。DMP 源码里还大量使用了 long 类型做定点数运算这个在 F103 上是 32 位完全兼容不用担心。5.4 DMP初始化与数据读取调用链移植完成后主程序里调用的逻辑非常固定。先用 mpu_init 初始化芯片再用 mpu_set_sensors 开启传感器配置 FIFO最后开启 DMP。mpu_init(); mpu_set_sensors(INV_XYZ_GYRO | INV_XYZ_ACCEL); mpu_configure_fifo(INV_XYZ_GYRO | INV_XYZ_ACCEL); mpu_set_sample_rate(100); mpu_set_dmp_state(1);数据读取通过 mpu_dmp_get_data 完成。这个函数会返回四元数和当时的传感器时间戳姿态角可以由四元数换算。#define q30 1073741824.0f float pitch, roll, yaw; long quat[4]; unsigned long sensor_timestamp; unsigned char sensor_num; unsigned char more; mpu_dmp_get_data(quat, sensor_timestamp, sensor_num, more); float q0 quat[0] / q30; float q1 quat[1] / q30; float q2 quat[2] / q30; float q3 quat[3] / q30; pitch asinf(-2 * q1 * q3 2 * q0 * q2) * 57.29578f; roll atan2f(2 * q2 * q3 2 * q0 * q1, -2 * q1 * q1 - 2 * q2 * q2 1) * 57.29578f; yaw atan2f(2 * (q1 * q2 q0 * q3), q0 * q0 q1 * q1 - q2 * q2 - q3 * q3) * 57.29578f;这里的 q30 是 DMP 库输出四元数的定点数比例因子因为 DMP 输出的是长整型定点数而姿态角计算要用浮点数。除以 2 的 30 次方后四元数就归一化到了 [-1, 1] 范围再套用欧拉角公式就能得到角度。读出来的 pitch、roll、yaw 单位是度。理论上 roll 和 pitch 在静止状态下应该非常接近 0yaw 会缓慢漂移这是陀螺仪积分导致的不可避免的现象。如果漂移速度特别快一块每秒漂好几度多半是 DMP 没有正常校准或者板子震动干扰太大。5.5 内存与中断的注意事项DMP 库运行起来会占用不少 RAM但 F103 的 20KB SRAM 完全够用。主要注意别在中断里调用 mpu_dmp_get_data因为 DMP 数据读取过程包含多次 I2C 读写整个流程耗时可能在几毫秒放在中断里会严重影响系统实时性。我一般的做法是在主循环里轮询读取或者用一个定时器标志位每 10 毫秒置一次标志主循环检测到标志后再去读 DMP 数据。这样既保证了数据更新的节奏又不会阻塞中断服务函数。堆栈方面默认的 1KB 栈基本够用但如果调用栈比较深建议在启动文件里把栈改到 2KB防止莫名的 HardFault。6. 经典错误与排查技巧6.1 L6218E 链接错误精讲标题里那个错误.\objects\project.axf: error: l6218e: undefined symbol mpu6050 (referred from...)是 Keil 环境下最经典的链接错误很多入门读者遇到后会一头雾水。翻译成人话就是代码里用到了某处定义为 mpu6050 的符号函数或变量但链接器在整个工程的所有编译产物里都找不到这个符号的定义。这种错误按顺序排查90% 的情况都能解决。先看工程树里有没有真正包含对应的 .c 文件特别是刚从外部文件夹复制进来的 DMP 库文件再看头文件里声明的函数名和源文件里定义的函数名是否完全一致下划线和大小写差一个字符都不行然后看目标文件是否成功生成如果 .c 文件有语法错误编译阶段就会报错根本到不了链接阶段。一个容易忽略的点是条件编译。有些源码里整个函数体外围被#ifdef包起来如果对应宏没有定义函数体就不会被编译自然产生 undefined symbol。在 inv_mpu.c 里#ifndef MPU6050这样的宏控制很多移植时多看几眼文件头部的宏定义说明。6.2 运行时读写异常速查表现象可能原因解决思路WHO_AM_I 读出 0xFFSDA 没接上拉、接线错误、从机地址错误检查上拉电阻、确认引脚定义和实际接线WHO_AM_I 读出 0x00芯片未供电、SCL/SDA 接反检查 3.3V 供电、交叉调换 SCL/SDA加速度和陀螺仪全 0芯片处于睡眠模式确认 0x6B 寄存器清除了 SLEEP 位数据随机跳动过大DLPF 未配置、模块固定不牢开启 DLPF、检查模块与主板之间是否松动DMP 输出姿态角漂移很快陀螺仪零偏未校准静止上电等待几秒或做零偏校正读 DMP 数据时程序卡死I2C 时序异常从机无响应用示波器/逻辑分析仪看 SDA 时序延长延时读写异常里最让人头疼的是那种偶尔成功、偶尔失败的情况。这种基本可以确定是时序余量不足把 i2c_delay_us 的延时从 2 微秒加到 5 微秒问题大概率会消失。I2C 总线对上升沿和下降沿有要求软件模拟时依靠延时保证时序满足规范延时留足余量非常关键。6.3 调试时最值得投资的两件事第一个是逻辑分析仪。几十块钱的 8 通道逻辑分析仪配合 PulseView 软件可以直接抓取 SCL 和 SDA 线上的波形对照 I2C 协议规范看起始位、停止位、数据位是否标准。我遇到过引脚接反的问题用万用表量半天没看出问题逻辑分析仪一抓波形立刻真相大白。第二个是串口打印。底层调通之后先用串口把 WHO_AM_I、加速度原始值、陀螺仪原始值全部打出来确认这些基础数据都正常了再去移植 DMP。不要一上来就奔着 DMP 姿态角去基础数据有问题时DMP 移植完也会得到一堆莫名其妙的结果。分阶段验证是嵌入式调试最重要的习惯。写在最后的一点经验这套软件 I2C 加 MPU6050 加 DMP 的方案我自己在不同项目里验证过很多次从最初做两轮平衡车到后来做过一个手势控制的机械臂用的都是同一套底层。最大的体会是软件 I2C 虽然名字听起来土但它在工程里带来的确定性和可控性比硬件外设强太多。特别是 MPU6050 这种对时序要求不算苛刻的传感器软件模拟的可靠性完全足够。如果你刚入门建议按这个顺序推进先把软件 I2C 的四个底层函数写出来用 WHO_AM_I 验证通信能稳定读到 0x68 之后再去做原始数据读取最后再去移植 DMP。每一步都有明确的验证标准卡住了也知道在哪一步能定位问题。等这套流程走通了你会对 I2C 协议有真正的理解后面再去用硬件 I2C 心里也会更有底气。