ARTICLE DETAIL

资讯详情

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

RK3588平台RKMPP硬解H264与RGA转BGRA8888完整实践指南

RK3588平台RKMPP硬解H264与RGA转BGRA8888完整实践指南 最近在RK3588平台上做视频接入相关的开发核心任务是把摄像头推上来的H264码流实时解码成BGRA8888格式供上层算法框架和显示模块消费。整套链路最开始我直接用FFmpeg软解结果在1080p60多路接入的场景下CPU被抓得干干净净后来切到了RKMPP硬解码加RGA做格式转换才把性能彻底压下来。这篇文章把我从RKMPP解H264到RGA转BGRA8888的完整过程整理出来包括代码结构、调用时序、参数配置以及我踩过的几个坑给准备在Rockchip平台上做视频硬解的同学一个可以直接抄作业的参照。1. 整体方案设计与选型思路1.1 为什么必须用硬解软解到底差在哪先说我最初踩的坑。第一次实现时我用FFmpeg的avcodec直接软解H264码流单路1080p30还好说一旦到了4路1080p或者单路1080p60CPU占用立刻飙到60%以上四核A76直接被吃掉一大半。上层还要跑推理、跑显示、跑网络转发根本没法接受。软解慢的原因其实很直观H264解码要做熵解码、帧内预测、帧间补偿、环路滤波一大堆操作这些计算在CPU上是一步步串行执行的。而Rockchip的VPUVideo Processing Unit本身就是专门为H264/H265解码设计的硬件模块一个主频几百兆的硬件电路吃掉4K60的码流都轻轻松松。我实测下来的数据对比大概是这样解码方式单路1080p60 CPU占用内存拷贝次数4路1080p30整体表现FFmpeg软解18% ~ 25%2~3次CPU爆炸无法稳定RKMPP硬解 2%1次稳定CPU余量充足1.2 为什么直接用RKMPP而不是FFmpeg的hwaccel确定硬解之后很多人第一反应是用FFmpeg的h264_rkmpp解码器这个方案我也试过能用但有几个地方让我很难受FFmpeg对MPP封装了一层很多底层参数暴露不出来帧的buffer生命周期被FFmpeg接管我想把DRM/FD传给RGA做零拷贝转换时得绕过AVFrame的data指针非常别扭而且FFmpeg的MPP兼容层在不同版本之间行为有差异自己排查起来费劲。直接调RKMPPRockchip Media Process Platform就没有这些问题。它是瑞芯微官方提供的多媒体处理库接口设计得很干净核心就围绕MppCtx、MppApi、MppPacket、MppFrame这几个概念展开。你直接控制码流投喂、帧获取、buffer分配手里的资源都是底层的DMABUF句柄想怎么流转都行。提示如果你的应用只是播放文件或者拉流预览FFmpeg的h264_rkmpp也够用但如果要做视频分析、多路编码、格式转换再送AI框架强烈建议直接用RKMPP拿底层控制权。1.3 RGA在整条链路里到底干什么解码器从H264码流里解出来的原始帧格式一般是NV12YUV420的半平面变体这个格式在视频编码、硬件显示里很常见但给上层算法库用就麻烦了——绝大多数深度学习框架、图像处理库、GUI显示组件更喜欢BGRA8888或RGBA8888这种内存连续性好的格式。在CPU上用libyuv或者其他库把NV12转换成BGRA8888也可以但1080p一帧大约3MB左右的数据量纯CPU转换一帧就要3~5ms4路就是12~20ms基本把CPU预算吃光了。而RGARockchip Graphics Acceleration是芯片内部专门做2D图形运算的硬件加速单元格式转换、缩放、旋转、合成这些都是它的看家本领。NV12转BGRA8888这种操作RGA2在RK3588上实测一帧1080p只需要0.3~0.6ms几乎可以忽略不计。所以整个链路就是网络或本地文件拿到H264码流RKMPP硬解码得到NV12帧帧内存是dma_buf把NV12帧的fd直接丢给RGA做格式转换RGA输出BGRA8888帧交给上层算法或显示模块这条链路全程不经过CPU做像素级的memcpy真正的零拷贝方案。2. 开发环境准备与数据链路设计2.1 硬件平台和系统环境我做实验用的是RK3588平台具体板卡是香橙派5不过RK3568/RK3566/RK3399等平台思路完全一样MPP和RGA的API是通用的差异主要在RGA版本和解码能力上限比如RK3568最高支持到4K60解码RK3399的高帧率支持会弱一些。系统环境我用的Ubuntu 22.04的Rockchip社区镜像内核5.10版本。这里有个关键点必须确认内核里VPU和RGA的驱动都正确加载了。检查方法很简单ls /dev/mpp_service 2/dev/null echo MPP device exist ls /dev/dri/renderD128 2/dev/null echo DRM render node existMPP设备节点在不同内核版本里名字不一样有的叫/dev/mpp_service有的叫/dev/mpp/vpu_service新内核改成了子设备目录如果找不到可以先dmesg | grep mpp确认驱动是否报错。安装MPP和RGA的开发库直接apt装就行Rockchip社区镜像源里自带sudo apt install rockchip-mpp rockchip-mpp-dev librga librga-dev注意不要用apt install rockchip-mpp-static这种老包新版MPP已经把静态库和动态库分开了开发用rockchip-mpp-dev就够了。RGA库建议用较新版本老的librga 1.6之前的API接口和新版差异很大代码迁移会麻烦。安装完成之后可以用pkg-config --modversion rockchip-mpp librga来确认版本我当时MPP的版本是1.5.0librga是1.9.1这个组合稳定性不错。2.2 数据链路与线程模型设计硬件解码不是把一段码流丢进去就能立刻拿到帧的内部有DTS/PTS排序、参考帧管理、DPB缓冲的复杂机制。所以代码实现上要设计成流水线模型而不是简单的函数调用。我采用的是两个线程协作解码线程负责从网络/文件读H264码流打包成MppPacket后投递给MPP解码器然后从解码器取回解码完成的MppFrame拿到帧之后把帧的dma_buf fd放入一个环形队列。转换线程从环形队列拿fd调用RGA把NV12转成BGRA8888转换完成后通知上层算法模块。两个线程之间用无锁队列或者带互斥锁的条件变量通信都可以关键是要解耦解码和转换的速度差。如果在一个线程里同步做解码转换VPU的取帧节奏会被RGA拖慢造成解码器内部缓冲堆积。提示MPP的解码输出帧不是立刻可以释放的它内部的参考帧可能还在引用这块buffer。释放帧之前必须确保已经put掉否则会出现花屏或者严重的内存踩踏。我下面的代码会给出具体的处理方式。2.3 解码器初始化之前的准备工作在真正写解码代码之前有几块内存和参数要提前准备好。首先是输入buffer也就是承载H264码流数据的buffer。MPP的buffer要从mpp_buffer_get分配它会自动走DRM/ION内存保证VPU可以直接访问。然后是解码参数。H264码流如果是裸流Annex-B格式就是带00 00 00 01起始码的需要让MPP开启split_mode也就是让它自己从码流里切割NALU。如果是已经分好帧的AVCC格式长度前缀方式则需要关掉split模式按整帧投喂。MppCtx ctx nullptr; MppApi *mpi nullptr; // 创建解码器上下文 ret mpp_create(ctx, mpi); if (ret ! MPP_OK) { printf(mpp_create failed, ret%d\n, ret); return -1; } // 初始化H264解码 ret mpp_init(ctx, MPP_CTX_DEC, MPP_VIDEO_CodingAVC); if (ret ! MPP_OK) { printf(mpp_init failed, ret%d\n, ret); return -1; }初始化只是创建了VPU的一个实例真正让解码器跑起来还要配置解码策略、分配解码缓冲、注册信息回调这些我会在下一节完整展开。3. RKMPP解码H264的核心实现与调用时序3.1 解码器的参数配置和缓冲管理MPP初始化解码器后有几个参数必须配置到位不然后面会非常痛苦。第一个是split_mode。这个参数告诉解码器输入码流是裸流还是分帧流。我这边从网络拿到的都是裸流所以开启split模式MppDecCfg cfg; mpp_dec_cfg_init(cfg); mpp_dec_cfg_set_u32(cfg, base:split_parse, 1); // 开启split解析 mpp_dec_cfg_set_u32(cfg, base:split_mode, MPP_DEC_SPLIT_MODE_ALL); // 所有帧都走split mpp_dec_cfg_set_u32(cfg, base:split_out, 1); // 输出split后的帧信息 mpi-control(ctx, MPP_DEC_SET_CFG, cfg); mpp_dec_cfg_deinit(cfg);第二个是低延迟模式。如果做实时监控或对延迟敏感要把这个参数打开它会让MPP不再严格依赖B帧重排尽量降低解码延迟RK_U32 timeout 1000; // 1秒超时 mpi-control(ctx, MPP_DEC_SET_PARSER_SPLIT_MODE, timeout);第三个是输出缓冲管理。MPP解码输出帧的内存可以通过mpp_buffer_get在用户态分配然后挂到MppFrame上。但对于解码器来说真正重要的是在info change事件到来时重新分配buffer后面会专门讲。3.2 H264码流投喂packet的创建与时机RKMPP对外提供的是decode_put_packet和decode_get_frame两个核心接口。投喂码流时可以把数据封装成MppPacket提交给解码器。我封装了一个简单的送帧函数int send_packet(MppApi *mpi, MppCtx ctx, uint8_t *data, size_t size, int eos) { MppPacket packet nullptr; // 用import方式包装外部数据避免数据拷贝 mpp_packet_import(packet, data, size); if (eos) { mpp_packet_set_eos(packet); } int ret mpi-decode_put_packet(ctx, packet); mpp_packet_deinit(packet); return ret; }注意我用的是mpp_packet_import而非mpp_packet_init。import是直接引用外部buffer不产生memcpy而init会从MPP内部重新分配一块buffer并把数据拷进去。对于实时音视频场景import能省一次耗时大概0.1~0.2ms的拷贝1000帧下来就不少了。但import有个使用前提数据缓冲区在decode_put_packet返回之前必须保持有效MPP不保证拷贝你的数据它只是引用。所以我的网络接收buffer采用了环形缓冲确保在解码线程真正取完数据之前不会被覆盖。提示如果你的码流来源是文件可以直接用init多一次拷贝也没关系代码简单不容易错。但如果是网络流建议用import并做好生命周期管理不然数据被覆盖会出现诡异的解码错帧而且这种bug非常难复现。3.3 解码帧获取info change与stride对齐的坑投喂码流之后通过decode_get_frame循环取帧MppFrame frame nullptr; int ret mpi-decode_get_frame(ctx, frame); if (ret MPP_OK frame ! nullptr) { // 处理这一帧 mpp_frame_get_width(frame); mpp_frame_get_height(frame); // ... mpp_frame_deinit(frame); }但解码器启动之后遇到的第一帧不是普通帧而是info change事件。这个事件告诉上层解码器识别到码流的宽高、分辨率信息了此时需要重新配置解码buffer。代码判断方式RK_U32 info_change 0; ret mpi-control(ctx, MPP_DEC_GET_INFO_CHANGE, info_change); if (ret MPP_OK info_change) { RK_U32 width 0, height 0; mpp_frame_get_width(frame); // 这里拿到的宽高是宏块对齐后的 mpp_frame_get_height(frame); mpp_frame_get_hor_stride(frame); // 这个才是关键 mpp_frame_get_ver_stride(frame); // 用frame_control分配新的解码buffer ret mpi-control(ctx, MPP_DEC_SET_INFO_CHANGE_READY, nullptr); }这里有个非常大的坑mpp_frame_get_width拿到的width和VPU实际解码的stride不一定一致。比如1920x1080的帧VPU为了内部并行处理实际解码宽高可能是1920x1088或者1920x1152取决于对齐要求。如果你拿width直接去分配bufferRGA处理时就会错位出现花屏。正确做法是始终使用mpp_frame_get_hor_stride和mpp_frame_get_ver_stride来分配RGA输入buffer和相关内存。3.4 解码帧释放与引用计数MPP解码帧的内存不推荐每次解码完就立刻释放。解码器内部DPBDecoded Picture Buffer会保留若干参考帧如果你把某帧buffer回收了后续的P帧或B帧引用时就会crash。我采用的方式是延迟回收解码帧放到环形队列后等RGA转换完成再调用mpp_frame_deinit或者mpp_buffer_put。也就是说MPP负责分配帧bufferRGA只读不持有转换线程处理完该帧后把引用计数减一这样既安全又高效。另外MPP的帧buffer默认是DRM分配不需要自己mmap直接把fd传给RGA就可以。这也是后面能做到零拷贝的关键。// 取回frame后获取buffer的fd给RGA用 MppBuffer buf mpp_frame_get_buffer(frame); int fd mpp_buffer_get_fd(buf); uint32_t size mpp_buffer_get_size(buf);注意mpp_frame_get_buffer返回的buffer引用不能被直接释放要等到帧的引用计数用完后再通过mpp_frame_deinit统一释放。我一开始没注意RGA转换时顺手把buffer put了结果解码器用参考帧时直接段错误排查了一整天。4. RGA格式转换从NV12到BGRA8888的实现细节4.1 RGA能做什么不能做什么先把RGA的能力边界搞清楚。RGA2RK3588集成的版本支持格式转换NV12、NV21、YUYV、RGB888、BGRA8888、RGBA8888等常见格式互相转换缩放任意比例的缩放最大输出尺寸取决于RGA版本RK3588上能达到4096x4096旋转/翻转0/90/180/270度旋转水平/垂直翻转合成两个source合成到dst不支持的常见操作包括透视变换、alpha混合中的透明遮罩部分版本支持有限、对YUV422的10bit高深处理等。对于我们的场景只需要完成NV12 - BGRA8888的单图层转换这是RGA最典型的应用性能最好。4.2 librga新版API的使用librga有两代API老的rga_info_t风格和新的im2d风格。我推荐直接使用im2d风格代码更简洁而且官方更新积极。这里给出完整的NV12转BGRA8888的代码#include im2d.h #include RgaUtils.h #include rga.h int nv12_to_bgra(int src_fd, int src_w, int src_h, int dst_fd, int dst_w, int dst_h) { int ret 0; ImHandle src_handle im2d_handle_create_from_fd( src_fd, src_w, src_h, src_w, src_h, // wstride, hstride IM_FMT_NV12, IM_COLOR_SPACE_BT601_LIMITED // 根据你的码流实际颜色空间配置 ); ImHandle dst_handle im2d_handle_create_from_fd( dst_fd, dst_w, dst_h, dst_w, dst_h, IM_FMT_BGRA8888, IM_COLOR_SPACE_BT601_FULL ); im_rect src_rect {0, 0, src_w, src_h}; im_rect dst_rect {0, 0, dst_w, dst_h}; ret improcess(src_handle, dst_handle, src_rect, dst_rect, IM_OP_COLOR_CONVERT); im2d_handle_release(src_handle); im2d_handle_release(dst_handle); return ret; }不过注意im2d_handle_create_from_fd底层走的是im2d的封装如果你不想依赖中间的handle结构直接用rga_info_t会更底层。但对我们这种不折腾超复杂场景的im2d足够简洁高效。关键参数说明IM_FMT_NV12指定源帧格式。MPP解码输出默认就是NV12如果MPP配置成了其他格式这里要同步修改。IM_COLOR_SPACE_BT601_LIMITED颜色空间。H264码流标准里默认是BT601 limited range如果你的码流是BT709或者full range需要手动设置否则色彩会偏灰或过饱和。wstride/hstride必须传入MPP帧的实际stride不是宽高这是最容易出错的地方。4.3 RGA输出的内存分配与对齐规则RGA输出的BGRA8888 buffer我推荐用DRM buffer分配这样可以继续复用零拷贝路径。分配方式int rga_output_fd -1; // 用DRM alloc或dma_buf alloc接口分配一块至少 dst_w * dst_h * 4 大小的缓冲区 // 注意RGA要求 buffer 的宽高stride对齐RGA各个版本对对齐要求不同但保守起见建议wstride按16字节对齐hstride按2对齐因为NV12的半平面结构。比如实际图像是1920x1080最好分配1920x1088的stride保证VPU和RGA两边的数据排布一致直接零拷贝走完整个链路。如果不注意对齐规则可能会出现RGA转换后图像整体偏移图像有绿边或边缘有残影输出buffer最后一两行图像错乱这些都是我实际踩过的坑后来我统一用宏做了一次对齐处理#define ALIGN_TO(x, align) (((x) (align) - 1) / (align) * (align)) int wstride ALIGN_TO(src_w, 16); int hstride ALIGN_TO(src_h, 16); // 保守起见按16对齐4.4 性能测试与优化空间我实测RK3588上单路1080p NV12转BGRA8888大概耗时0.4ms左右CPU占用为0。4路同时转总耗时约1.5ms完全不影响实时性。如果转换耗时异常高比如超过2ms可以先检查是否走了RGA硬件而不是软件模拟。可以看内核日志dmesg | grep rga确认rga设备是否正常工作。是否多次mmap/umap。RGA执行时如果频繁映射/解除映射buffer开销会显著增加。buffer是否非连续内存。RGA对连续内存支持最好如果buffer是scatter-gather的效率会下降。优化方向上RGA支持im2d的多任务并行比如可以用imconfig一次性提交多个操作减少调用开销也可以开启RGA的零拷贝模式IM_SYNC_NONE让RGA不等待操作完成就返回CPU控制权由后续的同步锁来管理完成标志。但对初版实现先不用过度优化稳定跑通最要紧。5. 常见问题与排查技巧5.1 花屏、绿屏、图像错位这类问题绝大多数出在stride不匹配或buffer生命周期管理上。我整理了一个快速排查表现象最常见原因排查方法图像整体偏移/斜切MPP帧stride与RGA传入stride不一致打印MPP的hor_stride/ver_stride与RGA的wstride/hstride对比图像最下方有绿条strided高度不对确认hstride按2对齐并匹配MPP输出图像色彩偏灰/偏绿颜色空间设置错误检查IM_COLOR_SPACE_BT601_LIMITED/FULL是否匹配码流偶发性花屏split mode没开或码流分包问题打印DPB占用量开启split_parse图像间歇性错帧输入buffer生命周期未管理好检查import的buffer是否在decode_put_packet返回前被复用还有一个非常隐蔽的坑MPP输出帧的宽高信息在解码过程中是可能变化的比如码流中间出现了SPS/PPS更新分辨率切换了。如果没用MPP_DEC_GET_INFO_CHANGE重新配置后续所有帧都会花。所以info change处理逻辑必须放在decode_get_frame的循环里并且要支持动态重新分配RGA输出buffer。5.2 解码卡死或丢帧严重卡死通常出现在decode_put_packet阻塞上。MPP的decode_put_packet默认是阻塞模式当解码器内部buffer满时会等待。解决方案是给解码器设置非阻塞或超时参数RK_U32 timeout 100; // 100ms mpi-control(ctx, MPP_SET_INPUT_TIMEOUT, timeout);设置了超时后decode_put_packet在buffer满时会返回超时错误这时需要把数据缓存到应用层等解码器空闲后再投喂而不是无限等待。丢帧严重则要关注投喂节奏。如果网络接收线程码流抖动大一次投喂太多帧解码器可能因为input buffer打满而丢掉后续帧。建议控制每次解码循环投喂不超过几十帧或者用独立的码流队列平滑输入。5.3 RGA返回错误码的排查librga的错误码通常不会打印到日志里只返回一个负值或IM_STATUS_FAILED。排查时需要打开RGA调试信息# 设置RGA日志级别查看详细错误 export RK_LOGT_LEVEL4或者读内核日志dmesg | tail -20RGA驱动会打印具体的失败原因比如不支持的格式组合、buffer分配失败、地址越界等。常见的RGA错误EINVAL参数校验失败检查width/height/format是否正确stride是否合理ENOMEMbuffer分配失败检查是不是drm buffer耗尽重启应用或增加buffer池大小EBUSYRGA硬件忙多实例竞争时出现加锁或等待重试5.4 性能排查工具与调优清单最后分享我的调优路径先用perf top看哪个线程CPU高确认瓶颈在解码还是转换。用MPP的mpp_frame_get_decoded_pts和当前时间戳对比确认解码延迟。用RGA的im2d_timer计时确认单帧转换耗时。检查是否有不必要的memcpy比如从dma_buf拷贝到malloc内存这一步能省则省。多路场景下把解码和转换线程绑定到不同CPU核心避免调度抖动。我在正式部署时把解码线程绑到了A76大核上RGA转换线程绑到了另一个A76核实测整体帧间隔从原来的4ms降到2.3ms比不绑核稳定不少。6. 最终代码结构与后续扩展思路6.1 一个可参考的代码骨架整个工程我最终分成了几个模块decoder.h/cpp封装MPP解码器暴露openDecoder、decodeFrame、getFrame接口。converter.h/cpp封装RGA转换暴露convertNv12ToBgra接口。pipeline.h/cpp环形队列和解码/转换线程调度。main.cpp从本地文件或网络拉流组装整个流程。核心的调用流程伪代码如下// 主流程 Decoder dec; dec.open(1920, 1080); Converter conv; conv.init(1920, 1080); while (running) { // 读码流 size_t len read_h264_stream(buf); if (len 0 is_eof) { dec.sendEos(); break; } // 投喂解码器 dec.decodeFrame(buf, len); // 循环取帧 while (dec.hasFrame()) { Frame *frame dec.getFrame(); // 交给RGA转换 conv.convert(frame-fd, frame-w, frame-h, bgra_fd); // 通知上层 notify_upper_layer(bgra_fd); // 释放帧引用 dec.releaseFrame(frame); } }6.2 解码AI推理的进一步扩展如果你后续要接AI推理这套方案的扩展性也很好。RGA输出的BGRA8888可以直接作为TensorRT或RKNN的输入也可以用rga再做一次缩放比如从1080p缩放到640x640同时输出这样图像预处理也交给硬件了CPU占用会进一步下降。我当时跑YOLO模型检测叠加人脸识别整条视频处理链路在单路1080p30下CPU占用不到5%RGA和VPU的硬件利用率大概在30%左右余量非常充足。6.3 多路性能调整方向多路并发时需要注意几个点每路解码上下文是独立的MPP支持创建多个ctx实例并行解码RGA作为共享硬件多实例同时提交时内部会排队建议每路创建一个独立的RGA句柄fd避免共享fd时同步锁竞争buffer池要按路数扩大不然帧buffer不够会导致丢帧。实测4路1080p30输入每路独立解码线程、独立RGA句柄整体CPU占用约12%主要是线程调度和边界开销内存占用约600MB主要是帧缓冲和转换输出稳定运行72小时无内存增长、无丢帧。这个数据让我对RK3588这块芯片的视频处理能力有了更直接的认知。现在我再看这套实现其实从一开始选型就对了——硬件能力摆在那里关键是你愿不愿意花时间把MPP和RGA的接口吃透。说实话这些接口文档不算多很多细节都是靠实验和踩坑摸出来的但跑通之后带来的收益绝对是值得的。如果你也在Rockchip平台上做视频处理希望这篇文章能帮你少走点弯路。
返回列表