ARTICLE DETAIL

资讯详情

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

Rockchip MPP硬解H264完整指南:从码流分析到YUV输出与Qt显示

Rockchip MPP硬解H264完整指南:从码流分析到YUV输出与Qt显示 做嵌入式音视频开发的人几乎躲不开Rockchip平台上的MPPMedia Process Platform库。这篇文章要解决的问题很具体怎么用MPP把一根H264编码的视频流解出来最终拿到YUV裸数据并落成文件。适用对象包括刚上手Rockchip开发板、想把视频解码从软解换成硬解、或者在做低功耗视频采集与显示链路的工程师。我会把整个流程从编译库、理解核心数据结构到线程模型、packet发送、帧获取再到YUV格式与Qt显示路线的完整逻辑写清楚。读完后你能直接照着搭出一个可跑的H264硬解demo。1. 硬解H264为什么非Rockchip MPP不可1.1 H264编码的核心结构从SPS/PPS到NAL单元的旅程在做解码之前我建议先花十分钟把H264码流结构捋一遍不然后面遇到花屏、宽高错误、解码不出帧这类问题你会完全摸不着头脑。H264码流的基本单位是NAL单元Network Abstraction Layer Unit。每个NAL单元一般以起始码开头常见的是00 00 00 01少部分场景用00 00 01。起始码之后紧跟一个字节的NAL Header低5位就是NAL Type。我们最关心的几种类型是SPSSequence Parameter SetNAL Type 7序列参数集里面记录了解码需要的宽、高、帧率、参考帧数量等关键信息。解码器必须先拿到SPS才能开始解。PPSPicture Parameter SetNAL Type 8图像参数集记录熵编码方式、片组划分等更细的参数。IDR帧Instantaneous Decoder RefreshNAL Type 5关键帧也叫I帧解码器看到IDR就知道可以刷新所有参考帧状态从这个位置开始解必能出完整画面。非IDR的SliceNAL Type 1或2普通图像帧可能是P帧也可能是B帧解码依赖前面的帧。硬件解码器在工作前同样需要这些参数。好消息是MPP内置了split解析可以自动从裸码流里切出NAL单元并识别头部信息我们不需要像做协议栈那样自己一字节一字节去解析。但你至少要理解正确码流的SPS/PPS一定会在第一个IDR帧之前出现。我踩过最典型的坑就是从视频中间截了一段数据喂给解码器结果解码器一直不输出帧查了半天才发现是SPS缺失。NAL里面装的压缩数据是怎么变成图像的简单说H264用帧内预测和帧间预测大幅去掉空间和时间冗余剩下的残差再过DCT变换、量化、熵编码。这些运算量极大尤其在高分辨率高帧率场景下CPU软解经常撑不住这就引出了硬解的必要性。1.2 软解与硬解的差距MPP到底帮你省了什么软解的意思是用CPU的通用算术单元去算反变换、反量化、运动补偿和环路滤波每一步都是实打实的整数/浮点计算。拿一块主频1.8GHz左右的ARM Cortex-A55来算软解1080P H264 30fps时CPU占用能跑到80%以上如果同时还要跑Qt界面、网络传输系统基本就卡成幻灯片了。而4K H264软解在多数嵌入式芯片上根本跑不动。硬解则不同。Rockchip芯片内部有一块专门的视频解码模块常见叫VDPUVideo Decoder Processing Unit或VPU本质是一颗专用硬件加速器。用户态通过MPP库把压缩码流和参数下发内核驱动负责把任务配置到硬件寄存器上硬件完成后把YUV数据写进内存。整个过程CPU只需要做控制和搬运解码计算几乎不占主核。实测在RK3568上一路1080P H264 30fps硬解CPU占用通常只有5%左右整机功耗也能压住。MPP就是这个过程的用户态统一入口。它把不同平台的差异封装在库里对外提供一套接近C语言风格的API。只要代码写得规范从RK3399换到RK3568再换到RK3588基本不用改业务逻辑。这也是我建议新手直接学MPP而不是去翻VDPU寄存器手册的原因硬件抽象和内存管理这些脏活累活MPP已经替你做掉了。2. 环境准备编译MPP库与理解关键数据结构2.1 本地编译、交叉编译与工具链选择MPP官方仓库在GitHub上仓库名是rockchip-linux/mpp。拿到代码后第一步是编译这一步看似简单坑其实不少。如果你是在开发板上直接编译那最简单确认系统里有cmake和gcc就行git clone https://github.com/rockchip-linux/mpp.git cd mpp mkdir build cd build cmake .. make -j4 sudo make install编译产物会生成两个关键库文件librockchip_mpp.so主库和librockchip_mpp_vepu.so编码相关。解码只依赖主库链接的时候写-lrockchip_mpp就够了。如果你的目标平台是ARM开发板但习惯在x86主机上开发那就需要交叉编译。Rockchip官方提供了build/cmake下的交叉编译脚本比如arm.linux.cross.cmake。使用方式cd mpp/build cmake -DCMAKE_TOOLCHAIN_FILE../cmake/arm.linux.cross.cmake \ -DCMAKE_INSTALL_PREFIX$PWD/install \ .. make -j8交叉编译有几个细节要注意。工具链路径如果不在系统PATH里要在cmake文件里改CMAKE_C_COMPILER的绝对路径。Rockchip各平台ARMv7和ARMv8不同AArch64的板子就要用aarch64的工具链否则编译出的库根本跑不起来。这里我建议优先用开发板配套的SDK里自带的MPP源码比如Buildroot或Debian SDK里通常已经有编译好的版本能省不少事。编译完成后把librockchip_mpp.so拷贝到板子的/usr/lib或应用同目录并确保/usr/lib路径能被动态链接器找到。最简单的验证方式ldconfig -p | grep rockchip_mpp能看到输出说明库已经可以被链接。2.2 核心API与数据结构速查MppCtx、MppApi、MppPacket、MppFrameMPP的API设计比较直接核心抽象就这么几个先建立概念再写代码会顺手很多。MppCtx解码器上下文句柄代表一个解码实例。一个视频解码器一个ctx。MppApi操作接口集合是个结构体里面装着decode_put_packet、decode_get_frame、control这些函数指针。拿到ctx之后通过mpi-xxx()调用。MppPacket输入数据包用来包装要送给解码器的H264压缩码流。可以理解成一个装着数据的快递盒盒子里有数据指针、长度、时间戳这些信息。MppFrame输出帧解码器产出的YUV图像数据都挂在它上面。通过mpp_frame_get_buffer拿到MppBuffer再通过mpp_buffer_get_ptr拿到可读的内存起始地址。MppBufferMPP管理的内存块。如果你自己用malloc分配内存给解码器用性能和兼容性都会打折扣MPP内部推荐用自己管理的buffer池这一点后面细说。还有一个比较重要的配置结构体是MppDecCfg它用于在创建解码器时设置参数比如split解析开关、参考缓冲数量、是否禁用错误帧等。常用方式是通过mpp_dec_cfg_init初始化后用mpp_dec_cfg_set_u32设置键值再通过mpi-control下发。代码里最常见的一段初始化长这样MppCtx ctx NULL; MppApi *mpi NULL; MppDecCfg cfg NULL; mpp_create(ctx, mpi); mpp_dec_cfg_init(cfg); mpp_dec_cfg_set_u32(cfg, base:split_parse, 1); mpp_init(ctx, MPP_CTX_DEC, MPP_VIDEO_CodingAVC); mpi-control(ctx, MPP_DEC_SET_CFG, cfg);初始化完成后ctx和mpi就绑定了解码器状态。MPP_VIDEO_CodingAVC就是H264的编码类型常量对应值为7。后面所有操作都通过mpi里的函数指针发起。3. H264文件解码到YUV的完整实现3.1 模块划分与线程模型设计把解码做成一个真正可用的模块而不是跑一次就结束的测试脚本第一步就是设计线程模型。最简单的单线程模型长这样读文件、发packet、循环取帧、处理帧数据、直到EOS。这种模式适合验证功能跑短视频没问题但一旦码流大、帧率高就会出现取帧阻塞发送的情况整体吞吐上不去。MPP官方测试工具mpi_dec_test里其实保留了一个更推荐的模式生产者和消费者分离。生产者线程负责从文件或网络读取码流切成合理的packet喂给解码器。消费者线程单独调用decode_get_frame把解码结果取出来处理。MPP的接口对这两个操作是做了并发保护的允许put和get在不同线程同时执行这也是官方推荐的用法。两个线程之间不需要额外加锁因为解码器内部已经管理了任务队列和buffer。实际项目中我建议再加一个管理线程做状态控制和资源回收比如告诉生产者停止、检查解码错误、释放buffer。解码模块对外暴露的就三个接口初始化、写入数据、取输出帧。这样上层逻辑很干净后续接RTSP、接摄像头都方便。线程模型定下来之后内存管理策略也要想清楚。解码器内部会维护buffer池输出帧如果不及时归还池子会被占满解码就会卡住。所以消费者线程拿到帧之后要尽快处理处理完调用mpp_frame_deinit归还帧资源而不是无脑累积。3.2 解码器创建和初始化细节创建解码器的完整代码我先贴出来再解释每一处关键点#include rk_mpi.h #include mpp_buffer.h #include mpp_packet.h #include mpp_frame.h #include mpp_dec_cfg.h #define MAX_READ_SIZE (1024 * 1024) static MppCtx ctx NULL; static MppApi *mpi NULL; int decoder_init(void) { MPP_RET ret; MppDecCfg cfg NULL; ret mpp_create(ctx, mpi); if (ret ! MPP_OK) { printf(mpp_create failed\n); return -1; } ret mpp_init(ctx, MPP_CTX_DEC, MPP_VIDEO_CodingAVC); if (ret ! MPP_OK) { printf(mpp_init failed\n); return -1; } mpp_dec_cfg_init(cfg); mpp_dec_cfg_set_u32(cfg, base:split_parse, 1); ret mpi-control(ctx, MPP_DEC_SET_CFG, cfg); if (ret ! MPP_OK) { printf(set cfg failed\n); return -1; } return 0; }mpp_init时指定编码类型为MPP_VIDEO_CodingAVC这里不能传错。MPP_VIDEO_CodingAVC对应H264MPP_VIDEO_CodingHEVC对应H265MPP_VIDEO_CodingVP9对应VP9。base:split_parse这个配置是我强烈建议开启的。它的作用是让MPP自己从码流里识别NAL起始码自动把数据切片成帧。如果不开启你必须自己保证每次发送的MppPacket恰好是完整的一帧数据否则解码器很可能报错或输出烂帧。开了split_parse之后哪怕你每次只读几KB塞进去MPP也会自己凑齐一个完整帧才开始解码这在处理网络流或者文件流时省了大量工作。初始化的control调用里MPP_DEC_SET_CFG负责把配置下发。除了split_parse还可以配置base:pre_alloc_buf_num预分配参考帧buffer数量等参数。记住一点配置尽量早做放在第一帧发送之前否则某些字段可能不生效。3.3 文件读取与packet发送流式喂数据不要一口吃成胖子很多MPP示例代码喜欢一次性把整个文件读进内存再发出去这种方式在demo里没问题但放到真实项目里就是个隐患。一个100MB的文件一次read进内存嵌入式设备本来内存就紧张再加上解码器内部buffer开销很可能直接OOM。所以我推荐流式读取一次读一小块边读边发。发送数据的核心函数是mpi-decode_put_packet。它接收一个MppPacket每个packet代表一段码流数据。流程是先把数据放进MppBuffer再用mpp_packet_init_with_buffer把buffer包装成packet设置好长度后发送。MppBuffer pkt_buf NULL; MppPacket packet NULL; uint8_t *pkt_ptr NULL; size_t read_len fread(tmp_buf, 1, MAX_READ_SIZE, fp_in); if (read_len 0) break; // 从MPP buffer池申请一块内存 mpp_buffer_get(NULL, pkt_buf, read_len); pkt_ptr mpp_buffer_get_ptr(pkt_buf); memcpy(pkt_ptr, tmp_buf, read_len); // 包装成packet mpp_packet_init_with_buffer(packet, pkt_buf); mpp_packet_set_length(packet, read_len); mpp_packet_set_pts(packet, pts); // 发送给解码器 mpi-decode_put_packet(ctx, packet); // 发送完可以立即回收buffer mpp_packet_deinit(packet); mpp_buffer_put(pkt_buf);这里有个容易被忽略的细节mpp_buffer_get(NULL, pkt_buf, size)的第一个参数传NULL表示让MPP从它自己管理的默认buffer组里分配。刚接触时容易直接用malloc分配内存再填数据这在某些平台上会导致解码器无法访问内存出现奇怪的花屏或崩溃。正确做法是始终用MPP的buffer接口。mpp_packet_set_pts设置的是显示时间戳。如果数据来源是文件你可以按帧率估算一个pts如果来源是摄像头或网络协议直接从封装层取真实时间戳。MPP解码默认不会自动填充时间戳需要上游传入。如果只是做离线的YUV转储pts填0也行。发送完一段数据后packet和buffer可以立刻释放。MPP内部已经把数据拷贝到自己的解码buffer里了吗并不是所有情况都拷贝但如果解码器正在使用这个bufferMPP内部有自己的引用计数管理你主动归还buffer并不会导致正在解码的数据失效。这一点比我预想的要安全可即便如此我依然建议你在发送完并确认没用到之后立刻归还避免buffer池被耗尽。文件读到末尾后需要发送一个EOS标记告诉解码器流结束了。方法是用mpp_packet_set_eos(packet, 1)发一个空数据包或者在最后一个包上设置EOS。不设置EOS的话消费者线程会永远阻塞在decode_get_frame上等到超时才发现问题。3.4 输出帧获取与YUV数据落盘消费者线程的核心循环是decode_get_frame。它和decode_put_packet成对出现一个负责往里塞数据一个负责往外取结果。int decode_output_loop(FILE *fp_out) { MppFrame frame NULL; MPP_RET ret; while (1) { ret mpi-decode_get_frame(ctx, frame); if (ret ! MPP_OK) { printf(decode_get_frame failed: %d\n, ret); break; } if (frame NULL) { // 缓冲池暂时没有输出帧继续等待 usleep(500); continue; } // 分辨率变化时的处理 if (mpp_frame_get_info_change(frame)) { uint32_t w mpp_frame_get_width(frame); uint32_t h mpp_frame_get_height(frame); printf(resolution changed: %dx%d\n, w, h); // 根据新分辨率调整输出buffer } // 判断是否结束 if (mpp_frame_get_eos(frame)) { mpp_frame_deinit(frame); break; } // 获取YUV数据 MppBuffer frame_buf mpp_frame_get_buffer(frame); if (frame_buf) { uint8_t *base mpp_buffer_get_ptr(frame_buf); uint32_t width mpp_frame_get_width(frame); uint32_t height mpp_frame_get_height(frame); uint32_t hor_stride mpp_frame_get_hor_stride(frame); uint32_t ver_stride mpp_frame_get_ver_stride(frame); // 按stride写文件不是按width/height size_t y_size hor_stride * ver_stride; size_t uv_size y_size / 2; fwrite(base, 1, y_size, fp_out); fwrite(base y_size, 1, uv_size, fp_out); printf(saved frame: %ux%u, stride %ux%u, pts %lld\n, width, height, hor_stride, ver_stride, mpp_frame_get_pts(frame)); } mpp_frame_deinit(frame); } return 0; }mpp_frame_deinit这个地方极其重要。MPP的buffer池有限每取出一帧如果不归还很快池子就满了后面解码器不再输出新帧。我发现很多人第一次写都会漏掉这一步然后来问为什么解码几十帧后卡死。如果帧数据需要长期使用正确做法是把YUV数据拷贝到自己的内存里再归还frame而不是保留frame不放。判断EOS这一步也别漏。如果不判断EOS文件读完后消费者线程可能一直空转frame永远返回NULL程序看起来就像卡死了。最终输出的YUV文件是裸数据没有文件头也没有宽高信息。后续要用ffplay查看时必须手工指定宽高和格式ffplay -f rawvideo -pixel_format nv12 -video_size 1920x1080 output.yuv4. YUV数据格式细节与Qt5.15显示链路4.1 NV12布局与stride对齐问题MPP硬解H264默认输出格式一般是NV12也就是YUV420SP的一种。NV12的内存布局是一整块Y平面紧跟着一整块交错排列的U和V。UV是交错存放的每两个像素共用一对UV所以叫半平面布局。内存布局可以画成这样Y平面hor_stride * ver_stride字节每个像素一个字节取值0~255。UV平面(hor_stride / 2) * (ver_stride / 2) * 2字节也就是每两个水平像素共用一对UV每两行像素共用一行UV。很多人在这一步踩坑原因是不理解stride。解码器输出的hor_stride和ver_stride不一定是实际分辨率的宽高而是硬件对齐后的值。比如实际1920x1080的画面hor_stride可能是1920ver_stride可能是1088因为硬件按16像素对齐。你如果按1920x1080去读Y平面读出来的数据是对的但如果按1080去计算UV平面起始地址就会错位直接导致画面底部出现一条花屏带。我写文件时坚持用hor_stride * ver_stride作为Y平面的实际大小用这个值再除以2得到UV平面大小。如果你希望最终得到紧凑且无对齐的YUV文件可以在拷贝时逐行把数据从stride宽度截断到实际宽度代码如下for (uint32_t row 0; row height; row) { memcpy(dst row * width, src row * hor_stride, width); }UV平面同理按行拷贝时宽度是width / 2行数是height / 2步长是hor_stride / 2。处理完之后文件就能直接用-video_size 1920x1080播放了。4.2 从YUV到可视画面的三种转换路线拿到YUV裸数据后下一步通常是要显示。常见场景是在嵌入式Qt5.15界面上实时预览视频这时候有几条路线可以选。路线一用libyuv软件转换。libyuv是Google开源的图像缩放和格式转换库性能比手写循环好得多。用法是调用NV12ToI420或者NV12ToARGB把YUV转成RGB再用QImage包一层交给Qt绘制。#include libyuv.h // NV12 - BGRA方便QImage使用 uint8_t *dst_argb (uint8_t *)malloc(width * height * 4); NV12ToARGB(y_ptr, hor_stride, uv_ptr, hor_stride, dst_argb, width * 4, width, height); QImage img(dst_argb, width, height, QImage::Format_ARGB32);软件转换的优点是简单、跨平台、无硬编码依赖缺点是RGB转换本身也要算不少乘法和加法。在较低端的Cortex-A7平台1080P转换一帧可能耗时十几毫秒连续解码就来不及了。路线二用Rockchip RGA硬件转换。RGA是Rockchip的2D图形加速单元可以做格式转换、缩放、旋转。它把YUV转RGB的运算搬到硬件上CPU只负责提交任务。MPP和RGA在同一个SDK里共存接口比较统一。这种方式是我在正式项目里的首选帧率再高都不怕。路线三用OpenGL着色器做实时转换。把NV12分成Y纹理和UV纹理上传到GPU在fragment shader里直接做矩阵运算把YUV映射成RGB再绘制。Qt5.15里可以用QOpenGLWidget配合自定义shader实现性能同样很高而且避免了GPU-CPU-GPU的拷贝。缺点是代码量最大调试门槛也高适合对渲染链路有较高要求的团队。三种路线我用一个表格总结一下方案实现成本性能适用场景libyuv软件转换低中等CPU参与计算快速验证、低帧率预览RGA硬件转换中高不占CPU量产产品、高帧率显示OpenGL shader转换高很高GPU参与需要叠加OSD、动效的场景Qt5.15环境下如果只是简单预览直接走libyuv就够了如果是产品化我强烈建议用RGA整体链路省心很多。5. 排障实录与性能优化笔记5.1 解码异常与花屏问题速查我在不同平台、不同码流上跑了很久MPP把最常碰到的问题整理成一张速查表可以直接当排查手册用。现象可能原因解决办法一直取不到帧decode_get_frame返回空SPS/PPS缺失或错误检查文件是不是从流中间截取的先发SPS/PPS再发IDR前几帧正常后面花屏输入码流有丢包或损坏开启split_parse发送时不要拆散帧数据画面下方有彩条stride和width没分清写文件或拷贝时使用hor_stride * ver_stride颜色整体偏绿或偏紫YUV格式理解错误确认输出是NV12还是I420NV12是UV交错I420是三个平面解码几十帧后卡死没有调用mpp_frame_deinit归还帧确保每帧处理完都归还buffer程序崩溃在mpp_buffer_get_ptrbuffer被提前释放检查发送packet后是否过早释放了源buffer分辨率变化后画面错乱没有处理info_change事件检测mpp_frame_get_info_change并重建输出逻辑mpp_init返回失败平台不支持该编码格式确认芯片VDPU支持H264检查MPP_VIDEO_CodingAVC宏定义这里面最容易忽略的是EOS问题我单独说一下。文件读完后如果不发EOS标记消费者线程可能永远得不到退出信号。但EOS也不要乱发mpp_packet_set_eos(packet, 1)必须放在最后一个packet之后单独发送或者放在最后一个packet上。有几次我在发送中途不小心设了EOS结果解码器提前结束后面的帧全部丢弃。另一个值得提醒的坑是pts。如果你的H264数据是从MP4等容器里解封装出来的时间戳不一定从0开始且存在DTS/PTS两个时间戳。MPP的packet只接收PTS送错时间戳会导致后续显示顺序错乱。离线转YUV文件时影响不大但做实时播放就必须处理。5.2 性能优化方向与实测心得解码性能瓶颈不一定在解码器本身有时候是内存和显示环节拖了后腿。我在RK3568上做过一轮优化记录几个关键心得。内存分配是第一优先优化点。MPP官方推荐用内部buffer组管理内存因为硬件解码器通过IOMMU/DMA访问内存普通用户态malloc出来的内存可能需要额外映射效率低且可能失败。尽量做到buffer复用不要每帧都重新分配再释放。比如输入侧用固定的几个buffer轮转输出侧用固定大小的YUV缓冲区。第二点是减少无谓拷贝。解码器输出的YUV数据如果是给软件做算法处理没办法必须拷贝如果只是显示尽量走零拷贝路线。Rockchip平台上MPP buffer和RGA、DRM显示框架之间可以做buffer共享避免YUV先拷进用户态再拷给显示层。这个优化做下来1080P 60fps的链路延迟能降低将近一半。第三点是控制消费者线程的处理时间。decode_get_frame返回的帧必须在下一帧解码完成前处理完否则buffer池会积压。我习惯在消费者线程里只做取帧、快速处理、归还三步复杂的图像算法放到独立线程用队列衔接避免拖慢解码主链路。在视频尺寸上也有讲究。硬解H264对分辨率的适配很宽但某些分辨率会触发不必要的内部缩放。比如1920x1080和1920x1088这样细微的差异解码性能几乎没有区别但如果你自己做了非对齐的裁剪反而增加额外开销。所以编码端尽量用标准分辨率MPP省事后面显示也省事。还有一个很多人没意识到的点H264码流Level和RefFrame数量直接影响硬解性能。如果码流里reference frame数量大于芯片支持的最大值硬解会出现能解但很慢或偶发花屏的情况。这时候最简单的办法是在编码端限制参考帧数量或者用FFmpeg转码时加参数ffmpeg -i input.mp4 -c:v libx264 -refs 4 -level 4.0 output.h264在低端芯片如RK3308这种不带VDPU的型号上MPP硬解H264根本不生效会回退到软解或者直接报错。选型之前务必确认芯片规格里是否有硬件解码单元。整条链路跑通之后我的习惯是先用mpi_dec_test快速验证码流和硬件是否正常mpi_dec_test -t 7 -i input.h264 -o output.yuv -w 1920 -h 1080 -n 100参数里-t 7表示H264-w和-h是宽高-n 100表示只解前100帧。这个工具非常稳如果它都跑不正常那就不是业务代码的问题而是码流、板子或SDK版本的问题。把这把工具用熟排查效率会高很多。最后再分享一个我自己的习惯每次改了解码流程我都会把同一份H264样本文件拿出来回归测试保证输出YUV可以稳定落盘。音频视频工程里解码链路是最底层的地基这里稳了上层才能放心干活。
返回列表