LVGL帧率优化
214.3.13 LVGL帧率优化
/* lcd_init.h:声明本课新增的可变长度 SPI 发送函数。 */
void LCD_Writ_Bus_Len(u8 *dat, uint32_t len); /* dat 指向待发送字节,len 表示连续发送的字节数。 */
/* lcd_init.c:原有单字节发送函数,本课用它分析性能瓶颈,最终仍供命令发送使用。 */
void LCD_Writ_Bus(u8 dat) /* 向 LCD 发送一个字节。 */
{ /* 开始一次单字节 SPI 事务。 */
LCD_CS_Clr(); /* 拉低 LCD 片选 CS1,选中 ST7796。 */
HAL_SPI_Transmit(&hspi1, &dat, 1, 100); /* 以阻塞轮询方式发送 1 字节,超时参数为 100 ms。 */
LCD_CS_Set(); /* 拉高 CS1,结束并释放本次 LCD 事务。 */
} /* 单字节发送结束。 */
/* lcd_init.c:本课新增的批量发送函数,保留配套源码中的函数名 Writ。 */
void LCD_Writ_Bus_Len(u8 *dat, uint32_t len) /* 连续发送 dat 指向的 len 个字节。 */
{ /* 开始一次批量 SPI 事务。 */
LCD_CS_Clr(); /* 整批数据发送期间只拉低一次 CS1。 */
HAL_SPI_Transmit(&hspi1, dat, len, 100); /* 用一次 HAL 调用发送整个连续缓冲区。 */
LCD_CS_Set(); /* 全部字节发送完成后再释放 LCD。 */
} /* 批量发送结束。 */
/* lcd_init.c:原有 16 位数据发送函数,用来说明旧代码为何会产生两次 SPI 调用。 */
void LCD_WR_DATA(u16 dat) /* 按高字节在前的顺序发送一个 16 位数据。 */
{ /* 开始拆分 16 位数据。 */
LCD_Writ_Bus(dat>>8); /* 右移 8 位,先发送高 8 位。 */
LCD_Writ_Bus(dat); /* 参数转成 u8 后,再发送低 8 位。 */
} /* 一个 16 位数据发送结束。 */
/* lcd_init.c:命令发送函数;D/C 必须在命令和参数之间切换。 */
void LCD_WR_REG(u8 dat) /* 向 ST7796 写入一个 8 位命令。 */
{ /* 开始发送命令。 */
LCD_DC_Clr(); /* D/C=0,声明后续字节是命令。 */
LCD_Writ_Bus(dat); /* 发送命令字节。 */
LCD_DC_Set(); /* D/C=1,为紧随其后的参数或像素数据做准备。 */
} /* 命令发送结束。 */
/* lcd_init.c:本课最终的显示窗口设置函数。 */
void LCD_Address_Set(u16 x1, u16 y1, u16 x2, u16 y2) /* 设置闭区间列、行范围,并启动显存写入。 */
{ /* 开始组织窗口命令和参数。 */
uint8_t XY_Buf[8]; /* 4 个 16 位坐标共需 8 个字节。 */
XY_Buf[0] = (x1>>8) & 0xff; /* 保存起始列 x1 的高字节。 */
XY_Buf[1] = x1 & 0xff; /* 保存起始列 x1 的低字节。 */
XY_Buf[2] = (x2>>8) & 0xff; /* 保存结束列 x2 的高字节。 */
XY_Buf[3] = x2 & 0xff; /* 保存结束列 x2 的低字节。 */
XY_Buf[4] = (y1>>8) & 0xff; /* 保存起始行 y1 的高字节。 */
XY_Buf[5] = y1 & 0xff; /* 保存起始行 y1 的低字节。 */
XY_Buf[6] = (y2>>8) & 0xff; /* 保存结束行 y2 的高字节。 */
XY_Buf[7] = y2 & 0xff; /* 保存结束行 y2 的低字节。 */
LCD_WR_REG(0x2a); /* 发送 CASET:设置列地址。 */
LCD_Writ_Bus_Len(XY_Buf, 4); /* 一次发送 x1、x2 的高低 4 个字节。 */
LCD_WR_REG(0x2b); /* 发送 RASET:设置行地址。 */
LCD_Writ_Bus_Len(XY_Buf+4, 4); /* 从数组第 5 个字节起发送 y1、y2。 */
LCD_WR_REG(0x2c); /* 发送 RAMWR:后续连续字节写入显示 RAM。 */
} /* 显示窗口设置结束。 */
/* lcd.c:用一次批量发送替换原来的逐像素、逐字节调用。 */
void LCD_Color_Fill(u16 sx, u16 sy, u16 ex, u16 ey, lv_color_t *color) /* 把 LVGL 刷新矩形中的连续 RGB565 数据送往 LCD。 */
{ /* 开始区域刷新。 */
u16 i, j; /* 配套源码保留但当前没有使用的局部变量。 */
u32 k = 0; /* 配套源码保留但当前没有使用的局部变量。 */
uint32_t x = 0, y = 0; /* 配套源码保留但当前没有使用的局部变量。 */
u16 height, width; /* 保存闭区间矩形的高和宽。 */
width = ex - sx + 1; /* 闭区间宽度包含起点和终点,所以加 1。 */
height = ey - sy + 1; /* 闭区间高度包含起点和终点,所以加 1。 */
LCD_Address_Set(sx, sy, ex, ey); /* 设置本次刷新的 LCD 写入窗口。 */
LCD_Writ_Bus_Len((uint8_t *)color, width*height*2); /* RGB565 每像素 2 字节,整块一次发送。 */
} /* 区域刷新结束。 */
/* lv_conf.h:匹配直接按内存字节顺序发送 RGB565 缓冲区的方案。 */
#define LV_COLOR_DEPTH 16 /* 保持 RGB565 色深,每个像素仍占 16 位。 */
#define LV_COLOR_16_SWAP 1 /* 交换 RGB565 的两个字节,使 SPI 先发显示器需要的高字节。 */
#define LV_DISP_DEF_REFR_PERIOD 30 /* LVGL 默认每 30 ms 调度一次显示刷新。 */
本节源码边界: 上述代码来自第 18 号
18LCD_Lvgl_Ok配套工程。LCD_Writ_Bus_Len()、LCD_Address_Set()的批量参数发送、LCD_Color_Fill()的整块像素发送及LV_COLOR_16_SWAP=1是本课修改重点;disp_flush()仍沿用上一课对LCD_Color_Fill()的调用,没有在本课改写。最终源码保留了i/j/k/x/y等未使用变量,本笔记只作说明,没有擅自删改。实际归档构建结果为 0 error、1 个来自未调用的 stress demo 源文件的既有 warning。
一、屏幕优化
1.1 任务信息资源导图
上一课已经完成 LVGL 显示和触摸移植,但 Widgets 页面切换需要数秒,触摸后的视觉反馈非常迟缓,性能监视器约显示 4 FPS。本课从第 17 号工程复制出第 18 号工程,并命名为 18LCD_Lvgl_Ok,目标是把这套 LVGL 移植优化到基本可用状态。
原逐字稿中的 F163Z1T6 是 STM32F103ZET6。课堂曾怀疑“芯片性能太低”或“没有使用 DMA”是根因,但最终实验说明,当前最严重的问题不是 LVGL 功能本身,而是显示驱动把大量连续像素拆成了成千上万次极小的 HAL SPI 调用。
优化前后的数据通路没有改变:
LVGL 绘制缓冲区 color_p
→ disp_flush()
→ LCD_Color_Fill()
→ LCD_Address_Set() 设置 ST7796 写入窗口
→ SPI1 把 RGB565 像素发给 LCD
→ lv_disp_flush_ready() 通知 LVGL 缓冲区可复用
真正改变的是“同样的字节怎样分组发送”:
| 阶段 | 优化前 | 优化后 |
|---|---|---|
| 地址参数 | 一个字节一次 HAL 调用 | 相邻的 4 字节参数一次发送 |
| 像素数据 | 一个像素拆成 2 次 HAL 调用 | 整个刷新块一次发送 |
| CS1 | 每发送 1 字节就拉低、拉高一次 | 一整段连续数据只切换一次 |
| SPI 模式 | 阻塞轮询 | 仍是阻塞轮询 |
| DMA | 未使用 | 仍未使用 |
DMA 不会自动提高 SPI 线上的时钟频率。它主要减少 CPU 搬运负担,并在异步设计中让 CPU 绘图与外设发送重叠;如果仍把数据切成大量小事务、频繁启动 DMA并等待完成,提升可能很有限。本课没有实现 DMA,最终提速来自减少函数调用、片选翻转和逐字节事务开销。
1.2 具体代码修改
1.2.1 修改LCD_colorful函数
原笔记和逐字稿中的 LCD colorful 指的是课程新增的 LCD_Color_Fill(),不是名为 LCD_colorful 的函数。disp_flush() 每次拿到一个失效矩形及其连续像素缓冲区后,就调用该函数把数据送到 LCD。
本课分两步优化:
- 先优化
LCD_Address_Set(),合并坐标参数的发送; - 再优化
LCD_Color_Fill(),整块发送 RGB565 像素数据。
第一次只略有改善,第二次才明显加速。原因可由调用数量直接解释,而不是凭主观感觉判断。
1.2.1.1 相关函数分析
显示端口里与底层 LCD 刷新直接相关的入口主要有两个:
disp_init()只在启动阶段调用LCD_Init(),不是持续刷新时的热点;disp_flush()会在界面更新期间反复调用LCD_Color_Fill(),其执行时间直接限制 LVGL 刷新速度。
沿调用链向下跟踪:
disp_flush()
└─ LCD_Color_Fill()
├─ LCD_Address_Set()
│ ├─ LCD_WR_REG()
│ └─ LCD_WR_DATA() / LCD_Writ_Bus_Len()
└─ LCD_WR_DATA() / LCD_Writ_Bus_Len()
└─ HAL_SPI_Transmit()
旧 LCD_Writ_Bus() 每次只发送 1 字节,并在调用前后各切换一次 CS1。旧 LCD_WR_DATA() 又把一个 16 位值拆成高、低两个字节,因此调用一次 LCD_WR_DATA() 会进一步触发两次 HAL_SPI_Transmit() 和四次 GPIO 片选操作。
SPI1 当前配置为 8 位数据、MSB first、APB2 时钟 72 MHz、4 分频,因此串行时钟为 18 Mbit/s。优化没有提高这个位时钟,而是让同一段有效负载在一次事务中连续发送。
1.2.1.2 具体代码操作
先在 lcd_init.c 中复制原单字节函数的结构,新增:
void LCD_Writ_Bus_Len(u8 *dat, uint32_t len)
原函数接收一个 u8 dat,调用 HAL 时固定传 Size=1;新函数改成接收字节指针和长度,让调用者决定一次发送多少字节。随后在 lcd_init.h 中添加同名声明,否则其他 .c 文件调用时会缺少原型。
配套源码函数名确实写作 Writ,少了字母 e。为保持课程源码一致,本课不把它改名为 Write。
这个函数仍是阻塞发送:HAL_SPI_Transmit() 返回后,整批字节才算完成,所以随后拉高 CS1,并由上层在 LCD_Color_Fill() 返回后调用 lv_disp_flush_ready(),时序仍然成立。
1.2.1.2.1 LCD_Address_Set函数
LCD_Address_Set(x1,y1,x2,y2) 向控制器发送三条命令和两组参数:
DC=0:0x2A(列地址设置)
DC=1:[x1高][x1低][x2高][x2低]
DC=0:0x2B(行地址设置)
DC=1:[y1高][y1低][y2高][y2低]
DC=0:0x2C(开始写显示 RAM)
DC=1:随后发送像素数据
这与 ST7796S 数据手册中 CASET、RASET、RAMWR 的命令顺序一致。x1/x2/y1/y2 都是 u16,每个坐标要按高字节、低字节拆开;四个坐标共 8 字节,所以建立 XY_Buf[8]。
1.2.1.2.1.1 函数作用
字节拆分规则以 x1 为例:
[ x1_{high}=(x1\gg8)\ &\ 0xFF ]
[ x1_{low}=x1\ &\ 0xFF ]
例如 x1=0x012C(十进制 300):
(x1 >> 8) & 0xFF = 0x01
x1 & 0xFF = 0x2C
发送顺序 = 01 2C
赋给 uint8_t 本来也会截断到低 8 位,课程再使用 & 0xFF 是把“只保留目标字节”的意图写得更明确。原转写所说“只取第八位”不准确,应为“只保留低 8 位”或“取出高 8 位后再限制为 8 位”。
XY_Buf 的布局如下:
| 索引 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 内容 | x1高 | x1低 | x2高 | x2低 | y1高 | y1低 | y2高 | y2低 |
LCD_Writ_Bus_Len(XY_Buf,4) 从索引 0 发送列参数;XY_Buf+4 是指向索引 4 的指针,所以第二次从 y1 高字节开始发送 4 字节行参数。
旧地址设置一共调用 HAL SPI 约 11 次:3 个命令各 1 次,加上 4 个 16 位坐标各 2 次。优化后为 5 次:3 个命令各 1 次,加上列参数和行参数各 1 次。线上发送的有效字节仍是 11 个,没有减少;减少的是软件事务次数。
1.2.1.2.1.2 LCD_WR_REG函数
本模块使用 4 线串行显示接口,SPI 的 MOSI 同时承载命令、参数和像素数据,由额外的 D/C 引脚区分字节含义:
| 信号 | 本工程引脚 | 作用 |
|---|---|---|
SCK | PA5 | SPI1 时钟 |
MOSI | PA7 | MCU 向 ST7796 发送命令、参数和像素 |
DC | PA1 | 低电平表示命令,高电平表示参数或显示数据 |
CS1 | PA3 | ST7796 低有效片选 |
CS2 | PA4 | XPT2046 触摸控制器片选,不可与 CS1 混用 |
CS1 回答“当前 SPI 总线选择哪一个器件”,DC 回答“发给 LCD 的当前字节该按命令还是数据解释”,二者不是同一种信号。
LCD_WR_REG() 先把 DC 拉低,再借助 LCD_Writ_Bus() 发送命令,最后把 DC 拉高。于是紧跟的 LCD_Writ_Bus_Len() 会在数据模式下发送参数。命令与参数之间必须改变 DC,不能在保持同一个 DC 电平的情况下把 0x2A 和后续四个参数简单拼进同一批;课程只合并了 DC 电平相同且连续的参数字节。
课程前面对模块引脚的核对结果、本工程 main.h 宏及 GPIO 初始化共同表明 DC 接 PA1、CS1 接 PA3,二者都配置成推挽输出。调试时若页面完全不对,可用逻辑分析仪同时观察 CS1、DC、SCK、MOSI:命令字节期间 DC 应为低,参数和像素期间应为高,且 CS1 只在对应事务内为低。
1.2.1.2.1.3 优化过程
课堂最初设想把地址设置中的所有字节一次发出,随后发现命令与数据之间需要切换 DC,因此改为:
- 三条命令仍逐条调用
LCD_WR_REG(); - x1、x2 的四个相邻数据字节合并发送;
- y1、y2 的四个相邻数据字节合并发送。
首次编译时缺少 LCD_Writ_Bus_Len() 声明,课程把原型加入 lcd_init.h 后重新编译、烧录。页面刷新仅快了一点,因为每个刷新块只设置一次地址,而真正占大头的是随后成千上万个像素字节。
数量关系非常直观。假设一次刷新包含 (N) 个 RGB565 像素:
[ calls_{old}=11+2N ]
只优化地址后:
[ calls_{address}=5+2N ]
当 (N) 很大时,减少 6 次调用几乎被 (2N) 淹没,所以这一阶段的视觉提升有限。
1.2.1.3 应用案例
1.2.1.3.1 例题: 屏幕优化效果展示
地址设置优化后,工程能正常编译和烧录,画面也保持正确,但速度只比以前略快。这个实验说明:优化前应估算热点的调用频率,不能只因为某个函数看起来调用了十几次 SPI,就默认它是最大瓶颈。
随后课程回到 LCD_Color_Fill()。旧代码对矩形内每个像素执行:
读取 color->full
→ LCD_WR_DATA() 拆成高、低字节
→ LCD_Writ_Bus() 调两次
→ HAL_SPI_Transmit(Size=1) 调两次
→ color++
当前 LVGL 绘制缓冲最多容纳 480×10=4800 个像素。一次最大块刷新时,仅像素部分就会调用:
[ 4800\times2=9600\text{ 次 HAL_SPI_Transmit} ]
连同旧地址设置约为 9611 次。最终批量实现则是 5 次地址相关发送加 1 次像素发送,共约 6 次。有效像素仍是 9600 字节,但函数进入退出、HAL 状态检查和 CS1 GPIO 翻转次数大幅下降,这才是明显提速的直接原因。
1.2.1.3.2 例题: 屏幕优化颜色问题
课程把旧的逐像素循环注释掉,换成:
LCD_Writ_Bus_Len((uint8_t *)color, width * height * 2);
这里有三个关键点:
color本来是lv_color_t *,当前lv_color_t为 RGB565、每个元素 2 字节;HAL_SPI_Transmit()接收uint8_t *,强制转换只改变编译器看待指针的类型,不复制、不重排原数据;- 发送长度的单位是字节,所以必须是
像素数 × 2。
像素数仍按闭区间计算:
[ pixel_count=(ex-sx+1)\times(ey-sy+1)=width\times height ]
字节数为:
[ byte_count=width\times height\times2 ]
改完后页面刷新明显加快,但颜色失真。这不是坐标错误,也不是把 RGB565 改成了其他色深,而是批量发送暴露了 MCU 内存字节序与 LCD 线上字节顺序之间的不匹配。
旧路径以红色 0xF800 为例:
color->full = 0xF800
LCD_WR_DATA() 主动先发 0xF8,再发 0x00
LCD 收到的 RGB565 = 0xF800,颜色正确
STM32F103 的内存采用小端序,普通 0xF800 在内存里依次是 [0x00][0xF8]。直接把 color 转成 uint8_t * 后,SPI 会按地址递增先发 0x00,再发 0xF8;显示器把它解释成 0x00F8,红、绿、蓝位自然错乱,于是出现花色画面。
1.2.1.4 颜色深度设置
原笔记把这一步写成“颜色深度从 0 改成 1”,这是错误的。真正修改的是 lv_conf.h 第 30 行附近的字节交换开关:
#define LV_COLOR_16_SWAP 1
LV_COLOR_DEPTH 仍保持 16,像素格式仍是 RGB565。LV_COLOR_16_SWAP=1 让 LVGL 按交换后的两个字节组织 16 位颜色,使缓冲区按 uint8_t * 直接发送时,线上顺序变成 LCD 需要的高字节在前。LVGL v8.3 官方颜色文档也明确说明,该开关用于通过 SPI 这类面向字节的接口发送 RGB565 时处理两个字节的顺序。
仍以红色为例:
交换关闭:内存 [00][F8] → 原样发送 00 F8 → 错色
交换开启:内存 [F8][00] → 原样发送 F8 00 → 正确红色
它不是“所有 SPI 屏都必须设为 1”的固定规则,而要与刷新回调的发送方式配套:
| 刷新方式 | 本工程应使用的设置 |
|---|---|
取 color->full 后手动高字节、低字节发送 | LV_COLOR_16_SWAP=0 |
| 把 RGB565 缓冲区强转成字节指针并原样连续发送 | LV_COLOR_16_SWAP=1 |
如果驱动本身已经逐像素交换,再同时开启 LVGL 交换,就可能发生二次交换而重新出错。判断依据应是逻辑分析仪看到的线上字节顺序,而不是仅凭“接口是 SPI”。
还要注意当前课程实现的边界:
LCD_Writ_Bus_Len()的len是uint32_t,但 STM32F1 HAL 的HAL_SPI_Transmit()将Size定义为uint16_t;本工程最大 10 行缓冲只有 9600 字节,处于安全范围,若以后扩大到超过 65535 字节必须分段发送或改接口;- 100 ms 是本次阻塞调用的超时值,函数没有检查 HAL 返回状态;线路或外设异常时,上层无法区分成功与超时;
- 课程仍使用单缓冲和阻塞 SPI,优化目标只是减少事务开销,并未实现绘制与传输并行;
lv_disp_flush_ready()仍应在批量阻塞发送返回之后调用,不能提前通知 LVGL 复用缓冲区。
硬件命令与字节顺序核验依据:ST7796S 数据手册、LVGL v8.3 Colors 文档。
1.3 帧率展示
重新编译、烧录后,Widgets demo 颜色恢复正常,点击切换和动画速度明显快于优化前。课程投屏观察到的性能监视值在 22~23 FPS 左右,偶尔约 24 FPS;这是本课硬件、界面和驱动组合下的实测现象,不是 STM32F103ZET6 的通用固定帧率。
lv_conf.h 中:
#define LV_DISP_DEF_REFR_PERIOD 30
表示 LVGL 默认显示刷新定时器每 30 ms 获得一次调度机会。因此当前配置对应的整数理论上限为:
[ FPS_{limit}=\left\lfloor\frac{1000\text{ ms}}{30\text{ ms}}\right\rfloor=33\text{ FPS} ]
原笔记写作 $FPS=1000ms/刷新间隔(ms)$,补全量纲后应理解为:
[ FPS\approx\frac{1\text{ s}}{T_{frame}}=\frac{1000\text{ ms}}{T_{frame}(\text{ms})} ]
但 30 ms 是 LVGL 的调度周期,不等于每次刷新一定能在 30 ms 内完成,也不等于 LCD 面板内部扫描频率。实际一帧还要经历界面计算、软件绘制、失效区域合并、SPI 数据传输等步骤,任何一步耗时过长都会使实际 FPS 低于 33。
另一方面,LVGL 通常只刷新变化的失效区域,不一定每一帧都传输完整的 480×320 屏幕。因此 22~23 FPS 也不能反推为 SPI 每秒发送了 22~23 个完整帧缓冲。性能标签适合比较同一界面优化前后的相对变化;若要精确定位耗时,还应分别测量绘制时间、disp_flush() 占用时间、刷新面积和 SPI 波形。
本课最终结论为:
地址参数由逐字节调用改为两组 4 字节批量发送
→ 界面速度略有改善
像素数据由逐像素两次发送改为整个刷新块一次发送
→ 界面速度显著改善,但最初发生字节序错色
LV_COLOR_16_SWAP 从 0 改为 1
→ RGB565 线上字节顺序恢复正确
最终约 22~23 FPS
→ 低于 30 ms 周期对应的 33 FPS 上限,但已达到课堂认可的可用状态