第12章 I2C总线与EEPROM
前边学习了一种通信协议叫做UART异步串行通信,本章要学习第二种常用的通信协议I2C(重要且常用)。I2C总线是由PHILIPS公司开发的两线式串行总线,多用于连接微处理器及其外围芯片。I2C总线的主要特点是接口方式简单,两条线可以挂多个参与通信的器件,即多机模式,而且任何一个器件都可以作为主机,当然同一时刻只能有一个主机。
从原理上来讲,UART属于异步通信,比如电脑发送给单片机,电脑只负责把数据通过TXD发送出来即可,接收数据是单片机自己的事情。而I2C属于同步通信,SCL时钟线负责收发双方的时钟节拍,SDA数据线负责传输数据。I2C的发送方和接收方都以SCL这个时钟节拍为基准进行数据的发送和接收。
从应用上来讲,UART通信多用于板间通信,比如单片机和电脑,这个设备和另外一个设备之间的通信。而I2C多用于板内通信,比如单片机和本章要学的EEPROM之间的通信。
12.1I2C时序初步认识
在硬件上,I2C总线是由时钟总线SCL和数据总线SDA两条线构成,连接到总线上的所有器件的SCL都连到一起,所有SDA都连到一起。I2C总线是开漏引脚并联的结构,因此外部要添加上拉电阻。开漏电路外部加上拉电阻,就组成了线“与”的关系。总线上线“与”的关系就是说,所有接入的器件保持高电平,这条线才是高电平,而任何一个器件输出一个低电平,那这条线就会保持低电平,因此可以做到任何一个器件都可以拉低电平,也就是任何一个器件都可以作为主机,如图12-1所示,添加了R63和R64两个上拉电阻。
虽然说任何一个设备都可以作为主机,但绝大多数情况下都是用单片机来做主机,而总线上挂的多个器件,每一个都像电话机一样有自己唯一的地址,在信息传输的过程中,通过这唯一的地址就可以正常识别到属于自己的信息。
学习UART串行通信的时候,知道了通信流程分为起始位、数据位、停止位这三部分,同理在I2C中也有起始信号、数据传输和停止信号,如图12-2所示。
从图上可以看出来,I2C和UART时序流程有相似性,也有一定的区别。UART每个字节,都有一位起始位、8位数据位、1位停止位。而I2C分为起始信号、数据传输部分、停止信号。其中数据传输部分,可以一次传输很多个字节,字节数是不受限制的,而每个字节的数据最后额外跟了一位,这一位叫做应答位,通常用ACK表示,类似于UART的停止位。
下面通过和UART通信做比较的方式把I2C通信时序进行剖析。首先要理解,UART通信虽然用了TXD和RXD两根线,但是实际一次通信中,1条线就可以完成,2条线是把发送和接收分开而已。而I2C每次通信,不管是发送还是接收,必须2条线都参与工作才能完成,为了更方便的看出来每一位的传输流程,把图12-2改进成图12-3。
(1)起始信号:UART通信是从一直持续的高电平出现一个低电平标志起始位;而I2C通信的起始信号的定义是SCL为高电平期间,SDA由高电平向低电平变化产生一个下降沿,表示起始信号,如图12-3中的Start部分所示。
(2)数据传输:首先,UART是低位在前,高位在后;而I2C通信是高位在前,低位在后。其次,UART通信数据位是固定长度,波特率分之一,一位一位固定时间发送完毕就可以了。而I2C没有固定波特率,但是有时序的要求,要求当SCL在低电平的时候,SDA允许变化,也就是说,发送方必须先保持SCL是低电平,才可以改变数据线SDA,输出要发送的当前数据的一位;而当SCL在高电平的时候,SDA绝对不可以变化,因为这个时候,接收方要来读取当前SDA的电平信号是0还是1,要保证SDA的稳定,如图12-3中的每一位数据的变化,都是在SCL的低电平位置。8位数据位后边跟着的是一位应答位,应答位后边还要具体介绍。
(3)停止信号:UART通信的停止位是一位固定的高电平信号;而I2C通信停止信号的定义是SCL为高电平期间,SDA由低电平向高电平变化产生一个上升沿,表示结束信号,如图12-3中的Stop部分所示。
12.2I2C寻址模式
上一节介绍的是I2C每一位信号的时序流程,而I2C通信在字节级的传输中,也有固定的时序要求。I2C通信的起始信号(Start)后,首先要发送一个从机的地址,这个地址一共有7位,紧跟着的第8位是数据方向位(R/W),“0”表示接下来要发送数据(写),‘“1”表示接下来是请求数据(读)。
打电话的时候,当拨通电话,接听方捡起电话肯定要回一个“喂”,这就是告诉拨电话的人,这边有人了。同理,这个第九位ACK实际上起到的就是这样一个作用。当发送完了这7位地址和1位方向后,如果发送的这个地址确实存在,那么这个地址的器件应该回应一个ACK(拉低SDA即输出“0”),如果不存在,就没“人”回应NACK(SDA将保持高电平即“1”)。
写一个简单的程序,访问一下Kingst51开发板上的EEPROM的地址,另外再写一个不存在的地址,看看它们是否能回一个ACK,来了解和确认一下这个问题。
Kingst51开发板上的EEPROM器件型号是24C02,在24C02的数据手册3.6节中可查到,24C02的7位地址中,其中高4位是0b1010,低3位的地址取决于具体电路的设计,由芯片的A2、A1、A0这3个引脚的实际电平决定,来看一下电路图,如图12-4所示。
从图12-4可以看出,A2、A1、A0都是接的GND,也就是说都是0,因此24C02的7位地址实际上是二进制的0b1010000,也就是0x50。用I2C的协议来寻址0x50,另外再寻址一个不存在的地址0x62,寻址完毕后,通过逻辑分析仪观察一下两个地址是否回复ACK。
/*****************************main.c文件程序源代码******************************/
#include <reg52.h>
#include <intrins.h>
#define I2CDelay() {_nop_();_nop_();_nop_();_nop_();}
sbit I2C_SCL = P3^7;
sbit I2C_SDA = P3^6;
bit I2CAddressing(unsigned char addr);
void main()
{
I2CAddressing(0x50); //查询地址为0x50的器件
I2CAddressing(0x62); //查询地址为0x62的器件
while (1);
}
/* 产生总线起始信号 */
void I2CStart()
{
I2C_SDA = 1; //首先确保SDA、SCL都是高电平
I2C_SCL = 1;
I2CDelay();
I2C_SDA = 0; //先拉低SDA
I2CDelay();
I2C_SCL = 0; //再拉低SCL
}
/* 产生总线停止信号 */
void I2CStop()
{
I2C_SCL = 0; //首先确保SDA、SCL都是低电平
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; //返回从机应答值
}
/* I2C寻址函数,即检查地址为addr的器件是否存在,返回值-从器件应答值 */
bit I2CAddressing(unsigned char addr)
{
bit ack;
I2CStart(); //产生起始位,即启动一次总线操作
ack = I2CWrite(addr<<1); //器件地址需左移一位,因寻址命令的最低位
//为读写位,用于表示之后的操作是读或写
I2CStop(); //不需进行后续读写,而直接停止本次总线操作
return ack;
}
前面的章节中已经提到利用库函数_nop_()
可以进行精确延时,一个_nop_()
的时间就是一个机器周期,这个库函数包含在intrins.h
这个文件中,如果要使用这个库函数,只需要在程序最开始,和包含reg52.h一样,include<intrins.h>
之后,程序中就可以使用这个库函数了。
还有一点要提一下,I2C
通信分为低速模式100kbit/s
、快速模式400kbit/s
和高速模式3.4Mbit/s
。因为所有的I2C
器件都支持低速,但却未必支持另外两种速度,所以作为通用的I2C程序选择100k
这个速率,也就是说实际程序产生的时序必须小于等于100k
的时序参数,很明显也就是要求SCL
的高低电平持续时间都不短于5us,因此在时序函数中通过插入I2CDelay()
这个总线延时函数(它实际上就是4个NOP
指令,用define
在文件开头做了定义),加上改变SCL
值语句本身占用的至少一个周期,来达到这个速度限制。如果以后需要提高速度,那么只需要减小这里的总线延时时间即可。
此外学习一个发送数据的技巧,就是I2C
通信时如何将一个字节的数据发送出去。注意函数I2CWrite
中,用的for
循环的技巧。for (mask=0x80; mask!=0; mask>>=1)
,由于I2C
通信是从高位开始发送数据,所以先从最高位开始,0x80
和dat
进行按位与运算,从而得知dat
第7位是0还是1,然后右移一位,也就是变成了用0x40
和dat
按位与运算,得到第6位是0还是1,一直到第0位结束,最终通过if
语句,把dat
的8位数据依次发送了出去。
使用Kingst LA5016逻辑分析仪将抓到的波形显示出来,并且用过I2C的协议解码器将协议解析出来,如图12-5所示。从图上可以看出,第一个字节发的是0x50,回复了一个ACK;第二个字节发了一个0x62,但是出现的是NAK,说明这个地址没有产生应答。
在逻辑分析仪的I2C协议设置中,有三种地址格式显示方式,也就是目前市面上各种资料对I2C协议地址定义的方式。如图12-6所示。
前边讲I2C发送的第一个字节是7位地址加一位读写位,但是有些资料直接将读写位归结到I2C的地址,也有的资料将7位地址位认为是高7位,以开发板的0x50
地址和0x62
地址为例,即地址二进制0b1010 000
,写的时候是0b1010 0000
;读的时候是0b1010 0001
。
方式1:8-bit,包含读/写位,0x50地址对应这种方式写地址为0xA0;读地址为0xA1。
方式2:8-bit,读/写位显示为0,即写地址和读地址都是0xA0。
方式3:7-bit,本教材采用的方式,写地址和读地址都是0x50。
12.3EEPROM的学习
在实际应用中,保存在单片机RAM中的数据,掉电后就丢失了,使用code关键字保存在单片机的FLASH中的数据,又不能随意改变,也就是不能用它来记录变化的数值。但是在某些场合,又确实需要记录下某些数据,而它们还时常需要改变或更新,掉电之后数据还不能丢失,比如家用电表度数,电视机里边的频道记忆,一般都是使用EEPROM来保存数据,特点就是掉电后不丢失。Kingst51开发板上使用的这个器件是24C02,是一个容量大小是2Kbits,也就是256个字节的EEPROM。一般情况下,EEPROM拥有30万到100万次的寿命,也就是它可以反复写入30-100万次,而读取次数是无限的。
24C02是一个基于I2C通信协议的器件,但要分清楚,I2C是一个通信协议,它拥有严密的通信时序逻辑要求,而EEPROM是一个器件,只是这个器件采用了I2C协议的接口与单片机相连而已,二者并没有必然的联系,EEPROM可以用其它接口,I2C也可以用在其它很多器件上。
12.3.1EEPROM单字节读写操作时序
-
EEPROM写数据流程
(1)首先是I2C的起始信号,接着跟上首字节,也就是I2C的器件地址,并且在读写方向上选择“写”操作。
(2)发送数据的存储地址。24C02一共256个字节的存储空间,地址从0x00~0xFF,想把数据存储在哪个位置,此刻写的就是哪个地址。
(3)发送要存储的数据第一个字节、第二个字节……注意在写数据的过程中,EEPROM每个字节都会回应一个“应答位0”,来通知用户写EEPROM数据成功,如果没有回应答位,说明写入不成功。
在写数据的过程中,每成功写入一个字节,EEPROM存储空间的地址就会自动加1,当加到0xFF后,再写一个字节,地址会溢出又变成了0x00。 -
EEPROM读数据流程
(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、读的时候应答信号则由主机给出,表示是否继续读下去。
下面写一个程序,读取EEPROM的0x02这个地址上的一个数据,不管这个数据之前是多少都将读出来的数据加1,再写到EEPROM的0x02这个地址上。此外将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; //首先确保SDA、SCL都是高电平
I2C_SCL = 1;
I2CDelay();
I2C_SDA = 0; //先拉低SDA
I2CDelay();
I2C_SCL = 0; //再拉低SCL
}
/* 产生总线停止信号 */
void I2CStop()
{
I2C_SCL = 0; //首先确保SDA、SCL都是低电平
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能看出,第一个字节是器件地址0x50+ACK,第二个字节是数据地址0x02+ACK,第三个字节是器件地址0x50+ACK,第四个是读取到了0x04+NAK数据,第五个字节是器件地址0x50+ACK,第6个字节是数据地址0x02+ACK,第七个字节是写入数据0x05+ACK。
12.3.2EEPROM多字节读写操作时序
读取EEPROM的时候很简单,EEPROM根据主机的时序,直接就把数据送出来了,但是写EEPROM却没有这么简单了。给EEPROM发送数据后,先保存在了EEPROM的缓存,EEPROM必须要把缓存中的数据搬移到“非易失”的区域,才能达到掉电不丢失的效果。而往非易失区域写需要一定的时间,每种器件不完全一样,ATMEL公司的24C02的这个写入时间最高不超过5ms。在往非易失区域写的过程,EEPROM是不会再响应访问的,不仅接收不到数据,即使用I2C标准的寻址模式去寻址,EEPROM都不会应答,就如同这个总线上没有这个器件一样。数据写入非易失区域完毕后,EEPROM再次恢复正常。
12.2节程序中写数据的代码,程序上有读取应答ACK,但是读取完毕后没有做任何处理。这是因为一次只写一个字节的数据进去,等到下次重新再写的时候,时间肯定远远超过了5ms,但是如果是连续写入几个字节的时候,就必须得考虑到应答位的问题了。写入一个字节后,再写入下一个字节之前,必须要等待EEPROM再次响应才可以。
先从EEPROM的0x90这个地址连续读出5个字节,然后把这5个数据分别加1,加2,加3, 加4,加5后重新写入到这五个地址中去。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[i] = buf[i] + 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表格看出,第一行为读到的4个字节的数据,下面只有红框内为写入EEPROM的数据,而红框外的为检测0x50是否响应。由于EEPROM正在将前次写入的数据搬移到非易失区,因此一直检测一直等待到EEPROM响应才能再次往里边写数据。
12.3.3EEPROM的页写入
在向EEPROM连续写入多个字节的数据时,如果每写一个字节都要等待几ms的话,整体上的写入效率就太低了。因此EEPROM的厂商就想了一个办法,把EEPROM分页管理。24C01、24C02这两个型号是8个字节一个页,而24C04、24C08、24C16是16个字节一页。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[i] = buf[i] + 1 + i;
}
E2Write(buf, 0x8E, sizeof(buf)); //再写回到E2中
while(1);
}
同样数量的多字节写入时间和页写入的时间到底差别多大呢?现在把两次写入时间用逻辑分析仪给抓了出来,并且用时间标签A1和A2标注了开始位置和结束位置,如图12-9和图12-10所示,右侧显示的|A1-A2|就是最终写入5个字节所耗费的时间。多字节一个一个写入,每次写入后都需要再次通信检测EEPROM是否在“忙”,因此耗费了大量的时间,同样的写入5个字节的数据,一个一个写入用了8.4ms左右的时间,而使用页写入,并且还跨页操作,只用了3.5ms左右的时间。
12.4练习题
- 彻底理解I2C的通信时序。
- 能够独立完成EEPROM任意地址的单字节读写、多字节的跨页连续写入读出。
- 将前边学的交通灯进行改进,使用EEPROM保存红灯和绿灯倒计时的时间,并且可以通过UART改变红灯和绿灯倒计时时间。