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

温馨提示:内容源自米醋电子工作室培训课,由本人总结

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


📚 一、AT24C02 芯片概述

1.1 核心特性

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

1.2 工作原理图解

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

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

2.1 地址结构解析

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

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

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

操作 二进制地址 十六进制 记忆口诀
写操作 1010 0000 0xA0 A0️⃣ = “爱写(0)”
读操作 1010 0001 0xA1 A1️⃣ = “爱读(1)”

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


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

3.1 两种写入方式对比

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

3.2 ⚠️ 关键注意事项

为什么地址必须是 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 页

💡 生活比喻:就像停车位,一个车位(页)只能停 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();
}
🔍 代码细节解析
  1. 指针递增 \*EEPROM_String++

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

    • AT24C02 内部写入需要 5ms 的擦写时间
    • 如果写太快,数据会丢失
    • 就像给存储卡"反应时间" ⏳

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

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();
}
🔍 关键点解析

为什么要"先写后读"?

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

应答信号的"潜台词":

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

📦 五、完整底层驱动代码

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();
}

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

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);                │
│  ↓                                       │
│  断电关机...                              │
│  ↓                                       │
│  重新上电 → 数据依然是修改后的值!         │
└──────────────────────────────────────────┘

🔧 七、常见问题与解决方案

问题 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;  // 防止一次写太多

📊 八、数据类型与存储空间

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: 校验码或标志位                  │
└─────────────────────────────────────────┘

📚 九、知识点总结

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

🎉 结语

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

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


🔗 快速参考

/* ========== 常用代码片段 ========== */

// 保存单个变量
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);
}
Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