>


基于STM32的I2C的简单应用

111

关键词:I2C, STM32, AT24C02, STM32CubeMX

最近在对学习过的一些通讯协议进行复习,想了想,就从I2C通讯协议开始吧! 🧑‍💻

本文将使用寄存器版本和HAL库版本分别对I2C通讯协议进行相关实验。

硬件准备:

  • STM32F103C8T6T

  • AT24C02 EEPROM存储模块

  • ST-LINK V2 烧录器

  • CH340 USB转TTL串口模块

环境准备:

  • keil5 MDK-ARM

  • STM32CubeMX

  • AiThinker Serial Tool

注意:本文将不会对AT24C02模块及UART通信进行介绍,请读者自行了解

I2C 简单了解✏️

介绍及注意事项

I2C是一种双向两线制总线协议,支持同步串行半双工通信,广泛应用于系统内多个集成电路(IC)间的低速通信。

I2C设备间进行通讯主要依靠两根线:SCL(串行时钟总线)和SDA(串行数据总线)。SCL主要用于数据收发的同步,而SDA主要用于数据的传输。每个使用I2C进行通讯的设备都会有唯一地址,主机通过这个地址与从机进行通讯,起始信号发出后需要发送一个字节的数据,该数据前7位为从设备地址(以7位地址的设备为例),最后一位为数据传输方向,即:主机向总线写数据(0)或主机从总线读取数据(1)。

需要注意的是总线通过上拉电阻连接到电源,设备空闲时处于高阻态,由上拉电阻将总线拉成高电平。因此,在后续配置I2C引脚模式时,需选用开漏模式。

涉及的信号

I2C涉及到的信号有起始信号、停止信号、应答信号等。

  • 起始信号:当SCL高电平期间,SDA从高电平切换到低电平。

  • 停止信号:当SCL高电平期间,SDA从低电平切换到高电平。

  • 应答信号:应答信号由接收方发送,当收到发送方发送的数据,接收方会发送应答或非应答信号

开始实验🧑‍🔧

再次强调:本文将不会对AT24C02模块及UART通信进行介绍,请读者自行了解

实验中使用STM32F103C8T6的I2C2,涉及到的引脚为PB10和PB11。

寄存器版本

代码中使用到的宏定义:

#define SCL(x) x? (GPIOB->ODR |= GPIO_ODR_ODR10) : (GPIOB->ODR &= ~GPIO_ODR_ODR10)
#define SDA(x) x? (GPIOB->ODR |= GPIO_ODR_ODR11) : (GPIOB->ODR &= ~GPIO_ODR_ODR11)
#define SDA_READ (GPIOB->IDR & GPIO_IDR_IDR11)
#define DELAY_10_US Delay_us(10)
#define ACK 0
#define NACK 1
#define ADDR 0xA0

SCL(x)、SDA(x)用于对PB10和PB11引脚的输出电平进行设置,SDA_READ用于读取SDA总线上电平信号。

初始化I2C引脚

void I2C2_Init(void)
{
	/*PB10  SCL
	  PB11  SDA*/
 	RCC->APB2ENR |= RCC_APB2ENR_IOPBEN;
	GPIOB->CRH |= (GPIO_CRH_MODE10 | GPIO_CRH_MODE11);
	GPIOB->CRH |= (GPIO_CRH_CNF10_0 | GPIO_CRH_CNF11_0);
	GPIOB->CRH &= ~(GPIO_CRH_CNF10_1 | GPIO_CRH_CNF11_1);
}

首先需要使能APB2外设时钟(PB10、PB11在APB2总线上),APB2外设时钟使能寄存器(RCC_APB2ENR)如下图所示:

APB2外设时钟使能寄存器(RCC_APB2ENR).png

接着配置PB10和PB11的引脚模式,端口配置高寄存器(GPIOx_CRH)如下图所示:

端口配置高寄存器(GPIOx_CRH).png

通过上述代码,将PB10、PB11配置为通用开漏输出,最大输出速度为50MHz。

起始信号和停止信号

根据时序图可知:

  • 起始信号:当SCL高电平期间,SDA从高电平切换到低电平。

  • 停止信号:当SCL高电平期间,SDA从低电平切换到高电平。

起始停止信号.png

