最近在RK3588平台上做视频接入相关的开发,核心任务是把摄像头推上来的H264码流实时解码成BGRA8888格式,供上层算法框架和显示模块消费。整套链路最开始我直接用FFmpeg软解,结果在1080p@60多路接入的场景下CPU被抓得干干净净,后来切到了RKMPP硬解码加RGA做格式转换,才把性能彻底压下来。这篇文章把我从RKMPP解H264到RGA转BGRA8888的完整过程整理出来,包括代码结构、调用时序、参数配置以及我踩过的几个坑,给准备在Rockchip平台上做视频硬解的同学一个可以直接抄作业的参照。
1. 整体方案设计与选型思路
1.1 为什么必须用硬解,软解到底差在哪
先说我最初踩的坑。第一次实现时我用FFmpeg的avcodec直接软解H264码流,单路1080p@30还好说,一旦到了4路1080p或者单路1080p@60,CPU占用立刻飙到60%以上,四核A76直接被吃掉一大半。上层还要跑推理、跑显示、跑网络转发,根本没法接受。
软解慢的原因其实很直观:H264解码要做熵解码、帧内预测、帧间补偿、环路滤波一大堆操作,这些计算在CPU上是一步步串行执行的。而Rockchip的VPU(Video Processing Unit)本身就是专门为H264/H265解码设计的硬件模块,一个主频几百兆的硬件电路,吃掉4K@60的码流都轻轻松松。
我实测下来的数据对比大概是这样:
| 解码方式 | 单路1080p@60 CPU占用 | 内存拷贝次数 | 4路1080p@30整体表现 |
|---|---|---|---|
| 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兼容层在不同版本之间行为有差异,自己排查起来费劲。
直接调RKMPP(Rockchip Media Process Platform)就没有这些问题。它是瑞芯微官方提供的多媒体处理库,接口设计得很干净,核心就围绕MppCtx、MppApi、MppPacket、MppFrame这几个概念展开。你直接控制码流投喂、帧获取、buffer分配,手里的资源都是底层的DMABUF句柄,想怎么流转都行。
提示:如果你的应用只是播放文件或者拉流预览,FFmpeg的h264_rkmpp也够用;但如果要做视频分析、多路编码、格式转换再送AI框架,强烈建议直接用RKMPP拿底层控制权。
1.3 RGA在整条链路里到底干什么
解码器从H264码流里解出来的原始帧格式一般是NV12(YUV420的半平面变体),这个格式在视频编码、硬件显示里很常见,但给上层算法库用就麻烦了——绝大多数深度学习框架、图像处理库、GUI显示组件更喜欢BGRA8888或RGBA8888这种内存连续性好的格式。
在CPU上用libyuv或者其他库把NV12转换成BGRA8888也可以,但1080p一帧大约3MB左右的数据量,纯CPU转换一帧就要3~5ms,4路就是12~20ms,基本把CPU预算吃光了。而RGA(Rockchip 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最高支持到4K@60解码,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 exist"MPP设备节点在不同内核版本里名字不一样,有的叫/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.0,librga是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直接去分配buffer,RGA处理时就会错位,出现花屏。
正确做法是始终使用mpp_frame_get_hor_stride和mpp_frame_get_ver_stride来分配RGA输入buffer和相关内存。
3.4 解码帧释放与引用计数
MPP解码帧的内存不推荐每次解码完就立刻释放。解码器内部DPB(Decoded Picture Buffer)会保留若干参考帧,如果你把某帧buffer回收了,后续的P帧或B帧引用时就会crash。
我采用的方式是延迟回收:解码帧放到环形队列后,等RGA转换完成,再调用mpp_frame_deinit或者mpp_buffer_put。也就是说,MPP负责分配帧buffer,RGA只读不持有,转换线程处理完该帧后把引用计数减一,这样既安全又高效。
另外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的能力边界搞清楚。RGA2(RK3588集成的版本)支持:
- 格式转换: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_LEVEL=4或者读内核日志:dmesg | tail -20,RGA驱动会打印具体的失败原因,比如不支持的格式组合、buffer分配失败、地址越界等。
常见的RGA错误:
EINVAL:参数校验失败,检查width/height/format是否正确,stride是否合理ENOMEM:buffer分配失败,检查是不是drm buffer耗尽,重启应用或增加buffer池大小EBUSY:RGA硬件忙,多实例竞争时出现,加锁或等待重试
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模型检测叠加人脸识别,整条视频处理链路在单路1080p@30下CPU占用不到5%,RGA和VPU的硬件利用率大概在30%左右,余量非常充足。
6.3 多路性能调整方向
多路并发时需要注意几个点:每路解码上下文是独立的,MPP支持创建多个ctx实例并行解码;RGA作为共享硬件,多实例同时提交时内部会排队,建议每路创建一个独立的RGA句柄(fd),避免共享fd时同步锁竞争;buffer池要按路数扩大,不然帧buffer不够会导致丢帧。
实测4路1080p@30输入,每路独立解码线程、独立RGA句柄,整体CPU占用约12%(主要是线程调度和边界开销),内存占用约600MB(主要是帧缓冲和转换输出),稳定运行72小时无内存增长、无丢帧。
这个数据让我对RK3588这块芯片的视频处理能力有了更直接的认知。现在我再看这套实现,其实从一开始选型就对了——硬件能力摆在那里,关键是你愿不愿意花时间把MPP和RGA的接口吃透。说实话,这些接口文档不算多,很多细节都是靠实验和踩坑摸出来的,但跑通之后带来的收益绝对是值得的。如果你也在Rockchip平台上做视频处理,希望这篇文章能帮你少走点弯路。