STM32H743 QSPI屏ST77903驱动与LVGL刷新优化

发布时间:2026/10/26 17:07:35
STM32H743 QSPI屏ST77903驱动与LVGL刷新优化

简介:这份工程面向采用矽创ST77903小尺寸LCD的嵌入式开发者,围绕STM32H743、RT-Thread、LVGL与QSPI给出可运行的参考方案,重点解决该IC内部无显存、必须由主机以专用线程持续刷屏的问题,适合穿戴类屏幕驱动移植与联调。压缩包共2591个文件,约13.66MB,以C/H源码、汇编启动文件、SConscript构建脚本、链接与分散加载配置、Kconfig菜单及说明文档为主,另含少量图片、PDF和固件文件,便于按目录定位驱动、板级配置与构建入口。目前已有2592人学习下载。DEMO内置三套手表表盘,可用于理解LVGL显示驱动接入、RT-Thread线程调度与QSPI连续传输的配合,并参考工程组织快速搭建验证环境。

1. 从一块 QSPI 屏说起:ST77903 在 STM32H743 上的最小可跑通路径

手里有一块 ST77903 LCD 屏,主控是 STM32H743,系统跑 RT-Thread,GUI 用 LVGL,屏走 QSPI——这套组合听起来像把几个热门模块拼在一起,真正跑起来却常卡在“能读 ID 但不亮”“能亮但花屏”“能显示但 LVGL 刷新像幻灯片”。ST77903 这套 DEMO 的价值不是再贴一份初始化序列,而是把 QSPI 命令通道、RT-Thread 设备模型和 LVGL 刷新回调串成一条可复现的链路。适合已经能把 H743 点灯、但第一次碰 QSPI 屏的工程师,也适合手里有类似屏模组、想确认驱动分层是否合理的老手。先别急着开双缓冲,最小闭环跑通再谈帧率。

2. 硬件与驱动选型:QSPI 四线、DMA 和 ST77903 命令通道怎么对齐

2.1 为什么 STM32H743 的 QSPI 外设适合 ST77903

QSPI 屏最容易让人误判的地方,是把 QSPI 当成“SPI 多两根数据线”。STM32H743 的 QUADSPI 外设确实能一次把 4 bit 数据推出去,但 ST77903 的命令阶段和像素阶段线数并不总是一样:初始化命令常见单线发命令、单线或四线发参数,到了 RAMWR 才切四线传像素。驱动里如果从头到尾都按四线发,读 ID 可能正常,一写像素就花屏。选 H743 的理由很直接:QSPI 时钟分频灵活、带 FIFO 和 DMA 请求、AXI SRAM 能做 DMA 缓冲;但前提是先把总线模式、时钟和线数拆开配置,而不是拿一份 SPI 屏代码改个设备名。

这套 DEMO 里我建议把 QSPI 分成三个参数面看:总线模式决定 CLK 极性/相位和位序,时钟分频决定实际像素吞吐,线数决定每个阶段怎么发。H743 的 QSPI 还支持内存映射模式,但 LCD 不是 NOR Flash,不需要把屏挂到地址空间里,medium_size这类参数别照抄 Flash 配置。常见做法是先按 50MHz、SDR、模式 0、MSB 先出调通,再逐级往上加频率;一上来就冲最高时钟,花屏和偶发卡死会混在一起,排查成本很高。

