向ESP32-S3移植LVGL并测试性能

乐鑫最新的ESP32-S3芯片发布了,正好一直有做一个信息牌的想法,ESP32-S3就成了最好的选择。本文介绍用于测试的硬件,以及如何基于ESP32-S3移植LVGL并测试运行效果。

ESP32-S3提供了专用的LCD控制器,可以控制8080并口与16位RGB接口的LCD面板。由于ESP32-S3的内部RAM容量实在是有限,放不下完整的全屏缓冲区,并且PSRAM的带宽非常有限,因此最终还是选用了16位8080接口的LCD。

为了测试,我先设计了一块基于ESP32-S3-WROOM-1模组的PCB。PCB采用四层板结构,PCB上除了模组之外主要有USB接口、DC-DC、蜂鸣器、按键以及屏幕排线接口。屏幕选用的是40Pin的480*320标准16位并口屏,这种屏幕有各种主控芯片与面板的组合可供选择,灵活性较大。

https://img.yuanze.wang/posts/lvgl-on-esp32s3/esp32s3-pcb-front.jpg
PCB正面

https://img.yuanze.wang/posts/lvgl-on-esp32s3/esp32s3-pcb-back.jpg
PCB正面

软件方面,自然还是使用老朋友ESP-IDF。随着测试版4.4的发布,还带来了一个新的模块ESP-LCD。这个模块总共分成了3层,分别是总线层、屏幕设备层与屏幕面板层,内部封装了各种总线的LCD的总线驱动,使用起来非常方便。

驱动方面,我并没有使用ESP-LCD提供的第三层屏幕面板层,而是在第二层的基础上直接进行开发。下面是ESP-LCD的总线层与屏幕设备层的初始化代码,该代码接收总线频率、单次传输最大数据量与传输完成回调函数为参数,返回屏幕面板的句柄。

esp_lcd_panel_io_handle_t lcd_i80_bus_io_init(uint16_t pclk_mhz, uint16_t transfer_size, esp_lcd_panel_io_color_trans_done_cb_t trans_done_cb)
{
    /* 初始化背光 */
    lcd_bl_init();

    /* 初始化8080总线:16位数据,DC与WR脚 */
    esp_lcd_i80_bus_handle_t i80_bus = NULL;
    esp_lcd_i80_bus_config_t bus_config = {
        .dc_gpio_num = BSP_LCD_DC_PIN,
        .wr_gpio_num = BSP_LCD_WR_PIN, //DC与WR引脚
        .data_gpio_nums = BSP_LCD_DATA_PINS,
        .bus_width = 16, //总线宽度16位
        .max_transfer_bytes = transfer_size, //缓冲区大小
    };
    esp_lcd_new_i80_bus(&bus_config, &i80_bus);

    /* 初始化LCD面板,使用上述8080总线,并设置CS引脚以及总线频率 */
    esp_lcd_panel_io_handle_t io_handle;
    esp_lcd_panel_io_i80_config_t io_config = {
        .cs_gpio_num = BSP_LCD_CS_PIN, //CS引脚
        .pclk_hz = pclk_mhz*1000000, //总线时钟频率
        .trans_queue_depth = 1,
        .dc_levels = {
            .dc_idle_level = 1,
            .dc_cmd_level = 0,
            .dc_dummy_level = 1,
            .dc_data_level = 1,
        },
        .lcd_cmd_bits = 8,
        .lcd_param_bits = 8, //指令与指令参数的长度
        .on_color_trans_done = trans_done_cb,
    };
    esp_lcd_new_panel_io_i80(i80_bus, &io_config, &io_handle);

    return io_handle;
}

由于40Pin接口是通用接口,有非常多型号的驱动IC与面板可选,同时对于最常见的ST7796ILI9488,他们的关键指令(例如控制显存指针与写入的0x2A 0x2B 0x2C等)都是通用的,因此理论上只需要更换初始化代码即可兼容不同的屏幕面板。

void lcd_init_reg(const esp_lcd_panel_io_handle_t io, const lcd_panel_reg_t reg_table[])
{
    uint8_t i = 0;
    while(reg_table[i].len != 0xFF) {
        esp_lcd_panel_io_tx_param(io, reg_table[i].reg, reg_table[i].val, reg_table[i].len);
        i ++;
    }
}

为了最大化提升兼容性,我将屏幕初始化所使用的寄存器数据封装为了结构体,在初始化的时候传入对应屏幕的结构体,即可实现对不同面板的初始化。下面是一个面板的初始化寄存器表的示例:

const lcd_panel_reg_t panel_st7796s_w350ce024a_40z_reg_table[] = {
    { 0x11, NULL, 0 }, //退出休眠模式
    { 0x21, NULL, 0 }, //开启反色显示
    { 0x35, (uint8_t[]){0x00}, 1 }, //开启TE信号输出,信号只包括垂直同步信息
    { 0x36, (uint8_t[]){0x48}, 1 }, //显存访问方式: MY=0, MX=1, MV=0, ML=0, RGB=1, MH=0
    { 0x3A, (uint8_t[]){0x55}, 1 }, //像素颜色格式: RGB模式16位, MCU模式16位

    { 0xF0, (uint8_t[]){0xC3}, 1 }, //使能指令集2第1部分
    { 0xF0, (uint8_t[]){0x96}, 1 }, //使能指令集2第2部分
    { 0xB4, (uint8_t[]){0x01}, 1 }, //显示反转控制 
    { 0xB7, (uint8_t[]){0xC6}, 1 }, //Entry Mode设置
    { 0xC0, (uint8_t[]){0x80, 0x64}, 2 }, //AVDD&AVCL&VGH&VGL设置
    { 0xC1, (uint8_t[]){0x13}, 1 }, //GVDD&GVCL设置
    { 0xC2, (uint8_t[]){0xA7}, 1 }, //源极&gamma驱动电流设置
    { 0xC5, (uint8_t[]){0x08}, 1 }, //VCOM设置
    { 0xE0, (uint8_t[]){0xF0, 0x06, 0x0B, 0x07, 0x06, 0x05, 0x2E, 0x33, 0x47, 0x3A, 0x17, 0x16, 0x2E, 0x31}, 14 }, //正gamma控制
    { 0xE1, (uint8_t[]){0xF0, 0x09, 0x0D, 0x09, 0x08, 0x23, 0x2E, 0x33, 0x46, 0x38, 0x13, 0x13, 0x2C, 0x32}, 14 }, //负gamma控制
    { 0xE8, (uint8_t[]){0x40, 0x8A, 0x00, 0x00, 0x29, 0x19, 0xA5, 0x33}, 8 }, //显示参数配置
    { 0xF0, (uint8_t[]){0x3C}, 1 }, //失能指令集2第1部分
    { 0xF0, (uint8_t[]){0x69}, 1 }, //失能指令集2第2部分

    { 0, NULL, 0xFF } //寄存器列表结束
};

与此同时,我还在Kconfig中设置了菜单,方便用户快速切换对应的屏幕面板型号与总线频率。

https://img.yuanze.wang/posts/lvgl-on-esp32s3/menuconfig.png
menuconfig中的菜单

然后,在程序中包含sdkconfig.h,并对宏进行判断即可:

    /* 初始化寄存器 */
#if defined(CONFIG_LVGL_LCD_PANEL_W350CE024A_40Z)
    lcd_init_reg(panel_io, panel_st7796s_w350ce024a_40z_reg_table);
#elif defined(CONFIG_LVGL_LCD_PANEL_CL35BC1017_40A)
    lcd_init_reg(panel_io, panel_st7796s_cl35bc1017_40a_reg_table);
#elif defined(CONFIG_LVGL_LCD_PANEL_CL35BC106_40A)
    lcd_init_reg(panel_io, panel_ili9488_cl35bc106_40a_reg_table);
#elif defined(CONFIG_LVGL_LCD_PANEL_LHC03540)
    lcd_init_reg(panel_io, panel_ili9481_lhc03540_reg_table);
#elif defined(CONFIG_LVGL_LCD_PANEL_CUSTOM)
    lcd_init_reg(panel_io, panel_custom_reg_table);
#endif

有了初始化函数与矩形写入函数之后,移植LVGL便变得很简单了。ESP-LCD内部是使用DMA传输的,通过前面总线初始化函数中传入的回调函数,结合FreeRTOS的信号量与双缓冲,即可在传输过程中开始渲染下一个缓冲区,并在渲染结束后交出CPU使用权,直到发送操作执行完毕。

static void disp_flush(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p)
{
    /* 启动新的传输 */
    lcd_draw_rect(disp_drv->user_data, area->x1, area->y1, area->x2, area->y2, color_p);
}

static void disp_wait(lv_disp_drv_t *disp_drv)
{
    /* 等待传输完成 */
    xSemaphoreTake(trans_done_semphr, portMAX_DELAY);
    /* 通知lvgl传输已完成 */
    lv_disp_flush_ready(disp_drv);
}

static bool lcd_trans_done_cb(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_io_event_data_t *edata, void *user_ctx)
{
    BaseType_t woken = false;
    xSemaphoreGiveFromISR(trans_done_semphr, &woken);
    return woken;
}

关于程序中其他的细节部分,请参考文末的完整工程链接。请使用esp-idf 4.4以上的版本进行编译,因为ESP-LCD组件是自这个版本引入的。

为了测试ESP32-S3运行LVGL的性能,我使用了LVGL官方提供的例程lv_demo_music进行测试。

将整个仓库拉到工程的components/lvgl_demo目录下,并在此目录下新建一个CMakeList.txt文件,加入下列内容

file(GLOB_RECURSE SOURCES lv_demos/*.c)

idf_component_register(SRCS ${SOURCES}
                       INCLUDE_DIRS lv_demos/
                       REQUIRES lvgl)

将所有文件注册为工程的组件,然后修改components/lvgl_demo/lv_demos/lv_demo_conf.h,打开宏LV_USE_DEMO_MUSICLV_DEMO_MUSIC_AUTO_PLAY,保存之后在主函数中调用lv_demo_music()即可。

测试过程中手动将CPU的频率设置为了240Mhz,并将Flash设置为了80Mhz QIO。同时,因为ESP32-S3新增了Cache大小自定义功能,我将ICache设置为32k,DCache设置为64k

https://img.yuanze.wang/posts/lvgl-on-esp32s3/esp32s3-pcb-with-lcd.jpg
测试硬件

测试效果如视频所示。LVGL给出的帧率为33fps,相比初代ESP32的22fps有了很大的进步。提升的主要原因是翻倍的Cache带来的巨大提升,以及LX7内核相比LX6内核的IPC提升。

 视频中使用的工程