深入探究51单片机存储:EEPROM(I2C)技术解析

先认识I2C通信

基本概述 

IICInter-Integrated Circuit)其实是IICBus简称,所以中文应该叫集成电路总线,它是一种串行通信总线,使用多主从架构,由飞利浦公司在1980年代为了让主板、嵌入式系统或手机用以连接低速周边设备而发展。I²C的正确读法为“I平方C”("I-squared-C"),而“I二C”("I-two-C")则是另一种错误但被广泛使用的读法。自2006年10月1日起,使用I²C协议已经不需要支付专利费,但制造商仍然需要付费以获取I²C从属设备地址。

I2C总线是一种同步、半双工,带数据应答的二线制串行总线。它只需要两根线即可在连接于总线上的器件之间传送信息。

主器件用于启动总线传送数据,并产生时钟以开放传送的器件,此时任何被寻址的器件均被认为是从器件。

在总线上主和从、发和收的关系不是恒定的,而取决于此时数据传送方向。

如果主机要发送数据给从器件,则主机首先寻址从器件,然后主动发送数据至从器件,最后由主机终止数据传送;如果主机要接收从器件的数据,首先由主器件寻址从器件,然后主机接收从器件发送的数据,最后由主机终止接收过程。在这种情况下.主机负责产生定时时钟和终止数据传送。

IIC是为了与低速设备通信而发明的。

比如:本来高速设备一个周期发送1位,但是低速设备5个周期才能接收1位。两者速率无法同步,现在通过主设备控制1个时钟频率,使得主从设备能够在同一个时钟频率下工作。

IIC的传输速率比不上SPI。

通信速率一般(kbps级别),不适合语音、视频等信息类型。

主要用途:SoC和周边外设之间的通信(典型的如EEPROM、电容触摸IC、各种sensor等)

物理接口:SCL + SDA
SCL(serial clock):时钟线,传输CLK信号,一般是I2C主设备向从设备提供时钟的通道。
SDA(serial data):数据线,通信数据都通过SDA线传输。

