蓝桥杯单片机学习笔记(七)——AT24C02 存储芯片完全攻略

:bullseye: 蓝桥杯单片机学习笔记(七)——AT24C02 存储芯片完全攻略

核心概念:AT24C02 就像是单片机的"移动硬盘":floppy_disk:,即使断电关机,数据依然安全保存。它基于 I²C 总线通信,拥有 2KB(256 字节 × 8 位)的存储空间,是嵌入式系统中常用的非易失性存储器。


:books: 一、AT24C02 芯片概述

1.1 核心特性

特性 参数 生动比喻
存储容量 2KB (2048 位 = 256 字节) 相当于一本 256 页的小笔记本 :ledger:
通信协议 I²C 总线 只需两根线(SDA/SCL)就能"对话"
数据保持 掉电不丢失 就像写在纸上的字,停电也不会消失 :writing_hand:
擦写次数 100 万次以上 足够你用一辈子
页大小 8 字节/页 一次最多写 8 个字符

1.2 工作原理图解

 单片机 (主设备)                    AT24C02 (从设备)
     │                                   │
     ├──── SCL (时钟线) ────────────────┤
     │      "指挥棒,控制节奏"            │
     │                                   │
     ├──── SDA (数据线) ────────────────┤
     │      "运输车,双向传输数据"        │
     │                                   │
     └───────────────────────────────────┘

:id_button: 二、设备地址:AT24C02 的"门牌号"

2.1 地址结构解析

AT24C02 的 I²C 地址由 8 位二进制组成:

 ┌────┬────┬────┬────┬────┬────┬────┬────┐
 │ 1  │ 0  │ 1  │ 0  │ A2 │ A1 │ A0 │R/W │
 └────┴────┴────┴────┴────┴────┴────┴────┘
   固定前缀 (1010)      硬件地址    读写位

蓝桥杯开发板配置(A2/A1/A0 全部接地):

操作 二进制地址 十六进制 记忆口诀
写操作 1010 0000 0xA0 A​:zero: = “爱写(0)”
读操作 1010 0001 0xA1 A​:one: = “爱读(1)”

:light_bulb: 比喻理解:这就像快递包裹,1010 是省份,000 是街道,最后一位告诉快递员你是要"收件(读)“还是"寄件(写)”。


:writing_hand: 三、写入数据:把信息"刻"进芯片

3.1 两种写入方式对比

写入方式 特点 适用场景 形象比喻
字节写入 一次写 1 个字节 零散数据存储 一个字一个字地抄书 :pencil:
页写入 一次写 8 个字节 批量数据存储 一次复制一整段 :clipboard:

3.2 :warning: 关键注意事项

为什么地址必须是 8 的倍数?

AT24C02 的页大小是 8 字节,这意味着:

 地址范围:0x00 ~ 0xFF (0 ~ 255)
 页划分:
 ┌─────────┬─────────┬─────────┬─────────┐
 │ 第 0 页  │ 第 1 页  │ 第 2 页  │ ... 31页 │
 │ 0~7     │ 8~15    │ 16~23   │ 248~255 │
 └─────────┴─────────┴─────────┴─────────┘

错误示范:

 // ❌ 从地址 5 开始写 8 个字节
 EEPROM_Write(data, 5, 8);  
 // 结果:只能写入 3 个字节(5,6,7),剩下的会跨页出错!

正确做法:

 // ✅ 从地址 0 或 8 或 16... 开始
 EEPROM_Write(data, 0, 8);   // 正好写满第 0 页
 EEPROM_Write(data, 8, 8);   // 正好写满第 1 页

:light_bulb: 生活比喻:就像停车位,一个车位(页)只能停 8 辆车(字节)。如果你从车位中间开始停,后面的车就没地方了!

3.3 写入函数详解

 /**
  * @brief  向 EEPROM 写入数据
  * @param  EEPROM_String: 要写入的数据数组指针
  * @param  addr: 起始地址(必须是 8 的倍数!)
  * @param  num: 写入字节数(建议 ≤8,刚好写满一页)
  * @retval 无
  */
 void EEPROM_Write(unsigned char* EEPROM_String, unsigned char addr, unsigned char num)
 {
     // 【第1步】发送起始信号 —— "喂,AT24C02,准备接收数据!"
     I2CStart();
     
     // 【第2步】发送设备地址(写模式)—— "我要往你这里存东西"
     I2CSendByte(0xA0);  // 写地址
     I2CWaitAck();       // 等待芯片应答
     
     // 【第3步】发送存储地址 —— "存到第 X 个格子里"
     I2CSendByte(addr);  // 例如 0、8、16...
     I2CWaitAck();
     
     // 【第4步】循环写入数据 —— "一个一个往里塞"
     while(num--) {
         I2CSendByte(*EEPROM_String++);  // 发送一个字节
         I2CWaitAck();
         I2C_Delay(200);  // ⚠️ 关键!给芯片时间写入(约 5ms)
     }
     
     // 【第5步】发送停止信号 —— "写完了,收工!"
     I2CStop();
 }

