【STM32学习】I2C通信协议 | OLED屏

🐱作者:一只大喵咪1201

🐱专栏:《STM32学习》

🔥格言:你只管努力,剩下的交给时间!

今天需要将代码烧录到开发板中,本喵默认大家都会创建工程,以及进行基本的外设配置。

I2C通信协议 | OLED屏

😽I2C协议

I2C协议是一种通信协议,通常用来在主设备和从设备之间进行通信,本喵使用的主设备是STM32F103ZET6芯片的开发板,从设备使用的是SSD1306芯片驱动的OLED屏幕。

I2C在硬件上的接法如上图所示,主控芯片引出两条线SCLSDA线,在一条I2C总线上可以接很多I2C设备,我们还会放一个上拉电阻(放一个上拉电阻的原因以后我们再说)。

🙈数据格式

写操作:

如上图所示,白色背景表示"主→从",灰色背景表示"从→主",具体流程如下:

  • 主芯片要发出一个start信号,表示通信开始。
  • 然后发出一个字节的数据,包括设备地址(用来确定是往哪一个从芯片写数据),该地址有7个比特位以及方向(读/写,0表示写,1表示读),这里该位是0
  • 从设备回应(用来确定这个设备是否存在),如果存在就可以传输数据。
  • 主设备发送一个字节的数据给从设备,并等待回应。
  • 每传输一个字节数据,接收方要有一个回应信号(确定数据是否接受完成),然后再传输下一个数据。
  • 数据发送完之后,主芯片就会发送一个停止信号。

读操作:

如上图所示,白色背景表示"主→从",灰色背景表示"从→主",具体流程如下:

  • 主芯片要发出一个start信号,表示通信开始。
  • 然后发出一个字节的数据,包括设备地址(用来确定是往哪一个从芯片写数据),该地址有7个比特位以及方向(读/写,0表示写,1表示读),这里该位是1
  • 从设备回应(用来确定这个设备是否存在),然后就可以接收数据。
  • 从设备发送一个字节数据给主设备,并等待回应。
  • 主设备每接收一个字节数据,就要有一个回应信号(确定数据是否接受完成),然后再接收下一个数据。
  • 主设备认为数据接收完之后,就会发送一个停止信号。

上面的写操作和读操作,都是由主设备占据主动,无论是开始发送数据还是接收数据,从设备被动的根据方向位的值来配合主设备工作。

🙈I2C信号时序

如上图所示便是I2C信号的时序图,I2C协议中数据传输的单位是字节,也就是8位。但是要用到9个时钟,前面8个时钟用来传输8数据,第9个时钟用来传输应答信号,传输时,先传输最高位(MSB)。

  • SDA线上传输的数据必须在SCL线为高电平期间保持稳定,只能在SCL为低电平期间变化(由高到低或由低到高)。
  • 开始信号(S):SCL为高电平时,SDA由高电平向低电平跳变,开始传送数据。
  • 结束信号(P):SCL为高电平时,SDA由低电平向高电平跳变,结束传送数据。
  • 应答信号(ACK):接收方在接收到8位数据后,在第9个时钟周期,拉低SDA上的电平状态。

在一个字节传输完成,并且得到应答ACK信号以后,需要将SCL线上的电平状态拉低一段时间,为了给接收方充足的时间去处理数据,避免数据覆盖。

细节:

  • 主、从设备都可以通过SDA发送数据,肯定不能同时发送数据,怎么错开时间?

在9个时钟里,前8个时钟由主设备发送数据的话,第9个时钟就由从设备发送应答数据;前8个时钟由从设备发送数据的话,第9个时钟就由主设备发送应答数据。

  • 双方设备中,某个设备发送数据时,另一方怎样才能不影响SDA上的数据?

假设主设备正在给从设备发送数据,但是在某个时刻,从设备发生了故障或者误操作,导致连接双方的SDA线有了电势差,此时SDA线就导通了,可能产生严重的影响甚至烧坏芯片。

