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

GFX 边栏:ESP-IDF 上 ILI9341 显示驱动程序的内部工作原理

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2021年5月6日

MIT

26分钟阅读

viewsIcon

7196

downloadIcon

103

探索 ESP32 功能强大的 IoT 显示驱动程序的内部工作原理。

ILI9341 demo

引言

GFX 第 4 部分中,我介绍了一个 ILI9341 显示驱动器,但没有解释它的工作原理。由于它是一个功能非常强大的驱动器,支持全套写入操作,因此理解它以便实现自己的驱动器会很有帮助。为此,我们将探索这个驱动器的代码。此外,考虑到驱动器的所有功能,深入研究它应该会很有趣。

构建这个大杂烩

您需要安装 Visual Studio Code 并安装 Platform IO 扩展。您需要一个连接了 ILI9341 LCD 显示屏的 ESP32。我推荐 Espressif ESP-WROVER-KIT 开发板,它集成了显示屏和多个预布线外设,还有一个集成调试器和更优越的 USB 转串口桥接器,上传速度更快。它们可能比标准 ESP32 开发板更难找到,但我发现它们在 JAMECO 和 Mouser 上售价约为 40 美元。如果您进行 ESP32 开发,它们非常值得投资。集成调试器虽然与 PC 相比速度很慢,但比连接到标准 WROVER 开发板的外部 JTAG 探头更快。

此项目默认设置为上述套件。如果您使用的是通用 ESP32,则必须将配置设置为generic-esp32(如platformio.ini文件中所列)。构建时请务必选择适当的配置。此外,您需要更改main.cs开头附近的 SPI 引脚设置,以匹配您的引脚。默认值适用于ESP-WROVER-KIT。您还需要更改 ILI9341 的特定额外引脚,例如 DC、RST 和背光引脚。

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

注意:Platform IO IDE 有时会有点反复无常。首次打开项目时,您可能需要转到左侧的 Platform IO 图标 — 它看起来像一个外星人。单击它打开侧边栏,然后在快速访问|Platform IO Core CLI 杂项下查找。单击它,然后当出现提示时,键入pio run以强制它下载必要的组件并构建。除非您在尝试构建时再次开始出现错误,否则您不需要再次执行此操作。

概念化这个混乱的局面

从结构上看,GFX 库对任何显示驱动程序都没有特殊了解,但如果显示驱动程序可以绑定到其绘图功能,那么它们就会了解 GFX。因此,对于任何充当 GFX 绘图目标的显示驱动程序,都存在对 GFX 的依赖。然而,GFX 本身对任何显示驱动程序都没有依赖。

由于驱动器上存在 GFX 绑定,因此假定您对 GFX 有少量熟悉。但需要注意的是,GFX 绑定本身只是底层驱动器功能的薄薄包装器,它们可以潜在地被移除,以消除对 GFX 的依赖。

显示驱动器本身可能支持各种操作,如 `caps` 成员所示。GFX 使用此成员来确定如何调用绘制目标。由于绘制目标实际上可以是任何可以绑定到 GFX 的东西,因此不同的东西自然具有不同的能力。例如,位图不暴露异步操作,因为它没有意义——对位图内存的写入已经几乎是瞬时的,使其异步只会增加额外开销。对于必须通过相对较慢的总线连接的显示设备来说,情况就不同了。在这种情况下,异步执行操作意味着您可以在总线仍在发送上次数据时绘制下一个东西。`caps` 结构只是让 GFX 知道绘制目标能够做什么。

驱动程序还公开了一个 `pixel_type` 别名,它指示驱动程序的帧缓冲区支持的像素类型。需要注意的是,此库在运行时无法切换像素格式。您必须在编译时确定像素类型。此驱动程序目前仅支持 RGB565,因此像素类型不可配置。但是,显示设备还支持另外两种像素格式,但驱动程序尚不支持它们。

驱动程序必须支持写入,并且可以支持读取。ILI9341 驱动程序目前不支持读取,尽管设备支持。原因是读取命令的数据手册不清楚,我还没有弄清楚如何从中获取数据。

在写入方面,基本操作是清除矩形、填充矩形、将位图等源写入帧缓冲区,以及将像素写入帧缓冲区。

除此之外,驱动程序可能支持批量写入,这样你就可以预先指定一个写入窗口矩形,然后对像素进行缓冲写入,并在完成后提交。这样做比逐像素写入效率高得多。如果驱动程序支持批量操作,GFX 在大多数情况下(如果不是全部)都会使用它们。

除此之外,驱动程序还可以支持其方法的异步版本。使用这些版本,操作可以被调度并在操作完成之前返回控制。如果队列中没有更多空间用于事务,则不一定会立即返回控制。在这种情况下,函数将等待直到队列有一个空闲槽。即使在这种情况下,排队也可以提供显著的优化机会,您可以在当前帧发送到显示器时开始绘制下一帧。

