本章将学习 ESP32-S3 的硬件 SPI 接口,使用 SPI 接口去驱动 LCD 屏。在本章中,实现和 LCD 屏之间的通信,实现 ASCII 字符、彩色、图片和图形的显示。
本章分为如下几个小节:
22.1 SPI 与 LCD 简介
22.2 硬件设计
22.3 程序设计
22.4 下载验证

22.1 SPI 与 LCD 简介
22.1.1 SPI 介绍

        SPI(Serial Peripheral Interface,串行外设接口)是一种高速、全双工、同步的串行通信协议,由摩托罗拉公司(Motorola)推出,广泛应用于微控制器与各种外围设备之间的短距离通信。SPI 是一种高速的全双工、同步、串行的通信总线,已经广泛应用在众多 MCU、存储芯片、 AD转换器和 LCD 之间。
        SPI 通信跟 IIC 通信一样,通信总线上允许挂载一个主设备和一个或者多个从设备。为了跟从设备进行通信,一个主设备至少需要 4 跟数据线,分别为:
        MOSI(Master Out / Slave In):主数据输出,从数据输入,用于主机向从机发送数据。
        MISO(Master In / Slave Out):主数据输入,从数据输出,用于从机向主机发送数据。
        SCLK(Serial Clock):时钟信号,由主设备产生,决定通信的速率。
        CS(Chip Select):从设备片选信号,由主设备产生,低电平时选中从设备。
        多从机 SPI 通信网络连接如下图所示。

图 22.1.1.1 多从机 SPI 通信网络图

        从上图可以知道, MOSI、 MISO、 SCLK 引脚连接 SPI 总线上每一个设备,如果 CS 引脚为低电平,则从设备只侦听主机并与主机通信。 SPI主设备一次只能和一个从设备进行通信。如果主设备要和另外一个从设备通信,必须先终止和当前从设备通信,否则不能通信。
        SPI 通信有 4 种不同的模式,不同的从机可能在出厂时就配置为某种模式,这是不能改变的。通信双方必须工作在同一模式下,才能正常进行通信,所以可以对主机的 SPI 模式进行配置。SPI 通信模式是通过配置 CPOL(时钟极性)和 CPHA(时钟相位)来选择的。
        CPOL,详称 Clock Polarity,就是时钟极性,当主从机没有数据传输的时候即空闲状态,SCL 线的电平状态,假如空闲状态是高电平, CPOL=1;若空闲状态时低电平,那么 CPOL = 0。
        CPHA,详称 Clock Phase,就是时钟相位,实质指的是数据的采样时刻。 CPHA = 0 表示数据的采样是从第 1 个边沿信号上即奇数边沿,具体是上升沿还是下降沿的问题,是由 CPOL 决定的。 CPHA=1 表示数据采样是从第 2 个边沿即偶数边沿。
        SPI 的 4 种模式对比图,如下图所示。

图 22.1.1.2 SPI 的 4 种模式对比图

模式​

​CPOL​

​CPHA​

​时钟空闲状态​

​数据采样时刻​

​数据发送时刻​

0

0

0

低电平

时钟第一个边沿(上升沿)

时钟第二个边沿(下降沿)

1

0

1

低电平

时钟第二个边沿(下降沿)

时钟第一个边沿(上升沿)

2

1

0

高电平

时钟第一个边沿(下降沿)

时钟第二个边沿(上升沿)

3

1

1

高电平

时钟第二个边沿(上升沿)

时钟第一个边沿(下降沿)

表 22.1.1.1 SPI 的 4 种模式对比

        主设备和从设备必须在​​相同的SPI模式​​下才能正常通信。​​模式0​​和​​模式3​​较为常用。

22.1.2 SPI 控制器介绍

        ESP32-S3 芯片集成了四个 SPI 控制器,分别为 SPI0、 SPI1、 SPI2 和 SPI3。 SPI0 和 SPI1 控制器主要供内部使用以访问外部 FLASH 和 PSRAM,所以只能使用 SPI2 和 SPI3。 SPI2 又称为HSPI,而 SPI3 又称为 VSPI,这两个属于 GP-SPI。
        GP-SPI 特性:
(1)支持主机模式和从机模式;
(2)支持半双工通信和全双工通信;
(3)支持多种数据模式:

  • SPI2: 1-bit SPI 模式、 2-bit Dual SPI 模式、 4-bit Quad SPI 模式、 QPI 模式、 8-bit Octal模式、 OPI 模式
  • SPI3: 1-bit SPI 模式、 2-bit Dual SPI 模式、 4-bit Quad SPI 模式、 QPI 模式

        下面用一个表格总结这些模式的核心区别:

模式名称​

​数据线数量​

​每时钟传输位数​

​通信方式​

​典型应用场景​

​特点​

​​1-bit SPI (标准模式)​​

2根 (MOSI, MISO)

1位

全双工

各种常见外设(传感器、ADC、常规Flash)

最基础、最广泛兼容的模式

​​2-bit Dual SPI​​

2根 (SIO0, SIO1)

2位

​​半双工​​

需要提速的SPI Flash、部分显示器

将传统SPI的全双工改为半双工,利用原有的MOSI和MISO作为双向数据线,实现一个时钟周期传输2位数据

​​4-bit Quad SPI (QSPI)​​

4根 (IO0, IO1, IO2, IO3)

4位

​​半双工​​

高速SPI Flash、存储器扩展、液晶屏初始化

使用4根双向数据线,吞吐量是标准SPI的4倍。支持​​内存映射​​,CPU可直接读取外部Flash

​​QPI模式​​

4根 (IO0, IO1, IO2, IO3)

4位 (命令、地址、数据)

​​半双工​​

特定型号的SPI Flash

是Quad SPI的增强版。​​命令、地址和数据​​阶段都使用4线传输,进一步减少了总线切换开销,时序更简单

​​8-bit Octal SPI​​

8根 (IO0-IO7)

8位

​​半双工​​

超高速数据存取、高性能计算

使用8根数据线,每个时钟周期传输1字节,提供极高的吞吐量

​​OPI模式​​

8根 (IO0-IO7)

8位 (命令、地址、数据)

​​半双工​​

最新一代超高速Flash存储器(如eMMC、UFS接口的替代)

Octal SPI的增强版。​​命令、地址和数据​​全部通过8根数据线传输,是当前SPI协议家族中的顶级性能模式

表 22.1.2.1 SPI 的数据模式对比        

        所有这些模式都基于同一个核心思想:​​在同步时钟(SCLK)的控制下,通过并行化数据线来倍增每个时钟周期传输的数据量​​,从而在相同的时钟频率下实现更高的数据吞吐率。

(4)时钟频率可配置:

  • 在主机模式下:时钟频率可达 80MHz
  • 在从机模式下:时钟频率可达 60MHz

(5)数据位的读写顺序可配置;
(6)时钟极性和相位可配置;
(7)四种 SPI 时钟模式:模式 0 ~ 模式 3;
(8)在主机模式下,提供多条 CS 线

  • SPI2: CS0 ~ CS5
  • SPI3: CS0 ~ CS2

(9)支持访问 SPI 接口的传感器、显示屏控制器、 flash 或 RAM 芯片
        SPI2 和 SPI3 接口相关信号线可以经过 GPIO 交换矩阵和 IO_MUX 实现与芯片引脚的映射,IO 使用起来非常灵活。

22.1.3 LCD 介绍

        在介绍LCD之前,建议在B站上搜索看下LCD的工作原理,对下面学习有一定帮助。本例程支持两款屏幕,一款是原子的1.3 寸显示模块 ATK-MD0130,另一款是原子 2.4 寸显示模块 ATK-MD0240。这两款显示模块的 LCD 分辨率分别为 240*240 和 320*240,支持 16 位真彩色显示。模块采用 ST7789V 作为 LCD 的驱动芯片,该芯片自带 RAM,无需外加驱动器或存储器。使用外接的主控芯片时,仅需使用 SPI 接口就可以轻松地驱动这两个显示模块。
        屏幕模块通过 2*4 的排针( 2.54m 间距)同外部相连接,该模块可直接与正点原子ESP32-S3 开发板的 WIRELESS 接口( SPI 接口)连接,而对于没有板载 WIRELESS 接口的开发板,可以通过杜邦线连接。
        ATK-MD0240模块的外观,如下图所示。

图 22.1.3.1 ATK-MD0240 模块实物图

        模块的原理图,如下图所示。

图 22.1.3.2 ATK-MD0130 模块原理图

        模块通过一个 2*4 的排针( 2.54mm 间距)同外部电路连接,各引脚的详细描述,如下表所示。

序号 名称 说明
1 3V3 3.3V 电源供电
2 CS SPI 通讯片选信号(低电平有效)
3 SDA SPI 通讯 MOSI 信号线
4 RST 硬件复位引脚(低电平有效)
5 WR 写命令/数据信号线(低电平:写命令;高电平:写数据)
6 SCK SPI 通讯 SCK 信号线
7 PWR LCD 背光控制引脚(低电平:关闭;高电平:开启)
8 GND 电源地

表 22.1.3.1 ATK-MD0130 和 ATK-MD0240 模块引脚说明

22.1.4 模块 SPI 时序介绍

        ATK-MD0130 和 ATK-MD0240 模块在四线 SPI 通讯模式下,最少仅需四根信号线(CS、SCK、 SDA、 WR(DC))就能够完成与这两个显示模块的通讯,四线 SPI 接口时序如下图所示。

图 22.1.4.1 四线 SPI 接口时序图

        上图中各个时间参数,如下图所示。

图 22.1.4.2 四线 SPI 接口时序时间参数

        从上图中可以看出, ATK-MD0130 和 ATK-MD0240 模块四线 SPI 的写周期是非常快的(TSCYCW = 66ns),而读周期就相对慢了很多(TSCYCR = 150ns)。更详细的时序介绍,可以参考 ST7789V 的数据手册《ST7789V_SPEC_V1.4.pdf》。

22.1.5 模块驱动说明

        ATK-MD0130 和 ATK-MD0240 模块采用 ST7789V 作为 LCD 驱动器, LCD 的显存可直接存放在 ST7789V 的片上 RAM 中, ST7789V 的片上 RAM 有 240*320*3 字节,并且 ST7789V 会在没有外部时钟的情况下,自动将其片上 RAM 的数据显示至 LCD 上,以最小化功耗。
        在每次初始化显示模块之前,必须先通过 RST 引脚对显示模块进行硬件复位,硬件复位要求 RST 至少被拉低 10 微秒,拉高 RST 结束硬件复位后,须延时 120 毫秒等待复位完成后,才能够往显示模块传输数据。
        PWR 引脚用于控制显示模块的 LCD 背光,该引脚自带下拉电阻,当 PWR 引脚被拉低或悬空时, ATK-MD0130 模块的 LCD 背光都处于关闭状态,当 PWR 引脚被拉高时,显示模块的LCD 背光才会点亮。
        ST7789V最高支持 18位色深(262K色),但一般使用 16位色深(65K色)的 RGB565 格式,这样可以在 16 位色深下达到最快的速度。在 16 位色深模式下, ST7789V 采用 RGB565 格式传输、存储颜色数据,如下图所示。

