STM32内部集成了硬件I2C收发电路,可以由硬件自动执行时钟生成、起始终止条件生成、应答位收发、数据收发等功能,减轻CPU的负担。STM32的I2C外设支持多主机模型,支持7位/10位地址模式,支持不同的通讯速度,标准速度(高达100 kHz),快速(高达400 kHz),支持DMA,兼容SMBus协议。STM32F103C8T6 硬件I2C资源有I2C1、I2C2两个独立的I2C外设。
我们来看一下I2C外设的框图,首先,左侧是外设的通信引脚SDA和SCL,SMBALERT是SMBus使用的。像这种外设模块引出来的引脚一般都是借助GPIO口的复用模式与外部世界相连的,具体复用在的GPIO口查引脚定义就可以知道。接着我们继续看内部电路,首先上面这一块是SDA,也就是数据控制部分,这里数据收发的核心部分是最上面的数据寄存器和数据移位寄存器。当我们需要发送数据时,可以把一个字节数据写到数据寄存器DR,当移位寄存器没有数据移位时,数据寄存器里的值就会进一步转到移位寄存器里,在移位的过程中,我们就可以直接把下一个数据放到数据寄存器里等着了,一旦前一个数据移位完成,下一个数据就可以无缝衔接,继续发送。当数据由数据寄存器转到移位寄存器时,就会置状态寄存器的TXE位为1,表示发送寄存器为空,这就是发送的过程。输入时,输入的数据一位一位地从引脚移入到移位寄存器里,当一个字节的数据收齐之后,数据就整体从移位寄存器转到数据寄存器,同时置标志位RXNE表示接收数据寄存器非空,这时候我们就可以把数据从数据寄存器里读出来了。上述的流程和串口部分一样,只不过串口的接收和发送是全双共,这里数据的接收和发送是分开的,是半双工,所以数据收发是同一组数据寄存器和移位寄存器。所以有了最上面那一块,SDA的数据收发就可以完成了。至于什么时候收,什么时候发,需要我们写入控制寄存器的对应位进行操作,对于起始条件终止条件、应答位什么的,这里也都有控制电路可以完成。数据收发之后,下面还有两个功能,一个是比较器和自身地址寄存器、双地址寄存器;另一个是帧错误校验计算和帧错误校验寄存器。比较器和地址寄存器是从机模式使用的,由于STM32的I2C是基于可变多主机模型设计的,STM32不进行通信的时候,就是从机,既然作为从机,它就应该可以被别人召唤,想被别人召唤那就应该要有从机地址,从机地址的具体取值可以由自身地址寄存器指定。我们可以自定一个从机地址,写到这个寄存器,当STM32作为从机,在被寻址时,如果收到的寻址通过比较器判断,和自身地址相同,那STM32就作为从机,响应外部主机的召唤,并且这个STM32支持同时响应两个从机地址,所以就有自身地址寄存器和双地址寄存器。帧错误校验计算和帧错误校验寄存器是STM32设计的一个数据校验模块,当我们发送一个多字节的数据帧时,在这里硬件可以自动执行CRC校验计算,CRC是一种很常见的数据校验算法,它会根据前面数据帧的数据进行各种运算,然后得到一个字节的校验位附加在数据帧后面。在接收到这一帧数据后,STM32的硬件也可以自动执行校验的判定,如果数据在传输的过程中出错了,CRC校验算法就不通过,硬件就会置校验错误标志位。
继续看下面SCL的这部分,时钟控制是用来控制SCL线的,在时钟控制寄存器(CCR)写对应的位,电路就会执行对应的功能。写入控制寄存器(CR)传递到控制逻辑电路,可以对整个电路进行控制;读取状态寄存器(SR),可以得知电路的工作状态。最下方是中断,当内部有一些标志位置1之后,可能事件比较紧急,就可以申请中断。如果我们开启了这个中断,那当这个事件发生后,程序就可以跳到中断函数来处理这个事件了。最后是DMA请求与响应,在进行很多字节的收发时可以配合DMA来提高效率。
接着我们来看一下基本结构图,得到内部的简化结构如下所示。首先移位寄存器和数据寄存器DR的配合是通信的核心部分,由于I2C是高位先行,所以移位寄存器是向左移位,一个SCL时钟移位一次,移位8次,就能把一个字节由高位到低位,依次放到SDA线上了。在接收的时候,数据通过GPIO口从右边依次移进来,最终移8次,一个字节就接收完成了。之后GPIO口这里,使用硬件I2C的时候,这两个对应的GPIO口都要配置为复用开漏输出的模式。复用就是GPIO口的状态是交由片上外设来控制的,开漏输出是I2C要求的端口配置。SCL这里,时钟控制器通过GPIO口去控制时钟线。SDA的输出数据通过GPIO输出到端口;输入数据也是通过GPIO输入到移位寄存器。这两个箭头连接在GPIO结构中的红色部分。来自片上外设的复用功能输出输入到下半部分电路,控制N-MOS的通断,进而控制I/O引脚是拉低到低电平还是释放悬空。并且虽然是复用开漏输出,但是上半部分输入这一路任然有效,I/O引脚的高低电平通过TTL触发器进入片上外设,来进行复用功能输入。
接下来,我们来看一下硬件I2C的操作流程,下面两张图展示的是主机发送和主机接收的操作流程,我们写程序时也是参考这些流程来写的。
我们先看主机发送,当STM32想要执行指定地址写的时候,就要按照主发送器传送序列图来进行。这里有7位地址的主发送和10位地址的主发送,它们的区别就是7位地址起始条件后的一个字节是寻址;10位地址,起始条件后的两个字节都是寻址,其中前一个字节写的是帧头,内容是5位的标志位11110+2位地址+1位读写位,后一个字节内容就是纯粹的8位地址了,两个字节加一起,构成10位的寻址。首先,初始化之后,总线默认空闲状态,STM32默认是从模式,为了产生一个起始条件,STM32需要写入控制寄存器,在控制寄存器(CR)中,在START位写1就可以产生起始条件,当起始条件发出后,这一位可以由硬件清除,之后STM32由从模式转为主模式。控制完硬件电路后,我们就要检查标志位,来看看硬件有没有达到我们想要的状态。在这里,起始条件之后,会发生EV5事件,这个EV5事件,你可以把它当成是标志位。手册这里都是用EV几这几个事件来代替标志位的。为什么要设计这样的EV几事件而不直接说产生什么标志位呢?这是因为有的状态会同时产生多个标志位,所以这个EV几事件,就是组合了多个标志位的一个大标志位。在库函数中也有对应的检查EV几事件是否发生的函数。下面解释一下EV5事件就是SB(Start Bit)标志位为1,SB是状态寄存器的一个位,表示了硬件状态,我们可以在手册的状态寄存器(SR)中找到这个位,SB置1,代表起始条件已发送,软件读取SR寄存器后,也就是查看了这一位。然后写数据寄存器(DR)的操作将清除该位,而写数据寄存器DR就是我们接下来的操作,所以按照正常的流程来,这个状态寄存器是不需要手动清除的。然后继续这个流程,当我们检测起始条件已发送时就可以发送一个字节的从机地址了,从机地址需要写道数据寄存器DR中,写入DR后硬件电路就会自动把这一字节转到移位寄存器里,再把这一个字节发送到I2C总线上,之后硬件会自动接收应答位并判断,如果没有应答,硬件会置应答失败的标志位,然后这个标志位可以申请中断来提醒我们。在寻址完成后,会发送EV6事件,对于EV6事件的解释就是ADDR标志位为1,在手册中可以找到ADDR标志位在主模式状态下就代表地址发送结束。EV6事件结束后是EV8事件,EV8_1事件就是TxE标志位=1,移位寄存器非空,数据寄存器空,这时需要我们写入数据寄存器DR进行数据发送了。因为移位寄存器也是空,所以DR会立刻转到移位寄存器进行发送,这时就是EV8事件,移位寄存器非空,数据寄存器空,这时就是移位寄存器正在发数据的状态,所以流程这里数据1的时序就产生了。而数据2处的数据在EV8事件发生后就被写入到数据寄存器里等着了,然后接收应答位之后,数据2就转入移位寄存器进行发送,此时的状态又变成了移位寄存器非空,数据寄存器空,所以此时,EV8事件又发生了。也就是说,一旦检测到EV8事件,就可以写入下一个数据了。最后,当我们想要发送的数据写完之后,这时就没有新的数据可以写入到数据寄存器了。当移位寄存器当前的数据移位完成时,此时就是移位寄存器空,数据寄存器也是空的状态,这个事件就是EB8_2,下面解释EV8_2是TxE=1,也就是数据寄存器空,BTF=1(字节发送结束标志位,置1表示在发送时,当一个新数据将被发送且数据寄存器还未被写入新的数据)。所以在这里,当检测到EV8_2时就可以产生终止条件了。而产生终止条件显然应该在控制寄存器里有相应的位可以控制,也就是在参考手册控制寄存器(CR)中的STOP位写1就会在当前字节传输,或在当前起始条件发出后产生停止条件。至此,一个完整的时序就发送完成了。
之后是主机当前地址接收的序列图,首先,写入控制寄存器的START位,产生起始条件,然后等待EV5事件,之后是寻址,接收应答,结束后产生EV6事件。之后数据1这一块,代表数据正在通过移位寄存器进行输入,EV6_1事件的解释是没有对应的事件标志,只适用于接收1个字节的情况。这个EV6_1可以看到,数据1其实还正在移位,还没收到呢,所以这个事件就没有标志位。之后当这个时序单元完成时,硬件会自动根据我们的配置,把应答位发送出去,如何配置是否要给应答呢?也是看参考手册,控制寄存器(CR)中有一位ACK,应答使能,如果写1,在接收到一个字节后就返回一个应答,写0就是不给应答。当这个时序单元结束后,就说明移位寄存器就已经成功移入一个字节的数据了,这时,移入的一个字节就整体转移到数据寄存器,同时置RXNE标志位,表示数据寄存器非空,也就是收到了一个字节的数据,这个状态也就是EV7事件。事件的解释是RxNE=1(数据寄存器非空)读DR寄存器清除该事件,也就是收到数据了,当我们把数据读走之后,这个事件就没有了。当然数据1还没读走的时候,数据2就已经直接移入移位寄存器了。之后,数据2移位完成,收到数据2,产生EV7事件,读走数据2,EV7事件消失,然后按照这个流程,就可以一直接收数据了。当然,当我们不需要继续接收时,需要在最后一个时序单元发生时,提前把刚才说的应答位控制寄存器ACK置0,并且设置终止条件请求。这就是EV7_1事件,其解释和EV7一样,但是多了设置ACK=0和STOP请求。由于设置了ACK=0,所以最后一位数据读走后就会给出非应答。最后由于设置STOP位,所以产生终止条件,这样接收一个字节的时序就完成了。
接下来到编写代码的环节,首先要配置I2C外设,具体步骤为:
1.开启I2C外设和对应GPIO口的时钟
2.把I2C外设对应的GPIO口初始化为复用开漏模式
3.使用结构体,对整个I2C进行配置
4.I2C_Cmd使能I2C
接下来介绍一下I2C库函数中的一些函数。之前讲到过的一些类似功能的函数在这里省略。
首先是I2C_GenerateSTART函数,调用一下这个函数,就可以生成起始条件了
之后I2C_GenerateSTOP函数也就很明显了,调用一下生成终止条件
I2C_AcknowledgeConfig函数就是配置在收到一个字节之后,是否给从机应答。具体方式是操作CR1的ACK(应答使能)这一位,就是STM32作为主机,在接收到一个字节后是给从机应答还是非应答。
I2C_SendData就是发送数据,实际就是把Data数据直接写入到DR寄存器,在发送器模式下,当写一个字节至DR寄存器时,自动启动数据传输,一旦传输开始,也就是TxE=1,发送寄存器空,如果能及时把下一个需传输的数据写入DR寄存器,I2C模块将保持连续的数据流。
I2C_ReceiveData就是读取DR的数据,作为返回值。在接收器模式下,接收到的字节被拷贝到DR寄存器,这时就是RxNE=1,接收寄存器非空,那在接收到下一个字节之前读出数据寄存器,即可实现连续的数据传送
I2C_Send7bitAddress是发送七位地址的专用函数,实际上,Address这个参数也是通过DR发送的,只不过是它在发送之前帮我们设置了Address最低位的读写位
由于EV几事件时,可能会同时置多个标志位,如果你在编程时只检查某一个标志位就认为这个状态已经发生了可能不太严谨,而如果你用GetFlagStatus函数读多次,再进行判断又可能比较麻烦。所以库函数头文件的最后部分就给了我们多种监控标志位的方案。
第一种:基本状态监控(推荐),使用I2C_CheckEvent函数,这个方式就是同时判断一个或多个标志位,来确定EV几这个状态是否发生。
第二种:高级状态监控(一般不用),使用I2C_GetLastEvent函数,就是把SR1和SR2这两个状态寄存器拼接成16位的数据扔给你
第三种:基于标志位的状态监控,使用I2C_GetFlagStatus函数,这是我们之前一直在使用的方法,可以判断某一个标志位是否置1
注意一下,在编写主机接收模式的时序实现时,在主机接收最后一个字节之前,需要将ACK置0,表示不再接收,同时把停止条件生成位STOP置1,提前表明不再接收后续字节。如果不提前在数据还没收到的时候给ACK置0,那等时序到了响应之后,数据已经收到了,你再突然说我要置0,我要给非应答,这时就晚了,数据收到之前,应答位就已经发送出去了,这时再给ACK置0,那只能是在下一个数据之后给非应答了。所以在最后一个数据之前,就要给ACK置0。同时,也应该提前设置STOP终止条件,这个终止条件,也不会截断当前字节,它会等当前字节接收完成后,再产生终止条件的波形。总结就是,如果是主机读取多个字节,那直接等待EV7事件,读取DR,就能收到数据了,这样依次接收,在接收最后一个字节之前,也就是EV7_1事件,需要提前把ACK置0,STOP置1,如果你只需要读取一个字节,那在EV6事件之后,就要立刻ACK置0,STOP置1,不然设置晚了,时序上就会多出一个字节出来。