在 ILI9341 驱动程序上,原始驱动程序方法和包装它们的 GFX 绑定暴露在同一个类上。它们不必如此,但这样做简化了代码,即使这意味着接口有点混乱。由于驱动程序是低级的,我没有优先考虑使其表面积“干净”和最小化。通常,驱动程序的低级函数命名为名词_动词,而 GFX 绑定命名为动词_名词。

使用这个烂摊子

在我们深入了解它的工作原理之前,我们应该先谈谈如何使用它。

由于显示器使用 SPI 连接到 ESP32,所以我们首先要初始化 SPI 总线

spi_master spi_host(&error_result_code,
                    LCD_HOST,
                    PIN_NUM_CLK,
                    PIN_NUM_MISO,
                    PIN_NUM_MOSI,
                    GPIO_NUM_NC,
                    GPIO_NUM_NC,
                    DMA_SIZE,
                    DMA_CHAN);

您可以看到我们有一个 `error_result_code`,后面跟着一堆看起来像 `#define` 的值(它们确实是),传递给构造函数。在大多数情况下,这些代表您的 SPI 的 GPIO 引脚号。目前,我不会自动将它们分配给合理的默认值(不同 ESP32 种类不同),因此您必须指定它们。我们还在这里声明了 DMA 传输的最大大小。对于图形,这应该至少与您的显示缓冲区或您计划传输到屏幕的最大位图相同,再加上 8,取其中最大值。我们还指定了一个 DMA 通道,因为我们需要 DMA 进行快速传输。同时,`error_result_code` 可以是 `null`,也可以是接收 SPI 初始化结果/状态值的地址。

驱动程序本身是一个模板,要使用它,您首先必须填写所有模板参数并为其建立一个具体类型

using lcd_type = ili9341<LCD_HOST,
                        PIN_NUM_CS,
                        PIN_NUM_DC,
                        PIN_NUM_RST,
                        PIN_NUM_BCKL>;

在这里,我们使用了一堆 `#define` 值来设置引脚号。只要这些值被定义为与您板上的接线匹配,它就会起作用。我排除了一些通常不需要设置的参数,例如缓冲区大小和超时。您可以看到我们已将此声明别名为 `lcd_type`。您会想要一个别名,因为我们需要在使用驱动程序时引用该类型。

现在是实例化驱动程序的时候了

lcd_type lcd;

实例化就是这样。我们不需要任何构造函数参数,因为我们通过将其作为模板参数传递来处理了所有必要的信息。

LCD 不会在构造时初始化,因为我在全局部分的 `app_main()` 之前进行 I/O 时遇到过稳定性问题。因此,它采用惰性初始化,即在第一次调用图形函数时或调用 `initialize()` 时进行初始化,如果尚未初始化,则会强制进行初始化。

首先,在类本身上,有几个静态成员可以获取显示器的 `width` 和 `height` 等信息,并提供对所有传入的模板参数值的访问。

如我所述,`ili9341<>` 模板类上有两层驱动程序调用。首先,我们有低级原生层,然后我们有使用该层并允许设备被 GFX 绘制的 GFX 绑定。为了简单和方便,这都在一个类中。该驱动程序并非设计为直接使用,但它可以。因此,它并非设计为具有干净的 API 占用空间,而是具有一组完整功能但冗余最少的功能性 API。

原生层

既然这不是一篇关于 GFX 本身的文章,我们将从原生层开始。

基础知识

请记住,原生层是驱动程序特定的。其他驱动程序可能会以完全不同的方式公开其功能。之所以能这样,是因为 GFX 只关心 GFX 绑定。

至于原生层,我们再次有几个基本的概念操作。我们可以将像素写入显示器,或者我们可以将位图数据写入帧缓冲区。够简单吧?没错。

首先,我们有 `pixel_write(x,y,pixel_color)` 允许您将单个像素写入显示器。

接下来是 `frame_write(x1,y1,x2,y2,bmp_data)`,允许您将内存中的位图写入显示器的一部分。

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.pixel_write(x,y,v)) {
            printf("write pixel failed\r\n");
            y=lcd_type::height;
            return;
        }
    }
}

上面,我们通过黑白交替的方式在整个显示器上写入黑白交叉图案/抖动。这很简单,但速度慢得让你想下车推。它慢是因为它会淹没 SPI 总线。淹没总线可能会触发看门狗超时,尽管根据我的经验,它不会强烈到导致机器重启,而是仅仅通过串口发出抱怨。

肯定有比这更好的填充屏幕的方法。幸运的是,有。

批处理