配置项调通阶段建议作用与注意点
时钟分频先降到 50MHz 以内排除信号完整性和屏厂时序余量问题
采样边沿Half cycle 起步H743 QSPI 采样点偏了会偶发读错
FIFO 阈值8 或 16 字节太小中断多,太大短命令等待变长
CS 高电平时间至少 2 个 QSPI 时钟命令之间留够片选释放时间
DDR 模式先关闭ST77903 这套 DEMO 按 SDR 配置更稳
内存映射关闭LCD 只走命令/数据,不走地址映射
/* 板级 QSPI 参数,按屏厂时序和实际布线填,不要直接抄频率 */ static int st77903_qspi_init(void) { struct rt_qspi_configuration cfg = {0}; rt_qspi_device *dev = rt_qspi_device_find("qspi1"); if (dev == RT_NULL) return -RT_ERROR; cfg.parent.mode = RT_SPI_MODE_0 | RT_SPI_MSB; /* 常见组合:模式 0,MSB 先出 */ cfg.parent.max_hz = 50 * 1000 * 1000; /* 先 50MHz 调通,稳定后再阶梯上调 */ cfg.parent.data_width = 8; /* 命令和参数按 8bit 组织 */ cfg.medium_size = 0; /* LCD 不做内存映射,置 0 */ cfg.ddr_mode = 0; /* 这套 DEMO 用 SDR */ cfg.qspi_lines = 4; /* 像素阶段四线,命令阶段在传输时另设 */ if (rt_qspi_configure(dev, &cfg) != RT_EOK) return -RT_ERROR; return RT_EOK; }

这段代码只做总线级配置,不碰具体命令。max_hz是上限,不是每个阶段都跑这个频率;qspi_lines是默认数据线数,真正发命令时还要在rt_qspi_message里分别设置instruction.qspi_lines和data.qspi_lines。medium_size=0很关键,照抄 Flash 的容量会让 QSPI 驱动进入不该走的路径。实际调的时候先把max_hz降到 30MHz 或 50MHz,等纯色图、渐变图都正常,再以 10MHz 一档往上试,别一次改三个参数。

2.2 引脚、时钟与 RT-Thread 设备注册

硬件检查比软件配置更值得先花半小时。QSPI 的 CLK、CS、D0~D3 不是“连上就行”,CS 要有上拉,CLK 串联电阻按板子留,D0~D3 尽量等长,复位和背光必须独立 GPIO 控制。ST77903 这类屏模组常见的翻车点是背光先开、复位还没释放,或者复位低电平只有几毫秒,屏内部电源没稳定,后面初始化序列写得再对也白搭。RT-Thread 下先把 QSPI 设备名确认清楚,qspi1、qspi10这种命名跟 BSP 有关,别在代码里硬编码错设备。

信号检查点常见错误
CS上拉、片选释放时间多个设备共用 CS,命令粘连
CLK串联电阻、走线长度高频下波形过冲,读 ID 偶发失败
D0~D3等长、不要跨电源分割四线像素传输花屏,命令却正常
RST独立 GPIO、低电平时间复位不充分,初始化序列被忽略
BL最后再开、PWM 可选白屏误判为驱动没跑
/* 复位和背光单独用 GPIO,别指望 QSPI 外设帮你拉 */ #define LCD_RST_PIN GET_PIN(B, 1) #define LCD_BL_PIN GET_PIN(B, 0) static void st77903_hw_reset(void) { rt_pin_mode(LCD_RST_PIN, PIN_MODE_OUTPUT); rt_pin_mode(LCD_BL_PIN, PIN_MODE_OUTPUT); rt_pin_write(LCD_BL_PIN, PIN_LOW); /* 初始化期间先关背光,避免白屏误判 */ rt_pin_write(LCD_RST_PIN, PIN_HIGH); rt_thread_mdelay(10); rt_pin_write(LCD_RST_PIN, PIN_LOW); rt_thread_mdelay(20); /* 低电平时间按屏厂手册,常见不短于 10ms */ rt_pin_write(LCD_RST_PIN, PIN_HIGH); rt_thread_mdelay(120); /* 等内部电源稳定,常见 120ms 量级 */ } static void st77903_backlight(int on) { rt_pin_write(LCD_BL_PIN, on ? PIN_HIGH : PIN_LOW); }

复位的逻辑是“先关背光、再拉复位、等电源稳定”,不是简单拉低再拉高。rt_thread_mdelay(20)和120是常见量级,不同屏模组要求不同,以手头屏厂初始化说明为准。背光最后开的好处是:如果初始化序列没跑完,你看到的是黑屏而不是白屏,排查方向会清楚很多。RT-Thread 下这些延时用线程延时,不要在中断里调用;初始化线程栈别太小,QSPI 命令表和 LVGL 初始化都可能吃掉不少栈空间。