:magnifying_glass_tilted_left: 代码细节解析

  1. 指针递增 \*EEPROM_String++

     // 等价于:
     I2CSendByte(*EEPROM_String);  // 先取当前值
     EEPROM_String++;              // 再指向下一个元素
    
  2. 为什么需要延时 I2C_Delay(200)

    • AT24C02 内部写入需要 5ms 的擦写时间

    • 如果写太快,数据会丢失

    • 就像给存储卡"反应时间" :hourglass_not_done:


:open_book: 四、读取数据:把信息"取"出来

4.1 两种读取方式

读取方式 特点 使用场景
随机读取 从指定地址开始读 读取特定位置的数据
顺序读取 连续读取多个字节 读取连续的数据块

4.2 读取函数详解

 /**
  * @brief  从 EEPROM 读取数据
  * @param  EEPROM_String: 存放读取数据的数组指针
  * @param  addr: 起始地址
  * @param  num: 读取字节数
  * @retval 无
  */
 void EEPROM_Read(unsigned char* EEPROM_String, unsigned char addr, unsigned char num)
 {
     // 【第1步】起始信号 + 写地址 —— "我要告诉你读哪里"
     I2CStart();
     I2CSendByte(0xA0);  // 先发写地址
     I2CWaitAck();
     
     // 【第2步】发送要读取的地址 —— "读第 X 个格子"
     I2CSendByte(addr);
     I2CWaitAck();
     
     // 【第3步】重新起始 + 读地址 —— "现在切换到接收模式"
     I2CStart();
     I2CSendByte(0xA1);  // 读地址
     I2CWaitAck();
     
     // 【第4步】循环接收数据
     while(num--) {
         *EEPROM_String++ = I2CReceiveByte();  // 读一个字节
         
         if(num) {
             I2CSendAck(0);  // 还有数据,发送应答(继续读)
         } else {
             I2CSendAck(1);  // 最后一个数据,发送非应答(停止)
         }
     }
     
     // 【第5步】停止信号
     I2CStop();
 }

:magnifying_glass_tilted_left: 关键点解析

为什么要"先写后读"?

 正常人的思维:我要读数据,直接发读命令不就行了?
 实际情况:AT24C02 需要先知道你要读"哪个地址",所以:
     1. 先发写地址(0xA0),告诉它位置
     2. 再发读地址(0xA1),开始接收数据

应答信号的"潜台词":

 I2CSendAck(0);  // "继续,我还要读!" 👍
 I2CSendAck(1);  // "够了,别读了!" 🛑

:package: 五、完整底层驱动代码

5.1 iic.h 头文件

 #ifndef __IIC_H_
 #define __IIC_H_
 ​
 // ========== I²C 基础协议函数 ==========
 void I2CStart(void);                        // 起始信号
 void I2CStop(void);                         // 停止信号
 void I2CSendByte(unsigned char byt);        // 发送一个字节
 unsigned char I2CReceiveByte(void);         // 接收一个字节
 unsigned char I2CWaitAck(void);             // 等待应答
 void I2CSendAck(unsigned char ackbit);      // 发送应答
 ​
 // ========== AT24C02 专用函数 ==========
 /**
  * @brief 写入数据到 EEPROM
  * @param EEPROM_String 数据源数组指针
  * @param addr 起始地址(建议 0, 8, 16, 24...)
  * @param num 写入字节数(建议 ≤8)
  */
 void EEPROM_Write(unsigned char* EEPROM_String, unsigned char addr, unsigned char num);
 ​
 /**
  * @brief 从 EEPROM 读取数据
  * @param EEPROM_String 数据接收数组指针
  * @param addr 起始地址
  * @param num 读取字节数
  */
 void EEPROM_Read(unsigned char* EEPROM_String, unsigned char addr, unsigned char num);
 ​
 #endif