void I2C2_Start(void)
{
	/*先拉高SDA和SCL 延时*/
	SCL(1);
	SDA(1);
	DELAY_10_US;
	/*拉低SDA 延时*/
	SDA(0);
	DELAY_10_US;
	/*拉低SCL准备写数据*/
	SCL(0);
}
void I2C2_Stop(void)
{
	/*SCL拉高 SDA拉低 延时*/
	SCL(1);
	SDA(0);
	DELAY_10_US;
	/*SDA拉高 延时*/
	SDA(1);
	DELAY_10_US;
}

应答信号、非应答信号及等待应答信号

​ 当接收方收到数据后会发送应答信号(ACK = 0)或非应答信号(NACK = 1)。在此期间,发送方会让出SDA总线的控制权,接收方可向SDA总线上发送应答信号或非应答信号。

应答信号和非应答信号.png

void I2C2_Ack(void)
{
	/*SCL拉低,SDA拉高 延时*/
	SDA(1);
	SCL(0);
	DELAY_10_US;
	/*SDA拉低 延时*/
	SDA(0);
	DELAY_10_US;
	/*SCL拉高 延时*/
	SCL(1);
	DELAY_10_US;
	/*SCL拉低 延时*/
	SCL(0);
	DELAY_10_US;
	/*SDA拉高 延时*/
	SDA(1);
	DELAY_10_US;
}
void I2C2_NAck(void)
{
	/*SDA拉高 SCL拉低 延时*/
	SDA(1);
	SCL(0);
	DELAY_10_US;
	/*SCL拉高 延时*/
	SCL(1);
	DELAY_10_US;
	/*SCL拉低 延时*/
	SCL(0);
	DELAY_10_US;
}
uint8_t I2C2_WaitAck(void)
{
	/*SDA拉高,将主动权交给对方 延时*/
	SDA(1);
	DELAY_10_US;
	/*SCL拉低 延时*/
	SCL(0);
	DELAY_10_US;
	/*SCL拉高,延时*/
	SCL(1);
	DELAY_10_US;
	/*读取SDA线上的信号*/
	uint8_t ack = ACK;
	if(SDA_READ)
	{
		ack = NACK;
	}
	/*SCL拉低,延时*/
	SCL(0);
	DELAY_10_US;
	/*返回信号*/
	return ack;
}

SCL处于低电平时,可向SDA写入数据,当SCL处于高电平时,SDA必须保持稳定,在此期间,发送方从SDA总线上读取应答信号或非应答信号

发送、接收数据

数据发送与接收.png

发送一个字节
void I2C2_SendByte(uint8_t byte)
{
	for(uint8_t i = 0; i < 8; i++)
	{
		/*SDA拉低 SCL拉低,延时*/
		SDA(0);
		SCL(0);
		DELAY_10_US;
		/*写数据*/
		if(byte & (0x80 >> i))
		{
			SDA(1);
		}
		else
		{
			SDA(0);
		}
		DELAY_10_US;
		/*时钟拉高,延时*/
		SCL(1);
		DELAY_10_US;
		/*拉低SCL准备下一次写数据 延时*/
		SCL(0);
		DELAY_10_US;	
	}
		/*写完数据*/
}

采用for循环向SDA总线上连续写入一个字节数据,数据的写入为高位先行,byte & (0x80 >> i)表示从高位向低位依次获取byte的每位数据。然后,在SCL处于低电平时向SDA上写入每位数据。

接收一个字节
uint8_t I2C2_ReadByte(void)
{
	uint8_t data = 0;
	for(uint8_t i = 0; i < 8; i++)
	{
		/*拉低SCL延时*/
		SCL(0);
		DELAY_10_US;
		/*拉高SCL 延时*/
		SCL(1);
		DELAY_10_US;
		/*读数据*/
		data <<= 1;
		if(SDA_READ)
		{
			data |= 0x01;
		}
		/*拉低SCL 延时*/
		SCL(0);
		DELAY_10_US;
	}
	return data;
}

接收数据同样采用for循环,在SCL处于高电平时,SDA处于稳定状态,在此时进行数据的读取,将读取的数据存放于data变量中。data初始时为0,在读取数据之前data需先进行左移操作,如果左移操作放在读取数据之后,会导致在读取最后一个数据后,第一个数据被移出,从而造成数据读取错误。

向AT24C02中写入字节数据

首先贴出手册中的设备地址

AT24C02设备地址.png

向AT24C02写入字节的时序:

AT24C02写入字节.png

