I²C由两条线组成,一条双向串行数据线SDA,一条串行时钟线SCL。SDA线上的数据必须在时钟的高电平周期保持稳定,数据线的高或低电平状态只有在 SCL 线的时钟信号是低电平时才能改变。换言之,SCL为高电平时表示有效数据,SDA为高电平表示“1”,低电平表示“0”;SCL为低电平时表示无效数据,此时SDA会进行电平切换,为下次数据表示做准备。数据有效性示意图如图 16.1.2 所示。
I²C起始信号(S):当SCL高电平时,SDA由高电平向低电平转换;I²C停止信号(P):当SCL高电平时,SDA由低电平向高电平转换;
I²C每次传输的8位数据,每次传输后需要从机反馈一个应答位,以确认从机是否正常接收了数据。当主机发送了8位数据后,会再产生一个时钟,此时主机放开SDA的控制,读取SDA电平,在上拉电阻的影响下,此时SDA默认为高,必须从机拉低,以确认收到数据。
I²C完整传输流程如下:① SDA和SCL开始都为高,然后主机将SDA拉低,表示开始信号;② 在接下来的8个时间周期里,主机控制SDA的高低,发送从机地址。其中第8位如果为0,表示接下来是写操作,即主机传输数据给从机;如果为1,表示接下来是读操作,即从机传输数据给主机;另外,数据传输是从最高位到最低位,因此传输方式为MSB(Most Significant Bit)。③ 总线中对应从机地址的设备,发出应答信号;④ 在接下来的8个时间周期里,如果是写操作,则主机控制SDA的高低;如果是读操作,则从机控制SDA的高低;⑤ 每次传输完成,接收数据的设备,都发出应答信号;⑥ 最后,在SCL为高时,主机由低拉高SDA,表示停止信号,整个传输结束;
16.1.2 EEPROM介绍
EEPROM的全称是“电可擦除可编程只读存储器”,即Electrically Erasable Programmable Read-Only Memory。通常用于存放用户配置信息数据,比如在开发板首次运行时,需要屏幕校准,校准后的配置信息就可以保存在EEPROM里,开发板断电后配置信息不丢失,下次启动,开发板自动读取EEPROM的校准配置信息,就不需要重新校准。
EEPROM和Flash的本质上是一样的,Flash包括MCU内部的Flash和外部扩展的Flash,本开发板就有一个SPI接口的外部Flash(W25Q64),在后面SPI接口再讲解。从功能上,Flash通常存放运行代码,运行过程中不会修改,而EEPROM存放用户数据,可能会反复修改。从结构上,Flash按扇区操作,EEPROM通常按字节操作。两者区别这里不再过多赘述,读者理解EEPROM在嵌入式中扮演的角色即可。
EEPROM类型众多,其中比较常见是AT24Cxx系列,从命名上看,AT24Cxx中xx的单位是K Bit,如AT24C08,其存储容量为8K Bit。本开发板上的EEPROM型号为AT24C02,其存储容量为2K Bit,2*1024=2048 Bit。
对于AT24C01/02,每页大小为8 Byte,对于AT24C04/08/16,每页大小为16 Byte。如图 16.1.6 所示,AT24C02由32页(Page)组成,每一页由8个字节(Byte)组成,每个Byte由8位(Bit)组成,Bit为最小存储单位,存放1个0或1。
I²C设备都会有一个设备地址,不同容量的AT24C02,设备地址定义会有所差异,由芯片数据手册《AT24Cxx.pdf》可知,如图 16.1.7 所示。
AT24C02的容量为2K,对应上图中的第一行,高四位固定为“1010”,中间三位由A2、A1、A0引脚的电平决定,比如A2~0引脚全接地,则值为“000”,最后的最低位为读写位,0代表写命令,1代表读命令。
A2、A1、A0引脚电平需要由原理图决定,假设全接电源地,则如果需要向AT24C02写数据,则发送地址“1010 0000”,如果需要向AT24C02读数据,则发送地址“1010 0001”。
假设开发板有多个AT24C02挂在同一I²C总线上,通过这个规则,只需设计电路时,让A2、A1、A0引脚电平不同,即可区分两个AT24C02。
对于容量再大一点的AT24Cxx系列,比如AT24C04,器件地址由A2、A1引脚决定,数据空间有P0决定。比如对AT24C04的0~2K空间操作,则P0为0,对2K~4K空间操作,则P0为1。
AT24Cxx支持字节写模式和页写模式。字节写模式是一个地址一个数据的写;页写模式是连续写数据,一个地址多个数据的写,但是页写模式不能自动跨页,如果超出一页长度,超出的数据会覆盖原先写入的数据。
如图 16.1.8 所示,为AT24Cxx字节写模式的时序,在MCU发出开始信号(Start)后,发出8 Bit的设备地址信息(图中读写位为低电平,即写数据),待收到AT24Cxx应答信号后,再发出要写的数据地址,再次等待AT24Cxx应答,最后发出8 Bit数据写数据,待AT24Cxx应答后,发出停止信号(Stop),完成一次单字节写数据。
图 16.1.8 AT24Cxx字节写模式时序 AT24C02容量为2K,因此数据地址范围为0x00~0xFF,即0~255,每个数据地址每次写1Byte,即8bit,也就刚好256*8=2048Bit。对于1K容量的产品,数据地址范围为0x00~0x7F,最高位不会用到,因此图中数据地址的最高位为“*”,意思是对于1K容量的产品,该位无需关心。
图 16.1.10 为AT24Cxx的页写模式时序,与字节写模式的差异在于,不是只发送1Byte数据,而是任意多个。需要注意,该模式不能跨页写,遇到跨页时,需要重新发送完整的时序。 图 16.1.10 AT24Cxx页写模式时序 值得一提的是,《AT24Cxx.pdf》里提到每次写完之后,再到下次写之前,需要间隔5ms时间,以确保上次写操作在芯片内部完成,如图 16.1.11 所示。
AT24Cxx支持当前地址读模式、随机地址读模式和顺序读模式。当前地址读模式就是在上一次读/写操作之后的最后位置,继续读出数据,比如上次读/写在地址n,接下来可以直接从n+1处读出数据;随机地址读模式是指定数据地址,然后读出数据;顺序读模式是连续读出多个数据。
在当前地址读模式下,无需发送数据地址,数据地址为上一次读/写操作之后的位置,时序如图 16.1.12 所示,注意在结尾,主机接收数据后,无需产生应答信号。
图 16.1.12 AT24Cxx当前地址读模式 在随机地址读模式下,需要先发送设备地址,待读的数据地址,接着再重新发出开始信号,设备地址,读出数据,时序如图 16.1.13 所示。
在顺序读模式下,需要先从当前地址读模式或随机地址读模式启动,随后便可连续读多个数据,时序如图 16.1.14 所示
16.2 硬件设计如图 16.2.1 为开发板EEPROM部分的原理图,U6为AT24C02芯片,它的A0、A1、A2都接地,因此该设备地址为“1010 000X”,当读该设备时,X为1,写该设备时,X为0。U4的7脚为写保护引脚(Write Protect,WP),当该引脚为高,则禁止写AT24C02,这里直接拉低WP,任何时候都可直接写AT24C02。此外,I2C的两个脚SCL和SDA都进行了上拉处理,从而保证I2C总线空闲时,两根线都必须为高电平。如果没有上拉,在主机发送完数据后,放开SDA,此时SDA的电平状态不确定,可能为高,也可能为低,无法确定是从机拉低给出应答信号。结合原理图可知,PB6作为了I2C1的SCL,PB7作为了I2C1的SDA。
16.3 软件设计
16.3.1 软件设计思路
实验目的:本实验通过GPIO模拟I2C总线时序,对EEPROM设备AT24C02进行读写操作。
1) 引脚初始化:GPIO端口时钟使能、GPIO引脚设置为输入/输出模式(PB6、PB7);
2) 封装I2C每个环节的时序函数:起始信号、响应信号、读写数据、停止信号;
3) 使用I2C协议函数,实现对AT24C02的读写;
4) 主函数,每按一次按键,写一次AT24C02,接着读出来验证是否和写的数据一致;
本实验配套代码位于“5_程序源码\8_通信—模拟I2C\”。
16.3.2 软件设计讲解
1) GPIO选择与接口定义
首先定义SCL和SDA引脚,引脚的高低电平宏定义,如代码段 16.3.1 所示。
<span]接着将两个GPIO引脚初始化,使能引脚时钟,先默认设置为输出模式。SCL引脚为时钟信号,始终为输出模式,SDA引脚为数据引脚,可能输出或者输入,因此还需要编写函数实现输入、输出的切换,如代码段 16.3.2 所示。 代码段 16.3.2 I2C引脚初始化(driver_i2c.c)
/*
* 函数名:void I2C_Init(void)
* 输入参数:
* 输出参数:无
* 返回值:无
* 函数作用:初始化模拟I2C的引脚为输出状态且SCL/SDA都初始为高电平
*/
void I2C_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
SCL_PIN_CLK_EN();
SDA_PIN_CLK_EN();
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
GPIO_InitStruct.Pin = SCL_PIN;
HAL_GPIO_Init(SCL_PORT, &amp;GPIO_InitStruct);
GPIO_InitStruct.Pin = SDA_PIN;
HAL_GPIO_Init(SDA_PORT, &amp;GPIO_InitStruct);
SCL_H();
SDA_H();
}
/*
* 函数名:static void I2C_SDA_OUT(void)
* 输入参数:
* 输出参数:无
* 返回值:无
* 函数作用:配置SDA引脚为输出
*/
static void I2C_SDA_OUT(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_PULLUP;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
GPIO_InitStruct.Pin = SDA_PIN;
HAL_GPIO_Init(SDA_PORT, &amp;GPIO_InitStruct);
}
/*
* 函数名:static void I2C_SDA_IN(void)
* 输入参数:
* 输出参数:无
* 返回值:无
* 函数作用:配置SDA引脚为输入
*/
static void I2C_SDA_IN(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
GPIO_InitStruct.Pin = SDA_PIN;
HAL_GPIO_Init(SDA_PORT, &amp;GPIO_InitStruct);
}
<span] 参考前面图 16.1.4 所示,编译应答信号,如代码段 16.3.4 所示。
代码段 16.3.4 应答/非应答/等待应答信号(driver_i2c.c)
/*
* 函数名:void I2C_ACK(void)
* 输入参数:
* 输出参数:无
* 返回值:无
* 函数作用:I2C发出应答信号
*/
void I2C_ACK(void)
{
I2C_SDA_OUT();
SCL_L();
I2C_Delay();
SDA_L();
I2C_Delay();
SCL_H();
I2C_Delay();
SCL_L();
I2C_Delay();
}
/*
* 函数名:void I2C_NACK(void)
* 输入参数:
* 输出参数:无
* 返回值:无
* 函数作用:I2C发出非应答信号
*/
void I2C_NACK(void)
{
I2C_SDA_OUT();
SCL_L();
I2C_Delay();
SDA_H();
I2C_Delay();
SCL_H();
I2C_Delay();
SCL_L();
I2C_Delay();
}
/*
* 函数名:uint8_t I2C_GetACK(void)
* 输入参数:
* 输出参数:无
* 返回值:1无应答,0有应答
* 函数作用:I2C等待从机的应答信号
*/
uint8_t I2C_GetACK(void)
{
uint8_t time = 0;
I2C_SDA_IN();
SCL_L();
I2C_Delay();
SDA_H();
I2C_Delay();
SCL_H();
I2C_Delay();
while(SDA_INPUT())
{
time++;
if(time&gt;250)
{
SCL_L();
return 1;
}
}
SCL_L();
return 0;
}- 8~23行:应答信号,在一个SDA时钟周期里,将SCL拉低;
- 32~47行:非应答信号,在一个SDA时钟周期里,将SCL拉高;
- 56~82行:等待应答信号,拉高SDA后放开SDA,读取SDA是否被拉低,如果拉低返回0,否则返回1;
<span] 整个I2C协议函数中,经常用到“ I2C_Delay()”来实现SCL时钟周期。对于AT24Cxx,由其芯片手册可知,时钟脉冲宽度(Clock Pulse Width)需要大于5us,也就是SCL如果刚变为高电平,需要等待至少5us才能变为低电平,因此定义“ I2C_Delay()”为5us以上即可。
#define I2C_Delay() us_timer_delay(5) // Clock Pulse Width &gt;5us<span] 3) AT24C02读写函数
编写好I2C协议函数后,参考AT24C02手册编写读写数据函数,如代码段 16.3.7 所示。
代码段 16.3.7 读写AT24C02一字节数据(driver_eeprom.c)
/*
* 函数名:uint8_t EEPROM_WriteByte(uint16_t addr, uint8_t data)
* 输入参数:addr -&gt; 写一个字节的EEPROM初始地址
* data -&gt; 要写的数据
* 输出参数:无
* 返回值:无
* 函数作用:EEPROM写一个字节
*/
void EEPROM_WriteByte(uint16_t addr, uint8_t data)
{
/* 1. Start */
I2C_Start();
/* 2. Write Device Address */
I2C_SendByte( EEPROM_DEV_ADDR | EEPROM_WR );
/* 3. Data Address */
if(EEPROM_WORD_ADDR_SIZE==0x08)
{
I2C_SendByte( (uint8_t)(addr &amp; 0x00FF) );
}
else
{
I2C_SendByte( (uint8_t)(addr&gt;&gt;8) );
I2C_SendByte( (uint8_t)(addr &amp; 0x00FF) );
}
/* 4. Write a byte */
I2C_SendByte(data);
/* 5. Stop */
I2C_Stop();
}
/*
* 函数名:uint8_t EEPROM_ReadByte(uint16_t addr, uint8_t *pdata)
* 输入参数:addr -&gt; 读一个字节的EEPROM初始地址
* data -&gt; 要读的数据指针
* 输出参数:无
* 返回值:无
* 函数作用:EEPROM读一个字节
*/
void EEPROM_ReadByte(uint16_t addr, uint8_t *pdata)
{
/* 1. Start */
I2C_Start();
/* 2. Write Device Address */
I2C_SendByte( EEPROM_DEV_ADDR | EEPROM_WR );
/* 3. Data Address */
if(EEPROM_WORD_ADDR_SIZE==0x08)
{
I2C_SendByte( (uint8_t)(addr &amp; 0x00FF) );
}
else
{
I2C_SendByte( (uint8_t)(addr&gt;&gt;8) );
I2C_SendByte( (uint8_t)(addr &amp; 0x00FF) );
}
/* 4. Start Again */
I2C_Start();
/* 5. Write Device Address Read */
I2C_SendByte( EEPROM_DEV_ADDR | EEPROM_RD );
/* 6.Read a byte */
*pdata = I2C_ReadByte(NACK);
/* 7. Stop */
I2C_Stop();
}<span]需要注意的是,AT24Cxx每次写操作后,有一个写间隔,需要间隔5ms以上,因此在写多个字节时,每次写完都需要延时5ms以上。
4) 主函数控制逻辑
在主函数里,每按一下按键,调用“EEPROM_Write_Nbytes()”对AT24C02写一串数据,再调用“EEPROM_Read_Nbytes()”读出该数据,如代码段 16.3.9 所示。
代码段 16.3.9 主函数控制逻辑(main.c)
// 初始化I2C
I2C_Init();
while(1)
{
if(key_flag) // 按键按下
{
key_flag = 0;
printf(&quot;\n\r&quot;);
printf(&quot;Start write and read eeprom.\n\r&quot;);
// 读写一串字符,并打印
EEPROM_Write_NBytes(0, tx_buffer, sizeof(tx_buffer)); // 写数据
HAL_Delay(1);
EEPROM_Read_NBytes(0, rx_buffer, sizeof(tx_buffer)); // 读数据
HAL_Delay(1);
printf(&quot;EEPROM Write: %s\n\r&quot;, tx_buffer);
printf(&quot;EEPROM Read : %s\n\r&quot;, rx_buffer);
memset((uint8_t*)rx_buffer, 0, sizeof(rx_buffer)); // 清空接收的数据
}
} 16.4 实验效果
本实验对应配套资料的“5_程序源码\8_通信—模拟I2C\”。打开工程后,编译,下载,按下按键KEY,即可看到串口如图 16.4.1 所示。
图 16.4.1 模拟I2C读写AT24C02数据
|