基于STM32的I2C的简单应用
关键词: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)如下图所示:
接着配置PB10和PB11的引脚模式,端口配置高寄存器(GPIOx_CRH)如下图所示:
通过上述代码,将PB10、PB11配置为通用开漏输出,最大输出速度为50MHz。
起始信号和停止信号
根据时序图可知:
起始信号:当SCL高电平期间,SDA从高电平切换到低电平。
停止信号:当SCL高电平期间,SDA从低电平切换到高电平。
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总线上发送应答信号或非应答信号。
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总线上读取应答信号或非应答信号。
发送、接收数据
发送一个字节
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写入字节的时序:
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中读取字节数据
同样,先给出时序图:
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参数配置:
(🧐🧐不要忘记配置串口哦!本实验通过使用串口打印读取的数据,建议重写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);
由现象可知,代码中AT24C02_Write_Bytes(0x00, "wojiaozengchaoaertyhg", 21);
写入的字节数超过一页(16字节),超过部分会覆盖之前的部分,第17个字节会覆盖第1个字节所在的位置,以此类推。
结束👋
本文章在此就结束了,总体还是比较简单的。大家可以使用AT24C02模块进行一些操作,比如单片机启动后从AT24C02中读取某地址的数据,根据此数据进行指定功能的运行等等。时间差不多了,我先溜了。👋👋🙇
寄存器版本和HAL库版本工程百度网盘链接:
https://pan.baidu.com/s/14aZARKGckK5a0OtuBPRvzg 提取码:0624