void AT24C02_Write_Byte(uint8_t inneraddr, uint8_t data)
{
	/*开始信号*/
	I2C2_Start();
	/*写设备地址*/
	I2C2_SendByte(ADDR);
	/*等应答*/
	if(I2C2_WaitAck() == ACK)
	{
		/*写AT24C02内部地址*/
		I2C2_SendByte(inneraddr);
		/*等应答*/
		I2C2_WaitAck();
		/*写数据*/
		I2C2_SendByte(data);
		/*等应答*/
		I2C2_WaitAck();
		/*停止信号*/
		I2C2_Stop();
	}
	DELAY_10_US;
}

由时序图可知,需要写入两次地址。第一次为AT24C02设备地址,即ADDR(宏定义,见文章开头部分),接着便是写入AT24C02内部地址,即确定将后续数据写入存储器的哪个地址。确定写入数据的位置后,便可进行数据的写入。可以在写数据时使用循环进行连续写入,最多16字节,超过16字节就需要进行换页了,否则会覆盖掉之前位置写入的数据,具体换页操作自己去了解,这里不做过多的描述。累😩😩😩

void AT24C02_Write_Bytes(uint8_t inneraddr, uint8_t *data, uint8_t len)
{
	/*开始信号*/
	I2C2_Start();
	/*写设备地址*/
	I2C2_SendByte(ADDR);
	/*等应答*/
	if(I2C2_WaitAck() == ACK)
	{
		/*写内部地址*/
		I2C2_SendByte(inneraddr);
		/*等应答*/
		I2C2_WaitAck();
		/*写数据*/
		for(uint8_t i = 0; i < len; i++)
		{
			I2C2_SendByte(data[i]);
			/*等应答*/
			I2C2_WaitAck();	
		}
		/*停止信号*/
		I2C2_Stop();
	}	
	Delay_ms(5);
}

最后加个延时,避免数据还未写进去而导致数据读不出

从AT24C02中读取字节数据

同样,先给出时序图:

AT24C02读数据.png

uint8_t AT24C02_Read_Byte(uint8_t inneraddr)
{
	uint8_t rdata = 0;
	/*起始信号*/
	I2C2_Start();
	/*写设备地址*/
	I2C2_SendByte(0xA0);
	/*等待应答*/
	if(I2C2_WaitAck() == ACK)
	{
		/*写内部地址*/
		I2C2_SendByte(inneraddr);
		/*等待应答*/
		I2C2_WaitAck();
		/*起始信号*/
		I2C2_Start();
		/*要读的设备地址*/
		I2C2_SendByte(0xA1);
		/*等待应答*/
		I2C2_WaitAck();
		/*读数据*/
		rdata = I2C2_ReadByte();
		/*给非应答*/
		I2C2_NAck();
		/*停止信号*/
		I2C2_Stop();
	}
	return rdata;
}

在真正读取数据之前有个DUMMY WRITE操作,需要确定设备地址和要读取数据所在的设备内部地址。之后需要再产生一个起始信号进行数据读取的操作。以下再贴上读取一串数据的代码。

void AT24C02_Read_Bytes(uint8_t inneraddr,uint8_t*bytes, uint8_t len)
{
	/*起始信号*/
	I2C2_Start();
	/*写设备地址*/
	I2C2_SendByte(0xA0);
	/*等待应答*/
	if(I2C2_WaitAck() == ACK)
	{
		/*写内部地址*/
		I2C2_SendByte(inneraddr);
		/*等待应答*/
		I2C2_WaitAck();
		/*起始信号*/
		I2C2_Start();
		/*要读的设备地址*/
		I2C2_SendByte(0xA1);
		/*等待应答*/
		I2C2_WaitAck();
		/*读数据*/
		for(uint8_t i = 0; i < len; i++)
		{
			bytes[i] = I2C2_ReadByte();
			if(i < len - 1)
			{
                /*给应答信号*/
				I2C2_Ack();
			}
		}
		/*给非应答*/
		I2C2_NAck();
		/*停止信号*/
		I2C2_Stop();	
	}
}

🀄实验现象最后放出🀄

HAL库版本

HAL库版本比较简单,具体时序图不再给出,参照寄存器版本。并且,STM32CubeMX的基础配置不进行赘述。

I2C2配置,本实验只需进行Parameter Settings参数配置:

HAL_I2C配置.png

🧐🧐不要忘记配置串口哦!本实验通过使用串口打印读取的数据,建议重写printf函数)。

