ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Rockchip MPP硬解H264:从文件读取到YUV输出的完整实践指南

Rockchip MPP硬解H264:从文件读取到YUV输出的完整实践指南 1. 解码这件事为什么我劝你直接上MPP先说个背景。我在RK3588和RK3399的板子上折腾视频解码不是一两天了之前处理H264流的方式很“省事”直接调FFmpeg软解反正代码网上到处都是一抄就能跑。但真到了实际项目里问题一下就冒出来了——软解在4K分辨率下CPU占用直接飙到七八十机器还没开始干正事就已经热得不行而且多路解码的需求一来软解方案基本就废了。这个时候Rockchip MPPMedia Process Platform就成了绕不开的选择。MPP是Rockchip官方的媒体处理平台它把硬件编解码能力封装成了一层API接口。简单理解H264解码这件事CPU不再负责逐块计算而是把码流数据交给板子上的VPUVideo Processing Unit硬件模块去处理CPU只负责把数据送进去、把结果拿出来。解码100帧4K视频CPU占用可能都不到10%。这个标题里说“从文件到YUV的完整流程”实际上就是在讲一件非常具体的事怎么把硬盘上的一个.h264文件通过MPP解码得到原始的YUV视频帧数据并保存下来。这几乎是一切视频应用的地基——无论是做显示、推流、录像还是做AI分析最终都要落在这份YUV数据上。这篇文章适合谁看准备在Rockchip平台做视频产品的嵌入式开发工程师或者刚拿到开发板想把硬解跑通的入门选手。我会把整个流程拆开揉碎从环境准备、API理解、代码结构到踩坑记录都讲一遍尽量做到你照着就能复现。2. 动手前先花十分钟确认板子和环境很多人在跑MPP时第一道坎不是代码逻辑而是环境里的人为障碍。别急着抄代码先把板子上的MPP验证清楚后面能省下大量排查时间。2.1 板子里MPP到底有没有Rockchip的SDK里通常会预编译好MPP库但也有精简版系统或者自己裁剪的内核漏了模块。第一步确认用户空间库ls /usr/lib/librkmpp* # 或者 ls /usr/lib/*mpp*正常会看到librckmpp.so这样的文件不同SDK版本命名略有差异。如果找不到就得从Rockchip的官方仓库拉源码自己编译。MPP的源码量不大交叉编译也比较直接git clone https://github.com/rockchip-linux/mpp.git cd mpp mkdir build cd build cmake .. -DCMAKE_TOOLCHAIN_FILEyour_toolchain.cmake -DCMAKE_BUILD_TYPERelease make -j8 make install编译安装完成之后板子上还会出现对应的头文件目录这一步会直接决定你后面能不能成功交叉编译自己的代码建议提前确认。2.2 先跑一跑官方自带的测试程序MPP源码里自带了一个mpi_dec_test这是排查环境最有力的工具。在编译好的目录里能找到它直接拿一个H264文件喂给它mpi_dec_test -t 7 -i input.h264 -o output.yuv -w 1920 -h 1080 -n 100-t 7表示H264解码参考源码里的MPP_VIDEO_CodingAVC定义-w和-h是期望分辨率-n指定解码帧数。如果这个程序能稳定输出YUV文件说明内核里的VPU驱动正常MPP库也正常接下来可以放心写自己的程序。如果这一步就花屏、崩溃或者提示mpp_buffer_group_get failed先回头解决环境问题。一个容易被忽略的点mpi_dec_test解码得到的YUV文件可能很大4K分辨率一帧就是1920x1080x1.5字节也就是单帧约3MB100帧就是300MB。提前准备足够的存储空间不要看到文件写到一半设备报No space left on device才知道着急。3. 解码器初始化的技术细节决定你后面顺不顺畅MPP初始化阶段看似只有几行代码但里面藏着的对象关系、参数含义和码流耦合关系值得先讲清楚。3.1 理解MppCtx和MppApi的关系MPP的核心设计思路是一个解码器实例对应一个MppCtx上下文而真正干活的是挂在上下文上的MppApi接口集。创建一个解码器的代码长这样MppCtx ctx NULL; MppApi *mpi NULL; mpp_create(ctx, mpi); MPP_RET ret mpp_init(ctx, MPP_CTX_DEC, MPP_VIDEO_CodingAVC); if (ret ! MPP_OK) { printf(mpp_init failed\n); return -1; }MPP_CTX_DEC声明这是解码器MPP_VIDEO_CodingAVC声明输入的是H264码流。这两个枚举是写死的但很多人会在这一步开始出问题比如拿VBR的H265流直接喂给AVC解码器——后续就只会得到一堆decode error。建议封装初始化参数时把编码类型明确作为配置项这样将来做多格式兼容只需要改这里。3.2 配置解析模式split_parse的关键作用初始化完之后MPP默认假定你送入的每个包都恰好是完整的、边界规整的帧数据。但现实里从文件或网络读到的H264流通常是一整段连续码流不一定恰好按帧切好。这时就需要开启MPP的“分包解析”能力RK_U32 split_parse 1; mpp_dec_cfg_set_u32(cfg, split_parse, split_parse); mpp_dec_cfg_apply(ctx, cfg);开启split_parse之后MPP会在内部帮你做NALU分割自动从码流中找出帧边界。这就好比做快递分拣你不需要提前把每个包裹单独递过去只要告诉机器“这堆东西里有包裹你自己分”它就能按规则拆好。那个mpi_dec_test跑通的前提之一就是开了这个选项。如果你已经做好了帧切割每个MppPacket都恰好是一帧完整数据那可以不开split_parse还能省一点解析开销。但我个人建议默认开启省心远大于那点性能损失。3.3 SPS/PPS和初始化解码器的耦合关系H264码流里有两个最关键的非图像NALUSPS序列参数集和PPS图像参数集它们和硬件解码是强耦合的。MPP的硬件解码器必须拿到SPS/PPS才能完成初始化比如正确配置参考帧数量和分辨率。所以从文件读码流时不要直接就往解码器里灌数据。要先把码流扫描一遍找到SPS和PPS以00 00 00 01 67开头的是SPS00 00 00 01 68开头的是PPS把它们作为一个包先送进去。如果你把SPS/PPS混在后面的数据里或者干脆丢掉最常见的表现就是第一帧解不出来后面连续报错或者画面变成绿色。提示直接从视频文件里拆码流时一定保留SPS/PPS很多在线视频封装的H264里它们只出现在关键帧前别因为第一包没解出图像就怀疑代码。4. 读懂文件里的H264再把数据喂给MPP现在进入流程里最繁复制、也最容易出错的数据搬运阶段读取文件、拆包、包装成MppPacket。4.1 H264 Annex-B格式与起始码的识别我们最常见的H264文件格式是Annex-B也就是用起始码Start Code分隔每个NALU。起始码有三种形态00 00 00 014字节起始码最常见00 00 013字节起始码出现在某些编码器输出中读取文件时要按起始码把码流拆成独立的NALUstatic uint8_t *find_start_code(uint8_t *buf, int len) { for (int i 0; i len - 3; i) { if (buf[i] 0 buf[i1] 0 buf[i2] 0 buf[i3] 1) return buf i; if (buf[i] 0 buf[i1] 0 buf[i2] 1) return buf i; } return NULL; }找到两个起始码之间的区域就是一个完整的NALU。这个拆分逻辑虽然基础但容易在边界处理上出错——比如文件末尾的NALU没有后续起始码以及3字节和4字节起始码混合出现的情况。我处理的办法是先把整个文件读进内存统一扫描最后一个NALU的结束位置就是文件末尾。4.2 把NALU封装成MppPacket并送入解码器拿到NALU之后MPP要求把它封装成MppPacket。这里不用每次都创建新包MPP提供了buffer复用的机制MppPacket packet NULL; mpp_packet_init(packet, nal_data, nal_size); mpp_packet_set_pts(packet, current_pts); mpp_packet_set_eos(packet, is_eos); ret mpi-decode_put_packet(ctx, packet); if (ret ! MPP_OK) { printf(decode put packet failed\n); }注意decode_put_packet是异步的它只是把数据交到MPP内部的队列不保证立刻解码完成。所以后面必须配合decode_get_frame来把解码结果取出来。这个“一放一取”的异步模型是MPP设计里和软解最大的区别很多初学者在这里会搞乱思路——以为put完就能马上get到同一帧的图像数据其实中间隔着一个硬件解码队列。4.3 pts和时间戳应该怎么传mpp_packet_set_pts是很多文档里一笔带过的API但它和实际项目的关联很大。MPP解码时帧序和包序不一定一致因为B帧的存在导致解码顺序和显示顺序不同。如果你要做帧同步显示必须从包一进来就维护好pts和帧索引的对应关系。不过如果你只是做“文件到YUV”的纯离线解码没有实时显示需求可以直接把pts设为0或随便填一个递增的数。MPP内部不会为0的pts做特殊处理它只是原样带上。5. 解码主循环与YUV帧的采集保存这个阶段是整条链路的核心拿帧、读信息、取数据、存文件。写对这段代码离出片只剩一步。5.1 解码主循环的正确姿态主循环应该写成“喂包 取帧”交替进行的形态MppFrame frame NULL; do { // 送一包 ret mpi-decode_put_packet(ctx, packet); // 尽量取帧 do { ret mpi-decode_get_frame(ctx, frame); if (frame ! NULL) { // 处理一帧YUV handle_frame(frame); mpp_frame_deinit(frame); frame NULL; } else { break; } } while (1); // 移动文件指针准备下一包 } while (bytes_read 0);两次get_frame之间的循环很关键。一次put_packet可能对应0帧、1帧也可能对应多帧输出尤其是B帧较多的码流一包数据解出两帧的情况很常见。我见过不少人只做了一次get_frame就继续去读下一个包结果解码器队列里堆积帧越来越多最后内存暴涨。正确做法是每次put之后把能get的帧全部取出来。5.2 一包一帧和关键帧的判断MPP里面decode_get_frame返回的MppFrame不一定都是普通视频帧还有可能是只包含信息的“元数据帧”比如分辨率变化、EOS标志等。拿到MppFrame之后首先判断它是否携带有效数据uint32_t width mpp_frame_get_width(frame); uint32_t height mpp_frame_get_height(frame); MppBuffer buffer mpp_frame_get_buffer(frame);如果buffer为空这帧里没有YUV数据一般可以直接丢弃继续取下一帧。关键帧判断用的是uint32_t eos mpp_frame_get_eos(frame);EOS帧是解码器告诉我们“所有数据都处理完了”的信号。读文件时如果给最后一个包设置了EOS标志那么最终取到的帧一定会带EOS这时整个解码循环就应该结束。5.3 从MppFrame取YUV数据并写入文件获取到有效buffer之后核心工作就变成了把数据从MPP的buffer拷贝到我们自己的内存里。MPP的buffer可以映射到用户空间void *data mpp_buffer_get_ptr(buffer); size_t size mpp_buffer_get_size(buffer); fwrite(data, 1, size, fp_yuv);但直接按size写会有个隐患MPP buffer的内部布局是带对齐的分配的实际大小往往大于有效像素数据直接写会把padding垃圾数据也写进文件。正确的做法是按YUV格式的实际尺寸来写。H264解码器输出通常默认是NV12格式也就是YUV420半平面Semi-Planar。NV12的布局是先一整块Y平面宽x高字节然后是UV交织平面宽x高/2字节。专业一点讲Y分量的stride可能不等于width因为硬件要对齐一般是16字节或32字节对齐。所以严谨的写法是int width mpp_frame_get_width(frame); int height mpp_frame_get_height(frame); int h_stride mpp_frame_get_hor_stride(frame); int v_stride mpp_frame_get_ver_stride(frame); uint8_t *base (uint8_t *)mpp_buffer_get_ptr(buffer); uint8_t *y_plane base; uint8_t *uv_plane base h_stride * v_stride; // 逐行拷贝Y平面跳过stride padding for (int i 0; i height; i) { fwrite(y_plane i * h_stride, 1, width, fp_yuv); } // 逐行拷贝UV平面 for (int i 0; i height / 2; i) { fwrite(uv_plane i * h_stride, 1, width, fp_yuv); }这块逻辑是“文件到YUV”最容易出错但又最少被文档细讲的地方直接fwrite会存进杂质只写width的话又会丢数据。建议刚开始调试时就把width、h_stride、v_stride都打印出来看一眼心里有底。5.4 这些YUV格式和RGB之间怎么换算NV12在YUV420家族里属于半平面格式不同于更常见的I420YUV420P三个平面完全分开。它们的区别就在UV数据的存放方式上I420的U平面和V平面各自独立NV12把它们交错放在一起按U、V、U、V循环排列。所以NV12又叫YUV420SP。如果你后续要做AI推理RKNN等框架接受的通常是NV12/YCbCr格式可以直接复用MPP的输出省去一次颜色空间转换。但如果要做RGB显示就需要转一遍公式Y (YUV_Y 8); R (Y 1.402 * (V - 128)) 8; G (Y - 0.344 * (U - 128) - 0.714 * (V - 128)) 8; B (Y 1.772 * (U - 128)) 8;这部分是典型的重复工作实际项目中建议交给RGA硬件做格式转换CPU做一个像素点的转RGB的计算开销都嫌大更别提4K视频了。6. 实测踩坑解码结果不对时我查了哪些地方整个流程跑通不难但解码结果“看起来不对”时很多人的排查方向是乱的。我把这两年遇到的高频问题整理成一张排查表每个问题后面跟着真实的定位思路。6.1 解码出的YUV颜色发绿或者花屏颜色发绿通常意味着YUV格式不匹配。MPP输出的默认格式在不同SDK版本里有过调整有的版本默认NV12有的版本会跟随码流的色彩空间信息自动选择。排查时先把输出像素格式打出来MppFrameFormat fmt mpp_frame_get_fmt(frame); printf(format: %c%c%c%c\n, (fmt 24) 0xFF, (fmt 16) 0xFF, (fmt 8) 0xFF, fmt 0xFF);如果格式不是MPP_FMT_YUV420SP对应NV12那大概率是MPP按码流里的色彩描述自己切换到了其他格式。要强制固定mpp_dec_cfg_set_u32(cfg, output_format, MPP_FMT_YUV420SP); mpp_dec_cfg_apply(ctx, cfg);花屏的另一大类原因是SPS/PPS不完整或者参考帧缺失尤其是在输入流本身就是截断过的视频时比如直接从直播流里录下来的片段。这种情况没有通用解法只能保证SPS/PPS作为第一包送进去。6.2 buffer复用导致画面出现小块错位MPP解码器为了性能内部buffer是复用的上一帧的buffer可能被下一帧覆盖。如果你把MppFrame的指针直接作为pixel buffer往渲染器里送下一帧解出来之后上一帧的数据已经变了。这也是很多人保存YUV时发现“帧A的画面里混着帧B的内容”的原因。正确的拷贝时机是在从decode_get_frame拿到帧之后立刻拷贝或者使用MPP的mpp_frame_get_buffer手动的增加引用计数。我自己的习惯是需求是离线保存时拿到buffer后立刻拷到用户自己的临时缓冲区实时显示时则用DRM带dma-buf的方式直接引用MPP buffer避免拷贝但要做好生命周期管理。6.3 EOS帧没等到程序卡在decode_get_frame有一些码流本身有问题或者你设置了EOS但没有把它放在最后一个数据包上会导致解码器内部还有残留帧但decode_get_frame一直返回NULL。这种时候需要做“冲刷”flush操作。MPP没有专门的flush接口比较通用的做法是设置EOS之后循环调用decode_get_frame直到返回带EOS标志的帧这时候一定把所有余帧拿干净了。如果设置了EOS之后拿不到任何帧多半是前面的包没有真正进入解码器队列检查一下decode_put_packet的返回值和mpp_packet_get_eos是否真的设置成功。6.4 大分辨率视频在弱设备上卡顿的排查路径如果板子比较弱比如RK3288而视频是4K首先要确认VPU有没有降频或热保护。在/sys/class/mpp_service或通过vpu_service节点可以查看当前的频率信息但不建议在正式代码里依赖路径。更实际的优化是先确认有没有开split_parse以及有没有用mpp_buffer_get从buffer group里复用内存而不是每个包都叫分配。频繁分配和释放是卡顿的真凶之一。7. 性能优化思路解码只是起点后面才是大头把YUV拿到手只是拿到了原材料。实际产品里后面连着什么决定了整体架构怎么做。7.1 如果目标是把YUV送去显示不要经过内存拷贝再给显示层Rockchip平台推荐的是drm buffer配合MPP的解码buffer直接引用。MPP的mpp_buffer本身支持mmap导出dma-buf fd有了fd就能给DRM做plane显示这一步能把整个显示路径的延迟压到非常低。流程大概是dmabuf_fd mpp_buffer_get_fd(mpp_buffer); // 把这个fd交给drmModeAddFB / drmModePageFlip很多SDK里已经有现成的示例直接抄比重新发明轮子靠谱。我见过不少项目在这里走了弯路硬生生把YUV拷到CPU内存再转成GBM bufferGPU没用到CPU先爆了。7.2 如果目标是把YUV送去做AI推理RKNN的输入格式和MPP的输出格式之间往往就差一个RGA。RGA是Rockchip的2D硬件加速器能在不经过CPU的情况下做格式转换和裁剪缩放。一个典型链路是MPP解码 - 得到NV12 - RGA裁剪缩放 - 转成RGB或保持NV12 - 直接喂给RKNN模型。这条链路全程都是硬件加速CPU占用几乎为零。7.3 多路解码的资源规划MPP支持创建多个上下文同时解码但VPU的资源是有限的具体能同时解几路取决于分辨率和码率。有一个粗略的计算方式RK3588的VPU强劲一些4K解码能力大概在60fps左右如果解8路1080p30大概率没问题但要给每路预留独立的MppBufferGroup避免解码器之间因buffer不足互相阻塞。多路的性能瓶颈通常不在VPU的算力上而在内存带宽所以多路场景下斟酌buffer的分配策略特别重要。注意不要多线程共享同一个MppCtx实例。MPP的上下文不是线程安全的同一路的decode_put_packet和decode_get_frame要在同一个线程里串行调用。8. 核查清单与最后的建议最后按经验给出一份可以直接照着检查的清单每一条都是踩过坑之后总结出来的。[ ] 板子里的MPP库存在吗mpi_dec_test能不能稳定跑通[ ] 开启split_parse了吗还是自己做了帧切割[ ] SPS/PPS是不是作为第一个包送入解码器[ ] 文件读取时有没有正确处理3字节和4字节起始码的混用[ ]decode_put_packet和decode_get_frame是不是在线程里串行调用[ ] 每次put后是否把能get的帧全部取干净了[ ] 保存YUV时用的是h_stride/v_stride还是直接fwrite整个buffer[ ] 拿到的MppFrame的buffer有没有及时拷贝或引用计数[ ] 最后一个包设置EOS了吗EOS帧是否被正确处理并退出循环如果你把这些全部确认过H264文件到YUV的境界基本就拿下了。接着可以去挑战H265、JPEG解码或者尝试把解码输出的buffer直接送显示链路做到真正的零拷贝。再分享一个自己坚持了很久的习惯第一次跑这个流程时不要直接拿一个很长的视频做测试剪出十来帧的短片段然后在代码里把每个关键节点的信息包大小、NAL类型、帧宽高、stride、pts都打出来。把前几帧的数据对着码流分析工具逐一核对一旦对上了后面所有开发都会顺很多。解码这件事一次跑通不难难的是哪天画面出了问题你知道往哪里查。希望这篇文章能让你少踩几个坑。
返回列表