IIC读写AT24C08

IIC协议

起始、停止条件:IIC的起始信号为当时钟信号线(SCL)为高电平时,数据线(SDA)产生一个下降沿,停止信号为当时钟信号线(SCL)为高电平时,数据线(SDA)产生一个上升沿。



应答位、非应答位:当主机传送8位数据结束后,主机会将SDA线拉高,此时如果从机正确接收数据则会将SDA拉低并在SCL高电平时保持低电平,这个信号为ACK信号。如果在传输8位数据后从机没有将SDA拉低则该信号为NACK。如果出现NACK则表示数据传输出错。

数据有效性:当时钟信号为高电平的时候,数据线上的信号需要保持不变也就是在时钟线为高电平的时候数据线出现上升下降沿的话就会产生停止和启动信号,从而导致数据的传输出错。

byte组织:SDA上的数据传输是以8bit即一个字节为单位传输的,每一次传输的字节数没有限制,每传输完一个字节后必须跟随一个应答位。

我们以01001001(0X49)为例,其时序图如下:

写操作:主机确定了从机的设备地址后,生成一个开始信号,然后向IIC总线上面发送设备的地址和读写方向标志。从机检测到该地址和自己设备地址相对应后,回复主机一个应答信号。主机接收到应答信号后就开始向这个设备以字节为单位发送数据,每一个字节后面都会带有从机的应答信号,直到主机发送完成最后一个数据后生成一个停止信号结束此次数据的传输。

读操作:读操作与写操作有一些类似,同样的是需要确定需要读取的从设备的地址。然后主机生成开始信号,再向IIC总线上发送从设备的地址和读数据的指令。从设备接收到地址与自己的吻合后会产生一个应答信号。就这从设备就开始向主机发送主机想要读取的数据,主机正确接收数据后会向从机回复应答信号,当主机想要结束读取操作时,主机会回复一个非应答信号,然后生成停止信号结束数据的读取。


首先我们定义SCL和SDA引脚,以及AT24C08的地址:

1
2
3
#define AT24C08_ADDR 0xA0
#define SCL P36
#define SDA P37

定义一个函数用于短暂延时

1
2
3
4
5
6
7
8
void flash()
{
_nop_();
_nop_();
_nop_();
_nop_();
_nop_();
}

定义一个函数用于发送开始信号,IIC的起始信号为当时钟信号线(SCL)为高电平时,数据线(SDA)产生一个下降沿

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* @Brief IIC开始信号
* @Call Internal
* @Param None
* @Note None
* @RetVal None
*/
static void iicStart()
{
SDA = 1;//数据线电平拉高
flash();//等待一个指令周期
SCL = 1;//时钟信号线电平拉高
flash();//等待
SDA = 0;//数据线拉低产生一个下降沿
flash();//等待
SCL = 0;
flash();
}

定义一个函数发送一个字节数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* @Brief IIC发送一个字节数据
* @Call Internal
* @Param None
* @Note None
* @RetVal None
*/
static void iicSendByte(uint8_t byte)
{
uint8_t i;
for(i = 0 ; i < 8 ; i++)
{
SCL = 0; //SCL拉低允许切换SDA电平
flash();
if (byte & 0x80) //如果最高位为1则拉高SDA发送1
{
SDA = 1;
flash();
}
else //否则则拉低SDA发送0
{
SDA = 0;
flash();
}
SCL = 1; //SCL拉高,从机读取数据
flash();
byte <<= 1; //右移一位将最高位丢掉
}
SCL = 0; //拉低SCL停止数据发送,等待应答
flash();
SDA = 1; //拉高SDA,等待应答
flash();
}

定义一个函数用于发送停止信号,停止信号为当时钟信号线(SCL)为高电平时,数据线(SDA)产生一个上升沿。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @Brief IIC停止信号
* @Call Internal
* @Param None
* @Note None
* @RetVal None
*/
static void iicStop()
{
SDA = 0;//数据线拉低
flash();//等待一个时钟周期
SCL = 1;//时钟信号线拉高
flash();//等待
SDA = 1;//数据线拉高
flash();//等待
}