生成工程后在Application/User/Core中有一个i2c.c文件,里面包含了I2C2的初始化配置。

需要用到的HAL库函数:

HAL_StatusTypeDef HAL_I2C_Mem_Write(I2C_HandleTypeDef hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t pData, uint16_t Size, uint32_t Timeout);
HAL_StatusTypeDef HAL_I2C_Mem_Read(I2C_HandleTypeDef hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t pData, uint16_t Size, uint32_t Timeout);

上述两个函数位于stm32f1xxhal_i2c.c文件中,分别用于数据的写和读。

hi2c:I2C2的句柄。

DevAddress:设备地址。

MemAddress:存储地址。

MemAddSize:存储器地址大小,包括8位和16位:

#define I2C_MEMADD_SIZE_8BIT            0x00000001U
#define I2C_MEMADD_SIZE_16BIT           0x00000010U

pData:写入/读取的数据。

Size:写入/读取的数据的大小。

Timeout:超时时间。因为该方法采用阻塞的方式进行数据的写入和读取,因此需要设置一个超时时间。

直接给AT24C02操作的代码吧😪😪😪

void AT24C02_Init(void)
{
	MX_I2C2_Init();
}
void AT24C02_Write_Byte(uint8_t inneraddr, uint8_t data)
{
	HAL_I2C_Mem_Write (&hi2c2, ADDR, inneraddr, I2C_MEMADD_SIZE_8BIT , &data, 1, 1000);
	HAL_Delay(5);	
}
uint8_t AT24C02_Read_Byte(uint8_t inneraddr)
{
	uint8_t data;
	HAL_I2C_Mem_Read (&hi2c2, ADDR, inneraddr, I2C_MEMADD_SIZE_8BIT , &data, 1, 1000);
	return data;
}
void AT24C02_Write_Bytes(uint8_t inneraddr, uint8_t *data, uint8_t len)
{
	HAL_I2C_Mem_Write (&hi2c2, ADDR, inneraddr, I2C_MEMADD_SIZE_8BIT , data, len, 1000);
	HAL_Delay(5);	
}
void AT24C02_Read_Bytes(uint8_t inneraddr,uint8_t*data, uint8_t len)
{
	HAL_I2C_Mem_Read (&hi2c2, ADDR, inneraddr, I2C_MEMADD_SIZE_8BIT , data, len, 1000);
}

实验结果

寄存器版本的主函数中的部分代码,主要告诉读者写入的数据。(HAL库写入的数据一致)

AT24C02_Init();
uint8_t bytes[100] = {0};
AT24C02_Write_Bytes(0x00, "wojiaozengchao", 14);
AT24C02_Read_Bytes(0x00, bytes, 14);
printf("%s\r\n",bytes);
memset(bytes, 0, sizeof(bytes));
AT24C02_Write_Bytes(0x00, "wojiaozengchaoaertyhg", 21);
AT24C02_Read_Bytes(0x00, bytes, 21);
printf("%s\r\n",bytes);
uint8_t byte1 = AT24C02_Read_Byte(0x01);
uint8_t byte2 = AT24C02_Read_Byte(0x02);
uint8_t byte3 = AT24C02_Read_Byte(0x03);
uint8_t byte4 = AT24C02_Read_Byte(0x04);
uint8_t byte5 = AT24C02_Read_Byte(0x05);
uint8_t byte6 = AT24C02_Read_Byte(0x06);
uint8_t byte7 = AT24C02_Read_Byte(0x07);
uint8_t byte8 = AT24C02_Read_Byte(0x08);
printf("%c%c%c%c%c%c%c%c\r\n",byte1,byte2,byte3,byte4,byte5,byte6,byte7,byte8);

实验现象1.png

由现象可知,代码中AT24C02_Write_Bytes(0x00, "wojiaozengchaoaertyhg", 21);写入的字节数超过一页(16字节),超过部分会覆盖之前的部分,第17个字节会覆盖第1个字节所在的位置,以此类推。

结束👋

本文章在此就结束了,总体还是比较简单的。大家可以使用AT24C02模块进行一些操作,比如单片机启动后从AT24C02中读取某地址的数据,根据此数据进行指定功能的运行等等。时间差不多了,我先溜了。👋👋🙇

寄存器版本和HAL库版本工程百度网盘链接:

https://pan.baidu.com/s/14aZARKGckK5a0OtuBPRvzg 提取码:0624