简介:面向嵌入式显示驱动开发者与智能穿戴产品工程师的ST77903驱动DEMO,围绕矽创(SITRONIX)小体积LCD驱动IC展开,运行于STM32H743平台,集成RT-Thread实时系统、LVGL图形框架与QSPI接口,可直接编译验证。由于ST77903内部不含显存,QSPI必须保持连续传输,工程专门开辟独立线程专职完成刷屏,避免与UI主逻辑争抢总线,是理解此类无RAM屏驱动架构与线程调度思路的典型参考。压缩包共2591个文件、约13.66MB,以C源文件与头文件为主体(1016个.c、1090个.h),并配套链接脚本与分散加载文件、SConstruct/SConscript构建脚本、Kconfig配置项及少量PDF、Markdown说明与png素材,层次覆盖底层驱动、板级移植到应用构建。DEMO内置三个手表表盘,可直观观察LVGL界面在QSPI屏上的实际刷新表现。目前已有2592人学习下载,适合需要快速搭建穿戴类GUI方案、研究刷屏线程与总线占用的开发者参考。
1. ST77903 这块 QSPI 屏,第一次点亮通常不是黑屏而是“花”
ST77903 是 Sitronix 那颗走四线 QSPI 的 LCD 驱动 IC,配 STM32H743 + RT-Thread + LVGL 这套组合,第一现场往往不是完全不亮,而是背光已经亮了、画面却像蒙了一层毛玻璃:颜色发绿、边缘撕裂、刷两三秒直接进 HardFault。先别怀疑屏体坏了,九成的问题出在 QSPI 时序、D-Cache 一致性,或者 LVGL 的刷屏缓冲放错了内存区。
这套组合在带屏的 H7 项目里出现频率极高——H743 主频够、片上 QUADSPI 外设现成、RT-Thread 有设备框架、LVGL 有成熟移植层,凑在一起谁都想直接拿来用。它适合手上已经有一块 ST77903 屏、想把界面真正跑起来的人,也适合之前只点过单色 SPI 屏、头一回碰四线 QSPI 的人。装不对不会给你任何报错,只会让你在“到底是硬件还是软件”里耗掉一整周,这段排查记下来能省你不少时间。
2. 版本与依赖盘点:H743 上这四个组件该锁哪个版本
这套环境最容易出问题的环节,其实是开工前那十分钟没把版本定下来。屏幕驱动对时序敏感,RT-Thread 的设备框架在版本之间改过接口,LVGL v8 和 v9 的移植层几乎是两套代码——任何一项版本随意变动,都会让后面所有调试失去参照。
2.1 四个组件各自的版本窗口
我一般按下面这张表确定版本,尤其两个项目并行时,版本不一致会直接影响后面能不能复用调试经验。
| 组件 | 我通常锁的版本 | 选择理由 | 换版本的代价 |
|---|---|---|---|
| arm-none-eabi-gcc | 10.3 | 对老 BSP 的启动汇编和链接脚本兼容性最好 | 上 12/13 要处理一堆-Werror和 newlib 重定向 |
| RT-Thread | 4.1.x 稳妥,新工程可上 5.x | QSPI 设备框架已经成型,rt_qspi_*接口够用 | Kconfig 选项名和drv_qspi.c里的 HAL 调用会变,编译错误会集中在驱动层 |
| LVGL | v8.3 | DEMO 移植层基本都按 v8 的lv_disp_drv_t写 | 升 v9 要改lv_display_create、lv_draw_buf全套对接 |
| CubeMX / H7 HAL 包 | 跟 BSP 自带.ioc同代 | 引脚复用和时钟树能一键生成 | 跨代升级 HAL,QSPI 的结构体字段名会跟着变 |
LVGL 那条要特别说一句:v9 把显示驱动抽象整个换掉了,flush_cb的签名从(drv, area, color_p)变成带lv_display_t的一套,缓冲区也从lv_disp_draw_buf_t换成lv_draw_buf_t。你手上这份 DEMO 如果是 v8 写的,就老老实实锁 v8.3,别一边调屏一边改 API,那是给自己增加不必要的困难。
2.2 工具链和构建依赖装到位
RT-Thread 的 STM32 BSP 用 SCons 构建,Linux 和 macOS 下先把这套装齐,Windows 上直接用 MDK 也完全可以,但menuconfig还是要有 Python 环境。
# Debian/Ubuntu 上我常用的一套,先确认基础工具在 sudo apt install -y git scons python3 python3-pip python3 -m pip install kconfiglib # 交叉工具链,装完一定验证版本,多个版本共存时 PATH 顺序会坑人 arm-none-eabi-gcc --version scons --versionkconfiglib是scons --menuconfig的依赖,缺了它菜单打不开,报错信息还挺隐晦。arm-none-eabi-gcc --version这一步不是形式主义——机器上同时装过两套工具链时,which arm-none-eabi-gcc指到哪套决定了你编出来的是不是同一份固件。我一般还会顺手把arm-none-eabi-gcc -print-file-name=libc.a跑一遍,确认链接时找得到 newlib。
用 MDK 的话对应关系是:AC6 编译器版本跟 ARM Compiler 包走,RT-Thread 的scons --target=mdk5会重新生成工程文件,此时你在 Keil 里手改的 include 路径会被覆盖。常见做法是把自定义路径写进SConscript,别写在工程文件里。
2.3 工程目录先分层,别把 DEMO 直接解压进 BSP
把 DEMO 解压到bsp/stm32/stm32h743-xxx/里是最省事也最贵的一步,后面scons --dist打包、git status看改动都会一团糟。我习惯按职责分三层:
| 目录 | 放什么 | 谁维护 |
|---|---|---|
bsp/stm32/stm32h743-xxx/ | 板级初始化、时钟、rtconfig.h、CubeMX 生成的board/CubeMX_Config | 官方 BSP + 自己改 |
applications/lcd/ | ST77903 初始化序列、QSPI 屏驱动封装 | 自己写 |
applications/lvgl_port/ | flush_cb、lv_conf.h、LVGL 线程入口 | 自己写 |
LVGL 源码本体我不放进applications,而是单独放一个packages/lvgl或者用 RT-Thread 的软件包管理拉下来,版本写在pkgs --update的清单里。DEMO 里如果自带一份 LVGL 源码,先看它的lv_version.h确认是不是 v8,别跟自己项目里那份混用,链接时会报重复符号,而且报的位置跟真正的原因差得很远。
3. QSPI 链路搭起来:从 CubeMX 引脚到 rt_qspi_device
屏能不能亮,八成的功夫在这一章。思路是先让 RT-Thread 承认“总线上挂了个 QSPI 设备”,再验证这个设备能往屏里写、能从屏里读,最后才轮到 LVGL 往上画。顺序颠倒过来,你会拿着一个花屏的界面去猜是驱动错了还是渲染错了。
3.1 拿一个能跑的最小 BSP 打底
先别急着接屏,让板子能起 shell、能点灯。找一个引脚和时钟最接近的 H743 BSP 复制一份改,比从零建工程快得多。
# 以某个 H743 的 BSP 为底,复制成自己的板级目录 cd rt-thread/bsp/stm32 cp -r stm32h743-xxx stm32h743-st77903 cd stm32h743-st77903 scons --menuconfig # 先只改晶振频率和串口,别动别的 scons -j8 # 编出来烧进去,确认 msh 能敲命令scons -j8里的-j是并行编译任务数,按机器核数给,H7 这种带一堆 HAL 源文件的工程单线程编要等很久。烧进去后串口能出msh />提示符,说明时钟、串口、启动文件这三块是对的,后面再出问题就能排除掉一大半。
--menuconfig这一步只改两处:RT_USING_SERIAL确认打开,板子晶振BSP_USING_HSE_VALUE按实际值填。晶振值填错不会编译报错,只会让串口波特率整体偏掉,打印出来全是乱码——这是新手最容易误判成“串口线坏了”的地方。
3.2 CubeMX 里把 QUADSPI 配成四线
H743 的 QUADSPI 引脚是固定复用,但具体落在哪组 GPIO、AF 号是几,跟封装和板子走线强相关。以 CubeMX 里选中 QUADSPI 后自动带出的 AF 号为准,别照抄别人板子的引脚表——我见过的翻车案例里,有一半是把 CLK 和 NCS 的 AF 写反了,结果是“能发数据但屏完全没反应”。
关键参数就这几个,在board/CubeMX_Config里重新生成后会被写进MX_QUADSPI_Init:
| 参数 | 作用 | 起步值 | 调优方向 |
|---|---|---|---|
ClockPrescaler | QSPI 内核时钟分频 | 先给 8 分频 | 点亮稳定后逐级降到 2 分频 |
FifoThreshold | 触发 DMA 请求的 FIFO 水位 | 4 | 刷大块像素时调到 16,减少中断次数 |
SampleShifting | 采样点偏移半个时钟 | 高速时开HAL_QSPI_SAMPLE_SHIFTING_HALFCYCLE | 频率上到 50MHz 以上基本必开 |
FlashSize | 24 位地址能寻到的空间,2^N | 按屏 GRAM 大小对齐 | 填小了高位地址会被截断 |
ChipSelectHighTime | CS 拉高保持的时钟周期数 | 2 | 给不够会出现两条命令连在一起 |
ClockMode | 空闲时时钟电平(CPOL) | 按屏手册 | 配错表现为“偶尔能读到 ID,偶尔读不到” |
前三个参数决定能不能通,后三个决定稳不稳。我一般的顺序是:先把ClockPrescaler拉到 8 分频、SampleShifting关掉,确保能读到屏 ID;再逐步往上提频率,每提一级跑十分钟看有没有偶发花屏。这个“先降频再提频”的过程比较枯燥,但比事后怀疑排线接触不良靠谱得多。
3.3 让 RT-Thread 认出 qspi10 这个设备
CubeMX 生成的只是 HAL 层的初始化,RT-Thread 那边还要把 QUADSPI 注册成设备框架里的一个rt_qspi_device。BSP 的drv_qspi.c在打开BSP_USING_QSPI之后会把总线注册成qspi1,设备名是qspi10。
#include <rtdevice.h> #include <drv_qspi.h> static struct rt_qspi_device *qspi_dev = RT_NULL; static int st77903_bus_init(void) { struct rt_qspi_configuration cfg = {0}; qspi_dev = (struct rt_qspi_device *)rt_qspi_device_find("qspi10"); if (qspi_dev == RT_NULL) { rt_kprintf("qspi10 not found, check BSP_USING_QSPI\n"); return -RT_ERROR; } cfg.parent.mode = RT_SPI_MODE_0 | RT_SPI_MSB; /* 屏手册多半是模式 0,先照抄 */ cfg.parent.max_hz = 50 * 1000 * 1000; /* 先给 50MHz,稳了再往上抬 */ cfg.parent.data_width = 8; cfg.qspi_dl_width = 4; /* 数据线 4 位,ST77903 的像素就是这么送的 */ cfg.medium.qspi_dl_width = 4; return rt_qspi_configure(qspi_dev, &cfg); }rt_qspi_device_find拿不到设备,八成是BSP_USING_QSPI没开或者drv_qspi.c没被加进SConscript,先在 msh 里敲list_device看总线上有没有对应条目。cfg.qspi_dl_width是数据线宽度,配成 1 就退化成单线 SPI,速度掉四倍但不报错,特别容易在“怎么突然这么慢”的时候被忽略。
真正发一条命令用rt_qspi_transfer_message,把指令、地址、dummy 周期、数据长度填进struct rt_qspi_message里。指令码和地址位宽这几个字段,直接照 DEMO 里的 QSPI 配置抄,别自己按 flash 的习惯猜——QSPI 闪存的 0x02 是页编程,屏的写命令虽然也用 0x02 附近的值,但地址段含义完全不同,猜错了就是“写进去了,屏不动”。
3.4 DMA 与 D-Cache:H7 上最容易被忽略的一步
H743 带 D-Cache,这是好事也是坑的来源。CPU 写进缓冲区的像素数据先落在 Cache 里,DMA 从内存搬的时候拿到的可能是旧数据,表现就是画面局部错位、偶尔跳一块旧图。解决办法在发送前把对应区间清一次 Cache。
/* 32 字节对齐由 Cache 行大小决定,不对齐 clean 会连带邻居一起刷 */ static lv_color_t disp_buf[SCREEN_W * 40] __attribute__((aligned(32))); static void st77903_write_pixels(const void *data, uint32_t len) { /* 先把脏行推回 SRAM,DMA 才看得到最新像素 */ SCB_CleanDCache_by_Addr((uint32_t *)data, len); st77903_qspi_send(data, len); }还有一条硬性限制:DMA1/DMA2 访问不到 DTCM(0x20000000 起始那块)。如果你把disp_buf定义成普通全局变量,链接器很可能就把它放在 DTCM 里,DMA 搬过去全是 0,屏上就是一整片纯色。常见做法是用链接脚本把它钉到 AXI SRAM(0x24000000):
/* 链接脚本里加一段,把刷屏缓冲单独放到 D1 域 */ .ram_d1 (NOLOAD) : { . = ALIGN(32); *(.ram_d1) . = ALIGN(32); } > AXI_SRAM代码里配__attribute__((section(".ram_d1")))就能放进去。用 MDK 的话对应写法是__attribute__((at(0x24000000))),但要注意别跟其他变量地址冲突。这一步做完,用SCB_InvalidateDCache_by_Addr在读回屏数据时也要配套调一次,读写两侧都干净,问题才不会反复。
4. ST77903 屏参与 LVGL 对接:flush 回调里要做对的三件事
链路通了,接下来才是“画得像不像”。这一层的症结几乎都集中在三处:屏参没对齐、初始化序列组织乱了、flush_cb里少做了某一步。三处任意一处出错,现象都长得差不多——花屏。
4.1 屏参先对齐再谈显示
屏的分辨率、色深、QSPI 线数,以屏厂给的规格书和 DEMO 里的宏为准,四个地方必须同步改,漏一个就是错位:
| 参数 | 改哪里 | 不一致时的现象 |
|---|---|---|
| 水平/垂直分辨率 | lv_conf.h的LV_HOR_RES_MAX、驱动里的SCREEN_W/H | 画面右半边或下半边内容错位 |
| 色深 | LV_COLOR_DEPTH | 设 32 但屏只吃 RGB565,颜色会整体偏 |
| 字节序 | LV_COLOR_16_SWAP | 红蓝互换,或者一片“马赛克” |
| 数据线宽 | cfg.qspi_dl_width | 能亮但刷新慢到肉眼可见 |
LV_COLOR_16_SWAP这个宏特别容易懵:它不改变 LVGL 内部的颜色结构,只影响送出去时的字节顺序。屏接收 RGB565 时是高字节在前,如果你发现红色显示成蓝色,先别怀疑屏参,把这个宏翻一下试试,成本最低。
4.2 初始化序列表怎么组织
ST77903 的上电序列里有一堆厂商私有命令(0xE0、0xF0 那一段),这些值必须从 DEMO 或屏厂给的序列里逐条抄,手写必错。我用一个结构体把“命令 + 参数 + 延时”打包成表,主循环只负责遍历,改参数的时候不用动逻辑。
typedef struct { uint8_t cmd; uint8_t len; const uint8_t *data; uint16_t delay_ms; /* 这一条发完要等多久,Sleep Out 之后必须等 */ } lcd_cmd_t; static const uint8_t para_1[] = {0x00}; /* 占位,实际值照 DEMO 的表填 */ static const lcd_cmd_t st77903_init_seq[] = { {0x01, 0, RT_NULL, 120}, /* 软复位,给足时间 */ {0x11, 0, RT_NULL, 120}, /* Sleep Out,等 120ms 再继续 */ {0xF0, 1, para_1, 0}, /* 厂商私有命令段,参数抄 DEMO */ {0x3A, 1, (const uint8_t[]){0x55}, 0},/* 像素格式 16bit,按屏手册确认 */ {0x29, 0, RT_NULL, 20}, /* Display On */ }; void st77903_init(void) { for (uint32_t i = 0; i < sizeof(st77903_init_seq) / sizeof(lcd_cmd_t); i++) { st77903_send_cmd(st77903_init_seq[i].cmd, st77903_init_seq[i].data, st77903_init_seq[i].len); if (st77903_init_seq[i].delay_ms) rt_thread_mdelay(st77903_init_seq[i].delay_ms); } }0x01、0x11、0x3A、0x29这些是 MIPI DCS 的标准命令,各家屏都通用;0xF0开头的厂商段才是各家不同。延时字段千万别为了“快一点”全填 0——Sleep Out 之后不等待就发 Display On,屏会直接不理会后面的命令,现象是“背光亮,永远白屏”,而且复位一次好一次,非常让人困惑。
初始化必须在 LVGL 起来之前跑完,最好放在一个INIT_APP_EXPORT或者独立线程的最前面。放在main里屏还没上电就发命令,也是偶发不亮的一个来源。
4.3 flush_cb 的发送链路
LVGL 每次渲染完一块区域就会回调flush_cb,这里要做三件事:开窗口、清 Cache、异步发数据。顺序不能乱。
static void disp_flush(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { uint32_t w = lv_area_get_width(area); uint32_t h = lv_area_get_height(area); uint32_t len = w * h * sizeof(lv_color_t); /* 1. 先告诉屏这次要写哪块区域:CASET / RASET / RAMWR 三连 */ st77903_set_window(area->x1, area->y1, area->x2, area->y2); /* 2. 脏行推回 SRAM,否则 DMA 搬的是旧像素 */ SCB_CleanDCache_by_Addr((uint32_t *)color_p, len); /* 3. 异步发送,DMA 完成回调里再调 lv_disp_flush_ready */ st77903_write_pixels_dma(color_p, len); }第三步是异步的,所以flush_cb里不能立刻调lv_disp_flush_ready(drv),否则 LVGL 会以为这块画完了,转头把同一片缓冲改成下一帧内容,DMA 还在搬——这就是“画面偶尔撕裂”最典型的成因。正确位置在 DMA 传输完成的中断回调里调。
LVGL v9 的话flush_cb签名换成lv_display_flush_cb_t,参数是lv_display_t *和const lv_area_t *,color_p要从lv_draw_buf里取,逻辑一样,接口不同。
4.4 LVGL 的堆、刷新周期和线程
LVGL 不是线程安全的,所有lv_*调用必须在同一个线程里,或者外面套互斥锁。RT-Thread 下我一般单独起一个线程,栈给 8KB 起,跑起来看list_thread里的栈水位再调。
static void lvgl_thread_entry(void *param) { lv_init(); lv_port_disp_init(); /* 里面注册 disp_drv 和 flush_cb */ lv_port_indev_init(); /* 有触摸的话 */ while (1) { lv_timer_handler(); rt_thread_mdelay(5); /* 5ms 对应 200Hz 的定时粒度,比这更快没意义 */ } }LV_MEM_SIZE在lv_conf.h里,默认值往往是几十 KB,一上图片和字体就LV_MEM分配失败,现象是控件画到一半消失。带中文字体和大图的项目我一般直接给到 128KB 以上,H743 的 AXI SRAM 有 512KB,不缺这点。改完记得把lv_conf.h里的LV_MEM_ADR留空,让它走内部数组,自己指定地址反而容易和刷屏缓冲撞车。
rt_thread_mdelay(5)这个值也不是随便写的:给太小会让 LVGL 线程占满 CPU,串口 shell 都卡;给太大动画明显掉帧。5 到 10ms 之间调一调,配合后面的帧率验证一起看。
5. 排查记录:ST77903 上电不亮、花屏、掉帧的五种翻车现场
下面五条是我在这套组合上真实遇到过的,按“现象 → 原因 → 解决”记,遇到了直接对号入座比盲猜快得多。
第一条:背光亮,屏全白,复位一次好一次
现象是上电后偶尔白屏,按一下复位就正常,冷启动概率性复现。原因基本是两个:初始化序列里 Sleep Out 之后的延时被省掉了,或者屏的 RESET 引脚在 RT-Thread 启动之前就已经被拉高,等驱动跑起来时屏还处在复位后的不稳定期。解决方式是把复位脚显式控制一遍——拉低 10ms、拉高 120ms,再开始发初始化命令;同时把0x11后面的delay_ms补回 120。这条我踩过两次,第二次是因为换了个线程优先级,初始化提前跑了,还是同一个坑。
第二条:能读到 ID,但刷出来的画面整片偏移
现象是通过 QSPI 回读寄存器一切正常,一刷图就发现内容整体往右下偏了几十像素,或者右边缘有一条重复的竖条。原因是CASET/RASET的窗口坐标写成了包含端点和不包含端点的混用——有的屏把结束坐标当“最后一个像素”,有的当“后一个像素”。解决方式是先刷一个 10x10 的小方块到 (0,0),看实际画出来是 10 像素还是 11 像素,再决定结束坐标要不要-1。窗口开错不会报错,只会默默错位。
第三条:刷两三秒直接 HardFault
现象是界面能跑,几秒后进 HardFault,看栈回溯落在一个和显示无关的地址上。两个高频原因:disp_buf放在 DTCM 里,DMA 搬不动还触发了总线错误;或者 LVGL 缓冲区越界——lv_disp_draw_buf_init给的大小和实际数组长度对不上,多刷几帧就踩到相邻变量。解决方式是先list_device和看 map 文件确认缓冲地址在 0x24000000 段,再把sizeof(buf)用sizeof算而不是手写常量。HardFault 的栈回溯在 H7 上经常指错地方,别死磕调用栈,先查内存布局。
第四条:颜色对但边缘撕裂,动一下界面就闪一条
现象是静态画面正常,一有动画就从中间撕开一条。原因是flush_cb里提前调了lv_disp_flush_ready,或者只用了单缓冲。解决方式是把lv_disp_flush_ready挪进 DMA 完成回调,同时改成双缓冲——两个disp_buf交替,LVGL 在 DMA 搬一块的时候可以渲染另一块。双缓冲的代价是内存翻倍,480 宽 40 行的缓冲两块也就几十 KB,H743 扛得住。
第五条:帧率上不去,滑动像放幻灯片
现象是功能全对,就是卡,滑动列表明显掉帧。三个方向查:QSPI 频率还在 8 分频没提上来;FifoThreshold太小导致 DMA 中断过于频繁;或者每帧都在全屏刷新。解决顺序是先提频率(配合SampleShifting开半周期采样),再把 FIFO 水位调到 16,最后把刷新区域改成 partial 模式只刷脏区。我见过有人直接上 memory-mapped 模式从 0x90000000 写像素,速度确实快,但 MPU 要单独配成 Device 属性,配错就是随机丢数据,没跑稳之前别碰。
6. 跑通验证:从读 ID 到 8 小时长跑的三道关
屏亮了不算跑通,能稳定跑一晚上才算。我一般分三道关验,每道关卡的判据都很硬,过了就是过了。
第一道关是链路验证,不涉及任何图形。在 msh 里单独调一次读 ID,把返回值打出来和规格书上的厂商码对一遍;再读一次关键寄存器,确认写入的值能回读一致。这一步过不了,后面所有图形问题都不用查。
/* 最小验证:读 ID,能对上说明指令段、地址位宽、dummy 周期都是对的 */ uint32_t id = 0; st77903_read_reg(0x04, &id, 3); /* 具体命令号按屏手册,别照抄 */ rt_kprintf("st77903 id = 0x%06x\n", id);第二道关是图形验证,用不依赖 LVGL 的裸刷屏先画三样东西:纯红、纯绿、纯蓝三个色块,一条 0 到 255 的灰阶渐变条,一个贴着屏幕四角的 1 像素边框。色块看颜色映射对不对,渐变条看有没有色阶断层(有断层多半是色深配错),边框看窗口坐标和分辨率对不对。这三样都在flush_cb接进来之前跑,能把屏参问题和 LVGL 问题彻底分开。
第三道关是压力验证,跑起来之后盯三个指标:
| 检查项 | 通过标准 | 怎么测 |
|---|---|---|
| 帧率 | 滑动列表时稳定在 30fps 以上 | 在flush_cb里统计每秒回调次数 |
| 内存 | list_thread里 LVGL 线程栈水位不持续上涨 | 连续切页面 200 次后对比 |
| 长稳 | 8 小时不花屏、不进 HardFault | 循环播放动画,串口挂日志 |
长跑这一步别省。我做过的项目里,有两个问题都是跑到四五个小时才出现的:一个是 LVGL 里某个 label 反复重建没释放,内存曲线缓慢爬升;另一个是 QSPI 在温度上来之后时序余量不够,偶发丢一帧。这两个问题在十分钟的测试里完全看不见,一上产线就会出问题。
从那以后我每次接新屏,都强制自己把三道关走一遍,尤其是第三关的长跑——哪怕工期再紧,也留一晚上让板子自己跑着,第二天看日志。前期多花的这一晚,比后期在客户现场抓问题的成本低太多。希望帮到你。
本文还有配套的精品资源,点击获取