第18章 硬件I2C
第十八章 硬件I2C
EEPROM是一种掉电后数据不丢失的存储器,常用来存储一些配置信息,以便系统重新上电的时候加载之。 EEPOM芯片最常用的通讯方式就是I2C协议, 本小节以EEPROM的读写实验为大家讲解STM32的I2C使用方法。 实验中STM32的I2C外设采用主模式,分别用作主发送器和主接收器,通过查询事件的方式来确保正常通讯。
1. 硬件设计
本实验板中的EEPROM芯片(型号:AT24C02)的SCL及SDA引脚连接到了STM32对应的I2C引脚中,结合上拉电阻, 构成了I2C通讯总线,它们通过I2C总线交互。EEPROM芯片的设备地址一共有7位,其中高4位固定为:1010 b, 低3位则由A0/A1/A2信号线的电平决定,图中的R/W是读写方向位,与地址无关。
按照我们此处的连接,A0/A1/A2均为0,所以EEPROM的7位设备地址是:101 0000b,即0x50。 由于I2C通讯时常常是地址跟读写方向连在一起构成一个8位数,且当R/W位为0时,表示写方向, 所以加上7位地址,其值为“0xA0”,常称该值为I2C设备的“写地址”;当R/W位为1时,表示读方向, 加上7位地址,其值为“0xA1”,常称该值为“读地址”。
EEPROM芯片中还有一个WP引脚,具有写保护功能,当该引脚电平为高时,禁止写入数据,当引脚为低电平时, 可写入数据,我们直接接地,不使用写保护功能。
2. 软件设计
2.1 编程目的
-
配置通讯使用的目标引脚为开漏模式;
-
使能I2C外设的时钟;
-
配置I2C外设的模式、地址、速率等参数并使能I2C外设;
-
编写基本I2C按字节收发的函数;
-
编写读写EEPROM存储内容的函数;
-
编写测试程序,对读写数据进行校验。
2.2 代码分析
- I2C硬件相关宏定义
/**************************I2C参数定义,I2C1或I2C2********************************/
#define EEPROM_I2Cx I2C1 // 我们这里使用I2C1
#define EEPROM_I2C_APBxClock_FUN RCC_APB1PeriphClockCmd // 使能I2C时钟
#define EEPROM_I2C_CLK RCC_APB1Periph_I2C1 // I2C时钟源
#define EEPROM_I2C_GPIO_APBxClock_FUN RCC_APB2PeriphClockCmd // 使能GPIO时钟
#define EEPROM_I2C_GPIO_CLK RCC_APB2Periph_GPIOB // GPIO时钟源
#define EEPROM_I2C_SCL_PORT GPIOB // SCL引脚所在GPIO端口
#define EEPROM_I2C_SCL_PIN GPIO_Pin_6 // 使用PB6引脚
#define EEPROM_I2C_SDA_PORT GPIOB // SDA引脚所在GPIO端口
#define EEPROM_I2C_SDA_PIN GPIO_Pin_7 // 使用PB7引脚
// STM32 I2C 快速模式
#define I2C_Speed 400000
// 这个地址只要与STM32外挂的I2C器件地址不一样即可
#define I2Cx_OWN_ADDRESS7 0X0A
// AT24C01/02每页有8个字节
#define I2C_PageSize 8
以上代码根据硬件连接,把与EEPROM通讯使用的I2C号 、引脚号都以宏封装起来,并且定义了自身的I2C地址及通讯速率,以便配置模式的时候使用。
- 初始化I2C的GPIO
利用上面的宏,编写I2C GPIO引脚的初始化函数
// IC2的IO基础配置
static void I2C_GPIO_Config(void)
{
// 1.定义一个结构体
GPIO_InitTypeDef GPIO_InitStructure;
// 2.使能与 I2C 有关的时钟
EEPROM_I2C_APBxClock_FUN(EEPROM_I2C_CLK, ENABLE ); // I2C时钟使能
EEPROM_I2C_GPIO_APBxClock_FUN(EEPROM_I2C_GPIO_CLK, ENABLE ); // GPIO时钟使能
// 3.配置I2C_SCL、I2C_SDA
GPIO_InitStructure.GPIO_Pin = EEPROM_I2C_SCL_PIN; // 选择引脚-PB6
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; // 选择速度50MHz
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD; // 使用开漏输出
GPIO_Init(EEPROM_I2C_SCL_PORT, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Pin = EEPROM_I2C_SDA_PIN; // 选择引脚-PB7
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; // 选择速度50MHz
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_OD; // 开漏输出
GPIO_Init(EEPROM_I2C_SDA_PORT, &GPIO_InitStructure);
}
开启相关的时钟并初始化GPIO引脚,函数执行流程如下:
-
使用GPIO_InitTypeDef定义GPIO初始化结构体变量, 以便下面用于存储GPIO配置;
-
调用库函数RCC_APB1PeriphClockCmd(代码中为宏EEPROM_I2C_APBxClock_FUN)使能I2C外设时钟, 调用RCC_APB2PeriphClockCmd(代码中为宏EEPROM_I2C_GPIO_APBxClock_FUN)来使能I2C引脚使用的GPIO端口时钟, 调用时我们使用“|”操作同时配置两个引脚。
-
向GPIO初始化结构体赋值,把引脚初始化成复用开漏模式, 要注意I2C的引脚必须使用这种模式。
-
使用以上初始化结构体的配置, 调用GPIO_Init函数向寄存器写入参数,完成GPIO的初始化。
- 配置I2C的模式
以上只是配置了I2C使用的引脚,还不算对I2C模式的配置,下面我们来配置一下:
// I2C工作模式配置
static void I2C_Mode_Configu(void)
{
// 1.首先定义一个结构体
I2C_InitTypeDef I2C_InitStructure;
// 2.I2C 配置
I2C_InitStructure.I2C_Mode = I2C_Mode_I2C; // 使用stm32库函数-stm32f10x_i2c.h中定义的I2C模式
// 3.使用高电平数据稳定,低电平数据变化 SCL 时钟线的占空比
I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2; // 使用stm32库函数-stm32f10x_i2c.h中定义的I2C时钟占空比
I2C_InitStructure.I2C_OwnAddress1 = I2Cx_OWN_ADDRESS7; // 选择自己的地址
I2C_InitStructure.I2C_Ack = I2C_Ack_Enable; // 使能应答
// 4.选择I2C的寻址模式
I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit; // 7位地址模式
// 5.选择通信速率
I2C_InitStructure.I2C_ClockSpeed = I2C_Speed; // 高速模式-400000Hz
// 6.I2C 初始化
I2C_Init(EEPROM_I2Cx, &I2C_InitStructure);
I2C_Cmd(EEPROM_I2Cx, ENABLE); // 使能I2C
}
熟悉STM32 I2C结构的话,这段初始化程序就十分好理解,它把I2C外设通讯时钟SCL的低/高电平比设置为2,使能响应功能, 使用7位地址I2C_OWN_ADDRESS7以及速率配置为I2C_Speed(前面在bsp_i2c_ee.h定义的宏)。最后调用库函数I2C_Init把这些配置写入寄存器, 并调用I2C_Cmd函数使能外设。
当然了,出现了新的库函数,我们是要解释一下的:
I2C_InitTypeDef
结构体
这个结构体用于配置 I2C 外设的工作模式。主要字段如下:
I2C_Mode
:
- 功能:选择 I2C 模式。
- 可选值:
I2C_Mode_I2C
:标准 I2C 模式。I2C_Mode_SMBusDevice
:SMBus 设备模式(仅适用于某些 STM32 设备)。
I2C_DutyCycle
:
- 功能:设置时钟线的占空比。
- 可选值:
I2C_DutyCycle_1
:占空比为 1/16(标准模式,100 kHz 时钟频率)。I2C_DutyCycle_2
:占空比为 1/8(标准模式,400 kHz 时钟频率)。
I2C_OwnAddress1
:
- 功能:配置 I2C 外设的自定义地址。
- 类型:这是一个 7 位或 10 位的地址,根据选择的地址模式。
- 示例值:
0x00
(如果不使用从设备地址)。
I2C_Ack
:
- 功能:选择应答功能。
- 可选值:
I2C_Ack_Enable
:启用应答。I2C_Ack_Disable
:禁用应答。
I2C_AcknowledgedAddress
:
- 功能:选择地址模式。
- 可选值:
I2C_AcknowledgedAddress_7bit
:7 位地址模式。I2C_AcknowledgedAddress_10bit
:10 位地址模式。
I2C_ClockSpeed
:
- 功能:设置 I2C 时钟频率。
- 示例值:可以设置为
100000
(100 kHz)或400000
(400 kHz)。
函数配置示例:
void I2C_Config(void)
{
I2C_InitTypeDef I2C_InitStructure;
// 配置 I2C 模式
I2C_InitStructure.I2C_Mode = I2C_Mode_I2C;
// 设置时钟占空比
I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2; // 高速模式下推荐
// 设置自己的地址
I2C_InitStructure.I2C_OwnAddress1 = 0x00;
// 启用应答
I2C_InitStructure.I2C_Ack = I2C_Ack_Enable;
// 设置地址模式
I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;
// 配置时钟速度
I2C_InitStructure.I2C_ClockSpeed = 100000; // 100 kHz
// 初始化 I2C
I2C_Init(I2C1, &I2C_InitStructure);
I2C_Cmd(I2C1, ENABLE);
}
---
为方便调用,我们把I2C的GPIO及模式配置都用I2C_EE_Init函数封装起来。
- 有关EEPROM的函数,我们只需知道就行了,在这里分析一个
初始化好I2C外设后,就可以使用I2C通讯,我们看看如何向EEPROM写入一个字节的数据
// 通讯等待超时时间
#define I2CT_FLAG_TIMEOUT ((uint32_t)0x1000)
#define I2CT_LONG_TIMEOUT ((uint32_t)(10 * I2CT_FLAG_TIMEOUT))
/**
* @brief I2C等待事件超时的情况下会调用这个函数来处理
* @param errorCode:错误代码,可以用来定位是哪个环节出错.
* @retval 返回0,表示IIC读取失败.
*/
static uint32_t I2C_TIMEOUT_UserCallback(uint8_t errorCode)
{
/* 使用串口printf输出错误信息,方便调试 */
EEPROM_ERROR("I2C 等待超时!errorCode = %d",errorCode);
return 0;
}
/**
* @brief 写一个字节到I2C EEPROM中
* @param pBuffer:缓冲区指针
* @param WriteAddr:写地址
* @retval 正常返回1,异常返回0
*/
uint32_t I2C_EE_ByteWrite(u8* pBuffer, u8 WriteAddr)
{
/* 产生I2C起始信号 */
I2C_GenerateSTART(EEPROM_I2Cx, ENABLE);
/*设置超时等待时间*/
I2CTimeout = I2CT_FLAG_TIMEOUT;
/* 检测 EV5 事件并清除标志*/
while (!I2C_CheckEvent(EEPROM_I2Cx, I2C_EVENT_MASTER_MODE_SELECT))
{
if ((I2CTimeout--) == 0) return I2C_TIMEOUT_UserCallback(0);
}
/* 发送EEPROM设备地址 */
I2C_Send7bitAddress(EEPROM_I2Cx, EEPROM_ADDRESS,
I2C_Direction_Transmitter);
I2CTimeout = I2CT_FLAG_TIMEOUT;
/* 检测 EV6 事件并清除标志*/
while (!I2C_CheckEvent(EEPROM_I2Cx,
I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED))
{
if ((I2CTimeout--) == 0) return I2C_TIMEOUT_UserCallback(1);
}
/* 发送要写入的EEPROM内部地址(即EEPROM内部存储器的地址) */
I2C_SendData(EEPROM_I2Cx, WriteAddr);
I2CTimeout = I2CT_FLAG_TIMEOUT;
/* 检测 EV8 事件并清除标志*/
while (!I2C_CheckEvent(EEPROM_I2Cx,
I2C_EVENT_MASTER_BYTE_TRANSMITTED))
{
if ((I2CTimeout--) == 0) return I2C_TIMEOUT_UserCallback(2);
}
/* 发送一字节要写入的数据 */
I2C_SendData(EEPROM_I2Cx, *pBuffer);
I2CTimeout = I2CT_FLAG_TIMEOUT;
/* 检测 EV8 事件并清除标志*/
while (!I2C_CheckEvent(EEPROM_I2Cx,
I2C_EVENT_MASTER_BYTE_TRANSMITTED))
{
if ((I2CTimeout--) == 0) return I2C_TIMEOUT_UserCallback(3);
}
/* 发送停止信号 */
I2C_GenerateSTOP(EEPROM_I2Cx, ENABLE);
return 1;
}
先来分析I2C_TIMEOUT_UserCallback函数,它的函数体里只调用了宏EEPROM_ERROR,这个宏封装了printf函数, 方便使用串口向上位机打印调试信息,阅读代码时把它当成printf函数即可。在I2C通讯的很多过程,都需要检测事件, 当检测到某事件后才能继续下一步的操作,但有时通讯错误或者I2C总线被占用,我们不能无休止地等待下去, 所以我们设定每个事件检测都有等待的时间上限,若超过这个时间,我们就调用I2C_TIMEOUT_UserCallback函数输出调试信息(或可以自己加其它操作),并终止I2C通讯。
了解了这个机制,再来分析I2C_EE_ByteWrite函数,这个函数实现了前面讲的I2C主发送器通讯流程
(1) 使用库函数I2C_GenerateSTART产生I2C起始信号, 其中的EEPROM_I2C宏是前面硬件定义相关的I2C编号;
(2) 对I2CTimeout变量赋值为宏I2CT_FLAG_TIMEOUT,这个I2CTimeout变量在下面的while循环中每次循环减1, 该循环通过调用库函数I2C_CheckEvent检测事件,若检测到事件,则进入通讯的下一阶段,若未检测到事件则停留在此处一直检测, 当检测I2CT_FLAG_TIMEOUT次都还没等待到事件则认为通讯失败,调用前面的I2C_TIMEOUT_UserCallback输出调试信息,并退出通讯;
(3) 调用库函数I2C_Send7bitAddress发送EEPROM的设备地址, 并把数据传输方向设置为I2C_Direction_Transmitter(即发送方向), 这个数据传输方向就是通过设置I2C通讯中紧跟地址后面的R/W位实现的。发送地址后以同样的方式检测EV6标志;
(4) 调用库函数I2C_SendData向EEPROM发送要写入的内部地址, 该地址是I2C_EE_ByteWrite函数的输入参数,发送完毕后等待EV8事件。 要注意这个内部地址跟上面的EEPROM地址不一样,上面的是指I2C总线设备的独立地址,而此处的内部地址是指EEPROM内数据组织的地址, 也可理解为EEPROM内存的地址或I2C设备的寄存器地址;
(5) 调用库函数I2C_SendData向EEPROM发送要写入的数据, 该数据是I2C_EE_ByteWrite函数的输入参数,发送完毕后等待EV8事件;
(6) 一个I2C通讯过程完毕, 调用I2C_GenerateSTOP发送停止信号。
在这个通讯过程中,STM32实际上通过I2C向EEPROM发送了两个数据,但为何第一个数据被解释为EEPROM的内存地址,这是由EEPROM的自己定义的单字节写入时序
EEPROM的单字节时序规定,向它写入数据的时候,第一个字节为内存地址,第二个字节是要写入的数据内容。 所以我们需要理解:命令、地址的本质都是数据,对数据的解释不同,它就有了不同的功能。
- main函数
完成基本的读写函数后,接下来我们编写一个读写测试函数来检验驱动程序
#include "stm32f10x.h"
#include "./led/bsp_led.h"
#include "./usart/bsp_usart.h"
#include "./i2c/bsp_i2c_ee.h"
#include <string.h>
#define EEP_Firstpage 0x00 // 定义EEPROM的起始页地址
uint8_t I2c_Buf_Write[256]; // 定义写入缓冲区
uint8_t I2c_Buf_Read[256]; // 定义读取缓冲区
uint8_t I2C_Test(void);
int main(void)
{
LED_GPIO_Config(); // LED初始化
LED_BLUE; // 蓝灯亮
USART_Config(); // USART初始化
printf("\r\n 这是一个I2C外设(AT24C02)读写测试例程 \r\n");
// I2C 外设初(AT24C02)始化
I2C_EE_Init();
printf("\r\n 这是一个I2C外设(AT24C02)读写测试例程 \r\n");
//EEPROM 读写测试
if(I2C_Test() == 1)
{
LED_GREEN; // 绿灯亮
}
else
{
LED_RED; // 错误,红灯亮
}
while (1)
{
}
}
// I2C EEPROM读写测试函数
uint8_t I2C_Test(void)
{
uint16_t i;
printf("写入的数据\n\r");
for(i=0; i <= 255; i++) //填充缓冲
{
I2c_Buf_Write[i] = i; // 写入数据
printf("0x%02X ", I2c_Buf_Write[i]); // 打印写入数据
if(i % 16 == 15) // 每行打印16个数据
printf("\n\r");
}
//将I2c_Buf_Write中顺序递增的数据写入EERPOM中
I2C_EE_BufferWrite(I2c_Buf_Write, EEP_Firstpage, 256); // 写入EEPROM函数参数:写入缓冲区,起始页地址,写入长度
EEPROM_INFO("\n\r写成功\n\r"); // 宏定义,打印提示信息
EEPROM_INFO("\n\r读出的数据\n\r"); // 宏定义,打印提示信息
//将EEPROM读出数据顺序保持到I2c_Buf_Read中
I2C_EE_BufferRead(I2c_Buf_Read, EEP_Firstpage, 256); // 读取EEPROM函数参数:读取缓冲区,起始页地址,读取长度
// 将I2c_Buf_Read中的数据通过串口打印
for(i = 0; i < 256; i++)
{
if(I2c_Buf_Read[i] != I2c_Buf_Write[i]) // 判断写入与读出的数据是否一致
{
EEPROM_ERROR("0x%02X ", I2c_Buf_Read[i]);
EEPROM_ERROR("错误:I2C EEPROM写入与读出的数据不一致\n\r");
return 0;
}
printf("0x%02X ", I2c_Buf_Read[i]); // 打印读出数据
if(i % 16 == 15) // 每行打印16个数据
printf("\n\r");
}
EEPROM_INFO("I2C(AT24C02)读写测试成功\n\r");
return 1;
}
代码中先填充一个数组,数组的内容为1,2,3至N,接着把这个数组的内容写入到EEPROM中,写入时可以采用单字节写入的方式或页写入的方式。 写入完毕后再从EEPROM的地址中读取数据,把读取得到的与写入的数据进行校验,若一致说明读写正常,否则读写过程有问题或者EEPROM芯片不正常。 其中代码用到的EEPROM_INFO跟EEPROM_ERROR宏类似,都是对printf函数的封装,使用和阅读代码时把它直接当成printf函数就好。 具体的宏定义在“bsp_i2c_ee.h文件中”,在以后的代码我们常常会用类似的宏来输出调试信息。
3. 小结
啊,说了怎么多,本质还是一个配置的事,因为stm32标准库的强大,我们想要使用功能只用配置就好了,接下来的一个模拟I2C可能就没那么顺利了。根据传统,现在我们需要总结一下程序了:
-
首先是要配置好串口和led了,目的是方便观察实验效果,串口和led我们都已经见过啦
-
接下来就是I2C和外设的宏定义了,和以往不同的是,我们还宏定义了信息输出模板
/*信息输出*/
#define EEPROM_DEBUG_ON 0
#define EEPROM_INFO(fmt,arg...) printf("<<-EEPROM-INFO->> "fmt"\n",##arg)
#define EEPROM_ERROR(fmt,arg...) printf("<<-EEPROM-ERROR->> "fmt"\n",##arg)
#define EEPROM_DEBUG(fmt,arg...) do{\
if(EEPROM_DEBUG_ON)\
printf("<<-EEPROM-DEBUG->> [%d]"fmt"\n",__LINE__, ##arg);\
}while(0)
-
正式开始配置:GPIO基础配置、I2C基础配置、I2C工作模式配置、外设(EEPROM)初始化
-
配置里面当然包含外设有关函数:写入数据、写入字节、循环写入、取出数据(这些我们复制粘贴就好了)
-
主函数测试:调用我们写好的函数,再写一个测试函数,外设正常绿灯亮、错误红灯亮
// I2C EEPROM读写测试函数
uint8_t I2C_Test(void)
{
uint16_t i;
printf("写入的数据\n\r");
for ( i=0; i <= 255; i++) //填充缓冲
{
I2c_Buf_Write[i] = i; // 写入数据
printf("0x%02X ", I2c_Buf_Write[i]); // 打印写入数据
if(i % 16 == 15) // 每行打印16个数据
printf("\n\r");
}
//将I2c_Buf_Write中顺序递增的数据写入EERPOM中
I2C_EE_BufferWrite(I2c_Buf_Write, EEP_Firstpage, 256); // 写入EEPROM函数参数:写入缓冲区,起始页地址,写入长度
EEPROM_INFO("\n\r写成功\n\r"); // 宏定义,打印提示信息
EEPROM_INFO("\n\r读出的数据\n\r"); // 宏定义,打印提示信息
//将EEPROM读出数据顺序保持到I2c_Buf_Read中
I2C_EE_BufferRead(I2c_Buf_Read, EEP_Firstpage, 256); // 读取EEPROM函数参数:读取缓冲区,起始页地址,读取长度
// 将I2c_Buf_Read中的数据通过串口打印
for(i = 0; i < 256; i++)
{
if(I2c_Buf_Read[i] != I2c_Buf_Write[i]) // 判断写入与读出的数据是否一致
{
EEPROM_ERROR("0x%02X ", I2c_Buf_Read[i]);
EEPROM_ERROR("错误:I2C EEPROM写入与读出的数据不一致\n\r");
return 0;
}
printf("0x%02X ", I2c_Buf_Read[i]); // 打印读出数据
if(i % 16 == 15) // 每行打印16个数据
printf("\n\r");
}
EEPROM_INFO("I2C(AT24C02)读写测试成功\n\r");
return 1;
}
2024.9.4 第一次修订,后期不再维护