GPIO软件模拟I2C:从时序原理到实战驱动与避坑指南
发布时间:2026/9/27 3:25:09来源:尧图网络
1. 为什么还要用GPIO模拟I2C但凡做过几年嵌入式的人手里多少都攒着几套软件模拟I2C的代码。有人觉得这是新手才用的东西硬件I2C外设它不香吗我一开始也这么想直到在一个项目上被硬件I2C坑了整整两天——某个国产MCU的硬件I2C在从设备时钟拉伸Clock Stretching时直接死锁翻遍勘误手册才发现是硅片bug。从那以后我的原则就变成了能用硬件I2C就用硬件但只要涉及多从机、时钟拉伸、或者引脚被PCB布线逼到墙角的情况软件模拟永远是兜底方案。软件模拟I2C的本质就是用两个普通GPIO口通过精确控制电平翻转的先后顺序和持续时间去手工复现I2C协议规定的时序波形。它不依赖MCU内部的I2C外设因此不受特定硬件模块的限制——任何两个能输出高低电平、能读取电平状态的引脚都可以拿来用。代价是CPU要全程参与时序控制通信速率上不去通常100kHz~400kHz而且对中断延迟敏感。这篇文章我会从I2C协议的物理层和协议层讲起把时序图上的每一个参数对应到代码里的每一行操作然后给出一套经过实际项目验证的软件模拟I2C完整代码包括起始、停止、应答、读写字节、以及典型器件如AT24C02 EEPROM和SSD1306 OLED的驱动示例。最后聊聊我在实际调试中踩过的坑和总结出来的经验。适合阅读的人群刚接触嵌入式、想搞懂I2C底层时序的初学者被硬件I2C外设坑过、需要一套可靠软件模拟方案的中级开发者以及想复习通信协议基础的资深工程师。2. I2C协议的物理层与协议层拆解2.1 两根线撑起的通信SDA与SCL的硬件约束I2C只用两根信号线SDASerial Data负责传输数据SCLSerial Clock负责同步时钟。两根线都是开漏Open-Drain输出结构这意味着器件只能把线拉低不能主动拉高。线要变高靠的是上拉电阻把电平拉回VCC。这个开漏结构是I2C能支持多主多从的物理基础——任何器件拉低总线总线就是低电平只有所有器件都释放总线上拉电阻才能把线拉高。所以上拉电阻的选型非常关键总线速率典型上拉电阻说明100kHz标准模式4.7kΩ~10kΩ阻值大上升沿缓功耗低400kHz快速模式2.2kΩ~4.7kΩ阻值小上升沿快功耗高1MHz快速模式1kΩ~2.2kΩ需要更强的驱动能力上拉电阻太大上升沿变缓高速通信时数据采样会出错太小则灌电流过大可能超过器件的拉低能力通常要求3mA以上。我一般先用4.7kΩ起步用示波器看上升沿如果超过1μs就换小一档。另外总线电容也是隐形杀手。每挂一个器件、每增加一段走线都会引入几pF到几十pF的电容。I2C规范要求总线电容不超过400pF。如果你挂了七八个器件走线又长上升沿会明显变缓这时候要么减小上拉电阻要么降低通信速率。2.2 时序图上的每一个参数起始、数据、应答、停止I2C的通信过程由几种基本信号单元组成理解它们是写模拟代码的前提。起始条件StartSCL为高电平时SDA从高变低。这个违规的电平变化被所有器件识别为通信开始。停止条件StopSCL为高电平时SDA从低变高。通信结束。数据位传输SCL为低电平时SDA上的数据可以变化SCL为高电平时SDA必须保持稳定接收方在这个窗口采样数据。每个数据位对应一个SCL时钟脉冲。应答ACK/NACK每传输完8个数据位发送方释放SDA接收方在第九个时钟周期把SDA拉低表示应答ACK保持高电平表示非应答NACK。把这些信号单元串起来一次完整的I2C传输是这样的主机发送起始条件主机发送7位从机地址 1位读写方向位0写1读从机拉低SDA应答主机发送或接收数据字节每字节后跟一个应答位主机发送停止条件时序参数方面标准模式100kHz下SCL高电平时间和低电平时间各不小于4.7μs和4.0μs快速模式400kHz下分别不小于0.6μs和1.3μs。软件模拟时这些时间靠延时函数来保证。2.3 软件模拟与硬件外设的本质差异很多人以为软件模拟I2C就是用代码把硬件做的事重做一遍其实两者的工作方式有本质区别。硬件I2C外设是一个独立的状态机你只需要把数据写入数据寄存器配置好从机地址和方向外设会自动生成完整的时序波形CPU在此期间可以去做别的事传输完成后产生中断通知你。它的时序精度由硬件时钟保证不受中断和任务调度影响。软件模拟I2C则是CPU逐位操作GPIO每一位都要经历拉低SCL→设置SDA→拉高SCL→延时→拉低SCL这样的循环。CPU全程被占用而且如果中途来了中断时序就可能被拉长。对于从机来说时钟变慢通常没问题I2C没有最小时钟频率要求但如果从机有时钟超时检测就可能出问题。所以软件模拟I2C的适用场景很明确低速、短距离、对时序精度要求不苛刻、或者硬件I2C不可用/不够用的情况。反过来如果你需要高速连续传输大量数据还是老老实实用硬件I2C加DMA。3. 从时序图到代码软件模拟I2C的逐行实现3.1 GPIO的初始化与方向切换策略软件模拟I2C的第一个关键点是GPIO的工作模式。由于I2C是开漏总线理想情况下SDA和SCL都应该配置为开漏输出模式配合外部上拉电阻使用。但很多初学者用的是推挽输出这在单主单从、没有总线冲突的场景下也能跑但一旦总线上有多个器件同时驱动推挽输出会导致电流倒灌长期下来可能损坏引脚。我的做法是分两种情况有外部上拉电阻SDA和SCL都配置为开漏输出GPIO_MODE_OUTPUT_OD这样最符合I2C规范。没有外部上拉电阻利用MCU内部上拉如果有配置为开漏输出加内部上拉。但内部上拉通常几十kΩ上升沿很慢只适合很低速的场合。对于SDA线因为需要在发送和接收之间切换方向有两种处理方式方式一动态切换输入/输出模式。发送时配置为输出接收时配置为输入。这种方式逻辑清晰但每次切换都要操作寄存器稍慢。方式二始终配置为开漏输出读取时直接读输入寄存器。开漏输出模式下输出寄存器写1时引脚实际由外部上拉决定电平此时读输入寄存器就能得到总线真实状态。这种方式不需要切换模式效率更高也是我推荐的做法。// GPIO初始化示例以STM32 HAL库风格为例其他平台同理 void I2C_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; // 使能GPIO时钟 __HAL_RCC_GPIOB_CLK_ENABLE(); // SCL配置开漏输出高速 GPIO_InitStruct.Pin I2C_SCL_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull GPIO_PULLUP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(I2C_SCL_PORT, GPIO_InitStruct); // SDA配置开漏输出高速 GPIO_InitStruct.Pin I2C_SDA_PIN; HAL_GPIO_Init(I2C_SDA_PORT, GPIO_InitStruct); // 初始状态两根线都拉高释放总线 I2C_SCL_H(); I2C_SDA_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 #define I2C_SCL_H() HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET) #define I2C_SCL_L() HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET) #define I2C_SDA_H() HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_SET) #define I2C_SDA_L() HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_RESET) #define I2C_SDA_READ() HAL_GPIO_ReadPin(I2C_SDA_PORT, I2C_SDA_PIN)3.2 延时函数的精度控制与速率计算延时是软件模拟I2C的节奏控制器。延时太长通信慢延时太短从机来不及响应。I2C标准模式要求SCL高低电平各至少4.7μs和4.0μs对应约100kHz。快速模式要求各至少0.6μs和1.3μs对应约400kHz。最粗糙的做法是用for循环空转static void I2C_Delay(void) { for (volatile uint32_t i 0; i 10; i); }这个循环的实际延时取决于MCU主频和编译器优化等级换一个平台就要重新调。更可靠的做法是用硬件定时器或者系统滴答定时器SysTick做微秒级延时static void I2C_DelayUs(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t cycles us * (SystemCoreClock / 1000000); while ((DWT-CYCCNT - start) cycles); }DWTData Watchpoint and Trace是Cortex-M内核自带的周期计数器精度高、开销小。用之前需要先使能void DWT_Init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; }有了精确的微秒延时就可以计算通信速率了。一个完整的SCL周期包括高电平时间和低电平时间。如果各延时2.5μs周期就是5μs对应200kHz。实际项目中我一般把标准模式的目标定在100kHz左右延时取4~5μs留足余量。注意延时函数里不要调用任何可能被中断打断的操作。如果系统中断频繁考虑在I2C传输期间关闭全局中断或者至少把I2C传输放在高优先级任务中。3.3 起始、停止、应答信号的代码级实现起始条件SCL为高时SDA从高变低。void I2C_Start(void) { I2C_SDA_H(); // 先确保SDA为高 I2C_SCL_H(); // SCL拉高 I2C_DelayUs(4); // 等待电平稳定 I2C_SDA_L(); // SCL高时SDA拉低——起始条件 I2C_DelayUs(4); I2C_SCL_L(); // 钳住SCL准备传输数据 I2C_DelayUs(4); }停止条件SCL为高时SDA从低变高。void I2C_Stop(void) { I2C_SDA_L(); // 先确保SDA为低 I2C_SCL_H(); // SCL拉高 I2C_DelayUs(4); I2C_SDA_H(); // SCL高时SDA拉高——停止条件 I2C_DelayUs(4); }发送应答位主机在接收完一个字节后需要发送ACK或NACK。void I2C_SendAck(uint8_t ack) { I2C_SCL_L(); if (ack) I2C_SDA_H(); // NACK保持高 else I2C_SDA_L(); // ACK拉低 I2C_DelayUs(4); I2C_SCL_H(); // 从机在SCL高时采样 I2C_DelayUs(4); I2C_SCL_L(); I2C_DelayUs(4); I2C_SDA_H(); // 释放SDA }等待从机应答主机发送完一个字节后释放SDA读取从机是否拉低。uint8_t I2C_WaitAck(void) { uint8_t ack; I2C_SDA_H(); // 释放SDA让从机控制 I2C_DelayUs(2); I2C_SCL_H(); // 第9个时钟 I2C_DelayUs(4); ack I2C_SDA_READ(); // 读SDA0ACK1NACK I2C_SCL_L(); I2C_DelayUs(4); return ack; }这里有个细节在拉高SCL之前要先释放SDA并延时一小会儿给从机时间把SDA拉低。如果从机响应慢这个延时不够就会误判为NACK。我在调试AT24C02时遇到过这个问题后来把释放SDA到拉高SCL之间的延时从1μs加到2μs就稳定了。3.4 字节收发MSB先行与时钟同步I2C规定数据高位先行MSB First每个字节8位第9位是应答位。发送一个字节void I2C_SendByte(uint8_t data) { for (uint8_t i 0; i 8; i) { I2C_SCL_L(); // 拉低SCL允许SDA变化 I2C_DelayUs(2); if (data 0x80) // 取最高位 I2C_SDA_H(); else I2C_SDA_L(); data 1; // 左移准备下一位 I2C_DelayUs(2); I2C_SCL_H(); // 拉高SCL从机采样 I2C_DelayUs(4); } I2C_SCL_L(); // 第8位后拉低SCL I2C_DelayUs(2); }接收一个字节uint8_t I2C_RecvByte(void) { uint8_t data 0; I2C_SDA_H(); // 释放SDA由从机驱动 for (uint8_t i 0; i 8; i) { data 1; I2C_SCL_L(); I2C_DelayUs(4); I2C_SCL_H(); // 从机在SCL高时输出数据 I2C_DelayUs(2); if (I2C_SDA_READ()) data | 0x01; // 读到的位放到最低位 I2C_DelayUs(2); } I2C_SCL_L(); I2C_DelayUs(2); return data; }注意接收时是先左移再读位这样第一个读到的位MSB最终会停在最高位。发送时是先判断最高位再左移逻辑是对称的。4. 完整代码框架与典型器件驱动实战4.1 软件模拟I2C的完整头文件与源文件把上面的函数组织成一个可复用的模块。头文件#ifndef __SOFT_I2C_H #define __SOFT_I2C_H #include main.h void I2C_GPIO_Init(void); void I2C_Start(void); void I2C_Stop(void); void I2C_SendAck(uint8_t ack); uint8_t I2C_WaitAck(void); void I2C_SendByte(uint8_t data); uint8_t I2C_RecvByte(void); // 复合操作 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); uint8_t I2C_WriteBuf(uint8_t devAddr, uint8_t regAddr, uint8_t *buf, uint16_t len); uint8_t I2C_ReadBuf(uint8_t devAddr, uint8_t regAddr, uint8_t *buf, uint16_t len); #endif源文件中的复合操作// 写单个寄存器 uint8_t I2C_WriteReg(uint8_t devAddr, uint8_t regAddr, uint8_t data) { I2C_Start(); I2C_SendByte(devAddr 1 | 0); // 地址写 if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(regAddr); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(data); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_Stop(); return 0; } // 读单个寄存器 uint8_t I2C_ReadReg(uint8_t devAddr, uint8_t regAddr, uint8_t *data) { I2C_Start(); I2C_SendByte(devAddr 1 | 0); // 地址写 if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(regAddr); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_Start(); // 重复起始条件 I2C_SendByte(devAddr 1 | 1); // 地址读 if (I2C_WaitAck()) { I2C_Stop(); return 1; } *data I2C_RecvByte(); I2C_SendAck(1); // 最后一个字节回NACK I2C_Stop(); return 0; }这里用到了重复起始条件Repeated Start在读取寄存器时先写寄存器地址然后不发送停止条件直接再发一个起始条件切换为读模式。这是I2C读操作的标配很多初学者会在这里犯错——如果先Stop再Start某些从机如某些传感器会丢失内部寄存器指针。4.2 AT24C02 EEPROM的页写与随机读AT24C02是2Kbit256字节的I2C EEPROM7位地址是1010xxx其中低3位由A2/A1/A0引脚决定。它的写操作有字节写和页写两种模式页大小为8字节。字节写就是上面的I2C_WriteReg。页写则是在一次传输中连续写多个字节但不能跨页——如果起始地址是0x06最多只能再写2个字节到0x07第3个字节会回卷到页首0x00覆盖之前的数据。这是AT24C02最容易踩的坑之一。// AT24C02页写自动处理页边界 uint8_t AT24C02_PageWrite(uint8_t devAddr, uint8_t memAddr, uint8_t *buf, uint8_t len) { uint8_t pageRemain 8 - (memAddr % 8); // 当前页剩余空间 uint8_t written 0; while (len 0) { uint8_t chunk (len pageRemain) ? len : pageRemain; I2C_Start(); I2C_SendByte(devAddr 1 | 0); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(memAddr); if (I2C_WaitAck()) { I2C_Stop(); return 1; } for (uint8_t i 0; i chunk; i) { I2C_SendByte(buf[written i]); if (I2C_WaitAck()) { I2C_Stop(); return 1; } } I2C_Stop(); // EEPROM写周期等待典型5ms HAL_Delay(5); written chunk; len - chunk; memAddr chunk; pageRemain 8; // 后续从新页开始 } return 0; }注意AT24C02每次写操作后需要等待内部写周期完成典型5ms最大10ms期间不会应答任何通信。如果不等就发下一次起始条件会得到NACK。我一般用固定延时5ms如果对速度有要求可以用应答轮询方式反复发起始条件地址直到收到ACK为止。随机读则是先写内存地址伪写再重复起始条件切换为读uint8_t AT24C02_RandomRead(uint8_t devAddr, uint8_t memAddr, uint8_t *buf, uint8_t len) { I2C_Start(); I2C_SendByte(devAddr 1 | 0); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(memAddr); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_Start(); // 重复起始 I2C_SendByte(devAddr 1 | 1); if (I2C_WaitAck()) { I2C_Stop(); return 1; } for (uint8_t i 0; i len; i) { buf[i] I2C_RecvByte(); if (i len - 1) I2C_SendAck(0); // 非最后字节回ACK else I2C_SendAck(1); // 最后字节回NACK } I2C_Stop(); return 0; }4.3 SSD1306 OLED的初始化与显存刷新SSD1306是128x64的OLED驱动芯片I2C地址通常是0x3C7位。它的显存是128x64位按页组织8页每页8行每行128列。写显存时先设置页地址和列地址然后连续写数据。初始化序列比较长这里只列关键步骤void SSD1306_Init(void) { uint8_t cmds[] { 0xAE, // 关闭显示 0xD5, 0x80, // 设置时钟分频 0xA8, 0x3F, // 设置多路复用比 0xD3, 0x00, // 设置显示偏移 0x40, // 设置起始行 0x8D, 0x14, // 使能电荷泵 0x20, 0x00, // 水平寻址模式 0xA1, // 段重映射 0xC8, // 扫描方向 0xDA, 0x12, // COM引脚配置 0x81, 0xCF, // 对比度 0xD9, 0xF1, // 预充电周期 0xDB, 0x40, // VCOMH电压 0xA4, // 显示跟随RAM 0xA6, // 正常显示 0xAF // 开启显示 }; for (uint16_t i 0; i sizeof(cmds); i) { I2C_WriteReg(0x3C, 0x00, cmds[i]); // 0x00表示命令 } }刷新显存时先设置页和列地址然后连续写128字节void SSD1306_Refresh(uint8_t *gram) { for (uint8_t page 0; page 8; page) { I2C_WriteReg(0x3C, 0x00, 0xB0 page); // 设置页地址 I2C_WriteReg(0x3C, 0x00, 0x00); // 列低4位 I2C_WriteReg(0x3C, 0x00, 0x10); // 列高4位 // 连续写128字节显存数据 I2C_Start(); I2C_SendByte(0x3C 1 | 0); I2C_WaitAck(); I2C_SendByte(0x40); // 0x40表示数据 I2C_WaitAck(); for (uint8_t col 0; col 128; col) { I2C_SendByte(gram[page * 128 col]); I2C_WaitAck(); } I2C_Stop(); } }SSD1306的显存刷新数据量大1KB用软件模拟I2C在100kHz下大约需要100ms肉眼能看到刷新过程。如果要做动画建议用硬件I2C或者提高模拟速率到400kHz。5. 调试软件模拟I2C时踩过的坑5.1 从机不应答的排查链路从机不应答NACK是软件模拟I2C最常见的故障。我的排查顺序是这样的第一步确认硬件连接。用万用表量SDA和SCL对VCC的电压正常应该是上拉电阻分压后的高电平接近VCC。如果某根线一直是低说明有器件在持续拉低可能是器件损坏或者地址冲突。第二步确认从机地址。7位地址左移一位后才是发送的字节。比如AT24C02的地址是0x507位发送时要写0xA0写或0xA1读。我见过有人直接把0x50当发送字节结果当然不应答。第三步用示波器看波形。重点看起始条件是否干净、SCL高电平期间SDA是否稳定、第9个时钟从机有没有拉低SDA。如果从机完全没反应可能是供电或复位问题。第四步降低速率。把延时加大到10μs甚至20μs看是否能通。有些从机对时序要求宽松但有些尤其是老型号需要较慢的时钟。第五步检查上拉电阻。如果上升沿太缓SCL高电平还没到阈值就被拉低了从机会认为时钟无效。用示波器看上升时间超过1μs就换小电阻。5.2 时钟拉伸被忽略导致的读写异常时钟拉伸Clock Stretching是I2C协议允许从机在来不及处理数据时主动把SCL拉低强制主机等待。很多软件模拟I2C代码完全没考虑这个机制发送完一个字节后直接拉高SCL如果从机此时正拉低SCL就会产生总线冲突。正确的做法是在拉高SCL之前先释放SCL并检测它是否真的变高了。如果从机在拉伸时钟SCL会保持低主机应该等待。void I2C_SCL_H_Wait(void) { I2C_SCL_H(); // 等待SCL真正变高超时退出防止死锁 uint32_t timeout 10000; while (I2C_SCL_READ() 0 timeout--); }不过这里有个问题如果SCL配置为开漏输出写1后读输入寄存器就能知道总线状态。但如果配置为推挽输出写1就是强驱动高读回来永远是1检测不到从机的拉伸。所以用开漏输出模式是支持时钟拉伸的前提。我在驱动一个温湿度传感器时遇到过这个问题传感器在转换期间会拉伸时钟我的代码没检测结果读回来的数据全是0xFF。加上SCL等待逻辑后就正常了。5.3 多从机场景下的总线仲裁与地址冲突软件模拟I2C通常用于单主机场景但总线上可以挂多个从机。只要地址不冲突多从机完全没问题。但要注意每个从机的地址必须唯一。有些器件的地址由引脚决定PCB设计时要确保引脚接法不重复。总线电容会累加。挂的器件越多上升沿越缓。如果发现通信不稳定先减小上拉电阻试试。不同从机的速率可能不同。如果总线上既有100kHz的器件又有400kHz的器件只能按最低速率通信。如果确实需要多主机软件模拟I2C实现总线仲裁会非常复杂需要实时监测SDA电平是否与自己发送的一致。这种场景建议直接用硬件I2C外设。6. 软件模拟I2C的优化与替代思路6.1 用定时器中断驱动时序的可行性纯延时阻塞的软件模拟I2C会占用CPU大量时间。一个替代思路是用定时器中断来驱动SCL翻转主循环只负责准备数据。这样CPU可以在I2C传输期间处理其他任务。具体做法是配置一个定时器周期设为SCL半周期如5μs在中断服务函数里根据状态机推进时序。状态机包括起始、发送位、等待应答、接收位、停止等状态。这种方式的优点是CPU占用率低缺点是中断频繁100kHz下每5μs一次中断对MCU的中断响应能力有要求。如果系统里还有其他高优先级中断时序可能抖动。我一般只在低速10kHz以下或者MCU负载很轻的场景下用这种方式。6.2 什么时候该果断换回硬件I2C软件模拟I2C是兜底方案不是首选。以下情况应该优先考虑硬件I2C需要高速传输400kHz以上软件模拟很难稳定。需要DMA支持大量数据传输时硬件I2CDMA可以几乎不占CPU。多主机场景硬件I2C自带仲裁逻辑软件模拟实现复杂。低功耗场景硬件I2C传输完成后可以进低功耗模式软件模拟需要CPU全程参与。我现在的习惯是PCB设计时尽量把硬件I2C引脚引出来软件模拟作为备用方案。如果硬件I2C调通了就用硬件调不通或者引脚冲突再切软件模拟。两套代码都留着通过宏定义切换。6.3 代码复用到不同MCU平台的注意事项软件模拟I2C的代码逻辑是平台无关的但GPIO操作和延时函数需要适配。移植时重点改三个地方GPIO宏定义把I2C_SCL_H()、I2C_SDA_READ()等宏改成目标平台的操作。延时函数把I2C_DelayUs()改成目标平台的微秒延时实现。GPIO初始化把开漏输出配置改成目标平台的写法。我一般会把这三部分单独放在一个i2c_port.c文件里上层协议代码完全不动。这样换MCU时只需要重写这个文件几分钟就能搞定。另外不同MCU的GPIO翻转速度差异很大。同样是4μs延时STM32F1和STM32H7的实际波形可能差很多。移植后一定要用示波器或者逻辑分析仪确认时序不要想当然。7. 我在实际项目中总结的几条经验软件模拟I2C看起来简单但真正在项目里稳定运行需要注意的细节不少。分享几条我踩过坑之后总结的经验。第一起始条件之前先确保总线空闲。如果上一次通信异常中断SDA可能被从机拉低。此时直接发起始条件会失败。我的做法是在初始化时发送9个时钟脉冲让从机释放SDA然后发停止条件复位总线。第二读操作最后一个字节必须回NACK。很多从机在收到NACK后才会释放SDA如果最后一个字节回ACK从机会继续等待下一个时钟导致停止条件无法正确产生。第三EEPROM写操作后的延时不能省。AT24C02的5ms写周期是硬性要求省了就会丢数据。如果觉得5ms太长可以用应答轮询但实现起来麻烦除非对写入速度有极致要求否则固定延时最省心。第四示波器是调试I2C最好的朋友。逻辑分析仪能解码协议但示波器能看电平质量。上升沿、毛刺、电平阈值这些问题只有示波器能发现。我调试I2C时示波器基本不离手。第五代码里加超时机制。任何while等待从机应答的地方都要加超时计数否则从机故障时主机会死循环。超时后返回错误码上层可以重试或者报错。第六不同从机的时序要求要查数据手册。标准I2C是100kHz但有些器件支持400kHz甚至1MHz有些只支持100kHz。上电初始化时先用低速确认通信正常后再尝试提速。这套软件模拟I2C代码我在好几个项目里用过驱动过EEPROM、OLED、温湿度传感器、IO扩展芯片等。只要时序参数调对稳定性完全不输硬件I2C。希望这些内容能帮你少走一些弯路。
网站建设高端定制企业官网