CH32V307 - I2C基本时序详解(第十章)
从底层原理教你I2C程序为什么这么写
目录
本章概述
本章主要讲述I2C相关实战知识,因为I2C内容并不复杂所以不单独讲它的代码移植,我们直接上手写代码,相信看完本章节你就可以完全理解如何使用和编写I2C程序了。
一、什么是I2C
1.I2C概念
I2C是我们认识的第二种通信协议,它虽然与串口通信一样只有两根线,但功能完全不一样。I2C属于同步半双工通信协议,同步指的是它有一根时钟线(SCL)来作为数据传输时的时间标准,指挥所有设备什么时候发数据、什么时候读数据。而半双工指的是它的数据线(SDA)只有一条,发数据和收数据都在一条线上进行,无法同时进行发数据和收数据的动作。
I2C同时支持一主多从、多主多从的形式,主并不是指的发送方,上面我们知道所有设备都是可以进行发送和接收的,这里的主指的是掌控时钟线(SCL)的主机。所以说,谁是主机谁就来控制时钟线SCL,谁要发送数据谁就控制数据线SDA。
如果多设备之间没有严格的配合,就会造成两条线上同时出现高电平或低电平的情况,相当于正极和负极连接到了一根线上,电源就短路了。为了杜绝这种情况,I2C协议规定两条线都由外部上拉电阻上拉,这个上拉电阻一般选取4.7K左右。而设备采取开漏输出模式,开漏输出只有强下拉,置高电平时相当于释放该线,而该线又被上拉电阻上拉形成高电平。
由于外部弱上拉的这个特性造成I2C时序的上升沿波形较缓,波形的完整性直接影响到数据的传输效果,所以它的通信速率也受到限制,传统I2C的速度为100kHz(100kbps),而现在主流的是快速I2C模式,能达到400kHz(400kbps)。当然还有其他更高速的,但都是做了特殊处理的。
2.时序实现
基本结构
首先是所有通信协议的总体架构"起始信号"-"数据传输"-"终止信号"。I2C的实际时序结构为"起始信号"-"数据传输"-"应答位"-"终止信号",多了一位的应答位,所以I2C每次通信的数据实际位9位数据。当然也可以多数据连续收发"起始信号"-"数据传输"-"应答位"-"数据传输"-"应答位"..."数据传输"-"应答位"-"终止信号"。起始信号为在SCL高电平时产生一个SDA的下降沿信号,终止信号为在SCL高电平时产生一个SDA的上升沿信号。而数据的传输为发送方在SCL低电平时置SDA为高电平或低电平,接收方在SCL高电平时读取SDA的电平信号。同样的每次传输1个字节(8位二进制数),发送高位先行,接收高位先进。而应答位则只有1位二进制(1bit),1(高电平)为非应答,0(低电平)为应答,因为在I2C中高电平为空闲状态(被释放),低电平才能显示设备进行了动作。
应答位
应答位是时序中必须有的,但程序中是否要处理这个应答位则由具体情况决定。如果通信双方有发有收(收发角色转换)则必须处理应答位,如只是单向发送或接受则可以不必理会应答位。例如MPU6050,MCU需要向MPU6050发送命令,也需要接收MPU6050的数据,则必须处理应答位。MCU发命令给MPU6050,如果MPU6050没有应答则再次发送命令,如果对方应答了则MCU转为接收方。MCU接收到数据后如果还想继续接收数据就应答,如果想停止接受重新拿回SDA控制权发送命令则不应答。而MCU驱动OLED屏幕时,只需要向OLED发送数据,此时就只需要SCL产生一个应答位的信号让OLED发送应答位,而MCU可以不去处理这个应答位,不管OLED有没有应答我们都继续发送下一个数据。
从机地址
从机地址也是I2C的特点之一,它是为了一主多从、多主多从而设计的,但这也是时序规范,所以就算我们是一主一从也需要有从机地址。当总线上挂载了多个设备时,就需要根据从机地址来确定主机是要跟谁通信,主机开始时序后,所有从机都会开始接收,只有识别到自己的地址时才会应答并接收指令产生动作。这是I2C通信的规范,所以就算我们是一主一从也必须要有发送从机地址的这一步。
而涉及到多主多从则还牵扯到总线仲裁的问题,这个我们不详细展开讲。
3.应用方法
I2C作为通信协议那么它的发送和接收数据的内容和格式都是有要求的,而具体的要求则由不同的模块驱动要求来决定,是要查询模块的数据手册的。I2C就好比是一张嘴,它规定了大家张开嘴巴就是说话,闭上嘴巴就是结束说话,喊谁的名字就是跟谁说话。而你要说什么话对方才能听得懂则取决于对方对方是哪个国家的人你就要用哪个国家的语言格式。
例如OLED屏幕的驱动芯片SSD1306的数据手册中就会告诉你用I2C与它通信的规则,先发送地址,第一个字节最后一位决定读还是写,发送命令的格式是怎样的等等。通信要求、从机地址、命令对应什么字节等详细的内容都要去数据手册中查找。

