4.4.1 任务分析 本任务要求开发一个具备交互功能的人机界面应用,该应用集环境温湿度显示、光照强度显 示与日历功能于一身。系统与 DHT11 温湿度传感器、BH1750 环境光照强度传感器和 OLED 显 示模块连接,通电后默认每隔一段时间采集一次环境参数,并将其显示在 OLED 显示模块上。用 户操作按键并保持长按,系统切换为日历功能,OLED 显示模块上显示当前的日期、星期以及时 间。显示界面样式可参考图 4-4-1。
图 4-4-1(a)显示了环境参数,从图中可知当前环境温度为 15℃,环境湿度为 51%,光 照强度为 2135lx。图 4-4-1(b)为日历功能显示,样式中显示的当前日期为 2019 年 1 月 15 日、星期一,时间为早上 6 点 55 分 32 秒。 根据任务要求,OLED 显示模块的驱动与操作是本任务的核心内容。常见的 OLED 显示模块 通过 SPI 与 STM32F4 系列微控制器相连。因此,本任务涉及的知识点有以下 3 点: ? SPI 规范; ? OLED 显示模块的工作原理; ? 使用 STM32F4 系列微控制器进行 OLED 编程配置并显示指定内容的方法。 4.4.2 知识链接 1.SPI 规范 (1)SPI 概述 串行外围设备接口(Serial Peripheral Interface,SPI)是一种高速的、全双工、同步通信总 线。SPI 最早由摩托罗拉(Motorola)公司在其 MC68HCXX 系列处理器上定义,经过多年的发 展,已被广泛地应用于 EEPROM、Flash、实时时钟芯片、网络通信控制器、A/D 转换器、OLED 显示模块等器件上。 (2)典型的 SPI 通信系统的连接方式 图 4-4-2 展示了典型的 SPI 通信系统的连接方式。 从图 4-4-2 中可以看到,基于 SPI 的通信系统使用 4 条信号引脚:SCK、MOSI、MISO 和 NSS,下面分别对这 4 个信号引脚进行介绍。 ① SCK SCK(Serial Clock)引脚为同步时钟信号线,用于数据通信的同步。同步时钟由主机产生, 具体频率大小取决于数据接收方。不同的设备支持的最高时钟频率不同,当两个设备之间进行 SPI 通信时,通信速率取决于低速设备。 ② MOSI MOSI(Master Output/Slave Input)引脚为“主机输出/从机输入”信号线,用于数据收发。 当设备被配置为主机时,通过该信号线发送数据;当设备被配置为从机时,通过该信号线接收 数据。
?③ MISO MISO(Master Input/Slave Output)引脚为“主机输入/从机输出”信号线,用于数据收发。当设 备被配置为从机时,通过该信号线发送数据;当设备被配置为主机时,通过该信号线接收数据。 ④ NSS NSS(Negative Slave Select, Negative 代表取反)引脚为“从机选择”信号线,也被称为片选 端,在不同的设备上有时也表示为 SS 或 CS。SPI 总线上同一时刻连接了多个从机,当主机需要与 某一台从机通信时,就通过“从机选择”信号线来确定要通信的从机。该信号线为低电平有效,因 此主机将某台从机的NSS 信号线置低后选中该从机,然后主机就可以开始与该从机进行通信。 (3)SPI 通信系统的通信时序 图 4-4-3 展示了 SPI 通信系统的通信时序。
从图 4-4-3 中可以看到,NSS、SCK 和 MOSI 这 3 条信号线为输出方向,由主机控制输出。 而 MISO 信号线为输入方向,主机通过该信号线接收从机数据。MOSI 与 MISO 信号线上的数据 收发仅当 NSS 信号线为低电平时有效,每个同步时钟(SCK)的信号周期均采样一位数据。 (4)SPI 通信的起始信号和停止信号 SPI 通信的起始信号如图 4-4-3 中标号①处所示。主机控制 NSS 信号线由高电平转为低电 平,选中总线上某从机之后,主从机之间开始通信。
SPI 通信的停止信号如图 4-4-3 中标号⑤处所示。主机控制 NSS 信号线由低电平转为高电 平,取消从机的选中状态并结束 SPI 通信。 (5)SPI 通信的数据有效性 从图 4-4-3 中可以看到,NSS 信号线电平的高低状态决定了 SPI 通信数据有效与否。仅当 NSS 信号线为低电平时(图 4-4-3 中标号④位置),MOSI 和 MISO 信号线上的数据收发有效。 两条数据线在 SCK 信号线的每个时钟周期传输一位数据,而且数据的输入与输出是同时进 行的。图 4-4-3 中的数据传输模式为最高有效位(Most Significant Bit,MSB)先行,但 SPI 规范并未规定数据传输应该 MSB 先行或最低有效位(Least Significant Bit,LSB)先行,只要 SPI 通信的双方约定好即可。 从图 4-4-3 中标号②和③处可以看到,MOSI 和 MISO 信号线上的数据在 SCK 的上升沿期 间触发电平,在 SCK 的下降沿被采样。我们称数据被采样的时刻为“数据有效”时刻,此时数 据线上的高电平表示数据“1”,低电平表示数据“0”。而在非数据采样时刻,两条数据线上的数 据均无效。 根据 SPI 规范,数据线上每次传输的数据可以是 8 位或 16 位。 (6)SPI 通信的模式 根据 SPI 规范,SPI 通信共有 4 种模式,分别是模式 0~模式 3。这几种模式之间的主要不 同有两点:一是总线空闲时,SCK 的电平状态不同;二是 MOSI 和 MISO 信号线上数据的采样 时刻不同。 在学习 SPI 通信的模式之前,我们需要掌握两个概念,分别是“时钟极性”与“时钟相位”。 ① 时钟极性 “时钟极性(CPOL)”用于指示当 SPI 通信设备处于空闲状态(即 NSS 线为高电平)时 SCK 信号线的电平状态。当 CPOL=0 时,空闲状态的 SCK 为低电平;当 CPOL=1 时,空闲状态的 SCK 为高电平。 ② 时钟相位 “时钟相位(CPHA)”用于配置数据采样的时刻。当 CPHA=0 时,MOSI 和 MISO 信号线上 的信号将会在 SCK 线的“奇数边沿”被采样,如图 4-4-4 所示 。当 CPHA=1 时,MOSI 和 MISO 信号线上的信号将会在 SCK 线的“偶数边沿”被采样,如图 4-4-5 所示。
图 4-4-4 展示了 CPHA=0 时的 SPI 通信时序。从图中可以看到,MOSI 和 MISO 线上的数 据采样时刻位于 SCK 线上的奇数(第 1、3、5、7、9 等)边沿。注意:当 CPOL=0 时,时钟的 奇数边沿为上升沿;而 CPOL=1 时,时钟的奇数边沿为下降沿,但这并不影响数据的采样。 图 4-4-5 展示了 CPHA=1 时的 SPI 通信时序。从图中可以看到,MOSI 和 MISO 线上的数 据采样时刻位于 SCK 线上的偶数(第 2、4、6、8、10 等)边沿。同样地,数据采样时刻不受 CPOL 参数的影响。
综上所述,CPOL 和 CPHA 各有两种不同的取值,不同取值的组合形成了 SPI 通信的 4 种模 式,如表 4-4-1 所示。需要注意的是,在 SPI 通信系统中,主机和从机必须工作在相同的模式 下才能正常通信。?
2.STM32F4 系列微控制器 SPI 的功能特性 STM32F4 系列微控制器内部集成了 SPI 外设,该外设具有以下主要特性: ? 可作为主机也可作为从机; ? 支持双线全双工、双线单工以及单线传输; ? 支持 SPI 通信的 4 种模式; ? 可选择 8 位或 16 位传输帧格式,并可设置数据传输 MSB 先行或 LSB 先行; ? 支持的 SCK 频率最高为 f PCLK /2(对于 STM32F407ZGT6 型号 MCU 而言, f PCLK2 =84 MHz, f PCLK1 =42 MHz); ? 主机和从机模式都可通过硬件或软件方式进行 NSS 片选管理。 图 4-4-6 展示了 STM32F4 系列微控制器 SPI 外设的硬件框图。?
从图 4-4-6 中可以看到,SPI 外设的硬件框图被分为 4 个部分:外部引脚、数据收发控制、 通信波特率控制和 SPI 通信整体控制,下面分别对它们进行介绍。 (1)外部引脚 根据 SPI 规范,SPI 通信需要使用 4 根信号线:MOSI、MISO、SCK 和 NSS(图 4-4-6 中 标号①位置)。根据 ST 公司的产品规格书,STM32F4 系列微控制器配备了 6 个 SPI 外设,各 SPI 外设的引脚映射如表 4-4-2 所示。?
由表 4-4-2 可知,在 6 个 SPI 外设中,SPI1、SPI4、SPI5 和 SPI6 挂载于 APB2 总线上, 支持的最高通信速率为 84 MHz / 2 = 42 MHz,而 SPI2 和 SPI3 挂载于 APB1 总线上,支持的最 高通信速率为 42 MHz / 2 = 21 MHz。需要注意的是,只有 176 引脚的 MCU 才有 PH 和 PI 端口。 (2)数据收发控制 从图 4-4-6 中标号②处对应的阴影部分可知,“地址和数据总线”“接收缓冲区”“移位寄存 项目 4 环境参数监测与显示系统的设计与实现 ?233 Chapter 4 器”“发送缓冲区”“MOSI 与 MISO 数据引脚”等部分构成了 SPI 通信的数据收发控制逻辑。其 中,MOSI 与 MISO 数据引脚与移位寄存器直接相连,是 SPI 通信数据的主要出入口。下面对数 据收发控制流程进行介绍。 ① 数据发送流程 ? 微控制器通过“地址和数据总线”,将需要发送的数据填充到“发送缓冲区”中。 ? “移位寄存器”以“发送缓冲区”为数据源,将数据一位一位地通过 MOSI 信号线发送 出去。 ② 数据接收流程 ? MISO 信号线将采样到的数据一位一位地接收进来。 ? 通过“移位寄存器”将数据一位一位地存储到“接收缓冲区”中。 ? 读取“数据寄存器”,获得“接收缓冲区”中的内容。 另外,配置“CR1”中的“DFF”位段,可将数据帧长度配置为 8bit 或 16bit。配置“CR1” 中的“LSBFIRST”位段,可控制数据传输是 MSB 先行还是 LSB 先行。 (3)通信波特率控制 在 SPI 通信中,主机须提供 SCK 信号,该信号决定了 SPI 通信的波特率。SCK 信号的时钟 源是 f PCLK1 或 f PCLK2 ,经分频后由波特率发生器输出。从图 4-4-6 中标号③处对应的阴影部分可知, 分频系数由“CR1”中的 BR[2:0]位段控制,具体配置方式如表 4-4-3 所示。?
(4)SPI 通信整体控制 从图 4-4-6 中标号④处对应的阴影部分可知,跟 SPI 通信配置相关的寄存器主要有 3 个: SPI_CR1、SPI_CR2 和 SPI_SR。 SPI_CR1 和 SPI_CR2 可以对 SPI 的通信模式、波特率、是否 MSB 先行、主从模式、单双 向模式等工作参数进行配置。用户读取 SPI_SR 中相应位的值,可获取 SPI 通信的工作状态,为 程序的后续走向提供参考。另外,用户还应根据应用的需求,配置是否产生 SPI 中断、是否使用 DMA 进行数据的传输等参数。 接下来通过一个示例对 SPI 通信的过程进行解析。图 4-4-7 展示了 SPI 通信主机的数据收 发时序。 读者在分析该示例时,应关注数据收发的流程,并理解 SR 中各状态标志位(TXE、BSY 和 RXNE 等)对程序流程的控制作用。在该示例中主机需要发送 3 个字节的数据(0xF1、 0xF2 和 0xF3),接收 3 个字节的数据(0xA1、0xA2 和 0xA3),数据收发流程及主要事件 如下。
① 设置“SPI_CR1”中的“SPE”位为 1,使能 SPI 模块,并控制 NSS 信号线为低电平, 产生起始信号。 ② 向“DR”中写入第一个要发送的数据 0xF1,该数据将会存储到“发送缓冲区”中。 ③ “移位寄存器”通过 MOSI 数据线将“发送缓冲区”中的数据一位一位地发送出去。 ④ 一帧数据发送完毕后,“SR”中的“TXE 标志位”置 1。此时可向“DR”中写入第二个 要发送的数据 0xF2,重复第②步和第③步的过程。0xF3 数据的发送流程亦是如此。 ⑤ 等待“RXNE 标志位”变为 1,这表明接收到了一帧数据。此时可读取“DR”获取“接 收缓冲区”中的数据内容。读取“DR”的同时会清除“RXNE 标志位”。重复本步骤操作即可接 收所有的数据。 ⑥ 等待“BSY 标志位”变为 0 后关闭 SPI 模块。 在这个示例中,数据收发的控制是通过“软件查询标志位”的方式进行的。这种控制方式效 率较低,且浪费 CPU 资源。我们可采用以下两种控制方式提高程序执行的效率。 一是配置使能 SPI 通信的“TXE 中断”与“RXNE 中断”。事件发生时会进入相应的中断服 务函数,用户可在中断服务函数中编写事件处理方法的程序。 二是配置使能 SPI 通信的直接内存访问(DMA)功能。使用 DMA 方式收发“DR”中的数 据可极大地提升程序执行的效率。
3.使用 STM32F4 标准外设库配置 SPI 外设的步骤 STM32F4 标准外设库提供了初始化与数据收发等函数,用于 SPI 外设的初始化配置与操作 控制,相关函数定义在库文件“stm32f4xx_spi.c”和“stm32f4xx_spi.h”中。若要将 STM32F4 系列微控制器的 SPI 外设配置为主机模式并能进行数据的收发,则具体的步骤如下。 (1)使能 SPI 时钟,配置相关的 GPIO 引脚功能 在使用某个 SPI 外设之前,需要先使能相应的 SPI 时钟。调用 RCC_APBxPeriphClockCmd() 函数可实现该功能,下列代码片段表示可使能 SPI2 的时钟。 RCC_APB1PeriphClockCmd(RCC_APB1Periph_SPI2, ENABLE); 除此之外,我们还需配置 SPI 外设对应 GPIO 引脚的工作模式为复用功能(复用为 SPI 外设 引脚)。根据表 4-4-2,PB15、PB14 和 PB10 这 3 个 GPIO 引脚可分别复用为 SPI2 的 MOSI、 MISO 和 SCK,配置的关键代码片段如下:?
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; //GPIO 引脚配置为复用功能 GPIO_PinAFConfig(GPIOB,GPIO_PinSource15,GPIO_AF_SPI2); //PB15 复用为 SPI2_MOSI GPIO_PinAFConfig(GPIOB,GPIO_PinSource14,GPIO_AF_SPI2); //PB14 复用为 SPI2_MISO GPIO_PinAFConfig(GPIOB,GPIO_PinSource10,GPIO_AF_SPI2); //PB10 复用为 SPI2_SCK (2)配置 SPI 的工作参数并初始化 SPI STM32F4 标准外设库提供了 SPI 初始化结构体和相应的初始化函数来完成 SPI 工作参数的 配置。SPI 初始化结构体的原型定义如下:
typedef struct
{
uint16_t SPI_Direction; // 配置 SPI 的通信方向
uint16_t SPI_Mode; // 配置 SPI 的主 / 从机模式
uint16_t SPI_DataSize; // 配置 SPI 的数据帧长度,可选 8bit/16bit
uint16_t SPI_CPOL; // 配置时钟极性,可选高 / 低电平
uint16_t SPI_CPHA; // 配置时钟相位,可选奇 / 偶数边沿采样
uint16_t SPI_NSS; // 配置 NSS 引脚由 SPI 硬件控制还是软件控制
uint16_t SPI_BaudRatePrescaler; // 配置时钟分频系数, f PCLK / 分频系数 = f SCK
uint16_t SPI_FirstBit; // 配置 MSB 或 LSB 先行
int16_t SPI_CRCPolynomial; // 配置 CRC 校验的多项式
} SPI_InitTypeDef;
接下来对 SPI 初始化结构体各成员变量的功能进行介绍。 ① SPI_Direction 该成员被用于配置 SPI 的通信方向,可供配置的选项如下: ? 双线全双工(SPI_Direction_2Lines_FullDuplex); ? 双线只接收(SPI_Direction_2Lines_RxOnly); ? 单线只接收(SPI_Direction_1Line_Rx); ? 单线只发送(SPI_Direction_1Line_Tx)。 ② SPI_Mode 该成员被用于配置 SPI 的模式,可供配置的选项如下: ? 主机模式(SPI_Mode_Master); ? 从机模式(SPI_Mode_Slave)。 在 SPI 通信系统中,微控制器一般被配置为主机模式。 ③ SPI_DataSize 该成员被用于配置 SPI 通信的数据帧长度,可供配置的选项如下: ? 数据帧长度为 8 bit(SPI_DataSize_8b); ? 数据帧长度为 16 bit(SPI_DataSize_16b)。
④ SPI_CPOL 该成员被用于配置 SPI 外设的“时钟极性”参数,它与 SPI_CPHA 的不同取值组合形成了 4 种不同的 SPI 通信模式,具体如表 4-4-1 所示。可供配置的选项如下: ? CPOL 高电平(SPI_CPOL_High); ? CPOL 低电平(SPI_CPOL_Low)。 ⑤ SPI_CPHA 该成员被用于配置 SPI 外设的“时钟相位”参数,它与 SPI_CPOL 的不同取值组合形成了 4 种不同的 SPI 通信模式,具体如表 4-4-1 所示。可供配置的选项如下: ? 在 SCK 的奇数边沿采集数据(SPI_CPHA_1Edge); ? 在 SCK 的偶数边沿采集数据(SPI_CPHA_2Edge)。 ⑥ SPI_NSS 该成员被用于配置 NSS 引脚(俗称片选端)的控制模式,可供配置的选项如下: ? 硬件控制模式(SPI_NSS_Hard); ? 软件控制模式(SPI_NSS_Soft)。 在实际应用中,使用软件控制模式居多。本任务的示例程序就使用了软件控制模式对 NSS 引脚进行控制。 ⑦ SPI_BaudRatePrescaler 该成员被用于配置 SPI 波特率的分频系数,时钟源为 f PCLK ,分频后的时钟信号作为 SCK 信 号线的时钟信号,可供配置的参数如表 4-4-3 所示。 例如:要对 f PCLK 时钟进行 2 分频的宏定义为 SPI_BaudRatePrescaler_2,其他分频系数的配 置选项与此类似。 ⑧ SPI_FirstBit 该成员被用于配置 SPI 通信的数据传输是“高位数据先行”还是“低位数据先行”,可供配 置的选项如下: ? 高位数据(MSB)先行(SPI_FirstBit_MSB); ? 低位数据(LSB)先行(SPI_FirstBit_LSB)。 ⑨ SPI_CRCPolynomial 该成员被用于配置 SPI 通信的 CRC 校验多项式,一般配置其值大于 1 即可。 SPI 初始化结构体各成员变量配置完毕后,调用 SPI_Init()函数将变量值写入 SPI_CR1 中, 完成 SPI 外设的初始化,关键代码片段如下: SPI_Init(SPI2, &SPI_InitStructure); (3)使能 SPI 外设 配置好 SPI 的工作参数后,我们可使能某 SPI 外设。标准外设库提供了使能 SPI 的函数 SPI_Cmd(),该函数使能 SPI2 外设的代码片段如下: SPI_Cmd(SPI2, ENABLE); (4)编写 SPI 通信的数据传输函数 STM32F4 标准外设库提供了 SPI 发送数据函数和接收数据函数,它们的原型定义如下: void SPI_I2S_SendData(SPI_TypeDef* SPIx, uint16_t Data); uint16_t SPI_I2S_ReceiveData(SPI_TypeDef* SPIx);
前者为 SPI 发送数据函数,它需要两个参数:一是 SPI 编号;二是需要发送的数据,数据类 型为无符号 16 位整型数。 后者为 SPI 接收数据函数,它只需一个参数,即 SPI 编号。该函数返回接收到的数据,数据 类型为无符号 16 位整型数。 用户可对 STM32F4 标准外设库提供的基本数据收发函数进行封装,编写相应的数据传输函 数,以满足具体应用场景的需求。 (5)获取 SPI 传输的状态 我们在解析SPI通信数据收发示例时,提到了若干个在SPI通信中用于指示传输状态的标志, 如 TXE、RXNE、BSY 等。在 SPI 通信系统的程序设计过程中,我们经常需要获取 SPI 传输的状 态(如数据是否发送完成、是否收到了新数据等),并使其作为后续程序走向的判断依据。 STM32F4 标准外设库提供了 SPI_I2S_GetFlagStatus()函数来获取相应的状态标志。例如,判断 SPI2 是否收到了新数据的代码片段如下: SPI_I2S_GetFlagStatus(SPI2, SPI_I2S_FLAG_RXNE); 4.OLED 显示模块的编程控制 (1)OLED 显示模块概述 有机发光二极管(Organic Light-Emitting Diode,OLED),又称有机激光显示。OLED 同时 具备自发光、无需背光源、对比度高、厚度薄、视角广、反应速度快、可用于挠曲性面板、使用温 度范围广、构造及制程较简单等优良的特性,因此被认为是下一代的平面显示器的新兴应用技术。 OLED 分为被动矩阵 OLED 和主动矩阵 OLED 两种类型,两者在驱动方式上有所不同。 被动矩阵 OLED(PassiveMatrixOLED,PMOLED)由阴极带、有机层和阳极带构成,阳极 带与阴极带相互垂直,它们的交叉点形成像素,也就是发光的部位。外部电路向选取的阴极带和 阳极带施加电流,从而决定哪些像素发光,哪些像素不发光。另外,每个像素的亮度与施加电流 的大小成正比。PMOLED 的优点是结构简单,制造成本低。缺点是耗电量高,因此 PMOLED 不 适合在大尺寸、高分辨率的面板上应用。 主动矩阵 OLED(Active Matrix OLED,AMOLED)采用独立的薄膜晶体管(Thin Film Transistor,TFT)控制每个像素,每个像素都可以连续且独立地发光。AMOLED 的优点是耗电 量低,发光元件寿命长。 接下来介绍一种在智能电子产品中常用的 OLED 显示模块,其实物如图 4-4-8 所示。
该显示模块具有以下特点。
① 集成了显示驱动芯片(如 SSD1306),该驱动芯片体积很小,一般被封装在显示屏背面 的玻璃基板上。 ② 尺寸可选 0.96 寸、1.3 寸、1.54 寸(1 寸≈3.33cm)等,显示模块整体体积小。 ③ 分辨率较高,一般为 128 像素×64 像素。 ④ 所需供电电压低,一般为 3.3 V。 ⑤ 支持多种接口,如 8 位 6800/8080 并行接口、3 线/4 线 SPI 和 I 2 C 接口等,用户可根据 应用需求选择合适的接口方式。 (2)显示驱动芯片的控制原理 在实际应用中,我们一般使用 MCU 作为主控,通过编程驱动 OLED 显示模块显示指定的字 符和数字。在编写 OLED 驱动程序之前,我们须了解显示驱动芯片的控制原理。接下来对显示驱 动芯片 SSD1306 的控制原理进行介绍。 SSD1306 是一款内置了“有机/高分子”发光二极管点阵显示系统控制器的单片 CMOSOLED/PLED 驱动芯片,它专门为共阴极 OLED 显示面板而设计。SSD1306 内置了对比 度调节电路、显示存储器和振荡器,可支持 256 级亮度调节。 SSD1306 具有以下主要特性。 ? 支持 128 像素×64 像素的点阵显示面板。 ? 输入工作电压低:1.65~3.3 V。 ? 内部集成容量为(128×64)bit 的 SRAM 显示缓存。 ? 内部集成振荡器,可减少周边电路元器件。 ? 支持多种接口:8 位 6800/8080 并行接口、3 线/4 线 SPI 和 I 2 C 接口。 ? 行列均可重映射。 ? 支持水平方向和垂直方向的持续滚动,以实现屏幕保护的功能。 ① SSD1306 的 GDDRAM 介绍 图形显示数据存储器(Graphic Display Data Random Access Memory,GDDRAM)主要 用于存储显示数据。用户通过 MCU 把需要显示的数据写入 GDDRAM,然后向 SSD1306 发送相 应的显示命令,显示驱动芯片则会按照用户的命令要求逐帧扫描显示。 SSD1306 的 GDDRAM 存储空间大小为 1024B,该存储空间里的每个 bit 与 OLED 显示屏 上 128 像素×64 像素的像素点阵是一一对应的。这些空间被分成了 8 页(编 号 Page0~Page7), 每页含 128 个位段(编号 Seg0~Seg127),如 图 4-4-9 所示。
图 4-4-10 展示了 GDDRAM 某分页(Page2)的存储结构,从图中可以看到一个页包含 8 行、128 列(即每页的存储空间为 128B)。每个字节按竖向排列,低位在上、高位在下。
② SSD1306 的指令系统解析 按照功能来分,SSD1306 的指令可分为 5 大类,即基础指令、显示滚动指令、地址设置指 令、硬件配置指令和时序设置指令。这些指令长短不一,最短的是单字节指令,最长的是 6B 指 令。各指令的详细说明如表 4-4-4 所示。
?③ 存储器寻址模式介绍 要在 OLED 显示屏的指定位置显示字符,我们需要先指定字符的显示位置(又称为“地址” 或“指针”)。因此我们有必要了解 SSD1306 的存储器寻址模式。SSD1306 有 3 种不同的存储器 寻址模式:页寻址模式、水平寻址模式和垂直寻址模式。表 4-4-4 中的第 12 条指令即可设置 SSD1306 的存储器寻址模式。 页寻址模式支持在本页内连续写入数据。MCU 往 SSD1306 依次发送“确定寻址模式”“确 定页指针”“确定列起始指针”指令后,就可以逐个字节连续写入数据。这种模式既可以写入整 行数据,也可以在该页的任意一列起写入一列或者多列数据,是最灵活的一种写入方式。页寻址 模式下的地址指针移动示意如图 4-4-11 所示。
④ SSD1306 初始化流程 掌握了 SSD1306 显示驱动芯片的控制原理和指令系统后,接下来我们对其初始化流程进行 学习。SSD1306 的典型初始化流程如图 4-4-14 所示,该初始化流程是芯片手册推荐的流程, 我们只需对其进行小幅度修改即可适应项目需求。?
4.4.3 任务实施 1.硬件连接 按照表 4-4-5 所示的人机界面应用系统硬件接线表将温湿度传感器 DHT11、环境光照强度 传感器 BH1750、OLED 显示模块与 STM32F4 系列微控制器相连。?
2.编写 SPI 外设的初始化程序和数据收发程序 复制一份任务 4.3 的工程,并将其重命名为“task4.4_OLED”。在“ HARDWARE”文件夹 下新建名为“SPI”的子文件夹,新建“bsp_spi.c”和“bsp_spi.h”两个文件,将它们加入工 程中,并配置头文件包含路径。在“bsp_spi.c”文件中编写 SPI 外设的初始化程序和数据收发 程序,在“bsp_spi.h”文件中编写 SPI 外设主要引脚的宏定义和函数声明。 首先在“bsp_spi.h”文件中输入以下代码:
#ifndef __BSP_SPI_H
#define __BSP_SPI_H
#include "sys.h"
/* SPI2_MOSI PB15/PC3 */
#define SPI2_MOSI_CLK RCC_AHB1Periph_GPIOB
#define SPI2_MOSI_PORT GPIOB
#define SPI2_MOSI_PIN GPIO_Pin_15
#define SPI2_MOSI_PINSOURCE GPIO_PinSource15
#define SPI2_MOSI_AF GPIO_AF_SPI2
/* SPI2_SCK PB10/PB13 */
#define SPI2_SCK_CLK RCC_AHB1Periph_GPIOB
#define SPI2_SCK_PORT GPIOB
#define SPI2_SCK_PIN GPIO_Pin_10
#define SPI2_SCK_PINSOURCE GPIO_PinSource10
#define SPI2_SCK_AF GPIO_AF_SPI2
void SPI2_Init(void);
uint8_t SPI2_ReadWriteByte(uint8_t TxData);
#endif
?然后在“bsp_spi.c”文件中输入以下代码:
#include "bsp_spi.h"
/**
* @brief SPI2 外设初始化
* @param None
* @retval None
*/
void SPI2_Init(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
SPI_InitTypeDef SPI_InitStructure;
RCC_AHB1PeriphClockCmd(SPI2_MOSI_CLK|SPI2_SCK_CLK,ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_SPI2,ENABLE);
GPIO_PinAFConfig(SPI2_MOSI_PORT,SPI2_MOSI_PINSOURCE,SPI2_MOSI_AF);
GPIO_PinAFConfig(SPI2_SCK_PORT,SPI2_SCK_PINSOURCE,SPI2_SCK_AF);
/* MOSI-PB15 SCK-PB10 */
GPIO_InitStructure.GPIO_Pin = SPI2_MOSI_PIN;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; // 速度 50MHz
GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; // 推挽复用输出
GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_NOPULL;
GPIO_Init(SPI2_MOSI_PORT,&GPIO_InitStructure);
GPIO_InitStructure.GPIO_Pin = SPI2_SCK_PIN;
GPIO_Init(SPI2_SCK_PORT,&GPIO_InitStructure);
RCC_APB1PeriphResetCmd(RCC_APB1Periph_SPI2,ENABLE); // 复位 SPI2
RCC_APB1PeriphResetCmd(RCC_APB1Periph_SPI2,DISABLE); // 停止复位 SPI2
/* SPI 工作参数配置 */
SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex;// 双线全双工
SPI_InitStructure.SPI_Mode = SPI_Mode_Master; // 主机模式
SPI_InitStructure.SPI_DataSize =SPI_DataSize_8b; // 数据帧长度 8bit
SPI_InitStructure.SPI_CPOL = SPI_CPOL_High; // 空闲时 SCK 高电平
SPI_InitStructure.SPI_CPHA = SPI_CPHA_2Edge;// 在 SCK 的偶数边沿采集数据
SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; // 片选线由软件管理
SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_2;//2 分频
SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; // 高位在前
SPI_InitStructure.SPI_CRCPolynomial = 7; //CRC 多项式
SPI_Init(SPI2,&SPI_InitStructure);
SPI_Cmd(SPI2,ENABLE);
}
/**
* @brief SPI2 发送与接收数据
* @param TxData: 要发送的数据
* @retval 通过 SPI2 接收到的数据
*/
uint8_t SPI2_ReadWriteByte(uint8_t TxData)
{
uint8_t retry = 0;
/* 检查发送缓存空标志位 TXE */
while (SPI_I2S_GetFlagStatus(SPI2, SPI_I2S_FLAG_TXE) == RESET)
{
retry++;
if(retry>200) return 0;
}
SPI_I2S_SendData(SPI2, TxData); // 通过外设 SPI2 发送数据
retry=0;
/* 检查接收缓存非空标志位 RXNE */
while (SPI_I2S_GetFlagStatus(SPI2, SPI_I2S_FLAG_RXNE) == RESET)
{
retry++;
if(retry>200) return 0;
}
return SPI_I2S_ReceiveData(SPI2); // 返回 SPI2 接收的数据
}
3.编写 OLED 显示模块初始化程序和字符显示程序 在“HARDWARE”文件夹下新建名为“OLED”的子文件夹,新建“oled.c”和“oled.h” 两个文件,将它们加入工程中,并配置头文件包含路径。在“oled.c”文件中编写 OLED 显示模 块初始化程序和字符显示程序,具体函数清单如表 4-4-6 所示。在“oled.h”文件中编写 OLED 显示模块主要引脚的宏定义和函数声明。
首先在“oled.h”文件中输入以下代码:?
#ifndef __OLED_H
#define __OLED_H
#include "sys.h"
/* OLED CS(NSS) PA3 */
#define OLED_CS_CLK RCC_AHB1Periph_GPIOA
#define OLED_CS_PORT GPIOA
#define OLED_CS_PIN GPIO_Pin_3
#define OLED_CS_LOW GPIO_ResetBits(OLED_CS_PORT,OLED_CS_PIN)
#define OLED_CS_HIGH GPIO_SetBits(OLED_CS_PORT,OLED_CS_PIN)
/* OLED DC(Command or Data) PA12 */
#define OLED_DC_CLK RCC_AHB1Periph_GPIOA
#define OLED_DC_PORT GPIOA
#define OLED_DC_PIN GPIO_Pin_12
#define OLED_DC_LOW GPIO_ResetBits(OLED_DC_PORT,OLED_DC_PIN)
#define OLED_DC_HIGH GPIO_SetBits(OLED_DC_PORT,OLED_DC_PIN)
/* OLED RST PA11 */
#define OLED_RST_CLK RCC_AHB1Periph_GPIOA
#define OLED_RST_PORT GPIOA
#define OLED_RST_PIN GPIO_Pin_11
#define OLED_RST_LOW GPIO_ResetBits(OLED_RST_PORT,OLED_RST_PIN)
#define OLED_RST_HIGH GPIO_SetBits(OLED_RST_PORT,OLED_RST_PIN)
void OLED_Write_Data(u8 data);
void OLED_Write_Cmd(u8 cmd);
void OLED_GPIO_Config(void);
void OLED_Init(void);
void OLED_Set_Pos(u8 x,u8 y);
void OLED_DrawPoint(u8 x,u8 y,u8 mode);
void OLED_Display_Clear(void);
void OLED_Display_Onechar(u8 x,u8 y,u8 chr,u8 size,u8 mode);
void OLED_Display_String(u8 x,u8 y,char *p, u8 size);
#endif
然后在“oled.c”文件中输入以下代码:
#include "delay.h"
#include "oled.h"
#include "bsp_spi.h"
#include "oledfont.h" // 该文件为字库文件
/*
定义 OLED 的显存 , 存放格式如下
[Page0]0 1 2 3 ... 127 // 第 0 页
[Page1]0 1 2 3 ... 127 // 第 1 页
[Page2]0 1 2 3 ... 127 // 第 2 页
[Page3]0 1 2 3 ... 127 // 第 3 页
[Page4]0 1 2 3 ... 127 // 第 4 页
[Page5]0 1 2 3 ... 127 // 第 5 页
[Page6]0 1 2 3 ... 127 // 第 6 页
[Page7]0 1 2 3 ... 127 // 第 7 页
*/
uint8_t OLED_GRAM[128][8];
/**
* @brief 向显示驱动芯片写入数据
* @param data :要写入的数据
* @retval None
*/
void OLED_Write_Data(u8 data)
{
OLED_CS_LOW;
OLED_DC_HIGH; //DC 引脚高电平,写入数据
SPI2_ReadWriteByte(data); // 调用硬件 SPI 写入 1 个字节
}
/**
* @brief 向显示驱动芯片写入命令
* @param Cmd :要写入的命令
* @retval None
*/
void OLED_Write_Cmd(u8 cmd)
{
OLED_CS_LOW;
OLED_DC_LOW; //DC 引脚低电平,写入命令
SPI2_ReadWriteByte(cmd); // 调用硬件 SPI 写入 1 个字节
}
/**
* @brief 将要显示的数据写入 SSD1306 的 GDDRAM
* @param None
* @retval None
*/
void OLED_Refresh_Gram(void)
{
uint8_t page,column;
for(page=0; page<8; page++)
{
OLED_Write_Cmd(0xB0+page); // 设置页地址 (0~7)
OLED_Write_Cmd(0x00); // 设置列低 4 位地址
OLED_Write_Cmd(0x10); // 设置列高 4 位地址
for(column=0; column<128; column++)
OLED_Write_Data(OLED_GRAM[column][page]);
}
}
/**
* @brief 初始化 OLED 控制相关 GPIO 引脚
* @param None
* @retval None
*/
void OLED_GPIO_Config(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
RCC_AHB1PeriphClockCmd(OLED_CS_CLK|OLED_DC_CLK|OLED_RST_CLK,ENABLE);
/* CS(NSS)-PA3 | DC(Data or Command)-PA12 | RST-PA11 */
GPIO_InitStructure.GPIO_Pin = OLED_CS_PIN;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT;
GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(OLED_CS_PORT,&GPIO_InitStructure);
GPIO_InitStructure.GPIO_Pin = OLED_DC_PIN;
GPIO_Init(OLED_DC_PORT,&GPIO_InitStructure);
GPIO_InitStructure.GPIO_Pin = OLED_RST_PIN;
GPIO_Init(OLED_RST_PORT,&GPIO_InitStructure);
}
/**
* @brief OLED 显示模块初始化
* @param None
* @retval None
*/
void OLED_Init(void)
{
OLED_GPIO_Config(); // 初始化 OLED 控制相关引脚
OLED_RST_HIGH; // 硬件复位
delay_ms(100);
OLED_RST_LOW;
delay_ms(100);
OLED_RST_HIGH;
OLED_Write_Cmd(0xAE); // 关闭显示
OLED_Write_Cmd(0x20); // 设置存储器寻址模式
OLED_Write_Cmd(0x02); // 页寻址模式
OLED_Write_Cmd(0xA8); // 设置行扫多路系数
OLED_Write_Cmd(0x3F);
OLED_Write_Cmd(0xD3); // 设置行扫偏移 (0x00~0x3F)
OLED_Write_Cmd(0x00); //not offset
OLED_Write_Cmd(0x40|0x00); // 设置显示起始行 (0x00~0x3F)
OLED_Write_Cmd(0xA1); // 设置段重映射 0xa0 左右反置 0xa1 正常
OLED_Write_Cmd(0xC8); // 设置行扫方向 0xc0 上下反置 0xc8 正常
OLED_Write_Cmd(0xD9); // 设置充放电周期
OLED_Write_Cmd(0xF1); // 充电周期 15Clocks & 放电周期 1Clock
OLED_Write_Cmd(0xDA); // 设置行扫方式
OLED_Write_Cmd(0x12);
OLED_Write_Cmd(0x81); // 设置背光对比度
OLED_Write_Cmd(0x7F); // 默认是 0x7F(0x00~0xFF)
OLED_Write_Cmd(0xA4); // 设置非全屏显示 (0xa4/0xa5)
OLED_Write_Cmd(0xA6); // 设置正常显示 (0xa6/a7)
OLED_Write_Cmd(0xD5); // 设置时钟分频
OLED_Write_Cmd(0x80);
OLED_Write_Cmd(0x8D); // 打开内置升压泵
OLED_Write_Cmd(0x14); //0x14 打开, 0x10 关闭
OLED_Write_Cmd(0xDB); // 反向截止电压设置
OLED_Write_Cmd(0x40);
OLED_Write_Cmd(0xAF); // 打开 OLED 面板显示
OLED_Display_Clear(); // 清屏
}
/**
* @brief OLED 清屏函数
* @param None
* @retval None
*/
void OLED_Display_Clear(void)
{
u8 i,n;
for(i=0; i<8; i++)
for(n=0; n<128; n++)
OLED_GRAM[n][i]=0x00;
OLED_Refresh_Gram(); //OLED 显存写入全 0
}
/**
* @brief OLED 定位函数
* @param x :列地址 (0~127) , y :行地址 (0~63)
* @retval None
*/
void OLED_Set_Pos(u8 x,u8 y)
{
OLED_Write_Cmd(0xB0 + y/8); // 设置页指针
OLED_Write_Cmd(x&0x0F); // 列低 4 位地址
OLED_Write_Cmd(((x&0xF0)>>4)|0x10); // 列高 4 位地址
}
/**
* @brief 在指定位置画点函数
* @param x: 列地址 (0~127) , y: 行地址 (0~63)
* @param mode:1 填充, 0 清空
* @retval None
*/
void OLED_DrawPoint(u8 x,u8 y,u8 mode)
{
u8 page,bx,temp=0;
OLED_Set_Pos(x,y);
if(x>127||y>63) return; // 超出范围
page=y/8;
bx=y%8;
temp=1<<bx;
if(mode) OLED_GRAM[x][page]|=temp;
else OLED_GRAM[x][page]&=~temp;
/* 写入显示缓存 */
OLED_Write_Data(OLED_GRAM[x][page]);
}
/**
* @brief 在指定位置显示字符
* @param x :列地址 (0~127) , y :行地址 (0~63)
* @param chr :要显示的字符 | size :字体大小 12/16/24
* @param mode : 1 填充, 0 清空
* @retval None
*/
void OLED_Display_Onechar(u8 x,u8 y,u8 chr,u8 size,u8 mode)
{
u8 temp,t,t1;
u8 y0=y;
u8 csize=(size/8+((size%8)?1:0))*(size/2);
chr=chr-' '; // 得到偏移后的值
for(t=0; t<csize; t++)
{
if(size==12)
temp = oled_asc2_1206[chr][t]; // 调用 1206 字体
else if(size==16)
temp = oled_asc2_1608[chr][t]; // 调用 1608 字体
else if(size==24)
temp = oled_asc2_2412[chr][t]; // 调用 2412 字体
else return; // 字库中不存在相关字体
for(t1=0; t1<8; t1++)
{
if(temp & 0x80)
OLED_DrawPoint(x, y, mode);
else
OLED_DrawPoint(x, y, !mode);
temp <<= 1;
y++;
if((y-y0) == size)
{
y = y0; x++; break;
}
}
}
}
/**
* @brief 在指定位置显示字符串
* @param x :列地址 (0~127) , y :行地址 (0~63)
* @param *p :要显示的字符串起始地址
* @param size :字体大小 12/16/24
* @retval None
*/
void OLED_Display_String(u8 x,u8 y,char *p, u8 size)
{
while((*p <= '~') && (*p >= ' '))// 判断是否为非法字符
{
if(x > (128-(size/2)))
{
x = 0;
y += size;
}
if(y > (64-size))
{
y = x = 0;
OLED_Display_Clear();
}
OLED_Display_Onechar(x,y,*p,size,1);
x += size/2;
p++;
}
}
4.编写 OLED 显示模块显示日历功能的函数 在任务 4.3 中,我们已完成了 STM32F4 系列微控制器的 RTC(实时时钟)的应用开发。我 们可继续沿用已编写好的 RTC 初始化程序,同时根据本任务的“日历显示”功能要求,增加相 应的显示函数与其他代码。在“bsp_rtc.c”文件中增加以下代码:
#include "bsp_rtc.h"
#include "oled.h"
#include "usart.h"
char DateShow[50],TimeShow[50];
void OLED_Show_DateTime(void);
/**
* @brief 显示日期和时间
* @param None
* @retval None
*/
void RTC_Show_DateTime(void)
{
char WeekDay[4]; // 用于存放星期的缩写,如 Mon
RTC_TimeTypeDef RTC_TimeStructure;
RTC_DateTypeDef RTC_DateStructure;
/* 获取日历 */
RTC_GetTime(RTC_Format_BIN, &RTC_TimeStructure);
RTC_GetDate(RTC_Format_BIN, &RTC_DateStructure);
/* 将 RTC 获取到的“星期”参数转化为相应的英文缩写 */
switch(RTC_DateStructure.RTC_WeekDay)
{
case 1: // 星期一
WeekDay[0]='M';WeekDay[1]='O';WeekDay[2]='N';
break;
case 2: // 星期二
WeekDay[0]='T';WeekDay[1]='U';WeekDay[2]='E';
break;
case 3: // 星期三
WeekDay[0]='W';WeekDay[1]='E';WeekDay[2]='D';
break;
case 4: // 星期四
WeekDay[0]='T';WeekDay[1]='H';WeekDay[2]='U';
break;
case 5: // 星期五
WeekDay[0]='F';WeekDay[1]='R';WeekDay[2]='I';
break;
case 6: // 星期六
WeekDay[0]='S';WeekDay[1]='A';WeekDay[2]='T';
break;
case 7: // 星期日
WeekDay[0]='S';WeekDay[1]='U';WeekDay[2]='N';
break;
}
/* 串口显示日期 */
printf("The Date : Y:20%0.2d - M:%0.2d - D:%0.2d - W:%0.2d\r\n",
RTC_DateStructure.RTC_Year,
RTC_DateStructure.RTC_Month,
RTC_DateStructure.RTC_Date,
RTC_DateStructure.RTC_WeekDay);
/* 使用 OLED 显示日期 */
sprintf(DateShow,"20%0.2d-%0.2d-%0.2d %s",
RTC_DateStructure.RTC_Year,
RTC_DateStructure.RTC_Month,
RTC_DateStructure.RTC_Date,
WeekDay);
/* 串口显示时间 */
printf("The Time : %0.2d:%0.2d:%0.2d \r\n\r\n",
RTC_TimeStructure.RTC_Hours,
RTC_TimeStructure.RTC_Minutes,
RTC_TimeStructure.RTC_Seconds);
/* 使用 OLED 显示时间 */
sprintf(TimeShow,"%0.2d:%0.2d:%0.2d",
RTC_TimeStructure.RTC_Hours,
RTC_TimeStructure.RTC_Minutes,
RTC_TimeStructure.RTC_Seconds);
OLED_Show_DateTime(); // 使用 OLED 显示日期时间
}
/**
* @brief OLED 显示日期和时间
* @param None
* @retval None
*/
void OLED_Show_DateTime(void)
{
OLED_Display_String(32,0,"Calendar",16);
OLED_Display_String(4,32,DateShow,16);
OLED_Display_String(32,48,TimeShow,16);
}
在“bsp_rtc.h”文件中可增加函数声明。 5.编写 main()函数 在“main.c”文件中输入以下代码:
#include "sys.h"
#include "delay.h"
#include "usart.h"
#include "led.h"
#include "key.h"
#include "exti.h"
#include "dht11.h"
#include "bsp_iic.h"
#include "bh1750.h"
#include "bsp_spi.h"
#include "bsp_rtc.h"
#include "oled.h"
uint16_t bh1750Light = 0; // 采集的光照值,单位 lx
uint8_t temperature = 0; // 采集的温度值,单位℃
uint8_t humidity = 0; // 采集的湿度值
char tempString[50], humiString[50], lightString[50];
uint8_t refresh_flag = 2, keyValue = 0;
void Show_TempHumiLight(void);
int main(void)
{
delay_init(168); // 延时函数初始化
LED_Init(); //LED 端口初始化
Key_Init(); // 按键端口初始化
EXTIx_Init(); // 外部中断初始化
USART1_Init(115200); //USART1 初始化
IIC_Init(); //IIC 总线初始化
BH1750_Init(); //BH1750 初始化
DHT11_Init(); //DHT11 初始化
SPI2_Init(); //SPI2 外设初始化
OLED_Init(); //OLED 显示模块初始化
RTC_CLK_Config(1); //RTC 配置,时钟源选择 LSE
while(DHT11_Init()) //DHT11 初始化
{
printf("DHT11 Init Error!\r\n");
delay_ms(500);
}
printf("DHT11 Init Success!\r\n");
/* 若备份区域读取的值不对 */
if (RTC_ReadBackupRegister(RTC_BKP_DR0) != 0x5F5F)
{
RTC_Set_DateTime(); // 设置时间和日期
}
else
{
printf("\r\n 不需要重新配置 RTC …… \r\n");
/* 使能 PWR 时钟 */
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
/* PWR_CR:DBF 置 1 ,使能 RTC 、 RTC 备份寄存器和备份 SRAM 的访问 */
PWR_BackupAccessCmd(ENABLE);
/* 等待 RTC APB 寄存器同步 */
RTC_WaitForSynchro();
}
while(1)
{
if(keyValue == KEY_D_PRESS) // 如果“下键”按下
{
if(refresh_flag == 1)
{
refresh_flag = 2;
OLED_Display_Clear(); // 刷一次屏
}
RTC_Show_DateTime(); // 显示时间和日期
}
else // 如果“下键”没有按下
{
bh1750Light = BH1750_Measure(); // 读取 BH1750 的光照强度值
DHT11_Read_Data(&temperature,&humidity);// 读取 DHT11 的温湿度值
/* 组合需要显示的信息 */
sprintf(lightString,"Light:%0.5d",bh1750Light);
printf("%s",lightString);
sprintf(tempString,"Temp:%d",temperature);
printf("%s",tempString);
sprintf(humiString,"Humi:%d",humidity);
printf("%s",humiString);
if(refresh_flag == 2)
{
refresh_flag = 1;
OLED_Display_Clear(); // 刷一次屏
}
Show_TempHumiLight(); // 显示环境参数
}
LED1 = ~LED1;delay_ms(500);
}
}
/**
* @brief OLED 显示环境参数 ( 温度 / 湿度 / 光照强度 )
* @param None
* @retval None
*/
void Show_TempHumiLight(void)
{
OLED_Display_String(20,0,"Environment",16);
OLED_Display_String(20,16,tempString,16);
OLED_Display_String(20,32,humiString,16);
OLED_Display_String(20,48,lightString,16);
}
|