试用 LVGL Pro,一套完整的工具包,助您高效构建、测试、分享和交付 UI!
LVGL
教程

ESP32 的纯 MicroPython 显示驱动程序

使用 DMA 和双缓冲为 ESP32 构建纯 MicroPython ILI9341 显示驱动程序,包含性能基准测试和见解。

Amir GonnenAmir Gonnen29 分钟阅读

我为 ESP32 上的 ILI9341 创建了一个纯 MicroPython 显示驱动程序。在这里。

在这个意义上,"纯 MicroPython" 意味着所有逻辑都在 MicroPython 中实现,并使用 LVGL 和 ESP-SDK 库的标准 API。

LVGL 本身非常便携,可以在许多硬件设备和架构上使用。然而,它需要一个特定于运行硬件的显示驱动程序。因此,如果你使用 ESP32 并希望在 ILI9341 显示器上显示漂亮的 GUI,你需要一个 "LVGL-ESP32-ILI9341" 显示驱动程序。

LVGL 项目已经提供了该驱动程序的 C 实现,甚至还有一个适用于它的 MicroPython API,但我想创建同一驱动程序的纯 MicroPython 实现。

此驱动程序使用 DMA 和双缓冲来实现高性能。在本文末尾,你可以找到性能统计数据和关于使用 MicroPython 作为显示驱动程序的结论。

此显示驱动程序可以在两种模式下运行:

  • 纯 MicroPython 模式 - 所有显示驱动程序逻辑都在 MicroPython 中完成。
  • 混合模式 - 设置和初始化在 MicroPython 中完成,但关键路径(flush 和 ISR 函数)在 C 中实现。

为什么?#

为什么我要用 MicroPython 重写一个已经用 C 编写的模块?

有三个原因:

  • 这是与 MicroPython 绑定相关的许多有趣/高级功能的展示
  • 我想看看它与 C 驱动程序相比的性能如何
  • 它使得可以动态加载驱动程序

以下是纯 MicroPython 显示驱动程序中使用的一些有趣功能:

对任何 C 库的 MicroPython API#

驱动程序需要与 LVGL 和 ESP-IDF 交互。LVGL 提供用于注册驱动程序的 C API,ESP-IDF 提供用于与 ESP32 硬件交互、访问 GPIO 和 SPI 设备的 C API。

传统上,当你想向 MicroPython 公开 C API 时,你必须添加一个原生 MicroPython 模块。这意味着用特殊代码包装每个 C API 函数以转换参数,然后在 MicroPython 中注册它。对于几个函数,这还不错,但对于大型库来说,创建和维护变得繁琐。

另一件事是结构体和指针的问题。C 大量使用它们,但 MicroPython 实际上没有指针和结构体的概念,因此在手动创建 API 时需要以特殊方式处理它们。LVGL 是一个大型 C 库,手动创建 lvgl MicroPython 模块是不可能的。

相反,我创建了 MicroPython LVGL 绑定,其核心是一个 Python 脚本,它解析 C 头文件并从中自动生成 MicroPython 模块。自动生成的 MicroPython 模块不仅处理函数调用。它还提供了结构体、枚举、回调函数等的 API。

我一直在研究这个,然后我意识到 MicroPython 绑定可以在更广泛的范围内使用。

为什么只用于 LVGL?其他库呢?

在这篇博客文章中,我将向你展示如何使用 MicroPython 绑定为 ESP-IDF 创建 MicroPython API,以及 LVGL MicroPython API!

顺便说一下,我还用它为 lodepng 提供了一个 API,这是一个用于编码和解码 PNG 文件的库。但这超出了本博客文章的范围。如果你对此感兴趣,请查看这个示例脚本。

在 MicroPython 中使用指针#

C API 通常严重依赖指针。一些 C 函数分配或使用缓冲区,这些缓冲区作为指针传递。许多函数接收一个指针并更新它所指向的值,而不是返回一个值。

