简介:这份资源是面向嵌入式开发者的STM32F103 SPI屏幕LVGL移植工程,适合已掌握C语言与基础外设驱动、希望快速搭建图形界面的中初级工程师。项目围绕SPI接口配置、屏幕驱动适配、LVGL库集成与帧缓冲区管理展开,覆盖SSD1306、ST7789等常见SPI屏的初始化、画点与刷新流程,并涉及触摸输入、系统时钟与调试输出等实际环节。压缩包共761个文件,约3.77MB,以189个c与186个h源码为核心,配合117个d依赖文件、112个o编译产物、57个png界面素材及mk、bin、md等工程与说明文件,目录结构完整,可直接导入Keil或STM32CubeIDE继续开发。目前已有4227人学习下载,读者可参考其SPI通信配置、屏幕驱动写法与LVGL移植思路,快速将GUI方案应用到自己的STM32F103项目中。1. 从一块“点不亮”的 SPI 屏说起:这套 LVGL 移植程序到底能干什么如果你手上有一块 STM32F103 最小系统板,外加一块 SPI 接口的 TFT 屏,大概率经历过这样的场景:接线照着卖家给的图连好了,代码烧进去,屏幕要么全白,要么花屏,要么干脆一点反应都没有。更让人头大的是,想上 LVGL 做点像样的 GUI,结果底层驱动和 LVGL 的对接怎么都调不通,lv_port_disp改来改去还是黑屏。这套 STM32F103 SPI 屏幕移植程序,就是冲着这个痛点来的——它把 SPI 屏的底层驱动和 LVGL 的显示接口一次性打通,拿到手改几个引脚定义就能跑。这套程序面向的是用 STM32F103 系列(C8T6、RCT6 这类常见型号)驱动 SPI 接口 TFT 屏的开发者,核心价值在于“可用”两个字:不是那种只跑个lv_demo_widgets就完事的演示工程,而是把 SPI 初始化、屏幕初始化序列、刷屏函数、LVGL 的flush_cb回调、时基lv_tick_inc这些容易翻车的环节都串起来了。适合两类人:一类是刚接触 LVGL、想找个能跑的参考工程照着改的新手;另一类是手上有现成 SPI 屏、想快速验证 LVGL 效果、不想从零啃驱动的熟手。下面就从工程结构开始,一层层拆开看它是怎么组织的。2. 工程结构与技术选型:为什么是 STM32F103 + 软件 SPI + LVGL2.1 目录结构:一个能直接编译的 Keil 工程长什么样拿到这套源码,第一件事是看目录。一个能直接编译下载的 STM32F103 工程,通常不会把所有文件堆在一个文件夹里,而是按“库文件 / 驱动 / 应用 / LVGL”分层。这套程序的目录大致是这样组织的:Project/ ├── CORE/ // 内核启动文件、core_cm3.h ├── STM32F10x_FWLib/ // 标准外设库 ├── HARDWARE/ │ ├── SPI/ // SPI 底层驱动 │ ├── LCD/ // 屏幕初始化与刷屏 │ └── TIMER/ // 时基,给 LVGL 提供 tick ├── LVGL/ │ ├── lvgl/ // LVGL 源码 │ └── lv_port/ // 移植层:disp、indev、tick ├── USER/ │ ├── main.c │ └── stm32f10x_it.c └── Project.uvprojx // Keil 工程文件这个分层的好处是:HARDWARE里放的是和硬件强相关的代码,换屏、换引脚只动这里;LVGL/lv_port是移植层,负责把 LVGL 和你的硬件对接;USER/main.c只写业务逻辑。很多人移植失败,就是因为把这三层混在一起改,改到最后自己都不知道哪层出了问题。提示:如果你的工程里没有单独的lv_port目录,LVGL 的显示接口通常写在lv_conf.h附近或者直接塞在main.c里,这种结构后期维护会很痛苦,建议按上面的方式拆开。2.2 技术选型:标准库 + 软件 SPI 的取舍这套程序用的是 STM32 标准外设库(StdPeriph Lib),不是 HAL 库。原因很直接:F103 这颗芯片用标准库的工程资料最多,编译体积小,跑 LVGL 这种吃 Flash 和 RAM 的东西,能省一点是一点。LVGL 版本一般是 8.x,因为 8.x 的 API 相对稳定,lv_port_disp的写法也成熟。SPI 这块,程序用的是软件 SPI(GPIO 模拟)还是硬件 SPI,取决于你的屏和接线。软件 SPI 的好处是引脚随便选,不受硬件 SPI 固定引脚的限制,缺点是刷屏速度慢一些。对于 128x160 或 240x320 这种分辨率的屏,软件 SPI 跑 LVGL 勉强够用,但如果要做动画或者刷大图,还是建议改成硬件 SPI + DMA。这套程序默认给的是软件 SPI 的写法,因为兼容性最好,你拿到手不用改硬件就能先点亮。选型项方案理由MCUSTM32F103C8T6/RCT6资料多、便宜、够用库标准外设库体积小、编译快SPI软件模拟(可改硬件)引脚灵活、兼容性强GUILVGL 8.xAPI 稳定、移植资料多时基TIM2 定时器 1ms给 lv_tick_inc 提供心跳2.3 关键宏定义:改这几个地方就能适配你的屏在LCD.h或SPI.h里,通常会有一组宏定义,控制引脚和屏幕参数。这是你拿到源码后第一个要改的地方:// LCD.h 引脚定义,根据你的实际接线修改 #define LCD_SCL_PIN GPIO_Pin_5 #define LCD_SCL_PORT GPIOA #define LCD_SDA_PIN GPIO_Pin_7 #define LCD_SDA_PORT GPIOA #define LCD_RES_PIN GPIO_Pin_4 #define LCD_RES_PORT GPIOA #define LCD_DC_PIN GPIO_Pin_3 #define LCD_DC_PORT GPIOA #define LCD_CS_PIN GPIO_Pin_2 #define LCD_CS_PORT GPIOA #define LCD_BLK_PIN GPIO_Pin_1 // 背光,没有可不接 #define LCD_BLK_PORT GPIOA // 屏幕分辨率,必须和你的屏一致 #define LCD_WIDTH 240 #define LCD_HEIGHT 320这几个宏是整份代码的“总开关”。SCL/SDA是 SPI 时钟和数据线,RES是复位,DC是数据/命令选择,CS是片选。改完引脚,还要确认LCD_WIDTH和LCD_HEIGHT和你的屏匹配——如果这里写错了,LVGL 刷出来的画面会错位或者只显示一部分。我一般会先把这几个宏单独拎出来放在文件最上面,改的时候一眼就能看到。3. 底层驱动拆解:SPI 时序、屏幕初始化和刷屏函数3.1 软件 SPI 的读写时序:一个字节怎么发出去软件 SPI 的核心就是手动翻转时钟线,在时钟的上升沿或下降沿把数据位送出去。这段代码看着简单,但时序错了屏幕就是不出图:// 软件 SPI 写一个字节,高位先出 void SPI_WriteByte(uint8_t data) { uint8_t i; for (i = 0; i < 8; i++) { LCD_SCL = 0; // 时钟拉低 if (data & 0x80) // 取最高位 LCD_SDA = 1; else LCD_SDA = 0; data <<= 1; // 左移,准备下一位 LCD_SCL = 1; // 时钟拉高,从机采样 } }逻辑说明:循环 8 次,每次先拉低 SCL,把当前最高位放到 SDA 上,然后拉高 SCL。从机在 SCL 上升沿采样 SDA 的电平。data <<= 1是为了让下一次循环取到次高位。参数data就是要发送的字节。这里没有做延时,因为 F103 跑 72MHz,GPIO 翻转速度足够慢,SPI 屏能跟上;如果你的屏比较挑时序,可以在LCD_SCL = 1后面加一个__NOP()。注意:软件 SPI 的时序和硬件 SPI 的 CPOL/CPHA 是对应的。上面这段是“空闲低电平、上升沿采样”,对应硬件 SPI 的 Mode 0。如果你的屏要求 Mode 3,就得把时钟极性反过来。3.2 写命令和写数据:DC 引脚的作用SPI 屏靠 DC 引脚区分这次发的是命令还是数据。DC 拉低是命令,拉高是数据。这个逻辑封装成两个函数:// 写命令 void LCD_WriteCmd(uint8_t cmd) { LCD_DC = 0; // DC 低电平表示命令 LCD_CS = 0; // 片选拉低 SPI_WriteByte(cmd); LCD_CS = 1; // 片选拉高 } // 写数据 void LCD_WriteData(uint8_t data) { LCD_DC = 1; // DC 高电平表示数据 LCD_CS = 0; SPI_WriteByte(data); LCD_CS = 1; }逻辑说明:LCD_DC决定这次传输的性质,LCD_CS是片选,低电平有效。每次传输完拉高 CS,是为了让屏幕知道一个字节传完了。有些屏不需要每次拉 CS,但拉一下更保险。参数cmd和data都是 8 位,如果是 16 位色数据,需要分两次发,先高字节后低字节。3.3 屏幕初始化序列:那一长串寄存器配置屏幕初始化就是往一堆寄存器里写值,告诉屏幕用多少分辨率、什么颜色格式、扫描方向怎么设。这段代码通常很长,但结构很固定:void LCD_Init(void) { LCD_RES = 0; // 硬件复位 delay_ms(10); LCD_RES = 1; delay_ms(120); LCD_WriteCmd(0x11); // Sleep out delay_ms(120); LCD_WriteCmd(0x36); // 扫描方向 LCD_WriteData(0x00); // 根据你的屏调整 LCD_WriteCmd(0x3A); // 像素格式 LCD_WriteData(0x05); // 16bit/pixel LCD_WriteCmd(0x29); // Display on // ... 其他初始化命令 }逻辑说明:0x11是退出睡眠,必须延时等屏幕稳定;0x36控制扫描方向,这个值决定了你显示的画面是正的还是反的、是横屏还是竖屏;0x3A设置颜色格式,0x05表示 16 位色(RGB565)。不同屏的初始化序列不一样,这份代码里的序列是针对常见 ST7789/ILI9341 类控制器的,如果你的屏型号不同,需要替换成卖家给的初始化代码。3.4 刷屏函数:LVGL 的 flush_cb 最终调用的就是它LVGL 渲染完一帧,会调用你注册的flush_cb,而flush_cb最终要调用刷屏函数把像素数据写到屏幕上。这套程序里的刷屏函数大致是这样:// 设置刷屏区域 void LCD_SetWindow(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2) { LCD_WriteCmd(0x2A); // 列地址设置 LCD_WriteData(x1 >> 8); LCD_WriteData(x1 & 0xFF); LCD_WriteData(x2 >> 8); LCD_WriteData(x2 & 0xFF); LCD_WriteCmd(0x2B); // 行地址设置 LCD_WriteData(y1 >> 8); LCD_WriteData(y1 & 0xFF); LCD_WriteData(y2 >> 8); LCD_WriteData(y2 & 0xFF); LCD_WriteCmd(0x2C); // 开始写显存 } // 填充颜色数据 void LCD_FillColor(uint16_t color, uint32_t len) { LCD_DC = 1; LCD_CS = 0; while (len--) { SPI_WriteByte(color >> 8); SPI_WriteByte(color & 0xFF); } LCD_CS = 1; }逻辑说明:LCD_SetWindow告诉屏幕接下来要刷哪块区域,0x2A和0x2B分别设置列和行的起止地址,0x2C是写显存命令。之后连续发送的像素数据就会按顺序填进这块区域。LCD_FillColor是填充单色,LVGL 刷图时会调用类似的函数把颜色缓冲区发出去。参数color是 RGB565 格式,len是像素个数。4. LVGL 移植层:disp、tick 和主循环怎么接4.1 lv_port_disp:显示接口的注册LVGL 不知道你的屏幕长什么样,它只认一个flush_cb回调。这个回调在lv_port_disp_init里注册:void lv_port_disp_init(void) { static lv_disp_draw_buf_t draw_buf; static lv_color_t buf_1[LCD_WIDTH * 40]; // 缓冲区,40 行 lv_disp_draw_buf_init(&draw_buf, buf_1, NULL, LCD_WIDTH * 40); static lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); disp_drv.hor_res = LCD_WIDTH; disp_drv.ver_res = LCD_HEIGHT; disp_drv.flush_cb = disp_flush; disp_drv.draw_buf = &draw_buf; lv_disp_drv_register(&disp_drv); }逻辑说明:buf_1是渲染缓冲区,大小是LCD_WIDTH * 40,也就是一次渲染 40 行。缓冲区越大,刷屏次数越少,但占 RAM 越多。F103 的 RAM 只有 20KB 左右,40 行是个比较稳妥的值。disp_drv.hor_res和ver_res必须和屏幕分辨率一致。flush_cb指向下面要讲的disp_flush。4.2 disp_flush:把 LVGL 的颜色数据写到屏幕static void disp_flush(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) { LCD_SetWindow(area->x1, area->y1, area->x2, area->y2); LCD_DC = 1; LCD_CS = 0; uint32_t len = (area->x2 - area->x1 + 1) * (area->y2 - area->y1 + 1); for (uint32_t i = 0; i < len; i++) { SPI_WriteByte(color_p[i].full >> 8); SPI_WriteByte(color_p[i].full & 0xFF); } LCD_CS = 1; lv_disp_flush_ready(disp_drv); // 必须调用,告诉 LVGL 刷完了 }逻辑说明:area是 LVGL 告诉你要刷新的区域,color_p是颜色数据指针。先调LCD_SetWindow设置区域,然后把color_p里的每个像素按 RGB565 格式发出去。color_p[i].full是 LVGL 的颜色联合体,取full就是 16 位色值。最后必须调lv_disp_flush_ready,否则 LVGL 会一直等,界面就卡死了。这是新手最容易漏的一步。4.3 tick 时基:LVGL 的心跳不能停LVGL 需要知道时间流逝,才能处理动画、超时这些逻辑。时基靠一个 1ms 的定时器中断来喂:// TIM2 中断服务函数,1ms 一次 void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { lv_tick_inc(1); // 告诉 LVGL 过了 1ms TIM_ClearITPendingBit(TIM2, TIM_IT_Update); } }逻辑说明:lv_tick_inc(1)每次加 1ms,LVGL 内部维护一个 tick 计数器。这个中断必须稳定触发,如果定时器配置错了,LVGL 的动画会变慢或者卡顿。TIM2 的初始化在TIMER.c里,配置成 1ms 周期,使能更新中断。4.4 主循环:lv_timer_handler 的调用节奏int main(void) { delay_init(); LCD_Init(); TIM2_Init(); // 时基 lv_init(); lv_port_disp_init(); // 显示接口 // 创建一个标签测试 lv_obj_t *label = lv_label_create(lv_scr_act()); lv_label_set_text(label, "Hello LVGL"); lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); while (1) { lv_timer_handler(); // LVGL 任务处理 delay_ms(5); // 不能太频繁,也不能太久 } }逻辑说明:lv_timer_handler是 LVGL 的主任务函数,负责处理界面刷新、事件、动画。调用间隔一般 5ms 左右,太频繁浪费 CPU,太久界面响应迟钝。delay_ms(5)是简单的延时,实际项目里可以用系统滴答或者空闲中断来替代。5. 避坑与排查:移植过程中最容易翻车的几个点5.1 屏幕全白或全黑,一点反应没有现象:上电后屏幕背光亮,但没有任何画面,或者全白。原因通常是初始化序列不对,或者复位时序没满足。解决:先确认LCD_RES复位时序,低电平至少保持 10ms,拉高后延时 120ms 再发命令。然后检查初始化命令里0x11(Sleep out)后面有没有延时 120ms,很多屏不等这个延时就直接发后续命令,会不响应。最后确认0x29(Display on)有没有发。5.2 画面花屏、错位、颜色不对现象:能显示,但颜色是反的,或者画面偏移。原因一般是0x36扫描方向寄存器的值不对,或者0x3A颜色格式设错了。解决:把0x36的值改成0x00、0x60、0xC0这几个常见值试一遍,直到画面方向正确。颜色反了就把 RGB 和 BGR 对调,通常改0x36的 bit3 就能解决。5.3 LVGL 界面卡死,只显示一半现象:界面显示了一部分就不动了。原因大概率是disp_flush里忘了调lv_disp_flush_ready。LVGL 在等这个信号,等不到就不会发下一帧。解决:检查disp_flush函数最后一行有没有lv_disp_flush_ready(disp_drv)。另外,如果用了 DMA,要在 DMA 传输完成中断里调这个函数,不能在disp_flush里直接调。5.4 编译报错 undefined reference tolv_xxx现象:编译时一堆 LVGL 函数找不到。原因通常是lv_conf.h没配置好,或者 LVGL 源文件没全部加入工程。解决:确认工程根目录有lv_conf.h,并且#if 1打开了配置。然后在 Keil 里把LVGL/lvgl/src下所有.c文件加入工程,别漏了lv_core、lv_draw、lv_widgets这些子目录。5.5 刷屏速度慢,动画一顿一顿的现象:静态画面正常,一有动画就卡。原因是软件 SPI 刷屏太慢,或者缓冲区太小导致刷屏次数太多。解决:优先把缓冲区从 40 行加到 80 行甚至全屏(如果 RAM 够)。如果还不够,把软件 SPI 改成硬件 SPI,SPI 时钟开到 18MHz 或 36MHz。再不行就上 DMA,让 CPU 去干别的,DMA 负责传像素。6. 二次开发:换屏、加触摸、上硬件 SPI 的改造思路拿到这套程序,二次开发无非三个方向:换一块不同的屏、加触摸、把软件 SPI 换成硬件 SPI + DMA。这三个改造我都踩过坑,下面说下具体怎么动。换屏是最常见的。假设你从 240x320 的 ILI9341 换成 128x160 的 ST7735,要改的地方就三处:LCD_WIDTH和LCD_HEIGHT改成 128 和 160;LCD_Init里的初始化序列换成 ST7735 的;LCD_SetWindow里的列行地址命令确认一下,ST7735 的0x2A和0x2B参数格式和 ILI9341 略有不同,有的需要加偏移。改完先别急着上 LVGL,写个LCD_FillColor(0xF800, 128*160)全屏填红,确认底层通了再跑 LVGL。加触摸的话,如果是电阻屏,通常用 XPT2046 这类 SPI 触摸芯片。你需要再写一个lv_port_indev,在里面注册read_cb,读触摸坐标然后调lv_indev_set_cursor或者直接喂给 LVGL 的点击事件。注意触摸坐标和显示坐标要对应,横屏竖屏切换时坐标要转置。上硬件 SPI + DMA 是提升刷屏速度最有效的手段。改造步骤:把SPI_WriteByte换成硬件 SPI 的SPI_I2S_SendData;把LCD_FillColor里的循环换成 DMA 传输;在 DMA 传输完成中断里调lv_disp_flush_ready。这里有个血泪经验:DMA 传输期间不能改LCD_CS和LCD_DC,否则数据会错位。我一般会在 DMA 启动前把 DC 拉高、CS 拉低,传输完成中断里再拉高 CS。// 硬件 SPI + DMA 刷屏的核心逻辑 void LCD_FillColor_DMA(uint16_t *color, uint32_t len) { LCD_DC = 1; LCD_CS = 0; DMA_Cmd(DMA1_Channel3, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel3, len * 2); // 16位色,字节数x2 DMA_Cmd(DMA1_Channel3, ENABLE); SPI_I2S_DMACmd(SPI1, SPI_I2S_DMAReq_Tx, ENABLE); } // DMA 传输完成中断 void DMA1_Channel3_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC3) != RESET) { while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY) == SET); LCD_CS = 1; lv_disp_flush_ready(&disp_drv); // 这里通知 LVGL DMA_ClearITPendingBit(DMA1_IT_TC3); } }逻辑说明:DMA_SetCurrDataCounter设置要传的字节数,16 位色一个像素占 2 字节,所以是len * 2。SPI_I2S_DMACmd打开 SPI 的 DMA 发送请求。传输完成中断里先等 SPI 总线空闲,再拉高 CS,最后调lv_disp_flush_ready。这套流程跑通后,240x320 的屏刷一帧能压到 10ms 以内,动画就顺了。从那以后我每次移植 LVGL,都强制先跑一个纯色填充测试,确认底层刷屏没问题,再上 LVGL 的 demo。这个习惯帮我省了至少一半的调试时间。希望帮到你。本文还有配套的精品资源,点击获取
简介:这份资源是面向嵌入式开发者的STM32F103 SPI屏幕LVGL移植工程,适合已掌握C语言与基础外设驱动、希望快速搭建图形界面的中初级工程师。项目围绕SPI接口配置、屏幕驱动适配、LVGL库集成与帧缓冲区管理展开,覆盖SSD1306、ST7789等常见SPI屏的初始化、画点与刷新流程,并涉及触摸输入、系统时钟与调试输出等实际环节。压缩包共761个文件,约3.77MB,以189个c与186个h源码为核心,配合117个d依赖文件、112个o编译产物、57个png界面素材及mk、bin、md等工程与说明文件,目录结构完整,可直接导入Keil或STM32CubeIDE继续开发。目前已有4227人学习下载,读者可参考其SPI通信配置、屏幕驱动写法与LVGL移植思路,快速将GUI方案应用到自己的STM32F103项目中。1. 从一块“点不亮”的 SPI 屏说起:这套 LVGL 移植程序到底能干什么如果你手上有一块 STM32F103 最小系统板,外加一块 SPI 接口的 TFT 屏,大概率经历过这样的场景:接线照着卖家给的图连好了,代码烧进去,屏幕要么全白,要么花屏,要么干脆一点反应都没有。更让人头大的是,想上 LVGL 做点像样的 GUI,结果底层驱动和 LVGL 的对接怎么都调不通,lv_port_disp改来改去还是黑屏。这套 STM32F103 SPI 屏幕移植程序,就是冲着这个痛点来的——它把 SPI 屏的底层驱动和 LVGL 的显示接口一次性打通,拿到手改几个引脚定义就能跑。这套程序面向的是用 STM32F103 系列(C8T6、RCT6 这类常见型号)驱动 SPI 接口 TFT 屏的开发者,核心价值在于“可用”两个字:不是那种只跑个lv_demo_widgets就完事的演示工程,而是把 SPI 初始化、屏幕初始化序列、刷屏函数、LVGL 的flush_cb回调、时基lv_tick_inc这些容易翻车的环节都串起来了。适合两类人:一类是刚接触 LVGL、想找个能跑的参考工程照着改的新手;另一类是手上有现成 SPI 屏、想快速验证 LVGL 效果、不想从零啃驱动的熟手。下面就从工程结构开始,一层层拆开看它是怎么组织的。2. 工程结构与技术选型:为什么是 STM32F103 + 软件 SPI + LVGL2.1 目录结构:一个能直接编译的 Keil 工程长什么样拿到这套源码,第一件事是看目录。一个能直接编译下载的 STM32F103 工程,通常不会把所有文件堆在一个文件夹里,而是按“库文件 / 驱动 / 应用 / LVGL”分层。这套程序的目录大致是这样组织的:Project/ ├── CORE/ // 内核启动文件、core_cm3.h ├── STM32F10x_FWLib/ // 标准外设库 ├── HARDWARE/ │ ├── SPI/ // SPI 底层驱动 │ ├── LCD/ // 屏幕初始化与刷屏 │ └── TIMER/ // 时基,给 LVGL 提供 tick ├── LVGL/ │ ├── lvgl/ // LVGL 源码 │ └── lv_port/ // 移植层:disp、indev、tick ├── USER/ │ ├── main.c │ └── stm32f10x_it.c └── Project.uvprojx // Keil 工程文件这个分层的好处是:HARDWARE里放的是和硬件强相关的代码,换屏、换引脚只动这里;LVGL/lv_port是移植层,负责把 LVGL 和你的硬件对接;USER/main.c只写业务逻辑。很多人移植失败,就是因为把这三层混在一起改,改到最后自己都不知道哪层出了问题。提示:如果你的工程里没有单独的lv_port目录,LVGL 的显示接口通常写在lv_conf.h附近或者直接塞在main.c里,这种结构后期维护会很痛苦,建议按上面的方式拆开。2.2 技术选型:标准库 + 软件 SPI 的取舍这套程序用的是 STM32 标准外设库(StdPeriph Lib),不是 HAL 库。原因很直接:F103 这颗芯片用标准库的工程资料最多,编译体积小,跑 LVGL 这种吃 Flash 和 RAM 的东西,能省一点是一点。LVGL 版本一般是 8.x,因为 8.x 的 API 相对稳定,lv_port_disp的写法也成熟。SPI 这块,程序用的是软件 SPI(GPIO 模拟)还是硬件 SPI,取决于你的屏和接线。软件 SPI 的好处是引脚随便选,不受硬件 SPI 固定引脚的限制,缺点是刷屏速度慢一些。对于 128x160 或 240x320 这种分辨率的屏,软件 SPI 跑 LVGL 勉强够用,但如果要做动画或者刷大图,还是建议改成硬件 SPI + DMA。这套程序默认给的是软件 SPI 的写法,因为兼容性最好,你拿到手不用改硬件就能先点亮。选型项方案理由MCUSTM32F103C8T6/RCT6资料多、便宜、够用库标准外设库体积小、编译快SPI软件模拟(可改硬件)引脚灵活、兼容性强GUILVGL 8.xAPI 稳定、移植资料多时基TIM2 定时器 1ms给 lv_tick_inc 提供心跳2.3 关键宏定义:改这几个地方就能适配你的屏在LCD.h或SPI.h里,通常会有一组宏定义,控制引脚和屏幕参数。这是你拿到源码后第一个要改的地方:// LCD.h 引脚定义,根据你的实际接线修改 #define LCD_SCL_PIN GPIO_Pin_5 #define LCD_SCL_PORT GPIOA #define LCD_SDA_PIN GPIO_Pin_7 #define LCD_SDA_PORT GPIOA #define LCD_RES_PIN GPIO_Pin_4 #define LCD_RES_PORT GPIOA #define LCD_DC_PIN GPIO_Pin_3 #define LCD_DC_PORT GPIOA #define LCD_CS_PIN GPIO_Pin_2 #define LCD_CS_PORT GPIOA #define LCD_BLK_PIN GPIO_Pin_1 // 背光,没有可不接 #define LCD_BLK_PORT GPIOA // 屏幕分辨率,必须和你的屏一致 #define LCD_WIDTH 240 #define LCD_HEIGHT 320这几个宏是整份代码的“总开关”。SCL/SDA是 SPI 时钟和数据线,RES是复位,DC是数据/命令选择,CS是片选。改完引脚,还要确认LCD_WIDTH和LCD_HEIGHT和你的屏匹配——如果这里写错了,LVGL 刷出来的画面会错位或者只显示一部分。我一般会先把这几个宏单独拎出来放在文件最上面,改的时候一眼就能看到。3. 底层驱动拆解:SPI 时序、屏幕初始化和刷屏函数3.1 软件 SPI 的读写时序:一个字节怎么发出去软件 SPI 的核心就是手动翻转时钟线,在时钟的上升沿或下降沿把数据位送出去。这段代码看着简单,但时序错了屏幕就是不出图:// 软件 SPI 写一个字节,高位先出 void SPI_WriteByte(uint8_t data) { uint8_t i; for (i = 0; i < 8; i++) { LCD_SCL = 0; // 时钟拉低 if (data & 0x80) // 取最高位 LCD_SDA = 1; else LCD_SDA = 0; data <<= 1; // 左移,准备下一位 LCD_SCL = 1; // 时钟拉高,从机采样 } }逻辑说明:循环 8 次,每次先拉低 SCL,把当前最高位放到 SDA 上,然后拉高 SCL。从机在 SCL 上升沿采样 SDA 的电平。data <<= 1是为了让下一次循环取到次高位。参数data就是要发送的字节。这里没有做延时,因为 F103 跑 72MHz,GPIO 翻转速度足够慢,SPI 屏能跟上;如果你的屏比较挑时序,可以在LCD_SCL = 1后面加一个__NOP()。注意:软件 SPI 的时序和硬件 SPI 的 CPOL/CPHA 是对应的。上面这段是“空闲低电平、上升沿采样”,对应硬件 SPI 的 Mode 0。如果你的屏要求 Mode 3,就得把时钟极性反过来。3.2 写命令和写数据:DC 引脚的作用SPI 屏靠 DC 引脚区分这次发的是命令还是数据。DC 拉低是命令,拉高是数据。这个逻辑封装成两个函数:// 写命令 void LCD_WriteCmd(uint8_t cmd) { LCD_DC = 0; // DC 低电平表示命令 LCD_CS = 0; // 片选拉低 SPI_WriteByte(cmd); LCD_CS = 1; // 片选拉高 } // 写数据 void LCD_WriteData(uint8_t data) { LCD_DC = 1; // DC 高电平表示数据 LCD_CS = 0; SPI_WriteByte(data); LCD_CS = 1; }逻辑说明:LCD_DC决定这次传输的性质,LCD_CS是片选,低电平有效。每次传输完拉高 CS,是为了让屏幕知道一个字节传完了。有些屏不需要每次拉 CS,但拉一下更保险。参数cmd和data都是 8 位,如果是 16 位色数据,需要分两次发,先高字节后低字节。3.3 屏幕初始化序列:那一长串寄存器配置屏幕初始化就是往一堆寄存器里写值,告诉屏幕用多少分辨率、什么颜色格式、扫描方向怎么设。这段代码通常很长,但结构很固定:void LCD_Init(void) { LCD_RES = 0; // 硬件复位 delay_ms(10); LCD_RES = 1; delay_ms(120); LCD_WriteCmd(0x11); // Sleep out delay_ms(120); LCD_WriteCmd(0x36); // 扫描方向 LCD_WriteData(0x00); // 根据你的屏调整 LCD_WriteCmd(0x3A); // 像素格式 LCD_WriteData(0x05); // 16bit/pixel LCD_WriteCmd(0x29); // Display on // ... 其他初始化命令 }逻辑说明:0x11是退出睡眠,必须延时等屏幕稳定;0x36控制扫描方向,这个值决定了你显示的画面是正的还是反的、是横屏还是竖屏;0x3A设置颜色格式,0x05表示 16 位色(RGB565)。不同屏的初始化序列不一样,这份代码里的序列是针对常见 ST7789/ILI9341 类控制器的,如果你的屏型号不同,需要替换成卖家给的初始化代码。3.4 刷屏函数:LVGL 的 flush_cb 最终调用的就是它LVGL 渲染完一帧,会调用你注册的flush_cb,而flush_cb最终要调用刷屏函数把像素数据写到屏幕上。这套程序里的刷屏函数大致是这样:// 设置刷屏区域 void LCD_SetWindow(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2) { LCD_WriteCmd(0x2A); // 列地址设置 LCD_WriteData(x1 >> 8); LCD_WriteData(x1 & 0xFF); LCD_WriteData(x2 >> 8); LCD_WriteData(x2 & 0xFF); LCD_WriteCmd(0x2B); // 行地址设置 LCD_WriteData(y1 >> 8); LCD_WriteData(y1 & 0xFF); LCD_WriteData(y2 >> 8); LCD_WriteData(y2 & 0xFF); LCD_WriteCmd(0x2C); // 开始写显存 } // 填充颜色数据 void LCD_FillColor(uint16_t color, uint32_t len) { LCD_DC = 1; LCD_CS = 0; while (len--) { SPI_WriteByte(color >> 8); SPI_WriteByte(color & 0xFF); } LCD_CS = 1; }逻辑说明:LCD_SetWindow告诉屏幕接下来要刷哪块区域,0x2A和0x2B分别设置列和行的起止地址,0x2C是写显存命令。之后连续发送的像素数据就会按顺序填进这块区域。LCD_FillColor是填充单色,LVGL 刷图时会调用类似的函数把颜色缓冲区发出去。参数color是 RGB565 格式,len是像素个数。4. LVGL 移植层:disp、tick 和主循环怎么接4.1 lv_port_disp:显示接口的注册LVGL 不知道你的屏幕长什么样,它只认一个flush_cb回调。这个回调在lv_port_disp_init里注册:void lv_port_disp_init(void) { static lv_disp_draw_buf_t draw_buf; static lv_color_t buf_1[LCD_WIDTH * 40]; // 缓冲区,40 行 lv_disp_draw_buf_init(&draw_buf, buf_1, NULL, LCD_WIDTH * 40); static lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); disp_drv.hor_res = LCD_WIDTH; disp_drv.ver_res = LCD_HEIGHT; disp_drv.flush_cb = disp_flush; disp_drv.draw_buf = &draw_buf; lv_disp_drv_register(&disp_drv); }逻辑说明:buf_1是渲染缓冲区,大小是LCD_WIDTH * 40,也就是一次渲染 40 行。缓冲区越大,刷屏次数越少,但占 RAM 越多。F103 的 RAM 只有 20KB 左右,40 行是个比较稳妥的值。disp_drv.hor_res和ver_res必须和屏幕分辨率一致。flush_cb指向下面要讲的disp_flush。4.2 disp_flush:把 LVGL 的颜色数据写到屏幕static void disp_flush(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) { LCD_SetWindow(area->x1, area->y1, area->x2, area->y2); LCD_DC = 1; LCD_CS = 0; uint32_t len = (area->x2 - area->x1 + 1) * (area->y2 - area->y1 + 1); for (uint32_t i = 0; i < len; i++) { SPI_WriteByte(color_p[i].full >> 8); SPI_WriteByte(color_p[i].full & 0xFF); } LCD_CS = 1; lv_disp_flush_ready(disp_drv); // 必须调用,告诉 LVGL 刷完了 }逻辑说明:area是 LVGL 告诉你要刷新的区域,color_p是颜色数据指针。先调LCD_SetWindow设置区域,然后把color_p里的每个像素按 RGB565 格式发出去。color_p[i].full是 LVGL 的颜色联合体,取full就是 16 位色值。最后必须调lv_disp_flush_ready,否则 LVGL 会一直等,界面就卡死了。这是新手最容易漏的一步。4.3 tick 时基:LVGL 的心跳不能停LVGL 需要知道时间流逝,才能处理动画、超时这些逻辑。时基靠一个 1ms 的定时器中断来喂:// TIM2 中断服务函数,1ms 一次 void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { lv_tick_inc(1); // 告诉 LVGL 过了 1ms TIM_ClearITPendingBit(TIM2, TIM_IT_Update); } }逻辑说明:lv_tick_inc(1)每次加 1ms,LVGL 内部维护一个 tick 计数器。这个中断必须稳定触发,如果定时器配置错了,LVGL 的动画会变慢或者卡顿。TIM2 的初始化在TIMER.c里,配置成 1ms 周期,使能更新中断。4.4 主循环:lv_timer_handler 的调用节奏int main(void) { delay_init(); LCD_Init(); TIM2_Init(); // 时基 lv_init(); lv_port_disp_init(); // 显示接口 // 创建一个标签测试 lv_obj_t *label = lv_label_create(lv_scr_act()); lv_label_set_text(label, "Hello LVGL"); lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); while (1) { lv_timer_handler(); // LVGL 任务处理 delay_ms(5); // 不能太频繁,也不能太久 } }逻辑说明:lv_timer_handler是 LVGL 的主任务函数,负责处理界面刷新、事件、动画。调用间隔一般 5ms 左右,太频繁浪费 CPU,太久界面响应迟钝。delay_ms(5)是简单的延时,实际项目里可以用系统滴答或者空闲中断来替代。5. 避坑与排查:移植过程中最容易翻车的几个点5.1 屏幕全白或全黑,一点反应没有现象:上电后屏幕背光亮,但没有任何画面,或者全白。原因通常是初始化序列不对,或者复位时序没满足。解决:先确认LCD_RES复位时序,低电平至少保持 10ms,拉高后延时 120ms 再发命令。然后检查初始化命令里0x11(Sleep out)后面有没有延时 120ms,很多屏不等这个延时就直接发后续命令,会不响应。最后确认0x29(Display on)有没有发。5.2 画面花屏、错位、颜色不对现象:能显示,但颜色是反的,或者画面偏移。原因一般是0x36扫描方向寄存器的值不对,或者0x3A颜色格式设错了。解决:把0x36的值改成0x00、0x60、0xC0这几个常见值试一遍,直到画面方向正确。颜色反了就把 RGB 和 BGR 对调,通常改0x36的 bit3 就能解决。5.3 LVGL 界面卡死,只显示一半现象:界面显示了一部分就不动了。原因大概率是disp_flush里忘了调lv_disp_flush_ready。LVGL 在等这个信号,等不到就不会发下一帧。解决:检查disp_flush函数最后一行有没有lv_disp_flush_ready(disp_drv)。另外,如果用了 DMA,要在 DMA 传输完成中断里调这个函数,不能在disp_flush里直接调。5.4 编译报错 undefined reference tolv_xxx现象:编译时一堆 LVGL 函数找不到。原因通常是lv_conf.h没配置好,或者 LVGL 源文件没全部加入工程。解决:确认工程根目录有lv_conf.h,并且#if 1打开了配置。然后在 Keil 里把LVGL/lvgl/src下所有.c文件加入工程,别漏了lv_core、lv_draw、lv_widgets这些子目录。5.5 刷屏速度慢,动画一顿一顿的现象:静态画面正常,一有动画就卡。原因是软件 SPI 刷屏太慢,或者缓冲区太小导致刷屏次数太多。解决:优先把缓冲区从 40 行加到 80 行甚至全屏(如果 RAM 够)。如果还不够,把软件 SPI 改成硬件 SPI,SPI 时钟开到 18MHz 或 36MHz。再不行就上 DMA,让 CPU 去干别的,DMA 负责传像素。6. 二次开发:换屏、加触摸、上硬件 SPI 的改造思路拿到这套程序,二次开发无非三个方向:换一块不同的屏、加触摸、把软件 SPI 换成硬件 SPI + DMA。这三个改造我都踩过坑,下面说下具体怎么动。换屏是最常见的。假设你从 240x320 的 ILI9341 换成 128x160 的 ST7735,要改的地方就三处:LCD_WIDTH和LCD_HEIGHT改成 128 和 160;LCD_Init里的初始化序列换成 ST7735 的;LCD_SetWindow里的列行地址命令确认一下,ST7735 的0x2A和0x2B参数格式和 ILI9341 略有不同,有的需要加偏移。改完先别急着上 LVGL,写个LCD_FillColor(0xF800, 128*160)全屏填红,确认底层通了再跑 LVGL。加触摸的话,如果是电阻屏,通常用 XPT2046 这类 SPI 触摸芯片。你需要再写一个lv_port_indev,在里面注册read_cb,读触摸坐标然后调lv_indev_set_cursor或者直接喂给 LVGL 的点击事件。注意触摸坐标和显示坐标要对应,横屏竖屏切换时坐标要转置。上硬件 SPI + DMA 是提升刷屏速度最有效的手段。改造步骤:把SPI_WriteByte换成硬件 SPI 的SPI_I2S_SendData;把LCD_FillColor里的循环换成 DMA 传输;在 DMA 传输完成中断里调lv_disp_flush_ready。这里有个血泪经验:DMA 传输期间不能改LCD_CS和LCD_DC,否则数据会错位。我一般会在 DMA 启动前把 DC 拉高、CS 拉低,传输完成中断里再拉高 CS。// 硬件 SPI + DMA 刷屏的核心逻辑 void LCD_FillColor_DMA(uint16_t *color, uint32_t len) { LCD_DC = 1; LCD_CS = 0; DMA_Cmd(DMA1_Channel3, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel3, len * 2); // 16位色,字节数x2 DMA_Cmd(DMA1_Channel3, ENABLE); SPI_I2S_DMACmd(SPI1, SPI_I2S_DMAReq_Tx, ENABLE); } // DMA 传输完成中断 void DMA1_Channel3_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC3) != RESET) { while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY) == SET); LCD_CS = 1; lv_disp_flush_ready(&disp_drv); // 这里通知 LVGL DMA_ClearITPendingBit(DMA1_IT_TC3); } }逻辑说明:DMA_SetCurrDataCounter设置要传的字节数,16 位色一个像素占 2 字节,所以是len * 2。SPI_I2S_DMACmd打开 SPI 的 DMA 发送请求。传输完成中断里先等 SPI 总线空闲,再拉高 CS,最后调lv_disp_flush_ready。这套流程跑通后,240x320 的屏刷一帧能压到 10ms 以内,动画就顺了。从那以后我每次移植 LVGL,都强制先跑一个纯色填充测试,确认底层刷屏没问题,再上 LVGL 的 demo。这个习惯帮我省了至少一半的调试时间。希望帮到你。本文还有配套的精品资源,点击获取