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

高性能解耦总线,用于 IoT 显示屏

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2022年2月13日

MIT

9分钟阅读

viewsIcon

8863

downloadIcon

99

让您的物联网显示驱动程序独立于使用的总线运行,无论是 I2C、SPI 还是并行总线

bus sample

引言

我有一个名为 GFX 的图形库,我为物联网设备编写的。它有几个适用于各种物联网显示屏的驱动程序。其中大多数显示屏是基于 SPI 的,但有些是 I2C 甚至是 8 位并行。当我添加并行支持时,我意识到我应该重构一切,使总线独立于驱动程序,否则我将复制大量代码。

话虽如此,最终,这都是关于性能的。添加并行支持也是为了提高性能,而不是真正为了设备支持。如果您花费精力寻找并行显示器,您将希望随之而来的性能,否则有什么意义?此外,我意识到通过利用特定于平台的 SPI 操作,我可以获得相当不错的帧率。

正如我所说,这项工作最初是为了提高性能,而不是为了增加灵活性。因此,性能是代码的主要目标,即使它不是文章的主要目标。但是,由于它是面向性能的,因此并不总是最容易理解的。我会尽力按照我自己的理解来分解它。我的许多 SPI 和并行代码都受到了 Bodmer 的 TFT_eSPI 的启发,虽然它看起来与该代码几乎没有相似之处,但它从中衍生了许多操作原理。

免责声明:一些低级 SPI 代码直接从 TFT_eSPI 移植而来,我并不完全理解处理器特定的优化部分。在与 Bodmer 就此主题进行了一些交流后,似乎他从多个来源学习?如果 ESP32 上我使用的 SPI 层有相关文档,我还没有找到。我通常不喜欢发布我不完全理解的代码,但在此情况下我将破例,因为我不知道 ESP32 HAL 的这一层是否存在文档,所以我可能永远无法理解它。通用代码路径相当易于理解。只有在利用特定的硬件优化时才会变得复杂。ESP32 拥有硬件 SPI 控制器,ESP32 代码路径基本上与其接口。不知怎么的。这无疑是黑魔法,但我发现如果你偶尔挥动一只死鸡,它就能完美运行。

必备组件

您将需要一个 ESP32 来真正利用本文,以及一些受支持的显示屏。随附的wiring_guide.txt 将向您展示如何将显示屏连接到 ESP32。GFX 可以在其他设备上运行,但 ESP32 是一个全能的测试单元,并且可以充分利用 GFX,因为它拥有充足的内存和 CPU 功率。GFX 是在 ESP32 平台上开发和主要测试的,尽管它可以在一些 STM32 平台和其他平台上运行。如果您想使用其他平台,只要它是 Arduino 兼容的,它可能就能工作。并行支持可能不行。尽管代码是为通用的 Arduino 支持实现的,但我认为时序不够严格,无法正常工作。ESP32 优化代码确实有效。TFT_eSPI 也有类似的限制。

代码使用 PlatformIO 进行构建,所以请确保您已安装。我个人与 VS Code 扩展一起使用它。我想大多数人都是这样。

随附的代码假定为 128x32 的 SSD1306 显示屏,但包含了几种驱动程序。选择与您自己的屏幕匹配的一个,并使用项目文件夹根目录中的wiring_guide.txt 进行接线。您将必须向驱动程序模板提供适当的参数,这些参数可能与示例中的略有不同。

在继续之前,请确保上传并看到演示正常运行。

理解这段乱码

GFX 是使用泛型编程实现的,驱动程序也是如此。习惯使用模板。在这里,我们将使用模板参数来代替您曾为引脚分配等内容使用的预处理器#define

这里的想法是实例化适当的总线模板(tft_spi<>tft_i2c<>tft_parallel8<>),然后一旦完成,将其作为参数传递给驱动程序模板。

最后,您可以实例化驱动程序模板的一个实例,然后在其上进行绘制。它将使用您指定的任何总线样式。

Using the Code

考虑项目中的platform.ini 文件进行以下配置

[env:example]
platform = espressif32
board = node32s
board_build.partitions = no_ota.csv
framework = arduino
monitor_speed = 115200
upload_speed = 921600
build_unflags=-std=gnu++11
build_flags=-std=gnu++14
    -DI2C ; for I2C displays

这为基于 I2C 的 SSD1306 设置了一个示例配置。对于 SPI 设备,请删除上面的最后一行。

让我们从声明总线开始。以下是一个支持三种总线样式的通用主模板

