1、IIC简介
I2C,即Inter IC Bus。是由Philips公司开发的一种串行通用数据总线,主要用于近距离、低速的芯片之间的通信;有两根通信线:SCL(Serial Clock)用于通信双方时钟的同步、SDA(Serial Data)用于收发数据;具有同步,半双工,带数据应答,支持总线挂载多设备(一主多从、多主多从)等特点。
IIC总线是一种多主机总线,连接在IIC总线上的器件分为主机和从机,主机有权发起和结束一次通信,而从机只能被主机呼叫;当总线上有多个主机同时启用总线时,IIC也具备冲突检测和仲裁的功能来防止错误产生;每个连接到IIC总线上的器件都有一个唯一的地址(一般是7bit),且每个器件都可以作为主机也可以作为从机(同一时刻只能有一个主机),总线上的器件增加和删除不影响其它器件正常工作;IIC总线在通信时,总线上发送数据的器件为发送器,接收数据的器件为接收器。
所有I2C设备的SCL连在一起,SDA连在一起;设备的SCL和SDA均要配置成开漏输出模式;SCL和SDA各添加一个上拉电阻,阻值一般为4.7KΩ左右。
在STM32内部集成了硬件I2C收发电路,可以由硬件自动执行时钟生成、起始终止条件生成、应答位收发、数据收发等功能,减轻CPU的负担;支持多主机模型;支持7位/10位地址模式;支持不同的通讯速度,标准速度(高达100 kHz),快速(高达400 kHz);支持DMA;兼容SMBus协议。
STM32F103C8T6 硬件I2C资源:I2C1、I2C2
对于串口这样的异步时序来说,软件实现非常麻烦,硬件实现非常简单,所以串口的实现基本全都倒向硬件了;而对IIC这样的同步时序来说,软件实现反而简单灵活,硬件实现,相比之下,不能完全让人省心,所以IIC的实现,软件模拟的情况还是比较多的。
考虑到硬件IIC也有很多独有的优势,比如执行效率比较高,可以节省软件资源,功能比较强大,可以实现完整的多主机通信模型,时序波形规整,通信速率快等,所以硬件IIC也是有相应的应用场景的。
2、IIC结构图
以下结构图基于STM32F103xxx
这里的数据收发的核心部分是数据寄存器和数据移位寄存器,当我们需要发送数据时,可以把一个字节的数据写到数据寄存器DR,当移位寄存器没有数据移位时,这个数据寄存器的值就是进一步转到移位寄存器这里,在移位的过程中,我们就可以直接把下一个数据放在数据寄存器里等着了,一旦数据发送完成,下一个数据就可以无缝连接,继续发送。当数据由数据寄存器转到移位寄存器时,就会置状态寄存器的值TXE位为1,表示发送寄存器为空。
在接收时,也是这一路,输入的数据,一位一位的从引脚移入到移位寄存器里,当一个字节的数据收齐之后,数据就整体从移位寄存器转到数据寄存器,同时置标志位RXNE,表示接收寄存器非空,这时就可以把数据从数据寄存器读出来了。
基本框图
3、IIC时序
3.1 IIC时序基本单元
起始条件:SCL高电平期间,SDA从高电平切换到低电平
终止条件:SCL高电平期间,SDA从低电平切换到高电平
发送一个字节:SCL低电平期间,主机将数据位依次放到SDA线上(高位先行),然后释放SCL,从机将在SCL高电平期间读取数据位,所以SCL高电平期间SDA不允许有数据变化,依次循环上述过程8次,即可发送一个字节 。
接收一个字节:SCL低电平期间,从机将数据位依次放到SDA线上(高位先行),然后释放SCL,主机将在SCL高电平期间读取数据位,所以SCL高电平期间SDA不允许有数据变化,依次循环上述过程8次,即可接收一个字节(主机在接收之前,需要释放SDA)
发送应答:主机在接收完一个字节之后,在下一个时钟发送一位数据,数据0表示应答,数据1表示非应答
接收应答:主机在发送完一个字节之后,在下一个时钟接收一位数据,判断从机是否应答,数据0表示应答,数据1表示非应答(主机在接收之前,需要释放SDA)
3.2 IIC时序
3.2.1 指定地址写
对于指定设备(Slave Address),在指定地址(Reg Address)下,写入指定数据(Data)
这里这个指定设备,通过从机地址来确定,这里这个指定地址就是某个设备内部的寄存器地址。
3.2.2 当前地址读
对于指定设备(Slave Address),在当前地址指针指示的地址下,读取从机数据(Data)
在这个时序图中,主机发送第一个字节,指定读之后,第2个字节读写的方向就要反过来了,控制权交给从机,由从机来发送数据,这时主机无法去指定是由从机哪个寄存器发出的数据,那么这里这个当前地址指针指示的地址就很重要了。在从机中,所有的寄存器都被分配到了一个线性区域中,并且会有一个单独的指针变量指示着其中一个寄存器,这个指针上电一般默认0地址,并且每写入和读出一个字节后,这个指针就会自动自增一次,移动到下一个位置,那么在调用当前地址读的时序时,主机没有指定要读哪个地址,从机就会返回当前指针指向的寄存器的值。
3.2.3 指定地址读
对于指定设备(Slave Address),在指定地址(Reg Address)下,读取从机数据(Data)
这里先指定从机地址是1101000,读写标志位是0,代表我要进行写的操作。经常从机应答之后,再发送一个字节,第二个字节用来指定地址,这个数据就写入到了从机的地址指针中了,也就是从机接受到这个数据之后,他的寄存器指针就指向了0x19这个位置,之后再重复一个起始条件,因为指定读写标志位只能是跟着起始条件的第一个字节,如果想要切换读写方向,只能再来个起始条件,然后起始条件后,重新寻址并且指定读写标志位,此时读写标志位是1,代表我要开始读了,这时候接收到的就是0x19下的数据。
写入的地址会存在地址指针里面,所以这个地址并不会因为时序的停止而消失。
4、操作流程
4.1 主机发送
指定地址写:首先初始化之后,总线默认空闲状态,STM32默认是从模式,为了产生一个起始条件,STM32需要写入控制寄存器(这个要看一下手册的寄存器描述),之后STM32由从模式转为主模式,控制完硬件电路之后,要检查标志位,来看看硬件有没有达到我们想要的状态,在这里起始条件之后会发生EV5事件,这个EV5事件就可以把它当成标志位(这里使用EV几事件,而不写具体标志位,是因为有的事件会产生多个标志位,这里的EV几事件就是包含了多个标志位的大标志位,在库函数中也会有对应),检查到起始条件已发送的情况下就可以发送一个字节的从机地址了,从机地址需要写到数据寄存器DR中,写入DR后,硬件电路会把这个字节发送到移位寄存器中,再把这一个字节发送到IIC总线上,之后硬件会自动接收应答并判断,如果没有应答,硬件会置应答失败的标志位,然后这个标志位可以申请中断来提醒我们,在寻址完成之后,会发生EV6事件(代表主模式下地址发送结束),EV6事件结束之后是EV8_1事件(TXE标志位=1,移位寄存器空,数据寄存器空),这时需要我们写入数据寄存器DR进行数据发送了,一旦写入数据寄存器之后,因为移位寄存器也是空,所以DR会立刻转到移位寄存器进行发送,这时就是EV8事件(移位寄存器非空,数据寄存器空),这时就是移位寄存器正在发数据的状态,所以流程这里,数据1的时序就发生了,之后应该是写入了下一个数据,数据2此刻应该被写入到数据寄存器里等着了,然后接收应答位之后,数据2就转入移位寄存器进行发送,此时的状态是移位寄存器非空,数据寄存器空,所以这是EV8事件又发生了,之后重复该过程,一旦我们检测要EEV8事件,就可以写入下一个数据了,最后当我们想要发送的数据写完之后,这时就没有新的数据写入数据寄存器了,当移位寄存器当前的数据移位完成时,此时就是移位寄存器空,数据寄存器也空的状态,这个事件就是这里的EV8_2事件,当检测到EV8_2时,就可以产生终止条件了,产生终止条件在控制寄存器中有相应的位可以控制,到这里,一个完整的时序就发送完成了。
4.2 主机接收
从七位主接收来看,起始,从机地址+读,接收应答,然后就是,接收数据,发送应答,最后一个数据给非应答,之后终止。从这个时序看,这是当前地址读的一个时序。
5、示例代码
5.1 软件读写IIC
#include "stm32f10x.h" // Device header
#include "Delay.h"
//#define SCL_PORT GPIOB
//#define SCL_PIN GPIO_Pin_10
//对端口和引脚的封装,方便后续修改和移植
void MyI2C_W_SCL(uint8_t BitValue)
{
GPIO_WriteBit(GPIOB, GPIO_Pin_10, (BitAction)BitValue);
//I2C时序可以稍微慢一点,但是如果快了,那就要看一下手册对时序时间的要求
Delay_us(10);
}
void MyI2C_W_SDA(uint8_t BitValue)
{
GPIO_WriteBit(GPIOB, GPIO_Pin_11, (BitAction)BitValue);
//I2C时序可以稍微慢一点,但是如果快了,那就要看一下手册对时序时间的要求
Delay_us(10);
}
uint8_t MyI2C_R_SDA(void)
{
uint8_t BitValue;
BitValue = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11);
Delay_us(10);
return BitValue;
}
void MyI2C_Init(void)
{
//软件读取I2C只要gpio的库函数就可以了,I2C的库函数就不用看了
//任务一,将SCL和SDA都初始化为开漏输出模式
//任务二,将SCL和SDA都置高电平
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
//配置端口
//先定义一个结构体变量
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; //开漏输出,开漏输出模式仍然可以输入
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; //速度50MHz
GPIO_Init(GPIOB, &GPIO_InitStructure);
//释放总线,SCL和SDA处于高电平,此时I2C总线处于空闲状态
GPIO_SetBits(GPIOB, GPIO_Pin_10 | GPIO_Pin_11);
}
void MyI2C_Start(void)
{
//根据I2C时序要求,这里兼顾了开始时序和Sr期间时序
MyI2C_W_SCL(1);
MyI2C_W_SDA(1);
MyI2C_W_SDA(0);
MyI2C_W_SCL(0);
}
void MyI2C_Stop(void)
{
MyI2C_W_SDA(0);
MyI2C_W_SCL(1);
MyI2C_W_SDA(1);
}
void MyI2C_SendByte(uint8_t Byte)
{
// MyI2C_W_SDA(Byte & 0x80); //取出数据的最高位,SDA是高位先行
// //释放SCL,读走放在SDA的数据
// MyI2C_W_SCL(1);
// //再拉低SCL,就可以放下一位数据了
// MyI2C_W_SCL(0);
uint8_t i;
for (i = 0; i < 8; i ++)
{
MyI2C_W_SDA(Byte & (0x80 >> i)); //0x80 >> i,表示0x80右移i位
MyI2C_W_SCL(1);
MyI2C_W_SCL(0);
}
}
uint8_t MyI2C_ReceiveByte(void)
{
uint8_t i, Byte = 0x00;
//主机释放SDA,从机把数据放到SDA
MyI2C_W_SDA(1);
for (i = 0; i < 8; i ++)
{
//主机释放SCL,SCL高电平,主机就能读取数据了
MyI2C_W_SCL(1);
if (MyI2C_R_SDA() == 1)
{
Byte |= (0x80 >> i);
}
//再次拉低SCL,这时从机就会把数据放在SDA上
MyI2C_W_SCL(0);
}
return Byte;
}
void MyI2C_SendAck(uint8_t AckBit)
{
//函数进来时,SCL低电平,主机把AckBit放到SDA上
MyI2C_W_SDA(AckBit);
//SCL高电平,从机读取应答
MyI2C_W_SCL(1);
//SCL低电平,进入下一个时序单元
MyI2C_W_SCL(0);
}
uint8_t MyI2C_ReceiveAck(void)
{
uint8_t AckBit;
//函数进来时,SCl低电平
//主机释放SDA,防止从机干扰,同时从机应答位放到SDA
MyI2C_W_SDA(1);
//SCL高电平,主机读取应答位
MyI2C_W_SCL(1);
AckBit = MyI2C_R_SDA();
//SCL低电平,进入下一个时序单元
MyI2C_W_SCL(0);
return AckBit;
}
5.2 硬件读写IIC
//MyI2C_Init();
//用硬件来配置I2C外设,对I2C2外设进行初始化,来替换之前用软件实现的MyI2C_Init();
//第一步,开启I2C外设和对应GPIO口的时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
//第二步,把I2C外设对应的GPIO口初始化为复用开漏模式
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD; //复用开漏输出,开漏是I2C硬件要求,复用就是GPIO的控制权要交给硬件外设
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10 | GPIO_Pin_11;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; //速度50MHz
GPIO_Init(GPIOB, &GPIO_InitStructure);
//第三步,使用结构体,对整个I2C进行配置
I2C_InitTypeDef I2C_InitStructure;
I2C_InitStructure.I2C_Mode = I2C_Mode_I2C; //I2C的模式,这里选择是I2C
I2C_InitStructure.I2C_ClockSpeed = 50000; //配置SCL的时钟频率,数值越大,SCL频率越高,数据传输就越快,这里写的是50kHz
I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2; //时钟占空比,只有在时钟频率大于100kHz,也就是进入到快速状态时才有用,在小雨100kHz的标准速度下,占空比是标准的1:1
I2C_InitStructure.I2C_Ack = I2C_Ack_Enable; //应答位配置,这里给enable,默认是给应答的
I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit; //这里是指定STM32作为从机,可以响应几位的地址,这里选择7位地址
I2C_InitStructure.I2C_OwnAddress1 = 0x00; //自身地址1,这个也是stm32作为从机使用的,用于指定stm32的自身地址,方便别的主机呼叫它,这里暂时不需要做从机被别人使唤,随便给一个,只要不和总线上其它设备的地址重复就可以了
I2C_Init(I2C2, &I2C_InitStructure);
//第四步,I2C_Cmd,使能I2C
I2C_Cmd(I2C2, ENABLE);
//封装指定地址写和指定地址读的时序
//指定地址写寄存器
void MPU6050_WriteReg(uint8_t RegAddress, uint8_t Data)
{
// MyI2C_Start();
// MyI2C_SendByte(MPU6050_ADDRESS); //从机地址+读写位
// MyI2C_ReceiveAck();
// //发送指定寄存器地址
// MyI2C_SendByte(RegAddress);
// MyI2C_ReceiveAck();
// //发送指定要写入指定寄存器地址下的数据
// MyI2C_SendByte(Data);
// MyI2C_ReceiveAck();
// //终止时序
// MyI2C_Stop();
uint32_t Timeout;
//控制外设电路,实现指定地址写的时序,来替换上面的WriteReg
I2C_GenerateSTART(I2C2, ENABLE); //生成起始条件
//对于非阻塞的程序,在函数结束之后,都要等待相应的标志位,来确保这个函数的操作执行到位了
//对照PPT流程图,等待EV5的到来,stm32默认为从机,发送起始条件后变为主机
Timeout = 10000;
while (I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT) != SUCCESS) //监测EV5事件是否发生了
//在程序中如果while死循环等待用多了,一旦总线出问题了,就很容易造成整个程序卡死,还要设计一个超时退出的机制
{
Timeout --;
if (Timeout == 0)
{
break; //使用break跳出这个循环,使用return跳出整个函数
//在实际项目中,如果想让代码更加完善,这里不能只是简单的break了
//这里还应该做一些相应的错误处理操作,比如说打印错误日志、进行系统复位
//或者说,如果项目设计危险的机械结构,就要评估一下,是不是应该进行紧急停机的操作
}
}
// MPU6050_WaitEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT); //这一句就等同与上面的等待事件和超时退出的结合
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter); //发送从机地址,第三个参数是方向,也就是从机地址的最低位,读写位
//在这个库函数中,发送数据都自带了接收应答的过程,同样,接收数据也自带了发送应答的过程,如果应答错误,硬件会通过中断和标志位来提示我们,所以这里发送地址后,应答位就不需要处理了
//等待EV6事件
while (I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED) != SUCCESS);
//写入DR,发送数据
I2C_SendData(I2C2, RegAddress);
//等待EV8事件
while (I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTING) != SUCCESS);
//发送数据
I2C_SendData(I2C2, Data);
//等待事件,这里这个是最后一个字节,要等待EV8_2事件
while (I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED) != SUCCESS);
I2C_GenerateSTOP(I2C2, ENABLE);
}
//指定地址读
uint8_t MPU6050_ReadReg(uint8_t RegAddress)
{
uint8_t Data;
// MyI2C_Start();
// MyI2C_SendByte(MPU6050_ADDRESS); //从机地址+读写位
// MyI2C_ReceiveAck();
// //发送指定寄存器地址
// MyI2C_SendByte(RegAddress);
// MyI2C_ReceiveAck();
//
// //转入读的时序,就必须重新指定读写位,就必须重新起始
// MyI2C_Start();
// MyI2C_SendByte(MPU6050_ADDRESS | 0x01); //原从机地址,读写位为1
// MyI2C_ReceiveAck(); //接收应答后,总线控制权就正式交给从机了
// Data = MyI2C_ReceiveByte();
// //主机接收后,要给从机发送一个应答
// //参数给0,就是给从机应答,给1,就是不给从机应答;想继续读多个字节,那就要给应答,从机收到应答后就会继续发送数据
// MyI2C_SendAck(1);
// MyI2C_Stop();
//控制外设电路,来实现指定地址读的时序,来替换上面的ReadReg
I2C_GenerateSTART(I2C2, ENABLE); //生成起始条件
while (I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT) != SUCCESS); //监测EV5事件是否发生了
//在程序中如果while死循环等待用多了,一旦总线出问题了,就很容易造成整个程序卡死,还要设计一个超时退出的机制
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Transmitter); //发送从机地址,第三个参数是方向,也就是从机地址的最低位,读写位
while (I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED) != SUCCESS);
//写入DR,发送数据
I2C_SendData(I2C2, RegAddress);
while (I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_BYTE_TRANSMITTED) != SUCCESS);
I2C_GenerateSTART(I2C2, ENABLE); //重复生成起始条件
while (I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_MODE_SELECT) != SUCCESS); //监测EV5事件是否发生了
I2C_Send7bitAddress(I2C2, MPU6050_ADDRESS, I2C_Direction_Receiver); //第三个参数改为Receiver之后,函数内部就会自动把MPU6050_ADDRESS这个地址的最低位置1了,就不需要手动来改了
while (I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED) != SUCCESS);
//在接收最后一个字节之前,也就是EV7_1事件那里,需要提前把ACK置0,STOP置1,如果只需要读取一个字节,那在EV6事件之后就要立刻ACK置0,STOP置1,要是设置晚了,时序上就会多一个字节出来
I2C_AcknowledgeConfig(I2C2, DISABLE);
I2C_GenerateSTOP(I2C2, ENABLE);
//等待EV7事件,等EV7事件产生后,一个字节的数据就已经在DR里面了,我们读取DR即可拿出这一个字节
while (I2C_CheckEvent(I2C2, I2C_EVENT_MASTER_BYTE_RECEIVED) != SUCCESS);
Data = I2C_ReceiveData(I2C2);
//ack再次置1,我们的想法是,默认状态下ACK就是1,给从机应答,在接收最后一个字节之前,临时把ACK置0,给非应答。
//所以在接收函数的最后,要回复默认的ACk = 1,这个流程是为了方便指定地址收多个字节
I2C_AcknowledgeConfig(I2C2, ENABLE);
return Data;
}