定义一个函数用于检测应答信号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* @Brief 检测应答信号
* @Call Internal or External
* @Param None
* @Note None
* @RetVal None
*/
static bit iicDetectAck()
{
uint8_t i = 0;
SCL = 1;
flash();
while( i < 255 )
{
i++;
if (!SDA)
{
SCL = 0;
_nop_();
return(1);
}
return(0);
}

}

将上面的开始信号函数、停止信号函数、检测应答信号函数、发送字节数据函数结合,封装成一个可以直接传入地址和数据的数据写入函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* @Brief 向24C08写入一个字节数据
* @Call Internal
* @Param None
* @Note None
* @RetVal None
*/
static bit iicWriteByte(uint8_t addr, uint8_t dat)
{
iicStart();
iicSendByte(AT24C08_ADDR);
if (!iicDetectAck())
{
return(0);
}
iicSendByte(addr);
if (!iicDetectAck())
{
return(0);
}
iicSendByte(dat);
if (!iicDetectAck())
{
return(0);
}
iicStop();
return(1);
}

定义一个函数读取一个字节的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* @Brief 读取字节函数
* @Call Internal or External
* @Param None
* @Note None
* @RetVal None
*/
static uint8_t iicReceiveByte()
{
uint8_t i, dat = 0;
SCL = 0;
flash();
SDA = 1;
flash();
for(i = 0 ; i < 8 ; i++)
{
SCL = 1;
flash();
dat = dat << 1;
if (SDA)
{
dat |= 0x01; //最低位写1
}
SCL = 0 ;
flash();
}
return(dat);
}

定义一个函数发送应答和否应答信号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* @Brief 发送应答信号
* @Call Internal or External
* @Param None
* @Note None
* @RetVal None
*/
static void iicAck(bit i)
{
SCL = 0; // 拉低时钟总线允许SDA数据总线上的数据变化
_nop_(); // 让总线稳定
SDA = i; // 拉低数据总线表示应答,拉高数据总线表示否应答
_nop_();//让总线稳定
SCL = 1;//拉高时钟总线 让从机从SDA线上读走 主机的应答信号
flash();
SCL = 0;//拉低时钟总线, 占用总线继续通信
_nop_();
SDA = 1;//释放SDA数据总线。
_nop_();
}

综合开始信号函数、停止信号函数、检测应答信号函数、发送字节数据函数结合、读取字节数据函数、发送应答信号函数,封装成一个可以直接传入地址读取数据的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* @Brief 读取24C08一个字节数据
* @Call Internal or External
* @Param None
* @Note None
* @RetVal None
*/
static uint8_t iicReadByte(uint8_t addr)
{
uint8_t dat;
iicStart();
iicSendByte(AT24C08_ADDR);
if (!iicDetectAck())
{
return(0);
}
iicSendByte(addr);
if (!iicDetectAck())
{
return(0);
}
iicStart();
iicSendByte(AT24C08_ADDR+1);
if (!iicDetectAck())
{
return(0);
}
dat = iicReceiveByte();
iicAck(1);//表示不再读取数据
iicStop();
return(dat);
}

测试结果:

读写一页数据(16位):

在51单片机中读写无符号整型数据及uint16_t类型数据,需要2个字节(16位),此时我们可以使用连续读写,就是在读写完一个字节后不发停止信号或者应答否信号,转而继续读写数据代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* @Brief 向24C08写入一页(16位)数据
* @Call Internal
* @Param 8位地址;16位数据
* @Note None
* @RetVal bit
*/
static bit writePageAt(uint8_t addr, uint16_t dat)
{
iicStart(); //发送开始信号
iicSendByte(AT24C08_ADDR); //发送从设备地址
if (!iicDetectAck()) //检查从设备是否应答
{
return(0);
}
iicSendByte(addr); //发送要写入的地址
if (!iicDetectAck()) //检查从设备是否应答
{
return(0);
}
iicSendByte((uint8_t)(dat>>8)); //先发送数据的高8位
if (!iicDetectAck()) //检查从设备是否应答
{
return(0);
}
iicSendByte((uint8_t) dat); //在发送数据的低8位
if (!iicDetectAck()) //检查从设备是否应答
{
return(0);
}
iicStop(); //数据写入完毕,发送停止信号
return(1);
}


