
先说结论STM32F103裸机跑LVGL 8.2、带一块7寸800x480的屏幕这事能成而且没有想象中那么恐怖。网上搜F103LVGL十个帖子有八个在劝退理由不外乎内存不够、没有FPU、刷新跟不上。这些理由在F407上成立放到F103上要分情况看——LVGL从v7到v8一直在为低资源设备做优化分块渲染加脏矩形刷新机制让全帧缓冲不再是必需品。下面这套流程是我这次实际移植时走通的完整路线包括源码裁剪、lv_conf.h配置、显示和触摸回调对接还有移植过程中踩过的几个坑。适合手上正好有F103和一块7寸屏、想彻底换掉老式控件UI的人参考也适合被旧教程的v7/v8 API差异搞到头疼的初学者。1. 移植前的资源账72MHz主频和64KB SRAM够不够1.1 内存账为什么全帧缓冲直接出局但LVGL依然能跑7寸屏分辨率通常是800x480RGB565一个像素占2字节一帧全画面需要768000字节约750KB。STM32F103ZET6的SRAM只有64KBC8T6只有20KB。凡是上来就在代码里定义一个uint16_t framebuffer[800 * 480]的编译能过运行必挂。这是很多人在F103上移植失败的第一道墙不是LVGL不行是方案选错了。LVGL解决这个问题的思路是分块渲染。它内部维护一个draw buffer大小远小于全帧缓冲需要刷新时LVGL把整个需要重绘的区域切成一堆小块一块一块地完成像素计算再通过flush_cb把这块像素交给LCD。对一块800宽的屏来说如果给draw buffer分配16KB大约能存800x10行像素也就是一次只画10行。渲染完这10行就马上丢给屏幕控制器接着画下一批10行。屏幕控制器内部自带GRAM的型号比如RA8875、SSD1963这类驱动IC带数百KB显存MCU其实只负责把小块数据传过去真正的一帧画面存在屏端。这也解释了为什么F103这种小内存MCU跑大屏UI理论上是可行的。内存具体怎么分以ZET6的64KB SRAM为例两个draw buffer各16KB加起来32KB丢给刷新用LVGL对象内存池LV_MEM_SIZE开24KB用来存放控件、样式、事件等数据结构剩下8KB左右留给中断、局部变量和栈。如果屏幕是SPI接口且没有足够吞吐率可以退化成单draw buffer省16KB。这个预算很紧但做几个页面加若干控件够用。1.2 刷新账并口和SPI屏的差距不是一点半点7寸屏模块的接口五花八门但对F103来说接口基本决定了UI能做到多顺滑。16位并口8080时序通过FSMC外设把LCD映射成一块外部SRAM写一个像素就是一次普通总线写。72MHz下FSMC总线带宽远大于UI刷新需求只要规划好LCD窗口一屏画面理论写入时间在二三十毫秒量级跑LVGL的过渡动画可以接受。8位并口写入一个像素需要两次总线写速度下降一半用是能用画面略慢。SPI接口SPI1最高18Mbit/s换算约2.25MB/s传完整屏768KB需要340ms以上。也就是说刷新一整个画面要接近0.35秒拖动画、滑菜单这类操作基本没法看。SPI屏更适合做静态界面或者在屏厂驱动IC自带局部刷新指令时针对小块区域更新。所以要玩LVGLF1037寸屏我的建议很直接手上是并口屏就用起来SPI屏尽量换块屏再折腾。网传“F103带不动7寸屏”的说法很多是用SPI慢屏试出来的错觉换成FSMC并口后体验完全不同。1.3 芯片型号ZET6、RCT6和C8T6的差别移植到一半才会暴露资源账算完具体芯片选型也有讲究。F103系列SRAM容量差异很大STM32F103ZET664KB SRAM、512KB Flash做这个方案最合适。STM32F103RCT648KB SRAM、256KB Flash凑合能用draw buffer压到每块12KBLV_MEM_SIZE给16KB也能转起来。STM32F103C8T620KB SRAM、64KB Flash不推荐。draw buffer和LVGL内存池根本分不过来加上LVGL 8.2编译出来基础体积就有小几十KBFlash也很紧张。这几个型号的工程代码基本通用差别主要在启动文件的堆栈大小和linker脚本的内存范围。如果手上只有C8T6我的建议是降低分辨率换4.3寸屏或2.4寸屏UI一样能玩别硬上7寸。2. LVGL 8.2源码整理与lv_conf.h配置后面能不能跑起来全看这里2.1 源码目录精简三个该拿掉的文件夹和其他注意事项下载LVGL 8.2源码后解压出来会看到demos、examples、src几个主要目录。裸机移植时编译对象只需要srcdemos和examples里的东西统统拿掉它们主要是给你参考和演示用的加进来会拖慢编译并且白白消耗Flash。很多CubeMX工程模板默认会把整个lvgl文件夹全编译等烧录后才发现Flash不够排查半天最后发现是demo占了三分之一。src目录内部不建议按文件夹手动删文件因为LVGL各模块之间依赖比较复杂删错一个文件编译报错会让你怀疑人生。正确做法是保留整个src然后通过lv_conf.h里的开关把用不到的组件、主题、字体关掉。例如不做图表就不开LV_USE_CHART不用下拉框就关LV_USE_DROPDOWN每关一个组件都能省下对应的Flash和内存占用。还有一点LVGL 8.2要求工程能编译那些带lv_前缀的一堆源文件。用Keil时建议直接把src下所有.c文件加入工程组文件数量很多但值得编译时间也就几十秒。官方提供的lvgl.mk是给Makefile工程用的也能自动收集文件如果用Makefile不必手动罗列。2.2 lv_conf.h里必改的宏颜色深度、内存池、缓冲区挂钩把lvgl根目录下的lv_conf_template.h复制出来改名为lv_conf.h并确保它能被所有源文件包含。这个文件是整个移植最关键的一环几个宏直接决定系统能不能跑#define LV_COLOR_DEPTH 16 #define LV_COLOR_16_SWAP 1 /* 后续根据屏幕实测再调 */ #define LV_MEM_CUSTOM 0 /* 使用LVGL内部内存分配器 */ #define LV_MEM_SIZE (24 * 1024) #define LV_TICK_CUSTOM 0 #define LV_DISP_DEF_REFR_PERIOD 30 #define LV_FONT_MONTSERRAT_14 1 #define LV_FONT_MONTSERRAT_20 1LV_COLOR_DEPTH必须跟LCD面板实际格式一致绝大多数7寸屏是RGB565也就是16位色。如果板子配的是RGB888的屏修改成24后draw buffer占用会暴涨50%内存预算要重新算。LV_COLOR_16_SWAP是个很容易踩的坑。它是用来调整像素字节序的LVGL在内存中按小端方式存放16位色但不同LCD控制器对RGB两个字节的接收顺序有差异。现象就是界面“颜色偏蓝”或“偏紫”把这项在0和1之间切换有时候就能解决。LV_MEM_SIZE直接决定能创建多少控件。24KB听起来不多但LVGL内部是按对象拆分的一个普通按钮几十字节一页十几二十个控件完全能放下。如果创建控件后界面没反应、复位循环先怀疑这个值太小。2.3 别急着用malloc裸机环境下内存分配有更稳的选择LV_MEM_CUSTOM这个宏是很多人会忽略的。它设为1时LVGL会调用malloc/free设为0时LVGL使用内置的lv_mem_alloc/lv_mem_free内存池就是2.2里那个LV_MEM_SIZE定义的静态数组。裸机上直接用标准malloc我踩过坑。Keil的MicroLIB或者默认堆分配器在长时间运行后容易产生碎片而且标准malloc的堆大小由启动文件里的Heap_Size决定跟linker脚本又耦合排查起来很麻烦。LVGL内置分配器是为嵌入式场景设计的内部有块合并机制更适合裸机。如果确实想用自己的分配逻辑比如外部PSRAM才把LV_MEM_CUSTOM设为1并让LV_MEM_CUSTOM_INCLUDE包含对应的头文件。F103没有外部内存控制器老老实实用内置分配器最省心。主循环里调试时还可以用lv_mem_monitor函数看内存占用判断控件创建多了还是泄漏了后面避坑部分会再提。3. flush_cb显示回调像素如何从LVGL画布跑到LCD上3.1 FSMC并口屏把7寸屏映射成一块外部SRAM16位并口8080屏接到F103ZET6最标准的方式是用FSMC外设。FSMC本是用来扩展NOR Flash和SRAM的但LCD控制器本质上也是“读寄存器/写数据”的并行设备于是可以把LCD当成一块外部SRAM挂在Bank1上。我们只要对某个内存地址写一个值FSMC就会自动产生时序把数据送到LCD数据总线。连线思路是NE1负责片选NOE接RD引脚NWE接WR引脚地址线A16接LCD寄存器选择RS16根数据线D0-D15接LCD的DB0-DB15。FSMC对应的GPIO要设置为50MHz复用推挽输出同时打开FSMC外设时钟GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | ... | GPIO_Pin_15; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_Init(GPIOD, GPIO_InitStructure);关键在CMD和DATA的地址区分。LCD控制器通过RS引脚高低来区分当前是写命令还是写数据。RS接到FSMC的A16在16位数据模式下对应地址偏移是0x20000。源码里可以这样定义#define LCD_BASE ((uint32_t)0x60000000) #define LCD_REG (*(volatile uint16_t *)(LCD_BASE)) #define LCD_RAM (*(volatile uint16_t *)(LCD_BASE | 0x00020000))先给LCD_REG写命令再给LCD_RAM写数据。很多例程会把第二个地址写成0x60010000这是把A16偏移算错了结果就是LCD控制器收到的命令一塌糊涂屏幕初始化失败白屏。如果RS接的是A18偏移就是0x80000务必对着自己的原理图算偏移别照抄。FSMC时序初始化时地址建立时间给1个HCLK、数据建立时间给2个HCLK通常就能稳定驱动SSD1963这类控制器FSMC_NORSRAMInitStructure.FSMC_AddressSetupTime 1; FSMC_NORSRAMInitStructure.FSMC_DataSetupTime 2;如果屏幕出现闪烁或偶尔花屏把DataSetupTime加到3往往就好了。这也是个经验值具体参照屏幕控制器datasheet的读写周期要求调整。3.2 flush_cb完整写法设置窗口再连续写像素LVGL要刷新一块区域时会调用注册的flush_cb给一个area区域左上右下坐标和一个color_p像素指针。我们收到这套数据后要先告诉LCD控制器一个窗口范围然后连续写入像素。static void lcd_fill_area(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, const uint16_t *pixels) { LCD_REG 0x2A; /* 列地址设置 */ LCD_RAM x1 8; LCD_RAM x1 0xFF; LCD_RAM x2 8; LCD_RAM x2 0xFF; LCD_REG 0x2B; /* 行地址设置 */ LCD_RAM y1 8; LCD_RAM y1 0xFF; LCD_RAM y2 8; LCD_RAM y2 0xFF; LCD_REG 0x2C; /* 存储器写 */ uint32_t len (uint32_t)(x2 - x1 1) * (y2 - y1 1); for (uint32_t i 0; i len; i) { LCD_RAM pixels[i]; } }注意一定要先设置窗口再连续写数据别按整个屏幕的线性地址去推算像素位置。不同LCD控制器具体命令号有差异RA8875的窗口设置命令和SSD1963不一样但思路相同。flush回调本身不复杂static void disp_flush_cb(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) { lcd_fill_area(area-x1, area-y1, area-x2, area-y2, (uint16_t *)color_p); lv_disp_flush_ready(disp_drv); }FSMC循环写是同步操作数据写完后就能通知LVGL缓冲区可以复用了。这里有一个很多教程没讲透的细节在flush_cb里调lv_disp_flush_ready的位置本质上表示“这块区域的数据我已经拿走了”。FSMC写法简单直接写完立刻调用没问题DMA后台传输则不行必须等传输完成中断里再调用不然下一帧的像素可能覆盖当前正在传输的缓冲区画面会出现撕裂。3.3 SPI屏的DMA方案与“等传输完成再发ready”问题如果手里的7寸屏是SPI接口建议先权衡刷新速度非要移植的话DMA是必须的否则每像素都走CPU等待收发刷新直接没法看。SPI屏的flush思路是先向LCD控制器发送窗口设置命令再启动DMA把color_p里的像素数据转成SPI输出。关键点是lv_disp_flush_ready不能在启动DMA后立刻调用要在DMA传输完成中断里调用void DMA1_Channel3_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC3)) { DMA_ClearITPendingBit(DMA1_IT_TC3); lv_disp_flush_ready(disp_drv); } }这里还有一个对齐问题。STM32F103的DMA对源地址有对齐要求color_p是LVGL从draw buffer里切出来的地址是随机偏移可能不满足4字节或2字节对齐。实测中不放心的话可以在自己定义的flush函数里先把像素拷贝到一个静态对齐数组里再启动DMA。注意数组要带对齐属性static uint16_t dma_buf[800 * 10] __attribute__((aligned(4)));不过拷贝也费时间若能确保draw buffer起始地址和每次切块的偏移都对齐就能省掉这步。刚上手时我建议先加拷贝稳定性比重那一点速度重要。4. SysTick心跳与XPT2046触摸让UI活过来的两个底层驱动4.1 心跳时基SysTick被HAL库占用后怎么办LVGL所有动画、定时器、控件状态刷新都依赖一个毫秒级时基裸机上就是把lv_tick_inc(1)按固定周期喂给它。最简单方案是SysTick 1ms中断但如果你用CubeMX的HAL库生成工程SysTick已经被HAL库用来做HAL_GetTick的时基此时直接覆盖中断函数会引发HAL_Delay死循环。两个处理办法。第一在SysTick_Handler里同时调用HAL_IncTick()和lv_tick_inc(1)void SysTick_Handler(void) { HAL_IncTick(); lv_tick_inc(1); }这个办法代码少但SysTick里干了两件事以后想改时基会牵一发动全身。第二用TIM2做1ms定时中断单独把时基交给LVGLvoid TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update)) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); lv_tick_inc(1); } }我推荐第二种。TIM2和HAL互不干扰排查问题的时候逻辑清晰。lv_conf.h里LV_TICK_CUSTOM保持0LVGL就会用lv_tick_inc这个接口。主循环结构也需要注意while (1) { lv_timer_handler(); delay_ms(5); }lv_timer_handler的调用间隔决定了LVGL的刷新速率不能让它被其他耗时任务阻塞太久。如果主循环里还有别的业务逻辑尽量保证一次循环的耗时在10ms以内。4.2 XPT2046读取代码从SPI时序到触摸点坐标7寸电阻触摸屏最常见的触摸控制器是XPT2046它用SPI接口输出12位坐标数据。读取流程是拉低CS发送一个控制字节然后把SPI时钟继续打两轮从返回数据中拼出坐标。控制字节里0xD0表示读X坐标0x90表示读Y坐标。一个可用的读取函数static uint16_t tp_read_channel(uint8_t cmd) { uint16_t value 0; TP_CS_LOW(); spi_read_write(cmd); uint8_t b0 spi_read_write(0x00); uint8_t b1 spi_read_write(0x00); TP_CS_HIGH(); value ((uint16_t)b0 8 | b1) 3; return value; }右移3位是把16位拼接结果里的多余低位丢掉保留12位有效数据。SPI频率建议控制在1MHz左右XPT2046对时序要求不高但线长干扰多时降速更稳。裸机上读取触摸可以放在LVGL的indev回调里被动读取也可以用定时器主动扫描后保存全局变量。LVGL的indev回调本身就是周期调用直接在回调里读就行。电阻屏有个物理特性按下瞬间的采样值不稳定建议连续读三到五次取中间值或者做平均不然会出现点击跳点。4.3 坐标映射与旋转触摸错位的三个高频原因触摸回调用来喂给LVGL的是屏幕逻辑坐标而屏幕上应当是控件所在的像素坐标。XPT2046输出的是0-4095范围内的12位原始值背后是触摸面板的物理坐标系跟屏幕像素坐标系可能有镜像、翻转、缩放三种关系。最简单的一种映射是线性缩放data-point.x (uint16_t)((uint32_t)raw_x * 800 / 4096);>x 800 - x; /* 水平镜像 */ y 480 - y; /* 垂直镜像 */第二个是xy互换。有些7寸屏触摸面板的X方向对应LCD的Y方向需要交换x和y再做缩放。第三个是LVGL的显示旋转和触摸旋转没有同步。LVGL 8.2的disp_drv结构体里有rotated字段旋转屏幕后触摸回调收到的坐标系仍然是原始物理坐标如果只旋转显示不旋转触摸点击必然错位。我踩坑后的做法是尽量不依赖LVGL的rotated字段而是在自己的触摸映射函数里统一完成旋转和镜像这样逻辑最简单出错也容易排查。5. 实测避坑记录从白屏到触摸偏位的完整排查过程5.1 白屏不是LVGL没跑是FSMC写操作压根没进LCD白屏是7寸屏移植时遇到最多的现象大部分人第一反应是LVGL初始化失败其实九成是LCD通路本身没打通。我的排查顺序是这样的先在main函数里、任何LVGL代码之前单独跑一段LCD驱动测试——直接向LCD_REG写0x2C命令然后连续写1000个0xF800红色和0x07E0绿色。如果屏幕有红绿交替显示说明FSMC映射、LCD控制器初始化、GPIO配置都正确问题在LVGL侧如果屏一直白着问题在FSMC或LCD驱动跟LVGL无关。白屏时重点检查这四处FSMC时钟是否开启GPIO是否配置为AF_PPCMD/DATA地址偏移是否按RS所接地址线正确计算LCD控制器初始化时序是否够宽松尤其是DataSetupTime太小有没有把LCD的复位脚拉高并延时足够长有些屏需要50ms以上。按这个顺序排查基本能定位到根因。5.2 颜色发蓝或偏红RGB与BGR的字节序问题UI跑起来了但颜色整体不对比如红色按钮变成蓝色、肤色发紫。这个现象百分之八十是RGB565字节序不匹配。LVGL内部画像素时用的是16位整数值内存里低字节和高字节的排列顺序是固定的但LCD控制器端的串转并逻辑可能期望高字节在前或低字节在前。这时去lv_conf.h里切换LV_COLOR_16_SWAP的值0改1或1改0问题通常立刻消失。如果切换后颜色还是偏再去看LCD控制器的RGB顺序寄存器。RA8875和SSD1963都有类似“RGB/BGR”的模式位例如设置0x36寄存器可以让内存中的RGB顺序最终映射到屏幕像素。我当时同时动了lv_conf.h和LCD寄存器才把颜色调正而且两个地方的效果是叠加的所以调试时要一次只改一个变量别两个一起改。另外要注意draw buffer里的颜色位深必须与LV_COLOR_DEPTH一致。如果源工程是8位屏改过来的LV_COLOR_DEPTH还是8会出现颜色惨不忍睹的块状渐变。直接确认这个宏在16就行。5.3 运行几分钟后HardFault内存碎片与栈溢出界面能显示但运行一段时间后突然卡死进HardFault中断这是裸机移植的经典难题。原因通常是以下三个之一栈溢出。LVGL绘制过程中会嵌套调用大量函数尤其带样式、带圆弧的控件栈使用量比想象中大。启动文件里Stack_Size如果用默认的0x200或0x400很容易爆栈。我建议把Stack_Size改成0x1000也就是4KB。对于64KB RAM的ZET6来说这个开销值得。内存池耗尽。LV_MEM_SIZE设小了控件创建多了之后LVGL内部分配失败但上层代码没有正确判断继续操作空指针。解决方法是打开lv_conf.h里的LV_USE_MEM_MONITOR在外部循环定期调用lv_mem_monitor输出剩余内存观察是不是随着操作单调递减。如果数值在下降多半是某些控件的事件回调里反复创建对象但没有删除。DMA缓冲对齐问题。SPI DMA传输时如果源地址不满足控制器要求DMA会卡住或者传错数据。最稳妥是给DMA专用缓冲加aligned属性或者先拷贝到对齐数组成员。HardFault问题难排查好解决之后要总结出规律每次添加新控件或新动画后先把栈加大一档、内存池观察一段时间再上线复杂业务逻辑。5.4 界面刷新慢优化效果与参数实测7寸屏上LVGL的默认刷新率配置是30Hz理论上每33ms刷新一次脏区域。实际卡顿的优化方向很清晰编译器优化。Keil下-O0和-O2在纯渲染场景实测差距可能有1.5倍以上。LVGL这类需要密集像素计算和内存操作的代码不开优化等于让F103背着沙袋跑步。工程最终发布时务必开-O2。draw buffer尺寸。从一个16KB单缓冲改成两个16KB双缓冲撕裂明显减少再把每个buffer从16KB提到24KB渲染整块区域时批处理比例更高CPU反而更轻松。内存如果实在紧张优先保双缓冲尺寸可以压缩。减少透明效果和阴影。LVGL的默认主题带圆角、阴影、透明度这些效果在F103上每帧要额外计算一遍alpha混合。想流畅就把LV_THEME_DEFAULT的shadow_width改为0border_width用默认但关掉阴影。下面是我在ZET6上测过的几种配置效果配置实际感受单缓冲16KB-O0切页有明显闪烁滑动卡顿双缓冲16KBx2-O0不闪了但拖动响应不够跟手双缓冲16KBx2-O2基本流畅动画能看双缓冲24KBx2-O2主观跟手复杂页面偶有掉帧F103毕竟是72MHz的老架构想把7寸屏拖出F4那种丝滑感不现实但只要避开透明动画、大面积全屏重绘这几个高成本操作简单列表、按钮、仪表盘页面完全稳得住。我这次移植用的是SSD1963并口屏、XPT2046电阻触摸最后定下来双缓冲24KBx2、LV_MEM_SIZE 24KB、-O2优化。7寸屏跑起来普通页面切换和按钮交互都没问题唯一需要注意的是别在F103上做大量列表项的同时开阴影和渐变否则帧率肉眼可见地掉。这套参数在RCT6上我也试过把draw buffer压到12KBx2之后依然能跑只是内存更紧张。如果你也打算移植先在裸板上把LCD单通测试过了再谈LVGL配置这条路走下来会顺畅很多。