硬件I2C驱动OLED实验

所谓硬件I2C,指的就是利用单片机内部自带的I2C硬件来完成I2C通信的过程,关于I2C通信的过程,我们已经学过了。

所以基本上所有原理性的东西我们都已经了解了,使用硬件I2C无非就是调用外设标准库函数,基于单片机的I2C外设来实现I2C通信的过程。

STM32F103C8T6-系统结构图

由于我们也已经使用过软件I2C了,所以对我们而言,硬件I2C的使用难度并不会很大,最多就是调用标准外设库函数的过程会比较繁琐。

我们仍然以一个实验案例来讲解学习硬件I2C,并且在这里我们将I2C和串口两种通信结合起来。

于是实验的电路接线图,就如下图所示:

串口通信接线-图

学习I2C,顺带再复习一下串口通信。当然单片机USART外设接收数据的方式也要使用中断,而不是一般的轮询方式

单片机I2C外设的系统框图

使用引脚I2C通信,就需要使用单片机的I2C硬件外设。

其系统框图如下图所示:

单片机I2C外设的系统框图

这个框图将在后续的内容讲解中起到重要作用。

实验目的

实验目的:

在PC端,使用串口调试工具向STM32单片机发送指令:

  1. 发送指令’0’,表示关闭OLED屏幕,同时单片机向PC端回复消息:“OLED-TurnOff: Success”
  2. 发送指令’1’,表示开启OLED屏幕并点亮所有像素,同时单片机向PC端回复消息:“OLED-TurnOn: Success”
  3. 发送指令’2’,表示询问OLED屏幕当前点亮状态,并回复PC端。比如:“OLED-Status: OFF"或者"OLED-Status: ON”

所以本质上和软件I2C实现的功能是差不多的,只不过把串口通信模块加进来了。

当然这里我们继续使用模块化编程的思想,我们先来实现简单的模块。

USART模块实现

串口通信我们已经学过了,像上述实验目的中的串口通信功能对我们而言,已经可以说是手拿把掐了。

在工程的"Tools"目录下新建两个文件:“USART1.c"和"USART1.h”,然后把它们加入Keil5软件的Tools Group组当中。

串口通信模块,我们只需要实现下列两个功能就可以了:

  1. 配置USART1外设。包括: 初始化引脚,初始化USART1外设,开启RXNE标志位中断以及初始化NVIC。
  2. 单片机向PC端发送字符串。

注:单片机接收PC端发送的控制指令,没有必要单独作为一个函数去定义,可以直接在中断服务程序当中接收数据。

// USART.h
#ifndef __USART_H
#define	__USART_H
#include "stm32f10x.h"
#include "./Delay.h"
void init_USART1(void);
void SendString(char *data);
#endif

#include "./USART.h"

#define RX GPIO_Pin_10
#define TX GPIO_Pin_9
#define USART_PORT GPIOA
// 初始化USART1 引脚
static void init_USART_PIN(void){
	// 开时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
	GPIO_InitTypeDef GPIO_InitStruct;
	GPIO_InitStruct.GPIO_Speed = GPIO_Speed_2MHz;
	
	// 初始化Rx引脚PA10
	GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING;
	GPIO_InitStruct.GPIO_Pin = RX;
	GPIO_Init(GPIOA, &GPIO_InitStruct);
	
	// 初始化Tx引脚PA9
	GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
	GPIO_InitStruct.GPIO_Pin = TX;
	GPIO_Init(USART_PORT, &GPIO_InitStruct);

}

// 初始化USART1外设
static void init_USART(void){
	// 初始化USART引脚
	init_USART_PIN();
	// 开时钟
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
	USART_InitTypeDef USART_InitStruct;
	// 设置波特率
	USART_InitStruct.USART_BaudRate = 115200;
	// 不设置硬件控制流
	USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
	// 设置读写模式
	USART_InitStruct.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;
	// 无校验位
	USART_InitStruct.USART_Parity = USART_Parity_No ;
	// 设置一位停止位
	USART_InitStruct.USART_StopBits = USART_StopBits_1 ;
	// 传输长度为8b
	USART_InitStruct.USART_WordLength = USART_WordLength_8b;
	// 初始化USART1
	USART_Init(USART1, &USART_InitStruct);
	//	写使能
	USART_Cmd(USART1, ENABLE);
}
// 初始化NVIC
static void init_NVIC(void){
	// 初始化NVIC之前先设置中断分组
	NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
	
	//
	NVIC_InitTypeDef NVIC_InitStruct;
	
	NVIC_InitStruct.NVIC_IRQChannel = USART1_IRQn ;
	// 开启NVIC
	NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE;
	// 设置中断优先级
	NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 0;
	// 设置子优先级
	NVIC_InitStruct.NVIC_IRQChannelSubPriority = 0;
    // 初始化
    NVIC_Init(&NVIC_InitStruct);
	
}
// 设置中断源
void USART1_RXNEConfig(void){
	USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);
}

void init_USART1(void){
	init_USART_PIN();
	init_USART();
	init_NVIC();
	USART1_RXNEConfig();
}

// 发送字符串
void SendString(char *data){
	
	while(*data) {
		// 发送寄存器为空时,发送一个字节
		if(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == SET) USART_SendData(USART1, *data++);
		
	}
}

实现分析:OLED模块

在工程的"Tools"目录下新建两个文件:“OLED.c"和"OLED.h”,然后把它们加入Keil5软件的Tools group组当中。

首先硬件IC2在实现时,直接使用了STM32的I2C外设,采用的编程手段是标准外设库函数实现。

所以我们没有必要再去弄一个"I2C"模块,可以直接在OLED模块中调用标准库函数,实现我们需要的功能。

OLED模块需要的功能一共有四个,"OLED.h"的参考代码如下:

#ifndef __OLED_H
#define __OLED_H
#include "stm32f10x.h"
#include "../tools/Delay.h"
typedef enum{
    SUCCESS,
    FAIL
}RES;

// 初始化OLED屏幕
void init_OLED(void);
// 点亮OLED
RES OLED_On(void);
// 熄灭OLED
RES OLED_Off(void);
// 读取OLED开关状态
RES OLED_ReadStatus(uint8_t *OLED_StatusPtr);

#endif

注意下方三个涉及到和OLED屏幕通信的函数,都是具有返回值的。

这是因为我们通过标准库来实现I2C通信时,可以通过I2C外设内置的各种标志位进行错误校验和处理,所以这几个函数我们使用枚举类型给定了RES类型的返回值。

下面看一下具体的每一个函数的实现。

引脚和I2C外设初始化

