STM32F407 HAL库软件模拟I2C实战:GPIO模拟时序与总线恢复
发布时间:2026/9/9 16:41:05 锦皓数字建站

简介一份面向STC单片机开发者的模拟I2C通信程序源码包针对部分STC型号不支持硬件I2C接口的问题使用GPIO引脚精确模拟SCL时钟线与SDA数据线完整实现起始/停止信号、数据收发、应答检测等协议时序。压缩包仅2个文件包含i2c.c源文件和i2c.h头文件i2c.c提供起始/停止/读写等核心函数实现i2c.h声明接口便于工程调用包体仅1KB轻量易移植。已有848人学习下载适合正在学习I2C协议或需要在STC平台上外接传感器、存储器、显示屏等I2C设备的开发者参考。代码中涉及波特率延时设置、多设备地址切换、错误处理与兼容性设计等要点可帮助读者快速理解软件模拟I2C的底层逻辑并在此基础上适配自己的硬件项目。 STM32F407用HAL库实现软件模拟I2C这事我折腾过好几轮。最开始图省事直接用硬件I2C外设结果跟某款传感器通信时老是随机卡死排查了两天才发现是硬件I2C在异常时序下容易锁死总线。后来换了GPIO模拟不但问题消失代码还能无缝移植到其它引脚和芯片上。这篇文章就把我踩过的坑和最终稳定运行的方案完整分享出来。1. 为什么放着硬件I2C不用非要GPIO模拟很多初学者看到模拟I2C第一反应是STM32F407不是自带多个硬件I2C外设吗直接用不就行了这话对但仅限于理想情况。实际项目中我选择GPIO模拟I2C的原因很现实引脚分配自由硬件I2C的引脚是固定的如PB6/PB7对应I2C1一旦PCB布线冲突就得改板。模拟I2C可以用任意两个GPIO布线压力小很多。时序完全可控硬件I2C的时序由外设自动生成出问题时你只能调时钟配置看不到具体波形。模拟I2C的每一拍都是代码控制的逻辑分析仪一抓问题一目了然。兼容性更好有些器件的I2C时序比较挑剔比如需要很长的启动保持时间或者应答时序偏慢。硬件外设调起来麻烦模拟代码改延时函数就行。省一个外设资源F407的I2C外设数量有限如果项目里还需要挂多个总线或做其它用途模拟I2C能释放硬件外设给更需要的场景。代码可移植性强同一份模拟I2C代码改两个宏定义的引脚就能从F407挪到F103、G030甚至其它厂家的MCU上硬件I2C则没这么方便。当然模拟I2C也有代价占用CPU时间。因为所有时序都由代码翻转GPIO实现高速通信时CPU没法干别的。但I2C本身常用于配置寄存器、读取传感器数据速率一般不超过400kHz大多数场景下CPU开销完全可以接受。2. 模拟I2C的核心原理I2C总线只有两根线SCL时钟和SDA数据。所有通信协议都建立在这两根线的电平变化上。理解了这一点模拟I2C的逻辑就清晰了。2.1 开漏输出与上拉电阻I2C协议要求SCL和SDA必须是开漏输出结构。所谓开漏就是GPIO只能主动拉低到GND想输出高电平得靠外部上拉电阻把电平拉上去。这带来两个关键特性线与功能多个设备可以同时挂在总线上任何一个设备拉低总线总线就是低电平。这实现了多主机仲裁和从机时钟拉伸Clock Stretching。电平转换方便上拉电阻接3.3V就是3.3V电平接5V就是5V电平前提是GPIO容忍5V无需额外转换芯片。所以模拟I2C的第一步是配置GPIO为开漏输出模式。在STM32的HAL库中这对应GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; // 开漏输出2.2 时钟频率如何量化I2C速率比如100kHz或400kHz本质上取决于SCL高电平和低电平的持续时间。模拟时这个时间由延时函数控制。我的做法是写一个带参数的延时函数通过调整延时值来控制速率#define I2C_DELAY_TIME 5 // 半周期延时单位us适当调整得到目标频率 static void i2c_delay(void) { volatile uint32_t i I2C_DELAY_TIME; while (i--) { __NOP(); } }一个完整的SCL周期包含高电平延时和低电平延时如果各延时5us周期就是10us对应100kHz。想要400kHz就把延时空循环次数降到1左右。不过延时函数的实际耗时跟编译器优化级别、主频都有关系最好用逻辑分析仪实测校准。注意不同优化等级下volatile变量的循环次数相同但实际耗时可能不同。项目发布版本一定要用-O2或更高优化等级Debug模式测出来的时序不代表最终表现。2.3 起始、停止、应答信号的逻辑I2C通信中最基础的动作是起始信号START、停止信号STOP和应答位ACK。它们的时序逻辑其实非常简单起始信号SCL为高电平时SDA产生一个高到低的下降沿。停止信号SCL为高电平时SDA产生一个低到高的上升沿。应答信号主机在第9个时钟周期释放SDA控制权读取从机拉低的电平。如果是高电平表示NACK无法应答或通信结束。这个逻辑用伪代码表示就是起始SDA高 → SCL高 → SDA拉低 → SCL拉低 停止SDA低 → SCL高 → SDA拉高关键点在于SDA的电平变化必须发生在SCL为低电平期间只有在起始和停止信号时才允许在SCL高电平期间改变SDA。这就是I2C协议的数据有效性规则数据在SCL高电平期间必须保持稳定。3. HAL库下模拟I2C的完整实现我以STM32F407VET6为例使用STM32CubeMX生成工程框架然后手动添加模拟I2C代码。选两个GPIOPB8做SCLPB9做SDA。硬件上这两个引脚各接一个4.7kΩ上拉电阻到3.3V。3.1 CubeMX的GPIO配置在STM32CubeMX中把PB8和PB9配置为GPIO_OutputGPIO mode: Output Open Drain开漏输出GPIO Pull-up/Pull-down: Pull-up内部上拉和外部上拉电阻并联保险用Maximum output speed: High高速模式减少翻转沿的上升时间Initial level: High避免上电瞬间总线意外被拉低这里要说明一下为什么同时开内部上拉。正常情况下I2C需要有外部上拉电阻但如果在原型验证阶段忘记焊电阻内部上拉还能让总线勉强工作。量产设计建议只保留外部上拉因为内部上拉阻值约30-50kΩ对400kHz高速模式来说阻抗太高信号边沿会变缓。3.2 底层引脚操作宏定义宏定义能让代码更简洁也方便后续换引脚#define I2C_SCL_PIN GPIO_PIN_8 #define I2C_SCL_PORT GPIOB #define I2C_SDA_PIN GPIO_PIN_9 #define I2C_SDA_PORT GPIOB #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)读SDA的时候要注意开漏模式下读取引脚电平需要把引脚切回输入模式或者使用HAL库的读引脚函数#define I2C_SDA_READ() HAL_GPIO_ReadPin(I2C_SDA_PORT, I2C_SDA_PIN)HAL_GPIO_ReadPin在开漏输出模式下也能正常工作读取的是引脚实际电平不需要切换模式。3.3 起始与停止信号的实现这是模拟I2C最关键的两个时序函数/** * brief I2C起始信号 * 时序SCL高电平期间SDA产生下降沿 */ void i2c_start(void) { I2C_SDA_H(); I2C_SCL_H(); i2c_delay(); I2C_SDA_L(); // SCL为高时SDA由高变低 i2c_delay(); I2C_SCL_L(); // 拉低SCL准备传输数据 i2c_delay(); } /** * brief I2C停止信号 * 时序SCL高电平期间SDA产生上升沿 */ void i2c_stop(void) { I2C_SDA_L(); i2c_delay(); I2C_SCL_H(); i2c_delay(); I2C_SDA_H(); // SCL为高时SDA由低变高 i2c_delay(); }这两个函数的时序逻辑都不复杂但有一个容易踩坑的地方起始信号生成之前要确保SDA和SCL都处于高电平。如果上次通信在异常状态中结束总线上可能残留低电平。稳妥的做法是在i2c_start开头先强制拉高两个引脚配合延时让电平稳定。我在实际项目中还加了一个总线恢复机制后面会详细讲。3.4 字节发送与接收发送一个字节核心逻辑是高位先行逐位判断数据位并在SCL高电平期间保持SDA稳定/** * brief I2C发送一个字节 * param data: 要发送的数据 * retval 从机应答位0表示ACK1表示NACK */ uint8_t i2c_send_byte(uint8_t data) { uint8_t i; for (i 0; i 8; i) { if (data 0x80) // 高位先发 I2C_SDA_H(); else I2C_SDA_L(); data 1; i2c_delay(); I2C_SCL_H(); // SCL拉高从机在此时采样SDA i2c_delay(); I2C_SCL_L(); // SCL拉低为下一位做准备 i2c_delay(); } // 释放SDA等待从机应答 I2C_SDA_H(); i2c_delay(); I2C_SCL_H(); // 第9个时钟读取应答位 i2c_delay(); uint8_t ack I2C_SDA_READ(); // 读取SDA电平低电平为ACK I2C_SCL_L(); i2c_delay(); return ack; }注意上面的关键一行data 1。这里用的是先判断最高位再左移的方式每次判断data 0x80然后左移一位下一位自然就到最高位了。这个写法比data (1 (7 - i))效率高也更简洁。接收一个字节类似只不过SDA方向变成了输入。我前面提到开漏模式可以直接读引脚因此不需要切换模式/** * brief I2C接收一个字节 * param ack: 主机应答标志0发送ACK继续接收1发送NACK结束接收 * retval 接收到的数据 */ uint8_t i2c_recv_byte(uint8_t ack) { uint8_t i, data 0; // 释放SDA交还总线控制权给从机 I2C_SDA_H(); for (i 0; i 8; i) { data 1; // 先左移因为要一位一位收 I2C_SCL_H(); // SCL拉高从机发送数据 i2c_delay(); if (I2C_SDA_READ()) // 采样SDA电平 data | 0x01; I2C_SCL_L(); // SCL拉低从机准备下一位 i2c_delay(); } // 发送应答位ACK拉低SDANACK释放SDA if (ack) I2C_SDA_H(); // NACK else I2C_SDA_L(); // ACK i2c_delay(); I2C_SCL_H(); // 第9个时钟 i2c_delay(); I2C_SCL_L(); i2c_delay(); return data; }3.5 组合成完整的读写函数有了上面的基础函数就能组合成标准的主机读写流程。以读取某寄存器为例流程是起始信号 → 写入从机地址写标志 → 写入寄存器地址 → 重新起始信号 → 写入从机地址读标志 → 读取数据 → 发送NACK → 停止信号。/** * brief 向I2C设备的寄存器写入一个字节 * param dev_addr: 从机设备地址(7位) * param reg_addr: 寄存器地址 * param data: 要写入的数据 * retval 0成功1失败 */ uint8_t i2c_write_reg(uint8_t dev_addr, uint8_t reg_addr, uint8_t data) { i2c_start(); // 发送设备地址写标志地址左移1位最低位为0表示写 if (i2c_send_byte((dev_addr 1) | 0)) { i2c_stop(); return 1; } // 发送寄存器地址 if (i2c_send_byte(reg_addr)) { i2c_stop(); return 1; } // 发送数据 if (i2c_send_byte(data)) { i2c_stop(); return 1; } i2c_stop(); return 0; } /** * brief 从I2C设备的寄存器读取一个字节 * param dev_addr: 从机设备地址(7位) * param reg_addr: 寄存器地址 * param data: 读取的数据存储指针 * retval 0成功1失败 */ uint8_t i2c_read_reg(uint8_t dev_addr, uint8_t reg_addr, uint8_t *data) { i2c_start(); // 先写寄存器地址 if (i2c_send_byte((dev_addr 1) | 0)) { i2c_stop(); return 1; } if (i2c_send_byte(reg_addr)) { i2c_stop(); return 1; } // 重启起始信号切换为读模式 i2c_start(); if (i2c_send_byte((dev_addr 1) | 1)) { i2c_stop(); return 1; } // 读取数据最后一个字节发送NACK表示读取结束 *data i2c_recv_byte(1); i2c_stop(); return 0; }这个地址处理的写法值得单独说明一下。I2C设备地址有两种形式7位地址和8位地址。上面代码里dev_addr是7位地址左移一位后最低位填入读写标志。很多STM32的硬件I2C库会自动帮你左移但软件模拟时必须自己完成这一步。如果设备数据手册写的是8位地址形式比如0xA0那就不需要左移直接使用即可。新手最容易在这个地方懵。4. 实测调试与常见问题排查代码写完后理论上一编译就能用但实际调试时我几乎每次都会遇到新问题。下面是我在调试模拟I2C时踩过的最典型的几个坑。4.1 用逻辑分析仪验证波形模拟I2C最大的优势就是可以用逻辑分析仪直接验证时序。我的调试流程是把SCL和SDA分别接到逻辑分析仪的CH0和CH1设置采样率不低于2MHz。运行代码捕捉通信波形。对照I2C协议手册逐字节查看数据。重点检查起始/停止信号的建立时间以及每个字节的应答位。我用的逻辑分析仪是几十块钱的8通道USB逻辑分析仪配Saleae软件兼容模式。对100kHz的I2C信号来说2MHz采样率绰绰有余。曾经遇到一次通信异常抓波形发现SDA在起始信号之前有一个意外下降沿仔细检查才发现是上电初始化顺序问题GPIO配置之前引脚处于浮空状态外部设备通过上拉电阻对引脚充电导致电平不稳定。解决方法是初始化时先把引脚拉高再配置模式。4.2 总线死锁恢复这是模拟I2C开发者会遇到的经典问题。从机没有复位或受到干扰时可能处于异常状态表现为主机发送起始信号后从机一直将SDA拉低导致主机无法发送起始信号因为SDA无法变高总线看起来就卡死了。硬件I2C遇此情况会置位错误标志位但软件模拟则可能陷入死循环。我总结了一套有效的总线恢复方案/** * brief 模拟I2C总线恢复 * 尝试将总线恢复到空闲状态最多翻转SCL 9次 * retval 0成功1失败 */ uint8_t i2c_bus_recovery(void) { uint8_t i; // 先将SDA释放SCL拉高 I2C_SDA_H(); I2C_SCL_H(); i2c_delay(); // 检查SDA是否已被释放变为高电平 if (I2C_SDA_READ()) { return 0; // 总线已恢复 } // 若SDA仍为低尝试SCL时钟翻转让从机释放SDA for (i 0; i 9; i) { I2C_SCL_L(); i2c_delay(); I2C_SCL_H(); i2c_delay(); if (I2C_SDA_READ()) { // 总线释放了发送停止信号结束恢复 I2C_SCL_H(); i2c_delay(); I2C_SDA_H(); i2c_delay(); return 0; } } return 1; // 9个时钟都无法恢复可能需要硬件复位 }这个恢复机制的原理是总线上如果有从机正在输出数据拉低SDA它会监控SCL的边沿。通过对外发送多至9个时钟脉冲一个完整字节应答位从机会完成当前的数据传输阶段释放总线。实测下来这个方案能解决绝大多数总线卡死问题。注意总线恢复的前提是硬件接线正确、从机供电正常。如果从机本身已经死机到连SCL都不认那软件手段无法恢复只能硬件复位从机如果有复位引脚可以接一个GPIO专门控制。4.3 上拉电阻与通信速率的取舍上拉电阻的阻值直接影响I2C通信的稳定性和最高速率。阻值越小上升沿越快但低电平时的电流越大阻值越大功耗越小但上升沿变缓高速通信时信号边沿可能不满足协议要求。以3.3V供电的F407为例我实测过不同阻值的表现上拉电阻100kHz400kHz说明1kΩ稳定稳定功耗略大约3.3mA低电平电流4.7kΩ稳定稳定通用选择兼顾功耗与信号质量10kΩ稳定偶尔异常400kHz时上升沿偏缓偶尔误码如果总线上挂了多个从机设备等效上拉电阻会减小可以适当增大阻值。如果是电池供电的物联网设备可以为了省电选择10kΩ并把通信速率降到100kHz。我的经验是默认用4.7kΩ特殊需求再调整。4.4 地址不匹配的排查我在调一个新传感器时经常发现发送了地址后从机一直不回应NACK。排查步骤通常是确认设备地址很多芯片的地址引脚如A0/A1/A2会改变地址。对照数据手册确认7位地址是否与硬件接线一致。确认地址格式判断手册中的地址是7位还是8位形式。如果手册写的是8位地址0xD0那对应的7位地址是0x68。用I2C扫描程序写一个简单的地址扫描函数遍历0x01~0x7F所有地址看哪些地址有应答。这个方法能快速确定设备实际地址。下面是我常用的地址扫描代码void i2c_scan(void) { uint8_t addr; printf(I2C Scan Result:\r\n); for (addr 1; addr 128; addr) { i2c_start(); if (i2c_send_byte((addr 1) | 0) 0) { printf(Found device at 0x%02X\r\n, addr); } i2c_stop(); } }这个扫描需要结合串口打印使用。实测在F407上160个设备的扫描时间不到1秒考虑到软件延时的开销实际会稍微慢一点。4.5 时钟延展没处理导致的读数据失败一些从机设备特别是部分温湿度传感器处理速度较慢在主机发送读取命令后会主动拉低SCL让主机等待。这个机制叫时钟延展Clock Stretching。硬件I2C外设有些会自动处理时钟延展但软件模拟需要自己检查在SCL释放高电平后等待从机释放SCL。uint8_t i2c_wait_scl_high(uint32_t timeout) { uint32_t count 0; while (count timeout) { if (HAL_GPIO_ReadPin(I2C_SCL_PORT, I2C_SCL_PIN) GPIO_PIN_SET) { return 0; // SCL已变高 } count; } return 1; // 超时 }这个延时等待函数的引入意味着之前接收函数中I2C_SCL_H()后要插入等待逻辑。这样做的代价是每一次SCL拉高都要多调用一次读取函数指令周期会增加不少。但如果确认自己的从机设备不支持时钟延展可以不用这个机制以提升速度。5. 模拟I2C的边界与选择建议最后说说模拟I2C的适用边界。我目前的项目里如果是简单设备温度传感器、EEPROM、RTC、IO扩展芯片等通信频率不超过400kHz我基本都用模拟I2C。代码量小、问题直观、移植方便省了很多调试时间。如果是需要大规模数据传输比如和摄像头模组通信、总线挂载设备很多、或者MCU主频本身较低无法挤出CPU时间的情况我建议还是优先考虑硬件I2C甚至可以用SPI替代。还有一点补充如果你用的是STM32CubeMX HAL库开发方式可以这么理解生成的GPIO初始化和手动添加的模拟I2C代码两者的关系就像搭积木CubeMX负责搭基础的引脚配置模拟I2C是你的业务层。CubeMX重新生成工程后不会影响已有的模拟I2C函数只要你不是把它们写在CubeMX生成区/* USER CODE BEGIN */和/* USER CODE END */之外就行。模块化设计的好处是以后换任何MCU平台只要改掉宏定义底层的引脚操作和延时函数上层的读写逻辑完全不用动。我习惯把模拟I2C封装成一个独立文件soft_i2c.c和soft_i2c.h对外只暴露i2c_write_reg、i2c_read_reg、i2c_bus_recovery这几个接口。不管底层是STM32、GD32还是AT32不管上游是用寄存器还是HAL库上层代码永远只面对这几个API。这也是我推荐所有做MCU开发的朋友养成的习惯底层驱动与业务逻辑解耦移植时能省下大量时间。最后再分享一个小技巧写完模拟I2C后建议把延时函数里的空循环次数抽成一个宏在调试时通过修改宏快速切换100kHz和400kHz速率配合逻辑分析仪观察波形变化。我之前调试某颗触控芯片时就是靠这个方法对比不同速率下的波形差异最终发现该芯片在400kHz模式下需要更长的时间设置时钟延展从而定位了问题根因。有时候问题并不在协议逻辑本身而在于时序余量不足。掌握模拟I2C的底层细节对你理解I2C协议本身的帮助也是硬件I2C外设无法替代的。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。