LVGL显示代码移植(1)
210.3.9 LVGL显示代码移植(1)
/* lv_port_disp_template.h:本节把模板总开关由 0 改为 1。 */
#if 1 /* 让显示端口头文件的正文参与条件编译。 */
/* lv_port_disp_template.c:同样启用源文件,并接入课程已有的 LCD 驱动。 */
#if 1 /* 让显示端口源文件的正文参与条件编译。 */
#include "lv_port_disp_template.h" /* 引入本显示端口的声明以及 LVGL 类型。 */
#include <stdbool.h> /* 提供 bool、true 和 false。 */
#include "lcd_init.h" /* 提供 LCD_Init、LCD_W 和 LCD_H。 */
#include "lcd.h" /* 提供本课程 LCD 绘图函数的声明。 */
#ifndef MY_DISP_HOR_RES /* 尚未从别处定义显示宽度时进入该分支。 */
#define MY_DISP_HOR_RES LCD_W /* 用 LCD 驱动的宽度宏统一配置 LVGL。 */
#endif /* 结束显示宽度的条件编译。 */
#ifndef MY_DISP_VER_RES /* 尚未从别处定义显示高度时进入该分支。 */
#define MY_DISP_VER_RES LCD_H /* 用 LCD 驱动的高度宏统一配置 LVGL。 */
#endif /* 结束显示高度的条件编译。 */
static void disp_init(void); /* 前置声明底层 LCD 初始化函数。 */
static void disp_flush(lv_disp_drv_t * disp_drv, /* 前置声明显示刷新回调及驱动参数。 */
const lv_area_t * area, /* 声明待刷新的矩形区域参数。 */
lv_color_t * color_p); /* 声明连续像素数据指针参数。 */
void lv_port_disp_init(void) /* 定义 LVGL 显示端口的总初始化函数。 */
{ /* 开始显示端口初始化。 */
disp_init(); /* 先初始化实际 LCD 硬件。 */
static lv_disp_draw_buf_t draw_buf_dsc_1; /* 静态保存单绘制缓冲区的描述信息。 */
static lv_color_t buf_1[MY_DISP_HOR_RES * 10]; /* 静态分配能容纳 10 行像素的单缓冲区。 */
lv_disp_draw_buf_init(&draw_buf_dsc_1, buf_1, /* 绑定第一个绘制缓冲区。 */
NULL, /* 传入 NULL,表示不启用第二个缓冲区。 */
MY_DISP_HOR_RES * 10); /* 告诉 LVGL 缓冲容量是宽度乘 10 个像素。 */
static lv_disp_drv_t disp_drv; /* 静态保存显示驱动描述符。 */
lv_disp_drv_init(&disp_drv); /* 先用 LVGL 默认值初始化驱动描述符。 */
disp_drv.hor_res = MY_DISP_HOR_RES; /* 把水平分辨率设为 LCD_W。 */
disp_drv.ver_res = MY_DISP_VER_RES; /* 把垂直分辨率设为 LCD_H。 */
disp_drv.flush_cb = disp_flush; /* 注册把绘制结果送往 LCD 的刷新回调。 */
disp_drv.draw_buf = &draw_buf_dsc_1; /* 把所选单缓冲区交给显示驱动。 */
lv_disp_drv_register(&disp_drv); /* 向 LVGL 注册配置完成的显示驱动。 */
} /* 结束显示端口初始化。 */
static void disp_init(void) /* 定义 LVGL 显示端口的底层初始化函数。 */
{ /* 开始函数体。 */
LCD_Init(); /* 初始化课程使用的 LCD 控制器与接口。 */
} /* 结束函数体。 */
static void disp_flush(lv_disp_drv_t * disp_drv, /* disp_drv 是当前发起刷新的显示驱动。 */
const lv_area_t * area, /* area 给出本次待刷新的矩形坐标。 */
lv_color_t * color_p) /* color_p 指向该矩形的连续像素数据。 */
{ /* 开始刷新回调。 */
LCD_Fill(area->x1, area->y1, /* 本课暂把 LVGL 区域起点传给 LCD_Fill。 */
area->x2, area->y2, color_p); /* 本课暂把区域终点和像素指针传入;此方案后续会纠正。 */
lv_disp_flush_ready(disp_drv); /* 同步发送结束后,通知 LVGL 可再次使用缓冲区。 */
} /* 结束刷新回调。 */
/* lcd_init.h:课程选择方向 2,使逻辑分辨率变成 480×320。 */
#define USE_HORIZONTAL 2 /* 0/1 为竖屏,2/3 为横屏,本课选 2。 */
#if USE_HORIZONTAL == 0 || USE_HORIZONTAL == 1 /* 判断是否采用竖屏方向。 */
#define LCD_W 320 /* 竖屏逻辑宽度为 320 像素。 */
#define LCD_H 480 /* 竖屏逻辑高度为 480 像素。 */
#else /* 方向 2 或 3 时进入横屏分支。 */
#define LCD_W 480 /* 横屏逻辑宽度为 480 像素。 */
#define LCD_H 320 /* 横屏逻辑高度为 320 像素。 */
#endif /* 结束方向对应分辨率的条件编译。 */
/* lcd.h:本课为了接收 color_p,加入 LVGL 类型并修改 LCD_Fill 的声明。 */
#include "lvgl.h" /* 提供 lv_color_t 的类型定义。 */
void LCD_Fill(u16 xsta, u16 ysta, /* 声明矩形的起始坐标。 */
u16 xend, u16 yend, /* 声明矩形的结束坐标。 */
lv_color_t * color_p); /* 本课把原来的单个 u16 颜色改成颜色指针。 */
/* lcd.c:以下是本课把原纯色填充函数改成指针参数后的阶段性实现。 */
void LCD_Fill(u16 xsta, u16 ysta, /* 接收矩形起点。 */
u16 xend, u16 yend, /* 接收矩形终点。 */
lv_color_t * color_p) /* 接收颜色数据首地址。 */
{ /* 开始函数体。 */
u16 t = 1; /* 控制分批发送循环是否继续。 */
u32 num, num1; /* num 是剩余像素数,num1 是本批像素数。 */
num = (xend - xsta) * (yend - ysta); /* 按旧接口的右开区间规则计算像素数。 */
LCD_Address_Set(xsta, ysta, xend - 1, yend - 1); /* 把旧接口的结束坐标转换成 LCD 的闭区间终点。 */
LCD_CS_Clr(); /* 拉低片选,开始一次 LCD 传输。 */
while(t) /* 当仍有像素未发送时继续循环。 */
{ /* 开始一次分批处理。 */
if(num > 65534) /* 单批超过 65534 个像素时先拆出一批。 */
{ /* 开始大数据量分支。 */
num -= 65534; /* 从剩余数量中扣除本批像素。 */
num1 = 65534; /* 本批固定发送 65534 个像素。 */
} /* 结束大数据量分支。 */
else /* 剩余数量已不超过单批上限。 */
{ /* 开始最后一批分支。 */
t = 0; /* 标记本批结束后退出 while。 */
num1 = num; /* 最后一批发送全部剩余像素。 */
} /* 结束最后一批分支。 */
for(uint32_t i = 0; i < num1; i++) /* 按本批像素数循环发送。 */
HAL_SPI_Transmit(&hspi1, /* 使用课程已有的 SPI1 句柄。 */
(uint8_t *)color_p, /* 本课始终从同一个地址取 2 字节。 */
2, 100); /* 每个像素发送 2 字节,超时值为 100。 */
} /* 结束分批循环。 */
LCD_CS_Set(); /* 拉高片选,结束 LCD 传输。 */
} /* 结束函数体。 */
/* main.c:本节只用纯色值检查改过的 LCD_Fill 是否仍能工作。 */
uint16_t lcd_color; /* 保存一个 16 位 RGB565 纯色值。 */
lcd_color = BLACK; /* 课程先试 WHITE,随后改为 BLACK 再试。 */
LCD_Fill(0, 0, LCD_W, LCD_H, /* 请求填充整个 480×320 屏幕。 */
(lv_color_t *)&lcd_color); /* 把纯色变量地址强制转换成课程所改的指针类型。 */
本节源码边界: 上面保留的是视频在第 210 课实际采用的阶段性代码。
LCD_Fill()中color_p没有递增,因此白屏、黑屏测试只能证明“反复发送同一个颜色”仍可工作,不能证明 LVGL 的多颜色区域刷新正确。第 211 课运行 demo 后出现颜色异常,第 212 课才明确纠正这个认识并另写逐像素发送函数;这里不提前抄入后续实现。
一、工程修改
上一课只完成了 LVGL 源文件、Keil 分组、头文件路径和 C99 配置。本课进入移植任务的第四部分:配置屏幕输出,也就是在 LVGL 的显示抽象层与已有 ST7796 LCD 驱动之间建立连接。
本课涉及的主要文件如下:
| 文件 | 本节用途 |
|---|---|
midware/lvgl/examples/porting/lv_port_disp_template.c | 初始化显示、建立绘制缓冲、登记分辨率和刷新回调 |
midware/lvgl/examples/porting/lv_port_disp_template.h | 开启显示端口模板并声明初始化接口 |
BSP/LCD/lcd_init.h | 设置屏幕方向以及 LCD_W、LCD_H |
BSP/LCD/lcd.h、lcd.c | 阶段性修改 LCD_Fill() 参数和发送代码 |
Core/Src/main.c | 暂时用白色、黑色执行纯色填充测试 |
BSP/TOUCH/touch.c | 临时注释因 LCD_Fill() 改签名而报错的校准调用 |
二、配置屏幕输出
2.1 创建新工程并打开
将上一课的第 15 号工程复制一份作为第 16 号工程。课程把工程目录名从 15LCD_Display_Touch_Lvgl 改成 16LCD_Display_Lvgl,即名称中去掉 Touch,表示当前先集中处理显示端口;触摸输入的适配还没有开始。
第 15 号工程本来就是上一课保存的中间检查点,并非“已经移植完成”的工程。复制的意义是保留可回退快照,随后应确认 Keil 打开的是第 16 号目录中的工程,避免把改动继续写回第 15 号工程。
2.2 配置屏幕输出第一步: 修改条件编译
课程没有按模板顶部英文提示另存为 lv_port_disp.c/.h,而是继续使用已经加入 Keil 的:
midware/lvgl/examples/porting/lv_port_disp_template.c
midware/lvgl/examples/porting/lv_port_disp_template.h
把两个文件顶部的 #if 0 都改为 #if 1。这不是普通运行时判断,而是预处理条件:值为 0 时,模板主体在编译前就被排除;改为 1 后,显示端口的类型、函数和代码才真正进入编译。
首次直接启用模板后出现编译错误,课程先停用没有选择的缓冲示例。尤其是模板的第 3 个示例还引用了当前工程未定义的旧宏 LV_VER_RES_MAX,并且其两个全屏缓冲本来也远超本机内部 SRAM 容量。处理模板时不能同时把三个互斥示例都当作有效配置。
2.3 配置屏幕输出第二步: 添加屏幕初始化代码
lv_port_disp_init() 首先调用模板内部的 disp_init(),所以把 LCD_Init() 放进 disp_init(),并在端口源文件中包含 lcd_init.h。这样调用关系变为:
lv_port_disp_init()
└─ disp_init()
└─ LCD_Init()
课程随后把 main.c 原位置的 LCD_Init() 注释掉,目的是让 LCD 的硬件初始化归显示端口管理,避免两个位置重复初始化。要注意,本节尚未在 main() 中正式完成 lv_init()、lv_port_disp_init()、tick 与任务处理的整套调用;这些属于后续课程。因此这里完成的是端口代码接线,不等于 LVGL 已经运行。
2.4 配置屏幕输出第三步: 设置屏幕为横屏
在 BSP/LCD/lcd_init.h 中把:
#define USE_HORIZONTAL 0
改为:
#define USE_HORIZONTAL 2
该驱动约定 0、1 为竖屏,2、3 为横屏;课程选择 2。对应的条件编译结果为:
| 显示方向 | LCD_W | LCD_H |
|---|---|---|
| 竖屏(0/1) | 320 | 480 |
| 横屏(2/3) | 480 | 320 |
这里的方向值不只是交换两个数字。LCD_Init() 内还会根据它配置 LCD 控制器的扫描/映射方向;LCD_W、LCD_H 则让上层代码使用与该方向一致的逻辑坐标范围。
2.5 配置屏幕输出第四步: 选择图形缓冲模式
LVGL 先在 RAM 中的绘制缓冲区生成像素,再调用 flush_cb 把这批像素传给 LCD。它不是 LCD 控制器内部的显存,也不在 MCU 的 Flash 中。本工程把缓冲数组声明为 static lv_color_t[],实际占用的是 SRAM;原视频口述和原笔记把资源压力说成 Flash,精校后予以纠正。
模板给出三种示例,本课只保留第 1 种:
| 示例 | 缓冲配置 | 工作方式 | 本课状态 |
|---|---|---|---|
| 1 | 一个 10 行缓冲 | LVGL 画完一块,刷新完成后再复用同一缓冲 | 启用 |
| 2 | 两个 10 行缓冲 | 配合 DMA 时,可一边发送一块、一边绘制下一块 | 注释 |
| 3 | 两个全屏缓冲,并启用 full_refresh | 传统全帧双缓冲,通常更适合能直接切换帧缓冲地址的显示控制器 | 注释 |
课程配置 LV_COLOR_DEPTH == 16,因此每个 lv_color_t 像素为 2 字节。横屏 480×320 下的静态容量为:
[ \text{示例 1}=480\times10\times2=9600\text{ B}=9.375\text{ KiB} ]
[ \text{示例 2}=2\times480\times10\times2=19200\text{ B}=18.75\text{ KiB} ]
[ \text{示例 3}=2\times480\times320\times2=614400\text{ B}=600\text{ KiB} ]
第 1 种并不是完整帧缓冲:当待更新区域大于 10 行时,LVGL 会把它拆成多块,逐块绘制和刷新。一个缓冲时,调用 lv_disp_flush_ready() 前 LVGL 不能安全地重写这块内存;两个缓冲只有在底层采用 DMA 等异步传输时,才可能真正形成绘制与发送并行,不能只靠多声明一个数组就自动提速。
2.6 配置屏幕输出第五步: 配置LVGL屏幕分辨率
模板原值是固定的 320×240,本课改为使用 LCD 驱动的宏:
MY_DISP_HOR_RES → LCD_W → 480
MY_DISP_VER_RES → LCD_H → 320
然后 disp_drv.hor_res、disp_drv.ver_res 分别取这两个值。这样以后只需在 LCD 驱动中改变方向/尺寸,上层端口即可同步,不必在多处重复写死分辨率。
这些值有两种用途,必须保持一致:
buf_1[MY_DISP_HOR_RES * 10]用水平分辨率算出 10 行需要多少像素;disp_drv.hor_res/ver_res告诉 LVGL 合法的屏幕坐标范围与布局尺寸。
若缓冲长度仍按旧宽度计算,而驱动却按新宽度绘制,就可能发生越界;若 LVGL 分辨率与 LCD 的方向不一致,界面会出现截断、旋转或坐标错位。
2.7 配置屏幕输出第六步: 配置显示函数
disp_flush() 是 LVGL 与 LCD 驱动之间最关键的显示出口。LVGL 把本次无效区域重新绘制到缓冲区后,传入三个参数:
| 参数 | 正确含义 |
|---|---|
disp_drv | 发起本次刷新的显示驱动对象 |
area | 待刷新的矩形,坐标为 x1、y1、x2、y2 |
color_p | 指向该矩形第一个像素的指针,后面连续存放矩形内所有像素 |
LVGL 的区域终点是包含在区域内的,所以正确的宽、高和像素数应为:
[ w=x2-x1+1,\qquad h=y2-y1+1 ]
[ N=w\times h=(x2-x1+1)(y2-y1+1) ]
像素顺序可理解为从左到右、从上到下连续排列;每发送一个像素后,读取指针必须移到下一个 lv_color_t。模板原示例中的 color_p++ 正是在表达这一点。
2.7.1 本课实际采用的阶段性方案
视频先把 color_p 理解为“指向一个颜色”,因而直接复用原来的纯色填充函数:
- 把
LCD_Fill()的第 5 个参数从u16 color改成lv_color_t *color_p; - 把 SPI 发送地址从
&color改成color_p; - 在
lcd.h中包含lvgl.h,使lv_color_t可见; - 在
disp_flush()中把area的四个坐标和color_p传给LCD_Fill(); - 因函数签名变化,注释掉
touch.c中传入WHITE的校准清屏调用; - 在
main.c中定义uint16_t lcd_color,取其地址并强制转换为lv_color_t *,先后测试白屏与黑屏。
修改后工程能够编译,烧录后也能显示纯白和纯黑。这个实验只能验证:地址所指的同一个 16 位值被反复发送时,旧的纯色清屏功能仍然有效。
2.7.2 必须掌握的原理校正
本课方案还存在两个实质问题:
HAL_SPI_Transmit()循环中始终使用同一个color_p,没有color_p++,所以整个区域只能重复第一个像素,无法呈现 LVGL 缓冲区中的多种颜色;- 原
LCD_Fill(xsta, ysta, xend, yend, ...)把xend/yend当作右开区间,使用(xend-xsta)*(yend-ysta)并设置到xend-1/yend-1;LVGL 的area->x2/y2却是闭区间终点。直接传参会少算最后一列和最后一行。
例如 LVGL 请求刷新 (0,0)~(479,9),区域实际是 480×10,共 4800 个像素;按旧 LCD_Fill() 的算法只会得到:
[ (479-0)\times(9-0)=4311 ]
所以“编译通过”和“能刷纯色”都不等于刷新回调已经正确。本节应掌握接口对接的方向以及错误产生的原因;完整的逐像素修正留到课程后续内容。
无论底层采用阻塞 SPI 还是 DMA,刷新结束时都必须调用 lv_disp_flush_ready(disp_drv):
- 当前代码用阻塞式
HAL_SPI_Transmit(),应在全部像素发送结束后调用; - 若以后改成异步 DMA,则不能刚启动 DMA 就调用,而应在 DMA 传输完成后通知 LVGL。
三、时基配置
本节没有配置 LVGL 时基。视频结尾只预告后续还要修改实际配置并启用 demo;原笔记把这些写成了本课已完成事项,精校后明确课程边界。
截至第 210 课结束,准确状态是:
第 16 号工程已建立
→ 显示端口模板已启用
→ LCD 初始化已接入 disp_init()
→ 屏幕方向与 LVGL 分辨率已设为 480×320
→ 已选单个 10 行绘制缓冲
→ 已做阶段性的 LCD_Fill 刷新接线和纯色测试
→ LVGL tick 尚未配置
→ LVGL 初始化、任务处理与 demo 尚未完整运行
→ 多颜色区域刷新仍存在错误,后续课程继续修正
下一课才使用 TIM6 每 1 ms 调用 lv_tick_inc(1),并继续完成 LVGL 初始化与 demo 测试;这些内容不能提前算作本课成果。