一个多月前接到一个需求,给一块基于STM32的工业HMI面板做UI软件升级,客户提得很直接:开机后要有一段品牌视频动画,操作界面里还要加一个“操作演示”页面,点击后能播放教程视频。项目名字我倒挺喜欢,叫"STM32 MCU UI Software Upgrade Adds Video"——虽然听起来像把一个完整的软件升级功能硬生生插到MCU里,但本质上就是:在资源受限的STM32平台上,把视频播放能力集成进已有UI系统,并且通过固件升级的方式安全地发布到现场设备上。
这件事在PC上简单得不能再简单,但在MCU上完全是另一个世界。STM32这类单片机的Flash通常只有几百KB到2MB,内部SRAM更是以百KB计,CPU主频最高也就几百MHz,没有GPU,没有专用视频解码器(除非选带硬件JPEG的高端型号)。要在这样的硬件上让视频“看起来流畅且卡顿不明显”,需要对分辨率、编解码格式、内存布局、显示刷新、升级策略做一整套量体裁衣的设计。
我下面把整个项目从方案选型到落地排错的过程完整拆开。这篇文章适合正在做HMI、工业显示面板、LVGL界面,或者想在STM32上实现简易视频播放的工程师。文中没有厂商套话,都是我实际调板、改代码、看逻辑分析仪时得出的经验。
1. 需求分析与方案选型:UI软件加视频的坑在哪里
1.1 先搞清楚客户真正要的是什么
接到这种需求,第一件要做的事不是打开CubeMX配外设,而是把客户口中的“视频”翻译成技术语言。我遇到的真实需求是:开机动画大概15秒,是产品Logo渐变、旋转、淡出的品牌短片;操作演示视频约30秒,展示触屏操作流程。这两个场景都非常明确:短、循环、不需要声音(部分场景连声音都不要)、画面运动幅度不大、颜色比较鲜艳。
搞清楚这些之后就能发现,这类需求在MCU上有天然的优化空间——不需要硬解H.264/H.265,不需要同步音频。客户真正要的是一个“像视频一样符合预期观感”的视觉呈现,而不是一个通用播放器。
在方案评审会上,我一般会把“视频播放”拆成三种实现路径:
- 路径A:预渲染动画。让设计师把UI动效导出为连续帧序列(PNG/JPG),MCU按帧播放。适合纯UI动画,帧数少、画面规则。
- 路径B:MJPG视频软/硬解播放。MJPG是Motion JPEG,即一串JPEG帧连续播放。STM32高端型号自带硬件JPEG解码器,解起来很快。适合场景明确、时长较短的真实影像。
- 路径C:实时H.264解码。纯软件基本不现实(除非主频极高的Cortex-A内核),带硬件视频解码器的高端MCU可以做到,但成本、复杂度和功耗都会明显上升。
对这次项目来说,路径B是最优解。原因很简单:硬件解码器现成,解码本身不消耗CPU主频;JPEG压缩率虽然不如H.264,但对品牌动画和操作演示这类画面够用;MJPG每个视频帧彼此独立,万一解码掉一帧,绝对不会影响后续帧,容错性极好。
提示:千万不要一上来就追求在MCU上解码H.264,除非你选的是带硬解单元的跨界MCU。绝大多数STM32项目里,H.264的软解性能会让你怀疑人生。
1.2 芯片选型:视频播放不是“加个函数”那么简单
我们原方案用的是STM32F405RGT6,168MHz主频、192KB RAM、1MB Flash,跑LVGL显示静态界面和简单动画完全够用。但一评估“播放视频”这个需求,明显不够,最大瓶颈有三个:
- RAM不足。哪怕只播放320x240分辨率的RGB565画面,一帧缓冲也要3202402=153600字节,约150KB,几乎把F405的整个RAM吃光。这还不算LVGL自己的缓冲区。
- CPU算力不足。软件解码JPEG一个320x240帧,在Cortex-M4上大约需要80~150ms,也就是说算上解码的时间,极限帧率也就6~10fps,还不算图像传输到显示器的消耗。
- Flash不足。一段15秒的MJPG视频,假设15fps、平均每帧25KB,大概需要5.6MB,1MB的Flash根本放不下。
所以最后我们果断换了STM32H750VBT6。这颗料是Cortex-M7@480MHz,内置硬件JPEG编解码器(DJPEG)、LTDC液晶控制器、DMA2D图形加速器。最重要的是它有强大的外部存储接口,可以外挂SDRAM,把帧缓冲和UI缓冲都放到外部RAM,视频资源也可以放在外置QSPI Flash或SD卡里。
选型时我自己列了一个核心资源速查表,方便大家对比:
| 资源项 | F405(淘汰) | H750(选用) | 说明 |
|---|---|---|---|
| 主频 | 168MHz | 480MHz | M7有双发射流水线,实际算力约是M4的2倍以上 |
| 内部RAM | 192KB | 512KB(含TCM) | H750部分RAM跑零等待,但容量对视频仍不够 |
| 外部存储总线 | 无SDRAM控制器 | FMC SDRAM | 帧缓冲必须放外部SDRAM |
| 硬件JPEG | 无 | 支持 | 关键性优势,硬解JPEG几乎不耗CPU |
| LCD控制器 | 需外接屏驱 | 内置LTDC | 直接在RGB屏上显示,减少额外IC |
如果预算敏感,STM32F746/769系列也是好选择,同样带硬件JPEG和LTDC,只是主频稍低、SDRAM支持方式略有区别。但H750的性价比和Flexible布局在我这次项目里更合适。
1.3 视频格式与存储方案:MJPG是MCU玩家的最优解
视频源怎么来?客户手里是MP4的品牌动画文件。我们先用FFmpeg把它转成MJPG格式,同时做分辨率、帧率、码率三件事的限制:
- 分辨率:统一压成320x240。目标屏幕是800x480,播放区域占屏幕中间一块,320x240在5寸屏上看起来大小正合适,点对点不缩放也不会糊。
- 帧率:统一压成15fps。15fps对“品牌动画”和“操作演示”这类画面是达标线,既能保证流畅感,又能控制存储体积和解码负载。
- 码率/质量:JPEG Quality控制在70-80之间。画质损失肉眼看不明显,但压缩率提升非常可观。
执行命令很简单,我常用类似这条:
ffmpeg -i input.mp4 -vf "scale=320:240,fps=15" -c:v mjpeg -q:v 8 output.avi有人会问,为什么不直接用AVI里的MJPG,还要用FFmpeg转?因为客户源视频可能是H.264,本身就是帧间压缩,MCU没有H.264解码器,必须要转成MJPG。-q:v 8是FFmpeg里MJPG的质量参数,数值越小质量越高,我调过多次后觉得8对动画类素材最合适。
存储方案上,我们最终把视频文件放在SD卡里,原因很直接,视频资源体积在8~15MB之间,用外部QSPI Flash虽然也能放,但8MB容量的QSPI Flash成本并不低,而H750方案本来就可以内置SD卡接口,现场运维人员更新视频也非常方便——拔卡、拖文件、插回去即可。如果不想用SD卡,也可以把视频烧录进QSPI Flash,这时候需要自己管理文件系统或者在打包阶段把视频变成二进制数组,代码稍复杂,但减少了机械部件。
注意:视频文件存放介质的选择会影响UI软件升级方案的设计。如果放在SD卡,升级时只需要更新固件,视频单独拷贝;如果放在QSPI Flash,升级包就必须把视频资源一起打包,并做分区擦写。这一点决定了你后面升级链路怎么搭。
2. 视频播放核心实现:从解码到上屏的完整链路
2.1 硬件JPEG解码:H7系列凭什么能跑视频
STM32H7的硬件JPEG模块(DJPEG)在数据手册里很不起眼,但它确实是个好东西。它支持基线JPEG解码,输入压缩JPEG数据后,直接输出像素数据,支持YUV422、YUV420、RGB888等输出格式。对我们来说最方便的是直接输出RGB888,再在DMA2D里转成RGB565,或者直接在SDRAM里用RGB888做帧缓冲。
使用DJPEG的流程一般情况下是这样的:
- 配置DJPEG寄存器,开启解码。
- 输入JPEG文件的数据流(从SD卡读出来放到RAM)。
- 设置输出缓冲区地址(指向SDRAM中的帧缓冲)。
- 中断或轮询等待解码完成。
在H750上,对320x240的JPEG帧,硬解基本在5~15ms完成,远快于软件解码。这样15fps的帧间隔约66ms,解码只占四分之一不到,留出了大量CPU时间给LVGL刷新和处理触摸事件。
如果不用H7,用F4系列做软解,也不是完全不行,但要做好画面缩水:把分辨率压到160x120、帧率降到10fps、用最精简的JPEG解码库TinyJPEG,同时UI在视频播放期间就不能有其他动画了。我劝你如果不是极个别原型验证,别走这条路,投入产出比太低。
2.2 双缓冲与DMA2D:让解码和显示并行起来
MCU上播放视频最容易翻车的点就是“画面撕裂+闪烁”。原因是解码输出的数据直接写进正在被LTDC扫描的同一个内存区域,显示器一边扫描一边被改写,画面自然会撕裂。
解决办法就是双缓冲。我的做法是:在SDRAM中分配两块Frame Buffer,每块3202402 = 150KB;解码线程把当前帧写入Buffer A,同时LTDC正在从Buffer B读取并显示;解码完成后立即切换LTDC的当前帧地址到A,此时B作为下一帧的写入区。这就是典型的乒乓缓冲。
切换代码大概这样:
void play_video_frame(uint8_t *jpeg_data, uint32_t jpeg_size) { // 等待DJPEG解码完成上一帧 while (!is_djpeg_done()); // 把当前显示缓冲指针和写入缓冲指针互换 uint16_t *next_buf = (current_display_buf == fb0) ? fb1 : fb0; // 启动DJPEG解码 djpeg_start_decode(jpeg_data, jpeg_size, next_buf); // 解码完成后,等LTDC的VBlank到来再切换显示地址 wait_ltdc_vblank(); LTDC->Layer1->WHPCR &= ~LTDC_LxWHPCR_WHSTPOS; LTDC->Layer1->WHPCR |= (uint32_t)(new_wh_start_pos); LCD_FB_ADDR = (uint32_t)next_buf; current_display_buf = next_buf; }这里有一个容易被忽略的细节:不要在LTDC扫描到画面中间时切换显示地址,看起来只是横切一条线,实际观感就是画面在抖。正确做法是在VSYNC(垂直同步信号)中断里切换,CubeMX的LTDC驱动都提供了HAL_LTDC_Reload这类接口,配置成立即重新加载或者垂直同步后重新加载都行。
DMA2D在这里的作用是加速像素格式转换。DJPEG如果输出RGB888,而屏幕是RGB565,那每一帧都要做一次像素转换。挨个算的话320*240的像素量不小,用DMA2D做格式转换只是配置几个寄存器,一次DMA几乎不占CPU。我把这步放在解码完成后、等待VBlank的空闲时间里,实测对帧率影响可以忽略不计。
2.3 帧率控制:不追求60帧,追求“不卡”
写播放器时很多人上来就问“能不能跑到30fps”。我的看法是,在MCU上追求高帧率没意义,追求的应该是帧率稳定。嵌入式屏上体验最差的是隔一两秒掉一帧,比稳定15fps还难看。
所以我的播放循环用固定节奏:
#define FRAME_INTERVAL_MS 66 // 1000 / 15 static uint32_t last_tick = 0; void video_task(void) { while (video_playing) { uint32_t now = HAL_GetTick(); if (now - last_tick < FRAME_INTERVAL_MS) { osDelay(FRAME_INTERVAL_MS - (now - last_tick)); continue; } // 读取下一帧JPEG read_next_jpeg_from_sd(&jpeg_buf); // 解码+切换 play_video_frame(jpeg_buf, jpeg_size); last_tick = HAL_GetTick(); } }实际调试时发现,“读SD卡下一帧”经常是瓶颈,因为SD卡在连续读时如果遇到FATFS文件系统碎片,一次读可能卡几十毫秒。所以我加了一个预读缓冲:在读当前帧之前,已经把下一帧从SD卡读到另一块RAM里,这样解码和I/O可以部分并行。
实操心得:视频文件在SD卡里的碎片化程度对播放帧率影响巨大。推荐使用SD卡格式化工具把卡格式化成FAT32,文件尽量在SD卡刚格式化后一次性写入,避免反复擦写造成碎片。如果项目量比较大,建议直接用eMMC或QSPI Flash放视频文件,随机读取性能稳定得多。
3. 把视频资源塞进UI软件升级体系
3.1 固件分区规划:视频资源该放哪
原来的系统是两个分区:Bootloader区和App区。Bootloader负责启动跳转和基础IAP升级,App区就是实际UI固件。加入视频功能后,App区不仅代码量变大(JPEG驱动、FATFS、播放任务等),更重要的是视频资源不跟着代码走,而是单独作为一个“资源区”。
我最终的分区规划如下:
| 分区 | 存放介质 | 内容 | 容量 |
|---|---|---|---|
| Bootloader | 内部Flash(0x08000000) | 启动代码、IAP升级入口 | 64KB |
| App区 | 内部Flash | LVGL UI固件、视频播放模块 | 1MB |
| 资源区A | QSPI Flash | 当前视频、字体、图片 | 8MB |
| 资源区B | QSPI Flash | 备用视频资源区(升级缓冲) | 8MB |
| SD卡 | 外部SD | 升级包、临时视频文件 | 分区自带 |
资源区为什么要做A/B两个?这和App区做双备份是一个道理。如果升级视频资源时突然断电,只更新了半个视频文件,系统的UI界面还能跑,但一点“演示视频”就崩溃。A/B区加上资源区切换标志,可以保证至少有一个完整可用的视频资源。
如果你是第一次做这种设计,第一版可以简单一些:资源区只有一个,升级视频时先把整个视频文件复制到一个临时区,校验通过后再擦除正式区写入。但这样有个问题,QSPI Flash擦除很慢,8MB的块擦除可能要好几分钟。所以在项目一开始就规划好A/B方案,后面能少折腾。
3.2 升级包制作:UI固件和视频资源怎么打包
升级包的设计上,我沿用了自己一直在用的自描述格式。每个升级包文件包含:
- 文件头(魔数、版本号、目标设备型号)
- App固件镜像(带CRC32校验)
- 视频资源镜像(可能不止一个文件,有索引表)
- 文件尾(整体SHA256摘要)
打包用的Python脚本大概是这个结构:
import struct, hashlib, zlib, sys def make_upgrade_pkg(app_bin_path, video_dir, output_path): # 读App固件 with open(app_bin_path, 'rb') as f: app_bin = f.read() app_crc = zlib.crc32(app_bin) & 0xffffffff # 收集视频文件 video_entries = [] for root, dirs, files in os.walk(video_dir): for name in files: if name.endswith('.mjpg'): with open(os.path.join(root, name), 'rb') as f: data = f.read() video_entries.append({ 'name': name, 'data': data, 'crc': zlib.crc32(data) & 0xffffffff }) # 拼包 buf = b'STMCUPKG' buf += struct.pack('<I', 0x0100) # 版本 buf += struct.pack('<I', len(video_entries)) # 视频文件数 for e in video_entries: name_b = e['name'].encode('utf-8')[:32] buf += name_b + b'\x00' * (32 - len(name_b)) buf += struct.pack('<II', e['crc'], len(e['data'])) buf += struct.pack('<I', app_crc) buf += struct.pack('<I', len(app_bin)) buf += app_bin for e in video_entries: buf += e['data'] buf += hashlib.sha256(buf + b'SALT').digest() with open(output_path, 'wb') as f: f.write(buf) print(f'upgrade package generated: {output_path}, size={len(buf)}')这里最重要的一点是:App固件和视频资源在同一个包内,但拆包时互不阻塞。升级时先把App固件区覆写,成功后重启进入新的App;如果新App启动后检测到视频资源版本不符,再从包的剩余部分提取视频资源刷入资源区。这样即使视频资源刷写失败,App的代码也已经是新版本,系统具备回退能力。
3.3 升级流程与断电恢复设计
整个升级流程我在状态机里这样组织:
- Bootloader在启动时检查标志位,决定是跳转到App还是进入升级模式。
- App收到升级指令后,把升级包从SD卡中读出来,先校验整体SHA256。
- 校验通过后,把解包出来的App固件写入内部Flash的临时区。
- 写入完成后,设置“App新固件待激活”标志,并复位进入Bootloader。
- Bootloader发现待激活标志,把临时区的App固件搬运到正式App区,然后跳转。
- 新App启动后,检查视频资源版本号,如果不匹配,就继续从升级包里刷写视频资源区。
- 视频资源刷写完并校验CRC后,清除升级标志,系统进入正常界面。
断电恢复的关键点在于每一步都支持重复执行。也就是不管断电发生在第3步还是第6步,再次上电后Bootloader或App都应该能识别到“上次升级没完成”,自动从起始位置重新开始,而不是直接进入一个砖头状态。
实际工程中,我还会在SD卡里放一个upgrade.log文件,每次状态转移都记录一个字节的偏移量。排查现场问题时,看一眼日志就知道机器是在哪一步断电的。这个习惯救过我不少次。
注意:做内部Flash写入时,H750的Flash是2个Bank,如果条件允许,尽量用双Bank方案,Bank0跑旧固件,Bank1擦写新固件,写完直接切映射。这样连“临时区搬运”都省了,升级时间能缩短一大截。
4. 调试实录与常见问题排查
4.1 现象一:画面撕裂、闪烁
第一次把视频通路调通时,画面一直在闪,还有明显的撕裂横线。我当时就怀疑是双缓冲换页时机不对,确认之后发现果然是直接在DJPEG解码完成中断里改了LTDC层地址,而这个中断可能发生在屏幕扫描的任意时间点。
后来我把切页操作挪到了LTDC的VSYNC中断里,并且用HAL_LTDC_Reload做重新加载,横线消失。如果你用的MCU没有LTDC(比如很多F1/F4型号通过SPI驱动屏幕),那画面撕裂可能更严重,因为SPI屏根本不知道当前扫描到哪一行。这种时候要么降低刷新率,要么把帧率压到10fps以下,保证写入速度和屏幕刷新速度差距不大,视觉撕裂就不太容易被感知。
4.2 现象二:升级中途断电变砖
这是我最担心的场景,结果还真被客户现场遇到过。排查后发现,当时用的是“老固件加单视频区”方案,升级过程里已经把App区擦了,但视频资源还没刷完,断电后再开机Bootloader跳转到残缺的App,黑屏死机。
后面我把App升级改成“先写临时区、标记激活、再搬运”,视频资源改成A/B区,并且给每块区域加了状态标志。现在哪怕在最坏的时机断电,最多是升级没完成,重新上电后所有代码都会检测到状态不完整并自动重新升级。客户现场再也没报过变砖问题。
4.3 现象三:视频资源占用太大,升级总是失败
刚把升级包做出来时,一个包有14MB,SD卡写入没问题,但QSPI Flash擦写时间太长,现场操作人员等得不耐烦,偶尔还会误拔电源。
我做的优化有三个:一是把视频分辨率从640x360降到320x240,文件体积直接降了60%;二是把帧率统一压到15fps,而不是源视频的30fps;三是升级时先检查当前视频资源区的CRC,如果和要升级的版本一致,直接跳过刷写。这样大部分现场升级只需要刷App固件(400KB左右),加一个小的资源增量包,而不是每次把14MB全部重写。
4.4 常见问题速查表
| 问题 | 可能原因 | 解决思路 |
|---|---|---|
| 播放卡顿 | SD卡读取慢、文件碎片多 | 用预读缓冲;格式化SD卡后一次性写入视频文件;考虑换eMMC |
| 画面花屏 | DJPEG输出格式和LTDC配置不一致 | 检查RGB888/RGB565的像素格式和行对齐 |
| 解码还没完成就切页 | 没有同步DJPEG完成中断 | 用is_djpeg_done()轮询或DMA传输完成中断 |
| 升级后视频无声音 | MJPG本身不含音频轨道 | 如果需要声音,需要单独放一个音频文件,用DAC或I2S播放 |
| 升级时UI响应慢 | 刷Flash时中断频繁触发 | 刷写期间关掉不必要外设中断,只保留看门狗和升级状态机 |
| 视频播放时UI触摸无响应 | LVGL任务和视频任务抢占CPU | 把LVGL任务优先级调低于视频解码,但高于视频IO预读 |
最后再分享一个我在实际使用中的小体会:视频功能放到MCU UI软件里,本质上是一种“体验重构”,它改变了用户对整个产品的感知,但如果你只顾着把解码器跑起来,忽略了升级链路、资源管理、异常恢复这些“看不见的细节”,到现场出一次问题,前面所有功夫都会变成维护成本。做完这个项目后,我自己的感悟是:嵌入式视频播放的难点其实不在“播放”本身,而在“可交付、可维护、可升级”。先把升级和资源管理想清楚,再谈画面效果,才不会在客户现场手忙脚乱。