手把手教你用C语言实现I2C读写EEPROM——从原理到实战
你有没有遇到过这样的问题:设备断电后,用户设置全没了?校准参数每次都要重新输入?这其实是缺少一个可靠的“记忆体”。在嵌入式系统中,EEPROM就是那个能记住关键数据的小能手。而连接它的常用方式,正是简洁高效的I2C通信协议。
今天,我们就来彻底搞懂:如何用C语言写出稳定可靠的I2C读写EEPROM代码。不讲空话,不堆术语,带你从底层时序一步步搭建出可运行的驱动程序,哪怕你是单片机新手,也能照着做出来。
为什么是I2C + EEPROM?
先别急着敲代码,咱们得明白:为什么这个组合如此常见?
想象一下,你的智能温控器需要记住用户的温度偏好、设备编号和最后一次工作状态。这些数据不能丢,断电也得保存——这就轮到非易失性存储器出场了。Flash可以,但擦除单位太大;SRAM速度快,但一断电就清零。这时候,EEPROM的优势就凸显出来了:
- 容量适中(几百字节到几十KB)
- 按字节读写,灵活方便
- 写入简单,无需块擦除
- 寿命长,可达百万次擦写
再看接口。如果为每个外设都拉一组SPI或并行总线,MCU的IO很快就耗尽了。而I2C只需要两根线(SDA和SCL),就能挂多个设备,布线干净,成本低。因此,像AT24C系列这类I2C接口的EEPROM芯片,成了小数据存储的首选。
✅一句话总结:
I2C省IO,EEPROM断电不丢数据——两者结合,完美解决嵌入式系统中的“记忆”需求。
I2C通信到底怎么工作?一张图说清楚
很多人学I2C卡在“时序”上,觉得复杂。其实核心逻辑非常清晰:主机控制时钟,发起通信,通过地址找设备,然后收发数据。
总线结构与信号线
I2C只有两条线:
-SDA:串行数据线,双向传输
-SCL:串行时钟线,由主机提供
所有设备都并联在这两条线上,靠地址区分彼此。典型的7位地址支持128个设备(实际可用约110多个,部分被保留)。
这两条线都是开漏输出,必须接上拉电阻(通常4.7kΩ),才能保证高电平有效。这也是很多初学者接了芯片却通信失败的首要原因——忘了加上拉!
一次完整的读操作长什么样?
以从EEPROM读取一个字节为例,流程如下:
[起始] → [发设备地址+写] → [发内存地址] → [重复起始] → [发设备地址+读] → [接收数据+NACK] → [停止]注意中间有个“重复起始”(Repeated Start),它和“先停再启”不同,能防止其他主设备抢占总线。
整个过程就像你去图书馆借书:
1. 走到服务台说:“我要借书”(起始)
2. 告诉管理员你要哪本书:“《深入理解计算机系统》”(发送设备地址)
3. 再说明具体信息:“作者Randal E. Bryant”(发送内存地址)
4. 然后切换请求:“现在请把这本书给我”(重复起始 + 读命令)
5. 最后拿到书合上盖子离开(接收数据 + 停止)
每一步之后,对方都会给你一个“OK”信号,这就是ACK应答。如果没收到ACK,说明设备没响应,可能是地址错了或者没供电。
AT24C02:我们的实验主角
我们以最常用的AT24C02为例展开讲解。它是Microchip出品的经典I2C EEPROM芯片,2Kbit容量,即256字节,组织成32页×8字节。
关键特性一览
| 特性 | 参数 |
|---|---|
| 接口 | I2C(标准/快速模式) |
| 容量 | 256 × 8 bit |
| 工作电压 | 1.8V ~ 5.5V |
| 写周期时间 | ≤5ms(典型值) |
| 擦写寿命 | 1,000,000次 |
| 数据保持 | 100年 |
它有三个地址引脚 A0/A1/A2,通过接地或接电源设置设备地址,允许多片级联在同一总线上。默认情况下,若全部接地,则其7位地址为0b1010000(0x50),加上R/W位后变为:
- 写地址:0xA0
- 读地址:0xA1
⚠️坑点提醒:
- 写完一个字节后,芯片内部要花最多5ms完成写入。在此期间,它不会响应任何新的I2C请求!所以每次写操作后必须加延时。
- 页写不能跨页。例如AT24C02每页8字节,如果你从地址0x07开始写3个字节,第三个字节会回卷到0x00,造成数据错乱。
C语言实现:从GPIO模拟I2C开始
为了让你真正理解底层机制,我们采用GPIO模拟I2C时序的方式编程。这种方式虽然效率不如硬件I2C模块,但胜在通用性强,适用于STM32、51单片机、AVR等各种平台。
我们将以51单片机为例(如STC89C52),使用P1.0作为SDA,P1.1作为SCL。
第一步:基础配置与延时函数
#include <reg52.h> #include <intrins.h> // 提供_nop_()内联函数 // 定义I2C引脚 sbit SDA = P1^0; sbit SCL = P1^1; // EEPROM设备地址(A0=A1=A2=0) #define EEPROM_ADDR_WRITE 0xA0 #define EEPROM_ADDR_READ 0xA1 // 微秒级延时(根据晶振频率调整) void delay_us(unsigned char n) { while (n--); } // 毫秒级延时 void delay_ms(unsigned int n) { unsigned int i, j; for (i = 0; i < n; i++) for (j = 0; j < 110; j++); }📌说明:这里的延时函数是粗略估算,实际项目中建议使用定时器精确控制。但对于教学目的,足够用了。
第二步:实现I2C基本时序函数
起始信号(Start Condition)
void i2c_start(void) { SDA = 1; _nop_(); SCL = 1; _nop_(); // 确保总线空闲 SDA = 0; _nop_(); // SDA下降沿,SCL高 → 起始 SCL = 0; }停止信号(Stop Condition)
void i2c_stop(void) { SDA = 0; _nop_(); SCL = 1; _nop_(); // SCL上升沿时SDA低 SDA = 1; _nop_(); // SDA上升沿,SCL高 → 停止 }发送一个字节 + 等待ACK
void i2c_send_byte(unsigned char byte) { unsigned char i; for (i = 0; i < 8; i++) { SCL = 0; _nop_(); if (byte & 0x80) SDA = 1; else SDA = 0; _nop_(); SCL = 1; _nop_(); // 上升沿锁存数据 SCL = 0; byte <<= 1; } // 释放SDA,等待从机拉低应答 SCL = 1; _nop_(); while (SDA); // 等待ACK(SDA被拉低) SCL = 0; }⚠️ 注意:这里用while(SDA)等待ACK,是一种简化做法。更健壮的做法应加入超时判断,避免死循环。
接收一个字节(可选ACK/NACK)
unsigned char i2c_read_byte(unsigned char ack) { unsigned char i, byte = 0; SDA = 1; // 释放总线,允许从机输出 for (i = 0; i < 8; i++) { byte <<= 1; SCL = 1; _nop_(); if (SDA) byte |= 0x01; SCL = 0; _nop_(); } // 发送ACK/NACK SCL = 0; _nop_(); if (ack) SDA = 0; // ACK: 主机拉低SDA else SDA = 1; // NACK: 主机释放SDA _nop_(); SCL = 1; _nop_(); // 时钟上升沿发送应答 SCL = 0; return byte; }📌技巧:最后一个字节通常发NACK,告诉从机“我不再要数据了”,然后主机立即发STOP。
第三步:封装EEPROM专用读写函数
单字节写入
void eeprom_write_byte(unsigned char addr, unsigned char data) { i2c_start(); i2c_send_byte(EEPROM_ADDR_WRITE); // 发送器件地址(写) i2c_send_byte(addr); // 指定内存地址 i2c_send_byte(data); // 写入数据 i2c_stop(); delay_ms(10); // 必须等待写周期完成! }📌重点强调:delay_ms(10)不是可选项!这是确保数据写入成功的关键步骤。
单字节读取
unsigned char eeprom_read_byte(unsigned char addr) { unsigned char data; i2c_start(); i2c_send_byte(EEPROM_ADDR_WRITE); i2c_send_byte(addr); // 设置读地址 i2c_start(); // 重复启动 i2c_send_byte(EEPROM_ADDR_READ); data = i2c_read_byte(0); // 读取并发送NACK i2c_stop(); return data; }连续读取多字节(顺序读)
void eeprom_read_buffer(unsigned char start_addr, unsigned char *buffer, unsigned char len) { unsigned char i; i2c_start(); i2c_send_byte(EEPROM_ADDR_WRITE); i2c_send_byte(start_addr); i2c_start(); i2c_send_byte(EEPROM_ADDR_READ); for (i = 0; i < len; i++) { if (i == len - 1) buffer[i] = i2c_read_byte(0); // 最后一字节NACK else buffer[i] = i2c_read_byte(1); // 中间字节ACK } i2c_stop(); }这种模式利用了EEPROM内部地址自动递增的功能,非常适合批量加载配置参数。
第四步:主函数测试示例
void main(void) { unsigned char val; unsigned char buf[2]; delay_ms(100); // 上电延时,确保电源稳定 // 写入测试数据 eeprom_write_byte(0x00, 0x55); eeprom_write_byte(0x01, 0xAA); delay_ms(10); // 读取验证 val = eeprom_read_byte(0x00); // 应返回0x55 val = eeprom_read_byte(0x01); // 应返回0xAA // 批量读取 eeprom_read_buffer(0x00, buf, 2); while (1); // 结束 }💡调试建议:可在读取后通过串口打印结果,或点亮LED指示成功与否。
实战避坑指南:那些年我们踩过的雷
即使代码看起来没问题,实际调试中仍可能失败。以下是高频问题及解决方案:
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 总是收不到ACK | 地址错误 / 未加上拉电阻 / 电源异常 | 检查地址是否左移正确;确认SDA/SCL有4.7kΩ上拉;测量VCC是否正常 |
| 写入无效,读出为FF或00 | 未等待写周期结束 | 每次写后务必delay_ms(10) |
| 读出数据错乱 | 时序太快,MCU速度过高 | 降低主频或增加_nop_()延迟 |
| 总线锁死(SDA一直低) | 从机异常或中断干扰 | 尝试快速翻转SCL 9次以上,迫使从机释放总线 |
| 多次写后失效 | 频繁写入超出寿命 | 控制写频率,加入缓存机制 |
📌进阶技巧:
- 加入CRC校验,提高数据可靠性
- 使用双区域备份,防止单次写坏导致系统崩溃
- 将版本号、校验和放在固定地址,便于启动自检
这套代码能用在哪?
掌握了这套基础框架,你可以轻松扩展应用到以下场景:
- ✅ 存储Wi-Fi密码、MQTT服务器地址等物联网配置
- ✅ 记录设备运行次数、故障码历史
- ✅ 保存传感器出厂校准系数
- ✅ 实现用户界面的主题、亮度偏好记忆
- ✅ 构建小型日志系统(配合时间戳)
更重要的是,这套模拟I2C的思想可以迁移到其他无硬件I2C模块的MCU上,甚至用于驱动OLED、RTC(DS1307)、温度传感器(TMP102)等各类I2C设备。
写在最后:不只是学会一个功能
当你亲手实现了I2C读写EEPROM,你收获的不仅是几行代码,而是对嵌入式系统底层通信机制的理解能力。
你会发现,原来“协议”并不是神秘的黑盒,而是由一个个电平变化组成的确定流程;你会开始关注时序、ACK、地址匹配这些细节;你会更有信心去阅读数据手册,而不是依赖现成库。
而这,正是成为一名合格嵌入式工程师的第一步。
如果你正在学习STM32,下一步不妨尝试将这段代码移植过去,改用硬件I2C外设(如I2C1),体验DMA+中断方式的高效读写。技术之路,就是这样一步步走出来的。
如果你在实现过程中遇到了问题,欢迎留言交流。一起debug,才是最好的学习方式。