设备注册完以后,先写一个只读 ID 的小测试:发读 ID 命令,打印返回值,确认 QSPI 命令通道是通的。这个测试不要带 LVGL,也不要开 DMA,纯轮询发一条短命令即可。读 ID 正常但显示不正常,问题基本落在初始化序列、窗口坐标、像素格式或字节序;读 ID 都不正常,先回头查引脚、时钟、片选和线数。把这条分界线划清楚,后面能省很多来回。

2.3 上电时序与初始化序列的落地方式

ST77903 的初始化序列不适合写成一大串if/else,表驱动更好维护。每条记录包含命令、数据长度、数据指针和延时,跑表时统一发命令、统一延时。常见 DCS 命令包括 Sleep Out、Memory Access Control、Pixel Format、Display On,中间还会夹屏厂自己的电源、VCOM、Gamma 参数。这些值不能凭经验猜,必须按屏模组附带的初始化表填;DEMO 里最有价值的部分往往就是这张表,而不是 QSPI 初始化那几行。

typedef struct { uint8_t cmd; uint8_t data_len; const uint8_t *data; uint16_t delay_ms; } st77903_init_cmd_t; static const uint8_t madctl = 0x00; /* 行列方向、RGB/BGR 按屏模组填 */ static const uint8_t colmod = 0x55; /* RGB565 常见值,仍以屏厂序列为准 */ static const st77903_init_cmd_t init_seq[] = { {0x11, 0, RT_NULL, 120}, /* Sleep Out */ {0x36, 1, &madctl, 0}, /* Memory Access Control */ {0x3A, 1, &colmod, 0}, /* Pixel Format */ /* 中间省略屏厂 gamma、电源、VCOM 序列,按 DEMO 的 table 补齐 */ {0x29, 0, RT_NULL, 20}, /* Display On */ }; static rt_err_t st77903_run_init_seq(void) { for (rt_size_t i = 0; i < sizeof(init_seq) / sizeof(init_seq[0]); i++) { if (st77903_write_cmd(init_seq[i].cmd, init_seq[i].data, init_seq[i].data_len) != RT_EOK) return -RT_ERROR; if (init_seq[i].delay_ms) rt_thread_mdelay(init_seq[i].delay_ms); } return RT_EOK; }

st77903_write_cmd在第 3 章实现,这里先按“命令 + 参数”的语义调用。madctl控制行列方向和 RGB/BGR 顺序,colmod控制像素格式,0x55是 RGB565 的常见取值,但不同屏厂可能要求别的组合。表驱动的好处是改参数不用动流程,坏处是容易漏延时;Sleep Out 后面通常要等 120ms 量级,Display On 后也要留一点时间。每次改初始化表,只改一条,跑一次纯色图,不要把 gamma 和方向参数一起改,否则出错时根本不知道是哪一行造成的。

3. RT-Thread 驱动层:命令、显存与刷新队列的拆法

3.1 用设备模型封装命令通道与像素通道

ST77903 的 QSPI 传输可以粗分成两类:短命令和长像素。短命令要求低延迟、不能被打断;长像素要求高吞吐、最好走 DMA。把两者都塞进一个“写屏”函数,后面 LVGL 一刷新就会互相抢总线。更合理的拆法是底层只保留一个 QSPI 消息发送函数,上层分别封装write_cmd和write_pixels,用 RT-Thread 互斥量保护总线。命令阶段线数按屏厂要求设,像素阶段固定四线,这样调试时一眼能看出是哪类传输出了问题。

