☰
STM32H743+QSPI驱动ST77903:RT-Thread与LVGL显示方案详解
2026/10/16 7:01:01 网站建设 项目流程

简介:矽创ST77903 DEMO基于STM32H743平台,运行RT-Thread实时系统,并采用LVGL图形库,面向穿戴设备与小型LCD驱动开发者。该芯片为SITRONIX小体积IC,内部无RAM,QSPI必须连续传输,工程在HOST侧创建专门线程负责刷屏,示例内置3个手表表盘,演示无显存屏的驱动与UI整合。压缩包为7z,共2591个文件、约13.66MB;其中C源码和头文件合计超2100个,另含汇编启动文件、链接脚本、SConscript构建脚本、IAR工程配置、Kconfig配置及文档图片,目录结构清晰。该DEMO已有2585人学习下载。读者可从QSPI写入时序、刷屏线程调度、LVGL接口适配、多表盘界面组织中借鉴实现,并移植到其他主控或屏驱方案,是ST77903项目实用的参考。

1. 一块屏点不亮,问题往往不在屏本身

做嵌入式显示的同学大概率有过这种经历:新屏上板,电源正常、背光亮了、像素时钟也有了,但屏幕上要么是一片白,要么是雪花噪点,查了半天发现是初始化寄存器序列里某个延时不对,或者 QSPI 的读写时序跟驱动 IC 的预期对不上。这个 ST77903 的 Demo 项目解决的就是这么一件事——用一块 STM32H743 通过 QSPI 接口驱动矽创 ST77903 这块 LCD 驱动 IC,跑 RT-Thread 系统,再叠一层 LVGL 图形库把界面画出来。整套环境是 H743 + RT-Thread + LVGL + QSPI 的组合,适合要在自己的板子上快速点亮同类 LCD 屏、又不想从头啃 datasheet 和驱动时序的开发者。它能帮你把「屏亮了」到「界面能交互」这条链路完整跑通,省掉最折磨人的点亮阶段。

2. QSPI 驱动方案的架构剖析:为什么不用并口

ST77903 是一颗分辨率支持到 480x854 级别的 LCD 驱动芯片,常见的接法有两种:一是传统的 MCU 并口(8080 或 6800 时序),数据线七八根起步,还得配上 RD/WR/CS/RS 控制线;二是 SPI/QSPI 串行接口,引脚少、布线干净。这个 Demo 选的是 QSPI,也就是四线 SPI,一个时钟四根数据线,DMA 搬运数据之后 CPU 基本可以撒手不管。

2.1 外设资源分配与引脚规划

整个系统跑在 STM32H743 上,RT-Thread 作为实时操作系统内核,LVGL 跑在它上面做图形渲染。看这个 Demo 的工程,外设分配大概是这么个格局:QSPI 挂在 QUADSPI 外设上,使用 Bank1,映射到 AHB 地址空间;LCD 的 DC 脚(数据/命令选择)由普通 GPIO 控制,因为 QSPI 协议本身不区分命令和数据,得靠一个额外的引脚来告诉 ST77903 当前发的是命令还是数据;背光 PWM 走一个定时器通道,亮度可调;触摸如果有的话挂在 I2C 或 SPI 上,这个 Demo 主体是显示,触摸不是重点。

引脚规划的细节值得提一句:QSPI 的四根数据线(IO0-IO3)、时钟线(CLK)、片选线(CS)必须保证布线等长,高频下时序偏差才不会太离谱。H743 的 QUADSPI 引脚是固定的几个映射位置,比如 PF8-PF10 是 QSPI 的一组映射,不同封装引脚不一样,选型时先查数据手册确认你用的封装有没有把 QSPI 引脚引出来。这个坑我在别的项目里踩过——芯片选了 100 脚的封装,结果 QSPI 的四个 IO 被其他功能占掉了,最后只能改方案走普通 SPI。

2.2 目录结构:从驱动到应用的层次划分

这个 Demo 的工程目录沿用了 RT-Thread Studio 的标准结构,但加了 LCD 相关的几个关键目录层,大致如下:

project ├── applications │ ├── main.c // 入口,创建 GUI 线程 │ └── lv_demo/ │ ├── lv_demo_main.c // Demo 页面逻辑 │ └── lv_demo_assets/ // 图片、字体资源 ├── drivers │ ├── qspi_if.c // H743 QUADSPI 底层读写封装 │ ├── st77903.c // ST77903 初始化、背光控制、显存配置 │ └── lcd_port.c // LVGL 的 flush 回调、画布底层对接 ├── rt-thread │ └── components │ └── drivers │ └── spi │ └── qspi.c // RT-Thread QSPI 设备驱动框架 └── packages └── LVGL // LVGL 源码包

