
简介一份基于STM32F103的SPI接口屏幕移植LVGL图形库的完整工程面向嵌入式开发者和GUI初学者解决在低资源MCU上搭建流畅界面、适配常见SPI屏如ST7789、SSD1306等的难题。压缩包整体约3.77MB共761个文件其中以C源码189个与头文件186个为主还包含Keil工程文件、链接脚本、编译批处理、图片bin素材与PNG预览图目录较规整可直接对照参考或二次开发。资源已获得4176人浏览学习自写源码包含SPI外设配置、屏幕驱动、LVGL事件处理与帧缓冲更新等关键模块并提供blue_flower等示例UI图标和BIN资源便于理解LVGL对象创建、动画与触摸输入流程。对于想要在F103上快速跑起LVGL、避开底层移植坑的开发者这份可运行的工程能节省不少调试时间。1. 项目背景与方案选型F103为什么还能再战把STM32F103和一块SPI接口的小屏幕、再加上LVGL图形库凑在一起属于嵌入式UI开发里非常经典的一套组合。很多人觉得F103这颗Cortex-M3老芯片跑图形界面肯定吃力但实际上只要把方案选对它完全能跑出一个看得过去的交互界面而且在成本和功耗上比上Cortex-M7或者带LTDC的芯片划算太多。我自己这个项目就是冲着“低成本、低功耗、快速出界面”去的折腾完以后的效果说实话超出预期。这套方案的典型应用场景包括小家电面板、温控器、手持仪表、电机控制器的人机交互部分这些场合用不上高分辨率大屏也不需要复杂的动画特效要的就是能稳定显示、能响应按键或触摸、刷新不卡顿。F103的72MHz主频、最高64KB RAM配合SPI接口的小尺寸屏1.8寸到3.5寸之间跑一个简约风格的LVGL界面刚刚好。选型时最关键的判断点是屏幕驱动IC和接口类型。市面上常见的SPI屏小尺寸多用ST7735、ST77892.4寸以上常见ILI9341这些驱动IC的初始化代码高度相似LVGL层根本不用区分是哪颗芯片因为显示驱动只负责把像素数据怼到屏幕上具体像素格式、行列扫描方向由底层SPI驱动和初始化序列决定。所以移植LVGL的第一步不是写LVGL代码而是先把屏幕点亮、能刷纯色块确认底层SPI通路完全通畅。这里要额外提一句SPI屏和并口屏的本质区别在于数据传输带宽。并口屏一次能传8位或16位数据SPI屏一次只有1位串行数据。所以SPI屏刷屏天然慢刷新率上限大概率在20到40帧之间具体取决于SPI时钟、是否有DMA、像素格式等。LVGL在这种带宽限制下就不能追求高帧率动画而是要让每一帧的绘制开销尽可能小。2. 硬件准备与接线细节确认驱动IC是第一步2.1 屏幕驱动IC识别与接线表拿到一块屏幕先别急着接F103第一步是确认屏幕用的驱动IC是谁。绝大多数屏幕模组背面会印型号或丝印比如常见的ST7735S、ST7789V、ILI9341。实在认不出来可以先把屏幕接到某个能跑通用初始化代码的平台上试一遍或者查商家给出的资料。驱动IC决定了初始化序列和颜色格式LVGL在这上面帮不了你它只认RGB565或者RGB888这种抽象颜色格式至于屏幕怎么把颜色值刷到物理像素上那是底层驱动的事。确认驱动IC以后按照模组的引脚定义和F103的SPI外设做接线。以一个典型的1.8寸ST7735屏幕为例引脚定义一般是VCC、GND、CS、RESET、DC也叫RS、SCLK也叫SCK、MOSI也叫SDA、BL背光。其中MISO大多数情况下可以不接因为SPI屏本身是单向写为主的设备读寄存器虽然走MISO但实际项目中一般不用读回功能留空即可。屏幕引脚功能F103接线建议说明VCC电源正3.3V部分屏幕可接5V但逻辑电平不匹配会烧IO建议统一3.3VGND地GND共地必须接否则SPI时序不稳定CS片选任意GPIO或硬件NSS建议用软件GPIO控制方便后续扩展RESET复位任意GPIO拉低至少10us再释放DC/RS数据/命令选择任意GPIO高电平写数据低电平写命令SCLKSPI时钟PA5SPI1_SCK也可用软件模拟MOSISPI主机输出PA7SPI1_MOSI软件模拟则任意GPIOBL背光任意GPIO或直接3.3V建议接GPIO方便控制亮度2.2 接线时的注意事项接线的坑主要在电源和电平匹配上。很多SPI屏幕模组丝印写着支持5V但如果你用的是正点原子、野火这类开发板IO口输出的是3.3V电平屏幕SCLK和MOSI的识别阈值也是3.3V这没问题。但如果你把屏幕VCC接到5V而GPIO输出3.3V部分屏幕的逻辑输入阈值可能偏到0.7倍VCC即3.5V导致SPI信号识别不了出现白屏或者错乱。最稳妥的做法是全程3.3V供电哪怕屏幕支持宽压也建议用3.3V。CS片选的处理也是一个容易踩坑的点。F103的SPI有硬件NSS功能但真正用起来很麻烦因为硬件NSS在主模式下的行为受SPI_CR1寄存器里的SSM、SSI位影响远不如直接用GPIO拉高拉低来得直观。另一个相关的坑是SPI总线上如果挂了多个从设备比如屏幕和W25Q64 Flash共用一个SPI软件片选比硬件片选灵活得多这也是很多有经验的人坚持用软件GPIO片选的原因。闪存芯片和屏幕的CS如果都挂了SPI1每次切换设备时先拉高上一个设备的CS再拉低下一个设备的CS中间加一点延时能避免总线冲突。复位引脚RESET一般也要用GPIO控制上电后延迟20ms再拉高然后发送初始化序列。如果RESET直接接3.3V不控制部分屏幕在上电瞬间可能因为电源爬升速度太慢而没有正确复位表现为屏幕亮但没有任何显示内容。3. SPI底层驱动与刷屏逻辑从寄存器到DMA3.1 SPI工作模式与F103库函数配置F103的SPI外设工作在全双工主模式屏幕只需要半双工写所以可以不关心MISO。比较关键的是SPI的极性和相位屏幕驱动IC普遍兼容SPI Mode 0CPOL0CPHA0或者Mode 3CPOL1CPHA2实测下来Mode 0用得最多。用标准外设库配置SPI1的代码非常成熟关键参数是时钟极性、时钟相位、8位数据帧格式、MSB先行。一般配SPI1时钟为9MHz左右比较稳。F103的SPI1挂在APB2总线上APB2时钟默认72MHz所以分频系数取8得到9MHz。有人会尝试把分频系数设成4、得到18MHz短排线加杜邦线的情况下大概率也能跑但一旦屏幕和MCU之间距离超过10厘米信号完整性就会出问题出现花屏、乱码。我最终选择9MHz作为稳定工作点如果开了DMA刷屏9MHz也能达到每秒几MB的传输量够LVGL用了。代码实现上用标准外设库初始化SPI1的流程如下SPI_InitTypeDef SPI_InitStructure; GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_5 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // 注意如果使用软件片选CS引脚配置为通用推挽输出 SPI_InitStructure.SPI_Direction SPI_Direction_2Lines_FullDuplex; SPI_InitStructure.SPI_Mode SPI_Mode_Master; SPI_InitStructure.SPI_DataSize SPI_DataSize_8b; SPI_InitStructure.SPI_CPOL SPI_CPOL_Low; SPI_InitStructure.SPI_CPHA SPI_CPHA_1Edge; SPI_InitStructure.SPI_NSS SPI_NSS_Soft; SPI_InitStructure.SPI_BaudRatePrescaler SPI_BaudRatePrescaler_8; SPI_InitStructure.SPI_FirstBit SPI_FirstBit_MSB; SPI_Init(SPI1, SPI_InitStructure); SPI_Cmd(SPI1, ENABLE);GPIO模式配置这里有个细节SCK和MOSI要设成复用推挽输出GPIO_Mode_AF_PPCS、DC、RESET这些控制脚设成通用推挽输出GPIO_Mode_Out_PP别搞混了。如果你用软件模拟SPI而不是硬件SPI那就把所有引脚都配成普通推挽输出软件翻转和延时反而更灵活但速率上限只有硬件SPI的一半左右不推荐跑LVGL时用软件模拟。3.2 刷屏传输的DMA设计F103跑LVGL最影响体验的是刷屏函数也就是把一整块像素数据从内存送到屏幕的过程。如果不用DMACPU需要逐个字节地往SPI_DR寄存器写数据发送的同时还要等待TXE标志位72MHz的单核CPU被无谓地占用了界面操作会有肉眼可见的卡顿。用DMA之后SPI数据搬运交给外设CPU可以去跑LVGL的绘制任务刷屏效率大幅提升。F103的SPI1发送DMA请求映射到DMA1的Channel 3配置DMA时方向是内存到外设外设地址是SPI1_DR的地址内存地址是像素缓冲区的地址传输长度按字节算。每次刷屏前先把数据放进一个缓冲区然后启动DMA传输传输完成中断里设置一个标志位告诉LVGL当前帧已经送出去了。关键点在于DMA传输完成并不代表SPI已经完全发完最后一个字节还需要额外等待SPI的BSY标志位清零否则可能会出现最后一列像素丢失的暗纹。void LCD_DMA_Config(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel3); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)SPI1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)lcd_dma_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize LCD_PIXEL_COUNT * 2; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode DMA_Mode_Normal; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel3, DMA_InitStructure); }DMA缓冲区大小要根据屏幕分辨率和LVGL的刷屏机制来定。LVGL每次flush回调传入的区域一般不是整个屏幕而是一个脏矩形区域所以缓冲区不一定要覆盖全屏只要能够容纳LVGL一帧最大的数据量即可。对于320x240的屏幕如果选择全屏单缓冲方案缓冲区就是320x240x2153600字节也就是150KBF103根本放不下。所以更现实的方案是让LVGL每次只刷10行或者更少缓冲区开成10x320x26400字节约6.25KB这个大小F103勉强能接受。4. LVGL移植与配置lv_conf.h和显示驱动接入4.1 LVGL版本选择与目录结构LVGL目前主流版本是V7和V8V9的接口变化更大。我的建议是F103这种资源紧张的平台用V7或者V8.3。V8的API更规范控件更多文档也全但内存占用比V7略高V7更轻量适合RAM很小的板子。如果追求稳定、资料多V7足够很多网上教程也都是V7的。我自己用的是V8.3原因是它的性能模式LV_COLOR_DEPTH16和动画接口在F103上表现很好而且针对低内存场景有裁剪配置。LVGL源码目录里你需要把lvgl文件夹整体拷贝到工程然后编译时加入src下的所有.c文件同时把lv_conf_template.h复制一份改成lv_conf.h放在工程根目录。lv_conf.h是整个LVGL的配置中枢里面有大量宏定义比如LV_COLOR_DEPTH颜色深度SPI屏一般设16对应RGB565LV_MEM_SIZELVGL内部内存池大小F103建议8KB到16KB太小控件会创建失败LV_TICK_CUSTOM自定义心跳定时器必须开启LV_FONT_MONTSERRAT_14内置字体至少开一个否则无法显示文本配置完直接编译会报一些未定义或者重复定义的错误这是因为lv_conf.h里面有部分用不到的开关没关掉。V8的项目里如果不需要GPU、不需要文件系统、不需要中文输入法把这些相关宏全部设成0能显著减少Flash占用和RAM占用。4.2 显示驱动注册与flush回调写法LVGL的显示驱动核心是lv_disp_drv_t结构体和三个函数disp_init、disp_flush、disp_fill。在V8里主要是实现flush回调LVGL会把需要绘制的像素数据以RGB565的格式通过flush回调传给你。你要做的就是把数据从回调给的缓冲区通过SPI DMA送到屏幕对应区域。一个典型的flush回调长这样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_WriteData((uint8_t *)color_p, (area-x2 - area-x1 1) * (area-y2 - area-y1 1) * 2); lv_disp_flush_ready(disp_drv); }关键在于最后一行lv_disp_flush_ready必须在SPI DMA传输完全结束、屏幕已经把数据处理完之后调用。如果你在调用LCD_WriteData后立刻调用flush_ready而DMA还在传输或SPI还在发送LVGL会认为这块区域已经刷完复用缓冲区去画下一块区域导致数据错乱、花屏。正确做法是在DMA传输完成中断里设置标志位然后再调用lv_disp_flush_ready()。这里还有一个很容易被忽略的性能点LVGL内部会根据flush回调传入的area区域大小把屏幕划分成多个矩形块逐个刷。area越小切换窗口地址的开销占比越大刷屏效率越低。有一种优化办法是强制LVGL每次以整行或半个屏幕为单位刷在lv_conf.h里把LV_USE_PERF_MONITOR打开观察刷新耗时然后调整flush策略。我实际测试下来320x240的屏每次刷10行左右是F103性能均衡点太少则窗口切换过多太多则缓冲区爆炸。5. 性能调优与内存优化让F103跑得更流畅5.1 减少内存消耗的几种手段F103的RAM是最硬的约束。以C8T6为例只有20KB RAM一个为320x240屏幕开的6.25KB显存缓冲加上LVGL自己的8KB内存池再加系统栈和全局变量RAM已经非常紧张。如果用的是RCT6或者ZET664KB RAM会从容一些但依然建议能省则省。降低内存的最直接手段是减少像素格式位数。LVGL的LV_COLOR_DEPTH设成16也就是RGB565这是SPI屏最常见的选择。虽然LVGL也支持8位或1位颜色深度但那种情况显示效果大打折扣工业仪表上看起来颜色断层很严重不建议。另一个手段是关闭LVGL的动画缓存和图层功能LV_USE_SNAPSHOT、LV_USE_TRANSFORM这些宏直接设0它们在实际项目中用到的概率很低但每一个都会增加内存开销。控制LVGL控件数量也很重要。F103上如果一次性创建几十个对象LV_MEM_SIZE设成8KB肯定不够用对象多了会出现创建失败或者界面卡死。解决办法是用完即删比如弹窗关闭后调用lv_obj_del删除对象而不是lv_obj_clean隐藏。实测下来同一个页面上的控件数量控制在20个以内、页面切换时删掉旧界面16KB内存池能稳定运行。5.2 刷屏效率与帧率的实测数据在实际刷屏测试中用9MHz SPI加DMA320x240 RGB565全屏刷新理论传输量为320x240x2153600字节按9MHz算需要153600x8/9000000约136ms也就是7帧左右。但LVGL实际不会全屏刷新它只刷脏区域所以实际观感帧率取决于界面复杂度。如果只是点击按钮、切换页面少量区域更新可以做到30帧以上的体感流畅度如果是全屏动画比如滑动切换页面帧率掉到10帧左右有一点点卡但在低成本的工业面板上完全能接受。想要进一步提升效率可以考虑用局部刷新模式。LVGL有一个LV_USE_MONITOR宏和lv_refr_now函数组合可以在特定任务中限制刷新频率。在F103这种平台上不建议每帧都全量刷新而是用lv_disp_drv_t里的full_refresh字段如果设置为trueLVGL会强制每次刷新都把自己内部缓冲区整段交给flush回调虽然看起来是“整段”但因为内部缓冲只有10行所以实际刷屏面积依然有限不会导致卡死。5.3 CPU资源分配与任务调度F103跑LVGL还要注意CPU占用。LVGL的主循环一般放在while(1)里每次调用lv_task_handler()处理UI事件和动画更新。如果主循环里还要做其他的业务逻辑比如电机控制、传感器读取、Modbus协议处理就需要合理安排时间片。一个实用做法是保持10ms调用一次lv_timer_handlerV8.3中lv_task_handler改名了给其他任务留出90%的时间片这样UI虽然有轻微延迟但不会阻塞业务逻辑。心跳定时器也不可少。LVGL需要tick来驱动动画和事件超时在SysTick中断或者一个1ms定时器中断里调用lv_tick_inc(1)。如果用裸机while循环跑不弄心跳的话LVGL内部很多依赖时间的功能会异常比如按钮长按、滑动惯性动画、闪烁控件全部失灵。6. 常见问题与排查实录6.1 白屏、花屏、乱码问题白屏是SPI屏最常见的故障表现。按照经验白屏优先检查三个地方第一RESET引脚是否正常拉高如果屏幕一直处于复位状态就纯白第二初始化序列是否和驱动IC匹配比如你拿ST7735的驱动去初始化ILI9341大概率白屏或花屏第三SPI极性相位是否和屏幕要求匹配不匹配时时钟和数据错位屏幕收到的是乱命令同样白屏。花屏或乱码则大概率是数据顺序问题。RGB565像素数据在内存中是小端存储的如果你的屏幕控制器要求高字节在前但LVGL默认输出低字节在前颜色就会错乱看起来像是“颜色反转”而不是花屏。这种情况要在flush里做字节交换比如把颜色值的两个字节调换再发送。很多屏幕模组实际上用RBG顺序如果你发现红色和蓝色反了就要到初始化序列里改颜色顺序寄存器而不是改LVGL的颜色格式。还有一种情况是屏幕刷出来的图像有重影或拖尾这种多半是SPI时钟太高、信号质量差导致的。用示波器看SCK波形会发现上升沿不够陡峭或者有振铃。解决办法是把SPI分频系数调大降低速率或者缩短屏幕和MCU之间的杜邦线长度再不行就在SCK和MOSI上串联33欧姆电阻做阻抗匹配。6.2 刷新闪屏、撕裂感问题LVGL在F103上出现闪屏的原因通常是flush_ready调用时机不对。如果LVGL认为缓冲区可以复用了而DMA还在搬运上一帧的数据就会出现上半屏新画面、下半屏旧画面的撕裂现象tearing。解决方法是严格按照DMA完成中断再调用lv_disp_flush_ready有些屏幕模组还支持TE引脚Tearing Effect Output把它接到GPIO中断在VSYNC信号到来时再开始刷下一帧能彻底消除撕裂不过这让驱动复杂度上升F103裸机上一般没必要。另一个闪屏的常见原因是背光控制。很多人把BL背光引脚直接接3.3V常亮这没问题但如果背光在电源管理策略里频繁开关或者PWM频率太低视觉上就会闪烁。建议BL用一个独立GPIO控制默认上电后点亮PWM调光频率至少保证1kHz以上否则人眼能感知到频闪尤其在低亮度时更明显。6.3 LVGL内存不足和UI卡死问题LVGL运行中出现“Error: not enough memory”或者界面假死基本可以断定是LV_MEM_SIZE设小了。F103平台LV_MEM_SIZE建议至少设12KB到16KB不要贪小设8KB。同时可以通过lv_mem_monitor()函数在运行时打印内存使用情况逐个界面排查是哪个页面创建时内存消耗最多。记住一个原则界面切换时先删后建不要保存所有页面实例。UI卡死还有一种可能性是lv_timer_handler调用太频繁或者被其他耗时任务阻塞太久。F103如果在一个中断里做了大量的浮点运算或者SPI DMA传输期间CPU长时间忙等都会导致LVGL的定时回调迟迟不执行看起来就像死机。解决方法是把耗时业务拆小段处理或者在主循环里给lv_timer_handler保证固定的最小调用频率。6.4 我的调试经验与建议根据我自己移植这一路的经验最值得推荐的做法是把问题分层处理先用纯色块和单色屏测SPI底层接着用清屏函数测DMA传输然后跑LVGL自带的demo最后再写自己的业务UI。每一层都确认稳定之后再往上层走不要一次把所有代码都写完再调试否则出了错根本不知道是屏幕问题、SPI问题还是LVGL配置问题。调试工具上多备一张SPI逻辑分析仪CLK和MOSI两根线夹上去就能看到命令和数据的发送序列。初始化序列错了、字节顺序反了、DMA长度算错了在逻辑分析仪上都能一眼看出来比靠肉眼盯着屏幕猜花屏原因高效得多。另一个实用技巧是给LVGL的屏幕显示加一个“调试页”开机后读一次LVGL版本号、内存剩余量、SPI计数器直接显示在屏幕上后面排查问题会省很多事。最后分享一个小技巧F103的Flash写寿命有限不要在调试阶段频繁用J-Link烧写带有大数组的程序否则Flash先挂了。把刷屏用的测试缓冲区放到RAM里或者用ST-Link的ram执行模式调试能有效减少Flash的写次数。整个项目做完之后再把最终固件优化一次把不用的调试输出全部关掉你会发现性能和内存都还能再挤出来一截这就是F103移植LVGL的乐趣所在。本文还有配套的精品资源点击获取