进入批处理。正如我所说,一次写入一个像素会产生大量的 SPI 流量,并且 I/O 会导致显著的开销。在必须使用 `write_pixel()` 时,这样做是没问题的,但在许多情况下,有更有效率的写入方法。主要方法是批处理。批处理通过设置一个矩形目标区域,然后从左到右、从上到下写入像素颜色,直到目标区域被填充(例如,一个 8x8 的区域需要写入 64 个像素)。由于输出是缓冲的,当您完成后,您将提交任何剩余的数据。

让我们做得比上面更好、更快!

// batched, much faster and more reliable
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) {
        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;
            return;
        }
    }
}
lcd.batch_write_commit();

我已将相关更改加粗。第一行和最后一行很重要,因为它们建立了我们批处理操作的范围。在其中,我们调用 `batch_write()` 并传递两个参数。第一个是包含一个或多个像素值的数组,第二个是该数组中的计数。由于它是 C++,我们可以简单地传递标量值的地址并将其视为长度为 1 的数组。我喜欢 C++。我应该指出,一次传递多个值并没有那么快,因为所有这些批处理写入都是缓冲的,但这确实节省了多次调用 `batch_write()` 的微小开销,因此如果您愿意,可以使用它。

务必提交您的批次!几乎所有操作在必须开始它们正在做的事情之前,都会提交任何挂起的批次,但这并非总是必要的,而且这些例程通常尽可能懒惰,因此如果您不提交,那么您的最终数据可能会在缓冲区中积灰,直到另一个操作最终需要将其写入显示器。何时发生取决于几个因素,包括显示器设置了多少个 SPI 事务。

异步排队

如果需要,您有时可以使用异步排队驱动程序操作从应用程序中榨取更多速度。这些实际上并不比非排队类型“快”。事实上,它们会增加一点开销。然而,它们的优点是它们返回得更快——有时是立即——并在后台处理剩余的工作。这使您的代码可以在操作完成时计算下一帧。SPI 主机本身将使用后台的 DMA 内存继续传输。所有上述操作都有立即返回的排队对应项,尽管 `queued_write_pixel()` 在实践中并不是很有用。它只是为了完整性和一致性而提供的。

下面是一个不应该做的例子。它与上面的代码相同,只是使用了排队调用

// queued/asynchronous batching - SLOWER than the above
lcd.queued_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) {
        // here's the reason this is no good:
        // we're simply not doing enough work
        // below to make it worthwhile. The
        // only way queued operations are
        // worth it is if your work between
        // driver calls is more than the 
        // additional overhead incurred by
        // queuing. Since all we're doing
        // below is some very basic math,
        // this doesn't pay for itself
        uint16_t v=0xFFFF*((x+y)%2);
        if(lcd_type::result::success!=lcd.queued_batch_write(&v,1)) {
            printf("write pixel failed\r\n");
            y=lcd_type::height;
            return;
        }           
    }
}
lcd.queued_batch_write_commit();

让我再解释一下中间的注释。这里发生的是,我们对异步性的利用不足。为了获得净性能优势,您的 CPU 必须在排队操作之间尽可能长时间地保持忙碌。这是因为这些操作发生在后台。想想看。您希望后台/排队操作与您的 CPU 执行大量工作同时运行。这样,两件事情都“尽可能快地旋转”,并且一切都是并行的。上面的例子不是非常并行。在调用之间,CPU 的利用率不足以弥补排队后台工作的额外开销。这就是底线。

排队功能最适用于大型传输。您发送的数据越多,效果越好。对于批处理操作,这由 `BufferSize` 模板参数/`buffer_size` 成员决定。更大的值可以提高批处理传输效率,但会占用更多内存。默认值为 64 字节,属于合理范围的较小端。在进行性能分析之前不要修改它。然而,对于排队帧写入,`buffer_size` 是无关紧要的。“缓冲区”实际上是您传入的位图数据,因此它将传输该数据。位图数据大小限制由 SPI 主机 (`spi_master`) 中指定的 DMA 传输大小决定。

传输量越大越好的原因是,当总线发送更多数据所需时间越长时,CPU 就会有更多的喘息空间。例如,如果您的总线发送一些数据需要 100 纳秒,那么在数据发送期间,您就有 100 纳秒的空闲时间来做您想做的事情。总线传输完成所需时间越短,CPU 介入排队更多数据的频率就越高,从而增加了开销。我希望这能说得通。

别担心,虽然我向你展示了如何执行异步排队操作,但在文章结束之前,我们将探讨正确的方法。

原生 API