在上述实验中,STM32单片机中我们使用的I2C引脚是:

  1. SCL — PB10
  2. SDA — PB11

在一般情况下,PB10和PB11两个引脚都只是一般的、普通IO引脚。那么这两个引脚可以作为实现硬件I2C通信的引脚吗?

我们可以通过查表来获取这两个引脚的复用和重定义功能,以确定如何初始化这两个引脚。

具体的引脚定义表可以参考之前的文档

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

通过查表,我们可以得出下列信息,STM32F103C8T6共有下列三对可用为I2C通信的引脚:

假如使用引脚的复用功能的话:

第一对:

  1. PB6的复用功能是: I2C1_SCL,即I2C1外设的SCL引脚。
  2. PB7的复用功能是: I2C1_SDA,即I2C1外设的SDA引脚。

第二对:

  1. PB10的复用功能是: I2C2_SCL,即I2C2外设的SCL引脚。
  2. PB11的复用功能是: I2C2_SDA,即I2C2外设的SDA引脚。

根据我们选择的接线方式,我们使用的I2C硬件外设是I2C2,PB10作为SCL引脚,PB11作为SDA引脚。

当然,这两个引脚都需要设置为复用开漏输出模式。

第三对:

  1. PB8的重定义功能是: I2C1_SCL,即I2C1外设的SCL引脚。
  2. PB9的重定义功能是: I2C1_SDA,即I2C1外设的SDA引脚。

总之,有三对实现硬件I2C的引脚可以选择,但我们选择其中的第二对。

在这里需要注意,先看下图

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

我们首先要初始化GPIOB外设,它挂载在APB2,而I2C1/I2C2则是挂载在APB1,所以在初始化开时钟时要注意这一点

void init_OLED(void){
	// 初始化引脚
	init_I2C_Pin();
	
	// 	复位I2C外设
	RCC_APB1PeriphResetCmd(RCC_APB1Periph_I2C2, ENABLE);  // 复位施加信号,类似于按下RESET按键
	 RCC_APB1PeriphResetCmd(RCC_APB1Periph_I2C2, DISABLE);// 复位释放信号,类似于释放RESET按键
	
	// 开时钟
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C2, ENABLE);
	
	I2C_InitTypeDef I2C_InitStruct;
	// 设置ACK
	I2C_InitStruct.I2C_Ack = I2C_Ack_Enable;
	// 指定设备地址的长度,即使用 7 位还是 10 位地址
	// 我们学习的是7位字节,即传输1Byte(7 + 1) 7位地址和1位读写
	I2C_InitStruct.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;
	// 设置时钟频率,标准模式100kHz,快速模式400kHz
	// 我们使用硬件实现I2C可以选择快速模式400kHz
	I2C_InitStruct.I2C_ClockSpeed = 400000;
	// 在快速模式(Fast Mode)下,设置 SCL 时钟信号的占空比
	// 就是设置SCL 信号高电平和低电平持续时间的比例可以设置2:1也可以设置16:9 差别不大
	I2C_InitStruct.I2C_DutyCycle = I2C_DutyCycle_2;
	// 设置标准I2C协议模式
	I2C_InitStruct.I2C_Mode = I2C_Mode_I2C;
	// 设置 STM32 自身作为 I2C 设备时的第一个地址。
	// 我们作为主机,故不需要设置
	//I2C_InitStruct.I2C_OwnAddress1 = ;
	I2C_Init(I2C2, &I2C_InitStruct);
	// 使能开启
	I2C_Cmd(I2C2, ENABLE);
}

这里有三处我们之前没有学过的内容,这里简单说一下。

I2C外设复位操作

I2C外设的复位:

代码中出现的下面两行代码:

RCC_APB1PeriphResetCmd(RCC_APB1Periph_I2C2, ENABLE);     // 施加复位信号
RCC_APB1PeriphResetCmd(RCC_APB1Periph_I2C2, DISABLE);    // 释放复位信号

它们的作用就是复位I2C外设,类似最小系统板上的复位按钮,它的主要作用是:

  1. 清除 I2C2 可能存在的错误状态(如 BUSY、ARBLOST 等)。
  2. 确保 I2C2 处于一个已知的初始状态,避免软件初始化后仍然异常。
  3. 重新初始化 I2C2 的内部寄存器,避免上一次的残留配置影响新初始化。

如果I2C外设之前被启动过,而且存在一些错误没有被清除,直接调用I2C_Init函数初始化它有可能无法重新工作。

所以利用硬件复位操作,让I2C外设彻底清空并重新开始工作是初始化I2C外设的常见做法.

但是一定要注意,该操作会清空重置I2C外设的一切配置,所以应该在I2C_Init函数初始化之前调用。

由于I2C外设的特殊性,所以在初始化I2C外设之前初始化它,是一个推荐的做法!

注(了解):

为什么I2C具有独特的复位机制,而其它的通信外设(USART、SPI等)往往不需要呢?

简单来说:

  1. I2C外设设计更复杂,实现的功能也更多。比如I2C外设实际支持多主多从模式,这使得I2C外设在通信时更容易出错导致外设卡死,从而就需要复位后再使用。
  2. I2C通信过程中,严格依赖双方的"互动"流程。比如单片机给从机设备发送数据,从机断开连接无响应,此时单片机的I2C外设就会卡死,这时如果不复位I2C外设就直接初始化使用是不行的。

再通俗点说:“单片机的I2C外设,是一个精密复杂的仪器,一旦某个流程没有完成它就会卡死在原地,需要重启(复位)它才会重新开始工作”。

I2C_Init函数和I2C_Cmd函数

I2C_Init函数和I2C_Cmd函数:

这一对函数同时都要调用,用于初始化I2C外设。类似的操作,我们之前也见过,和初始化USART外设是一样的。

通俗点说这两个函数的作用是:

  1. I2C_Init函数用于配置I2C外设。
  2. I2C_Cmd函数相当于一个控制此外设的总开关,用于开启和关闭此I2C外设。

下面来简单介绍一下这两个函数:

I2C_Cmd函数:

其函数声明如下:

void I2C_Cmd(I2C_TypeDef* I2Cx, FunctionalState NewState);

其传参的选择十分简单,如下所示:

参数 类型 说明
I2Cx I2C_TypeDef* 指定要控制的 I2C 外设,取值范围:I2C1I2C2(STM32F103)
NewState FunctionalState 开启或禁用 I2C 外设,取值:ENABLE(开启) 或 DISABLE(禁用)。

I2C_Init函数:

I2C_Init 是 STM32 标准外设库提供的一个函数,用于初始化和配置 I2C 外设,包括时钟速度、地址模式、应答模式等。

