I²C EEPROM:保存掉电不丢失的数据

2026-09-10 00:00    #ESP32-S3   #ESP-IDF   #外设   #阶段3  

本课对应《DNESP32S3 使用指南(IDF 版)》第 20 章。配套完整工程:examples/15-i2c-eeprom。

环境与验收边界: DNESP32S3 V1.2,ESP-IDF v5.5.5;本机 macOS 构建通过。Ubuntu 26、外设接线、屏幕效果和实际测量仍待实板验收。下文输出均为预期格式,不能当作已采集的硬件日志。

本课完成后能做什么

硬件连接与资源

信号或资源对应关系
SDA / SCLGPIO41 / GPIO42
AT24C02地址 0x50,容量 256 字节
页大小 / WP8 字节 / 板载接地允许写入
写入触发BOOT GPIO0,正常启动后按下

以下为正点原子 DNESP32S3 V1.2 原理图的相关页,保留原厂标识;表格用于快速定位,遇到板卡版本差异以实际原理图为准。点击图片可查看引脚与网络名。

DNESP32S3 原理图第 2 页

DNESP32S3 原理图第 4 页

本课信号与数据处理流程

EEPROM 与普通变量有什么不同

RAM 中的变量断电后丢失,AT24C02 的 EEPROM 在断电后保留数据。它适合保存变化不频繁的小配置,例如上次使用的模式,不适合把每次循环的传感器值都写进去。写入有耗时,也有擦写寿命限制。

AT24C02 有 256 个字节地址,范围 0~255。本例使用 7 位 I²C 地址 0x50,不要把旧资料中的写地址 0xA0 直接传给新驱动。板卡的 A0~A2 和 WP 接地,分别确定地址和允许写入。

跨页写入为什么必须拆包

EEPROM 的页写缓冲只有 8 字节。一次事务越过页尾,不会自动按你想象的方式写到下一页,可能在同页内回绕覆盖。

例如,从地址 6 保存含字符串结束符的 15 字节数据,需要拆成:

起点本次字节数写入范围
626~7
888~15
16516~20

计算式为 min(剩余字节数, 8 - 地址 % 8)。每一包先发送存储地址,再发送数据。总线事务结束不等于内部写周期结束,因此每包之后用有上限的 ACK 轮询等待器件重新应答。

怎样证明真的保存了,而不是刚写又读

程序启动时先读地址 6 并输出十六进制字节,不自动写入。按 BOOT 才保存 EEPROM-PERSIST\0,随后读回用 memcmp() 校验。校验使用固定长度,不依赖未初始化存储里是否恰好存在字符串结束符。

第一次上电读出 0xff 或旧内容都正常。完成写入后断电重启,不按 BOOT,确认启动日志仍有同样字节,才能证明持久化。本例使用固定内容,不是事务数据库;若产品要求断电写入也能恢复,应增加版本、长度、CRC 和双槽提交策略。

工程如何组织

驱动放在 components/BSP/,应用放在 main/main.c。本工程包含 BUS、EEPROM 模块。项目根目录的 components/BSP 会被 ESP-IDF 自动发现,main 的 PRIV_REQUIRES BSP 声明组件依赖;INCLUDE_DIRS 导出头文件搜索路径,不是运行时查找文件。

固定引脚以 CONFIG_LESSON_* 常量写入本工程 Kconfig.projbuild,构建后可在 sdkconfig 核对;这些默认值对应本板连线,不表示任意改数字就能改变硬件。

应用代码:入口与处理流程

下面代码直接取自配套工程的 main/main.c,头文件和 BSP 实现在仓库中一并提供。初学时先看 app_main(),再追踪它调用的模块。

 1#include "sdkconfig.h"
 2#include <stdio.h>
 3#include <stdint.h>
 4#include <stdbool.h>
 5#include <string.h>
 6#include <stdlib.h>
 7#include <assert.h>
 8#include "esp_err.h"
 9#include "esp_log.h"
10#include "esp_timer.h"
11#include "freertos/FreeRTOS.h"
12#include "freertos/task.h"
13#include "eeprom.h"
14#include "driver/gpio.h"
15void app_main(void)
16{
17    i2c_master_bus_handle_t bus;
18    ESP_ERROR_CHECK(bus_init(0,CONFIG_LESSON_I2C_SDA,CONFIG_LESSON_I2C_SCL,&bus));
19    ESP_ERROR_CHECK(eeprom_open(bus));
20    gpio_config_t key={.pin_bit_mask=1ULL<<CONFIG_LESSON_KEY,.mode=GPIO_MODE_INPUT,.pull_up_en=true};
21    ESP_ERROR_CHECK(gpio_config(&key));
22    const char message[]="EEPROM-PERSIST"; // 含结尾 0,共 15 字节,从地址 6 开始跨三页。
23    char data[sizeof(message)];
24    esp_err_t err=eeprom_read(6,data,sizeof(data));
25    if(err==ESP_OK) ESP_LOG_BUFFER_HEX("saved",data,sizeof(data));
26    ESP_LOGI("eeprom","Press BOOT to write; power cycle to inspect saved bytes");
27    bool previous=false;
28    while(1) {
29        bool pressed=gpio_get_level(CONFIG_LESSON_KEY)==0;
30        if(pressed && !previous) {
31            vTaskDelay(pdMS_TO_TICKS(30));
32            if(gpio_get_level(CONFIG_LESSON_KEY)==0) {
33                err=eeprom_write(6,message,sizeof(message));
34                if(err==ESP_OK) err=eeprom_read(6,data,sizeof(data));
35                if(err==ESP_OK && memcmp(data,message,sizeof(data))) err=ESP_ERR_INVALID_RESPONSE;
36                ESP_LOGI("eeprom","write + verify: %s",esp_err_to_name(err));
37            }
38        }
39        previous=pressed;
40        vTaskDelay(pdMS_TO_TICKS(20));
41    }
42}

