软件模拟I2C:从协议原理到代码实现与调试技巧
1. 为什么还要用软件模拟I2C搞嵌入式开发的人手里多少都碰过I2C。硬件I2C外设用起来确实省心配置好寄存器剩下的交给芯片就行。但实际项目里硬件I2C经常遇到几个让人头疼的问题某些单片机的I2C外设存在已知硬件缺陷比如STM32早期型号的I2C死锁问题一旦总线被拉低就再也起不来不同厂商的I2C外设行为不一致代码移植时寄存器配置全要重写还有些低成本MCU干脆就没有硬件I2C模块比如很多8位机、OTP芯片。这时候软件模拟I2C就成了刚需——只要有两个普通GPIO口就能跟任何I2C从设备通信。软件模拟I2C的核心思路很简单用两个GPIO引脚分别模拟SCL时钟线和SDA数据线通过精确控制引脚的电平变化和翻转时序来复现I2C协议规定的起始、停止、应答、数据位传输等信号。说白了就是把I2C协议用代码“手动”走一遍。它的优势在于移植性极强换任何平台只要改几个宏定义就能跑时序完全可控遇到从设备时序要求古怪的情况可以灵活调整调试也方便逻辑分析仪一挂就能看到每一根线的状态。这篇文章适合谁看如果你正在用没有硬件I2C的MCU或者被硬件I2C的bug折磨得不行又或者想彻底搞懂I2C协议到底怎么跑起来的那这篇内容就是给你准备的。我会从协议原理讲起把时序拆开揉碎然后给出完整的软件模拟I2C代码每一行都配上注释最后分享一些实际调试中踩过的坑和解决技巧。2. I2C协议核心原理拆解2.1 两根线怎么完成通信I2C全称Inter-Integrated Circuit中文叫集成电路总线是Philips在1980年代搞出来的一种同步串行通信协议。它只用两根线SCL是时钟线SDA是数据线。所有挂在总线上的设备都并联到这两根线上每个设备有唯一的7位地址也有10位地址的扩展模式但实际项目里7位用得最多。I2C是主从架构通信永远由主机发起。主机负责产生时钟信号控制数据传输的节奏从机被动响应根据主机发来的地址判断是不是在叫自己。总线上可以挂多个主机多主模式但同一时刻只能有一个主机在控制总线这个靠仲裁机制来保证。实际项目中绝大多数情况都是单主机多从机所以仲裁这部分可以先放一放。两根线都是开漏输出结构这意味着任何设备都可以把线拉低但拉高要靠外部上拉电阻。这个设计很巧妙多个设备同时驱动总线时不会出现电源对地的短路因为开漏输出只能拉低不能主动拉高。上拉电阻的阻值选择有讲究典型值在4.7kΩ到10kΩ之间。阻值太小功耗大阻值太大上升沿变缓高速通信时信号质量会变差。我一般先用4.7kΩ试如果波形上升沿太慢再减小。2.2 起始、停止和应答信号I2C协议定义了三种基本信号起始信号Start、停止信号Stop和应答信号ACK/NACK。起始信号的定义是SCL保持高电平时SDA从高电平跳变到低电平。这个跳变必须发生在SCL高电平期间因为SCL低电平时SDA的变化是允许的数据位传输时SDA只能在SCL低电平期间改变。起始信号告诉总线上所有从设备注意主机要开始通信了。停止信号刚好相反SCL保持高电平时SDA从低电平跳变到高电平。通信结束后主机发停止信号释放总线。应答信号出现在每传输完8个数据位之后。发送方把8位数据发完接收方需要在第9个时钟周期把SDA拉低表示“我收到了”。如果接收方没有拉低SDA保持高电平那就是NACK表示没有正确接收或者不需要再接收了。ACK/NACK机制是I2C可靠性的重要保障主机发完地址后要检查从机有没有ACK没有的话说明从机不在线或者地址错了。2.3 数据位的传输规则每个数据位在SCL的一个时钟周期内完成传输。规则是这样的SCL低电平期间发送方把数据放到SDA线上SCL高电平期间接收方采样SDA线的电平。也就是说SDA上的数据必须在SCL高电平期间保持稳定只能在SCL低电平期间改变。这个规则是I2C协议的基础也是软件模拟时最容易出错的地方。很多初学者写模拟代码时在SCL高电平期间去改SDA结果从设备采样到的数据全是乱的。记住一句话SCL高电平期间SDA必须稳如泰山。数据传输是MSB先行也就是先发最高位。一个完整的I2C通信帧通常是这样起始信号 → 7位从机地址 1位读写方向位 → ACK → 数据字节1 → ACK → 数据字节2 → ACK → ... → 停止信号。读写方向位0表示主机写数据到从机1表示主机从从机读数据。2.4 时钟同步与仲裁的简要说明时钟同步是多主机场景下的机制。当多个主机同时产生时钟时SCL线的实际电平是所有主机输出的“线与”结果。如果某个主机拉低了SCLSCL就是低只有所有主机都释放SCLSCL才被上拉电阻拉高。这就意味着时钟周期由最慢的主机决定快的主机会自动等待慢的主机。仲裁发生在两个主机同时发起始信号并发送不同数据时。每个主机在发数据的同时也在监测SDA线的实际电平如果发现自己发的和读到的电平不一致说明有别的设备在抢总线仲裁失败的主机会自动退出转为从机模式。仲裁机制保证了多主系统中数据不会冲突。不过说实话实际项目里用到多主仲裁的场景少之又少。大部分嵌入式工程师一辈子可能都碰不到一次。所以软件模拟I2C时仲裁和时钟同步可以先不实现把单主模式跑通再说。3. 软件模拟I2C的完整实现3.1 GPIO端口配置要点软件模拟I2C的第一步是把两个GPIO配置好。SCL和SDA都需要配置为开漏输出模式Open-Drain并且使能内部上拉或者外接上拉电阻。为什么用开漏而不是推挽因为I2C总线是线与逻辑如果配成推挽输出一个设备输出高电平另一个输出低电平就会短路。开漏输出只能拉低高电平靠上拉电阻天然支持线与。以STM32为例配置代码如下// I2C GPIO初始化 void I2C_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; // 使能GPIO时钟 __HAL_RCC_GPIOB_CLK_ENABLE(); // 配置SCL引脚 PB6 GPIO_InitStruct.Pin GPIO_PIN_6; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; // 开漏输出 GPIO_InitStruct.Pull GPIO_PULLUP; // 内部上拉 GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; // 高速模式 HAL_GPIO_Init(GPIOB, GPIO_InitStruct); // 配置SDA引脚 PB7 GPIO_InitStruct.Pin GPIO_PIN_7; HAL_GPIO_Init(GPIOB, GPIO_InitStruct); // 初始状态两根线都拉高 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET); }如果你用的是没有开漏输出模式的MCU也可以把引脚配置为输入输出切换模式需要输出低电平时配成推挽输出低需要释放总线时配成浮空输入让上拉电阻把线拉高。这种做法的缺点是切换方向有额外开销时序精度会受影响但低速通信100kHz以下完全够用。注意SDA引脚在读取ACK时必须是输入状态。如果配置为开漏输出读取引脚电平前要先确保输出寄存器写的是1释放总线否则读到的永远是0。3.2 微秒级延时函数的实现软件模拟I2C的时序全靠延时来控制。标准模式I2C速率是100kHz一个时钟周期10微秒高电平和低电平各5微秒。快速模式是400kHz周期2.5微秒。实际写延时的时候不用卡得那么死稍微留点余量反而更稳定。延时函数的实现方式取决于你的平台。如果有系统滴答定时器SysTick可以用它来做微秒延时// 微秒级延时函数基于SysTick void delay_us(uint32_t us) { uint32_t start SysTick-VAL; uint32_t ticks us * (SystemCoreClock / 1000000); uint32_t elapsed 0; while (elapsed ticks) { uint32_t current SysTick-VAL; if (current start) { elapsed start - current; } else { // 计数器翻转 elapsed start (SysTick-LOAD - current); } } }如果没有SysTick用简单的循环延时也行但需要根据主频校准// 简单循环延时需要根据实际主频校准 void delay_us(uint32_t us) { volatile uint32_t i; for (i 0; i us * 8; i) { // 8这个系数需要实测调整 __NOP(); } }我一般会在延时函数里留一个可调的系数用逻辑分析仪测实际波形然后微调这个系数直到时序正确。不同优化等级下循环延时的实际耗时差别很大所以校准这一步不能省。3.3 起始信号与停止信号的代码实现起始信号的实现逻辑先确保SCL和SDA都是高电平然后拉低SDA再拉低SCL。注意顺序不能反——必须先拉低SDA再拉低SCL因为起始信号的定义就是SCL高时SDA的下降沿。// 产生I2C起始信号 void I2C_Start(void) { SDA_HIGH(); // SDA拉高 SCL_HIGH(); // SCL拉高 delay_us(4); // 等待电平稳定 SDA_LOW(); // SCL高电平期间SDA由高变低 → 起始信号 delay_us(4); SCL_LOW(); // 拉低SCL准备传输数据 delay_us(4); }停止信号的逻辑先确保SCL和SDA都是低电平或者SDA低、SCL低然后拉高SCL再拉高SDA。停止信号是SCL高时SDA的上升沿。// 产生I2C停止信号 void I2C_Stop(void) { SDA_LOW(); // 确保SDA为低 delay_us(4); SCL_HIGH(); // SCL拉高 delay_us(4); SDA_HIGH(); // SCL高电平期间SDA由低变高 → 停止信号 delay_us(4); }这两个函数是整个模拟I2C的骨架起始和停止的时序错了后面全白搭。我见过有人把起始信号写成先拉低SCL再拉低SDA结果从设备根本识别不到起始条件。记住起始和停止都是SDA的跳变发生在SCL高电平期间。3.4 字节发送与ACK接收发送一个字节需要8个时钟周期每个周期发一位MSB先行。发送方在SCL低电平期间把数据位放到SDA上然后拉高SCL从设备在SCL高电平期间采样。// 发送一个字节返回从机应答0ACK, 1NACK uint8_t I2C_SendByte(uint8_t data) { uint8_t i; uint8_t ack; for (i 0; i 8; i) { SCL_LOW(); // 拉低SCL准备放数据 delay_us(2); if (data 0x80) { // 取最高位 SDA_HIGH(); } else { SDA_LOW(); } data 1; // 左移一位准备下一位 delay_us(2); SCL_HIGH(); // 拉高SCL从设备采样 delay_us(4); } // 第9个时钟周期接收ACK SCL_LOW(); delay_us(2); SDA_HIGH(); // 释放SDA让从机控制 delay_us(2); SCL_HIGH(); // 拉高SCL从机在此刻输出ACK delay_us(2); ack SDA_READ(); // 读取SDA电平0ACK, 1NACK delay_us(2); SCL_LOW(); // 拉低SCL结束ACK周期 delay_us(2); return ack; }这里有个关键细节在接收ACK之前主机必须释放SDA输出高电平否则主机的输出和从机的输出会打架。释放SDA后从机如果拉低SDA就是ACK保持高电平就是NACK。3.5 字节接收与ACK发送接收字节的过程和发送类似只是方向反过来。主机在SCL低电平期间释放SDA从机把数据放到SDA上主机在SCL高电平期间采样。// 接收一个字节参数ack: 0发送ACK, 1发送NACK uint8_t I2C_RecvByte(uint8_t ack) { uint8_t i; uint8_t data 0; SDA_HIGH(); // 释放SDA让从机控制 for (i 0; i 8; i) { SCL_LOW(); // 拉低SCL delay_us(4); SCL_HIGH(); // 拉高SCL从机输出数据 delay_us(2); data 1; // 左移准备接收下一位 if (SDA_READ()) { // 采样SDA data | 0x01; } delay_us(2); } // 第9个时钟周期发送ACK或NACK SCL_LOW(); delay_us(2); if (ack 0) { SDA_LOW(); // 发送ACK } else { SDA_HIGH(); // 发送NACK } delay_us(2); SCL_HIGH(); // 拉高SCL从机采样ACK delay_us(4); SCL_LOW(); // 拉低SCL结束 delay_us(2); SDA_HIGH(); // 释放SDA return data; }接收完最后一个字节后主机通常要发NACK告诉从机“不用再发了”然后发停止信号结束通信。这个细节在读传感器数据时特别重要如果最后一个字节发了ACK有些从机会继续输出数据导致总线卡死。4. 完整代码整合与典型应用4.1 头文件与宏定义把GPIO操作抽象成宏定义移植时只需要改这几个宏就能适配不同平台#ifndef __SOFT_I2C_H #define __SOFT_I2C_H #include main.h // 移植时需要修改的部分 #define I2C_SCL_PORT GPIOB #define I2C_SCL_PIN GPIO_PIN_6 #define I2C_SDA_PORT GPIOB #define I2C_SDA_PIN GPIO_PIN_7 // GPIO操作宏 #define SCL_HIGH() HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET) #define SCL_LOW() HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET) #define SDA_HIGH() HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_SET) #define SDA_LOW() HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_RESET) #define SDA_READ() HAL_GPIO_ReadPin(I2C_SDA_PORT, I2C_SDA_PIN) // // 函数声明 void I2C_GPIO_Init(void); void I2C_Start(void); void I2C_Stop(void); uint8_t I2C_SendByte(uint8_t data); uint8_t I2C_RecvByte(uint8_t ack); uint8_t I2C_WriteReg(uint8_t dev_addr, uint8_t reg_addr, uint8_t data); uint8_t I2C_ReadReg(uint8_t dev_addr, uint8_t reg_addr, uint8_t *buf, uint16_t len); #endif4.2 读写寄存器封装函数实际使用中我们不会直接调SendByte和RecvByte而是封装成读写寄存器的函数。以写一个字节到从机寄存器为例// 向从机寄存器写一个字节 // 返回0成功1失败 uint8_t I2C_WriteReg(uint8_t dev_addr, uint8_t reg_addr, uint8_t data) { I2C_Start(); // 发送从机地址 写方向 if (I2C_SendByte(dev_addr 1 | 0)) { I2C_Stop(); return 1; // 从机没有应答 } // 发送寄存器地址 if (I2C_SendByte(reg_addr)) { I2C_Stop(); return 1; } // 发送数据 if (I2C_SendByte(data)) { I2C_Stop(); return 1; } I2C_Stop(); return 0; }读寄存器的流程稍微复杂一点需要先写寄存器地址然后重新发起始信号再读数据// 从从机寄存器连续读取len个字节 uint8_t I2C_ReadReg(uint8_t dev_addr, uint8_t reg_addr, uint8_t *buf, uint16_t len) { uint16_t i; // 第一步写寄存器地址 I2C_Start(); if (I2C_SendByte(dev_addr 1 | 0)) { I2C_Stop(); return 1; } if (I2C_SendByte(reg_addr)) { I2C_Stop(); return 1; } // 第二步重新起始读数据 I2C_Start(); if (I2C_SendByte(dev_addr 1 | 1)) { I2C_Stop(); return 1; } // 连续读取 for (i 0; i len; i) { if (i len - 1) { buf[i] I2C_RecvByte(1); // 最后一个字节发NACK } else { buf[i] I2C_RecvByte(0); // 中间字节发ACK } } I2C_Stop(); return 0; }4.3 以AT24C02为例的读写测试AT24C02是最常见的I2C EEPROM地址是0xA07位地址1010000。写一个字节到地址0x00然后读回来验证void AT24C02_Test(void) { uint8_t write_data 0x55; uint8_t read_data 0; // 写数据 if (I2C_WriteReg(0x50, 0x00, write_data) 0) { printf(写入成功\n); } else { printf(写入失败\n); } // EEPROM需要等待写入周期完成典型5ms HAL_Delay(10); // 读数据 if (I2C_ReadReg(0x50, 0x00, read_data, 1) 0) { printf(读取成功: 0x%02X\n, read_data); } else { printf(读取失败\n); } // 验证 if (read_data write_data) { printf(数据校验通过\n); } else { printf(数据校验失败\n); } }AT24C02的7位地址是1010000左移一位后是0xA0写或0xA1读。我代码里传的0x50就是7位地址本身在函数内部左移。这个细节要注意有些资料直接给8位地址容易搞混。4.4 以MPU6050为例的传感器读取MPU6050是六轴传感器I2C地址是0x687位地址1101000。读取加速度计数据void MPU6050_ReadAccel(int16_t *ax, int16_t *ay, int16_t *az) { uint8_t buf[6]; // 从0x3B寄存器开始连续读6个字节加速度计X/Y/Z高低字节 I2C_ReadReg(0x68, 0x3B, buf, 6); *ax (int16_t)((buf[0] 8) | buf[1]); *ay (int16_t)((buf[2] 8) | buf[3]); *az (int16_t)((buf[4] 8) | buf[5]); }MPU6050的数据寄存器是连续排列的所以可以用连续读的方式一次读完6个字节比分6次读效率高得多。连续读的时候中间字节发ACK最后一个字节发NACK这个规则前面已经强调过了。5. 调试中常见问题与排查技巧5.1 从机不响应ACK怎么办这是最常见的问题。主机发了地址后读回来的是NACK说明从机没有应答。排查思路按优先级来第一检查硬件连接。SCL和SDA有没有接反上拉电阻有没有焊我遇到过好几次是SDA和SCL接反了波形看着正常但就是不通。用万用表量一下两根线在空闲时是不是都是高电平如果是低电平说明有设备在拉低总线或者上拉电阻没接。第二确认从机地址。7位地址和8位地址搞混是高频错误。比如AT24C02的7位地址是0x50写成8位就是0xA0。查数据手册时看清楚给的是7位还是8位。另外有些从机的地址可以通过引脚配置检查一下地址引脚的电平。第三检查时序参数。用逻辑分析仪抓波形看起始信号、数据位、ACK位的时序对不对。重点看SCL高电平期间SDA是否稳定以及SCL的频率有没有超过从机支持的最大值。有些从机只支持100kHz你跑400kHz它就不理你。第四检查电源和地。从机有没有正常供电地线有没有共地I2C通信要求主机和从机共地如果两块板子用不同电源供电但地没连在一起通信肯定失败。5.2 总线死锁的恢复方法总线死锁的表现是SCL或SDA被某个设备一直拉低总线空闲时也不是高电平。这种情况通常发生在通信过程中主机复位或者异常中断从机还在等时钟信号但主机已经不发了。恢复方法是在SDA上发送9个时钟脉冲让从机把剩下的数据位发完然后发停止信号。具体操作把SCL配置为推挽输出手动翻转9次每次检查SDA是否释放。如果9个时钟后SDA变高说明从机释放了总线再发一个停止信号就能恢复正常。// I2C总线死锁恢复 void I2C_BusRecovery(void) { uint8_t i; // 配置SCL为推挽输出SDA为输入 // ...根据具体平台配置 SDA_HIGH(); // 释放SDA for (i 0; i 9; i) { SCL_LOW(); delay_us(5); SCL_HIGH(); delay_us(5); if (SDA_READ()) { break; // SDA已释放跳出 } } // 发送停止信号 SDA_LOW(); delay_us(5); SCL_HIGH(); delay_us(5); SDA_HIGH(); delay_us(5); // 恢复GPIO为开漏模式 // ... }5.3 时序参数调整的经验软件模拟I2C的时序参数没有绝对标准需要根据从机的要求和MCU的主频来调。我一般遵循这几个原则延时宁大勿小。标准模式100kHz对应5微秒的半周期我通常给4到6微秒的延时。快速模式400kHz对应1.25微秒我给1.5到2微秒。延时稍微大一点通信速率会降但稳定性会好很多。起始和停止信号的延时可以比其他信号稍长。因为这两个信号是帧的边界从机对它们的识别需要更充裕的时间。我一般给起始和停止各4到5微秒的延时数据位给2到3微秒。SCL高电平的时间不能太短。从机在SCL高电平期间采样SDA如果高电平时间太短从机可能来不及采样。我一般保证SCL高电平至少2微秒。用逻辑分析仪实测波形是最靠谱的校准方法。把SCL和SDA接到逻辑分析仪上看实际频率和占空比然后调整延时参数直到波形符合从机手册的要求。5.4 常见问题速查表现象可能原因排查方法解决措施从机不ACK地址错误确认7位/8位地址查数据手册核对地址从机不ACK硬件连接问题量SCL/SDA空闲电平检查上拉电阻和接线读数据全0或全1SDA方向配置错误检查读取时SDA是否释放读取前SDA输出高电平通信偶尔失败时序余量不足逻辑分析仪看波形增大延时参数总线死锁通信中断未发停止信号量总线空闲电平执行9时钟恢复数据错位SCL高电平期间SDA变化检查代码中SDA赋值位置SDA只在SCL低时改变连续读失败最后一字节发了ACK检查RecvByte参数最后一字节发NACK提示逻辑分析仪是调试I2C的必备工具。几十块钱的8通道逻辑分析仪配合开源软件就能解码I2C协议直接看到地址、数据、ACK/NACK比用示波器一根根线看效率高得多。6. 软件模拟与硬件I2C的取舍6.1 什么场景优先用软件模拟软件模拟I2C最大的优势是灵活。GPIO口随便选不受硬件外设引脚固定的限制。PCB布线时可以优先照顾其他外设I2C随便找两个空闲引脚就行。移植性也好换MCU时只需要改几个宏定义不用重新研究I2C外设的寄存器。调试方便是另一个大优势。硬件I2C出问题时你只能看状态寄存器的错误标志然后猜哪里出了问题。软件模拟I2C每一步都是你写的代码逻辑分析仪一挂哪一位不对一目了然。我调试I2C设备时如果硬件I2C搞了半小时还没通就会切到软件模拟通常十分钟内就能定位问题。对于低速、小数据量的应用软件模拟完全够用。读个温度传感器、写个EEPROM配置100kHz的速率绰绰有余。CPU占用率也不高每次传输就几毫秒的事。6.2 什么场景必须用硬件I2C高速通信场景硬件I2C有绝对优势。400kHz以上的速率软件模拟很难稳定跑因为中断和任务调度会打乱时序。1MHz以上的高速模式更是只有硬件外设才能胜任。大数据量传输也是硬件I2C的强项。配合DMA硬件I2C可以在不占用CPU的情况下传输大量数据。软件模拟每传一个字节都要CPU参与传几KB数据时CPU基本被占满。低功耗场景硬件I2C更合适。硬件外设可以在CPU休眠时继续工作传输完成后中断唤醒CPU。软件模拟必须CPU全程参与功耗降不下来。6.3 混合使用的策略实际项目中我经常把软件模拟和硬件I2C混着用。比如主通信链路用硬件I2C跑高速调试用的EEPROM或者温度传感器用软件模拟挂在另外两个引脚上。这样既保证了性能又保留了调试的灵活性。还有一种情况是硬件I2C出bug时的临时替换。产品开发后期发现硬件I2C有死锁问题重新设计PCB来不及了就把I2C切换到软件模拟改几个宏定义的事一晚上就能搞定。虽然速率降了但至少功能能跑起来。我个人在实际操作中的体会是软件模拟I2C是每个嵌入式工程师都应该掌握的技能。它不复杂但关键时刻能救命。把起始、停止、发送、接收这四个基本函数写扎实再封装好读写寄存器的接口剩下的就是根据具体从设备调时序参数了。代码写完之后一定要用逻辑分析仪验证波形确认时序符合从设备手册的要求这一步省不得。