65.9K
CodeProject 正在变化。 阅读更多。
Home

GFX 第四部分:ILI9341 显示驱动程序和 JPEG 支持

starIconstarIconstarIconstarIconstarIcon

5.00/5 (5投票s)

2021 年 5 月 4 日

MIT

13分钟阅读

viewsIcon

9599

downloadIcon

288

在没有 Arduino 框架的情况下,从 ESP32 高效使用 ILI9341 显示屏。加载 JPEG。

ESP-WROVER-KIT

引言

我向你承诺了图像,也承诺了驱动程序。现在我将兑现诺言。在这篇文章中,我们将介绍一款功能极其强大的 ILI9341 驱动程序,以及一个改进的 GFX 库,该库具有一些 API 改进和 JPEG 支持。

我为这项工作感到筋疲力尽。这个库是我热爱之作。最终,我希望能够证明一个极其灵活且通用的图形库,它不依赖于源代码,这意味着它可以用于几乎任何类型的显示屏。正如你所见,即使将其渲染为 ASCII 字符也并非难事。

现在我们将进行真正的渲染。我们将使用一款智能且快速的 ILI9341 驱动程序。你可以像处理位图一样,使用 `gfx::draw` 直接向其绘图,但如果你深入研究,会发现一些提高性能的功能,如批处理操作和队列异步写入。GFX 已在许多地方使用了批处理,但如果你想要异步,目前需要自己直接将队列帧写入驱动程序层。最终,GFX 将为支持它的驱动程序提供异步绘制支持。

除此之外,我还进行了一些 API 调整,并增加了 JPEG 加载支持。在物联网设备上加载 JPEG 是棘手的,因为通常没有足够的 RAM 一次性将真实的 JPEG 加载到内存中。为了解决这个问题,这个库以及未来的图像加载器支持/将支持渐进式加载,即一次一个部分地调用回调函数来传递图像。

构建这个大杂烩

你需要安装了 Platform IO 扩展的 Visual Studio Code。你需要一个连接了 ILI9341 LCD 显示屏的 ESP32。我推荐 Espressif ESP-WROVER-KIT 开发板,它集成了一个显示屏和多个预接的周边设备,以及一个集成调试器和一个更高级的 USB 转串口桥,可提供更快的上传速度。它们可能比标准的 ESP32 开发板更难找到,但我发现在 JAMECO 和 Mouser 的售价约为 40 美元。如果你进行 ESP32 开发,这些投入是值得的。集成调试器虽然比 PC 慢很多,但比标准 WROVER 开发板连接外部 JTAG 探针要快。

此项目默认设置为上述套件。如果你使用的是通用 ESP32,你需要在 `platformio.ini` 文件中将配置设置为 *generic-esp32*。构建时请务必选择正确的配置。此外,你还需要更改 `main.cs` 开头附近的 SPI 引脚设置,以匹配你的实际引脚。默认设置是针对 ESP-WROVER-KIT 的。你还需要更改 ILI9341 的特定附加引脚,如 DC、RST 和背光引脚。

在运行之前,您必须在 Platform IO 侧边栏 - 任务下上传文件系统映像。

注意:Platform IO IDE 有时有点挑剔。第一次打开项目时,你可能需要点击左侧的 Platform IO 图标(看起来像一个外星人)。点击它打开侧边栏,然后在 *Quick Access|Miscellaneous* 下查找 *Platform IO Core CLI*。点击它,然后会出现一个提示符,输入 `pio run` 来强制下载必要的组件并构建。之后通常不需要再次执行此操作,除非在尝试构建时再次遇到错误。

概念化这个混乱的局面

驱动程序概念

GFX 对任何特定驱动程序都没有特别的了解,尽管在某些地方,如果驱动程序支持,它可以利用驱动程序特定的优化。

一个功能齐全的驱动程序会了解 GFX 并依赖于它来实现完全集成。这并非强制要求完全集成,而且像 SSD1306 这样的设备(我也有一个尚未发布的初步驱动程序)除了最基本的帧写入之外,缺乏其他能力。

ILI9341 驱动程序在同步写入显示屏方面功能齐全,但目前不支持读取操作。原因是数据手册对我来说在读取操作方面不够清晰,并且在尝试了两种不同的方法而没有结果后,我不知道下一步该做什么。我尝试查看了其他库,但它们并没有提供太多帮助。

通常,一个驱动程序会暴露两组功能 - 与 GFX 无关的原始设备调用,以及利用这些调用的 GFX 接口绑定。我更喜欢将它们放在同一个类中以方便使用,尽管这会在一定程度上“污染”类,使其拥有大量成员。

