十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

GPIO模拟I2C读取MPU6050:从时序到代码的完整实现

GPIO模拟I2C读取MPU6050:从时序到代码的完整实现 简介这是一份基于STM32的MPU6050模拟I2C读取工程源码包面向嵌入式入门开发者以及需要在无硬件I2C条件下完成传感器数据采集的工程师。资源围绕软件模拟I2C提供完整可编译的Keil工程包含MPU6050驱动、模拟I2C时序、延时与串口等底层模块解决时序模拟、寄存器读写与原始数据解析等问题。压缩包共74个文件以C源码32个和头文件33个为主辅以启动文件、Keil工程文件及可直接烧录的HEX固件整体约305KB目录结构清晰便于移植和二次开发。已有388人学习下载适合入门参考。通过阅读与调试该工程读者可掌握GPIO模拟SCL/SDA时钟与数据线、连续读取加速度计和陀螺仪数据寄存器、并将原始二进制值转换为g与度/秒工程单位的完整思路为姿态解算与运动追踪项目提供实用基础。 说实话我第一次看到mpu6050模拟iic读数据这个需求的时候第一反应是现在还有人这么折腾但仔细一想做嵌入式的大概率都遇到过——MCU硬件I2C引脚被别的外设占了或者某些国产芯片的硬件I2C跑起来死活不稳定又或者就是想省一路外设资源把时序腾出来。这种情况下用两个普通GPIO手动拉时序去读MPU6050就成了最靠谱的兜底方案。这篇东西不聊高大上的架构就把GPIO模拟I2C读MPU6050这件事从头到尾掰开揉碎为什么需要模拟、时序到底怎么走、代码怎么写、数据怎么换算、哪些坑我替你们先踩了。适合刚接触传感器驱动、在STM32/51/ESP32等平台上想快速读通MPU6050的人也适合被硬件I2C折磨到想换方案的老手参考。1. 为什么折腾模拟IIC硬件I2C不够用的真实场景1.1 你什么时候真的需要模拟IIC先泼盆冷水如果你的MCU硬件I2C外设闲着、引脚也没被占用那老老实实用硬件I2C就行别为了炫技去模拟。我之所以认真写过一版模拟IIC驱动是被一个实际项目逼出来的——主控的I2C1挂了OLED和一颗温湿度传感器I2C2引脚又被串口复用占掉了MPU6050只能放在两个普通IO上。这个场景在资源紧张的单片机上非常典型。另一个高频场景是某些MCU的硬件I2C确实难用。比如有的芯片硬件I2C在主从通信中偶发总线卡死SCL被外部拉低后外设就罢工了只能重启总线。用GPIO模拟的话时序完全由自己控制出了问题手动拉一次停止位就能释放总线调起来非常方便。还有一类场景是低成本小封装MCU引脚少到RTC和传感器要共用引脚。模拟IIC可以把任意引脚在普通IO和I2C总线之间切换做成动态复用的方案。之前一个电池供电的小采集器就是靠这招省了一颗引脚、避免了硬件I2C的待机电流问题。1.2 模拟IIC和硬件I2C的取舍对比项硬件I2CGPIO模拟IIC引脚占用固定引脚不可随意改任意GPIO灵活时序稳定性硬件保证极少出错依赖软件延时需调试通信速度通常支持400kHz甚至1MHz受延时函数影响100kHz~400kHz常见扩展性可接多个从机带DMA多从机也能接但每个设备都要自己维护时序调试难度出问题不好查内部状态逻辑分析仪直接看IO问题一目了然代码移植性依赖MCU外设库纯GPIO操作跨平台容易移植我个人的倾向是驱动一个MPU6050这种固定从机模拟IIC完全够用而且调试起来比硬件I2C更直观。因为IO口的波形就是你代码里的每一个高低电平逻辑分析仪一抓就能定位问题。硬件I2C一旦卡死在内部状态机里反而不如模拟的好排查。2. I2C读写MPU6050的时序拆解从起始位到连续读模拟IIC的核心不是代码是时序。只要你理解了I2C协议的几个基础动作代码就是照抄时序而已。这一节我尽量用大白话讲清楚。2.1 起始位、停止位、ACK先把地基打牢I2C只有两根线SCL时钟和SDA数据。MPU6050作为从机它所有动作都跟着SCL走。一个最直观的理解方式SCL是高电平的时候SDA上的电平是有效数据SCL由高变低的时候SDA可以切换电平准备下一个数据位。起始位SCL保持高电平时SDA从高电平跳变到低电平。相当于说我要开始通信了。停止位SCL保持高电平时SDA从低电平跳变到高电平。相当于说通信结束。数据位每个字节8位高位在前。SCL低电平时SDA准备数据SCL高电平时从机采样SDA的电平。ACK应答每发送完8位数据后第9个时钟周期由接收方拉低SDA表示我收到了。发送方在这个周期要释放SDA然后采样它是不是被拉低了。如果没被拉低NACK说明从机可能忙、地址不对或寄存器错误。这里最容易犯迷糊的就是把起始位和停止位记反。教你一个记忆方法从机是靠SDA在SCL高电平期间的跳变来识别起始和停止的。平时总线空闲时SCL和SDA都是高电平。SDA先降下来是我要开始SDA再升回去是我结束了。2.2 MPU6050读寄存器的一次完整旅程MPU6050工作在从机模式地址是7位默认0x68。I2C通信时要把地址左移一位最低位表示方向0表示写1表示读。所以通信中实际发送的字节是0x68 1 0xD0 作为写地址0x68 1 | 0x01 0xD1 作为读地址读MPU6050某个寄存器的标准流程我拆成三步发送起始位然后发送写地址0xD0等从机ACK。发送要读的寄存器地址比如0x3B加速度X轴高字节等ACK。发送一个重复起始位Repeated Start然后发送读地址0xD1等ACK。这时MPU6050会沿着SDA把寄存器数据推出来主机在第9个时钟采样。如果你是连续读多个寄存器比如读加速度的6个字节X、Y、Z各2字节MPU6050会自动把寄存器地址递增你只需要在读完每个字节后给出ACK最后一个字节回NACK表示够了然后发停止位。这是MPU6050一个很关键的特性寄存器地址在连续读时自动加1不需要反复发寄存器地址。补充一个小细节并不是所有I2C读操作都需要重复起始位。有些时序写完之后直接发停止位再发起新的读操作也兼容但标准流程用重复起始位更可靠省去了总线释放再竞争的过程。模拟IIC实现重复起始位其实就是再调一次起始位函数成本极低。3. 手写模拟IIC驱动引脚、延时、收发字节的完整代码3.1 引脚规划与模式选择开漏输出是最省心的选引脚的时候理想的情况是选两个支持外部中断的IO口不绝对需要但以后要接MPU6050的INT中断引脚时方便并且板子上这两根线各接一个4.7kΩ左右的上拉电阻到VCC。MPU6050模块一般板载上拉如果你是裸片自己画的电路这个上拉电阻绝对不能省后面专门讲。GPIO模式我强烈建议把SDA配成开漏输出Open-Drain。开漏输出的好处是写1时引脚实际是释放状态总线被上拉电阻拉高写0时引脚拉低。这样天然实现了释放SDA这个动作读数据时根本不需要切换GPIO方向直接读输入电平即可。STM32上用HAL库配置如下GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin MPU6050_SDA_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; // 开漏输出 GPIO_InitStruct.Pull GPIO_PULLUP; // 内部上拉配合外部上拉更稳 GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(MPU6050_SDA_PORT, GPIO_InitStruct); GPIO_InitStruct.Pin MPU6050_SCL_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; // SCL用推挽输出速度更快 GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(MPU6050_SCL_PORT, GPIO_InitStruct);SCL用推挽输出因为时钟线不需要从机拉低MPU6050不支持时钟伸展clock stretching这种花活推挽能保证上升沿干净。SDA用开漏省去方向切换读取时直接HAL_GPIO_ReadPin就能读到从机拉低后的电平读写两不误。如果你非要用推挽输出那必须在读数据前把SDA切换成输入模式、读完之后再切回输出模式多几个步骤还容易漏没必要。延时函数我习惯用简单的空循环。主频不同、编译器优化等级不同空循环的实际时间差别很大所以建议用逻辑分析仪实测波形把循环次数调到SCL高电平宽度在1.3us以上即可400kHz快速模式要求低电平最小1.3us。下面是个参考static void IIC_Delay(void) { volatile uint32_t i 20; while (i--) { __NOP(); } }3.2 核心函数start、stop、写字节、读字节代码我按最简结构来写方便移植到其他平台。宏定义好引脚之后四个基础函数就够用了。#define MPU6050_SCL_H() HAL_GPIO_WritePin(MPU6050_SCL_PORT, MPU6050_SCL_PIN, GPIO_PIN_SET) #define MPU6050_SCL_L() HAL_GPIO_WritePin(MPU6050_SCL_PORT, MPU6050_SCL_PIN, GPIO_PIN_RESET) #define MPU6050_SDA_H() HAL_GPIO_WritePin(MPU6050_SDA_PORT, MPU6050_SDA_PIN, GPIO_PIN_SET) #define MPU6050_SDA_L() HAL_GPIO_WritePin(MPU6050_SDA_PORT, MPU6050_SDA_PIN, GPIO_PIN_RESET) #define MPU6050_SDA_READ() HAL_GPIO_ReadPin(MPU6050_SDA_PORT, MPU6050_SDA_PIN) void IIC_Start(void) { MPU6050_SDA_H(); MPU6050_SCL_H(); IIC_Delay(); MPU6050_SDA_L(); // SCL高时SDA拉低产生起始位 IIC_Delay(); MPU6050_SCL_L(); // 拉低SCL准备传输数据 IIC_Delay(); } void IIC_Stop(void) { MPU6050_SDA_L(); MPU6050_SCL_H(); IIC_Delay(); MPU6050_SDA_H(); // SCL高时SDA拉高产生停止位 IIC_Delay(); } uint8_t IIC_WaitAck(void) { uint8_t ack_err 0; MPU6050_SDA_H(); // 释放SDA让从机控制 IIC_Delay(); MPU6050_SCL_H(); IIC_Delay(); if (MPU6050_SDA_READ()) { // 若SDA仍为高则没有收到ACK ack_err 1; } MPU6050_SCL_L(); IIC_Delay(); return ack_err; } void IIC_WriteByte(uint8_t data) { for (uint8_t i 0; i 8; i) { if (data 0x80) { MPU6050_SDA_H(); } else { MPU6050_SDA_L(); } data 1; IIC_Delay(); MPU6050_SCL_H(); // SCL高电平期间从机采样SDA IIC_Delay(); MPU6050_SCL_L(); IIC_Delay(); } } uint8_t IIC_ReadByte(uint8_t ack) { uint8_t data 0; MPU6050_SDA_H(); // 释放SDA准备读取 for (uint8_t i 0; i 8; i) { data 1; MPU6050_SCL_H(); IIC_Delay(); if (MPU6050_SDA_READ()) { data | 0x01; } MPU6050_SCL_L(); IIC_Delay(); } if (ack) { MPU6050_SDA_L(); // 主机拉低SDA发送ACK } else { MPU6050_SDA_H(); // 主机释放SDA发送NACK } IIC_Delay(); MPU6050_SCL_H(); IIC_Delay(); MPU6050_SCL_L(); IIC_Delay(); MPU6050_SDA_H(); return data; }这里IIC_WriteByte发完8位数据后需要紧接着调用IIC_WaitAck()来获取从机应答这个组合要记牢。IIC_ReadByte则是读取完成后再由主机发送ACK/NACK末尾参数ack1表示继续读下一字节ack0表示这是最后一字节。3.3 先读WHO_AM_I验证通路驱动写完后不要急着读加速度先读MPU6050的WHO_AM_I寄存器地址0x75。正常情况下它会返回固定值0x68这是芯片出厂写死的身份标识。这一步能同时验证接线对不对、设备地址对不对、I2C时序对不对。代码组合如下uint8_t MPU6050_ReadReg(uint8_t reg) { uint8_t data 0; IIC_Start(); IIC_WriteByte(0xD0); // 写地址 if (IIC_WaitAck()) { goto err; } IIC_WriteByte(reg); // 寄存器地址 if (IIC_WaitAck()) { goto err; } IIC_Start(); // 重复起始位 IIC_WriteByte(0xD1); // 读地址 if (IIC_WaitAck()) { goto err; } data IIC_ReadByte(0); // 读1字节发送NACK IIC_Stop(); return data; err: IIC_Stop(); return 0xFF; } // 主程序里验证 uint8_t who MPU6050_ReadReg(0x75); if (who 0x68) { // 通路OK } else { // 检查接线、地址、上拉电阻 }如果读出来的不是0x68优先检查AD0引脚是不是接地了接GND是0x68接VCC是0x69SDA/SCL是不是接反了上拉电阻有没有焊MCU的GPIO是不是真的配成了开漏/推挽。4. 对接MPU6050初始化寄存器、读加速度和陀螺仪、数据换算4.1 初始化从睡眠中唤醒配置量程MPU6050上电后默认处于睡眠模式不关掉的话读出来的数据全是0。需要往电源管理寄存器PWR_MGMT_1地址0x6B写入0x00让它退出睡眠。同时建议把量程、数字低通滤波器DLPF这些都配好避免后面读出的数据用起来不对。下面是常用寄存器速查表寄存器地址名称说明默认值0x6BPWR_MGMT_1电源管理写0x00退出睡眠0x400x19SMPLRT_DIV采样率分频器配置内部采样率0x000x1ACONFIGDLPF带宽配置写0x06对应5Hz0x000x1BGYRO_CONFIG陀螺仪量程写0x00对应±250dps0x000x1CACCEL_CONFIG加速度计量程写0x00对应±2g0x000x3B~0x40ACCEL_*加速度计数据6字节-0x41~0x42TEMP_OUT_*温度数据2字节-0x43~0x48GYRO_*陀螺仪数据6字节-0x75WHO_AM_I身份标识固定返回0x680x68初始化函数就是往这几个寄存器写值void MPU6050_Init(void) { MPU6050_WriteReg(0x6B, 0x00); // 退出睡眠使用内部8MHz振荡器 MPU6050_WriteReg(0x19, 0x07); // 采样率分频1kHz / (17) 125Hz MPU6050_WriteReg(0x1A, 0x06); // DLPF开启加速度带宽5Hz陀螺仪带宽5Hz MPU6050_WriteReg(0x1B, 0x00); // 陀螺仪满量程 ±250dps MPU6050_WriteReg(0x1C, 0x00); // 加速度计满量程 ±2g }其中MPU6050_WriteReg是写寄存器的组合函数和读函数类似流程是start 写地址 寄存器地址 要写的数据 stop每一步之间都要等ACK代码就不再贴了套用前面的基础函数拼起来即可。DLPF这个寄存器很多人忽略。MPU6050内部采样率是1kHz或8kHz通过DLPF可以把输出数据做低通滤波。如果你做姿态解算但数据一直抖多半是DLPF没配好。先用0x06把带宽压到5Hz适合静止或慢速运动做四轴这种快速响应场景建议配0x01184Hz或0x0294Hz看你的控制周期需要多快的响应。4.2 读取加速度和陀螺仪并换算读加速度数据从0x3B开始连续读6字节读陀螺仪从0x43开始连续读6字节。每轴数据是16位有符号数高字节在前。组合函数如下uint8_t MPU6050_ReadRegs(uint8_t reg, uint8_t *buf, uint8_t len) { IIC_Start(); IIC_WriteByte(0xD0); if (IIC_WaitAck()) { goto err; } IIC_WriteByte(reg); if (IIC_WaitAck()) { goto err; } IIC_Start(); IIC_WriteByte(0xD1); if (IIC_WaitAck()) { goto err; } for (uint8_t i 0; i len; i) { buf[i] IIC_ReadByte(i (len - 1) ? 0 : 1); } IIC_Stop(); return 0; err: IIC_Stop(); return 1; } void MPU6050_ReadData(int16_t *accel, int16_t *gyro) { uint8_t buf[6] {0}; MPU6050_ReadRegs(0x3B, buf, 6); accel[0] (int16_t)((buf[0] 8) | buf[1]); // ACCEL_X accel[1] (int16_t)((buf[2] 8) | buf[3]); // ACCEL_Y accel[2] (int16_t)((buf[4] 8) | buf[5]); // ACCEL_Z MPU6050_ReadRegs(0x43, buf, 6); gyro[0] (int16_t)((buf[0] 8) | buf[1]); // GYRO_X gyro[1] (int16_t)((buf[2] 8) | buf[3]); // GYRO_Y gyro[2] (int16_t)((buf[4] 8) | buf[5]); // GYRO_Z }读出来的raw值不是物理量要根据量程折算。满量程±2g时加速度计灵敏度是16384 LSB/g满量程±250dps时陀螺仪灵敏度是131 LSB/(dps)。换算公式float accel_g[3], gyro_dps[3]; for (int i 0; i 3; i) { accel_g[i] accel[i] / 16384.0f; // g gyro_dps[i] gyro[i] / 131.0f; // 度/秒 }其他量程对应的灵敏度量程加速度灵敏度 LSB/g量程陀螺仪灵敏度 LSB/(dps)±2g16384±250dps131±4g8192±500dps65.5±8g4096±1000dps32.8±16g2048±2000dps16.4如果改过0x1B和0x1C量程记得同步换灵敏度。这里最常出现的错误是读出来的angula数据一直是0但加速度正常——多半是睡眠没退出或者陀螺仪寄存器地址写错成0x43以外的地址了。5. 调试中的高频翻车现场SDA方向、上拉电阻、地址这三个坑5.1 SDA方向与开漏释放读不到数据的第一大元凶我调试模拟IIC遇到的第一个玄学问题就是读WHO_AM_I永远返回0xFF。波形用逻辑分析仪看完全正确起始位、停止位、8个时钟、地址都对但每次都是从机不拉低SDA主机收不到ACK。后来发现是GPIO方向的问题。如果用推挽输出模式SDA在等待ACK阶段被MCU一直驱动为高电平从机根本拉不动它于是主机永远读不到ACK。解决办法有一个半半个是上面说的换开漏输出一个就是把SDA在每次读之前切换成输入模式。我用开漏输出后这类问题再没出现过强烈建议读者照抄这个方案。如果你手里的库对开漏支持不好那就老老实实做方向切换void SDA_Set_Input(void) { /* 配置SDA为输入模式 */ } void SDA_Set_Output(void) { /* 配置SDA为输出模式 */ } // 在IIC_ReadByte函数开头调用SDA_Set_Input() // 在发送ACK/NACK之前调用SDA_Set_Output()方向切换必须是先切换、再操作这个顺序错一个函数都会出问题。5.2 上拉电阻没它I2C就是废的I2C总线是开漏结构高电平靠上拉电阻提供。有的MCU内部上拉能勉强工作但在长线传输、电磁干扰多的环境下就会出现间歇性读错数据。MPU6050模块通常自带上拉但如果你用的是自己打的板记得在SCL和SDA上各接一个4.7kΩ或10kΩ电阻到VCC。电阻太小会增大总线灌电流太大则上升沿变缓影响高速通信。判断是否缺上拉的一个技巧把SDA设为输入模式后用万用表量SDA对地电压。如果悬空时接近0V说明没有上拉或者上拉电阻没焊接正常情况下应该量到VCC电平。这个在板子一上电、MCU还没初始化GPIO时测最准。5.3 地址不对、WHO_AM_I对不上MPU6050的I2C地址由AD0引脚电平决定接地是0x68常用接VCC是0x69。很多模块把AD0引出来用户不小心跳线帽接到了高电平然后程序仍然用0x68去读自然读不到。判断方法就是前面说的先读WHO_AM_I寄存器连0x68和0x69都试一遍看哪个能读到正常返回值。另外要注意有些兼容芯片比如MPU6500、ICM-20600这些的WHO_AM_I返回值不一定等于0x68可能有0x70、0x71这类值。如果你用的是非原厂MPU6050模块读到这些值不代表失败查一下对应芯片手册确认即可。我见过有人拿MPU6500模块按MPU6050的地址读能通信但数据读出全是FF最后发现是寄存器地址不同芯片要按InvenSense新系列的高字节寄存器方式访问。5.4 延时太小导致的间歇性故障模拟IIC最容易翻车的地方其实是延时。延时太大通信速率掉到几十kHz功能没问题但系统感觉卡延时太小SCL高电平宽度不够从机采样就会出现十次里有一次数据不对这种间歇性问题。排查方法是用逻辑分析仪看波形中SCL高电平的宽度对比MPU6050数据手册的时序要求标准模式100kHz下SCL高电平最小4us快速模式400kHz下最小1.3us。我的经验是SCL高电平做到2us左右跑400kHz不会出问题给延时留出余量又不至于太慢。调试时还有个小技巧把失败信息用串口打出来配合重试机制。比如读数据连续失败3次就重新初始化一次I2C总线发一个停止位再重新开始这能在偶发故障时自动恢复省去人工复位。我在产品里就是用了读WHO_AM_I校验 失败重试这套长期运行下来偶发率基本为零。最后再分享一个小技巧如果你用的平台有多组模拟IIC需求可以把代码里的宏定义参数化成结构体一个结构体存一组SCL/SDA的端口和引脚号这样复制一份代码就能支持多路MPU6050或者多颗I2C从机。我自己在STM32上就是这么封装的从51移植过来的代码改成这种结构体方式后后续加传感器都是半小时搞定的事。模拟IIC这技术看起来死板但真到了产品上它是解决资源冲突和硬件I2C不稳定的那把钥匙。本文还有配套的精品资源点击获取
返回列表