LVGL显示代码移植(2)

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

211.3.10 LVGL显示代码移植(2)

/* tim.c:以下是 CubeMX 根据本课配置生成的 TIM6 基本定时器代码。 */
TIM_HandleTypeDef htim6;                               /* 定义 TIM6 的 HAL 句柄,供初始化、启动和中断代码共同使用。 */

void MX_TIM6_Init(void)                               /* 定义 TIM6 初始化函数。 */
{                                                     /* 开始 TIM6 初始化。 */
    TIM_MasterConfigTypeDef sMasterConfig = {0};      /* 建立并清零定时器主模式配置结构体。 */
    htim6.Instance = TIM6;                            /* 指定本句柄管理的硬件外设是 TIM6。 */
    htim6.Init.Prescaler = 71;                        /* 预分频寄存器写 71,实际分频系数为 71+1=72。 */
    htim6.Init.CounterMode = TIM_COUNTERMODE_UP;      /* 让计数器从 0 开始向上计数。 */
    htim6.Init.Period = 999;                          /* 自动重装载值写 999,每个周期实际计 999+1=1000 次。 */
    htim6.Init.AutoReloadPreload =                    /* 配置自动重装载寄存器的预装载功能。 */
        TIM_AUTORELOAD_PRELOAD_ENABLE;                /* 使新 ARR 值在更新事件时生效。 */
    if(HAL_TIM_Base_Init(&htim6) != HAL_OK)           /* 用上述参数初始化 TIM6,并检查是否成功。 */
    {                                                 /* 初始化失败时进入该分支。 */
        Error_Handler();                              /* 调用工程统一的错误处理函数。 */
    }                                                 /* 结束初始化结果判断。 */
    sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET; /* 本课不使用 TIM6 的主模式触发输出。 */
    sMasterConfig.MasterSlaveMode =                   /* 配置定时器主从模式。 */
        TIM_MASTERSLAVEMODE_DISABLE;                  /* 本课关闭主从同步功能。 */
    if(HAL_TIMEx_MasterConfigSynchronization(         /* 把主模式配置写入 TIM6。 */
           &htim6, &sMasterConfig) != HAL_OK)         /* 同时检查配置调用是否成功。 */
    {                                                 /* 配置失败时进入该分支。 */
        Error_Handler();                              /* 调用工程统一的错误处理函数。 */
    }                                                 /* 结束主模式配置结果判断。 */
}                                                     /* 结束 TIM6 初始化函数。 */

void HAL_TIM_Base_MspInit(                            /* 定义 HAL 定时器底层资源初始化回调。 */
    TIM_HandleTypeDef *tim_baseHandle)                 /* 接收当前正在初始化的定时器句柄。 */
{                                                     /* 开始底层资源初始化。 */
    if(tim_baseHandle->Instance == TIM6)              /* 只处理 TIM6 的时钟与中断资源。 */
    {                                                 /* 开始 TIM6 分支。 */
        __HAL_RCC_TIM6_CLK_ENABLE();                   /* 打开 TIM6 外设时钟。 */
        HAL_NVIC_SetPriority(TIM6_IRQn, 0, 0);         /* 把 TIM6 中断抢占/响应优先级都设为 0。 */
        HAL_NVIC_EnableIRQ(TIM6_IRQn);                 /* 在 NVIC 中使能 TIM6 全局中断。 */
    }                                                 /* 结束 TIM6 分支。 */
}                                                     /* 结束底层资源初始化回调。 */

/* stm32f1xx_it.c:CubeMX 生成的 TIM6 中断入口。 */
void TIM6_IRQHandler(void)                            /* 定义 TIM6 的中断服务函数。 */
{                                                     /* 开始 TIM6 中断处理。 */
    HAL_TIM_IRQHandler(&htim6);                       /* 交给 HAL 处理更新标志并分发周期回调。 */
}                                                     /* 结束 TIM6 中断处理。 */

/* lv_conf.h:本节启用 stress demo。 */
#define LV_USE_DEMO_STRESS 1                          /* 由 0 改成 1,让压力测试源码参与编译。 */

/* main.c:本节添加的 LVGL、显示端口与 demo 头文件。 */
#include "lvgl.h"                                    /* 声明 LVGL 初始化、tick 和任务处理接口。 */
#include "lv_port_disp_template.h"                   /* 声明课程显示端口初始化接口。 */
#include "lv_demo_stress.h"                          /* 声明 stress demo 的启动接口。 */