如上图所示,为了避免另一方对SDA线上的数据造成影响,需要让双方设备的SDA中有一个三极管,所以使用开极/开漏电路(三极管是开极,CMOS管是开漏,作用一样),并且使用上拉电阻将SDA线拉高。

  • 开漏输出模式正好符合上面的要求,所以使用I2C通信的时候,需要将主设备的SDA线和SCL线所在IO口设置成开漏输出模式。
  • 从设备也必须具有开漏输出的特性。

如上图所示是GPIO的输出电路,可以设置成推挽或者开漏输出模式,其中TTL肖特基触发器是打开的,所以IO口引脚的电平状态直接在输入数据寄存器中可以读到。

将输出设置为开漏输出模式时,输出驱动器中的P-MOS管就不会在导通了,只有N-MOS管在输出控制器输出低电平的时候会导通。

  • 输出控制器输出高电平时,IO引脚的电平状态由外部决定,由外部上拉电阻或者通信对端决定。
  • 输出控制器输出低电平时,IO口引脚接地,输出低电平。
  • 当开漏输出的IO控制器输出高电平时,相当于释放了该IO口的电平状态控制权。

所以当主设备A和从设备B都使用开漏输出模式控制SDA线的时候,SDA的真值表如下:

A B SDA
0 0 0
0 1 0
1 0 0
1 1 1
  • 通过真值表可以看到,SDA线上是不会存在电势差的,所以也不会导通。

所以接收方在接收数据之前,需要给SDA口输出高电平释放控制权(写1),此时SDA上的电平状态就完全由发送方决定,并且和IO口控制器输出的电平相一致。

而且双方都可以通过读取输入数据寄存器中的值来获取当前SDA线上的电平状态。


此时再看I2C通信中主设备向从设备写数据的过程:

启动信号发出后,前8个时钟clk

  • 从设备不能影响SDA线,所以不驱动N-MOS管,从设备IO口始终输出高电平,释放控制权。
  • 主设备决定数据,IO口变化SDA线电平状态,低电平时驱动N-MOS管,SDA线电平为低,高电平时不驱动N-MOS管,SDA线电平被外部上拉电阻拉高。

第9个时钟clk

  • 主设备不驱动N-MOS管,IO口输出高电平,释放SDA控制权。
  • 从设备决定数据,因为是应答信号,所以驱动N-MOS管,SDA线为低电平。
  • 在主设置经过8个clk后,需要先将SCL线电平拉低,同时给SDA写1,保持一定时间后再将SCL线拉高,读取SDA线的电平状态,如果变成低说明应答到来。
  • SCL拉低的目的是好让从设备改变SDA线电平状态,然后SCL保持高电,此时读到的SDA线电平才是真实的电平状态。
  • 为什么SCL也需要上拉呢?

在第9个时钟之后,如果有某一方需要更多的时间来处理数据,它可以一直驱动三极管把SCL拉低,也就是输出低电平。

SCL为低电平时候,大家都不应该使用I2C总线,只有当SCL从低电平变为高电平的时候,I2C总线才能被使用。

当它就绪后,就可以不再驱动三极管,这时上拉电阻把SCL变为高电平,其他设备就可以继续使用I2C总线了。

🙈I2C驱动代码

driver_i2c.h

cpp 复制代码
#ifndef __DRIVER_I2C_H
#define __DRIVER_I2C_H

#include "stm32f1xx_hal.h"

/*********引脚定义**********/

#define SCL_PIN				GPIO_PIN_10
#define SDA_PIN				GPIO_PIN_11

#define SCL_PORT			GPIOF
#define SDA_PORT			GPIOF

/*********宏定义**********/

#define SCL_LOW			HAL_GPIO_WritePin(SCL_PORT, SCL_PIN, GPIO_PIN_RESET)
#define SCL_HIGH		HAL_GPIO_WritePin(SCL_PORT, SCL_PIN, GPIO_PIN_SET)

#define SDA_LOW			HAL_GPIO_WritePin(SDA_PORT, SDA_PIN, GPIO_PIN_RESET)
#define SDA_HIGH		HAL_GPIO_WritePin(SDA_PORT, SDA_PIN, GPIO_PIN_SET)
#define SDA_IN			HAL_GPIO_ReadPin(SDA_PORT, SDA_PIN)