以下是原生驱动程序功能的 API 调用简介。

  • host_id - 驱动程序使用的 SPI 主机标识符
  • pin_cs - CS 引脚
  • pin_dc - DC 引脚
  • pin_rst - RST 引脚
  • pin_backlight - BKL 引脚
  • buffer_size - 批处理操作的缓冲区大小
  • width - 显示器的像素宽度
  • height - 显示器的像素高度
  • max_transactions - 驱动程序允许的最大事务数
  • timeout - 排队操作的超时时间
  • result - 驱动程序报告的错误代码枚举
  • initialized() - 指示驱动程序是否已初始化
  • initialize() - 强制驱动程序自行初始化。通常,驱动程序会等到首次使用时才初始化。
  • frame_write() - 将位图数据写入帧缓冲区的一部分
  • queued_frame_write() - 上述操作的异步排队版本
  • frame_fill() - 用颜色填充帧缓冲区的一部分
  • queued_frame_fill() - 上述操作的异步排队版本
  • batch_write_begin() - 为目标帧缓冲区窗口开始批处理写入操作
  • queued_batch_write_begin() - 上述操作的异步排队版本
  • batch_write() - 向批处理写入一个或多个像素
  • queued_batch_write() - 上述操作的异步排队版本
  • batch_write_commit() - 发送批处理缓冲区中任何剩余的数据
  • queued_batch_write_commit() - 上述操作的异步排队版本
  • pixel_write() - 在帧缓冲区上绘制一个像素
  • queued_pixel_write() - 上述操作的异步排队版本
  • queued_wait() - 等待所有挂起的排队操作完成

GFX 绑定层

原生层很好,但 GFX 不知道如何处理它。为了让 GFX 能够绘制到屏幕,驱动程序必须以 GFX 可以使用的方式公开其功能。为此,此驱动程序在同一个类上公开了一个 GFX 层,该层委托给原生层来完成大部分繁重的工作。

GFX 的设计目标是能够使用几乎任何驱动程序,即使是那些不具备所有功能的驱动程序。因此,一个支持 GFX 的驱动程序会公开一个成员,告诉 GFX 驱动程序支持哪些功能。然后 GFX 会利用这些信息来决定如何调用驱动程序。

除此之外,GFX 驱动程序公开了与底层驱动程序类似的功能,这是有道理的。GFX 支持批处理和异步操作,以及像素和帧写入,只要底层驱动程序支持这些功能,但它以稍微不同的方式公开它们。例如,它使用像 `rect16` 和 `gfx_result` 这样的 GFX 类型来接收参数并报告结果代码,这样 GFX 就可以以通用方式使用它。

现在我们来介绍一下绑定

  • type - 指示类型本身。GFX 实际上不使用它,但它是按惯例暴露的
  • pixel_type - 指示此驱动程序支持的像素类型。请注意,它在编译时是固定的。这是因为为了高效地处理不同的像素格式,必须生成代码来处理特定的格式,而不是在运行时计算这些操作。底线是,它要么是编译时,要么是显著降低图形速度。
  • caps - 这报告了我前面提到的驱动程序功能。可以查询它以了解驱动程序支持哪些功能。
  • dimensions() - 指示显示器的尺寸
  • bounds() - 指示显示器的边界矩形
  • point() - 在指定位置写入像素
  • point_async() - 上述方法的异步版本
  • fill() - 用指定的像素填充显示器的一部分
  • fill_async() - 上述方法的异步版本
  • clear() - 将显示器的一部分归零。对于此驱动程序,它相当于将区域填充为黑色,但某些像素格式归零时并非黑色。如果此驱动程序使用这种格式,结果将不是黑色。对于某些驱动程序,此操作比填充更快,但此驱动程序并非如此。
  • begin_batch() - 开始对目标矩形进行批处理操作
  • begin_batch_async() - 上述方法的异步版本
  • write_batch() - 将像素写入当前批处理操作
  • write_batch_async() - 上述方法的异步版本
  • commit_batch() - 提交所有未写入的批处理数据
  • commit_batch_async() - 上述方法的异步版本
  • write_frame<>() - 将源数据写入帧缓冲区
  • write_frame_async<>() - 上述方法的异步版本

正如我所说,在大多数情况下,这些只是委托给原生层的薄包装器。然而,例外是模板方法 `write_frame<>()` 和 `write_frame_async<>()`,它们将源数据写入帧缓冲区的一部分。“源”在 GFX 中的概念是松散的,大致意味着支持 GFX 绑定进行读取的东西。理论上,这个源可以是位图或另一个显示驱动程序,只要该驱动程序支持读取——这个驱动程序不支持。由于“源”的松散性,其类型必须作为模板参数,并且方法必须模板化。

编写这个混乱的程序

现在我们来深入了解它的工作原理。

演示