static rt_mutex_t lcd_lock; static rt_err_t st77903_qspi_xfer(uint8_t cmd, const void *tx, rt_size_t len, uint8_t cmd_lines, uint8_t data_lines) { struct rt_qspi_message msg = {0}; rt_qspi_device *dev = rt_qspi_device_find("qspi1"); if (dev == RT_NULL || lcd_lock == RT_NULL) return -RT_ERROR; msg.instruction.content = cmd; msg.instruction.qspi_lines = cmd_lines; /* 命令阶段常见 1 线 */ msg.address.size = 0; msg.dummy_cycles = 0; msg.data.buffer = (void *)tx; msg.data.length = len; msg.data.qspi_lines = data_lines; /* 像素阶段 4 线 */ rt_mutex_take(lcd_lock, RT_WAITING_FOREVER); rt_err_t err = rt_qspi_transfer_message(dev, &msg); rt_mutex_release(lcd_lock); return err; } rt_err_t st77903_write_cmd(uint8_t cmd, const uint8_t *data, rt_size_t len) { return st77903_qspi_xfer(cmd, data, len, 1, len ? 1 : 0); } rt_err_t st77903_write_pixels(const void *buf, rt_size_t len) { return st77903_qspi_xfer(0x2C, buf, len, 1, 4); /* RAMWR,像素走四线 */ }

这段代码的重点是“一个底层传输、两个语义封装”。instruction.qspi_lines和data.qspi_lines分开设置,命令可以单线,像素可以四线;0x2C是常见 RAMWR 命令,具体命令字仍以 ST77903 手册为准。互斥量保护的是整个 QSPI 消息,不是单条命令,否则命令和像素之间还是可能插队。data.buffer强转掉const是 RT-Thread QSPI 接口的类型限制,调用方要保证缓冲区在传输完成前不被改写。调通阶段可以把rt_qspi_transfer_message换成阻塞轮询,稳定后再接 DMA。

3.2 显存放在哪里:内部 SRAM、AXI SRAM 与 SDRAM 的取舍

H743 的内存域比较多,最容易踩的坑是把 LVGL 缓冲放进 DTCM,然后 DMA 怎么都不动。DTCM 速度快,但很多 DMA 控制器访问不到;AXI SRAM 适合做 DMA 缓冲;外部 SDRAM 容量大,但带宽和 cache 维护更麻烦。ST77903 这套 DEMO 如果只做局部刷新,40 行双缓冲已经够用:320×40×2×2 大约 51KB,放 AXI SRAM 压力不大。想上全屏双缓冲,320×480×2×2 接近 600KB,就得考虑 SDRAM 或单缓冲加局部刷新。

缓冲位置速度DMA 可用性适合场景
DTCM快通常不可用CPU 查表、栈、非 DMA 数据
AXI SRAM较快可用LVGL 局部双缓冲、QSPI DMA
外部 SDRAM一般可用,需配置全屏缓冲、多图层
内部 SRAM 小块快部分可用命令参数、小临时缓冲
/* LVGL 绘制缓冲:必须能 DMA,按 cache line 对齐 */ #define LCD_HOR_RES 320 #define LCD_VER_RES 480 #define LCD_BUF_LINES 40 static uint8_t lv_buf1[LCD_HOR_RES * LCD_BUF_LINES * 2] __attribute__((aligned(32))); static uint8_t lv_buf2[LCD_HOR_RES * LCD_BUF_LINES * 2] __attribute__((aligned(32)));

LCD_BUF_LINES是每个缓冲覆盖的行数,40 行对应约 25.6KB,双缓冲约 51.2KB;aligned(32)是为了配合 Cortex-M7 的 cache line,减少 DMA 前后的维护开销。如果编译后发现缓冲被放到 DTCM,检查链接脚本和段属性,别只看变量定义。放 SDRAM 时要确认 MPU 属性和 cache 策略,否则 CPU 写进去的数据可能还在 cache 里,DMA 读到的还是旧内容。调通阶段可以先用单缓冲,确认窗口和颜色都对,再换双缓冲。

3.3 刷新队列与 DMA 完成回调