图 22.1.4.3 位色深模式(RGB565)传输颜色数据

        如上图所示,一个像素的颜色数据需要使用 16 比特来传输,这 16 比特数据中,高 5 比特用于表示红色,低 5 比特用于表示蓝色,中间的 6 比特用于表示绿色。数据的数值越大,对应表示的颜色就越深。

22.1.6 ST7789V驱动芯片介绍

22.1.6.1 核心特性

        ST7789V 是一款由 ​​Sitronix​​(矽创)公司生产的​​单芯片 TFT LCD 控制器/驱动器​​。它以其高集成度、丰富的接口支持和优异的显示性能,广泛应用于各种中小尺寸彩色显示设备中。
        核心特性:

特性维度​

​说明​

​​最大分辨率​​

​240 RGB × 320​​ (即 240×320 像素),能驱动此分辨率及以下的各种尺寸屏幕 (如 1.3寸, 1.54寸, 2.0寸等常见模块) 

​​色彩深度​​

最高支持 ​​262K色​​ (18位,RGB 6-6-6),通常配置为​​65K色​​ (16位,RGB 5-6-5) 以平衡性能和带宽 

​​接口类型​​

​SPI​​ (3线/4线,最常用)
​并行接口​​ (如 8080 系列,8位/9位/16位/18位)
​RGB接口​​ (6/16/18位)
​VSYNC接口​

​​内置内存​​

​帧缓存​​ (Frame Memory),大小为 ​​240 x 320 x 18-bit​​ (约1.38Mb),可存储一整屏图像数据 

​​电源电压​​

​I/O 电压 (VDDI)​​:1.65V ~ 3.3V
​模拟电压 (VDD)​​:2.4V ~ 3.3V,支持宽电压范围,便于与不同电平的微控制器连接 

​​关键特性​​

内置时序控制器、伽马校正电路、电源管理单元(支持睡眠/待机等低功耗模式)、可编程帧率、部分显示功能 

​​典型应用​​

智能手表、物联网设备显示屏、便携式医疗设备、工业控制面板、智能家居显示终端等 

表 22.1.6.1.1 ST7789V芯片核心特性

22.1.6.2 工作原理与流程

        ST7789V 作为驱动芯片,其核心工作是接收来自主控制器(如 MCU)的指令和数据,并将其转换为控制 TFT LCD 面板的正确时序和电压信号。其基本工作流程,特别是常用的 SPI 模式,可以概括如下:

图 22.1.6.2.1 ST7789V工作原理与流程图

接口模式详解​​:
        ST7789V 支持多种接口模式,可通过硬件引脚(如 IM0, IM1, IM2, IM3)进行配置:
(1)SPI (串行外设接口)​​:​​最常用​​,尤其适合引脚资源紧张的 MCU;

  • 3线 SPI​​:使用 SCL(时钟)、SDA(数据线,双向)和 CS(片选)引脚。通过数据线的一位(DCX/D-C)来区分命令和数据。
  • 4线 SPI​​:使用 SCL、SDA(主出从入 MOSI)、DC(数据/命令选择)和 CS 引脚。这是最常见的形式,通信时序由主设备(MCU)控制。

(2)并行接口 (如 8080 系列)​​:​​传输速度快​​,适合需要高速刷新或高分辨率的场景。需要较多的数据线(D0-D17)和控制线(如 RD, WR, CS, DC);
(3)​​RGB 接口​​:直接接收 RGB 颜色数据、行场同步信号(HSYNC, VSYNC)和像素时钟(DOTCLK)。通常用于直接连接带有 LCD 控制器的更强大处理器(如 MPU),ST7789V 此时主要作为驱动器。

开发使用要点​​:
(1)初始化序列 (Initialization Sequence)​​:上电后,必须通过一系列特定的​​命令(Command)和数据(Data)​​ 对 ST7789V 进行配置,才能正常显示。包括设置扫描方向、颜色模式、伽马校正、打开显示等。通常供应商会提供参考初始化代码。
(2)内存与寻址​​:ST7789V 内置了帧缓存。当你向它发送像素数据时,数据会存储在其内部内存中。你可以通过命令设置​​列地址(CASET)​​ 和 ​​行地址(RASET)​​ 来定义一个要更新的“窗口”,然后连续写入该区域的数据,提高效率。
(3)常用命令示例​​:

  • 0x36:​​存储器访问控制​​(MX, MY, RGB mode等)
  • 0x3A:​​颜色模式设置​​(如 0x55为 16位/pixel,0x66为 18位/pixel)
  • 0x2A:​​列地址设置​​ (CASET)
  • 0x2B:​​行地址设置​​ (RASET)
  • 0x2C:​​存储器写入​​ (RAMWR) - 紧接着发送像素数据
  • 0x11:​​睡眠模式退出​​ (SLPOUT)
  • 0x29:​​打开显示​​ (DISPON) 

更详细命令如下表:

        建议用到的命令详细说明参考 ST7789V 的数据手册《ST7789V_SPEC_V1.4.pdf》的第9章节。

命令 (名称)

命令码 (Hex)

主要功能说明

参数示例/备注

​​SWRESET (软件复位)​​

0x01

软件复位,让芯片恢复初始状态。

无参数。通常需要延迟一段时间等待复位完成。

​​SLPOUT (睡眠模式关闭)​​

0x11

让芯片从睡眠模式唤醒,进入正常工作模式。

无参数。发送此命令后通常需要等待 120ms 左右 (如 delay(120))。

​​DISPON (显示开启)​​

0x29

开启显示,使得显存内容能够呈现在屏幕上。

无参数。

​​DISPOFF (显示关闭)​​

0x28

关闭显示,屏幕不再显示内容,但驱动电路可能仍在工作。

无参数。

​​CASET (列地址设置)​​

0x2A

设置要在哪个​​水平(X方向)区域​​写入像素数据。

4 个参数:起始列地址高8位、低8位,结束列地址高8位、低8位。例如要设置 X 从 0 到 239:0x00, 0x00, 0x00, 0xEF

​​RASET (行地址设置)​​

0x2B

设置要在哪个​​垂直(Y方向)区域​​写入像素数据。

4 个参数:起始行地址高8位、低8位,结束行地址高8位、低8位。例如要设置 Y 从 0 到 319:0x00, 0x00, 0x01, 0x3F

​​RAMWR (内存写入)​​

0x2C

向由 CASET 和 RASET 设定的区域​​连续写入像素数据​​。

其后跟随大量的像素数据。数据格式由 ​​COLMOD​​ 命令设置。

​​MADCTL (内存访问控制)​​

0x36

控制​​显示方向​​(旋转、镜像)、​​颜色顺序​​(RGB/BGR)和​​ 扫描方式​​。这是一个非常常用的命令。

1 个参数,各位含义丰富:

• ​​MY​​: 行地址顺序 (上下镜像)

• ​​MX​​: 列地址顺序 (左右镜像)

• ​​MV​​: 行列交换 (横屏/竖屏)

• ​​ML​​: 垂直刷新顺序

• ​​RGB​​: RGB/BGR 顺序

• ​​MH​​: 水平刷新顺序

​​COLMOD (接口像素格式)​​

0x3A

设置​​像素数据的颜色深度和格式​​,即每个像素点用多少位来表示。

1 个参数。常用模式:0x03(12-bit/pixel), **0x05** (16-bit/pixel, RGB565)0x06(18-bit/pixel, RGB666)。通常使用 ​​16位​​ (RGB565)。

​​INVON (反色显示开启)​​

0x21

开启显示颜色反转。

无参数。

​​INVOFF (反色显示关闭)​​

0x20

关闭显示颜色反转。

无参数。

​​PORCTRL (Porch设置)​​

0xB2

控制水平同步信号前沿和后沿的延迟时间,以及垂直同步信号前沿和后沿的延迟时间,影响显示时序和稳定性。

5个参数。

​​GCTRL (Gate控制)​​

0xB7

调整像素电压的开关时间,以控制液晶的亮度和对比度。

1个参数。

​​VCOMS (VCOM设置)​​

0xBB

设置液晶面板的电压,以调整图像的亮度和对比度。

1个参数。

​​VDVVRHEN (VDV和VRH命令启用)​​

0xC2

启用VDV(VCOM Deselect voltage)和VRH(Voltage range High)命令,以使VCOM电压和电压范围高度可调。

1个参数。

​​VRHS (VRH设置)​​

0xC3

设置VRH的值,以控制VCOM电压的范围。

1个参数。

​​VDVS (VDV设置)​​

0xC4

设置VDV的值,以调整VCOM电压的偏移量。

1个参数。

​​PWCTRL1 (电源控制1)​​

0xD0

控制液晶面板的电源,以调整电压和电流的输出。

2个参数。

​​PVGAMCTRL (正电压伽马控制)​​

0xE0

控制正电压伽马校正,以调整图像亮度和对比度。用于色彩校准。

15个参数。通常使用厂家提供的预设值。

​​NVGAMCTRL (负电压伽马控制)​​

0xE1

控制负电压伽马校正,以调整图像亮度和对比度。用于色彩校准。

15个参数。通常使用厂家提供的预设值。

表 22.1.6.2.1 ST7789V芯片命令

针对几个常用命名再做说明:

        ST7789V 支持连续读写 RAM 中存放的 LCD 上颜色对应的数据,并且连续读写的方向(LCD 的扫描方向)是可以通过命令 0x36 进行配置的,如下图所示。

图 22.1.6.2.12 命令 0x36

        从上图中可以看出,命令 0x36 可以配置 6 个参数,但对于配置 LCD 的扫描方向,仅需关心 MY、 MX 和 MV 这三个参数,如下表所示。

参数 LCDLCD 扫描方向(RAM 自增方向)
MY MX MV
0 0 0 从左到右,从上到下
1 0 0 从左到右,从下到上
0 1 0 从右到左,从上到下
1 1 0 从右到左,从下到上
0 0 1 从上到下,从左到右
1 0 1 从上到下,从右到左
0 1 1 从下到上,从左到右
1 1 1 从下到上,从右到左

表 22.1.6.2.2 命令 0x36 配置 LCD 扫描方向

        仅需设置一次坐标,然后连续地往 ATK-MD0130 和 ATK-MD0240 模块传输颜色数据即可。大大地提高 ATK-MD0130 和 ATK-MD0240 模块在刷屏时的效率,
        在往 ATK-MD0130 和 ATK-MD0240 模块写入颜色数据前,还需要设置地址,以确定随后写入的颜色数据对应 LCD 上的哪一个像素,通过命令 0x2A 和命令 0x2B 可以分别设置 ATK-MD0130 和 ATK-MD0240 模块显示颜色数据的列地址和行地址,命令 0x2A 的描述,如下图所示。

