从零开始搞懂I2C读写EEPROM:手把手带你写出稳定可靠的存储代码
你有没有遇到过这样的问题——设备断电后,之前设置的参数全没了?比如Wi-Fi密码要重新输入、屏幕亮度每次都要调一遍。这背后其实缺了一个“记忆”功能。
今天我们就来解决这个问题:用最便宜、最简单的方案,给你的嵌入式系统加上“长期记忆”能力。核心就是——I2C接口的EEPROM芯片。
别被术语吓到。即使你是刚学单片机的新手,也能看懂这篇文章,并亲手实现一套能跑的读写代码。我们不堆概念,只讲实战,一步一步拆解“怎么让MCU通过两根线把数据存进EEPROM”,还会告诉你哪些坑必须避开。
为什么选I2C + EEPROM?
在嵌入式世界里,RAM速度快但断电就丢数据;Flash可以掉电保存,但擦写次数有限、操作复杂。而EEPROM(Electrically Erasable Programmable Read-Only Memory)正好补上了这块短板:
- 断电不丢数据
- 支持字节级读写(不像Flash动不动就得擦一页)
- 写寿命高达10万次
- 成本极低(一片AT24C02只要几毛钱)
那怎么和MCU通信呢?UART只能点对点,SPI又要占好几个IO口……这时候I2C总线的优势就出来了:
它只需要SDA(数据)和SCL(时钟)两根线,就能挂多个设备,简直是引脚紧张小MCU的救星!
所以,“I2C读写EEPROM”成了很多项目的标配技能。掌握了它,你就离做出一个真正“智能”的小设备不远了。
I2C到底是个啥?先搞明白这三条规则
很多人一开始写I2C代码失败,不是代码错,而是根本没理解协议的本质。我们跳过教科书式的定义,直接说重点。
1. 两根线,开漏输出,必须加上拉电阻
I2C只有两条线:
-SDA:串行数据
-SCL:串行时钟
它们都是开漏输出(Open Drain),意味着芯片只能拉低电平,不能主动输出高电平。因此,必须外接上拉电阻(通常4.7kΩ~10kΩ),靠电阻把电压“拉”上去。
📌常见翻车现场:忘了加上拉电阻 → 总线永远处于低电平 → 通信失败
✅建议做法:PCB设计时,在靠近MCU端各加一个4.7kΩ上拉到VCC
2. 每次通信都有“起始”和“停止”信号
I2C不是一直在线传输,而是以“事务”为单位进行。每个事务都以Start Condition(起始条件)开始,以Stop Condition(停止条件)结束。
- 起始:SCL为高时,SDA由高变低
- 停止:SCL为高时,SDA由低变高
中间的数据传输必须在这两个信号之间完成。
还有一个特殊操作叫Repeated Start(重复起始):在不发Stop的情况下再次发起Start,用于切换读写方向(后面读EEPROM会用到)。
3. 每个字节后必须有一个ACK/NACK应答位
I2C是主从结构,主机控制节奏,但从机也得“说话”。每传完一个字节,接收方要给出一个应答位(ACK)表示“我收到了”。
- ACK:接收方将SDA拉低
- NACK:接收方保持SDA为高
如果主机发地址后没收到ACK,说明:
- 设备不存在
- 地址错了
- 总线被占用或短路
这个机制非常重要,是我们调试通信是否正常的第一手依据。
AT24C02:我们的“记忆芯片”长什么样?
市面上最常见的I2C EEPROM就是AT24C系列,比如AT24C02(2Kbit = 256字节)。小巧、便宜、资料多,非常适合入门。
它的DIP-8封装长这样:
┌───┬───┐ A0 │1 └───┘ 8│ VCC A1 │2 7│ SCL A2 │3 6│ SDA GND │4 5│ WP └───────┘其中几个关键引脚解释一下:
-A0-A2:地址选择引脚,决定设备地址的最后三位
-WP:写保护。接地允许写入,接VCC则只能读
-SDA/SCL:I2C通信线
-GND/VCC:供电
它的地址是怎么算出来的?
AT24Cxx的设备地址有固定格式:
| 固定前4位 | A2 | A1 | A0 | R/W |
|---|---|---|---|---|
| 1010 |
例如,如果你把A0=A1=A2都接地,那么:
- 写地址:1010 0000=0x50
- 读地址:1010 0001=0x51
⚠️ 注意:HAL库中使用的是7位地址,所以传参时要传0x50,而不是0xA0(那是8位左移后的形式)。
动手写代码!四个核心函数全解析
我们现在基于STM32 HAL库来实现完整的EEPROM读写功能。所有代码都可以直接移植到其他平台(如Arduino、ESP-IDF等),只需替换底层I2C调用即可。
1. 写一个字节:最基础的操作
我们要把一个字节写进EEPROM的某个地址,流程如下:
- 发起Start
- 发送设备写地址(0x50)
- 发送内存地址(比如0x0A)
- 发送要写的数据(比如0x55)
- 等待芯片内部写周期完成(约5ms)
/** * @brief 向EEPROM指定地址写入一个字节 * @param hi2c: I2C句柄指针 * @param device_addr: 设备7位地址(如0x50) * @param mem_addr: 要写入的内存地址(0~255 for AT24C02) * @param data: 要写入的数据 * @retval HAL_StatusTypeDef */ HAL_StatusTypeDef EEPROM_WriteByte(I2C_HandleTypeDef *hi2c, uint8_t device_addr, uint8_t mem_addr, uint8_t data) { uint8_t buffer[2]; buffer[0] = mem_addr; // 先发送目标地址 buffer[1] = data; // 再发送数据 // 使用HAL库发送:设备地址左移1位 + buffer[地址+数据] return HAL_I2C_Master_Transmit(hi2c, device_addr << 1, buffer, 2, 1000); }📌重点提醒:
-device_addr << 1是因为HAL库要求传入7位地址,硬件会在低位自动补0(写)或1(读)
-写完之后一定要延时至少5ms!否则下一次操作可能失败,因为芯片还在“烧录”数据
使用示例:
HAL_StatusTypeDef status = EEPROM_WriteByte(&hi2c1, 0x50, 0x0A, 0x55); if (status == HAL_OK) { HAL_Delay(5); // 必须等待写完成 } else { Error_Handler(); // 可在这里加入重试逻辑 }2. 读一个字节:典型的“随机读”流程
读操作稍微复杂一点,因为它需要两个步骤:
1. 先告诉EEPROM:“我要读哪个地址?” → 用写操作设置地址指针
2. 然后发起读操作,获取数据
这就是所谓的“I2C随机读”,需要用到Repeated Start
/** * @brief 从EEPROM指定地址读取一个字节 * @param hi2c: I2C句柄 * @param device_addr: 设备地址(0x50) * @param mem_addr: 内部地址 * @param pData: 存放结果的指针 * @retval HAL_StatusTypeDef */ HAL_StatusTypeDef EEPROM_ReadByte(I2C_HandleTypeDef *hi2c, uint8_t device_addr, uint8_t mem_addr, uint8_t *pData) { HAL_StatusTypeDef status; // Step 1: 发送内存地址(写模式) status = HAL_I2C_Master_Transmit(hi2c, device_addr << 1, &mem_addr, 1, 1000); if (status != HAL_OK) return status; // Step 2: Repeated Start + 读操作 status = HAL_I2C_Master_Receive(hi2c, (device_addr << 1) | 0x01, pData, 1, 1000); return status; }💡 小技巧:这个过程不需要手动控制“重复起始”,HAL库内部已经处理好了。
3. 连续读多个字节:批量读配置/字符串
有时候你想一口气读出一段数据,比如保存的用户名、校准系数等。这时可以用“当前地址读”或“顺序读”。
原理是:地址指针会自动递增,直到读完指定长度。
/** * @brief 从指定地址连续读取多个字节 * @param hi2c: I2C句柄 * @param device_addr: 设备地址 * @param start_addr: 起始地址 * @param pData: 缓冲区 * @param size: 读取字节数 * @retval HAL_StatusTypeDef */ HAL_StatusTypeDef EEPROM_ReadBuffer(I2C_HandleTypeDef *hi2c, uint8_t device_addr, uint8_t start_addr, uint8_t *pData, uint16_t size) { HAL_StatusTypeDef status; // 先定位地址 status = HAL_I2C_Master_Transmit(hi2c, device_addr << 1, &start_addr, 1, 1000); if (status != HAL_OK) return status; // 再读数据 status = HAL_I2C_Master_Receive(hi2c, (device_addr << 1) | 0x01, pData, size, 1000); return status; }你可以用它读字符串、结构体甚至小型日志。
4. 页写操作:提升效率的关键优化
虽然可以逐字节写,但频繁发起I2C事务效率很低。AT24C系列支持页写(Page Write),一次最多写入一页数据。
不同型号页大小不同:
- AT24C02:8字节/页
- AT24C64:32字节/页
⚠️大坑警告:如果你跨页写(比如从第30字节写到第35字节),超出部分会回卷到页首!也就是说第32字节会被写到第0字节去!
所以写之前一定要判断边界。
/** * @brief 向EEPROM执行页写操作(不超过单页) * @note 调用者需确保 len <= 当前页剩余空间 */ HAL_StatusTypeDef EEPROM_PageWrite(I2C_HandleTypeDef *hi2c, uint8_t device_addr, uint8_t start_addr, uint8_t *pData, uint8_t len) { uint8_t buffer[32 + 1]; // 最大32字节数据 + 1字节地址 buffer[0] = start_addr; memcpy(buffer + 1, pData, len); return HAL_I2C_Master_Transmit(hi2c, device_addr << 1, buffer, len + 1, 1000); }📌 实际项目中建议封装一个高级写函数,自动判断是否需要分页:
// 伪代码示意 void EEPROM_WriteData(uint8_t addr, uint8_t *data, uint8_t len) { while (len > 0) { uint8_t page_space = 32 - (addr % 32); // 计算当前页剩余空间 uint8_t chunk = (len > page_space) ? page_space : len; EEPROM_PageWrite(..., addr, data, chunk); HAL_Delay(5); // 每次写后延时 addr += chunk; data += chunk; len -= chunk; } }实战演练:完整流程演示
下面是一个典型的应用场景:保存并读回一段字符串。
uint8_t tx_data[] = "Hello, EEPROM!"; uint8_t rx_data[16]; // 写入数据(从地址0x10开始) HAL_StatusTypeDef status = EEPROM_PageWrite(&hi2c1, 0x50, 0x10, tx_data, 14); if (status == HAL_OK) { HAL_Delay(5); // 等待写完成 } else { Error_Handler(); } // 读取验证 status = EEPROM_ReadBuffer(&hi2c1, 0x50, 0x10, rx_data, 14); if (status == HAL_OK && memcmp(tx_data, rx_data, 14) == 0) { // 成功!数据一致 }运行后,你会发现即使断电重启,这段数据依然存在。
工程实践中的那些“坑”与应对策略
你以为写了代码就能一帆风顺?Too young。以下是我们在真实项目中踩过的坑和解决方案。
❌ 问题1:总是返回HAL_ERROR,找不到设备
可能原因:
- 上拉电阻没焊
- 地址配错(A0~A2接法不对)
- SDA/SCL接反
- 电源没供上(尤其是模块板)
🔧排查方法:
- 用万用表测VCC和GND是否正常
- 用逻辑分析仪抓包,看是否有ACK响应
- 尝试扫描I2C总线上所有设备地址
// 简易扫描函数 for (int i = 0; i < 128; i++) { if (HAL_I2C_Master_Transmit(&hi2c1, i << 1, NULL, 0, 100) == HAL_OK) { printf("Found device at 0x%02X\r\n", i); } }❌ 问题2:写进去的数据读出来是错的
常见原因:
- 写完没延时,就读了
- 跨页写了导致数据回绕
- 多次快速写没等前一次完成
🔧解决方案:
- 每次写后务必HAL_Delay(5)
- 实现“轮询ACK”方式等待写完成(更高效)
// 替代延时的方法:不断尝试发送设备地址,直到收到ACK为止 HAL_StatusTypeDef EEPROM_WaitReady(I2C_HandleTypeDef *hi2c, uint8_t device_addr, uint32_t timeout_ms) { uint32_t start = HAL_GetTick(); while (HAL_I2C_Master_Transmit(hi2c, device_addr << 1, NULL, 0, 100) != HAL_OK) { if (HAL_GetTick() - start > timeout_ms) { return HAL_TIMEOUT; } } return HAL_OK; }这样就不必傻等5ms,实际可能2ms就完成了。
✅ 高阶技巧:如何延长EEPROM寿命?
虽说能写10万次,但如果每秒写一次,不到一天就报废了。
推荐做法:
1.RAM缓存 + 定时刷盘:平时改数据只改内存,定时(如每分钟)才写入EEPROM
2.变化才写:比较新旧值,不一样再写
3.磨损均衡:把数据分散写到不同地址,轮流使用,避免某一页被反复擦写
总结:你学到的不只是代码,而是一种思维方式
看到这里,你应该已经能够独立完成I2C读写EEPROM的功能开发了。但我们真正想传递的,远不止这几行代码。
你学会了:
- 如何理解一种通信协议的核心逻辑(起始/停止、ACK、地址机制)
- 如何阅读芯片手册的关键信息(设备地址、页大小、写周期)
- 如何把复杂的操作分解成可复用的函数模块
- 如何在实际工程中规避常见陷阱
这些能力,才是让你从“会抄代码”走向“能独立开发”的关键。
下一步你可以尝试:
- 把结构体数据保存进EEPROM
- 实现带CRC校验的可靠存储
- 在Bootloader中读取配置启动参数
- 用Flash模拟EEPROM(某些MCU没有外置EEPROM)
无论你是做毕业设计的学生、DIY爱好者,还是刚入行的工程师,掌握“I2C读写EEPROM”这项技能,都会让你在嵌入式开发路上走得更稳、更远。
如果你在实现过程中遇到了问题,欢迎在评论区留言交流。我们一起debug,一起进步。