另一方面,MicroPython 就像 Python 一样,不需要也没有像 C 中那样的原生指针。在 Python(以及其他语言如 JavaScript)中,对象是通过引用访问的。没有指针本身,没有指针解引用,没有指针算术等。

MicroPython 绑定弥合了这个差距 - 它使得可以在 MicroPython 中使用指针。可以将它们传递给函数,将它们分配给结构体中的字段,解引用它们,或将它们转换为 MemoryView。

纯 MicroPython 显示驱动程序在几个上下文中使用指针:

  • 用于分配 DMA 可用的 RAM
  • 发送 DMA 事务时
  • 在接收指针的 ESP-IDF 函数上。

从 C 到 MicroPython 的回调函数#

当我们想注册一个 C 回调函数时,我们传递一个函数指针。但是如果我们想让回调函数调用 MicroPython 代码呢?

纯 MicroPython 显示驱动程序代码展示了几个回调函数用例:

  • 将 C 函数注册为回调函数(调用者和被调用者都在 C 中定义,但回调函数注册由 MicroPython 代码执行)。
  • 注册 MicroPython 数据以传递给 C 回调函数。
  • 在 LVGL API 上将 MicroPython 函数注册为回调函数,该 API 支持 MicroPython 回调函数。(调用者在 C 中定义,而被调用者在 MicroPython 中定义)。
  • 在 ESP-IDF API 上将 MicroPython 函数注册为回调函数,该 API 不支持 MicroPython 回调函数,因此需要特殊处理。

MicroPython 中的中断处理#

MicroPython 允许你在 MicroPython 中编写 ISR 并处理中断,而无需在 C 中为其编写专用函数。这涉及几个限制和注意事项。

纯 MicroPython 显示驱动程序实现了一个 DMA 完成中断,以向 LVGL 发出显示 "flush" 已完成的信号。

如何?#

以下是来自 ili9341.py、espidf.h、espidf.c 的片段,以说明和解释纯 MicroPython 驱动程序中的不同功能。

自动生成的 C API#

import espidf as esp
import lvgl as lv
python

这两个模块由 MicroPython 绑定脚本自动生成。

  • lvgl 从 lvgl.h 生成,并提供对整个 LVGL 库的 API。
  • espidf 从 espidf.h 生成,以提供对某些 ESP-IDF 的 API。

以下是 espidf.h 中最重要的部分:

// 以下包含是 esp-idf micropython 模块的源。
// 所有包含的文件都是我们想要包含在模块中的 API
 
#include "driver/gpio.h"
#include "driver/spi_master.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_clk.h"
c

所以我们没有提供完整的 ESP-IDF API。那将是巨大的。目前,只添加了这些包含文件中的函数和结构体的 API。