/*********I2C引脚初始化**********/

void extern I2C_GPIO_ReInit(void);

/*********I2C驱动********/
extern void I2C_Start(void);
extern void I2C_Stop(void);
extern int I2C_GetAck(void);
extern void I2C_Ack(void);
extern void I2C_WriteByte(uint8_t data);
extern uint8_t I2C_ReadByte(uint8_t ack);

#endif /*__DRIVER_I2C_H*/

将用到的资源进行宏定义,像SCL线电平拉高拉低,SDA线电平拉高拉低等简单操作,同样通过宏来实现,比较复杂的操作就用函数实现,这里放的是函数声明具体的定义再driver_i2c.c中,下面本喵就讲解它们的实现。

I2C延时函数:

cpp 复制代码
/*********I2C延时函数*********/
void I2C_Delay(uint32_t cnt)
{
	volatile uint32_t tmp = cnt;
	while(tmp--);
}

SCL线和SDA线上的电平状态需要保持一定的时间,HAL_Delay延时函数的单位是1ms,所以最短延时1ms,对于I2C通信来说,这个时间太长了,通信效率太低,所以本喵自己实现了一个用来I2C延时的函数,具体时间大家可以自己决定。

cpp 复制代码
/*********I2C引脚初始化**********/
void I2C_GPIO_ReInit(void)
{
	GPIO_InitTypeDef GPIO_InitStruct = {0};//实例化IO口
	
	HAL_GPIO_DeInit(SCL_PORT,SCL_PIN);
	HAL_GPIO_DeInit(SDA_PORT,SDA_PIN);//恢复默认
	
	__HAL_RCC_GPIOF_CLK_ENABLE();//开启IO口时钟
	
	GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD;//设置开漏输出模式
  	GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
	
	GPIO_InitStruct.Pin = SCL_PIN;//指定SCL引脚
	HAL_GPIO_Init(SCL_PORT,&GPIO_InitStruct);//初始化SCLIO口
	
	GPIO_InitStruct.Pin = SDA_PIN;//指定SDA引脚
	HAL_GPIO_Init(SDA_PORT,&GPIO_InitStruct);//初始化SDAIO口
}

上面代码是对SDA线和SCL线IO口引脚的初始化,必须设置成开漏输出模式,其他部分不解释,可以根据实际情况做修改。

开始信号:

cpp 复制代码
/**********开始***********/
void I2C_Start(void)
{
	SCL_HIGH;//SCL线拉高
	SDA_HIGH;//SDA线拉高
	I2C_Delay(100);//保持
	
	SDA_LOW;//SDA线拉低
	I2C_Delay(100);//保持
}

先将SDA线和SCL线都拉高,维持一段时间后将SDA线拉低,再维持一段时间,此时就实现了SCL高电平期间,SDA由高电平变成了低电平,I2C通信开始。

停止信号:

cpp 复制代码
/**********结束**********/
void I2C_Stop(void)
{
	SCL_HIGH;//SCL线拉高
	SDA_LOW;//SDA线拉低
	I2C_Delay(100);
	
	SDA_HIGH;//SDA线拉高
	I2C_Delay(100);
}

先将SCL线拉高和SDA线拉低,维持一段时间后再将SDA线拉高,再维持一段时间,此时就实现了SCL高电平期间,SDA由低电平变成了高电平,I2C通信结束。

获取应答信号:

cpp 复制代码
/**********获取应答**********/
int I2C_GetAck(void)
{
	uint16_t i = 0;
	
	SCL_LOW;//SCL线拉低
	SDA_HIGH;//SDA线拉高
	I2C_Delay(100);
	
	SCL_HIGH;//SCL线拉高
	
	while(SDA_IN != 0)
	{
		//读取SDA状态一段时间
		i++;
		if(i == 1000)	
		{
			SCL_LOW;//SCL线拉低
			return -1;//仍然是1返回-1表示无应答信号
		}
	}
	SCL_LOW;//SCL线拉低
	return 0;//读到低电平返回0表示这是应答信号
}

