简介:面向嵌入式开发者的MPU6050模拟IIC读取数据完整工程包,基于STM32平台,解决无硬件I2C或引脚冲突时用GPIO软件模拟时序读取传感器数据的问题。工程共74个文件,包含33个头文件、32个C源文件,以及启动文件、HEX固件、Keil工程文件、辅助脚本和说明文档,整体约305KB,结构清晰,适合直接导入编译运行。资源已有388人浏览学习,适合初学I2C协议或正在做六轴姿态项目的开发者参考。包内不仅提供MPU6050驱动函数和寄存器操作示例,还完整实现模拟IIC的起始/停止/应答机制、加速度计与陀螺仪原始数据连续读取、高低字节合成与工程单位转换流程;readme与工程模板可帮助对照代码理解GPIO模拟I2C底层时序,并可直接作为后续卡尔曼滤波或四元数姿态解算的底层数据源,减少重复造轮子,在平衡车、机械臂、可穿戴设备等六轴惯性测量场景中具备直接复用价值。 搞嵌入式这几年,MPU6050这颗六轴传感器算是绕不开的熟面孔。这次项目里又遇到它,只不过主控的硬件IIC接口被另一块外设占满了,没法直接挂,只能腾出两个普通GPIO用模拟IIC来读数据。听起来“模拟IIC”像是什么高深技巧,拆开看其实就是用代码控制引脚电平,把IIC协议里的起始、停止、应答、字节传输一步步“画”出来。这个思路在很多资源紧张的单片机项目里非常实用,想用哪个引脚就用哪个,不受硬件外设限制。这篇文章我会把自己从外围电路到驱动代码,再到数据解析和中断应用的完整过程写出来,适合正在调MPU6050、又不想被硬件IIC卡住的朋友。
1. 为什么这个项目值得拆开聊
1.1 硬件IIC不够用的现实
很多STM32、ESP32、GD32都有硬件IIC外设,但真到项目里,你会发现引脚被复用、和别的外设冲突是家常便饭。尤其是在一块板子上要塞多个传感器、屏幕、存储芯片的时候,IIC、SPI、UART的引脚互相打架,硬件IIC经常让位给其他更重要的功能。硬件IIC虽然快,但也有一些老生常谈的坑,比如总线忙状态卡死、从机时钟拉伸处理不到位、不同内核的IIC模块行为不一致。我之前在某个系列单片机上用硬件IIC读MPU6050,开机偶发卡在Busy状态,硬件复位都没用,最后只能重新上电。改用模拟IIC之后,这种问题直接消失,因为时序完全由自己的代码控制,不会进入任何硬件状态机。
1.2 模拟IIC到底模拟了什么
模拟IIC的本质是“用GPIO电平变化还原IIC总线的协议波形”。IIC总线只有两根线:SCL和SDA。主机通过控制SCL的时钟边沿,配合SDA的高低电平,完成起始、停止、数据位、应答等操作。MPU6050作为从机,只做两件事:接收主机发来的寄存器地址,然后按地址返回或接收数据。换句话说,我要做的就是用代码把SCL和SDA两个引脚按照时序要求去翻转,并且在每个电平翻转之间插入合适的延时。这个延时决定了通信速率,通常模拟IIC跑100kHz-400kHz都没有问题。关键是要保证SCL高电平期间SDA不能变化,否则会被当成起始或停止信号。这个“时序正确性”正是模拟IIC的核心。
2. 从外围电路开始:硬件准备清单
2.1 接线方式与从机地址
先列一份最简接线表:
| 引脚 | 连接目标 | 说明 |
|---|---|---|
| VCC | 3.3V | MPU6050是3.3V器件,千万别直接接5V |
| GND | GND | 共地 |
| SCL | 任意GPIO | 本文选PA5作为SCL |
| SDA | 任意GPIO | 本文选PA6作为SDA |
| AD0 | GND | 接地时从机地址为0x68 |
| INT | 可选GPIO | 用于中断输出,如自由落体检测 |
MPU6050的从机地址只有两种,由AD0引脚决定。AD0接GND时地址是0x68,接3.3V时是0x69。换算成IIC总线上传输的8位地址,写地址是0xD0,读地址是0xD1。这块最容易搞混,因为很多代码里写的是0xD0而不是0x68,其实指的是同一条总线上发送的第一个字节。你在写驱动时最好把地址宏定义成0xD0,这样WriteByte里不用再做移位,不容易出错。
2.2 上拉电阻和去耦电容,别省
MPU6050模块成品板一般已经把上拉电阻、滤波电容焊好了,但如果你是自己画板子、直接用裸芯片,外围电路就省不得。IIC是开漏结构,SCL和SDA必须接上拉电阻,典型值4.7kΩ,总线距离长、速率高时可以换2.2kΩ,但不能太小,否则灌电流过大。电源引脚要放一个0.1μF陶瓷电容,靠近VCC引脚;如果是电机、舵机这些大电流负载共用的电源,最好再加一个10μF电解电容远处稳住电压。还有一个容易被忽略的点:VCC上电时序。MPU6050内部有个上电复位流程,如果VCC上升太慢或者有跌落,芯片可能进入异常状态,读寄存器全是0xFF。以前为了省钱省地方,我直接用单片机3.3V引脚给模块供电,一旦WiFi模组瞬时拉电流,MPU6050就“死机”,后来改成独立LDO供电,问题就再没出现过。
2.3 小技巧:逻辑分析仪必须上场
调试模拟IIC最怕“觉得自己时序对,结果芯片不理你”。我一直建议,USB逻辑分析仪是调IIC的神器,几十块钱的8通道款就够用,采样率24MHz以上完全能看清100kHz的IIC波形。把SCL和SDA两个通道接到逻辑分析仪上,然后跑一段读ID的代码,看波形是否符合IIC协议:起始条件、地址字节、应答位、数据字节。很多问题一眼就能看出来,比如SDA在SCL高电平期间变了,或者应答位没拉低,或者时钟频率太快导致从机没跟上。逻辑分析仪自带的协议解析功能还能直接把IIC帧解出来,对照寄存器地址看哪一步发错了。我个人的习惯是,每改一次时序参数就抓一次波形,对比波形抓问题比盲调代码快十倍。
3. 模拟IIC驱动的实现细节
3.1 起始、停止、应答,时序三件套
IIC通信的每个动作都有固定的电平变化顺序:
- 起始条件:SCL为高电平期间,SDA由高变低。表示总线开始被主机占用。
- 停止条件:SCL为高电平期间,SDA由低变高。表示通信结束。
- 数据位:SCL为低电平期间,主机改变SDA电平;SCL为高电平期间,SDA保持稳定,从机采样。
- 应答位:主机发送完8位后释放SDA,从机会在第9个时钟把SDA拉低表示应答;如果从机不应答,SDA保持高电平。
模拟IIC就是把这几个动作拆成函数。注意起始和停止条件里,SCL必须保持高电平,SDA跳变沿要在SCL为高的窗口内完成,这是协议上最容易出错的地方。很多人写代码时先拉SDA再拉SCL,顺序反了,总线就会误判。
3.2 代码骨架:一个可移植的软件IIC驱动
下面给出一套最精简的软件IIC驱动,我通常用STM32标准库的GPIO操作,换成其他平台只需要改电平操作和延时函数:
#define MPU6050_ADDR_W 0xD0 // AD0=0, 写地址 #define MPU6050_ADDR_R 0xD1 // AD0=0, 读地址 #define SCL_H() GPIO_SetBits(GPIOA, GPIO_Pin_5) #define SCL_L() GPIO_ResetBits(GPIOA, GPIO_Pin_5) #define SDA_H() GPIO_SetBits(GPIOA, GPIO_Pin_6) #define SDA_L() GPIO_ResetBits(GPIOA, GPIO_Pin_6) #define SDA_IN() { GPIOA->CRL &= ~(0xF << (6*4)); GPIOA->CRL |= (8 << (6*4)); } #define SDA_OUT() { GPIOA->CRL &= ~(0xF << (6*4)); GPIOA->CRL |= (3 << (6*4)); } #define SDA_READ() (GPIOA->IDR & GPIO_Pin_6) void IIC_Delay(void) { // 这里用简单的延时,约5us,对应约100kHz的SCL for (volatile uint8_t i = 0; i < 20; i++); } void IIC_Start(void) { SDA_OUT(); SDA_H(); SCL_H(); IIC_Delay(); SDA_L(); IIC_Delay(); SCL_L(); } void IIC_Stop(void) { SDA_OUT(); SCL_L(); SDA_L(); IIC_Delay(); SCL_H(); IIC_Delay(); SDA_H(); } uint8_t IIC_WriteByte(uint8_t data) { uint8_t i; SDA_OUT(); for (i = 0; i < 8; i++) { if (data & 0x80) SDA_H(); else SDA_L(); data <<= 1; IIC_Delay(); SCL_H(); IIC_Delay(); SCL_L(); } // 释放SDA,等待从机应答 SDA_IN(); IIC_Delay(); SCL_H(); IIC_Delay(); uint8_t ack = SDA_READ() ? 1 : 0; SCL_L(); return ack; } uint8_t IIC_ReadByte(void) { uint8_t i, data = 0; SDA_IN(); for (i = 0; i < 8; i++) { SCL_H(); IIC_Delay(); data <<= 1; if (SDA_READ()) data |= 0x01; SCL_L(); IIC_Delay(); } return data; } void IIC_SendAck(uint8_t ack) { SDA_OUT(); if (ack) SDA_H(); else SDA_L(); IIC_Delay(); SCL_H(); IIC_Delay(); SCL_L(); SDA_H(); // 释放总线 }这套代码有几个关键点:写字节时每个数据位都在SCL低电平期间设置SDA,然后在SCL高电平期间让从机采样;读字节时切换SDA为输入,从机在SCL低电平期间放数据,主机在SCL高电平期间采样。SDA方向的切换一定要准确,这是模拟IIC相对硬件IIC多出来的工作,漏了方向切换,读回来的数据就是错的。还有,读最后一个字节后主机要发送非应答,让从机知道“别发了”,否则从机下个字节还会继续输出,可能导致总线释放不及时。
3.3 读取MPU6050的完整流程与关键寄存器
MPU6050上电后不是立刻就能读加速度的,需要先做基本配置。一个最简配置流程是:
- 复位设备:向PWR_MGMT_1(0x6B)写入0x80,然后延时100ms。
- 退出睡眠:向PWR_MGMT_1写入0x00,选择内部时钟源。
- 设置采样率:写SMPLRT_DIV(0x19),例如0x07。
- 设置低通滤波:写CONFIG(0x1A),例如0x06。
- 设置陀螺仪量程:写GYRO_CONFIG(0x1B),例如0x00表示±250dps。
- 设置加速度计量程:写ACCEL_CONFIG(0x1C),例如0x00表示±2g。
对应的代码封装如下:
void MPU6050_WriteReg(uint8_t reg, uint8_t val) { IIC_Start(); IIC_WriteByte(MPU6050_ADDR_W); IIC_WriteByte(reg); IIC_WriteByte(val); IIC_Stop(); }读加速度数据的核心是连续读6个寄存器:ACCEL_XOUT_H(0x3B)到ACCEL_ZOUT_L(0x40)。读多字节时,IIC协议允许主机在收到每字节后回ACK,表示“继续发”,直到最后一个字节回NACK,然后发停止条件。
void MPU6050_ReadAccel(int16_t *ax, int16_t *ay, int16_t *az) { uint8_t buf[6]; IIC_Start(); IIC_WriteByte(MPU6050_ADDR_W); IIC_WriteByte(0x3B); IIC_Start(); // 重复起始 IIC_WriteByte(MPU6050_ADDR_R); for (int i = 0; i < 6; i++) { buf[i] = IIC_ReadByte(); if (i == 5) IIC_SendAck(1); // 最后一字节回NACK else IIC_SendAck(0); // 前面字节回ACK } IIC_Stop(); *ax = (int16_t)((buf[0] << 8) | buf[1]); *ay = (int16_t)((buf[2] << 8) | buf[3]); *az = (int16_t)((buf[4] << 8) | buf[5]); }注意我的代码里IIC_SendAck(1)代表非应答,因为参数里1对应SDA高电平。命名上容易绕,建议在项目里用IIC_SendNAck和IIC_SendAck两个宏代替,减少误解。重复起始条件的用法要熟练,读寄存器是“先写寄存器地址,再重复起始,再读数据”,整个过程不能发停止条件,否则MPU6050会认为一次独立通信结束,寄存器指针复位。
读角速度就是把起始寄存器换成0x43,读6个字节即可,逻辑完全一样。
4. 数据怎么用:从裸数据到物理量
4.1 加速度和角速度的换算
MPU6050的加速度和角速度输出都是16位有符号整数,但直接拿原始值来看是没有意义的,必须根据量程换算成物理量。加速度的灵敏度是:±2g时16384 LSB/g,±4g时8192 LSB/g,±8g时4096 LSB/g,±16g时2048 LSB/g。角速度的灵敏度是:±250dps时131 LSB/dps,±500dps时65.5 LSB/dps,±1000dps时32.8 LSB/dps,±2000dps时16.4 LSB/dps。换算就是把原始值除以对应灵敏度。比如量程±2g,读到加速度X原始值256,那么ax = 256/16384 = 0.0156g,也就是约15.6mg。静止放置时,理论上三轴加速度模值应该是1g,但你会看到Z轴大约1g,X、Y轴接近0,这是检查传感器是否正常的最直观方法。
4.2 温度数据与DMP的一点说明
温度寄存器是TEMP_OUT_H(0x41)和TEMP_OUT_L(0x42),换算公式是:温度(℃) = 原始值/340 + 36.53。这里不做过多展开,但要注意温度和加速度、角速度在同一个IIC总线上读,所以流程完全一样。至于DMP(数字运动处理器),很多教程喜欢用官方MotionDriver库来读四元数,省去姿态解算的麻烦。但DMP库本身对IIC时序敏感,模拟IIC跑DMP也不是不行,只是读取频率太高时CPU占用会变大。如果只是做倾角测量、动作检测这类简单应用,自己读原始六轴数据然后做互补滤波或Mahony滤波,完全够用,没必要一上来就上DMP。
4.3 自由落体中断:让MPU6050自己“喊救命”
MPU6050内置自由落体检测功能,也就是检测到三轴加速度模值低于某个阈值且持续时间足够长时,会拉低INT引脚。这个功能非常适合跌落检测、防摔报警场景。需要配置三个寄存器:FF_THR(0x1D)设置自由落体阈值,FF_DUR(0x1E)设置持续时间,INT_ENABLE(0x38)使能中断。这两个阈值寄存器的设置不能照抄公式,因为实际触发条件和传感器的安装方式、校准结果都有关系,实践上先给一组中间值,比如FF_THR=0x10、FF_DUR=0x20,然后用逻辑分析仪或者串口打印中断状态寄存器INT_STATUS(0x3A),反复做跌落测试微调。中断输出是推挽模式,可以直接接主控的外部中断引脚。模拟IIC在这里完全不影响中断功能,因为中断是硬件引脚直接输出的,和通信协议无关。我实际测过,把MPU6050从10cm高处自由落体,INT引脚能稳定触发,响应很快。
5. 踩坑实录:常见问题与排查方法
5.1 读取数据始终是0xFF
如果MPU6050读出来的寄存器值全是0xFF,大概率是地址不对、IIC总线没通或者芯片没正常工作。第一步查供电,VCC是不是3.3V,电源纹波大不大;第二步查接线,SDA和SCL有没有接反,AD0有没有虚焊;第三步查上拉电阻,没有上拉的话SDA无法被拉低,主机读到的永远是高电平,也就是0xFF;第四步查从机地址,确认0x68还是0x69。还有一个容易被忽略的点:有些模块板上的上拉电阻已经焊好,但如果你自己飞线出来,线太长、接触不良,也会导致通信不稳定。这个时候逻辑分析仪最有用,直接看波形上有没有地址应答,如果没有应答,说明芯片根本没回应主机。
5.2 数据飘移和跳变
数据飘移有几个常见原因。一是传感器没有固定好,用手捏着模块和用螺丝固定,读出来的零漂完全不一样;二是量程没设置对,比如代码里写的是±2g,但寄存器配置实际是±16g,换算出来数值自然不对;三是电源噪声,MPU6050对电源纹波比较敏感,如果和电机共用一个电源,读出来的数据会周期性跳变,解决办法是加LC滤波或者用独立LDO;四是IIC时序不稳,SCL电平翻转速度太慢或者太快,导致偶尔读错字节。遇到数据跳变,建议先看原始字节,再换算物理量,一步一步定位。我常干的事是连续读500帧,打原始值,看看是随机跳还是固定跳,固定跳多半是寄存器配置写错了。
5.3 时序、任务调度与性能的平衡
模拟IIC最大的缺点是占用CPU。读一次加速度6字节,加上起始、地址、应答,至少需要几十次GPIO操作,如果每字节还加延时,一次完整读取可能要几百微秒。在裸机循环里没问题,但在RTOS里,如果高优先级任务频繁打断IIC时序,会导致SDA和SCL的电平变化被切片,MPU6050就会回应乱码。解决办法有几个:一是把IIC读取放在临界区,关中断读数据,读完后马上开中断,牺牲一点实时性保通信正确;二是降低读取频率,MPU6050内部FIFO可以缓存数据,不需要每毫秒都去读;三是把读取任务放在低优先级,并保证其他任务不会长时间阻塞。如果项目的IIC设备多、数据量大,那就老老实实把硬件IIC和外设引脚重新规划,别硬用模拟IIC。
最后再分享一个我自己的习惯:不管模拟IIC还是硬件IIC,第一版驱动里一定加一个WHO_AM_I寄存器(0x75)的读取,MPU6050正常返回0x68。只要这个值读对了,后面所有复杂功能都顺理成章;读不对,就先别往下写。
本文还有配套的精品资源,点击获取