I2C通信
I2C就是IIC或者I2C,全称Inter-Integrated Circuit,内部集成电路总线,是飞利浦公司在上世纪八十年代初开发的一种同步的串行通信总线。 与SPI相比,它的接线形式更简单,只需要两根信号线就可以实现数据的双向传送。 它被广泛地应用在EEPROM这样的存储设备上,近年来在四轴无人机上也有广泛的应用,比如说MPU6050这样的IMU传感器。
I2C是一种同步的通信机制,两根信号线一根用于时钟信号,另一根则用于数据传送。根据时钟信号的来源,I2C的通信设备分为主从两种模式。 每次通信都是由主设备发起,并产生时钟信号用于主从设备之间的数据同步,在通信结束后,主设备需要停止产生时钟。I2C总线上允许连接多个设备, 与SPI的片选信号不同的是,它采用寻址的方式选定从设备通信。
I2C设备有主从之分,STM32的设计应该可以同时工作在主从两种模式下。本文只阐述主模式下的工作方式和实现,从模式留待以后实现。 网上很多人都说STM32的I2C有点问题,所以都是用GPIO模拟的。我以前也是这样做的,但最近在想,都这么久了意法半导体应该做了一些改进吧。 于是又用硬件的I2C试了一下,发现没什么毛病。
1. 物理层连接和数据层逻辑
如下图1所示,I2C的物理连接只需要SCL和SDA两根信号线。其中SCL为同步时钟信号,由主设备驱动;SDA为数据信号,其信号是双向的。通信时, 由主设备驱动时钟信号,主从设备根据时钟信号和通信内容,控制SDA信号线,同一时间SDA只由一种设备控制。
图1 I2C物理连接 |
主设备通过保持SCL为高电平信号,同时控制SDA信号产生一个下降沿,来产生一个起始信号(START)到总线上,标志着一次通信的开始,如下图2所示。 此时总线处于被占用的状态,当主设备产生一个结束信号(STOP)到总线上时,总线被释放。结束信号则是在保持SCL为高电平信号时,控制SDA产生一个上升沿。 如果主设备在产生结束信号之前,重复发送了多个起始信号,则认为总线一直被占用。
I2C的通信是以字节为单位进行的,先发送高位后发送低位,每一位的数据发送都是在SCL处于高电平信号时进行的。I2C的通信协议中规定, 每个字节传送完毕后,必须由数据的接收方产生一个ACK信号到总线上。所谓的ACK信号,就是接受方保持SDA为低电平信号,直到第9个时钟结束。 I2C并没有限定一次通信的字节数,因此在一个字节发送完毕,产生ACK信号之后,数据的发送方可以继续发送下一个字节的数据, 直到主设备产生停止信号释放总线,才标志着一次通信的结束。
图2 I2C传送一个字节的时序图 |
如果从设备忙,或者接收方不能及时响应,它们可以把SCL信号线拉低,强制主设备进入等待状态。 这样设计挺好,可以支持流控制,当从设备或者接收方空闲后,就可以释放SCL信号线。但实际的实现有可能导致总线死锁,进而出现各种各样莫名的问题。 貌似,一直以来被人们诟病的STM32的I2C实现就是这个毛病(具体什么原因,没有仔细研究过),所以很多人都是通过GPIO来用软件实现的I2C通信时序。 现在STM32好像修复了这个BUG,直接用硬件实现也没什么毛病。
I2C总线支持同时连接多个设备,具体与哪个设备通信,由通信地址决定。下图3中描述了一次I2C通信的过程,主设备产生开始信号之后, 将发送一个7位的地址(ADDRESS)和一位读写(R/W)信号到SDA信号线上。每个I2C设备都有一个地址,当监听到总线上的地址与自己本地的地址匹配时, 就建立起了连接。以后都是与这一从设备进行的通信。读写信号标识了本次通信是向目标设备中写入数据,还是从中读取数据。 低电平为写信号,高电平为读信号。
图3 I2C通信过程 |
一般情况下,在地址和读写信号之后,还应当在发送一个字节,标志着对从设备具体哪个寄存器上的数据进行的操作,再然后才是具体的数据。 这里关于寄存器需要做些解释。在MPU6050这样的设备是有寄存器的概念的,不同的寄存器有不同的意义。EEPROM虽然没有明确的寄存器概念, 但我们也是通过寻址的方式,指定数据的存储位置的。有时我们可能会自己实现一个I2C设备,可能压根儿不存在寄存器的概念, 我们只要保证数据的发送和接收满足上面所讲的协议就好了,不一定非要整个寄存器的机制出来。只是这样一来,我们就必须特别的做出一些约定。
2. STM32的I2C
如左图所示,为STM32的I2C外设模块结构图。它对外有三个引脚SCL、SDA和SMBA,其中SCL和SDA分别是I2C总线的时钟和数据信号线, SMBA则是一种基于I2C总线协议的SMBus(System Management Bus)的Alert引脚。这里我们不研究SMBus是个什么鬼。
有两个控制寄存器CR1和CR2用于控制逻辑,通过它们可以触发起始和停止信号,做出ACK响应,配置外设时钟频率,开启DMA和中断的功能。 同时控制逻辑的状态会反馈到SR1和SR2两个状态寄存器上,根据它们可以知道当前总线是否被占用,本机是主设备还是从设备, 数据是否发送完毕等。
STM32专门提供了一个时钟控制器(Clock Control)用于驱动同步时钟信号线SCL。通过配置CCR寄存器,我们可以调整SCL的频率。 还有一个与SCL有关的寄存器TRISE,它并没有体现在这个结构图中。它定义了通信过程中时钟信号上升沿的最大时间。
数据的收发主要涉及到数据寄存器(Data Register, DR)和数据移位寄存器(Data Shift Register, DSR),其中DSR是不能够直接访问的。
当我们需要发送数据时,把要发送的字节写入DR寄存器。硬件会判定DSR寄存器是否为空,把DR中的字节搬到DSR中。 然后在时钟信号的控制下,把DSR最高位的数据放到数据线SDA上,并对DSR进行移位操作。当8位数据通过移位操作发送完毕之后, 如果通信没有结束,将再次从DR中搬一个字节到DSR中,继续发送数据。
当设备工作在接收机状态下时,数据控制器(Data Control)根据时钟信号,把SDA线上的高低电平转换为‘1’‘0’数据,写到DSR的最低位。 同时DSR左移位,当接收完一个字节的8位数据后,把DSR中的数据搬到DR寄存器中。我们再从DR寄存器中把数据读出来, 模块会产生一个ACK信号到总线上。
在STM32中,I2C模块可以工作主从两种模式下。模块有两个地址寄存器,通过与DSR寄存器中接收的数据对比,来监听总线上的地址信号。 如果能够匹配上,则建立起一个连接与主设备进行通信。
3. I2C通信的实现
3.1 初始化过程
在使用I2C外设之前,需要先对它进行初始化操作。主要涉及三个方面的工作:(1)打开外设相关的时钟, (2)对外设涉及的引脚进行配置, (3)修改外设的寄存器配置外设的工作方式。整个过程如函数i2c1_init()的实现:
|
|
我们在函数i2c_init()中对外设I2C1进行初始化配置。首先在3~6行重置外设,使得外设的各种控制器恢复到初始状态,放置异常的状态导致总线死锁。 接着我们在第8行,设置CR2寄存器设定I2C的工作频率为42MHz。然后在10~13行设置TRISE和CCR寄存器,配置同步时钟信号线的频率和上升沿时间, 因为对这两寄存器的改写必须保证外设关闭,所以在10行中先关闭外设,再在第13行中打开。我们配置CR1寄存器,使得外设在接收到数据后能够返回一个ACK信号。 最后在第16行,我们随意指定了本地的地址为0xC0。
关于初始化过程,我们需要强调的一个地方是,第12行对CCR的配置。在STM32中,I2C有标准和快速两种工作方式,标准模式总线上的时钟频率小于100KHz, 快速的的为400KHz,这点通过配置CCR寄存器来实现。CCR寄存器中有三个字段F/S、DUTY和CCR。F/S设定了外设的工作模式,对应CCR寄存器的第16位,为1表示快速模式。 DUTY则定义了时钟信号的占空比,所谓的占空比就是指一个时钟周期中高电平的时间比例,这里的配置是低电平的时间时高电平时间的两倍。 CCR则定义了SCL的频率,具体计算公式参考手册,这里不再细讲。
3.2 读写操作
下面的函数i2c_read_bytes()实现了从外设中读取一段数据的操作。它由5个参数,i2c是一个指向外设寄存器的指针,可以的取值为I2C1,I2C2和I2C3。 addr则是目标从设备的地址。reg描述了访问外设的寄存器地址,len则描述了欲读取的字节数,buf则指向了一篇缓存用于存放读取的数据。
函数的工作流程在程序中的注释已经写得比较清楚了,这里就不在赘述。
void i2c_read_bytes(i2c_regs_t *i2c, uint8 addr, uint8 reg, uint8 len, uint8 *buf) {
// 总线空闲
while (1 == i2c->SR2.bits.BUSY);
// 产生START,进入Master模式
i2c->CR1.bits.START = 1;
while (TRUE != i2c_check_status(i2c, I2C_STA_BUSY_MSL_SB));
// 发送7位地址, 发送模式
i2c->DR = (addr << 1) | I2C_DIRECTION_TX;
while (TRUE != i2c_check_status(i2c, I2C_STA_BUSY_MSL_ADDR_TXE_TRA));
// 发送寄存器地址
i2c->DR = reg;
while (TRUE != i2c_check_status(i2c, I2C_STA_BUSY_MSL_TXE_TRA_BTF));
// 重新产生起始位, 进入Master Receiver模式
i2c->CR1.bits.START = 1;
while (TRUE != i2c_check_status(i2c, I2C_STA_BUSY_MSL_SB));
i2c->DR = (addr << 1) | I2C_DIRECTION_RX;
while (TRUE != i2c_check_status(i2c, I2C_STA_BUSY_MSL_ADDR));
// 依次接收字节,每次接收都需要返回一个ACK
i2c->CR1.bits.ACK = 1;
while (len > 1) {
while (TRUE != i2c_check_status(i2c, I2C_STA_BUSY_MSL_RXNE));
buf[0] = i2c->DR;
buf++; len--;
}
// 读最后一个字节,所以返回NACK
i2c->CR1.bits.ACK = 0;
i2c->CR1.bits.STOP = 1;
while (TRUE != i2c_check_status(i2c, I2C_STA_BUSY_MSL_RXNE));
buf[0] = i2c->DR;
i2c->CR1.bits.ACK = 1;
}
类似的,我们定义了函数i2c_write_bytes()用于向外设发送一堆数据。它也有5个参数,定义与读操作的参数一样, 只是最后一个参数buf指引的是要发送数据的缓存。因为不希望在函数中对原来的数据做出修改,所以加上了const修饰符。
void i2c_write_bytes(i2c_regs_t *i2c, uint8 addr, uint8 reg, uint8 len, const uint8 *buf) {
// 总线空闲
while (1 == i2c->SR2.bits.BUSY);
// 产生START,进入Master模式
i2c->CR1.bits.START = 1;
while (TRUE != i2c_check_status(i2c, I2C_STA_BUSY_MSL_SB));
// 发送7位地址, 发送模式
i2c->DR = (addr << 1) | I2C_DIRECTION_TX;
while (TRUE != i2c_check_status(i2c, I2C_STA_BUSY_MSL_ADDR_TXE_TRA));
// 发送寄存器地址
i2c->DR = reg;
while (TRUE != i2c_check_status(i2c, I2C_STA_BUSY_MSL_TXE_TRA_BTF));
// 发送数据
while (len--) {
i2c->DR = buf[0];
while (TRUE != i2c_check_status(i2c, I2C_STA_BUSY_MSL_TXE_TRA_BTF));
buf++;
}
i2c->CR1.bits.STOP = 1;
}
另外,为了使用方便,我们还定义了i2c_read_byte()和i2c_write_byte()两个函数用于读取和发送一个字节的操作。 这两个函数都只是对上述两个函数的一个封装而已。
|
|
3.3 硬件I2C驱动MPU6050
MPU6050是一个六轴的IMU传感器,集成了陀螺仪和加速度计,在无人机等智能设备中广泛的应用。这里简单的介绍通过I2C从MPU6050中读取数据, 详细的传感器使用参见一个四旋翼的飞控系统系列文章。
STM32F407的引脚PB8和PB9分别对应了I2C1的SCL和SDA,在探索者的开发板上它们挂载了一个MPU6050和一个24C02的EEPROM。
在main函数中,我们先调用i2c1_init()对I2C1的外设进行初始化,然后就可以通过函数i2c_read_byte()从传感器中读取数据。 这里访问的传感器的0x75的寄存器,它描述了传感器的设备ID。
uint8 gtestdata;
int main(void) {
usart1_init(115200);
i2c1_init();
config_interruts();
Delay(168000000);
uint8 id = i2c_read_byte(I2C1, 0x68, 0x75);
usart1_send_bytes((uint8*)&id, 1);
while (1) { }
}
我们可以看到在系统复位一段时间后,就可以通过串口发送一个值为0x68的字节,它正是mpu6050的I2C地址。 详细参见源码。
4. 总结
STM32的I2C可以工作在主从两种模式下,具体由发送起始信号还是接收到匹配地址来决定。虽然人们一直在说STM32的I2C实现有些问题, 但经过我的测试发现,意法半导体好像现在已经给解决了,硬件实现也没什么毛病。