#include "common/tft_io.hpp"
using namespace arduino;
#if defined(PARALLEL8)
#define PIN_NUM_BCKL -1
#define PIN_NUM_CS   33  // Chip select control pin (library pulls permanently low
#define PIN_NUM_DC   22  // (RS) Data Command control pin - must use a pin in the range 0-31
#define PIN_NUM_RST  32  // Reset pin, toggles on startup
#define PIN_NUM_WR    21 // Write strobe control pin - must use a pin in the range 0-31
#define PIN_NUM_RD    15 // Read strobe control pin
#define PIN_NUM_D0   2   // Must use pins in the range 0-31 for the data bus
#define PIN_NUM_D1   13  // so a single register write sets/clears all bits.
#define PIN_NUM_D2   26  // Pins can be randomly assigned, this does not affect
#define PIN_NUM_D3   25  // TFT screen update performance.
#define PIN_NUM_D4   27
#define PIN_NUM_D5   12
#define PIN_NUM_D6   14
#define PIN_NUM_D7   4
#elif defined(I2C)
#define TFT_PORT 0
#define PIN_NUM_SDA 21
#define PIN_NUM_SCL 22
#define PIN_NUM_RST -1
#define PIN_NUM_DC -1
#define TFT_ADDR 0x3C
#else
#define TFT_HOST VSPI
#define PIN_NUM_CS 5
#define PIN_NUM_MOSI 23
#define PIN_NUM_MISO 19
#define PIN_NUM_CLK 18
#define PIN_NUM_DC 2
#define PIN_NUM_RST 4
#endif

#ifdef PARALLEL8
using bus_type = tft_p8<PIN_NUM_CS,
                        PIN_NUM_WR,
                        PIN_NUM_RD,
                        PIN_NUM_D0,
                        PIN_NUM_D1,
                        PIN_NUM_D2,
                        PIN_NUM_D3,
                        PIN_NUM_D4,
                        PIN_NUM_D5,
                        PIN_NUM_D6,
                        PIN_NUM_D7>;
#elif defined(I2C)
using bus_type = tft_i2c<TFT_PORT,
                        PIN_NUM_SDA,
                        PIN_NUM_SCL>;
#else
using bus_type = tft_spi<TFT_HOST,
                        PIN_NUM_CS,
                        PIN_NUM_MOSI,
                        PIN_NUM_MISO,
                        PIN_NUM_CLK,
                        SPI_MODE0,
                        PIN_NUM_MISO<0
#ifdef OPTIMIZE_DMA
                        ,(TFT_WIDTH*TFT_HEIGHT)*2+8
#endif
>;
#endif

示例项目中的代码与上述类似。

现在,您可以使用 -DPARALLEL8-DI2C 作为编译器选项,从 SPI 切换到所选的总线类型。为您的显示设备选择合适的。注意我们将引脚分配传递给总线。一旦声明了总线,就不需要进一步的操作来初始化它,因为驱动程序会自动完成。

接下来,我们需要选择适当的显示驱动程序并包含它。我们将使用 SSD1306,因为它们便宜且无处不在。它们通常有 I2C 或 SPI 变体,虽然在内部,它们能够进行并行 I/O,但我从未见过具有该设备并行接口的 breakout 板。

注意实际项目中顶部前一个 include 之后的驱动程序 include

#include "ssd1306.hpp"

现在在 bus_type 的声明下方,我们实例化驱动程序模板,然后声明一个实例,并将总线类型馈送给它

using tft_type = ssd1306<TFT_WIDTH,
                        TFT_HEIGHT,
                        bus_type,
                        TFT_ADDR,
                        TFT_VDC_3_3,
                        PIN_NUM_DC,
                        PIN_NUM_RST,
                        true>;
tft_type tft;

最后,驱动程序已准备好使用,但要对其进行绘制,我们像示例项目一样包含 GFX

#include "gfx_cpp14.hpp"
using namespace gfx;

即使这是单色的,通常也会在 tft_type 的原生像素格式中声明 X11 颜色

using tft_color = 
  color<typename tft_type::pixel_type>;

现在我们可以使用 drawtft 上绘制

draw::filled_rectangle(tft,
                      (srect16)tft.bounds(),
                      tft_color::black);
for(int i = 1;i<100;i+=10) {
    // calculate our extents
    srect16 r(i*(tft.dimensions().width/100.0),
            i*(tft.dimensions().height/100.0),
            tft.dimensions().width-i*
              (tft.dimensions().width/100.0)-1,
            tft.dimensions().height-i*
              (tft.dimensions().height/100.0)-1);

    draw::line(tft,
              srect16(0,
                      r.y1,
                      r.x1,
                      tft.dimensions().height-1),
              tft_color::white);
    draw::line(tft,
              srect16(r.x2,
                      0,
                      tft.dimensions().width-1,
                      r.y2),
              tft_color::white);
    draw::line(tft,
              srect16(0,r.y2,r.x1,0),
              tft_color::white);
    draw::line(tft,
              srect16(tft.dimensions().width-1,
                      r.y1,
                      r.x2,
                      tft.dimensions().height-1),
              tft_color::white);
}

