手把手教你实现 I2C 读写 EEPROM:从协议到代码的完整实践
在嵌入式开发中,我们常常需要一种“断电不丢数据”的方式来保存设备配置、用户设置或校准参数。RAM 不行,它一掉电就清零;Flash 虽然非易失,但擦写寿命短、必须按扇区操作,不适合频繁修改小数据。这时候,EEPROM就成了理想选择。
而为了让 MCU 和 EEPROM 高效通信,I2C 总线几乎是标配方案——仅用两根线(SCL + SDA),就能把多个外设串在一起。本文将带你从零开始,深入理解I2C 协议与 EEPROM 的协同工作机制,并手写一套可移植性强、逻辑清晰的 C 语言代码,真正掌握i2c读写eeprom代码的底层实现。
为什么是 I2C + EEPROM?
先来看一个真实场景:你正在做一个智能温控器,每次用户调节温度后,希望下次上电还能记住上次的设定值。这个需求看似简单,却涉及两个关键技术点:
- 存储介质要能掉电保存数据→ 需要用到非易失性存储器;
- 接口要节省引脚资源且易于扩展→ 不想为一个小小配置占用太多 GPIO。
这正是 I2C 接口 EEPROM 的用武之地。
以常见的AT24C02为例:
- 容量 256 字节,足够存几十个参数;
- 支持百万次擦写,远超 Flash 寿命;
- 通过 I2C 通信,仅需两根 IO 线;
- 成本低至几毛钱,适合量产。
更关键的是,它支持字节级读写,不像 Flash 必须整页擦除再写。这意味着你可以精准地只改一个字节,而不影响其他数据。
I2C 是怎么工作的?别被协议吓住
很多人看到“I2C 协议”四个字就头大,其实它的核心机制非常直观:主设备发号施令,从设备听命行事。
两条线,四种状态
I2C 只有两根信号线:
-SCL(Serial Clock):时钟线,由主设备控制;
-SDA(Serial Data):数据线,双向传输。
所有设备都挂在同一总线上,靠地址寻址区分彼此。每条消息都有明确的起止标志:
| 信号 | 条件 |
|---|---|
| 起始条件(Start) | SCL 高电平时,SDA 从高变低 |
| 停止条件(Stop) | SCL 高电平时,SDA 从低变高 |
中间的数据传输则是同步进行:每个时钟周期传送一位数据,高位先行。
主设备如何找到目标从机?
每个 I2C 设备都有一个唯一的7位地址。比如 AT24C02 的固定前缀是1010,剩下的三位由 A0~A2 引脚接 GND 或 VCC 决定。
假设你的 A0~A2 全接地,那地址就是1010000,换算成十六进制就是0x50。
但在实际编程中,很多库函数要求传入8位地址,其中最低位表示读写方向:
- 写模式:0xA0(即 0x50 << 1 | 0)
- 读模式:0xA1(即 0x50 << 1 | 1)
⚠️ 注意:不同芯片手册和驱动库可能对地址格式有差异,务必核对文档!
每次通信都像一场对话
一次典型的 I2C 通信流程如下:
[Start] → [Slave Addr + W] → [ACK] → [Mem Addr] → [ACK] → [Data] → [ACK] → [Stop]接收方每收到一个字节后,都要拉低 SDA 表示ACK(应答),否则为主机知道通信失败。这种反馈机制大大提升了通信可靠性。
EEPROM 内部是怎么运作的?
虽然叫“只读存储器”,但 EEPROM 实际上是可以反复擦写的。它的内部结构可以简化为三个部分:
- 存储阵列:一块连续的内存空间(如 256 字节);
- 地址计数器:记录当前操作位置;
- 控制逻辑:解析 I2C 命令,执行读写动作。
当你发送“写命令 + 地址 + 数据”时,EEPROM 会把数据写入指定地址,并自动递增地址指针,方便后续连续写入。
但要注意:写操作不是瞬间完成的!
AT24C02 的片内编程时间最长可达5ms。在这期间,芯片处于“忙”状态,不会响应新的 I2C 请求。如果你贸然发起下一条指令,很可能导致失败。
解决办法很简单:写完之后 delay 至少 6ms,或者使用“轮询应答”方式等待芯片就绪。
动手写代码:基于 STM32 HAL 库的实现
下面我们将使用 STM32F103 系列 MCU 和 HAL 库,一步步写出完整的 I2C 读写 EEPROM 代码。即使你用的是 ESP32 或 Arduino,这里的逻辑也完全适用。
硬件连接一览
AT24C02 → STM32 VCC → 3.3V GND → GND SCL → PB6 (I2C1_SCL) SDA → PB7 (I2C1_SDA) A0,A1,A2 → GND → 地址 = 0x50 WP → GND → 允许写入记得在 SCL 和 SDA 上各加一个4.7kΩ 上拉电阻到 VCC,这是 I2C 正常工作的必要条件。
第一步:初始化 I2C 外设
I2C_HandleTypeDef hi2c1; void I2C1_Init(void) { hi2c1.Instance = I2C1; hi2c1.Init.ClockSpeed = 100000; // 100kHz,标准模式 hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2; hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; hi2c1.Init.OwnAddress1 = 0; hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE; if (HAL_I2C_Init(&hi2c1) != HAL_OK) { Error_Handler(); } }这段代码配置了 I2C1 工作在 100kbps 下,关闭时钟延展功能(某些 EEPROM 不支持),确保兼容性。
第二步:实现字节写入
#define EEPROM_ADDR_WRITE 0xA0 // 写地址 #define EEPROM_ADDR_READ 0xA1 // 读地址 /** * @brief 向指定内存地址写入一个字节 * @param memAddr 存储地址 (0~255) * @param data 要写入的数据 * @return HAL_OK 表示成功 */ HAL_StatusTypeDef EEPROM_WriteByte(uint16_t memAddr, uint8_t data) { uint8_t buffer[2]; buffer[0] = (uint8_t)memAddr; // 地址 buffer[1] = data; // 数据 return HAL_I2C_Master_Transmit(&hi2c1, EEPROM_ADDR_WRITE, buffer, 2, 1000); }注意:这里一次性发送“地址 + 数据”,让 EEPROM 自动定位并写入。
第三步:实现字节读取
读操作比写复杂一点,因为需要分两步走:
- 先告诉 EEPROM “我要读哪个地址”(写模式);
- 再发起一次读操作获取数据。
/** * @brief 从指定地址读取一个字节 * @param memAddr 存储地址 * @param data 用于接收数据的指针 * @return HAL_OK 表示成功 */ HAL_StatusTypeDef EEPROM_ReadByte(uint16_t memAddr, uint8_t *data) { HAL_StatusTypeDef status; // 步骤1:发送目标地址(写模式) status = HAL_I2C_Master_Transmit(&hi2c1, EEPROM_ADDR_WRITE, &memAddr, 1, 1000); if (status != HAL_OK) return status; // 步骤2:重新启动并读取数据(读模式) status = HAL_I2C_Master_Receive(&hi2c1, EEPROM_ADDR_READ, data, 1, 1000); return status; }这就是所谓的“重启动(Repeated Start)”机制——不发送 Stop,直接切换读写方向,避免其他设备抢占总线。
第四步:优化写入函数,避免忙等
前面提到,EEPROM 写入需要时间。如果我们连续写多个字节,必须保证前一次已完成。
最简单的做法是在每次写后加延时:
void Safe_EEPROM_Write(uint16_t addr, uint8_t data) { // 等待总线空闲 while (HAL_I2C_IsActiveFlag_Busy(&hi2c1)); EEPROM_WriteByte(addr, data); HAL_Delay(6); // 等待写周期完成(>5ms) }当然,更高效的方式是采用轮询法:不断尝试发送一个空写命令,直到收到 ACK 为止,说明芯片已就绪。
void WaitForWriteComplete(void) { while (HAL_I2C_Master_Transmit(&hi2c1, EEPROM_ADDR_WRITE, NULL, 0, 100) != HAL_OK); }这种方式无需固定延时,响应更快,尤其适合高频写入场景。
第五步:批量读取,提升效率
利用地址自动递增特性,我们可以一口气读出多个字节:
/** * @brief 连续读取多个字节(页读) * @param startAddr 起始地址 * @param buffer 数据缓冲区 * @param len 读取长度 */ HAL_StatusTypeDef EEPROM_ReadPage(uint16_t startAddr, uint8_t *buffer, uint16_t len) { HAL_StatusTypeDef status; status = HAL_I2C_Master_Transmit(&hi2c1, EEPROM_ADDR_WRITE, (uint8_t*)&startAddr, 1, 1000); if (status != HAL_OK) return status; return HAL_I2C_Master_Receive(&hi2c1, EEPROM_ADDR_READ, buffer, len, 1000); }例如,你想读取地址 0x10 开始的 16 字节配置信息,只需调用一次即可。
完整测试例程:验证读写是否正常
int main(void) { HAL_Init(); SystemClock_Config(); I2C1_Init(); uint8_t test_data = 0xAB; uint8_t read_back = 0; // 写入测试数据 Safe_EEPROM_Write(0x10, test_data); // 延时确保写入完成 HAL_Delay(10); // 读回验证 EEPROM_ReadByte(0x10, &read_back); if (read_back == test_data) { // 点亮 LED 指示成功 HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); } while (1) { } }烧录运行后,如果 LED 亮起,说明你的 I2C 读写 EEPROM 已经跑通!
实际项目中的最佳实践
掌握了基础读写还不够,要在真实产品中稳定运行,还需要注意以下几点:
✅ 合理规划存储布局
不要随意写地址!建议提前设计好参数表:
| 地址范围 | 用途 |
|---|---|
| 0x00~0x0F | 设备序列号 |
| 0x10~0x1F | Wi-Fi 配置 |
| 0x20~0x2F | 校准偏移量 |
| 0x30~0x3F | 用户偏好设置 |
这样便于维护,也防止覆盖关键数据。
✅ 加入 CRC 校验,防错更可靠
单纯读写可能受干扰出错。可以在数据后附加一个 CRC-8 校验码:
uint8_t CalculateCRC8(uint8_t *data, uint8_t len) { uint8_t crc = 0; for (int i = 0; i < len; i++) { crc ^= data[i]; for (int j = 0; j < 8; j++) { if (crc & 0x80) crc = (crc << 1) ^ 0x07; else crc <<= 1; } } return crc; }写入时一起保存 CRC,读取时校验一致性,大幅提升系统鲁棒性。
✅ 使用 WP 引脚防止误写
AT24C02 有个WP(Write Protect)引脚。将其接高电平,即可物理锁定写操作。适合在出厂后锁定关键参数。
✅ 注意页写边界
AT24C02 每页 8 字节。连续写入不能跨页,否则会从页首开始覆盖。例如:
- 从地址 0x06 写 4 字节 → 实际写入 0x06, 0x07,0x00, 0x01(回卷!)
解决方法:手动拆分写操作,确保不越界。
它还能用在哪?这些应用场景你一定用得上
- 智能家居:保存 Wi-Fi 密码、开关状态、定时任务;
- 工业仪表:存储传感器校准系数、累计运行时间;
- 医疗设备:记录患者设置参数、操作日志;
- 消费电子:记忆音量、亮度、语言等 UI 设置;
- 物联网节点:缓存未上传的数据,断网续传。
甚至可以配合 RTC 芯片,构建一个小型“黑匣子”,定期记录环境数据。
结语:这项技能的价值远超想象
掌握i2c读写eeprom代码,不只是学会了一个驱动编写技巧,更是打通了嵌入式系统中“状态持久化”的任督二脉。
你会发现,在很多项目中,能不能记住上次的状态,直接决定了用户体验的好坏。而 EEPROM + I2C 的组合,正是一种低成本、高可靠、易实现的解决方案。
随着国产 RISC-V MCU 的普及,这类基础外设的应用只会越来越广泛。未来你还可以进一步探索:
- 软件模拟 I2C(Bit-Banging),在没有硬件 I2C 的芯片上也能用;
- 构建轻量级 NVS(Non-Volatile Storage)系统;
- 移植 LittleFS 或 FATFS 到大容量 EEPROM 上;
技术的成长,往往始于这样一个小小的读写操作。
如果你正在学习嵌入式开发,不妨今天就动手试一试。点亮那盏代表成功的 LED,你会感受到硬件编程独有的魅力。
如果你在实现过程中遇到问题,欢迎留言交流。一起把每一个细节抠明白。