图 22.1.6.2.3 命令 0x2A

        命令 0x2B 的描述,如下图所示。

图 22.1.6.2.4 命令 0x2B

        以默认的 LCD 扫描方式(从左到右,从上到下)为例,命令 0x2A 的参数 XS 和 XE 和命令0x2B 的参数 YS 和 YE 就在 LCD 上确定了一个区域,在连读读写颜色数据时, ST7789V 就会按照从左到右,从上到下的扫描方式读写设个区域的颜色数据。

22.2 硬件设计
22.2.1 例程功能

        本章实验功能简介:使用开发板的 SPI 接口连接正点原子 SPI LCD 模块(仅限 SPI 显示模块),实现 SPI LCD 模块的显示。通过把 LCD 模块插入底板上的 WIRELESS 接口(SPI 接口),按下复位之后,就可以看到 SPI LCD 模块不停的显示一些信息并不断切换底色。 LED 闪烁用于提示程序正在运行。

22.2.2 硬件资源

        1. LED
                LED - IO1
        2. 2.4 寸 SPI LCD 模块

22.2.3 原理图

        本章实验使用2.4 寸的 SPI LCD 模块,该模块与板载的 WIRELESS 接口进行连接,该接口与板载 MCU 的连接原理图,如下图所示:

图 22.2.3.1 SPILCD 模块与 MCU 的连接原理图

对应驱动管脚:

管脚标识 真实管脚 说明
SLCD_PWR IO1_3     使用XL9555的扩展管脚
SLCD_CS IO21
SPI_SCK IO12
SPI_MOSI IO11
IO_SEL IO40 通过跳线帽连接LCD_DC->IO40
SLCD_RST IO1_2 使用XL9555的扩展管脚

表 22.2.3.1 MCU驱动SPILCD 模块对应管脚

22.3 程序设计
22.3.1 程序流程图

        程序流程图能帮助我们更好的理解一个工程的功能和实现的过程,对学习和设计工程有很好的主导作用。下面看看本实验的程序流程图:

图 22.3.1.1 SPI_LCD 实验程序流程图

22.3.2 SPI_LCD 函数解析

        ESP-IDF 提供了一套 API 来配置 SPI。要使用此功能,需要导入必要的头文件:

#include "driver/spi_master.h"

        接下来,介绍一些常用的 ESP32-S3中的 SPI函数,以及 IO扩展芯片中用到的函数,这些函数的描述及其作用如下:
(1) 初始化和配置

        该函数用于初始化 SPI 总线,并配置其 GPIO引脚和主模式下的时钟等参数,该函数原型如下所示:

esp_err_t spi_bus_initialize(spi_host_device_t host_id, 
                             const spi_bus_config_t *bus_config, 
                             spi_dma_chan_t dma_chan);

        该函数的形参描述,如下表所示:

​参数名​

​类型​

​说明​

​备注/常用值​

​​host_id​​

spi_host_device_t

指定要初始化的 ​​SPI 主机控制器编号​

SPI2_HOST(即HSPI) 或 SPI3_HOST(即VSPI)。SPI0和SPI1通常用于内部Flash和PSRAM,不推荐用户使用。

​​bus_config​​

const spi_bus_config_t *

指向 ​​SPI 总线配置结构体​​ 的指针,该结构体定义了 GPIO 引脚、最大传输大小等参数

详见下方 spi_bus_config_t结构体详解。

​​dma_chan​​

spi_dma_chan_t

指定 ​​DMA 通道​

SPI_DMA_DISABLED(禁用DMA), SPI_DMA_CH1SPI_DMA_CH2, 或 SPI_DMA_CH_AUTO(自动分配,常用)。

表 22.3.2.1 spi_bus_initialize()函数形参描述

 spi_bus_config_t结构体说明:

typedef struct {
    int mosi_io_num;         /*!< MOSI 引脚 GPIO 编号 */
    int miso_io_num;         /*!< MISO 引脚 GPIO 编号 */
    int sclk_io_num;        /*!< SCLK (时钟) 引脚 GPIO 编号 */
    int quadwp_io_num;      /*!< 四线 SPI 模式下的 WP (写保护) 引脚 GPIO 编号,不使用则设为 -1 */
    int quadhd_io_num;      /*!< 四线 SPI 模式下的 HD (保持) 引脚 GPIO 编号,不使用则设为 -1 */
    int max_transfer_sz;    /*!< 最大传输大小(以字节为单位) */
    int intr_flags;         /*!< 中断标志位,通常设置为 0 */
} spi_bus_config_t;

函数使用流程:

(1)配置总线参数​​:填充 spi_bus_config_t结构体,指定 MOSI、MISO、SCLK 等引脚。不使用的功能(如 Quad SPI)其引脚号设为 -1;
(2)​​调用函数初始化​​:使用 spi_bus_initialize()并指定 SPI 主机(如 SPI2_HOST)、上述配置结构体和 DMA 通道(通常用 SPI_DMA_CH_AUTO);
(3)检查返回值​​:函数返回 ESP_OK表示初始化成功,否则需根据错误码排查问题。

注意事项:

(1)SPI 控制器选择​​:ESP32 有多个 SPI 控制器。​​SPI0​​ 和 ​​SPI1​​ 通常预留给内部连接 Flash 和 PSRAM,​​严禁​​在应用代码中初始化它们,否则可能导致系统崩溃;
(2)用户应用应使用 ​​SPI2 (HSPI)​​ 或 ​​SPI3 (VSPI)​​
        GPIO 引脚映射​​:SPI2 和 SPI3 的信号线可以通过 ​​GPIO 交换矩阵​​ 映射到几乎所有空闲的GPIO 引脚,这提供了极大的灵活性。但需注意:

  • 使用 GPIO 矩阵时,​​SCLK 的最高频率会受限​​(通常降至 40 MHz);
  • 为了追求更高的速度和稳定性,建议优先使用芯片数据手册推荐的 ​​IO_MUX 专用引脚​​。

(3)DMA 通道​​:对于大数据量传输(如操作显示屏、读写大容量 Flash),​​强烈建议启用 DMA​​(如 SPI_DMA_CH_AUTO),这可以显著减轻 CPU 负担并提高效率,对于小数据量或简单设备,可以禁用 DMA (SPI_DMA_DISABLED);
(4)最大传输大小​​:max_transfer_sz参数限制了​​单次 SPI 事务​​能传输的最大数据量(字节)。请根据实际应用需求设置此值,特别是启用 DMA 时,ESP32 的 DMA 对单次传输数据量有约束;
(5)后续步骤​​:spi_bus_initialize()​​仅初始化了总线本身​​。总线初始化成功后,还需要使用 spi_bus_add_device()来​​添加具体的 SPI 从设备​​,然后才能使用 spi_device_transmit()等函数进行数据传输;
(6)错误处理​​:务必检查函数的返回值 (esp_err_t),确保初始化成功后再进行后续操作。

(2)设备配置

        该函数用于在 SPI 总线上分配设备,函数原型如下所示:

esp_err_t spi_bus_add_device(spi_host_device_t host_id, 
                             const spi_device_interface_config_t *dev_config, 
                             spi_device_handle_t *handle);

 该函数的形参描述,如下表所示:

​参数名​

​类型​

​说明​

​​host_id​​

spi_host_device_t

指定要添加设备的 ​​SPI 主机控制器编号​​,需与 spi_bus_initialize初始化时使用的 host_id一致。

​​dev_config​​

const spi_device_interface_config_t *

指向 ​​SPI 设备接口配置结构体​​ 的指针,该结构体定义了设备的通信参数。

​​handle​​

spi_device_handle_t *

​输出参数​​,用于返回新添加的 SPI 设备的句柄。后续对该设备的所有操作(如数据传输)都需使用此句柄。

表 22.3.2.1 spi_bus_add_device()函数形参描述

    spi_device_interface_config_t 结构体:

    此结构体用于配置 SPI 从设备的通信参数,需根据从设备的数据手册进行设置。

typedef struct {
    uint8_t command_bits;          /*!< 命令阶段的位数 (0-16),若无需命令阶段则设为0 */
    uint8_t address_bits;          /*!< 地址阶段的位数 (0-64),若无需地址阶段则设为0 */
    uint8_t dummy_bits;            /*!< 在地址和数据阶段之间插入的虚拟位数,用于满足时序要求 */
    uint8_t mode;                  /*!< SPI 模式 (0-3),由 CPOL 和 CPHA 决定 */
    spi_clock_source_t clock_source; /*!< SPI 时钟源,通常使用默认值 `SPI_CLK_SRC_DEFAULT` */
    uint16_t duty_cycle_pos;        /*!< 时钟正占空比,单位为 1/256,128 表示 50% */
    uint16_t cs_ena_pretrans;       /*!< 传输开始前 CS 信号需要提前激活的时钟周期数 (0-16),用于半双工 */
    uint16_t cs_ena_posttrans;      /*!< 传输结束后 CS 信号需要保持激活的时钟周期数 (0-16),用于半双工 */
    int clock_speed_hz;             /*!< SPI 时钟频率,单位 Hz */
    int input_delay_ns;             /*!< 从设备最大输入延迟(MISO 有效时间),用于时序调整 */
    int spics_io_num;               /*!< 该设备专用的片选 (CS) 引脚 GPIO 编号 */
    uint32_t flags;                 /*!< 设备特性标志位(位掩码),如字节序、双工模式等 */
    int queue_size;                 /*!< 事务队列大小,决定可挂起的待处理事务数量 */
    transaction_cb_t pre_cb;        /*!< 传输开始前的回调函数(在中断中调用) */
    transaction_cb_t post_cb;       /*!< 传输完成后的回调函数(在中断中调用) */
} spi_device_interface_config_t;

关键配置字段详解
(1)mode(SPI模式)​​:必须与从设备要求的模式一致,有4种选择:

  • ​​0​​: CPOL=0,CPHA=0(空闲时SCLK低电平,数据在第一个边沿采样)
  • 1​​: CPOL=0,CPHA=1(空闲时SCLK低电平,数据在第二个边沿采样)
  • ​​2​​: CPOL=1,CPHA=0(空闲时SCLK高电平,数据在第一个边沿采样)
  • ​​3​​:CPOL=1,CPHA=1(空闲时SCLK高电平,数据在第二个边沿采样)

