ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RK3568平台MJPEG硬件解码实战:用MPP框架解决CPU占用难题

RK3568平台MJPEG硬件解码实战:用MPP框架解决CPU占用难题 做嵌入式音视频开发的朋友估计都碰到过这种局面方案定了芯片选了RK3568客户甩过来一个USB摄像头输出的是MJPEG流要求在屏幕上实时预览。你第一反应可能是直接ffmpeg软解跑起来一看1080P30fps就吃掉一大半CPU系统直接卡顿其他业务线程全被拖累。这时候瑞芯微MPP框架里的MJPEG硬件解码就是唯一正解。这篇文章我从工程实现的角度把MJPEG硬件解码这件事彻底讲清楚包括MPP框架怎么理解、开发环境怎么准备、解码流程怎么拆、能直接抄作业的代码长什么样以及真枪实弹排过的坑和性能调优经验。不管你是做安防、USB摄像头采集、还是网关设备上的视频转发读完都能少走弯路。1. MPP框架整体认知MJPEG硬解到底是怎么回事1.1 MPP是瑞芯微媒体栈的总调度MPP全称是Media Process Platform是一套运行在Linux用户态的处理平台库。它把瑞芯微SoC里所有视频相关的硬件单元——VPU视频编解码器、VEPU编码器、JPEG协处理器、RGA图形加速器——统一封装成一致的API。应用程序通过MPP接口申请buffer、下发码流、收回解码帧根本不用关心底层硬件具体是哪个模块、寄存器怎么配、中断怎么处理这些都被MPP内部消化掉了。我之所以强调先建立对MPP的整体认知是因为很多新手一上来就搜mpp解码失败搜半天找不到有效信息根本原因是没搞清楚MPP在系统里处于什么位置。如果你去GitHub拉一下rockchip-linux/mpp会看到它分好几个层次最底层是hal层跟具体芯片的编解码硬件打交道往上是mpp组件层把解析器parser、解码器decoder、packet、buffer管理组装成流水线最上面才是面向应用层的mpi层也就是你include头文件后实际调用的那套接口。这个分层设计有点类似于我们平时写服务端的架构思路接口层稳定内部实现随芯片迭代。所以你会发现在RK3568和RK3588上写MJPEG解码业务代码几乎一模一样变的只是硬件特性参数和不同的能力上限。理解了这一层你后面遇到问题才知道该去哪里查是应用层调用不对还是底层驱动和库的版本不匹配。还有一个很容易被搞混的点如果你之前搞过海思或者全志的方案会发现它们的媒体平台也有一个叫MPP的东西。别搞混瑞芯微这套MPP和那些是独立演进的虽然上层设计思路上有相似之处但接口、数据流、buffer管理模型完全是自家的体系没有代码层面的兼容性。1.2 MJPEG硬解的特殊性每帧独立但要处理好边界MJPEG不是一种高效压缩的视频格式本质上就是一连串JPEG静态图片按时间顺序排列。好处是每个画面独立编码、延迟低、支持逐帧精确seek坏处是压缩率低、数据量大。但也正因为每帧独立编码硬件解码器不需要参考帧缓存可以逐帧快速解码流水线压力小。这里有个关键点要重点讲MPP的解码接口decode_put_packet/decode_get_frame设计上是以包为单位接收输入。对于H.264这些流格式MPP有内部解析器来拆分NALU但对于MJPEG它不会自动帮你找到每帧的边界你必须自己在字节流中定位JPEG的SOI0xFFD8和EOI0xFFD9标记把完整的一帧封装成一个MppPacket再喂给解码器。这是MJPEG硬解和H.264硬解在编程体验上最大的差异H.264是丢流进去MPP自己拆帧MJPEG是你得先切好帧再丢进去。如果这一步没做好把两个半截JPEG拼在一起送进去或者把一个JPEG从中切两半分别送解码器要么报错、要么输出花屏帧。我在项目里最初就是在切帧上偷懒结果一晚上都在查为什么偶发花屏。所以这个切帧逻辑是MJPEG硬解的重中之重后面代码部分我会给出一个可靠的实现方案。2. 开发前的环境准备工具链、设备树与基线验证2.1 交叉编译环境与MPP源码获取我自己的开发习惯是在x86_64主机上交叉编译aarch64目标这也是多数RK项目的常态。前置动作不复杂几十行命令就能搞定# 安装aarch64交叉编译器 sudo apt-get install -y gcc-aarch64-linux-gnu g-aarch64-linux-gnu # 获取mpp源码 git clone https://github.com/rockchip-linux/mpp.git cd mpp # 编译 mkdir build cd build cmake -DCMAKE_TOOLCHAIN_FILE../cmake/toolchains/aarch64.linux.cmake \ -DCMAKE_BUILD_TYPERelease \ -DBUILD_TESTON \ -DBUILD_SHARED_LIBSON make -j8编译完成之后build目录下会生成librockchip_mpp.so、librockchip_mpp_rga.so这些动态库test目录下会有mpi_dec_test这类测试工具。这里务必把BUILD_TEST打开后面做基线验证要靠它很多新手编译的时候图省事给关掉了结果到板子上出了问题还要自己写测试代码排查白白浪费时间。关于编译有两个小建议。第一交叉工具链版本尽量新一些MPP代码用到了较新的C/C特性老编译器会报一些奇怪的语法错误。我在Ubuntu 20.04上默认gcc-9编译没问题换成老的gcc-7就遇到过兼容性问题。第二如果产品最终是Flash空间紧张的小设备可以只编译需要的模块去掉测试程序甚至做静态链接体积会有明显差别。2.2 设备树与驱动确认编译好库你还得确认目标板上的内核驱动和设备树节点都是正常的。RK平台解码头用的硬件单元在不同芯片上叫法不一样RK3568上通常叫vpuRK3588上则是vpu-service。以RK3568为例开机后我一般按下面四步做检查# 1. 查看设备树节点是否存在 ls /sys/firmware/devicetree/base/ ls /sys/firmware/devicetree/base/vpufdea0000/ # 2. 查看驱动是否加载 dmesg | grep -i vpu dmesg | grep -i mpp # 3. 查看硬件设备节点是否创建 ls /dev/vpu_service 2/dev/null || ls /dev/mpp_service 2/dev/null # 4. 查看CMA内存情况 cat /proc/meminfo | grep Cma如果你发现设备树里vpu节点的status是disabled或者驱动加载时报interrupt、clk相关错误解码肯定跑不起来先解决内核问题再说。在较新内核上kernel侧已经使用video节点加media controller模型但用户态MPP依然走字符设备不太需要你关心。真正需要注意的是MPP用户态库和内核的mpp_service驱动是配套演进的两者版本差异太大ioctl会出现不认识命令的问题表现就是解码初始化失败或者控制命令返回错误。2.3 先用mpi_dec_test做基线验证写任何业务代码之前我强烈建议先跑一遍官方测试工具确认整个硬件解码链路是通的。编译出来的mpi_dec_test是在PC上执行的但链接的是交叉编译的库所以需要把librockchip_mpp.so、librockchip_mpp_rga.so、test/mpi_dec_test这三个文件一起拷贝到板子上然后执行./mpi_dec_test -t 6 -i input.mjpg -w 1920 -h 1080 -o out.yuv这里-t 6对应MPP_VIDEO_CodingMJPEG-w/-h是输入图像的宽高-o表示输出YUV文件。如果你手头没有MJPEG测试文件可以用ffmpeg软编码生成一个ffmpeg从摄像头推流、或从视频文件转成MJPEG的封装都行。重点在于先确认链路通不通。如果跑通了终端会输出类似decode mpi_dec_test run success的信息。看到这个说明设备树的vpu节点正常、MPP库和内核驱动兼容、硬件JPEG解码器可用接下来才轮到你写上层代码。如果连这一步都失败千万别去折腾应用层先解决环境问题——这是节省调试时间的重要顺序我见过太多人环境都没通就开始写代码最后绕了一大圈。3. 硬件解码流程拆解MPP内部在干什么3.1 解码上下文的生命周期一个标准的MPP解码流程生命周期可以用五步总结清楚mpp_create创建上下文、mpp_init绑定解码类型、通过mpi-control设置解码参数、循环执行decode_put_packet和decode_get_frame、最后mpp_destroy销毁所有资源。我想重点解释一下mpp_init这一步。MPP的上下文既可以是解码器也可以是编码器mpp_init里第二个参数定义工作模式MPP_CTX_DEC还是MPP_CTX_ENC第三个参数定义码流格式。对MJPEG来说你告诉它我这里是MJPEG类型底层就会去调用对应的JPEG硬件解码单元而不是走H.264的解码通道。这种先约定类型再开始干活的设计避免了解码器从码流中盲猜格式造成的问题。值得一提的是MJPEG解码和H.264解码在内部还有一个区别H.264解码需要维护参考帧列表解码器在显示顺序和参考顺序之间要做reorderMJPEG不用每一帧独立输出处理逻辑简单得多。所以MPP内部在管理MJPEG解码时buffer周转和帧输出顺序都很直接这也让硬件解码的延迟可以压得很低。3.2 核心数据结构速览MPP里最常见的几个数据结构我习惯把它们理解成快递件和货物的关系MppCtx整个解码会话的句柄相当于快递中转站MppApi函数指针集合相当于你手上可用的业务窗口MppPacket输入缓冲的抽象包含码流数据指针、长度、pts就是你发出去的包裹MppFrame输出帧包含图像数据、宽高、格式、pts就是你收到的货物。实际编码中MppPacket通过mpp_packet_init创建用mpp_packet_set_pts设置时间戳MppFrame是通过decode_get_frame从解码器取出来的。这里有一个特别容易出错的地方MppPacket创建后你填入的数据指针在调用decode_put_packet之后MPP会异步读写这块内存你不能再随便改它或者提前释放。一旦你改了正在被解码器读取的数据轻则花屏重则崩溃。我的经验是每帧数据包用完之后再回收或者给每帧单独分配一块buffer保证和MppPacket的生命周期严格同步。3.3 控制命令与info change机制MPP里很多能力都是通过mpi-control命令字实现的。常见几个命令mpi-control(ctx, MPP_DEC_SET_CFG, cfg); mpi-control(ctx, MPP_DEC_SET_INFO_CHANGE_READY, NULL); mpi-control(ctx, MPP_DEC_SET_PARSER_SPLIT_MODE, NULL);其中MPP_DEC_SET_INFO_CHANGE_READY是极其重要的一个控制命令。当MPP从码流中解析出分辨率、格式变化后当你从decode_get_frame拿到的帧带有info change标记时你必须通过这个命令告诉MPP我已经准备好接收新的输出格式了你继续吧。如果不发这个响应解码器会一直卡在等待配置就绪的状态不再输出后续帧。很多解码中途不输出帧的问题根源就是漏了这步。对于MJPEG因为每帧JPEG头里都有分辨率信息理论上每次换分辨率都会触发一次info change。如果你在做一个动态分辨率切换的应用比如摄像头从1080P切成720P就一定要在代码里做好这个处理否则切换后解码器大概率卡死。这里也是排解为什么大分辨率OK切成小分辨率后不出帧这一类问题的最快入口。4. 完整代码实现从零写一个MJPEG硬件解码器4.1 代码整体结构这里我给出一份精简但完整的demo直接对照着改就能跑。代码分三个部分MJPEG帧分割函数、解码器初始化、解码主循环。主流程是这样打开输入的MJPEG文件初始化MPPmpp_create mpp_init读取数据到缓存从缓存中找到一帧完整JPEG封装成MppPacket调用decode_put_packet调用decode_get_frame取输出帧如果有info change更新宽高并触发MPP_DEC_SET_INFO_CHANGE_READY处理帧数据这里简单打印信息循环直到输入结束清理资源。4.2 MJPEG帧分割正确切帧是第一要务要正确解码必须先正确切帧。JPEG帧由多个Marker段组成SOI是0xFFD8EOI是0xFFD9。实用中还需要注意JPEG数据里可能出现字节填充0xFF 0x00但完整帧的结尾一定是FFD9。下面是一个简化版切帧函数static int find_jpeg_frame(const uint8_t *buff, size_t size, size_t *offset, size_t *length) { for (size_t i 0; i size - 1; i) { if (buff[i] 0xFF buff[i 1] 0xD8) { // SOI for (size_t j i 2; j size - 1; j) { if (buff[j] 0xFF buff[j 1] 0xD9) { // EOI *offset i; *length j - i 2; return 1; } } // 找到SOI但没找到EOI说明当前数据不完整 return 0; } } return 0; }这个函数有两个边界情况需要注意一是数据可能在帧中间截断这时需要返回0等待继续读入后续数据二是如果有多个SOI出现在前一个EOI之前要从第一个SOI开始匹配完整帧。实际项目里我推荐的做法是先把输入数据缓存得足够大再循环查找完整帧遇到不完整的数据块用memmove把剩余数据移到缓冲区头部等后续数据补充后再继续查。我自己在数据切帧这个环节的教训是——千万不要为了减少一次memmove而省掉边界判断数据一旦错位解码出来的帧就会花屏而且这种问题特别难复现。4.3 初始化与解码主循环初始化代码#include rk_mpi.h #define BUFFER_SIZE (4 * 1024 * 1024) int main(int argc, char **argv) { const char *input_file input.mjpg; FILE *fp fopen(input_file, rb); if (!fp) { printf(open %s failed\n, input_file); return -1; } MppCtx ctx NULL; MppApi *mpi NULL; int ret mpp_create(ctx, mpi); if (ret ! MPP_OK) { printf(mpp_create failed: %d\n, ret); fclose(fp); return -1; } ret mpp_init(ctx, MPP_CTX_DEC, MPP_VIDEO_CodingMJPEG); if (ret ! MPP_OK) { printf(mpp_init failed: %d\n, ret); mpp_destroy(ctx); fclose(fp); return -1; } // 设置解码器帧缓冲池大小 MppDecCfg cfg NULL; mpp_dec_cfg_init(cfg); mpp_dec_cfg_set_u32(cfg, bufcnt, 6); mpi-control(ctx, MPP_DEC_SET_CFG, cfg); mpp_dec_cfg_deinit(cfg); printf(decode start\n); decode_loop(mpi, ctx, fp); mpp_destroy(ctx); fclose(fp); return 0; }解码主循环核心代码static int decode_loop(MppApi *mpi, MppCtx ctx, FILE *fp) { uint8_t *stream malloc(BUFFER_SIZE); if (!stream) return -1; size_t stream_len 0; int frame_count 0; while (1) { // 读数据到缓冲区留有尾部余量 if (stream_len BUFFER_SIZE - 1024 * 1024) { size_t n fread(stream stream_len, 1, 1024 * 1024, fp); if (n 0) { stream_len n; } else if (stream_len 0) { break; // 输入结束且没有剩余数据 } } size_t off 0, len 0; int found find_jpeg_frame(stream, stream_len, off, len); if (!found) { // 数据不完整大循环继续读 continue; } // 构造MPP输入包 MppPacket pkt NULL; mpp_packet_init(pkt, stream off, len); // 按30fps估算pts mpp_packet_set_pts(pkt, frame_count * 33333); int ret mpi-decode_put_packet(ctx, pkt); if (ret ! MPP_OK) { printf(put_packet failed: %d\n, ret); } mpp_packet_deinit(pkt); frame_count; // 取所有可用输出帧 MppFrame frame NULL; while (mpi-decode_get_frame(ctx, frame) MPP_OK frame) { if (mpp_frame_get_info_change(frame)) { RK_U32 w mpp_frame_get_width(frame); RK_U32 h mpp_frame_get_height(frame); printf(info change: %ux%u\n, w, h); mpi-control(ctx, MPP_DEC_SET_INFO_CHANGE_READY, NULL); } RK_U32 w mpp_frame_get_width(frame); RK_U32 h mpp_frame_get_height(frame); MppBuffer buf mpp_frame_get_buffer(frame); void *data mpp_buffer_get_ptr(buf); printf(frame %d: %ux%u buf%p\n, frame_count - 1, w, h, data); // 这里可以对你拿到的NV12帧做后续处理 mpp_frame_deinit(frame); frame NULL; } // 移走已消费的数据 memmove(stream, stream off len, stream_len - (off len)); stream_len - (off len); } free(stream); return 0; }有几点必须特别说明。第一喂包和取帧不是严格一一对应的硬件JPEG解码是流水线处理put_packet返回不等于这帧已经解码完成get_frame取到的可能是前面某几帧的输出也可能多次put_packet后才取到一帧。所以我在代码里用while循环把当前可取的帧全部取完而不是写put一帧就必须get一帧的逻辑否则在高码率或硬件忙时很容易丢帧。第二第一次get_frame返回的帧通常带着info change标记这时的width和height才真正可靠。MJPEG因为帧头里有SOF标记MPP能自动解析出宽高所以初始化时不显式设置width和height也没关系。第三MPP的buffer使用引用计数管理如果要把frame传给其他线程异步处理应当先调用mpp_buffer_inc_ref增加一个引用等处理完再mpp_buffer_dec_ref否则MPP可能把这帧的buffer回收复用造成画面错乱。5. 常见问题与性能优化实录5.1 mpp解码失败的高频原因平时被问得最多的就是mpp解码失败我按出现频率总结了一下基本能覆盖大部分情况现象根因规避方法put_packet返回错误输入包不是完整JPEG帧或packet生命周期没维护好严格按SOI/EOI切帧packet在异步解码期间保持有效拿到花屏帧帧数据被半途覆盖可能同一块buffer同时喂了多个packet每帧独立申请内存使用MPP buffer管理解码很久get_frame一直返回NULL漏掉info change应答或buffer池被占满及时发MPP_DEC_SET_INFO_CHANGE_READY调高bufcntmpp_init失败硬件节点不可用或编码类型枚举错误检查/dev/vpu_service和dmesg核对-t类型性能不足、CPU占用异常高业务层频繁memcpy或用了低效的格式转换减少拷贝利用RGA做转换第一个问题核心就是切帧。MJPEG每帧独立SOI/EOI之间才是一个完整可解码的JPEG。如果你把多个JPEG一次性当作一个packet喂进去MPP一般只会解出第一帧或者直接报错。建议把帧分割模块做成独立的函数用单测覆盖好边界情况再接入解码流程。第二个问题在多人协作项目里特别容易出。假设同一个packet buffer指针被喂了两次或者把前面已释放的buffer又交给新包使用解码线程异步读这部分数据时就会花屏。根源在于decode_put_packet是异步API当着解码器的面改buffer内容等于给自己挖坑。项目里我们后来统一改成一帧一buf的策略只有确认这一帧对应的packet不再被解码器使用后才把数据空间交还给缓冲池。第三个问题我在前面已经讲过这里再强调一下见到info change标记一定要按键应答。很多从海思平台转过来的朋友总习惯拿不到帧就重开decoder其实在MPP里这只是漏了一条control命令的小事重开反而引发其他状态问题。5.2 性能优化从解码到输出的全链路优化硬件解码本身已经非常高效真正的瓶颈通常在数据搬运和后处理。我调优的时候主要盯四个方向第一减少字节拷贝。MJPEG数据从网络或者USB读进来后不要在业务层反复memcpy可以直接让接收模块把数据写入MPP管理的buffer池或者至少保证一帧一个buffer避免组装大流时多一次拷贝。如果数据是DMA进来的务必搞清楚内存的cache一致性处理否则CPU读出来的数据可能是旧的。第二调好buffer池大小。通过解码cfg的bufcnt参数可以设置帧缓冲数量默认值在不同SDK版本里不一样但通常不会很大。在码率抖动的场景buffer偏少会导致解码器阻塞等待空闲buffer。实测把bufcnt调到6到8对稳定帧率有明显帮助。这个参数的本质是解码器最多允许多少帧在流水线里等待输出设太大内存占用高设太小容易卡。第三后续处理交给RGA。解码输出是NV12格式如果要转RGB或者做缩放千万别用CPU硬算。RK平台有RGA硬件2D加速器像RK3588上mpp配合rga是黄金搭档。一个NV12到RGBA的缩放转换CPU可能要几十毫秒RGA只需要几毫秒。很多解码很快但整体很卡的项目就是卡在CPU软转换这一步。第四注意缓存一致性。如果你拿到的buffer是带DMA性质的而且没有做cache操作CPU读取前要确保MPP做过必要的sync。如果发现画面偶发撕裂、有陈旧数据优先检查是否需要做cache_invalidate以及MPI层的DMA同步是否被打开。这个问题在低端芯片上更容易出现因为低端平台对cache一致性要求更严格。5.3 实测数据与选型参考最后给几个参考值方案预研阶段可以拿来做粗略估算。以RK3568平台、DDR4默认频率为例纯硬解1080P MJPEG30fps输入的情况下硬件解码本身消耗的CPU资源非常低主要CPU开销集中在数据搬运和业务处理如果用RGA把NV12再转成RGBA输出整体CPU占用依然能控制在个位数到十几百分比。RK3588的JPEG解码能力明显更强4K分辨率的MJPEG解码也能跑出不错的帧率实际帧率受码流大小影响比较大码流越高DDR带宽压力越大。需要强调不同内核、DDR频率、编译选项都会影响结果数据只是量级参考。如果你是做多路解码有个经验性原则MPP支持多个解码实例并行多路MJPEG可以每路创建一个contextMPP内部会做硬件调度。但多路并行时DDR带宽会成为最大瓶颈2080P×2或4K×1基本就能触到中端平台的带宽上限。规划方案时建议先按压缩码流大小输出帧格式转换两个维度估算带宽提前留好余量。最后分享一点实际体会做MJPEG硬解这件事真正的难点从来不在API本身而在数据切帧、buffer生命周期、info change应答这堆看起来不起眼的细节。我见过太多人在网上问mpp解码失败最后查出来都是切帧错了或者漏了应答命令。如果你认真把这个demo在板子上跑通再回头去看H.264、H.265的硬解会发现流程骨架完全一样只是多了一层参考帧的处理学起来会非常快。我自己也是从MJPEG这个小切口入手逐步摸清了MPP的整体设计逻辑后面做多路解码和大分辨率编码时很多坑都能提前避开。希望这篇文章能帮你少走点弯路。
返回列表