主机获取应答信号时,先将SCL线拉低才能将SDA线拉高,然后维持一段时间再将SCL线拉高,释放SDA线控制权,再检测SDA线电平状态,检测一定时间后,如果SDA仍然是高电平,说明从机没有返回应答,返回-1,如果SDA变成低电平,说明从机返回了应答信号,返回0。

发送应答信号:

cpp 复制代码
/***********发送应答**********/
void I2C_Ack(void)
{
	SCL_LOW;//SCL拉低
	SDA_LOW;//SDA拉低
	I2C_Delay(100);
	SCL_HIGH;//SCL拉高
	I2C_Delay(100);
}

先将SCL先和SDA线都拉低,维持一段时间后将SCL线拉高,好让从机读取被主机拉低的SDA

不发送应答信号:

cpp 复制代码
/**********不发送应答信号********/
void I2C_NoAck(void)
{
	SCL_LOW;
	SDA_HIGH;//SDA线不拉低
	I2C_Delay(100);
	SCL_HIGH;
	I2C_Delay(100);
}

主机不发送应答信号时,只需要维持SDA线是高电平即可。

使用I2C发送一个字节的数据:

cpp 复制代码
/***********发送一个字节数据**********/
void I2C_WriteByte(uint8_t data)
{
	uint8_t i = 0;
	//8个比特位,8个clk
	for(i = 0; i< 8; i++)
	{
		SCL_LOW;//SCL拉低
		I2C_Delay(100);
		
		if(data & 0x80)
		{
			//发送数据的高位是1,拉高SDA
			SDA_HIGH;
		}
		else
		{
			//发送数据的高位是0,拉低SDA
			SDA_LOW;
		}
		data <<= 1;//左移1位,方便下次判断次高位
		SCL_HIGH;//SCL拉高
		I2C_Delay(100);
	}
	I2C_GetAck();//8个clk结束后,获取应答信号
}

一个字节有8个比特位,所以需要8个clk来发送一个字节的数据,每发送一个比特位时,先将SCL拉低并维持,然后判断要发送数据data的高位。

如果高位是1,则将SDA拉高,如果是高位是0,则将SDA拉低,然后将数据data左移移位,方便下次判断次高位,并且将SCL线拉高保持,好让对方读取SDA状态。

当8个比特位全部发送完毕后,去获取接收方的应答信号。

  • 在发送一个字节数据的时候,先判断的是data的高位,并且通过SDA线发送,所以发送一个字节是按照从高位到低位的顺序发送的。

读取一个字节数据:

cpp 复制代码
/**********读取一个字节数据***********/
uint8_t I2C_ReadByte(uint8_t ack)
{
	uint8_t i = 0;
	uint8_t data = 0;
	
	SDA_HIGH;//SDA拉高放弃控制权
	//8个比特位,读取8次
	for(i = 0; i < 8; i++)
	{
		SCL_LOW;//SCL拉低,让从机改变SDA状态
		I2C_Delay(100);
		SCL_HIGH;//SCL拉高
		I2C_Delay(100);
		
		data <<= 1;//高位左移移位,方便接收次高位
		if(SDA_IN == 1)
		{
			//SDA高电平
			data++;
		}
	}
	
	//决定要不要给从机应答
	if(ack == 0)
	{
		I2C_Ack();//接收完毕,给从机应答信号
	}
	else if(ack == 1)
	{
		I2C_NoAck();//接收完毕,不给从机应答信号
	}
	
	return data;//返回接收到的数据
}

从机向主机发送数据时,先将SDA拉高放弃SDA线的控制权,此时SDA线的状态由从机决定。一个字节8个比特位,所以需要8个clk读取8次,每次读取时,先将SCL拉低,此时从机才能改变SDA电平状态,才能发送数据,然后保持一段时间后再拉高SCL,此时主机读到的SDA数据才是准确的。

