首页 关于
树枝想去撕裂天空 / 却只戳了几个微小的窟窿 / 它透出天外的光亮 / 人们把它叫做月亮和星星
目录

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()的实现:

        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);
        }
        void i2c_init(i2c_regs_t *i2c) {
            // 重置外设,放置死锁
            RCC->APB1RSTR.bits.i2c1 = 1;
            RCC->APB1RSTR.bits.i2c1 = 0;
            i2c->CR1.bits.SWRST = 1;
            i2c->CR1.bits.SWRST = 0;
            // i2c工作频率42MHz
            i2c->CR2.bits.FREQ = 42;
            // 400KHz的快速通信, F/S=1,DUTY=0,CCR=3
            i2c->CR1.bits.PE = 0;
            i2c->TRISE.bits.TRISE = 13;
            i2c->CCR.all = 0x83;
            i2c->CR1.bits.PE = 1;

            i2c->CR1.bits.ACK = 1;
            i2c->OAR1.bits.add = 0xC0;
        }

我们在函数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实现有些问题, 但经过我的测试发现,意法半导体好像现在已经给解决了,硬件实现也没什么毛病。




Copyright @ 高乙超. All Rights Reserved. 京ICP备16033081号-1