简介:面向需要在 STM32F103 上快速搭建图形界面的开发者,这份可直接运行的 SPI 屏幕移植与 LVGL 工程,把底层 SPI 通信配置、常见屏幕驱动适配、LVGL 库集成以及帧缓冲管理等环节串成完整实例,适合嵌入式入门者或正在做界面方案的工程师对照参考。压缩包共 761 个文件,大小约 3.77MB;其中 c/h 源码和工程配置构成主体,另有固件、图片素材及说明文档,便于对照界面效果与移植步骤。已有 4222 人浏览学习。代码中可以看到屏幕初始化命令时序、画点与显存刷新方式、LVGL 控件创建和布局方法,也能学习到在内存有限时如何借助外部存储或局部缓冲区完成画面更新。配合串口日志或开发工具调试,可进一步观察事件处理与刷新时序,快速定位异常,是一份验证 GUI 效果、减少重复排错的实用参考。
1. 这块 F103 小屏跑 LVGL:从「移植程序」到「直接出 demo」的全过程
手头有一块 SPI 接口的小屏(ST7789、ILI9341 这类),想挂到 STM32F103 上跑 LVGL 做界面,最容易翻车的地方从来不是代码本身,而是分辨率和内存的账没算清、SPI 时钟开太低、以及没有校准时序就白屏。这份自写可用的 STM32F103 SPI 屏幕移植程序,已经帮你把 LVGL 的底层代买好了:初始化序列、SPI 参数、flush 回调和裁剪好的 lv_conf.h 都齐了,能直接出官方 demo 的效果。适合正在做低成本 GUI 项目、课设,或者刚把 LVGL 引入到 F103 平台的人。接下来我从屏幕选型开始讲,把每一处关键参数和背后的取舍都摆出来。
2. 屏幕驱动与 SPI 引脚接线:先选对屏,再谈性能
2.1 先确认屏幕用的是什么驱动 IC:读 ID 比看型号靠谱
市面上卖的小屏,丝印上的型号经常混着用。同一块 2.4 寸屏,驱动可能是 ST7789,也可能是 ILI9341,甚至有些小厂屏用的是兼容型号,寄存器行为略有差异。如果不先确认驱动 IC,后续调颜色、调扫描方向全在猜,浪费的时间足够把这个工程重写一遍。
最可靠的办法是让 SPI 主机去读屏幕的 ID 寄存器。ST7789 的读 ID 命令是 0x04,ILI9341 的读 ID 命令也是 0x04,但返回的字节不同。读不读得回来,取决于屏的 MISO 引脚有没有接出来。很多 4 线 SPI 屏为了省引脚,MISO 是不接的,这种情况下读 ID 就变成纯碰运气。
实际工程里遇到这种「读不了 ID」的情况,我的做法是先用示波器或逻辑分析仪抓一下屏的初始化时序,或者直接看屏的驱动 IC 型号丝印位置。如果实在确认不了,可以先跑一份 ST7789 的初始化序列,看白屏还是偏色。因为这套初始化的兼容性比较好,ILI9341 也能吃下大部分命令,白屏比偏色更容易定位问题。移植程序里默认的初始化序列写的是标准 ST7789 流程,你拿到手后第一件事就是对一下屏的实际驱动。下面这段是初始化序列的关键部分:
lcd_reset(); // 拉低 RST,延时 20ms,再拉高,延时 120ms lcd_write_cmd(0x11); // SLPOUT 退出睡眠 HAL_Delay(120); // 退出睡眠要等内部 DC-DC 稳定 lcd_write_cmd(0x36); // MADCTL:扫描方向 + RGB/BGR lcd_write_data(0x00); // 默认左上角开始,RGB 顺序 lcd_write_cmd(0x3A); // COLMOD:像素格式 lcd_write_data(0x05); // 0x05 = 16bit/pixel (RGB565) lcd_write_cmd(0x21); // INVON 反色开启,有些屏要改 0x20 HAL_Delay(10); lcd_write_cmd(0x2A); // CASET:X 范围 lcd_write_data(0x00); lcd_write_data(0x00); lcd_write_data(0x00); lcd_write_data(0xEF); // 0~239 lcd_write_cmd(0x2B); // RASET:Y 范围 lcd_write_data(0x00); lcd_write_data(0x00); lcd_write_data(0x01); lcd_write_data(0x3F); // 0~319 lcd_write_cmd(0x2C); // RAMWR,准备写显存这里有两个参数需要特别说明。0x3A 设置为 0x05 代表 16 位色深 RGB565,这是 LVGL 端LV_COLOR_DEPTH必须保持一致的数值;如果设成 0x06(18 位 666)但 LVGL 那边仍按 16 位打包数据,颜色会明显发灰偏色。MADCTL 的 0x00 是默认方向,如果你的屏幕装到外壳后画面旋转了 90 度,要改成 0x60 或 0xC0 这类带 MY/MX/MV 位的组合值,而不是动 LVGL 的坐标代码。
读 ID 的代码逻辑在移植包里是一段独立函数,用普通 GPIO 模拟 SPI 时序去读。之所以不用硬件 SPI 读,是因为有些屏的 MISO 在普通 4 线模式下不输出数据,需要额外切到 Read 模式。这个函数可以留着,以后换屏时先跑一遍,能少走很多弯路。
2.2 SPI 引脚与时钟分频:从哪里接入,速率开到多大才不白费
确认完驱动 IC 之后再看硬件连接。SPI 屏一共 6 根信号线:SCLK、MOSI、CS、DC、RST、BL,加上电源就是 8 根。移植程序里默认使用 STM32F103 的 SPI1,引脚分配是 PA5=SCK、PA7=MOSI,CS/DC/RST/BL 用普通 GPIO 控制。
为什么用 SPI1 而不是 SPI2,这里有个容易被忽略的硬件差异。F103 的 SPI1 挂在 APB2 总线上,APB2 最高可以跑到 72MHz,SPI 时钟再经过预分频,最高能到 36MHz;而 SPI2 挂在 APB1 总线上,APB1 最高只有 36MHz,SPI 时钟最高只能到 18MHz。两者刷屏速度差一倍,对 240x320 分辨率的屏来说,就是 22 帧和 13 帧的区别。所以只要引脚没有冲突,优先用 SPI1。
给你看一下 HAL 库的初始化参数,这是我能正常跑起来的最小配置:
SPI_HandleTypeDef hspi1; hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_2LINES; // 只发不收,2LINES 即可 hspi1.Init.DataSize = SPI_DATASIZE_8BIT; // 命令和数据都按字节 hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; // CPOL=0, CPHA=0 hspi1.Init.NSS = SPI_NSS_SOFT; // 片选手动拉 hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_2; // APB2=72MHz 时 SPI=36MHz hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; HAL_SPI_Init(&hspi1);这里最容易出错的是BaudRatePrescaler。很多人抄例程时直接保留SPI_BAUDRATEPRESCALER_16,SSD1306 这类小屏没问题,但 240x320 刷全屏时 SPI 时钟只有 4.5MHz,刷一帧要 270ms 以上,动画完全是幻灯片。改成SPI_BAUDRATEPRESCALER_2之后,SPI 时钟提到 36MHz,理论上刷一帧 150KB 的 RGB565 数据只需要 34ms。
还有一点要注意:CPOL/CPHA 必须和屏幕驱动 IC 的要求一致。ST7789 和 ILI9341 都支持 Mode 0(CPOL=0, CPHA=0)和 Mode 3(CPOL=1, CPHA=1),但如果你的屏之前跑过别的代码,且画面出现整体偏移的噪点,优先检查这个参数,而不是怀疑接线。
3. LVGL 工程接入:文件结构、裁剪配置与 flush 回调
3.1 移植工程的目录与文件接入:lv_conf.h 该放在哪
拿到移植程序后先看目录结构。LVGL 源码本身有几千个文件,但实际编译进 F103 项目的只有其中一部分。下载包里已经按最小可运行集整理过,核心路径长这样:
project/ ├── lvgl/ │ ├── src/ │ │ ├── core/ # LVGL 状态机、显示驱动、对象树 │ │ ├── draw/ # 绘制后端(软件渲染) │ │ ├── font/ # 内置字体,按需裁剪 │ │ ├── hal/ # 显示和输入设备抽象层 │ │ ├── misc/ # 内存、定时器、日志工具 │ │ └── widgets/ # 控件库 │ ├── lv_conf.h # 总配置文件,放在工程根目录 │ └── lv_version.h └── user/ ├── lcd_driver.c # SPI 屏驱动,含初始化序列 ├── lv_port_disp.c # LVGL 显示接口对接文件 └── main.clv_conf.h的位置是整个工程能不能编译通过的关键。这个文件默认放在 LVGL 源码根目录,但推荐的做法是复制一份放到工程根目录,同时把LV_CONF_INCLUDE_SIMPLE打开。这样所有源文件 include lv_conf.h 时都能从这个全局目录找到,避免改错文件。
另一个容易踩的坑是编译源文件时漏掉lv_hal_disp.c和lv_hal_tick.c。LVGL 的显示设备注册和时基维护都在这一层,漏了之后链接器报的是各种 undefined reference,报错信息里看不到明显问题,实际排查起来很费时间。把下载包里lvgl/src下的 .c 文件按目录加进工程,目录结构保持不变,这个坑就避开了。
3.2 lv_conf.h 里的关键开关:色深、分辨率、内存、tick
lv_conf.h 决定了 LVGL 在这个平台上的行为边界。移植包里已经做了一轮裁剪,但你还是应该逐项确认下表里的值,因为屏幕分辨率不同,这些值就不同:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| LV_COLOR_DEPTH | 16 | RGB565,与屏幕 COLMOD 0x05 对应 |
| LV_COLOR_16_SWAP | 0 | 大端传输时才需要开,F103 一般不开 |
| LV_HOR_RES_MAX | 240 | 屏幕水平分辨率 |
| LV_VER_RES_MAX | 320 | 屏幕垂直分辨率 |
| LV_MEM_SIZE | 32 * 1024 | LVGL 对象和控件堆大小 |
| LV_TICK_CUSTOM | 1 | 使用自定义 tick 源 |
| LV_USE_GPU | 0 | F103 无 GPU,必须关掉 |
| LV_FONT_MONTSERRAT_14 | 1 | 内置数字英文字体,跑 demo 够用 |
LV_COLOR_DEPTH设成 16 是最关键的一项。很多人直接在默认配置上改,默认是 32,刷屏数据量翻倍,F103 的 SPI 带宽直接被打满,表现为画面刷新慢一半。LV_HOR_RES_MAX和LV_VER_RES_MAX要严格对应屏幕分辨率和 CASET/RASET 的设置,这里设错了屏幕画面会错位。
LV_MEM_SIZE是给 LVGL 对象和管理器用的,不是给帧缓冲用的。帧缓冲的内存来自你自己定义的 static 数组,这两块内存是分开的。32KB 的堆对 F103 来说比较宽裕,但如果你的芯片是 C8T6(20KB RAM),建议把LV_MEM_SIZE降到 16KB,否则内存不够会导致运行时控件创建失败。
LV_TICK_CUSTOM这个开关值得多说一句。打开之后 LVGL 不再占用 SysTick,而是通过lv_tick_inc()这个外部函数获取时基。移植包里用的是 SysTick 中断里调用lv_tick_inc(1)的方式,这样省掉一个定时器资源,动画和控件状态机全部靠它驱动。如果你自己的工程里 SysTick 已经被占用,可以从定时器中断里调用同一个函数。
3.3 flush 回调怎么接:CPU 轮询版与 DMA 版的完整写法
LVGL 的显示驱动核心就是flush_cb这个回调函数。它负责把 LVGL 绘制好的像素数据写到屏幕里。先看 CPU 轮询版本,这是最容易确认正确性的写法:
void lv_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); // 设置屏幕写入窗口 lcd_set_address(area->x1, area->y1, area->x2, area->y2); lcd_write_cmd(0x2C); // RAMWR // 逐像素写数据,SPI 时钟越快越好 for (uint32_t i = 0; i < len; i += 2) { lcd_write_data_16(((uint16_t *)color_p)[i >> 1]); } // 通知 LVGL 本次刷新完成 lv_disp_flush_ready(drv); }这个版本逻辑很直白:把 LVGL 交下来的那个矩形区域写到屏幕的对应坐标范围。注意lcd_set_address必须使用area里的坐标,而不是整个屏幕范围。如果用全屏范围,局部刷新(比如按钮按下变亮)就会把其他区域的内容冲掉,画面上出现横向的整条残留。
CPU 轮询版的问题在于等待 SPI 发送完成的这段时间,CPU 被拖死了。对 240x320 全屏来说,一次刷 150KB,在 36MHz SPI 下也要 34ms,这段时间里lv_timer_handler()没法执行,动画帧率会受影响。所以能上 DMA 就上 DMA。下面是移植包里实际用的 DMA 版本:
static lv_disp_drv_t *disp_drv_ref; static const lv_area_t *disp_area_ref; static volatile uint8_t dma_busy = 0; void lv_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); uint16_t *pixels = (uint16_t *)color_p; disp_drv_ref = drv; lcd_set_address(area->x1, area->y1, area->x2, area->y2); lcd_write_cmd(0x2C); // 等待上次 DMA 完成,避免缓冲区被覆盖 while (dma_busy); dma_busy = 1; HAL_SPI_Transmit_DMA(&hspi1, (uint8_t *)pixels, w * h * 2); } void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { dma_busy = 0; lv_disp_flush_ready(disp_drv_ref); // DMA 完成后再通知 LVGL } }关键点在于lv_disp_flush_ready的位置。为什么不能放在 flush 回调里立刻调?因为 LVGL 在收到 flush_ready 之后会认为这块缓冲已经空闲,马上开始往里面绘制新内容。如果 DMA 还在后台传输,新数据会覆盖掉正在传输的像素,结果就是画面中间出现一条明显的花屏行带。把它放到 DMA 完成回调里,LVGL 就会一直等到传输结束才复用缓冲区。
dma_busy这个标志的作用是防止连续两次 flush 重叠。LVGL 是单线程模型,正常情况下不会同时发两个 flush,但串口日志或调试中断可能引入时序变化,加一个保险标志成本很低。我这里用的是全局变量加volatile,够用;你如果手头有多块屏,可以把它们挂到同一个数据结构里管理。
4. 帧缓冲与刷新速度:从单缓冲到 DMA 的取舍
4.1 为什么 F103 跑不了全屏双缓冲:算一笔内存账
LVGL 官方推荐双缓冲方案:一块给 LVGL 绘制,一块给显示器 DMA,两块交替使用,画面完全无撕裂。这套方案在 Cortex-M4 上没问题,但在 F103 上先算笔账:
240x320 分辨率,RGB565 色深,一块全屏缓冲需要 240 × 320 × 2 = 153600 字节,也就是 150KB。双缓冲就是 300KB。而 F103 三个常用型号的 RAM 分别是:C8T6 只有 20KB,RCT6 是 48KB,ZET6 是 64KB,没有一款装得下双缓冲。
这份移植程序标注「自写可用」,指的不是在全屏双缓冲下跑通,而是在局部缓冲策略下把 demo 完整跑起来。下表是三种型号下我建议的缓冲高度配置:
| 芯片型号 | RAM | 推荐缓冲高度 | 占用内存 | 可运行控件复杂度 |
|---|---|---|---|---|
| STM32F103C8T6 | 20KB | 20 行 | 9.6KB | 基础控件,简单布局 |
| STM32F103RCT6 | 48KB | 40 行 | 19.2KB | 中等复杂度界面 |
| STM32F103ZET6 | 64KB | 60 行 | 28.8KB | 可跑大多数 demo |
缓冲行数越高,LVGL 一次性绘制的面积越大,刷屏次数越少,画面越流畅。但代价是 RAM 占用和绘制延迟上升。C8T6 选 20 行不是因为它不能跑 40 行,而是 40 行要占 19.2KB,LVGL 对象堆只剩几百字节,创建几个控件就崩了。
4.2 部分缓冲的工作机制:用多少行缓冲才合适
LVGL 在 16 位色深下的默认工作模式是:把屏幕划分成若干水平条带,每个条带的高度等于 draw buffer 的高度。它先绘制第一条带的像素,然后调用 flush 把这条带刷到屏幕,再继续画下一条。代码里初始化的方式如下:
#define DRAW_BUF_W 240 #define DRAW_BUF_H 40 static lv_color_t draw_buf[DRAW_BUF_W * DRAW_BUF_H]; void lv_port_disp_init(void) { lv_disp_draw_buf_init(&draw_buf_obj, draw_buf, NULL, DRAW_BUF_W * DRAW_BUF_H); lv_disp_drv_init(&disp_drv); disp_drv.hor_res = 240; disp_drv.ver_res = 320; disp_drv.flush_cb = lv_disp_flush; disp_drv.draw_buf = &draw_buf_obj; lv_disp_drv_register(&disp_drv); }lv_disp_draw_buf_init的第三个参数传 NULL,表示只使用单缓冲。如果你传两个等长 buffer,LVGL 会在绘制和刷新之间做流水线并行,画面明显更流畅,但内存翻倍,F103 上绝大多数场景用不起。
关键参数是DRAW_BUF_H。它决定了每次 flush 的条带高度。40 行的缓冲,刷一帧需要 8 次 flush(320 / 40);20 行的缓冲需要 16 次 flush。flush 次数越多,命令开销(设置窗口地址、写 RAMWR 等)占比越大,帧率会进一步下降。但如果你的芯片是 C8T6,40 行缓冲可能会挤压 LVGL 对象堆,动态创建控件失败时没有任何明显报错,界面只显示一部分内容,排查起来会比帧率低更棘手。
我一般会先用 40 行跑官方 demo,确认系统稳定后把 demo 代码换成自己的界面,再根据控件数量决定降到 20 行还是 30 行。这个流程比一开始就选 20 行更高效。
4.3 帧率量化与取舍:36MHz SPI 下的理论上限与实际帧率
把 SPI 频率、像素格式和传输时间放到一张表里,你就能对「快」有量化概念。下面的计算以 240x320 全屏刷新为基准:
| SPI 分频 | SPI 时钟 | 传输 150KB 耗时 | 理论帧率 |
|---|---|---|---|
| 2 | 36MHz | 34ms | 29 FPS |
| 4 | 18MHz | 68ms | 14 FPS |
| 8 | 9MHz | 136ms | 7 FPS |
| 16 | 4.5MHz | 270ms | 3.7 FPS |
这里面传输耗时算的是纯数据时间,还没包括每次 flush 的地址设置命令。条带高度为 40 行时,一帧要执行 8 次地址设置,每次 8 个字节的命令开销,加上片选拉低拉高的等待,实际帧率大概要打个七折。所以分频 2 下理论上 29 FPS,实际能稳定跑 20 FPS 左右,对工业触摸界面来说已经属于可接受范围。
但这里有个隐性性能点很多人注意不到:DMA 版 flush 让 CPU 不用在 SPI 传输时忙等。传输 34ms 期间 CPU 可以继续执行 LVGL 的定时器和事件处理,等 DMA 完成回调再把 buffer 交回去。这意味着同样的帧率下,DMA 版本能让 UI 交互响应快很多,控件状态更新和刷屏各走各的,互不拖累。
5. 移植避坑:五个必踩的坑与排查思路
5.1 显示颜色红蓝互换:MADCTL 的 BGR 位与硬件接线的双因素
现象:画面上红色显示成蓝色,蓝色显示成红色,绿色正常,人物肤色发紫。
原因:屏幕驱动 IC 的 MADCTL 寄存器第 3 位(BIT3)控制 RGB/BGR 顺序。ST7789 默认是 GRB 物理排列,部分模组厂商出厂时在内部已做了翻转,导致同样写进 0x36 的 0x00,有的屏是 RGB,有的是 BGR。另外如果屏的 RGB 数据线接反了(这种问题极少,但不是没有),也会出现同样现象。
解决:改 MADCTL 值。把初始化序列里的lcd_write_data(0x00)改成lcd_write_data(0x08),只加 BIT3,重新烧录看效果。如果颜色恢复正常就结束;如果从红蓝互换变成了红绿互换,那说明是硬件接线的物理交叉,需要用飞线处理,单纯改寄存器救不了。
5.2 屏幕只刷一半或整体错位:CASET/RASET 窗口与 AREA 坐标对不上
现象:画面左侧的内容跑到右侧,或者底部内容跑到顶部,也有只刷新屏幕一半的情况。用 LVGL 官方 demo 时症状最明显,方块动画在屏幕上像被切成两段。
原因:flush 回调里lcd_set_address用的坐标范围和 LVGL 传入的area不一致。最常见的是直接把地址窗口固定为全屏(0,0,239,319),但 LVGL 在部分缓冲模式下每次只传一个条带,比如一条 240x40 的区域。地址窗口如果固定全屏,显示就整体错位。
解决:flush 回调严格使用area->x1, area->y1, area->x2, area->y2四个值作为地址窗口参数。另外检查初始化序列里的 CASET 和 RASET 是否与分辨率匹配。ST7789 的 X 范围是 0~239,Y 范围是 0~319;ILI9341 也是 0~239、0~319。如果某个屏的分辨率是 320x240(横屏),就要把 CASET 设为 0~319,RASET 设为 0~239,同时调整 LVGL 的LV_HOR_RES_MAX和LV_VER_RES_MAX。
5.3 DMA 刷屏出现局部花屏:buffer 复用时序的问题
现象:开启 DMA 刷屏后,图像整体正常,但画面滚动时某一水平区域出现断断续续的色条,大约每隔几十行出现一次,频率和帧率一致。
原因:lv_disp_flush_ready被提前调用,LVGL 认为缓冲已空闲,开始往里面绘制新的内容,而 DMA 还在读取这块缓冲发送上一帧的数据。这是 DMA 版本最典型的时序错误,并且它只在动画滚动时出现,静态画面反而看不出问题,很容易被误判成 SPI 信号质量差。
解决:确保lv_disp_flush_ready放在 DMA 传输完成回调里,而不是 flush 函数末尾。同时加上dma_busy标志,在发起新传输前等待上一次完成。如果你已经这样做了还花屏,检查 SPI1 的 DMA 接收是否正确配置——SPI DMA 发送模式下要同时使能 DMA 发送和 DMA 接收,接收方向用来产生 TXE 事件,可以直接关闭接收来避免干扰。
5.4 编译链接时 RAM 溢出:全屏大缓冲直接爆掉
现象:代码编译通过,但在链接阶段报错,提示region RAM overflowed by xxx bytes,或者编译能过但一运行 LVGL 控件创建失败,界面空白。
原因:LVGL 官方默认配置的 draw buffer 是按 320x240 的全屏大小分配的,两个 buffer 再加上 LVGL 对象堆,总需求远超 F103 的 RAM。C8T6 的 20KB 面对 150KB 的单缓冲完全无解,RCT6 的 48KB 也很紧张。
解决:按第 4.1 节的表格把 draw buffer 高度降到 20~40 行之间,并把LV_MEM_SIZE控制在 16~32KB。链接报错时优先看哪块数组占了大头,通常就是 draw_buf 数组。如果你用的是 Keil 工程,还有一个常用检查手段:在 map 文件里搜draw_buf,能直接看到它占了多少 RAM。
5.5 LVGL 界面「卡住不动」:TICK 源没接上
现象:屏幕能出画面,单片机也在跑,但点击按钮没反应,动画一帧不动,整个界面像是被冻结。
原因:LVGL 的所有动画、控件状态机、事件轮询都依赖lv_tick_inc()提供的时基。如果这个函数没被定时调用,LVGL 的lv_timer_handler()会认为时间没有流逝,永远不推进动画帧,也不触发超时事件。常见原因有两个:一是 SysTick 中断没开启;二是LV_TICK_CUSTOM开着,但代码里没有提供lv_tick_inc的实现。
解决:在 SysTick 中断或任意 1ms 定时器中断里调用lv_tick_inc(1)。如果你用HAL_GetTick()作为时钟源,也可以把LV_TICK_CUSTOM打开并直接使用 HAL 的 tick,但那样要保证HAL_GetTick()中断优先级足够高,否则会被其他阻塞逻辑饿死。
6. 验证与进阶:用 FPS 计数和内存监控确认移植边界
LVGL 自己带了一套运行时监控接口,移植完成后先不要急着做界面,跑一组量化测试能帮你确认移植到什么水平。在lv_timer_handler()的循环里加一个计数器,每秒通过串口打印一次,代码量只有几行:
static uint32_t last_tick, frame_cnt; void check_fps(void) { frame_cnt++; uint32_t now = lv_tick_get(); if (now - last_tick >= 1000) { uint32_t fps = frame_cnt * 1000 / (now - last_tick); printf("FPS: %lu\n", fps); last_tick = now; frame_cnt = 0; } lv_timer_handler(); }在 demo 界面跑 10 秒,如果 FPS 在 20 左右,说明 SPI 时钟、DMA 和缓冲配置都合理;如果只有个位数,先检查BaudRatePrescaler有没有设成分频 2,再把 draw buffer 高度减半看是否有明显变化。
内存监控用 LVGL 自带的接口更直接:lv_mem_monitor返回一个结构体,里面有total_size和free_size两个关键字段。在进入主循环前打印一次,能确认你的LV_MEM_SIZE是否够用;如果 free 只剩几百字节,说明控件或图表对象的堆分配已经接近边缘,优先减小缓冲高度。
进阶方向我一般建议做两件事。第一件是中文显示:F103 的 Flash 放不下全量汉字库,用 LVGL 官方字体转换工具把 UI 里用到的汉字导成子集字体数组,生成 .c 文件后加入工程,然后调用lv_style_set_text_font绑定字体即可,这个流程对一个 30 汉字左右的界面很实用。第二件是用户触摸屏的坐标校准:此工程虽然只包含显示部分,但底层 flush 坐标已经和屏幕方向一致,接 XPT2046 触摸芯片时只需要做一个坐标旋转映射,不需要反过来改显示部分,两者解耦能让调试周期缩短很多。
这套工程我用了三个项目,每次换屏都会踩到上面几个坑。从那以后我拿到一块没标型号的 SPI 屏,第一件事不再是插线跑初始化,而是先读 IC ID 确认驱动,再对 MADCTL 方向和 SPI 时钟,最后才进 LVGL 界面调试。这个习惯帮我省的不止两三天。希望帮到你。
本文还有配套的精品资源,点击获取