将存放数据的data左移一位,方便接收次高位,当SDA线的电平是高时,data加一,如此反复八次。

  • 这个过程中,先接收到的比特位是高位,所以会被不停左移,八次读取后得到的8个比特位拼成一个字节的数据。
  • 和发送时先发送高位相对应。

一个字节数据读取完毕后,根据ack形参的值决定要不要给从机应答信号,最后再将接收到的数据data返回。

😽OLED显示

在OLED屏上还有一块驱动芯片,它是用来让屏幕显示内容的,我们让OLED显示内容其实就是在控制这块驱动芯片,本喵使用的OLED是SSD1306驱动芯片。

🙈SSD1306

特点:

  • 128×64点阵面板,也就是一共有8192个点。
  • 有256阶对比度可调节。
  • 支持6800/8080并行总线。
  • 支持SPI、I2C串行总线。
  • 支持水平方向和垂直方向的滚动。
  • 支持行或列的重映射,也就是反转方向。

设备地址:

从芯片手册中可以看到,该芯片的地址有7位,从b1~b7,其中b2~b7是固定的,二进制序列是0111 10b1是由芯片的D/C引脚决定的。

从上面的芯片电路图中可以看到,D/C引脚是接地的,所以b1的值就是0,所以该芯片的地址就是0111 100,通过这7个比特位可以找到这个芯片。

b0是读写控制位,1表示从该芯片中读取数据,0表示向该芯片写入数据,所以:

  • 0b0111 1000十六进制0x78是写数据时的设备地址。
  • 0b0111 1001十六进制0x79是读数据时的设备地址。
cpp 复制代码
/***************定义设备SSD1306读写地址*************/
#define OLED_WRITE_ADDR 		0x78		//写地址
#define OLED_READ_ADDR			0x79		//读地址

/***************定义设备控制命令**************/
#define OLED_WRITE_CMD			0x00		//向OLED写命令
#define OLED_WRITE_DATA			0x40		//向OLED写数据

在代码中使用宏来定义设备的读写地址,以及告诉从设备是写命令还是写数据。

🙈 SSD1306的I2C总线数据格式

如上图所示便是和SSD1306控制芯片通信的I2C总数据格式,主机STM32F103ZET6首先发送起始信号S,然后发送设备地址(由7位Slave Address和1位R/W组成一个字节),再读取从机SSD1306的应答信号。

得到应答信号以后再发送一个控制字节,告诉SSD1306芯片,接下来的数据是控制命令 还是向驱动芯片的GRAM中写入数据。

如上图所示便是控制字节,其中Co位表示该字节中紧跟着的数据是仅有数据字节还是会包含控制字节,默认为0,D/C位为1表示紧跟着的字节数据为写入驱动芯片GRAM的数据,为0则表示这是一个命令数据。

写命令:

cpp 复制代码
/***********写命令**********/
void OLED_WriteCmd(uint8_t cmd)
{
	I2C_Start();//开始信号
	I2C_WriteByte(OLED_WRITE_ADDR);//写从送设备地址
	I2C_WriteByte(OLED_WRITE_CMD);//告诉设备要写命令
	I2C_WriteByte(cmd);//写具体命令
	I2C_Stop();//停止信号
}

先产生开始信号,然后发送从设备SSD1306芯片地址(写函数中已经包含获取应答),再发送控制字节表明要向从设备中写命令,然后再写入具体的命令cmd,最后产生停止信号。

写一个字节数据:

cpp 复制代码
/***********写一个字节数据**********/
void OLED_WriteDate(uint8_t data)
{
	I2C_Start();//开始信号
	I2C_WriteByte(OLED_WRITE_ADDR);//写从送设备地址
	I2C_WriteByte(OLED_WRITE_DATA);//告诉设备要写数据
	I2C_WriteByte(data);//写具体数据
	I2C_Stop();//停止信号
}

通信开始后,先写从设备地址,然后发送控制字节告诉从设备要写入数据,再写入具体的数据,最后产生停止信号。

写多个字节数据:

cpp 复制代码
/***********写多个字节数据*********/
void OLED_WriteNBytes(uint8_t* buffer,	uint16_t length)
{
	uint16_t i = 0;
	if(buffer == NULL)	return;//源缓冲区为空直接返回
	I2C_Start();//开始信号
	I2C_WriteByte(OLED_WRITE_ADDR);//写从设备地址
	I2C_WriteByte(OLED_WRITE_DATA);//告诉从设备要写数据
	//写入多个字节
	for(i = 0; i< length; i++)
	{
		I2C_WriteByte(buffer[i]);
	}
	I2C_Stop();//停止信号
}

首先进行判断,如果写数据的源缓冲区为空,则直接返回,不为空则继续执行。通信开始后,同样需要写从设备地址并且告诉从设备要写数据,之后多次调用写一个字节的函数发送多个字节数据,最后停止通信。

🙈OLED的显示

如上图所示便是OLED的内部示意图,外部处理器STM32F103ZET6通过I2C协议将数据发送到OLED内部的MCU上,然后内部MUC将数据给到GDDRAM上存储,再将数据给到显示控制器,然后进行行/列地址驱动,最终在OLED屏幕上显示内容。

如上图所示是OLED屏幕示意图,整个拼命有128×64个像素点,分为128列,64行,由于一个字节有8个比特位,所以一列中每8行对应一个字节。

64个行又划分为8页,每一页有127列×8页个像素点。上图中,第一页PAGE0的第一列COL0对应的8个比特位是01010101,右边屏幕上对应比特位为1的像素点是白色,其他为黑色。

  • OLED的显示其实就是在填充这128×64个像素点。

当I2C发送多个字节数据的时候,显存GDDRAM又是如何保存这些数据的呢?保存这些数据有三种地址模式:页地址模式,垂直地址模式,水平地址模式。

本喵这里仅介绍最常用的页地址模式:

如上图所示,在页地址模式下,当往显存里面写入数据后,列地址指针会自动递增1,所以设置好起始页和起始列之后,就可以连续发送数据,而不用每发送一个数据就去指定一个页和列的地址了。

如果列地址指针递增到了设置的结束列地址,那么列地址指针就会复位回到设置的起始列地址,而页地址指针是不会有变化的。

  • 向下一页显存中存放数据时,用户必须设置新的页和列的起始地址。

如上图所示,这是从SSD1306芯片手册中截取的,用来设置显存的地址模式,主设备需要先向从设备发送0x20控制字节,表示要设置页地址模式,然后再发送一个字节范围为0x00~0x03的数据来指定地址模式。

设置地址模式:

cpp 复制代码
typedef enum
{
    H_ADDR_MODE     = 0,    // 水平地址模式
    V_ADDR_MODE     = 1,    // 垂直地址模式
    PAGE_ADDR_MODE  = 2,    // 页地址模式
}MEM_MODE;  // 内存地址模式

static MEM_MODE mem_mode = PAGE_ADDR_MODE;
void OLED_SetMemAddrMode(MEM_MODE mode)
{
	if((mode != H_ADDR_MODE) && (mode != V_ADDR_MODE) && (mode != PAGE_ADDR_MODE))      return;
    OLED_WriteCmd(0x20);
    OLED_WriteCmd(mode);
    mem_mode = mode;
}

根据芯片手册所描述的,给从设备发对应的数据就可以设置成页地址模式,这也是一种最常用的地址模式。


如上图所示,这是用来设置页起始地址的,在写指令时发送一个字节范围是0xB0~0xB7的数据,其中低3位的值是告诉显存要将数据存放在哪一页。

设置起始页地址:

cpp 复制代码
#define PAGE_ADDR_MODE_BASE			0xB0
void OLED_SetPageAddr_PAGE(uint8_t addr)
{
	if(mem_mode != PAGE_ADDR_MODE)  return;
	if(addr > 7)   return;
	OLED_WriteCmd(PAGE_ADDR_MODE_BASE + addr);
}

在调用该函数的时候,可以指定起始页地址,但是不能超过7,因为一共有8页,判断合法后,写命令写入起始页地址的值。