驱动程序可以执行诸如帧读写等操作,将像素数据块传输到或从帧缓冲区。所有 GFX 兼容的驱动程序都必须支持 `frame_write()`, `clear()`, `fill()`, `bounds()`, `dimensions()`, `caps` 和 `pixel_type` 绑定,尽管请注意,有些驱动程序只能一次性处理整个显示屏,而不是部分。一些驱动程序,如包含的驱动程序,支持其他操作,例如批写入和设置单个像素。此外,一些驱动程序可能支持读取帧缓冲区。请参阅包含的驱动程序以获取实现所有这些功能的示例。

驱动程序本身使用了我编写的几个小程序类来进行原始设备 I/O,在本例中是我的 `spi_master` 和 `spi_device` 类。这些类旨在大大简化代码并减小代码大小。

在实例化驱动程序时,通常会通过模板参数指定引脚、任何缓冲和其他功能。

请注意,虽然驱动程序接口方法名称是 `noun_verb`,例如 `frame_write<>()`,但 GFX 接口是 `verb_noun`,例如 `write_frame<>()`。由于我将两个接口都放在同一个类中(虽然不必如此),这应该有助于区分它们。

与 GFX 接口

GFX 不使用标准的运行时多态来实现其接口。相反,它使用模板,这意味着你不必继承任何东西,但你必须实现必要的成员。这有一些缺点,例如,如果你在运行测试时不够小心地检查所有实例化,可能会将不良的(未编译且有问题)代码泄漏到你的生产分支中。

如果你熟悉 C++ 中的 *泛型编程*,这段代码对你来说应该很熟悉。它经过精心设计,在大多数常见情况下可以避免代码膨胀,尽管在某些情况下,例如尝试同时运行两个 ILI9341 屏幕,它在代码大小方面效率不高。我可以改进这一点,但这对于如此不太可能的情况似乎不值得。

通常,当你接受一个图形目标时,你会将其类型作为模板参数传递给你的方法或类型。然后你可以对它进行绘制和查询操作,只要你遵守标准,你就可以使用相同的代码来处理显示驱动程序或位图。请参阅 *gfx_drawing.hpp* 中的 `draw` 类获取示例。当你研究那个文件时,你可能会注意到一些奇怪的嵌套 `XXXX_helper` 模板。这些模板是为了基于绘制目标的特性进行特化。这样,GFX 就可以查询目标以查看它是否支持某种方法,如果支持,则可以调用它,或者如果不存在该方法,则可以运行备选的(效率较低的)代码路径。你可能需要采用类似的技术来实施不同的绘制操作,以便它能够进行学习。

使用图像

目前,GFX 只支持 JPEG,但未来它将支持 PNG,并可能支持 BMP。正如我之前提到的,图像加载是通过回调实现的,每次回调触发时,都会传递图像的一部分。这比一次性加载整个图像内存效率更高,而这在物联网设备上通常是不现实的。

请注意,JPEG 加载代码是从 Chan 的 *tjpgd.c* 代码移植过来的,版权信息包含在源文件中。

可以使用 `image::load()` 从实现 `io:stream` 的任何类型加载图像。文件和内存已存在流实现,但没有什么能阻止你创建自己的流实现。

在输出函数中,你将获得一个小的 24 位 RGB 位图,代表图像的一部分。你很可能需要转换像素格式,但 `draw::bitmap()` 会以一种目标无关的方式自动为你完成。

上面的内容实际上应该是基于 YCbCr 的位图,因为 JPEG 本身使用 YCbCr。然而,我的 YCbCr `pixel<>`.`convert()` 代码产生的颜色“褪色”且色调略有偏差。在花费数小时试图找出问题所在后,我暂时放弃了。因此,在 *gfx_image.hpp* 的顶部,有一个 `HTCW_JPEG_AS_RGB` 宏,它强制 JPEG 生成 RGB 像素,使用内部转换例程,该例程似乎没有同样的问题。这将在未来的版本中修复。如果你想了解我所说的颜色损坏是什么意思,可以取消定义上面的宏。它“感觉”像一个缩放或舍入问题,但我就是找不到。

为了减少交叉依赖,库的这部分没有被其他任何东西引用。因此,`draw` 类没有绘制图像的方法。你必须在回调中自己绘制图像,这非常简单。请参见示例代码。

使用这个烂摊子

代码比文字有价值得多,所以让我们从演示代码开始。

extern "C" { void app_main(); }

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_system.h"
#include "spi_master.hpp"
#include "esp_spiffs.h"
#include "ili9341.hpp"
#include "stream.hpp"
#include "gfx_bitmap.hpp"
#include "gfx_image.hpp"
#include "gfx_drawing.hpp"
#include "gfx_color_cpp14.hpp"
#include "../fonts/Bm437_ATI_8x16.h"
using namespace espidf;
using namespace io;
using namespace gfx;

