我为 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 lvpython这两个模块由 MicroPython 绑定脚本自动生成。
以下是 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)pythonsend_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_valpython在这种情况下,我们将 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.monitorpython在这里,显示驱动程序实例化并填充 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}pythonesp.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;cdc 以直接的方式读取 - 从 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;
}cspi_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));
}
}ccb_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。让我们希望有一天它们会可用。