其函数声明如下:

void I2C_Init(I2C_TypeDef* I2Cx, I2C_InitTypeDef* I2C_InitStruct);

其第一个参数很简单,传参想要初始化的I2C外设,对于STM32F103系列来说就只有两个I2C外设,即I2C1I2C2

其核心参数是第二个参数,和以往学习的所有Init函数一样,需要传入一个I2C_InitTypeDef类型的结构图对象指针。

这个结构体的类型定义如下:

typedef struct{
    uint32_t I2C_ClockSpeed;          // 设置 I2C 时钟频率(单位:Hz)
    uint16_t I2C_Mode;                // 设置 I2C 工作模式
    uint16_t I2C_DutyCycle;           // 配置 I2C 时钟的占空比
    uint16_t I2C_OwnAddress1;         // 配置设备自身的 I2C 地址
    uint16_t I2C_Ack;                 // 启用或禁用 I2C 硬件的应答(ACK)
    uint16_t I2C_AcknowledgedAddress; // 配置 I2C 的应答地址模式
} I2C_InitTypeDef;

逐一来解释一下这些成员:

I2C_ClockSpeed成员:

该成员需要传参一个32位无符号整数,用于设置 I2C 的时钟频率。

I2C的时钟频率是衡量I2C通信数据传输速率的核心要素,在上面软I2C的实现中我们已经讲了这个概念。

I2C协议当中的规定了典型的三种时钟线频率,如下表所示:

模式 最大 SCL 频率 SCL 一个时钟周期 理论最大传输速率(单位:千字节每秒)
标准模式 (SM) 100 kHz (100,000 Hz) 10 μs 11.1KB/s
快速模式 (FM) 400 kHz (400,000 Hz) 2.5 μs 44.4KB/s
高速模式 (HS) 1 MHz (1,000,000 Hz) 1 μs 111.1KB/s

我们使用的STM32F10x系列芯片最大只支持快速模式,也就是只能选择标准模式和快速模式,高速模式乃至于更高速度的模式只有更高性能、更贵的芯片才支持(比如F4系列)。

在实际传参时,我们可以直接手动传参400000这个整数,来表示选择使用I2C通信的快速模式,即SCL线时钟频率是400kHz。

既然选择使用硬件I2C,就要发挥其通信速率高的优势,而且快速模式大多数从机设备也都是支持的,通常都不会担心兼容性问题。

I2C_Mode成员:

用于设置I2C外设的工作模式。

标准库中给定了三个可以选择的取值:

#define I2C_Mode_I2C                    ((uint16_t)0x0000)
#define I2C_Mode_SMBusDevice            ((uint16_t)0x0002)  
#define I2C_Mode_SMBusHost              ((uint16_t)0x000A)

I2C_Mode_I2C:最常用的 I2C 模式,也是标准的 I2C 模式,通常用于正常的 I2C 数据传输。

I2C_Mode_SMBusDevice:SMBus 从机模式。

I2C_Mode_SMBusHost:SMBus 主机模式。

SMBus(系统管理总线)是一种在 I2C 基础上进行扩展的协议,当前我们就选择使用标准I2C通信模式即可。

所以此成员只需要直接设置成I2C_Mode_I2C即可。

I2C_DutyCycle成员:

I2C_DutyCycle 成员是用于设置 I2C 通信中SCL时钟周期的占空比 的一个配置项。

**所谓占空比,是描述1个时钟周期内高电平与低电平时间比例的参数。**也就是说占空比影响 SCL 时钟信号线上的高电平和低电平的持续时间比。

占空比有啥用呢?

在一个时钟周期内低电平的比例越大,高电平比例就越小:

  1. 此时发送端准备数据的时间就会更长,通信抗干扰的能力会更强一些。
  2. 但由于高电平的时间占比更小了,工作时间变短,接收端采集数据的响应速度就会变短。这就要求接收端需要更快响应数据接收。

在一个时钟周期内高电平的比例越大,低电平比例就越小:

  1. 此时接收端采集数据的响应时间就变长了,对接收端设备的要求就会降低。
  2. 但发送端发送数据的准备时间更短,通信就更容易受到干扰。

注意:占空比和通信速率没什么关系,只要SCL信号线频率固定,时钟周期就是固定的数量,高低电平的比例不影响通信速率。

SPL标准库为程序员配置I2C时,提供了两个占空比选项:

  1. I2C_DutyCycle_16_9 表示"低电平:高电平"的比值为 16:9:
    1. 高电平占比大概36%
    2. 低电平占比大概64%
  2. I2C_DutyCycle_2 表示"低电平:高电平"的比值为 2:1:
    1. 高电平占比大概33%
    2. 低电平占比大概67%

所以这两个占空比其实差距不大,相对而言,I2C_DutyCycle_2的低电平占比更高,通信的抗干扰能够更好,支持这种占空比的从机设备也更多,是硬件I2C通信更常见的选择。

当你选择使用I2C通信的快速模式时,占空比属于必须要配置的一个成员属性,不能省略!!!

I2C_OwnAddress1成员:

I2C通信是主从模式的,一般情况下单片机都被视为主机,但I2C也支持将单片机作为从机使用。

若选择单片机作为从机的模式,为了让外部主机寻址到该从机单片机,就需要提供此单片机的地址。

这个参数就用于表示这个地址。

但我们不会选择将单片机作为从机使用,所以该成员可以忽略,不进行配置。

I2C_Ack成员:

I2C_Ack用于配置单片机作为接收端时(也就是单片机读从机数据时),是否向发送端回复ACK。

它的传参选项有两个:

  1. I2C_Ack_Enable:启用应答,单片机收到数据后会发送 ACK,表示数据成功接收。
  2. I2C_Ack_Disable:禁用应答,单片机收到数据后会直接发送 NACK。

如果你希望开启主机应答就配置开始,当然也可以选择禁用主机应答。

主机应答通常是开启的,所以我们可以直接给该成员配置为I2C_Ack_Enable取值。

I2C_AcknowledgedAddress成员:

配置单片机在作为从机时,其从机设备地址的长度是7位还是10位。

但我们不会选择将单片机作为从机使用,所以该成员可以忽略,不进行配置。

核心实现:主机发送多个控制指令

在单片机与OLED屏幕进行I2C通信的过程中,单片机需要向OLED从机发送多个控制指令,从而实现控制OLED的功能。

这里需要实现的核心函数就是:

static int8_t hOLED_I2CWriteCmds(const uint8_t *Cmds, uint8_t Length) 