(2)clock_speed_hz(时钟频率):设置通信速率。注意,当使用GPIO矩阵(非专用IO_MUX引脚)时,最高频率可能受限(如40MHz);
(3)spics_io_num(片选引脚)​​:指定控制该设备的片选信号线GPIO。​​同一总线上不同设备必须使用不同的片选引脚​​;
(4)flags(标志位)​​:用于设置一些特殊设备属性,常用的有:

  • SPI_DEVICE_TXBIT_LSBFIRST:发送数据时低位在先(LSB)
  • SPI_DEVICE_RXBIT_LSBFIRST:接收数据时低位在先(LSB)
  • SPI_DEVICE_3WIRE:使用三线制SPI(双向数据线)
  • SPI_DEVICE_HALFDUPLEX:设备工作在半双工模式
  • SPI_DEVICE_POSITIVE_CS:片选信号高电平有效(默认为低电平有效)

(5)​​queue_size(队列大小)​​:设置挂起事务队列的长度。如果使用spi_device_queue_trans进行异步传输,此值应大于1。

注意事项:

(1)线程安全​​:SPI驱动本身是线程安全的,但​​对同一设备的操作​​若来自不同任务,则需用户自行添加互斥锁(如FreeRTOS的互斥量)进行保护;
(2)​​句柄管理​​:成功添加设备后获得的spi_device_handle_t句柄​​必须妥善保存​​,后续所有的数据传输(如spi_device_transmit)以及最后移除设备时都需要使用它;
(3)参数验证​​:部分配置参数(如clock_speed_hz)有物理限制(如IO_MUX引脚最高80MHz,GPIO矩阵引脚最高40MHz),设置时需注意;
(4)专用引脚​​:为了获得更好的性能和更高的时钟速率,建议优先使用ESP32-S3的​​IO_MUX专用引脚​​作为SPI信号线;
(5)错误处理​​:务必检查函数的返回值(esp_err_t),确保设备添加成功后再进行后续操作;
(6)资源释放​​:当不再需要与某设备通信时,应使用spi_bus_remove_device(handle)将其从总线上移除,并在所有设备都移除后可使用spi_bus_free(host_id)释放总线资源。

(3)数据传输

        根据函数功能,以下2个函数可以归为一类进行讲解,如下表所示。

函数 描述
spi_device_transmit() 该函数用于发送一个 SPI 事务,等待它完成,并返回结果。
handle: 设备的句柄。
trans_desc: 指向 spi_transaction_t 结构体的指针,描述了要发送的事务详情。
spi_device_polling_transmit() 该函数用于发送一个轮询事务,等待它完成,并返回结果。
handle: 设备的句柄。
trans_desc: 指向 spi_transaction_t 结构体的指针,描述了要发送的事务详情。

表 22.3.2.2 数据传输函数介绍       

spi_device_transmit()函数:

        该函数用于发送一个 SPI 事务,等待它完成,并返回结果。函数原型如下所示:

esp_err_t spi_device_transmit(spi_device_handle_t handle, 
                              spi_transaction_t *trans_desc);

该函数的形参描述,如下表所示:        

​参数名​

​类型​

​说明​

​handle

spi_device_handle_t

SPI 设备句柄,通过 spi_bus_add_device()获得

trans_desc​​​

spi_transaction_t *

​指向 spi_transaction_t结构体的指针,该结构体​​描述了此次传输的具体内容​​(发送缓冲、接收缓冲、长度、标志等)

表 22.3.2.3 spi_device_transmit()函数形参描述

spi_transaction_t 结构体​:

字段​

​类型​

​说明​

​注意事项​

​​flags​​

uint32_t

​传输标志位​​,用于控制特殊传输模式。例如:
• SPI_TRANS_USE_RXDATA: 将接收到的数据存入 rx_data数组(小于等于 4 字节时方便使用)
• SPI_TRANS_USE_TXDATA: 使用 tx_data数组作为发送数据(小于等于 4 字节时方便使用)
• SPI_TRANS_MODE_DIO: 使用双 I/O 模式
• SPI_TRANS_MODE_QIO: 使用四线 QIO 模式

根据需要设置,默认为 0。

​​cmd​​

uint16_t

​命令值​​。如果配置 SPI 设备时设置了 command_bits,此字段才有效。

通常用于发送一些控制指令。

​​addr​​

uint64_t

​地址值​​。如果配置 SPI 设备时设置了 address_bits,此字段才有效。

通常用于指定从设备的寄存器地址或存储地址。

​​length​​

size_t

​数据传输的总长度(单位:位)​​。

​注意是位(bit)数,而不是字节数​​。例如,要发送 3 个字节,则 length = 3 * 8 = 24

​​rxlength​​

size_t

​接收数据的长度(单位:位)​​。如果设置为 0,则接收数据长度由 length决定。

通常用于发送和接收长度不等的场景。

​​tx_buffer​​

const void*

​指向发送数据缓冲区的指针​​。如果使用 SPI_TRANS_USE_TXDATA标志,则应忽略此字段,数据来自 tx_data

发送大量数据时使用此字段。​​如果不需要发送数据,设置为 NULL​​。

​​rx_buffer​​

void*

​指向接收数据缓冲区的指针​​。驱动程序接收到的数据将填充到此缓冲区。如果使用 SPI_TRANS_USE_RXDATA标志,则数据会被接收到 rx_data数组中。

如果需要接收数据,必须指向一个足够大的有效缓冲区。​​如果不需要接收数据,设置为 NULL​​。

​​user​​

void*

​用户自定义数据​​。此变量不会被驱动程序使用,但会在回调函数中传递回用户。

常用于传递上下文信息,例如片选引脚号。

​​tx_data​​

uint8_t[4]

​内嵌的发送数据数组​​。当数据长度 ≤ 4 字节且设置了 SPI_TRANS_USE_TXDATA标志时,使用此数组而非 tx_buffer

方便短数据发送,避免额外定义缓冲区。

​​rx_data​​

uint8_t[4]

​内嵌的接收数据数组​​。当数据长度 ≤ 4 字节且设置了 SPI_TRANS_USE_RXDATA标志时,数据会接收到此数组而非 rx_buffer

方便短数据接收。

表 22.3.2.4 spi_transaction_t结构体描述

函数使用流程:

(1)初始化 SPI 总线​​:使用 spi_bus_initialize()。
(2)添加 SPI 设备​​:使用 spi_bus_add_device(),获得设备句柄 handle。
(3)配置事务结构体​​:填充 spi_transaction_t trans_desc,根据传输需求设置字段。
(4)执行传输​​:调用 spi_device_transmit(handle, &trans_desc)。
(5)​​检查返回值​​并处理数据。
(6)​​(可选) 移除设备和释放总线​​:当不再需要时,调用 spi_bus_remove_device()和 spi_bus_free()。

同步 vs. 异步传输

​​        spi_device_transmit()是同步的​​:它会​​阻塞​​当前任务,直到 SPI 事务完全完成后才返回。简单易用,但效率较低。
        ​​队列传输是异步的​​:使用 spi_device_queue_trans()将事务放入队列后函数立即返回,后续通过 spi_device_get_trans_result()获取结果。​​效率更高​​,适合复杂应用或需要并发处理多个事务的场景。

spi_device_polling_transmit()函数:

        该函数用于​​以轮询(阻塞)的方式同步执行一个 SPI 传输事务​​。它会启动 SPI 传输,并​​阻塞当前任务​​,直到整个传输完成后才返回。

特性维度​

​说明​

​​函数原型​​

esp_err_t spi_device_polling_transmit(spi_device_handle_t handle, spi_transaction_t *trans_desc);

​​核心功能​​

启动一个 SPI 传输,并​​阻塞当前任务​​,等待传输完成。

​​传输模式​​

​轮询(Polling)模式​​。CPU 会不断检查 SPI 外设的状态位,直到传输完成。

​​阻塞行为​​

​阻塞​​当前任务,直到 SPI 事务完全完成。在此期间,CPU 不能执行其他任务。

​​返回值​​

ESP_OK表示成功;否则为错误码,表示传输失败。

​​性能特点​​

​延迟低​​(节省了中断处理、队列管理和上下文切换的开销),但​​CPU 利用率高​​(传输期间CPU被独占)。

​​适用场景​​

对​​时序要求严格​​、​​数据量小​​或​​在关键代码段​​中需要简单可靠通信的场景。

​​线程安全​​

对​​同一 SPI 设备​​的访问不是线程安全的。如果多个任务可能访问同一设备,必须使用互斥锁(如 FreeRTOS 的互斥量)进行保护。

表 22.3.2.5 spi_device_polling_transmit()函数说明

   函数入参解释同spi_device_transmit()入参,使用流程也相同,不再重复介绍;
   spi_device_polling_transmit()函数会​​阻塞​​调用它的任务,并通过​​轮询​​ SPI 主控制器的状态寄存器来检查传输是否完成,而不是依赖中断来通知完成事件。这意味着在 SPI 传输进行期间,​​CPU 无法处理其他任务​​,会一直等待直到整个 SPI 事务操作完毕。这种方式的优点是减少了中断处理和任务调度的开销,使得通信延迟更小,时序更可控;缺点则是传输期间 CPU 被完全占用,效率较低。

spi_device_polling_transmit()函数优点与缺点​​:
优点​​:

  • 简单可靠​​:代码逻辑简单,不易受中断延迟或任务调度的影响;
  • 低延迟​​:免去了中断处理、任务队列管理和上下文切换的开销,传输的延迟更小,时序非常精确和可控。

缺点​​:

  • CPU 利用率高​​:在传输期间,CPU 被完全占用,无法处理其他任务,效率较低;
  • 可能影响系统实时性​​:长时间的 SPI 传输会阻塞整个任务,可能影响其他关键事件的响应。

重要提示​​:

  • 通信协议​​:ST7789 通常使用 SPI(串行外设接口)进行控制。在发送命令时,需要将 DC(数据/命令选择)引脚拉低;在发送数据或参数时,则需要将 DC 引脚拉高。
  • 复位时序​​:硬件复位(RST引脚)时,低电平脉冲持续时间至少需要 10μs,并且在拉高后需等待最多 120ms 才能进行软件访问。
  • 精细调整​​:PORCTRL, GCTRL, VCOMS, 伽马设置等命令主要用于调整屏幕的​​显示效果、亮度和对比度​​,通常直接使用屏幕厂商提供的默认值即可,除非有特殊的调试需求。
  • 数据格式​​:使用 ​​16位色 (RGB565)​​ 时,每个像素由两个字节组成,例如 0xF800表示红色,0x07E0表示绿色,0x001F表示蓝色。发送时注意字节顺序(大端序)
22.3.3 SPI_LCD 驱动解析

        看这个代码前为了更好理解代码,最好先看下第二十一章 OLED部分实现。在 IDF 版的 12_spilcd 例程中,在 12_spilcd \components\BSP 路径下新增了一个 SPI 文件夹和一个 LCD 文件夹,分别用于存放 spi.c、 spi.h 和 lcd.c 以及 lcd.h 这四个文件。其中,spi.h和 lcd.h 文件负责声明 SPI 以及 LCD 相关的函数和变量,而 spi.c 和 lcd.c 文件则实现了 SPI 以及LCD 的驱动代码。下面将详细解析这四个文件的实现内容。