通信特征:串行、同步、非差分、低速率

  • I2C属于串行通信,所有的数据以位为单位在SDA线上串行传输。
  • 同步通信就是通信双方工作在同一个时钟下,一般是通信的A方通过一根CLK信号线传输A自己的时钟给B,B工作在A传输的时钟下。所以同步通信的显著特征就是:通信线中有CLK。
  • 非差分。因为I2C通信速率不高,而且通信双方距离很近,所以使用电平信号通信。
  • 低速率。I2C一般是用在同一个板子上的2个IC之间的通信,而且用来传输的数据量不大,所以本身通信速率很低(一般几百Kbps,不同的I2C芯片的通信速率可能不同,具体在编程的时候要看自己所使用的设备允许的I2C通信最高速率,不能超过这个速率)
  • 突出特征1:主设备+从设备
    I2C通信的时候,通信双方地位是不对等的,而是分主设备和从设备。通信由主设备发起,由主设备主导,从设备只是按照I2C协议被动的接受主设备的通信,并及时响应。
    谁是主设备、谁是从设备是由通信双方来定的(I2C协议并无规定),一般来说一个芯片可以只做主设备、也可以只做从设备、也可以既当主设备又当从设备(软件配置)。

    突出特征2:可以多个设备挂在一条总线上(从设备地址)
    I2C通信可以一对一(1个主设备对1个从设备),也可以一对多(1个主设备对多个从设备)。


    主设备来负责调度总线,决定某一时间和哪个从设备通信。

    注意:同一时间内,I2C的总线上只能传输一对设备的通信信息,所以同一时间只能有一个从设备和主设备通信,其他从设备处于“休眠”状态,不能出来捣乱,否则通信就乱套了(广播然后匹配的思路)。
    每一个I2C从设备在通信中都有一个I2C从设备地址,这个设备地址是从设备本身固有的属性,然后通信时主设备需要知道自己将要通信的那个从设备的地址,然后在通信中通过地址来甄别是不是自己要找的那个从设备。(这个地址是一个电路板上唯一的,不是全球唯一的)

    关于I2C的上拉电阻:I2C协议规定,总线空闲时两根线都必须为高。

    小对比:

    SPI是通过片选来一对多的(需要多根片选线),I2C是通过地址识别来一对多的(用SDA来实现即可,无需额外的地址线)。

    时序:起始和结束

    I2C总线上有2种状态;空闲态(所有从设备都未和主设备通信,此时总线空闲)和忙态(其中一个从设备在和主设备通信,此时总线被这一对占用,其他从设备必须歇着)。

    整个通信分为一个周期一个周期的,两个相邻的通信周期是空闲态。每一个通信周期由一个起始位开始,一个结束位结束,中间是本周期的通信数据。

    起始位并不是一个时间点,起始位是一个时间段,在这段时间内总线状态变化情况是:SCL线维持高电平,同时SDA线发生一个从高到低的下降沿。
    与起始位相似,结束位也是一个时间段。在这段时间内总线状态变化情况是:SCL线维持高电平,同时SDA线发生一个从低到高的上升沿。

    时序:数据传输

    每一个通信周期的发起和结束都是由主设备来做的,从设备只有被动的响应主设备,没法自己自发的去做任何事情。
    主设备在每个通信周期会先发8位的数据(其中7位是从设备地址,还有1位表示主设备下面要写入还是读出,所以最多能连接2^7即128个从设备)到总线。然后总线上的每个从设备都能收到这个地址,并且收到地址后和自己的设备地址比较看是否相等。如果相等说明主设备本次通信就是给我说话,如果不相等说明这次通信与我无关,不用听了不管了。

    时序:ACK应答

    ACK,Acknowledge character,确认字符

    发送方发送一段数据后,接收方需要回应一个ACK。这个响应本身只有1个bit位,不能携带有效信息,只能表示2个意思(要么表示收到数据,即有效响应;要么表示未收到数据,无效响应)
    在某一个通信时刻,主设备和从设备只能有一个在发(占用总线,也就是向总线写),另一个在收(从总线读)。

    应答信号是可以被配置有或者没有的。

    数据格式如下:


    I2C通信时的基本数据单位也是以字节为单位的,每次传输的有效数据都是1个字节(8位)。

    起始位及其后的8个clk都是主设备在发送(主设备掌控总线),此时从设备只能读取总线来得知主设备发给它的信息;然后到了第9周期,按照协议规定从设备需要发送ACK给主设备,所以此时主设备必须释放总线(也就是主设备把SDA总线置为高电平然后不要动),同时从设备试图拉低总线发出ACK。

    如果从设备拉低总线失败,或者从设备根本就没有拉低总线,则主设备看到的现象就是总线在第9周期仍然一直保持高,这对主设备来说,意味着我没收到ACK,主设备就认为刚才给从设备发送的8字节不对(接收失败)

    写数据和读数据

    发送一个字节:

    SCL低电平期间,主机将数据位依次放到SDA线上(高位在前),然后拉高SCL,从机将在SCL高电平期间读取数据位,所以SCL高电平期间SDA不允许有数据变化,依次循环上述过程8次,即可发送一个字节。

    接收应答:

    在发送完一个字节之后,主机在下一个时钟接收一位数据,判断从机是否应答,数据0表示应答,数据1表示非应答。

    接收一个字节:

    SCL低电平期间,从机将数据位依次放到SDA线上(高位在前),然后拉高SCL,主机将在SCL高电平期间读取数据位,所以SCL高电平期间SDA不允许有数据变化,依次循环上述过程8次,即可接收一个字节。

    发送应答:

    在接收完一个字节之后,主机在下一个时钟发送一位数据,数据0表示应答,数据1表示非应答

    如果是读取数据,我们就会知道是否读到数据,所以一般无需发送应答。

    数据传输顺序

    SPI可以选择从最低位或者最高位开始发送,之前学DS1302的时候是从最低位开始发的,可是在学习SPI的时候又说是从最高位开始发送的。不知道哪个是对的。后面查资料,又说其实是可以配置的,具体情况具体对待的,看手册就知道了,不必纠结。

    虽然很多器件都是用了某种接口协议,比如SPI I2C等等,但是对这些接口协议的封装会根据具体功能有所差异,不是完全一致的。另一方面,有些协议是要收费的,所以制造商只能借鉴,不能完全使用。只是说,用了那些接口协议的思想。

    I2C一般是从最高位开始传输的。

    关于释放总线

    某个引脚如果接了地,那么无法将其拉高;如果引脚是高电平,那么可以控制其拉高或者拉低。因为接地的控制力比高电平高。

    释放总线,就是将总线置1,为什么,因为只有置1,其他设备才有可能改变其状态。如果是置0,那么其它设备就无法将其从接地中改变状态。

    关于边沿触发和电平触发

    在之前学习SPI时,是边沿触发。这里的EEPROM,或者说I2C,是高电平触发。

    关于触发方式,在硬件上来说,涉及到触发器和锁存器。如果器件处于锁存状态,那么输出就不受输入影响。如果想要某时刻的输入作用后能影响到输出,就要触发,那么,什么时候触发,就是个问题。

    有些电路设计让电平改变的瞬间触发,过了这个点,就会进入锁存状态。这就是边沿触发,分为上升沿触发、下降沿触发以及双边触发。

    有些电路设计,只要电平处于某种状态,比如高电平或者低电平,就可以一直触发。比如当处于高电平时,某电路只要输入一变,输出就会随之改变。

    更多可以参考:下降沿触发和低电平触发的区别 – 知乎

    我们进一步来考虑编程中的处理。

    比如,上升沿触发,和高电平触发,在编程时有何区别?

    这里很容易搞错,比如上升沿触发是SCL = 0;SCL = 1;高电平触发也是SCL = 0; SCL = 1;

    这里,一个是关注瞬间,一个是关注阶段。

    写数据时:

    先将数据放到数据线上,然后边沿触发就会在改变的瞬间写入;高电平触发在高电平期间就会起作用。按照原理来看,高电平触发可以先放数据然后再将电平拉高,也可以先将电平拉高然后再放数据。有的设计中,要求高电平至少持续一段时间才能将数据写入。

    读数据时:

    主设备先拉高电平,在电平拉高的瞬间,从设备的数据就会被发送到总线上,然后主设备去获取该数据。如果是电平触发,那么,主设备要先拉高电平,在高电平期间从设备就可以将数据放到总线上,然后主设备就可以去读取。读取完就可以将电平拉低。有的设计中,要求高电平至少持续一段时间从设备才能将数据放到总线上。

    在两种方式时,到达高电平之后,都会有一段延时。虽然都是延时,但是作用不同。

    边沿触发后的延时是为了构建正常的时钟信号,对延时的时长没有具体要求。

    电平触发的延时是为了构建起作用的条件,有时,还会对延时时间有要求。

    //写入///
    //边沿触发
    SDA = 1;
    SCL = 0;
    SCL = 1;
    
    //电平触发
    SDA = 1;
    SCL = 0;
    SCL = 1;
    Delay();
    //或者
    SCL = 0;
    SCL = 1;
    SDA = 1;
    Delay();
    
    //读数据///
    //边沿触发
    SCL = 0;
    SCL = 1;
    DAT = SDA;
    
    //电平触发
    SCL = 0;
    SCL = 1;
    DAT = SDA;
    Delay();
    SCL = 0;

    具体用什么方式来触发,一般取决于从设备所使用的协议要求。

    另外,是否需要延时,延时多久,都需要取决于从设备的协议要求,如果不延时,频率过快,从设备可能承受不了这么高的频率,从而导致数据获取错误。

    EEPROM

    ROM:Read-Only Memory,只读存储器,断电后也能保存数据。

    EEPROM:Electrically Erasable Programmable Read-Only Memory,电可擦除可编程只读存储器,最小读写单位为字节,读写速度很慢。

    EEPROM存在系统中的2种形式:内置在单片机内部,外部扩展。

    EEPROM如何编程?

  • I2C接口底层时序
  • 器件定义的寄存器读写时序
  • 24C02

    相关内容查看数据手册,这里提供一篇参考文章:

    24C02是一个2Kbit的串行EEPROM存储芯片,可存储256个字节数据。工作电压范围为1.8V到6.0V,具有低功耗CMOS技术,自定时擦写周期,1000000次编程/擦除周期,可保存数据100年。24C02有一个16字节的页写缓冲器和一个写保护功能。通过I2C总线通讯读写芯片数据,通讯时钟频率可达400KHz。

    可以通过存储IC的型号来计算芯片的存储容量是多大,比如24C02后面的02表示的是可存储2Kbit的数据,转换为字节的存储量为2*1024/8 = 256Byte;又比如24C04后面的04表示的是可存储4Kbit的数据,转换为字节的储存量为4*1024/8 = 512Byte;以此来类推其它型号的存储空间。
    24C02的管脚图如下:

    VCC和VSS是芯片的电源和地,电压的工作范围为:+1.8V~+6.0V。
    A0、A1、A2是IC的地址选择脚。
    WP是写保护使能脚。
    SCL是I2C通讯时钟引脚。
    SDA是I2C通讯数据引脚。

    下图为芯片从地址:


    以看出对于不同大小的24Cxx,具有不同的从器件地址。由于24C02为2k容量,也就是说只需要参考图中第一行的内容。

    芯片的寻址:
    AT24C设备地址为如下:前四位固定为1010,A2~A0为由管脚电平。AT24CXX EEPROM Board模块中默认为接地。A2-A0=000,最后一位表示读写操作。所以AT24Cxx的读地址为0xA1,写地址为0xA0。

    也就是说:
    写24C02的时候,从器件地址为10100000(0xA0);
    读24C02的时候,从器件地址为10100001(0xA1)。

    片内地址寻址(注意从设备地址和内部寻址的区别):

    芯片寻址可对内部256B中的任一个进行读/写操作,其寻址范围为00~FF,共256个寻址单位。
    具体解释:
    由于24C02只有256个字节的存储空间,所以只需要1个字节就可以寻址完24C02的存储空间,但是无法寻址完更大容量的存储IC,比如24C04的存储容量是512字节,需要9个bit的地址位才能寻址完。那怎么办呢?该芯片的解决方法是将A0作为其中一个地址位,由上图可以看到,24C04的设备地址内是没有A0参数的,也就是说24C04的A0引脚是不起作用的,这样也就造成了在I2C总线上只能同时挂载4个24C04芯片。其它存储器如24C08、24C16也可以这么类推。

    24C02的WP引脚是写保护引脚,当WP引脚接高电平时,24C02只能进行读取操作,不能进行写操作。只有当WP引脚悬空或接低电平时,24C02才能进行写操作。

    读写EEPROM

    在写EEPROM的时候,要连续写入三个字节,第一个字节为从设备地址,第二个字节为要寻址的内存地址,第三个字节为要写入的数据;

    同理,读的时候也要先写入从设备地址,然后就是寻址地址,然后开始读数据。

    至于是读还是写,上面说了,用从设备地址的最后一位来表示,1表示读,0表示写。

    页写:只发一次从设备地址和寻址首地址,之后直接写入多个数据,就会从首地址开始,按连续地址依次写入。

    读写代码实现

    注意,截至至2022年7月28日凌晨0点46分,此代码是有问题的,没法跑通,编译没问题,就是从设备怎么都没法应答成功,相对应的应该是数据没有写入到ROM中,不知道哪里出了问题,先放这,等查查资料再看看。

    eeprommain.c

    /************************************************************
    *日期:2022年7月27日
    *作者:星辰
    *文件内容:eeprom功能程序入口
    **************************************************************/
    
    #include "uart.h"
    #include "eeprom.h"
     
    /*************************************************************
    *
    *函数入口
    *
    **************************************************************/
    void main(void)
    {
        uchar flag1 = 0, flag2 = 0;
        uchar uartArr[1] = {0};
        
        I2cStart();
        flag1 = I2cWrite(0xA0) && I2cWrite(0x22) && I2cWrite('A'); //写入数据
        I2cStop();
    
        if(flag1)
        {
            I2cStart();
            flag2 = I2cWrite(0xA0) && I2cWrite(0x22);
            if(flag2)
            {
                I2cStart();
                I2cWrite(0xA1);
                uartArr[0] = I2cRead();  //读出的数据
            }
            I2cStop();
        }
        
        UartInit();
        SendSomeChar(uartArr, 1);    
    }

    eeprom.c

    /************************************************************
    *日期:2022年7月27日
    *作者:星辰
    *文件内容:eeprom读写
    **************************************************************/
    
    #include "eeprom.h"
    #include "somedelay.h"
    
    sbit SDA = P2^0;
    sbit SCL = P2^1;
    
    /************************************************************
    *
    *开始标志
    *
    **************************************************************/
    void I2cStart()
    {
        SDA = 1;
        Delay10us();
        SCL = 1;
        Delay10us();
        SDA = 0;
        Delay10us();
        SCL = 0;
        Delay10us();
    }
    
    /************************************************************
    *
    *结束标志
    *
    **************************************************************/
    void I2cStop()
    { 
        SDA = 0;
        Delay10us();
        SCL = 1;
        Delay10us();
        SDA = 1;
        Delay10us();
    }   
    
    /************************************************************
    *
    *按字节写入
    *
    **************************************************************/
    uchar I2cWrite(uchar dataToWrite)
    {
        uchar i = 0;
        SCL = 0;
    
        for(i; i < 8; i++)
        {
            SDA = dataToWrite >> 7;    //从最高位开始传输
            dataToWrite <<= 1;
            Delay10us();
            SCL = 1;
            Delay10us();
            SCL = 0;
            Delay10us();
        }
        
        SDA = 1;    //释放总线
        Delay10us();
        SCL = 1;
        Delay10us();
        
        if(SDA == 0)
        {
            SCL = 0;
            Delay10us(); 
            return 1;   //返回1表示写入成功
        }
        
        SCL = 0;
        Delay10us();   
        return 0;   //返回0表示失败		
    }
    
    /************************************************************
    *
    *按字节读取
    *读之前也要先写入从设备地址和寻址地址
    **************************************************************/
    uchar I2cRead()
    {
        uchar charGeted = 0;
        uchar i = 0;
        SCL = 0;
    
        for(i; i < 8; i++)
        {
            SCL = 1;
            Delay10us();
            charGeted |= SDA;
            if(i != 7)
            {
                charGeted  <<= 1;
            }  
            SCL = 0;
            Delay10us();
        }
        
        //读的时候不用发送ACK吧?我只要看有没有读出数据就可以了呀。
        return charGeted;
    }

    uart.c

    /************************************************************
    *日期:2022年7月27日
    *作者:星辰
    *文件内容:串口调试
    **************************************************************/
    
    #include "uart.h"
    #include "somedelay.h"
    
    /*************************************************************
    *
    *初始化串口
    *
    **************************************************************/
    void UartInit()
    {
        SCON = 0x50;        //设置使用模式1,波特率可变的8位UART,接收模式可用
        TMOD = 0x20;        //配置定时器1处于模式3,8位自动重载,用作波特率发生器
        PCON = 0x80;        //使用波特率加倍
        TH1 = TL1 = 243;    //设置波特率为4800Hz
        TR1 = 1;            //打开定时器1
    }
    
    /*************************************************************
    *
    *串口发送字符串
    *
    **************************************************************/
    void SendSomeChar(uchar charArr[], int len)
    {    
        while(1)
        {
            int i = 0;
            
            for(i; i < len; i++)
            {
                SBUF = charArr[i];      //直接把数据扔给硬件即可,之后的由硬件完成
                while(!TI);             //等待上一个数据发完再发下一轮
                TI = 0;                 //软件复位
                Delay100us();           //必须要延时,速度太快,会出错
            }
            
            SBUF = '\r';        //直接把数据扔给硬件即可,之后的由硬件完成
            while(!TI);         //等待上一个数据发完再发下一轮
            TI = 0;             //软件复位标志位
            Delay1s();          //目标对象之间的延时
        }
    }

    其他的一些延时代码就不放了~~~~~~~~~~~

    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    代码的分层设计

    经过SPI和I2C等协议的学习,可以看出,有些外设使用了这些协议来实现功能,那么,这里就涉及到了两个层次。

    底层用的就是这些协议,涉及到这些协议的基本读写。

    高层,也就是具体的外设,也有一些具体的协议,通过读写特定的数据,实现具体的功能。

    所以,在编程时,通常底层时序单独一个文件,高层时序单独一个文件,然后在main函数中,使用这些封装好的高层函数,而不直接使用底层函数。

    物联沃分享整理
    物联沃-IOTWORD物联网 » 深入探究51单片机存储:EEPROM(I2C)技术解析

    发表评论