尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32H750驱动三线SPI RGB屏跑LVGL,嵌入式GUI高效低成本的实践指南

STM32H750驱动三线SPI RGB屏跑LVGL,嵌入式GUI高效低成本的实践指南 简介本资源是面向嵌入式GUI开发者的STM32H750高性能平台实战项目聚焦裸机环境下LVGL V8.2图形库与480×480 RGB接口屏幕的深度集成解决高分辨率LCD驱动、LTDC图层配置及三线SPI精简通信等典型工程难点适用于工业HMI、智能面板等对实时性与显示效果有较高要求的场景。压缩包共1924个文件含544个C源码LTDC驱动、LVGL移植层、屏初始化逻辑、307个头文件硬件抽象与LVGL配置、192个编译中间文件及大量Python脚本自动化构建与资源转换、Markdown文档与PNG示意图整体83.17MB结构清晰模块划分明确。已有98人学习下载提供完整可运行例程含音乐封面图像资源、AXF/HEX烧录文件、Keil工程配置覆盖从GPIO时钟使能、LTDC时序参数调优到LVGL渲染回调适配的全流程实现显著降低RGB屏LVGL在Cortex-M7平台上的移植门槛。 STM32H750配三线SPI的RGB屏再跑LVGL这套组合我前后做了三四块板子从一开始的4.3寸裸屏到现在带触摸的7寸屏踩过的坑攒了一箩筐。如果你正在做HMI、设备操作面板、智能家居中控这类产品需要一套像样的图形界面又不想为了显示效果直接跳到Linux级别的MPU那STM32H750加LVGL是很值得投入的一条技术路线。这篇文章我会把这套方案的硬件选型逻辑、三线SPI驱动原理、LVGL移植优化和典型问题排查一次讲清楚把能直接用上的经验和代码都写出来。先说结论STM32H750本身是一颗性能非常猛的MCUM7内核跑到480MHz自带1MB RAM跑LVGL最吃紧的“内存”问题它天生不虚。而三线SPI加RGB屏的组合是在成本和显示效果之间找到的一个平衡点——接线少、PCB好画、驱动难度低屏幕价格也便宜。三者叠加之后能在几十块钱的物料成本里做出流畅的图形界面这是它最大的价值。1. 这套方案到底解决什么问题1.1 为什么偏偏是STM32H750市面上能跑LVGL的MCU不少F103、F429、H743都能跑但H750在这个方案里的位置很特别。它最吸引人的一点是内置了高达1MB的SRAM。LVGL的底层渲染、帧缓冲、控件对象的动态分配全部要往RAM里塞很多MCU死在内存不足上H750就很少遇到这种问题。我还试过在一颗F103上跑LVGL8KB的RAM连一个全屏帧缓冲都装不下最后只能缩水成局部刷新体验差很多。H750的第二个优点是M7内核加FPU480MHz的主频算力对LVGL这种2D控件级渲染来说绰绰有余。动画、抗锯齿、阴影效果跑起来比较轻松。它还有硬件JPEG编解码器、QSPI接口、LTDC、大量SPI外设和DMA这些背后都能给图形方案提供支撑。但H750也有一个绕不开的痛点内置Flash只有128KB。这个容量放LVGL库本身加基础资源还能凑合但如果你要放全字库、多套图标、开机Logo几乎不可能。所以做实际产品时几乎都得外挂一片QSPI Flash来放资源稍后我会专门讲这个怎么处理。看到这里你可能发现了H750其实是“用Flash短板换RAM和外设长板”的一颗芯片只要外围方案搭配合理它的性价比非常高。1.2 三线SPI加RGB屏是种折中也是种智慧“RGB屏”这个叫法在行业内其实有两层含义。严格来说传统的RGB接口屏指的是并行数据线的TFT屏比如RGB565要16根数据线、RGB888要24根这类屏通常要配合MCU的LTDC控制器才能驱动。但市面上大量所谓RGB屏其实是指屏幕内部用RGB565/RGB888颜色格式、通过SPI串行传输数据的TFT模组。标题里说的“三线SPI加RGB屏”在我的项目里指的就是后一种一块SPI接口的TFT-LCD彩屏用三线SPI协议做传输通道。三线SPI相比传统的四线SPI去掉了MISO这根回线。因为LCD屏幕绝大多数场景下根本不需要向MCU回传数据你只需要单向写命令、写像素就行。省掉MISO之后接线从四根变成三根——SCK时钟、SDA数据、CS片选再加上控制命令和数据的DC引脚及复位RST总共也就五六根线。这个好处在PCB布线时非常明显尤其做小尺寸面板走线空间紧张少一根线就少一堆麻烦。很多人在这个环节会纠结既然STM32H750自带LTDC为什么不直接上并口RGB屏加LTDC这里有一个很现实的考虑LTDC驱动并口RGB屏虽然刷新率高、效果好但需要十几根甚至二十几根信号线引脚占用大、时序要求高、PCB拉线痛苦。而且小尺寸的并口RGB屏成本并不低。而三线SPI加RGB屏的方案对中低分辨率屏幕320x240、480x320来说SPI加DMA直接内存存取的刷新率完全能接受接线简单国产屏幕资源充足整体物料成本低不少。1.3 LVGL夹在中间做什么LVGL在整条链路里充当的是“图形界面引擎”的角色。它的工作有两块一是提供控件比如按钮、标签、滑块、进度条、图表、键盘等让开发者不用从零手绘每个像素二是负责控件渲染和事件分发你告诉它“这里有个按钮按下时触发回调”它自动处理重绘、点击检测、动画过渡这些底层逻辑。我之前在没有LVGL的裸机上画过一个仪表盘唯一的方法是自己写画线、画圆、填充函数然后每次数据刷新都全屏重绘一遍又慢又丑改一个UI布局要改半天代码。换到LVGL之后这些都有了现成方案UI开发效率完全是两个量级。LVGL另一个价值是跨平台。我在PC上用VSCode加模拟器先把界面搭好调好交互逻辑再移植到STM32H750上硬件没到的时候软件已经能跑了整个项目周期缩短非常多。2. 硬件选型与电路设计细节2.1 主控与板级存储怎么搭主控我推荐用STM32H750VBT6这个型号是LQFP100封装引脚足够用也容易手工焊接调试。购买渠道和价格都比较友好是H750系列里做产品的主流选择。它的供电电压是3.3V内核有内置稳压器外部只需要配好去耦电容不需要额外复杂的电源方案。存储方面H750的128KB Flash确实太紧张我建议外挂一片QSPI Flash型号W25Q12816MB或者容量更小的W25Q648MB都可以。LVGL编译出来的代码主体可以烧在内部Flash里但字库、图片素材、图标这些大块资源全部放外部QSPI Flash运行时通过QSPI读取。有个细节是STM32H750支持从QSPI Flash做内存映射执行代码XIP可以把一些初始化代码直接放外部执行进一步省内部Flash空间。但这个方法要小心配置建议先把“内部Flash跑LVGL代码、外部Flash放资源”这套基本路线跑通XIP这些进阶玩法留到后面再研究。如果你的项目用了RTOS比如FreeRTOS也不需要额外加外部RAMH750的1MB SRAM通常够用。当然如果未来界面复杂度大幅提升帧缓冲想多开几层可以考虑外挂SDRAM但那就不是这套方案最经济的形态了。注意H750的部分SRAM区域在总线架构上有差异有些能被DMA访问有些不能具体分配时我会在后面的内存优化里细说。2.2 屏幕怎么选选屏幕时不要光看尺寸关键要看模组的接口定义和驱动IC。三线SPI加RGB屏方案里常见尺寸是1.3寸到4.3寸分辨率从240x240到480x320都有。常见的驱动IC包括ST7789、ILI9341、NT35510等这些驱动IC的数据手册比较容易找LVGL社区对它们的支持也成熟。我实际项目里用得最多的是ST7789V驱动的1.3寸/1.54寸圆屏和方屏以及ILI9341驱动的2.4寸/2.8寸屏。这两颗IC的初始化序列网上有大量现成代码出问题的概率低。如果你选了一块比较冷门的屏幕先确定三件事一是SPI时钟最高频率是多少二是它的接口是几线制三是有没有帧同步TE引脚。这三项直接决定了后面的DMA配置和防撕裂方案。“RGB屏”还有一个容易踩坑的地方屏幕模组上印刷的接口标注不一定和厂家数据手册里的“3-line SPI”定义一致。有些厂家的三线SPI是指包含DC位的9位协议有些厂家则把无MISO的普通SPI称为三线制。我建议下单前一定让屏厂提供数据手册确认好每个引脚的信号定义尤其是DC/RESET/CS这几个脚的映射避免板子做好之后发现引脚定义对不上。2.3 接线表与电平注意点下面我给出一个典型的接线表以STM32H750VBT6加一块ST7789 1.54寸屏为例。SPI外设选用SPI1因为它挂在APB2总线上时钟频率更高有助于提升SPI传输速度。屏幕引脚信号方向MCU引脚说明VCC-3.3V屏幕供电注意纹波GND-GND共地SCLH750 - 屏PA5 (SPI1_SCK)SPI时钟SDAH750 - 屏PA7 (SPI1_MOSI)SPI数据CSH750 - 屏PB0片选软件控制DCH750 - 屏PB1命令/数据选择RSTH750 - 屏PB2复位BLH750 - 屏PA6背光可用PWM调亮度触摸屏的话目前最常用的电容触摸芯片是GT911和CST816一般走I2C接口中断引脚接到MCU的EXTI外部中断上这部分留到LVGL输入设备适配时详细说明。硬件设计上几个细节我必须强调一是SPI时钟线建议串联一个22到33欧姆的电阻这个电阻的作用是抑制信号反射和过冲。SPI时钟跑到十几兆赫兹的时候走线长一点就容易出现振铃屏端收到的时钟沿不稳定会导致数据错位、花屏。串个小电阻后波形明显平滑很多我在量产板上都保留了这个电阻位。二是背光PWM频率不要太低建议至少1kHz以上否则人眼能看出屏幕闪烁。三是H750的IO引脚大多是容忍5V输入的但不代表屏幕信号可以接5V屏幕这边必须坚持3.3V电平防止烧屏。2.4 电源与复位电路H750的电源拓扑比较常规VDD接3.3V每对电源引脚都配100nF去耦电容靠近引脚放置。VCAP引脚需要接一个2.2uF电容这个千万不能省它关系到内部核心电压的稳定。如果你外接背光或者触摸屏总电流会上去建议给屏幕单独加一个小功率LDO低压差线性稳压器不要和MCU的数字电源混在一起否则屏幕刷新的时候电流波动可能把MCU的供电拉出毛刺系统跑飞的概率会上升。复位电路我用最基础也最可靠的方案RC复位加手动复位按键复位脚接10k上拉电阻加100nF下拉电容。虽然H750内置了电源复位但外部复位电路在调试阶段非常有用尤其是LVGL跑着跑着卡死的时候按键复位比断电重来快多了。另外DC引脚我习惯并联一个10k下拉电阻这样在MCU复位的瞬间屏幕不会因为IO浮空而误入数据模式这个小细节帮我避免过很多次莫名其妙的乱码显示。3. 三线SPI驱动原理与代码实现3.1 先搞清楚三线SPI的时序驱动任何设备之前先把通信时序搞清楚。三线SPI的本质是一种单向写模式的SPI通信。标准SPI有四根线SCK、MOSI、MISO、CS三线SPI把MISO这根去掉因为LCD只需要接收数据不需要向MCU回传数据。但从协议角度CS和SCK的时序关系、SCK空闲电平、数据采样沿这些核心参数和标准SPI完全一致。关键在于“命令和数据怎么区分”。目前有两种做法第一种多一根DC引脚DC为低时发送的是命令DC为高时发送的是数据。这是最通用、最推荐的做法因为STM32的SPI外设原生支持8位数据传输配合DC引脚的硬件控制代码简单还能随意使用DMA。第二种把DC信息编码到数据流里比如使用9位SPI模式前8位是内容第9位表示命令还是数据。这种模式在部分小屏上会出现但它对DMA不友好你需要在内存里把每个字节额外扩展成9位传输效率低代码也更绕。我强烈建议硬件的DC引脚独立出来否则为了兼容DC位的嵌入方式SPI数据要额外打包DMA的优势会大打折扣。SLAVE设备的SPI时钟极性极性一般是CPOL0、CPHA0也就是SCK空闲低电平数据在SCK上升沿被采样。不过不同屏幕的驱动IC也支持不同模式最好在初始化阶段用寄存器配置确认一下多数驱动IC默认就是模式0。在STM32H750上配置三线SPI时方向只能选写模式即“2线发送”或“单线发送”。如果你用HAL库要把SPI的方向配置成SPI_DIRECTION_2LINES同时把MISO引脚空出来或者配成普通GPIO。片选建议用软件控制不要用硬件NSS因为软件片选时序更灵活也方便在命令和数据的边界做电平切换。3.2 SPI外设配置与DMA搬运直接看代码这是SPI1的初始化配置基于STM32CubeMX生成的HAL库框架改的void MX_SPI1_Init(void) { hspi1.Instance SPI1; hspi1.Init.Mode SPI_MODE_MASTER; hspi1.Init.Direction SPI_DIRECTION_2LINES; hspi1.Init.DataSize SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity SPI_POLARITY_LOW; hspi1.Init.CLKPhase SPI_PHASE_1EDGE; hspi1.Init.NSS SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler SPI_BAUDRATEPRESCALER_8; hspi1.Init.FirstBit SPI_FIRSTBIT_MSB; hspi1.Init.TIMode SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation SPI_CRCCALCULATION_DISABLE; hspi1.Init.CRCPolynomial 10; HAL_SPI_Init(hspi1); }这里有几个参数需要解释。SPI_BAUDRATEPRESCALER_8表示SPI时钟是APB2时钟除以8H750的APB2时钟如果配置成133MHz那SPI1的时钟就是16.625MHz。为什么选这个值因为ST7789的数据手册上标注SPI时钟最高15.55MHz16.625MHz理论上略超了一点点但实测绝大多数屏幕都能工作。如果你要绝对稳妥可以选SPI_BAUDRATEPRESCALER_16时钟降到8.3MHz代价是刷新率减半。我批量产品里用分频8跑了很久没有因为SPI时钟出过问题。DMA配置是刷屏性能的关键。SPI传一个像素要8个bit一屏320x240的画面就是153600字节不用DMA纯靠CPU一个个写MCU会卡死LVGL的动画就更不用想了。DMA只需要配置一个方向内存到外设也就是把帧缓冲内存中的数据搬到SPI1的发送数据寄存器。初始化关键代码void MX_DMA_Init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); // 配置DMA1 Stream5为SPI1_TX具体流编号以参考手册为准 hdma_spi1_tx.Instance DMA1_Stream5; hdma_spi1_tx.Init.Request DMA_REQUEST_SPI1_TX; hdma_spi1_tx.Init.Direction DMA_MEMORY_TO_PERIPH; hdma_spi1_tx.Init.PeriphInc DMA_PINC_DISABLE; hdma_spi1_tx.Init.MemInc DMA_MINC_ENABLE; hdma_spi1_tx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_spi1_tx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_spi1_tx.Init.Mode DMA_NORMAL; hdma_spi1_tx.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_spi1_tx); __HAL_LINKDMA(hspi1, hdmatx, hdma_spi1_tx); }特别注意DMA的Mode用DMA_NORMAL就好不要用DMA_CIRCULAR因为每帧数据内容不同必须等一帧发完再启动下一帧传输。还有如果MCU端开启了D-CacheDMA从内存读取数据时可能读到D-Cache里的陈旧数据。解决办法是在启动DMA发送前执行SCB_CleanDCache_by_Addr((uint32_t*)buf, len)把缓存中的数据刷到内存。这个坑我最初没注意到屏幕花得一团糟排查了很久后来发现是DMA和D-Cache的一致性问题。3.3 初始化序列与像素写入屏幕驱动IC的初始化序列可以看作一小段“配置脚本”。以ST7789为例上电之后依次发送软件复位、退出睡眠、设置像素格式、设置扫描方向、设置伽马曲线、开启显示等命令。代码里通常用一张表来组织static const uint8_t st7789_init_cmds[] { 0x01, 0x00, // SWRESET 0x11, 0x00, // SLPOUT 0x3A, 0x01, 0x05, // COLMOD, 16bit/pixel RGB565 0x36, 0x01, 0x00, // MADCTL, 扫描方向 0x20, 0x00, // INVOFF 0x13, 0x00, // NORON 0x29, 0x00, // DISPON };发送时每条命令先拉低DC引脚发送命令字节拉高DC引脚再发送参数。整个过程用一个LCD_WriteCmd和LCD_WriteData封装起来。下面的写窗口和写像素函数是显示刷新的基础void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WriteCmd(0x2A); // CASET LCD_WriteData((x0 8) 0xFF); LCD_WriteData(x0 0xFF); LCD_WriteData((x1 8) 0xFF); LCD_WriteData(x1 0xFF); LCD_WriteCmd(0x2B); // RASET LCD_WriteData((y0 8) 0xFF); LCD_WriteData(y0 0xFF); LCD_WriteData((y1 8) 0xFF); LCD_WriteData(y1 0xFF); LCD_WriteCmd(0x2C); // RAMWR } void LCD_PushColors(uint16_t *colors, uint32_t len) { // DC置高 LCD_DC_Write(1); HAL_SPI_Transmit_DMA(hspi1, (uint8_t *)colors, len * 2); }写像素时RGB565格式下每个像素占两个字节高字节在前。STM32H750是小端处理器内存里一个uint16_t的高低位和屏幕要求的字节序相反所以在把帧缓冲数据发给屏之前要么在渲染层面调整字节序要么把SPI配置成16位数据模式利用H750和ST7789在16位传输时的对齐关系来规避字节序问题。我在LVGL适配里用的是自定义的lv_color_t格式处理和字节交换函数彻底解决字节序问题这一点在LVGL部分会再展开。3.4 双缓冲与撕裂控制很多人在这一步被“屏幕闪烁”和“撕裂”折磨。闪烁的本质是渲染速度跟不上刷新速度屏幕显示了一半新画面一半旧画面。撕裂更具体一点当你往屏里写一帧新数据写到一半的时候屏幕的扫描线已经走过了上半部分于是上半屏显示新一帧下半屏显示旧一帧中间有一条切线。解决撕裂最有效的办法是使用帧同步TE信号。支持TE引脚的屏幕会在每帧扫描开始前输出一个脉冲MCU检测到TE脉冲之后再启动这一帧的DMA发送保证写入速度和屏幕扫描速度对齐。实现上是把TE引脚配置成外部中断在中断服务函数里置一个标志位LVGL的刷新回调里等待这个标志位再发送数据。双缓冲则是另一个层面。LVGL做渲染时可以在内存里准备两个帧缓冲一个给LVGL绘制一个给DMA发送。绘制和发送交错进行避免共用同一块缓冲导致的覆盖问题。在LVGL里双缓冲通过lv_disp_draw_buf_init和flush_cb回调配合实现等下一节详细说。我最初的版本只有单缓冲刷屏的时候画面一闪一闪加上双缓冲和TE同步之后肉眼基本看不到撕裂和闪烁了。4. LVGL移植与资源优化4.1 工程组织与版本选择LVGL的版本现在主要分为8.3.x和9.x两大系。8.3是过去几年最稳定的版本资料多、教程多、和SquareLine Studio的兼容性也最好9.x的代码结构和API有比较大改动UI设计工具EEZ Studio以及最新版SquareLine Studio对9.x支持更好。如果你是从零开始的新项目我建议直接上9.x但如果你是照着老教程一步步来的8.3会更省心。我自己两个版本都跑过9.x在内存管理和渲染性能上确实有优化但API变动导致网上很多老代码用不了踩坑成本要考虑进去。工程结构上用CubeMX生成基础工程是最快的在Middlewares目录里加入LVGL源码也可以直接把LVGL源码整个放进工程。关键文件是lv_conf.h这是LVGL的配置总开关。记得在VC项目的头文件路径里加上lvgl和lvgl/src这两个目录否则编译会报找不到头文件。推荐在PC上先用VSCode加LVGL模拟器SDL2跑一遍UI逻辑确认界面没问题后再移植到STM32这个流程能省下大量烧录调试的时间。4.2 lv_conf.h关键配置lv_conf.h里的配置项很多但真正决定这套方案能不能跑起来的就那么几个#define LV_COLOR_DEPTH 16 #define LV_MEM_SIZE (64U * 1024U) #define LV_TICK_CUSTOM 1 #define LV_DISP_DEF_REFR_PERIOD 30 #define LV_USE_LOG 0 #define LV_FONT_MONTSERRAT_14 1 #define LV_FONT_SIMSUN_16_CJK 1LV_COLOR_DEPTH必须是16因为屏幕是RGB565如果设成32帧缓冲体积直接翻倍SPI传输数据量也翻倍性能会很难看。LV_MEM_SIZE是LVGL的动态内存池大小H750有1MB RAM这个值可以给64KB甚至128KB。给的越大能同时创建更多控件、更多动画但也意味着内存占用高具体按界面复杂程度调。LV_TICK_CUSTOM必须开启LVGL需要周期性的心跳信号来驱动动画和事件检测这里配合一个1ms的定时器中断。LV_DISP_DEF_REFR_PERIOD是LVGL刷新周期单位毫秒默认30ms一刷也就是约33fps的刷新频率。这个值调成20会更快但会消耗更多CPU和SPI带宽。LV_USE_LOG在调试时可以开发布时关掉能省一点内存和CPU开销。4.3 显示接口与输入设备适配LVGL迁移到具体硬件上核心工作是实现两个回调显示驱动回调flush_cb和输入设备回调read_cb。先看显示驱动。在LVGL 8.3里初始化显示驱动之后要设置一个绘制缓冲区和刷新回调。我用的配置是两块半屏缓冲每块320x240x2大约是150KB两块加起来300KBH750的RAM能扛得住。如果RAM紧张可以退化成单块半屏缓冲代价是局部刷新效率下降。flush_cb的核心逻辑是把LVGL传过来的绘制区域数据通过SPI DMA发送到屏幕。流程就是调用之前写好的LCD_SetWindow设置窗口然后LCD_PushColors启动DMA传输。但有一个关键点DMA发送是异步的LVGL只有在flush_cb里调用lv_disp_flush_ready(drv)之后才会认为这一块缓冲已经用完了可以继续往里面绘制。所以flush_cb不能在DMA发送开始时就立刻调用lv_disp_flush_ready必须等DMA传输完成中断里调用。我用一个全局标志位和SPI的DMA传输完成回调volatile uint8_t spi_dma_busy 0; void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { spi_dma_busy 0; lv_disp_flush_ready(disp_drv); } } void disp_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { LCD_SetWindow(area-x1, area-y1, area-x2, area-y2); spi_dma_busy 1; LCD_PushColors((uint16_t *)color_p, lv_area_get_size(area)); }有点反直觉的地方是LCD_PushColors里用的是HAL_SPI_Transmit_DMA函数本身立刻返回DMA在后台搬运数据这时候flush_cb不能马上执行lv_disp_flush_ready。如果不小心把lv_disp_flush_ready提前调用了LVGL就会认为缓冲已经空闲立刻往里面画新内容而DMA还在读这块内存画面就会出现半块新半块旧的错乱。输入设备的适配相对简单。电容触摸芯片GT911通过I2C返回触摸坐标在read_cb里读一次坐标调用lv_indev_set_gesture_dir或者直接把坐标写到style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />
返回列表