作为一名长期从事嵌入式开发的工程师,我最近在尝试将NumWorks图形计算器的软件系统移植到ESP32-S3平台上。这个过程中最关键的环节之一就是实现LCD驱动的对接。NumWorks原本使用的是STM32微控制器,而我们要将其移植到ESP32-S3平台,这意味着需要重新实现底层的硬件抽象层(Ion)。
在之前的文章中,我们已经成功在ESP32-S3上使用I8080并口驱动了ST7789屏幕。现在,我们需要将这个底层驱动封装到NumWorks的硬件抽象层中,让上层的Kandinsky图形库能够正常工作。这个过程涉及到帧缓冲管理、DMA传输优化以及与NumWorks原有图形接口的对接。
NumWorks的图形系统采用分层设计,最上层是Kandinsky图形库,提供各种绘图API;中间层是Ion硬件抽象层,负责与具体硬件对接;最下层才是实际的LCD驱动。这种设计使得上层应用无需关心底层硬件细节。
在Ion层中,所有显示操作最终都会调用Ion::Display命名空间下的几个关键函数:
cpp复制void pushRect(KDRect rect, const KDColor* pixels);
void pushRectUniform(KDRect rect, KDColor color);
void pullRect(KDRect rect, KDColor* pixels);
这三个虚函数构成了显示系统的核心:
pushRect:将指定矩形区域的像素数据绘制到屏幕上pushRectUniform:用单一颜色填充指定矩形区域pullRect:从屏幕上读取指定矩形区域的像素数据此外,还有两个重要的辅助函数:
cpp复制bool waitForVBlank(); // 等待垂直消隐
void refreshDisplay(); // 刷新显示
NumWorks使用Context类来管理显示上下文,这是一个KDContext的子类,由全局单例SharedContext管理。所有绘图操作都会通过这个单例转发到上述虚函数。这种设计使得显示系统可以灵活地支持不同的硬件平台,只需实现这些虚函数即可。
在嵌入式系统中,显示驱动通常有两种实现方式:
经过评估,我们选择了帧缓冲模式,原因如下:
pullRect等需要回读屏幕内容的操作对于ESP32-S3平台,我们还需要考虑帧缓冲的存储位置:
考虑到NumWorks的屏幕分辨率为320x240(RGB565格式,共153600字节),我们优先使用PSRAM,只有当PSRAM不可用时才回退到内部SRAM。
NumWorks使用RGB565色彩格式,这与ST7789控制器支持的格式一致。但是需要注意字节序问题:
esp_lcdAPI默认期望小端序KDColor的存储可能有不同处理如果发现颜色显示异常(如红蓝颠倒),我们需要在数据传输前进行字节序转换。这可以通过修改pushRect和pullRect实现,也可以在LCD初始化时配置控制器的寄存器。
我们在ion/src/esp32s3/目录下创建display.cpp文件,用于实现所有显示相关的函数。首先包含必要的头文件:
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;
我们定义一个静态指针来管理帧缓冲,并实现初始化函数:
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));
}
}
这个函数会在第一次绘图操作时自动调用,确保帧缓冲已经分配并初始化。
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();
// 边界检查
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(fb_line_start + row * Ion::Display::Width,
pixels + row * width,
width * sizeof(uint16_t));
}
}
这个函数将指定矩形区域的像素数据复制到帧缓冲中对应的位置。我们使用memcpy来提高复制效率。
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;
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;
}
}
}
这个函数用单一颜色填充指定矩形区域。对于大面积填充,我们使用逐行填充的方式,虽然不如memset高效,但可以处理非对齐的矩形区域。
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));
}
}
这个函数从帧缓冲中读取指定矩形区域的像素数据。主要用于实现窗口拖动等需要保存和恢复屏幕内容的操作。
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);
}
这个函数将整个帧缓冲通过DMA传输到LCD控制器。使用esp_lcd_panel_draw_bitmap函数可以利用ESP32-S3的DMA引擎,实现高效的数据传输。
cpp复制bool Ion::Display::waitForVBlank() {
// 如果屏幕TE引脚连接到GPIO,可以在这里实现等待
// 目前简单返回true
return true;
}
在大多数嵌入式LCD应用中,精确的垂直同步不是必须的。如果需要实现,可以将LCD的TE(Tearing Effect)引脚连接到ESP32-S3的GPIO,并在此函数中等待信号。
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)) {
// 构造函数中不需要额外初始化
}
void Ion::Display::Context::Putchar(char c) {
printf("%c", c);
}
void Ion::Display::Context::Clear(KDPoint newCursorPosition) {
// 清屏可通过pushRectUniform实现
}
这些函数完成了Context类的实现,其中SharedContext是一个全局单例,负责管理整个显示系统。
在ESP32-S3的板级初始化代码中,需要先初始化LCD驱动,再启动NumWorks主程序:
cpp复制extern "C" void app_main() {
// 初始化LCD (I8080并口)
bsp_lcd_i80_init();
// 初始化其他硬件:键盘、存储等...
// 进入NumWorks主循环
ion_main(0, nullptr);
}
在CMakeLists.txt中,需要确保:
完成代码后,可以进行以下测试:
如果遇到问题,可以:
refreshDisplay是否被定期调用MALLOC_CAP_DMA)屏幕无显示:
颜色异常:
刷新率低:
refreshDisplay的调用频率heap_caps_malloc分配,确保使用正确的内存类型在实际项目中,我发现帧缓冲方案的实现虽然简单,但对内存要求较高。在资源受限的系统上,可以考虑使用分块缓冲或直接绘制模式。此外,ESP32-S3的DMA性能非常出色,合理配置可以轻松实现60fps的刷新率。
对于颜色处理,建议在项目初期就建立完善的测试用例,验证各种颜色显示是否正确。我曾经遇到过因为字节序问题导致的颜色错乱,调试起来相当耗时。