找回密码
 立即注册

QQ登录

只需一步,快速开始

搜索
查看: 1|回复: 0
收起左侧

第12章 I2C总线与EEPROM12.3 12.4

[复制链接]
ID:1167894 发表于 2026-4-29 11:47 | 显示全部楼层 |阅读模式
12.3 EEPROM的学习
在实际应用中,保存在单片机RAM中的数据,掉电后就丢失了,使用code关键字保存在单片机的FLASH中的数据,又不能随意改变,也就是不能用它来记录变化的数值。但是在某些场合,又确实需要记录下某些数据,而它们还时常需要改变或更新,掉电之后数据还不能丢失,比如家用电表度数,电视机里边的频道记忆,一般都是使用EEPROM来保存数据,特点就是掉电后不丢失。Kingst51开发板上使用的这个器件是24C02,是一个容量大小是2Kbits,也就是256个字节的EEPROM。一般情况下,EEPROM拥有30万到100万次的寿命,也就是它可以反复写入30-100万次,而读取次数是无限的。
24C02是一个基于I2C通信协议的器件,但要分清楚,I2C是一个通信协议,它拥有严密的通信时序逻辑要求,而EEPROM是一个器件,只是这个器件采用了I2C协议的接口与单片机相连而已,二者并没有必然的联系,EEPROM可以用其它接口,I2C也可以用在其它很多器件上。
12.3.1 EEPROM单字节读写操作时序
1EEPROM写数据流程
1)首先是I2C的起始信号,接着跟上首字节,也就是I2C的器件地址,并且在读写方向上选择“写”操作。
2发送数据的存储地址。24C02一共256个字节的存储空间,地址从0x000xFF,想把数据存储在哪个位置,此刻写的就是哪个地址。
3发送要存储的数据第一个字节、第二个字节……注意在写数据的过程中,EEPROM每个字节都会回应一个“应答位0”,来通知用户EEPROM数据成功,如果没有回应答位,说明写入不成功。
在写数据的过程中,每成功写入一个字节,EEPROM存储空间的地址就会自动加1,当加到0xFF后,再写一个字节,地址会溢出又变成了0x00
2EEPROM读数据流程
1)首先是I2C的起始信号,接着跟上首字节,也就是I2C的器件地址,并且在读写方向上选择“写”操作。明明是读数据为何方向也要选“写”呢?24C02一共有256个地址,选择写操作,是为了把所要读的数据的存储地址先写进去,告诉EEPROM要读取哪个地址的数据。这就如同打电话,先拨总机号码(EEPROM器件地址),而后还要继续拨分机号码(数据地址),而拨分机号码这个动作,主机仍然是发送方,方向依然是“写”。
2发送要读取的数据的地址,注意是地址而非存在EEPROM中的数据,通知EEPROM要哪个分机的信息。
3)重新发送I2C起始信号和器件地址,并且在方向位选择“读”操作。
3步当中,每一个字节实际上都是在“写”,所以每一个字节EEPROM都会回应一个“应答位0”。
4)读取从器件发回的数据,读一个字节后,如果还想继续读下一个字节,就发送一个“应答位ACK(0)”,如果不想读了,通知EEPROM不想要数据了,那就发送一个“非应答位NAK(1)”。
和写操作规则一样,每读一个字节地址会自动加1如果想继续往下读,给EEPROM一个ACK(0)低电平,再继续给SCL完整的时序,EEPROM会继续往外送数据。如果不想读了,直接给一个NAK(1)高电平。
梳理一下几个要点:
1、在本例中单片机是主机,24C02是从机;
2、无论是读是写,SCL始终都是由主机控制的;
3、写的时候应答信号由从机给出,表示从机是否正确接收了数据;
4、读的时候应答信号则由主机给出,表示是否继续读下去。
下面写一个程序,读取EEPROM0x02这个地址上的一个数据,不管这个数据之前是多少都将读出来的数据加1,再写到EEPROM0x02这个地址上。此外将I2C的程序建立一个文件,写一个I2C.c程序文件,形成又一个程序模块。
/*****************************I2C.c文件程序源代码*****************************/
#include <reg52.h>
#include <intrins.h>
#define I2CDelay()  {_nop_();_nop_();_nop_();_nop_();}
sbit I2C_SCL = P3^7;
sbit I2C_SDA = P3^6;
/* 产生总线起始信号 */
void I2CStart()
{
    I2C_SDA = 1; //首先确保SDASCL都是高电平
    I2C_SCL = 1;
    I2CDelay();
    I2C_SDA = 0; //先拉低SDA
    I2CDelay();
    I2C_SCL = 0; //再拉低SCL
}
/* 产生总线停止信号 */
void I2CStop()
{
    I2C_SCL = 0; //首先确保SDASCL都是低电平
    I2C_SDA = 0;
    I2CDelay();
    I2C_SCL = 1; //先拉高SCL
    I2CDelay();
    I2C_SDA = 1; //再拉高SDA
    I2CDelay();
}
/* I2C总线写操作,dat-待写入字节,返回值-从机应答位的值 */
bit I2CWrite(unsigned char dat)
{
    bit ack;  //用于暂存应答位的值
    unsigned char mask;  //用于探测字节内某一位值的掩码变量
    for (mask=0x80; mask!=0; mask>>=1) //从高位到低位依次进行
    {
        if ((mask&dat) == 0)  //该位的值输出到SDA
            I2C_SDA = 0;
        else
            I2C_SDA = 1;
        I2CDelay();
        I2C_SCL = 1;          //拉高SCL
        I2CDelay();
        I2C_SCL = 0;          //再拉低SCL,完成一个位周期
    }
    I2C_SDA = 1;   //8位数据发送完后,主机释放SDA,以检测从机应答
    I2CDelay();
    I2C_SCL = 1;   //拉高SCL
    ack = I2C_SDA; //读取此时的SDA值,即为从机的应答值
    I2CDelay();
    I2C_SCL = 0;   //再拉低SCL完成应答位,并保持住总线
    return (~ack); //应答值取反以符合通常的逻辑:
                   //0=不存在或忙或写入失败,1=存在且空闲或写入成功
}
/* I2C总线读操作,并发送非应答信号,返回值-读到的字节 */
unsigned char I2CReadNAK()
{
    unsigned char mask;
    unsigned char dat;
    I2C_SDA = 1;  //首先确保主机释放SDA
    for (mask=0x80; mask!=0; mask>>=1) //从高位到低位依次进行
    {
        I2CDelay();
        I2C_SCL = 1;      //拉高SCL
        if(I2C_SDA == 0)  //读取SDA的值
            dat &= ~mask; //0时,dat中对应位清零
        else
            dat |= mask;  //1时,dat中对应位置1
        I2CDelay();
        I2C_SCL = 0;      //再拉低SCL,以使从机发送出下一位
    }
    I2C_SDA = 1;   //8位数据发送完后,拉高SDA,发送非应答信号
    I2CDelay();
    I2C_SCL = 1;   //拉高SCL
    I2CDelay();
    I2C_SCL = 0;   //再拉低SCL完成非应答位,并保持住总线
    return dat;
}
/* I2C总线读操作,并发送应答信号,返回值-读到的字节 */
unsigned char I2CReadACK()
{
    unsigned char mask;
    unsigned char dat;
    I2C_SDA = 1;  //首先确保主机释放SDA
    for (mask=0x80; mask!=0; mask>>=1) //从高位到低位依次进行
    {
        I2CDelay();
        I2C_SCL = 1;      //拉高SCL
        if(I2C_SDA == 0)  //读取SDA的值
            dat &= ~mask; //0时,dat中对应位清零
        else
            dat |= mask;  //1时,dat中对应位置1
        I2CDelay();
        I2C_SCL = 0;      //再拉低SCL,以使从机发送出下一位
    }
    I2C_SDA = 0;   //8位数据发送完后,拉低SDA,发送应答信号
    I2CDelay();
    I2C_SCL = 1;   //拉高SCL
    I2CDelay();
    I2C_SCL = 0;   //再拉低SCL完成应答位,并保持住总线
    return dat;
}
/****************************main.c文件程序源代码*****************************/
#include <reg52.h>
extern void I2CStart();
extern void I2CStop();
extern unsigned char I2CReadNAK();
extern bit I2CWrite(unsigned char dat);
unsigned char E2ReadByte(unsigned char addr);
void E2WriteByte(unsigned char addr, unsigned char dat);
void main()
{
    unsigned char dat;
    dat = E2ReadByte(0x02);    //读取指定地址上的一个字节
    dat++;                     //将其数值+1
    E2WriteByte(0x02, dat);    //再写回到对应的地址上
   
    while (1);
}
/* 读取EEPROM中的一个字节,addr-字节地址 */
unsigned char E2ReadByte(unsigned char addr)
{
    unsigned char dat;
   
    I2CStart();
    I2CWrite(0x50<<1); //寻址器件,后续为写操作
    I2CWrite(addr);    //写入存储地址
    I2CStart();        //发送重复启动信号
    I2CWrite((0x50<<1)|0x01); //寻址器件,后续为读操作
    dat = I2CReadNAK();       //读取一个字节数据
    I2CStop();
   
    return dat;
}
/* EEPROM中写入一个字节,addr-字节地址 */
void E2WriteByte(unsigned char addr, unsigned char dat)
{
    I2CStart();
    I2CWrite(0x50<<1); //寻址器件,后续为写操作
    I2CWrite(addr);    //写入存储地址
    I2CWrite(dat);     //写入一个字节数据
    I2CStop();
}
I2C.c文件提供了I2C总线底层函数,包括起始、停止、字节写、字节读+应答、字节读+非应答。这个程序复编译会发现Keil软件提示一个警告:*** WARNING L16: UNCALLED SEGMENT, IGNORED FOR OVERLAY PROCESS,这个警告的意思是在代码中存在没有被调用过的变量或者函数,I2C.c文件中的I2CReadACK()这个函数在本例中没有用到。
读取EEPROM的时候,由于只读了一个字节就要告诉EEPROM不需要再读数据了,读完后直接发送一个“NAK”,因此只调用了I2CReadNAK()这个函数,而并没有调用I2CReadACK()这个函数。今后很可能读数据的时候要连续读几个字节,因此这个函数写在了I2C.c文件中,作为I2C功能模块的一部分是必要的,方便这个文件以后移植到其他程序中使用,因此这个警告在这里就不必管它了。
将这个程序中,I2C的读写EEPROM操作用逻辑分析仪抓出来,并且用I2C-EEPROM协议解析出来,如图12-7所示。
                                                                                                                                                             12-7.png