那么 espidf.h 的其余内容是什么?

  • 一些包含保护定义(例如 #define INC_FREERTOS_H)。这是一个技巧,用于防止应用内部包含。虽然我们对上面包含的文件感兴趣,但这些文件又包含了我们不感兴趣的其他头文件(我们不想要整个 FreeRTOS API!)。这个技巧将排除它们。
  • 一些我们想包含在 ESP-IDF API 中的辅助函数,尽管它们实际上不是其中的一部分。例如,get_ccount 用于测量 CPU 周期。
  • 一些额外的枚举。枚举将成为 API 的一部分,而定义不会,所以我添加了我认为有用的内容。
  • ILI9341 混合模式函数(例如 ili9341_flush)。这些函数是可选的,纯 MicroPython 显示驱动程序不需要它们。但是当启用混合模式时,这些函数用于关键路径,驱动程序性能显著提高。有关更多详细信息,请参见下面的"性能"部分。

在构建 MicroPython 项目 的 ESP32 移植版本时,Makefile 包含生成这些模块的规则。例如:

#esp-idf 生成的模块
ESPIDFMOD_SOURCE = $(TOP)/lib/lv_bindings/driver/esp32/espidf.h
ALL_ESPIDFMOD_SRC = $(shell find $(subst -I,,$(INC_ESPCOMP)) -type f) $(ESPIDFMOD_SOURCE) $(SDKCONFIG_H)
ESPIDFMOD_MODULE = $(BUILD)/espidfmod/mp_espidf.c
ESPIDFMOD_PP = $(BUILD)/espidfmod/mp_espidf.pp.c
CFLAGS_MOD += $(ESPIDFMOD_CFLAGS) -Wno-deprecated-declarations
 
$(ESPIDFMOD_MODULE): $(ALL_ESPIDFMOD_SRC) $(LVGL_BINDING_DIR)/gen/gen_mpy.py
    $(ECHO) "ESPIDFMOD-GEN $@"
    $(Q)mkdir -p $(dir $@)
    $(Q)$(CPP) $(ESPIDFMOD_CFLAGS) -DPYCPARSER -I $(LVGL_BINDING_DIR)/pycparser/utils/fake_libc_include $(INC) $(INC_ESPCOMP) $(ESPIDFMOD_SOURCE) > $(ESPIDFMOD_PP)
    $(Q)$(PYTHON) $(LVGL_BINDING_DIR)/gen/gen_mpy.py -M espidf -E $(ESPIDFMOD_PP) $(ESPIDFMOD_SOURCE) > $@
makefile

这本质上做了两件重要的事情:

  • 通过 C 预处理器预处理 espidf.h。这应用了所有嵌套的 "includes" 并生成一个包含我们想要生成的整个 API 的单个预处理文件。
  • 在预处理文件上运行 gen_mpy.py。这就是魔法发生的地方,C 函数和结构体被转换为 MicroPython 模块。此步骤的输出是 mp_espidf.c,这是 ESP-IDF MicroPython 模块的实现。

最终,ESP-IDF MicroPython 模块被编译、链接并在项目中引用,使其可供 MicroPython 脚本导入。

那么生成的 API 看起来如何?

MicroPython v1.11-194-g29bbfd8-dirty on 2019-07-22; ESP32 module with ESP32
Type "help()" for more information.
>>>
>>> import espidf as esp
>>> help(esp)
object <module 'espidf'> is of type module
  __name__ -- espidf
  get_ccount -- <function>
  esp_err_to_name -- <function>
  esp_err_to_name_r -- <function>
  _esp_error_check_failed -- <function>
  _esp_error_check_failed_without_abort -- <function>
  gpio_init -- <function>
  gpio_output_set -- <function>
  gpio_output_set_high -- <function>
  gpio_input_get -- <function>
  gpio_input_get_high -- <function>
  gpio_intr_handler_register -- <function>
  gpio_intr_pending -- <function>
  gpio_intr_pending_high -- <function>
  gpio_intr_ack -- <function>
  gpio_intr_ack_high -- <function>
  gpio_pin_wakeup_enable -- <function>
  gpio_pin_wakeup_disable -- <function>
  gpio_matrix_in -- <function>
  gpio_matrix_out -- <function>
  gpio_pad_select_gpio -- <function>
  gpio_pad_set_drv -- <function>
  gpio_pad_pullup -- <function>
  gpio_pad_pulldown -- <function>
  gpio_pad_unhold -- <function>
  gpio_pad_hold -- <function>
  [... 更多函数 ...]

当然,它可以更大或更小,具体取决于提供给 MicroPython 绑定脚本的 H 文件中的内容。

很容易在 API 中包含太多函数/结构体。这是因为公共 C API 可能包含私有头文件,这些头文件通常不被用户使用。但脚本不知道这一点,并从预处理文件中获取每个函数/结构体/枚举。

要克服这个问题,你可以:

  • 禁用某些包含(例如通过定义某些包含保护)
  • 或编写一个单独的 C API 头文件,将调用委托给真实的 API。(调用将在脚本不考虑的 C 文件上)。这需要更多工作,但具有让你控制 API 并使其更 "python 友好" 的优势。

另一件要考虑的事情是 C 宏不由绑定脚本处理,因为它分析预处理文件。解决方法是将宏包装在枚举中,该枚举被转换为 MicroPython。

来自 espidf.h 的示例:

// 有用的常量
 
enum{
    CAP_DMA = MALLOC_CAP_DMA,
    CAP_INTERNAL = MALLOC_CAP_INTERNAL,
    CAP_SPIRAM = MALLOC_CAP_SPIRAM
};
c

这将生成一个 MicroPython 对象 esp.CAP,该对象对每个枚举成员都有一个字段。例如,这里我们使用 esp.CAP.DMA:

trans_buffer = esp.heap_caps_malloc(trans_buffer_len, esp.CAP.DMA)
python

几个注释:

  • 理想情况下,向 MicroPython 添加新 API 的过程也应该自动化,这样将 C 库转换为 MicroPython 将更加容易,并且不需要处理 Makefile 脚本,这从来都不是愉快的。
  • 应该更改一些命名。也许 gen_mpy.py 应该被称为 gen_upy.py 等。

ILI9341 python 类#

class ili9341:
 
    width = const(240)
    height = const(320)
 
    ######################################################
 
    disp_buf = lv.disp_buf_t()
    disp_drv = lv.disp_drv_t()
 
    def __init__(self, miso=5, mosi=18, clk=19, cs=13, dc=12, rst=4, backlight=2, spihost=esp.enum.HSPI_HOST, mhz=40, factor=4, hybrid=True):
 
        # 确保 Micropython 的构建使得颜色在 DMA 之前不需要处理
 
        if lv.color_t.SIZE != 2:
            raise RuntimeError('ili9341 micropython 驱动程序需要定义 LV_COLOR_DEPTH=16')
        if not hasattr(lv.color_t().ch, 'green_l'):
            raise RuntimeError('ili9341 micropython 驱动程序需要定义 LV_COLOR_16_SWAP=1')
python

在声明类时,我们首先检查颜色模式是否正确。LVGL 可以配置为使用不同的颜色模式,从 1 位单色到 32 位带 alpha 通道的 RGB。为了使纯 Python 显示驱动程序正确工作,LVGL 颜色模式必须与 ILI9341 颜色模式 16 位 RGB565 匹配。如果我们不这样做,我们将不得不将每个像素转换为 RGB565,这对于显示驱动程序来说是很多工作。

因此,通过使用此参数构建 lv_micropython 来实现:

LV_CFLAGS="-DLV_COLOR_DEPTH=16 -DLV_COLOR_16_SWAP=1"

使用指针#

如果我们只是传递指针,这非常简单。每个指针都包装在 MicroPython "Blob" 对象中,该对象可在 API 函数之间转换为指针,即使它们属于不同的库。例如:

self.buf1 = esp.heap_caps_malloc(self.buf_size, esp.CAP.DMA)
self.buf2 = esp.heap_caps_malloc(self.buf_size, esp.CAP.DMA)
 
...
 
lv.disp_buf_init(self.disp_buf, self.buf1, self.buf2, self.buf_size // lv.color_t.SIZE)
python

这里我们使用 ESP-IDF API 函数分配两个支持 DMA 的缓冲区,并将它们传递给初始化显示缓冲区的 LVGL 函数。

但是如果我们想在 MicroPython 中读取或写入指针所指向的数据呢?每个 "Blob" 都有一个 __dereference__ 函数,允许你将其转换为 MemoryView。

trans_buffer_len = const(16)
trans_buffer = esp.heap_caps_malloc(trans_buffer_len, esp.CAP.DMA)
 
...
 
def send_data(self, data):
    esp.gpio_set_level(self.dc, 1)	   # 数据模式
    if len(data) > self.trans_buffer_len: raise RuntimeError('Data too long, please use DMA!')
    trans_data = self.trans_buffer.__dereference__(len(data))
    trans_data[:] = data[:]
    self.spi_send(trans_data)
python

send_data 函数接收 data,这是一个字节数组,并通过调用 spi_send 发送它。但 spi_send 期望接收支持 DMA 的缓冲区!我们需要将 data 复制到支持 DMA 的内存中。因此,我们准备 trans_buffer,它是指向支持 DMA 的内存的指针,并将 data 复制到其中。为此,我们解引用 trans_buffer(self.trans_buffer.__dereference__(len(data))),并将数据复制到其中(trans_data[:] = data[:])

另一种情况是使用指针作为函数输出。如果我们有一个 API 函数接收指针并更新它所指向的数据呢?

例如:

esp_err_t spi_bus_add_device(spi_host_device_t host, const spi_device_interface_config_t *dev_config, spi_device_handle_t *handle);
c

在此函数上,dev_config 是输入。这很简单,只需创建一个结构体,填充它,然后传递过去。

例如:

devcfg = esp.spi_device_interface_config_t({
    "clock_speed_hz": self.mhz*1000*1000,   # 时钟速度为 DISP_SPI_MHZ MHz
    "mode": 0,                              # SPI 模式 0
    "spics_io_num": self.cs,                # CS 引脚
    "queue_size": 2,
    "flags": esp.ESP.HALF_DUPLEX,
    "duty_cycle_pos": 128,
})
 
...
 
ret = esp.spi_bus_add_device(self.spihost, devcfg, ptr_to_spi)
python

顺便说一下,在这里,我们使用字典通过将其传递给 esp.spi_device_interface_config_t 构造函数来初始化结构体。这仅在结构体初始化时可能。在其他情况下,字段可以作为对象属性访问。例如 devcfg.pre_cb = None 将为 devcfg 结构体的 pre_cb 字段分配 NULL。

但是 spi_bus_add_device 的最后一个参数呢?spi_device_handle_t *handle 是输出。我们需要创建一个指针,将其传递给 spi_bus_add_device 并解引用它。技巧是使用名为 C_Pointer 的结构体,如下所示:

ptr_to_spi = esp.C_Pointer()
ret = esp.spi_bus_add_device(self.spihost, devcfg, ptr_to_spi)
if ret != 0: raise RuntimeError("Failed adding SPI device")
self.spi = ptr_to_spi.ptr_val
python

在这种情况下,我们将 C_Pointer 类型的 ptr_to_spi 传递给函数,然后访问 ptr_to_spi.ptr_val 以解引用它并提取 spi_bus_add_device 写入的输出值。那么这个 C_Pointer 是什么?它是一个简单的辅助联合,如下所示:

typedef union {
    void *ptr_val;
    int int_val;
    unsigned int uint_val;
    const char *str_val;
} C_Pointer;
c

所以我们创建一个 C_Pointer 实例,将其传递给一个函数,该函数向其中写入内容(字符串、int 或另一个指针,如我们的情况)。函数更新后,通过访问正确的联合字段,很容易从中提取相关数据。

使用回调函数#

MicroPython C API 上的回调函数是一个微妙的主题。虽然在 C 中你只需要一个函数指针(仅代码),但在 MicroPython 上需要一个可调用对象(其中包含数据,而不仅仅是代码)。因此,当我们想要注册一个要从 C 调用的 MicroPython 函数时,我们需要找到一种方法在 C 上记录可调用对象并将其传递给回调函数。

在 LVGL 上,这是使用 user_data 字段完成的,该字段存在于使用回调函数的结构体中。在不遵循此约定的其他库上,你可能需要包装回调函数注册/调用,并找到另一种方法传递可调用对象。

disp_drv = lv.disp_drv_t()
 
def __init__(self, miso=5, mosi=18, clk=19, cs=13, dc=12, rst=4, backlight=2, spihost=esp.enum.HSPI_HOST, mhz=40, factor=4, hybrid=True):
 
    ...
    self.disp_drv.user_data = {'dc': self.dc, 'spi': self.spi}
    self.disp_drv.flush_cb = esp.ili9341_flush if self.hybrid and hasattr(esp, 'ili9341_flush') else self.flush
    self.disp_drv.monitor_cb = self.monitor
python

在这里,显示驱动程序实例化并填充 disp_drv_t 结构体。此结构体描述显示驱动程序。显示驱动程序实现两个回调函数:flush_cb 和 monitor_cb。

对于 flush_cb:

  • 如果在混合模式下运行,回调函数设置为 flush 的 C 实现(esp.ili9341_flush)。这种情况只是指针分配。
  • 如果不在混合模式下运行(而是在 "纯 python" 模式下),flush 注册为 self.flush 函数,这是 flush 的纯 python 实现。

在幕后,对 flush_cb 的分配将可调用对象 self.flush 保存在 disp_drv 的 user_data 成员中,这是自动的并且对用户隐藏。稍后,我将展示在 ESP-IDF 上注册回调函数的示例,其中回调函数约定不像在 LVGL 中那样遵循,并且可调用对象到 user_data 的复制不会自动发生。

考虑混合模式的情况。在这种情况下,我们将 C 函数 esp.ili9341_flush 注册到 flush_cb。当 LVGL 想要将显示数据 flush 到显示器时,它会调用它。esp.ili9341_flush 反过来应该访问 SPI 设备并 DMA 数据。然而,SPI 设备是在 MicroPython 代码中初始化的 - esp.ili9341_flush 如何访问它?

在 LVGL 上,每个回调函数都接收一个结构体作为第一个参数,该结构体包含与回调函数相关的数据,在我们的情况下 - lv_disp_drv_t。每个这样的结构体都包含一个名为 user_data 的字段,当注册 MicroPython 函数时,该字段保存可调用对象。我们可以使用它来存储我们自己的自定义数据,除了可调用对象!

self.disp_drv.user_data = {'dc': self.dc, 'spi': self.spi}
python

esp.ili9341_flush 需要 SPI 设备和 DC 引脚,这些是在 MicroPython 端初始化的,以便完成其工作。user_data 是 lv_disp_drv_t 的 void * 成员。但由于它被初始化为 python dict,因此可以转换回去。让我们在驱动程序初始化后在 ESP32 交互式控制台上查看它:

MicroPython v1.11-330-g6f2b2e2-dirty on 2019-07-29; ESP32 module with ESP32
Type "help()" for more information.
>>> import ili9341
ILI9341 initialization completed
Enable backlight
Double buffer
>>> print(ili9341.disp.disp_drv.user_data.cast())
{'lv_disp_drv_t_flush_cb': <function>, 'lv_disp_drv_t_monitor_cb': <bound_method>, 'dc': 12, 'spi': Blob}

如你所见,user_data 字典包含我们的 dc 和 spi 成员,以及 flush_cb 和 monitor_cb 的可调用对象。

现在,esp.ili9341_flush 如何从 user_data 中提取这些数据?让我们看看它:

void ili9341_flush(void *_disp_drv, const void *_area, void *_color_p)
{
    ...
 
    // 我们使用 disp_drv->user_data 将数据从 MP 传递到 C
    // 以下行提取 dc 和 spi
 
    int dc = mp_obj_get_int(mp_obj_dict_get(disp_drv->user_data, MP_OBJ_NEW_QSTR(MP_QSTR_dc)));
    mp_buffer_info_t buffer_info;
    mp_get_buffer_raise(mp_obj_dict_get(disp_drv->user_data, MP_OBJ_NEW_QSTR(MP_QSTR_spi)), &buffer_info, MP_BUFFER_READ);
    spi_device_handle_t *spi_ptr = buffer_info.buf;
c

dc 以直接的方式读取 - 从 user_data 获取字典并将 dc 字段(其名称作为 qstr 给出)转换为 int。另一方面,spi 是一个指针。指针使用缓冲区协议保存,因此使用 mp_get_buffer_raise 来提取它。

到目前为止,我向你展示了如何将回调函数(C 或 MicroPython)注册到 LVGL,LVGL 旨在自动将可调用对象保存在 user_data 中。但是如果 API 不是这样设计的呢?

当 DMA 完成时,ESP-IDF 调用 post_cb 函数(在 ISR 上下文中,但对于这一点来说无关紧要)。我们可能想将 MicroPython 函数注册为 post_cb,但 post_cb 不接收带有 user_data 成员的结构体,因此绑定脚本无法自动将可调用对象保存在那里。

幸运的是,post_cb 接收一个带有 user 字段的结构体,该字段可以完全用于此目的!我们将使用 user 字段来传递可调用对象,但这次,不使用 dict。相反,user 将包含一个新的 spi_transaction_ptr_type 类型的对象,该对象保存回调函数的信息,例如 post_cb 可调用对象。

DEFINE_PTR_OBJ_TYPE(spi_transaction_ptr_type, MP_QSTR_spi_transaction_ptr_t);
 
typedef struct{
    mp_ptr_t spi_transaction_ptr;
    mp_obj_t pre_cb;
    mp_obj_t post_cb;
} mp_spi_device_callbacks_t;
 
void *spi_transaction_set_cb(mp_obj_t pre_cb, mp_obj_t post_cb)
{
    mp_spi_device_callbacks_t *callbacks = m_new_obj(mp_spi_device_callbacks_t);
    callbacks->spi_transaction_ptr.base.type = &spi_transaction_ptr_type;
    callbacks->pre_cb = pre_cb != mp_const_none? pre_cb: NULL;
    callbacks->post_cb = post_cb != mp_const_none? post_cb: NULL;
    return callbacks;
}
c

spi_transaction_set_cb 是在 espidf.h 上声明的 C 函数,因此它将自动包含在 ESP-IDF MicroPython API 中。它接收两个可调用对象 pre_cb 和 post_cb,在 spi_transaction_ptr_type 对象中注册它们并返回。

显示驱动程序的 MicroPython 代码调用此函数:

def disp_spi_init(self):
    ...
    if self.hybrid and hasattr(esp, 'ili9341_post_cb_isr'):
        devcfg.pre_cb = None
        devcfg.post_cb = esp.ili9341_post_cb_isr
    else:
        devcfg.pre_cb = esp.spi_pre_cb_isr
        devcfg.post_cb = esp.spi_post_cb_isr
 
    ...
    self.spi_callbacks = esp.spi_transaction_set_cb(None, flush_isr)
...
def spi_send_dma(self, data):
    ...
    self.trans.user = self.spi_callbacks
    esp.spi_device_queue_trans(self.spi, self.trans, -1)
python

初始化代码注册 flush_isr 回调函数,并保存 spi_callbacks 结果(类型为 spi_transaction_ptr_type)。发送 DMA 时,事务 user 字段设置为该 spi_callbacks。在纯 python 模式下,post_cb C 回调函数设置为 esp.spi_post_cb_isr,它从 user 字段中提取回调函数:

void spi_post_cb_isr(spi_transaction_t *trans)
{
    mp_spi_device_callbacks_t *self = (mp_spi_device_callbacks_t*)trans->user;
    if (self) {
        self->spi_transaction_ptr.ptr = trans;
        cb_isr(self->post_cb, MP_OBJ_FROM_PTR(self));
    }
}
c

cb_isr 接收 post_cb 可调用 MicroPython 对象,并将在 ISR 上下文中调用它。

在 ISR 上下文中调用 MicroPython 代码#

当你想在 ISR 上下文中运行 MicroPython 时,你有两个选项:

  • 安排 MicroPython 函数在 MicroPython 核心准备好时运行。
  • 立即运行 MicroPython - 有一些限制。

最简单的做法是安排 MicroPython。这意味着 MicroPython 不会在 ISR 上下文中运行,而是在标准用户模式 FreeRTOS 任务上下文中稍后运行。缺点是该函数不会立即运行。它需要一些时间,可能需要上下文切换等。如果你想立即做某事怎么办?

两个选项:

  • 在 C 中编写 ISR。
  • 立即运行 MicroPython - 有一些限制。

在 ISR 中直接运行 MicroPython 有哪些限制?

MicroPython 文档有一章正好讨论这个问题,如何在 MicroPython 中编写中断处理程序。ESP32 显然有一些额外的复杂性,但这仍然是可行的。

在纯 MicroPython 驱动程序中,我们注册 flush_isr MicroPython 函数,当在纯 MicroPython 模式(非混合)下运行时,在 DMA 完成 post_cb 调用时在 ISR 上下文中直接调用

性能#

测试条件:

  • SPI 配置为 40MHz
  • 每帧发送约 104KB(即 ILI9341 显示区域的约 70% 在每帧刷新)
  • DMA,双缓冲,两个非全屏大小的缓冲区
  • 每个显示缓冲区占用显示区域的 25%
  • LVGL 配置为 LV_COLOR_DEPTH=16 和 LV_COLOR_16_SWAP=1,这使显示缓冲区与 ILI9341 兼容,并且不需要处理每个像素。否则,每个像素都必须转换为正确格式的 RGB。
  • LVGL 每帧刷新屏幕的大区域,但不会做很多工作来渲染它。这是因为我们正在测量显示驱动程序性能,而不是 lvgl 渲染性能,所以我们不希望 lvgl 成为此测量的瓶颈。

那么,在时间预算中,LVGL 渲染时间隐藏在哪里?由于此驱动程序使用双缓冲,两件事同时发生:DMA 到缓冲区 A 和渲染到缓冲区 B!(然后在下一帧切换,DMA 到缓冲区 B 和渲染到缓冲区 A),这就是双缓冲背后的想法。因此,只要 LVGL 渲染时间 ≤ DMA 时间,渲染时间根本不影响 FPS。为了此测试的目的,我保持 LVGL 渲染时间足够低(比 DMA 时间短),这样我们可以忽略它。

结果:

纯 MicroPython 驱动程序#

  • 每帧 35ms(28.5FPS)
  • DMA 在 35ms 中占 20ms
  • MicroPython 代码在 35ms 中占 15ms

混合 MicroPython 驱动程序#

SPI 设置、ILI9341 初始化等保留在 MicroPython 中。只有关键的 "flush" 函数和中断处理在 C 中完成(参见这里的 ili9341_flush)

  • 每帧 20ms(50FPS)
  • 限制因素是 DMA 和 ILI9341。从 DMA 的角度来看,使用更高的 SPI 频率(80MHz)可能实现高达 100FPS 的更高 FPS,但需要使用专用 IO 引脚(IOMUX 而不是 GPIO 矩阵),因此未测试。从 ILI9341 的角度来看,以高于 10MHz 的速度运行它超出了规格,但这是一种常见做法。许多人以高达 40Mhz 的更高速率运行它,有些甚至高达 80Mhz!

结论#

用纯 MicroPython 编写显示驱动程序有意义吗?可能没有,至少对于关键路径来说没有。在 MicroPython 中编写设置代码仍然有用(例如设置 SPI 和初始化 ILI9341),但每帧支付 15ms 只是为了运行 MicroPython 代码来将数据 flush 到显示器,对我来说感觉太多了。

但仍有希望!MicroPython 有一个原生代码发射器和 Viper 代码发射器,它们将 MicroPython 代码直接转换为原生 CPU 操作码!这将显著提升性能。不幸的是,这些代码发射器目前还不适用于 ESP32。让我们希望有一天它们会可用。

关于作者

Amir Gonnen
Amir Gonnen

社区贡献者

LVGL MicroPython 绑定的开发者和维护者,使嵌入式设备上的 Python UI 开发成为可能。

认识博客背后的作者们

了解那些分享 LVGL 知识的优秀作者们

查看作者

订阅我们的通讯 不错过任何关于 LVGL 的新闻。我们每月最多发送 2 封邮件。

LVGL

LVGL 是最受欢迎的免费开源嵌入式图形库,支持任何 MCU、MPU 和显示类型,助您构建精美的用户界面。

我们还提供 UI 设计、实现和咨询等服务。

© 2026 LVGL。保留所有权利。
YouTubeGitHubLinkedIn