// the following is configured for the ESP-WROVER-KIT
// make sure to set the pins to your set up.
#ifdef CONFIG_IDF_TARGET_ESP32
#define LCD_HOST    HSPI_HOST
#define DMA_CHAN    2

#define PIN_NUM_MISO GPIO_NUM_25
#define PIN_NUM_MOSI GPIO_NUM_23
#define PIN_NUM_CLK  GPIO_NUM_19
#define PIN_NUM_CS   GPIO_NUM_22

#define PIN_NUM_DC   GPIO_NUM_21
#define PIN_NUM_RST  GPIO_NUM_18
#define PIN_NUM_BCKL GPIO_NUM_5
#elif defined CONFIG_IDF_TARGET_ESP32S2
#define LCD_HOST    SPI2_HOST
#define DMA_CHAN    LCD_HOST

#define PIN_NUM_MISO GPIO_NUM_37
#define PIN_NUM_MOSI GPIO_NUM_35
#define PIN_NUM_CLK  GPIO_NUM_36
#define PIN_NUM_CS   GPIO_NUM_34

#define PIN_NUM_DC   GPIO_NUM_4
#define PIN_NUM_RST  GPIO_NUM_5
#define PIN_NUM_BCKL GPIO_NUM_6
#elif defined CONFIG_IDF_TARGET_ESP32C3
#define LCD_HOST    SPI2_HOST
#define DMA_CHAN    LCD_HOST

#define PIN_NUM_MISO GPIO_NUM_2
#define PIN_NUM_MOSI GPIO_NUM_7
#define PIN_NUM_CLK  GPIO_NUM_6
#define PIN_NUM_CS   GPIO_NUM_10

#define PIN_NUM_DC   GPIO_NUM_9
#define PIN_NUM_RST  GPIO_NUM_18
#define PIN_NUM_BCKL GPIO_NUM_19
#endif

// To speed up transfers, every SPI transfer sends as much data as possible. 
// This define specifies how much. More means more memory use, but less 
// overhead for setting up / finishing transfers.
#define PARALLEL_LINES 16

// configure the spi bus. Must be done before the driver
spi_master g_spi_host(nullptr,
                    LCD_HOST,
                    PIN_NUM_CLK,
                    PIN_NUM_MISO,
                    PIN_NUM_MOSI,
                    GPIO_NUM_NC,
                    GPIO_NUM_NC,
                    // This is much bigger than we need:
                    PARALLEL_LINES*320*2+8,
                    DMA_CHAN);

// we use the default, modest buffer - it makes things slower but uses less
// memory. it usually works fine at default but you can change it for performance 
// tuning. It's the final parameter: Note that it shouldn't be any bigger than 
// the DMA size
using lcd_type = ili9341<LCD_HOST,
                        PIN_NUM_CS,
                        PIN_NUM_DC,
                        PIN_NUM_RST,
                        PIN_NUM_BCKL
                        /*,PARALLEL_LINES*320*2+8*/>;

// declaring this saves us typing - we can do lcd_color::white for example:
using lcd_color = gfx::color<typename lcd_type::pixel_type>;
lcd_type lcd;