该函数我们在软I2C中也实现过类似的,现在我们改用STM32标准外设库来实现这个函数。

I2C外设状态标志位

使用硬件I2C实现通信的过程中,不得不提的一个概念就是——I2C外设状态标志位。

利用单片机的USART外设实现串口通信时,我们是通过USART外设标志位来完成通信过程的,那么I2C外设也不例外。

STM32的I2C外设中,存在两个用于存储表示当前I2C通信状态、硬件状态的寄存器:

  1. SR1,即Status Register1
  2. SR2,即Status Register2

通过这些状态标志位,用户可以了解 I2C 通信的进展,检查错误状态,或者确保数据传输的每个步骤正确执行。

以下是 STM32 的 I2C 外设常用状态标志位的表格描述:

标志位宏定义 英文词组含义 标志位含义 标志位置为 1 时的条件 用途
I2C_FLAG_BUSY BUSY flag 总线忙碌标志 当 I2C 总线正在传输数据时,标志位置为 1 用于判断总线是否空闲,确保在总线空闲时开始通信
I2C_FLAG_SB START Bit flag 起始位发送完成标志 起始位(START)发送完成时,标志位置为 1 用于判断起始信号是否成功发送,确保后续步骤可进行
I2C_FLAG_ADDR Address flag 从机地址匹配成功标志 主机地址与从机地址匹配时,标志位置为 1 确保主从机地址匹配完成,可以进入数据传输阶段
I2C_FLAG_AF Acknowledge Failure flag 主机收到从机NACK标志 从机发送 NACK时,标志位置为 1 用于检测 ACK 错误,通常表示接收失败或通信中断
I2C_FLAG_TXE Transmit Empty flag 发送数据寄存器空标志 发送寄存器为空时,,标志位置为 1 用于判断是否可以向数据寄存器写入新的数据**(不能用于发地址)**
I2C_FLAG_RXNE Receive Not Empty flag 接收数据寄存器非空标志 接收寄存器中有数据时,标志位置为 1 用于判断接收寄存器是否有数据,确保可以读取数据
I2C_FLAG_BTF Byte Transfer Finished flag 字节传输完成标志 当前字节传输完成,标志位置为 1 用于判断发送和移位寄存器都为空,确保数据传输已成功结束
I2C_FLAG_STOPF STOP flag 停止位发送完成标志 停止位已发送完成时,标志位置为 1 用于判断通信是否完全结束,确认 I2C 总线空闲

那么如何来获取和清零这些状态标志位呢?

这就需要使用两个标准库函数了,即I2C_GetFlagStatus函数I2C_ClearFlag函数

I2C_GetFlagStatus函数: 用于获取某个I2C外设的某个状态标志位的取值。

其函数声明如下:

FlagStatus I2C_GetFlagStatus(I2C_TypeDef* I2Cx, uint32_t I2C_FLAG);

函数的两个形参:

  1. I2Cx: 指示要获取标志位的IC2外设名,可以传参 I2C1或者I2C2
  2. I2C_FLAG: 要检查的状态标志位。状态标志位可以是各种 I2C 状态寄存器中的标志。例如:I2C_FLAG_SBI2C_FLAG_ADDRI2C_FLAG_RXNE 等,具体的内容可以参考上面表格的第一列。

函数的返回值:

  1. 函数返回SET也就是1,表示标志位已被设置,状态标志位的条件已经满足。
  2. 函数返回RESET也就是0,表示标志位已被清零,状态标志位的条件未满足。

**I2C_ClearFlag函数:**用于清零某个I2C外设的某个状态标志位。

其函数声明如下:

void I2C_ClearFlag(I2C_TypeDef* I2Cx, uint32_t I2C_FLAG);

这个函数没有返回值,形参的使用和上面的函数完全一致,不再赘述。

了解了I2C外设的状态标志位概念后,下面我们就按照软I2C的实现思路,来一一找到我们需要的库函数:

  1. 主机发送起始位信号
  2. 主机发送寻址字节
  3. 主机读取从机发送的ACK确认
  4. 主机向从机发送多个字节的控制字节和其它控制指令。
  5. 主机读取从机发送的ACK确认
  6. 主机发送停止位信号

起始位发送函数

若想实现主机发送起始位信号,需要调用:I2C_GenerateSTART函数

这个函数非常简单,就表示主机发送起始位信号,其函数形参如下:

void I2C_GenerateSTART(I2C_TypeDef* I2Cx, FunctionalState NewState);

第一个参数指示生成起始信号的I2C外设,可以传参 I2C1或者I2C2

第二个参数我们也很熟悉,它可以传参以下枚举类型:

typedef enum {DISABLE = 0, ENABLE = !DISABLE} FunctionalState;

传参**ENABLE**即表示主机发送了一个起始位信号。

调用完这个函数后,不要着急继续发送寻址字节,标志位中的I2C_FLAG_SB用于指示发送起始信号完成。

该函数的调用表示通信开始,建议采用以下方式来调用这个函数:

调用该函数之前,先检查总线是否忙碌,若总线处于忙碌状态,即总线正在传输数据,则无法开始新的通信数据传输。

所以需要等待总线空闲:

while (I2C_GetFlagStatus(I2C2, I2C_FLAG_BUSY) == SET);  // 等待 I2C2 外设总线空闲,也就是等待BUSY标志位复位

除此之外,在调用此函数发送起始信号后,还需要调用:

while (I2C_GetFlagStatus(I2C2, I2C_FLAG_SB) == RESET);  // 等待I2C2外设作为主机, 发送完毕起始位

用于等待SB标志位置位,即表示主机发送起始信号结束。

上面两个状态标志位处理加在一起,所以在硬件I2C实现主机发送起始位的推荐方式是:

// 等待 I2C2 外设总线空闲
while (I2C_GetFlagStatus(I2C2, I2C_FLAG_BUSY) == SET); 

// 主机发送起始位信号
I2C_GenerateSTART(I2C2, ENABLE);

// 等待确认起始位发送完成
while (I2C_GetFlagStatus(I2C2, I2C_FLAG_SB) == RESET);

不要忘记使用I2C外设寄存器的状态标志位!依靠状态标志位指示通信状态,完成通信流程,是硬件I2C和软I2C非常显著的区别。

BUSY状态标志位的置位复位机制

I2C_FLAG_BUSY(BUSY 总线忙标志位)全程由硬件自动置位与自动清除不需要也不允许软件手动干预

参考手册中是这样描述的:

BUSY状态标志位的机制-图图 4.3-1 BUSY状态标志位的机制-图

