在物联网设备上渲染专业屏幕





5.00/5 (10投票s)
如何将您的物联网图形界面从 90 年代中期提升到现代水平。
引言
市面上大多数业余物联网小工具上的屏幕看起来都特别过时。它们有像素块状的位图字体,明显缺乏图像,而且通常颜色很少,或者颜色太多而不协调。其中一些是设计不佳,但大多数是由于现有图形库的限制 - GFX 的部分设计就是为了解决这些限制。
我们将涵盖一些不限于 GFX 甚至不限于图形本身的内容。专业的应用程序通常是非平凡的,而 ESP32 WROVER - 尽管对于物联网 MCU 来说功能强大 - 但在大局观上并非顶尖的性能。它只是一个小小的东西,而这些应用程序往往会挑战其能力的极限。这有时意味着需要发挥创意,我们在本文中将介绍其中一些内容。
运行这个项目
你需要安装 VS Code 和 Platform IO。
你可能需要一个 ESP32 WROVER。我尝试寻找具有可比功能的其他 MCU,但到目前为止还没有找到。你也许可以使用像 RTL8720DN BW16-Kit 这样的设备,外接一个 PSRAM 芯片连接到 SPI 总线上,但它会比 WROVER 慢,而且据我的经验,它们更难找到。我有一个从亚洲发货的,截至本文撰写时,大约需要 3 周才能到货。我还没有在上面测试过 GFX,但理论上,它可以在 ESP32 以外的设备上运行。只是其他 MCU 通常没有必要的 RAM 和 CPU 功率,尤其是 ESP32 WROVER。你可以勉强在 WROOM 上运行,但没有额外的 RAM,效果可能会有很大差异。它可能比 WROVER 慢很多,尤其是在绘制文本时。
你需要一个兼容的显示器。我喜欢连接到 800x480 屏幕的 RA8875,因为它的大小和分辨率,以及它带有触摸功能,但你几乎可以使用任何设备,甚至是电子纸显示器,尽管使用电子纸显示器通常是单色或最多只有几种颜色。演示适用于 ILI9341 或 RA8875,但你也可以使用其他驱动程序。在 GFX 演示 中包含了许多驱动程序。
最后,你需要 GFX 和相应的驱动程序。文章顶部的主项目链接包含了 GFX 和两个驱动程序,而我刚才提供的 GFX 演示链接则包含更多驱动程序。
我们将使用 Arduino 框架来探索这些技术,尽管其中大部分内容可以直接移植到 ESP-IDF。
注意:在尝试使用之前,不要忘记上传文件系统映像。必须在 SPIFFS 分区上提供资源,否则将无法正常工作。
创建你的环境
假设你使用的是 WROVER,你的 platformio.ini 文件应该看起来像这样
[env:Default]
platform = espressif32
board = node32s
board_build.partitions = no_ota.csv
framework = arduino
upload_speed = 921600
monitor_speed = 115200
build_unflags = -std=gnu++11
build_flags = -std=gnu++14
-DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue
我发现 board = node32s 行适用于所有 ESP32 变体,而 no_ota.csv(如下文所示)会禁用空中下载更新分区,为你提供更多的 SPIFFS 和程序空间。构建标志是为了让 GFX 能够正常构建,并让框架意识到 WROVER 上额外的 PSRAM。
这是 no_ota.csv 文件
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
app0, app, ota_0, 0x10000, 0x200000,
storage, data, spiffs, 0x210000,0x1F0000,
你可以根据你的具体需求调整分区大小,但这会给你更多的空间。
使用 GFX
你需要快速掌握 GFX 的使用,并理解诸如绘制源和绘制目标、使用 rect16
和 srect16
、使用 bitmap<>
模板等基础知识。你可以在演示代码中找到许多示例,以及 本文 进行一般性讲解。我不会在这里介绍基本原理。
选择“主题”和资源
使用 GFX,你可以使用 True Type/Open Type 字体、JPEG 和完整的 X11 颜色调色板(或你自己定义的颜色)来构建屏幕。
因此,你需要掌握整体配色方案、任何突出的徽标或图标以及你打算使用的字体集。
不要使用操作系统自带的字体等资源,因为在许多情况下,这些字体受版权保护,并且在使用你的项目时受到限制。我在 Font Squirrel 上找到了不错的字体。
不要在这些资源上花费过多精力,因为你的空间有限,你必须仔细选择显示什么以及何时显示,以免拖累你的设备。
通常,你希望有一种极简的外观,尤其是文本不经常动画(除了每秒一次的几个字段,或者每秒十次的某个字段)。
文本非常耗资源,既耗费 RAM 和闪存空间,也耗费 CPU。这就是我特意在上面提及它的原因。如果你小心谨慎,并且精心设计你的屏幕,你可以用 ESP32 处理大多数现实场景,但你必须在设计时就考虑周全。你必须节约使用文本,以及减少绘制文本的频率。
由于内存限制,JPEG 会逐行加载,所以较大的 JPEG 如果直接加载到显示设备,会明显地从左到右、从上到下加载。因此,最好使用较小的 JPEG。你不能用 GFX 调整 JPEG 的大小,所以你必须将其存储为你希望显示的大小,尽管 GFX 可以裁剪。
计时你的代码
你几乎肯定需要对代码的某些部分进行计时。如果你使用 delay()
,你就会影响应用程序的响应能力。相反,请考虑使用以下技术
首先,为你想要计时的事情声明一个(通常是全局的)uint32_t
时间戳。
其次,在一个较大的循环中,你试图计时其中的内容(无论是 loop()
函数(虽然你不应该使用它,但我们稍后会讲到),还是其他什么),你会做如下的事情
// does work once every second
if(millis() - timestamp >= 1000) {
timestamp = millis();
// do work
}
而不是
// do work
delay(1000);
你之所以这样做,是因为 delay()
会暂停当前的执行线程,而你的 CPU 可以利用这段时间做有用的工作,而不是仅仅等待。我们将在许多事情中需要保持一个主要的循环处于活动状态并运行,在其中插入 delay()
会导致你的应用程序卡顿。
堆栈怪物
实际上,ESP32 提供的堆栈空间并不充裕。你可以配置它,但在开箱即用的情况下,你必须非常小心本地变量的数量和大小,尤其是数组和结构体。
我知道大家都不喜欢全局变量,但在物联网代码中,不要 这样。全局变量在这里很有用。你的应用程序永远不会大到你无法处理全局变量污染的地步。
一个特别出名地占用堆栈空间的是 Arduino 的 File
对象。这个东西太大了!我所做的是,在全局声明一个,然后在每个函数中需要时使用它,每次需要打开文件时都重新使用它。我几乎从不需要同时打开一个文件以上,所以这样就可以了,但如果我需要,我就会声明两个全局文件对象。你明白我的意思。
不要使用 loop() 函数!
就是不要用。问题在于,在 ESP32 的 Arduino 框架实现中,会创建一个第二个 FreeRTOS “任务”(可以理解为线程),它具有极小的堆栈大小,而 loop()
在该线程上运行,因此在 loop()
中执行任何非平凡的操作都可能会耗尽堆栈并导致重启。
取而代之的是,在 setup()
中启动一个 while()
循环,并在其中进行操作。如果有人能告诉我一个不这样做的理由,请告诉我。否则,我将继续建议这样做,以便访问主线程更大的堆栈空间。
分派而非嵌套
在响应用户按下按钮或触摸屏幕时,从一个屏幕调用渲染代码来切换到另一个屏幕可能会很诱人。不要这样做。堆栈怪物最终会吞噬你的代码。
例如,不要这样做
static void screen_first_screen() {...
// other code here...
// render the next screen
screen_second_screen();
}
原因是堆栈负担。你将不得不将两个例程的局部变量都保留在堆栈上,而不是一次只保留一个。
取而代之的是,创建一个全局变量来保存你当前的屏幕和旧的屏幕。然后在你的主应用程序循环中 - 即你用来替代 loop()
的循环 - 你将使用这些变量来决定何时绘制下一个屏幕。它看起来有点像这样
enum struct screens {
initializing = 0,
start,
calibration1,
calibration2,
settings,
session1,
session2,
session3,
summary
};
// set both to "initializing" on startup
static screens screen_current;
static screens screen_old;
在你的应用程序的主循环中,你会有类似这样的内容
if(screen_current!=screen_old) {
screen_old = screen_current;
switch(screen_current) {
case screens::initializing:
screen_current=screens::start;
break;
case screens::start:
screen_start();
break;
case screens::settings:
screen_settings();
break;
case screens::calibration1:
screen_calibration1();
break;
case screens::calibration2:
screen_calibration2();
break;
case screens::session1:
screen_session1();
break;
case screens::session2:
screen_session2();
break;
case screens::session3:
screen_session3();
break;
case screens::summary:
screen_summary();
break;
default:
break;
}
}
上面调用的每个例程都将渲染给定的屏幕。
任何时候你想切换屏幕,你只需将 screen_current
设置为你想要的新屏幕,然后退出当前子程序。这个变化将在你的应用程序主循环的下一次迭代中被检测到。
如果这看起来不必要的复杂,那是因为它不是。它比其他方式保持了更浅的堆栈,因为你没有从一个屏幕连续调用到下一个屏幕。对于大多数实际应用程序来说,这一点至关重要。
充分利用你的内存
实际上,在 ESP32 启动时,你大约有 300kB 可用的 SRAM。WROVER 还提供了 4MB 的 PSRAM,但有一些限制 - 有时甚至更多,但你无法在 Arduino 中使用,它将你限制在 PSRAM 的前 4MB。
使用 PSRAM 还是不使用 PSRAM?
主要考虑因素是为了使用 PSRAM,它必须被启用为 #define
,你通常通过 platformio.ini 中的 build_flags
设置来使用 -D 编译器选项。
-DBOARD_HAS_PSRAM
-mfix-esp32-psram-cache-issue
第一个选项建立了那个定义。第二个选项修复了 ESP32 上 PSRAM 的一个问题,当定义了 BOARD_HAS_PSRAM
时,必须始终包含它。
你还应该使用 ps_malloc()
而不是 malloc()
来进行分配,尽管你仍然可以 free()
这些指针。
大多数时候,当你需要堆时,你会想使用 PSRAM。由于其相对较大的尺寸,它不太容易受到毁灭性碎片化的影响,而 SRAM 可以容纳诸如缓存代码和堆栈之类的东西,而 PSRAM 不能,因此 SRAM 非常宝贵。
在从位图到显示设备使用 GFX 进行 DMA 传输时,不要使用 PSRAM。如果你直接使用 draw::bitmap_async<>()
到显示器,它假定内存是 DMA 兼容的。PSRAM 不是。
在 Arduino 下,我通常会将位图分配到 PSRAM。在 ESP-IDF 下,这取决于该位图将写入何处 - 如果将写入显示设备,则放在 SRAM 中。
将资源预加载到 PSRAM
这是让 WROVER 比 WROOM 获得速度优势的主要方式。本质上,你想做的是获取你的资源,如 JPG 和字体 - 特别是 True Type 和 Open Type 字体 - 将整个文件流加载到 PSRAM,然后从中工作。
对于 open_font
,看起来是这样的
file = SPIFFS.open("/Libre.ttf","rb");
file_stream fs(file);
main_font_buffer_size = (size_t)fs.seek(0,seek_origin::end)+1;
fs.seek(0,seek_origin::start);
main_font_buffer = (uint8_t*)ps_malloc(main_font_buffer_size);
main_font_buffer_size = fs.read(main_font_buffer,main_font_buffer_size);
fs.close();
上面,main_font_buffer
和 main_font_buffer_size
是全局变量。
任何时候你需要使用该字体,都可以从缓冲区中创建它
open_font main_font;
const_buffer_stream cbs(
main_font_buffer,
main_font_buffer_size);
open_font::open(&cbs, &main_font);
这将使使用该字体绘制文本的速度比直接从 SPIFFS 加载字体(通常情况)快几个数量级。如果不这样做,在这些设备上使用 True Type 实际上是不现实的,尽管你可以通过将 TTF 或 OTF 直接嵌入到你的代码中作为 C++ 头文件来勉强实现,这会更快,但仍不如 PSRAM。这使得 WROOM 在 GFX 的矢量字体功能方面有些不切实际,也是本文假定使用 WROVER 的主要原因。
你也会从 JPG 加载中获得一些好处,但远不如从字体加载,因为解压缩仍然占用了加载时间的重要部分。实际上,我通常不会预加载我的 JPG,但如果它们足够小,我会将它们预加载到位图中。我们稍后会讲到,别担心。
预渲染到位图
我所说的预渲染到位图是指创建位图,在位图上绘图,然后将位图本身写入屏幕。
这是创建专业显示器的关键部分,因为它有助于防止显示闪烁。你实际上是在对你打算绘制的显示器的一部分进行双缓冲。
在写入文本时,这一点就变得更加重要,以便文本可以被抗锯齿。与位图字体不同,矢量字体被“平滑”处理,以消除曲线和对角线边缘的锯齿状外观。但是,为了实现这一点,GFX 需要能够alpha 混合。这要求目标能够被读取 - 即,充当绘制源以及绘制目标。大多数 GFX 的显示驱动程序无法做到这一点,这意味着如果你直接将文本写入显示器,它将不会被“平滑”处理,并且在边缘会出现锯齿状。位图既可以写入也可以读取,并且可以有效地进行 alpha 混合,因此它们是矢量字体的理想目标。
此外,即使是从 RAM 绘制,矢量字体也需要足够长的时间来绘制,因此最好在屏幕外绘制。
要做到这一点,你需要测量文本,分配足够的内存,创建位图,用适当的背景色填充它,然后在此位图上绘制文本。
一旦你有了位图,就可以很容易地将其 draw::bitmap<>()
到显示器本身。
我通常会创建几个函数来完成这项工作
static void font_draw(
const char* text,
float scale,
ssize16 textsz,
point16 location,
const open_font& fnt,
typename tft_type::pixel_type color,
typename tft_type::pixel_type backcolor) {
srect16 r = textsz.bounds();
r=r.normalize();
using bmp_type = bitmap<typename tft_type::pixel_type>;
uint8_t* buf = (uint8_t*)ps_malloc(
bmp_type::sizeof_buffer((size16)r.dimensions()));
if(buf==nullptr) return;
bmp_type bmp((size16)textsz,buf);
bmp.fill(bmp.bounds(),backcolor);
draw::text(bmp,
(srect16){0,0,1000,1000},
{0,0},
text,
fnt,
scale,
color);
draw::bitmap(tft,
(srect16)bmp.bounds().offset(location.x,location.y),
bmp,
bmp.bounds());
free(buf);
}
static void font_draw(
const char* text,
float scale,
point16 location,
const open_font& fnt,
typename tft_type::pixel_type color,
typename tft_type::pixel_type backcolor) {
ssize16 sz = fnt.measure_text({1000,1000},{0,0},text,scale);
font_draw(text,scale,sz,location,fnt,color,backcolor);
}
第一个函数和第二个函数之间的区别在于,第一个函数接受一个预先测量的 ssize16
,它表示文本的尺寸,由 measure_text()
提供。这样,你就可以测量一次,然后重复绘制而无需重新测量,或者预先测量以便进行右对齐文本等操作。
充分利用你的 CPU
小巧的 ESP32 WROVER 是一个双核设备,每个核心通常运行在 240MHz。虽然副核心主要用于运行网络通信,但该核心还有大量的剩余性能可以运行你自己的代码。要使用这个核心,你需要创建至少一个额外的线程。
此外,你最终会遇到处理回调的情况,这些回调源于非主线程。
由于这些情况,一点线程基础设施可以极大地帮助你的应用程序,特别是如果它能控制多线程的复杂性。
FreeRTOS 线程包
ESP32 运行 FreeRTOS,这是它管理线程的方式。不久前,我写了一个小头文件,它在 FreeRTOS 下提供了几个有用的线程构造,包括线程池和同步管理器。
同步来自其他线程的回调
简单地从源线程处理回调通常是不安全的,因为这通常不是主应用程序线程。因此,从副线程读取和写入主线程上的数据可能导致竞态条件。为了解决这个问题,我们将使用一个 FRSynchronizationContext
。它从在全局范围内声明一个开始
FRSynchronizationContext sync_ctx;
为了使其正常工作,你需要在你的主应用程序循环中添加一行,以及在你应用程序中任何你想让回调继续触发的循环中
sync_ctx.processOne();
如果你不在循环中调用它,那么只要该循环正在运行,回调就不会被触发。嗯,严格来说它们会被触发,但直到下次你调用上述方法时,你才会被通知它们已被调用。
现在,在你的回调中,你执行你的工作,但你是在一个 lambda 函数中执行的
sync_ctx.post([](void* state) {
// Do work here. Executes on main thread
}, my_state_to_pass);
该 lambda 中的任何内容都将在调用 sync_ctx.processOne()
的线程上安全地执行,并在该目标堆栈上运行。由于最后一点,这也是从没有大堆栈的副线程执行堆栈密集型函数的好方法,尽管实际上你是从主线程上执行代码,但这是响应副线程的触发。
在副核心上执行
我不建议在这些应用程序中使用线程,如果可以避免的话。首先,它增加了复杂性并使调试变得困难。其次,它引入了额外的开销,可以通过使用协作式时间分片而不是抢占式时间分片来避免。
尽管如此,为了利用副核心,你必须使用副线程。FreeRTOS 线程包包含了一种轻松地将函数排队以在第二个核心上执行的方法。
在线程包中,FRThreadPool
是一个极其轻量级的线程池,可以让你轻松地将代码分派到等待的线程上执行。你要做的是,在打算使用它的同一个函数中创建一个线程池,向其中添加一个在第二个核心上运行的线程,然后继续在池中执行代码。它的工作原理如下
FRThreadPool pool;
// 4000 allocates about 16k of stack space.
// 1 - xPortGetCoreID() is a way of saying "the core that isn't this one"
pool.createThreadAffinity(
1 - xPortGetCoreID(),
1,
4000);
现在,我们使用 queueUserWorkItem()
将代码分派到池中运行
pool.queueUserWorkItem([](void*state) {
// Do work to execute on 2nd core here
}, my_state_to_pass);
它将在方法退出时被清理。
记住,你可以使用之前的 sync_ctx
来同步从第二个核心对共享数据的任何访问。
使用 TFT_eSPI
gfx_demo 现在为 Bodmer 的 TFT_eSPI 库提供了绑定 - 这可能是 Arduino 上最快的图形驱动程序 - 可以用来提高你的帧率并支持额外的显示器。使用 TFT_eSPI 可以支持更多的显示器,在某些情况下还可以提高帧率。设置有点复杂,在这种情况下需要从 GitHub 下载并安装库,而不是使用 PlatformIO 的自动库获取功能,然后你需要编辑一个头文件来分配你的引脚号并选择你的实际显示硬件。它也不能用于驱动多个显示器,但如果你需要这样做,可以与 gfx_demo 的原生驱动程序一起使用。如果你想使用 TFT_eSPI,请按以下步骤操作。
概括和进一步解释
- 从 github 下载 TFT_eSPI 源代码
- 将其复制到你的 PlatformIO 项目下的“lib”文件夹中
- 编辑 User_Setup.h 来分配你的引脚并选择你的硬件。
- 将 gfx_tft_espi.hpp 从 gfx_demo/src/arduino/drivers 复制到你的项目的“src”文件夹中。
在编辑完 User_Setup.h 来选择你的引脚和硬件后,你还需要包含 gfx_cpp14.hpp ,然后是 gfx_tft_espi.hpp。最后,你将实例化 TFT_eSPI,然后实例化 gfx_tft_espi,并将其绑定到 TFT_eSPI。
#include <User_Setup.h>
#include <TFT_eSPI.h>
#include <gfx_cpp14.hpp>
#include "gfx_tft_espi.hpp"
...
using namespace gfx;
using namespace arduino;
using tft_type = gfx_tft_espi<true>;
using tft_color = color<typename tft_type::pixel_type>;
// create the TFT_eSPI driver so we wrap it.
TFT_eSPI tft_espi = TFT_eSPI();
tft_type tft(tft_espi);
然后,你可以使用 GFX 作为 tft
绘制目标,你还可以使用 tft_espi
全局变量来直接驱动 TFT_eSPI。如果需要,你可以同时使用两者。
你会注意到我们传递了 true
作为 gfx_tft_espi
的单一模板参数。这启用了从显示器读取,允许显示器用作绘制源,并对其进行 alpha 混合。并非所有硬件都支持这一点,因此默认情况下它是关闭的。
第二个参数(此处未使用)表示平台支持异步 DMA 传输。TFT_eSPI 必须支持你的平台和硬件,因为 Arduino 框架默认不支持。如果它不支持而你试图强制使用,你会在 TFT_eSPI 中遇到链接错误。Async
参数的默认值取决于你的平台,所以通常你不需要自己设置它。应该注意的是,有一个关于 TFT_eSPI 的 DMA 代码接口的开放性问题在此,因此目前 gfx_tft_espi
中的异步支持是禁用的。不过,它只是被屏蔽掉了,一旦 Bodmer 更新了他的代码就可以启用。因此,我将假设支持异步,并使用 XXXX_async()
调用进行绘制,因为它们会恢复为同步调用,但在问题解决后将变为异步。
屏幕演示
包含的屏幕演示是一个小例子,演示了文章中的大多数技术。只有一个屏幕是为了保持简单,但我仍然将分派代码放进去了,这样你就可以添加屏幕了。没有屏幕的主要原因是,我不想强迫你连接一个屏幕更改的输入方式。
在这个演示中,我们显示了一些文本和一张 JPG 图片,在欢迎屏幕上,我们启动了一个 Web 服务器,当你用浏览器访问它时,它会在显示器上短暂显示请求的 URL。
Web 服务器使用了 me-no-dev 的 ESPAsyncWebServer。我直接将其包含在项目中,因为 PlatformIO 在通过存储库包含它时会遇到问题。它在收到 HTTP 请求时会触发回调。在这些回调中,我们使用 sync_ctx
来更新显示器,因为从副线程执行此操作不安全。
在欢迎屏幕的结尾,我们只是在一个 while(true)
循环中等待 w3svc_last_url_ts
超时,以便从显示器上清除最后一个 URL。
对于实际应用程序,你会在某个条件满足后退出此例程(以及该循环),然后将 screen_current
设置为你想要的新目标屏幕。
你会注意到“Welcome to the demo.”出现了两次。下面的副本没有进行抗锯齿或“平滑”处理,目的是向你展示在中间位图中绘图(可以在其中进行平滑处理)和直接在显示器上绘图(无法进行平滑处理)之间的区别。
历史
- 2021年11月27日 - 初始提交