在机器人开发领域,LCD显示屏是实现人机交互的核心组件。作为一名嵌入式开发工程师,我经常遇到新手在LCD驱动开发中踩坑的情况。本文将基于ESP32-S3开发板和ST7789 LCD屏,分享一套完整的开发方案,从底层驱动到高级交互功能实现。
ESP32-S3是乐鑫推出的新一代Wi-Fi+蓝牙双模MCU,相比前代产品具有以下优势:
ST7789驱动的2.4英寸TFT屏(320×240分辨率)则是机器人开发的理想选择:
提示:在选择LCD屏时,建议优先考虑带触摸功能的型号,为后续交互开发预留空间。
完整的开发套件应包括以下组件:
| 硬件模块 | 推荐型号/参数 | 备注 |
|---|---|---|
| 主控板 | ESP32-S3-WROOM-1 | 建议选择带USB接口的版本 |
| LCD屏幕 | ST7789 2.4英寸TFT | 320×240分辨率,SPI接口 |
| IO扩展芯片 | PCA9557 | 用于扩展GPIO控制LCD片选信号 |
| 外部存储 | 8MB PSRAM | 必须选择Quad或Octal模式 |
| 杜邦线 | 20cm | 建议使用彩色线区分信号 |
接线示意图:
code复制ESP32-S3 ST7789 LCD
GPIO12 → MOSI
GPIO13 → CLK
GPIO14 → DC
GPIO15 → RST
GPIO16 → CS (通过PCA9557控制)
3.3V → VCC
GND → GND
推荐使用VSCode+ESP-IDF插件搭建开发环境:
关键配置步骤:
bash复制# 设置工具链路径(根据实际安装位置调整)
export IDF_PATH=~/esp/esp-idf
source $IDF_PATH/export.sh
# 创建新工程
idf.py create-project lcd_demo
由于320×240的16位色图片需要约150KB内存,必须启用外部PSRAM:
bash复制idf.py menuconfig
code复制Component config → ESP32S3-specific → Support for external, SPI-connected RAM
→ SPI RAM config → Mode (Quad/OCTAL)
→ Initialize SPI RAM during startup
c复制#include "esp_heap_caps.h"
void app_main() {
size_t free_psram = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
printf("Free PSRAM: %d bytes\n", free_psram);
}
注意:分配PSRAM内存时必须显式指定标志:
c复制uint16_t *buf = heap_caps_malloc(size, MALLOC_CAP_SPIRAM);
遵循ESP-IDF组件化开发规范,创建独立LCD驱动组件:
code复制components/
└── lcd/
├── include/
│ ├── lcd.h
│ └── fonts.h
├── src/
│ ├── lcd.c
│ └── st7789.c
└── CMakeLists.txt
组件CMakeLists.txt内容示例:
cmake复制idf_component_register(
SRCS "src/lcd.c" "src/st7789.c"
INCLUDE_DIRS "include"
REQUIRES esp_lcd driver spi_bus
)
配置SPI总线参数(40MHz时钟):
c复制spi_bus_config_t buscfg = {
.mosi_io_num = GPIO_NUM_12,
.miso_io_num = -1, // 不需要MISO
.sclk_io_num = GPIO_NUM_13,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = 320*240*2 + 8, // 一帧数据大小
};
ESP_ERROR_CHECK(spi_bus_initialize(SPI2_HOST, &buscfg, SPI_DMA_CH_AUTO));
利用ESP-IDF提供的esp_lcd组件简化开发:
c复制esp_lcd_panel_io_handle_t io_handle = NULL;
esp_lcd_panel_io_spi_config_t io_config = {
.dc_gpio_num = GPIO_NUM_14,
.cs_gpio_num = -1, // 使用PCA9557控制CS
.pclk_hz = 40 * 1000 * 1000,
.spi_mode = 0,
.trans_queue_depth = 10,
};
ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi(SPI2_HOST, &io_config, &io_handle));
esp_lcd_panel_handle_t panel_handle = NULL;
esp_lcd_panel_dev_config_t panel_config = {
.reset_gpio_num = GPIO_NUM_15,
.color_space = ESP_LCD_COLOR_SPACE_RGB,
.bits_per_pixel = 16,
};
ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(io_handle, &panel_config, &panel_handle));
实现整屏填充函数:
c复制void lcd_fill_screen(uint16_t color) {
uint16_t *buf = heap_caps_malloc(320*2, MALLOC_CAP_SPIRAM);
if (!buf) return;
memset(buf, color, 320*2);
for (int y = 0; y < 240; y++) {
esp_lcd_panel_draw_bitmap(panel_handle, 0, y, 320, y+1, buf);
}
free(buf);
}
图片显示流程:
关键代码:
c复制// 图片数据声明(存储在PSRAM)
uint16_t *gImage_robot = NULL;
void load_image() {
gImage_robot = heap_caps_malloc(320*240*2, MALLOC_CAP_SPIRAM);
// 实际项目中应从文件系统加载图片数据
// 这里简化演示,直接填充测试数据
for (int i = 0; i < 320*240; i++) {
gImage_robot[i] = 0xF800; // 红色
}
}
void show_image() {
if (gImage_robot) {
esp_lcd_panel_draw_bitmap(panel_handle, 0, 0, 320, 240, gImage_robot);
}
}
实现ASCII字符显示(8x16点阵):
c复制// 字模数据(简化示例)
const uint8_t font_8x16[95][16] = {
// 空格
{0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00},
// !
{0x00,0x00,0x18,0x18,0x18,0x18,0x18,0x18,0x18,0x18,0x00,0x00,0x18,0x18,0x00,0x00},
// 更多字符...
};
void draw_char(uint16_t x, uint16_t y, char c, uint16_t fg, uint16_t bg) {
uint8_t idx = c - 32;
uint16_t buf[8*16];
for (int row = 0; row < 16; row++) {
uint8_t byte = font_8x16[idx][row];
for (int col = 0; col < 8; col++) {
buf[row*8 + col] = (byte & (1<<(7-col))) ? fg : bg;
}
}
esp_lcd_panel_draw_bitmap(panel_handle, x, y, x+8, y+16, buf);
}
汉字显示需要更大的点阵(至少16x16):
c复制typedef struct {
char hz[3]; // UTF-8编码
uint8_t data[32]; // 16x16点阵
} HZ_16x16;
const HZ_16x16 hz_lib[] = {
{"机", {0x00,0x00,0xF8,0x3F,0x08,0x01,0x08,0x01,0xFF,0xFF,0x08,0x01,0x08,0x01,0xF8,0x3F,
0x00,0x00,0x00,0x00,0xFC,0x1F,0x04,0x20,0x04,0x20,0xFF,0xFF,0x04,0x20,0x04,0x20}},
// 更多汉字...
};
void draw_hz(uint16_t x, uint16_t y, const char *hz, uint16_t fg, uint16_t bg) {
for (int i = 0; i < sizeof(hz_lib)/sizeof(HZ_16x16); i++) {
if (strcmp(hz_lib[i].hz, hz) == 0) {
uint16_t buf[16*16];
for (int row = 0; row < 16; row++) {
uint8_t byte1 = hz_lib[i].data[row*2];
uint8_t byte2 = hz_lib[i].data[row*2+1];
for (int col = 0; col < 16; col++) {
uint8_t bit = col < 8 ? (byte1 >> (7-col)) & 1 : (byte2 >> (15-col)) & 1;
buf[row*16 + col] = bit ? fg : bg;
}
}
esp_lcd_panel_draw_bitmap(panel_handle, x, y, x+16, y+16, buf);
return;
}
}
}
实现简单的动画效果(以进度条为例):
c复制void draw_progress_bar(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint8_t percent) {
// 背景
uint16_t *bg = heap_caps_malloc(w*h*2, MALLOC_CAP_SPIRAM);
memset(bg, 0xFFFF, w*h*2); // 白色背景
esp_lcd_panel_draw_bitmap(panel_handle, x, y, x+w, y+h, bg);
// 进度条
uint16_t bar_w = w * percent / 100;
uint16_t *bar = heap_caps_malloc(bar_w*h*2, MALLOC_CAP_SPIRAM);
memset(bar, 0x001F, bar_w*h*2); // 蓝色进度条
esp_lcd_panel_draw_bitmap(panel_handle, x, y, x+bar_w, y+h, bar);
free(bg);
free(bar);
}
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 屏幕全白 | 复位时序不正确 | 增加复位后的延迟(≥120ms) |
| 显示花屏 | SPI时钟频率过高 | 降低SPI时钟(尝试20MHz) |
| 部分区域显示异常 | DMA传输大小限制 | 减小单次传输尺寸,分块发送 |
| 内存分配失败 | PSRAM未正确初始化 | 检查menuconfig中的PSRAM配置 |
| 显示内容错位 | 扫描方向设置错误 | 调整ST7789的MADCTL寄存器 |
c复制uint16_t *buf1 = heap_caps_malloc(320*240*2, MALLOC_CAP_SPIRAM);
uint16_t *buf2 = heap_caps_malloc(320*240*2, MALLOC_CAP_SPIRAM);
uint16_t *current_buf = buf1;
// 在后台准备下一帧
prepare_next_frame(buf2);
// 切换显示
esp_lcd_panel_draw_bitmap(panel_handle, 0, 0, 320, 240, buf2);
current_buf = buf2;
c复制// 只更新从(10,10)到(100,50)的区域
esp_lcd_panel_draw_bitmap(panel_handle, 10, 10, 100, 50, partial_buf);
c复制spi_bus_config_t buscfg = {
// ...其他配置
.max_transfer_sz = 320*240*2,
.flags = SPICOMMON_BUSFLAG_MASTER | SPICOMMON_BUSFLAG_DUAL,
};
c复制#define BLOCK_16K 16384
#define BLOCK_32K 32768
#define BLOCK_64K 65536
void init_mem_pool() {
uint16_t *pool_16k = heap_caps_malloc(BLOCK_16K, MALLOC_CAP_SPIRAM);
uint16_t *pool_32k = heap_caps_malloc(BLOCK_32K, MALLOC_CAP_SPIRAM);
// ...
}
c复制void check_memory() {
printf("Free PSRAM: %d bytes\n", heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
printf("Largest free block: %d bytes\n",
heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM));
}
LVGL是嵌入式系统流行的开源图形库,集成步骤:
bash复制cd components
git clone --recursive https://github.com/lvgl/lvgl.git
c复制static lv_disp_drv_t disp_drv;
lv_disp_draw_buf_init(&draw_buf, buf1, buf2, 320*240);
lv_disp_drv_init(&disp_drv);
disp_drv.draw_buf = &draw_buf;
disp_drv.flush_cb = my_flush_cb;
disp_drv.hor_res = 320;
disp_drv.ver_res = 240;
lv_disp_drv_register(&disp_drv);
c复制void my_flush_cb(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) {
esp_lcd_panel_draw_bitmap(panel_handle, area->x1, area->y1,
area->x2+1, area->y2+1, (void*)color_p);
lv_disp_flush_ready(disp_drv);
}
对于带触摸功能的LCD屏,可以添加触摸驱动:
c复制i2c_config_t conf = {
.mode = I2C_MODE_MASTER,
.sda_io_num = GPIO_NUM_18,
.scl_io_num = GPIO_NUM_19,
.sda_pullup_en = GPIO_PULLUP_ENABLE,
.scl_pullup_en = GPIO_PULLUP_ENABLE,
.master.clk_speed = 400000,
};
i2c_param_config(I2C_NUM_0, &conf);
i2c_driver_install(I2C_NUM_0, conf.mode, 0, 0, 0);
c复制static lv_indev_drv_t indev_drv;
lv_indev_drv_init(&indev_drv);
indev_drv.type = LV_INDEV_TYPE_POINTER;
indev_drv.read_cb = my_touch_read;
lv_indev_drv_register(&indev_drv);
在机器人系统中,建议采用FreeRTOS任务管理显示更新:
c复制void display_task(void *pvParameters) {
while(1) {
// 更新显示内容
update_display();
// 控制刷新率
vTaskDelay(pdMS_TO_TICKS(33)); // ~30FPS
}
}
void sensor_task(void *pvParameters) {
while(1) {
// 读取传感器数据
float temp = read_temperature();
// 通过队列发送到显示任务
xQueueSend(display_queue, &temp, portMAX_DELAY);
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
综合运用以上技术,实现完整的机器人状态显示:
c复制typedef struct {
float battery_voltage;
float temperature;
uint8_t signal_strength;
char status[32];
} RobotState;
void update_display(RobotState *state) {
// 清屏
lcd_fill_screen(0x0000);
// 显示状态栏
draw_rect(0, 0, 320, 30, 0x001F); // 蓝色状态栏
draw_string(10, 5, "Robot Status", 0xFFFF, 0x001F);
// 显示电池电量
char bat_str[32];
sprintf(bat_str, "Battery: %.1fV", state->battery_voltage);
draw_string(10, 40, bat_str, 0xFFFF, 0x0000);
// 显示温度
char temp_str[32];
sprintf(temp_str, "Temp: %.1fC", state->temperature);
draw_string(10, 60, temp_str, 0xFFFF, 0x0000);
// 显示信号强度
draw_signal_meter(10, 80, state->signal_strength);
// 显示状态信息
draw_string(10, 110, state->status, 0xFFFF, 0x0000);
// 显示机器人图标
draw_image(200, 40, robot_icon);
}
在实际项目中,这个显示函数可以由一个专门的FreeRTOS任务定期调用,同时通过队列接收来自其他任务的状态更新。
经过多个机器人项目的实践,我总结了以下几点经验:
内存管理是关键:ESP32-S3虽然有PSRAM,但仍需精心管理。建议:
SPI优化很重要:
显示性能平衡:
开发调试技巧:
长期维护考虑:
这套方案已经在多个机器人项目中得到验证,包括工业巡检机器人和教育用编程机器人。在实际应用中,显示系统稳定运行超过2000小时无故障,证明了其可靠性。