1. 为什么还要软件模拟I2C1.1 硬件I2C的痛点在哪里刚入行那会儿我对I2C的理解就是“两根线挂一堆设备”觉得这东西简单得不行。直到有一次用某款国产MCU做项目硬件I2C死活调不通示波器抓波形发现时钟拉伸处理有问题换了两版芯片都一样。项目周期压得紧最后只能临时改成软件模拟结果半天就跑通了。从那以后我养成了一个习惯不管什么平台手里始终备一份软件模拟I2C的代码。硬件I2C的问题主要集中在几个方面。第一是芯片差异大不同厂商的I2C外设寄存器设计、时序参数、错误处理机制都不一样移植起来很头疼。第二是灵活性差硬件I2C的引脚是固定的PCB画错了或者引脚冲突了基本没救。第三是调试困难硬件I2C出问题的时候你很难知道它到底卡在哪一步是起始条件没发出去还是ACK没收到寄存器状态位看半天也看不明白。软件模拟I2C就不一样了。任意两个GPIO都能当I2C用引脚随便换PCB改板不用重新画。时序完全可控你想让它快就快想让它慢就慢遇到时序要求苛刻的器件微调一下延时就行。调试直观每一步都可以打印日志或者翻转一个调试引脚用示波器看问题定位非常快。当然软件模拟也有代价。它占用CPU时间因为每个时钟沿都需要CPU去翻转GPIO。在高速通信场景下比如400kHz以上CPU开销会比较明显。但对于大多数传感器读取场景100kHz左右这点开销完全可以接受。而且很多传感器本身就不支持高速模式用软件模拟反而更稳妥。1.2 软件模拟I2C适合哪些场景我总结了几类特别适合用软件模拟I2C的情况。第一类是引脚紧张的板子硬件I2C引脚被其他功能占用了或者PCB布局导致I2C走线不方便这时候随便找两个空闲GPIO就能解决问题。第二类是多设备挂载场景比如一个系统里挂了七八个I2C传感器地址还有冲突用软件模拟可以分组管理一组一个I2C总线互不干扰。第三类是时序要求特殊的器件有些国产传感器或者老型号EEPROM对起始保持时间、数据建立时间有特殊要求硬件I2C的时序参数是固定的改不了软件模拟就灵活多了。第四类是学习和调试阶段你想搞清楚I2C到底怎么通信的用软件模拟一步步打印出来比看硬件手册直观一百倍。还有一类容易被忽略的场景跨平台移植。你写好的软件模拟I2C代码从STM32搬到ESP32从GD32搬到华大基本上只需要改几个宏定义GPIO操作函数换一下就行。硬件I2C的代码移植那得重写驱动层。1.3 软件模拟I2C的核心思路软件模拟I2C的本质就是用GPIO的电平变化来模拟I2C总线的时序。I2C总线有两条线SCL时钟线和SDA数据线。所有的通信都是通过这两条线的电平组合和变化来实现的。核心操作就四个起始条件Start、停止条件Stop、发送一个字节SendByte、接收一个字节ReceiveByte。起始条件是SCL高电平期间SDA从高变低停止条件是SCL高电平期间SDA从低变高。发送字节就是逐位把数据放到SDA上每放一位就产生一个SCL时钟脉冲。接收字节类似只不过SDA的方向变成输入在每个SCL高电平期间读取SDA的电平。这里有一个关键点SDA和SCL都需要配置为开漏输出Open-Drain或者至少在输出低电平时能拉低输出高电平时靠外部上拉电阻拉高。如果GPIO配置成推挽输出总线上多个设备同时输出高电平就会短路。开漏输出配合上拉电阻才能实现“线与”逻辑这是I2C总线能挂多个设备的基础。注意如果你用的GPIO不支持开漏输出可以在输出高电平时把引脚切换成输入模式高阻态靠外部上拉电阻把电平拉高。很多软件模拟I2C的代码就是这么做的效果一样。2. I2C协议时序拆解与参数计算2.1 起始条件和停止条件的精确时序起始条件Start Condition的定义是在SCL为高电平期间SDA从高电平跳变到低电平。这个跳变被总线上的所有设备识别为通信开始的信号。停止条件Stop Condition则相反在SCL为高电平期间SDA从低电平跳变到高电平。看起来很简单但实际操作中有几个细节容易踩坑。第一起始条件之前必须确保总线是空闲的。空闲状态的定义是SCL和SDA都为高电平。如果上一次通信异常中断SDA可能被某个设备拉低这时候你直接发起始条件总线上的设备可能识别不到。我的做法是在初始化的时候先手动发送几个SCL脉冲通常9个把总线“抖”干净让所有设备释放SDA。第二起始条件的建立时间和保持时间。以标准模式100kHz为例起始条件的建立时间SDA下降沿到SCL下降沿最小是4.0微秒保持时间SCL下降沿到SDA变化最小也是4.0微秒。快速模式400kHz下这两个参数分别是0.6微秒。软件模拟的时候这些时间靠延时函数来保证。第三停止条件之后需要给总线足够的恢复时间。I2C规范里叫“总线空闲时间”Bus Free Time标准模式下最小4.7微秒快速模式下1.3微秒。如果你连续读写多个设备中间不加延时可能会因为总线没恢复导致下一个起始条件失败。我一般会在代码里定义几个宏来控制这些延时#define I2C_DELAY() do { \ for (volatile int i 0; i I2C_DELAY_CNT; i); \ } while(0)I2C_DELAY_CNT的值需要根据你的CPU主频来调整。比如72MHz的STM32一个空循环大概几个时钟周期要产生4微秒的延时大概需要循环几十次。这个值不用特别精确宁大勿小稍微慢一点没关系太快了反而容易出问题。2.2 数据位的建立时间和保持时间发送一个数据位的过程是这样的先把SDA设置成要发送的电平然后拉高SCL保持一段时间再拉低SCL。接收方在SCL高电平期间读取SDA的电平。这里的关键参数是数据建立时间Data Setup Time和数据保持时间Data Hold Time。建立时间是指SDA变化到SCL上升沿之间的时间标准模式下最小250纳秒快速模式下100纳秒。保持时间是指SCL下降沿到SDA变化之间的时间标准模式下最小0快速模式下最小0但最大不能超过900纳秒否则会被误判为起始或停止条件。软件模拟的时候我通常会在SDA变化之后加一个短延时然后再拉高SCL。这个延时不需要太长几百纳秒就够了。但如果你用的是主频很低的MCU比如8MHz不加延时可能会出问题因为GPIO翻转本身就有延迟。还有一个容易忽略的点SCL高电平的持续时间。标准模式下最小4.0微秒快速模式下0.6微秒。这个时间决定了通信速率。如果你把SCL高电平时间设得太短从设备可能来不及采样。我一般会把SCL高电平和低电平的时间设成一样这样占空比50%最稳妥。2.3 ACK和NACK的时序处理每发送完一个字节8位主设备需要释放SDA设置为输入或高电平然后在第9个SCL时钟周期读取SDA的电平。如果SDA为低表示从设备发送了ACK应答如果SDA为高表示NACK非应答。这里有一个非常容易踩的坑很多初学者在发送完8位数据后忘记把SDA切换成输入模式结果主设备还在驱动SDA从设备想拉低SDA发ACK也拉不动读到的永远是高电平误判为NACK。正确的做法是发送完8位数据后立即把SDA引脚配置为输入模式或开漏输出并写1然后产生第9个SCL脉冲读取SDA电平。接收数据的时候也类似。主设备接收完8位数据后需要发送ACK或NACK给从设备。如果要继续接收发送ACK拉低SDA如果这是最后一个字节发送NACK释放SDA。发送ACK的时序是在第9个SCL时钟周期主设备拉低SDA然后在SCL高电平期间保持低电平SCL下降沿之后释放SDA。我见过不少代码在发送NACK之后直接发停止条件中间没有给SDA恢复的时间导致停止条件识别失败。正确的顺序是发送NACK - 拉低SCL - 释放SDA - 拉高SCL - 拉高SDA产生停止条件。每一步之间都要有适当的延时。2.4 时钟频率与延时的计算关系假设你要实现100kHz的I2C通信速率那么一个完整的SCL周期是10微秒。这个周期包括SCL低电平时间 SCL高电平时间。如果占空比50%那么低电平5微秒高电平5微秒。在软件模拟中SCL低电平时间由“拉低SCL - 操作SDA - 拉高SCL”之间的延时决定高电平时间由“拉高SCL - 拉低SCL”之间的延时决定。你需要根据CPU主频计算出合适的延时循环次数。以STM32F10372MHz为例一个简单的for循环延时void i2c_delay(void) { for (volatile int i 0; i 10; i); }这个循环大概产生1-2微秒的延时取决于编译优化等级。要得到5微秒的延时可以把循环次数调到30-40。但这种方法不精确受编译器优化影响很大。更可靠的方法是使用SysTick定时器或者硬件定时器来做微秒级延时。不过在实际项目中我很少精确计算延时。我的做法是先用一个大概的延时值把通信跑通然后用示波器或者逻辑分析仪抓波形看SCL频率是多少再微调延时值。比如你发现SCL频率是200kHz想降到100kHz就把延时值翻倍。这种方法比理论计算快得多也更准确。提示如果你没有示波器可以用一个空闲的GPIO在每次SCL翻转时取反然后用万用表测频率有些万用表有频率档或者用MCU的定时器输入捕获功能来测量。虽然麻烦一点但总比盲调强。3. 软件模拟I2C完整代码实现3.1 GPIO端口配置与宏定义先说一下GPIO的配置。以STM32为例SCL和SDA都配置为开漏输出模式GPIO_Mode_Out_OD输出速度选50MHz其实低速也行但高速模式波形更干净。上拉电阻用4.7kΩ到10kΩ之间一般4.7kΩ比较常见。如果总线电容比较大线长、设备多可以适当减小上拉电阻到2.2kΩ但功耗会增加。// i2c_soft.h #ifndef __I2C_SOFT_H #define __I2C_SOFT_H #include stm32f10x.h // 引脚定义方便移植 #define I2C_SCL_PIN GPIO_Pin_6 #define I2C_SCL_PORT GPIOB #define I2C_SDA_PIN GPIO_Pin_7 #define I2C_SDA_PORT GPIOB // GPIO操作宏 #define I2C_SCL_H() GPIO_SetBits(I2C_SCL_PORT, I2C_SCL_PIN) #define I2C_SCL_L() GPIO_ResetBits(I2C_SCL_PORT, I2C_SCL_PIN) #define I2C_SDA_H() GPIO_SetBits(I2C_SDA_PORT, I2C_SDA_PIN) #define I2C_SDA_L() GPIO_ResetBits(I2C_SDA_PORT, I2C_SDA_PIN) #define I2C_SDA_READ() GPIO_ReadInputDataBit(I2C_SDA_PORT, I2C_SDA_PIN) // 延时宏根据CPU主频调整 #define I2C_DELAY() do { \ for (volatile int i 0; i 8; i); \ } while(0) // 函数声明 void I2C_Init(void); void I2C_Start(void); void I2C_Stop(void); void I2C_SendByte(uint8_t byte); uint8_t I2C_ReceiveByte(void); uint8_t I2C_WaitAck(void); void I2C_SendAck(uint8_t ack); uint8_t I2C_WriteReg(uint8_t devAddr, uint8_t regAddr, uint8_t data); uint8_t I2C_ReadReg(uint8_t devAddr, uint8_t regAddr, uint8_t *data); #endif这里我把GPIO操作都封装成宏移植的时候只需要改这几个宏定义就行。I2C_DELAY的循环次数8是我在72MHz的STM32F103上实测出来的SCL频率大概在100kHz左右。如果你的MCU主频不同需要调整这个值。3.2 起始、停止、发送、接收的代码实现先看起始条件和停止条件的实现// i2c_soft.c #include i2c_soft.h void I2C_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin I2C_SCL_PIN | I2C_SDA_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; // 开漏输出 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(I2C_SCL_PORT, GPIO_InitStructure); // 初始状态总线空闲SCL和SDA都为高 I2C_SCL_H(); I2C_SDA_H(); } void I2C_Start(void) { // 确保SDA和SCL都是高电平空闲状态 I2C_SDA_H(); I2C_SCL_H(); I2C_DELAY(); // SCL高电平期间SDA从高变低 I2C_SDA_L(); I2C_DELAY(); // 拉低SCL准备发送数据 I2C_SCL_L(); I2C_DELAY(); } void I2C_Stop(void) { // 先确保SDA是低电平 I2C_SDA_L(); I2C_DELAY(); // 拉高SCL I2C_SCL_H(); I2C_DELAY(); // SCL高电平期间SDA从低变高 I2C_SDA_H(); I2C_DELAY(); }起始条件里我先确保SDA和SCL都是高然后拉低SDA再拉低SCL。停止条件里先确保SDA是低然后拉高SCL再拉高SDA。每一步之间都有延时保证时序满足要求。发送一个字节的代码void I2C_SendByte(uint8_t byte) { uint8_t i; for (i 0; i 8; i) { // 准备SDA电平从最高位开始发送 if (byte 0x80) { I2C_SDA_H(); } else { I2C_SDA_L(); } byte 1; I2C_DELAY(); // 拉高SCL从设备在SCL高电平期间采样SDA I2C_SCL_H(); I2C_DELAY(); // 拉低SCL准备下一位 I2C_SCL_L(); I2C_DELAY(); } }注意这里是从最高位MSB开始发送的I2C协议规定MSB first。每发送一位先设置SDA然后拉高SCL延时再拉低SCL。这样从设备在SCL高电平期间就能读到稳定的SDA电平。接收一个字节的代码uint8_t I2C_ReceiveByte(void) { uint8_t i; uint8_t byte 0; // 释放SDA让从设备驱动 I2C_SDA_H(); I2C_DELAY(); for (i 0; i 8; i) { byte 1; // 拉高SCL从设备在SCL高电平期间输出数据 I2C_SCL_H(); I2C_DELAY(); // 读取SDA电平 if (I2C_SDA_READ()) { byte | 0x01; } // 拉低SCL I2C_SCL_L(); I2C_DELAY(); } return byte; }接收的时候主设备要先释放SDA写1或设为输入然后拉高SCL读取SDA电平再拉低SCL。循环8次每次把读到的位放到byte的最低位然后左移。3.3 ACK应答与NACK的代码处理等待ACK的代码uint8_t I2C_WaitAck(void) { uint8_t ack; // 释放SDA让从设备驱动 I2C_SDA_H(); I2C_DELAY(); // 拉高SCL从设备在SCL高电平期间拉低SDA表示ACK I2C_SCL_H(); I2C_DELAY(); // 读取SDA电平 if (I2C_SDA_READ()) { ack 1; // NACK } else { ack 0; // ACK } // 拉低SCL I2C_SCL_L(); I2C_DELAY(); return ack; }发送ACK或NACK的代码void I2C_SendAck(uint8_t ack) { // 如果要发送ACK拉低SDA如果要发送NACK释放SDA if (ack 0) { I2C_SDA_L(); } else { I2C_SDA_H(); } I2C_DELAY(); // 拉高SCL I2C_SCL_H(); I2C_DELAY(); // 拉低SCL I2C_SCL_L(); I2C_DELAY(); // 释放SDA I2C_SDA_H(); I2C_DELAY(); }这里ack0表示发送ACKack1表示发送NACK。发送完ACK/NACK之后一定要释放SDA否则会影响后续的停止条件或者下一个起始条件。3.4 完整的读写寄存器函数有了上面的基础函数读写寄存器就很简单了。以常见的传感器为例写寄存器的流程是起始条件 - 发送设备地址写方向 - 等待ACK - 发送寄存器地址 - 等待ACK - 发送数据 - 等待ACK - 停止条件。uint8_t I2C_WriteReg(uint8_t devAddr, uint8_t regAddr, uint8_t data) { I2C_Start(); // 发送设备地址 写方向最低位为0 I2C_SendByte(devAddr 0xFE); if (I2C_WaitAck()) { I2C_Stop(); return 1; // 设备无应答 } // 发送寄存器地址 I2C_SendByte(regAddr); if (I2C_WaitAck()) { I2C_Stop(); return 2; // 寄存器地址无应答 } // 发送数据 I2C_SendByte(data); if (I2C_WaitAck()) { I2C_Stop(); return 3; // 数据无应答 } I2C_Stop(); return 0; // 成功 }读寄存器的流程稍微复杂一点需要先写寄存器地址然后重新起始再读数据uint8_t I2C_ReadReg(uint8_t devAddr, uint8_t regAddr, uint8_t *data) { I2C_Start(); // 发送设备地址 写方向 I2C_SendByte(devAddr 0xFE); if (I2C_WaitAck()) { I2C_Stop(); return 1; } // 发送寄存器地址 I2C_SendByte(regAddr); if (I2C_WaitAck()) { I2C_Stop(); return 2; } // 重新起始 I2C_Start(); // 发送设备地址 读方向最低位为1 I2C_SendByte(devAddr | 0x01); if (I2C_WaitAck()) { I2C_Stop(); return 3; } // 读取数据 *data I2C_ReceiveByte(); // 发送NACK表示不再继续读取 I2C_SendAck(1); I2C_Stop(); return 0; }这两个函数基本上覆盖了90%的I2C传感器操作。像MPU6050、BMP280、AT24C02这些常见器件都是这个读写模式。你只需要知道设备地址和寄存器地址直接调用就行。注意有些器件的寄存器地址是16位的比如某些EEPROM需要发送两个字节的寄存器地址。这时候把I2C_SendByte(regAddr)改成发送高字节和低字节两次就行。另外有些器件支持连续读取多个寄存器只需要在读取完一个字节后发送ACK而不是NACK然后继续读取下一个字节。4. 实际调试中遇到的坑与排查方法4.1 总线死锁与恢复技巧I2C总线死锁是我遇到最多的问题。现象是程序卡在I2C_WaitAck里出不来SDA一直被某个设备拉低SCL也是低电平。这种情况通常发生在通信过程中突然复位或者从设备供电不稳导致状态机跑飞。死锁的根本原因是主设备在发送数据的过程中从设备拉低了SDA表示ACK但主设备因为某种原因比如中断打断没有继续产生SCL时钟从设备就一直等着SDA一直保持低电平。这时候你再去发起始条件因为SDA是低的起始条件识别不了。恢复方法是手动发送9个SCL脉冲让从设备把剩下的位发完然后发送停止条件。具体操作是把SCL配置为推挽输出SDA配置为输入然后循环9次拉低SCL延时拉高SCL延时。最后手动产生一个停止条件SDA低 - SCL高 - SDA高。void I2C_BusRecover(void) { GPIO_InitTypeDef GPIO_InitStructure; uint8_t i; // 配置SCL为推挽输出SDA为输入 GPIO_InitStructure.GPIO_Pin I2C_SCL_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(I2C_SCL_PORT, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin I2C_SDA_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入 GPIO_Init(I2C_SDA_PORT, GPIO_InitStructure); // 发送9个SCL脉冲 for (i 0; i 9; i) { I2C_SCL_L(); I2C_DELAY(); I2C_SCL_H(); I2C_DELAY(); } // 产生停止条件 I2C_SDA_L(); I2C_DELAY(); I2C_SCL_H(); I2C_DELAY(); I2C_SDA_H(); I2C_DELAY(); // 重新配置为开漏输出 GPIO_InitStructure.GPIO_Pin I2C_SCL_PIN | I2C_SDA_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; GPIO_Init(I2C_SCL_PORT, GPIO_InitStructure); }这个恢复函数我一般会在I2C_Init里调用一次确保上电时总线是干净的。另外在每次通信失败后也可以调用一次防止总线卡死。4.2 上拉电阻选择与波形质量上拉电阻的选择直接影响波形质量。电阻太大上升沿变缓SCL和SDA的高电平建立时间变长高速通信时可能采样错误。电阻太小功耗增加而且有些器件的灌电流能力有限可能拉不低。我一般用4.7kΩ作为默认值。如果通信速率是100kHz总线电容小于200pF4.7kΩ完全够用。如果总线挂了很多设备电容超过400pF上升时间会明显变慢这时候需要减小到2.2kΩ甚至1.5kΩ。怎么判断波形质量用示波器看SCL和SDA的上升沿。理想的上升沿应该是陡峭的从低到高在几百纳秒内完成。如果上升沿很缓像一条斜线说明上拉电阻太大或者总线电容太大。如果上升沿有振铃说明阻抗不匹配可以在靠近主设备的地方串一个几十欧姆的电阻。还有一个容易被忽略的点电源电压。I2C总线的上拉电阻应该接到从设备的电源电压上而不是主设备的电源电压。如果主设备是3.3V从设备是5V上拉电阻要接到5V否则从设备可能识别不到高电平。但主设备的GPIO如果耐压不够5V就需要加电平转换电路。这个坑我在早期项目中踩过烧了一个传感器。4.3 常见问题速查表问题现象可能原因排查方法解决方案起始条件发不出去总线死锁SDA被拉低用万用表测SDA电压调用总线恢复函数一直收到NACK设备地址错误查数据手册确认地址修正设备地址读到的数据全是0xFFSDA一直高从设备没响应示波器看SDA波形检查从设备供电和上拉电阻读到的数据全是0x00SDA一直低总线短路断开从设备逐个排查检查PCB焊接通信偶尔失败延时不够时序临界增大I2C_DELAY值降低通信速率波形上升沿太缓上拉电阻太大示波器看上升时间减小上拉电阻多设备通信冲突设备地址重复逐个设备单独测试使用I2C多路复用器停止条件识别失败SDA释放太慢示波器看停止时序增加停止条件后的延时这张表是我这些年调试I2C问题的经验总结基本上覆盖了80%的常见故障。遇到问题的时候先查表能省很多时间。4.4 用逻辑分析仪抓包分析如果你有逻辑分析仪比如Saleae或者国产的几十块钱的版本调试I2C会轻松很多。把SCL和SDA接到逻辑分析仪的两个通道上设置I2C解码就能看到完整的通信过程起始条件、设备地址、读写方向、ACK/NACK、数据字节、停止条件。我一般会重点看几个地方设备地址是否正确包括读写位、ACK是否正常从设备有没有拉低SDA、数据字节的顺序MSB first、停止条件是否完整。如果逻辑分析仪解码出来的数据和你的代码预期不一致那肯定是代码或者硬件有问题。没有逻辑分析仪怎么办可以用示波器的双通道一个通道接SCL一个通道接SDA用触发模式抓起始条件。虽然看起来麻烦一点但也能分析出问题。再不行就在代码里加打印每一步都输出状态虽然慢但能定位到具体哪一步出错。提示逻辑分析仪的地线一定要和目标板共地否则抓到的波形全是噪声。另外采样率要设高一点至少是SCL频率的10倍以上否则可能漏掉窄脉冲。5. 软件模拟I2C的优化与扩展5.1 提高通信速率的几种方法软件模拟I2C的通信速率主要受限于CPU的GPIO翻转速度和延时函数的精度。在72MHz的STM32上我用上面的代码实测SCL频率大概在100kHz到150kHz之间。想再快有几个方法。第一减少延时。把I2C_DELAY的循环次数从8降到4甚至2SCL频率能翻倍。但要注意太快了从设备可能跟不上而且GPIO翻转本身的延迟会变得不可忽略。我试过把延时降到0SCL频率能到500kHz左右但波形已经不太干净了而且不是所有从设备都支持。第二用寄存器直接操作GPIO。GPIO_SetBits和GPIO_ResetBits是库函数内部有函数调用开销。直接用BSRR寄存器操作能省不少时间。比如#define I2C_SCL_H() (GPIOB-BSRR GPIO_Pin_6) #define I2C_SCL_L() (GPIOB-BRR GPIO_Pin_6) #define I2C_SDA_H() (GPIOB-BSRR GPIO_Pin_7) #define I2C_SDA_L() (GPIOB-BRR GPIO_Pin_7)这样每个操作就是一个寄存器写速度比库函数快很多。实测能提高30%到50%的速率。第三用定时器中断或者DMA。这个比较复杂但能实现非常精确的时序。思路是用一个定时器产生固定频率的中断在中断里翻转GPIO。这样CPU只需要处理数据不需要管时序。不过对于大多数应用来说没必要这么复杂。5.2 多总线管理与设备分组当一个系统里挂了太多I2C设备地址冲突或者总线电容太大导致通信不稳定时可以考虑多总线管理。简单说就是定义多组SCL和SDA引脚每组独立管理。比如typedef struct { GPIO_TypeDef *sclPort; uint16_t sclPin; GPIO_TypeDef *sdaPort; uint16_t sdaPin; } I2C_Bus_t; I2C_Bus_t bus1 {GPIOB, GPIO_Pin_6, GPIOB, GPIO_Pin_7}; I2C_Bus_t bus2 {GPIOB, GPIO_Pin_8, GPIOB, GPIO_Pin_9};然后每个操作函数都传入I2C_Bus_t指针这样就能同时管理多条总线。设备分组的好处是每条总线的电容负载小波形质量好地址冲突的设备可以挂到不同总线上互不干扰一条总线出问题不影响其他总线系统更稳定。我做过一个项目一个MCU挂了12个I2C传感器分成3条总线每条总线4个设备。运行了一年多没出过通信问题。如果全挂一条总线上电容超过1000pF上升沿慢得没法看。5.3 超时机制与错误恢复软件模拟I2C最大的风险是死循环。如果从设备没响应I2C_WaitAck会一直等下去整个程序卡死。所以必须加超时机制。我的做法是在I2C_WaitAck里加一个计数器每次循环递增超过一定值就返回错误uint8_t I2C_WaitAck(void) { uint8_t ack; uint16_t timeout 0; I2C_SDA_H(); I2C_DELAY(); I2C_SCL_H(); I2C_DELAY(); while (I2C_SDA_READ()) { timeout; if (timeout 1000) { I2C_SCL_L(); return 1; // 超时返回NACK } } ack 0; I2C_SCL_L(); I2C_DELAY(); return ack; }超时值1000是在100kHz速率下大概10毫秒足够从设备响应了。如果超时就调用总线恢复函数然后重新初始化I2C。这样即使某个设备挂了系统也能自动恢复不会整个卡死。另外我建议在每次读写操作的外层也加超时。比如连续读写10次都失败就重新初始化I2C总线。这种双重保护机制在实际项目中非常有用尤其是工业环境电磁干扰大I2C通信偶尔出错很正常能自动恢复就行。5.4 从软件模拟迁移到硬件I2C当你把软件模拟I2C调通之后如果项目对性能要求高可以考虑迁移到硬件I2C。迁移的时候上层逻辑不用改只需要把底层的I2C_Start、I2C_Stop、I2C_SendByte、I2C_ReceiveByte、I2C_WaitAck、I2C_SendAck这几个函数替换成硬件I2C的库函数就行。但要注意硬件I2C的错误处理机制和软件模拟不一样。硬件I2C有状态寄存器你需要检查各种错误标志位比如AF、BERR、ARLO然后清除标志。软件模拟的错误处理就是超时和总线恢复。迁移的时候这部分逻辑要重写。还有硬件I2C的时钟频率是通过寄存器配置的不是靠延时。你需要根据从设备支持的最高速率来设置。比如从设备支持400kHz你就把硬件I2C配置成400kHz。但要注意硬件I2C的时钟频率是SCL的实际频率而软件模拟的“100kHz”可能因为延时不准而有偏差。我的建议是先用软件模拟把功能跑通验证传感器数据和寄存器配置都正确然后再迁移到硬件I2C。这样即使硬件I2C出问题你也能确定是硬件I2C的配置问题而不是传感器或者上层逻辑的问题。调试效率会高很多。最后分享一个我常用的技巧在软件模拟I2C的代码里把I2C_DELAY定义成一个全局变量而不是宏。这样你可以在运行时动态调整通信速率方便调试。比如uint16_t i2c_delay_cnt 8; #define I2C_DELAY() do { \ for (volatile int i 0; i i2c_delay_cnt; i); \ } while(0)调试的时候你可以通过串口命令修改i2c_delay_cnt的值观察不同速率下的通信稳定性。找到最合适的值之后再把它固定下来。这个技巧我在多个项目中都用过非常实用。
