简介:面向穿戴设备显示方案的ST77903 LCD驱动与LVGL集成Demo,硬件平台为STM32H743,软件栈采用RT-Thread与QSPI接口,重点解决该IC无内置RAM时刷屏数据需连续传输、须由专用线程驱动的难点。资源包13.66MB,共2591个文件,以C/H源码、汇编启动文件、链接脚本、Kconfig配置和工程文件为主,包含LCD驱动、LVGL移植、RT-Thread线程调度及三个手表表盘示例,目录划分清楚,便于直接定位和二次开发。已有2585人学习下载,适合具备一定嵌入式基础、正在选型或移植ST77903的开发者。可从中获取QSPI连续刷屏的线程设计思路、LVGL与RTOS的集成方法,也可参考表盘UI布局快速搭建可穿戴产品原型。
1. 拿到了 ST77903 的板子,别急着写驱动,先搞清楚这条链路
ST77903 这颗矽创的 LCD 驱动 IC 在国产智能手表、小尺寸圆屏里出现频率非常高,480×480 分辨率、RGB 或者 SPI 接口都有,而 QSPI 驱动的方案在成本和引脚数上比 RGB 接口友好得多。但这颗芯片和市面上常见的 ST7789、ILI9341 不一样,初始化序列、内存映射方式、帧率配置都有自己的一套脾气,尤其是配合 STM32H743 的高主频和缓存机制,稍微没配对就会出现花屏、白屏或者刷新撕裂。这套 Demo 解决的就是从裸屏到 LVGL 界面点亮并流畅刷新的完整链路,适合手里正好有同款屏、想在 RT-Thread 上快速出界面的开发者。看完你会知道怎么接线、怎么配 QSPI、怎么把 LVGL 的 flush 回调稳稳接住,以及哪些坑是芯片手册里根本不会告诉你的。
2. QSPI 驱动 ST77903:选型依据和初始化细节
2.1 为什么是 QSPI 而不是普通 SPI 或 RGB 接口
ST77903 本身支持多种接口,但在这个 Demo 里选 QSPI 是有明确理由的。480×480 分辨率、16 位色深,一帧裸数据是 480×480×2 = 460800 字节,如果用普通 SPI 跑 20MHz,刷一帧要 180 毫秒以上,LVGL 的动画效果基本没法看。STM32H743 的 QSPI 接口可以工作在 100MHz 以上,还支持 Dual 和 Quad 模式,等于把数据传输宽度翻倍甚至翻四倍。QSPI 还有一个隐藏优势:可以开启内存映射模式,把外部闪存或者 LCD 控制寄存器映射进 MCU 的地址空间,访问屏幕就像访问内存一样,这对 LVGL 的 framebuffer 操作非常有利。
接线方面,QSPI 比 RGB 接口少太多引脚。RGB 接口至少要 16 根数据线加时钟和同步信号,而 QSPI 只需要 CLK、CS、D0-D3 加上复位和命令选择引脚就能跑 Quad 模式。这对引脚资源紧张的 H743 封装来说非常友好。不过我一般不建议直接把 D0-D3 全部接满,原因后面避坑章节会说。
2.2 QSPI 外设配置:时钟、引脚映射和命令封装
H743 的 QSPI 外设用 HAL 库配置不算复杂,但要注意几个关键参数的连带关系。首先是时钟分频,QSPI 的时钟来自 AHB 总线,H743 跑 480MHz 时 AHB 通常是 240MHz,QSPI 至少要分频 3 才能跑到 80MHz 左右。我习惯先把时钟配到 80MHz 验证基本通信,稳定之后再往上提。其次是 FlashSize 这个参数,它决定了地址字节数,对于 ST77903 这类显示驱动 IC,地址段通常只有一到两个字节,不要照抄外部 NOR Flash 的 24 位地址配置。
HAL 库的 QSPI 配置结构体里有个容易忽略的字段是 FifoThreshold,默认是 4,但实际使用中根据 DMA 和中断配合要调大一些。Command 部分反而是整个驱动的核心,ST77903 的命令写入需要构造完整的指令包,包括指令码、地址、数据、以及是否走 Quad 模式。我封装了一个通用的命令发送函数:
static int lcd_qspi_cmd(uint8_t cmd, uint32_t addr, uint8_t addr_len, uint8_t *data, uint32_t data_len) { QSPI_CommandTypeDef qspi_cmd = {0}; qspi_cmd.Instruction = cmd; qspi_cmd.InstructionMode = QSPI_INSTRUCTION_1_LINE; qspi_cmd.AddressSize = QSPI_ADDRESS_8_BITS; qspi_cmd.Address = addr; qspi_cmd.AddressMode = (addr_len > 0) ? QSPI_ADDRESS_1_LINE : QSPI_ADDRESS_NONE; qspi_cmd.AlternateByteMode = QSPI_ALTERNATE_BYTES_NONE; qspi_cmd.DataMode = (data_len > 0) ? QSPI_DATA_1_LINE : QSPI_DATA_NONE; qspi_cmd.DummyCycles = 0; qspi_cmd.DataSize = QSPI_DATA_SIZE_8_BITS; if (HAL_QSPI_Command(&hqspi, &qspi_cmd, 1000) != HAL_OK) { return -1; } if (data_len > 0) { if (HAL_QSPI_Transmit(&hqspi, data, 1000) != HAL_OK) { return -2; } } return 0; }这里 Command 阶段的 InstructionMode 必须用 1_LINE,因为 ST77903 的命令阶段始终是单线协议,只有数据阶段才支持 Quad。数据阶段的 DataMode 要配合屏幕手册里具体命令的说明,比如 0x3A 这个像素格式命令,数据阶段走单线还是四线,得看初始化序列里有没有开启 Quad 写模式。DummyCycles 对于大部分 ST77903 的控制命令是 0,但读操作和某些特殊命令需要配置,这也是后面排查花屏的一个重点。
2.3 初始化序列的写法:节拍、延时和版本差异
ST77903 的初始化序列不同批次、不同模组厂给出的版本会有差异,但核心寄存器配置逻辑一致。拿到一个屏幕模组,首先看背光供电和 IO 电平是不是匹配,然后按上电 → 复位 → 初始化寄存器 → 开显示的顺序走。复位引脚的低电平时间至少要有 5 毫秒,我一般给到 10 毫秒,等释放复位后再延时 120 毫秒,让内部 DC-DC 稳定。
初始化序列我习惯做成表格驱动,每个条目包含命令码、数据长度、数据指针和延时时间。这样做的好处是后续换屏幕模组改起来快,不用来回改代码。常见的初始化流程包括:关显示 (0x28)、像素格式 (0x3A)、色彩增强、Gamma 校正、显示窗口设置、开显示 (0x29)。其中 0xB0 到 0xB5 这几个命令是 ST77903 的供应商参数区,不同模组厂的推荐值差别非常大,直接用 Demo 里的默认值大概率会偏色或者亮度异常。
static const lcd_seq_t lcd_init_seq[] = { {0x28, 0, NULL, 0}, {0x11, 0, NULL, 120}, {0x3A, 1, (uint8_t[]){0x55}, 0}, {0xB0, 4, (uint8_t[]){0xC0, 0x02, 0x08, 0x05}, 10}, {0xB1, 2, (uint8_t[]){0x00, 0x1F}, 0}, {0x36, 1, (uint8_t[]){0x00}, 0}, {0x2A, 4, (uint8_t[]){0x00, 0x00, 0x01, 0xDF}, 0}, {0x2B, 4, (uint8_t[]){0x00, 0x00, 0x01, 0xDF}, 0}, {0x29, 0, NULL, 100}, };0x36 是扫描方向寄存器,值改一下屏幕旋转方向就变了,这个在圆屏和方屏切换的时候特别常用。0x2A 和 0x2B 后面的数据是列地址和行地址范围,480×480 是双 0x01DF 结尾,如果改成其他尺寸的屏注意同步修改。每个命令之间的延时不是随便给的,0x11 退出睡眠模式之后必须等到 120 毫秒才能继续写寄存器,否则后续命令会被忽略,而很多人翻车就在把这个延时压到了 20 毫秒。
3. LVGL 对接:flush 回调、缓冲区策略和 RT-Thread 线程模型
3.1 framebuffer 的设计和 LVGL 内存配置
LVGL 在 STM32H743 上跑起来不难,难的是缓冲区怎么分配。LVGL 内部绘制需要一个 buffer,推荐的大小是屏幕尺寸的 1/10 以上,480×480 的屏幕按 1/10 算要 46KB,这个量在 H743 上完全放得下。我一般直接开二维数组,让链接器把它放在 AXI SRAM 或者 DTCM 里,访问速度比外部 SDRAM 快很多。
static lv_color_t buf_1[480 * 80]; static lv_color_t buf_2[480 * 80]; static lv_disp_draw_buf_t disp_buf; void lcd_lvgl_init(void) { lv_init(); lv_disp_draw_buf_init(&disp_buf, buf_1, buf_2, 480 * 80); static lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); disp_drv.hor_res = 480; disp_drv.ver_res = 480; disp_drv.flush_cb = lcd_flush_cb; disp_drv.draw_buf = &disp_buf; lv_disp_drv_register(&disp_drv); }双缓冲的目的是让 LVGL 在一个缓冲区绘制的同时,另一个缓冲区的内容可以被 DMA 或者 QSPI 传输出去,这样 CPU 和传输链路可以并行工作。两个缓冲区各 480×80 行,总共占 153600 字节左右,H743 的 TCM 可能放不下,所以我会确认它们被放在 AXI SRAM 区域。如果内存紧张,可以缩到 480×40 行,但刷新率会掉一截。
3.2 lcd_flush_cb 的正确写法:别在回调里傻等
flush 回调是所有 LVGL 移植里最容易写崩的地方。典型错误是在回调里直接阻塞等待 QSPI 传输完成,再调用 lv_disp_flush_ready 释放缓冲区。这样 LVGL 的绘制线程会被卡住,两个缓冲区的并行优势完全发挥不出来。正确做法是用信号量或者 DMA 中断回调来通知传输完成。
static void lcd_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { uint32_t addr = LCD_FB_ADDR + 2 * (area->x1 + area->y1 * 480); lcd_set_window(area->x1, area->y1, area->x2, area->y2); lcd_qspi_write(addr, (uint8_t *)color_p, area_width * area_height * 2); }这里 lcd_set_window 就是构造 0x2A、0x2B 命令,把屏幕的写窗口切到 LVGL 要更新的区域。lcd_qspi_write 是真正的数据搬运函数。注意我计算地址的时候乘的是 2,因为每个像素是 16 位色深,这个细节错了就是花屏。flush 回调本身返回后 LVGL 会认为缓冲区已经空了,所以如果 QSPI 传输是异步的,就必须确保在传输完成之前 LVGL 不会往同一个缓冲区里画。最佳实践是用 DMA 传输并在完成中断里调用 lv_disp_flush_ready。
3.3 RT-Thread 环境下 LVGL 线程的优先级和堆栈设置
RT-Thread 加 LVGL 最常见的做法是把 LVGL 放在一个独立线程里,用软件定时器调用 lv_timer_handler。这个线程的优先级不能太高,否则会抢掉 QSPI 的传输线程或者触控扫描的 CPU 时间,但也不能太低,不然界面刷新会卡顿。我一般把 LVGL 线程优先级设为 20,在 RT-Thread 默认 32 级优先级里属于中等偏上,堆栈大小给到 4096 字节,LVGL 自身的动态内存独立开一个内存池。
void lvgl_thread_entry(void *param) { lcd_lvgl_init(); ui_init(); while (1) { lv_timer_handler(); rt_thread_mdelay(5); } }lv_timer_handler 是 LVGL 的心跳,每一轮遍历一次所有注册的动画、控件和事件。5 毫秒的周期对于 480×480 的屏幕够用,动画效果丝滑。如果发现刷新率不够,可以把这个周期改成 2 毫秒,但注意 CPU 占用率会明显上升。
4. ST77903 移植避坑:花屏、白屏和刷新异常排查
4.1 花屏且带有规律性条纹
现象:屏幕能亮,背光正常,但显示的图像有规律的彩色条纹,像被横向切割过。原因:颜色格式匹配错了。ST77903 支持 16 位 RGB565、18 位 RGB666 甚至 24 位 RGB888,LVGL 默认可能是 RGB565,但初始化序列里 0x3A 寄存器写成了 0x66,也就是 18 位模式,数据宽度对不上。解决:确保 0x3A 写成 0x55 或者把 LVGL 的颜色深度改成 16 位,两边必须一致。这个我用排除法检查,先看初始化序列,再查 LVGL 配置文件里的 LV_COLOR_DEPTH 宏。
4.2 QSPI 内存映射模式下写入无效
现象:初始化完成,用 QSPI 命令模式写入数据正常,但开启内存映射模式后,直接往映射地址写数据,屏幕毫无反应。原因:QSPI 内存映射模式的写入行为取决于芯片设计。H743 的 QSPI 内存映射区域是只读的,写操作不会自动转成 QSPI 写时序。这个从芯片参考手册看是明确写了的,但很多人拿到手就会踩。解决:往 LCD 传像素数据不要走内存映射写,而是用间接模式,也就是 HAL_QSPI_Transmit。读操作可以用内存映射,写操作必须走命令模式。
4.3 初始化序列跑完屏幕还是白屏
现象:代码流程看起来没毛病,复位时序也对,寄存器序列完整执行,但屏幕就是白屏。原因:大概率是复位引脚被复用配置成了别的功能。H743 引脚复用复杂,QSPI 引脚和复位脚如果被初始化两次,可能第二次把复用状态覆盖成 GPIO 输入或模拟模式。解决:检查 MX_GPIO_Init 里的复位脚配置,确保在 QSPI 初始化之后再初始化复位脚,或者干脆把复位脚挂在 QSPI 初始化完成之后的代码里。
4.4 LVGL 刷新出现画面撕裂
现象:屏幕上半部分已经更新成新帧,下半部分还是旧帧,中间有一条明显的分界线在滚动。原因:flush 回调里直接调用 lv_disp_flush_ready,但实际上 QSPI 传输还在进行,LVGL 已经开始往另一个缓冲区绘制,并触发下一次 flush,两次 flush 的窗口区域重叠或错位。解决:flush 回调里不要同步等待传输完成,用 DMA 传输完成回调触发 lv_disp_flush_ready,确保一帧数据完整发送后才允许下一次刷屏。
4.5 背光亮度不均匀且屏幕闪烁
现象:屏幕显示正常,但背光亮度不均匀,低亮度时有明显闪烁。原因:背光 PWM 频率太低。ST77903 的背光控制如果直接用一个低频 PWM,人眼能感知到频闪。解决:把背光 PWM 频率提到至少 1kHz,最好用 4kHz 以上,具体值看 PWM 定时器的时钟分频。这个属于硬件设计问题,但很多开发板上的背光默认就配置成低频,软件可以补救。
5. 进阶实践:用时间戳统计法验证 LVGL 帧率与 DMA 带宽利用
LVGL 自带的帧率统计通常只能看到绘制耗时,看不到 QSPI 传输这部分的开销,而恰恰传输才是这个方案的瓶颈。我习惯在 flush 回调入口和 DMA 完成中断里各打一个时间戳,把两个时间差单独计算出来,这就是一帧数据的实际传输时间。
static uint32_t flush_start_tick; static uint32_t busy_time_max = 0; static void lcd_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { flush_start_tick = DWT->CYCCNT; lcd_set_window(area->x1, area->y1, area->x2, area->y2); lcd_qspi_write_dma(area, color_p); } void qspi_tx_complete_isr(void) { uint32_t cost = DWT->CYCCNT - flush_start_tick; if (cost > busy_time_max) { busy_time_max = cost; } lv_disp_flush_ready(NULL); }DWT 计数器在 H743 上是 480MHz 的频率,1 毫秒就是 480000 个 tick。用这个可以算出整帧 460800 字节的传输时间到底跑在哪。如果你的数据实际传输时间超过了理论值,优先检查 QSPI 时钟配置是否真的跑到了目标频率,以及 DMA 突发长度有没有配满。DMA 的突发长度默认可能是 1,改成 16 或者 32 之后带宽利用率能明显改善。
帧率还可以从另一个角度验证:每秒调用 lv_timer_handler 的次数乘以每次处理的动画步长,但这个方法受动画复杂度影响。直接看 DWT 测量出来的总线占用率最直接。我曾经有个项目刷同一张图,QSPI 时钟同样是 80MHz,优化 DMA 突发长度和缓冲行数之后,整帧传输时间从 9.8 毫秒降到 5.6 毫秒。从那以后我每次做完驱动移植,都会强制走一遍这套测量流程,确认数据链路没有瓶颈才上 UI 层。希望这套排查思路和代码片段能帮你少跑几天冤枉路。
本文还有配套的精品资源,点击获取