(1)spi.h 文件

/* 引脚定义 */
#define SPI_MOSI_GPIO_PIN   GPIO_NUM_11         /* SPI2_MOSI */
#define SPI_CLK_GPIO_PIN    GPIO_NUM_12         /* SPI2_CLK */
#define SPI_MISO_GPIO_PIN   GPIO_NUM_13         /* SPI2_MISO */

        该文件下定义了 SPI 的时钟引脚与通讯引脚。

(2)spi.c 文件

/**
 * @brief       初始化SPI
 * @param       无
 * @retval      无
 */
void spi2_init(void)
{
    esp_err_t ret = 0;
    spi_bus_config_t spi_bus_conf = {0};

    /* SPI总线配置 */
    spi_bus_conf.miso_io_num = SPI_MISO_GPIO_PIN;                               /* SPI_MISO引脚 */
    spi_bus_conf.mosi_io_num = SPI_MOSI_GPIO_PIN;                               /* SPI_MOSI引脚 */
    spi_bus_conf.sclk_io_num = SPI_CLK_GPIO_PIN;                                /* SPI_SCLK引脚 */
    spi_bus_conf.quadwp_io_num = -1;                                            /* SPI写保护信号引脚,该引脚未使能 */
    spi_bus_conf.quadhd_io_num = -1;                                            /* SPI保持信号引脚,该引脚未使能 */
    spi_bus_conf.max_transfer_sz = 320 * 240 * 2;                               /* 配置最大传输大小,以字节为单位 */
    
    /* 初始化SPI总线 */
    ret = spi_bus_initialize(SPI2_HOST, &spi_bus_conf, SPI_DMA_CH_AUTO);        /* SPI总线初始化 */
    ESP_ERROR_CHECK(ret);                                                       /* 校验参数值 */
}

/**
 * @brief       SPI发送命令
 * @param       handle : SPI句柄
 * @param       cmd    : 要发送命令
 * @retval      无
 */
void spi2_write_cmd(spi_device_handle_t handle, uint8_t cmd)
{
    esp_err_t ret;
    spi_transaction_t t = {0};

    t.length = 8;                                       /* 要传输的位数 一个字节 8位 */
    t.tx_buffer = &cmd;                                 /* 将命令填充进去 */
    ret = spi_device_polling_transmit(handle, &t);      /* 开始传输 */
    ESP_ERROR_CHECK(ret);                               /* 一般不会有问题 */
}

/**
 * @brief       SPI发送数据
 * @param       handle : SPI句柄
 * @param       data   : 要发送的数据
 * @param       len    : 要发送的数据长度 
 * @retval      无
 */
void spi2_write_data(spi_device_handle_t handle, const uint8_t *data, int len)
{
    esp_err_t ret;
    spi_transaction_t t = {0};

    if (len == 0)
    {
        return;                                     /* 长度为0 没有数据要传输 */
    }

    t.length = len * 8;                             /* 要传输的位数 一个字节 8位 */
    t.tx_buffer = data;                             /* 将命令填充进去 */
    ret = spi_device_polling_transmit(handle, &t);  /* 开始传输 */
    ESP_ERROR_CHECK(ret);                           /* 一般不会有问题 */
}

/**
 * @brief       SPI处理数据
 * @param       handle       : SPI句柄
 * @param       data         : 要发送的数据 
 * @retval      t.rx_data[0] : 接收到的数据
 */
uint8_t spi2_transfer_byte(spi_device_handle_t handle, uint8_t data)
{
    spi_transaction_t t;

    memset(&t, 0, sizeof(t));

    t.flags = SPI_TRANS_USE_TXDATA | SPI_TRANS_USE_RXDATA;
    t.length = 8;
    t.tx_data[0] = data;
    spi_device_transmit(handle, &t);

    return t.rx_data[0];
}

        在 spi2_init()函数中主要工作就是对于 SPI 参数的配置,如 SPI 管脚配置和数据传输大小以及 SPI 总线配置等,通过该函数就可以完成 SPI 初始化。
        SPI 驱动中对 SPI 的各种操作,请读者结合 SPI 的时序规定查看本实验的配套实验源码。

(3)lcd.h 文件
        lcd.c 和 lcd.h 文件是驱动函数和引脚接口宏定义以及函数声明等。 lcdfont.h 头文件存放了 4种字体大小不一样的 ASCII 字符集(12*12、 16*16、 24*24 和 32*32)。这个跟 oledfont.h 头文件一样的,只是这里多了 32*32 的 ASCII 字符集,制作方法请回顾 OLED 实验 。下面我们还是先
介绍 lcd.h 文件,首先是 LCD 的引脚定义:

/* 引脚定义 */
#define LCD_NUM_WR      GPIO_NUM_40
#define LCD_NUM_CS      GPIO_NUM_21

/* IO操作 */
#define LCD_WR(x)       do{ x ? \
                            (gpio_set_level(LCD_NUM_WR, 1)):    \
                            (gpio_set_level(LCD_NUM_WR, 0));    \
                        }while(0)

#define LCD_CS(x)       do{ x ? \
                            (gpio_set_level(LCD_NUM_CS, 1)):    \
                            (gpio_set_level(LCD_NUM_CS, 0));    \
                        }while(0)

#define LCD_PWR(x)       do{ x ? \
                            (xl9555_pin_write(SLCD_PWR_IO, 1)): \
                            (xl9555_pin_write(SLCD_PWR_IO, 0)); \
                        }while(0)

#define LCD_RST(x)       do{ x ? \
                            (xl9555_pin_write(SLCD_RST_IO, 1)): \
                            (xl9555_pin_write(SLCD_RST_IO, 0)); \
                        }while(0)

/* 常用颜色值 */
#define WHITE           0xFFFF      /* 白色 */
#define BLACK           0x0000      /* 黑色 */
#define RED             0xF800      /* 红色 */
#define GREEN           0x07E0      /* 绿色 */
#define BLUE            0x001F      /* 蓝色 */ 
#define MAGENTA         0XF81F      /* 品红色/紫红色 = BLUE + RED */
#define YELLOW          0XFFE0      /* 黄色 = GREEN + RED */
#define CYAN            0X07FF      /* 青色 = GREEN + BLUE */  

/* 非常用颜色 */
#define BROWN           0XBC40      /* 棕色 */
#define BRRED           0XFC07      /* 棕红色 */
#define GRAY            0X8430      /* 灰色 */ 
#define DARKBLUE        0X01CF      /* 深蓝色 */
#define LIGHTBLUE       0X7D7C      /* 浅蓝色 */ 
#define GRAYBLUE        0X5458      /* 灰蓝色 */ 
#define LIGHTGREEN      0X841F      /* 浅绿色 */  
#define LGRAY           0XC618      /* 浅灰色(PANNEL),窗体背景色 */ 
#define LGRAYBLUE       0XA651      /* 浅灰蓝色(中间层颜色) */ 
#define LBBLUE          0X2B12      /* 浅棕蓝色(选择条目的反色) */ 

/* 扫描方向定义 */
#define L2R_U2D         0           /* 从左到右,从上到下 */
#define L2R_D2U         1           /* 从左到右,从下到上 */
#define R2L_U2D         2           /* 从右到左,从上到下 */
#define R2L_D2U         3           /* 从右到左,从下到上 */
#define U2D_L2R         4           /* 从上到下,从左到右 */
#define U2D_R2L         5           /* 从上到下,从右到左 */
#define D2U_L2R         6           /* 从下到上,从左到右 */
#define D2U_R2L         7           /* 从下到上,从右到左 */

#define DFT_SCAN_DIR    L2R_U2D     /* 默认的扫描方向 */

/* 屏幕选择 */
#define LCD_320X240     0
#define LCD_240X240     1



/* LCD信息结构体 */
typedef struct _lcd_obj_t
{
    uint16_t        width;          /* 宽度 */
    uint16_t        height;         /* 高度 */
    uint8_t         dir;            /* 横屏还是竖屏控制:0,竖屏;1,横屏。 */
    uint16_t        wramcmd;        /* 开始写gram指令 */
    uint16_t        setxcmd;        /* 设置x坐标指令 */
    uint16_t        setycmd;        /* 设置y坐标指令 */
    uint16_t        wr;             /* 命令/数据IO */
    uint16_t        cs;             /* 片选IO */
} lcd_obj_t;

/* LCD缓存大小设置,修改此值时请注意!!!!修改这两个值时可能会影响以下函数 lcd_clear/lcd_fill/lcd_draw_line */
#define LCD_TOTAL_BUF_SIZE      (320 * 240 * 2)
#define LCD_BUF_SIZE            15360

/* 导出相关变量 */
extern lcd_obj_t lcd_self;
extern uint8_t lcd_buf[LCD_TOTAL_BUF_SIZE];

        第一部分的宏定义是对 WR/CS 引脚的定义,第二部分宏定义是 LCD_WR/CS/PWR/RST 引脚操作的定义,接下来的部分是对一些常用颜色的 RGB 数值以及 LCD 信息结构体的定义。

(4)lcd.c 文件

#define SPI_LCD_TYPE    1           /* SPI接口屏幕类型(1:2.4寸SPILCD  0:1.3寸SPILCD) */  

spi_device_handle_t MY_LCD_Handle;
uint8_t lcd_buf[LCD_TOTAL_BUF_SIZE];
lcd_obj_t lcd_self;


/* LCD需要初始化一组命令/参数值。它们存储在此结构中  */
typedef struct
{
    uint8_t cmd;
    uint8_t data[16];
    uint8_t databytes; /* 数据中没有数据;比特7=设置后的延迟;0xFF=cmds结束 */
} lcd_init_cmd_t;

/**
 * @brief       发送命令到LCD,使用轮询方式阻塞等待传输完成(由于数据传输量很少,因此在轮询方式处理可提高速度。使用中断方式的开销要超过轮询方式)
 * @param       cmd 传输的8位命令数据
 * @retval      无
 */
void lcd_write_cmd(const uint8_t cmd)
{
    LCD_WR(0);
    spi2_write_cmd(MY_LCD_Handle, cmd);
}

/**
 * @brief       发送数据到LCD,使用轮询方式阻塞等待传输完成(由于数据传输量很少,因此在轮询方式处理可提高速度。使用中断方式的开销要超过轮询方式)
 * @param       data 传输的8位数据
 * @retval      无
 */
void lcd_write_data(const uint8_t *data, int len)
{
    LCD_WR(1);
    spi2_write_data(MY_LCD_Handle, data, len);
}

