# ESPDS-基于ESP32S3的双屏显示项目 **Repository Path**: zijinkunjiang/ESPDualSCreen ## Basic Information - **Project Name**: ESPDS-基于ESP32S3的双屏显示项目 - **Description**: ESPDualScreen,基于ESP32S3的双屏显示项目 本项目将会给出一个简单的解决方案,实现ESP32的双屏甚至是多屏的显示 项目实现从双屏独立驱动到打通两块屏幕显示 注:屏幕驱动的源代码基于正点原子的屏幕驱动代码修改而来 - **Primary Language**: C/C++ - **License**: GPL-3.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 4 - **Forks**: 0 - **Created**: 2025-01-25 - **Last Updated**: 2025-06-13 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # ESPDS-基于ESP32S3的双屏显示项目 ## 介绍 ESPDualScreen,基于ESP32S3的双屏显示项目 本项目将会给出一个简单的解决方案,实现ESP32的双屏甚至是多屏的显示 项目实现从双屏独立驱动到打通两块屏幕显示 注:屏幕驱动的源代码基于正点原子的屏幕驱动代码修改而来 ## 特别介绍 本项目中的硬件开源位于————立创开源广场 项目地址: 本项目中的视频演示 演示地址: ## 环境说明 本程序基于ESP32-S3 N16R8 MCU平台开发,使用SPILCD屏幕为硬件进行设计(其他屏幕和其他单片机平台可对此代码进行参考) 使用ESP-IDF开发MCU程序,ESP-IDF版本为5.1.2 ## 使用说明 1. 需安装ESP-IDF(版本5.1.2+) 2. 编译程序的目录请不要包含任何中文 3. 请认真阅读仓库说明,选取您需要的程序及源代码 4. 本代码的单屏LCD驱动参考自正点原子(ALIENTEK)适配的SPILCD驱动 ## 仓库说明 本仓库包含了整个工程中产生的各个版本的文件,请注意! |-Target 工程设计中产生的所有版本的工程源文件上传于此文件夹 |-2LCD_Driver 注册两个独立的LCD驱动程序对屏幕进行驱动,目的:验证双屏驱动方案的可行性 |-DSLCD_Driver 使用一个LCD驱动程序,驱动两个屏幕 相较于2LCD_Driver,本程序使用1个驱动程序驱动两块LCD,调用函数的时候通过宏定义参数传递对需要驱动的LCD进行设置 |-DSLCD_GUI 基础的GUI,提供三个基本的函数接入以及常用图形显示函数和文字显示函数的原型,供使用者调用 DSLCD_GUI两个屏幕作为一块拼接的大屏使用,对屏幕进行坐标分配 四个基本API(供其他的图形函数和GUI库调用) |-lcd_draw_pixel 根据坐标在屏幕上自动画点 |-lcd_fill 快速单色填充 |-lcd_color_fill_image 自动显示取模图片(Image2LCD) |-lcd_color_fill_lvgl 为LVGL优化的刷屏函数 其余内容自行参考 [备注:在每个Target下都包含了Readme文件,记录开发日志] |-Source 工程设计后产生的源代码 源代码从工程中提取,在工程中经过验证,保证了源代码的可用性 使用者直接使用源代码 ## 实现流程 #### **1. SPILCD的使用** SPILCD通过SPI总线跟单片机进行通讯,如下图 在这里将总线分为两个部分:SPI通讯总线和LCD控制线 SPI通讯总线包括SPICLK(SCL)和SPIMOSI(SDA)以及片选信号SPICS。 LCD控制线一般有LCDWR,LCDRST和LCDPWR |--LCDWR (有的屏幕标注DC),因为屏幕只使用一条SPI总线进行通信,而控制LCD包括向LCD发送命令或数据,因此LCD引出数据/命令引脚(DC),依靠此引脚的电平高低告诉LCD发送的是数据还是命令。 |--LCDRST 复位引脚,没啥好说的,低电平复位高电平正常(进行复位操作的时候电平保持至少10ms) |--LCDPWR 有的LCD是背光引脚,有的LCD是屏幕开关引脚,有的LCD没有此引脚(默认上电就是启动LCD) 为了节约引脚(减轻引脚本来就少的ESP32的负担),我设计的PCB中,LCDPWR拉低,默认LCD打开 那么,我们的单片机如何连接多个LCD呢,需要两组SPI吗?这就要说到SPI了。 SPI允许用户在一条总线上挂载多个设备,通过片选来选择要通讯的设备。即主设备与从设备之间的SCL(时钟信号),MOSI与MISO都连接在一起,每一个挂载在主设备上的从设备都通过片选与主设备连接,如下图 ![SPIBUS](Image/SPIBUS.png) 通过片选信号,决定主机跟哪个从机进行通讯,即通过片选对需要通讯的设备给予区分 ![SPIBUS 与从设备1通讯](SPIBUS-T1.png) ![SPIBUS 与从设备2通讯](Image/SPIBUS-T2.png) ![SPIBUS 与从设备3通讯](Image/SPIBUS-T3.png) 在我们的项目中,单片机作为从机,SPI屏幕作为主机,屏幕与单片机的连接方式如下: 两块屏幕的SCL,SDA(SCL和MOSI)连接到单片机的SCL和MOSI上,片选信号单独连接到单片机上,通讯的时候,单片机通过片选对屏幕进行选择。 ![LCD与单片机通过SPI的基本连接方法](Image/LCD-SPIBUS-Conn.png) 实际上,屏幕还有LCDDC与LCDRST两个引脚,这两组引脚也单独与单片机进行连接,如下图: ![输入图片说明](Image/LCD-Master-Conn.png) ###### **小Tips** 1.为什么不使用两组SPI对屏幕连接 使用两组SPI,单条SPI总线上挂载的设备数量少,理论上减少了SPI总线的任务调度和SPI总线的操作冲突,但是对于我们的ESP32,IO数量实在是太少,连接两组SPI必然会浪费IO资源 2.LCDDC(LCDWR,下方简称WR) LCDRST可以并联吗? 目前版本尚未测试并联,从时序图来看(如下),片选信号和WR信号是一起送出的,实际上我使用的程序里面是先送出WR信号,再进行通讯。 如果您认为IO占用过多,可以将RST引脚连接到IO扩展板,因为RST只需要在启动的时候对屏幕进行复位 特别提示:不得去掉RST,否则屏幕可能无法正常显示 ![输入图片说明](Image/image.png) 为了方便后续的开发,使用嘉立创设计一块专门的PCB(保证后续开发的稳定性) 有关PCB的开源项目,请前往立创开源广场: #### **2.双屏显示驱动** ###### **2.1**最简单的方法:调用两个独立的LCD驱动程序对屏幕进行刷屏 最简单额双屏显示————使用两个独立的LCD驱动程序进行驱动 在程序里面,两个LCD驱动程序分别调用两个不同的SPI任务队列,向两块屏幕发送数据,如下: ![使用两个LCD驱动程序对屏幕进行刷屏](Image/DSLCD_Driver.png) 当需要向LCD1刷屏的时候... ![调用LCD1进行刷屏](Image/Flush%20LCD1_New.png) 当需要向LCD2刷屏的时候... ![输入图片说明](Image/Flush%20LCD2.png) 因此,我们在工程中引入两个LCD驱动程序----LCD_1 和 LCD_2 说明:引入的程序在函数,函数中分别对LCD1和LCD2的刷屏程序注册两个SPI任务队列 除此之外,程序相关变量加上了_1 _2对两个LCD的驱动程序进行区分,举个例子: ``` /*初始化*/ lcd_1_init(); lcd_2_init(); /*清屏*/ lcd_1_clear(WHITE); //LCD1刷入白色清屏 lcd_2_clear(WHITE); //LCD2刷入白色清屏 /*调用屏幕信息*/ printf("LCD1 Width %d \r\n",lcd_1_self.width); printf("LCD1 Height %d \r\n",lcd_1_self.height); printf("LCD2 Width %d \r\n",lcd_2_self.width); printf("LCD2 Height %d \r\n",lcd_2_self.height); /*屏幕缓冲空间*/ uint8_t LCD_1_BUF[7680]; uint8_t LCD_2_BUF[7680]; ``` 程序测试: 编译能通过,上传程序测试,程序能给两块屏幕进行刷屏 问题: 1.因为引入两个屏幕驱动,所有的资源都存在两份,例如显示缓冲,双屏显示缓冲就要占用15360字节的显存,对SRAM较小的单片机很不友好... 2.双份资源和函数,反复调用的时候容易出现疏漏(尤其是长代码的情况下) 因此我们在此基础上进行改良,只需要一个驱动程序就能对LCD进行驱动 ###### **2.2 DSLCD_Driver 单驱动驱动两块LCD** 现在我们来讨论如何将两个分立的驱动融入到一个驱动中 思路:当用户调用刷屏函数的时候,通过参数传递的方式指定需要驱动哪块屏幕 ![选择LCD1](Image/DSLCD_Driver_Select.png) ![输入图片说明](Image/DSLCD_Driver_Select2_New.png) 这样,我们只需要调用同一个函数,传入需要驱动的LCD,函数会根据用户设置的LCD进行自动驱动 ``` LCD1_SELECT //驱动LCD1 LCD2_SELECT //驱动LCD2 LCD_SELECT_ALL //驱动LCD1 LCD2,此时程序会先驱动LCD1,然后驱动LCD2,两个LCD传入的内容相同 ``` ###### **2.2 Driver实现流程** DSLCD_Driver的实现流程在DSLCD_Driver工程中有演示,请注意 1.配置引脚 配置引脚包括配置LCD的引脚和更新引脚的操作程序,如下: ``` //引入两块屏幕的引脚定义(可以根据你的硬件进行修改) /* 引脚定义 第一块LCD*/ #define LCD_1_NUM_WR GPIO_NUM_40 #define LCD_1_NUM_CS GPIO_NUM_21 #define LCD_1_NUM_RST GPIO_NUM_4 /* 引脚定义 第二块LCD*/ #define LCD_2_NUM_WR GPIO_NUM_48 #define LCD_2_NUM_CS GPIO_NUM_47 #define LCD_2_NUM_RST GPIO_NUM_5 /* 备注:LCD使用的SPI是SPI2,SPI引脚定义在SPI里面 通过宏定义的方式定义IO操作的函数,这里需要引入两块屏幕的RST CS WR(DC)三个引脚的IO操作: (操作方式参考自DNESP32S3示例代码) */ #define LCD_1_WR(x) do{ x ? \ (gpio_set_level(LCD_NUM_WR, 1)): \ (gpio_set_level(LCD_NUM_WR, 0)); \ }while(0) ``` 2.更新LCD信息结构体 为了后续对两块分辨率不相同的两块屏幕进行兼容,这里保留两块LCD的独立信息 ``` //注意,这里需要存放两个LCD的信息(为后续驱动两块不相同的显示屏做准备) /* LCD信息结构体 */ typedef struct _lcd_1_obj_t { /* --------LCD1屏幕信息结构体-------- */ uint16_t lcd1_width; /* LCD1宽度 */ uint16_t lcd1_height; /* LCD1高度 */ uint8_t lcd1_dir; /* LCD1横屏还是竖屏控制:0,竖屏;1,横屏。 */ uint16_t lcd1_wramcmd; /* LCD1开始写gram指令 */ uint16_t lcd1_setxcmd; /* LCD1设置x坐标指令 */ uint16_t lcd1_setycmd; /* LCD1设置y坐标指令 */ uint16_t lcd1_wr; /* LCD1命令/数据IO */ uint16_t lcd1_cs; /* LCD1片选IO */ /* --------LCD2屏幕信息结构体-------- */ uint16_t lcd2_width; /* LCD2宽度 */ uint16_t lcd2_height; /* LCD2高度 */ uint8_t lcd2_dir; /* LCD2横屏还是竖屏控制:0,竖屏;1,横屏。 */ uint16_t lcd2_wramcmd; /* LCD2开始写gram指令 */ uint16_t lcd2_setxcmd; /* LCD2设置x坐标指令 */ uint16_t lcd2_setycmd; /* LCD2设置y坐标指令 */ uint16_t lcd2_wr; /* LCD2命令/数据IO */ uint16_t lcd2_cs; /* LCD2片选IO */ } lcd_obj_t; ``` 3.LCD缓冲区 LCD缓冲保留LCD单缓冲和LCD双缓冲 LCD单缓冲:两块屏幕共用一个缓冲区域,因为刷入两块屏幕的刷屏不是同时的,因此不存在缓冲区的冲突 LCD双缓冲:两块屏幕拥有两个缓冲空间,以对双屏幕分辨率不同进行支持 单,双缓冲设置 用户根据需求设置需要使用单缓冲还是双缓冲 如需使用单缓冲/双缓冲,修改这行代码: #define LCDBUF_USED USE_DOUBLE_LCDBUF //使用双缓冲区 #define LCDBUF_USED USE_SINGLE_LCDBUF //使用双缓冲区 当用户定义的缓冲区使用宏定义丢失的时候,默认使用双缓冲区!!!!!! 注意,所驱动的屏幕分辨率不同的时候,必须使用双缓冲区 ``` //LCD缓冲区保留LCD单缓冲和LCD双缓冲 #define USE_SINGLE_LCDBUF 0x00 #define USE_DOUBLE_LCDBUF 0x01 #define LCDBUF_USED USE_SINGLE_LCDBUF //使用双缓冲区 #if LCDBUF_USED == USE_SINGEL_LCDBUF //如果使用双缓冲区 /* LCD缓存大小设置,修改此值时请注意!!!!修改这两个值时可能会影响以下函数 lcd_1_clear/lcd_1_fill/lcd_1_draw_line */ #define LCD_TOTAL_BUF_SIZE (320 * 240 * 2) #define LCD_BUF_SIZE 7680 extern uint8_t lcd_buf[LCD_BUF_SIZE]; #else //因为双缓冲buffer经过测试,如果用户对缓冲区的定义丢失的时候,默认使用双缓冲模式 /* LCD缓存大小设置,修改此值时请注意!!!!修改这两个值时可能会影响以下函数 lcd_1_clear/lcd_1_fill/lcd_1_draw_line */ #define LCD_1_TOTAL_BUF_SIZE (320 * 240 * 2) #define LCD_1_BUF_SIZE 7680 /* LCD缓存大小设置,修改此值时请注意!!!!修改这两个值时可能会影响以下函数 lcd_1_clear/lcd_1_fill/lcd_1_draw_line */ #define LCD_2_TOTAL_BUF_SIZE (320 * 240 * 2) #define LCD_2_BUF_SIZE 7680 extern uint8_t lcd_1_buf[LCD_1_BUF_SIZE]; extern uint8_t lcd_2_buf[LCD_2_BUF_SIZE]; #endif ``` 接着适配LCD的驱动函数 先罗列需要适配的LCD驱动程序,如下 ![LCD驱动列表](Image/DSLCD_Driver_List.png) **首先是SPILCD通讯函数** ,这些函数基于LCD通讯的特点,对单片机的SPI通讯函数进行封装: ![输入图片说明](Image/DSLCD_Driver_SPI.png) 1.添加了对LCDWR的操作 2.增加“LCD选择”参数,根据所传入的参数对指定的LCD进行通讯 以下方代码举例 ``` void lcd_write_cmd(const uint8_t cmd,uint8_t lcd_select) { if((lcd_select & LCD1_SELECT) != 0) { //选择LCD2,操作LCD2,写入数据 LCD_1_WR(0); spi2_write_cmd(LCD_1_Handle, cmd); } if((lcd_select & LCD2_SELECT) != 0) { //选择LCD2,操作LCD2,写入数据 LCD_2_WR(0); spi2_write_cmd(LCD_2_Handle, cmd); } } ``` 根据用户传入的参数lcd_select,程序选择要跟哪个LCD进行通讯 注意,程序选择需要通讯的LCD并不是0和1的二极管思维,程序会按顺序判断是否跟LCD1 LCD2进行通信,如果用户传入LCD_SELECT_ALL,两块LCD都会进行通讯 **接下来是LCD控制函数,** 这部分的控制函数就不过多赘述了,在单屏驱动程序中,增加了lcd_select,程序选择驱动LCD 最后讲讲LCD初始化函数,LCD初始化的流程如下: ![输入图片说明](Image/DSLCD_Init_New.png) 因为移植程序,更换不同类型的LCD需要修改LCD_Init程序,这里建议大家深入了解,以下内容也会对这部分详细介绍 LCD相关参数配置,这里主要是传入LCD的WR CS引脚,以及显示方向 ``` //配置LCD1的显示方向,将LCD1的WR引脚和CS引脚参数传入LCD信息结构体中 lcd_self.lcd1_dir = 0; lcd_self.lcd1_wr = LCD_1_NUM_WR; /* 配置WR引脚 */ lcd_self.lcd1_cs = LCD_1_NUM_CS; /* 配置CS引脚 */ //配置LCD2的显示方向,将LCD1的WR引脚和CS引脚参数传入LCD信息结构体中 lcd_self.lcd2_dir = 0; lcd_self.lcd2_wr = LCD_2_NUM_WR; /* 配置WR引脚 */ lcd_self.lcd2_cs = LCD_2_NUM_CS; /* 配置CS引脚 */ ``` **注意,其他信息保存在了:lcd_display_dir(uint8_t dir,uint8_t lcd_select)里面,如需修改,请前往此函数:** ``` /** * @brief 设置LCD显示方向 * @param dir:0,竖屏; 1,横屏 * @retval 无 */ void lcd_display_dir(uint8_t dir,uint8_t lcd_select) { if((lcd_select & LCD1_SELECT) != 0) { lcd_self.lcd1_dir = dir; if (lcd_self.lcd1_dir != 0) /* 竖屏 */ { lcd_self.lcd1_width = 240; lcd_self.lcd1_height = 320; lcd_self.lcd1_wramcmd = 0X2C; lcd_self.lcd1_setxcmd = 0X2A; lcd_self.lcd1_setycmd = 0X2B; } else /* 横屏 */ { lcd_self.lcd1_width = 320; /* 默认宽度 */ lcd_self.lcd1_height = 240; /* 默认高度 */ lcd_self.lcd1_wramcmd = 0X2C; lcd_self.lcd1_setxcmd = 0X2A; lcd_self.lcd1_setycmd = 0X2B; } lcd_scan_dir(LCD1_DFT_SCAN_DIR,LCD1_SELECT); /* 默认扫描方向 */ } if((lcd_select & LCD2_SELECT) != 0) { lcd_self.lcd2_dir = dir; if (lcd_self.lcd2_dir != 0) /* 竖屏 */ { lcd_self.lcd2_width = 240; lcd_self.lcd2_height = 320; lcd_self.lcd2_wramcmd = 0X2C; lcd_self.lcd2_setxcmd = 0X2A; lcd_self.lcd2_setycmd = 0X2B; } else /* 横屏 */ { lcd_self.lcd2_width = 320; /* 默认宽度 */ lcd_self.lcd2_height = 240; /* 默认高度 */ lcd_self.lcd2_wramcmd = 0X2C; lcd_self.lcd2_setxcmd = 0X2A; lcd_self.lcd2_setycmd = 0X2B; } lcd_scan_dir(LCD2_DFT_SCAN_DIR,LCD2_SELECT); /* 默认扫描方向 */ } } ``` 配置SPI任务句柄,这里的代码不需要修改,如果您使用的是其他单片机,可对此处程序进行修改 ``` /* SPI驱动接口配置 */ //注册LCD1SPI事件------------------------------------------------------------------------- spi_device_interface_config_t lcd1_devcfg = { .clock_speed_hz = 60 * 1000 * 1000, /* SPI时钟 */ .mode = 0, /* SPI模式0 */ .spics_io_num = lcd_self.lcd1_cs, /* SPI设备引脚 */ .queue_size = 7, /* 事务队列尺寸 7个 */ }; /* 添加SPI总线设备 - LCD1SPI*/ ret = spi_bus_add_device(SPI2_HOST, &lcd1_devcfg, &LCD_1_Handle); /* 配置SPI总线设备 */ ESP_ERROR_CHECK(ret); //注册LCD2SPI事件------------------------------------------------------------------------- spi_device_interface_config_t lcd2_devcfg = { .clock_speed_hz = 60 * 1000 * 1000, /* SPI时钟 */ .mode = 0, /* SPI模式0 */ .spics_io_num = lcd_self.lcd2_cs, /* SPI设备引脚 */ .queue_size = 7, /* 事务队列尺寸 7个 */ }; /* 添加SPI总线设备 - LCD2SPI*/ ret = spi_bus_add_device(SPI2_HOST, &lcd2_devcfg, &LCD_2_Handle); /* 配置SPI总线设备 */ ESP_ERROR_CHECK(ret); ``` 配置GPIO,以下是GPIO的初始化程序: ``` //配置普通GPIO引脚 - LCD1 WS RST引脚---------------------------------------------------- //配置WR引脚 gpio_init_struct.intr_type = GPIO_INTR_DISABLE; /* 失能引脚中断 */ gpio_init_struct.mode = GPIO_MODE_OUTPUT; /* 配置输出模式 */ gpio_init_struct.pin_bit_mask = 1ull << lcd_self.lcd1_wr; /* 配置引脚位掩码 */ gpio_init_struct.pull_down_en = GPIO_PULLDOWN_DISABLE; /* 失能下拉 */ gpio_init_struct.pull_up_en = GPIO_PULLUP_ENABLE; /* 使能下拉 */ gpio_config(&gpio_init_struct); /* 引脚配置 */ //配置复位引脚 gpio_init_struct.intr_type = GPIO_INTR_DISABLE; /* 失能引脚中断 */ gpio_init_struct.mode = GPIO_MODE_OUTPUT; /* 配置输出模式 */ gpio_init_struct.pin_bit_mask = 1ull << LCD_1_NUM_RST; /* 配置引脚位掩码 */ gpio_init_struct.pull_down_en = GPIO_PULLDOWN_DISABLE; /* 失能下拉 */ gpio_init_struct.pull_up_en = GPIO_PULLUP_ENABLE; /* 使能下拉 */ gpio_config(&gpio_init_struct); //配置普通GPIO引脚 - LCD2 WS RST引脚---------------------------------------------------- //配置WR引脚 gpio_init_struct.intr_type = GPIO_INTR_DISABLE; /* 失能引脚中断 */ gpio_init_struct.mode = GPIO_MODE_OUTPUT; /* 配置输出模式 */ gpio_init_struct.pin_bit_mask = 1ull << lcd_self.lcd2_wr; /* 配置引脚位掩码 */ gpio_init_struct.pull_down_en = GPIO_PULLDOWN_DISABLE; /* 失能下拉 */ gpio_init_struct.pull_up_en = GPIO_PULLUP_ENABLE; /* 使能下拉 */ gpio_config(&gpio_init_struct); /* 引脚配置 */ //配置复位引脚 gpio_init_struct.intr_type = GPIO_INTR_DISABLE; /* 失能引脚中断 */ gpio_init_struct.mode = GPIO_MODE_OUTPUT; /* 配置输出模式 */ gpio_init_struct.pin_bit_mask = 1ull << LCD_2_NUM_RST; /* 配置引脚位掩码 */ gpio_init_struct.pull_down_en = GPIO_PULLDOWN_DISABLE; /* 失能下拉 */ gpio_init_struct.pull_up_en = GPIO_PULLUP_ENABLE; /* 使能下拉 */ gpio_config(&gpio_init_struct); // ``` **关于GPIO引脚,请前往DSLCD_Driver.h中,如下:** ``` //引入两块屏幕的引脚定义(可以根据你的硬件进行修改) /* 引脚定义 第一块LCD*/ #define LCD_1_NUM_WR GPIO_NUM_40 #define LCD_1_NUM_CS GPIO_NUM_21 #define LCD_1_NUM_RST GPIO_NUM_4 /* 引脚定义 第二块LCD*/ #define LCD_2_NUM_WR GPIO_NUM_48 #define LCD_2_NUM_CS GPIO_NUM_47 #define LCD_2_NUM_RST GPIO_NUM_5 ``` 接着发送LCD初始化序列,设置LCD显示方向,刷入清屏颜色即可 ###### **2.3 上传程序,测试** ###### **2.4 调试和优化日志 - DSLCD_Driver**