11 IIC通讯协议
前言
IIC协议在前面03 OLED显示屏中初略的介绍了一下IIC协议,并且使用软件模拟IIC协议来和OLED显示屏进行通讯,但是之前的那一章主要是介绍如何写入数据到从设备中,没有介绍如何从从设备那接收发送过来的数据,并且还有硬件实现IIC也没有说,这一节就讲读取和硬件IIC来统一说明。
一、IIC介绍
IIC、SPI、USART等都属于通讯协议,是由飞利浦公司开发的一款同步半双工、一主一从、一主多从或多主多从的通讯协议,它有两条线组成,一根是SCL时钟线,另一根是SDA数据线。
SCL主要是提供一个时钟,只能由主设备来控制,主设备和从设备根据这一根时钟线来进行同步的数据传输。
1.IIC的时序
这里介绍一下IIC的时序,在前面讲OLED显示屏的时候是讲过该IIC的时序的,这里再介绍一下IIC的时序。
首先是开始和结束时序:
在IIC中,要开始进行IIC协议的通讯前需要发送一个开始信号,这个开始信号在START
框中,当开始信号进行后就可以开始传送数据了,当传输完成后就需要发送一个结束信号来结束当前的IIC协议,这个信号在STOP
中。
然后是发送数据时序:
可以看到,当SCL时钟为低电平的时候SDA的数据开始切换,当SCL上升沿的时候稳定,并将数据发送出去。
之后是读取数据时序:
和发送数据时一样,只不过就是在高电平时不是发送数据了,而是读取数据,SCL在低电平时,从机改变SDA的电平,当SCL上升沿时SDA稳定然后开始读取从机的数据。
最后就是读取ACK和发送ACK的时序:
这个就是读取和发送ack的时序,其实就是上面的读取和发送时序,很简单的。
2.使用IIC对从机寄存器的写操作流程
这个操作流程是:
- 发送IIC开始信号
- 发送从机写地址
- 接收从机发送的ack信号
- 发送需要操作的寄存器地址
- 接收从机发送的ack信号
- 发送需要写入的数据
- 接收从机发送的ack信号
- 发送IIC结束信号
这样就是一个发送数据到从机的过程。
3.使用IIC对从机寄存器的读操作流程
这个操作流程是:
- 发送IIC开始信号
- 发送从机写地址
- 接收从机发送的ack信号
- 发送需要读的寄存器地址
- 接收从机发送的ack信号
- 再次发送IIC开始信号
- 发送从机读地址
- 读取SDA线上的数据
- 发送NACK结束读取
- 发送结束信号
这样就完成了主机读取从机的数据操作。
需要注意这里接收完成后发送NACK是接收一个字节的数据才发送,当发送完NACK给从机后,从机会将SDA信号线拉高,也就是将SDA的控制权交给主机。
如果从机的这个寄存器中的数据有多个字节,那需要在第一次接收8位数据后发送一个ACK响应,当从机接收到后会继续发送数据。
二、软件实现IIC协议
1.GPIO口配置
这里用软件实现IIC协议推荐使用开漏输出,然后从机的引脚需要接上一个上拉电阻,为了防止在一条IIC总线上大家都进行推挽输出导致电流的灌制。
配置的代码如下:
GPIO_InitTypeDef GPIO_InitStruct = {0};
RCC_APB2PeriphClockCmd(MPU6050_GPIO_RCC, ENABLE);
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_OD;
GPIO_InitStruct.GPIO_Pin = MPU6050_IIC_SCL | MPU6050_IIC_SDA;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(MPU6050_GPIO, &GPIO_InitStruct);
这里直接用我写好的代码了,我把IIC引脚全部使用宏定义替换了,这样后面移植的时候好进行更改。
2.IIC开始信号
这里可以看一下上面的图:
在开始时,SDA是高电平,然后被拉低,而SCL是高电平,当SDA拉低后SCL才被拉低,所以代码可以这样写:
IIC_MPU6050_SCL_Set(1);
IIC_MPU6050_SDA_Set(1);
IIC_MPU6050_SDA_Set(0);
IIC_MPU6050_SCL_Set(0);
这里我做了一个封装,把SDA和SCL的操作封装到一个函数中,其实本质上的操作就是GPIO_WriteBit()
函数进行操作,这里就懒一下了。
3.IIC结束信号
这里就需要写一下结束信号,参考上面的图,可以看到SDA信号线一开始是低电平,然后被拉为高电平,而SCL信号线一直都是高电平,所以代码就可以这样写,先让SDA为低电平,SCL为高电平,最后拉高SDA即可:
IIC_MPU6050_SDA_Set(0);
IIC_MPU6050_SCL_Set(1);
IIC_MPU6050_SDA_Set(1);
这样就完成了结束信号的编写。
4.发送数据
这里可以看一下上面的时序:
这里是在SCL为低电平的时候更改SDA的电平,当SCL被拉高后就会将SDA上的数据发送到从机中,所以这里的代码可以这样写,先将数据给SDA,然后拉高SCL信号线,这样就会将数据发送给从机了,然后再把SCL信号线拉低更改SDA上的数据。
也可以先拉低SCL的电平,但因为前面在开始信号的时候SCL的电平就是为低电平了,所以一开始可以不用将SCL拉低:
uint8_t i;
for (i = 0; i < 8; i++)
{
IIC_MPU6050_SDA_Set(bit & (0x80 >> i));
IIC_MPU6050_SCL_Set(1);
IIC_MPU6050_SCL_Set(0);
}
其中
IIC_MPU6050_SDA_Set(bit & (0x80 >> i));
IIC_MPU6050_SCL_Set(1);
IIC_MPU6050_SCL_Set(0);
是发送一位数据的操作,重复8次就是发送一个字节的数据了。
5.接收数据
这里看一下时序:
可以看到,也是在SCL为高电平的时候读出数据,所以代码可以这样写,先将SDA信号线释放,直接给高电平即可,然后当SCL拉高后读取SDA信号线上的数据,再将SDA拉低为下一次读取做准备:
uint8_t i, bit = 0;
IIC_MPU6050_SDA_Set(1); // 释放SDA信号线
for (i = 0; i < 8; i++)
{
bit <<= 1;
IIC_MPU6050_SCL_Set(1);
bit |= IIC_MPU6050_SDA_Read();
IIC_MPU6050_SCL_Set(0);
}
6.接收ACK响应
这里就不多说了,直接就使用接收数据的那个逻辑即可,这里是一条数据,所以只需要执行一次即可,就不用使用循环了:
uint8_t ack = 0;
IIC_MPU6050_SDA_Set(1); // 释放SDA信号线
IIC_MPU6050_SCL_Set(1);
ack = IIC_MPU6050_SDA_Read();
IIC_MPU6050_SCL_Set(0);
7.发送ACK和NACK响应
这个东西很简单,其实就是发送一个位即可,主要是ACK和NACK是怎么发送而已。
在前面接收从机发送的ACK信号可以判断是否接收的为0,如果为0就是接收成功了,所以我们知道了,ACK信号其实就是0,而NACK就是ACK信号的取反,也就是1。
这样不就清楚了吧,当发送0时就是发送ACK,当发送1时就是发送NACK。
发送ACK代码如下:
IIC_MPU6050_SDA_Set(0);
IIC_MPU6050_SCL_Set(1);
IIC_MPU6050_SCL_Set(0);
发送NACK代码如下:
IIC_MPU6050_SDA_Set(1);
IIC_MPU6050_SCL_Set(1);
IIC_MPU6050_SCL_Set(0);
代码一样,也就是发送的SDA不一样而已。
IIC协议的基础代码就写完了,后面就开始使用写好的这些封装来进行寄存器的操作了。
8.对寄存器进行写处理
这里可以看一下上面写的那个写操作:
- 发送IIC开始信号
- 发送从机写地址
- 接收从机发送的ack信号
- 发送需要操作的寄存器地址
- 接收从机发送的ack信号
- 发送需要写入的数据
- 接收从机发送的ack信号
- 发送IIC结束信号
现在来把它转换为代码的写法:
void MPU6050_WriteByte(uint8_t cmd, uint8_t date)
{
// 开始IIC协议
IIC_MPU6050_Start();
// 写入从机的写地址
IIC_MPU6050_WritBit(MPU6050_ADDR);
// 等待从机发送的ACK
IIC_MPU6050_ReadACK();
// 写需要写入的从机寄存器地址
IIC_MPU6050_WritBit(cmd);
// 等待从机发送的ACK
IIC_MPU6050_ReadACK();
// 向从机寄存器地址中写入数据
IIC_MPU6050_WritBit(date);
// 等待从机发送的ACK
IIC_MPU6050_ReadACK();
// 结束IIC协议
IIC_MPU6050_Stop();
}
这样就可以对从机的寄存器中写入一些数据了。
9.对寄存器进行读处理
这里还是看前面说的那个过程:
- 发送IIC开始信号
- 发送从机写地址
- 接收从机发送的ack信号
- 发送需要读的寄存器地址
- 接收从机发送的ack信号
- 再次发送IIC开始信号
- 发送从机读地址
- 读取SDA线上的数据
- 发送NACK结束读取
- 发送结束信号
现在来进行实现:
uint8_t MPU6050_ReadByte(uint8_t cmd)
{
uint8_t byte = 0;
IIC_MPU6050_Start(); // 开启IIC
IIC_MPU6050_WritBit(MPU6050_ADDR); // 写入从机的写地址
IIC_MPU6050_ReadACK();
IIC_MPU6050_WritBit(cmd); // 写入需要读取的寄存器地址
IIC_MPU6050_ReadACK();
IIC_MPU6050_Start(); // 再次开启IIC协议
IIC_MPU6050_WritBit(MPU6050_ADDR | 0x01); // 写入从机读地址
IIC_MPU6050_ReadACK();
byte = IIC_MPU6050_ReadBit(); // 接收读取的数据
IIC_MPU6050_SendNACK(); // 发送NACK结束接收
IIC_MPU6050_Stop(); // 结束IIC协议
return byte;
}
这样模拟的IIC协议就写完了,大家可以使用MPU6050陀螺仪模块来进行测试,但我这手表上的陀螺仪忘记画上拉电阻了,导致读取不了数据,所以就不展示了。
三、硬件实现IIC协议
使用硬件可以节约CPU的使用率,让其可以只控制IIC控制器完成IIC协议的通讯。
当时在stm32f103中只有2个IIC通讯引脚,硬件资源还是蛮少的,但是IIC可以一主多从,其实也是够用的。
这里主要介绍一下硬件IIC产生的事件,使用硬件IIC会根据不同的时段产生一些信号,我们可以通过判断这些信号来进行相应的处理。
上图的硬件IIC主机发送数据的序列图,可以看到有两个,这个不用了解,一般的IIC通讯的设备都是7位地址的,我没见过10位的,所以这里只讲7位的。
当发送IIC开始信号后会产生一个EV5
的事件,当发送完地址和响应后会产生一个EV6
和EV8_1
的事件,这里只需要判断一下EV6
即可,而EV8_1
不需要进行判断,因为在刚开始肯定都是空的。
然后就开始发送数据到移位寄存器中,这个时候会产生一个信号EV8
,当数据从移位寄存器中到数据寄存器中后这个事件就会清除。
最后不想发送数据后,就可以等待EV8_2
,当移位寄存器和数据寄存器都清空了就可以发送停止位了。
发送的还是比较简单,接下来看一下接收的:
可以看到一下子复杂起来了,这里还是看7位的,这里是直接列出二次开启后的事件产生了,第二次产生也是产生一个EV5
事件,然后接收从机读地址,还是一个EV6
信号,然后到DR数据寄存器中读取数据后就会讲EV7
进行清空。
这里需要注意一下,如果只读一个字节数据,那这里需要先进行结束后再去读取DR寄存器中的值,因为在接收一次后会存放到DR寄存器中,然后继续读,所以要先发送停止后再读取并发送NACK信号。
这样就能明白其中的意义了,就可以来写代码了。
1.初始化GPIO口
这里需要注意一下,因为硬件IIC是GPIO口的一个复用功能,所以要使用复用开漏输出,我在写的时候就遇到这个问题,一直读取不了数据,并且开启AFIO
的时钟,代码如下:
GPIO_InitTypeDef GPIO_InitStruct = {0};
RCC_APB2PeriphClockCmd(MPU6050_GPIO_RCC, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_OD;
GPIO_InitStruct.GPIO_Pin = MPU6050_IIC_SCL | MPU6050_IIC_SDA;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(MPU6050_GPIO, &GPIO_InitStruct);
这样就配置好GPIO口了。
2.配置IIC的时钟
使用一个外设最重要的就是开启对应的时钟,IIC的时钟是在APB1总线下的,所以要通过APB1来进行开启:
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE);
3.配置IIC
这里也是使用结构体的方式进行IIC的配置,使用的结构体如下:
typedef struct
{
uint32_t I2C_ClockSpeed; /*!< 指定时钟频率。
此参数必须设置为低于 400kHz 的值 */
uint16_t I2C_Mode; /*!< 指定 I2C 模式。
此参数的值可以是 @ref I2C_mode */
uint16_t I2C_DutyCycle; /*!< 指定 I2C 快速模式占空比。
此参数的值可以是 @ref I2C_duty_cycle_in_fast_mode */
uint16_t I2C_OwnAddress1; /*!< 指定第一个设备自己的地址。
此参数可以是 7 位或 10 位地址。 */
uint16_t I2C_Ack; /*!< 启用或禁用确认。
此参数的值可以是 @ref I2C_acknowledgement */
uint16_t I2C_AcknowledgedAddress; /*!< 指定是确认 7 位地址还是 10 位地址。
此参数的值可以是 @ref I2C_acknowledged_address */
}I2C_InitTypeDef;
这里不一个一个解释了,直接上配置代码:
I2C_InitTypeDef I2C_InitStruct = {0};
I2C_InitStruct.I2C_Ack = ENABLE; // 使能ACK响应
I2C_InitStruct.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit; // 设置地址为7位地址
I2C_InitStruct.I2C_ClockSpeed = 50000; // 时钟速率
I2C_InitStruct.I2C_DutyCycle = I2C_DutyCycle_2; // 快速模式的占空比
I2C_InitStruct.I2C_Mode = I2C_Mode_I2C; // I2C模式
I2C_InitStruct.I2C_OwnAddress1 = 0x00; // 自身地址
I2C_Init(I2C2, &I2C_InitStruct); // 第一个是选择使用的IIC编号
可以看到上面的这个注释来进行了解,其中IIC的速率可以去看对应从机能接收的最大速率来进行配置,然后占空比其实速率不高用1:2即可,如果速度高就用6:9。
4.对寄存器进行写处理
这里不需要再自己写开始信号和结束信号,这里直接用库函数即可。
首先开始信号使用的是I2C_GenerateSTART()
,第一个参数是IIC的编号,第二个填使能或者失能,如果要发送开始信号就填写使能。
发送完成后用I2C_CheckEvent()
函数判断一下EV5
信号是否产生,EV5
信号的宏定义是I2C_EVENT_MASTER_MODE_SELECT
,然后产生会返回SUCCESS
,否则返回ERROR
。
然后开始发送从机地址,这里使用I2C_Send7bitAddress()
函数进行发送,第一个参数是IIC的编号,第二个参数是填写从机地址,从机地址可以在数据手册中寻找,第三个参数是填写读还是写操作。
发送完成后用I2C_CheckEvent()
函数判断一下EV6
信号是否产生,这里就需要注意一下了,它有两个EV6
信号,分别是:I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED
和I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED
。
第一个是发送,也就是写操作的时候产生的信号,第二个是接收,也就是读操作的时候产生的信号,这里需要看你在I2C_Send7bitAddress()
函数的第三个参数填写的是什么,如果是写,那就用第一个,如果是读就用第二个。
然后就是写数据,这里使用I2C_SendData
函数进行写数据,第一个参数也是IIC的编号,第二个是要写入的数据。
最后使用I2C_GenerateSTOP()
发送结束信号,参数和I2C_GenerateSTART()
一致。
所以这里的完整代码如下:
I2C_GenerateSTART(I2C2, ENABLE);
while (I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT) == ERROR); // 判断是否接收到EV5信号
I2C_Send7bitAddress(I2C2, MPU6050_ADDR, I2C_Direction_Transmitter);
while (I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED) == ERROR); // 判断是否接收到EV6信号的写信号
I2C_SendData(I2C2, addr);
while (I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTING) == ERROR); // 判断是否接收到EV8,后面还需要传输一个字节数据
I2C_SendData(I2C2, dat);
while (I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED) == ERROR); // 判断是否接收到EV8_2,
I2C_GenerateSTOP(I2C2, ENABLE);
5.对寄存器进行读处理
主要注重于读操作即可,前面和二次的都一样,在发送地址这就有问题了,这里要的是读信号,所以得判断是否产生I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED
。
然后使用I2C_ReceiveData()
函数进行读取,但在读取前需要用I2C_AcknowledgeConfig()
函数不发送ACK,然后发送结束信号,发送完结束信号后用读取函数进行读取,最后再用I2C_AcknowledgeConfig()
函数发送ACK信号。
这里我觉得麻烦,所以把I2C_CheckEvent()
给封装了一下,代码如下:
uint8_t byte;
I2C_GenerateSTART(I2C2, ENABLE);
MPU6050_WaitEvent(I2C_EVENT_MASTER_MODE_SELECT); // 检测是否产生EV5事件
I2C_Send7bitAddress(I2C2, MPU6050_ADDR, I2C_Direction_Transmitter);
MPU6050_WaitEvent(I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED); // 检测是否产生EV6事件
I2C_SendData(I2C2, addr);
MPU6050_WaitEvent(I2C_EVENT_MASTER_BYTE_TRANSMITTING); // 检测是否产生EV8事件
I2C_GenerateSTART(I2C2, ENABLE);
MPU6050_WaitEvent(I2C_EVENT_MASTER_MODE_SELECT); // 检测是否产生EV5事件
I2C_Send7bitAddress(I2C2, MPU6050_ADDR, I2C_Direction_Receiver);
MPU6050_WaitEvent(I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED); // 检测是否产生EV6事件
I2C_AcknowledgeConfig(I2C2, DISABLE); // 不发送ACK
I2C_GenerateSTOP(I2C2, ENABLE); // 发送停止信号
MPU6050_WaitEvent(I2C_EVENT_MASTER_BYTE_RECEIVED); // 检测是否产生EV7事件
byte = I2C_ReceiveData(I2C2); // 读取数据
I2C_AcknowledgeConfig(I2C2, ENABLE); // 发送ACK
总结
IIC协议其实了解了它的时序图后会发现,其实IIC是非常的简单的,其实就做个项目就可以明白了的,这一章只讲其中驱动的使用,后面我会单独出一个专题来介绍一些大型项目的实现。