LVGL 的flush_cb不是“把像素写完再返回”的地方。它跑在 LVGL 的任务上下文里,如果里面直接等 QSPI DMA 完成,整个 GUI 线程会被拖住,动画和触摸都会卡。更稳的做法是flush_cb只把区域坐标和缓冲指针投递到消息队列,由独立的 LCD 刷新线程做窗口设置和像素传输,DMA 完成中断释放信号量,刷新线程拿到信号后再调用lv_disp_flush_ready。这样 LVGL 的绘制和屏的传输能重叠起来。

typedef struct { uint16_t x1, y1, x2, y2; const uint8_t *data; } lcd_area_t; static struct rt_messagequeue lcd_mq; static char lcd_mq_pool[8 * sizeof(lcd_area_t)]; static rt_sem_t lcd_dma_done; static void lcd_flush_task(void *arg) { lcd_area_t area; while (1) { if (rt_mq_recv(&lcd_mq, &area, sizeof(area), RT_WAITING_FOREVER) == RT_EOK) { st77903_set_window(area.x1, area.y1, area.x2, area.y2); st77903_write_pixels(area.data, (area.x2 - area.x1 + 1) * (area.y2 - area.y1 + 1) * 2); /* 若用 DMA,这里等完成信号量;裸轮询只适合调通阶段 */ rt_sem_take(lcd_dma_done, RT_WAITING_FOREVER); lv_disp_flush_ready(&disp_drv); } } }

队列深度 8 是经验值,刷新区域多、动画快的时候可以加到 16;队列太深会增加延迟,太浅会在flush_cb里阻塞。lcd_area_t只保存坐标和指针,不拷贝像素,所以必须保证 LVGL 缓冲在 DMA 完成前不被下一帧覆盖,双缓冲就是为这个服务的。lcd_dma_done在 QSPI DMA 完成中断里释放,中断里不要调用会阻塞的 API。调通阶段可以先把rt_sem_take去掉,改成轮询等待 QSPI 忙标志,确认链路没问题后再接 DMA 和信号量。

4. LVGL 显示移植:flush_cb、窗口坐标与 RGB565 字节序

4.1 LVGL 显示驱动注册的最小闭环

LVGL 移植最容易写成“能显示就行”的一团代码,后面加触摸、改分辨率、换缓冲都很痛苦。最小闭环其实只有四件事:初始化绘制缓冲、填显示参数、注册flush_cb、提供 tick。绘制缓冲的第三个参数是像素数,不是字节数,RGB565 下一个像素 2 字节,别把sizeof混进去。full_refresh先关,局部刷新能让 ST77903 的 QSPI 带宽压力小很多;等局部刷新稳定,再考虑全屏刷新或双缓冲。

static lv_disp_draw_buf_t draw_buf; static lv_disp_drv_t disp_drv; static void lvgl_port_init(void) { lv_disp_draw_buf_init(&draw_buf, lv_buf1, lv_buf2, LCD_HOR_RES * LCD_BUF_LINES); lv_disp_drv_init(&disp_drv); disp_drv.hor_res = LCD_HOR_RES; disp_drv.ver_res = LCD_VER_RES; disp_drv.flush_cb = st77903_flush_cb; disp_drv.draw_buf = &draw_buf; disp_drv.full_refresh = 0; /* 先局部刷新,别一上来全屏 */ lv_disp_drv_register(&disp_drv); }

lv_disp_draw_buf_init的第三个参数是LCD_HOR_RES * LCD_BUF_LINES个像素,和缓冲区字节数不是一回事;full_refresh=0表示 LVGL 只把脏区域交给flush_cb。这段代码是 LVGL 8.x 风格,如果工程用 LVGL 9.x,API 会变成lv_display_create那一套,但“缓冲、分辨率、flush 回调、tick”四件事不变。hor_res和ver_res必须和 ST77903 实际行列方向一致,MADCTL 里改了方向,这里也要跟着改,否则触摸坐标和显示坐标会对不上。

