从零实现I2C读写EEPROM:HAL库实战全解析
你有没有遇到过这样的场景?设备断电重启后,用户设置全部清零,校准参数又要重新输入。这种“健忘”的系统显然无法满足实际需求。
解决这个问题的关键,在于非易失性存储——而其中最简单、最可靠的方案之一,就是使用I²C接口的EEPROM芯片配合STM32的HAL库完成数据持久化。今天我们就来手把手实现一套稳定可用的“i2c读写eeprom代码”,让你的嵌入式项目真正具备记忆能力。
为什么是I²C + EEPROM?
在众多外设中,为何I²C与EEPROM的组合如此经典?
答案很简单:资源占用少、开发成本低、稳定性高。
- 只需两个GPIO(SDA和SCL)即可挂载多个设备;
- EEPROM支持字节级擦写,不像Flash那样需要整页擦除;
- 数据掉电不丢失,寿命长达百万次写入;
- 配合STM32 HAL库,几行代码就能完成读写操作。
无论是保存传感器校准值、记录开机次数,还是存储用户偏好配置,这套方案都游刃有余。
I²C通信机制:不只是两根线那么简单
虽然I²C物理上只有SDA(数据线)和SCL(时钟线),但它的协议设计非常精巧。
主设备通过拉低SDA发起起始条件(Start),随后发送目标从机地址+读写位。每个从设备都有唯一7位地址,比如常见的AT24C02默认地址为0b1010000(即0x50),加上写标志后变为0xA0。
通信过程中,每传输一个字节,接收方必须在第9个时钟周期给出应答信号(ACK),否则表示设备未响应或总线异常。这一点至关重要——很多初学者发现I²C“不通”,往往就是因为忽略了ACK检测。
更关键的是,I²C支持重复起始(Repeated Start)。例如在随机读操作中:
1. 先以写模式启动,发送内存地址;
2. 不发送Stop,而是直接再次发送Start;
3. 切换为读模式,开始接收数据。
这种方式避免了总线释放,确保整个操作原子性,防止其他主设备抢占。
EEPROM怎么存数据?别被“地址”搞糊涂了
很多人第一次用EEPROM时会困惑:我给了地址,也写了数据,为啥读出来不对?
问题出在对“地址”的理解上。
以AT24C02为例,它有256字节存储空间,内部地址范围是0~255。但这个地址不是I²C从机地址!I²C地址用于选中设备,而这个内部地址才是真正的数据存放位置。
打个比方:
- I²C地址像大楼门牌号(比如“电子市场3栋”);
- 内部地址则是房间号(“301室”);
- 数据就是房间里住的人。
所以完整的写操作流程是:
Start → 发送设备地址(写)→ ACK → 发送内部地址 → ACK → 发送数据 → ACK → Stop
HAL库为我们封装了这一复杂流程。我们只需调用HAL_I2C_Mem_Write(),指定设备地址、内存地址和数据即可,底层自动处理两次传输。
HAL库API怎么选?别再手动拼接时序了
早期开发常有人用GPIO模拟I²C,既费时又容易出错。现在有了STM32 HAL库,根本不需要!
核心函数就两个:
HAL_I2C_Mem_Write(&hi2c1, DevAddress, MemAddress, MemAddSize, pData, Size, Timeout); HAL_I2C_Mem_Read(&hi2c1, DevAddress, MemAddress, MemAddSize, pData, Size, Timeout);它们专为带内部寄存器/存储地址的I²C设备设计,自动完成“先写地址再传数据”的复合事务,省去了手动控制起始/停止的麻烦。
特别注意参数含义:
-DevAddress:设备I²C地址(左移1位后的7位地址,通常0xA0)
-MemAddress:EEPROM内部地址(如0x00 ~ 0xFF)
-MemAddSize:内存地址宽度,8位用I2C_MEMADD_SIZE_8BIT,16位用I2C_MEMADD_SIZE_16BIT
-Timeout:超时时间(毫秒),防止死等
这些函数内部已集成超时判断、重试机制和错误状态返回,极大提升了鲁棒性。
实战代码:可复用的EEPROM驱动模块
下面是一套经过验证的完整实现,可直接集成到你的工程中。
初始化配置
I2C_HandleTypeDef hi2c1; void MX_I2C1_Init(void) { hi2c1.Instance = I2C1; hi2c1.Init.Timing = 0x2000090E; // 400kHz Fast Mode (由CubeMX生成) hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE; 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(); // 自定义错误处理 } }⚠️
Timing值依赖于系统时钟,请根据实际使用STM32CubeMX生成准确配置。
基础读写函数
#define EEPROM_ADDR 0xA0 // AT24C02写地址(7位地址<<1 | 0) #define EEPROM_TIMEOUT 100 // 超时时间(ms) /** * @brief 写一个字节到指定地址 */ HAL_StatusTypeDef EEPROM_Write_Byte(uint16_t mem_addr, uint8_t data) { return HAL_I2C_Mem_Write(&hi2c1, EEPROM_ADDR, mem_addr, I2C_MEMADD_SIZE_8BIT, &data, 1, EEPROM_TIMEOUT); } /** * @brief 从指定地址读一个字节 */ HAL_StatusTypeDef EEPROM_Read_Byte(uint16_t mem_addr, uint8_t *data) { return HAL_I2C_Mem_Read(&hi2c1, EEPROM_ADDR, mem_addr, I2C_MEMADD_SIZE_8BIT, data, 1, EEPROM_TIMEOUT); }批量操作优化:页写与连续读
EEPROM支持页写(Page Write),一次最多写入8字节(AT24C02)。但不能跨页!例如当前地址是7,再写3个字节就会越界。
为此我们加入边界检查:
#define PAGE_SIZE 8 /** * @brief 安全页写:确保不跨页 */ HAL_StatusTypeDef EEPROM_Page_Write(uint16_t mem_addr, uint8_t *buf, uint16_t size) { // 检查是否跨页 if ((mem_addr % PAGE_SIZE) + size > PAGE_SIZE) return HAL_ERROR; return HAL_I2C_Mem_Write(&hi2c1, EEPROM_ADDR, mem_addr, I2C_MEMADD_SIZE_8BIT, buf, size, EEPROM_TIMEOUT); }连续读则没有限制,可以直接读任意长度:
/** * @brief 连续读取多字节(自动地址递增) */ HAL_StatusTypeDef EEPROM_Read_Buffer(uint16_t start_addr, uint8_t *buf, uint16_t len) { return HAL_I2C_Mem_Read(&hi2c1, EEPROM_ADDR, start_addr, I2C_MEMADD_SIZE_8BIT, buf, len, EEPROM_TIMEOUT); }关键细节:那些手册里不会明说的坑
你以为写完就能用了?别急,还有几个隐藏雷区等着你。
✅ 写入后必须等待!
EEPROM写入不是即时完成的。芯片内部要进行编程操作,典型时间为5ms,最大可达10ms。在这期间如果再次访问,可能得不到ACK响应。
常见做法是插入延时:
EEPROM_Write_Byte(0x00, 0x5A); HAL_Delay(10); // 等待写完成但在实时系统中,阻塞Delay显然不合适。更好的方式是轮询确认:
HAL_StatusTypeDef EEPROM_Wait_Ready(uint32_t timeout_ms) { uint32_t tickstart = HAL_GetTick(); while (HAL_I2C_IsDeviceReady(&hi2c1, EEPROM_ADDR, 1, 1) != HAL_OK) { if ((HAL_GetTick() - tickstart) > timeout_ms) return HAL_TIMEOUT; } return HAL_OK; }调用写操作后,改用EEPROM_Wait_Ready(10)代替Delay,效率更高且更安全。
✅ 地址冲突怎么办?
如果你同时接了RTC(DS1307)、EEPROM(AT24C02)等多个I²C设备,一定要确认它们的地址不冲突。
AT24C系列可通过A0/A1/A2引脚接地或接VDD来改变地址。例如:
- A0=0, A1=0, A2=0 → 地址0xA0
- A0=1, A1=0, A2=0 → 地址0xA2
建议在PCB设计阶段就规划好地址分配,并保留上拉电阻焊盘以便调试。
✅ 上拉电阻怎么选?
开漏输出必须外接上拉电阻。一般推荐:
- 标准模式(100kbps):4.7kΩ ~ 10kΩ
- 快速模式(400kbps):1kΩ ~ 2kΩ
阻值太大会导致上升沿缓慢,高速下通信失败;太小则功耗增加。
还可以根据总线电容估算:
$$ R_{pull-up} \approx \frac{300ns}{C_{bus}} $$
实际中可用示波器观察SDA波形,调整至边沿陡峭且无振铃为止。
工程实践建议:让代码更健壮
1. 添加重试机制
I²C通信受干扰可能导致失败。不要轻易放弃,加个重试:
HAL_StatusTypeDef EEPROM_Write_With_Retry(uint16_t addr, uint8_t data, uint8_t retries) { HAL_StatusTypeDef status; for (int i = 0; i < retries; i++) { status = EEPROM_Write_Byte(addr, data); if (status == HAL_OK) return HAL_OK; HAL_Delay(10); } return status; }2. 结构体数据整包读写
实际应用中,通常要保存结构体数据。可以这样封装:
typedef struct { float calib_gain; int16_t offset; uint8_t brightness; uint32_t boot_count; } SystemConfig_t; SystemConfig_t config; // 保存配置 void Save_Config(void) { EEPROM_Page_Write(0x10, (uint8_t*)&config, sizeof(config)); EEPROM_Wait_Ready(10); } // 加载配置 void Load_Config(void) { if (EEPROM_Read_Buffer(0x10, (uint8_t*)&config, sizeof(config)) != HAL_OK) { // 读取失败,加载默认值 config.calib_gain = 1.0f; config.offset = 0; config.brightness = 50; config.boot_count++; Save_Config(); } }3. 寿命均衡:避免局部磨损
频繁更新同一地址会导致该区域提前失效。解决方案是使用循环缓冲区或镜像备份。
简单做法:将常用变量分散存储,或每隔一定次数切换存储位置。
总结:掌握这项技能意味着什么?
当你能熟练写出稳定可靠的“I2C读写EEPROM代码”,说明你已经跨越了入门门槛,具备以下能力:
- 理解硬件协议与软件抽象层的协同关系;
- 具备基本的外设调试能力和问题排查思维;
- 能够将理论知识转化为可运行的工程代码;
- 开始关注可靠性、容错性和可维护性。
这不仅是学会了一个功能,更是建立起一套嵌入式开发的方法论。
未来你可以在此基础上拓展:
- 使用DMA实现非阻塞I²C传输;
- 在RTOS中创建独立的存储任务;
- 实现简单的文件系统管理多块数据;
- 迁移到SPI Flash或FRAM等新型存储介质。
技术演进永不停歇,但I²C+EEPROM这套经典组合,因其简洁可靠,仍将在工业控制、智能家居、医疗设备等领域长期存在。
如果你正在做一个需要记忆功能的小项目,不妨试试这套方案。几行代码,就能让你的作品真正“记住”它的用户。