举一些例子:

  1. 主机发送起始位成功(START 成功),BUSY状态标志位就会自动置位,表示总线处于忙碌通信状态…
  2. 通信结束,主机发送停止位,两条总线都回归高电平,总线进入空闲状态,BUSY标志位自动复位。

所以我们完全不需要操作BUSY标志位的置位和复位问题,可以放心的使用这个标志位。

除了BUSY状态标志位,我们还使用了SB状态标志位,那么它的置位与复位是如何处理的呢?

这一点,我们放到下面的小节来进行讲解。

停止位发送函数

起始位和停止位发送函数的名字非常类似,所以我们直接一起看。

只需要把I2C_GenerateSTART函数名,改成I2C_GenerateSTOP即可表示主机发送停止位。

其函数声明如下:

void I2C_GenerateSTOP(I2C_TypeDef* I2Cx, FunctionalState NewState);

停止位发送函数在发送时,通常不需要对标志位进行处理判断,可以直接调用表示结束通信。具体代码这里就不再赘述了。

I2C_Send7bitAddress函数

主机发送起始位完成后,接下来就轮到主机发送寻址字节了。

在实现软件I2C时,我们没有专门编写函数实现主机发送寻址字节,而是直接将寻址字节、普通数据字节的发送共用了一个函数。

但是硬件I2C的SPL标准外设库,却将这两个发送操作分开了:

  1. I2C_Send7bitAddress函数,专门用于表示主机发送寻址字节。
  2. I2C_SendData函数,专门用于表示主机发送普通数据字节。

注意这两个函数不要混用,既然使用SPL标准外设库,操作单片机的I2C外设实现I2C通信,就需要严格遵守SPL库设计好的通信流程,这是由底层硬件的控制逻辑决定的,不像软件 I2C 可以自由处理。

那么该如何使用I2C_Send7bitAddress函数,完成主机寻址字节的发送呢?

此函数声明如下:

void I2C_Send7bitAddress(I2C_TypeDef* I2Cx, uint8_t Address, uint8_t Direction);

一共有三个参数:

第一个参数I2Cx:

这个参数非常简单,用于指定哪个 I2C 外设发送寻址字节,传参取值比如I2C1I2C2

第二个参数Address:

第二个参数是该函数在调用传参时最需要注意的。

应该来说,这是ST公司设计I2C外设的SPL标准库时,非常坑爹的一个参数设计!!!

SPL库源码中的注释如下:

Address: specifies the slave address which will be transmitted

翻译成中文,意为:

Address: 指定了将要传输的从机地址。

这个注释简直就是一句彻头彻尾的废话,因为它根本就没有说清楚。现在Address的传参可以有以下可能:

  1. 像软I2C一样,传入一个带有7位从机地址,加上一位读写标志位的寻址字节。
  2. 直接传参7位从机地址,但是最高位补一个0
  3. 直接传参7位从机地址,但是最低位补一个0

那么到底该如何传参呢?

这就需要结合这个函数的源码来分析了,分析过程如下图所示:

发送寻址字节函数-源码分析图

经过这样一段分析,你知道Address参数应该如何传参了嘛?

很简单:

只需要用7位从机地址,然后在末尾低位随便加一个0或者1都可以!!!

注意仍然要传参1个字节寻址字节,只不过最低位的0或1随便填!!!

举个例子:

OLED从机设备的7位从机地址是:

011 1100

在实际传参Address时,可以传参:

0111 1000:0x78

0111 1001:0x79

为什么呢?

因为I2C_Send7bitAddress函数在实现时,最终发送的寻址字节的最低位是由参数Direction来确定的,不管之前是0还是1,最终都能根据Direction参数的传入获得一个正确读写标志位。

所以程序员只需要保证高7位的从机地址传参正确即可,至于最低位实际上是无所谓的。

第三个参数Direction:

参数名的含义是方向,实际上这个参数就表示寻址字节当中的读写标志位,该参数的取值通过查看注释可以得知有两个:

  1. I2C_Direction_Transmitter:表示单片机主机作为发送端,发送数据给从机,也就是读写当中的写。
  2. I2C_Direction_Receiver:表示单片机主机作为接收端,从机发送数据给主机,也就是读写当中的读。

这个参数的传参还是非常简单。

现在如果你想向从机OLED屏幕发送数据,你只需要这样调用函数,发送寻址字节:

I2C_Send7bitAddress(I2C2, 0x78, I2C_Direction_Transmitter);     // 这里也可以传参Ox79

现在如果你想读从机OLED屏幕发送的状态字节,你只需要这样调用函数,发送寻址字节:

I2C_Send7bitAddress(I2C2, 0x79, I2C_Direction_Receiver);        // 这里也可以传参Ox78

注意,寻址字节一定要在发送起始位完成,并且成功后,才可以调用此函数发送。

以上。

SB状态标志位的置位复位机制

现在已经完成了寻址字节的发送,我们先不考虑后续的处理(比如从机应答处理),我们先来考虑一下:SB状态标志位的置位复位机制。

SB标志位,用于指示主机发送起始位完成,它的置位和复位机制,参考手册中是这么描述的:

SB状态标志位的机制-图图 4.6-1 SB状态标志位的机制-图

所以当主机发送完成起始位后,硬件会自动置位 SB,表明主机发送起始位完成、通信已经开始了,接下来主机可以发送寻址字节了。

所以SB标志位的置位由硬件自动置位,那么它什么时候复位呢?

它复位依赖于软件自动复位:

软件读取SR1寄存器后,写数据寄存器的操作将清除该位。

这样的操作如何实现呢?

我们实现的代码中是这么写的:

// 等待 I2C2 外设总线空闲
while (I2C_GetFlagStatus(I2C2, I2C_FLAG_BUSY) == SET); 

// 主机发送起始位信号
I2C_GenerateSTART(I2C2, ENABLE);

// 等待确认起始位发送完成
while (I2C_GetFlagStatus(I2C2, I2C_FLAG_SB) == RESET);

// 主机发送寻址字节
I2C_Send7bitAddress(I2C2, 0x78, I2C_Direction_Transmitter); 

// 代码执行到这里,依赖于软件自动复位,SB标志位复位

其中:

  1. while (I2C_GetFlagStatus(I2C2, I2C_FLAG_SB) == RESET);用于获取SB状态标志位的状态,而SB标志位就存储在SR1寄存器当中,这样就完成了读SR1寄存器。
  2. I2C_Send7bitAddress(I2C2, 0x79, I2C_Direction_Transmitter);用于主机发送寻址字节给从机,这个过程中显然写了(发送)数据寄存器。

于是这样在"无形中",就完成了SB状态标志位的软件自动复位。