这是演示输出视频。如您所见,它正在实时进行流畅的全屏动画。这之所以可能,是因为异步排队允许我们在通过 SPI 总线发送前一帧时处理下一帧。您可以通过简单修改代码来同步执行并查看差异。在这种情况下,异步排队给我们带来了巨大的吞吐量提升,但这很大程度上是因为我们发送的数据量。正如我之前所说,您发送的数据越多越好,而在这里,我们每次传输发送的正是 320x16 像素,即整整 10KB,对于这个小设备来说,这实际上相当多。当这 10KB 的传输进行时,我们可以处理计算接下来的 16 行,我们将在下一次迭代中将其也发送到显示器。

需要指出的是,我在此演示中 shameless 地借鉴了 Espressif 的一个公共领域提供的效果和想法,该演示在您拆箱 ESP-WROVER-KIT 时烧录到其固件中。然而,该代码是 C 语言,而不是 C++,并且没有使用我的驱动程序或我的 GFX 库,所以我对其进行了改编。剩下的只是核心效果和每次排队 16 行并同时计算该效果的概念。除另有说明外,其他所有内容都是我的代码。

我不会在这里介绍整个演示,因为它有几个文件。我将介绍重要的内容。

首先,初始化,我们在全局作用域中进行——我总是将我的设备放在全局作用域中,因为硬件在概念上实际上是全局的

// 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. Must be divisible by 240
#define PARALLEL_LINES 16 // max in this case is 24 before we run out of RAM

// configure the spi bus. Must be done before the driver
spi_master spi_host(nullptr,
                    LCD_HOST,
                    PIN_NUM_CLK,
                    PIN_NUM_MISO,
                    PIN_NUM_MOSI,
                    GPIO_NUM_NC,
                    GPIO_NUM_NC,
                    PARALLEL_LINES*320*2+8,
                    DMA_CHAN);

// alias our driver config
using lcd_type = ili9341<LCD_HOST,
                        PIN_NUM_CS,
                        PIN_NUM_DC,
                        PIN_NUM_RST,
                        PIN_NUM_BCKL>;

// instantiate the driver:
lcd_type lcd;

这是演示的核心。它循环交替发送两个缓冲区。它在一个缓冲区上工作,同时传输另一个缓冲区,然后切换,在另一个缓冲区上工作,同时传输第一个缓冲区,如此反复,直到所有行都处理完毕,然后它会一遍又一遍地永远重复。

// Simple routine to generate some patterns and send them to
// the LCD. Don't expect anything too impressive. Because the
// SPI driver handles transactions in the background, we can
// calculate the next line while the previous one is being sent.
static void display_pretty_colors()
{
    uint16_t *lines[2];
    //Allocate memory for the pixel buffers
    for (int i=0; i<2; i++) {
        lines[i]=(uint16_t*)heap_caps_malloc(
        320*PARALLEL_LINES*sizeof(uint16_t), MALLOC_CAP_DMA);
        assert(lines[i]!=NULL);
    }
    int frame=0;
    //Indexes of the line currently being sent to the LCD and the line we're calculating.
    int sending_line=-1;
    int calc_line=0;

    while(true) {
        ++frame;
        for (int y=0; y<240; y+=PARALLEL_LINES) {
            //Calculate a line.
            pretty_effect_calc_lines(lines[calc_line], y, frame, PARALLEL_LINES);
            //Finish up the sending process of the previous line, if any
            //if (sending_line!=-1) lcd.queued_wait();//send_line_finish(spi);
            //Swap sending_line and calc_line
            sending_line=calc_line;
            calc_line=(calc_line==1)?0:1;
            //Send the line we currently calculated.
            // queued_frame_write works better the larger the transfer size.
            lcd.queued_frame_write(0,
                y,
                lcd_type::width-1,
                y+PARALLEL_LINES-1,
                (uint8_t*)lines[sending_line]);
            //The line set is queued up for sending now; the actual sending happens in the
            //background. We can go on to calculate the next line set as long as we do not
            //touch line[sending_line]; the SPI sending process is still reading from that.
        }
    }
}

现在让我们转到实际的 `ili9341.hpp` 头文件,并探索其中的类。

SPI 管理

SPI 有时会很棘手。在 ESP32 下使用 SPI 有一些规则

  1. SPI 设备本质上必须始终从同一个线程访问
  2. SPI 轮询事务比排队操作更高效,但它们会阻塞
  3. SPI 设备在存在挂起排队事务时不能执行轮询事务
  4. SPI 事务结构和内存必须在事务期间保留
  5. 每个排队事务都必须有一个完成调用
  6. 事务数量不能超过 `max_transactions`。

第一条很简单。驱动程序不是线程安全的,因此无需执行任何操作。

第二点很重要,因为它意味着我们不应该总是使用排队事务。