BSP 关键实现:EEPROM

以下是本课对应驱动的完整源文件。对外声明见 eeprom.h。公共 BUS 模块统一使用新 I²C master API,将单次事务等待限制在 50 ms;设备 API 使用 7 位地址。

 1#include "eeprom.h"
 2#include <string.h>
 3#include "freertos/FreeRTOS.h"
 4#include "freertos/task.h"
 5static i2c_master_dev_handle_t s_dev;
 6static i2c_master_bus_handle_t s_bus;
 7esp_err_t eeprom_open(i2c_master_bus_handle_t bus)
 8{
 9    s_bus=bus;
10    return bus_device(bus,0x50,&s_dev);
11}
12esp_err_t eeprom_read(size_t address, void *data, size_t n)
13{
14    if(!data || !n || address>=256 || n>256-address) return ESP_ERR_INVALID_ARG;
15    return reg_read(s_dev,(uint8_t)address,data,n);
16}
17esp_err_t eeprom_write(size_t address, const void *data, size_t n)
18{
19    if(!data || !n || address>=256 || n>256-address) return ESP_ERR_INVALID_ARG;
20    const uint8_t *p=data;
21    while(n) {
22        size_t chunk=8-address%8; // AT24C02 一页只有 8 字节,不能跨页发一次写命令。
23        if(chunk>n) chunk=n;
24        uint8_t packet[9]={(uint8_t)address};
25        memcpy(packet+1,p,chunk);
26        ESP_RETURN_ON_ERROR(i2c_master_transmit(s_dev,packet,chunk+1,50),"eeprom","write");
27        esp_err_t ready=ESP_FAIL;
28        // 写周期内芯片 NACK 是正常忙状态;最多等约 20 ms,不能无限等。
29        for(int attempt=0;attempt<10;attempt++) {
30            vTaskDelay(pdMS_TO_TICKS(2));
31            ready=i2c_master_probe(s_bus,0x50,2);
32            if(ready==ESP_OK) break;
33        }
34        if(ready!=ESP_OK) return ready;
35        address+=chunk; p+=chunk; n-=chunk;
36    }
37    return ESP_OK;
38}

CMake 与中文注释

1# 私有依赖只供本组件源码使用。
2idf_component_register(SRCS "main.c" INCLUDE_DIRS "."
3    PRIV_REQUIRES esp_timer BSP esp_driver_gpio)

每个工程有自己的 project() 名称和 sdkconfig.defaults。VS Code 设置已关闭普通 CMake 自动配置,C/C++ 语言服务读取 build/compile_commands.json;首次构建前文件尚不存在,应先激活 IDF 并构建,而不是手工拼头文件路径。

编译、烧录与观察

 1# 激活 EIM 安装的 IDF;每个新终端都需要激活。
 2source ~/.espressif/tools/activate_idf_v5.5.5.sh
 3# 进入单个示例根目录,不是在 examples 总目录运行。
 4cd ~/mycode/esp32-blog/examples/15-i2c-eeprom
 5# 首次配置目标;已有正确配置时不用每次重复 set-target。
 6idf.py set-target esp32s3
 7# 编译会生成 build/compile_commands.json,供 VS Code 使用。
 8idf.py build
 9# 把这个值换成实际连接开发板的端口;macOS 通常为 /dev/cu.*。
10PORT=/dev/ttyUSB0
11idf.py -p "$PORT" flash monitor
12# 串口监视退出:Ctrl+]。

预期输出或屏幕内容(示意;数值、日志前缀和启动时间依现场而变):

1saved: 45 45 50 52 4f 4d 2d 50 45 52 53 49 53 54 00
2eeprom: write + verify: ESP_OK

故障定位与逐项验收

先检查目标是否是 esp32s3、实际端口与供电,再按本文引脚表核对跳线。编译成功只能说明源码与工具链兼容,无法证明模块型号、物理线路或时序已经正确。

遇到通信错误先看错误码和对应模块,禁止简单用“错误时返回 0”掩盖异常。预期日志是阅读程序的参考,验收时应保存自己的现象、日志与接线照片。

与教材和官方实现对照

对照 09_iic_eeprom:保留 AT24C02、0x50 和 8 字节页;增加容量校验、跨页拆分、有界 ACK 轮询,以及启动只读、按键才写的持久化验收流程。

本地验证记录与参考

除了构建,本课的真实 EEPROM BSP 还通过主机模拟测试:模拟器故意实现页内回绕,检查跨页拆分是否保护相邻数据,并验证越界拒绝和忙状态超时。它不模拟断电保持,后者必须实板验证。

1# 在 esp32-blog 根目录运行,不需要接开发板。
2bash examples/tests/stage3/run.sh

返回课程目录