代码写到这里,我们使用的两个状态标志位:SB和BUSY,置位和复位都可以算得上自动管理,程序员不需要额外考虑手动复位问题。

主机发送寻址字节后的处理

主机发送寻址字节后,从机如果收到会应答ACK,主机则需要读这个从机ACK,从而确定寻址是否成功。

这个操作在我们自己实现的软件I2C当中,非常简单,但硬件I2C关于主机发送寻址字节后的处理就麻烦多了。

首先,主机发送寻址字节后的处理,仍然依赖标志位完成,这里就需要用到两个标志位了:

  1. ADDR标志位,宏定义为I2C_FLAG_ADDR,复位时表示寻址字节尚未发送完成,置位时表示寻址成功,即主机收到从机应答。
  2. AF标志位,宏定义为I2C_FLAG_AF,复位状态表示应答没有失败,置位时表示未收到从机应答,应答失败,即从机无应答。

很明显这两个标志位是互斥的,而且寻址字节发送后一定会有一个标志位置位:

  1. 一旦寻址成功,ADDR标志位置位,AF标志位一定不会置位。
  2. 如果寻址失败,AF标志位置位,ADD标志位则一定不会置位。

所以我们可以写一个循环,来等待这两个标志位中的某一个置位:

// 等待AF和ADDR两个标志位的其中一个置位,也就是等待寻址成功亦或者失败
while (I2C_GetFlagStatus(I2C2, I2C_FLAG_ADDR) == RESET 
       && I2C_GetFlagStatus(I2C2, I2C_FLAG_AF) == RESET
      );
// 代码执行到这里说明两个标志位一定有一个置位了,接下来进行处理

寻址成功后的处理:

寻址成功后,ADDR标志位会被置位,按照ST公司的I2C外设设计,当ADDR标志置为1后,总线会等待手动复位此标志位,否则I2C外设将始终等待无法进行后续的数据传输工作。

如何复位ADDR标志位呢?

ST公司关于复位ADDR标志位的设计比较繁琐,要求必须使用I2C_ReadRegister函数先读SR1寄存器,后读SR2寄存器才能够完全清除ADDR标志位。

具体的函数调用代码就是:

// 如果ADDR标志位置位,寻址成功
if (I2C_GetFlagStatus(I2C2, I2C_FLAG_ADDR) == SET) {
    // 复位 ADDR 状态标志位
    I2C_ReadRegister(I2C2, I2C_Register_SR1);  // 读取 SR1 寄存器
    I2C_ReadRegister(I2C2, I2C_Register_SR2);  // 读取 SR2 寄存器
}

ADDR 标志位被ST公司设计为必须依赖读SR1和SR2两个硬件寄存器才能被复位(清零),无法通过手动调用函数软件复位!

一定要注意,寻址成功后,要及时按照手册中的说明来复位ADDR标志位,如果忘记了复位,那么后续通信将无法继续进行。

寻址失败后的处理:

寻址失败后,AF标志位会被置位,表示应答失败。

ST公司在设计I2C外设的AF标志位时,做了这样的设定:

  1. AF标志位一旦置位,必须由程序员手动调用函数,手动软件复位!!
  2. AF标志位一旦置位,I2C通信就一定无法继续,通信将处于卡死状态!!
  3. 如果不进行AF标志位的手动软件复位,那么AF标志位将持续保持置位状态,乃至于影响下一次I2C通信过程!!!

AF标志位的这种强制设定,使得程序员不能忽视通信过程中的应答错误,避免了通信在"带病、异常"的情况下继续强行进行,也能够使得程序员必须专注于解决通信中的问题,从而实现更健壮、更可用的通信。

当然,这么设计也会有一些缺点:

AF标志位如果忘记了手动复位,将会始终保持置位,影响后续的通信,这也是我们初始化I2C外设时,最好先复位外设的原因。

基于上述描述,一旦寻址失败,我们应该仅需下述处理:

// 如果AF标志位置位,在这里表示寻址失败
if (I2C_GetFlagStatus(I2C2, I2C_FLAG_AF) == SET) {
    // 手动软件复位AF标志位
    I2C_ClearFlag(I2C2, I2C_FLAG_AF);
    // 直接发送停止位, 表示终止通信
    I2C_GenerateSTOP(I2C2, ENABLE);
    // 可以继续其它处理,这里函数返回-1表示寻址失败
    return -1;
}

到这里,我们就完成了寻址字节发送后的处理,相信你已经感受到了硬件I2C使用的繁琐和不便,不过这也没什么办法,别人怎么设计,我们就必须怎么用。

I2C_SendData函数

寻址成功后,下面主机向从机发送普通数据字节,这时就需要使用函数:I2C_SendData函数。

该函数的声明如下:

void I2C_SendData(I2C_TypeDef* I2Cx, uint8_t Data);

这个函数的调用十分的简单,第一个参数就表示主机的I2Cx外设,第二个参数表示向从机发送的1个字节的数据Data。

主机发送普通字节数据后的处理

在主机发送数据到从机的过程中,除了第一个字节的寻址字节外,其余要发送的数据都是普通的字节数据。

发送普通字节后,应该进行什么处理呢?

肯定和发送寻址字节的处理不同。

主机发送普通字节后,有以下两种可能性:

  1. 若从机应答ACK,表示主机发送数据成功:
    1. I2C_FLAG_TXEI2C_FLAG_BTF这两个标志位都会被置为1
    2. 其中TXE表示发送数据寄存器为空,BTF表示数据传输已完成
    3. 我们可以使用BTF这个标志位,是否置1,来判断1个字节的数据是否发送完成。
  2. 若从机无应答,也就是应答NACK,则表示从机拒收,主机发送数据失败:
    1. I2C_FLAG_AF会被置1,表示ACK失败,从机发送NACK非确认应答。
    2. 注意,不要忘记手动复位AF标志位。

主机发送普通字节数据后的处理逻辑是这样的:

等待BTF标志位为空,即等待主机发送全部数据,然后再判断AF标志位处理从机ACK。

参考的处理方式如下:

// 主机发送非寻址字节数据后的处理逻辑(重点)
// 等待此1个字节的数据发送完成
while (I2C_GetFlagStatus(I2C2, I2C_FLAG_BTF) == RESET);

// 再检查AF标志位是否置1, 判断从机是否应当成功
if (I2C_GetFlagStatus(I2C2, I2C_FLAG_AF) == SET) {
    // 从机无应答, 拒收此数据
    I2C_GenerateSTOP(I2C2, ENABLE);     // 停止通信
    I2C_ClearFlag(I2C2, I2C_FLAG_AF);   // 手动清零AF标志位
    return -2;
}