// demonstrates how to use the "bare metal" driver calls, bypassing GFX
void raw_driver_batch_demo() {
    lcd.batch_write_begin(0,0,lcd_type::width-1,lcd_type::height-1);
    for(uint16_t y=0;y<lcd_type::height;++y) {
        for(uint16_t x=0;x<lcd_type::width;++x) {
            // alternate white and black
            uint16_t v=0xFFFF*((x+y)%2);
            if(lcd_type::result::success!=lcd.batch_write(&v,1)) {
                printf("write pixel failed\r\n");
                y=lcd_type::height;
                break;;
            }           
        }
    }
    lcd.batch_write_commit();
}
void app_main(void)
{
    // check to make sure SPI was initialized successfully
    if(!g_spi_host.initialized()) {
        printf("SPI host initialization error.\r\n");
        abort();
    }
    // mount SPIFFS
    esp_err_t ret;
    esp_vfs_spiffs_conf_t conf = {};
    conf.base_path="/spiffs";
    conf.format_if_mount_failed=false;
    conf.max_files=5;
    conf.partition_label="storage";
    ret=esp_vfs_spiffs_register(&conf);
    ESP_ERROR_CHECK(ret);   

    raw_driver_batch_demo();

    // clear the display
    lcd.clear(lcd.bounds());
    
    // we actually don't need more than 3 bits here for the colors 
    // we are using. Storing it in 3 bits saves memory but the 
    // color depth isn't realistic for most things
    using bmp_type = bitmap<rgb_pixel<3> /*typename lcd_type::pixel_type*/>;
    using bmp_color = color<typename bmp_type::pixel_type>;
    const size16 bmp_size(64,64);
    uint8_t* bmp_buffer = (uint8_t*)malloc(bmp_type::sizeof_buffer(bmp_size));
    if(nullptr==bmp_buffer) {
        printf("out of memory\r\n");
        abort();
    }
 
    bmp_type bmp(bmp_size,bmp_buffer);
    bmp.clear(bmp.bounds());
   
    // bounding info for the face
    srect16 bounds=(srect16)bmp.bounds();
    rect16 ubounds=(rect16)bounds;

    // draw the face
    draw::filled_ellipse(bmp,bounds,bmp_color::yellow);
    
    // draw the left eye
    srect16 eye_bounds_left(spoint16(bounds.width()/5,
                            bounds.height()/5),
                            ssize16(bounds.width()/5,
                            bounds.height()/3));
    
    draw::filled_ellipse(bmp,eye_bounds_left,bmp_color::black);
    
    // draw the right eye
    srect16 eye_bounds_right(
        spoint16(
            64-eye_bounds_left.x1-eye_bounds_left.width(),
            eye_bounds_left.y1
        ),eye_bounds_left.dimensions());
    draw::filled_ellipse(bmp,eye_bounds_right,bmp_color::black);
    
    // draw the mouth
    srect16 mouth_bounds=bounds.inflate(-bounds.width()/7,
                                    -bounds.height()/8).normalize();
    // we need to clip part of the circle we'll be drawing
    srect16 mouth_clip(mouth_bounds.x1,
                    mouth_bounds.y1+mouth_bounds.height()/(float)1.6,
                    mouth_bounds.x2,
                    mouth_bounds.y2);
    draw::ellipse(bmp,mouth_bounds,bmp_color::black,&mouth_clip);

    // draw it centered horizontally 
    draw::bitmap(lcd,bounds.offset((lcd_type::width-bmp_size.width)/2,0),
                                bmp,
                                ubounds);
    
    const font& f = Bm437_ATI_8x16_FON;
    const char* text = "Have a nice day!";
    // center the text
    srect16 sr = (srect16)lcd.bounds().offset(0,bmp_size.height).crop(lcd.bounds());
    ssize16 tsiz = f.measure_text(sr.dimensions(),text);
    sr=sr.offset((sr.width()-tsiz.width)/2,0);
    // draw it
    draw::text(lcd,sr,text,f,lcd_color::antique_white);
    
    vTaskDelay(3000/portTICK_PERIOD_MS);

    // load an image
    io::file_stream fs("/spiffs/image3.jpg");
    if(!fs.caps().read) {
        printf("image file not found\r\n");
        abort();
    }
    gfx_result gr=image::load(&fs,[](const image::region_type& region,
                                    point16 location,
                                    void* state) {
        lcd_type* plcd = (lcd_type*)state;
        // we're not using it here, but we can do things like specify 
        // clipping here if we need it:
        return gfx_result::success==
            draw::bitmap(*plcd,
                        srect16((spoint16)location,
                        (ssize16)region.dimensions()),
                        region,region.bounds());
        // alternatively we could have just done this, 
        // which doesn't support clipping:
        // return gfx_result::success==plcd->write_frame(region.bounds(),
        //                                             region,
        //                                             location);
    },&lcd);
    if(gr!=gfx_result::success) {
        printf("image draw error %d\r\n",(int)gr);
    }
    vTaskDelay(portMAX_DELAY); 
}

首先是我们的 include 文件。我们不必显式包含每个 GFX 头文件,也不必包含 `stream.hpp` 头文件,因为它们会包含在其他 include 文件中。但是,我想展示在应用程序中可能包含的各种可能性。我们还包括了 ILI9341 驱动程序和 SPI 类。最后,还有一个嵌入式字体,它通过 *./fonts* 中的字体 include 文件包含进来。这些头文件是从旧的 Windows 3.1 *.FON* 文件中存储的光栅字体生成的。选择它们是因为它们最初是为 16 位系统设计的,并且开销较低。用于生成头文件的 *fontgen* 工具位于 *./lib/gfx/tools* 下。它是一个 ELF 二进制文件,并在 Ubuntu Linux 上进行了测试。该文件夹中所有字体的头文件都已包含。

第一部分的大部分其余内容只是配置设置。请根据你的设置进行更改。

