IIC通信实验
IIC简介
I²C(Inter-Integrated Circuit)字面上的意思是集成电路之间,它其实是I²C Bus简称,所以中文应该叫集成电路总线,它是一种串行通信总线,使用多主从架构,由飞利浦公司在1980年代为了让主板、嵌入式系统或手机用以连接低速周边设备而发展。I²C的正确读法为“I平方C”("I-squared-C"),而“I二C”("I-two-C")则是另一种错误但被广泛使用的读法。自2006年11月1日起,使用I²C协议已经不需要支付专利费,但制造商仍然需要付费以获取I²C从属设备地址。
为使用串行数据线(SDA)和串行时钟线(SCL)、拥有7bit寻址空间的总线。 总线上有两种类型角色的节点:
- 主节点 - 产生时钟并发起与从节点的通信
- 从节点 - 接收时钟并响应主节点的寻址
该总线是一种多主控总线,即可以在总线上放置任意多主节点。此外,在停止位(STOP)发出后,一个主节点也可以成为从节点,反之亦然。
总线上有四种不同的操作模式,虽然大部分设备只作为一种角色和使用其中两种操作模式:
- 主节点发送 - 主节点发送数据给从节点
- 主节点接收 - 主节点接收从节点数据
- 从节点发送 - 从节点发送数据给主节点
- 从节点接收 - 从节点接收主节点数据
一开始,主节点处于主节点发送模式,发送起始位(START),跟着发送希望与之通信的从节点的7bit位地址,最后再发送一个bit读写位,该数据位表示主节点想要与从节点进行读(1)还是写(0)操作。
如果从节点在总线上,它将以ACK字符比特位应答(低有效)该地址。主节点收到应答后,根据它发送的读写位,处于发送模式或者接收模式,从节点则处于对应的相反模式(接收或发送)。
地址和数据首先发送最高有效位。 起始位在SCL位高时,由SDA上电平从高变低表示;停止位在SCL为高时,由SDA上电平从低变高表示。其他SDA上的电平变化在SCL为低时发生。
如果主节点想要向从节点写数据,它将发送一个字节,然后从节点以ACK位应答,如此重复。此时,主节点处于主节点发送模式,从节点处于从节点接收模式。
如果主节点想要读取从节点数据,它将不断接收从节点发送的一个个字节,在收到每个字节后发送ACK进行应答,除了接收到的最后一个字节。此时,主节点处于主节点接收模式,从节点处于从节点发送模式。
此后,主节点要么发送停止位终止传输,要么发送另一个START比特以发起另一次传输(即“组合消息”)。
拓展
原始的I²C系统是在1980年代所创建的一种简单的内部总线系统,当时主要的用途在于控制由飞利浦所生产的芯片。
- 1992年完成了最初的标准版本发布,新增了传输速率为400 kbit/s的快速模式及长度为10比特的地址模式可容纳最多1008个节点。
- 1998年发布了2.0版,新增了传输速率为3.4Mbit/s的高速模式并为了节省能源而减少了电压及电流的需求。
- 2.1版则在2001年完成,这是一个对2.0版做一些小修正,
- 3.0版于2007年发布。
- 2012年2月13日发布Specification Rev. 新增 5-MHz的超快速模式(UFM)。
- 2012年,第4版增加5 MHz的超快速模式(UFM),使用推挽式逻辑没有上拉电阻新的USDA和USCS线,并增加了制造商指定的ID表。
- 2012年,第5版修正错误。
- 在2014年,第6版纠正了两个图。这是目前最新的标准。
实验
信号类型及实验
I2C总线在传送数据过程中共有三种类型的信号,他们分别是:
开始信号:SCL为高电平时,SDA由高电平向低电平跳变,开始传输数据。
结束信号:SCL为高电平时,SDA由低电平向高电平跳变,结束传输数据。
应答信号:接受数据的IC在接收到8bit数据后,向发送数据的IC发出特定的低电平脉冲,表示已经接受到数据。CPU向受控单元发出一个信号后,等待受控单元发出一个应答信号,CPU接收到应答信号后,根据实际情况作出是否继续传递信号的判断。若未收到应答信号,由判断为受控单元出现故障。
这些信号中,起始信号是必需的,结束信号和应答信号,都可以不要。I2C总线时序如下图:
STM32F767上面板载的EEPROM(电子抹除式可复写只读存储器)芯片型号为24C02。该芯片的总容量为256个字节,该芯片通过I2C总线与外部连接,我们本实验就通过I2C来实现24C02的读写。
目前大部分MCU都带有I2C总线接口,STM32F767不例外。但是,我们这里不使用STM32F767的硬件I2C来读写24C02,而是通过软件模拟。ST为了规避飞利浦I2C的专利问题,将STM32的硬件I2C设计的比较复杂,而且稳定性极差,给开发带来非常多的不便,所以这里我们并不推荐使用,有兴趣的可以下来自己查资料,来研究下STM32F767的硬件I2C。
我们在这里使用了软件来模拟I2C协议,这样做的好处是,同一个代码兼容所有的MCU,任何一个单片机只要有IO口,就可以很快的移植过去,而且不需要特定的IO口,只需要简单的更改IO口的定义,就可以快速使用。而硬件I2C,则换一次MCU,基本上等于重新搞一次I2C驱动,非常之麻烦。
I2C的实验功能简介:开机的时候先检测24C02是否存在,然后在主循环里面检测两个按键,其中1个按键(KEY1)用来执行写入24C02操作,另外一个按键(KEY0)用来执行读出操作,在LCD模块上显示相关信息,同时DS0闪烁,提示程序运行正常。
硬件部分
实验需要用到指示灯DS2,以及按键KEY0,1和LCD显示屏,24C02。
前面的硬件咱们都已经基本介绍过了,这里我们只简单介绍以下24C02与STM32F767的连接,24C02的SCL与SDA分别连接在STM32F767的PH4和PH5上的,连接关系如下图:
软件部分
首先来看I2C的初始化,我们要使用软件来模拟,就要让硬件也做出I2C硬件协议相关的工作,所以我们来操作两个IO口来模拟I2C的SCL和SDA就行了,具体方法如下:
I2C初始化
void I2C_Init(void)
{
GPIO_InitTypeDef I2C_Initure;
__HAL_RCC_GPIOH_CLK_ENABLE(); //使能GPIOH时钟
//PH4,5初始化设置
I2C_Initure.Pin = GPIO_PIN_4 | GPIO_PIN_5;
I2C_Initure.Mode = GPIO_MODE_OUTPUT_PP; //推挽输出
I2C_Initure.Pull = GPIO_PULLUP; //上拉
I2C_Initure.Speed = GPIO_SPEED_FAST; //快速
HAL_GPIO_Init(GPIOH, &IC2_Initure);
I2C_SDA(1); //SDA线拉高
I2C_SCL(1); //SCL线拉高
}
我们在初始化中,将PH4,5两个IO口设置为推挽输出,然后拉上,并设为快速,然后调用HAL_GOIO_Init初始化函数,并且将两条IO先的输出电平先拉高,符合I2C协议的静默状态。至于后两行代码 I2C_SDA(),I2C_SCL
我们在对应的头文件里面用宏函数来定于,具体如下:
#define I2C_SDA(n) (n?HAL_GPIO_WritePin(GPIOH, GPIO_PIN_4, GPIO_PIN_SET):HAL_GPIO_WritePin(GPIOH, GPIO_PIN_4, GPIO_PIN_RESET))
#define I2C_SCL(n) (n?HAL_GPIO_WritePin(GPIOH, GPIO_PIN_5, GPIO_PIN_SET):HAL_GPIO_WritePin(GPIOH, GPIO_PIN_5, GPIO_PIN_RESET))
那么这样SDA,SCL线都已经准备好了,那么要开始发送信号吧,代码如下:
产生I2C起始信号
软件模拟起始信号的代码如下:
void I2C_Strat(void)
{
SDA_OUT(); //SDA线输出
I2C_SDA(1);
I2C_SCL(1);
delay_us(4);
I2C_SDA(0); //在SCL线为高电平时,SDA线拉低为起始信号
delay_us(4);
I2C_SCL(0); //拉低SCL线,准备开始发送或者接收数据
}
其中函数 SDA_OUT()
同样是一个宏函数,定义在头文件中,具体如下:
#define SDA_OUT() {GPIOH->MODER &= ~(0x3 << (10));GPIOH->MODER |= 0x0 << 10;}
通过函数 I2C_Start()
就可以发送一个开始信号,来发送或者接受数据了,本质上来说,就是我们使用了IO操作来模拟了I2C的开始阶段的电压跳变,非常简单。
产生I2C停止信号
有起始后,需要来停止,代码如下:
void I2C_Stop(void)
{
SDA_OUT();
I2C_SCL(0);
I2C_SDA(0);
delay_us(4);
I2C_SCL(1);
I2C_SDA(1);
}
依然遵从I2C的时序图,在停止信号处,先让SDA线输出,然后将SCL和SDA线拉低,待一段时间后,再将SCL和SDA线全部拉高,回到静默状态。
等待应答信号
在起始信号发送了后,需要等待应答,代码如下:
u8 I2C_Wait_Ack(void)
{
u8 ucErrTime = 0;
SDA_IN(); //SDA线切换为输入模式
I2C_SDA(1); delay_us(1);
I2C_SCL(1); delay_us(1);
while(READ_SDA) {
ucErrTime++;
if (ucErrTime > 250) {
IC_Stop();
return 1;
}
}
I2C_SLC(0); //时钟线拉低
return 0;
}
这里用到了两个宏函数,仍然定义在头文件当中,代码如下:
#define SDA_IN() {GPIOH->MODER &= ~(0x3 << 10); GPIOH->MODER |= 0x0 << 10}
#define READ_SDA HAL_GPIO_ReadPin(GPIOH, GPIO_PIN_5) //输入SDA信号
这个函数也很容易理解,参照I2C的时序图,将SDA线设置为了输入模式,并拉高SDA线和SCL线,使用轮询读取PH5的电平值,但SDA线出现低电平,表示应答信号来到,拉低SCL线,return 0,表示接收应答成功。
产生应答信号
在作为接收方时,需要产生应答信号,代码如下:
void I2C_Ack(void)
{
I2C_SCL(0);
SDA_OUT();
I2C_SDA(0);
delay_us(2);
I2C_SCL(1);
delay_us(2);
I2C_SCL(0);
}
这个函数根据I2C的时序图,将应答信号就可以发送出去了,代码很好理解。
不产生应答信号
如果不产生应答信号,代码如下:
void I2C_NAck(void)
{
I2C_SCL(0);
SDA_OUT();
I2C_SDA(1);
delay_us(2);
I2C_SCL(1);
delay_us(2);
I2C_SCL(0);
}
和上边的代码反过来就行了,在SCL线拉低后,SDA继续输出高电平,那么就不会产生应答信号了。
I2C发送一个字节
void I2C_Send_Byte(u8 txd)
{
u8 t;
SDA_OUT();
I2C_SCL(0); //拉低时钟开始数据发送
for(t = 0; t < 8; t++) {
I2C_SDA((txd & 0x80) >> 7);
txd <<= 1;
delay_us(2);
I2C_SCL(1);
delay_us(2);
I2C_SCL(0);
delay_us(2);
}
}
这个函数的设计也是相当的简单了,一个字节是8位,用for循环,每次发送他的第8位,然后整体向左移动一位,每次发送一位后,通过调整SCL线电平来确定时序。
I2C读取一个字节
有了发送,就相应的来接收就行,代码如下:
u8 I2C_Read_Byte(u8 ack)
{
u8 i,receive = 0;
SDA_IN(); //SDA线切换为输入,来接收数据
for(i = 0; i < 8; i++) {
I2C_SCL(0);
delay_us(2);
I2C_SCL(1);
receive <<= 1;
if (READ_SDA) receive++;
delay_us(1);
}
if (!ack) {
I2C_NAck(); //不发送应答信号
} else {
I2C_Ack(); //发送ACK信号
}
return receive;
}
这个函数和发送字节其实没有什么区别,就是反过来读,然后return就行了,区别在于和用参数来确定要不要发送ack应答信号。
I2C的处理函数,就介绍完了,代码非常简单,就是通过IO操作来设置I2C_SDA及SCL。接下来来看下24C02的处理函数。
初始化I2C接口
void 24CXX_Init(void)
{
I2C_Init(); //直接调用I2C初始化就行
}
在24CXX指定地址读取一个数据
读操作的时候,要先确定读的地址,所以:
写模式-->写读的地址-->读模式-->读数据
代码实现如下:
u8 24CXX_ReadOneByte(u16 ReadAdder)
{
u8 temp = 0;
I2C_Start();
I2C_Send_Byte(0xa0 + ((ReadAdder / 256) << 1)); //发送器件地址0xa0,写数据
I2C_Wait_Ack();
I2C_Send_Byte(ReadAdder % 256); //发送低地址
I2C_Wait_Ack();
I2C_Start();
I2C_Send_Byte(0xa1); //进入接收模式
I2C_Wait_Ack();
temp = I2C_Read_Byte(0);
I2C_Stop(); //产生停止信号
return temp;
}
在开始的时候,首先发送起始信号,然后将要读取数据的地址写入,并发送到E2PROM,分两次,首先发送高8位,然后发送低8位,然后等待ack后,恢复到起始状态,进入接收模式,再一个ack后,就可以读取数据。
在24CXX指定地址写一个数据
写操作的时候,同样先确定写的地址,所以要写模式-->写地址-->写数据代码实现如下:
void 24CXX_WriteOneByte(u16 WriteAddr,u8 DataToWrite)
{
I2C_Start();
I2C_Send_Byte(0xa0 + ((WriteAddr / 256) << 1)); //发送器件地址OXA0,写数据
I2C_Wait_Ack();
I2C_Send_Byte(WriteAddr % 256); //发送低地址
I2C_Wait_Ack();
I2C_Send_Byte(DataToWrite);
I2C_Wait_Ack();
I2C_Stop(); //产生停止信号
delay_ms(10);
}
这样单字节的写或者读非常繁琐,那么再给他封装一层,来个多字节读写,代码如下:
void 24CXX_WriteLneByte(u16 WriteAddr, u32 DataToWrite, u8 Len)
{
u8 t;
for(t = 0; t < Len; t++) {
24CXX_WriteOneByte(WriteAddr + t, (DataToWrite >> (8 * t)) & 0xff);
}
}
u32 24CXX_ReadLenByte(u16 ReadAddr, u8 Len)
{
u8 t;
u32 temp = 0;
for (t = 0; t < Len; t++) {
temp <= 8;
temp += 24CXX_ReadOneByte(ReadAddr + Len -t - 1);
}
return temp;
}
这2个函数非常好理解,就是用for循环来调用单字节读写函数即可。
这里最好还需要一个函数来检测24C02的状态,当IC出错时能够反馈错误,代码如下:
u8 24CXX_Check(void)
{
u8 temp;
temp = 24CXX_ReadOneByte(255); //避免每次开机都写24CXX
if (temp == 0x55) return 0;
else { //排除第一次初始化
24CXX_WriteOneByte(255, 0x55);
temp = 24CXX_ReadOneByte(255);
if (temp == 0x55) return 0;
}
return 1;
}
这个函数就是使用24XX的最后一个地址(255)来存储标志字0x55,通过判断0x55来看是不是24C02设备,如果这里使用的其他24C系列,需要更改这个地址。
再定义两个在指定地址读写指定长度的数据的函数,代码如下:
void 24CXX_Read(u16 ReadAddr, u8 *pBuffer, u16 NumToRead)
{
while(NumToRead) {
*pBuffer++ = 24Cxx_ReadOneByte(ReadAddr++);
NumToRead--;
}
}
void 24CXX_Write(u16 WriteAddr, u8 *pBuffer, u16 NumToWrite)
{
while(NumToWrite--) {
24CXX_WriteOneByte(WriteAddr, *pBuffer);
WriteAddr++;
pBuffer++;
}
}
以上的代码基本可以支持24C02了,我们的正点原子的开发板,把24C02地址引脚都设置为0。