芯片手册中,D7~D3的值是固定的0b1011 0,所以PAGE_ADDR_MODE_BASE0xB0,页地址在这个基础上作偏移即可。


还有屏幕的打开和关闭等等:

cpp 复制代码
#define DISP_ON()             	OLED_WriteCmd(0xAF) //开始显示
#define DISP_OFF()            	OLED_WriteCmd(0xAE)	//关闭显示

设置起始列地址等等功能的方法等等,大家可以自己对着芯片手册去查找它的使用规则,本喵后面会将源码及手册分享出来。

OLED初始化

如上图所示是OLED整个初始化过程,这个过程图在芯片手册中也有,我们只需要按照流程挨个调用自己实现的功能函数即可。

初始化:

cpp 复制代码
void OLED_Init(void)
{   
    OLED_SetMemAddrMode(PAGE_ADDR_MODE);    			// 0. 设置地址模式
    OLED_SetMuxRatio(0x3F);                 			// 1. 设置多路复用率
    OLED_SetDispOffset(0x00);               			// 2. 设置显示的偏移值
    OLED_SetDispStartLine(0x00);            			// 3. 设置起始行
    OLED_SEG_REMAP();                       			// 4. 行翻转
    OLED_SCAN_REMAP();                      			// 5. 翻转扫描
    OLED_SetComConfig(COM_PIN_SEQ, COM_NOREMAP);  		// 6. COM 引脚设置
    OLED_SetContrastValue(0x7F);            			// 7. 设置对比度
    ENTIRE_DISPLAY_OFF();                   			// 8. 背景熄灭
    DISP_NORMAL();                          			// 9. 显示模式
    OLED_SetDCLK_Freq(0x00, 0x08);          			// 10. 设置分频系数和频率增值
    OLED_SetChargePump(PUMP_ENABLE);        			// 11. 使能电荷碰撞
    
    OLED_SetComConfig(COM_PIN_ALT, COM_NOREMAP);	//改变显示字体大小
    
    DISP_ON();																		//开始显示
}

其中第4步就是让原本在右边显示变成在左边显示,第5步是让原本在下面显示变成在上面显示,根据屏幕摆放的位置做好调整即可,本喵这里就是将原本从右下角开始显示变成从左上角开始显示。

显示字符

到目前在OLED上只能点亮指定位置的像素点,如果要显示字符还需要我们将字符对应的所有像素点点亮,通过字符生成工具,可以直接获得要显示的字符所有对应的数据。

如上图,设置成阴码显示,选择列行式以及逆向取模,然后输入字符A点击生成以后,就会生成一个长为16字节的数组,将这个数组中的数据发送给显存,就会显示出来字符A。

设置显示起始位置:

cpp 复制代码
void OLED_SetPosition(uint8_t page, uint8_t col)
{
	OLED_SetPageAddr_PAGE(page);	//设置页起始地址
    OLED_SetColAddr_PAGE(col);		//设置列起始地址
}

设置显示起始位置,指定起始页地址和起始列地址。

cpp 复制代码
	uint8_t ch[16] = {0x00,0x00,0xC0,0x38,0xE0,0x00,0x00,0x00,0x20,0x3C,0x23,0x02,0x02,0x27,0x38,0x20};/*"A",0*/
	I2C_GPIO_ReInit();	//I2C的GPIO配置
	OLED_Init();		//初始化OLED
	OLED_Clear();		//清屏

	OLED_SetPosition(0, 0);		//设置起始位置
	OLED_WriteNBytes(ch,16);	//发送16个字节

main.c中,初始化完成后,设置显示的起始地址是第0页的第0列,发送A字符对应的16个字节数据,显示字符A。

如上图所示,但是此时显示的并不是一个完整的字符A,这是因为,我们用软件生成的子模是8×16的,所以需要用两页来显示。

cpp 复制代码
	OLED_SetPosition(0, 0);		//第0页
	OLED_WriteNBytes(ch,8);		//发送8字节
	
	OLED_SetPosition(1, 0);		//第1页
	OLED_WriteNBytes(ch + 8,8);//发送8字节