5.2 iic.c 实现文件

 #include "iic.h"
 #include "reg52.h"
 #include "intrins.h"
 ​
 #define DELAY_TIME 10
 ​
 sbit sda = P2 ^ 1;  // 数据线
 sbit scl = P2 ^ 0;  // 时钟线
 ​
 // ========== 微秒级延时 ==========
 static void I2C_Delay(unsigned char n)
 {
     do {
         _nop_();_nop_();_nop_();_nop_();_nop_();
         _nop_();_nop_();_nop_();_nop_();_nop_();
         _nop_();_nop_();_nop_();_nop_();_nop_();        
     } while(n--);       
 }
 ​
 // ========== I²C 起始信号 ==========
 void I2CStart(void)
 {
     sda = 1;
     scl = 1;
     I2C_Delay(DELAY_TIME);
     sda = 0;  // SDA 下降沿
     I2C_Delay(DELAY_TIME);
     scl = 0;  // 拉低 SCL,准备传输
 }
 ​
 // ========== I²C 停止信号 ==========
 void I2CStop(void)
 {
     sda = 0;
     scl = 1;
     I2C_Delay(DELAY_TIME);
     sda = 1;  // SDA 上升沿
     I2C_Delay(DELAY_TIME);
 }
 ​
 // ========== 发送一个字节(高位先发) ==========
 void I2CSendByte(unsigned char byt)
 {
     unsigned char i;
     for(i=0; i<8; i++) {
         scl = 0;
         I2C_Delay(DELAY_TIME);
         
         // 根据最高位设置 SDA
         if(byt & 0x80) {
             sda = 1;
         } else {
             sda = 0;
         }
         I2C_Delay(DELAY_TIME);
         scl = 1;  // 上升沿,从设备读取
         byt <<= 1;  // 左移,准备下一位
         I2C_Delay(DELAY_TIME);
     }
     scl = 0;
 }
 ​
 // ========== 接收一个字节 ==========
 unsigned char I2CReceiveByte(void)
 {
     unsigned char da = 0;
     unsigned char i;
     
     for(i=0; i<8; i++) {   
         scl = 1;
         I2C_Delay(DELAY_TIME);
         da <<= 1;  // 左移一位
         if(sda) {
             da |= 0x01;  // 如果 SDA 为高,最低位置 1
         }
         scl = 0;
         I2C_Delay(DELAY_TIME);
     }
     return da;
 }
 ​
 // ========== 等待应答 ==========
 unsigned char I2CWaitAck(void)
 {
     unsigned char ackbit;
     scl = 1;
     I2C_Delay(DELAY_TIME);
     ackbit = sda;  // 读取应答位(0=应答,1=非应答)
     scl = 0;
     I2C_Delay(DELAY_TIME);
     return ackbit;
 }
 ​
 // ========== 发送应答 ==========
 void I2CSendAck(unsigned char ackbit)
 {
     scl = 0;
     sda = ackbit;  // 0=应答,1=非应答
     I2C_Delay(DELAY_TIME);
     scl = 1;
     I2C_Delay(DELAY_TIME);
     scl = 0;
     sda = 1;  // 释放 SDA
     I2C_Delay(DELAY_TIME);
 }
 ​
 // ========== EEPROM 写入函数 ==========
 void EEPROM_Write(unsigned char* EEPROM_String, unsigned char addr, unsigned char num)
 {
     I2CStart();
     I2CSendByte(0xA0);  // 写地址
     I2CWaitAck();
     I2CSendByte(addr);  // 存储地址
     I2CWaitAck();
     
     while(num--) {
         I2CSendByte(*EEPROM_String++);
         I2CWaitAck();
         I2C_Delay(200);  // ⚠️ 写入延时(约 5ms)
     }
     
     I2CStop();
 }
 ​
 // ========== EEPROM 读取函数 ==========
 void EEPROM_Read(unsigned char* EEPROM_String, unsigned char addr, unsigned char num)
 {
     I2CStart();
     I2CSendByte(0xA0);  // 写地址(定位)
     I2CWaitAck();
     I2CSendByte(addr);  // 读取地址
     I2CWaitAck();
     
     I2CStart();  // 重新起始
     I2CSendByte(0xA1);  // 读地址
     I2CWaitAck();
     
     while(num--) {
         *EEPROM_String++ = I2CReceiveByte();
         if(num) {
             I2CSendAck(0);  // 继续读
         } else {
             I2CSendAck(1);  // 停止读
         }
     }
     
     I2CStop();
 }

:bullseye: 六、实战案例:数据掉电保存

6.1 需求分析

设计一个温度设定系统:

  • 上电时自动读取上次保存的温度值

  • 通过按键调整温度

  • 按下"保存"键后,数据写入 EEPROM

  • 即使断电重启,数据依然存在