二、软件I2C时序代码
这里我只讲软件I2C,因为软件I2C移植太方便太通用了,而且效果也不比硬件的差,随便两个IO口即可直接使用,而且写软件I2C也能加深你对I2C时序的理解。
IO口读写函数
/* 写SCL */
void I2C_W_SCL(uint8_t Byte)
{
GPIO_WriteBit(GPIOC,GPIO_Pin_1,(BitAction)Byte); //PC1->SCL
Delay_Us(1); //MCU速度过快就要适当延时
}
/* 写SDA */
void I2C_W_SDA(uint8_t Byte)
{
GPIO_WriteBit(GPIOC,GPIO_Pin_2,(BitAction)Byte); //PC2->SDA
Delay_Us(1); //MCU速度过快就要适当延时
}
/* 读SDA */
uint8_t I2C_R_SDA(void)
{
uint8_t Byte;
Byte = GPIO_ReadInputDataBit(GPIOC,GPIO_Pin_2); //读取的可以不加延时
return Byte;
}
这里是对读写IO端口的库函数进行封装,我们CH32作为主机控制SCL,所以SCL只写,SDA又读又写。CH32频率较高,所以对于写的动作要加入一定的延时,否则从机来不及读取电平,而读SDA就可以不用延时了。
I2C对应IO口初始化
/* I2C对应IO口初始化 */
void I2C_GPIO_Init(void)
{
/*开启时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); //开启GPIOA的时钟
/*GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; //都初始化为开漏输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1 | GPIO_Pin_2; //开启PC1/PC2
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOC, &GPIO_InitStructure);
I2C_W_SCL(0); //拉低SCL再操作SDA,防止误操作
I2C_W_SDA(1); //释放SDA
I2C_W_SCL(1); //释放SCL
}
我选的是SCL->PC1,SDA->PC2。都开启开漏输出模式,正常初始化IO口即可。值得注意的是SCL与SDA的电平我们最好也初始化一下,防止出现异常。为了严谨我们先拉低SCL,此时释放SDA为高电平防止误操作。
I2C开始时序
/* I2C起始时序 */
void I2C_Start(void)
{
I2C_W_SDA(1); //确保SDA为高电平
I2C_W_SCL(1); //确保SCL为高电平,这里必须先SDA再SCL,防止误操作
I2C_W_SDA(0); //SCL高电平时拉低SDA,产生起始信号
I2C_W_SCL(0); //除终止时序外每个时序结尾都拉低SCL,方便拼接时序
}
先确保SDA为高电平,再确保SCL为高电平,我们需要在SCL高电平期间把SDA拉低形成起始信号。最后要把SCL拉低,方便后续时序的拼接。
I2C终止时序
/* I2C终止时序 */
void I2C_Stop(void)
{
I2C_W_SDA(0); //确保SDA为低电平
I2C_W_SCL(1); //确保SCL为高电平,这里必须先SDA再SCL,防止误操作
I2C_W_SDA(1); //SCL高电平时释放SDA,产生终止信号
}
终止时序中我们也要先确保SDA为低电平,再释放SCL,在SCL高电平时释放SDA形成终止信号。最后不用再把SCL拉低,让两线保持空闲状态,同时也能加速下一帧的起始信号产生。
I2C发送一个字节
/* I2C发送一个字节 */
void I2C_SendByte(uint8_t Byte)
{
uint8_t i;
for(i=0; i<8; i++)
{
I2C_W_SDA(Byte & (0x80 >> i)); //高位先行,循环发送每一位
I2C_W_SCL(1); //SCL高电平让从机读取数据
I2C_W_SCL(0); //拉低SCL准备置位下一个数据
}
}
由于I2C是高位先行,所以我们从高位到低位循环发送一个字节(8bit)即可。这里用位操作更加直观快速,0x80=1000 0000,i等于几这个1就往右移几位,这是移位操作。与操作能取出对应位,对应位为1与操作之后就为1,对应位为0与操作之后就为0。位操作对于一些稍复杂的程序是十分重要的工具,内存占用少、运行效率高、代码也更简洁直观。置位完SDA,就释放SCL让从机读取SDA电平,然后拉低SCL准备下一次的置位。
I2C接收一个字节
/* I2C接收一个字节数据 */
uint8_t I2C_ReseiveByte(void)
{
uint8_t i,Byte = 0x00; //必须清零
I2C_W_SDA(1); //开始接收前先释放SDA
for(i=0; i<8; i++)
{
I2C_W_SCL(1); //SCL高电平准备读取数据
if(I2C_R_SDA()){Byte |= (0x80 >> i);} //高位先进,循环接收每一位
I2C_W_SCL(0); //拉低SCL准备接受下一个数据
}
return Byte;
}
接收一个字节的写法也是差不多的,但是进入接收之前必须先释放SDA,因为我们开始时序中是把SDA拉低了的,就算做了处理也要防止SDA被其他意外程序拉低的可能,所以在所有接收程序之前我们都应该先释放SDA。然后先释放SCL为高电平,接着读取SDA数据,读取到的数据也是由高到低存入接收Byte中。然后拉低SCL让从机置位SDA下一个数据。值得注意的是,Byte定义时要初始化为0,这样每次进入接收程序时接受字节Byte都是全零状态,我们只需置1即可。
I2C发送一个应答位
/* I2C发送一个应答位 / 0为应答,1为不应答*/
void I2C_SendAck(uint8_t AckBit)
{
I2C_W_SDA(AckBit); //先置位SDA
I2C_W_SCL(1); //SCL高电平等待从机读取数据
I2C_W_SCL(0); //拉低SCL,方便时序拼接
}
发送一个应答位与发送一个字节原理是一样的,只是应答位只有1位数据,而一个字节有8位数据,所以省去循环即可。先置位再操作SCL。
I2C接收一个应答位
/* I2C接收一个应答位 / 0为应答,1为未应答*/
uint8_t I2C_ReceiveAck(void)
{
uint8_t AckBit;
I2C_W_SDA(1); //接收数据前先释放SDA
I2C_W_SCL(1); //SCL高电平准备接收数据
AckBit = I2C_R_SDA();
I2C_W_SCL(0); //拉低SCL,方便时序拼接
return AckBit;
}
接收一个应答位也是与接收一个字节差不多的,记住先释放SDA,再释放SCL为高电平,接着读取SDA数据,最后拉低SCL。
三、函数调用
因为我们写的是最基础的时序,所以我们要拼接所有的时序来调用,"起始信号"-"发送或接收一个字节"-"应答位"-"停止信号"。实际驱动一些模块时我们可以再把这些函数进行二次封装再来调用,这样代码更加直观简洁,也不容易漏掉某个时序。例如OLED驱动时,我们可以直接把接收应答位写在发送一个字节的函数最后,这样每次直接调用发送字节即可,不用另外加上接收应答位。
while(1)
{
OLED_ShowString(1,3,"MPU6050-TEST");
if(Key_Flag == 1)
{
Key_Flag = 0;
I2C_Start(); //I2C开始
//MPU6050写地址/0xD1为读地址
I2C_SendByte(0xD0); //发送一个字节
//或者换成OLED屏幕的地址0x78
OLED_ShowString(2,4,"OK!"); //显示发送成功
if(!(I2C_ReceiveAck())) //接收应答位
{
OLED_ShowString(3,4,"OK!"); //接收成功
}
I2C_Stop(); //I2C停止
}
}
四、实验现象
我们按照下表给MPU6050接线
| MPU6050 | CH32V307 |
| VCC | 3V3 |
| GND | GND |
| SCL | PC1 |
| SDA | PC2 |
如果你没有MPU6050也可以直接用OLED屏幕来测试,把OLED原本的接线换成上面表格的接线,那么就把接收成功的代码换成LED亮的代码即可,注意这时候你就要把主函数中原本的OLED代码给都注释掉。
这里我们按照时序ping一下MPU6050模块的地址,如果代码没问题的话我们就会接收到它的应答。"起始信号"-"发送MPU6050的地址"-"接收应答位"-"停止信号",发送后我们在OLED第二行显示"OK!",如果应答了我们会接收到0,在OLED第三行显示"OK!"。这里地址给0xD0或者0xD1都可以,最后一位表示我们写指令还是读数据。换成其他地址则会接收失败。
五、完整代码
main.c
#include "debug.h"
#include "LED.h"
#include "Key.h"
#include "OLED.h"
#include "I2C.h"
int main(void)
{
//模块初始化
LED_Init();
OLED_Init();
Key_Init();
I2C_GPIO_Init();
Delay_Init();
OLED_ShowString(1,3,"MPU6050-TEST");
OLED_ShowString(2,1,"TX:");
OLED_ShowString(3,1,"RX:");
while(1)
{
OLED_ShowString(1,3,"MPU6050-TEST");
if(Key_Flag == 1)
{
Key_Flag = 0;
I2C_Start(); //I2C开始
//MPU6050写地址/0xD1为读地址
I2C_SendByte(0xD0); //发送一个字节
//或者换成OLED屏幕的地址0x78
OLED_ShowString(2,4,"OK!"); //显示发送成功
if(!(I2C_ReceiveAck())) //接收应答位
{
OLED_ShowString(3,4,"OK!");
}
I2C_Stop(); //I2C停止
}
}
}
I2C.c
#include "ch32v30x.h"
#include "debug.h"
/* 写SCL */
void I2C_W_SCL(uint8_t Byte)
{
GPIO_WriteBit(GPIOC,GPIO_Pin_1,(BitAction)Byte); //PC1->SCL
Delay_Us(1); //MCU速度过快就要适当延时
}
/* 写SDA */
void I2C_W_SDA(uint8_t Byte)
{
GPIO_WriteBit(GPIOC,GPIO_Pin_2,(BitAction)Byte); //PC2->SDA
Delay_Us(1); //MCU速度过快就要适当延时
}
/* 读SDA */
uint8_t I2C_R_SDA(void)
{
uint8_t Byte;
Byte = GPIO_ReadInputDataBit(GPIOC,GPIO_Pin_2); //读取的可以不加延时
return Byte;
}
/* I2C对应IO口初始化 */
void I2C_GPIO_Init(void)
{
/*开启时钟*/
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); //开启GPIOA的时钟
/*GPIO初始化*/
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; //都初始化为开漏输出
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1 | GPIO_Pin_2; //开启PC1/PC2
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOC, &GPIO_InitStructure);
I2C_W_SCL(0); //拉低SCL再操作SDA,防止误操作
I2C_W_SDA(1); //释放SDA
I2C_W_SCL(1); //释放SCL
}
/* I2C起始时序 */
void I2C_Start(void)
{
I2C_W_SDA(1); //确保SDA为高电平
I2C_W_SCL(1); //确保SCL为高电平,这里必须先SDA再SCL,防止误操作
I2C_W_SDA(0); //SCL高电平时拉低SDA,产生起始信号
I2C_W_SCL(0); //除终止时序外每个时序结尾都拉低SCL,方便拼接时序
}
/* I2C终止时序 */
void I2C_Stop(void)
{
I2C_W_SDA(0); //确保SDA为低电平
I2C_W_SCL(1); //确保SCL为高电平,这里必须先SDA再SCL,防止误操作
I2C_W_SDA(1); //SCL高电平时释放SDA,产生终止信号
}
/* I2C发送一个字节 */
void I2C_SendByte(uint8_t Byte)
{
uint8_t i;
for(i=0; i<8; i++)
{
I2C_W_SDA(Byte & (0x80 >> i)); //高位先行,循环发送每一位
I2C_W_SCL(1); //SCL高电平让从机读取数据
I2C_W_SCL(0); //拉低SCL准备置位下一个数据
}
}
/* I2C接收一个字节数据 */
uint8_t I2C_ReseiveByte(void)
{
uint8_t i,Byte = 0x00; //必须清零
I2C_W_SDA(1); //开始接收前先释放SDA
for(i=0; i<8; i++)
{
I2C_W_SCL(1); //SCL高电平准备读取数据
if(I2C_R_SDA()){Byte |= (0x80 >> i);} //高位先进,循环接收每一位
I2C_W_SCL(0); //拉低SCL准备接受下一个数据
}
return Byte;
}
/* I2C发送一个应答位 / 0为应答,1为不应答*/
void I2C_SendAck(uint8_t AckBit)
{
I2C_W_SDA(AckBit); //先置位SDA
I2C_W_SCL(1); //SCL高电平等待从机读取数据
I2C_W_SCL(0); //拉低SCL,方便时序拼接
}
/* I2C接收一个应答位 / 0为应答,1为未应答*/
uint8_t I2C_ReceiveAck(void)
{
uint8_t AckBit;
I2C_W_SDA(1); //接收数据前先释放SDA
I2C_W_SCL(1); //SCL高电平准备接收数据
AckBit = I2C_R_SDA();
I2C_W_SCL(0); //拉低SCL,方便时序拼接
return AckBit;
}
I2C.h
#ifndef __I2C_H
#define __I2C_H
void I2C_GPIO_Init(void);
void I2C_Start(void);
void I2C_Stop(void);
void I2C_SendByte(uint8_t Byte);
uint8_t I2C_ReseiveByte(void);
void I2C_SendAck(uint8_t AckBit);
uint8_t I2C_ReceiveAck(void);
#endif
Key.c / Key.h
Key模块代码与 第六章 "CH32V307-USART收发HEX数据包" 的模块代码一致。
LED.c / LED.h
LED模块代码与 第三章 "CH32C307-通用模块" 的模块代码一致。
OLED.c / OLED.h / OLED_Font.h
OLED模块代码与 第二章 "CH32V307-OLED驱动" 的模块代码一致。
总结
软件I2C的程序还是比较少的,不过时序要求比较复杂,要多练习几遍把每个时序都理解。下一章我会给出MPU6050的完整移植代码,方便大家练习I2C协议。加油!
更多推荐



所有评论(0)