资讯详情

STM32F103+SPI屏+LVGL移植实践:从驱动到GUI的完整链路

📅 2026/10/15 2:45:35 | 华诺云谱 👁 阅读
STM32F103+SPI屏+LVGL移植实践:从驱动到GUI的完整链路
简介一套可直接在STM32F103上运行的SPI接口LVGL屏幕移植程序面向有嵌入式基础的GUI开发者和需要快速搭建人机界面的单片机工程师。内容覆盖从SPI外设寄存器配置、常见屏幕驱动适配到LVGL库移植、帧缓冲管理与控件事件响应的完整链路自写代码可实际运行便于学习或二次开发。压缩包共761个文件约3.77MB以C/H源码、工程配置、编译中间文件为主另有图片素材、脚本与说明文档目录结构清晰可直接对照阅读。已有4222人学习下载适合希望理解MCU驱动大屏显示原理或想少走弯路完成STM32F103LVGL界面落地的开发者。1. 一块SPI屏和一个GUI库为什么这个组合值得认真对待在嵌入式产品里“能显示”和“显示得好”之间隔着一条很深的沟。很多开发者拿到一块SPI接口的TFT彩屏和一套LVGL源码以为点亮屏幕就能把界面跑起来结果折腾两周还停在白屏或者花屏上。这里的问题不在于LVGL本身难而在于底层移植链路太长SPI时序、屏幕控制器初始化、显存策略、LVGL的刷新钩子每一环都可能把整体拖垮。这篇笔记围绕“STM32F103 SPI屏幕 LVGL自写移植可用”这个组合把驱动到GUI的完整路径拆开讲适合正在做小型仪表盘、家用设备交互界面、低成本彩屏应用的开发者。结论先行F103跑LVGL完全可行前提是你愿意在帧率、缓存和控件复杂度上做正确取舍。2. 先看清楚整条链路从SPI物理层到LVGL渲染管线的三段式结构2.1 三段分离驱动层、适配层和GUI层各自要解决什么移植LVGL到F103SPI屏本质上是在驱动层Dri→ 适配层Port→ GUI层这三段之间建立一条高效的通道。驱动层负责把像素字节按时序写到屏幕控制器里适配层负责告诉LVGL“我的显存长什么样、怎么刷、多久刷一次”GUI层则是你真正写界面代码的地方。很多移植失败的案例问题都出在把三段混在一起调LVGL初始化失败先怀疑屏幕驱动屏幕出现撕裂又去改GUI逻辑。实际上这三段可以独立验证顺序是先让驱动层单独能画纯色再让适配层能通过LVGL的回调刷出一张简单的单色图最后才进入控件和动画阶段。在F103上SPI外设的时钟配置需要格外注意。我一般把SPI1挂在APB2总线上时钟配置为18MHz分频到9MHz或者4.5MHz这取决于屏幕控制器的极限速率和你的PCB走线质量。超过10MHz的SPI时钟在杜邦线连接下很容易出现数据错位表现为屏幕颜色错乱或者局部花屏这不是代码问题是物理链路问题。一个常见的误区是盲目追求满速SPI。ST7789这类控制器虽然标称支持几十MHz但F103的SPI外设本身要处理DMA请求和FIFO加上中断延迟实际稳定吞吐率在6-9MHz已经足够。LVGL的帧率瓶颈往往不在SPI速度而在F103的Cortex-M3内核跑图形运算的速度。2.2 屏幕控制器差异ST7789、ILI9341与ST7735的选型权衡在这个标题组合里屏幕控制器基本逃不开三款ST7789、ILI9341和ST7735。它们都是SPI接口的TFT控制芯片但初始化序列、显存组织方式和色彩格式支持有细微差别这会直接影响你接下来的移植代码怎么写。ST7789是目前小尺寸圆屏和方屏里最常见的初始化序列短支持RGB565和RGB666显存是240x320或者240x280F103驱动起来压力不大适合做2.0寸以下的产品界面。ILI9341是经典大屏控制器初始化序列长但文档丰富支持240x320SPI读取功能完整适合需要读回屏幕内容的场景但代价是初始化代码占Flash较多。ST7735是老一代小屏控制器色彩偏淡初始化时序对拉高拉低的顺序敏感新项目建议直接避开。这三款控制器的代码不能直接通用但移植成本差异不大每个控制器的初始化序列本质是一串寄存器写入操作你只要把序列换成目标控制器对应的值再微调像素格式设置即可。这不叫从零移植叫换控制器适配。真正决定工作量的不是控制器型号而是你的屏幕分辨率、排线和F103的Flash/RAM余量。2.3 显存策略全缓存、行缓存与分块缓存的取舍LVGL渲染需要一个中间缓冲这直接决定了F103的RAM够不够用。F103系列里C8有20KB RAMRCT6有48KB RAMZET6有64KB RAM。而一个240x320的RGB565全屏缓存是240x320x2字节约150KB——没有任何一款F103能装下。所以必须用局部缓存。LVGL 8.x的配置里LV_MEM_SIZE是LVGL内部动态内存池而lv_disp_draw_buf_t是渲染缓冲区这两个概念经常被混为一谈。实际工程中我的做法是给lv_disp_draw_buf_t分配两个缓冲区每个大小取屏幕宽度的十分之一行数也就是一行的1/10约24行240x24x211520字节约11KB。两个缓冲即22KB在RCT6上占不到一半RAM剩余空间留给LVGL对象和你的业务变量。一帧的刷新次数和缓冲区大小成反比缓冲区越小刷新次数越多撕裂概率越高但内存占用越低。F103上经典的折中方案是240x24x2的x2双缓冲。如果你用的是ZET6可以放宽到240x40x2的x2流畅度会有可感知的提升。而全缓存方案只在内存大于150KB的平台上才成立F103不要想。3. 先把屏幕点亮用最少代码验证SPI和控制器初始化3.1 一个最小可用的SPI驱动模板与其参数解释在碰LVGL之前先把屏幕点亮并保持纯色稳定输出。这是整条移植链的“冒烟测试”如果你连纯色都刷不出来就不要继续往下走。以下是我常用的一套最小SPI初始化模板面向ST77894线SPI模式// spi_screen_init.h 片段基于STM32标准外设库 void SPI_Screen_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; SPI_InitTypeDef SPI_InitStructure; // 使能SPI1和对应GPIO的时钟SPI1在APB2上 RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1 | RCC_APB2Periph_GPIOA, ENABLE); // SCKPA5, MOSIPA7, CSPA4, DCPA2, RSTPA3 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); GPIO_InitStructure.GPIO_Pin GPIO_Pin_4 | GPIO_Pin_2 | GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); 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_High; // SPI模式0CPOL1, CPHA1 SPI_InitStructure.SPI_CPHA SPI_CPHA_2Edge; SPI_InitStructure.SPI_NSS SPI_NSS_Soft; SPI_InitStructure.SPI_BaudRatePrescaler SPI_BaudRatePrescaler_8; // 72MHz / 8 9MHz SPI_InitStructure.SPI_FirstBit SPI_FirstBit_MSB; SPI_Init(SPI1, SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); }这个初始化块的关键点有两个。一是SPI模式必须和屏幕控制器匹配ST7789和ILI9341都支持SPI Mode 0CPOL0, CPHA1和Mode 3CPOL1, CPHA2但不同厂家模组默认接法可能不同。如果你在纯色测试阶段发现颜色错乱但画面完整先试着翻转CPOL和CPHA不要改接线。二是波特率预分频选8是因为STM32F103的APB2最高72MHz分频后9MHz落在稳定区间。如果你用杜邦线连接且线长超过10cm建议降到16分频即4.5MHz。3.2 初始化序列之外的两个验证方法纯色刷屏和像素点扫描屏幕控制器的初始化序列会很长这里不逐行粘贴。但有两种验证方法能快速判断初始化序列是否正确比看白屏瞎猜高效得多。第一种是刷纯色。初始化完成后向整个显存区域写入0xFF白色和0x00黑色用肉眼观察屏幕亮度是否变化。如果黑屏完全没有变化大概率是背光引脚没拉高、RST引脚时序不对或者初始化序列根本没执行到。如果屏幕显示雪花噪点或条形干扰则多半是SPI时钟极性/相位配错或者MISO/MOSI接反。第二种是像素点扫描。用一个双层循环向屏幕的每个像素坐标写入一个颜色渐变值坐标到颜色的映射关系写成一个固定公式比如color (x * 5) 8 | (y * 3)。扫描完成后屏幕上应该出现一个从左上到右下连续渐变的矩形。如果渐变方向反了说明你的列地址和行地址映射关系写反如果渐变出现断层说明你写入的坐标超出了屏幕的显存范围。这两个方法总共需要不到100行代码却能筛掉后面LVGL移植中80%的玄学问题。我见过太多开发者跳过这一步直接上LVGL结果白屏后只能靠猜到底是SPI问题还是LVGL问题一颗几十元的屏幕先花一小时做冒烟测试后续能省两周的排查时间。4. 把LVGL跑起来裁剪配置、回调注册与心跳机制4.1 lv_conf.h 里必须改的7个配置项LVGL下载下来后第一步不是写代码而是裁剪lv_conf.h。F103的资源有限默认配置是面向开发板的宽松设置直接编译会超出Flash和RAM。以下7个配置项是必须手动确认的每一项都直接关系能否在F103上跑起来。// lv_conf.h 关键裁剪项LVGL 8.x #define LV_COLOR_DEPTH 16 // RGB565最省内存的色彩格式 #define LV_MEM_SIZE (32U * 1024U) // LVGL内部动态内存池RCT6可用32KB #define LV_DISP_DEF_REFR_PERIOD 30 // 刷新周期30ms约33fps #define LV_DPI_DEF 80 // DPI定义影响控件物理尺寸缩放 #define LV_FONT_MONTSERRAT_14 1 // 只保留14号默认字体 #define LV_USE_LABEL 1 // 标签控件开启 #define LV_USE_BTN 1 // 按钮控件开启 // LV_USE_CHART、LV_USE_TABLE、LV_USE_CANVAS 等重型控件保持0LV_COLOR_DEPTH设为16是硬件层面的决定你的屏幕控制器接收RGB565格式F103的帧缓冲也按RGB565计算可以做到一次像素搬运不做格式转换。如果设为32每个像素多占一倍缓冲刷新速度反而下降。LV_MEM_SIZE是LVGL内部malloc的内存池大小这个值不是越大越好。F103 RCT6的48KB RAM里你还要留一部分给两个帧缓冲一部分给业务代码的局部变量。我实测32KB够跑一个标签按钮进度条的界面如果后续界面复杂度上来先调这个值再考虑是否增加外部SRAM。LV_DISP_DEF_REFR_PERIOD是LVGL的心跳刷新周期。LVGL不是实时重绘而是每过这个周期主动检查一次是否需要重绘。30ms是F103能稳定处理的刷新节奏如果界面有快速动画调到20ms也能跑但CPU占用会明显上升。4.2 三个回调函数flush、hres/vres与tick的注册顺序LVGL跑起来需要三个底层钩子显示刷新回调disp_flush、分辨率描述hor_res和ver_res以及系统心跳tick_get。这三个钩子的注册顺序很重要顺序错了会出现“能编译但屏幕不刷新”的怪现象。// LVGL显示驱动初始化模板 void LVGL_Port_Init(void) { static lv_disp_draw_buf_t draw_buf; static lv_color_t buf_1[240 * 24]; static lv_color_t buf_2[240 * 24]; lv_init(); lv_disp_draw_buf_init(draw_buf, buf_1, buf_2, 240 * 24); // 先初始化draw_buf再初始化display driver顺序不能颠倒 static lv_disp_drv_t disp_drv; lv_disp_drv_init(disp_drv); disp_drv.hor_res 240; disp_drv.ver_res 320; disp_drv.flush_cb my_disp_flush; disp_drv.draw_buf draw_buf; lv_disp_drv_register(disp_drv); lv_tick_set_cb(my_tick_get); }hor_res和ver_res必须和屏幕控制器的实际分辨率一致不能填屏幕模组的物理对角线尺寸。比如你的屏幕物理尺寸是2.0寸但分辨率是240x320这里就必须填240和320。如果填错LVGL的坐标系统会错乱控件排列和触摸映射全部偏移。lv_tick_set_cb用于注册系统心跳。正常情况下F103的SysTick中断里要调用lv_tick_inc(1)或者提供一个返回毫秒数的钩子函数。抖动的心跳会导致动画忽快忽慢这在F103上很隐蔽如果你发现按钮按下去动画正常但进度条走得有时快有时慢先查时钟源再查LVGL的代码。my_disp_flush是核心回调它负责把一个矩形区域的像素数据真正写到屏幕上。这个回调会在LVGL渲染完一个局部区域后被调用参数包括左上角坐标、右下角坐标和像素数据指针。回调里要做的三件事设置屏幕的窗口区域、逐个像素写入、最后调用lv_disp_flush_ready通知LVGL缓冲区已释放。这后面两个步骤缺一不可只有flush_ready被调用LVGL才会继续用这块缓冲区渲染下一块区域。4.3 一个能跑通的标准flush回调实现把flush回调写得又快又稳是F103上LVGL性能的关键。以下是一个经过实测的模板直接用SPI DMA搬运像素数据。// my_disp_flush 典型实现 void my_disp_flush(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) { // 设置屏幕窗口区域让控制器知道数据从哪个坐标开始写 ST7789_SetWindow(area-x1, area-y1, area-x2, area-y2); // 像素数据按行连续搬运利用SPI DMA减少CPU占用 uint32_t pixel_count (area-x2 - area-x1 1) * (area-y2 - area-y1 1); uint32_t byte_count pixel_count * 2; // RGB565 每像素2字节 ST7789_WriteDataDMA((uint8_t *)color_p, byte_count); // 必须调用flush_ready否则LVGL会一直等待缓冲区释放 lv_disp_flush_ready(disp_drv); }这里的ST7789_SetWindow是对控制器指令CASET和RASET的封装它告诉屏幕“接下来要写哪个矩形区域”。如果你的屏幕控制器是ILI9341只是寄存器地址不同函数内部换成对应指令即可。ST7789_WriteDataDMA内部使用SPI1的DMA通道使能DMA传输后不等传输完成就直接返回。这里有一个微妙的点如果DMA还在搬运数据lv_disp_flush_ready就开始通知LVGL缓冲区可用新一帧的渲染可能会覆盖尚在DMA传输中的数据。解决方式是在DMA传输完成中断里调用lv_disp_flush_ready而不是在发起DMA后立即调用。这一点如果没处理好会出现画面随机撕裂而且很难复现。color_p指针指向的是LVGL内部的渲染缓冲不是你的显存。所以不要在flush回调里试图“保存”这份数据它的生命周期只持续到flush_ready被调用之前。5. 移植过程中的五个高频坑现象、原因和解决办法5.2 坑一白屏但背光亮初始化序列跑完没反应现象是屏幕背光正常点亮但屏幕一直白色没有任何显示即使刷纯色也没有反应。这个坑最容易让人怀疑LVGL实际上问题几乎都在底层。原因排查下来有三种常见情况RST引脚时序不对屏幕控制器一直处于复位状态DC引脚电平不对数据/命令模式错乱导致初始化指令被当成像素数据写入供电不足F103的3.3V引脚带不动屏幕模组内部的升压电路。解决方法是逐一确认RST引脚先拉低至少10ms再拉高等待120ms让控制器完成上电复位用示波器或万用表量DC引脚在初始化期间的电平是否在切换给屏幕模组单独接一个100uF电解电容和0.1uF陶瓷电容分别放在模组电源引脚附近。这三级排查做完99%的白屏都能解决。5.3 坑二画面出现水平撕裂纹刷新越快越明显现象是屏幕在刷新动画时出现明显的水平分割线上半屏和下半屏的内容不在同一帧时刻。这在低端屏上非常常见但在F103上往往是FPU和DMA的配合问题。原因在于LVGL的flush回调发起了DMA传输但flush_ready被过早调用。DMA还在搬运上半屏的数据时下半屏已经在被渲染和写入。解决方法是把lv_disp_flush_ready从flush回调的直接调用处移到DMA传输完成中断里确保一帧数据完全送出后才通知LVGL重新使用缓冲区。这类问题用软件难以彻底消除但可以大幅降低发生率。另一个有效的辅助手段是打开LVGL的LV_DISP_DEF_REFR_PERIOD到40ms人为降低刷新频率给DMA留出足够时间。5.4 坑三触摸坐标和显示画面错位偏移量固定现象是界面上的按钮显示在A位置但通过触摸屏点击时必须点击B位置才能触发。这个坑出现在带触摸功能的屏幕模组上SPI屏幕和触摸控制器是两个独立器件坐标系统天然不同。原因是触摸控制器的X/Y坐标方向和屏幕控制器的扫描方向不一致。很多模组的触摸面板和显示面板在物理上呈90度旋转你的LVGL坐标以屏幕左上角为原点但触摸控制器上报的坐标以触摸面板的物理原点为准。解决方法是在触摸回调里做坐标转换常见做法是交换X/Y并把对应方向取反。比如屏幕240x320、触摸面板320x240时转换逻辑为lv_x touch_ylv_y 319 - touch_x。具体数值取决你的模组出厂方向所以不要在代码里写死先用一个简单的测试界面打印原始触摸坐标再确定转换公式。5.5 坑四LVGL编译通过但运行后进入HardFault现象是程序烧录后屏幕不亮调试器显示停在HardFault中断卡死在memcpy或者memset相关函数。原因通常是LV_MEM_SIZE配置过大超过了F103的RAM余量。F103 RC系列只有20KB RAM如果你把LV_MEM_SIZE配成40KB再加上两个帧缓冲各11KBRAM直接爆掉栈指针指向非法地址后触发HardFault。解决方法是第一步裁剪LV_MEM_SIZE降到24KB帧缓冲从240*24降到240*16先确认能跑通再逐一加大回原来的水平。第二步要看map文件确认内存分布编译生成的.map文件里能查到堆、栈和全局变量的地址范围只要这些区域重叠必定HardFault。5.6 坑五长时间运行后内存耗尽界面卡死现象是界面运行几分钟后开始卡顿最终完全无响应重启后恢复正常。这个坑在LVGL 8.x里很典型。原因是LVGL对象创建后没有释放。每次点击按钮动态创建标签或图片却没有在合适时机调用lv_obj_del导致LVGL内部的内存池一点点耗尽。F103的LV_MEM_SIZE只有32KB一个标签对象约占用200-300字节看似不多但如果界面每秒创建3个对象并持续运行几分钟内存池必然见底。解决方法是建立对象复用机制在界面初始化时一次性创建好所有可能用到的控件后续只是修改它们的文字、位置和颜色不要频繁创建和删除。另一种做法是在创建新对象前检查内存池余量用lv_mem_monitor打印剩余空间低于阈值时主动删除不可见对象。6. 从能显示到看得顺用局部刷新和控件精简榨干F103的最后性能当你的LVGL界面能稳定运行下一步是让它“看得顺眼”。这里有一个具体技巧把LVGL的“全区域重绘”思维改成“局部区域重绘”思维。LVGL默认会合并同一帧内的多个脏区域但如果你有一个实时更新的数据面板比如温湿度数字这个面板每秒钟变一次而周围的背景图标和标题都不会变那么你不应该让LVGL把整个屏幕都重画一遍。实现方式是利用LVGL的lv_obj_invalidate方法只让数据面板所在的对象标记为“需要重绘”并把对象的opa_scale_en和paddings设到最小。这个操作能显著缩短每帧的渲染面积F103上实测全屏重绘需要约45ms而局部重绘一个120x60的数字面板只需要约8ms这是一个数量级的差距。另一个技巧是精简字体加载。F103的Flash只有128KB或256KB完整的中文字库动辄几百KB根本放不下。正确的做法是只加载你界面里实际出现的汉字剩下的用图片或者ASCII替代。LVGL 8.x支持字形子集裁剪你可以把界面里所有中文字符汇总成一个字符串用官方工具生成一个自定义字体文件体积可以控制在20KB以内这在F103上是非常宝贵的空间。再来说说控件精简。F103的CPU频率只有72MHz没有FPUCortex-M3的整数运算能力也有限。LVGL的阴影、圆角、渐变和透明效果都会消耗大量运算一个带圆角的进度条在F103上重绘一次的耗时是不带圆角的三到五倍。所以我的习惯是背景用纯色按钮用平面样式进度条不做渐变动画字体只用14号一个尺寸。画面上没有“酷炫”但有稳定的帧率和实时的响应。我最初在一款基于F103的低成本设备上做界面时也是什么都想加结果帧率掉到个位数。后来咬着牙把界面砍到只剩状态栏、一个动态数值和一个开关按钮功耗和流畅度都达到量产要求。这个取舍不只在F103上适用任何低算力MCU跑GUI都是这个逻辑先保帧率再谈效果。最后留一个调试习惯第一次跑LVGL界面时打开LV_USE_LOG并把日志等级设为LV_LOG_LEVEL_WARN这一段代码会在串口输出LVGL内部的内存警告和刷新警告很多玄学的卡顿和内存问题都能从这里直接看到蛛丝马迹。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