给第0页发送8字节数据,再给第1页发送8字节数据,此时字符A才能显示完整。

如上图,此时一个完整的字符就显示完成了,那么如果要显示字符串呢?难道把所有需要的字符都生成一遍吗?

同样使用该软件生成一个字库,该字库中包含所有ASCII码中的所有值。

如上图所示,该字库是一个二维数组,其中行号就对应着ASCII码值,所以根据行号就可以找到任何一个英文字母所对应的16字节数据,然后发给显存即可。

显示一个字符:

cpp 复制代码
void OLED_PutChar(uint8_t page, uint8_t col, char c)
{
	OLED_SetPosition(page, col);										
	OLED_WriteNBytes((uint8_t*)&ascii_font[c][0],8);//根据ASCII码索引,发送前8字节
	
	OLED_SetPosition(page + 1, col);
	OLED_WriteNBytes((uint8_t*)&ascii_font[c][8],8);//根据ASCII码索引,发送后8字节
}

在发送一个字符的时候,同样需要两页来显示,根据ASCII码值,在二维数组中找到对应字符所对应的数据,第一页发送前8个数据,第二页发送后8个数据。

显示一个字符串:

cpp 复制代码
void OLED_PrintString(uint8_t page, uint8_t col, char* str)
{
	while(*str != '\0')
	{
		OLED_PutChar(page,col,*str);
		col+=8;
		if(col > 127)
		{
			//127列显示满,调整页数
			page += 2;
		}		
		if(page > 7)
		{
			//全部显示满后,从头开始显示
			page = 0;
		}
		str++;
	}
}

调用该函数显示字符串的时候,传入一个字符串的形参,通过该指针将字符串中的所有字符挨个显示出来,直到遇到'\0'结束显示,当127列显示满后就需要调整页数,当页数满了以后,从头开始重新显示。

cpp 复制代码
	EnableDebugIRQ();
	KEY_GPIO_ReInit();
	I2C_GPIO_ReInit();
	OLED_Init();
	OLED_Clear();

	OLED_PrintString(0,0,"I Love Shanghai");

main.c中执行上面代码就会显示I Love Shanghai字符串在OLED屏幕上。

如上图,成功显示字符串,它的大小样式等都可以调节,有兴趣的小伙伴可以自己研究一下。

😽源码及资料

本喵已经将源码,包括I2C驱动源,OLED驱动源码,还有字模制作工具,SSD1306驱动芯片等资源上传,有需要的小伙伴可以去下载。传送门

😽总结

用OLED屏幕来显示字符是人机交互的一种重要方式,也是本喵之后要做的小项目中的一部分,I2C在这个过程中扮演了非常重要的角色,通过应用OLED可以对I2C通信协议有一个清晰的认识。

相关推荐
知识分享小能手4 小时前
React学习教程,从入门到精通, React 属性(Props)语法知识点与案例详解(14)
前端·javascript·vue.js·学习·react.js·vue·react
茯苓gao7 小时前
STM32G4 速度环开环,电流环闭环 IF模式建模
笔记·stm32·单片机·嵌入式硬件·学习
是誰萆微了承諾7 小时前
【golang学习笔记 gin 】1.2 redis 的使用
笔记·学习·golang
DKPT8 小时前
Java内存区域与内存溢出
java·开发语言·jvm·笔记·学习
aaaweiaaaaaa8 小时前
HTML和CSS学习
前端·css·学习·html
点灯小铭9 小时前
基于STM32单片机的智能粮仓温湿度检测蓝牙手机APP设计
stm32·单片机·智能手机·毕业设计·课程设计
看海天一色听风起雨落9 小时前
Python学习之装饰器
开发语言·python·学习
生擒小朵拉9 小时前
STM32添加库函数
java·javascript·stm32
云伴枫轻舞9 小时前
我对 OTA 的理解随记,附GD32/STM32例程
stm32·单片机·嵌入式硬件
speop10 小时前
llm的一点学习笔记
笔记·学习