玩转任意引脚的I2C通信:在STM32F103上从零实现软件模拟I2C
你有没有遇到过这样的情况?项目里要用好几个I2C传感器——一个温湿度、一个气压计、再来个EEPROM存配置。结果发现,你的STM32F103只有两个硬件I2C接口,还被串口调试和触摸芯片占了?或者某个传感器死活不回应ACK,示波器一抓才发现时序对不上……
这时候,别急着换主控或加I2C多路复用器。有一种更灵活、更可控、甚至更适合学习底层原理的方案——软件模拟I2C(Bit-Banging I2C)。
今天我们就以STM32F103为例,手把手带你从GPIO操作开始,一步步构建一套稳定可靠的模拟I2C驱动。不仅讲清楚“怎么写”,更要让你明白“为什么这么写”。
为什么需要模拟I2C?硬件不够香吗?
先泼一盆冷水:硬件I2C确实高效省资源,但现实开发中它并不总是“即插即用”的完美选择。
硬件I2C的真实痛点
- 资源有限:STM32F103系列通常只提供I2C1和I2C2,其中I2C1的默认引脚是PB6/PB7,常与调试接口冲突;
- 兼容性问题频发:某些国产传感器对SCL低电平时间要求苛刻,标准库函数容易因中断打断导致超时;
- 总线锁死无解:一旦SDA被拉低卡住,硬件模块往往无法恢复,只能靠外部复位;
- 引脚固定不可变:你想用PA9/PA10做I2C?抱歉,除非重映射,否则不行。
而模拟I2C恰恰能绕开这些坑:
✅ 可用任意GPIO
✅ 完全掌控时序细节
✅ 出错后可主动恢复(比如发送9个时钟脉冲唤醒设备)
✅ 不依赖特定外设,移植性强
当然,代价也很明显:CPU占用率高,不适合高频通信或实时系统。但对于大多数传感器应用(100kHz足矣),这点开销完全可以接受。
I2C协议精要:5步走通整个通信流程
在动手前,我们必须搞懂I2C协议的核心机制。记住一句话:所有操作都是围绕SCL和SDA的状态变化展开的。
半双工同步串行通信的本质
I2C使用两条线:
-SCL:由主机驱动的时钟线
-SDA:双向数据线,支持多设备挂载
它的通信像一场“对话”:
1. 主机说:“大家注意!” → 起始信号
2. 主机喊名字:“DS1307出来!” → 发送地址 + 写标志
3. DS1307答:“到!” → 拉低SDA表示ACK
4. 主机传指令:“读第3寄存器” → 数据传输
5. 最后说:“散会!” → 停止信号
这五个关键动作构成了每一次I2C交互的基础。
关键信号时序图解
| 信号 | 条件 | 说明 |
|---|---|---|
| 起始 (Start) | SCL=H, SDA从H→L | 标志一次通信开始 |
| 停止 (Stop) | SCL=H, SDA从L→H | 标志一次通信结束 |
| 数据有效窗口 | SCL=L期间更新SDA | 数据必须在SCL上升前稳定 |
| 采样点 | SCL=H时读取SDA | 接收方在此刻读取数据 |
特别注意:SDA只能在SCL为低时改变状态,否则会被误判为Start/Stop!
STM32F103上的GPIO魔法:如何让普通IO变成I2C总线
现在我们把目光转向MCU本身。STM32F103的强大之处在于其灵活的GPIO控制能力,尤其是BSRR/BRR寄存器,可以单周期置位或清零引脚,这对精确时序至关重要。
为什么SDA必须配置为开漏输出?
I2C总线采用OD(Open Drain)结构,配合外部上拉电阻工作。好处是:
- 多设备共享总线不会短路(谁想说话就拉低,不想就说“放手”)
- 支持双向通信:主机发完数据后,释放SDA让从机拉低回ACK
所以我们这样配置:
// SCL: 推挽输出即可(仅主机驱动) GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 推挽 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; // SDA: 必须开漏!因为它要切换输入模式读ACK GPIO_InitStructure.GPIO_Pin = GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; // 开漏🔧 小贴士:实际布板时务必加上拉电阻(推荐4.7kΩ),VDD=3.3V或5V视设备而定。
核心代码实现:逐行拆解模拟I2C驱动
下面是最关键的部分。我们将用最基础的方式实现每一个通信环节,并解释每一行背后的逻辑。
宏定义简化操作
#define I2C_PORT GPIOB #define I2C_SCL_PIN GPIO_Pin_6 #define I2C_SDA_PIN GPIO_Pin_7 // 利用BSRR/BRR实现原子操作,避免读-改-写风险 #define SCL_H() I2C_PORT->BSRR = I2C_SCL_PIN // Set Pin High #define SCL_L() I2C_PORT->BRR = I2C_SCL_PIN // Reset Pin Low #define SDA_H() I2C_PORT->BSRR = I2C_SDA_PIN #define SDA_L() I2C_PORT->BRR = I2C_SDA_PIN // 读取SDA电平状态 #define READ_SDA() ((I2C_PORT->IDR & I2C_SDA_PIN) != 0)⚠️ 注意:不能用
GPIO_WriteBit()这类函数,它们效率太低,可能破坏微秒级时序。
微秒级延时函数设计
目标速率:100kHz→ 每bit约10μs,高低各5μs。
static void I2C_Delay(void) { uint32_t i = 70; // 经实测,在72MHz下约为5μs while (i--); }📌 提示:你可以用DWT Cycle Counter来获得更高精度,但在简单应用中循环延时已足够。
起始与停止信号生成
void Soft_I2C_Start(void) { SDA_H(); SCL_H(); // 确保空闲状态 I2C_Delay(); SDA_L(); // 在SCL高时拉低SDA → Start! I2C_Delay(); SCL_L(); // 拉低SCL,准备发送数据 }void Soft_I2C_Stop(void) { SCL_L(); SDA_L(); // 先拉低两者 I2C_Delay(); SCL_H(); // 先升SCL I2C_Delay(); SDA_H(); // 再升SDA → Stop! I2C_Delay(); }✅ 关键点:Stop必须是SCL高时SDA从低变高,顺序不能错!
发送一个字节并等待ACK
uint8_t Soft_I2C_SendByte(uint8_t byte) { uint8_t i; for(i = 0; i < 8; i++) { if(byte & 0x80) { SDA_H(); // 数据位为1 } else { SDA_L(); // 数据位为0 } I2C_Delay(); SCL_H(); // 上升沿,从机采样 I2C_Delay(); SCL_L(); // 下降沿,允许主机改变数据 I2C_Delay(); byte <<= 1; // 左移一位,准备下一位 } // === 读取ACK === SDA_H(); // 释放SDA,让从机控制 I2C_Delay(); // 切换SDA为输入模式(上拉输入) GPIO_InitTypeDef cfg; cfg.GPIO_Pin = I2C_SDA_PIN; cfg.GPIO_Mode = GPIO_Mode_IPU; // 上拉输入 GPIO_Init(I2C_PORT, &cfg); SCL_H(); // 第9个时钟,读ACK I2C_Delay(); uint8_t ack = !READ_SDA(); // 若SDA为低,则收到ACK SCL_L(); // 恢复SDA为开漏输出 cfg.GPIO_Mode = GPIO_Mode_Out_OD; GPIO_Init(I2C_PORT, &cfg); return ack; }🧠 思考点:为什么要临时切换输入模式?因为只有这样才能检测到从机是否拉低了ACK。
接收一个字节(支持NACK)
uint8_t Soft_I2C_ReadByte(uint8_t ack) { uint8_t i, data = 0; SDA_H(); // 释放总线 GPIO_InitTypeDef cfg; cfg.GPIO_Pin = I2C_SDA_PIN; cfg.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(I2C_PORT, &cfg); for(i = 0; i < 8; i++) { I2C_Delay(); SCL_H(); // 上升沿采样 I2C_Delay(); data = (data << 1) | READ_SDA(); SCL_L(); } // === 发送ACK/NACK === cfg.GPIO_Mode = GPIO_Mode_Out_OD; GPIO_Init(I2C_PORT, &cfg); if(ack) { SDA_L(); // ACK: 拉低表示继续接收 } else { SDA_H(); // NACK: 释放表示结束 } I2C_Delay(); SCL_H(); // 第9个时钟 I2C_Delay(); SCL_L(); SDA_H(); // 释放SDA return data; }🎯 应用场景:最后一个字节通常发NACK,通知从机停止发送。
实战案例:向AT24C02 EEPROM写入一字节
假设我们要把数据0x55写入地址0x00。
void AT24C02_Write_Byte(uint8_t addr, uint8_t data) { Soft_I2C_Start(); Soft_I2C_SendByte(0xA0); // 写设备地址 Soft_I2C_SendByte(addr); // 内部地址 Soft_I2C_SendByte(data); // 要写的数据 Soft_I2C_Stop(); Delay_ms(10); // 等待内部写周期完成(最大10ms) }💡 注意:每次写操作后必须延时,否则下次读可能失败!
高级技巧与避坑指南
别以为写了就能跑通。以下是我在真实项目中踩过的坑和解决方案。
❌ 坑点1:ACK始终收不到?
常见原因:
- 上拉电阻缺失或阻值过大(>10kΩ)
- 电源未共地或电压不匹配
- 地址错误(注意有些芯片左移7位后再加R/W)
🔧 秘籍:用示波器看SDA波形,确认是否真有设备拉低ACK。
❌ 坑点2:偶尔通信失败?
可能是中断干扰了时序!
✅ 解决方法:在关键段禁用全局中断:
__disable_irq(); Soft_I2C_Start(); // ... 发送过程 ... Soft_I2C_Stop(); __enable_irq();⚠️ 注意:时间越短越好,避免影响其他中断响应。
❌ 坑点3:总线被锁死(SDA一直低)?
某些设备掉电或异常会导致SDA拉死。
✅ 恢复大法:强制发送9个SCL脉冲尝试唤醒:
void I2C_Recover_Bus(void) { for(int i = 0; i < 9; i++) { SCL_L(); Delay_us(5); SCL_H(); Delay_us(5); } Soft_I2C_Stop(); // 尝试补一个Stop }✅ 最佳实践清单
| 项目 | 建议 |
|---|---|
| 上拉电阻 | 4.7kΩ,靠近MCU端放置 |
| 通信速率 | 初始调试建议设为50kHz,稳定后再提频 |
| 引脚选择 | 尽量选同一端口(如都用GPIOB),减少初始化开销 |
| 电源管理 | 所有I2C设备共地,跨压需用电平转换器 |
| 调试手段 | 示波器抓波形 > 打日志 > 猜问题 |
扩展思路:不止于“替代”,还能做得更多
模拟I2C不只是“备胎”。正因为它是软件实现,反而带来了更多可能性:
🔄 多组I2C总线轻松扩展
// 第二组I2C使用PC0/PC1 #define I2C2_SCL_H() GPIOC->BSRR = GPIO_Pin_0 #define I2C2_SDA_H() GPIOC->BSRR = GPIO_Pin_1 // ... 同样实现一套函数,命名加2即可无需任何硬件改动,就能接入更多设备。
🛠 自定义时序适配特殊器件
某些老旧EEPROM要求t_SU:DAT ≥ 1μs,标准库可能达不到。但在模拟I2C中:
// 加长建立时间 I2C_Delay_Long(); // 延时1μs以上再升SCL SCL_H();完全自主掌控。
📈 结合RTOS实现总线互斥
在FreeRTOS中可用信号量保护总线访问:
SemaphoreHandle_t i2c_mutex; void task_sensor_read(void *pv) { xSemaphoreTake(i2c_mutex, portMAX_DELAY); read_bmp180(); xSemaphoreGive(i2c_mutex); }防止多个任务同时操作造成混乱。
写在最后:理解比调用更重要
当你熟练掌握了模拟I2C,你会发现:
- 硬件I2C不再神秘;
- 遇到通信故障时,你能快速定位是时序、电平还是协议问题;
- 你开始关注数据手册中的时序参数表,而不是只看寄存器说明。
这正是嵌入式工程师成长的关键一步。
“授人以鱼不如授人以渔。”
模拟I2C不是为了取代硬件,而是为了让我们真正掌握通信的本质。
如果你正在学习STM32,不妨亲手写一遍这套代码。哪怕最终换成硬件I2C,这段经历也会让你受益无穷。
💬互动话题:你在项目中用过模拟I2C吗?遇到过哪些奇葩问题?欢迎留言分享你的“排坑日记”!