从零实现GPIO模拟I2C:手把手教你用软件“捏”出EEPROM读写
你有没有遇到过这种情况——项目快收尾了,突然发现主控芯片没有硬件I2C外设?或者想给一个老旧的51单片机加上掉电保存功能,但周围全是满负荷的引脚?
别急。今天我们就来不用任何专用模块,只靠两个普通GPIO口,手动“捏”出一套完整的I2C通信系统,并成功驱动AT24C系列EEPROM完成数据读写。
这不是什么黑科技,而是嵌入式开发中极为实用的一招“软硬兼施”:用软件模拟硬件行为。它不依赖特定芯片,代码可移植性强,更重要的是——能让你真正看懂I2C协议背后的每一个电平跳变。
为什么非得“自己动手”造I2C?
在现代MCU里,I2C通常是标配。但现实往往更复杂:
- 某些低成本8位MCU(如STC15、PIC16)压根没集成I2C控制器;
- 即便有硬件I2C,也可能因为引脚复用冲突或固件Bug而无法使用;
- 教学场景下,直接操作寄存器太快,学生根本不知道“起始信号”到底是怎么产生的。
这时候,GPIO模拟I2C(也叫bit-banging I2C)就成了最灵活的解决方案。
它的本质很简单:把SCL和SDA当作普通IO口来控制,通过精确时序的高低电平切换,复现标准I2C协议的行为。虽然效率不如硬件模块,但它胜在哪里都能跑,而且对理解协议底层帮助极大。
I2C协议的核心骨架:不只是两根线那么简单
很多人以为I2C就是“一根时钟、一根数据”,其实不然。真正的I2C是一套严密的状态机,每一步都有明确的电气定义。
关键信号必须精准到位
| 信号 | 物理表现 | 作用 |
|---|---|---|
| 起始条件(Start) | SCL为高时,SDA从高变低 | 标志一次通信开始 |
| 停止条件(Stop) | SCL为高时,SDA从低变高 | 标志通信结束 |
| 数据有效性 | SCL为低时允许改变SDA;SCL为高时SDA必须稳定 | 保证采样准确 |
| 应答机制(ACK) | 接收方在第9个时钟周期将SDA拉低 | 表示已成功接收 |
这些规则不是随便定的。比如SCL高期间SDA不能跳变,就是为了防止误触发起停条件。如果你在SCL还高的时候就提前释放SDA,可能下一秒总线就被别的设备占用了。
主从如何对话?以AT24C02为例
假设我们要往地址0x50的EEPROM写一个字节,流程如下:
- 主机发起起始信号
- 发送设备地址 + 写标志(
0xA0) - 等待从机应答(ACK)
- 发送内存地址(比如
0x0F) - 再次等待ACK
- 发送要写的数据
- 收到ACK后发停止信号
- 等待内部写周期完成(约5~10ms)
整个过程像两个人打电话:“喂?是50号吗?”“是我。”“我要写到位置15。”“收到。”“数据是0xFF。”“OK。”
而读操作更讲究技巧——需要先“假装写”地址,再重启总线进入读模式。这叫复合模式(Repeated Start),避免中途释放总线导致被抢占。
如何用GPIO“复刻”I2C时序?
既然没有硬件生成波形,那就只能靠代码一步步“画”出来。我们选取MSP430平台为例(逻辑通用),仅需两个引脚:
- P1.5 → SCL(时钟)
- P1.7 → SDA(数据)
两者都接4.7kΩ上拉电阻到VCC,这是I2C开漏输出的关键设计:只有“拉低”能力,释放即自动上拉。
最关键的四个函数:起、停、发、收
// 宏定义(根据实际平台调整) #define SCL_HIGH() (P1OUT |= BIT5) #define SCL_LOW() (P1OUT &= ~BIT5) #define SDA_HIGH() (P1OUT |= BIT7) #define SDA_LOW() (P1OUT &= ~BIT7) #define SDA_INPUT() (P1DIR &= ~BIT7) // 输入 = 释放总线 #define SDA_OUTPUT() (P1DIR |= BIT7) // 输出 = 可控驱动 #define READ_SDA() (P1IN & BIT7) // 微延时(基于1MHz主频,每次约5μs) void i2c_delay(void) { __delay_cycles(5); }⚠️ 注意:这里的延时非常关键!标准模式要求SCL周期至少10μs,所以每个边沿之间要有足够等待时间。太快会导致从机来不及响应。
1. 起始信号:SCL高时SDA下降
void i2c_start(void) { SDA_OUTPUT(); SDA_HIGH(); SCL_HIGH(); i2c_delay(); SDA_LOW(); // 在SCL为高时下跳 → Start! i2c_delay(); SCL_LOW(); // 拉低SCL,准备发送数据 i2c_delay(); }注意顺序不能错:必须先确保SCL和SDA都是高,再单独拉低SDA。否则可能被识别为“停止”或其他异常状态。
2. 停止信号:SCL高时SDA上升
void i2c_stop(void) { SDA_OUTPUT(); SDA_LOW(); SCL_LOW(); i2c_delay(); SCL_HIGH(); // 先升SCL i2c_delay(); SDA_HIGH(); // 再升SDA → Stop! i2c_delay(); }这个顺序也很重要:如果SDA先升,而SCL还是低,那只是普通数据变化,不算停止。
3. 发送一个字节并等待ACK
uint8_t i2c_write_byte(uint8_t data) { uint8_t i, ack; for (i = 0; i < 8; i++) { if (data & 0x80) SDA_HIGH(); else SDA_LOW(); i2c_delay(); SCL_HIGH(); // 上升沿采样 i2c_delay(); SCL_LOW(); i2c_delay(); data <<= 1; // 左移一位,准备下一位 } // 释放SDA,读取ACK SDA_INPUT(); SCL_HIGH(); i2c_delay(); ack = (READ_SDA() == 0) ? 1 : 0; // SDA=0 表示收到ACK SCL_LOW(); SDA_OUTPUT(); // 恢复输出模式 return ack; }这里有个细节:发送完8位后,主机要主动释放SDA(设为输入),让从机能将其拉低表示ACK。如果不释放,总线会被锁住,无法正常通信。
4. 接收一个字节并发送ACK/NACK
uint8_t i2c_read_byte(uint8_t send_ack) { uint8_t i, data = 0; SDA_INPUT(); // 主机释放SDA,由从机驱动 for (i = 0; i < 8; i++) { i2c_delay(); SCL_HIGH(); i2c_delay(); data = (data << 1) | ((READ_SDA()) ? 1 : 0); SCL_LOW(); } // 发送ACK/NACK SDA_OUTPUT(); if (send_ack) SDA_LOW(); // ACK: 主机拉低 else SDA_HIGH(); // NACK: 释放,保持高 i2c_delay(); SCL_HIGH(); i2c_delay(); SCL_LOW(); i2c_delay(); return data; }最后一个字节通常发NACK,告诉从机“我不想要更多了”。这也是协议规定的终止方式之一。
实战:封装EEPROM读写API
有了基础操作,接下来就可以组合成对AT24Cxx EEPROM的实际访问。
设备地址与内存寻址
不同容量的AT24C芯片地址格式略有差异:
| 型号 | 地址位数 | 示例地址(写) |
|---|---|---|
| AT24C02 | 8位 | 0xA0 |
| AT24C64 | 16位 | 0xA0(高位+低位) |
我们统一按16位处理,兼容更大容量。
#define EEPROM_ADDR_WRITE 0xA0 #define EEPROM_ADDR_READ 0xA1写一个字节:先送地址,再送数据
uint8_t eeprom_write_byte(uint16_t addr, uint8_t data) { i2c_start(); if (!i2c_write_byte(EEPROM_ADDR_WRITE)) goto error; // 未收到ACK i2c_write_byte((addr >> 8) & 0xFF); // 高位地址 i2c_write_byte(addr & 0xFF); // 低位地址 i2c_write_byte(data); i2c_stop(); // 等待内部写周期(典型5~10ms) __delay_cycles(10000); // 保守延时 return 1; error: i2c_stop(); return 0; }⚠️ 注意:写操作后必须延时!否则连续写会失败。更高级的做法是应答轮询——不断尝试发送设备地址,直到收到ACK为止,说明写操作已完成。
读一个字节:伪写 + 重启动 + 读
uint8_t eeprom_read_byte(uint16_t addr) { uint8_t data; i2c_start(); i2c_write_byte(EEPROM_ADDR_WRITE); i2c_write_byte((addr >> 8) & 0xFF); i2c_write_byte(addr & 0xFF); // 重启动(Repeated Start) i2c_start(); i2c_write_byte(EEPROM_ADDR_READ); data = i2c_read_byte(0); // 读最后一个字节,发NACK i2c_stop(); return data; }这个“先写地址再读”的套路叫做当前地址读的一种变体,广泛用于随机访问。
批量读取:顺序读模式
void eeprom_read_buffer(uint16_t addr, uint8_t *buf, uint8_t len) { i2c_start(); i2c_write_byte(EEPROM_ADDR_WRITE); i2c_write_byte((addr >> 8) & 0xFF); i2c_write_byte(addr & 0xFF); i2c_start(); i2c_write_byte(EEPROM_ADDR_READ); while (len-- > 1) { *buf++ = i2c_read_byte(1); // 中间字节发ACK } *buf = i2c_read_byte(0); // 最后一字节发NACK i2c_stop(); }这样一次可以读出一页数据,适合加载配置参数。
工程实践中那些“坑”,你踩过几个?
❌ 坑点1:SDA没释放,死活收不到ACK
新手常犯错误:在接收ACK前忘了把SDA设为输入模式。结果从机想拉低回应ACK,但主机还在强行输出高电平,形成“电平对抗”,总线僵持不下。
✅秘籍:每次期望从机反馈时(如ACK/NACK),务必调用SDA_INPUT()释放总线!
❌ 坑点2:延时太短,波形“挤成一团”
尤其是在高速主频下(如16MHz),几条指令就过了几微秒。若用空循环延时而不校准,可能导致SCL频率远超400kHz,EEPROM直接罢工。
✅秘籍:根据主频计算NOP数量,或使用定时器辅助延时。可用逻辑分析仪抓波形验证是否符合规范。
❌ 坑点3:中断打断导致时序错乱
如果开了全局中断,在发送过程中被定时器或UART打断,可能造成某个时钟周期异常延长,破坏协议同步。
✅秘籍:在i2c_start()到i2c_stop()之间临时关闭中断,确保原子性。
__disable_interrupt(); i2c_start(); ... i2c_stop(); __enable_interrupt();当然,频繁关中断会影响实时性,建议仅用于关键段。
✅ 进阶技巧:用应答轮询替代固定延时
目前我们用__delay_cycles(10000)等10ms,太浪费CPU资源。更好的方法是利用写操作期间EEPROM不会应答的特点,进行轮询:
void eeprom_wait_ready(void) { while (1) { i2c_start(); if (i2c_write_byte(EEPROM_ADDR_WRITE)) { // 收到ACK,说明内部写已完成 i2c_stop(); break; } i2c_stop(); // 可加小延时再试 } }这种方法更高效,且适应不同温度下的写入速度波动。
为何说这项技能值得掌握?
也许你会问:现在谁还用手动模拟I2C?硬件不是更稳定吗?
没错,但在以下场景,这项能力依然不可或缺:
- 教学演示:让学生亲眼看到“起始信号”是如何由两条语句生成的;
- 极限资源环境:在仅有几百字节RAM的老MCU上,精简版软件I2C反而更轻量;
- 调试利器:当硬件I2C出问题时,可以用软件模拟做对比测试,快速定位是驱动bug还是线路故障;
- 多路扩展:一个MCU要接多个I2C设备,但只有一个硬件通道?剩下的用GPIO模拟即可。
更重要的是,当你亲手实现了I2C,下次看SPI、CAN甚至USB协议时,眼里看到的不再是抽象术语,而是一个个可控的电平变化。
结语:从“调库”到“造轮子”,才是工程师的成长之路
今天我们从最基础的GPIO操作出发,一步步构建出了完整的I2C通信链路,并实现了对EEPROM的可靠读写。整个过程没有依赖任何中间件,也没有使用HAL库,全靠对协议本质的理解。
这套代码虽然简单,但它揭示了一个深刻的道理:所有复杂的硬件功能,归根结底都可以用最基本的数字逻辑来实现。
下次当你面对“缺少某个外设”的困境时,不妨想想:能不能用软件补上?哪怕只是为了学习,动手模拟一遍,也会让你对嵌入式系统的掌控力提升一个层次。
如果你正在做毕业设计、产品原型或竞赛项目,完全可以把这个方案拿去直接用。只要改一下GPIO宏定义,就能跑在STM32、51、AVR、ESP32等各种平台上。
提示:完整工程可在GitHub搜索关键词
gpio bitbanging i2c eeprom获取开源参考实现。
如有疑问,欢迎留言交流。你在项目中是否也曾被迫“手搓”通信协议?欢迎分享你的故事。