1. 项目概述与整体思路拆解
1.1 为什么选择ESP-IDF驱动ST7789
拿到“用ESP-IDF驱动ST7789屏幕”这个需求,第一反应大概率是:网上教程一堆,直接抄不就行了?但真上手之后你会发现,坑远比想象的多。ST7789这颗驱动IC在国产小屏里出货量极大,240x240、240x320分辨率的IPS屏基本都用的它,价格便宜、色彩表现不错,淘宝上十几块钱就能买到一块带转接板的模组。但便宜归便宜,真要让它稳定显示动态内容,SPI时序、初始化序列、背光控制、帧缓冲管理,每一环都能卡住你半天。
为什么我坚持用ESP-IDF而不是Arduino?说实话,Arduino生态里驱动ST7789确实快,Adafruit的库拖进来就能跑,但那是封裝过的库,底层SPI怎么配、DMA怎么用、双缓冲怎么做,你基本接触不到。而ESP-IDF虽然是ESP32的官方框架,学习曲线比Arduino陡好几个台阶,但它给你的是完全的控制权——SPI时钟频率能自己算、DMA能自己配、显示区域可以按需刷新。等你在ESP-IDF里把一套驱动跑通了,再去回头看Arduino的库,会发现那些报错和性能瓶颈你都能自己定位了。
这个项目适合什么基础的人?我的建议是:至少用过ESP-IDF跑过Hello World级别的例程,知道menuconfig怎么进、知道怎么编译烧录。如果连ESP-IDF的环境都没搭好,建议先花半天时间跑通官方hello_world示例再回来。当然,如果你是个狠人,直接从零开始边学边踩坑也不是不行,就是心态要准备好。
1.2 这个项目到底要解决什么问题
说白了,这篇文章要解决三件事。第一,把ESP32的SPI外设和ST7789的通信机制讲透,不是那种“照着抄就行”的教程,而是让你知道这行配置改了会有什么后果。第二,给出一个完整、可直接编译的工程结构,包括初始化序列、写像素、画图形、显示文字这些基础能力。第三,重点放在动态内容显示上——比如实时刷新传感器数据、滚动文字、动画效果,这部分才是真正生产环境里用得最多的场景,也是网上教程最薄弱的环节。
我见过太多人在静态图片显示上折腾了一周,结果一跑动态刷新就卡成PPT,或者显示区域错乱、残影严重。根源基本都是三个:SPI时钟配太高导致信号质量差、没有用DMA导致CPU被刷新任务霸占、帧缓冲管理混乱导致撕裂。这篇文章会把这几个问题逐一拆开讲,并且给出实际验证过的解决方案。
还有一个很多人忽略的点:ESP32的SPI外设不只是“发数据”这么简单。它支持DMA、支持多设备共用总线、可以配成全双工。ST7789本身是半双工设备(主机发命令和数据,从机不回数据),但有一些骚操作——比如用MISO引脚做触摸或其他传感器通信——可以让你一条SPI总线同时带屏幕和别的外设。这些内容我不会展开太多,但会在相关位置提一嘴,给你留个思路。
2. ST7789核心细节与SPI配置要点
2.1 ST7789是什么,它的命令体系怎么理解
ST7789是一颗单芯片LCD驱动IC,内部集成了240x320分辨率的驱动电路,支持RGB565、RGB666等色彩格式,内置GRAM(显存),主机只需要把像素数据写到它的GRAM里,它自己负责把GRAM内容扫描到液晶面板上。这就好比你在纸上画了一幅画,然后连拍了一堆照片快速翻动,ST7789就是那个自动翻页的相册。
它通过SPI接收命令和数据,命令以命令号开头,数据跟在后面。所有命令里最重要的就是0x36(MADCTL,控制扫描方向和RGB/BGR顺序)、0x3A(COLMOD,设置像素格式)、0x2A/0x2B(设置列地址和行地址,也就是显示窗口)、0x2C(写显存数据)。理解了这四个命令,你就掌握了90%的驱动逻辑:先告诉屏幕“我要在哪个区域画东西”,再把像素数据通过0x2C灌进去。
有个容易踩坑的点是:ST7789在不同模组上的初始化序列略有差异。有的是1.3寸、有的是1.54寸,分辨率不一样,初始化时用的命令参数也不一样。市面上大多数模组用的是240x240分辨率和240x320分辨率两种规格。你买模组时一定要问清楚或者看商品详情页标注的分辨率,否则初始化序列用错了,屏幕要么显示偏移、要么显示花屏。
2.2 SPI配置的技术细节:时钟、模式、DMA选择
ESP32的SPI外设分SPI0到SPI3,其中SPI0和SPI1被Flash和PSRAM占用,用户可用的通常是SPI2和SPI3,前者就是大家常说的SPI2(VSPI),后者是SPI3(HSPI)。在ESP-IDF里,官方推荐用SPI2或SPI3,并且建议用新的spi_bus_config_t和spi_device_interface_config_t结构体配合driver API,而不是直接操作寄存器,因为driver帮你处理了中断和DMA的事情。
时钟频率怎么选?ST7789的SPI时钟上限理论上能到62.5MHz,但实际上受杜邦线和面包板的影响非常大。我实测的经验是:用杜邦线连接,12MHz就差不多了;如果用了焊接良好的排线或PCB,40MHz也能稳定跑。这里有个计算公式你可以自己把握:屏幕的SPI时钟占空比、信号上升沿、走线电容这些因素决定你能跑多快。简单粗暴的验证方法就是跑一个满屏填充的测试,如果出现闪烁、颜色错乱或者显示异常,就把时钟降一半再试。
SPI工作模式方面,ST7789支持SPI Mode 0和Mode 3。Mode 0是CPOL=0、CPHA=0,Mode 3是CPOL=1、CPHA=1。大多数模组默认Mode 0,我用过的几块屏也都是Mode 0正常。但如果你是买的裸屏自己接的转接板,建议仔细看手册确认。还有一个小细节:ST7789支持把DC引脚也当作第9位数据位来用,那种叫“3线SPI+DC”,但一般我们用的都是“4线SPI”,即CS、SCLK、MOSI、DC各占一个引脚,DC用于区分命令和数据。这个务必搞清楚,很多新手一上来就把DC忘了接,结果屏幕完全没反应。
DMA配置建议:能开就开。ESP32的SPI DMA可以让你把要发送的数据一次性交给DMA控制器,然后CPU可以去干别的事。比如你刷一个满屏240x240的RGB565图片,数据量是240x240x2约115KB,如果不走DMA,CPU会被占住很长时间。开了DMA之后,你只需要调用一次spi_device_polling_transmit或者spi_device_queue_trans,数据发完再回调中断即可。注意这里有个坑:DMA传输buffer要求4字节对齐,而且占用的是内部SRAM,如果你的图像数据放在PSRAM里,需要先拷到内部buffer再发,否则会报错或者数据错乱。
2.3 引脚分配与连接注意事项
以ESP32 DevKitC开发板为例,我习惯这样分配引脚:
| 信号 | GPIO引脚 | 说明 |
|---|---|---|
| SCLK | GPIO18 | SPI时钟 |
| MOSI | GPIO23 | 主机输出从机输入 |
| CS | GPIO5 | 片选,低电平有效 |
| DC | GPIO19 | 数据/命令选择,高电平数据低电平命令 |
| RST | GPIO21 | 复位,低电平有效 |
| BLK | GPIO22 | 背光控制,可接PWM调节亮度 |
这个分配只是我个人的习惯,你完全可以根据自己项目需要调整。但有一点要注意:如果板子上接了PSRAM,SPI2和SPI3的引脚有可能跟PSRAM冲突,具体要看板子的设计。ESP32-S3的话,如果用了Octal PSRAM,可用的SPI引脚会更紧张,建议查一下官方引脚映射表再动手。
电平问题也必须提一下:ESP32的GPIO是3.3V电平,ST7789模组也基本都是3.3V供电和逻辑,这是匹配的。但如果你用的是5V单片机(比如Arduino Uno),就需要确认模组是否自带电平转换。不要想当然地认为“反正能亮就行”,长期超压工作会导致模组发热、寿命缩短,甚至烧掉主控。
还有背光引脚:很多模组的背光是一上电就默认最亮的,你要是不想那么刺眼,可以软件上用一个PWM通道去控制。配置PWM的代码很简单,用LEDC驱动就行了,调一个合适的占空比还能省电。
3. 从零构建工程:环境搭建与框架选型
3.1 ESP-IDF版本选择与工程创建
2025年这个时间点,我建议直接用ESP-IDF v5.x版本,官方release分支已经非常稳定,espressif的文档也以5.x为主。v4.x虽然老项目还在用,但新项目没必要从旧版开始了。v5.x相比v4.x最大的变化是驱动模型统一到了driver2,还有一些API名字变了,比如老的spi_bus_initialize依然保留,但一些示例代码里混用了新旧API,你在复制网上代码时要注意上下文和你自己的IDF版本是否匹配。最靠谱的方法是把官网的spi_master示例代码打开对比一下。
创建工程有两种方式:一是用idf.py create-project,这是官方推荐的模板方式;二是手动建文件夹然后写CMakeLists.txt。我个人建议用前者,省心,且目录结构规范。如果你用CLion开发,新版CLion对ESP-IDF的支持已经很成熟,开箱基本能用。但这里我穿插一个很多人问的热搜问题:“CLion 2023工具里的Marketplace里为什么找不到ESP-IDF插件?”这个问题的原因是JetBrains官方插件商店对部分插件做了分区域或分版本下架,你需要在CLion的Settings里把插件仓库地址切换为官方全体仓库地址。但说实话,CLion的ESP-IDF插件就是个壳,核心功能还是靠IDF自己的命令行工具调用,所以即便没有插件,你完全可以用CLion打开ESP-IDF工程,通过自定义build target来编译,效果差别不大。这个稍后在问题排查章节会详细展开。
工程建好之后,先不要急着写驱动代码,把示例工程的hello_world编译烧录一遍,确认环境通了再继续。这一步能省很多排查时间——至少能确定工具链、烧录器、串口驱动都没问题。
3.2 使用组件化方式管理驱动代码
我看到很多ESP-IDF的初学者把driver代码写在一个超长的main.c里,这当然能跑,但维护起来很痛苦。ESP-IDF v5.x引进了组件(component)的概念,每个组件是一个独立的文件夹,里面有自己的CMakeLists.txt和源文件。建议把ST7789的驱动封装成一个component,比如叫st7789_driver,然后你的主工程只负责业务逻辑,驱动的事情交给组件去干。
组件的目录结构大致是这样:
your_project/ ├── CMakeLists.txt ├── main/ │ ├── CMakeLists.txt │ └── main.c └── components/ └── st7789_driver/ ├── CMakeLists.txt ├── st7789.h ├── st7789.c ├── st7789_platform.c └── st7789_gfx.c其中st7789_platform.c负责和ESP32的SPI驱动打交道,st7789_gfx.c负责画点、画线、画矩形、显示字符这些图形功能。分层的好处是,如果你以后想换平台(比如换到ESP32-S3、ESP32-C3),只需要改platform这一层,图形部分不用动。这个设计思路我强烈建议你一开始就养成,后面加需求的时候就知道有多省事了。
3.3 开启SPI相关配置
在工程里需要确认menuconfig里的一些配置项:一是Component config > ESP32-specific > SPI Flash driver相关,另一个是Driver Options里的SPI master驱动选项,默认是开启的。比较重要的是堆内存大小,如果你的显示buffer比较大,而且模型不带PSRAM,那内存会非常紧张——240x240的RGB565缓冲需要115KB,而ESP32内部SRAM总共也就520KB左右,再算上系统占用,大buffer会很吃力。这正是为什么很多正式产品直接用“局部刷新”而不是全屏刷新:通过合理控制显示窗口,把buffer控制在几KB甚至几十字节,刷新哪块就发哪块的数据,省内存且效率高。
4. 核心代码实现:从初始化到动态显示
4.1 ST7789初始化序列:做什么、为什么这么做
ST7789的初始化序列网上有成套的代码,但很多人直接抄完就用,不知道每条命令在干嘛。这里挑几条关键的讲一下。
首先是最重要的软件复位:拉低RST引脚至少10ms,再拉高,再等120ms以上。这个时序不满足,屏幕可能上电后处于未知状态,后续命令全部无效。初始化里第一段命令通常是0x01(SWRESET),这是软复位,跟引脚复位二选一即可,但我习惯两个都做,确保彻底。
接下来是0x3A(COLMOD)命令,这个决定像素格式。ST7789支持12/16/18位色模式,我们最常用的就是0x55,表示16位色RGB565。这个模式一个像素占2字节,颜色编码是高位在前(big-endian)。如果你直接发RGB888的3字节数据而不改这个设置,显示出来颜色会乱。
然后是0x36(MADCTL),它控制扫描方向和颜色顺序。这个命令的值是位域组合,含有MY、MX、MV、ML、MH、RGB几个位,组合出来的效果就是屏幕的显示方向(横屏/竖屏)和镜像方向。用0x00是正常竖屏,0xA0可以旋转180度。每个模组的物理接线不一样,你需要根据实际屏幕方向试值。调试方法很简单:先显示一个纯色,再显示一个左上角为白色、其他地方为黑色的图像,如果白点跑到右上角去了,说明扫描方向需要调整。这个过程我每次换屏都要做一遍,所以建议你把这个“方向校准”做成一个独立的测试函数。
初始化序列的剩余部分就是一些panel tuning命令,比如0x21(INVON,开启反色)、0x13(NORON,正常显示模式)、0x29(DISPON,开显示)。有些模组还需要设置伽马曲线,比如0xE0和0xE1里写一串伽玛校正值,这些值在不同的模组上有差异,但大多数买到的模组在老版本寄存器上已经烧录了默认伽玛,不需要额外设置。如果遇到颜色泛白或者对比度异常,再去查模组厂家提供的初始化代码。
4.2 一个可用的初始化函数示例
下面这段代码是我在自己项目里验证过的,可以直接用。为了节省篇幅,我把关键命令和注释写清楚,你复制到自己的工程里稍微调整一下引脚就能跑。
void st7789_init(void) { // 复位引脚拉低拉高,保证模组处于确定状态 gpio_set_level(ST7789_RST_GPIO, 0); vTaskDelay(pdMS_TO_TICKS(20)); gpio_set_level(ST7789_RST_GPIO, 1); vTaskDelay(pdMS_TO_TICKS(150)); st7789_send_cmd(0x01); // SWRESET vTaskDelay(pdMS_TO_TICKS(150)); st7789_send_cmd(0x11); // SLPOUT vTaskDelay(pdMS_TO_TICKS(500)); st7789_send_cmd(0x36); // MADCTL st7789_send_data(0x00); st7789_send_cmd(0x3A); // COLMOD st7789_send_data(0x55); // RGB565 st7789_send_cmd(0xB2); // PORCTRL st7789_send_data(0x0C); st7789_send_data(0x0C); st7789_send_data(0x00); st7789_send_data(0x33); st7789_send_data(0x33); st7789_send_cmd(0xB7); // GCTRL st7789_send_data(0x35); st7789_send_cmd(0xBB); // VCOMS st7789_send_data(0x19); st7789_send_cmd(0xC0); // LCMCTRL st7789_send_data(0x2C); st7789_send_cmd(0xC2); // VDV and VRH command enable st7789_send_data(0x01); st7789_send_cmd(0xC3); // VRH set st7789_send_data(0x12); st7789_send_cmd(0xC4); // VDV set st7789_send_data(0x20); st7789_send_cmd(0xC6); // FRCTRL2 st7789_send_data(0x0F); st7789_send_cmd(0xD0); // PWCTRL1 st7789_send_data(0xA4); st7789_send_data(0xA1); st7789_send_cmd(0xE0); // 正伽玛校正 const uint8_t gamma_pos[] = {0xD0, 0x04, 0x0D, 0x11, 0x13, 0x2B, 0x3F, 0x54, 0x4C, 0x18, 0x0D, 0x0B, 0x1F, 0x23}; for (int i = 0; i < sizeof(gamma_pos); i++) { st7789_send_data(gamma_pos[i]); } st7789_send_cmd(0xE1); // 负伽玛校正 const uint8_t gamma_neg[] = {0xD0, 0x04, 0x0C, 0x11, 0x13, 0x2C, 0x3F, 0x44, 0x51, 0x2F, 0x1F, 0x1F, 0x20, 0x23}; for (int i = 0; i < sizeof(gamma_neg); i++) { st7789_send_data(gamma_neg[i]); } st7789_send_cmd(0x21); // INVON st7789_send_cmd(0x13); // NORON st7789_send_cmd(0x29); // DISPON }这里有个小细节:很多ST7789模组在例程里会写在0x3A和0x21的先后顺序,不同厂家的初始化代码里头会有差异。出现花屏或者颜色发紫时不要慌,先检查COLMOD是不是设成了0x55,再确认MADCTL里RGB位是0还是1。这个顺序问题我前后折腾过一下午,最后发现就是模组初始化代码里少了0x3A命令。
4.3 写像素:设置窗口与打点
ST7789最核心的操作是设置显示窗口。窗口是指你接下来要往GRAM哪个矩形区域写入数据。设置窗口用0x2A(CASET,列地址)和0x2B(RASET,行地址),然后用0x2C连续写数据。好处是:如果我要刷新屏幕左上角一个50x50的区域来更新传感器数字,就不需要全屏刷新,只往这个窗口灌数据就行。
例如要在(x0, y0)到(x1, y1)这个矩形区域写入数据:
void st7789_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { st7789_send_cmd(0x2A); // CASET st7789_send_data(x0 >> 8); st7789_send_data(x0 & 0xFF); st7789_send_data(x1 >> 8); st7789_send_data(x1 & 0xFF); st7789_send_cmd(0x2B); // RASET st7789_send_data(y0 >> 8); st7789_send_data(y0 & 0xFF); st7789_send_data(y1 >> 8); st7789_send_data(y1 & 0xFF); st7789_send_cmd(0x2C); // RAMWR }设置完窗口后,再用SPI连续发送像素数据。这里要注意,0x2C命令发出后,数据是按线性填充的,一行行从左到右、从上到下。如果你发送的数据长度超过了窗口面积大小,多的数据会被丢弃;如果长度不够,剩余区域就保持原样。所以如果你要实现“模糊刷新”或者“局部高亮”这些小特效,本质上就是精确计算窗口大小和发送数据的长度。
最基础的画点函数长这样:
void st7789_draw_pixel(uint16_t x, uint16_t y, uint16_t color) { if (x >= ST7789_WIDTH || y >= ST7789_HEIGHT) return; st7789_set_window(x, y, x, y); uint16_t pixel = __builtin_bswap16(color); // 转成大端 st7789_send_data16(&pixel, 1); }__builtin_bswap16这个函数作用是把RGB565的字节序交换成ST7789期望的大端格式。如果你不交换,你会发现红色和蓝色对调了。这个字节序问题我在第一次调屏幕时也踩过,这里提前帮你们排掉。
4.4 使用DMA加速刷新
演进到动态显示,性能就是第一优先级。如果不使用DMA,刷新一帧240x240 RGB565的图像需要CPU持续占用SPI发送115KB数据,按10MHz时钟算大约需要0.09秒,视觉上确实会感到卡顿。而开了DMA之后,虽然总时间差不多,但CPU被释放掉了,可以做别的计算。
ESP-IDF里发数据有两种常用API:
// 轮询方式,发送完才返回,适合小批量和低延迟场景 esp_err_t spi_device_polling_transmit(spi_device_handle_t handle, spi_transaction_t *trans);// 队列方式,发送完触发回调,适合大块数据和DMA场景 esp_err_t spi_device_queue_trans(spi_device_handle_t handle, spi_transaction_t *trans, TickType_t ticks_to_wait);spi_device_queue_trans搭配spi_device_get_trans_result使用,能实现双缓冲或者环形缓冲。我用DMA刷新一整屏的实测结果是:40MHz时钟下,一帧240x240大概需要6~8ms,CPU占用率几乎可以忽略。这里有个性能对比可以参考:
| 刷新方式 | 240x240全屏刷新耗时 | CPU占用 | 适用场景 |
|---|---|---|---|
| 轮询发送 | ~60ms | 100% | 静态图片显示 |
| DMA单缓冲 | ~30ms | 中 | 简单动画 |
| DMA双缓冲 | ~20ms | 低 | 滚动、视频播放 |
双缓冲的思路是:在后台DMA发送当前帧时,CPU同时往另一块buffer里绘制下一帧,等发送完成回调把两块buffer交换。这个玩法在日常动态显示上收益很明显,特别是你要同时做主控逻辑和UI刷新时。
4.5 动态内容显示:文字、图形和滚动
搞定了基础打点,动态内容就是“怎么组织画面”的问题。
文字显示的基础是字模。最简单的方式是把字模直接以C数组的形式存在Flash里,比如16x16的汉字,每个字占32字节。取模工具有很多,我用的是PCtoLCD2002,也可以用Image2Lcd,选择“阴码、逐行式、顺向”这种模式生成数组。然后把字模数据通过st7789_draw_pixel逐行画上去就行。当然这样光标效率比较低,更高效的做法是先把字模更新到局部buffer,再一次SPI发出去。
滚动文字的逻辑也不难:维护一个字符串的起始x坐标,每次刷新前把坐标偏移几个像素,然后用背景色把旧区域清掉,再在新位置画上新文字。这个思路跟游戏里的“卷轴”一样。但要注意的是局部刷新时窗口计算不能出错,否则清不干净,出现“鬼影”。
图形方面,画线算法可以简单用Bresenham,画矩形就填充窗口后灌色块,画圆用中点画圆法。这些基础的图形学算法在网上都能搜到,但如果你嫌麻烦,可以直接用ESP-IDF组件仓库里现成的esp_lcd库,新版的IDF已经带了esp_lcd_panel_io和esp_lcd_panel_st7789组件,官方支持ST7789,直接用官方组件初始化面板,然后自己画图形就行。我测试下来官方的esp_lcd组件在ESP32-S3上表现很好,支持RGB565和RGB888切换,还支持双缓冲,强烈推荐你先看官方的esp_lcd_st7789例程再决定自己造轮子还是用官方库。
我的观点是:如果只是简单显示文字和数字,自己写个轻量驱动更灵活;如果要跑复杂的GUI,比如LVGL,那直接用官方esp_lcd组件对接LVGL更省事。这两种方案各有场景,后续要不要演进到LVGL,取决于你的界面复杂度。
5. 动手实操:一个温湿度显示器的完整样例
5.1 功能规划与页面布局
为了把上面的原理落到实际代码,我设计了一个简单的温湿度显示项目:ESP32接一个DHT22传感器,把采集到的温湿度显示在ST7789屏幕上,每秒刷新一次,同时屏幕顶部显示一个动态变化的进度条,用来直观展示湿度百分比。这样的任务涵盖了传感器读取、数据处理、局部刷新、动态元素绘制几个关键点,比起单纯刷满屏颜色要实用得多。
页面布局大致是这样:
- 顶部:标题文字“Env Monitor”
- 中间:温度值,用大号数字字体
- 中间偏下:湿度值,同样大号显示
- 底部:湿度的水平进度条,每秒平滑更新一次
这种布局的好处是大多数刷新过程都集中在局部区域,主循环同时能继续做传感器读取。
5.2 局部刷新与动态进度条实现
进度条的思路比较简单:先定义好进度条的背景区域,用灰色填充,然后根据湿度百分比计算出绿色条占据的宽度,再把这个矩形区域填充成绿色。每次刷新时,先把上一次绿色条的区域恢复成背景色,再画出新的绿色条,这样就避免了全屏刷新。
关键代码如下:
#define BAR_X0 20 #define BAR_X1 220 #define BAR_Y0 160 #define BAR_Y1 180 static uint8_t last_humidity = 255; void draw_humidity_bar(uint8_t humidity) { if (humidity == last_humidity) return; // 恢复背景 fill_rect(BAR_X0, BAR_Y0, BAR_X1, BAR_Y1, COLOR_BG_GRAY); // 计算目标宽度 uint16_t bar_width = (uint16_t)((BAR_X1 - BAR_X0) * humidity / 100); if (bar_width > (BAR_X1 - BAR_X0)) { bar_width = BAR_X1 - BAR_X0; } // 画绿色进度条 fill_rect(BAR_X0, BAR_Y0, BAR_X0 + bar_width, BAR_Y1, COLOR_GREEN); last_humidity = humidity; }这个函数的局部刷新窗口大小是200x20像素,每次只需要传约8000字节的数据,在40MHz SPI下1ms内能传输完成,视觉上非常流畅。这与全屏刷新115KB相比,开销小了两个数量级。
5.3 数字字体和大数字显示
大号数字字模我直接从网上下载了一个16x32的数码管风格字模,用取模工具转成C数组。显示函数就是遍历字模数组,为1的位置画前景色,为0的位置画背景色。为了防止刷新时出现残影,画数字前先用背景色在数字区域内填一个矩形,再画新的数字。数字更新频率每秒一次,实际测试没有肉眼可见的闪烁。
要注意的是,这种按像素画字模的方式,在刷新大量文字时效率会很低,适合简单UI。如果你要显示多行文本、中英文混排、抗锯齿文字,那就得上字库芯片(比如中景园的字库模块)或者直接上LVGL。LVGL内建的字体渲染可以做到抗锯齿,效果完全不是一个档次,但内存消耗也翻倍,需要权衡。
5.4 完整主循环与处理逻辑
主循环非常简单:
void app_main(void) { st7789_init(); gfx_init(); dht22_init(); while (1) { float temp, humi; if (dht22_read(&temp, &humi) == ESP_OK) { draw_big_number(60, 40, (int)temp, COLOR_WHITE, COLOR_BLACK); draw_humidity_bar((uint8_t)humi); // 可以在这里更新字符串形式的完整信息 char buf[48]; snprintf(buf, sizeof(buf), "Humidity: %.1f%%", humi); draw_text(20, 200, buf, COLOR_YELLOW, COLOR_BLACK); } vTaskDelay(pdMS_TO_TICKS(1000)); } }这里我加了1秒的延时,避免DHT22采样太频繁。如果你的传感器读取本身也耗时,注意不要让屏幕刷新和传感器读取相互阻塞。如果传感器读取跑到50ms开外,建议把读传感器放在独立任务里,通过队列把结果发给显示任务。两个任务优先级不同,显示任务优先级可以高一些,保证刷新不掉帧。
5.5 实测结果与调优记录
整套系统跑起来后的实测结果:一是温度显示在左上角,湿度进度条在底部;二是每秒刷新一次,温湿度变化时进度条平滑增长或回退;三是CPU占用主要来自DHT22的时序等待,如果把DHT22换成I2C接口的SHT30,CPU占用会更低;四是系统功耗肉眼可见主要消耗在背光上,如果做低功耗产品,建议用PWM把背光亮度调低,或者增加一个环境光传感器自动调节背光。
关于背光,还有一个值得尝试的优化:当屏幕长时间没有内容变化时,除了关背光,还可以给屏幕发一个0x28(DIPSOFF,关闭显示)命令。关显示不等于关背光,它的作用是让ST7789停止把GRAM数据刷新到面板上,但是GRAM里的数据还在。需要更新时直接更新完再发0x29开显示,这样可以省一点电,同时避免刷新过程中背光开着导致的残影。
6. 常见问题与排查技巧实录
6.1 CLion 2023中找不到ESP-IDF插件
先说这个被问最多的问题。CLion里的ESP-IDF插件如果Marketplace里搜不到,大概率不是网络问题,而是插件仓库里该插件的发布发生了变动。我可以提供三种解决思路。第一种是在CLion的Settings -> Plugins里点右上角齿轮,选择Manage Plugin Repositories,把官方全部仓库地址加上,然后刷新。第二种是直接去JetBrains插件市场网页下载插件的zip包,然后本地安装。第三种是干脆不用插件:CLion本身有OpenProject能力,你只要把ESP-IDF工程目录当CMake工程打开,再配置好工具链路径,在CMakeLists.txt里把idf.py的构建命令对接成自定义target,不用插件也能编译烧录。我个人目前在用第三种方式,稳定且不受插件市场波动影响。
6.2 屏幕白屏、不亮、显示错乱排查表
这里我整理了一份排查清单,建议按顺序检查:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全不显示 | 背光引脚未拉高 | 用万用表测BLK电压,确认是否为高电平 |
| 完全不显示 | RST复位时序不对 | 用逻辑分析仪抓RST引脚波形,确认低电平保持时间超过10ms |
| 显示全白 | 初始化序列不完整 | 确认DISPON(0x29)命令是否已发出 |
| 显示花屏 | SPI时钟频率过高 | 把时钟降到10MHz以下再试,确认是信号质量问题 |
| 颜色偏蓝/偏红 | RGB和BGR位设置错误 | 调MADCTL里的RGB位,0和1互换测试 |
| 显示方向错误 | MADCTL的扫描方向位不对 | 逐位调整MY、MX、MV,找到正确方向 |
| 只有上半屏正常 | 窗口设置时列地址或行地址超出范围 | 检查set_window函数传入的坐标,确认没有超过屏幕分辨率 |
| 刷新出现残影 | 未关闭显示直接写GRAM | 刷屏前先发0x28关显示,写完再发0x29 |
| 动态内容卡顿 | 未启用DMA | 把spi_device_polling_transmit换成spi_device_queue_trans |
| 动态内容撕裂 | 刷新window和发送数据不同步 | 确保set_window和RAMWR数据发送在同一个spi_device_acquire_bus/spi_device_release_bus保护区内 |
6.3 字节序和颜色编码问题
ST7789的数据线时钟上升沿采数据的模式下,颜色编码的字节序是大端还是小端?答案是:ST7789默认按大端接收,也就是先收到的高8位对应RGB565的高字节。如果你直接给一个uint16_t类型变量(小端模式),发送时低字节会先发出去,结果就是颜色错乱。解决办法除了前面提到的__builtin_bswap16,还可以在定义颜色常量时直接用宏:
#define RGB565(r, g, b) \ ((uint16_t)(((r) & 0xF8) << 8) | \ ((uint16_t)(((g) & 0xFC) << 3)) | \ ((uint16_t)(((b) & 0xF8) >> 3)))这个宏生成的就是已经符合ST7789期望的数值,但发送时仍然需要按大端发送。我习惯在st7789_send_data16函数里统一做字节序转换,这样调用方只需要传原始颜色值。
6.4 动态显示中的内存管理经验
动态显示最大的敌人是内存碎片和一次性全屏buffer占用过大部分SRAM。如果你既想保留全屏buffer做双缓冲,又不想内存爆炸,我建议你看看ESP32的PSRAM(外部伪静态随机存储器)。带PSRAM的模组(比如ESP32-WROVER系列)可以把全屏buffer放在PSRAM里,代价是PSRAM的带宽比内部SRAM低,但大多数应用根本到不了瓶颈。在使用时要注意:通过DMA发送的数据源buffer必须放在内部SRAM,所以从PSRAM取图像数据后,需要先拷到内部小buffer再发。这个拷贝动作在40MHz的SPI下不是瓶颈,实测240x240的图片拷贝大概2~3ms,可以接受。
6.5 新手最容易忽略的几个坑
下面这几点是我带过好几个同事后总结出来的高频翻车点,值得反复看。第一个是DC引脚必须配合命令/数据切换,切换时机必须精确:在发送命令字节时DC必须为低,在发送数据时DC必须为高。用简单GPIO控制时,必须在一次spi_device_polling_transmit事务内先拉低DC再发命令字节,之后再拉高DC发数据字节。如果DC切换和SPI发送之间插入过长延时,某些模组会反应不过来。
第二个是CS片选电平极性,ST7789要求低有效,但有些模组的CS引脚上拉了,你如果初始化把它配成高有效,片子永远不干活。用逻辑分析仪看CS波形是最直接的排查手段。
第三个是初始化时序中延时的问题。有些命令发出后需要等待,比如0x11(SLPOUT)之后要等150ms,0x20和0x28之后也需要等待。如果延时不够,显示效果会随机性闪烁。这个时间在ST7789的数据手册里有明确要求,别为了追“启动快”就压缩延时。
第四个是温度和环境光的干扰。如果你把屏幕放在不规则光照环境下调试,颜色偏色和亮度差异会被误判为驱动问题。遇到“颜色不对”先排除环境因素,再排查软件。
7. 后续扩展:从裸驱动到LVGL
如果你的动态显示需求从“刷个数字”升级到“多页面UI、按钮交互、滑动手势”,那裸写驱动的方案就有点吃力了。我对LVGL的接入也是走过一轮完整流程的,简单提几个关键点。
第一是LVGL的底层对接:LVGL不需要你直接操作ST7789的API,它经过lv_disp_drv_t注册一个flush_cb回调函数,你在这个回调里把LVGL给出的buffer发送到屏幕即可。实际上,LVGL通过disp_drv调用你的flush函数,传入一块有效的像素数据buffer,你需要做的就是把这块buffer通过SPI发出去,然后调用lv_disp_flush_ready通知LVGL发送完成。
第二是buffer大小规划,LVGL最低要求一行像素的buffer,比如240x1x2=480字节即可开启完整功能。但为了性能和流畅度,我建议至少开20行及以上,也就是240x20x2约9.6KB。如果你有PSRAM,可以开到全屏240x240x2约115KB,体验会非常丝滑。
第三是帧率协调:LVGL内部有一个lv_timer_handler需要周期性调用,通常放在独立任务里,频率和屏幕刷新匹配。加上freeRTOS后,需要把所有相关操作放在GUI任务中执行,避免多任务并发操作同一个SPI设备导致数据错乱。
这些扩展内容可以单独写好几篇文章,这里先点到为止。你们如果有特定的需求,我再根据反馈展开。
从裸驱动到LVGL这个跨度,选哪条路取决于你的产品需求。我的建议是:前期原型验证用裸驱动,逻辑简单可控;正式产品界面复杂度上来了,直接切LVGL,开发效率和UI效果都更好。两条路之间切换的成本其实不高,因为底层SPI和初始化代码是可以复用的。