简介:面向嵌入式开发者的 ST77903 LCD 驱动与 UI 集成完整示例,基于 STM32H743 + RT-Thread + LVGL 环境,针对该芯片无内部 RAM、需 QSPI 连续传输的特点,演示了如何在主机端创建专用刷屏线程以完成表盘渲染。整套工程共包含 2591 个文件,以 C 源码、头文件、链接脚本及工程配置为主,压缩包约 13.66MB,目录结构清晰、整体结构完整,适合中高级开发者直接参考或移植。资源内置 3 个手表表盘示例,完整覆盖 LVGL 界面搭建、QSPI 数据传输调度以及 RT-Thread 多线程协作等关键环节,可帮助理解受限 LCD 驱动在实时系统中的实现思路。从线程创建到显示刷新均有清晰代码路径,并附带工程构建配置,便于复现与二次开发。目前已有 2585 人学习,可作为穿戴设备屏幕驱动和 GUI 开发的实际工程参考样例。
1. 从 QSPI 点亮 ST77903:这套 DEMO 解决了什么
在嵌入式调试现场最扎心的场景,就是屏明明接好了,却死活不亮。凑齐一块矽创 ST77903 的 LCD 模组、一块 STM32H743 开发板,以为接上 QSPI 就能把 LVGL 跑起来,结果不是花屏就是白屏,能独占一个下午。这套 DEMO 把 H743 + RT-Thread + LVGL + QSPI + ST77903 整条链路搭通,解决了“显示走通”和“GUI 搬上去”两件事。适合手上有 ST77903 屏幕、准备在 RTOS 上做彩色 GUI,又不愿意用 8 位或 16 位 RGB 并口占掉一堆管脚的开发者。看完不仅能照着手上的工程把屏点亮,也能搞清楚 QSPI 驱动 LCD 和普通 SPI 刷屏的本质差别在哪里。
2. QSPI 接口与硬件接线:为什么 ST77903 要用 QSPI 而不是 SPI 并口
2.1 选型:QSPI、4-wire SPI 和 RGB 并口怎么选
ST77903 这一类驱动 IC 对外提供多种通信方式,最常见的有 4 线 SPI、QSPI 和 RGB 并行接口。4 线 SPI 的优势是接线少,四根线加复位和背光就能工作,但一个像素 16 位数据都要靠一根数据线一位一位移出去,320x480 分辨率下全屏刷新时 CPU 占用非常难看。RGB 并口一次能传 16 位或 18 位像素,带宽高,代价是占用大量 GPIO,而且需要保证时序与面板的同步信号贴合,初始化维护成本也高。
QSPI 是这台 DEMO 选的折中点。它把数据线从 1 根扩到 4 根,在同样时钟频率下,像素传输带宽大约等于 4-wire SPI 的四倍。再加上 STM32H743 自带的 QUADSPI 外设支持内存映射模式,可以把 LCD 映射成一段普通地址,直接用 memcpy 往里面写数据就完成了像素输出,这让“刷屏”变成纯内存操作。
实际选型时还要看模组设计。有些 ST77903 模组把 QSPI 数据脚和 RGB 并口同时引出来,焊盘上多留了 IO0-IO3,这种就非常适合 QSPI 方案。另有一些模组只引出 4 线 SPI,那就没必要硬怼 QSPI。这个 DEMO 的前提是屏厂的参考原理图里留了 QSPI 引脚,所以如果你的屏只有四根 SPI 线,先确认是不是同一款模组。
2.2 H743 QSPI 引脚规划与时钟配置
不同开发板对 H743 的 QSPI 引脚分配不太一样,但核心引脚是固定的:QSPI_CLK、QSPI_CS、QSPI_IO0 到 QSPI_IO3。我手头这块板子走的是 QUADSPI1 的常规映射,CLK 用 PB2,CS 用 PG6,IO0-IO3 分别走 PF6、PF7、PF8、PF9。这里有个关键点:CS 片选必须接到 LCD 的 CSX 引脚,而不是接到板上 Flash 的 CS。
| 信号 | STM32H743 引脚 | LCD 端 | 说明 |
|---|---|---|---|
| QSPI_CLK | PB2 | SCL | 时钟,带上拉 |
| QSPI_CS | PG6 | CSX | 片选,低有效 |
| QSPI_IO0 | PF6 | SDI/SDA0 | 数据双向或输入 |
| QSPI_IO1 | PF7 | SDO/SDA1 | 数据双向或输出 |
| QSPI_IO2 | PF8 | SDA2 | 数据双向 |
| QSPI_IO3 | PF9 | SDA3 | 数据双向 |
时钟初始化的顺序一般建议先把 QSPI 的时钟源配到 PLL1Q,再开启 GPIO 的 AF 功能。ST77903 的 SCL 频点上限通常在手册里有个额定值,但实际调试时我不建议一上来就跑到极限。常见做法是先按 20MHz 左右起调,确保初始化序列稳定,再逐步往上加。H743 的 QSPI 时钟最终频率由 QUADSPI 的时钟分频系数决定,该系数为偶数,这一点在代码里要注意。第一次点屏如果出现花屏,先检查是不是分频配成了奇数,CLK 波形会因此产生占空比畸变,较真起来非常玄学。
2.3 上电时序:复位、背光、电源的先后顺序
很多 ST77903 项目翻车不在代码,而在这条时序链。LCD 模组的复位引脚 RST 需要在供电稳定之后拉低再拉高,拉低时间一般要求不低于 1ms,有的模组要求 10ms,最好用延时函数保证。复位之后要等 IC 内部上电完成,通常给 120ms 左右的缓冲,再发初始化命令才有效。
背光控制建议放在最后。先开背光再初始化是常见误区,因为此时屏内部偏压还没建立,点亮瞬间会看到整块背光里屏发白,随后可能带出残影。正确顺序是 VCI 供上电并稳定,拉复位,等待 Delay(120ms),发 Sleep Out 和 Display On 序列,最后再开背光。进入休眠状态时也要反向操作:先关背光,再进 Sleep In。
上电时序这块有一个容易被忽略的参数,就是复位引脚默认电平。如果复位脚被 GPIO 复用成其他功能,或者忽略了 MCU 上电瞬间 IO 输出的不确定电平,LCD 会在系统启动阶段反复复位。我一般会在复位引脚上加一个 10k 上拉到复位无效电平,确保 MCU 还没接管之前屏处于正确状态。
3. QSPI 底层驱动:从命令初始化写到内存映射刷屏
3.1 QSPI 间接模式发命令:初始化序列怎么发
ST77903 的初始化序列本质上和大多数 RGB 接口 LCD 驱动 IC 相似,核心命令包括 Sleep Out、像素格式设置、Gamma 校正、Display On 等。区别在于命令传输要走 QSPI 外设,而 QSPI 外设和普通 SPI 外设在命令构造上不一样。
在 QSPI 间接模式下,发一条命令需要填充 QSPI_CommandTypeDef 结构体。下面这段代码是发单字节命令的通用写法:
static void st77903_write_cmd(uint8_t cmd) { QSPI_CommandTypeDef sCmd = {0}; sCmd.InstructionMode = QSPI_INSTRUCTION_1_LINE; // 命令用单线发送 sCmd.Instruction = cmd; // 命令字节本身 sCmd.AddressMode = QSPI_ADDRESS_NONE; // 无地址阶段 sCmd.DataMode = QSPI_DATA_NONE; // 无数据阶段 sCmd.DummyCycles = 0; // 无需空周期 sCmd.AlternateByteMode = QSPI_ALTERNATE_BYTES_NONE; sCmd.SIOOMode = QSPI_SIOO_INST_EVERY_CMD; // 仅发送指令,不带数据 if (HAL_QSPI_Command(&hqspi, &sCmd, 1000) != HAL_OK) { // 失败要返回错误码,不能静默吞掉 error_handler(); } }这段代码里最关键的两个参数是InstructionMode和DataMode。InstructionMode决定命令阶段的数据线数量,初始化阶段建议保持单线,因为 LCD 在收到进入 QSPI 模式的特定指令之前,并不确定你一定就走四根线。DataMode置为QSPI_DATA_NONE说明这次只传命令不带数据。命令后如果还需要数据,就要把DataMode改成QSPI_DATA_1_LINE或QSPI_DATA_4_LINE,同时通过HAL_QSPI_Transmit把数据发出去。
HAL 层的HAL_QSPI_Command执行完只代表命令被送进 QSPI 的 FIFO,真正要确认 LCD 收到了,还得靠后续读写状态或延时兜底。初始化序列通常包含几十条命令,每两条之间建议留出至少 1ms 间隔,少部分 Gamma 相关命令间间隔还要加长。不要图快全部连发,ST77903 内部并非所有寄存器都能同步生效。
3.2 像素批量传输:内存映射与 DMA 写入
初始化完成之后,真正刷屏的数据量就大了。如果还走间接模式,每条命令都靠 CPU 去搬运,一个 320x480 的 RGB565 全屏就是 300KB 数据,CPU 会被拖死。QSPI 外设有一个激进用法,把 LCD 映射到 H743 的地址空间,然后通过 memcpy 或者 DMA 直接写。
H743 的 QSPI 内存映射模式默认段在 0x90000000。开启之后,写这个地址段就等于把数据发射到 QSPI 数据线。配合 ST77903 的像素格式写入,设置命令里把写数据长度和方向配好,就能一个窗口一个窗口地刷新。
// 配置内存映射模式 static void qspi_memory_mapped_enable(void) { QSPI_MemoryMappedTypeDef memCfg = {0}; QSPI_CommandTypeDef sCmd = {0}; // 像素数据按 4 线 QSPI 发送 sCmd.InstructionMode = QSPI_INSTRUCTION_NONE; sCmd.DataMode = QSPI_DATA_4_LINE; sCmd.AddressMode = QSPI_ADDRESS_NONE; sCmd.DummyCycles = 0; memCfg.TimeOutActivation = QSPI_TIMEOUT_DISABLE; memCfg.TimeOutPeriod = 0; memCfg.TimeOutRate = 0; if (HAL_QSPI_MemoryMapped(&hqspi, &sCmd, &memCfg, 1000) != HAL_OK) { error_handler(); } }配好之后,刷一个指定区域的代码会简洁很多:
// 从 LVGL 拿到的局部刷新区域,逐行拷贝到 QSPI 映射地址 static void st77903_flush_area(const lv_area_t *area, lv_color_t *color_p) { uint32_t width = lv_area_get_width(area); uint32_t height = lv_area_get_height(area); uint32_t row_bytes = width * sizeof(lv_color_t); uint32_t offset = 0; // 先通过 QSPI 间接模式设置窗口坐标 st77903_set_window(area->x1, area->y1, area->x2, area->y2); // 逐行写入映射地址,QSPI 底层会自动按 4 线把数据推出去 for (uint32_t row = 0; row < height; row++) { memcpy((void *)(LCD_FB_ADDR), color_p + offset, row_bytes); offset += width; } }这里有三个容易踩的细节。第一,LCD_FB_ADDR不能是枚举值直接写死地址,最好是经过系统编译的宏定义,并且确保地址对齐到 16 字节,否则 DMA 搬运时容易遇到对齐异常。第二,在内存映射模式下,QSPI 每次访问的粒度取决于外设配置,建议最少按 16 字节为单位访问,局部刷新时如果矩形宽度过窄,会浪费带宽。第三,窗口命令本身依然要发间接命令,所以st77903_set_window不能走内存映射,必须先退出映射模式或把窗口设置放在进入映射之前。
3.3 颜色格式对齐:RGB565 与 RGB888
ST77903 的像素颜色格式可以配置为 RGB565、RGB666 或 RGB888。LVGL 默认按颜色深度的不同走不同格式,常见配置是 LV_COLOR_DEPTH 16,也就是 2 字节 RGB565。
最直接的对应关系是:LVGL 里用lv_color_t表示颜色,在 16bit 深度下,它就是一个 uint16_t,内部排列是 R 高 5 位、G 中间 6 位、B 低 5 位。ST77903 的 RGB565 格式下,数据线上先收到高字节还是低字节,取决于写入命令后的字节序配置。
// 设置 ST77903 像素格式为 16 位 RGB565 static void st77903_set_pixel_format(void) { st77903_write_cmd(0x3A); // 像素格式命令 st77903_write_data_byte(0x55); // 0x55 对应 RGB565 }如果你发现屏幕显示的颜色像是被通道互换了,先不要怀疑初始化序列,大概率是字节序没对上。H743 的 QSPI 在内存映射模式下,如果配置成低位在前发送,而 ST77903 期望高位在前,就会出现红蓝互换和渐变异常。两种格式之间没有什么巧妙算法,真正稳妥的办法是先发一个纯红块、纯绿块、纯蓝块,逐一核对寄存器设置。花一刻钟做这个动作,能省下后面调两天颜色的时间。
4. RT-Thread + LVGL 集成:把显示链路装进多任务框架
4.1 在 RT-Thread 里注册 QSPI 显示设备
裸机环境下,所有代码都串行执行,刷屏的时候 CPU 就在刷屏。RT-Thread 下就必须考虑任务的优先级和等待关系,不能让刷屏任务把一个其余任务全部阻塞。
常见做法是把 QSPI 显示封装成 RT-Thread 设备。一种方式是直接把 HAL 层的 hqspi 句柄包装成一个字符设备,把 flush_area 作为 write 接口的底层实现。另一种方式更贴合 RT-Thread 原生的 SPI 设备框架,用 SPI 设备节点挂载 QSPI 总线,上层通过 rt_spi_transfer 发送数据。
我一般推荐后者,因为 RT-Thread 的 SPI 框架天然带设备互斥锁和 DMA 完成回调,多任务场景下不会出现两个任务同时抢 QSPI 的问题。注册时先调用rt_hw_qspi_register把 QUADSPI 挂到总线,再rt_spi_bus_attach_device绑定到 LCD 片选节点上。
设备注册完成之后,初始化线程只需要打开设备、发送初始化序列、开启 LVGL 刷新任务。设备打开的时候要显式传入 RT_DEVICE_OFLAG_RDWR,LVGL 的刷新任务里每次 write 都会经过框架互斥,防止与传感器等其他外设产生并发冲突。
4.2 LVGL flush_cb 适配:区域窗口与像素搬运
LVGL 渲染完成后,会调用显示驱动里注册的flush_cb,把脏矩形区域的像素数据交给底层。传统 SPI 屏驱动最常犯的错误是在flush_cb里同步等待发送完成,导致 LVGL 的渲染管线被迫停下来等 QSPI 传输。
正确的做法是:flush_cb里只负责接收像素数据、下发窗口命令、启动 DMA 传输,然后立刻返回,等 DMA 完成中断里再调用lv_disp_flush_ready通知 LVGL 缓冲区已经释放。
static void lcd_flush_cb(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 total_pixels = w * h; // 记录当前传输上下文,DMA 完成后会用到 lcd_ctx.busy = 1; lcd_ctx.pos = 0; lcd_ctx.total = total_pixels; lcd_ctx.color_depth_bytes = sizeof(lv_color_t); // 设置写入窗口 st77903_set_window(area->x1, area->y1, area->x2, area->y2); // 启动 QSPI DMA 传输 HAL_QSPI_Transmit_DMA(&hqspi, (uint8_t *)color_p, total_pixels * lcd_ctx.color_depth_bytes); } // DMA 传输完成回调,在中断上下文调用 void HAL_QSPI_TxCpltCallback(QSPI_HandleTypeDef *hqspi) { if (hqspi->Instance == QSPI1) { lcd_ctx.busy = 0; lv_disp_flush_ready(lcd_ctx.drv); } }这段代码里lcd_ctx是全局上下文结构体,关键参数是busy标记和drv指针。busy用于给上层判断当前是否还有未完成的传输,LVGL 自身有缓冲区保护,但你自己维护的底层标志位也必须跟上。lv_disp_flush_ready必须在 DMA 真正完成后调用,提前调用会导致 LVGL 认为缓冲区已释放,下一帧直接覆盖正在传输的数据,表现出来就是画面上下两部分撕裂。如果 DMA 没有配置完成中断,也可以用定时器轮询HAL_QSPI_GetState代替,但刷新率会打折扣。
4.3 GUI 任务划分与缓冲区管理
RT-Thread 环境下,LVGL 的 tick 时钟和刷新任务都要放在合适的线程里。LVGL 本身不是线程安全的,建议不要在多线程里直接调lv_task_handler,而是把 GUI 相关操作全部集中到一个 GUI 线程。
常见的线程划分如下:初始化线程负责完成 LCD 复位和初始化序列,然后创建 GUI 线程;GUI 线程优先级设置在中等偏上,栈空间根据 LVGL 控件复杂度给到 8KB 以上,否则控件多的时候容易栈溢出。LVGL 的 tick 可以用 RT-Thread 的软件定时器,每 1ms 调用一次lv_tick_inc(1),或者挂到系统滴答定时器里。
缓冲区的大小直接决定刷新体验。LVGL 支持单缓冲、双缓冲和部分刷新三种模式。在底端 ST77903 上,比较合适的配置是给 LVGL 一个 1/10 屏幕大小的缓冲区,也就是 320x48 的 RGB565 缓冲,这样每次 flush 不超过 30KB,DMA 传输时间短,屏幕撕裂不明显。
// lv_conf.h 中关掉 USE_PERF_MONITOR 前的关键配置 #define LV_MEM_SIZE (64U * 1024U) // LVGL 堆内存,按控件数量调整 #define LV_DISP_BUF_SIZE (320 * 48) // 部分刷新缓冲,约等于 1/10 屏幕 #define LV_COLOR_DEPTH 16 // RGB565 #define LV_TICK_CUSTOM 0 // 用外部 tick 源缓冲区使用 RT-Thread 的内存池分配,而不是普通 malloc。原因是 QSPI DMA 要求源地址对齐到 4 字节甚至 16 字节,普通 malloc 分配出来的地址虽然大多对齐,但偶尔一次不对齐就足够让整块屏幕刷新失败。内存池可以在创建时就指定对齐字节,比事后查缓冲地址更省心。
5. 避坑与常见问题:ST77903 QSPI 现场翻车记录
5.1 白屏:初始化与背光时序错位
现象:按常规流程发出初始化序列后,屏幕依然保持全白,测量背光电压正常。
原因:背光开启时机过早,或者复位脉冲宽度不足。ST77903 内部偏压电路在复位释放后需要一个建立时间,如果在这段时间内直接发 Sleep Out,IC 内部状态机没有正确进入激活模式。
解决:把背光控制延后到 Display On 之后至少 50ms。复位部分,RST 引脚拉低时间从 1ms 加到 10ms,释放后再等 120ms。排查时用示波器同时看 RST 和 VCI 两条线,确认复位发生时 VCI 已经稳定,而不是两边同时跳变。
5.2 花屏:QSPI 时钟超出面板承受
现象:初始化序列发送正常,但刷新画面出现随机彩色杂点,不是稳定的图像破损,而是整屏像被人撒了一把彩色斑点。
原因:QSPI 时钟频率设置过高。LCD 模组受柔性排线长度和连接器接触电阻影响,实际承受的时钟上限通常低于 H743 能输出的最大值。STM32H743 的 QSPI 默认时钟从 AHB 分频出来,分频系数设置不当会直接跑出比面板额定值高一倍的频率。
解决:将分频系数调整为偶数分频,先压到 20MHz 验证纯净度,再以 10MHz 为单位逐步提升,每提升一次就刷一张颜色渐变测试图。如果 20MHz 下依然花屏,考虑排线长度和 IO 上拉电阻值,在允许范围内降低 IO 驱动电流或调整采样边沿极性。
5.3 刷屏瞬间整机卡死:DMA 缓冲区对齐问题
现象:LVGL 刷新正常,但偶尔刷新到一半时 GUI 线程卡死,看门狗超时复位。
原因:LVGL 传进来的color_p缓冲区地址没有对齐到 DMA 要求地址,H743 的 DMA 在搬运非对齐地址时产生总线错误。
解决:分配缓冲区时不用 malloc,改用rt_mem_aligned分配 32 字节对齐的缓冲区。同时在flush_cb里加一个地址检查,地址未对齐时先拷贝到对齐缓冲,虽然多一次拷贝,但系统稳定性优先。
5.4 偏色与渐变异常:RGB565 字节序反转
现象:纯色图片显示正常,但渐变区域出现明显断层,红蓝两色在边缘处互换。
原因:QSPI 内存映射模式下字节顺序与 LCD 驱动 IC 期望的顺序不一致。ST77903 支持高低字节交换和上下拉交换,但这些寄存器没有统一默认值。
解决:先发三张纯色测试图确认通道映射,如果红蓝互换,调整0x36地址控制寄存器里的 BGR 位。如果颜色变成类似灰色偏绿的效果,多半是高字节和低字节发送顺序反了,需要在 QSPI 初始化结构体里找到数据段的大小端配置并翻转。每次调整后都重新刷一遍白、红、绿、蓝四张图做基准。
5.5 背光调光时屏幕闪烁:PWM 频率与刷屏频率耦合
现象:背光亮度调低之后,屏幕出现低频闪烁,亮度越低越明显,全屏刷新时尤其突出。
原因:背光 PWM 频率与 LVGL 的刷新率没有错开。PWM 频率如果接近刷新率,两者会形成差频闪烁。
解决:背光 PWM 频率提高到 1kHz 以上,最好达到 10kHz 级别,避开 30Hz 到 100Hz 的刷新敏感区间。同时确认 PWM 输出引脚不是和 QSPI 引脚共用同一个定时器通道,H743 的定时器复用冲突会在编译期报错,但有些低端调试工具捕获不到这种冲突,表现就是背光控制失灵。
6. 帧率验证与进阶:双缓冲 DMA 接力提速
这套 DEMO 里 LVGL 默认跑在单缓冲模式下,也就是渲染一帧、传输一帧、再渲染下一帧。想让 320x480 画面更跟手,下一步就是切双缓冲。LVGL 配置双缓冲后,渲染线程写 A 缓冲,DMA 传输 B 缓冲,两者并行,画面的撕裂几乎消失。
切换方法很简单,把lv_disp_buf_init里的两个缓冲区都填上真实地址即可。底层flush_cb不需要大改,但必须保证 DMA 传输的是当前传入的color_p,而不是自己缓存的旧指针。双缓冲最怕底层把两个缓冲区当成同一个地址,这样 DMA 还没传完,LVGL 就已经往同一块内存里写入新渲染数据。
帧率验证方面,LVGL 自带 per monitor 功能,开启LV_USE_PERF_MONITOR之后,屏幕上会在角落显示两条读数,一个是渲染耗时 FPS,一个是 CPU 占用率。真实刷新率还有一个更土但更直接的办法:在flush_cb里翻转一个 GPIO,用逻辑分析仪量翻转频率。GPIO 的方式不受 LVGL 版本影响,也能直接看出 DMA 传输占用了多少时间片。
进阶调优时可以给flush_cb加一个分段传输逻辑,把大块像素切割成 16KB 一包,逐包 DMA,避免一次传输过大导致 QSPI FIFO 溢出。ST77903 的写窗口命令支持自动换行,分段传输不会影响图像正确性,代价是多几次中断回调。从那以后我每次接手新屏,第一步都是先点亮跑纯色测试,再切 LVGL 单缓冲,最后才开双缓冲调参数,这套流程帮我绕开了大半屏显玄学,希望帮到你。
本文还有配套的精品资源,点击获取