这个分层思路是:RT-Thread 的设备框架负责把 QSPI 抽象成设备,qspi_if.c 实现底层的读写函数,st77903.c 调这些函数做初始化序列,最上层 lcd_port.c 把 LVGL 的绘制请求转换成往 ST77903 显存里写像素。每一层只干一件事,调试的时候能快速定位是底层读写问题还是上层渲染问题。

2.3 QSPI 协议下 ST77903 的命令与数据封装

ST77903 工作在 QSPI 模式时,传输的格式分为命令阶段和数据阶段。QSPI 控制器发命令的时候,四个数据线全部走命令内容;发数据的时候四个线并行传输数据字节。用 QUADSPI 外设时,典型操作是这么组织的:先通过 QUADSPI 的通信配置寄存器设置传输模式,命令阶段用单线 SPI 模式发指令码,数据阶段用四线 QSPI 模式写参数。

这里有个关键点:ST77903 的 QSPI 模式下,发命令和发数据靠 DC 引脚区分,QSPI 外设本身不感知这个区别。所以驱动里每次操作都是同一个套路——先拉低 DC 脚,写命令字节;再拉高 DC 脚,写参数数据。如果 DC 脚电平搞反了,命令和数据互换了,那结果就是屏幕花屏或者完全没反应,而逻辑分析仪上看到的波形又好像是对的,这个坑排查起来很玄学。

3. ST77903 初始化时序拆解:一帧图像的完整生命周期

屏要亮起来,初始化顺序不能乱。ST77903 从上电到正常显示,中间要经过硬件复位、电源设置、显示模式配置、显存写入、背光开启这么几个阶段。这个 Demo 的 st77903.c 文件把初始化序列整理成了一个命令表结构,每一行是一条命令加参数,配合延时控制,这种方式的好处是后期调整参数时不用改代码流程,只改表就行。

3.1 初始化命令表的组织方式

命令表结构在 Demo 里大概是这样的:

