十三、51单片机实战:I2C驱动AT24C02 EEPROM实现数据持久化
1. EEPROM基础与AT24C02特性当你用51单片机做项目时经常会遇到一个头疼的问题断电后数据就消失了。比如你精心调试的温控参数或者设备唯一的序列号总不能每次上电都重新设置吧这时候EEPROM就是你的救星。AT24C02这颗只有8个引脚的小芯片能帮你保存256字节的关键数据而且擦写寿命能达到100万次。我第一次用AT24C02是在一个智能门锁项目里。当时需要保存用户设置的密码和开锁记录试过用片内Flash但发现擦写次数根本不够用。后来改用AT24C02不仅解决了数据持久化问题还发现它的I2C接口特别省IO口。说到I2C这可能是嵌入式领域最实用的通信协议了——两根线SCL时钟线和SDA数据线就能搞定主从设备通信比SPI省线比UART可靠。AT24C02的硬件连接简单到令人发指VCC接5VGND接地A0-A2接地这样器件地址就是0x50WP引脚接地关闭写保护剩下的SCL和SDA随便找两个GPIO就行。我习惯用P2.0和P2.1因为这两个引脚在开发板上通常没有其他功能冲突。实际布线时要注意SCL和SDA线上最好加个4.7K的上拉电阻这是很多新手容易忽略的细节。2. I2C协议深度解析别看I2C只有两根线里面的门道可不少。记得我第一次调试I2C时用逻辑分析仪抓波形看到一堆乱码差点怀疑人生。后来才发现是时序没把握好——I2C对信号边沿的时间要求非常严格。起始信号Start Condition是通信的开始当SCL为高时SDA从高变低。这个下降沿就像敲门一样告诉从设备我要开始通信了。对应的停止信号Stop Condition则是SCL为高时SDA从低变高。这里有个坑起始信号之后SCL必须拉低否则接下来的数据发送会出问题。发送数据时要在SCL低电平时准备好数据然后在SCL高电平时保持稳定。就像两个人传纸条必须等对方说准备好了SCL变高才能把纸条递过去。每个字节发送完后要等待从设备的应答信号ACK——从设备会把SDA拉低表示收到。如果没有ACK可能是从设备地址错了或者根本没接设备。读数据时更要注意主机在读完一个字节后要发送ACK或NACK。如果想继续读下个字节就发ACKSDA拉低如果是最后一个字节就发NACKSDA保持高。这个细节在连续读取多个地址时特别重要。3. 底层驱动开发实战写I2C驱动就像教单片机说一门新语言得从最基础的字母开始教起。下面这个延时函数是我们的字母表基础void I2C_Delay() // 约5us延时12MHz { _nop_(); _nop_(); _nop_(); }起始信号函数要特别注意时序void I2C_Start() { SDA 1; // 先拉高SDA SCL 1; // 再拉高SCL I2C_Delay(); SDA 0; // SDA下降沿 I2C_Delay(); SCL 0; // 拉低SCL准备发送数据 }发送一个字节的函数最复杂要处理ACK判断bit I2C_WriteByte(uint8_t dat) { uint8_t i; for(i0; i8; i) { SDA (dat 0x80) ? 1 : 0; // 先发高位 dat 1; SCL 1; I2C_Delay(); SCL 0; I2C_Delay(); } SDA 1; // 释放SDA线 SCL 1; // 第9个时钟脉冲 I2C_Delay(); if(SDA) { // 检测ACK SCL 0; return 0; // NACK } SCL 0; return 1; // ACK }读字节函数要注意最后发NACKuint8_t I2C_ReadByte(bit ack) { uint8_t i, dat 0; SDA 1; // 确保SDA为输入模式 for(i0; i8; i) { dat 1; SCL 1; I2C_Delay(); if(SDA) dat | 0x01; SCL 0; I2C_Delay(); } SDA !ack; // 发送ACK/NACK SCL 1; I2C_Delay(); SCL 0; return dat; }4. AT24C02应用层封装有了底层驱动我们就可以给AT24C02穿上更漂亮的外衣了。先定义器件地址#define AT24C02_ADDR 0xA0 // 写地址写一个字节的函数要注意页写延迟void AT24C02_Write(uint8_t addr, uint8_t dat) { I2C_Start(); I2C_WriteByte(AT24C02_ADDR); I2C_WriteByte(addr); I2C_WriteByte(dat); I2C_Stop(); Delay10ms(); // 必须的写入等待时间 }读操作要分两次通信uint8_t AT24C02_Read(uint8_t addr) { uint8_t dat; I2C_Start(); I2C_WriteByte(AT24C02_ADDR); I2C_WriteByte(addr); I2C_Start(); // 重复起始条件 I2C_WriteByte(AT24C02_ADDR | 0x01); // 读地址 dat I2C_ReadByte(0); // 读1字节发NACK I2C_Stop(); return dat; }实际项目中我们经常需要读写多字节。AT24C02有个特性叫页写可以一次写入最多8字节一页但要注意不能跨页。这里有个我踩过的坑如果跨页写入数据会回卷到页首覆盖之前的数据。void AT24C02_PageWrite(uint8_t addr, uint8_t *buf, uint8_t len) { uint8_t i; if(len 8 || (addr/8) ! ((addrlen-1)/8)) { return; // 超出页限制 } I2C_Start(); I2C_WriteByte(AT24C02_ADDR); I2C_WriteByte(addr); for(i0; ilen; i) { I2C_WriteByte(buf[i]); } I2C_Stop(); Delay10ms(); }连续读取就简单多了不需要考虑分页void AT24C02_SeqRead(uint8_t addr, uint8_t *buf, uint8_t len) { uint8_t i; I2C_Start(); I2C_WriteByte(AT24C02_ADDR); I2C_WriteByte(addr); I2C_Start(); I2C_WriteByte(AT24C02_ADDR | 0x01); for(i0; ilen-1; i) { buf[i] I2C_ReadByte(1); // 发ACK } buf[len-1] I2C_ReadByte(0); // 最后发NACK I2C_Stop(); }5. 项目实战设备参数存储系统现在我们来个真实案例。假设要做一个温控器需要保存这些参数设备ID4字节温度上限2字节温度下限2字节校准参数4字节首先定义参数结构体和存储地址映射typedef struct { uint32_t dev_id; int16_t temp_high; int16_t temp_low; float calib_factor; } SysParams; #define DEV_ID_ADDR 0x00 #define TEMP_HIGH_ADDR 0x04 #define TEMP_LOW_ADDR 0x06 #define CALIB_FACT_ADDR 0x08参数保存函数要注意数据类型转换void SaveParams(SysParams *params) { uint8_t buf[4]; // 保存设备ID buf[0] params-dev_id 24; buf[1] params-dev_id 16; buf[2] params-dev_id 8; buf[3] params-dev_id; AT24C02_PageWrite(DEV_ID_ADDR, buf, 4); // 保存温度上限 buf[0] params-temp_high 8; buf[1] params-temp_high; AT24C02_Write(TEMP_HIGH_ADDR, buf[0]); AT24C02_Write(TEMP_HIGH_ADDR1, buf[1]); // 保存温度下限类似上限 // ... // 保存校准参数float转为4字节 uint8_t *p (uint8_t*)params-calib_factor; AT24C02_PageWrite(CALIB_FACT_ADDR, p, 4); }参数读取函数void LoadParams(SysParams *params) { uint8_t buf[4]; // 读取设备ID AT24C02_SeqRead(DEV_ID_ADDR, buf, 4); params-dev_id (buf[0]24) | (buf[1]16) | (buf[2]8) | buf[3]; // 读取温度上限 AT24C02_SeqRead(TEMP_HIGH_ADDR, buf, 2); params-temp_high (buf[0]8) | buf[1]; // 读取温度下限类似上限 // ... // 读取校准参数 AT24C02_SeqRead(CALIB_FACT_ADDR, (uint8_t*)params-calib_factor, 4); }在实际项目中我还喜欢加个参数校验机制。比如在参数区最后放个校验和读取时先校验uint8_t CalcChecksum(SysParams *params) { uint8_t *p (uint8_t*)params; uint8_t sum 0; for(uint8_t i0; isizeof(SysParams)-1; i) { sum p[i]; } return sum; }这样在LoadParams时可以检查参数是否被破坏uint8_t saved_sum AT24C02_Read(CHECKSUM_ADDR); uint8_t calc_sum CalcChecksum(params); if(saved_sum ! calc_sum) { // 参数错误使用默认值 SetDefaultParams(params); }6. 调试技巧与常见问题调试I2C设备时逻辑分析仪绝对是你的好朋友。没有硬件分析仪的话可以用IO模拟示波器看波形。我常用的调试方法是在每个关键步骤后加个LED状态指示比如起始信号成功 → LED快闪收到ACK → LED慢闪读写完成 → LED常亮AT24C02有几个常见坑点写周期时间Write Cycle Time每次写入后要等5-10ms否则下次操作会失败页写限制不能跨页写入否则会回卷地址回绕地址超过0xFF会回绕到0x00上电延时VCC稳定后要等几ms才能操作如果遇到设备无响应按这个顺序排查检查电源电压4.5-5.5V检查上拉电阻通常4.7K用示波器看SCL/SDA波形确认器件地址是否正确A0-A2引脚电平检查WP引脚是否被意外拉高对于需要频繁写入的场景建议实现一个简单的磨损均衡算法。比如轮流使用不同地址存储数据避免总是写同一个区域。我在一个数据记录项目中这样实现#define RECORD_SIZE 32 #define PAGE_NUM 8 uint8_t current_page 0; void SaveRecord(uint8_t *data) { uint8_t addr current_page * RECORD_SIZE; AT24C02_PageWrite(addr, data, RECORD_SIZE); current_page (current_page 1) % PAGE_NUM; }这样数据会循环写入8个区域大大延长了EEPROM寿命。