/* main() 中,以下调用位于 CubeMX 外设初始化之后。 */
HAL_TIM_Base_Start_IT(&htim6);                        /* 启动 TIM6 计数并打开更新中断。 */
lv_init();                                            /* 初始化 LVGL 的全局状态和内部子系统。 */
lv_port_disp_init();                                  /* 注册显示驱动;其中还会初始化 LCD。 */
lv_demo_stress();                                     /* 创建并启动 LVGL 的 stress 测试界面。 */

/* main() 的 while(1) 中,课程暂时注释原触摸测试代码并加入以下两行。 */
lv_timer_handler();                                   /* 执行已到期的 LVGL 定时任务、动画和刷新。 */
HAL_Delay(5);                                         /* 主循环至少等待 5 ms 后再处理下一轮任务。 */

/* main.c:HAL 在每次 TIM6 更新中断后回调该函数。 */
void HAL_TIM_PeriodElapsedCallback(                   /* 定义定时器周期到达回调。 */
    TIM_HandleTypeDef *htim)                           /* 接收本次产生更新事件的定时器句柄。 */
{                                                     /* 开始周期回调。 */
    if(htim->Instance == TIM6)                        /* 确认中断来源确实是 TIM6。 */
    {                                                 /* 开始 TIM6 分支。 */
        lv_tick_inc(1);                               /* 告诉 LVGL:又经过了 1 ms。 */
    }                                                 /* 结束 TIM6 分支。 */
}                                                     /* 结束周期回调。 */

本节源码边界: 上述代码是第 211 课实际增加或由 CubeMX 根据本课配置生成的关键业务片段。课程只是把已有 lv_demo_stress.c 加入工程并调用其公开接口,没有讲写 demo 内部实现,因此这里不抄入整份库源码。第 16 号配套目录后来又保存了第 212 课对 LCD 刷新函数的修改,不能把那些后续代码倒算成本课成果。

一、专题思维导图

上一课已经启用显示端口、配置 480×320 分辨率并建立 10 行绘制缓冲,但还缺少让 LVGL 真正持续工作的两个时间环节:

TIM6 每 1 ms 更新中断
└─ lv_tick_inc(1):累计 LVGL 的毫秒时间

main() 主循环约每 5 ms 执行
└─ lv_timer_handler():处理定时任务、动画、布局与屏幕刷新

二者职责不同。lv_tick_inc(1) 只告诉 LVGL “时间过去了 1 ms”,并不在中断里直接绘制界面;真正检查哪些任务到期并执行它们的是主循环中的 lv_timer_handler()。原转写反复出现的“四函数”实际是 tick 函数 lv_tick_inc(),不是某个名为“四”的函数。

本课还启用 LVGL 自带的 stress demo,用它验证初始化、时基、任务调度和显示刷新这条链路。最终动画能够变化,但颜色错误;因此本课达成的是“LVGL 已运行并开始刷新”,还不能记录为“显示完全正确”。

1.1 配置屏幕输出

思维导图把这一阶段称为“实际配置”。虽然原笔记沿用了“配置屏幕输出”标题,本节的新增重点实际是 LVGL 时基、任务处理和 demo;屏幕方向、缓冲区与刷新回调已经在第 210 课配置。

LVGL 当前配置中 LV_TICK_CUSTOM 为 0,所以库不会从 Arduino、操作系统等外部表达式自动获取时间,应用程序必须周期性调用 lv_tick_inc(x)。本课选择 TIM6 每 1 ms 调用一次 lv_tick_inc(1),参数与真实经过的毫秒数一致。

“心脏跳动”只是视频为了帮助理解使用的比喻。更准确地说,tick 是 LVGL 的单调递增时间基准,动画、长按、滚动、定时器和刷新周期都会用它判断已经过去多长时间。

1.2 配置定时器

在第 16 号工程的 .ioc 文件中配置 TIM6:

  1. 时钟源选择 Internal Clock;
  2. Prescaler 设置为 71;
  3. Counter Period 设置为 999;
  4. Auto-reload preload 设置为 Enable;
  5. 在 NVIC Settings 中使能 TIM6 全局中断;
  6. 重新生成代码。

