一文搞懂I2C通信协议:原理与STM32实现详解
一文搞懂I2C通信协议:原理与STM32实现详解
**I2C(Inter-Integrated Circuit)是一种同步串行通信总线,由飞利浦公司在上世纪八十年代初开发。与SPI相比,I2C只需要两根信号线(SCL和SDA)就可以实现数据的双向传送,因此接线形式更简单。I2C被广泛应用于EEPROM存储设备、四轴无人机的IMU传感器(如MPU6050)等场景。本文将详细介绍I2C通信协议的工作原理,并以STM32微控制器为例,讲解其具体实现。
1. I2C通信协议基础
I2C是一种同步通信机制,两根信号线一根用于时钟信号(SCL),另一根则用于数据传送(SDA)。根据时钟信号的来源,I2C的通信设备分为主从两种模式。每次通信都是由主设备发起,并产生时钟信号用于主从设备之间的数据同步。在通信结束后,主设备需要停止产生时钟。
I2C总线上允许连接多个设备,采用寻址的方式选定从设备通信。主设备通过保持SCL为高电平信号,同时控制SDA信号产生一个下降沿,来产生一个起始信号(START)到总线上,标志着一次通信的开始。结束信号则是在保持SCL为高电平信号时,控制SDA产生一个上升沿。
I2C的通信是以字节为单位进行的,先发送高位后发送低位,每一位的数据发送都是在SCL处于高电平信号时进行的。每个字节传送完毕后,必须由数据的接收方产生一个ACK信号到总线上。如果从设备忙,或者接收方不能及时响应,它们可以把SCL信号线拉低,强制主设备进入等待状态。
2. STM32的I2C外设模块
STM32的I2C外设模块结构如图所示,对外有三个引脚SCL、SDA和SMBA。有两个控制寄存器CR1和CR2用于控制逻辑,通过它们可以触发起始和停止信号,做出ACK响应,配置外设时钟频率,开启DMA和中断的功能。同时控制逻辑的状态会反馈到SR1和SR2两个状态寄存器上。
STM32专门提供了一个时钟控制器用于驱动同步时钟信号线SCL。通过配置CCR寄存器,可以调整SCL的频率。还有一个与SCL有关的寄存器TRISE,它定义了通信过程中时钟信号上升沿的最大时间。
数据的收发主要涉及到数据寄存器(DR)和数据移位寄存器(DSR)。当需要发送数据时,把要发送的字节写入DR寄存器。硬件会判定DSR寄存器是否为空,把DR中的字节搬到DSR中。然后在时钟信号的控制下,把DSR最高位的数据放到数据线SDA上,并对DSR进行移位操作。当8位数据通过移位操作发送完毕之后,如果通信没有结束,将再次从DR中搬一个字节到DSR中,继续发送数据。
当设备工作在接收机状态下时,数据控制器根据时钟信号,把SDA线上的高低电平转换为‘1’‘0’数据,写到DSR的最低位。同时DSR左移位,当接收完一个字节的8位数据后,把DSR中的数据搬到DR寄存器中。再从DR寄存器中把数据读出来,模块会产生一个ACK信号到总线上。
在STM32中,I2C模块可以工作主从两种模式下。模块有两个地址寄存器,通过与DSR寄存器中接收的数据对比,来监听总线上的地址信号。如果能够匹配上,则建立起一个连接与主设备进行通信。
3. I2C通信的实现
3.1 初始化过程
在使用I2C外设之前,需要先对它进行初始化操作。主要涉及三个方面的工作:
- 打开外设相关的时钟
- 对外设涉及的引脚进行配置
- 修改外设的寄存器配置外设的工作方式
整个过程如函数i2c1_init()
的实现:
void i2c1_init(void) {
// 开启外设时钟
RCC->AHB1ENR.bits.gpiob = 1;
RCC->APB1ENR.bits.i2c1 = 1;
// 配置引脚功能
GPIOB->AFR.bits.pin8 = GPIO_AF_I2C1;
GPIOB->AFR.bits.pin9 = GPIO_AF_I2C1;
struct gpio_pin_conf pincof;
pincof.mode = GPIO_Mode_Af;
pincof.otype = GPIO_OType_OD;
pincof.pull = GPIO_Pull_Up;
pincof.speed = GPIO_OSpeed_High;
gpio_init(GPIOB, GPIO_Pin_8 | GPIO_Pin_9, &pincof);
// 配置外设
i2c_init(I2C1);
}
在函数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()
两个函数用于读取和发送一个字节的操作。这两个函数都只是对上述两个函数的一个封装而已。
uint8 i2c_read_byte(i2c_regs_t *i2c, uint8 addr, uint8 reg) {
uint8 data;
i2c_read_bytes(i2c, addr, reg, 1, &data);
return data;
}
void i2c_write_byte(i2c_regs_t *i2c, uint8 addr, uint8 reg, uint8 data) {
i2c_write_bytes(i2c, addr, reg, 1, &data);
}
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实现有些问题,但经过测试发现,意法半导体已经解决了这个问题,硬件实现也没有什么毛病。
I2C通信协议以其简单易用、接线少等特点,在嵌入式系统中得到了广泛应用。通过本文的介绍,相信读者已经对I2C通信协议有了全面的了解,并能够基于STM32微控制器实现具体的I2C通信应用。