/**
 * @brief       发送数据到LCD,使用轮询方式阻塞等待传输完成(由于数据传输量很少,因此在轮询方式处理可提高速度。使用中断方式的开销要超过轮询方式)
 * @param       data 传输的16位数据
 * @retval      无
 */
void lcd_write_data16(uint16_t data)
{
    uint8_t dataBuf[2] = {0,0};
    dataBuf[0] = data >> 8;
    dataBuf[1] = data & 0xFF;
    LCD_WR(1);
    spi2_write_data(MY_LCD_Handle, dataBuf,2);
}

/**
 * @brief       设置窗口大小
 * @param       xstar:左上角x轴
 * @param       ystar:左上角y轴
 * @param       xend:右下角x轴
 * @param       yend:右下角y轴
 * @retval      无
 */
void lcd_set_window(uint16_t xstar, uint16_t ystar,uint16_t xend,uint16_t yend)
{	
    uint8_t databuf[4] = {0,0,0,0};
    databuf[0] = xstar >> 8;
    databuf[1] = 0xFF & xstar;
    databuf[2] = xend >> 8;
    databuf[3] = 0xFF & xend;
    lcd_write_cmd(lcd_self.setxcmd);
    lcd_write_data(databuf, 4);

    databuf[0] = ystar >> 8;
    databuf[1] = 0xFF & ystar;
    databuf[2] = yend >> 8;
    databuf[3] = 0xFF & yend;
    lcd_write_cmd(lcd_self.setycmd);
    lcd_write_data(databuf, 4);

    lcd_write_cmd(lcd_self.wramcmd);    /* 开始写入GRAM */
}   

/**
 * @brief       以一种颜色清空LCD屏
 * @param       color 清屏颜色
 * @retval      无
 */
void lcd_clear(uint16_t color)
{
    uint16_t i, j;
    uint8_t data[2] = {0};  // 准备一个2字节的数组

	// 将16位的color拆分成两个8位数据(高位和低位)
    data[0] = color >> 8;  // 获取颜色值的高8位
    data[1] = color;       // 获取颜色值的低8位 (发生截断,实际取低8位)
    
    // 设置LCD的写入窗口为整个屏幕
    // 从(0, 0)到(lcd_self.width-1, lcd_self.height-1),即左上角到右下角
    lcd_set_window(0, 0, lcd_self.width - 1, lcd_self.height - 1);

	 // 填充缓冲区 lcd_buf(重点理解循环次数)
    // LCD_BUF_SIZE / 2:因为我们要填充的是16位的颜色,但操作是以8位为单位。
    // 所以缓冲区大小15360字节,对应 15360 / 2 = 7680 个16位颜色值。
    for(j = 0; j < LCD_BUF_SIZE / 2; j++)
    {
		// 将缓冲区中每一对字节都设置为目标颜色的高8位和低8位
        lcd_buf[j * 2] =  data[0];   // 偶数索引位置放颜色高位
        lcd_buf[j * 2 + 1] =  data[1];  // 奇数索引位置放颜色低位
    }
	// 此时,lcd_buf这个数组已经被完全填充为重复的color值了。

	// 分批次将缓冲区数据写入LCD
    // LCD_TOTAL_BUF_SIZE / LCD_BUF_SIZE = 153600 / 15360 = 10
    // 所以这个循环会执行10次
    for(i = 0; i < (LCD_TOTAL_BUF_SIZE / LCD_BUF_SIZE); i++)
    {
		// 每次写入LCD_BUF_SIZE(15360)字节的数据。
        // 由于之前设置了窗口,数据会连续地填充到屏幕GRAM中。
        lcd_write_data(lcd_buf, LCD_BUF_SIZE);
    }
}

/**
 * @brief       在指定区域内填充单个颜色
 * @param       (sx,sy),(ex,ey):填充矩形对角坐标,区域大小为:(ex - sx + 1) * (ey - sy + 1)
 * @param       color:要填充的颜色(32位颜色,方便兼容LTDC)
 * @retval      无
 */
void lcd_fill(uint16_t sx, uint16_t sy, uint16_t ex, uint16_t ey, uint16_t color)
{
    uint16_t i;
    uint16_t j;
    uint16_t width;
    uint16_t height;

    width = ex - sx + 1;
    height = ey - sy + 1;
    lcd_set_window(sx, sy, ex, ey);

    for (i = 0; i < height; i++)
    {
        for (j = 0; j < width; j++)
        {
            lcd_write_data16(color);
        }
    }
    lcd_set_window(sx, sy, ex, ey);
}


/**
 * @brief       设置光标的位置
 * @param       Xpos:左上角x轴
 * @param       Ypos:左上角y轴
 * @retval      无
 */
void lcd_set_cursor(uint16_t xpos, uint16_t ypos)
{
    lcd_set_window(xpos,ypos,xpos,ypos);	
} 

/**
 * @brief       设置LCD的自动扫描方向(对RGB屏无效)
 * @param       dir:0~7,代表8个方向(具体定义见lcd.h)
 * @retval      无
 */
void lcd_scan_dir(uint8_t dir)
{
    uint8_t regval = 0;
    uint8_t dirreg = 0;
    uint16_t temp;

    /* 横屏时,对1963不改变扫描方向, 其他IC改变扫描方向!竖屏时1963改变方向, 其他IC不改变扫描方向 */
    if (lcd_self.dir == 1)
    {
        dir = 5;
    }

    /* 根据扫描方式 设置 0X36/0X3600 寄存器 bit 5,6,7 位的值 */
    switch (dir)
    {
        case L2R_U2D:                           /* 从左到右,从上到下 */
            regval |= (0 << 7) | (0 << 6) | (0 << 5);
            break;

        case L2R_D2U:                           /* 从左到右,从下到上 */
            regval |= (1 << 7) | (0 << 6) | (0 << 5);
            break;

        case R2L_U2D:                           /* 从右到左,从上到下 */
            regval |= (0 << 7) | (1 << 6) | (0 << 5);
            break;

        case R2L_D2U:                           /* 从右到左,从下到上 */
            regval |= (1 << 7) | (1 << 6) | (0 << 5);
            break;

        case U2D_L2R:                           /* 从上到下,从左到右 */
            regval |= (0 << 7) | (0 << 6) | (1 << 5);
            break;

        case U2D_R2L:                           /* 从上到下,从右到左 */
            regval |= (0 << 7) | (1 << 6) | (1 << 5);
            break;

        case D2U_L2R:                           /* 从下到上,从左到右 */
            regval |= (1 << 7) | (0 << 6) | (1 << 5);
            break;

        case D2U_R2L:                           /* 从下到上,从右到左 */
            regval |= (1 << 7) | (1 << 6) | (1 << 5);
            break;
    }

    dirreg = 0x36;                              /* ​​存储器访问控制​​命令,对绝大部分驱动IC, 由0X36寄存器控制 */
    
    uint8_t date_send[1] = {regval};
    
    lcd_write_cmd(dirreg);
    lcd_write_data(date_send, 1);
    
    if (regval & 0x20)
    {
        if (lcd_self.width < lcd_self.height)   /* 交换X,Y */
        {
            temp = lcd_self.width;
            lcd_self.width = lcd_self.height;
            lcd_self.height = temp;
        }
    }
    else
    {
        if (lcd_self.width > lcd_self.height)   /* 交换X,Y */
        {
            temp = lcd_self.width;
            lcd_self.width = lcd_self.height;
            lcd_self.height = temp;
        }
    }
    
    lcd_set_window(0, 0, lcd_self.width,lcd_self.height);
}

/**
 * @brief       设置LCD显示方向
 * @param       dir:0,竖屏; 1,横屏
 * @retval      无
 */
void lcd_display_dir(uint8_t dir)
{
    lcd_self.dir = dir;
    
    if (lcd_self.dir == 0)                  /* 竖屏 */
    {
        lcd_self.width      = 240;
        lcd_self.height     = 320;
        lcd_self.wramcmd    = 0X2C;         /* 向由 列地址(CASET) 和 行地址(RASET)​​ 设定的区域​​连续写入像素数据​​ */
        lcd_self.setxcmd    = 0X2A;         /* 设置要在哪个​​水平(X方向)区域​​写入像素数据 */
        lcd_self.setycmd    = 0X2B;         /* 设置要在哪个​​垂直(Y方向)区域​​写入像素数据 */
    }
    else                                    /* 横屏 */
    {
        lcd_self.width      = 320;          /* 默认宽度 */
        lcd_self.height     = 240;          /* 默认高度 */
        lcd_self.wramcmd    = 0X2C;         /* 向由 列地址(CASET) 和 行地址(RASET)​​ 设定的区域​​连续写入像素数据​​ */
        lcd_self.setxcmd    = 0X2A;         /* 设置要在哪个​​水平(X方向)区域​​写入像素数据 */
        lcd_self.setycmd    = 0X2B;         /* 设置要在哪个​​垂直(Y方向)区域​​写入像素数据 */
    }

    lcd_scan_dir(DFT_SCAN_DIR);             /* 默认扫描方向 */
}

/**
 * @brief       硬件复位
 * @param       self_in:LCD结构体
 * @retval      无
 */
void lcd_hard_reset(void)
{
    /* 复位显示屏 */
    LCD_RST(0);
    vTaskDelay(100);
    LCD_RST(1);
    vTaskDelay(100);
}

/**
 * @brief       绘画一个像素点
 * @param       self_in:LCD结构体
 * @param       x:x轴坐标
 * @param       y:y轴坐标
 * @param       color:颜色值
 * @retval      无
 */
void lcd_draw_pixel(uint16_t x, uint16_t y, uint16_t color)
{
    lcd_set_cursor(x, y);
    lcd_write_data16(color);
}

/**
 * @brief       画线函数(直线、斜线)
 * @param       x1,y1   起点坐标
 * @param       x2,y2   终点坐标
 * @param       color 填充颜色
 * @retval      无
 */
void lcd_draw_line(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t color)
{
    uint16_t t; 
    int xerr = 0, yerr = 0, delta_x, delta_y, distance; 
    
    int incx, incy, urow, ucol; 

    delta_x = x2 - x1;                      /* 计算坐标增量 */
    delta_y = y2 - y1; 
    urow = x1; 
    ucol = y1; 
    
    if (delta_x > 0)
    {
        incx = 1;                           /* 设置单步方向 */
    }
    else if (delta_x == 0)
    {
        incx = 0;                           /* 垂直线 */
    }
    else
    {
        incx =-1;
        delta_x =-delta_x;
    } 
    if(delta_y > 0)
    {
        incy = 1; 
    }
    else if(delta_y == 0)
    {
        incy = 0;                           /* 水平线 */
    }
    else
    {
        incy =-1;
        delta_y=-delta_y;
    } 
    
    if( delta_x>delta_y)
    {
        distance = delta_x;                 /* 选取基本增量坐标轴 */
    }
    else
    {
        distance = delta_y; 
    }
    
    for (t = 0;t <= distance + 1;t++ )      /* 画线输出 */
    {
        lcd_draw_pixel(urow,ucol,color);    /* 画点 */ 
        xerr += delta_x ; 
        yerr += delta_y ; 
        
        if(xerr>distance)
        { 
            xerr -= distance; 
            urow += incx; 
        } 
        
        if (yerr > distance)
        { 
            yerr -= distance; 
            ucol += incy; 
        } 
    } 
}

