LVGL帧率优化

Tutorial: 卓越工程师 Category: 嵌入式 Published: 2026-10-23 11:05:29 Views: 0 Likes: 0 Comments: 0

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。

本课分两步优化:

  1. 先优化 LCD_Address_Set(),合并坐标参数的发送;
  2. 再优化 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 的布局如下:

索引01234567
内容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 引脚区分字节含义:

信号本工程引脚作用
SCKPA5SPI1 时钟
MOSIPA7MCU 向 ST7796 发送命令、参数和像素
DCPA1低电平表示命令,高电平表示参数或显示数据
CS1PA3ST7796 低有效片选
CS2PA4XPT2046 触摸控制器片选,不可与 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,因此改为:

  1. 三条命令仍逐条调用 LCD_WR_REG();
  2. x1、x2 的四个相邻数据字节合并发送;
  3. 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);

这里有三个关键点:

  1. color 本来是 lv_color_t *,当前 lv_color_t 为 RGB565、每个元素 2 字节;
  2. HAL_SPI_Transmit() 接收 uint8_t *,强制转换只改变编译器看待指针的类型,不复制、不重排原数据;
  3. 发送长度的单位是字节,所以必须是 像素数 × 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 上限,但已达到课堂认可的可用状态