ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32 GPIO模拟I2C从原理到实践:代码、时序与排坑全攻略

2026/10/5 14:58:32 拓冰建站 浏览量
STM32 GPIO模拟I2C从原理到实践:代码、时序与排坑全攻略 做嵌入式开发的朋友多半都有过这样的体验项目里需要挂一个传感器或者EEPROM明明MCU自带硬件I2C外设可真调起来却折腾得够呛——要么引脚被占用换不了要么总线上挂多个器件频繁卡死要么状态机进了死胡同只能复位。后来我遇到这种情况干脆直接用IO口模拟I2C时序两根GPIO线手写协议反而稳得一批。这篇文章就围绕STM32平台把IO口模拟I2C的思路、代码实现、时序参数计算和排坑经验完整捋一遍给正在被I2C折磨的朋友一个可以直接抄作业的方案。本文的核心内容是基于STM32使用普通GPIO引脚模拟I2C主模式时序适用于以下场景硬件I2C外设引脚被复用、需要将I2C功能映射到任意IO口、项目中同时挂多路I2C总线、或者纯粹想深入理解I2C协议底层机制。无论你是刚入门STM32的新手还是被硬件I2C折磨过的老手这篇文章的代码和思路都能直接用。1. 为什么需要软件模拟I2C——先搞清楚硬件外设的痛点很多初学者会有疑问STM32明明自带硬件I2C为什么还要费劲用GPIO模拟我在实际项目中遇到过几个非常典型的场景。1.1 硬件I2C外设的局限性在哪里STM32的硬件I2C外设确实功能完整有起始停止条件自动生成、应答检测、仲裁、时钟拉伸看起来很省心。但落实到实际项目中问题就来了。首先是引脚固定问题。以F103系列为例硬件I2C1只能映射到PB6/PB7或者PB8/PB9重映射I2C2固定在PB10/PB11。如果PCB布局把这两个引脚给了别的功能比如接了RS485方向控制或者PWM输出硬件I2C基本就废了。PCB一旦打样出来芯片内部外设又不能改脚只能另想办法。IO口模拟I2C最大的好处就是引脚任意SCL和SDA可以放在任意两个空闲GPIO上板子怎么画都行硬件设计自由度直接拉满。其次是状态机卡死问题。硬件I2C在异常情况下比如从设备无应答、总线上有毛刺、从设备复位导致时序错乱容易卡在某个状态无法恢复需要软件复位外设甚至重启MCU。这个在很多厂家的MCU上都有类似问题STM32的I2C外设也不例外。软件模拟就没有这个问题——时序完全由自己控制任何时刻发现不对直接重新初始化GPIO再发起始信号就能恢复正常。还有多路I2C总线需求。一块板子上挂好几组传感器分别在不同电压域或者需要隔离的场景硬件I2C只有2个不够用。GPIO模拟可以随意开几组只要定时器资源和CPU负载允许就行。1.2 模拟I2C的核心思路与协议本质I2C协议本质上是两根线SCL时钟线、SDA数据线上的电平时序约定。只要严格按照时序图控制这两个引脚的电平变化放在哪个引脚上其实无所谓。这就是IO口模拟的理论基础。模拟I2C相比硬件外设的优势很明确引脚随便挑、出问题随时复位、代码完全透明可控。缺点也很明显占用CPU时间传输速率受制于GPIO翻转速度和中断延迟。但对于绝大多数传感器、EEPROM、RTC这类低速设备100kHz的标准模式完全够用CPU那点开销几乎可以忽略不计。2. I2C协议时序拆解——先看懂波形再去写代码写模拟I2C之前必须把协议时序图看懂。网络上I2C时序图很多这里把关键部分拆开讲透搞清楚每个电平跳变的含义代码就是顺理成章的事。2.1 起始信号与停止信号的精确含义I2C总线的空闲状态是SCL和SDA都是高电平。起始信号START的定义是在SCL为高电平期间SDA从高电平跳变到低电平。停止信号STOP的定义是在SCL为高电平期间SDA从低电平跳变到高电平。这两个信号是I2C通信开始和结束的标志由主机发出。注意关键点是SCL为高电平期间这个条件非常重要。也就是说收发数据时SDA必须在SCL低电平期间变化而起始和停止信号恰恰相反是在SCL高电平期间变化。这个细节很多新手容易搞混我在调试时也踩过这个坑——数据线和时钟线的变化时机搞反了总线上所有设备直接罢工。起始和停止信号之间有一个最小时间间隔标准模式下要求起始信号建立时间tSU:STA最小4.7us停止信号建立时间tSU:STO最小4.0us。这些参数在代码延时里都要体现出来。2.2 数据字节与应答机制起始信号之后是设备地址和读写方向位接着是数据字节最后是停止信号。每个字节的传输都是MSB先行高位在前总共8位。每一位数据在SCL低电平期间由主机或从机放到SDA线上在SCL高电平期间被对方采样。所以数据位的规则是SCL低电平时改变SDASCL高电平时保持稳定。发送方在SCL低电平期间设置SDA接收方在SCL高电平期间读取SDA。8位数据发送完成后第9个时钟周期是应答位ACK。应答的规则是发送方在第9个时钟前释放SDA拉高由接收方在第9个时钟的低电平期间将SDA拉低表示应答拉低表示ACK保持高表示NACK。读写数据的应答规则不同主机写从机时从机应答每个字节主机读从机时主机在收到最后一个字节前需要给从机ACK收到最后一个字节后给NACK表示不再读取。2.3 时序参数与延时计算I2C标准模式100kHz和快速模式400kHz的时序参数不同模拟I2C通常跑标准模式就够了。标准模式下核心参数如下参数含义标准模式最小值tHD:STA起始信号保持时间4.0ustSU:STA起始信号建立时间4.7ustSU:STO停止信号建立时间4.0ustSU:DAT数据建立时间250nstHD:DAT数据保持时间0ns实际至少几百nstHIGHSCL高电平时间4.0ustLOWSCL低电平时间4.7us从SCL时钟周期来看标准模式周期是10us高低电平各约5us。以STM32F103主频72MHz计算一个NOP指令周期约13.9ns要实现5us延时大约需要360个NOP。实际代码中常用空循环加NOP实现简单延时函数大致如下static void i2c_delay_us(uint32_t us) { for (uint32_t i 0; i us; i) { for (volatile uint32_t j 0; j 8; j) { __NOP(); } } }这个延时函数在主频72MHz下大约延时1us具体和编译器优化级别有关实测5us延时误差在可接受范围内。更精确的做法是用DWT计数器或定时器延时但I2C是慢速协议几百纳秒误差不影响通信。3. 代码实现——从GPIO配置到完整时序函数理论基础有了现在开始写代码。下面的代码基于STM32标准库切换到HAL库思路完全一样重点在时序函数逻辑具体GPIO宏根据自己芯片型号改一下就好。3.1 GPIO模式的选择与配置模拟I2C的GPIO配置有个重要细节SDA引脚最好配置为开漏输出并外接上拉电阻。SCL同理也配置为开漏输出。为什么必须用开漏两个原因。第一开漏输出配合外部上拉可以实现线与功能——总线上任意设备拉低该线就是低电平。这是I2C协议多设备共享总线的基础。第二SDA既要做输出又要做输入开漏模式下需要读取时直接把引脚切到输入模式或者直接读取输出数据寄存器的映射电平。实际代码中我习惯把SDA配置为开漏输出读取时切换为输入模式或者配置为开漏输出后读取时先写1释放总线再用GPIO的IDR寄存器读取电平。下面代码采用第一种方式逻辑更清晰。#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 SCL_H() GPIO_SetBits(I2C_SCL_PORT, I2C_SCL_PIN) #define SCL_L() GPIO_ResetBits(I2C_SCL_PORT, I2C_SCL_PIN) #define SDA_H() GPIO_SetBits(I2C_SDA_PORT, I2C_SDA_PIN) #define SDA_L() GPIO_ResetBits(I2C_SDA_PORT, I2C_SDA_PIN) #define SDA_IN() { GPIO_InitTypeDef GPIO_InitStructure;\ GPIO_InitStructure.GPIO_Pin I2C_SDA_PIN;\ GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING;\ GPIO_Init(I2C_SDA_PORT, GPIO_InitStructure); } #define SDA_OUT() { GPIO_InitTypeDef GPIO_InitStructure;\ GPIO_InitStructure.GPIO_Pin I2C_SDA_PIN;\ GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD;\ GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz;\ GPIO_Init(I2C_SDA_PORT, GPIO_InitStructure); } #define SDA_READ() GPIO_ReadInputDataBit(I2C_SDA_PORT, I2C_SDA_PIN)GPIO初始化函数void i2c_gpio_init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); // SCL配置为开漏输出 GPIO_InitStructure.GPIO_Pin I2C_SCL_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(I2C_SCL_PORT, GPIO_InitStructure); // SDA先配置为开漏输出后续读写时切换 GPIO_InitStructure.GPIO_Pin I2C_SDA_PIN; GPIO_Init(I2C_SDA_PORT, GPIO_InitStructure); // 初始状态SCL和SDA都拉高总线空闲 SCL_H(); SDA_H(); }3.2 起始、停止、应答读写函数上面的GPIO准备工作完成后核心时序函数逐个实现。起始信号对应2.1小节讲的定义SCL高电平期间SDA拉低。void i2c_start(void) { SDA_OUT(); SDA_H(); SCL_H(); i2c_delay_us(5); SDA_L(); // SCL为高时SDA拉低产生起始信号 i2c_delay_us(5); SCL_L(); // 拉低SCL准备发送数据 } void i2c_stop(void) { SDA_OUT(); SDA_L(); SCL_H(); i2c_delay_us(5); SDA_H(); // SCL为高时SDA拉高产生停止信号 i2c_delay_us(5); }这里start和stop函数里的延时对应协议中的tHD:STA和tSU:STO都是4us以上。我习惯统一用5us留出余量。应答检测函数主机发送8位数据后释放SDA在第9个时钟读取从设备的应答电平uint8_t i2c_wait_ack(void) { uint8_t ack 0; SDA_IN(); // SDA切为输入读取从设备应答 SDA_H(); // 释放总线从设备才能拉低 i2c_delay_us(2); SCL_H(); i2c_delay_us(5); if (SDA_READ()) { ack 1; // 读到高电平无应答 } SCL_L(); i2c_delay_us(2); return ack; }主机发送应答和非应答void i2c_send_ack(uint8_t ack) { SDA_OUT(); if (ack) { SDA_L(); // 拉低表示ACK } else { SDA_H(); // 释放表示NACK } i2c_delay_us(2); SCL_H(); i2c_delay_us(5); SCL_L(); i2c_delay_us(2); SDA_H(); }3.3 发送和接收一个字节的完整逻辑发送字节函数MSB先行每次在SCL低电平期间改变SDAvoid i2c_send_byte(uint8_t data) { SDA_OUT(); for (uint8_t i 0; i 8; i) { if (data 0x80) { SDA_H(); } else { SDA_L(); } data 1; i2c_delay_us(2); // 数据建立时间 SCL_H(); i2c_delay_us(5); // SCL高电平时间 SCL_L(); i2c_delay_us(2); // SCL低电平时间 } }接收字节函数先释放SDA让从设备发送数据每个时钟周期读取SDA引脚电平uint8_t i2c_recv_byte(void) { uint8_t data 0; SDA_H(); // 释放SDA SDA_IN(); for (uint8_t i 0; i 8; i) { data 1; SCL_H(); i2c_delay_us(5); if (SDA_READ()) { data | 0x01; } SCL_L(); i2c_delay_us(2); } return data; }有的实现里会在切换SDA_IN之前设置SDA_H()这个非常重要。如果SDA还是输出模式且输出为低切换为输入后引脚被内部上拉/外部上拉拉高需要时间直接读的话可能读到不确定电平。4. 完整驱动框架——以AT24C02为例的读写实战时序函数全部到位后组一个完整的EEPROM读写驱动验证整个方案是否可用。以AT24C02为例这是I2C通信最经典的从设备8Kbit容量硬件I2C和模拟I2C入门必玩。4.1 设备地址与读写流程AT24C02的I2C设备地址是7位由硬件引脚A2、A1、A0决定。常见模块上这三个引脚全部接地所以地址是0x50二进制1010000。往总线上发送时7位地址左移1位最低位是读写方向0表示写1表示读。写操作地址是0xA0读操作地址是0xA1。写一个字节到指定内存地址的完整流程起始信号→发送设备地址写方向→等待ACK→发送内存地址→等待ACK→发送数据字节→等待ACK→停止信号。读一个字节需要两次通信先发送设备地址写方向和内存地址然后重新发送起始信号重复起始再发送设备地址读方向读取数据后主机发送NACK最后停止。4.2 完整代码展示#define AT24C02_ADDR 0xA0 // 器件地址写A0/A1/A2全接地 // 写一个字节到指定地址 uint8_t at24c02_write_byte(uint8_t mem_addr, uint8_t data) { i2c_start(); i2c_send_byte(AT24C02_ADDR); // 发送设备地址 写位 if (i2c_wait_ack()) { i2c_stop(); return 1; } i2c_send_byte(mem_addr); // 发送内存地址 if (i2c_wait_ack()) { i2c_stop(); return 1; } i2c_send_byte(data); // 发送数据 if (i2c_wait_ack()) { i2c_stop(); return 1; } i2c_stop(); return 0; } // 从指定地址读一个字节 uint8_t at24c02_read_byte(uint8_t mem_addr, uint8_t *data) { i2c_start(); i2c_send_byte(AT24C02_ADDR); // 发送设备地址 写位 if (i2c_wait_ack()) { i2c_stop(); return 1; } i2c_send_byte(mem_addr); // 发送内存地址 if (i2c_wait_ack()) { i2c_stop(); return 1; } i2c_start(); // 重复起始信号 i2c_send_byte(AT24C02_ADDR | 0x01); // 发送设备地址 读位 if (i2c_wait_ack()) { i2c_stop(); return 1; } *data i2c_recv_byte(); i2c_send_ack(1); // 主机发送NACK表示读完了 i2c_stop(); return 0; }调用示例int main(void) { uint8_t read_data 0; i2c_gpio_init(); // 往0x00地址写入0x5A at24c02_write_byte(0x00, 0x5A); // EEPROM写周期需要5ms左右等待一下 for (volatile uint32_t i 0; i 720000; i); // 读回验证 at24c02_read_byte(0x00, read_data); // read_data应该等于0x5A if (read_data 0x5A) { // 通信成功 } while (1); }4.3 实测效果与注意事项把这段代码烧进STM32用逻辑分析仪抓波形没有逻辑分析仪就用示波器能看到标准的起始信号、地址帧、数据帧、应答位和停止信号。SCL时钟频率实测大约90kHz在标准模式100kHz以内符合I2C规范。这里有个细节必须提醒EEPROM写入完成后有内部写周期AT24C02大概需要5ms。写完立刻去读会读不到数据因为此时EEPROM还在忙着把数据写入内部存储阵列不响应任何I2C请求。这个不是时序问题是芯片自身特性需要加延时或者用轮询ACK的方式等待写完成。另外我遇到过一种情况快速连续写多个字节时如果每个字节之间不加间隔直接发偶尔会丢数据。用轮询ACK方式等待写完成更保险——写入完成后EEPROM会在收到设备地址后应答否则返回NACK。5. 常见问题与排查技巧实录代码写完不代表万事大吉实际调试中总会碰到各种问题。这里把我在项目里遇到过的典型问题整理成速查表每个问题都附上排查思路和解决办法。5.1 时序信号异常——SCL拉不高、SDA电平不对表现逻辑分析仪抓到的波形中SCL或者SDA高电平只有零点几伏信号升不上去。排查顺序先看硬件SCL和SDA是否有外部上拉电阻。I2C总线必须配置上拉电阻STM32的GPIO内部上拉太弱驱动不了多设备总线。我一般用4.7k欧姆总线设备多或通信距离长时用2.2k或1k。没有上拉电阻开漏输出根本拉不出高电平。再看代码确认GPIO是否配置为开漏输出。如果误配成了推挽输出虽然功能上能通信但电平兼容性和抗干扰能力都会变差极端情况下还会拉低总线影响到其他设备。5.2 从设备一直无应答NACK表现发送设备地址后i2c_wait_ack()返回1从设备没有拉低SDA。排查顺序先拿逻辑分析仪确认设备地址对不对。AT24C02地址是0x50左移一位变0xA0如果A0/A1/A2引脚不是全接地地址要相应变化。很多模块上这几个引脚有拨码开关或者跳线地址不匹配最常见。接着确认从设备是否上电。I2C接口的芯片如果供电电压不对或者干脆没焊接好自然不会应答。用万用表测一下VCC和GND之间电压。再考虑总线电容问题。如果SCL和SDA走线过长或者总线上挂了很多设备信号边沿变缓可能导致从设备采样失败。这时降低I2C时钟频率加大延时函数里的延时或者换更小的上拉电阻比如从4.7k换成2.2k通常能解决。5.3 数据读出来全是0xFF或者乱码表现写进去0x5A读出来变成0xFF或者每次读出来的数据随机。这种问题多半在接收字节函数。检查接收函数里是否在SDA_IN()之前正确释放总线SDA_H()。如果SDA还处于输出模式且输出低电平直接切换成输入引脚电平被拉低读到的全是0。另外注意接收数据时每个SCL高电平周期内要等延时稳定后再读SDA我在代码里用i2c_delay_us(5)保证采样点在SCL高电平中间位置。还有一个隐蔽的坑如果代码里使用了中断而中断服务函数里刚好操作了同一组GPIO或者占了太长时间会导致I2C时序被拉长、SCL高电平期间的SDA数据被意外改变。用模拟I2C时通信过程中最好关闭相关中断或者把I2C延时时间加长让中断不影响采样点。5.4 总线死锁恢复方法表现程序运行一段时间后I2C通信卡死SDA一直被某个从设备拉低主机无法发出起始信号。这是因为通信过程中某个字节传输被打断从设备还在等待后续时序。有一个通用恢复技巧主机反复发送9个SCL时钟脉冲同时让SDA保持高电平把从设备的内部状态机复位掉。代码实现很简单void i2c_bus_recover(void) { SDA_OUT(); SDA_H(); SCL_H(); for (uint8_t i 0; i 9; i) { SCL_L(); i2c_delay_us(5); SCL_H(); i2c_delay_us(5); } i2c_stop(); }加上这个恢复函数后即使通信中途异常主程序也能在下一轮通信前把总线恢复到空闲状态。我在批量产测固件里加了这层保护总线卡死率基本降为零。6. 模拟I2C的扩展应用与初始化检查除了基础的EEPROM读写模拟I2C能做的扩展很多。比如一个项目里要接多个不同型号的传感器每组传感器有自己的I2C地址可以靠两条GPIO线依次访问。如果板子上有两组不同电压域的设备可以开两组软件I2C一组挂3.3V的传感器一组挂5V的RTC每组独立上拉互不干扰。关于初始化顺序再分享一个细节gpio_init()函数里在配置完GPIO后要主动把SCL和SDA拉高一次。这个动作很重要。如果上电瞬间GPIO默认是浮空输入或者输出寄存器默认是0SCL/SDA可能短暂拉低总线。有些对时序敏感的从设备在上电时发现总线不空闲内部状态机会出错。初始化时先拉高总线进入空闲状态后续通信才可靠。从我个人的实际经验来看升级到HAL库后模拟I2C的代码逻辑不需要变只需要改一下GPIO读写宏#define SCL_H() HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET) #define SCL_L() HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET) #define SDA_H() HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_SET) #define SDA_L() HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_RESET) #define SDA_READ() HAL_GPIO_ReadPin(I2C_SDA_PORT, I2C_SDA_PIN)这样一套代码基本能通吃F1、F4、H7系列换芯片时只改引脚定义时序函数完全复用。有朋友问过我H7系列的GPIO翻转速度更快是不是可以把延时缩短跑400kHz快速模式理论上可以但实际还要考虑从设备是否支持快速模式、总线电容和PCB走线质量。我建议先用标准模式跑通功能再逐步缩短延时测试极限稳定之后再固定参数不要一步到位。最后分享一个我踩过的坑在延时函数里如果用优化等级高的编译器选项空的for循环可能被优化掉延时时间变得极短或者函数被完全忽略导致I2C时序完全错乱。解决方法是把循环变量声明为volatile或者在循环里加__NOP()确保编译器不会优化掉延时。这个检查清单建议放在所有模拟I2C代码实现之前先确认延时函数在目标优化级别下确实产生了预期的延时间隔再往下走就不容易出怪问题了。