简介:这是一份面向嵌入式开发者的矽创ST77903屏幕驱动与应用Demo,适用于STM32H743平台,结合RT-Thread实时操作系统与LVGL图形库,通过QSPI接口实现小尺寸LCD的高效刷新。ST77903是矽创面向穿戴设备的小尺寸LCD控制IC,内部不带显存,需依赖主机连续传输,Demo在主机侧建立专门刷新线程,保证刷屏稳定。压缩包共2591个文件,以C源文件(1016个c)与头文件(1090个h)为主体,辅以链接脚本、sconscript构建脚本及少量png/gif素材,整体约13.66MB,目录结构完整,便于按模块阅读。工程代码按RT-Thread目录组织,既有内核与BSP适配,也有应用层表盘绘制,三种表盘示例覆盖指针、数字与简单动画,可直接按需裁剪复用。资源已有2585人学习,适合正在调试QSPI屏、RT-Thread+LVGL渲染或评估ST77903的开发者参考,可节省环境搭建与驱动适配时间。
1. 矽创 ST77903 LCD 上 QSPI:接口一错,后面全是白屏
第一次用 STM32H743 的 QSPI 外设去拉矽创 ST77903 这块 LCD 时,我在这颗屏上栽了个大跟头。手册标的是 DBI Type C 接口,很多人下意识以为能直接挂到并口总线上,结果板子回来发现信号线一共就四根 SPI 再加控制线,整个显示方案要从 QSPI 外设重新捋。ST77903 是 SITRONIX 的显示驱动 IC,常见于 2.4 到 4.3 寸的中小尺寸 IPS 屏,和 STM32 的 QSPI 外设搭配做低成本图形界面,在消费类仪表、工控小屏里出现频率很高。
这套环境本身不复杂:一颗 STM32H743、一块 ST77903 屏幕、RT-Thread 操作系统、LVGL 图形库,四样东西各管一摊。可一旦接口模式选错、初始化序列没对上、像素格式不匹配,表现出来就是白屏、花屏、刷新拖影,排查起来非常玄学。尤其初始化时序,厂家给一版数组,我们照着抄还得改命令长度,改错一个延迟标记,屏幕就罢工。
这篇文章就把这套环境的搭建过程完整记录一遍,从硬件引脚分配、RT-Thread 驱动裁剪,到 ST77903 初始化序列、LVGL 对接,再到白屏花屏的排查方法,适合正在用 STM32H743 做界面开发,或者刚拿到 ST77903 规格书不知道从哪下手的开发者。
2. 环境准备:STM32H743 的 QSPI 外设与 RT-Thread 驱动裁剪
2.1 引脚分配:四线 SPI 模式下的 QSPI 通道
先说明一点:STM32H743 的 QSPI 外设支持单线、双线、四线模式,而 ST77903 这颗屏的数据接口实际跑的是标准 3 线/4 线 SPI。项目里把 QSPI 外设配置成单线 SPI 模式,命令和数据都从 QSPI_D0 这根线上走,速度完全够用,而且能让 PCB 走线简单不少。
引脚分配建议按下面这张表来,QSPI 时钟、片选、数据线之间留出独立的 GPIO 口用于复位和背光,方便后续单独控制:
| 信号 | STM32H743 引脚 | 说明 |
|---|---|---|
| QSPI_CLK | PF6 | 时钟线,极性/相位按手册设置 |
| QSPI_CS | PF10 | 片选,低有效 |
| QSPI_D0 | PF8 | 数据线,单线模式下命令与数据共用 |
| LCD_RST | PH6 | 复位,普通 GPIO 输出 |
| LCD_BL | PH7 | 背光,PWM 或 GPIO 控制 |
实际引脚要按你手里的板子来,但分组思路是固定的。复位脚和背光脚一定不要和 QSPI 四根线挤在一起,因为初始化时序里复位需要单独拉低再拉高,背光要推迟到显示初始化完成后再开,混在一组容易互相干扰。
时钟树方面,STM32H743 的 QSPI 外设挂接在 AHB3 总线上,最大支持到 133MHz 左右,一般设成 50MHz 甚至更低更稳妥。这个频率不是越高越好,长线缆或杜邦线连接时高频下时序会劣化,信号完整性差一点就是花屏。
配置工作最好在 CubeMX 里先完成一半,剩下的一半在 RT-Thread 工程里补。CubeMX 里打开 QUADSPI 外设,选择 Single Flash 模式,把 Prescaler 设为 4,这样在 200MHz 的系统时钟下 QSPI 时钟就是 50MHz。再把 GPIO 的复用功能配成 AF10(QUADSPI),速度等级选 High。
注意:H743 的 QSPI 外设时钟源可能在 PLL1Q 或者系统时钟的不同分支上,改时钟树之后一定要回到 QSPI 配置页再确认一次实际输出频率,不然你以为的 50MHz 可能是 100MHz。
2.2 RT-Thread 驱动裁剪:QSPI 框架和 DMA 开关
RT-Thread 的 QSPI 驱动框架是挂在 SPI 设备驱动之上的,所以裁剪驱动时先看 SPI 总线设备有没有打开,再看 QSPI 设备有没有注册。在 env 工具里进入 menuconfig,依次打开 RT-Thread Components -> Device Drivers -> SPI 相关的选项,找到串行 Flash 通用驱动库(SFUD)旁边的 QSPI 支持选项,把它勾上。
$ cd rt-thread/bsp/stm32/stm32h743-xxx $ menuconfig在菜单里逐级进入 RT-Thread Components -> Device Drivers -> SPI,确认 SPIBUS 已经打开。然后进入 QSPI 选项,使能 QSPI 设备支持。保存退出后执行scons --dist生成独立工程目录,再执行scons编译。
这里的逻辑是:RT-Thread 把 QSPI 抽象成 SPI 设备的一个变种,注册时用 SPI 框架的接口,发送和接收都走同一套 buffer 机制。所以裁剪漏了 SPI 总线,QSPI 也会跟着失效,不少人在这一步编译过了、下载运行却报 device not found,就是这个原因。
DMA 开关值得单独说。QSPI 发送大块像素数据时,手动查忙标志的方式会让 CPU 空转,帧率掉得厉害。所以我在工程里把 QSPI 的 DMA 库打开,在 CubeMX 里使能 QUADSPI 的 DMA 请求,然后在 RT-Thread 驱动的 platform 头文件里把使用 DMA 的宏定义打开:
// board.h 或 stm32h7xx_hal_conf.h 相关位置 #define BSP_USING_QSPI #define BSP_USING_QSPI_DMA打开后再编译,QSPI 发送数据时会自动申请 DMA 通道,CPU 只要把 buffer 地址和长度交给外设就行。注意这里的 DMA 中断优先级不要和系统心跳中断抢,一般设成比定时器中断低一档。
2.3 搭建工程:从 BSP 模板起步比从零开始稳
H743 的 BSP 模板通常带好了 GPIO、串口、SPI 的板级初始化,拿到手先编译一遍确认工具链正常。项目工程我一般从 bsp/stm32 目录下复制一份 H743 系列模板,然后按 2.1 的引脚表改 board.c 里的 GPIO 配置。
编译第一次会碰到几个典型报错:缺 rtconfig.h 里的配置、QSPI 相关头文件路径没加进去、HAL 库里 QUADSPI 模块未使能。这些都在芯片型号的头文件里处理,搜索 HAL_QSPI_MODULE_ENABLED,确认宏是 1。
工程能跑起来之后,先用 msh 命令确认外设注册成功。
msh> list_device msh>设备列表里能看到 qspi,至少说明硬件初始化和驱动注册这一层通了。到这里环境准备才算是真正告一段落,后面可以把注意力全部放到 ST77903 的初始化上。
3. 初始化 ST77903:从 SITRONIX 手册到 QSPI 命令封装
3.1 读时序参数:RESET 时序和 SPI Mode 决定成败
ST77903 的初始化分两段:一段是硬件的复位和上电时序,一段是软件寄存器配置。第一段如果没做对,后面写什么都白搭。屏的复位引脚要有一个由高到低再到高的动作,低电平保持时间至少 10us,然后拉高,拉高之后至少要等 120ms 再发第一条命令,这个 120ms 要写进初始化代码里而不能靠人工延时糊弄。
这一段的代码骨架固定得很死,我一般写成一个小函数,供初始化流程开头调用:
void st77903_hard_reset(void) { rt_pin_write(LCD_RST_PIN, PIN_LOW); // 拉低复位 rt_thread_mdelay(20); // 保持低电平 20ms,比手册要求的 10us 宽裕 rt_pin_write(LCD_RST_PIN, PIN_HIGH); // 释放复位 rt_thread_mdelay(150); // 等待片内上电稳定,至少 120ms }拉低复位和释放复位之间留的时间,我习惯按手册最小值的十倍来写。原因是屏的内部电源和 OSC 需要时间收敛,尤其低温环境下启动会更慢。这参数宁可放大,不要卡着最小值用,否则偶发性白屏排查起来最痛苦。
SPI Mode 方面,ST77903 支持 Mode 0(CPOL=0, CPHA=0)和 Mode 3(CPOL=1, CPHA=1),两种模式读取时序有差异。QSPI 外设配置成 Mode 0 最省心,命令和数据的采样点都对齐在上升沿。如果之前在其他平台用过这块屏,注意先确认平台用的 SPI 模式,移植到 H743 时要保持一致。
3.2 初始化序列:厂家数组与命令长度
初始化寄存器序列一般由屏厂提供,格式通常是 命令字节 + 参数 + 延迟标记 的数组。不同的屏厂给的格式不同,有的用结构体,有的用纯数组,拿到手先做一步转换,统一成下面这种结构:
const uint8_t st77903_init_seq[] = { 0x01, 0x00, 0x80, // Software Reset,参数 0,延迟标记 128 0x11, 0x00, 0x80, // Sleep Out 0x36, 0x01, 0x00, // MADCTL,参数长度 1,参数值 0x00 0x3A, 0x01, 0x05, // COLMOD,参数长度 1,参数值 0x05 表示 16bit // ... 后续厂家私有初始化命令 0x29, 0x00, 0x00, // Display ON };数组里每三个字节一组:第一个字节是命令号,第二个字节是参数个数,第三个字节在高位标记是否需要延时时,剩余的低位是延迟时间。上面 0x80 表示命令发送完成后要等待,0x00 表示不需要延时。
重点说下几个命令。0x36 地址控制命令里,0x00 表示 RGB 顺序、扫描方向从上到下从左到右,如果你的屏是横屏却显示反的,改这个值比改代码逻辑快得多。0x3A 像素格式命令,0x05 对应 16 位色深,LVGL 端就要配 RGB565;0x06 对应 18 位,LVGL 端就得换 RGB888 的驱动配置,两边的像素格式必须完全一致,否则就是花屏。
发这种命令序列时,数据长度和命令长度要分清楚。屏先读一个命令字节,再按参数个数继续读参数,顺序错了屏就显示乱码。常见错误是数组更新大小时忘了把延迟标记位挪位置,结果整段初始化序列错位。
3.3 QSPI 命令封装:发送命令与发送数据的边界
QSPI 外设发送命令时,裸命令和数据在协议上要区分清楚。为此我把发送封装成两个函数,一个发命令字节和参数,一个发像素数据,后者会被 LVGL 的 flush 回调频繁调用,性能要求更高:
static rt_err_t st77903_send_cmd(uint8_t cmd, uint8_t *params, uint16_t len) { rt_device_t dev = rt_device_find("qspi"); struct rt_qspi_message msg; msg.instruction = cmd; // 命令字节 msg.instruction_size = QSPI_1_LINE;// 单线模式 msg.address_size = QSPI_1_LINE; msg.qspi_data_lines = QSPI_1_LINE; msg.dummy_cycles = 0; msg.parent.cs_take = 1; msg.parent.cs_release = 1; msg.parent.send_buf = params; msg.parent.recv_buf = RT_NULL; msg.parent.length = len; return rt_qspi_transfer_message(&dev->device, &msg); }参数说明:这条命令发送的指令字节永远是单线模式发送,ST77903 在命令阶段只认一根数据线,这一点和很多 Nor Flash 的 QSPI 四线命令模式不同。params 为空、len 为 0 时,就只发命令字节不发数据,正好用于软件复位和睡眠退出这类无参数命令。
像素数据的发送则走同一套 rt_qspi_transfer_message,区别在于把 instruction 设为显存写命令 0x2C(RAMWR),数据段就是整个帧缓冲:
static rt_err_t st77903_write_gram(uint8_t *buf, uint32_t len) { struct rt_qspi_message msg; msg.instruction = 0x2C; msg.instruction_size = QSPI_1_LINE; msg.qspi_data_lines = QSPI_1_LINE; msg.parent.send_buf = buf; msg.parent.length = len; return rt_qspi_transfer_message(qspi_dev, &msg); }注意 write_gram 调用频率很高,每次刷一帧就是几百 KB 的数据量。发送前先确认之前一次传输已经完成,不然 RT-Thread 的 SPI 设备忙标志会直接返回错误。我一般会加一个状态检查,忙时等待一小段时间,保证刷新不丢帧。
注意:QSPI 命令封装好后先用逻辑分析仪抓一次写命令的波形,确认 CS 拉低、命令字节、数据字节的顺序没有颠倒,再继续往后接 LVGL,这一步能避免后续一大堆显示异常。
到这里屏的底层通路已经打通,接下来把 LVGL 接上,让界面系统真正动起来。
4. LVGL 对接:缓冲策略与刷新机制决定帧率
4.1 显示设备注册:把 QSPI 屏接进 RT-Thread 设备框架
LVGL 跑在 RT-Thread 上,最常用的对接方式是走 RT-Thread 的显示设备框架,或者直接把 QSPI 屏的驱动封装成一个 lv_disp_drv_t 结构体。两种方式我都会用,但推荐后者,因为它省掉一层抽象,flush 回调直接调 QSPI 发送函数,路径最短、延迟最低。
在 lv_port_disp_init 中,需要实现两个函数指针:init 回调做屏的初始化,flush 回调把 draw buffer 写入显存。注册代码大致长这样:
static lv_disp_drv_t disp_drv; static lv_disp_buf_t disp_buf; static lv_color_t buf[LV_HOR_RES_MAX * 40]; // 40 行缓冲,可按内存调整 void lv_port_disp_init(void) { st77903_hard_reset(); st77903_init_seq(); // 发送初始化数组 st77903_set_window(0, 0, LV_HOR_RES_MAX - 1, LV_VER_RES_MAX - 1); lv_disp_buf_init(&disp_buf, buf, NULL, LV_HOR_RES_MAX * 40); lv_disp_drv_init(&disp_drv); disp_drv.buffer = &disp_buf; disp_drv.flush_cb = st77903_flush; disp_drv.hor_res = LV_HOR_RES_MAX; disp_drv.ver_res = LV_VER_RES_MAX; lv_disp_drv_register(&disp_drv); }这一段里 set_window 是很容易忽略的点。ST77903 的 RAM 写入要先设置窗口坐标,再用 0x2C 命令连续写入,如果不设窗口,屏的列地址和行地址会维持上电默认值,写入的数据跑到显示区域外面,屏幕上就是一片空白或者颜色错乱。
缓冲大小的取舍:LV_HOR_RES_MAX * 40 即 800x40 像素的 RGB565 缓冲,在 H743 上需要约 64KB 内存。如果板子内存紧张,可以缩减到 20 行,代价是 LVGL 需要更频繁地调用 flush 回调,刷新率会下降一点。
4.2 像素格式与每像素位数:RGB565 还是 RGB888
LVGL 的 lv_conf.h 里 LV_COLOR_DEPTH 必须和屏端 COLMOD 设置一致。上一步初始化数组里写的是 0x05,对应 16 位色深,所以 LV_COLOR_DEPTH 设为 16,LV_COLOR_16_SWAP 按屏厂要求开或关。
像素格式错位的现象很诡异:画面能显示,但颜色明显偏色,红色显示成蓝色,绿色偏紫。这种问题查 LVGL 配置查不出原因,要回到屏端检查 COLMOD 参数。
还有一个容易踩的坑是字节序。STM32H743 是 Cortex-M7,默认小端,内存里 RGB565 的低字节在前。ST77903 的 RAM 接口如果接收的是高字节在前,颜色就会错位。解决办法是调 LV_COLOR_16_SWAP 宏,或者在发送前把数据高低字节交换。我习惯在 flush 回调里用 DMA 的双缓冲配合手动交换单行数据,开销比整帧交换小得多。
4.3 全屏刷新和局部刷新如何选
LVGL 默认是局部刷新,它把脏区域切分成若干个水平条带,每次只刷变化区域。对小屏界面来说,这种策略配合 40 行缓冲已经足够流畅。如果界面滑动时出现撕裂感,可以改成全屏缓冲模式,代价是双缓冲需要 800x480 像素的整帧内存,约 768KB,H743 的 SRAM 通常扛得住,但注意和系统其他内存池的分配冲突。
两种方案的对比表:
| 刷新方式 | 内存占用 | 帧率表现 | 适用场景 |
|---|---|---|---|
| 40 行行缓冲 | 约 64KB | 流畅,但快速滑动有轻微撕裂 | 内存紧张的工程 |
| 整帧双缓冲 | 约 1.5MB | 无撕裂,刷新最平滑 | 动画较多、内存充足 |
触发 flush 时,QSPI 的 DMA 是在后台搬运数据的,LVGL 会认为 flush 尚未完成,必须等到 flush_cb 里发出 lv_disp_flush_ready 信号,LVGL 才会继续下一轮绘制。DMA 发送完成回调里发这个信号,效率最高。如果忽略这个信号或者发送顺序错乱,LVGL 的绘制线程会一直等,界面卡住不动。
到这里整条链路已经通了:RT-Thread 管理外设、SPI 传输数据、LVGL 绘制、ST77903 显示。接下来要处理的就是现实中最常遇到的几个显示异常。
5. 排坑与排查:白屏、花屏、闪烁三板斧
5.1 白屏:复位后立刻背光全开
现象:程序烧进去,屏幕亮是亮了,但整屏白色或者背光异常,什么内容都没有。
原因:这类问题九成出在初始化时序上。背光在 reset 之前就被点着,或者 reset 释放后等待时间不足 120ms 就发了第一条命令,屏的内部状态还没稳定,命令全被丢弃。还有一种是初始化命令里缺少 Display On,寄存器配完了但显示功能没打开。
解决:把背光控制挪到 init 流程最后一步,确认 reset 释放后有足够延时,再逐条核对初始化命令里是否包含 0x29。我把初始化流程固定成:hard_reset -> 延时150ms -> 初始化序列 -> set_window -> 开背光,这个顺序之后再没翻过车。
5.2 花屏:像素格式不一致或窗口设置漂移
现象:画面能显示,但颜色杂乱,图像错位,有时候一条一条的竖线。
原因:一种是 LV_COLOR_DEPTH 与 COLMOD 不一致,RGB565 和 RGB888 混用,每像素字节数对不上,数据的连续切片错位。另一种是 set_window 设置的范围漂移,比如实际驱动分辨率是 800x480,窗口却设置成 1024x600,GRAM 地址跳跃产生不规则分布。
解决:第一步查 COLMOD 值,保证 0x05 对应 LV_COLOR_DEPTH 16。第二步查窗口参数,和屏的实际分辨率对齐。第三步查字节序,在 QSPI 发送函数里加临时 swap 再看颜色是否正常复位。这三步按顺序排查,花屏基本没有能逃掉的。
5.3 刷新慢:手动等待传输完成占满 CPU
现象:界面能显示,但刷新一帧要好几秒,滑动列表像幻灯片。
原因:flush 回调里用了轮询方式查询 SPI 传输完成标志,CPU 在等 DMA 的时候全被占用,LVGL 的刷新线程被饿着。或者 QSPI 时钟配得太低,实际只有 10MHz 甚至 5MHz。
解决:改为 DMA 中断方式发送像素数据,发送期间让出 CPU 给其他线程。再把时钟频率调回 50MHz。这两步做完,帧率通常能翻几倍。如果还慢,检查 LVGL 缓冲的行数是不是太少,行数太少意味着中断次数太多,每次中断都要重新开始一次 QSPI 命令序列,开销翻倍。
5.4 背光闪烁:PWM 频率接近听觉范围
现象:屏幕亮度整体波动,或者能听到滋滋声,以为是屏坏了。
原因:背光 PWM 频率设成 1kHz 甚至几百 Hz,LED 驱动器的响应跟着 PWM 抖动,人眼能感知到闪烁。同时也是 PCB 上电感啸叫的关键来源。
解决:背光 PWM 频率提到 20kHz 以上,或者干脆用 GPIO 常量输出控制亮度。ST77903 这块屏对背光电流纹波敏感,PWM 频率加大后闪烁问题会消失。注意 STM32H743 的定时器 TIM 时钟配置要能支持 20kHz 输出,一般都能做到。
5.5 唤醒后无显示:Sleep Out 遗漏
现象:从低功耗模式唤醒后屏幕不亮,但程序没崩溃,重新烧录又正常。
原因:屏进入 Sleep In 后,显存内容可能保持但显示功能被关闭,唤醒后没有重新发送 Sleep Out 和 Display On。
解决:在系统唤醒流程里补发 0x11 和 0x29,再重设窗口。这里顺带提一个稳妥做法:屏幕的复位和初始化不放在系统启动时只跑一次,而是封装成可重入函数,每次链路异常时都能重新执行一遍。
6. 装完怎么验证:测一帧的真实耗时才叫跑通
6.1 用 LVGL 自带的帧率测试
LVGL 仓库里带了一个 performance monitor 功能,打开 lv_conf.h 里的 LV_USE_PERF_MONITOR 宏,界面右上角会实时显示当前帧率和 CPU 使用率。我把这个宏打开后,先跑一段 LVGL 自带的 demo,连续滑动界面观察帧率数字。
在 H743 主频 480MHz、QSPI 时钟 50MHz、40 行缓冲的条件下,简单界面的帧率一般能稳定在 30 到 60 FPS 之间。如果跌到 15 FPS 以下,直接按第 5 章的 5.3 排查。
如果不想用图形界面观察,我还会在代码里加一个统计函数,把每秒钟 flush 的次数打印到串口,更直观也更方便记录:
volatile uint32_t frame_count = 0; void lv_benchmark_timer(void) { static uint32_t last_count = 0; static uint32_t last_tick = 0; if (rt_tick_get() - last_tick >= 1000) { rt_kprintf("FPS: %d\n", frame_count - last_count); last_tick = rt_tick_get(); last_count = frame_count; } }这里的思路是:flush 回调每完成一次传输就把 frame_count 加一,定时器每秒钟读一次差值,差值就是真实帧率。这个数字比 LVGL 右上角的性能监视器更接近物理链路的表现,因为它统计的是 QSPI 完成一帧传输的真实次数。
6.2 用 GPIO 引脚实测刷新周期
帧率数字毕竟是统计数据,和屏端实际刷新的物理耗时不完全一样。我用示波器量 QSPI 的 CS 引脚,从 CS 拉低到下一次拉低之间的时间就是一次完整刷屏的物理耗时。测量结果和 LVGL 报告的数字差值如果超过 30%,说明传输过程中还有阻塞或等待。
这两种验证我都跑完后,某个项目不光能显示一个静态界面,连续滑动、定时更新、中文字体渲染都能稳定运行,这套环境才算真正跑通。其实每次搭完这种屏幕链路后,我都有个习惯:先把背光开到最大,跑一整晚的动画 demo,如果第二天屏幕没有白屏或者颜色漂移,这板子才算过了我这关。从那以后我每做完一版驱动都会强制走一遍这个流程,省得后续排错排到人麻。希望这套环境搭建过程对你也有同样的帮助。
本文还有配套的精品资源,点击获取