/**
 * @brief       画水平线
 * @param       x0,y0: 起点坐标
 * @param       len  : 线长度
 * @param       color: 矩形的颜色
 * @retval      无
 */
void lcd_draw_hline(uint16_t x, uint16_t y, uint16_t len, uint16_t color)
{
    if ((len == 0) || (x > lcd_self.width) || (y > lcd_self.height))return;

    lcd_fill(x, y, x + len - 1, y, color);
}

/**
 * @brief       画一个矩形
 * @param       x1,y1   起点坐标
 * @param       x2,y2   终点坐标
 * @param       color 填充颜色
 * @retval      无
 */
void lcd_draw_rectangle(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1,uint16_t color)
{
    lcd_draw_line(x0, y0, x1, y0,color);
    lcd_draw_line(x0, y0, x0, y1,color);
    lcd_draw_line(x0, y1, x1, y1,color);
    lcd_draw_line(x1, y0, x1, y1,color);
}

/**
 * @brief       画一个圆
 * @param       x0,y0   圆心坐标
 * @param       r   圆半径
 * @param       color 填充颜色
 * @retval      无
 */
void lcd_draw_circle(uint16_t x0, uint16_t y0, uint16_t r, uint16_t color)
{
    int a, b;
    int di;
    a = 0;
    b = r;
    di = 3 - (r << 1);

    while (a <= b)
    {
        lcd_draw_pixel(x0 - b, y0 - a, color);
        lcd_draw_pixel(x0 + b, y0 - a, color);
        lcd_draw_pixel(x0 - a, y0 + b, color);
        lcd_draw_pixel(x0 - b, y0 - a, color);
        lcd_draw_pixel(x0 - a, y0 - b, color);
        lcd_draw_pixel(x0 + b, y0 + a, color);
        lcd_draw_pixel(x0 + a, y0 - b, color);
        lcd_draw_pixel(x0 + a, y0 + b, color);
        lcd_draw_pixel(x0 - b, y0 + a, color);
        a++;

        if (di < 0)
        {
            di += 4 * a + 6;
        }
        else
        {
            di += 10 + 4 * (a - b);
            b--;
        }

        lcd_draw_pixel(x0 + a, y0 + b, color);
    }
}

/**
 * @brief       在指定位置显示一个字符
 * @param       x,y  : 坐标
 * @param       chr  : 要显示的字符:" "--->"~"
 * @param       size : 字体大小 12/16/24/32
 * @param       mode : 叠加方式(1); 非叠加方式(0);
 * @param       color : 字符的颜色;
 * @retval      无
 */
void lcd_show_char(uint16_t x, uint16_t y, uint8_t chr, uint8_t size, uint8_t mode, uint16_t color)
{
    uint8_t temp = 0, t1 = 0, t = 0;
    uint8_t *pfont = 0;
    uint8_t csize = 0;                                      /* 得到字体一个字符对应点阵集所占的字节数 */
    uint16_t colortemp = 0;
    uint8_t sta = 0;

    csize = (size / 8 + ((size % 8) ? 1 : 0)) * (size / 2); /* 得到字体一个字符对应点阵集所占的字节数 */
    chr = chr - ' ';                                        /* 得到偏移后的值(ASCII字库是从空格开始取模,所以-' '就是对应字符的字库) */

    if ((x > (lcd_self.width - size / 2)) || (y > (lcd_self.height - size)))
    {
        return;
    }

    lcd_set_window(x, y, x + size / 2 - 1, y + size - 1);   /* (x,y,x+8-1,y+16-1) */

    switch (size)
    {
        case 12:
            pfont = (uint8_t *)asc2_1206[chr];              /* 调用1206字体 */
            sta = 6;
            break;

        case 16:
            pfont = (uint8_t *)asc2_1608[chr];              /* 调用1608字体 */
            sta = 8;
            break;

        case 24:
            pfont = (uint8_t *)asc2_2412[chr];              /* 调用2412字体 */
            break;

        case 32:
            pfont = (uint8_t *)asc2_3216[chr];              /* 调用3216字体 */
            sta = 8;
            break;

        default:
            return ;
    }

    if (size != 24)
    {
        csize = (size / 8 + ((size % 8) ? 1 : 0)) * (size / 2);
        
        for (t = 0; t < csize; t++)
        {
            temp = pfont[t];                                /* 获取字符的点阵数据 */

            for (t1 = 0; t1 < sta; t1++)
            {
                if (temp & 0x80)
                {
                    colortemp = color;
                }
                else if (mode == 0)                     /* 无效点,不显示 */
                {
                    colortemp = 0xFFFF;
                }

                lcd_write_data16(colortemp);
                temp <<= 1;
            }
        }
    }
    else
    {
        csize = (size * 16) / 8;
        
        for (t = 0; t < csize; t++)
        {
            temp = asc2_2412[chr][t];

            if (t % 2 == 0)
            {
                sta = 8;
            }
            else
            {
                sta = 4;
            }

            for (t1 = 0; t1 < sta; t1++)
            {
                if(temp & 0x80)
                {
                    colortemp = color;
                }
                else if (mode == 0)                         /* 无效点,不显示 */
                {
                    colortemp = 0xFFFF;
                }

                lcd_write_data16(colortemp);
                temp <<= 1;
            }
        }
    }
}

/**
 * @brief       m^n函数
 * @param       m,n     输入参数
 * @retval      m^n次方
 */
uint32_t lcd_pow(uint8_t m, uint8_t n)
{
    uint32_t result = 1;

    while(n--)result *= m;

    return result;
}

/**
 * @brief       显示len个数字
 * @param       x,y : 起始坐标
 * @param       num : 数值(0 ~ 2^32)
 * @param       len : 显示数字的位数
 * @param       size: 选择字体 12/16/24/32
 * @retval      无
 */
void lcd_show_num(uint16_t x, uint16_t y, uint32_t num, uint8_t len, uint8_t size, uint16_t color)
{
    uint8_t t, temp;
    uint8_t enshow = 0;

    for (t = 0; t < len; t++)                                               /* 按总显示位数循环 */
    {
        temp = (num / lcd_pow(10, len - t - 1)) % 10;                       /* 获取对应位的数字 */

        if (enshow == 0 && t < (len - 1))                                   /* 没有使能显示,且还有位要显示 */
        {
            if (temp == 0)
            {
                lcd_show_char(x + (size / 2)*t, y, ' ', size, 0, color);    /* 显示空格,占位 */
                continue;                                                   /* 继续下个一位 */
            }
            else
            {
                enshow = 1;                                                 /* 使能显示 */
            }

        }

        lcd_show_char(x + (size / 2)*t, y, temp + '0', size, 0, color);     /* 显示字符 */
    }
}

/**
 * @brief       扩展显示len个数字(高位是0也显示)
 * @param       x,y : 起始坐标
 * @param       num : 数值(0 ~ 2^32)
 * @param       len : 显示数字的位数
 * @param       size: 选择字体 12/16/24/32
 * @param       mode: 显示模式
 *              [7]:0,不填充;1,填充0.
 *              [6:1]:保留
 *              [0]:0,非叠加显示;1,叠加显示.
 * @param       color : 数字的颜色;
 * @retval      无
 */
void lcd_show_xnum(uint16_t x, uint16_t y, uint32_t num, uint8_t len, uint8_t size, uint8_t mode, uint16_t color)
{
    uint8_t t, temp;
    uint8_t enshow = 0;

    for (t = 0; t < len; t++)                                                           /* 按总显示位数循环 */
    {
        temp = (num / lcd_pow(10, len - t - 1)) % 10;                                   /* 获取对应位的数字 */

        if (enshow == 0 && t < (len - 1))                                               /* 没有使能显示,且还有位要显示 */
        {
            if (temp == 0)
            {
                if (mode & 0X80)                                                        /* 高位需要填充0 */
                {
                    lcd_show_char(x + (size / 2)*t, y, '0', size, mode & 0X01, color);  /* 用0占位 */
                }
                else
                {
                    lcd_show_char(x + (size / 2)*t, y, ' ', size, mode & 0X01, color);  /* 用空格占位 */
                }
                continue;
            }
            else
            {
                enshow = 1;                                                             /* 使能显示 */
            }
        }
        lcd_show_char(x + (size / 2)*t, y, temp + '0', size, mode & 0X01, color);
    }
}


/**
 * @brief       显示字符串
 * @param       x,y         : 起始坐标
 * @param       width,height: 区域大小
 * @param       size        : 选择字体 12/16/24/32
 * @param       p           : 字符串首地址
 * @retval      无
 */
void lcd_show_string(uint16_t x, uint16_t y, uint16_t width, uint16_t height, uint8_t size, char *p, uint16_t color)
{
    uint8_t x0 = x;
    width += x;
    height += y;

    while ((*p <= '~') && (*p >= ' '))   /* 判断是不是非法字符! */
    {
        if (x >= width)
        {
            x = x0;
            y += size;
        }

        if (y >= height) break;  /* 退出 */

        lcd_show_char(x, y, *p, size, 0, color);
        x += size / 2;
        p++;
    }
}

/**
 * @brief       打开LCD
 * @param       self_in:SPI控制块
 * @retval      mp_const_none:初始化成功
 */
void lcd_on(void)
{
    LCD_PWR(1);
    vTaskDelay(10);
}

/**
 * @brief       关闭LCD
 * @param       self_in:SPI控制块
 * @retval      mp_const_none:初始化成功
 */
void lcd_off(void)
{
    LCD_PWR(0);
    vTaskDelay(10);
}

/**
 * @brief       LCD初始化
 * @param       无
 * @retval      无
 */