注意:

在上面的操作中,我们手动清零了AF标志位,但并没有手动清零TXE和BTF标志位。

这是因为:根据ST公司的I2C外设设计,这两个标志位的置位和复位都可以认为是硬件/软件自动完成的,不需要程序员手动操作!

参考实现代码

//1. 主机发送起始位信号
//2. 主机发送寻址字节
//3. 主机读取从机发送的ACK确认
//4. 主机向从机发送多个字节的控制字节和其它控制指令。
//5. 主机读取从机发送的ACK确认
//6. .....
//7. 主机发送停止位信号
static int8_t OLED_I2CWriteCmds(const uint8_t *Cmds, uint8_t Length){

	  // 1. 等待 I2C 总线空闲(BUSY 位复位)
    while (I2C_GetFlagStatus(I2C2, I2C_FLAG_BUSY) == SET);

    // 2. 发送起始信号(START),开启通信
    I2C_GenerateSTART(I2C2, ENABLE);

    // 3. 等待起始条件发送完成(SB 位置位)
    while (I2C_GetFlagStatus(I2C2, I2C_FLAG_SB) == RESET);

    // 4. 发送寻址字节(由第三个参数确定为写模式)
    I2C_Send7bitAddress(I2C2, 0x78, I2C_Direction_Transmitter);

    // 5. 等待寻址阶段结束:要么收到应答(ADDR),要么发生应答失败(AF)
    // 注意这两个标志位最多有一个置位, 也必然有一个置位
    // 因为寻址成功和失败总会发生一个, 也只会发生一个
    while (
        I2C_GetFlagStatus(I2C2, I2C_FLAG_ADDR) == RESET &&
        I2C_GetFlagStatus(I2C2, I2C_FLAG_AF) == RESET
    );

    // 6. 检查是否发生寻址失败(AF),如果失败,发送停止位退出通信
    // 但是不要忘记手动软件清除AF标志位
    if (I2C_GetFlagStatus(I2C2, I2C_FLAG_AF) == SET) {
        I2C_ClearFlag(I2C2, I2C_FLAG_AF);        // 清除 AF 标志
        I2C_GenerateSTOP(I2C2, ENABLE);          // 发送 STOP 释放总线
        return -1;
    }

    // 7. 读取 SR1 和 SR2 两个寄存器,手动复位 ADDR 标志,使得通信能够继续进行
    I2C_ReadRegister(I2C2, I2C_Register_SR1);
    I2C_ReadRegister(I2C2, I2C_Register_SR2);

    // 8. 循环发送控制指令数组中的每个字节
    for (uint8_t i = 0; i < Length; i++) {
        I2C_SendData(I2C2, Cmds[i]);  // 写入发送数据寄存器发送

        // 等待当前字节发送完成(移位寄存器与数据寄存器都空,BTF 置位)
        while (I2C_GetFlagStatus(I2C2, I2C_FLAG_BTF) == RESET);

        // 检查是否收到从机的 NACK
        if (I2C_GetFlagStatus(I2C2, I2C_FLAG_AF) == SET) {
            I2C_ClearFlag(I2C2, I2C_FLAG_AF);    // 清除 AF
            I2C_GenerateSTOP(I2C2, ENABLE);      // 通信失败,发送 STOP
            return -2;
        }
    }

    // 9. 所有数据发送完毕,发送 STOP 终止通信
    I2C_GenerateSTOP(I2C2, ENABLE);
    return 1;

} 

点亮和熄灭OLED

int8_t OLED_On(void){
	uint8_t OLED_TurnOnCmds[] = {
        0x00,       // 下面的指令是命令流
        0x8D, 0x14, // 开启电荷泵
        0xAF,       // 打开屏幕
        0xA5        // 让屏幕全亮,点亮所有像素点
    };
    return OLED_I2CWriteCmds(OLED_TurnOnCmds, sizeof(OLED_TurnOnCmds));
}

int8_t OLED_Off(void){

    uint8_t OLED_TurnOffCmds[] = {
        0x00,       // 下面的指令是命令流
        0xA4,       // 普通显示模式
        0xAE,       // 关闭 OLED 显示
        0x8D, 0x10  // 关闭电荷泵
    };
    return OLED_I2CWriteCmds(OLED_TurnOffCmds, sizeof(OLED_TurnOffCmds));
}

核心实现:读取OLED状态字节

现在只剩下最后一个核心函数没有实现了,它就是:

int8_t OLED_ReadStatus(uint8_t *oled_flag);

有了上面的基础上,这个函数实现起来也非常的容易。

这里我们再来学习几个函数:

I2C_ReceiveData函数:

有上面的I2C_SendData函数,就有相应的接收函数。此函数的声明如下:

uint8_t I2C_ReceiveData(I2C_TypeDef* I2Cx);

此函数的形参只需要填入要接收数据的外设,可以是I2C1I2C2

它的返回值是一个8位的无符号数,也就是返回接收到的1个字节数据。

这个函数非常简单,但需要注意:最好在I2C_FLAG_RXNE接收数据寄存器非空标志位置为1时再调用,也就是有数据了再去读数据。

所以此函数的一般调用方式如下:

// 等待接收数据
while (I2C_GetFlagStatus(OLED_I2Cx, I2C_FLAG_RXNE) == RESET);  // 等待接收缓冲区非空

// 读取数据
uint8_t status = I2C_ReceiveData(OLED_I2Cx);  // 读取 OLED 状态数据

紧接着,我们还需要知道主机如何发送NACK给从机,表示主机不再希望接收从机数据。

这就需要使用函数I2C_AcknowledgeConfig了。

I2C_AcknowledgeConfig函数:

在硬件I2C当中,主机发送ACK还是NACK是自动的,而且是在配置初始化I2C外设时就决定了。

就是调用I2C_Init函数时,I2C_Ack成员的设置。

在前面我们已经将该成员设置为I2C_Ack_Enable,这表示:主机收到从机数据时回复ACK给从机。

而我们现在不需要主机发送ACK了,所以只需要改一下这个配置即可。

I2C_AcknowledgeConfig函数的声明如下:

void I2C_AcknowledgeConfig(I2C_TypeDef* I2Cx, FunctionalState NewState);

调用它的参数格式如下:

参数 类型 说明
I2Cx I2C_TypeDef* 选择 I2C 外设(如 I2C1I2C2
NewState FunctionalState ENABLE(发送 ACK)或 DISABLE(禁用 ACK,发送NACK)

所以只需要调用:

I2C_AcknowledgeConfig(OLED_I2Cx, DISABLE);  // 禁用ACK, 主机发送NACK

即可让主机在接收到数据后,发送NACK。

整体的参考实现代码如下:

int8_t OLED_ReadStatus(uint8_t *OLED_StatusPtr){
	 // 1. 等待 I2C 总线空闲
    while (I2C_GetFlagStatus(I2C2, I2C_FLAG_BUSY) == SET);

    // 2. 主机发送起始位
    I2C_GenerateSTART(I2C2, ENABLE);

    // 3. 等待起始位发送完成
    while (I2C_GetFlagStatus(I2C2, I2C_FLAG_SB) == RESET);

    // 4. 主机发送寻址字节
    I2C_Send7bitAddress(I2C2, 0x78, I2C_Direction_Receiver);  // 读操作

    // 5. 等待寻址响应(ADDR 或 AF 至少有一个置位)
    while (
        I2C_GetFlagStatus(I2C2, I2C_FLAG_ADDR) == RESET &&
        I2C_GetFlagStatus(I2C2, I2C_FLAG_AF) == RESET
    );

    // 6. 如果发生应答失败(AF),发送 STOP 并返回失败
    // 不要忘记手动软件复位AF标志位
    if (I2C_GetFlagStatus(I2C2, I2C_FLAG_AF) == SET) {
        I2C_ClearFlag(I2C2, I2C_FLAG_AF);
        I2C_GenerateSTOP(I2C2, ENABLE);
        return 0;
    }

    // 7. 读取 SR1 和 SR2 寄存器以复位 ADDR标志位,以进行通信后续流程
    I2C_ReadRegister(I2C2, I2C_Register_SR1);
    I2C_ReadRegister(I2C2, I2C_Register_SR2);

    // 寻址成功了,接下来从机OLED就会发送状态字节从主机
    // 单片机的I2C外设中的接收数据寄存器会自动接收这个状态字节
    // 这个接收过程不需要程序员手动完成
    // 程序员只需要等待接收数据寄存器非空,然后调用函数把数据读出来即可

    // 8. 从机发送状态字节后,主机就不再需要从机发送了
    // 于是直接禁用主机 ACK,主机接收数据后会给从机发送NACK
    I2C_AcknowledgeConfig(I2C2, DISABLE);

    // 9. 等待接收寄存器非空(RXNE 置位)
    while (I2C_GetFlagStatus(I2C2, I2C_FLAG_RXNE) == RESET);

    // 10. 读取接收到的状态字节
    uint8_t ReceiveByte = I2C_ReceiveData(I2C2);
    *OLED_StatusPtr = ((ReceiveByte >> 6) & 0x01);  // 解析状态位

    // 11. 发送 STOP 结束通信
    I2C_GenerateSTOP(I2C2, ENABLE);

    return 1;
}

USART中断处理函数

为了实现外部中断的处理函数,需要声明以下函数:

// 开启屏幕并发送开启成功与否给PC端
void OLED_HandleOn(void);
// 关闭屏幕并发送关闭成功与否给PC端
void OLED_HandleOff(void);
// 读取屏幕开启状态并把这个状态发送给PC端
void OLED_HandleStatus(void);
// USART外设全局中断处理函数, 在这里我们处理RXNE标志位中断
void USART1_IRQHandler(void);

它们的实现也非常简单,如下所示:

// 打开OLED的处理函数
void OLED_HandleOn(void) {
    if (OLED_On() == 0) {
        SendString("OLED-TurnOn: Success");
    } else {
        SendString("OLED-TurnOff: Failed");
    }
}

// 关闭OLED的处理函数
void OLED_HandleOff(void) {
    if (OLED_Off() == 0) {
        SendString("OLED-TurnOFF: Success");
    } else {
        SendString("OLED-TurnOFF: Failed");
    }
}

// 查询OLED状态的处理函数
void OLED_HandleStatus(void) {
    uint8_t OLED_Status;
    if (OLED_ReadStatus(&OLED_Status) == 0) {
        if (OLED_Status == 0) {
            SendString("OLED-Status: ON");
        } else {
            SendString("OLED-Status: OFF");
        }
    } else {
        SendString("OLED-Status: Failed to read");
    }
}

// USART外设全局中断处理函数, 在这里我们处理RXNE标志位中断
void USART1_IRQHandler(void) {
    if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { 
        uint8_t receivedData = USART_ReceiveData(USART1); // 读取接收数据
        // 根据命令执行相应操作
        switch (receivedData) {
            case '1':  // 打开OLED
                OLED_HandleOn();
                break;

            case '0':  // 关闭OLED
                OLED_HandleOff();
                break;

            case '2':  // 查询OLED状态
                OLED_HandleStatus();
                break;

            default:
                SendString("Command Error: Invalid command");
                break;
        }
    }
}

因为文件比较多,整个项目我就不在这里展示了可以去我的git仓库clone一下

git clone https://gitee.com/xingyexiakong/cnblog.git

I2C通信的优缺点总结

至此,我们已经掌握了 I2C 通信的原理与基本用法。最后,我们来系统地总结一下 I2C 的优势与局限。

首先来看一下I2C通信的优势:

  1. 只需两根信号线(SCL、SDA),节省引脚、简化布线,适合板载多设备芯片之间的通信;
  2. 支持多主机与多从机架构,具有高度灵活性;
  3. 软件和硬件实现皆可,可以根据不同的需求,选择不同的实现;
  4. 功能完善强大,支持寻址,应答,主机切换,主从数据发送等功能;
  5. 成本低廉,无需专用通信器件,仅依赖通用 IO 与上拉电阻即可构建完整通信系统,性价比极高。

I2C的劣势如下:

  1. 通信距离有限,由于总线结构为开漏+上拉电阻,抗干扰能力弱,不适合长距离数据传输。
  2. 通信速度相对较低,上拉电阻导致 SDA/SCL 上升沿缓慢,严重制约通信速率,典型速率为 100kHz ~ 400kHz。
  3. 功耗略高,相比推挽结构,I²C 持续存在上拉通路,产生静态功耗。
  4. 如果选择多主机模式,容易出现总线冲突,通信过程比较复杂,出错几率高。
  5. 由于I2C通信协议的复杂性,再加上STM32部分单片机在I2C外设设计上的固有缺陷,使得硬件I2C在不在乎性能时反而不如软件I2C。

I²C 通信协议正是为了以最少的引脚、最低的成本,提供多设备互联的能力而设计的。

它的确做到了“小投入,强功能”的设计目标。

I2C是一种性价比极高、强大且灵活的通信协议,是嵌入式系统中必学且最常用的通信方式之一。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