原笔记中的 “MX imax”“AMRC”“帧 code”均为语音识别错误,正确名称分别是 STM32CubeMX、NVIC 和 Generate Code。

STM32F103ZET6 一共有 8 个定时器:TIM1、TIM8 是高级控制定时器,TIM2~TIM5 是通用定时器,TIM6、TIM7 是基本定时器。本课只需要周期性产生更新中断,不需要输入捕获、PWM 等通道功能,所以选择 TIM6。

“使用基本定时器”并不是让 CPU 不再消耗资源;它的意义是功能正好匹配需求,并把带通道的通用/高级定时器留给其他外设任务。

1.2.1 定时器频率及计数值配置

工程时钟树为:

HSE 8 MHz × PLL 9 = SYSCLK 72 MHz
HCLK = 72 MHz
APB1 = HCLK ÷ 2 = 36 MHz
APB1 分频不为 1,因此 APB1 定时器时钟 = 2 × PCLK1 = 72 MHz

TIM6 的更新频率公式为:

[ f_{update}=\frac{f_{TIM6}}{(PSC+1)(ARR+1)} ]

代入 PSC=71、ARR=999:

[ f_{counter}=\frac{72\text{ MHz}}{71+1}=1\text{ MHz} ]

计数器每计一次需要:

[ T_{counter}=\frac{1}{1\text{ MHz}}=1\ \mu s ]

一次更新周期包含 1000 个计数:

[ T_{update}=(999+1)\times1\ \mu s=1000\ \mu s=1\text{ ms} ]

所以 TIM6 每秒产生 1000 次更新事件。寄存器值写 71 和 999,是因为硬件的实际分频数和计数次数均要再加 1;原笔记中的“71 分撇”应为 预分频值 71。

TIM_AUTORELOAD_PRELOAD_ENABLE 的准确含义是启用 ARR 预装载:软件以后修改自动重装载值时,新值先进入缓冲寄存器,到更新事件再装入实际 ARR。计数器溢出后重新从 0 计数是定时器的周期工作机制,不是这个选项单独实现的。

1.2.2 中断配置

CubeMX 生成的底层代码完成三件事:打开 TIM6 外设时钟、把中断优先级设为 0、在 NVIC 中使能 TIM6_IRQn。中断到来后的调用链是:

TIM6 计数到 ARR,产生更新事件
→ CPU 进入 TIM6_IRQHandler()
→ HAL_TIM_IRQHandler(&htim6)
→ HAL_TIM_PeriodElapsedCallback(&htim6)
→ 判断 htim->Instance == TIM6
→ lv_tick_inc(1)

在回调中判断 htim->Instance 很重要:HAL 为多个定时器共用同一个周期回调接口,只有确认来源是 TIM6,才应累计本课的 1 ms LVGL tick。

1.3 打开定时器

MX_TIM6_Init() 只写入配置并准备外设,不会自动开始计数。必须在它执行之后调用:

HAL_TIM_Base_Start_IT(&htim6);

函数名中的含义为:

  • Base:使用基本计时功能;
  • Start:启动计数器;
  • IT:同时启用中断方式,而不是只启动不产生中断。

课程把启动语句放在 CubeMX 的 /* USER CODE BEGIN 2 */ 区域。这样以后重新生成代码时,用户代码受到保护;如果写到 CubeMX 管理区,下一次生成可能被覆盖。

本工程的实际顺序是 MX_TIM6_Init() → HAL_TIM_Base_Start_IT() → lv_init()。笔记应忠实记录该顺序,不能把“初始化定时器”和“启动定时器”混为一步。

1.4 配置回调函数

在 HAL_TIM_PeriodElapsedCallback() 的 TIM6 分支中调用:

lv_tick_inc(1);

参数 1 的单位是毫秒,表示从上次调用到现在经过了 1 ms。它必须与实际中断周期匹配:若定时器改成每 5 ms 中断一次,应传 5,而不是仍传 1。

中断回调应保持简短。本课只累计 tick,没有把 lv_timer_handler() 或复杂绘制放入 TIM6 中断,这是正确的职责划分:即使一次界面处理耗时较长,独立的 1 ms 中断仍能较准确地记录时间流逝。

1.5 配置判断定时器

本节在 main.c 中加入 #include "lvgl.h",使编译器能找到 lv_tick_inc()、lv_init() 和 lv_timer_handler() 的声明。然后用:

if(htim->Instance == TIM6)

过滤回调来源。这里判断的是句柄中的硬件实例指针,而不是比较 htim 与 &htim6 的文本名称。

完成 tick 部分后,视频编译得到 0 error,但 LVGL 源码仍产生若干 warning。课程暂时继续向下移植;“有 warning 可以先忽略”不应理解成所有警告都永远无关,正式工程仍应逐条确认警告是否影响行为。

1.6 配置显示Demo测试

本课选择的是 LVGL 官方 demos/stress 压力测试。它会连续创建、修改和删除多种控件,用来给渲染、定时器和内存管理施加较高负载;它不是专门进行“应力分析”的硬件工具。

完整接入分成两层:先让 LVGL 与显示端口进入主程序,再把 stress demo 自身加入构建。

1.6.1 配置Demo测试第一步

第一步,在 main.c 的用户头文件区域加入:

#include "lvgl.h"
#include "lv_port_disp_template.h"

lv_port_disp_template 是显示输出端口;同一 porting 目录中的 lv_port_indev_template 是输入设备端口。输入设备可以是触摸屏、按键或编码器。本课只验证显示,所以没有初始化输入端口,并暂时注释了原来的触摸扫描代码。

第二步,在 main() 的外设初始化完成后调用:

lv_init();
lv_port_disp_init();

顺序不能颠倒:先初始化 LVGL 内核,再注册显示驱动。上一课已经把 LCD_Init() 放进 lv_port_disp_init() 内部的 disp_init(),所以不再单独调用原来的 LCD_Init()。

1.6.2 配置Demo测试第三步

课程把 lv_init() 和 lv_port_disp_init() 放在原 LCD/触摸测试代码之前,并注释以下旧测试:

  • LCD_Init() 和黑屏纯色填充;
  • TP_Init();
  • 坐标文字显示;
  • 主循环中的触摸扫描、画线和坐标更新。

这是为了先隔离变量,只验证 LVGL 显示链路。它不表示触摸驱动被删除;后续课程还会建立 LVGL 输入端口。

初始化职责应理解为:

lv_init():初始化 LVGL 软件内核
lv_port_disp_init():初始化 LCD 并向 LVGL 注册显示驱动
lv_demo_stress():创建 demo 所需的对象和内部定时器

原笔记说“LVGL 初始化已经包含 LCD 初始化”不够准确:lv_init() 本身不知道这块 ST7796 屏幕;真正调用 LCD_Init() 的是课程自行适配的 lv_port_disp_init()。

1.6.3 配置Demo测试第四步

在 while(1) 中加入:

lv_timer_handler();
HAL_Delay(5);

lv_timer_handler() 会处理已经到期的 LVGL 内部定时器、动画、界面重绘和显示刷新。stress demo 自身创建的测试定时器周期为 50 ms,但仍需主循环不断调用 handler,才能在到期时真正运行。

HAL_Delay(5) 表示每轮在 handler 返回后至少延时 5 ms,因此实际两次调用间隔约为“本轮处理耗时 + 5 ms”,不是硬实时、精确等于 5 ms。对本课裸机演示足够,但不要把它与 TIM6 的精确 1 ms tick 混为一谈。

1.6.4 配置Demo测试第五步

这一部分把 stress demo 加入 Keil 构建、启用配置宏并在初始化阶段调用。原笔记把多个动作都归到“第五步”,下面按课程实际操作分别校正。

1.6.4.1 添加头文件路径

在 Options for Target → C/C++ → Include Paths 中新增:

..\midware\demos
..\midware\demos\stress

第一条让 demo 总头文件之间的包含关系可解析,第二条让 main.c 能找到 lv_demo_stress.h。应确认选择的是第 16 号工程内部的目录,不能误选第 14 号原始素材或第 15 号快照。

Include Path 只解决“去哪里寻找头文件”,不会把 lv_demo_stress.c 自动编译进工程;源码文件还要加入 Keil Group。

1.6.4.2 修改配置文件

打开 midware/lvgl/lv_conf.h,把约第 768 行的:

#define LV_USE_DEMO_STRESS 0

改为:

#define LV_USE_DEMO_STRESS 1

