2026/10/16 7:23:40

AT24C64 EEPROM驱动开发实战:I2C页写、地址回绕与避坑指南

AT24C64 EEPROM驱动开发实战:I2C页写、地址回绕与避坑指南 简介AT24C64驱动文件面向嵌入式开发人员尤其是需要在微控制器系统中读写I²C接口EEPROM的工程师与学习者。AT24C64常用于保存设备配置信息或小量数据本资源提供一套可直接集成的驱动组件解决底层通信与数据存取问题。压缩包共3个文件包含1个c源文件、1个h头文件和1个txt文本整体约1KB。其中c文件实现I²C总线初始化、指定地址的数据读取与写入、通信错误检查及等待应答等辅助操作h文件提供宏定义、函数原型以及配置结构体或枚举声明便于其他模块调用。文本文件可能记录使用说明或开发笔记。目前已有450人学习下载。借助这套驱动读者可快速完成AT24C64的读写验证理解I²C时序与页写入限制并提升固件代码的可移植性与可维护性。1. 从一颗EEPROM说起AT24C64驱动到底解决什么问题如果你手头有一块带I2C总线的开发板想存一点掉电不丢的配置参数比如设备序列号、校准系数、用户设置AT24C64这颗64Kbit的EEPROM大概率会被翻出来。它便宜、稳定、两线接口几乎每块工控板或消费类主板上都能见到。但真正写驱动的时候很多人会卡在页写边界、写周期等待、地址回绕这些细节上——数据手册翻了好几遍代码跑起来还是偶尔丢字节。这份AT24C64驱动文件就是冲着这些实际问题去的它把I2C底层读写、页缓冲管理、跨页写入、写完成轮询这些逻辑封装成一套可移植的接口适合正在做嵌入式存储模块、需要快速集成EEPROM读写的开发者。不管你是用STM32、GD32还是别家的MCU只要I2C能通这套驱动就能接上去用。2. 驱动架构拆解从I2C时序到页写缓冲的完整链路2.1 分层设计硬件抽象层与器件操作层怎么切这份驱动没有把I2C寄存器操作和AT24C64的页写逻辑搅在一起而是切成了两层。底层是硬件抽象层只负责产生I2C起始、停止、字节收发这些时序动作通常对接MCU的硬件I2C外设或软件模拟I2C。上层是器件操作层处理AT24C64特有的地址组织、页边界判断、写周期延时。这样切的好处是换MCU时只改底层几个函数上层逻辑原封不动。常见做法是定义一个函数指针结构体把底层读写函数注册进去。比如typedef struct { int (*i2c_write)(uint8_t dev_addr, const uint8_t *buf, uint16_t len); int (*i2c_read)(uint8_t dev_addr, uint8_t *buf, uint16_t len); void (*delay_ms)(uint32_t ms); } at24c64_hal_t; static at24c64_hal_t g_hal; void at24c64_register_hal(const at24c64_hal_t *hal) { g_hal *hal; // 拷贝一份避免调用方栈上变量失效 }这里dev_addr是7位从机地址AT24C64的地址由A2/A1/A0引脚决定通常左移一位后或上读写位。i2c_write和i2c_read内部要处理完整的起始-地址-数据-停止序列。delay_ms用于写周期等待因为EEPROM每次写操作后需要内部擦写时间典型5ms最坏10ms。参数说明at24c64_hal_t结构体把平台相关的东西全部抽走上层不关心你用的是硬件I2C还是GPIO模拟。注册函数用值拷贝而不是指针保存防止调用方传入局部变量后失效——这个坑我在早期项目里踩过调试了半天才发现是结构体生命周期问题。2.2 地址组织16位地址怎么拆成两个字节AT24C64的容量是64Kbit也就是8KB地址范围0x0000到0x1FFF。每次读写要先发送2字节地址高字节在前。很多新手会在这里翻车把地址当成8位直接发结果只能访问前256字节。static int at24c64_send_addr(uint16_t mem_addr) { uint8_t addr_buf[2]; addr_buf[0] (uint8_t)(mem_addr 8); // 高8位 addr_buf[1] (uint8_t)(mem_addr 0xFF); // 低8位 // 注意这里不发送停止条件紧接着要发数据或重复起始 return g_hal.i2c_write(g_dev_addr, addr_buf, 2); }逻辑说明mem_addr是器件内部字节地址范围0~8191。先发高字节再发低字节这是AT24C64的固定格式。i2c_write内部在发送完这两个字节后不能产生停止条件否则后续数据字节会被当成新的传输开始。我一般会在底层实现里区分“写地址”和“写数据”两个阶段或者用一个标志位控制是否发停止。参数说明g_dev_addr是7位从机地址左移一位后的值比如A2A1A0全接地时7位地址是0x50左移后是0xA0。读操作时或上0x01变成0xA1。这个细节在底层函数里处理上层不用管。2.3 页写缓冲为什么不能一次写8KBAT24C64的页大小是32字节。这意味着你连续写入时如果起始地址不是页对齐的写到页末尾会自动回绕到本页开头覆盖掉前面的数据。这不是bug是芯片设计如此。所以驱动必须把长数据拆成多次页写。#define AT24C64_PAGE_SIZE 32 #define AT24C64_TOTAL_SIZE 8192 int at24c64_write(uint16_t mem_addr, const uint8_t *data, uint16_t len) { uint16_t written 0; while (written len) { uint16_t cur_addr mem_addr written; uint16_t page_remain AT24C64_PAGE_SIZE - (cur_addr % AT24C64_PAGE_SIZE); uint16_t chunk (len - written) page_remain ? (len - written) : page_remain; if (at24c64_send_addr(cur_addr) ! 0) return -1; if (g_hal.i2c_write(g_dev_addr, data written, chunk) ! 0) return -1; g_hal.delay_ms(6); // 等待内部写周期完成典型5ms留1ms余量 written chunk; } return 0; }逻辑说明循环里先算当前地址距离本页末尾还剩多少字节然后取剩余数据和页剩余空间的最小值作为本次写入长度。写完一页后延时等待再处理下一页。这样保证每次写入都不跨页避免回绕覆盖。参数说明mem_addr是起始地址data是数据指针len是长度。delay_ms(6)是保守值实际可以改成轮询ACK的方式提高效率——发完地址后反复发起始条件如果器件返回ACK说明写周期结束。但轮询实现依赖底层I2C能检测NACK不是所有硬件都支持所以延时法更通用。注意如果你写入的数据长度经常超过32字节建议在应用层就按页对齐拆分驱动层只保证单页写入正确。这样职责更清晰也方便后续换成页更大的型号。3. 跑通驱动从I2C初始化到读写验证的完整步骤3.1 硬件连接与地址确认先把硬件接对。AT24C64的SDA和SCL分别接MCU的I2C数据线和时钟线两根线都要加上拉电阻典型4.7kΩ到10kΩ。A2/A1/A0三个引脚决定从机地址全接地时7位地址是0x50全接VCC是0x57。WP引脚是写保护接低电平才能写入接高电平只能读。我见过有人调试半天写不进去最后发现WP悬空被内部上拉到了高电平。确认地址的方法很简单上电后发一个起始条件加地址字节看器件是否返回ACK。如果NACK要么地址不对要么硬件没接好。用逻辑分析仪抓一下波形最直接没有的话就用MCU的I2C状态寄存器判断。3.2 底层I2C函数实现示例以常见的硬件I2C为例底层写函数大概长这样static int platform_i2c_write(uint8_t dev_addr, const uint8_t *buf, uint16_t len) { // 等待总线空闲 while (I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY)); // 发起始条件 I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); // 发从机地址写 I2C_Send7bitAddress(I2C1, dev_addr, I2C_Direction_Transmitter); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); // 发数据 for (uint16_t i 0; i len; i) { I2C_SendData(I2C1, buf[i]); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); } // 发停止条件 I2C_GenerateSTOP(I2C1, ENABLE); return 0; }逻辑说明这是标准的主发送流程。先等总线空闲发起始条件等起始完成事件发地址等地址被应答然后循环发数据每发一个字节等传输完成事件最后发停止条件。dev_addr是已经左移过的8位地址。参数说明I2C1是外设实例不同MCU可能叫I2C0或I2C1。I2C_Direction_Transmitter表示写方向。事件检查用轮询方式简单可靠缺点是阻塞CPU。如果对实时性要求高可以改成中断或DMA方式但驱动上层逻辑不用变。读函数稍微复杂一点需要先写地址再重复起始条件切换成读方向static int platform_i2c_read(uint8_t dev_addr, uint8_t *buf, uint16_t len) { while (I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY)); I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, dev_addr, I2C_Direction_Transmitter); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); // 这里假设地址已经由上层发过了实际要配合send_addr使用 // 重复起始条件 I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, dev_addr | 0x01, I2C_Direction_Receiver); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED)); for (uint16_t i 0; i len; i) { if (i len - 1) { I2C_AcknowledgeConfig(I2C1, DISABLE); // 最后一个字节发NACK I2C_GenerateSTOP(I2C1, ENABLE); } while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_RECEIVED)); buf[i] I2C_ReceiveData(I2C1); } I2C_AcknowledgeConfig(I2C1, ENABLE); // 恢复ACK return 0; }逻辑说明读操作的关键是重复起始条件。先以写方向发地址然后不发停止直接再发起始条件再发读方向地址。接收数据时最后一个字节要发NACK并产生停止条件否则从机不知道什么时候结束。参数说明dev_addr | 0x01把写地址变成读地址。I2C_AcknowledgeConfig控制ACK/NACK接收完最后一个字节后要恢复成ACK否则下次读会出问题。3.3 读写验证写一串数据再读回来对比底层通了之后跑一个最简单的验证void at24c64_test(void) { uint8_t write_buf[10] {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A}; uint8_t read_buf[10] {0}; at24c64_write(0x0000, write_buf, 10); at24c64_read(0x0000, read_buf, 10); for (int i 0; i 10; i) { if (read_buf[i] ! write_buf[i]) { printf(mismatch at %d: wrote 0x%02X, read 0x%02X\n, i, write_buf[i], read_buf[i]); return; } } printf(AT24C64 test passed\n); }逻辑说明从地址0写10个字节再读回来逐个比较。如果全部一致说明底层时序和页写逻辑都正确。注意写完之后要延时再读因为EEPROM内部写周期还没完成时读操作会失败。参数说明0x0000是起始地址可以改成任意0~8191的值。测试数据用递增序列方便定位错误位置。如果某个字节不匹配先检查是不是跨页了——10个字节从0开始写不会跨页但从30开始写就会跨页。提示第一次跑测试建议从页对齐地址开始比如0x0000、0x0020、0x0040排除跨页因素。确认单页写入没问题后再测试跨页场景。4. 避坑与排查AT24C64驱动调试中最容易翻车的五个点4.1 写进去读出来全是0xFF现象写入数据后立即读取返回全是0xFF或者部分字节是0xFF。原因最常见的是写周期没有等待。EEPROM每次写操作后需要5~10ms内部擦写时间这期间器件不响应任何命令。如果写完立刻读器件还没准备好读回来就是无效数据。另一个原因是WP引脚被拉高写保护生效数据根本没写进去。解决在每次写操作后加延时典型6ms。如果WP引脚悬空确认硬件上有没有下拉电阻没有的话在初始化时把WP引脚配置成推挽输出并拉低。用示波器看WP引脚电平最直接。4.2 跨页写入导致数据覆盖现象从地址30开始写10个字节读回来发现前2个字节正确后面8个字节覆盖了地址0~7的数据。原因AT24C64页大小32字节地址30到31是本页最后2字节再写就回绕到本页开头地址0。这是芯片内部地址指针的回绕机制不是驱动bug。解决驱动层必须做页边界判断把长数据拆成多次页写。上面at24c64_write函数里的page_remain计算就是干这个的。如果你在应用层直接调底层I2C写一定要自己拆。4.3 读操作最后一个字节出错现象读10个字节前9个正确最后一个字节是0xFF或随机值。原因接收最后一个字节前没有发NACK和停止条件。I2C协议规定主机接收完最后一个字节后要发NACK告诉从机停止发送然后发停止条件。如果一直发ACK从机会继续输出数据最后一个字节可能被后续时序干扰。解决在接收循环里判断i len - 1时关闭ACK并产生停止条件。接收完成后记得恢复ACK使能否则影响下一次传输。4.4 地址计算错误导致只能访问前256字节现象写入地址0x0100的数据读回来发现写到了0x0000。原因地址只发了低8位高8位被忽略或当成数据发了。AT24C64是16位地址必须发两个字节。解决检查at24c64_send_addr函数确认mem_addr 8和mem_addr 0xFF都正确发送。用逻辑分析仪抓波形看地址阶段是不是两个字节。如果底层I2C函数把地址和数据混在一起发要分开处理。4.5 I2C总线死锁导致后续通信全部失败现象某次读写操作后SDA一直被拉低后续所有I2C设备都不响应。原因主机在从机发送数据中途产生停止条件或者从机在ACK阶段被复位导致SDA被从机拉死。这是I2C总线的经典问题不是AT24C64独有。解决在I2C初始化时加总线恢复逻辑——把SCL配置成GPIO手动发送9个时钟脉冲然后发停止条件再重新初始化I2C外设。很多MCU的I2C外设自带总线恢复功能查一下参考手册。我一般会在platform_i2c_write入口加一个超时检测如果等待BUSY标志超过10ms就触发恢复流程。5. 二次开发改造从单字节读写到结构化存储的进阶技巧驱动跑通之后下一步通常是把裸的字节读写封装成配置项存储。比如设备有10个参数每个参数有类型和默认值你肯定不想在应用层到处写at24c64_write(0x0010, val, 2)。我一般会加一层参数表映射typedef struct { uint16_t addr; // EEPROM内部地址 uint8_t size; // 字节数 void *ptr; // 指向RAM中的变量 uint32_t default_val; // 默认值 } at24c64_param_t; static at24c64_param_t g_params[] { {0x0000, 2, g_device_id, 0x1234}, {0x0002, 4, g_calib_coeff, 1000}, {0x0006, 1, g_work_mode, 0}, }; void at24c64_load_params(void) { for (int i 0; i sizeof(g_params)/sizeof(g_params[0]); i) { at24c64_read(g_params[i].addr, g_params[i].ptr, g_params[i].size); // 简单校验如果读出来全是0xFF说明没写过用默认值 uint8_t *p (uint8_t *)g_params[i].ptr; int all_ff 1; for (int j 0; j g_params[i].size; j) { if (p[j] ! 0xFF) { all_ff 0; break; } } if (all_ff) { memcpy(g_params[i].ptr, g_params[i].default_val, g_params[i].size); at24c64_save_param(i); } } }逻辑说明参数表把地址、大小、RAM指针、默认值绑在一起。加载时逐个读取如果发现全是0xFF新芯片或擦除后的状态就用默认值覆盖并回写。这样应用层只操作RAM变量掉电前调一次at24c64_save_all就行。参数说明addr要保证不重叠size要和ptr指向的变量类型匹配。default_val用uint32_t统一存储实际拷贝时按size截取。这个方案适合参数数量固定、类型简单的场景。如果参数很多或者有字符串建议加一个长度字段和CRC校验。另一个实用改造是加写平衡。EEPROM每个扇区有擦写次数限制典型100万次。如果某个参数频繁更新比如运行时长统计每次都写同一地址会加速老化。我一般会开一个环形缓冲区每次写到下一个位置读取时取最新的一条。实现上就是维护一个写指针存在EEPROM固定位置每次写入后指针加一到末尾回绕。#define LOG_START_ADDR 0x1000 #define LOG_ENTRY_SIZE 8 #define LOG_MAX_ENTRIES 64 void at24c64_log_write(uint32_t timestamp, uint16_t event) { static uint16_t log_idx 0; uint8_t buf[LOG_ENTRY_SIZE]; memcpy(buf, timestamp, 4); memcpy(buf 4, event, 2); // 剩余2字节留作CRC或保留 at24c64_write(LOG_START_ADDR log_idx * LOG_ENTRY_SIZE, buf, LOG_ENTRY_SIZE); log_idx (log_idx 1) % LOG_MAX_ENTRIES; // 把log_idx存到固定位置下次上电恢复 at24c64_write(0x000E, (uint8_t *)log_idx, 2); }逻辑说明环形缓冲区把写操作分散到64个位置每个位置被写的频率降到1/64。log_idx存在固定地址0x000E上电时先读出来恢复。读取日志时从log_idx往前遍历找到有效条目。参数说明LOG_START_ADDR要避开参数区LOG_ENTRY_SIZE按实际数据结构调整。LOG_MAX_ENTRIES乘以LOG_ENTRY_SIZE不能超过EEPROM总容量减去参数区。这个方案牺牲了随机读取能力适合只追加不修改的日志场景。从那以后我每次集成新的EEPROM型号都强制先跑一遍页边界测试和写周期压力测试确认驱动在极端地址下不出问题再往上叠业务逻辑。希望帮到你。本文还有配套的精品资源点击获取