4.2 flush_cb 里把 area 转成 ST77903 窗口

flush_cb收到的area是包含端点的坐标,宽度是x2 - x1 + 1,高度同理。ST77903 设置窗口通常要发 Column Address Set 和 Row Address Set,再发 RAMWR 写像素。坐标端点如果少加 1,会出现最后一列或最后一行不刷新;坐标顺序如果和 MADCTL 不一致,图像会整体偏移。RGB565 的字节序也要在这里确认:STM32 是小端,LVGL 默认像素排列和屏厂要求不一定一致,颜色偏蓝或偏红时先查这个,不要急着改 gamma。

static void st77903_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { lcd_area_t req = { .x1 = area->x1, .y1 = area->y1, .x2 = area->x2, .y2 = area->y2, .data = (const uint8_t *)color_p, }; /* 只投递,不在这里等 DMA;DMA 完成后再调用 flush_ready */ if (rt_mq_send(&lcd_mq, &req, sizeof(req)) != RT_EOK) { /* 队列满时退化为同步,避免丢帧造成花屏 */ st77903_set_window(req.x1, req.y1, req.x2, req.y2); st77903_write_pixels(req.data, (req.x2 - req.x1 + 1) * (req.y2 - req.y1 + 1) * 2); rt_sem_take(lcd_dma_done, RT_WAITING_FOREVER); lv_disp_flush_ready(drv); } }

队列满时退化为同步是“后悔药”,宁可这一帧慢一点,也不要把缓冲指针直接丢掉。st77903_set_window内部要按 ST77903 的命令格式发0x2A、0x2B,再发0x2C等像素;这些命令的参数长度和字节序以手册为准。color_p指向 LVGL 的绘制缓冲,RGB565 下每像素 2 字节,传输长度就是宽 × 高 × 2。如果显示颜色明显不对,先试 LVGL 的LV_COLOR_16_SWAP配置,或者在发送前手动交换高低字节,别一上来就改屏厂初始化表。

4.3 刷新率与局部刷新:什么时候该开双缓冲

以 320×480、RGB565 为例,一帧像素约 300KB。QSPI 四线在 50MHz 下的理论带宽是 25MB/s 量级,实际受命令开销、DMA 启动、总线竞争影响,能到 12~18MB/s 就不错,全屏一帧大约 17~25ms。单缓冲局部刷新在 GUI 场景里通常够用,因为 LVGL 只传脏区域;但动画大面积刷新时,单缓冲会出现“绘制下一帧时上一帧还没发完”的撕裂。双缓冲能缓解撕裂,代价是内存翻倍,而且 flush 完成回调的时序要更严谨。

方案内存占用撕裂适合场景
单缓冲 40 行约 25.6KB大区域动画可能撕裂首版调通、静态页面
双缓冲 40 行约 51.2KB明显改善常规 GUI、列表滑动
全屏单缓冲约 300KB刷新期间可能可见内存充足的 SDRAM 方案
全屏双缓冲约 600KB较好,但带宽压力大高帧率动画、视频帧
static void lvgl_tick_thread(void *arg) { while (1) { lv_tick_inc(2); /* 2ms tick,按 LVGL 配置的 tick 周期改 */ rt_thread_mdelay(2); } }

tick 线程的周期要和 LVGL 内部配置一致,lv_tick_inc(2)配 2ms 延时是常见组合;不要把这个线程优先级设得比 LCD 刷新线程还高,否则它会和 QSPI 传输抢 CPU。局部刷新阶段先把LCD_BUF_LINES设为 20~40 行,观察列表滑动和大面积重绘的表现,再决定要不要加到 80 行或改双缓冲。QSPI 时钟也是同样思路:50MHz 稳定后再试 65MHz、80MHz,每次只改一个变量,跑十分钟看有没有偶发花屏。

5. 避坑与排查:ST77903 QSPI 屏最容易翻车的五处

5.1 现象:白屏,但读 ID 正常

现象是上电

本文还有配套的精品资源,点击获取