从零开始手撕I2C:用GPIO模拟协议的底层真相
你有没有遇到过这种情况?项目做到一半,发现MCU的硬件I2C引脚已经被占用了,而你还得接一个温湿度传感器。或者更糟——明明代码写得没问题,逻辑分析仪一抓波形,SCL死活拉不起来。
这时候,如果会用GPIO软件模拟I2C,你就有了“备胎中的战斗机”。
别被“模拟”两个字骗了,这不是什么黑科技补丁,而是一项嵌入式工程师必须掌握的基本功。它不仅能救急,更能让你真正看透I2C协议背后的电平游戏规则。
为什么我们要手动“捏”出一根I2C总线?
I2C是Philips在80年代搞出来的一套轻量级通信标准,只需要两根线:SCL(时钟)和SDA(数据),就能让主控芯片跟一堆外设对话。现在几乎每个传感器、EEPROM、OLED屏都支持这玩意儿。
但问题来了:很多便宜或老旧的MCU压根没有硬件I2C模块,或者只有一个。你想多挂几个设备?地址冲突、引脚不够……各种麻烦接踵而至。
这时候怎么办?放弃吗?当然不。我们可以自己动手,用两个普通的GPIO引脚,“手搓”一条I2C总线出来。
这种方法叫bit-banging(位带操作)——靠软件精准控制每个电平变化的时间顺序,复现整个I2C物理层行为。虽然慢一点、费CPU一点,但它灵活、可移植、还能帮你彻底搞懂协议本质。
I2C到底是怎么“说话”的?
要模拟,先得明白人家是怎么交流的。
I2C通信是典型的主从结构,所有动作由主机发起。它的核心不是传数据,而是对电平时序的精确操控。哪怕错几百纳秒,对方也可能听不懂你在说什么。
关键信号:起始与停止
- 起始条件(START):SCL为高时,SDA从高变低。
- 停止条件(STOP):SCL为高时,SDA从低变高。
这两个动作就像打电话前的“喂?”和挂电话前的“再见”,缺一不可。
⚠️ 注意:SCL必须处于高电平期间,SDA的变化才具有特殊含义!否则会被当作普通数据位处理。
数据怎么传?一位一位来
每传输一个字节,都是高位先行(MSB),共8位。之后紧跟一个ACK/NACK位:
- 如果接收方成功收到,就会在第9个周期把SDA拉低(ACK);
- 若未响应,则保持高电平(NACK),表示拒绝或忙。
这个机制保证了通信的可靠性。
速度模式与时序要求(以100kHz为例)
| 参数 | 含义 | 最小值 | 推荐延时 |
|---|---|---|---|
| T_HIGH | SCL高电平时间 | 4.0 μs | 延时5μs |
| T_LOW | SCL低电平时间 | 4.7 μs | 延时5μs |
| T_SU:STA | 起始建立时间 | 4.7 μs | 确保SDA下降前SCL已稳定为高 |
这些数字来自NXP官方文档《UM10204》,是我们写延时函数的依据。
为了兼容大多数平台,我们通常设置每次操作后延时5微秒,这样既能满足标准模式(100kHz),又不至于太苛刻。
实战编码:一步步实现软I2C
下面这段代码可以在STM32、ESP32、AVR甚至51单片机上运行,只要你会配置GPIO就行。
我们先把底层操作抽象成宏,方便跨平台移植:
// 用户根据实际硬件修改引脚定义 #define I2C_SDA_PIN 5 #define I2C_SCL_PIN 6 // GPIO操作封装 #define SET_SDA() gpio_set_level(I2C_SDA_PIN, 1) // SDA = 1 #define CLR_SDA() gpio_set_level(I2C_SDA_PIN, 0) // SDA = 0 #define READ_SDA() gpio_get_level(I2C_SDA_PIN) // 读SDA状态 #define SET_SCL() gpio_set_level(I2C_SCL_PIN, 1) #define CLR_SCL() gpio_set_level(I2C_SCL_PIN, 0) // 微秒级延时(需用户实现) #define I2C_DELAY i2c_delay_us(5)1. 发送起始信号
void i2c_start(void) { SET_SDA(); // 空闲状态:SDA/SCL均为高 SET_SCL(); I2C_DELAY; CLR_SDA(); // SCL保持高,SDA下拉 → 起始条件 I2C_DELAY; CLR_SCL(); // 拉低SCL,准备发送数据 }关键点:先拉低SDA,再拉低SCL。顺序不能反!
2. 发送停止信号
void i2c_stop(void) { CLR_SDA(); // 当前SCL为低,SDA为低 SET_SCL(); // 先抬高SCL I2C_DELAY; SET_SDA(); // 再抬高SDA → 停止条件 I2C_DELAY; }记住口诀:“高SCL时SDA上升即STOP”。
3. 发送一个字节并等待ACK
uint8_t i2c_send_byte(uint8_t data) { for (int i = 0; i < 8; i++) { if (data & 0x80) { SET_SDA(); } else { CLR_SDA(); } I2C_DELAY; SET_SCL(); // 上升沿,从机采样 I2C_DELAY; CLR_SCL(); // 下降沿,为主机准备下一位 I2C_DELAY; data <<= 1; // 左移一位,准备发送下一位 } // 释放SDA,读取ACK SET_SDA(); // 主机释放总线 SET_SCL(); I2C_DELAY; uint8_t ack = READ_SDA(); // 低电平 = ACK CLR_SCL(); return ack; // 返回0表示收到确认 }注意:发送完8位后,主机必须主动释放SDA,才能让从机有机会拉低应答。
4. 接收一个字节,并手动发ACK/NACK
uint8_t i2c_read_byte(uint8_t ack) { uint8_t data = 0; SET_SDA(); // 释放SDA,允许从机驱动 for (int i = 0; i < 8; i++) { data <<= 1; SET_SCL(); // 上升沿,从机输出有效数据 I2C_DELAY; if (READ_SDA()) { data |= 0x01; } CLR_SCL(); // 下降沿,主机采样完成 I2C_DELAY; } // 发送ACK/NACK if (ack) { SET_SDA(); // NACK:保持高 } else { CLR_SDA(); // ACK:拉低 } SET_SCL(); // 第9个时钟脉冲 I2C_DELAY; CLR_SCL(); SET_SDA(); // 总线释放 return data; }最后一个字节通常发NACK,告诉从机“我已经读够了”。
这些坑,我替你踩过了
你以为写了函数就万事大吉?Too young.
❌ 坑1:SDA没释放,总线锁死
最常见的问题是:忘记将GPIO设为输入模式或开漏输出,导致SDA一直被强推高/低,其他设备无法驱动。
✅ 解决方案:
- 使用开漏输出 + 上拉电阻(推荐4.7kΩ);
- 或者在读取ACK前调用SET_SDA()的同时,确保引脚方向为输入(仅输入模式才能安全读取外部电平)。
❌ 坑2:延时不准,通信失败
编译器优化可能把你精心计算的循环给“优化”没了。
比如你用for循环做延时:
for(int i=0; i<100; i++);结果-O2优化直接删掉……那你的时间全乱套了。
✅ 正确做法:
- 使用定时器中断;
- 或者加volatile关键字防止优化;
- 更稳妥的是用SysTick或DWT周期计数。
示例:
static void i2c_delay_us(uint32_t us) { volatile uint32_t count = SystemCoreClock / 1000000 * us / 5; // 根据主频调整 while(count--); }❌ 坑3:电压不匹配,信号失真
如果你的MCU是3.3V,而I2C设备是5V逻辑,直接连上去可能导致损坏或通信异常。
✅ 对策:
- 加电平转换芯片(如PCA9306、TXS0108E);
- 或使用双电源上拉(复杂且不稳定,不推荐)。
实际应用场景:双总线架构设计
假设你的MCU只有一个硬件I2C接口,但需要连接以下设备:
- BMP280 气压传感器(地址0xEE)
- AT24C02 EEPROM(地址0xA0)
- PCF8563 RTC(地址0xA2)
三个设备地址不同,理论上可以挂在同一总线上。但如果PCB布线困难,或者某个设备距离较远容易干扰,怎么办?
答案:软I2C + 硬I2C双总线分离
+------------------+ | MCU | | | 硬件I2C ────→| SCL, SDA |←─── 软件I2C(GPIO5/6) | | +------------------+ | | +---------v------+ +-----v-------+ | BMP280 + RTC | | AT24C02 | |(短距离高速) | |(低频配置) | +----------------+ +-------------+分工明确:
- 硬件I2C跑高速任务(如实时采集气压);
- 软I2C负责偶尔读写EEPROM配置参数。
既节省资源,又提高系统鲁棒性。
如何提升稳定性?进阶技巧分享
✅ 技巧1:加入重试机制
当i2c_send_byte()返回NACK时,不要立刻报错,尝试重新发送几次:
uint8_t i2c_write_with_retry(uint8_t addr, uint8_t reg, uint8_t data) { for (int i = 0; i < 3; i++) { i2c_start(); if (i2c_send_byte(addr)) continue; // NACK if (i2c_send_byte(reg)) continue; if (i2c_send_byte(data)) continue; i2c_stop(); return 0; // 成功 } i2c_stop(); return 1; // 失败 }✅ 技巧2:总线恢复机制
万一某从机卡住了SCL或SDA怎么办?
可以尝试发送9个时钟脉冲唤醒:
void i2c_recover_bus(void) { // 强制产生9个SCL脉冲 for (int i = 0; i < 9; i++) { SET_SCL(); I2C_DELAY; CLR_SCL(); I2C_DELAY; } // 然后发一个STOP尝试复位 SET_SDA(); SET_SCL(); I2C_DELAY; CLR_SDA(); I2C_DELAY; SET_SCL(); I2C_DELAY; SET_SDA(); }有时候能奇迹般地“救活”死掉的设备。
写在最后:软I2C的意义不止于“应急”
有人说:“有硬件不用,非要用软件模拟,是不是浪费性能?”
没错,软I2C确实占用CPU,不适合高频通信。但在如下场景中,它是最佳选择:
- 教学演示:让学生看清每一比特是如何传输的;
- 调试阶段:绕过硬件故障快速验证设备;
- 小型项目:成本敏感、资源受限的场合;
- 多设备扩展:突破硬件I2C通道数量限制。
更重要的是,当你亲手实现一遍起始、停止、ACK检测之后,你会发现那些神秘的“I2C错误”变得不再可怕。
下次再看到“NACK returned”这种提示,你知道该去查哪根线、哪个时序、哪个上拉电阻了。
这才是真正的“掌控感”。
如果你正在做一个传感器节点、自制开发板,或是想深入理解串行通信的本质,不妨试试从零实现一次GPIO模拟I2C。
哪怕只跑通一次AT24C02读写,那种“我造出了通信”的成就感,也值得你熬夜调试。
毕竟,在嵌入式的世界里,最强大的工具,永远是理解原理的大脑。
评论区聊聊:你第一次用GPIO模拟I2C时,卡在哪一步?欢迎分享你的“翻车现场”。