接下来,我们配置 SPI 总线,这必须在显示驱动程序之前完成。之后,我们实例化并初始化显示驱动程序。

我们声明一些类型别名,以便以后使用。

现在我们进入主程序。由于我们没有从构造函数中获取结果,我们需要确保 SPI 已正确初始化。

之后,有一些原始的 ESP-IDF 代码来挂载 SPIFFS 分区。

接下来,我们清除整个屏幕,然后进行 `raw_batch_driver()` 演示,该演示通过原始驱动程序调用快速在屏幕上绘制一个斜线图案。我们在这里立即继续,只是为了展示批处理的速度有多快。你的显示驱动程序的缓冲区越大,它的速度就会越快,直到达到一个可以通过调优找到的阈值。通常情况下,你不会使用原始驱动程序调用,但为了完整性,这里包含了它。

之后,我们声明一个 64x64 像素的 3 位/像素的位图。这个位深度非常低,仅用于演示目的 - 因为我们可以。请注意,由于每个颜色(红、绿、蓝)只有一个值,因此你绘制的颜色最终会减少到那个值。结果通常……不那么微妙,但在这里我们可以勉强接受。好处是,这比 16 位/像素占用的内存少得多。另一方面,从中读取和写入稍微慢一些,因为它在部分字节中工作。最快的选项将是 RGB565 像素,每像素 16 位,因为 LCD 使用的就是这种类型。请注意,我们如何需要单独声明位图的内存缓冲区。这有多种原因。部分原因是,并非所有平台上的内存都相同。例如,有些内存可能不支持 DMA,比如 ESP32 WROVER 上的 PSRAM。另一个原因是,如果你愿意,可以为较小的位图在堆栈上声明缓冲区。总之,我们只是 `malloc()` 了它。我们永远不会 `free()`,因为没有必要,因为我们在结束时就停止了。

现在我们清除位图,并使用几个绘制操作按照头部、眼睛和嘴巴的顺序绘制一个经典的笑脸。嘴巴是通过将一个椭圆沿着其垂直轴剪裁到一半来绘制的,所以我们只得到了它的底部。

之后,我们将位图水平居中在屏幕上,然后绘制它。我们不必这样做。我们可以直接绘制到屏幕上,但首先我想演示如何使用位图,其次,使用位图来暂存一系列绘制操作,然后一次性将其绘制到屏幕上可能更有效率,就像我们在这里所做的那样,只要你有足够的内存。

之后,我们使用嵌入式字体在屏幕上写上“Have a nice day!”。我们通过使用字体的 `measure_text()` 方法来获取尺寸,以便知道它的大小以便居中。我们直接将其绘制到屏幕上,颜色略显偏白,但很微妙。

现在我们加载一张安迪·沃霍尔那张面无表情、戴着墨镜的脸的 JPEG 图片,原因?纯粹是喜欢!这是通过使用 `image::load()` 并传递一个 `io::stream` 来实现的。在这种情况下,我们只是使用了一个 `file_stream`。我们还提供了一个回调函数。你可以通过该回调函数传递状态。即使它是全局的,我们不需要,但我们通过 `state` 参数将 `lcd` 传递给了例程。我们使用了扁平的 lambda 函数,该例程所做的就是绘制位图。这里注释掉的代码实际上更简单,但功能较少。`draw::bitmap()` 方法的主要复杂之处在于翻译坐标,因为它接受一个有符号矩形和一个无符号矩形,而我们需要生成它们。除此之外,它相当直接,因为所有像素转换等都为我们完成了。请注意,如果你真的愿意,你可以在此输出例程中制作一个特效,但如果你这样做,那么在应用特效的同时将像素转换为 RGB565(`rgb_pixel<16>`)会更有效率。如果你不这样做,那么绘制例程必须重新遍历位图并逐个转换像素。既然你已经在遍历以生成你的特效,那么最好一举两得。

我差点在这个演示中加入异步绘图,但没有这样做,因为目前 GFX 没有为此提供 API。只有 `ili9351<>` 上的原始驱动程序调用。我之所以没有这样做,是因为这篇文章已经够长了,我想留出一些空间来解释如何排队操作,以及深入探讨编写有效的异步 GFX 代码的*技术*和*概念*。完成后,我将发布另一篇文章。完成后,你将能够在 ESP32 上至少实现简单的实时全屏动画,每秒重绘多次 LCD 帧。

下一步

我其实不确定。我想我可能会完成并涵盖异步排队。不久后,我将发布一篇总结性文章,一次性解释整个 API,这也是一种可能性。我们将拭目以待。

历史

  • 2021 年 5 月 4 日 - 初次提交
© . All rights reserved.