做嵌入式的小伙伴应该都有过这种经历:程序写完烧进去,功能一切正常,结果一断电重启,之前配置好的WiFi账号、传感器校准值、设备编号全都没了,只能重新设置。我最早做设备端项目时就因为参数存储没想清楚,被现场返修搞得很狼狈。后来学乖了,给主控外挂一颗EEPROM,主控用的还是ESP32,开发框架是Espressif官方推荐的ESP-IDF。今天就把我实际调通的一套AT24C02驱动源码完整分享出来,从硬件接线、I2C地址推导、驱动API选型到读写函数实现,再到实测中踩过的写周期、页边界这几个坑,一步不落讲清楚。如果你正好在做参数掉电保存这类需求,这份笔记可以直接抄作业。
1. 为什么是AT24C02:参数存储场景下的芯片选型逻辑
先说个现实问题:很多嵌入式项目跑起来之后,需要保存的数据其实非常小——一组WiFi的SSID和密码、几个传感器校准系数、一个设备序列号,加起来可能不超过64字节。这点数据如果用大容量Flash去存,就像用卡车拉一袋米,浪费接口资源不说,还得处理Flash的擦除粒度、扇区管理等额外逻辑。AT24C02这种I2C接口EEPROM,容量2Kbit也就是256字节,两根线就能通信,正好是这类"小参数"场景最顺手的解决方案。
可能有人会问:ESP32内部不是也有Flash吗?直接往内部Flash写不就行了?理论上可以,但实际有几个麻烦:一是内部Flash的擦写次数有限,典型寿命在10万次左右,频繁的掉电保存操作会让它加速老化;二是内部Flash和代码存储共用存储介质,擦写操作一旦出错可能连固件都损坏,风险太大。EEPROM按字节擦写,标称写寿命在100万次左右,数据保持时间按芯片手册写的是常温下100年,用于保存配置参数绰绰有余。
外部存储方案里主要有三类:EEPROM、SPI Flash、RTC的SRAM备份区。SPI Flash(像W25Q系列)容量大、价格低,但擦写粒度是4KB扇区,想改一个字节也得先整扇区读改写,而且需要自己做磨损均衡;RTC SRAM靠电池供电,掉电不丢,但电池终归是个耗材,长期无人维护的设备没人愿意留着这个隐患。相比之下,AT24C02的按字节随机读写特性,特别适合"读多写少、每次只改几个字段"的参数保存场景。
从成本角度看,AT24C02的单颗价格在小批量采购时大概是几毛钱人民币,配合两个上拉电阻,总体硬件成本不到一块钱。对于消费级量产产品来说,这个成本控制相当理想。而且I2C总线在ESP32上几乎不占额外资源,同一组SDA/SCL引脚上还能挂OLED屏幕、温湿度传感器等其他I2C设备,一颗芯片的成本换来一块独立的参数存储区域,我认为这是当下性价比最高的方案之一。
我个人的选型经验是:需要保存的数据量小于256字节、写入频率不高(一天几次甚至几天一次)、数据格式可能频繁变更——这三个条件同时满足时,AT24C02基本就是最优解。如果数据量超过1KB,或者有数百Hz以上的周期性写入需求,那就得老老实实考虑SPI Flash加上磨损均衡算法了。
2. 硬件连接与I2C地址推导:动手前必须搞清楚的三个细节
AT24C02是标准的8脚SOP封装,引脚功能看芯片手册一目了然,但真正接电路的时候有三个细节容易出错,我挨个说一下。
第一个是I2C从机地址的推导。AT24C02的引脚里有三根地址选择脚——A0、A1、A2,它们的电平状态直接决定芯片在总线上的设备地址。数据手册里写了,AT24C02的设备地址固定前缀是1010,后三位就是A2/A1/A0的电平组合。以三根地址脚都接地为例,二进制地址就是1010000,换算成十六进制就是0x50。如果把A0接到高电平,地址就变成1010001,也就是0x51。一颗总线上最多可以挂8颗不同地址的AT24C02,只要A0/A1/A2的电平组合不重复就行。
这里要特别提醒一个很多人困惑的点:ESP-IDF的驱动API里,设备地址到底填0x50还是0xA0?在新版I2C驱动中,device_address字段填的是7位地址0x50,驱动内部会自动左移一位并拼上读写位;如果是老版驱动用手写命令链的方式,i2c_master_write_byte传入的则是含读写位的8位地址,写操作要填0xA0(10100000),读操作填0xA1。这两个API对地址的表示方式不同,混用的话通信一定失败,初学者最常见的问题就出在这里。
第二个细节是上下拉电阻。I2C总线是开漏结构,SCL和SDA引脚本身只能拉低,拉高要靠外部上拉电阻,这属于I2C协议的物理层要求。ESP32的GPIO内部有弱上拉,但驱动能力较弱,在低速(100kHz)短距离(板内几厘米)场景下勉强能用,总线波形不太好看,一旦线缆稍微长一点就可能出现通信不稳定。更好的做法是在SCL和SDA上各接一颗4.7kΩ的外部上拉电阻到3.3V——注意是3.3V,不是5V,AT24C02在3.3V供电时完全正常工作,接到5V上反而可能通过上拉电阻往ESP32的GPIO灌电流。
第三个细节是WP写保护脚。这个引脚接高电平时芯片进入只读模式,所有写操作都会被芯片忽略。很多开发板出于方便,把WP直接接地让芯片始终保持可写状态,这样调试是省心了,但量产产品里如果这个脚悬空,受到干扰时芯片可能意外进入写保护状态,导致设备重启后参数写不进去。稳妥的做法是在WP脚上接一个10kΩ下拉电阻到GND,或者直接用MCU的一个GPIO控制它,需要写入时拉低,平时拉高,兼顾安全和灵活。
顺便把典型接线列一下,方便对照检查:
| AT24C02引脚 | 功能 | 接法 |
|---|---|---|
| VCC | 电源 | 3.3V,旁边放一个100nF去耦电容 |
| GND | 地 | 接GND |
| SDA | 数据线 | 接ESP32的GPIO21,经4.7kΩ上拉到3.3V |
| SCL | 时钟线 | 接ESP32的GPIO22,经4.7kΩ上拉到3.3V |
| A0/A1/A2 | 地址选择 | 全部接地,得到地址0x50 |
| WP | 写保护 | 接地(或GPIO控制) |
3. ESP-IDF两代I2C驱动API:从旧驱动迁移到新驱动的完整记录
ESP-IDF在版本迭代过程中,I2C驱动经历了一次比较大的重构。我最早用的是v4.4,那时候的标志性写法是"命令链"风格:先创建一条I2C命令链,然后把start、写地址、写数据、stop这些操作一步步追加进去,最后一次性执行。整个过程像在拼接一段指令序列,逻辑上是直观的,但代码非常啰嗦,而且用完之后必须记得释放命令链,否则会内存泄漏。
v4.x时代的初始化代码长这样:
#include "driver/i2c.h" #define I2C_MASTER_SCL_IO 22 #define I2C_MASTER_SDA_IO 21 #define I2C_MASTER_NUM I2C_NUM_0 #define I2C_MASTER_FREQ_HZ 100000 void i2c_master_init(void) { i2c_config_t conf = { .mode = I2C_MODE_MASTER, .sda_io_num = I2C_MASTER_SDA_IO, .scl_io_num = I2C_MASTER_SCL_IO, .sda_pullup_en = GPIO_PULLUP_ENABLE, .scl_pullup_en = GPIO_PULLUP_ENABLE, .master.clk_speed = I2C_MASTER_FREQ_HZ, }; i2c_param_config(I2C_MASTER_NUM, &conf); i2c_driver_install(I2C_MASTER_NUM, I2C_MODE_MASTER, 0, 0, 0); } esp_err_t i2c_write_byte_to(uint8_t dev_addr_7bit, uint8_t mem_addr, uint8_t data) { i2c_cmd_handle_t cmd = i2c_cmd_link_create(); i2c_master_start(cmd); i2c_master_write_byte(cmd, (dev_addr_7bit << 1) | I2C_MASTER_WRITE, true); i2c_master_write_byte(cmd, mem_addr, true); i2c_master_write_byte(cmd, data, true); i2c_master_stop(cmd); esp_err_t ret = i2c_master_cmd_begin(I2C_MASTER_NUM, cmd, pdMS_TO_TICKS(100)); i2c_cmd_link_delete(cmd); return ret; }到ESP-IDF v5.0之后,官方把I2C驱动重写成了面向对象的"总线+设备"模型。整个思路变成了两步:先创建一个I2C总线实例,然后把具体设备加到总线上,之后所有操作都是对设备句柄调用传输函数,再也不用手动拼接命令链了。新的API在可读性上提升非常明显,代码结构也更接近Linux内核的I2C子系统设计。
我当前项目用的是ESP-IDF v5.x,新驱动的初始化代码简洁很多:
#include "driver/i2c_master.h" #define I2C_MASTER_PORT I2C_NUM_0 #define I2C_SCL_GPIO 22 #define I2C_SDA_GPIO 21 #define AT24C02_ADDR 0x50 static i2c_master_bus_handle_t s_bus_handle; static i2c_master_dev_handle_t s_dev_handle; esp_err_t at24c02_init(void) { i2c_master_bus_config_t bus_cfg = { .i2c_port = I2C_MASTER_PORT, .sda_io_num = I2C_SDA_GPIO, .scl_io_num = I2C_SCL_GPIO, .clk_source = I2C_CLK_SRC_DEFAULT, .glitch_ignore_cnt = 7, .flags.enable_internal_pullup = true, }; ESP_RETURN_ON_ERROR(i2c_new_master_bus(&bus_cfg, &s_bus_handle), "at24c02", ESP_FAIL); i2c_device_config_t dev_cfg = { .dev_addr_length = I2C_ADDR_BIT_LEN_7, .device_address = AT24C02_ADDR, .scl_speed_hz = 100000, }; ESP_RETURN_ON_ERROR(i2c_master_bus_add_device(s_bus_handle, &dev_cfg, &s_dev_handle), "at24c02", ESP_FAIL); return ESP_OK; }从旧版迁移到新版,有几个变化需要特别注意。第一是头文件变了,旧版用driver/i2c.h,新版用driver/i2c_master.h;第二是地址语义变了,device_address直接填7位地址,不要再左移;第三是新版API已经内置超时处理,不需要手动在i2c_master_cmd_begin里传超时参数,而是在每个传输函数的最后一个参数传入pdMS_TO_TICKS(x)。如果你是从v4.x项目升级过来,强烈建议先把旧代码中所有手动拼接命令链的地方全部替换成新的i2c_master_transmit系列函数,官方文档说旧版驱动仍然可用,但它已经不推荐新项目使用了,而且新驱动的调试体验好不少。
4. 驱动源码拆解:单字节读写、页写入的实现与逐行注释
现在进入正题,我把完整的AT24C02驱动源码按功能模块拆开讲。整体代码我放在一个at24c02.c文件里,配套头文件导出几个简单的接口函数,使用时只需要调用at24c02_init()完成初始化,然后读写函数直接调就行了。
先定义芯片相关的宏和静态句柄:
#include <string.h> #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_log.h" #include "driver/i2c_master.h" #define AT24C02_ADDR 0x50 #define AT24C02_PAGE_SIZE 8 #define AT24C02_TOTAL_SIZE 256 #define AT24C02_WRITE_CYCLE_MS 5 static const char *TAG = "at24c02"; static i2c_master_bus_handle_t s_bus_handle; static i2c_master_dev_handle_t s_dev_handle;初始化函数就是上一节里的at24c02_init,不再重复。这里的关键参数是clk_source和glitch_ignore_cnt。时钟源用默认值就行;glitch_ignore_cnt是滤波值,默认7比较合适,数值越大对总线毛刺的容忍度越高,但也会影响通信速率上限。对100kHz的I2C来说这不是瓶颈,保持默认就好。
单字节随机写,也就是先发送目标地址,再发送一个字节数据:
esp_err_t at24c02_write_byte(uint8_t addr, uint8_t data) { if (addr >= AT24C02_TOTAL_SIZE) { return ESP_ERR_INVALID_ARG; } uint8_t buf[2] = { addr, data }; esp_err_t ret = i2c_master_transmit(s_dev_handle, buf, sizeof(buf), pdMS_TO_TICKS(100)); if (ret != ESP_OK) { ESP_LOGE(TAG, "write byte failed, mem_addr=0x%02x, ret=0x%x", addr, ret); } return ret; }注意这里加了地址范围检查,这是一个很容易被忽略的细节。AT24C02的有效地址范围是0x00~0xFF共256字节,传入超出范围的地地址,芯片会自动回绕,也就是说传0x100进去,芯片实际操作的是0x00。这种回绕行为在没有心理预期时排查起来极其隐蔽,代码里显式做检查能在早期拦截错误。
单字节随机读,在旧版驱动里需要先发地址再重新发起读命令,在新版驱动中可以直接用i2c_master_transmit_receive一条API完成:
esp_err_t at24c02_read_byte(uint8_t addr, uint8_t *data) { if (addr >= AT24C02_TOTAL_SIZE) { return ESP_ERR_INVALID_ARG; } esp_err_t ret = i2c_master_transmit_receive(s_dev_handle, &addr, 1, data, 1, pdMS_TO_TICKS(100)); if (ret != ESP_OK) { ESP_LOGE(TAG, "read byte failed, mem_addr=0x%02x, ret=0x%x", addr, ret); } return ret; }i2c_master_transmit_receive的作用是先在线发送&addr这一字节,然后不产生停止条件,直接切换到读模式接收一字节数据。这正是EEPROM随机读的标准时序:发送目标地址→重复起始条件→读数据。
接下来是页写入,这是AT24C02批量写入的核心接口。芯片内部按8字节一个页组织存储空间,单次写命令最多写入8字节,一旦超过页边界,数据会回绕到当前页的开头:
esp_err_t at24c02_write_page(uint8_t addr, const uint8_t *data, size_t len) { if (addr >= AT24C02_TOTAL_SIZE || len == 0 || len > AT24C02_PAGE_SIZE) { return ESP_ERR_INVALID_ARG; } uint8_t buf[1 + AT24C02_PAGE_SIZE]; buf[0] = addr; memcpy(buf + 1, data, len); return i2c_master_transmit(s_dev_handle, buf, 1 + len, pdMS_TO_TICKS(100)); }注意buf的大小是1 + AT24C02_PAGE_SIZE,多出来的一个字节用来放目标地址。很多初学者会直接定义一个uint8_t buf[8],然后把地址和数据都塞进去,结果地址就被当成了第一个数据字节写入存储区,光这个问题就够调半小时。
连续多字节写入,这是最常用的上层接口,好处是调用者不用关心页边界,函数内部自动拆分:
esp_err_t at24c02_write_buf(uint8_t addr, const uint8_t *data, size_t len) { if (addr + len > AT24C02_TOTAL_SIZE) { return ESP_ERR_INVALID_ARG; } while (len > 0) { size_t page_remain = AT24C02_PAGE_SIZE - (addr % AT24C02_PAGE_SIZE); size_t chunk = (len < page_remain) ? len : page_remain; esp_err_t ret = at24c02_write_page(addr, data, chunk); if (ret != ESP_OK) { return ret; } ESP_RETURN_ON_ERROR(at24c02_wait_ready(), TAG, "wait ready failed"); addr += chunk; data += chunk; len -= chunk; } return ESP_OK; }核心逻辑在page_remain这一行:当前地址对页大小取模,得出已经写到了页内的第几个字节,用8减去它就是本页剩余空间,然后取剩余数据量和页剩余空间的最小值作为本次写入长度。写完一页后要调用at24c02_wait_ready()等待芯片内部编程完成,再继续写下一页。
最后是等待写周期结束的函数。AT24C02每次收到写命令后,内部需要约5ms时间把数据真正写入存储单元,这段时间内芯片不响应任何I2C命令。最省事的做法是写完固定延时5ms,但更专业的做法是轮询:写完后就尝试发一个无关命令(比如读地址0x00),如果芯片没有返回ACK,说明写周期还没结束;一旦返回ACK,立即继续后续操作。这样在写入时既不会漏等,也不会白白浪费多余的延时:
esp_err_t at24c02_wait_ready(void) { uint8_t dummy = 0; for (int i = 0; i < 100; i++) { esp_err_t ret = i2c_master_transmit(s_dev_handle, &dummy, 1, pdMS_TO_TICKS(20)); if (ret == ESP_OK) { return ESP_OK; } vTaskDelay(pdMS_TO_TICKS(1)); } ESP_LOGE(TAG, "wait ready timeout"); return ESP_ERR_TIMEOUT; }这个函数在写周期里至关重要。如果每次写完只固定延时5ms,写入一页数据后立刻写下一页,理论上是够的,因为单页写入最多8字节,5ms之后芯片肯定完成了内部编程。但要注意,一旦I2C总线速率提到400kHz,或者芯片在高温下写周期变长,固定延时的方案就可能不够稳。轮询方案无论如何都更可靠,而且代码量只多了几行,在量产环境中能省掉大量隐性故障。
5. 实测最容易翻车的三个场景:写周期、页边界与读回验证
代码写完不一定就完事,实际跑起来总会遇到一些"理论上没毛病,实测就是不对"的情况。我把AT24C02调试过程中最容易翻车的三个场景一一列出来,每一步都说明现象和排查路径,方便你对照。
第一个场景是"写完马上读,读出来是旧数据"。我最初做驱动时,写完一个字节紧接着就调用读函数,期望直接读回新值,结果读出的还是上一次写入的内容。第一次遇到时只觉得"I2C通信崩了",后来查了芯片手册才发现,AT24C02的写操作分成两步:总线传输只是把数据锁存到芯片内部的页缓冲里,真正的存储单元编程要等内部定时器触发,大约5ms才能完成。在这5ms之内你去读,数据缓冲还没刷新,读到的自然就是旧的。解决方案就是上一节的at24c02_wait_ready()轮询——注意,轮询能够生效,是因为芯片在写周期期间对总线上任何访问都不回ACK,所以"发送成功"本身就说明写周期结束了。这个机制在几乎所有I2C接口的EEPROM上都适用。
第二个场景是"连续写入超过8字节,前面的数据被覆盖了"。我把一个长度为16的字节数组直接通过一次页写入命令发出去,想着芯片应该能连续写入16字节。实际结果是:前8字节正常写入,第9~16字节被写到了同一页的开头位置,把前8字节覆盖掉了。原因在于AT24C02的地址计数器在页内是8字节回绕的——它不像大容量EEPROM那样地址连续递增到255才回绕,而是在每个8字节页的边界就自动回到页首。跨越页边界的连续写入必须由软件自己拆分,这正是at24c02_write_buf里page_remain逻辑存在的意义。我在这个坑里卡了大概三个小时,最后是在示波器上看波形时才意识到,总线上的地址字节根本就没变过,问题不在I2C通信,而在芯片的地址回绕机制上。
第三个场景是"读回验证时,读到的数据看似合理,其实是另一个地址的东西"。有一次我在地址0x30写入一条配置,然后在0x31读回验证,发现0x31里有一个看似合理但完全陌生的值。追寻了半天才想起来,之前测试时我用过一个越界的地址0x100写入操作,芯片自动回绕到0x00执行了写入,污染了原本干净的开头区域,而那个"陌生值"正是之前在0x00写下的调试标记。这类问题在代码层面很难直接发现,因为它不会报错,I2C通信也完全正常,唯一的破绽是数据莫名其妙地出现在不该出现的地方。所以我在驱动里对所有地址参数都做了范围检查,防的就是这种隐性覆盖。
顺便说说读回验证的正确姿势。写完一个字节后,调用at24c02_write_byte(),紧接着调用at24c02_wait_ready(),确认写周期结束后再读。验证时读出的值要和写入值比较,一旦不一致就要考虑是不是WP引脚被拉高了。我遇到过一种现象:写函数返回ESP_OK(因为I2C总线上传输本身是成功的),但实际数据没写进去——原因就是WP脚意外变成高电平,芯片把所有写操作都默默忽略了。这种故障用逻辑分析仪都很难看出来,因为总线波形完全正常,唯一异常是写入之后读回的数据不变。排查手段就一个:先确认WP被可靠接地,或者写完后多读几遍,看数据是否稳定。
6. 从驱动到应用层:一个可直接复用的参数存储封装设计
驱动层搞定之后,真正项目中还需要在它之上做一层封装,因为直接把散乱的字节写入和读取暴露给业务代码,很容易造成地址管理混乱。我的做法是定义一个配置结构体,把要保存的参数打包成一个整体,然后通过EEPROM读写函数做序列化和反序列化。这样做的好处是一个结构体就完成了所有配置的打包,新版固件增加字段时,只需要改结构体定义和版本号,不需要逐个字段去操作EEPROM地址。
先定义一个典型的配置结构体:
#define APP_CFG_MAGIC 0x5AA55AA5 #define APP_CFG_VERSION 1 #define APP_CFG_ADDR 0x00 #define APP_CFG_SIZE sizeof(app_cfg_t) typedef struct { uint32_t magic; // 魔数,用于判定数据是否有效 uint8_t version; // 结构体版本 uint8_t mode; // 工作模式 uint16_t threshold; // 阈值参数 uint8_t crc; // 简单校验字节 uint8_t reserved[6];// 预留空间 } app_cfg_t;magic字段是整个封装的第一道保险。EEPROM出厂时所有字节都是0xFF,如果上电后直接读取并当作有效配置使用,那设备会用一堆无意义的值去初始化,行为完全不可控。加一个固定的魔数,读取时先判断魔数是否匹配,不匹配就说明这片区域从没写过数据或者已经损坏,此时加载默认配置。
version字段用于结构体演进。项目量产之后难免要增加新参数,如果直接把结构体加大,旧固件写进去的数据在新固件里字段偏移就全错了。有了版本号,读取时可以按旧版本结构体先迁移一次,再填充默认值。crc字段用于检测数据在传输或存储过程中的损坏,虽然EEPROM本身可靠性很高,但I2C总线上的干扰、电源波动都可能造成写入过程中数据出错,加一个简单的字节累加和就能覆盖大部分这类问题。
读取和保存的代码实现如下:
esp_err_t app_cfg_load(app_cfg_t *cfg) { uint8_t buf[APP_CFG_SIZE]; esp_err_t ret = at24c02_read_buf(APP_CFG_ADDR, buf, sizeof(buf)); if (ret != ESP_OK) { return ret; } memcpy(cfg, buf, sizeof(app_cfg_t)); if (cfg->magic != APP_CFG_MAGIC) { return ESP_ERR_NOT_FOUND; // 未初始化,调用方应加载默认配置 } uint8_t checksum = 0; for (int i = 0; i < sizeof(app_cfg_t); i++) { checksum += ((uint8_t *)cfg)[i]; } if (checksum != 0xFF) { return ESP_ERR_INVALID_CRC; } return ESP_OK; } esp_err_t app_cfg_save(const app_cfg_t *cfg) { app_cfg_t tmp; memcpy(&tmp, cfg, sizeof(tmp)); tmp.magic = APP_CFG_MAGIC; tmp.version = APP_CFG_VERSION; uint8_t checksum = 0; for (int i = 0; i < sizeof(tmp); i++) { checksum += ((uint8_t *)&tmp)[i]; } tmp.crc = (uint8_t)(0xFF - checksum); // 令全字节累加和为0xFF return at24c02_write_buf(APP_CFG_ADDR, (uint8_t *)&tmp, sizeof(tmp)); }校验算法很简单:保存时把所有字节累加(包括crc自己在内),调整crc的值让总和等于0xFF;读取时重新做一次累加,如果结果等于0xFF就说明数据完好。这个算法的目的不是安全加密,而是快速识别"某个字节翻转了"这类损坏。写入时直接覆盖整个结构体,注意每次写之前确保地址加上长度不超过256字节,这个结构体现在占26字节,放在0x00起始位置完全没有问题。
磨损均衡在这个场景下可以做一些很轻量的处理。AT24C02的100万次写寿命是针对单个字节而言的,如果设备每天保存一次配置,100万次可以用两千多年,完全不需要担心。但如果设备每小时保存一次,那就只剩一百来年,虽然还是很远,但对于某些长期运行的工业设备来说,还是可以留个心思。简单做法是把存储区分成两个槽位,交替写入,读的时候比较两个槽位的版本号取最新的。这个思路非常简单,代码量也不多,但在绝大多数项目中其实是"过度设计"——先用结构体封装,等真出现寿命问题时再升级磨损均衡方案也不迟,毕竟256字节的空间改起来并不难。
最后贴一段完整的使用示例,把上面的封装串起来:
app_cfg_t g_cfg = { .mode = 1, .threshold = 520, }; void app_init(void) { ESP_ERROR_CHECK(at24c02_init()); esp_err_t ret = app_cfg_load(&g_cfg); if (ret == ESP_ERR_NOT_FOUND) { // 首次上电,保存一份默认配置 ESP_ERROR_CHECK(app_cfg_save(&g_cfg)); } else if (ret == ESP_ERR_INVALID_CRC) { ESP_LOGW(TAG, "config crc error, use default"); memset(&g_cfg, 0, sizeof(g_cfg)); g_cfg.mode = 1; g_cfg.threshold = 520; ESP_ERROR_CHECK(app_cfg_save(&g_cfg)); } } void app_on_threshold_changed(uint16_t new_threshold) { g_cfg.threshold = new_threshold; esp_err_t ret = app_cfg_save(&g_cfg); if (ret != ESP_OK) { ESP_LOGE(TAG, "save config failed"); } }我在项目中就是这么用的:系统启动先加载配置,如果魔数不对就写入默认配置;如果CRC不对就重置为默认配置并重新保存;运行中任何参数变更只改结构体字段,然后一键写回。整个参数存储的代码只有两三百行,但已经经历了多次现场升级的考验,结构清晰、排查方便。
最后再分享一个小习惯,是我调AT24C02踩过几次坑之后养成的:每次写完EEPROM,我都强制做一次回读,读出来和写进去的值不一致就直接报错,而不是让错误数据悄悄潜入系统。这个习惯让很多原本要隔很久才暴露的数据损坏问题,在产品测试阶段就被拦截掉了。你如果正在做类似的项目,从第一天就加上这层保险,后面会省很多事。