视频在 08:55 处口误为“把 1 改为 0”,随后实际操作和解释都明确是 0 改为 1。该宏控制 lv_demo_stress.c 中 #if LV_USE_DEMO_STRESS 包围的实现是否参与编译;只加入 .c 文件而不启用宏,接口实现仍会被条件编译排除。

正确文件名是 lv_conf.h,不是原笔记中的 LVGL_config.h。可在 Keil 中展开 lvgl.h 的包含关系定位它,也可以从工程目录直接打开。

1.6.4.3 新建Group并添加文件

在 Keil 工程中创建 demo 代码组。配套 .uvprojx 最终记录的组名是:

midware\demo

向其中只加入:

..\midware\demos\stress\lv_demo_stress.c

本节没有把 demos 下所有示例全部加入,也没有新增触摸输入组。磁盘文件、Include Path 和 Keil Group 各有不同作用:

项目作用
文件存在于磁盘课程源码确实可用
添加 Include Path编译器能找到 .h 声明
把 .c 加入 Group该实现文件成为编译单元
LV_USE_DEMO_STRESS == 1条件编译真正保留 demo 实现

四项缺少任意一项,都可能造成找不到头文件、链接不到函数或 demo 代码根本未启用。

1.6.4.4 添加头文件

在 main.c 中加入:

#include "lv_demo_stress.h"

这是工程本地头文件,所以课程使用双引号。它声明 lv_demo_stress() 和关闭 demo 的接口;头文件本身不会显示动画,也不能代替 .c 实现。

原笔记关于尖括号与双引号的泛化说明并非本课重点。二者的核心区别是编译器的搜索顺序由工具链决定;本工程的本地模块统一按课程源码使用双引号即可。

1.6.4.5 调用接口显示动画

在 lv_init()、lv_port_disp_init() 之后调用:

lv_demo_stress();

它负责创建 stress demo 的初始状态和内部 LVGL 定时器;真正让动画持续推进的仍是主循环中的 lv_timer_handler(),时间判断则依赖 TIM6 中断不断调用 lv_tick_inc(1)。

准确的运行依赖关系为:

lv_init()
→ lv_port_disp_init()
→ lv_demo_stress()
→ while(1) 周期调用 lv_timer_handler()

同时:TIM6 每 1 ms 调用 lv_tick_inc(1)

视频所谓“关闭 LCD 显示”是指注释旧的 LCD_Fill() 纯色测试,不是关闭 LCD 外设或背光;LCD 仍由 LVGL 显示端口使用。

1.6.4.6 编译并解决问题

首次编译时,LV_USE_DEMO_STRESS 看似已改为 1,实际 lv_conf.h 没有保存成功。Keil 提示 cannot write file,原因是课程文件当时不可写,而不是 demo 代码逻辑错误。

视频采用的处理过程是:

  1. 在资源管理器中定位 lv_conf.h;
  2. 复制出一个新副本;
  3. 关闭占用该文件的 Keil;
  4. 删除原来的不可写文件;
  5. 把副本改回 lv_conf.h;
  6. 重新打开工程,将 LV_USE_DEMO_STRESS 改为 1 并保存;
  7. 再次编译。

复制替换之所以有效,是新副本不再带有原文件的只读状态。处理此类问题时必须保证替换的仍是第 16 号工程内同一路径、同一文件名的 lv_conf.h,否则可能编辑了副本而编译器仍读取旧文件。

重新保存后工程编译无 error,烧录并复位后屏幕确实出现动态画面。这证明以下环节已基本接通:

TIM6 tick → LVGL 时间推进
主循环 handler → demo 定时任务与动画运行
显示端口 → 刷新数据到 LCD

但是画面颜色明显错误,不能把结果记为“移植成功”。本课停在问题复现处;原因是第 210 课的 LCD_Fill() 始终重复发送 color_p 指向的第一个像素,无法传输 LVGL 区域内连续的多颜色数据。第 212 课才分析并修正颜色填充接口,同时继续处理栈大小问题。

本课结束状态可归纳为:

TIM6 已配置为 1 ms 更新中断
→ lv_tick_inc(1) 已接入 HAL 周期回调
→ lv_init() 与 lv_port_disp_init() 已调用
→ lv_timer_handler() 约每 5 ms 执行
→ stress demo 已加入工程并启用
→ 工程编译、烧录成功,动态画面已运行
→ 颜色显示错误尚未解决
→ 触摸输入端口尚未移植