typedef struct { uint8_t cmd; // 命令码 uint8_t data_len; // 参数长度 uint8_t *data; // 参数数组指针 uint16_t delay_ms; // 命令发送后的延时 } lcd_init_cmd_t; static const uint8_t st77903_power_seq[] = {0x01, 0x02, 0x03}; static const uint8_t st77903_gamma_seq[] = {0x10, 0x20, 0x30, 0x40}; static const lcd_init_cmd_t st77903_init_sequence[] = { {0x11, 0, NULL, 120}, // Sleep Out {0xB0, 3, st77903_power_seq, 10}, // 电源控制 {0x36, 1, (uint8_t[]){0x00}, 0}, // 扫描方向 {0x3A, 1, (uint8_t[]){0x66}, 0}, // 像素格式 RGB666 {0xB6, 2, (uint8_t[]){0x0A, 0x82}, 0}, // 显示功能控制 {0x29, 0, NULL, 20}, // Display ON };

每个命令发送函数会遍历这张表,逐条调用 qspi_write_cmd() 发送。命令码 0x11 是 Sleep Out,让芯片从睡眠模式唤醒;0x36 设置 RGB 扫描方向和颜色顺序,这个寄存器决定图像的旋转方向;0x3A 设置像素格式,0x66 表示 R、G、B 各 6 位,也就是 RGB666,正好符合 ST77903 支持的最大并行位数。

延时的作用最早我在写驱动时没太在意,后来发现少一个延时屏幕就是花的。ST77903 内部有 DC-DC 电源电路,切换工作模式后需要时间等待内部稳压器稳定,这个延时写短了,后续命令在电源未稳定时被忽略,表现就是寄存器写入了但从示波器上看到的 DC 脚波形又都对。所以这个命令表里必须保留延时字段,上电初期几个关键节点的延时宁多勿少。

3.2 QSPI 读写函数的核心实现

底层读写函数是驱动的地基,看这段代码:

uint8_t qspi_write_cmd(uint8_t cmd, uint8_t *data, uint32_t len) { QSPIDrv_Handle.Command = cmd; QSPIDrv_Handle.Address = 0; QSPIDrv_Handle.DataSize = len; QSPIDrv_Handle.AddressSize = 0; QSPIDrv_Handle.DummyCycles = 0; QSPIDrv_Handle.InstructionMode = HAL_QSPI_INSTRUCTION_1_LINE; QSPIDrv_Handle.AddressMode = HAL_QSPI_ADDRESS_NONE; QSPIDrv_Handle.DataMode = (len > 1) ? HAL_QSPI_DATA_4_LINES : HAL_QSPI_DATA_1_LINE; QSPIDrv_Handle.NbData = len; HAL_QSPI_Command(&hqspi, &QSPIDrv_Handle, HAL_QSPI_TIMEOUT_DEFAULT_VALUE); HAL_QSPI_Transmit(&hqspi, data, HAL_QSPI_TIMEOUT_DEFAULT_VALUE); return 0; }

这段代码的关键在 QSPIDrv_Handle 的配置上:InstructionMode 设成单线模式发命令码,DataMode 根据参数长度自动选择四线或单线。LCD 命令一般只有一两个字节,走四线反而浪费,所以这里做了一个分支。AddressMode 设为 NONE 是因为 ST77903 的显存访问走的是连续的窗口地址,不需要单独发地址阶段。

参数说明:hqspi 是初始化好的 QUADSPI 句柄;HAL_QSPI_TIMEOUT_DEFAULT_VALUE 是超时值,一般用默认的 1000ms。注意 HAL_QSPI_Command 和 HAL_QSPI_Transmit 是两个独立调用,Command 负责把命令码和传输参数配置好,Transmit 才是真正启动传输。有同事把这个流程写成先 Transmit 后 Command,结果数据全被当成命令发出去,屏幕直接花掉。

3.3 显存地址窗口的配置:写像素前先划区域

ST77903 内部有 GRAM,访问它需要先设置访问窗口范围。每次刷屏或者 LVGL 局部刷新时,驱动会把窗口设成需要更新的那块区域,然后连续写像素数据。窗口设置命令是整个显示流程里调用频率最高的部分:

void st77903_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { uint8_t x0h = (x0 >> 8) & 0xFF, x0l = x0 & 0xFF; uint8_t x1h = (x1 >> 8) & 0xFF, x1l = x1 & 0xFF; uint8_t y0h = (y0 >> 8) & 0xFF, y0l = y0 & 0xFF; uint8_t y1h = (y1 >> 8) & 0xFF, y1l = y1 & 0xFF; uint8_t col_data[4] = {x0h, x0l, x1h, x1l}; uint8_t row_data[4] = {y0h, y0l, y1h, y1l}; qspi_write_cmd(0x2A, col_data, 4); // 列地址设置 qspi_write_cmd(0x2B, row_data, 4); // 行地址设置 qspi_write_cmd(0x2C, NULL, 0); // 准备写入 GRAM }

0x2A 是列地址寄存器,0x2B 是行地址寄存器,0x2C 是显存写命令。窗口范围按照高字节在前、低字节在后的顺序传进去,刚好覆盖到 480x854 的最大分辨率。设置完窗口后,连续向 QSPI 写像素数据即可,芯片会自动把数据填入窗口并滚动指针。

要注意的是 0x2C 这条命令在 ST77903 上可以不带数据直接发,芯片进入写显存状态后,后续传输的全部字节都按像素数据处理。这个设计在 LVGL flush 时特别有用,可以一口气刷一大堆数据而不用每条都重复设置窗口,吞吐量能翻好几倍。

4. LVGL 移植落地:一张图像的渲染路径

LVGL 在本项目里跑在 RT-Thread 上,通过软件定时器驱动它的 tick 心跳,显示部分对接 st77903 驱动,输入设备暂时用不到。整体链路是:LVGL 的渲染引擎计算完一帧画面后,调用用户注册的 flush 回调,把像素数据交给底层驱动写入显存,QSPI + DMA 完成实际搬运。

4.1 flush 回调与画布缓冲的实现

LVGL 的显示驱动结构体 lv_disp_drv_t 里需要注册 flush_cb,核心代码长这样:

static void lcd_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { st77903_set_window(area->x1, area->y1, area->x2, area->y2); qspi_write_pixels((uint8_t *)color_p, area->x2 - area->x1 + 1, area->y2 - area->y1 + 1); lv_disp_flush_ready(drv); }

area 结构体保存了 LVGL 需要更新的矩形区域坐标,color_p 是指向像素缓冲区的指针。flush 回调里先设置显存窗口到目标区域,然后把整个区域的像素一次性写入 QSPI。lv_disp_flush_ready 必须在数据写完后再调用,它通知 LVGL 这块缓冲区可以回收复用了。如果提前调用,LVGL 可能会在 DMA 还没搬完数据时复用这块 buffer,画面上就会出现撕裂或闪烁。

像素格式这块需要对齐,LVGL 编译时需要把 LV_COLOR_DEPTH 设成 16 或 24,取决于采用的 RGB565 还是 RGB888。Demo 里 ST77903 配的是 RGB666,但 LVGL 的颜色深度不可能设 18,常见做法是 LVGL 用 RGB565,传输给 ST77903 之前做一次转换。如果直接写 RGB565 数据到 ST77903 的 RGB666 模式里,颜色会偏色,而且偏得很奇怪。这个的解决思路是 ST77903 支持 RGB565 输入格式,把像素格式寄存器改成 0x55(RGB565),两边就对齐了,性能还比 RGB666 高。

4.2 双缓冲设计:用空间换流畅度

LVGL 可以配单缓冲也可以配双缓冲,这个 Demo 用的是双缓冲模式:

static lv_color_t buf1[MY_DISP_HOR_RES * 40]; static lv_color_t buf2[MY_DISP_HOR_RES * 40]; lv_disp_draw_buf_init(&draw_buf, buf1, buf2, MY_DISP_HOR_RES * 40);

buf1 和 buf2 分成两个相同大小的 buffer,LVGL 在绘制 buf1 里的内容时,DMA 可以在搬 buf2 里的已绘制数据。两个 buffer 交替使用,渲染和传输流水线化,画面刷新率明显提升。行数取 40 的意思是每次渲染 40 行像素,大约占整个屏幕高度的 1/12,虽然不算大,但对 QSPI 传输来说已经是足够大的一块连续数据。

若内存允许,把 buffer 高度放大到全屏的一半甚至整屏,刷新率还能再上一个台阶。但 H743 的内部 RAM 就那么大,还得给 RT-Thread 的任务栈和 LVGL 的堆预留空间,所以双缓冲的高度选择是个折中。用 DMA 传输像素数据时,buffer 地址要注意 32 位对齐,否则 DMA 配置直接报错,这是 H7 系列一个容易翻车的细节。

4.3 RT-Thread 环境下的 LVGL 心跳与线程模型

LVGL 需要一个周期性的心跳来计时动画和任务调度,在裸机环境下通常用一个定时器中断。在 RT-Thread 里,可以借助软件定时器的形式实现:

static rt_timer_t lvgl_tick_timer; static void lvgl_tick_handler(void *parameter) { lv_tick_inc(1); } void lvgl_port_init(void) { lv_init(); lvgl_tick_timer = rt_timer_create("lvtick", lvgl_tick_handler, RT_NULL, 1, RT_TIMER_FLAG_PERIODIC | RT_TIMER_FLAG_SOFT_TIMER); rt_timer_start(lvgl_tick_timer); }

每个系统 tick 调用一次 lv_tick_inc(1),LVGL 内部就会维护自己的时间基准,动画和输入设备的轮询都倚赖这个时间源。这里用软定时器而不是硬定时器,好处是不会阻塞中断上下文。tick 间隔取 1ms,适配 LVGL 默认的 LV_DISP_DEF_REFR_PERIOD 配置。

界面渲染线程的写法也很值得看一下:

void gui_thread_entry(void *parameter) { lvgl_port_init(); lv_demo_main(); while (1) { lv_timer_handler(); rt_thread_mdelay(5); } }

lv_timer_handler() 是 LVGL 的核心循环函数,它处理所有待执行的任务——包括动画、对象刷新、输入事件。每 5ms 调用一次,正好匹配 LVGL 推荐的 20fps 左右的刷新节奏。mdelay 5ms 会让出 CPU,给其他 RT-Thread 任务留出运行时间,不至于因为 GUI 线程死循环把整个系统卡死。

5. 实战避坑记录:三个屏幕问题的排查全过程

跑这种显示项目,屏幕能亮跟屏幕稳定显示之间差了十万八千里。整理几个在这类项目里反复出现的坑,每条都是真实调试路径的复盘,希望能帮你省点时间。

5.1 现象一:上电后屏幕全白,初始化命令像没发出去

原因:QSPI 的 IO 引脚没配置成复用功能,或者复用功能编号选错了。H743 的每个 PF 引脚可以映射到多个外设,如果 GPIO 配置里没把 AF 号选到 QUADSPI 对应的那个,那 QUADSPI 发数据时引脚根本不响应。

解决:检查 GPIO 初始化代码,确认 AF 号与芯片参考手册里的 QUADSPI 映射表一致。AF 号码跟引脚的物理位置无关,同一个 QUADSPI_CLK 在不同引脚上的 AF 编号可能是不同的。一个验证办法是先把 QSPI 速度降到最低(比如 2MHz 分频),用逻辑分析仪抓一下 CLK 线有没有波形——如果有波形那基本是时序问题,如果没波形就是引脚配置问题。

5.2 现象二:图像正常但偏色,红色和蓝色互换

原因:像素格式寄存器 0x3A 里的格式位选错。ST77903 支持 RGB565、RGB666 等多种格式,但控制位在不同版本芯片上的含义有差异。有时候寄存器写的是 0x66(RGB666),LVGL 那边给的是 RGB565 数据,两边一错位,颜色通道就乱了。

解决:要么把 LVGL 的 LV_COLOR_DEPTH 改成 16 并配套把 ST77903 设成 RGB565,要么保持 RGB666 并把 LVGL 的 LV_COLOR_DEPTH 配成 24。最省事的做法是统一成 RGB565。改完记得同时检查 0x36 寄存器里的 RGB 顺序位(BGR 还是 RGB),有的屏是 BGR 排列,数据一样但颜色定义不同,表现同样是偏色。

5.3 现象三:LVGL 动画一开就闪烁,画面隔几帧撕裂一次

原因:单缓冲模式下 LVGL 边渲染边显示,DMA 传输和渲染引擎同时访问同一个 buffer,互相干扰。另一个原因是 flush 回调里没有等 DMA 完成就调用了 lv_disp_flush_ready。

解决:至少启用双缓冲,让渲染和传输并行。flush 回调里等待 DMA 传输完成,可以用 HAL_QSPI_GetState 轮询状态位,或者用 DMA 的中断标志。确认传输完成后才调用 lv_disp_flush_ready,这是一个很隐蔽但后果很严重的问题——时序差那么一点点,表现出来的就是满屏闪烁,而这跟 QSPI 的时钟频率没有关系。

6. 二次开发思路:从 Demo 到可交付产品的三个改造点

这个 Demo 跑通之后,离一个真正能交付的产品还有一段路要走,经验和踩坑的价值往往体现在这些改造上。

第一个改造点是分辨率适配。ST77903 支持的分辨率和扫描方向取决于屏厂的具体面板切割,不同的屏模组可能同样的 IC 配不同的初始化参数。拿到新屏之后不要直接复用命令表,先找屏厂要一份出厂初始化代码,把命令表里的差异参数替换掉,尤其是伽马曲线相关的寄存器组,跟屏厂的调色结果直接挂钩。命令表设计成数组的好处在这里就体现出来了——改参数不用改逻辑。

第二个改造点是动画性能的验证方法。LVGL 的流畅度跟 QSPI 的时钟频率和 DMA 的传输效率强相关,可以在工程里加一个帧率统计的 hook 函数,在 lv_timer_handler() 里每隔一秒统计一次刷新帧数,对比不同 QSPI 分频系数下的帧率。我一般会用这个方式去验证 QSPI 时钟频率的选型是否合理。H743 的 QUADSPI 最高可以跑到 100MHz 以上,但 PCB 布线质量不够的话高频就跑不稳,帧率测试能让你快速定位是布线瓶颈还是芯片瓶颈。

第三个改造点是耗时操作的任务化。现在的 GUI 线程里如果直接处理大文件的读取或者复杂的计算,LVGL 的动画就会卡顿。RT-Thread 的优势这时候就能发挥出来,把耗时操作放到独立线程里执行,GUI 线程保持轻载。这个 Demo 可以作为多线程 GUI 架构的起点模板,把 LVGL 的任务优先级安排在低于关键实时任务的级别,避免界面卡顿影响到底层控制逻辑。

做完这几个改造,再回头看这个 Demo,其实套路清楚了也就没那么神秘:QSPI 负责搬运,ST77903 负责显示,LVGL 负责画图,RT-Thread 负责调度。从那以后我每接到一个带屏的新项目,都会先按这个架构走一遍——先点亮屏幕,再跑 LVGL Demo,最后才做功能逻辑,这套流程帮我少走了很多弯路,希望也帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询