简介:面向可穿戴小屏与嵌入式显示开发者的矽创 ST77903 实战演示工程,主控采用 STM32H743,系统运行 RT-Thread,界面框架为 LVGL,并通过 QSPI 与液晶屏连接。该芯片体积小且内部不带显存,QSPI 必须保持连续传输,因此工程单独划分刷屏线程承担数据搬运,并配套三套手表表盘示例,便于理解穿戴产品中显示驱动、界面刷新与系统调度的配合方式。压缩包共 2591 个文件,整体约 13.66MB,以头文件和源文件为主,同时包含汇编文件、构建脚本以及少量文档、图片和配置文件,覆盖底层驱动、系统配置、构建流程与界面素材。已有 2592 人学习下载,可作为 QSPI 连续刷屏、线程划分和图形库移植适配的参考样例,帮助读者快速定位关键显示链路并复用手表界面框架,适合中高级嵌入式显示开发者对照学习。
1. 矽创 ST77903 DEMO 拆解:H743 上的 QSPI 屏怎么跑起来
手里有一块带 ST77903 驱动的屏,主控是 STM32H743,想上 RT-Thread 和 LVGL,卡在第一步的 QSPI 通路上——这个场景在工控面板、小型仪表、便携设备的小尺寸显示里非常典型。这套 DEMO 的价值就在这儿:它把 QSPI 初始化、ST77903 的寄存器时序、RT-Thread 设备驱动模型和 LVGL 的显示接口一次性串起来了,省去你自己对着两份手册逐位拼寄存器的时间。它适合已经能点亮普通 SPI 屏、想往高速刷屏和图形界面走的开发者,也适合拿 H743 做产品原型的团队评估这块屏的实际帧率上限。
2. QSPI 与 ST77903 的匹配逻辑:为什么不是普通 SPI
2.1 四线 QSPI 对这类屏意味着什么
ST77903 是矽创做的一颗 LCD 驱动 IC,本身支持串行接口,在高速刷屏场景下最常用的是四线 QSPI 模式。很多新手第一反应是用普通 SPI 的 DMA 去推,结果分辨率稍微大一点帧率就掉到个位数。原因不复杂:普通 SPI 单线或双线传输,每帧像素数据都要逐字节走总线,H743 主频虽高,但总线带宽就摆在那儿。
四线 QSPI 的关键差别在于数据宽度。同样一个时钟周期,四条数据线并行搬运,理论带宽是单线的四倍。对 ST77903 这种把显存和像素接口做在片内的驱动 IC 来说,瓶颈往往不在芯片内部,而在主控到屏之间的这条数据通道。这也是为什么 DEMO 选择 QSPI 而不是 SPI:它不是为了炫技,而是让 H743 的 QSPI 外设特性能对上屏的像素吞吐需求。
这里有个容易被忽略的点:ST77903 的命令阶段和数据阶段宽度可以不同。常见的配置是命令用单线发、像素数据用四线发。如果你在 CubeMX 里把指令也配成四线,有些批次会直接无响应。我一般先按「指令单线、地址单线、数据四线」这套保守配置点亮,确认通信正常后再考虑把命令也提到四线。
2.2 H743 的 QSPI 外设边界
H743 的 QUADSPI 外设支持内存映射模式和间接模式。内存映射模式适合把外部 Flash 当只读数据源,但对 LCD 这种需要写命令、写数据、读状态混合操作的外设,实际更常用的是间接模式——每次传输手动配置地址、数据长度、方向。
DEMO 里对 QSPI 的封装大致分三层:底层 HAL 的 QSPI 命令结构体配置、中间一层针对 ST77903 的命令/数据封装、上层对接 RT-Thread 的设备读写接口。这种分层不是为了看起来整齐,而是因为 ST77903 的寄存器访问有严格的先后关系:写命令、写参数、发 NOP、读状态,中间任意一步被打断都可能让屏进入异常状态。
提示:H743 的 QUADSPI 时钟源来自 AHB 总线分频,配置时要注意 QSPI 内核时钟不要超过手册给的频率上限。很多人把系统时钟拉到 480MHz 后直接分频出 120MHz 的 QSPI 时钟,屏还没初始化完就出问题了。
参数上,QSPI 的 ClockPrescaler、FifoThreshold、SampleShifting 这几个字段直接影响稳定性。SampleShifting 设成 HalfCycle 在高速下更稳,代价是采样点延迟半个周期;FifoThreshold 影响中断触发频率,屏驱动这种持续性写入的场景下建议设大一点减少中断开销。
3. 从零搭 DEMO:工程结构与关键配置
3.1 工程目录该怎么摆
拿到 DEMO 后不要急着编译,先看清楚目录划分。典型的组织方式是按「BSP 层 / 驱动层 / 应用层」三段切分,BSP 放 H743 的时钟、GPIO、QSPI 初始化;驱动层放 ST77903 的命令表和读写封装;应用层放 RT-Thread 线程和 LVGL 对接代码。这种分法让你在换屏或换主控时只改一层。
project/ ├── bsp/ │ ├── board.c # 时钟树、引脚复用 │ ├── qspi_config.c # QUADSPI 外设参数 │ └── st77903_port.c # 屏的复位、背光、片选控制 ├── drivers/ │ ├── st77903.c # 寄存器命令表 │ ├── st77903_reg.h # 命令与参数宏 │ └── st77903_lvgl.c # LVGL flush_cb 对接 ├── applications/ │ ├── main.c # 线程创建与启动 │ └── lv_port_disp.c # LVGL 显示接口注册 └── rtconfig.h # RT-Thread 裁剪配置这个结构不是硬性规定,但分层思路值得保留。我看过不少把 QSPI 初始化、屏命令、LVGL 刷新全塞在 main.c 里的写法,调试时定位问题极其痛苦——屏不亮到底是 QSPI 没起来、命令发错、还是 LVGL 没调 flush,全混在一起。
3.2 QUADSPI 初始化参数逐项说明
下面是 DEMO 中 QSPI 初始化的核心片段,用 HAL 库风格写。实际工程里 HAL 版本可能有差异,字段名以你手上的库为准,但参数含义是通用的。
/* QSPI 句柄与初始化,H743 平台 */ QSPI_HandleTypeDef hqspi; void MX_QUADSPI_Init(void) { hqspi.Instance = QUADSPI; /* 时钟分频:AHB 分频得到 QSPI 内核时钟,再除以 ClockPrescaler+1 */ hqspi.Init.ClockPrescaler = 2; /* 实际时钟 = 内核时钟 / 3 */ hqspi.Init.FifoThreshold = 16; /* 减少中断触发次数 */ hqspi.Init.SampleShifting = QSPI_SAMPLE_SHIFTING_HALFCYCLE; hqspi.Init.FlashSize = 23; /* 屏不是 Flash,此项仅占位 */ hqspi.Init.ChipSelectHighTime = QSPI_CS_HIGH_TIME_2_CYCLE; hqspi.Init.ClockMode = QSPI_CLOCK_MODE_0; hqspi.Init.FlashID = QSPI_FLASH_ID_1; hqspi.Init.DualFlash = QSPI_DUALFLASH_DISABLE; if (HAL_QSPI_Init(&hqspi) != HAL_OK) { Error_Handler(); } }ClockPrescaler是第一个要调的参数。分频太小屏收发不上,分频太大帧率上不去。我的习惯是从保守值起步,先让屏点亮,再用示波器或逻辑分析仪看 SCLK 波形质量,逐步往上提。SampleShifting选 HalfCycle 是因为在高速下主控采样点容易错过数据有效窗口,延迟半周期采样能显著降低误码。
FifoThreshold和中断相关。屏驱动基本是持续大块写,FIFO 阈值设小会让 CPU 频繁进出中断,设太大又可能一次传输结束才触发,各有利弊。DEMO 里给 16 是个折中值,实际按你的帧大小和中断优先级调整。
3.3 ST77903 初始化序列的执行顺序
屏的初始化序列是一组有严格顺序的命令,顺序错了屏要么不亮、要么花屏。DEMO 里的命令表大致分几段:软复位、休眠退出、像素格式设置、显示窗口设置、TE 信号配置、显示开启。
/* ST77903 初始化命令表,节选关键项 */ static const st77903_cmd_t init_seq[] = { {0x01, NULL, 0, 120}, /* SWRESET 软复位,等待 120ms */ {0x11, NULL, 0, 120}, /* SLPOUT 退出休眠,等待 120ms */ {0x3A, (uint8_t[]){0x55}, 1, 0}, /* COLMOD 像素格式,0x55 对应 16bit */ {0x36, (uint8_t[]){0x00}, 1, 0}, /* MADCTL 扫描方向 */ {0x44, (uint8_t[]){0x00, 0x00}, 2, 0}, /* 行地址窗口起点与长度 */ {0x29, NULL, 0, 20}, /* DISPON 开显示 */ };0x3A这条命令决定了像素数据宽度。0x55 是 16 位色(RGB565),LVGL 的默认颜色格式也是 RGB565,两边对上就不需要额外转换。如果你想用 18 位色,这条命令参数要改,同时 LVGL 的LV_COLOR_DEPTH也得跟着改,否则颜色会整体偏。
每条命令后面的延时不能省。软复位和退出休眠的延时尤其关键,等待时间不足时后续命令会被屏丢弃,表现为「部分命令生效、部分不生效」这种玄学现象。血泪经验是宁可延时多给 20ms,也不要卡在边界值上。
注意:发完初始化序列后建议读一次屏的状态寄存器,确认没有异常标志位,再进入 LVGL 初始化。直接往下走会让问题延迟暴露。
4. RT-Thread 与 LVGL 的对接:把设备模型串进刷新链路
4.1 QSPI 驱动如何注册成 RT-Thread 设备
RT-Thread 的设备驱动模型把外设抽象成设备节点,应用层通过rt_device_find/rt_device_open访问。QSPI 在 RT-Thread 里通常有两种挂法:一是用 RT-Thread 自带的 QSPI 框架,二是把 HAL 的 QSPI 封装成自定义设备。DEMO 走的是第二种,因为 ST77903 的读写模式比较特殊,通用框架反而增加了适配成本。
/* 注册一个自定义 QSPI 设备,供屏驱动调用 */ static rt_err_t st77903_qspi_open(rt_device_t dev, rt_uint16_t oflag) { return RT_EOK; } static rt_size_t st77903_qspi_write(rt_device_t dev, rt_off_t pos, const void *buffer, rt_size_t size) { /* 这里的 pos 复用为命令码,buffer 为数据,size 为长度 */ return st77903_cmd_write((uint8_t)pos, buffer, size); } static struct rt_device st77903_dev; int st77903_dev_register(void) { st77903_dev.type = RT_Device_Class_SPIDevice; st77903_dev.open = st77903_qspi_open; st77903_dev.write = st77903_qspi_write; st77903_dev.user_data = (void *)&hqspi; return rt_device_register(&st77903_dev, "lcd0", RT_DEVICE_FLAG_WRONLY); }把命令码塞进pos参数是一种常见做法,省去额外定义命令接口。开放时用WRONLY是因为大多数场景只写屏、不读屏,要读屏状态再加RDONLY。注册完成后,LVGL 的显示驱动就可以通过lcd0这个节点访问,应用层不需要直接碰 HAL。
4.2 LVGL 显示缓冲区与 flush 回调
LVGL 的刷屏机制是「画到缓冲区、再调用 flush 把缓冲区推给屏」。缓冲区大小和 flush 的实现方式直接决定帧率。DEMO 里用的是双缓冲,缓冲区高度设成屏幕高度的十分之一左右,兼顾内存占用和刷新效率。
/* LVGL 显示初始化与 flush 回调 */ #define DISP_HOR_RES 240 #define DISP_VER_RES 320 #define BUF_LINES 32 static lv_disp_draw_buf_t draw_buf; static lv_color_t buf1[DISP_HOR_RES * BUF_LINES]; static lv_color_t buf2[DISP_HOR_RES * BUF_LINES]; static void disp_flush(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { uint32_t w = area->x2 - area->x1 + 1; uint32_t h = area->y2 - area->y1 + 1; /* 设置窗口后整块写入像素,避免逐点写 */ st77903_set_window(area->x1, area->y1, area->x2, area->y2); st77903_write_pixels((uint8_t *)color_p, w * h * 2); lv_disp_flush_ready(drv); } void lv_port_disp_init(void) { lv_disp_draw_buf_init(&draw_buf, buf1, buf2, DISP_HOR_RES * BUF_LINES); static lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); disp_drv.hor_res = DISP_HOR_RES; disp_drv.ver_res = DISP_VER_RES; disp_drv.flush_cb = disp_flush; disp_drv.draw_buf = &draw_buf; lv_disp_drv_register(&disp_drv); }st77903_set_window先发窗口命令,再发像素数据,是这类驱动 IC 的标准做法。如果每画一小块就重设一次窗口,命令开销会吃掉不少带宽。缓冲区高度取 32 行,意味着一次 flush 覆盖的像素数量适中,既不会一次传输太久阻塞 LVGL 主循环,也不会频繁切换窗口。
lv_disp_flush_ready必须在数据传输完成后调用,否则 LVGL 会一直等。如果想做异步刷新,需要配合 DMA 完成中断,在中断里调用这个函数。DEMO 用的是同步阻塞式,简单但会占用 CPU。
4.3 线程与调度:别让刷屏拖垮其他任务
RT-Thread 里 LVGL 的刷新通常放在一个独立线程里,或者用定时器周期性调用lv_timer_handler。DEMO 用的是独立线程加信号量同步。
/* LVGL 主循环线程 */ static void lvgl_thread_entry(void *parameter) { lv_init(); lv_port_disp_init(); while (1) { lv_timer_handler(); rt_thread_mdelay(5); } } int lvgl_thread_start(void) { rt_thread_t tid = rt_thread_create("lvgl", lvgl_thread_entry, RT_NULL, 4096, 10, 10); if (tid != RT_NULL) { rt_thread_startup(tid); } return 0; }线程栈给 4096 字节是常见起点,LVGL 的绘制函数里局部变量不算少,栈太小会莫名死机。优先级设 10,低于系统关键线程但高于空闲线程。rt_thread_mdelay(5)让出 CPU,避免 LVGL 空转吃掉全部算力。如果界面有动画,刷新间隔要相应减小。
提示:如果界面刷新时其他线程出现卡顿,先看 LVGL 线程的优先级和栈使用率,再看 QSPI 传输是否阻塞了调度。同步阻塞式 flush 在大块刷屏时会让调度器长时间得不到执行权。
5. 避坑与排查:屏不亮、花屏、帧率低的定位路径
5.1 上电后屏完全不亮
现象是背光可以单独点亮,但屏上无任何图像、连初始化时的花屏都没有。原因按概率排序:复位时序不对、QSPI 通道没通、初始化序列根本没发出去。解决方式是先绕开 RT-Thread 和 LVGL,写一个裸机最小用例,只做 GPIO 复位加软复位命令,用逻辑分析仪抓 SCLK 和 MOSI。
如果抓不到任何波形,问题在 QSPI 外设配置或引脚复用,重点查 CubeMX 里的 AF 编号和时钟使能。如果有波形但屏不响应,查复位引脚的电平持续时间是否满足手册要求,很多屏要求复位低电平保持 10ms 以上。血泪经验是:复位引脚上下拉电阻值不匹配时,上电瞬间会被拉成中间电平,屏直接进入未定义状态。
5.2 能点亮但花屏或颜色错乱
现象是屏上有图像,但颜色整体偏移,或者边缘出现撕裂。原因通常是像素格式不匹配或窗口设置越界。LVGL 的颜色深度和 ST77903 的COLMOD必须一致,一边 RGB565 一边 RGB888 一定花屏。
解决顺序是:先确认LV_COLOR_DEPTH与COLMOD参数对上,再检查set_window的坐标是否超出屏的物理分辨率。另外 MADCTL 的扫描方向要和 LVGL 的坐标系一致,方向配反时图像会镜像或旋转,表现出来也像花屏。
5.3 帧率远低于预期
现象是界面能显示,但滑动列表或动画明显卡顿。原因是 QSPI 时钟分频过大、flush 粒度太细、或者开了一堆调试打印。先用示波器量 SCLK 实际频率,对比理论值,确认分频参数生效。
如果时钟没问题,看 flush 的调用频率。缓冲区高度设太小会导致每屏被切成很多块,每块都要重设窗口,命令开销累积起来很可观。把缓冲区高度提到 64 行往往能明显改善。另外关掉串口调试输出,一条rt_kprintf在某些配置下能阻塞几毫秒,刷屏路径上绝对不能留。
5.4 长时间运行后死机或显示异常
现象是跑几分钟到几十分钟后,屏突然静止或整屏错乱。原因是 DMA 缓冲区被覆盖、或者 QSPI 传输没有正确等待完成标志。同步阻塞式写如果没检查HAL_QSPI_GetState,在忙状态下再次发起传输会让外设进入异常。
解决方式是在每次写之前轮询外设空闲状态,或者用完成回调把传输状态管起来。RT-Thread 的线程如果栈溢出,也可能表现为运行一段时间后死机,把栈翻一倍做验证是最快的排除法。
5.5 LVGL 刷新与 RT-Thread 延时冲突
现象是界面刷新节奏不稳定,有时快有时明显一顿。原因是lv_timer_handler的调用周期被其他高优先级线程挤占,或者rt_thread_mdelay的实际延时受调度影响。
处理办法是给 LVGL 线程一个相对稳定的时间片,检查是否有其他线程在做长耗时操作。如果系统里有看门狗喂狗线程或通信线程,确认它们的优先级和阻塞时长,必要时把 LVGL 的刷新间隔改成由定时器驱动,而不是靠线程内的固定延时。
6. 进阶验证:用伪帧率与波形双重确认这套 DEMO 的真实上限
屏点亮之后最容易犯的错是「看着挺流畅就以为成了」。真实上限需要两个维度验证:一是抓波形看总线利用率,二是测实际帧率看端到端延迟。
先说帧率测量。最土但最有效的办法是在 flush 回调里翻转一个空闲 GPIO,用示波器测翻转频率,再除以每帧的 flush 次数。这比在代码里打时间戳准,因为时间戳本身有开销。我一般会在disp_flush入口和lv_disp_flush_ready之后各翻转一次,两次沿之间的宽度就是一次 flush 的实际耗时。
/* 用 GPIO 翻转测量单次 flush 耗时 */ static void disp_flush_profiled(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { TEST_PIN_HIGH(); /* flush 开始打点 */ uint32_t w = area->x2 - area->x1 + 1; uint32_t h = area->y2 - area->y1 + 1; st77903_set_window(area->x1, area->y1, area->x2, area->y2); st77903_write_pixels((uint8_t *)color_p, w * h * 2); TEST_PIN_LOW(); /* flush 结束打点 */ lv_disp_flush_ready(drv); }把测到的单次耗时乘以每帧 flush 次数,再乘上 LVGL 的渲染耗时,就能估出帧率上限。如果这个上限和你看到的实际流畅度差距很大,问题多半在渲染侧,比如控件层级太深、用了透明度或圆角这些相对费算力的特性。
再说总线利用率。用逻辑分析仪抓 SCLK 和 CS,看一次像素传输里 CS 拉低后 SCLK 是否连续输出,中间有没有明显空隙。有间隙说明每次写之间被软件打断,可能是 FIFO 阈值设得太小、或者传输函数里夹了日志输出。连续无间隙的波形才说明 QSPI 带宽被吃满了。
| 验证项 | 观测手段 | 正常表现 | 异常时的方向 |
|---|---|---|---|
| 单次 flush 耗时 | 空闲 GPIO + 示波器 | 稳定且与像素数成正比 | 抖动大看调度,偏慢看分频 |
| SCLK 连续性 | 逻辑分析仪 | CS 有效期内波形连续 | 有空隙查 FIFO 与日志 |
| 帧率上限 | 单次耗时 × 每帧次数 | 与目测流畅度一致 | 差距大查渲染侧 |
| 长时间稳定性 | 连续运行 30 分钟以上 | 无静止、无错乱 | 查 DMA 缓冲与栈 |
这套验证做完,你对这块屏在 H743 上的真实能力就有数了:哪些帧率是硬件给的,哪些是软件让出去的。常见做法是先在最大时钟下测出上限,再回退一档留余量用于量产,毕竟温升和批次差异会让边界值变得不可信。
从那以后我每次拿到新的屏驱动 DEMO,都会先干三件事:裸机最小用例确认通道通、GPIO 打点测一次 flush 的真实耗时、连续跑半小时看会不会翻车。这三步走完再谈功能,比一上来就往 LVGL 里堆界面靠谱得多。希望这套拆法能帮到你。
本文还有配套的精品资源,点击获取