第 3 条可能很棘手。我们所做的是确保在开始轮询操作之前,我们已经提交了任何挂起的异步操作。这在 `send_transaction()` 内部处理,尽管在某些情况下,我们手动提交。

第 4 点很有趣。虽然调用者有责任保留其数据,但事务结构本身也必须保留。我们通过分配与 `max_transactions` 相同数量的事务结构来解决这个问题。这些结构位于一个名为 `m_trans` 的数组类成员中。每当我们开始一个新事务时,我们都会使用数组中下一个空闲槽,如果达到长度则循环。这创建了一个循环顺序调度方案,其中最旧的结构成为下一个结构。由于如果达到 `max_transactions`,新事务必须在旧事务完成之前开始,因此我们总能在数组中找到一个可用槽。事实证明这很简单。这由 `send_next_cmd()` 和 `send_next_data()` 处理。

第 5 条要求我们强制您调用完成方法(`queued_wait()`),或者以某种方式管理事务,以便它们在完成后得到完成。后者实际上由与处理第 6 条相同的机制处理。

第 6 条通过跟踪正在运行的排队事务数量来处理。当该数量达到 `max_transactions` 并且我们需要另一个事务时,我们首先等待先前的事务以释放一个槽。这同样在 `send_transaction()` 中处理。

所有这些都委托给处理低级 I/O 的 `spi_device` 类。

让我们来看看代码。

result send_transaction(spi_transaction_t* trans,bool queued,bool skip_batch_commit=false) {
    // initialize the display if necessary
    result r = initialize();
    if(result::success!=r)
        return r;
    spi_result rr;
    spi_transaction_t tmp;
    // if we're not queuing but there are queued transactions currently
    // we have to flush everything:
    bool batch_committed=false;
    if(!queued && 0!=m_queued_transactions) {
        // commit the batch if we have to
        if(!skip_batch_commit && 0!=m_batch_left) {
            r=commit_batch_internal(&tmp,true);
            if(result::success!=r)
                return r;
            batch_committed=true;
            // wait for everything to complete
            if(0!=m_queued_transactions)
                r= queued_wait();
        } else {
            // wait for everything to complete
            r=queued_wait();
        }
        if(result::success!=r)
            return r;
        
    } 
    // commit the batch if necessary and we haven't already
    if(!batch_committed && !skip_batch_commit&&0!=m_batch_left) {
        if(!queued) {
            r=commit_batch_internal(&tmp,false);
            if(result::success!=r)
                return r;
        } else {
            // HACK: We can't use tmp here because
            // the transaction won't complete immediately
            // so what we have to do is forcibly open
            // a new slot in m_trans, move the current
            // *trans to the new slot, and then replace
            // *the current slot* with the batch commit
            r=ensure_free_transaction();
            if(result::success!=r)
                return r;
            size_t next_free = (m_queued_transactions+1)%max_transactions;
            memcpy(&m_trans[next_free],trans,sizeof(spi_transaction_t));
            r=commit_batch_internal(trans,true);
            if(result::success!=r) {
                return r;
            }
            trans = &m_trans[next_free];
            m_batch_left=0;
        }
        m_batch_left=0;
        batch_committed=true;
    }
    // now actually send the transaction
    if(queued) {
        r=ensure_free_transaction();
        if(result::success!=r)
            return r;
        rr = m_spi.queue_transaction(trans,timeout);
    } else {
        rr = m_spi.transaction(trans,true);
    }
    if(spi_result::success!=rr) {
        return xlt_err(rr);
    }
    if(queued)
        ++m_queued_transactions;
    return result::success;
}

您可以看到这个例程相当复杂,但它负责几件事情。它处理显示器的延迟初始化,管理排队事务,并自动提交任何未提交的批处理(如果有)。

最糟糕的部分是 hack 部分。基本上发生的情况是,您需要在排队操作期间保留事务,因此我们不能使用 `tmp`,因为它在堆栈上。通常,我们不会在此例程中选择下一个 `m_trans[x]`——在 `send_next_cmd()` 或 `send_next_data()` 调用我们时,它已经为我们选择了。但是,在这里我们必须回收它,用它来存储我们的批处理提交事务。然后我们需要获取下一个空闲槽并用 `trans` 填充它,因此在这种情况下,我们实际上是排队两个事务而不是一个。

该例程的工作方式是,在排队时采用 FIFO 方案,使其始终保持一个空闲事务就绪,这样当您排队一个新事务时,旧事务会在必要时完成以释放一个槽。这样,您的事务会平稳地从一个流向下一个,而不是在队列满时等待所有事务。但是,如果您当前有排队事务正在等待完成,并且您想执行非排队操作,则该例程将等待所有排队事务完成,然后执行该事务以满足规则 #3。

