EEPROM、MCU6050和OLED显示屏外设都是通过IIC协议【半双工】进行通信。
除此之外,另一个广泛地使用在系统内多个集成电路间的通讯协议:SPI。
目录
一、IIC物理层:
MCU就是STM32。IIC协议就是一个支持多设备的总线,“总线”指多个设备(传感器)共用的信号线,由主机发起通信。一个IIC总线只使用两条总线线路,一条双向串行数据线(SDA),一条串行时钟线 (SCL)。数据线即用来表示数据,时钟线用于数据收发同步。
每个连接到总线的设备都有一个独立的地址,用7位的二进制数表示,主机可以利用这个地址进行不同设备之间的访问。
总线通过上拉电阻接到电源(在STM32里面是连接到3.3V)。当IIC设备空闲时,会输出高阻态,而当所有设备都空闲,都输出高阻态时,由上拉电阻把总线拉成高电平。(在数字电路里面,3.3V是逻辑1,0V是逻辑0,高阻态相当于断路)。
设备表达逻辑0或1的方法:如果所有的设备都空闲,都在输出高阻态,那么整条总线都可以通过上拉电阻拉到3.3V,也就是逻辑1;如果只要有一个设备没有空闲,那么通过这个设备总线就会连接到GND,这根总线就会拉成0V,变成逻辑0.所以:IIC里面,逻辑0的优先级是高于逻辑1的。这就是仲裁机制。多个主机同时使用总线时,为了防止数据冲突,会利用仲裁方式决定由哪个设备占用总线。
相同的输出模式在GPIO口的应用:GPIO可以通过开漏输出模式输出高阻态,此I/O口的电压就由外界的上拉电阻拉高决定。如果我们想通过I/O口输出一个5V的电压,就可以将其设置成开漏输出模式,外接5V的上拉电阻,就可以输出5V电压。
二、IIC协议层
IIC的协议定义了通讯的起始和停止信号、数据有效性、响应、仲裁、时钟同步和地址广播等环节。
1.IIC的读写过程
1-主机写数据到从机:
S:主机发送起始信号
SLAVE_ADDRESS: 从机地址
A/A非:应答(ACK)或非应答(NACK)信号
应答位:返回0,应答成功;返回1,应答失败。
主机发送起始信号之后,开始对地址进行点名,收到从机应答信号之后,开始传输数据。传输数据的过程是一个字节一个字节传输的,主机传完一个字节之后,就要等待从机去应答。如果从机返回了一个非应答信号,就说明从机接收的数据已经够多了,主机就会暂停传输,产生结束信号“P”。
2-主机由从机中读数据:
此过程与前面类似。
3-通讯复合格式
IIC的读和写复合在一起进行
大体过程与前面相同。
注意:我们确定读取/写入数据的从机是EEPROM而不是OLED,是取决于SLAVE_ADDRESS的。EEPROM总共能存储256个字节,那如何确定读取/写入数据是EEPROM的哪个字节?
第一次发送起始信号,确定是EEPROM的地址之后,先写数据,得到应答之后,发送一个数据,这个数据就是EEPROM的字节。然后再次发送开始信号,换成读数据,等待应答并且返回该字节内的数据。
2.通讯的起始和停止信号
当SCL线是高电平时,SDA线从高电平向低电平切换,这个情况表示通讯的起始。当SCL是高电平时SDA线由低电平向高电平切换,表示通讯的停止。
这两个信号都是由主机产生。
3.数据有效性
IIC使用SDA信号线来传输数据,使用SCL信号线进行数据同步。 SDA数据线在SCL的每个时钟周期传输一位数据(8位数据一个字节)。
SCL为高电平的时候SDA表示的数据有效,即此时的SDA为高电平时表示数据“1”,为低电平时表示数据“0”。当SCL为低电平时,SDA的数据无效,一般在这个时候SDA进行电平切换,为下一次表示数据做好准备。
4.地址及数据方向
IIC总线上的每个设备都有自己的独立地址,主机发起通讯时,通过SDA信号线发送设备地址(SLAVE_ADDRESS)来查找从机。设备地址可以是7位或10位。紧跟设备地址的一个数据位R/W用来表示数据传输方向,数据方向位为“1”时表示主机由从机读数据,该位为“0”时表示主机向从机写数据。
例如:EEPROM的地址确定:由A0、A1、A2引脚的值确定,比如A0接地,A1接3.3V.但在MINI板子上这三个引脚都接地(都是0,高4位是1010,是确定值,则地址位固定为1010 000x,x就是读/写模式位)【8位设备读地址:0XA1,写地址:0XA0】
5.响应
I2C的数据和地址传输都带响应。响应包括“应答(ACK)”和“非应答(NACK)”两种信号。每传输一个字节,都要带一个响应位。
传输时主机产生时钟,在第9个时钟时,数据发送端会释放SDA的控制权,由数据接收端控制SDA,若SDA为高电平,表示非应答信号(NACK),低电平表示应答信号(ACK)。【主机和从机都有可能成为发送端或者接收端】
三、程序
1.IIC协议底层程序
1-配置IIC宏定义
IIC协议的初始化GPIO端口函数,和一些产生起始信号、终止信号、应答信号,读取、写入字节等。该协议适用于全部IIC协议的模块,是较底层的函数,只需要修改相应的引脚、端口、时钟即可。
#define IIC_SCL_GPIO_CLK RCC_APB2Periph_GPIOA//时钟
#define IIC_SCL_GPIO_PORT GPIOA //端口
#define IIC_SCL_GPIO_PIN GPIO_Pin_2//pin 引脚
#define IIC_SDA_GPIO_CLK RCC_APB2Periph_GPIOA//时钟
#define IIC_SDA_GPIO_PORT GPIOA //端口
#define IIC_SDA_GPIO_PIN GPIO_Pin_3//pin
//控制引脚电平
#define EEPROM_IIC_SDA_1() GPIO_SetBits(IIC_SDA_GPIO_PORT,IIC_SDA_GPIO_PIN)//SDA 数据线 输出高电平
#define EEPROM_IIC_SDA_0() GPIO_ResetBits(IIC_SDA_GPIO_PORT,IIC_SDA_GPIO_PIN)
#define EEPROM_IIC_SCL_1() GPIO_SetBits(IIC_SCL_GPIO_PORT,IIC_SCL_GPIO_PIN)//SCL 时钟线 输出高电平
#define EEPROM_IIC_SCL_0() GPIO_ResetBits(IIC_SCL_GPIO_PORT,IIC_SCL_GPIO_PIN)
//读取引脚电平
#define EEPROM_IIC_SDA_READ() GPIO_ReadInputDataBit(IIC_SDA_GPIO_PORT,IIC_SDA_GPIO_PIN)//读取指定的输入端口引脚
2-初始化IIC用到的GPIO口
新建一个IIC通信的文件夹。IIC协议所用到的GPIO端口的初始化。EEPROM用到两个引脚:SDA和SCL其输出模式:开漏输出模式。
void IIC_GPIO_Config(void)//初始化相关的GPIO IIC-EEPROM
{
GPIO_InitTypeDef GPIO_InitStruct;
/*第一步:打开外设的时钟(RCC寄存器控制)*/
RCC_APB2PeriphClockCmd(IIC_SCL_GPIO_CLK|IIC_SDA_GPIO_CLK,ENABLE);
/*第二步:配置外设初始化结构体*/
GPIO_InitStruct.GPIO_Pin = IIC_SCL_GPIO_PIN;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_OD;//开漏输出
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_10MHz;
/*第三步:调用外设初始化函数,把配置好的结构体成员写到寄存器里面*/
GPIO_Init(IIC_SCL_GPIO_PORT,&GPIO_InitStruct);
GPIO_InitStruct.GPIO_Pin = IIC_SDA_GPIO_PIN;
GPIO_Init(IIC_SDA_GPIO_PORT,&GPIO_InitStruct);
}
3-IIC底层通信的基本程序
配置通讯的起始信号和终止信号,原理图见“通讯的起始和停止信号”。注意的是,要在电平升高/降低之前加一点延时,保持一段时间,因为硬件需要一些反应时间。延时函数使用原装的。
void IIC_Start(void)//产生起始信号
{
EEPROM_IIC_SDA_1();
EEPROM_IIC_SCL_1();
i2c_Delay();//需要在这个状态下加一点延时,保持一段时间
EEPROM_IIC_SDA_0();
i2c_Delay();//产生起始信号
EEPROM_IIC_SCL_0();
i2c_Delay();
}
void IIC_Stop(void)//产生停止信号
{
EEPROM_IIC_SDA_0();
EEPROM_IIC_SCL_1();
i2c_Delay();
EEPROM_IIC_SDA_1();
i2c_Delay();//产生停止信号
EEPROM_IIC_SCL_0();
i2c_Delay();
}
配置产生应答和非应答信号程序(接收端),原理图见“响应”。在应答程序内:配置完之后SDA拉成高电平,释放控制权,供其他设备使用。接着配置等待等待应答信号,无应答返回1;应答返回0。原理图见上图黄色部分。将SDA和SCL的电平都拉高,给个延时之后判断SDA返回的是高低电平,高电平返回无应答;低电平返回应答;最后再把SCL置0.
void IIC_Ask(void)//产生应答信号
{
EEPROM_IIC_SDA_0();
EEPROM_IIC_SCL_1();
i2c_Delay();
EEPROM_IIC_SCL_0();//SCL产生时钟
i2c_Delay();//产生应答信号
EEPROM_IIC_SDA_1();//SDA 拉成高电平,释放控制权,供其他设备使用
i2c_Delay();
}
void IIC_NAsk(void)//产生非应答信号
{
EEPROM_IIC_SDA_1();
EEPROM_IIC_SCL_1();
i2c_Delay();
EEPROM_IIC_SCL_0();//SCL产生时钟
i2c_Delay();//产生非应答信号
}
uint8_t IIC_Wait_Ask()//等待应答信号 无应答:1 应答:0
{
uint8_t reply;
EEPROM_IIC_SDA_1();//释放控制权
EEPROM_IIC_SCL_1();
i2c_Delay();
if(EEPROM_IIC_SDA_READ() == 1)
{
reply = 1;
}
else
{
reply = 0;
}
EEPROM_IIC_SCL_0();
i2c_Delay();
return reply;
}
配置写入一个字节的函数,通过for循环将数据一位一位传输过去,0X80:1000 0000,让要写入的数据一位一位的与0X80相与,直到整个数据都与其相与完毕。写入1的话返回1,写入0的话返回0。传输完之后,释放SDA控制权。
void IIC_Write_byte(uint8_t data)//写入一个字节
{
uint8_t i;
for(i=0;i<8;i++)
{
if(data & 0x80)//0X80:1000 0000 让要写入的数据一位一位的与 0X80 相与
{ //下面的 data <<= 1;直到整个数据都与其相与完毕。
EEPROM_IIC_SDA_1();//相与:写入1的话返回1,写入0的话返回0
}
else
{
EEPROM_IIC_SDA_0();
}
i2c_Delay();
EEPROM_IIC_SCL_1();
i2c_Delay();
EEPROM_IIC_SCL_0();//SCL产生时钟
i2c_Delay();
if(i == 7)
{
EEPROM_IIC_SDA_1();//释放控制权
}
data <<= 1;
}
}
配置读取一个字节的函数,用temp暂存读取的数据,通过for循环全部读取。
uint8_t IIC_Read_byte()//读取一个字节
{
uint8_t i,temp=0;
for(i=0;i<8;i++)
{
temp <<= 1;
EEPROM_IIC_SCL_1();
i2c_Delay();
if(EEPROM_IIC_SDA_READ() == 1)
{
temp += 1;
}//如果读取的数为1,就加进去;如果是0,就不操作
EEPROM_IIC_SCL_0();//SCL产生时钟
i2c_Delay();
}
return temp;
}
官方给的延时程序:
static void i2c_Delay(void)//官方给的延时程序
{
uint8_t i;
/*
下面的时间是通过逻辑分析仪测试得到的。
工作条件:CPU主频72MHz ,MDK编译环境,1级优化
循环次数为10时,SCL频率 = 205KHz
循环次数为7时,SCL频率 = 347KHz, SCL高电平时间1.5us,SCL低电平时间2.87us
循环次数为5时,SCL频率 = 421KHz, SCL高电平时间1.25us,SCL低电平时间2.375us
*/
for (i = 0; i < 10; i++);
}
2.配置上层程序(EEPROM为例)
建立一个新的文件夹,存放上层的设备程序。
1-EEPROM检测函数
首先是EEPROM的检测函数,检测其是否存在。注意:如果是读模式下,还要我们自己给EEPROM返回一个非应答信号才能停止。
//返回值为1:连接失败;返回值为0:连接正常
uint8_t EEPROM_check_device(uint8_t addr)//检测EEPROM是否存在
{
uint8_t result;
IIC_Start();//发送起始信号
IIC_Write_byte(addr);//发送 EEPROM 设备地址
/*这个与写入一个字节的程序一样,因为 EEPROM 自己知道
发送完起始信号之后,下一个写入的就是设备地址*/
if(IIC_Wait_Ask())//检测响应信号
{
result = 1;
}
//表示EEPROM不存在,无应答
else
{
result = 0;
} //表示EEPROM存在
IIC_NAsk();//如果是读模式下,还要我们自己给EEPROM返回一个非应答信号才能停止
IIC_Stop();
return result;
}
写完后在主函数内进行判断,把EEPROM的设备地址传输过去,看能否检测得到,检测的结果通过串口返回回来。
2-写字节程序:
如果检测的没有问题,就往EEPROM里面写入一个字节。先发送起始信号,发送EEPROM(写)的设备地址;检查响应信号,如果EEPROM应答,发送要写入的EEPROM的存储单元格地址(这个是根据具体的设备确定,有的设备不需要发送存储单元格地址,只要发送设备地址就可以写数据,但EEPROM要写);
WORD ADDRESS:往EEPROM里面的哪个存储空间写入字节。
DATA:写入的内容
再次检测响应信号,应答之后向EEPROM写入数据。写完之后再次检测响应信号,如果没有检测到响应信号(包括前面),就让他们停止并且返回0【goto 语句】。
由于EEPROM是有内部时序的,我们有时候需要连续调用写函数向EEPROM存储数据,所以我们要等待EEPROM内部时序完成之后再进行操作,所以我们写一个等待函数,放在起始信号之前和终止信号之后。这样就可以完成连续调用。
//返回值为1:等待超时;返回值为0:正常
uint8_t EEPROM_wait(void)//等待EEPROM内部时序完成
{
uint16_t cycle = 0;
while(EEPROM_check_device(EEPROM_ADDR | EEPROM_WRITE_DIR))//EEPROM无应答
{
cycle++;
if(cycle>=10000) return 1;//等待超时
}
return 0;//完成等待
}
//返回值为1:写入正常;返回值为0:写入错误
uint8_t EEPROM_write_byte(uint8_t w_addr,uint8_t data)//往EEPROM里面写入一个字节
{ //w_addr:EEPROM 内存储单元格的地址(0-256),data:写入的数据
if(EEPROM_wait() == 1) goto xiangying_w_fail;//超时
IIC_Start();//发送起始信号
IIC_Write_byte(EEPROM_ADDR | EEPROM_WRITE_DIR);//发送 EEPROM 设备地址
/*这个与写入一个字节的程序一样,因为 EEPROM 自己知道
发送完起始信号之后,下一个写入的就是设备地址*/
if(IIC_Wait_Ask())//检测响应信号
goto xiangying_w_fail;//表示EEPROM不存在,无应答
else
{
IIC_Write_byte(w_addr);//发送要写入的 EEPROM的存储单元格地址
if(IIC_Wait_Ask())//检测响应信号
goto xiangying_w_fail;
else
IIC_Write_byte(data);//向 EEPROM写入数据
if(IIC_Wait_Ask())//检测响应信号
goto xiangying_w_fail;
else {}
}
IIC_Stop();
if(EEPROM_wait() == 1) goto xiangying_w_fail;//超时
return 1;
xiangying_w_fail:
IIC_Stop();
return 0;
}
在写完写字节函数之后,可以在主函数内部加以判断,通过串口返回存储是否正确。
3-读字节程序:
注意的是,第一次发送的设备地址仍然是写模式下的地址;然后检测响应信号,发送要读出的EEPROM的存储单元格地址;再检测响应信号,发送第二次起始信号和第二次设备地址:读方向;再次检测响应信号,读出数据。
等待函数,只放在起始信号之前即可。
//返回值为1:读取正常;返回值为0:读取错误
uint8_t EEPROM_read_byte(uint8_t r_addr,uint8_t *data)//从EEPROM里面读取一个字节
{/*
r_addr:读EEPROM 哪个内存储单元格的地址(0-256),*data:读取到的数据存储到指针里面
data是数值,*data是地址
C语言要把一个函数内的数据传输给外面的变量,必须通过指针进行值传递
*/
if(EEPROM_wait() == 1) goto xiangying_r_fail;//超时
IIC_Start(); //发送第一次起始信号
IIC_Write_byte(EEPROM_ADDR | EEPROM_WRITE_DIR);//发送第一次设备地址:写方向
if(IIC_Wait_Ask())//检测响应信号
goto xiangying_r_fail;//表示EEPROM不存在,无应答
else
{
IIC_Write_byte(r_addr);//发送要读出的 EEPROM的存储单元格地址
if(IIC_Wait_Ask())//检测响应信号
goto xiangying_r_fail;
else
{
IIC_Start(); //发送第二次起始信号
IIC_Write_byte(EEPROM_ADDR | EEPROM_READ_DIR);//发送第二次设备地址:读方向
if(IIC_Wait_Ask())//检测响应信号
goto xiangying_r_fail;
else
{
*data = IIC_Read_byte();
}
}
}
IIC_NAsk();
IIC_Stop();
return 1;
xiangying_r_fail:
IIC_NAsk();
IIC_Stop();
return 0;
}
4-读多个字节程序:
每读一个字节之后,发送一个应答信号;发送完之后,发送一个非应答信号即可。
//返回值为1:读取正常;返回值为0:读取错误
uint8_t EEPROM_read_bytes(uint8_t r_addr,uint8_t *data,uint16_t size)//从EEPROM里面读取n个字节
{/*
r_addr:读EEPROM 哪个内存储单元格的地址(0-256),*data:读取到的数据存储到指针里面
size:要读取多少个字节
data是数值,*data是地址
C语言要把一个函数内的数据传输给外面的变量,必须通过指针进行值传递
*/
if(EEPROM_wait() == 1) goto xiangying_r_fail;//超时
IIC_Start(); //发送第一次起始信号
IIC_Write_byte(EEPROM_ADDR | EEPROM_WRITE_DIR);//发送第一次设备地址:写方向
if(IIC_Wait_Ask())//检测响应信号
goto xiangying_r_fail;//表示EEPROM不存在,无应答
else
{
IIC_Write_byte(r_addr);//发送要读出的 EEPROM的存储单元格地址
if(IIC_Wait_Ask())//检测响应信号
goto xiangying_r_fail;
else
{
IIC_Start(); //发送第二次起始信号
IIC_Write_byte(EEPROM_ADDR | EEPROM_READ_DIR);//发送第二次设备地址:读方向
if(IIC_Wait_Ask())//检测响应信号
goto xiangying_r_fail;
else
{
uint16_t i;
for(i=0;i<size;i++)
{
*data = IIC_Read_byte();
if(i == size - 1) IIC_NAsk();//数据接收够了
else IIC_Ask();
data++;//指针指向下一个数据
}
}
}
}
IIC_Stop();
return 1;
xiangying_r_fail:
IIC_NAsk();
IIC_Stop();
return 0;
}
5-写多个字节程序:
一次性最多写入8个字节,每8个字节为1页,我们可以分页写入。
三个形参:w_addr:EEPROM 内存储单元格的地址(0-256);
data:写入的数据; size:要写入多少个字节
用for循环,采用地址对齐的方法,每8个字节(0-7)一页,在每一页的开头发送起始信号。在发送起始信号之前:每写完一页的结束,先发送一次stop信号,结束前面的写入操作,如果是第一次写入也不影响,因为后面有等待EEPROM内部时序完成的程序。
在第一页或者每写完8个字节都要走一遍发送终止、起始信号发送EEPROM设备地址,发送要写入的EEPROM的存储单元格地址。
全部输入完成之后,再发送总的停止信号,等待EEPROM内部时序完成。
//返回值为1:写入正常;返回值为0:写入错误
uint8_t EEPROM_write_bytes(uint8_t w_addr,uint8_t *data,uint16_t size)//往EEPROM里面写入一个字节
{/*w_addr:EEPROM 内存储单元格的地址(0-256),data:写入的数据
size:要写入多少个字节*/
uint16_t i;
for(i=0;i<size;i++)
{
if(i == 0 || w_addr%8 == 0)//第一次或者每八次,i是从0-7的
//采用地址对齐的方法,每8个字节(0-7)一页,在每一页的开头发送起始信号
{
/*每写完一页的结束,先发送一次stop信号,结束前面的写入操作
如果是第一次写入也不影响,因为后面有等待EEPROM内部时序完成的程序*/
IIC_Stop();
if(EEPROM_wait() == 1) goto xiangying_w_fail;//超时
IIC_Start();//发送起始信号
IIC_Write_byte(EEPROM_ADDR | EEPROM_WRITE_DIR);//发送 EEPROM 设备地址
/*这个与写入一个字节的程序一样,因为 EEPROM 自己知道
发送完起始信号之后,下一个写入的就是设备地址*/
if(IIC_Wait_Ask())//检测响应信号
goto xiangying_w_fail;//表示EEPROM不存在,无应答
else
{
IIC_Write_byte(w_addr);//发送要写入的 EEPROM的存储单元格地址
if(IIC_Wait_Ask())//检测响应信号
goto xiangying_w_fail;
}
}//在第一页或者每写完8个字节都要走一遍上面的程序
IIC_Write_byte(*data);//向 EEPROM写入数据,一个字节一个字节的输入
if(IIC_Wait_Ask())//检测响应信号
goto xiangying_w_fail;
data++;
w_addr++;
}
/*全部输入完成之后*/
IIC_Stop();
if(EEPROM_wait() == 1) goto xiangying_w_fail;//超时 作用:等待EEPROM完成内部时序
return 1;
xiangying_w_fail:
IIC_Stop();
return 0;
}
注意:写入和读出数据传递的形参都是地址(原因后面介绍)
四、问题汇总
1.如何读取、存储16个字节的数?
把数据的指针写入进去,要占两个存储空间(一个存储空间能存8个字节),所以在传递指针的时候,我们要强制转换为8位的指针。读出数据的时候用数组的方式,将两位分开读。
写入:ABCD,输出结果:
r_temp0 = cd,r_temp1 = ab;
输出顺序与输入顺序相反原因:16进制的数一般左边为高位,右边为低位。STM32是小端模式,是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中。AB是高位,在temp1中。
然后我们再将其合并输出,将其转换为16位指针输出
printf("temp = %x",*(uint16_t *)r_temp);
2.为什么传入的数据都是地址
用指针把要写入的数据进行强制转换,一个字节一个字节地去读取数据的原始值(内部操作),然后通过write函数写进去。
读回来的时候也一样,先把原始值从EEPROM中读出来:
使用指针的方式把原始值强制转换为想要的数据类型