读写单精度数据
  1. 使用共同体构建一个浮点数和uint8_t数组共享内存的共同体变量,当修改共同体任意成员时会同步更新到其他成员
1
2
3
4
5
//共用体-单精度浮点数(c51中float型占4个字节)
typedef union{
uint8_t bytes[4];
float float_dat;
}FLOAT_U;
  1. 利用memcpy内存拷贝函数将单精度数据拷贝到数组所在uint8_t数组所在的内存地址中。
1
2
3
4
5
6
7
8
9
10
float number = 3.14159;
uint8_t array[4];
// 将float类型的数值复制到uint8_t类型的数组中
memcpy(array, &number, sizeof(float));
// 打印uint8_t类型的数组内容
for (int i = 0; i < sizeof(float); i++) {
printf("%02X ", array[i]);
}
printf("\n");

读写单精度浮点数,我选择了使用共同体将浮点数与字节数相互转换:


一个注意事项:51单片机不是基于堆栈的架构。 当参数不能放入CPU寄存器时,Keil Cx51编译器默认使用直接的内存位置来传递参数。 这种技术生成非常高效的代码,但限制了可以传递给间接调用的函数的参数。 当通过函数指针传递给函数的形参不适合寄存器时,编译器无法确定将形参放在内存中的哪个位置,因为函数在调用时是未知的。

  1. 使用可重入函数属性 reentrant 创建可重入函数。编译器模拟了一种基于堆栈的体系结构,它可以将几乎无限数量的参数传递给间接调用的函数。
  2. 限制参数的数量和类型,使它们都适合CPU寄存器。在参数前指明存储的位置,比如data、xdata

data:固定指前面0x00-0x7F的128个RAM,可以用acc直接读写,速度最快,生成的代码也最小。

idata:固定指前面0x00-0xFF的256个RAM,其中前128和data的128完全相同,只是访问的方式不同。

xdata:外部扩展RAM。

code:ROM。
使用第一种方法:

1
2
3
4
5
6
7
8
9
typedef struct
{
bit (*writeFloatAt)(uint8_t, float);
}AT24C08_T;
//改为下面的代码
typedef struct
{
bit (*writeFloatAt)(uint8_t, float)reentrant;
}AT24C08_T;
连续读写

数据手册第一页提到自写入周期为5ms,因此进行跨页连续写入的时候需要等待5ms

AT24C08一页16个字节,当连续写入地址跨页时,不会自动翻页,如果不手动翻页那么就会回到这一页的首部,覆盖原来的数据。

翻页需要发送停止信号,再次发送开始信号、设备地址和目标写入地址,下面是多字节写入函数的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
* @Brief 多字节写入24C08
* @Call Internal
* @Param None
* @Note None
* @RetVal None
*/
bit writeBytesAt(uint8_t addr,uint8_t *dat,uint8_t len)
{
iicStart(); //发送开始信号
iicSendByte(AT24C08_ADDR0); //发送设备地址
if (!iicDetectAck()) //检查设备是否响应
{
return(0);
}
iicSendByte(addr); //发送要写入的地址
if (!iicDetectAck()) //检查设备是否响应
{
return(0);
}
while(len > 0)
{
iicSendByte(*dat++);
if(!iicDetectAck()) //检查设备是否响应
{
return(0);
}
len--;
addr++;
if((addr & 0x0F) == 0) //每满16位翻页
{
iicStop();
Delay_ms(6);
iicStart();
iicSendByte(AT24C08_ADDR0);
if (!iicDetectAck()) //检查设备是否响应
{
return(0);
}
iicSendByte(addr); //发送下一页的起始地址
if (!iicDetectAck()) //检查设备是否响应
{
return(0);
}
}
}
iicStop(); //发送停止信号结束发送
return(1);
}

参考文章:KEIL C51中的data、idata、xdata、code详解