所有这些都委托给 `send_next_cmd()` 或 `send_next_data()`,它们只是选择一个空闲槽并准备下一个事务。它们还将事务的用户定义数据字段设置为 0 表示命令,1 表示数据。这会向我们的预事务回调发出信号,以将 DC 线设置为低电平 (0) 或高电平 (1),因为显示设备期望使用 DC 线来区分命令和数据。这真的很不幸,因为如果不需要控制 DC 线,我们可以将多个命令打包到一个事务中以实现最大效率。太糟糕了,但我尝试通过尽可能高效地管理事务来弥补一些差异。

这基本上是驱动器实际 SPI 通信层的核心。

唯一的例外是初始化,它使用原始 SPI 写入 `spi_device`,将源文件底部的 `static const` 命令数组加载到驱动程序中。它不会排队初始化——它总是同步使用更快的轮询事务,这种事务会阻塞,在该阶段优先考虑速度而不是吞吐量。

主要 LCD 操作

在通信层之上是我们的显示命令层,在这里我们将操作显示器的方法调用转换为通信层上的 SPI 事务。这就是我们前面介绍的原生层

您会注意到很多委托给私有 `XXXXX_impl()` 方法的情况。这是因为这些内部方法包含同步和排队操作之间共享实现的核心,我们将其公开为两种不同的方法,例如 `batch_write()` 和 `queued_batch_write()`,而不是像我们的内部例程那样有一个 `bool queued` 参数。

还有一个奇怪的地方,我们从 `frame_write()` 和 `queued_frame_fill()` 等例程中调用 `batch_write_begin()` 方法,但实际上没有执行批处理或提交它。原因是所有批处理开始例程所做的就是提交任何挂起的批处理,然后设置地址窗口并切换显示器到写入模式。我们重新利用该例程来完成这一点,然后不是执行批处理,而是从那里接管并基本上劫持批处理的其余部分,绕过它并用我们自己的写入替换它。我本可以创建一个单独的例程并委托给它,并给它一个更合适的名称,但这只会增加源代码的大小,而不会提供太多好处。

这是 `queued_frame_write()`

// queues a frame write operation. The bitmap data must be valid 
// for the duration of the operation (until queued_wait())
result queued_frame_write(uint16_t x1,
                        uint16_t y1, 
                        uint16_t x2, 
                        uint16_t y2,
                        uint8_t* bmp_data,
                        bool preflush=false) {
    // normalize values
    uint16_t tmp;
    if(x1>x2) {
        tmp=x1;
        x1=x2;
        x2=tmp;
    }
    if(y1>y2) {
        tmp=y1;
        y1=y2;
        y2=tmp;
    }
    if(x1>=width || y1>=height)
        return result::success;
    result r;
    if(preflush) {
        // flush any pending batches or 
        // transactions if necessary:
        r=batch_write_commit_impl(true);
        if(result::success!=r) {
            return r;
        }
        r=queued_wait();
        if(result::success!=r)
            return r;
    }
    // set the address window - we don't actually do a batch
    // here, but we use this for our own purposes
    r=batch_write_begin_impl(x1,y1,x2,y2,true);
    if(result::success!=r)
        return r;
    
    r=send_next_data(bmp_data,
                    (x2-x1+1)*(y2-y1+1)*2,
                    true);
    
    // When we are here, the SPI driver is busy (in the background) 
    // getting the transactions sent. That happens mostly using DMA, 
    // so the CPU doesn't have much to do here. We're not going to 
    // wait for the transaction to finish because we may as well spend
    // the time doing something else. When that is done, we can call
    // queued_wait(), which will wait for the transfers to be done.
    // otherwise, the transactions will be queued as the old ones finish
    return r;  
}

这实际上非常简单。预刷新允许我们提交任何批处理,然后在开始此操作之前清除所有挂起的事务。有时,根据情况,这可能很有帮助,以便在写入之前从一个全新的空事务队列开始。请记住,由于显示设备要求在命令和数据发送之间将 DC 线低电平或高电平切换,因此一次帧写入需要 6 个事务。

批处理更有趣,因为这个类控制着一个内部缓冲区,每当它满时就会发送。默认情况下,缓冲区只有 32 像素长,但这对于像绘制应用程序屏幕这样的标准用例来说是出人意料的合理。它可能对使用批处理进行实时动画效果不佳,但您始终可以增加缓冲区大小以降低总线开销。尽管如此,对于密集型动画,最好使用异步排队帧写入。因此,默认设置更倾向于标准应用程序 UI 用例。

无论如何,首先,当我们开始批处理时,我们必须设置地址窗口并打开写入模式