6.2 完整代码实现

 /* ========== 头文件 ========== */
 #include <Init.h>
 #include <Key.h>
 #include <Seg.h>
 #include <Led.h>
 #include <STC15F2K60S2.H>
 #include "iic.h"
 ​
 /* ========== 全局变量 ========== */
 unsigned char Key_Val, Key_Down, Key_Up, Key_Old;
 unsigned char Key_Slow_Down;           // 按键扫描减速(10ms)
 unsigned int Seg_Slow_Down;            // 数码管刷新减速(500ms)
 unsigned char Seg_Pos;                 // 数码管位选
 unsigned char Seg_Buf[8] = {10,10,10,10,10,10,10,10};  // 显示缓存
 unsigned char Seg_Point[8] = {0,0,0,0,0,0,0,0};        // 小数点
 unsigned char ucLed[8] = {0,0,0,0,0,0,0,0};            // LED 状态
 ​
 // ⭐ 核心数据:存储两个温度设定值
 unsigned char dat[2] = {30, 60};  // 默认 30°C 和 60°C
 ​
 /* ========== 按键处理 ========== */
 void Key_Proc()
 {
     if(Key_Slow_Down) return;
     Key_Slow_Down = 1;
     
     Key_Val = Key_Read();
     Key_Down = Key_Val & (Key_Val ^ Key_Old);
     Key_Up = ~Key_Val & (Key_Val ^ Key_Old);
     Key_Old = Key_Val;
     
     switch(Key_Down) {
         case 19:  // S19:温度1 减少
             if(dat[0] > 0) dat[0] -= 10;
             break;
             
         case 18:  // S18:温度2 减少
             if(dat[1] > 0) dat[1] -= 10;
             break;
             
         case 17:  // S17:保存到 EEPROM
             EEPROM_Write(dat, 0, 2);  // 从地址 0 开始写 2 个字节
             ucLed[0] = 1;  // LED 闪烁提示保存成功
             break;
     }
 }
 ​
 /* ========== 数码管显示 ========== */
 void Seg_Proc()
 {
     if(Seg_Slow_Down) return;
     Seg_Slow_Down = 1;
     
     // 显示格式:[温度1] -- [温度2]
     Seg_Buf[0] = dat[0] / 10;     // 十位
     Seg_Buf[1] = dat[0] % 10;     // 个位
     Seg_Buf[2] = 10;              // 熄灭
     Seg_Buf[3] = 10;              // 熄灭
     Seg_Buf[4] = 10;              // 熄灭
     Seg_Buf[5] = 10;              // 熄灭
     Seg_Buf[6] = dat[1] / 10;     // 十位
     Seg_Buf[7] = dat[1] % 10;     // 个位
 }
 ​
 /* ========== LED 闪烁 ========== */
 void Led_Proc()
 {
     static unsigned int led_count = 0;
     if(ucLed[0]) {
         if(++led_count >= 500) {  // 500ms 后自动熄灭
             ucLed[0] = 0;
             led_count = 0;
         }
     }
 }
 ​
 /* ========== 定时器初始化 ========== */
 void Timer0Init(void)  // 1ms @ 12MHz
 {
     AUXR &= 0x7F;
     TMOD &= 0xF0;
     TL0 = 0x18;
     TH0 = 0xFC;
     TF0 = 0;
     TR0 = 1;
     ET0 = 1;
     EA = 1;
 }
 ​
 /* ========== 定时器中断 ========== */
 void Timer0Server() interrupt 1
 {
     if(++Key_Slow_Down == 10) Key_Slow_Down = 0;
     if(++Seg_Slow_Down == 500) Seg_Slow_Down = 0;
     if(++Seg_Pos == 8) Seg_Pos = 0;
     
     Seg_Disp(Seg_Pos, Seg_Buf[Seg_Pos], Seg_Point[Seg_Pos]);
     Led_Disp(Seg_Pos, ucLed[Seg_Pos]);
 }
 ​
 /* ========== 主函数 ========== */
 void main()
 {
     // ⭐ 上电第一件事:从 EEPROM 读取保存的数据
     EEPROM_Read(dat, 0, 2);  // 读取地址 0~1 的 2 个字节
     
     System_Init();
     Timer0Init();
     
     while(1) {
         Key_Proc();   // 按键扫描
         Seg_Proc();   // 数码管显示
         Led_Proc();   // LED 闪烁
     }
 }

