numworks移植记录:7.移植LCD驱动——添加到numworks中

11 阅读8分钟
  1. 移植LCD驱动——添加到numworks中

    在前一篇文章中,我们成功在ESP32-S3上使用I8080并口驱动了ST7789屏幕,并可以通过esp_lcd API进行基本的绘图操作。但要让NumWorks的图形库(Kandinsky)能够使用这块屏幕,我们需要将底层的LCD驱动封装到NumWorks的硬件抽象层(Ion)中。本文将详细介绍如何将ESP32-S3的LCD驱动集成到NumWorks的Ion::Display接口中,实现上层绘图函数与底层硬件的对接。


    1. 理解NumWorks的显示接口

    NumWorks的图形输出最终都会调用Ion::Display命名空间下的函数,特别是Context类的三个虚函数:

    cpp

    void pushRect(KDRect rect, const KDColor* pixels);
    void pushRectUniform(KDRect rect, KDColor color);
    void pullRect(KDRect rect, KDColor* pixels);
    
    • pushRect:将一块矩形区域的像素数据(来自pixels数组)绘制到屏幕上指定的位置。
    • pushRectUniform:用单一颜色填充整个矩形区域。
    • pullRect:从屏幕上读取一块矩形区域的像素数据,存入pixels数组(用于窗口拖动时的内容恢复等)。

    此外,还有几个辅助函数:

    cpp

    bool waitForVBlank();      // 等待垂直消隐(用于同步刷新)
    void refreshDisplay();      // 将帧缓冲内容刷新到屏幕(通常由系统事件循环调用)
    

    Context类本身是一个KDContext的子类,由全局单例SharedContext管理,所有绘图操作最终都会通过这个单例转发到上述三个虚函数。

    因此,我们的任务就是实现这三个虚函数以及refreshDisplay,使其操作我们实际的LCD硬件


    2. 总体设计:帧缓冲方案

    为了获得最佳性能和简化实现,我们采用帧缓冲(Frame Buffer)方案

    • 在内存中开辟一块与屏幕分辨率相同的缓冲区(320×240×2字节,RGB565格式)。
    • 所有绘图操作(pushRectpushRectUniformpullRect)都直接读写这块内存缓冲。
    • refreshDisplay()被调用时,将整个帧缓冲通过I8080 DMA一次性发送到LCD控制器。
    • 这样既避免了频繁的小块传输,又能充分利用ESP32-S3的DMA能力,提高刷新率。

    帧缓冲可以放在内部SRAM或外部PSRAM中。考虑到NumWorks的UI通常需要全屏刷新,建议使用PSRAM(如果可用),以节省宝贵的内部SRAM。


    3. 实现步骤

    我们将创建一个新的源文件 ion/src/esp32s3/display.cpp,在其中实现所有需要的函数。

    3.1 包含必要的头文件

    cpp

    #include <ion/display.h>
    #include "esp_lcd_panel_ops.h"
    #include "esp_lcd_panel_io.h"
    #include "driver/gpio.h"
    #include <stdlib.h>
    #include <string.h>
    
    // 声明在板级初始化中创建的LCD面板句柄
    extern esp_lcd_panel_handle_t panel_handle;
    
    3.2 分配帧缓冲

    定义一个静态指针,并在首次使用时分配内存。注意使用MALLOC_CAP_DMA标志以确保缓冲区可用于DMA传输。

    cpp

    static uint16_t* sFrameBuffer = nullptr;
    
    static void initFrameBuffer() {
        if (sFrameBuffer == nullptr) {
            // 优先使用PSRAM,否则回退到内部SRAM
            sFrameBuffer = (uint16_t*)heap_caps_malloc(
                Ion::Display::Width * Ion::Display::Height * sizeof(uint16_t),
                MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT
            );
            if (sFrameBuffer == nullptr) {
                sFrameBuffer = (uint16_t*)heap_caps_malloc(
                    Ion::Display::Width * Ion::Display::Height * sizeof(uint16_t),
                    MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT
                );
            }
            // 初始化为黑色
            memset(sFrameBuffer, 0, Ion::Display::Width * Ion::Display::Height * sizeof(uint16_t));
        }
    }
    
    3.3 实现 pushRect

    将传入的像素数组复制到帧缓冲的对应矩形区域。

    cpp

    void Ion::Display::Context::pushRect(KDRect rect, const KDColor* pixels) {
        initFrameBuffer();
    
        int x = rect.x();
        int y = rect.y();
        int width = rect.width();
        int height = rect.height();
    
        // 边界裁剪(NumWorks通常保证不越界,但加上安全检查更稳妥)
        if (x < 0 || y < 0 || x + width > Ion::Display::Width || y + height > Ion::Display::Height) {
            return;
        }
    
        uint16_t* fb_line_start = sFrameBuffer + y * Ion::Display::Width + x;
        for (int row = 0; row < height; row++) {
            // KDColor可以直接转换为uint16_t(内部表示为RGB565)
            memcpy(fb_line_start + row * Ion::Display::Width,
                   pixels + row * width,
                   width * sizeof(uint16_t));
        }
    }
    
    3.4 实现 pushRectUniform

    用单一颜色填充帧缓冲的矩形区域。

    cpp

    void Ion::Display::Context::pushRectUniform(KDRect rect, KDColor color) {
        initFrameBuffer();
    
        int x = rect.x();
        int y = rect.y();
        int width = rect.width();
        int height = rect.height();
    
        if (x < 0 || y < 0 || x + width > Ion::Display::Width || y + height > Ion::Display::Height) {
            return;
        }
    
        uint16_t color16 = (uint16_t)color;  // KDColor隐式转换为uint16_t
        uint16_t* fb_line_start = sFrameBuffer + y * Ion::Display::Width + x;
        for (int row = 0; row < height; row++) {
            uint16_t* fb_row = fb_line_start + row * Ion::Display::Width;
            for (int col = 0; col < width; col++) {
                fb_row[col] = color16;
            }
        }
    }
    
    3.5 实现 pullRect

    从帧缓冲读取像素数据到传入的数组。

    cpp

    void Ion::Display::Context::pullRect(KDRect rect, KDColor* pixels) {
        initFrameBuffer();
    
        int x = rect.x();
        int y = rect.y();
        int width = rect.width();
        int height = rect.height();
    
        if (x < 0 || y < 0 || x + width > Ion::Display::Width || y + height > Ion::Display::Height) {
            return;
        }
    
        uint16_t* fb_line_start = sFrameBuffer + y * Ion::Display::Width + x;
        for (int row = 0; row < height; row++) {
            memcpy(pixels + row * width,
                   fb_line_start + row * Ion::Display::Width,
                   width * sizeof(uint16_t));
        }
    }
    
    3.6 实现 refreshDisplay

    将整个帧缓冲通过LCD面板的draw_bitmap函数发送到屏幕。

    cpp

    void Ion::Display::refreshDisplay() {
        if (panel_handle == nullptr || sFrameBuffer == nullptr) {
            return;
        }
    
        // 全屏刷新
        esp_lcd_panel_draw_bitmap(panel_handle,
                                  0, 0,
                                  Ion::Display::Width, Ion::Display::Height,
                                  sFrameBuffer);
    }
    

    如果希望提高刷新率,可以在此处实现“脏矩形追踪”,只发送被修改的区域。但NumWorks的UI通常是全屏刷新或逐行刷新,全屏刷新已经足够。

    3.7 实现 waitForVBlank

    垂直同步等待。如果屏幕没有提供TE(Tearing Effect)引脚,可以简单返回true(表示“无需等待”)。如果需要精确同步,可以配置一个GPIO中断并等待信号。

    cpp

    bool Ion::Display::waitForVBlank() {
        // 如果屏幕TE引脚连接到GPIO,可以在这里实现等待
        // 目前简单返回true
        return true;
    }
    
    3.8 实现 Context 单例

    Ion::Display::Context::SharedContext是一个全局单例,需要在源文件中定义,并实现构造函数。

    cpp

    OMG::GlobalBox<Ion::Display::Context> Ion::Display::Context::SharedContext;
    
    Ion::Display::Context::Context() : KDContext(KDPointZero, KDRect(0, 0, Ion::Display::Width, Ion::Display::Height)) {
        // 构造函数中不需要额外初始化,LCD初始化应在板级启动时完成
    }
    
    // 可选的调试输出函数
    void Ion::Display::Context::Putchar(char c) {
        printf("%c", c);  // 映射到ESP-IDF的printf
    }
    
    void Ion::Display::Context::Clear(KDPoint newCursorPosition) {
        // 清屏可通过pushRectUniform实现,这里留空或调用全屏填充
    }
    

    4. 集成到系统

    4.1 修改板级初始化

    在ESP32-S3的板级初始化代码(通常是app_mainbsp_init)中,必须先调用LCD驱动初始化(前一篇文章中的bsp_lcd_i80_init),确保panel_handle有效。然后NumWorks的Ion层才能正常工作。

    cpp

    extern "C" void app_main() {
        // 初始化LCD (I8080并口)
        bsp_lcd_i80_init();
    
        // 初始化其他硬件:键盘、存储等...
    
        // 进入NumWorks主循环
        ion_main(0, nullptr);
    }
    
    4.2 确保帧缓冲分配时机

    由于initFrameBuffer()在第一次调用绘图函数时才会执行,因此无需额外操作。但如果你希望提前分配,可以在LCD初始化后显式调用一次Ion::Display::Context::SharedContext->pushRectUniform(KDRectScreen, KDColorBlack);来触发分配。


    5. 注意事项

    1. 色彩格式一致性:NumWorks的KDColor使用RGB565格式,与ST7789的期望一致。但需要注意字节序:ESP-IDF的esp_lcd默认期望小端序,而KDColor的存储可能也是小端序(取决于编译器)。如果发现颜色错乱(如红蓝颠倒),可以在pushRect中转换字节序,或在初始化时通过esp_lcd_panel_swap_xy/esp_lcd_panel_mirror调整。
    2. DMA缓冲区要求:帧缓冲必须使用MALLOC_CAP_DMA分配,以确保DMA传输正确。如果使用PSRAM,请确认你的ESP32-S3版本支持PSRAM到LCD的DMA(通常需要启用SPIRAM_CACHE_WORKAROUND等选项)。
    3. 性能优化:全屏刷新一次约需传输153600字节,在20MHz的I8080总线上耗时约7.6ms(理论值),加上CPU开销,帧率可达60fps以上。如果感觉卡顿,可以尝试降低时钟频率或启用双缓冲(但NumWorks本身不依赖双缓冲)。
    4. 多线程安全:NumWorks的绘图通常在单个线程(事件循环)中执行,因此不需要锁。但如果你的项目在多个任务中调用绘图函数,需要对帧缓冲的访问加锁。
    5. 头文件依赖:确保ion/src/esp32s3/display.cpp能够找到ESP-IDF的头文件。在CMakeLists.txt中需要添加对应的依赖路径和组件链接。

    6. 测试验证

    完成上述代码后,编译并烧录到ESP32-S3。如果一切正常,NumWorks的启动画面应该会显示在屏幕上。你可以通过修改apps/中的某个应用(如计算器)来测试绘图功能,例如改变背景颜色或绘制简单图形。

    若屏幕无显示,请检查:

    • LCD初始化是否成功(背光是否点亮,SPI/I8080时序是否正确)。
    • 帧缓冲是否成功分配(可打印指针值)。
    • refreshDisplay是否被调用(NumWorks的系统事件循环会定期调用它)。
    • 色彩格式是否匹配(尝试在pushRect中将像素数据进行字节交换)。

    7. 总结

    通过将LCD驱动封装到NumWorks的Ion层,我们成功地将ESP32-S3的硬件显示能力与NumWorks的图形库对接起来。现在,所有Kandinsky的绘图命令都会经过pushRect等函数更新帧缓冲,并在refreshDisplay时通过高效的DMA传输刷新到屏幕。这为后续移植键盘、存储等其他模块打下了坚实的基础。

    在下一篇文章中,我们将开始处理输入部分——将ESP32-S3的GPIO按键映射到NumWorks的事件系统,让计算器能够响应用户操作。