void lcd_init(void)
{
    int cmd = 0;
    esp_err_t ret = 0;
    
    lcd_self.dir = 0;                                               /* 0 -> 竖屏 */
    lcd_self.wr = LCD_NUM_WR;                                       /* 配置WR引脚 */
    lcd_self.cs = LCD_NUM_CS;                                       /* 配置CS引脚 */
    
    gpio_config_t gpio_init_struct;

    /* SPI驱动接口配置 */
    spi_device_interface_config_t devcfg = {
        .clock_speed_hz = 60 * 1000 * 1000,                         /* SPI时钟 */
        .mode = 0,                                                  /* SPI模式0 */
        .spics_io_num = lcd_self.cs,                                /* SPI设备引脚 */
        .queue_size = 7,                                            /* 事务队列尺寸 7个 */
    };
    
    /* 添加SPI总线设备 */
    ret = spi_bus_add_device(SPI2_HOST, &devcfg, &MY_LCD_Handle);   /* 配置SPI总线设备 */
    ESP_ERROR_CHECK(ret);

    gpio_init_struct.intr_type = GPIO_INTR_DISABLE;                 /* 失能引脚中断 */
    gpio_init_struct.mode = GPIO_MODE_OUTPUT;                       /* 配置输出模式 */
    gpio_init_struct.pin_bit_mask = 1ull << lcd_self.wr;            /* 配置引脚位掩码 */
    gpio_init_struct.pull_down_en = GPIO_PULLDOWN_DISABLE;          /* 失能下拉 */
    gpio_init_struct.pull_up_en = GPIO_PULLUP_ENABLE;               /* 使能下拉 */
    gpio_config(&gpio_init_struct);                                 /* 引脚配置 */

    lcd_hard_reset();                                               /* LCD硬件复位 */

    /* 初始化代码 */
#if SPI_LCD_TYPE                                                    /* 对2.4寸LCD寄存器进行设置 */
    lcd_init_cmd_t ili_init_cmds[] =
    {
        {0x11, {0}, 0x80},  /* 0x11让芯片从睡眠模式唤醒,进入正常工作模式,芯片手册需要等待120ms */
        {0x36, {0x00}, 1},  /* 0x36 控制​​显示方向​​(旋转、镜像)、​​颜色顺序​​(RGB/BGR)和​​扫描方式​​ */
        {0x3A, {0x65}, 1},  /* 0x3A 设置​​像素数据的颜色深度和格式​​,即每个像素点用多少位来表示,0x65的6(110)为262K,5表示16bit表示一个像素 */
        {0X21, {0}, 0x80},  /* 开启显示颜色反转。*/
        {0x29, {0}, 0x80},  /*开启显示,使得显存内容能够呈现在屏幕上*/
        {0, {0}, 0xff},
    };

#else                                                               /* 不为0则视为使用1.3寸SPILCD屏,那么屏幕将不会反显 */
    lcd_init_cmd_t ili_init_cmds[] =
    {
        {0x11, {0}, 0x80},
        {0x36, {0x00}, 1},
        {0x3A, {0x65}, 1},
        {0xB2, {0x0C, 0x0C, 0x00, 0x33,0x33}, 5},
        {0xB7, {0x75}, 1},
        {0xBB, {0x1C}, 1},
        {0xC0, {0x2c}, 1},
        {0xC2, {0x01}, 1},
        {0xC3, {0x0F}, 1},
        {0xC4, {0x20}, 1},
        {0xC6, {0X01}, 1},
        {0xD0, {0xA4,0xA1}, 2},
        {0xE0, {0xD0, 0x04, 0x0D, 0x11, 0x13, 0x2B, 0x3F, 0x54, 0x4C, 0x18, 0x0D, 0x0B, 0x1F, 0x23}, 14},
        {0xE1, {0xD0, 0x04, 0x0C, 0x11, 0x13, 0x2C, 0x3F, 0x44, 0x51, 0x2F, 0x1F, 0x1F, 0x20, 0x23}, 14},
        {0X21, {0}, 0x80},
        {0x29, {0}, 0x80},
        {0, {0}, 0xff},
    };
#endif

    /* 循环发送设置所有寄存器 */
    while (ili_init_cmds[cmd].databytes != 0xff)
    {
        lcd_write_cmd(ili_init_cmds[cmd].cmd);
        lcd_write_data(ili_init_cmds[cmd].data, ili_init_cmds[cmd].databytes & 0x1F);
        
        if (ili_init_cmds[cmd].databytes & 0x80)
        {
            vTaskDelay(120);
        }
        
        cmd++;
    }

    lcd_display_dir(1);                                             /* 设置屏幕方向 */
    LCD_PWR(1);
    lcd_clear(WHITE);                                               /* 清屏 */
}

        从上的代码中可以看出,本章实验的 SPILCD 驱动是兼容了 1.3 寸与 2.4 寸SPILCD 模块的,因此在加载完 SPI 设备后,会与 SPILCD 进行通讯,确定 SPILCD 的型号,然后根据型号针对性地对 SPILCD 模块进行配置。
        SPILCD 驱动中与 SPILCD 模块通讯的函数,如下所示:

/**
 * @brief       发送命令到LCD,使用轮询方式阻塞等待传输完成(由于数据传输量很少,因此在轮询方式处理可提高速度。使用中断方式的开销要超过轮询方式)
 * @param       cmd 传输的8位命令数据
 * @retval      无
 */
void lcd_write_cmd(const uint8_t cmd)
{
    LCD_WR(0);
    spi2_write_cmd(MY_LCD_Handle, cmd);
}

/**
 * @brief       发送数据到LCD,使用轮询方式阻塞等待传输完成(由于数据传输量很少,因此在轮询方式处理可提高速度。使用中断方式的开销要超过轮询方式)
 * @param       data 传输的8位数据
 * @retval      无
 */
void lcd_write_data(const uint8_t *data, int len)
{
    LCD_WR(1);
    spi2_write_data(MY_LCD_Handle, data, len);
}

/**
 * @brief       发送数据到LCD,使用轮询方式阻塞等待传输完成(由于数据传输量很少,因此在轮询方式处理可提高速度。使用中断方式的开销要超过轮询方式)
 * @param       data 传输的16位数据
 * @retval      无
 */
void lcd_write_data16(uint16_t data)
{
    uint8_t dataBuf[2] = {0,0};
    dataBuf[0] = data >> 8;
    dataBuf[1] = data & 0xFF;
    LCD_WR(1);
    spi2_write_data(MY_LCD_Handle, dataBuf,2);
}

        在上述代码中, lcd_write_cmd()和 lcd_write_data()在调用 spi 的驱动函数前,按照 LCD 时序图,前者需要先将 WR 引脚电平信号置 0,后者则需要置 1。
        通过上面介绍的驱动函数就能够与 SPILCD模块进行通讯了,而在 SPILCD模块的显示屏上显示出特定的图案或字符或设置 SPILCD模块的显示方向等等的操作都是能够通过 SPILCD模块规定的特定命令来完成的。
        下面再理解下函数void lcd_clear(uint16_t color),具体已在函数中做了详细说明,下面讨论为什么这么实现?

(1)为何要分批次写入?​​
        微控制器的可用内存(RAM)可能有限,一次性分配153600字节的缓冲区(对于320x240的16位色屏需要150KB)可能不现实。而分配一个15KB的缓冲区(LCD_BUF_SIZE),然后循环使用10次,​​大大降低了对MCU RAM的需求​​。
        许多LCD驱动芯片(如ST7789, ILI9341等)支持​​连续写入​​(lcd_set_window设置了窗口后,后续写入的数据会自动填充该窗口)。所以只需要分批发送数据即可,无需重复设置地址。
(2)为什么先填充缓冲区?​​
        如果直接在循环里调用 lcd_write_data(&color, 2)来逐个像素写入,虽然简单,但​​效率极低​​,因为每次写入都有函数调用和通信开销(如SPI或8080并行的时序控制)。
        现在的方式是:​​先用一个紧凑的循环在内存中准备好一大块数据(填充缓冲区),然后一次性发送这一大块数据​​。这显著减少了函数调用次数和数据传输的次数,​​大大提高清屏速度​​。

22.3.4 CMakeLists.txt 文件

        打开本实验 BSP 下的 CMakeLists.txt 文件,其内容如下所示

set(src_dirs
            IIC
            LCD
            LED
            SPI
            XL9555)

set(include_dirs
            IIC
            LCD
            LED
            SPI
            XL9555)

set(requires
            driver)

idf_component_register(SRC_DIRS ${src_dirs} INCLUDE_DIRS ${include_dirs} REQUIRES ${requires})

component_compile_options(-ffast-math -O3 -Wno-error=format=-Wno-format)
22.3.5 实验应用代码

       打开 main/main.c 文件,该文件定义了工程入口函数,名为 app_main。该函数代码如下。

i2c_obj_t i2c0_master;

/**
 * @brief       程序入口
 * @param       无
 * @retval      无
 */
void app_main(void)
{
    uint8_t x = 0;
    esp_err_t ret;
    
    
    ret = nvs_flash_init();             /* 初始化NVS */

    if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND)
    {
        ESP_ERROR_CHECK(nvs_flash_erase());
        ret = nvs_flash_init();
    }

    led_init();                         /* 初始化LED */
    i2c0_master = iic_init(I2C_NUM_0);  /* 初始化IIC0 */
    spi2_init();                        /* 初始化SPI2 */
    xl9555_init(i2c0_master);           /* IO扩展芯片初始化 */
    lcd_init();                         /* 初始化LCD */

    while (1)
    {
        switch (x)
        {
            case 0:
            {
                lcd_clear(WHITE);
                break;
            }
            case 1:
            {
                lcd_clear(BLACK);
                break;
            }
            case 2:
            {
                lcd_clear(BLUE);
                break;
            }
            case 3:
            {
                lcd_clear(RED);
                break;
            }
            case 4:
            {
                lcd_clear(MAGENTA);
                break;
            }
            case 5:
            {
                lcd_clear(GREEN);
                break;
            }
            case 6:
            {
                lcd_clear(CYAN);
                break;
            }
            case 7:
            {
                lcd_clear(YELLOW);
                break;
            }
            case 8:
            {
                lcd_clear(BRRED);
                break;
            }
            case 9:
            {
                lcd_clear(GRAY);
                break;
            }
            case 10:
            {
                lcd_clear(LGRAY);
                break;
            }
            case 11:
            {
                lcd_clear(BROWN);
                break;
            }
        }

        lcd_show_string(10, 40, 240, 32, 32, "ESP32", RED);
        lcd_show_string(10, 80, 240, 24, 24, "SPILCD TEST", RED);
        lcd_show_string(10, 110, 240, 16, 16, "ATOM@ALIENTEK", RED);
        lcd_show_string(10, 150, 240, 12, 12, "ATOM@ALIENTEK", RED);
        x++;

        if (x == 12)
        {
            x = 0;
        }

        LED_TOGGLE();
        vTaskDelay(500);
    }
}

         从上面的代码中可以看出,在初始化完LCD后,便在LCD上显示一些本实验的相关信息,随后便每间隔 500 毫秒就更换一次 LCD 屏幕显示的背景色。

22.4 下载验证

        在完成编译和烧录操作后,可以看到 SPI LCD 上不断变换着不同的颜色, LED 灯闪烁。

图 22.4.1 SPI LCD 显示效果图

Logo

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

更多推荐