6.3 程序运行流程图

 ┌──────────────────────────────────────────┐
 │          系统上电                         │
 │  ↓                                       │
 │  从 EEPROM 读取上次保存的数据             │
 │  EEPROM_Read(dat, 0, 2);                 │
 │  ↓                                       │
 │  显示读取的数据:30°C 和 60°C            │
 │  ↓                                       │
 │  用户按下 S19/S18 调整温度               │
 │  ↓                                       │
 │  用户按下 S17 保存                        │
 │  EEPROM_Write(dat, 0, 2);                │
 │  ↓                                       │
 │  断电关机...                              │
 │  ↓                                       │
 │  重新上电 → 数据依然是修改后的值!         │
 └──────────────────────────────────────────┘

:wrench: 七、常见问题与解决方案

问题 1:数据写入后读出来全是 0xFF

可能原因:

  1. 写入延时不够(I2C_Delay(200) 太小)

  2. 硬件连接问题(SDA/SCL 接反或松动)

  3. 地址超出范围(>255)

解决方案:

 // 增加写入延时
 I2C_Delay(500);  // 从 200 改到 500
 ​
 // 检查地址合法性
 if(addr > 255) return;  // 防止地址越界

问题 2:跨页写入数据丢失

错误示范:

 unsigned char data[10] = {0,1,2,3,4,5,6,7,8,9};
 EEPROM_Write(data, 5, 10);  // ❌ 从地址 5 写 10 个字节

原因分析:

 第 0 页:[5][6][7] ← 只能写 3 个
 第 1 页:[8][9][10][11][12][13][14][15] ← 这里就乱了

正确写法:

 // 方案1:分两次写
 EEPROM_Write(data, 0, 8);      // 第一页写 8 个
 EEPROM_Write(data+8, 8, 2);    // 第二页写剩下 2 个
 ​
 // 方案2:改用字节写入
 for(i=0; i<10; i++) {
     EEPROM_Write(&data[i], i, 1);
 }

问题 3:数据读取异常

症状:读出的数据和写入的不一致

排查步骤:

 // 1. 写入后立即读取测试
 EEPROM_Write(dat, 0, 2);
 Delay_ms(10);  // 等待写入完成
 EEPROM_Read(test, 0, 2);
 ​
 // 2. 用串口打印调试
 printf("写入: %d %d\n", dat[0], dat[1]);
 printf("读取: %d %d\n", test[0], test[1]);
 ​
 // 3. 检查数组是否越界
 if(num > 8) return;  // 防止一次写太多

:bar_chart: 八、数据类型与存储空间

8.1 Keil C51 中的数据类型

类型 字节数 取值范围 实际应用
unsigned char 1 字节 0 ~ 255 温度、湿度、按键值
unsigned int 2 字节 0 ~ 65535 计时器、计数器
unsigned long 4 字节 0 ~ 4294967295 长时间计时

8.2 存储空间规划示例

 AT24C02 内存布局(256 字节):
 ┌─────────────────────────────────────────┐
 │ 0~7   : 系统配置参数(温度上下限等)      │
 │ 8~15  : 用户设置数据(亮度、音量等)      │
 │ 16~23 : 历史记录1                        │
 │ 24~31 : 历史记录2                        │
 │ ...                                     │
 │ 248~255: 校验码或标志位                  │
 └─────────────────────────────────────────┘

:books: 九、知识点总结

核心概念 关键要点 记忆口诀
设备地址 写 0xA0,读 0xA1 “爱写爱读”
页大小 8 字节/页 “八仙过海”
地址对齐 起始地址必须是 8 的倍数 “起点要整齐”
写入延时 每字节写入后延时 5ms “写完要等等”
应答信号 0=继续,1=停止 “零继续,一停止”

:tada: 结语

掌握 AT24C02,你就拥有了让单片机"拥有记忆"的能力!记住:

EEPROM 是数据的保险箱:locked_with_key:,正确使用它,你的系统将更加智能可靠!


:link: 快速参考

 /* ========== 常用代码片段 ========== */
 ​
 // 保存单个变量
 unsigned char temp = 25;
 EEPROM_Write(&temp, 0, 1);
 ​
 // 保存数组
 unsigned char data[4] = {10, 20, 30, 40};
 EEPROM_Write(data, 0, 4);
 ​
 // 读取数据
 unsigned char buffer[4];
 EEPROM_Read(buffer, 0, 4);
 ​
 // 清空 EEPROM(写入 0xFF)
 unsigned char clear[8] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
 for(i=0; i<32; i++) {  // 32 页
     EEPROM_Write(clear, i*8, 8);
 }