
如果你的HMI项目里也出现过这样的场景——界面要放一张2000×1500的现场布局图平时主页面流畅得像流水一点进图片详情画面直接卡成幻灯片拖动半天没反应调试串口里刷满了内存申请失败的日志——那这篇内容就是写给你的。我是在给某HMI设备做LVGL图显优化时正式接触SJPG的。那台设备用的是一块带RGB LCD接口的MCU外挂PSRAM屏幕分辨率1024×600需要展示一张2K级别的大图并支持拖动、缩放。最初直接拿LVGL默认流程加载JPEG结果一次全图解码耗时逼近两秒内存峰值冲到6MB以上在只有8MB PSRAM的板子上几乎把堆空间吃到渣都不剩。后来换成SJPGSplit JPEGLVGL的分块JPEG解码方案首屏打开速度从“等半分钟”变成“秒开”拖动帧率从2~3fps拉到了稳定20fps以上。这篇文章会把SJPG的原理、接入步骤、调参过程以及我踩过的几个坑全部讲透。不管你是刚接触LVGL的初学者还是已经在做量产级UI的老工程师应该都能从中找到有用的东西。1. 为什么会想动SJPG一次被大图卡到怀疑人生的HMI调试1.1 项目背景:小内存主控要撑起一张大图先还原一下我当时遇到的项目场景。那是一个带触摸屏的工业HMI设备交互逻辑并不复杂主页面几个功能按钮点击“布局总览”后进入图片查看页显示一张现场设备布局图。这张图是后端导出的JPEG格式分辨率2000×1500压缩后大约1.2MB。用户需要在查看页里拖动图片看细节也支持双指缩放。听起来很常规对吧可问题就出在“常规”二字上。主控是一颗M7级别、带LCD控制器、外挂PSRAM的MCU主频不算低但内存资源依然紧张。全图解码后按RGB565计算2000×1500×2字节整整6MB。要知道即便外挂8MB PSRAM扣掉帧缓冲、LVGL对象池、字体缓存之后能稳定分配给图片解码的也就1~2MB。更不用说JPEG解码是CPU密集型的串行运算一整张图算下来卡顿几乎不可避免。当时的调试日志我记得很清楚第一次点开图片页面串口直接打出内存分配失败系统进入异常处理。后来把PSRAM堆加大才勉强跑起来但要等1.8秒左右画面才完整出现拖动时每一帧都在重新解码帧率惨到只有2~3fps。这种体验别说给客户演示自己开发调试都觉得难受。1.2 现有方案的天花板:整图解码为什么注定卡要理解SJPG为什么有效得先想清楚一个很底层的问题为什么在MCU上直接解码整张JPEG这么吃力JPEG压缩本身是一种“有损、逐块编码”的格式。编码器把图像切成8×8的像素块对每个块做DCT变换、量化、霍夫曼编码块与块之间还有DC系数的差分依赖。这意味着解码时DCT块的顺序是严格串行的霍夫曼解码器必须从头开始一个块一个块地推进没法像PNG那样通过索引直接跳到任意像素区域。所以“只解码图片的某个局部”这件事在标准JPEG里几乎是做不到的——你想看右下角也得把左上角、中间、一直到右下角的块全部解码完。这就造成两个天然瓶颈。第一是CPU消耗。解码一张2000×1500的JPEG在240MHz级别的MCU上纯软件解码耗时通常在1~3秒之间。核心工作包括霍夫曼解码、反量化、反DCT、色彩空间转换每一步都是大量的乘加运算和位操作。第二是内存消耗。解码完后要输出一张完整的位图RGB565下6MBARGB8888下更是高达12MB。这在嵌入式环境里基本就是“不可用”的代名词。整体来看整图解码方案的性能天花板就摆在那里内存不够用CPU耗不起交互频率上不去。而且这只是静态图片的情况一旦涉及滚动、缩放每一帧都要重新走一遍完整的解码流程那就是灾难。有一阵子我都开始怀疑是不是该让UI设计把所有图片切成小格子手动管理了直到查资料时看到LVGL对SJPG的支持。2. SJPG到底动了什么“手术”分块解码的底层逻辑2.1 SJPG文件里装了什么一份带索引的JPEG分块包SJPG全称是Split JPEG它做的事情从文件格式上就能看出来。普通的文件流里只有一个JPEG数据体而SJPG文件的核心是一个“分块索引”外加若干独立的JPEG小块数据。你可以把它理解成一本带目录的习题册每一页上都有一道题目录告诉你每道题在第几页想翻哪题直接翻到对应页就行。实际转换过程也确实是这么干的。用来生成SJPG的Python脚本会读入一张标准JPEG按设定的瓦片尺寸常见的有64×64、80×80、128×128把图像切成网格对每一个瓦片独立执行JPEG编码最后把所有这些小JPEG块和位置索引一起打包成.sjpg文件。文件头会记录图像总宽高、瓦片宽高、瓦片行列数以及每个瓦片的偏移地址。解码端拿到这个文件不用从头读到尾只要读取文件头里对应的偏移量就能找到想要的某个瓦片数据块。这里有一个很关键的区别普通JPEG解码器面对的是“一整个未知结构的数据流”它必须顺着熵编码的链条从头推进SJPG由于每个瓦片都是独立编码的天然打破了块与块之间的依赖关系每个小JPEG块都可以单独解码成一小片RGB数据。这就是“可随机访问图像局部”的根基。2.2 只有看得见的像素才值得解码按需解码与瓦片命中SJPG真正优秀的地方是它完全围绕“视口驱动”来设计解码逻辑。LVGL在显示一张SJPG图片时并不会去解码整张图。它只计算当前显示区域内覆盖了哪些瓦片然后逐个解码这些瓦片。图片被拖动或者缩放时视口位置变了新进入视野的瓦片会被补解码已经滑出视野的瓦片可以按策略淘汰掉腾出内存给新瓦片使用。打个比方这就像你在图书馆隔着一扇橱窗看一排书架。橱窗大小有限你一次只能看清几本书。想看右边的书时不是让人把所有书架上的书全部搬到面前而是让管理员推着书架移动新出现的书才递给你看看过的书可以还回去。普通JPEG的方案相当于不管你看不看一口气把整个图书馆的藏书全部搬到你面前然后再告诉你“随便看”。这带来的性能提升是数量级的。一张2000×1500的图显示区域1024×600假设瓦片128×128那么当前视口大概覆盖9列×5行共45个瓦片。解码45个128×128的小块工作量跟解码整张图差了近17倍。而且拖动时每次坐标偏移往往只引入一排或一列的新瓦片解码量更小。对于缩放操作也一样只在目标缩放级别下重新解码可见瓦片即可不需要为那些被缩小到看不见的像素白白花费CPU时间。2.3 内存占用与解码效率的量化对比空谈原理不如直接算账。这里对比一下同样一张2000×1500的JPEG图片在RGB565颜色格式下的内存占用对比项普通JPEG整图解码SJPG分块解码解码输出内存2000×1500×2≈6MB视口内瓦片数×瓦片面积×2字节典型峰值内存6MB以上附近堆申请极易失败128×128瓦片×45块≈1.4MB仍可优化首次显示耗时全图解码典型1~3秒视口瓦片典型50~200ms拖动场景耗时每一帧重新整图解码仅解码新进入视口的瓦片命中缓存时近乎零开销大图无限屏滚动不适合一滚就卡天然适合内存恒定注意SJPG的峰值内存有一个重要特点它和图片总尺寸几乎解耦而只与视口大小和瓦片缓存数量有关。就算换成4000×3000的超大图只要屏幕还是1024×600解码缓冲区的量级也不会显著增加。这就让“在嵌入式设备上看超大图”这个需求在工程上变成了可行方案。SJPG当然也有代价。瓦片文件的体积会比原JPEG大一些因为每个瓦片是独立编码的失去了全局优化压缩的机会。实测中大概会膨胀20%~40%。但考虑到现在Flash和SD卡容量都起来了用少量存储空间换流畅交互是很划算的。3. 从普通JPEG到SJPG转换工具链与工程接入配置3.1 转换环节参数怎么选才不出幺蛾子工程侧要做的第一件事是生成.sjpg文件。SJPG支持工具主要是Python脚本底层可以选择使用TurboJPEG加速也可以退化到Pillow做纯软件编码。我在实际项目里用的方式是安装Pillow和turbojpeg的Python绑定转换速度快很多大图的体验差距尤其明显。假设脚本放在tools目录下常见命令长这样python lv_sjpg.py --input original.jpg --output layout.sjpg --bitdepth 16不同时期、不同分支的脚本参数命名可能有差别有的用-b有的用--bitdepth有的还要指定--tile-width和--tile-height。我的建议是先跑一遍python lv_sjpg.py --help看一眼实际支持的参数再动手。核心要决策的就两个一是颜色深度。--bitdepth 16对应RGB565屏幕也是RGB565时选它最省内存--bitdepth 32对应ARGB8888带透明度或者屏幕需要24位色时选它。二是瓦片尺寸。默认值通常够用但如果你发现拖动时滚动边缘频繁刷新可以考虑把瓦片调大如果发现内存紧张就调小一点。还有一点容易被忽略源图片最好先转成标准基线JPEGBaseline JPEG不要用渐进式JPEGProgressive JPEG。渐进式JPEG本身会存储多次扫描的数据转换脚本解析时容易出兼容性问题也增加不必要的体积。3.2 LVGL工程侧要改的四件事从宏开关到加载API生成好.sjpg文件后就是把它接进LVGL工程。我以LVGL 8/9两个版本通用的思路来讲具体宏名会有一点差异但改的东西是同一批。第一件事是打开解码器开关。在lv_conf.h里找到相关宏把SJPG使能/* 启用SJPG解码器 */ #define LV_USE_SJPG 1 /* 如果不需要Turbo JPEG整图解码可以关掉避免两个解码器干扰 */ #define LV_USE_TJPGD 0如果是LVGL 9之后的版本SJPG解码器通常会在初始化时自动注册不需要额外调用注册函数。但项目里如果用的LVGL 8或者厂家的定制分支很可能需要手动加一行初始化调用这点在编译链接时最容易暴露——如果提示找不到解码函数符号就去源码里找对应版本的初始化入口。第二件事是确保文件按可随机访问的方式提供给LVGL。SJPG通过偏移量快速读取瓦片的特性决定了它最好存放在文件系统里例如LittleFS、FATFS、SD卡让解码器可以seek到指定偏移量再读取。如果硬要把SJPG转成C数组烧进Flash虽然也能用但要确保底层解码流程支持按偏移读取内存流否则性能优势会打折扣。第三件事是配置图片缓存大小。LVGL的解码器之上有一层解码缓存命中缓存时可以跳过真实解码过程。SJPG拖动快不快、闪不闪很大程度上取决于这个缓存设置/* 缓存可容纳的图片解码结果数量需要按实际瓦片数和视口大小调整 */ #define LV_CACHE_DEF_SIZE 8在一个画面覆盖四五十个瓦片的场景里缓存只给8个肯定不够用画面会频繁重新解码造成闪烁。我最终在自定义配置里把缓存调到了64情况立刻好转。这个值要结合瓦片内存大小和可用RAM来权衡后面第四章会细说。第四件事是写加载代码。LVGL的API在8.x和9.x有命名差异但思路一致8.x写法大概这样lv_obj_t *img lv_img_create(parent); lv_img_set_src(img, A:/assets/layout.sjpg); lv_obj_set_pos(img, 0, 0);9.x里则是lv_image_create和lv_image_set_src底层会自动根据文件后缀或解码器匹配规则选中SJPG解码器。不需要手动指定图片格式这点非常省心。3.3 一套推荐的初始配置组合如果你不想做太多纠结这里给出一套我在1024×600屏、RGB565、8MB PSRAM环境下跑通的初始配置可以直接抄配置项推荐值备注瓦片尺寸128×128解码开销与文件体积的均衡点颜色深度RGB565屏幕原生格式内存占用最小LVGL图片缓存数64覆盖一屏瓦片数降低重复解码解码缓冲内存固定分配6MB区域中的2MB避免反复malloc碎片LVGL刷新方式脏矩形模式禁止整屏全刷这个组合在实测中首屏加载速度约150ms拖动帧率20fps左右内存峰值约2MB已经完全可以接受。如果你的屏幕分辨率更小、PSRAM更紧张可以再往下调瓦片尺寸和缓存数量。4. 实测效果与调参记录从3帧到20帧我做了什么4.1 压测方法与关键数据接入SJPG之后我做的第一件事是改变量方法。原来都是肉眼看流畅度后来发现这种做法根本没法定位瓶颈。我改成在主循环里用LVGL的时间戳统计帧间隔把每帧耗时打印到串口static uint32_t last_tick 0; uint32_t now lv_tick_get(); if (now - last_tick 2000) { LV_LOG_USER(fps %d, 1000 / (now - last_tick)); last_tick now; }这个简单的日志打印帮我拿到了第一批真实数据。整图JPEG方案下的表现是首屏显示耗时约1.8秒拖动时帧率2~3fps内存峰值6.2MB期间出现过多次malloc失败。换用SJPG并完成初始配置后首屏显示时间降到180ms帧率提升到8~12fps。内存峰值稳定在2MB以内malloc失败基本消失。8~12fps作为滑动查看场景来说其实已经“能用了”但想做到跟手机地图一样顺滑还不够。于是我开始调参。4.2 三组关键参数瓦片大小、缓存数量与预解码策略第一组试的是瓦片尺寸。从128×128改成80×80后每个瓦片的解码时间从约8ms降到约3ms但由于瓦片数量增多文件头索引和调度开销也上升整体帧率反而没有明显变化内存却因为瓦片元信息增多而小幅上涨。这说明在1024×600的视口下128×128是一个更均衡的选择。第二组试的是缓存数量。LVGL的解码缓存默认值很小最开始拖动时能看到明显的“马赛克一块块蹦出来”的现象。把缓存数量逐步从8加到32、64后画面的残块现象大幅缓解。我观察到一个规律缓存只要大于一屏瓦片数的一半往返拖动时就能有相当高的命中率不再反复去解那些刚被淘汰出去的瓦片。最终64缓存配合128×128瓦片帧率稳稳落在20fps以上。第三组是预解码策略。SJPG本身没有跨瓦片的预取机制但我在触摸释放后加了一个小逻辑根据当前视口坐标预判用户下一次的拖动方向把即将进入视野的那一列或一行瓦片预解码进缓存。这个优化把拖动结束后的画面补全时间从100ms以上压到了20ms以内。实现上不复杂就是调用一次解码目标瓦片的接口并把它放到缓存里。不过要注意预解码也有副作用。如果用户动作方向判断错了预解码的瓦片就浪费了白白消耗CPU和内存带宽。所以预取数量不要太多一次预取一行或一列就够了。4.3 颜色格式与内存带宽一个容易被忽略的隐形瓶颈随着帧率提高我开始注意到另一个瓶颈内存带宽。RGB565下1024×600的屏幕一帧全屏刷新要写约1.2MB数据。分辨率不变颜色格式越深刷屏带宽翻倍增长。当时板子的LCD接口是通过RGB并口刷屏的整屏刷新占用的带宽很大拖动图片时尤其明显。解决的办法有几个层面。最直接的是保持RGB565少用ARGB8888让颜色转换和解码输出的格式完全一致。如果解码器输出ARGB8888而屏幕是RGB565LVGL还要做一次逐像素颜色转换这一步在拖动时非常烧CPU。其次是用好LVGL的脏矩形刷新机制。只重绘内容变化的区域而不是整屏刷新。SJPG瓦片命中率高的时候视口移动量小脏矩形面积往往只有屏幕的很小一部分刷屏开销自然就降下来了。还有一个工程技巧把解码缓冲区放到DMA能直接访问的内存区域并尽量对齐到32字节。某些MCU的DMA和Cache处理不好解码缓冲起始地址不对齐会导致刷新时数据错位表现出来就是画面偶尔出现横纹或颜色花边。这个坑花了我很久才找到原因后面会再讲。5. 实战踩坑发虚、闪烁、崩溃这三兄弟5.1 图片发灰发紫颜色格式没对齐第一次把SJPG跑起来屏幕上显示出了画面但颜色整体发灰局部区域还透着一股诡异的紫色。我当时第一反应是转换脚本坏了重新生成好几次都一样。排查下来原因其实很俗转换时我用默认的ARGB8888输出而LVGL工程显示配置是RGB565。ARGB8888的数据被当成RGB565去解析字节序和通道全部错位自然发灰发紫。正确做法是让“转换脚本的颜色格式、LVGL的颜色配置、屏幕面板的原生格式”三者保持完全一致。如果屏幕面板是RGB565那么从源图转换到SJPG时就用--bitdepth 16如果是RGB888屏幕则用32位深度。项目里如果确实需要32位色也要注意LVGL内部是用小端还是大端存储像素必要时做一次字节序调整。这个坑也提醒我SJPG转换和LVGL配置最好做成工程文档的一部分而不是靠口头传承。同一份.sjpg在甲工程正常换到颜色格式不同的乙工程就会出现色偏不是文件坏了是配置没跟上。5.2 拖动时画面闪烁解码缓存不够导致的“边滚边解”另一个非常折磨人的问题是拖动图片时画面不断闪烁尤其是在快速、小幅度来回拖动时画面像得了帕金森一样抖。抓日志定位后确认问题出在LVGL解码缓存的命中率上。缓存数量默认值太小快速拖动时每一帧视口都移动了一大片瓦片新进入视口的瓦片还没来得及解完上一批瓦片就被从缓存里挤出去了。等用户又拖回来这些被挤出去的瓦片又要重新解码造成大量重复劳动和明显的帧间闪烁。我把缓存数量调大之后同时观察了内存占用情况从峰值来看缓存64块128×128瓦片RGB565下约2MBPSRAM完全扛得住。如果你发现调大缓存后内存还是紧张可以把瓦片尺寸改小到96×96牺牲一点单个瓦片的解码效率换回缓存数量。还有一个细节是设置LVGL更新显示时的重绘区域。SJPG解码是异步完成的如果画面更新把还未解码完成的瓦片区域也一并提交刷新就会出现局部窗口闪白或者旧画面残影。我当时的处理比较朴素在解码回调里把图像有效标志置位LVGL绘制回调里只绘制“已解码完成的瓦片区域”。这样可以避免把半成品数据送上屏幕。5.3 莫名崩死栈溢出与内存碎片问题SJPG接入后渲染速度上来了但稳定性又出问题了。现象是无规律地跑一会儿就死机有时在解码过程中有时在拖动结束后很久才崩。这种“时好时坏”的Bug最费时间。最后分两路排查。一路是栈空间。SJPG解码器内部会申请一个解码工作缓冲它运行在LVGL的任务上下文里。如果创建任务时给栈空间设得太小解码大瓦片时栈溢出就会随机踩坏相邻内存。我把LVGL任务的栈从默认的4KB调到8KB之后这类崩溃明显减少。另一路是内存碎片。项目启动时连续解码多张图每次解码都会临时申请一批瓦片缓冲用完释放。反复申请释放之后堆内存碎片化严重到某个临界点系统再申请大块连续内存就失败返回空指针解码器没有做空指针检查就直接解引用于是崩了。解决思路有三个解码缓冲在初始化时一次性预分配一个固定大小的池子解码器缓存的数量与瓦片最大尺寸绑定不随意变更每次从大图页面退出时主动释放所有瓦片缓存而不是让它们留在内存里占用碎片空间。做完这些稳定性才真正好了起来。如果你在项目里也进了这个坑我建议优先检查解码器初始化时是否申请了足够大的连续内存以及LVGL任务栈是否够用。别一上来就怀疑SJPG算法本身有问题它在这类场景下很多人已经在量产项目里验证过了。收官前的一点私人建议SJPG并不是一个“装上去就能永远不受苦”的万能药。它的好建立在你把瓦片尺寸、缓存数量、颜色格式、内存池这几样东西都安排明白的基础上。从我个人的项目经验来看前期花半小时把转换脚本参数和LVGL配置梳理清楚比后期调到半夜要划算得多。最后分享一个小技巧把生成SJPG的转换命令写成一个固定脚本连同源图片、目标图片、瓦片大小、颜色深度一起打包进项目仓库的tools目录并且在lv_conf.h里把对应配置项也注释清楚。这样同事接手项目或者三个月后的你自己重新打开工程都能快速知道“这张图是怎么来的、为什么这么配”。很多SJPG相关的奇怪问题追根溯源都发生在“源文件重新导出-重新转换-参数变了”这个流程里。流程固化了坑就少了一半。