news 2026/4/3 6:12:41

基于GPIO模拟i2c时序的eeprom读写代码

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于GPIO模拟i2c时序的eeprom读写代码

从零实现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写一个字节,流程如下:

  1. 主机发起起始信号
  2. 发送设备地址 + 写标志(0xA0
  3. 等待从机应答(ACK)
  4. 发送内存地址(比如0x0F
  5. 再次等待ACK
  6. 发送要写的数据
  7. 收到ACK后发停止信号
  8. 等待内部写周期完成(约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芯片地址格式略有差异:

型号地址位数示例地址(写)
AT24C028位0xA0
AT24C6416位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获取开源参考实现。

如有疑问,欢迎留言交流。你在项目中是否也曾被迫“手搓”通信协议?欢迎分享你的故事。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/3/30 9:10:56

Miniconda环境激活钩子:pre-activate与post-deactivate脚本

Miniconda环境激活钩子&#xff1a;pre-activate与post-deactivate脚本 在现代AI和数据科学项目中&#xff0c;一个常见的痛点是——为什么本地跑得通的代码&#xff0c;换到同事或服务器上就出问题&#xff1f;很多时候&#xff0c;答案藏在一个看似不起眼的地方&#xff1a;环…

作者头像 李华
网站建设 2026/4/3 4:09:24

Windows HEIC图片预览神器:3分钟告别“盲猜“时代

Windows HEIC图片预览神器&#xff1a;3分钟告别"盲猜"时代 【免费下载链接】windows-heic-thumbnails Enable Windows Explorer to display thumbnails for HEIC files 项目地址: https://gitcode.com/gh_mirrors/wi/windows-heic-thumbnails 还在为Windows系…

作者头像 李华
网站建设 2026/4/2 14:53:09

STM32使用JFlash烧录程序的完整指南

手把手教你用 JFlash 给 STM32 烧录程序&#xff1a;从入门到量产 你有没有遇到过这样的场景&#xff1f; 项目进入试产阶段&#xff0c;产线工人拿着开发板一个个接电脑&#xff0c;打开 Keil&#xff0c;点下载……结果连接失败、烧录中断、版本混乱。更头疼的是&#xff0…

作者头像 李华
网站建设 2026/4/1 12:41:24

基于STM32的Keil5下载及安装步骤完整示例

从零开始搭建STM32开发环境&#xff1a;Keil5安装实战与避坑指南 你是不是也曾在准备动手写第一行代码时&#xff0c;被“Keil打了个措手不及”&#xff1f; 下载卡在99%、Pack装不上、ST-Link识别不了……明明只是想点个LED&#xff0c;怎么连开发环境都配不起来&#xff1f…

作者头像 李华
网站建设 2026/4/1 21:33:45

英雄联盟外观自定义全攻略:LeagueSkinChanger实战指南

还在为心仪的英雄联盟外观价格高昂而烦恼&#xff1f;想要体验所有外观却不想花费点券&#xff1f;LeagueSkinChanger正是为你量身打造的解决方案。这款开源工具通过智能内存修改技术&#xff0c;让你免费解锁所有英雄外观&#xff0c;打造个性化的游戏体验。 【免费下载链接】…

作者头像 李华
网站建设 2026/4/2 2:36:17

VoiceFixer语音修复神器:让任何受损音频重获新生的终极指南

VoiceFixer语音修复神器&#xff1a;让任何受损音频重获新生的终极指南 【免费下载链接】voicefixer General Speech Restoration 项目地址: https://gitcode.com/gh_mirrors/vo/voicefixer 还在为录音中的杂音、失真或老旧音频的质量问题而烦恼吗&#xff1f;VoiceFixe…

作者头像 李华