result batch_write_begin_impl(uint16_t x1,
                            uint16_t y1,
                            uint16_t x2,
                            uint16_t y2,
                            bool queued) {
    // normalize values
    uint16_t tmp;
    if(x1>x2) {
        tmp=x1;
        x1=x2;
        x2=tmp;
    }
    if(y1>y2) {
        tmp=y1;
        y1=y2;
        y2=tmp;
    }
    //Column Address Set
    result r=send_next_command(0x2A,queued);
    if(result::success!=r)
        return r;
    uint8_t tx_data[4];
    tx_data[0]=x1>>8;             //Start Col High
    tx_data[1]=x1&0xFF;           //Start Col Low
    tx_data[2]=x2>>8;             //End Col High
    tx_data[3]=x2&0xff;           //End Col Low
    r=send_next_data(tx_data,4,queued,true);
    if(result::success!=r)
        return r;
    //Page address set
    r=send_next_command(0x2B,queued,true);
    if(result::success!=r)
        return r;
    tx_data[0]=y1>>8;        //Start page high
    tx_data[1]=y1&0xff;      //start page low
    tx_data[2]=y2>>8;        //end page high
    tx_data[3]=y2&0xff;      //end page low
    r=send_next_data(tx_data,4,queued,true);
    // Memory write
    return send_next_command(0x2C,queued,true);
}

除了几个命令,没有什么特别的。此时,我们还没有对实际的批处理操作进行任何簿记,除了提交任何已存在的挂起批处理(通过第一次 `send_next_command()` 调用)。

当我们进行批处理写入时,它才变得有趣

result batch_write_impl(const uint16_t* pixels,
                        size_t count,
                        bool queued) {
    if(!m_initialized)
        return result::io_error;
    result r;
    size_t index = m_batch_left;
    if(index==buffer_size/2) {
            r=send_next_data(m_buffer,buffer_size,queued,true);
        if(result::success!=r) {
            return r;
        }
        m_batch_left=0;
        index = 0;
    }
    uint16_t* p=((uint16_t*)m_buffer)+index;
    while(0<count) {    
        *p=*pixels;
        --count;
        ++m_batch_left;
        ++pixels;
        ++p;
        if(m_batch_left==(buffer_size/2)) {
            r=send_next_data(m_buffer,buffer_size,queued,true);
            if(result::success!=r)
                return r;
            p=(uint16_t*)m_buffer;
            m_batch_left=0;
        }
    }
    return result::success;
}

我们在这里所做的就是将传入的值复制到缓冲区中,我们递增索引,并且每次缓冲区满时,我们就发送它。

我会向您展示批处理写入提交,但这几乎不值得,因为它所做的只是刷新缓冲区中所有剩余的数据并将其作为最终事务发送。

让我们在 `frame_fill()` 的实现中看看它的实际效果

result frame_fill_impl(uint16_t x1,
                    uint16_t y1, 
                    uint16_t x2,
                    uint16_t y2,
                    uint16_t color,
                    bool queued) {
    // normalize values
    uint16_t tmp;
    if(x1>x2) {
        tmp=x1;
        x1=x2;
        x2=tmp;
    }
    if(y1>y2) {
        tmp=y1;
        y1=y2;
        y2=tmp;
    }
    uint16_t w = x2-x1+1;
    uint16_t h = y2-y1+1;
    result r=batch_write_begin_impl(x1,y1,x2,y2,queued);
    if(result::success!=r)
        return r;
    size_t pc=w*h;
    while(pc>0) {
        r=batch_write_impl(&color,1,queued);
        if(result::success!=r)
            return r;
        --pc;
    }
    r=batch_write_commit_impl(queued);
    return r;           
}

我们直接调用 `_impl` 方法的唯一原因是因为它们接受一个 `bool queued` 参数,与此例程相同,因此代码更少。

最后,也是最不重要的,我们有未批处理的像素写入。这应该作为最后的手段使用,因为它会产生大量的总线流量——如果您大量使用它,足以触发任务看门狗定时器并向串口报错。

result pixel_write_impl(uint16_t x,
                        uint16_t y,
                        uint16_t color,
                        bool queued) {
    // check values
    if(x>=width || y>=height)
        return result::success;
    
    // set the address window. we're not
    // actually batching here.
    result r=batch_write_begin_impl(x,y,x,y,queued);
    if(result::success!=r)
        return r;
    return send_next_data((uint8_t*)&color,2,queued);
}

如您所见,它与整个帧写入产生相同的开销!该设备根本没有用于获取或设置单个像素的缩写调用。

GFX 绑定

我将在未来的文章中介绍驱动程序的 GFX 绑定,届时我将作为 GFX 库文章系列的一部分介绍 GFX 驱动程序集成。

关注点

ST7789V 显示器在接受命令方面几乎完全相同,因此我将扩展此驱动程序以能够控制两者。

历史

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