深入理解STM32软件I2C:从时序逻辑到实战代码的完整拆解
你有没有遇到过这种情况:项目中明明有两个I2C外设,但其中一个被EEPROM占了,另一个又连着OLED,这时候突然要加一个温湿度传感器——引脚不够用了怎么办?
或者更糟心的是,硬件I2C莫名其妙“死锁”,状态寄存器卡在BUSY不放,复位都无效?
别急。今天我们就来聊一个嵌入式开发里的“老手艺”——软件I2C(也叫GPIO模拟I2C)。它不像硬件I2C那样“高大上”,但它足够灵活、足够稳定,尤其适合那些资源紧张、调试复杂的小型化系统。
更重要的是:搞懂软件I2C,你就真正看穿了I2C协议的本质。
为什么还要用软件I2C?硬件不是更好吗?
确实,STM32几乎每款芯片都集成了至少一两个I2C控制器。那为啥还要手动去翻GPIO、写延时、一位位发数据?
答案是:现实开发没那么理想。
硬件I2C的三大痛点
资源有限
很多小封装MCU只有1~2个I2C接口,而现代物联网设备动辄连接四五种I2C器件(传感器、触控、RTC、显示屏……),根本不够分。引脚受限
并非所有GPIO都能复用为I2C功能。有些引脚没有AF功能,或者PCB布局时已经占用,没法改。稳定性问题
特别是在STM32F1/F4系列中,硬件I2C模块存在著名的“死锁”Bug:当总线异常(比如从机掉电)时,SR2寄存器的BUSY位可能永远置位,导致整个I2C外设瘫痪,只能靠复位解决。
而软件I2C完全绕开这些坑——它不依赖任何专用外设,只靠两个普通GPIO和一段精准控制的代码,就能实现可靠的通信。
I2C协议的核心机制:你真的懂“起始条件”吗?
在动手写代码之前,我们必须先搞清楚一件事:I2C到底是怎么传数据的?
很多人背过口诀:“SCL高时SDA下降沿是起始,上升沿是停止”。但这背后其实有一套严格的物理层规则。
两根线,四种状态
- SCL:主控时钟线,由主机驱动
- SDA:双向数据线,所有设备共享
关键点在于:
✅SDA只能在SCL为低电平时改变电平;一旦SCL拉高,SDA必须保持稳定,否则会被当作控制信号!
这就是所谓的“建立时间与保持时间”要求。
所以你看下面这个典型波形:
SCL: ──┐ ┌───┐ ┌───┐ ┌── ... │ │ │ │ │ │ SDA: ──┼───┐ │ └───┐ │ └───┐ │ ┌── ... │ ▼ ▼ ▼ ▼ ▼ ▼ │ └── Start Data0 Data7 ACK你会发现:
- 起始条件:SCL高 → SDA从高变低
- 停止条件:SCL高 → SDA从低变高
- 数据变化全发生在SCL为低期间
- 每个字节后有一个ACK/NACK周期(第9个时钟)
这正是我们用软件模拟的基础逻辑。
软件I2C如何工作?一步步还原通信过程
既然不能靠硬件自动产生波形,那就只能“手搓”每一个电平跳变了。
整个流程就像一场精密的舞蹈,主角是你写的代码,舞台是SCL和SDA这两条线。
四大基本动作详解
1. 起始条件(Start Condition)
void i2c_start(void) { // 初始状态:SCL=1, SDA=1 HAL_GPIO_WritePin(I2C_SDA_GPIO, I2C_SDA_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_SET); us_delay(5); // SDA下降 → 起始信号 HAL_GPIO_WritePin(I2C_SDA_GPIO, I2C_SDA_PIN, GPIO_PIN_RESET); us_delay(5); // 拉低SCL,准备发送数据 HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_RESET); us_delay(5); }⚠️ 注意顺序不能错:
必须先保证SCL为高,再让SDA下跳,否则可能误触发停止或其他异常。
2. 发送一个字节(MSB优先)
每个字节8位,逐位输出,在SCL上升沿被从机采样。
void i2c_send_byte(uint8_t data) { for (int i = 0; i < 8; i++) { // SCL拉低 → 允许SDA变化 HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_RESET); us_delay(2); // 设置SDA电平(最高位) if (data & 0x80) HAL_GPIO_WritePin(I2C_SDA_GPIO, I2C_SDA_PIN, GPIO_PIN_SET); else HAL_GPIO_WritePin(I2C_SDA_GPIO, I2C_SDA_PIN, GPIO_PIN_RESET); data <<= 1; // 左移,准备下一位 us_delay(2); // SCL拉高 → 从机在此上升沿采样 HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_SET); us_delay(5); // SCL拉低 → 进入下一个bit周期 HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_RESET); } }📌 关键细节:
- 必须确保SCL为低时才能改SDA;
- 上升沿前要有足够的建立时间(setup time);
- 下降沿后要有保持时间(hold time);
- 实际延时需根据目标速率调整(100kHz ≈ 5μs/bit)。
3. 接收一个字节
接收比发送复杂一点,因为你要读取外部设备的数据。
uint8_t i2c_read_byte(void) { uint8_t data = 0; // 切换SDA为输入模式(释放总线) i2c_sda_input(); for (int i = 0; i < 8; i++) { data <<= 1; // SCL拉低 → 准备时钟上升沿 HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_RESET); us_delay(2); // SCL拉高 → 从机输出有效数据 HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_SET); us_delay(5); // 在SCL高电平时读取SDA if (HAL_GPIO_ReadPin(I2C_SDA_GPIO, I2C_SDA_PIN)) data |= 0x01; // SCL再次拉低 → 完成一个bit HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_RESET); } return data; }💡 提示:每次读取前必须将SDA设为输入模式,否则会与从机冲突!
4. 应答处理(ACK/NACK)
每传输完一个字节,都需要应答确认。
- 主机接收数据时:发ACK表示继续接收,NACK表示结束
- 主机发送数据时:读ACK判断从机是否在线
void i2c_send_ack(uint8_t ack) { HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_RESET); us_delay(2); i2c_sda_output(); // 主机控制SDA if (ack) HAL_GPIO_WritePin(I2C_SDA_GPIO, I2C_SDA_PIN, GPIO_PIN_SET); // NACK else HAL_GPIO_WritePin(I2C_SDA_GPIO, I2C_SDA_PIN, GPIO_PIN_RESET); // ACK us_delay(2); // 上升沿通知从机 HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_SET); us_delay(5); HAL_GPIO_WritePin(I2C_SCL_GPIO, I2C_SCL_PIN, GPIO_PIN_RESET); }最后一次读取通常发NACK,告诉从机“我要停了”。
实战案例:读取SHT30温湿度传感器
假设我们要通过软件I2C读取SHT30的数据,流程如下:
i2c_start()- 发送写地址:
0x88(即0x44 << 1 | 0) - 检查ACK
- 发送命令:
0x2C,0x06(启动周期测量) i2c_start()(重复起始)- 发送读地址:
0x89 - 读6字节数据(前2字节温度,中间2字节湿度,最后2字节CRC)
- 每次读完发ACK,最后一次发NACK
i2c_stop()
完整调用示例:
i2c_start(); i2c_send_byte(0x88); // 写地址 if (!i2c_read_ack()) goto err; // 可封装读ACK函数 i2c_send_byte(0x2C); i2c_send_byte(0x06); i2c_start(); // Repeated start i2c_send_byte(0x89); // 读地址 if (!i2c_read_ack()) goto err; temp_raw = i2c_read_byte(); i2c_send_ack(0); // ACK temp_raw = (temp_raw << 8) | i2c_read_byte(); i2c_send_ack(0); humid_raw = i2c_read_byte(); i2c_send_ack(0); humid_raw = (humid_raw << 8) | i2c_read_byte(); i2c_send_ack(0); crc_temp = i2c_read_byte(); i2c_send_ack(0); crc_humid = i2c_read_byte(); i2c_send_ack(1); // NACK i2c_stop();可以看到,重复起始(Repeated Start)是软件I2C的一大优势——你可以连续发起读写操作而不释放总线,避免其他主设备抢占。
如何提升稳定性?五个关键设计要点
软件I2C虽然简单,但也容易出问题。以下是实际项目中的经验总结:
1. 使用真正的微秒级延时
千万别用HAL_Delay(1)!它是毫秒级的,远超I2C时序需求。
推荐使用:
static void us_delay(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t cycles = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < cycles); }前提:开启DWT时钟(在main.c中添加CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;)
2. 配置为开漏输出 + 上拉电阻
gpio.Mode = GPIO_MODE_OUTPUT_OD; // 开漏输出 gpio.Pull = GPIO_PULLUP; // 外部或内部上拉这样可以模拟I2C总线的“线与”特性:任意设备拉低都会使总线为低。
如果没有硬件开漏支持,可以用推挽输出配合外部上拉电阻,但要注意避免强推冲突。
3. 关键段禁止中断
如果在发送中途被打断太久(>几微秒),可能导致时序错误。
建议在关键操作中临时关闭全局中断:
__disable_irq(); i2c_start(); i2c_send_byte(addr); __enable_irq();适用于对实时性要求高的场景。
4. 合理选择上拉电阻
| 速度 | 推荐阻值 |
|---|---|
| 标准模式 (100kHz) | 4.7kΩ |
| 快速模式 (400kHz) | 2.2kΩ |
太大会导致上升沿缓慢,太小则功耗高且易过载。
5. 总线空闲检测(可选)
在执行start前,检查SDA/SCL是否都为高,防止上次通信未正常结束。
while (HAL_GPIO_ReadPin(I2C_SCL_GPIO, I2C_SCL_PIN) == 0); // 等待SCL释放 if (HAL_GPIO_ReadPin(I2C_SDA_GPIO, I2C_SDA_PIN) == 0) { // SDA被拉低 → 总线忙 → 执行恢复流程 recover_bus(); }和硬件I2C比,到底谁更强?
| 对比项 | 软件I2C | 硬件I2C |
|---|---|---|
| 引脚自由度 | ✅ 任意GPIO | ❌ 仅限特定复用引脚 |
| CPU占用 | ⚠️ 较高(轮询+延时) | ✅ 极低(DMA支持) |
| 稳定性 | ✅ 不受硬件Bug影响 | ⚠️ F1/F4有死锁风险 |
| 调试可视性 | ✅ 可用逻辑分析仪逐bit观察 | ✅ 自动模式波形干净 |
| 多速率兼容 | ✅ 动态调节延时即可 | ⚠️ 需重新配置寄存器 |
| 开发难度 | ⚠️ 需掌握底层时序 | ✅ HAL库一键初始化 |
结论很明确:
🎯如果你追求极致灵活性和稳定性,选软件I2C;
🎯 如果你追求高性能和低功耗,选硬件I2C。
很多高手的做法是:混合使用——高速设备走硬件I2C,低速/备用设备走软件I2C。
最佳实践建议:封装成独立模块
不要把I2C代码散落在各个.c文件里。推荐做法:
/Drivers/ soft_i2c.c soft_i2c.h提供统一API:
int soft_i2c_init(void); int soft_i2c_write(uint8_t dev_addr, uint8_t reg, uint8_t *data, int len); int soft_i2c_read(uint8_t dev_addr, uint8_t reg, uint8_t *data, int len);这样不仅方便移植,还能快速替换底层实现(比如将来换成硬件I2C也不用改应用层)。
写在最后:掌握软件I2C,意味着你真正“看见”了协议
当你第一次用手动翻GPIO的方式,看着逻辑分析仪上一点点走出标准I2C波形时,那种成就感是无与伦比的。
它教会你的不只是“怎么通信”,而是:
- 协议是如何在物理层面落地的?
- 为什么要有建立时间和保持时间?
- 总线竞争是怎么发生的?
- 为什么需要上拉电阻?
这些问题的答案,都在那一行行看似简单的HAL_GPIO_WritePin()之中。
所以,哪怕你现在用的是高级RTOS+DMA+硬件I2C组合拳,我也建议你亲手实现一遍软件I2C。
因为它不仅是备胎方案,更是通往嵌入式底层世界的钥匙。
如果你在实现过程中遇到了SDA卡死、ACK失败、数据错乱等问题,欢迎在评论区留言讨论,我们可以一起分析波形、排查时序。毕竟,每一个嵌入式工程师,都是从“拉高低低”中成长起来的。