12-7  I2C-EEPROM解析结果图
从图12-7能看出,第一个字节是器件地址0x50+ACK,第二个字节是数据地址0x02+ACK,第三个字节是器件地址0x50+ACK,第四个是读取到了0x04+NAK数据,第五个字节是器件地址0x50+ACK,第6个字节是数据地址0x02+ACK,第七个字节是写入数据0x05+ACK
12.3.2 EEPROM多字节读写操作时序
读取EEPROM的时候很简单,EEPROM根据主机的时序,直接就把数据送出来了,但是写EEPROM却没有这么简单了。EEPROM发送数据后,先保存在了EEPROM的缓存,EEPROM必须要把缓存中的数据搬移到“非易失”的区域,才能达到掉电不丢失的效果。而往非易失区域写需要一定的时间,每种器件不完全一样,ATMEL公司的24C02的这个写入时间最高不超过5ms。在往非易失区域写的过程,EEPROM是不会再响应访问的,不仅接收不到数据,即使用I2C标准的寻址模式去寻址,EEPROM都不会应答,就如同这个总线上没有这个器件一样。数据写入非易失区域完毕后,EEPROM再次恢复正常。
12.2节程序中写数据的代码,程序上有读取应答ACK,但是读取完毕后没有做任何处理。这是因为一次只写一个字节的数据进去,等到下次重新再写的时候,时间肯定远远超过了5ms,但是如果是连续写入几个字节的时候,就必须得考虑到应答位的问题了。写入一个字节后,再写入下一个字节之前,必须要等待EEPROM再次响应才可以
先从EEPROM0x90这个地址连续读出4个字节,然后把这4个数据分别加1,加2,加3, 加4后重新写入到这四个地址中去。I2C.c文件和之前是完全一样的,因此只把main.c文件给发出来
/****************************I2C.c文件程序源代码******************************/
(此处省略,可参考之前章节的代码)
/****************************main.c文件程序源代码*****************************/
#include <reg52.h>
extern void I2CStart();
extern void I2CStop();
extern unsigned char I2CReadACK();
extern unsigned char I2CReadNAK();
extern bit I2CWrite(unsigned char dat);
void E2Read(unsigned char *buf, unsigned char addr, unsigned char len);
void E2Write(unsigned char *buf, unsigned char addr, unsigned char len);
void main()
{
    unsigned char i;
    unsigned char buf[5];
    E2Read(buf, 0x90, sizeof(buf));   //E2中读取一段数据
    for (i=0; i<sizeof(buf); i++)     //数据依次+1,+2,+3...
    {
        buf = buf + 1 + i;
    }
    E2Write(buf, 0x90, sizeof(buf));  //再写回到E2
   
    while(1);
}
/* E2读取函数,buf-数据接收指针,addr-E2中的起始地址,len-读取长度 */
void E2Read(unsigned char *buf, unsigned char addr, unsigned char len)
{
    do {                       //用寻址操作查询当前是否可进行读写操作
        I2CStart();
        if (I2CWrite(0x50<<1)) //应答则跳出循环,非应答则进行下一次查询
        {
            break;
        }
        I2CStop();
    } while(1);
    I2CWrite(addr);            //写入起始地址
    I2CStart();                //发送重复启动信号
    I2CWrite((0x50<<1)|0x01);  //寻址器件,后续为读操作
    while (len > 1)            //连续读取len-1个字节
    {
        *buf++ = I2CReadACK(); //最后字节之前为读取操作+应答
        len--;
    }
    *buf = I2CReadNAK();       //最后一个字节为读取操作+非应答
    I2CStop();
}
/* E2写入函数,buf-源数据指针,addr-E2中的起始地址,len-写入长度 */
void E2Write(unsigned char *buf, unsigned char addr, unsigned char len)
{
    while (len--)
    {
        do {                       //用寻址操作查询当前是否可进行读写操作
            I2CStart();
            if (I2CWrite(0x50<<1)) //应答则跳出循环,非应答则进行下一次查询
            {
                break;
            }
            I2CStop();
        } while(1);
        I2CWrite(addr++);  //写入起始地址
        I2CWrite(*buf++);  //写入一个字节数据
        I2CStop();         //结束写操作,以等待写入完成
    }
}
函数E2Read:读数据前,要查询当前是否允许读写操作,EEPROM正常响应才表示允许。读最后一个字节之前的,全部给ACK,而读完最后一个字节,要给出一个NAK
函数E2Write:写操作前,要查询当前EEPROM是否响应,正常响应后才可以写数据。
I2C多字节读写EEPROM的时序部分用逻辑分析仪抓取,由于此次的读写数据量特别大,因此用逻辑分析仪抓取后,直接将解析后的数据导出到excel表格中,如图12-8所示。
12-8.png
12-8 连续读写解析后数据示意图
从图12-8表格看出,第一行为读到的4个字节的数据,下面只有红框内为写入EEPROM的数据,而红框外的为检测0x50是否响应。由于EEPROM正在将前次写入的数据搬移到非易失区,因此一直检测一直等待到EEPROM响应才能再次往里边写数据。
12.3.3 EEPROM的页写入
在向EEPROM连续写入多个字节的数据时,如果每写一个字节都要等待几ms的话,整体上的写入效率就太低了。因此EEPROM的厂商就想了一个办法,把EEPROM分页管理。24C0124C02这两个型号是8个字节一个页,而24C0424C0824C1616个字节一页。Kingst51开发板上用的型号是24C02,一共是256个字节,8个字节一页,一共有32页。
分配好页之后,同一个页内连续写入几个字节后再发送停止位,EEPROM检测到停止位后,就会一次性把这一页的数据写到非易失区域,不需要写一个字节检测一次了,并且页写入的时间也不会超过5ms。如果写入的数据跨页了,写完了一页之后,要发送一个停止位,然后等待并且检测EEPROM的空闲模式,一直等到把上一页数据完全写到非易失区域后,再进行下一页的写入,这样就可以在很大程度上提高数据的写入效率,程序如下。
/****************************I2C.c文件程序源代码******************************/
(此处省略,可参考之前章节的代码)
/***************************eeprom.c文件程序源代码****************************/
#include <reg52.h>
extern void I2CStart();
extern void I2CStop();
extern unsigned char I2CReadACK();
extern unsigned char I2CReadNAK();
extern bit I2CWrite(unsigned char dat);
/* E2读取函数,buf-数据接收指针,addr-E2中的起始地址,len-读取长度 */
void E2Read(unsigned char *buf, unsigned char addr, unsigned char len)
{
    do {                       //用寻址操作查询当前是否可进行读写操作
        I2CStart();
        if (I2CWrite(0x50<<1)) //应答则跳出循环,非应答则进行下一次查询
        {
            break;
        }
        I2CStop();
    } while(1);
    I2CWrite(addr);            //写入起始地址
    I2CStart();                //发送重复启动信号
    I2CWrite((0x50<<1)|0x01);  //寻址器件,后续为读操作
    while (len > 1)            //连续读取len-1个字节
    {
        *buf++ = I2CReadACK(); //最后字节之前为读取操作+应答
        len--;
    }
    *buf = I2CReadNAK();       //最后一个字节为读取操作+非应答
    I2CStop();
}
/* E2写入函数,buf-源数据指针,addr-E2中的起始地址,len-写入长度 */
void E2Write(unsigned char *buf, unsigned char addr, unsigned char len)
{
    while (len > 0)
    {
        //等待上次写入操作完成
        do {                       //用寻址操作查询当前是否可进行读写操作
            I2CStart();
            if (I2CWrite(0x50<<1)) //应答则跳出循环,非应答则进行下一次查询
            {
                break;
            }
            I2CStop();
        } while(1);
        //按页写模式连续写入字节
        I2CWrite(addr);           //写入起始地址
        while (len > 0)
        {
            I2CWrite(*buf++);     //写入一个字节数据
            len--;                //待写入长度计数递减
            addr++;               //E2地址递增
            if ((addr&0x07) == 0) //检查地址是否到达页边界,24C02每页8字节,
            {                     //所以检测低3位是否为零即可
                break;            //到达页边界时,跳出循环,结束本次写操作
            }
        }
        I2CStop();
    }
}
遵循模块化的原则,把EEPROM的读写函数单独写成一个eeprom.c文件。其中E2Read函数和上一节是一样的,因为读操作与分页无关。重点是E2Write函数,在写入数据的时候,要计算下一个要写的数据的地址是否是一个页的起始地址,如果是的话,则必须跳出循环,等待EEPROM把当前这一页写入到非易失区域后,再进行后续页的写入。
/****************************main.c文件程序源代码*****************************/
#include <reg52.h>
extern void E2Read(unsigned char *buf, unsigned char addr, unsigned char len);
extern void E2Write(unsigned char *buf, unsigned char addr, unsigned char len);
void main()
{
    unsigned char i;
    unsigned char buf[5];
    E2Read(buf, 0x8E, sizeof(buf));   //E2中读取一段数据
    for (i=0; i<sizeof(buf); i++)     //数据依次+1,+2,+3...
    {
        buf = buf + 1 + i;
    }
    E2Write(buf, 0x8E, sizeof(buf));  //再写回到E2
   
    while(1);
}
同样数量的多字节写入时间和页写入的时间到底差别多大呢?现在把两次写入时间用逻辑分析仪给抓了出来,并且用时间标签A1A2标注了开始位置和结束位置,如图12-9和图12-10所示,右侧显示的|A1-A2|就是最终写入5个字节所耗费的时间。多字节一个一个写入,每次写入后都需要再次通信检测EEPROM是否在“忙”,因此耗费了大量的时间,同样的写入5个字节的数据,一个一个写入用了8.4ms左右的时间,而使用页写入,并且还跨页操作,只用了3.5ms左右的时间。
12-9.png
12-9  多字节写入时间
12-10.png
12-10  跨页写入时间
12.4练习题
1、彻底理解I2C的通信时序。
2、能够独立完成EEPROM任意地址的单字节读写、多字节的跨页连续写入读出。
3、将前边学的交通灯进行改进,使用EEPROM保存红灯和绿灯倒计时的时间,并且可以通过UART改变红灯和绿灯倒计时时间。

回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|51黑电子论坛 |51黑电子论坛6群 QQ 管理员QQ:125739409;技术交流QQ群281945664

Powered by 单片机教程网

快速回复 返回顶部 返回列表