这会在显示屏边框周围绘制一个图案。

绘制并不是本文的重点,但我认为包含它以便您能从头到尾了解代码。

这里的关键点是您的总线类型可以随意选择。对于这个显示屏,正如我之前提到的,它通常有 SPI 和 I2C 变体。通过在platformio.ini 文件中包含 -DI2C 来选择 I2C,这将该开关添加到编译器,从而将 I2C 定义给 C/C++ 预处理器。如果您不指定它,则选择 SPI。此外,还可以选择 -DPARALLEL8,但我从未见过具有该接口的 SSD1306。

但是,我确实有一个带有并行接口的 ILI9341。如果您有一个,可以使用ili9341.hpp 驱动程序与 -DPARALLEL8 接口,以及随附的wiring_guide.txt 将所有内容连接起来。使用驱动程序完全相同,只是设置略有不同,并且绘制代码可以与任何设备一起使用。只需确保适当设置您的 #define,例如宽度和高度。还要注意,一些驱动程序比其他驱动程序接受不同的模板参数。

使用其他设备大致相同。只需选择一个总线,包含适当的头文件和驱动程序实例化,并将所有内容连接起来。

工作原理

现在我们进入重点。

我们利用了这样一个事实:在几乎所有设备上,都有类似所需的行为。例如,设备有命令和数据。数据通常是命令的参数,但有时是像素流,尽管从技术上讲,这是内存写入命令的一个 BLOB 参数。无论如何,在 SPI 设备上,您通常有一个额外的“DC”线,用于在命令和数据之间切换。I2C 也有类似的东西,除了切换是通过每个 I2C 事务的第一个字节中的一个代码指示的。并行总线也有一个 DC 线,尽管它通常称为 RS,但它的功能与 SPI 版本相同。

这里的想法是我们将扩展总线 API 的表面区域,以包含适用于任何类型总线的所有内容,例如,您可能拥有 begin_transaction()end_transaction(),对于 SPI 来说,它们定义了事务边界,但在并行版本中什么也不做。

I2C 总线相当直接,但 SPI 总线和并行总线由于具有特定于处理器的优化而变得复杂得多。应指出的是,并行总线的通用实现在我对该测试中不起作用。我认为这是一个时序问题,当我有时间和动力时,我至少可以再尝试一件事。目前,它基本上是 ESP32 和 STM32 ARMs 的一个功能。

使用模板进行总线和驱动程序的一个优点是,不同的参数会产生不同的具体类,这意味着任何静态变量都特定于该模板实例化。其好处是,您可以运行多个显示屏,无论是相同类型还是不同类型。这与 TFT_eSPI 形成对比,后者由于使用应用程序范围的全局变量和非每个设备的静态变量,因此只能驱动单个显示类型。

tft_core.hpp 包含所有总线类型的一些基本通用代码。tft_spi.hpptft_i2c.hpptft_parallel8.hpp 各包含相应的总线类型,而 tft_io.hpp 仅包含所有这些,并且是推荐使用的头文件。

tft_driver.hpp 用于驱动总线。它驱动 SPI 的 DC 线。它还向总线指示它是命令模式还是数据模式,但目前唯一需要该信息的总线是 I2C,它不使用 DC 线,而是在每个事务负载中使用一个前导字节代码,如前所述。总线可以与也驱动总线的 tft_driver 一起驱动,利用我们在此部分开头提到的共性。一些终端驱动程序代码直接驱动总线,而不是通过 tft_driver,主要是出于性能原因。

这里的一个难题是 SPI 可以进行 DMA,这允许异步 I/O 操作与您的 CPU 任务并行运行。基本上,在非 SPI 设备上,我们将 dma_wait()(等待挂起的 DMA 操作)视为无操作,并通过 write_raw_dma() 将其转发到非 DMA 函数,同步执行 I/O。当 DMA 可用时,将存在 OPTIMIZE_DMA 定义。要启用 DMA,必须将 DMA 传输的最大大小指定为 SPI 总线参数,并且应包含 8 字节的填充。

结论

我相信将总线与物联网显示驱动程序解耦是一种新颖的方法,可以灵活地支持物联网上各种各样的显示配置。使用模板进行此操作以及驱动程序本身,可以在保持运行时性能的同时提供很大的灵活性。

希望这段代码能启发您在物联网项目中使用 GFX。通过 GFX,您可以执行高级功能,如 alpha 混合、JPG 显示和 TrueType 字体,让您创建时尚现代的界面。通过这个新的驱动程序框架,您可以获得所有这些功能,以及比以前代码更好的帧率。

尽情享用!

历史

  • 2022 年 2 月 12 日 - 初次提交
  • 2022 年 2 月 18 日 - 更新和简化代码。添加了更多驱动程序。
© . All rights reserved.