这块RK3588板子在我的工位上服役已经大半年了,第一批需求里最磨人的不是模型推理,而是把一路RTSP里的H264拉下来硬解,再转成BGRA8888喂给上位机做画面显示和算法输入。CPU还得留着跑调度、跑业务逻辑,所以软解这条路从立项第一天就被否掉了。Rockchip平台的常规答案就是RKMPP加RGA的组合拳:RKMPP专门负责H264硬解码,RGA这块2D硬件加速单元负责把解码出来的NV12帧转换成BGRA8888。两条硬件流水线串起来,CPU基本只做搬运和调度,负载非常舒服。
这套方案看起来简单,真做起来细节非常多。解码器的初始化参数、H264码流的格式协商、解码帧的拿取时机、RGA转换时的stride对齐、内存的cache一致性,每一个环节都埋着坑。我这次把从零到跑通的全过程,以及后来在代码评审和稳定性测试里踩过的几个雷,一并整理出来。无论是刚接触Rockchip平台的嵌入式开发,还是已经在用MPP但被RGA转换折腾过的人,这篇都能当一份排错手册用。
1. 为什么说RKMPP和RGA是Rockchip平台上绕不开的组合
1.1 软解被直接否掉的原因
先说结论:H264 1080p30的软解,在一颗Cortex-A76核上大概能跑,但CPU占用率会冲到百分之三四十甚至更高,这还是在没有任何业务负载的前提下。如果还有RTSP拉流、图像预处理、网络发送、UI渲染这些活,CPU直接就被吃满。RK3588虽然八核,但总核心数架不住多路视频流。
嵌入式项目里CPU是所有业务的总调度池,把最耗费算力的视频解码扔给专用硬件单元,是性价比最高的做法。RKMPP是Rockchip自研的Media Process Platform,内部通过VEPU/VPU等硬件模块完成编解码,对外暴露一套统一API。H264解码只是它能力的一部分,H265、VP9、JPEG也都在它的管辖范围内。
1.2 解码和格式转换的分工
解码器解出来的帧,默认是以NV12格式输出的,也就是YUV420SP,Y平面和UV交错平面分开存放。这个格式在视频编码领域很常见,但直接拿去做显示、送给深度学习前处理或者Qt渲染,往往不是最优解。上位机那边的接口通常会要BGRA8888,每个像素四个字节,内存按B-G-R-A顺序排列,这也是很多GUI框架和图像库通用的像素格式。
RKMPP管解码,RGA管像素搬运和格式转换,两者各管一段。RGA全称Raster Graphic Acceleration,本质是一块2D图形加速硬件,能做事先定义好的格式转换、缩放、旋转、裁剪、颜色空间转换。用RGA做NV12到BGRA8888的转换,一个核心设定就是让工作量远离CPU,毕竟这种逐像素的格式转换如果交给CPU做memcpy级的循环,带宽和算力都吃不消,还容易引起缓存抖动。
1.3 这套方案适合谁
我的判断是,以下几类项目可以直接参考这条路线:
- 需要在Rockchip平台上解码多路H264/H265视频流的设备,比如NVR、多路RTSP网关。
- 解码后图像要送往算法模块做检测、识别,而算法输入要求BGRA/RGB的成套系统。
- 嵌入式GUI需要视频画面显示,又不想引入额外CPU开销的场景。
- 正在评估RKMPP和RGA性能边界,想知道这两块硬件到底能顶多少路流的开发者。
后面整个过程以RK3588为主,部分经验兼容RK3568、RK3399等平台,遇到平台差异我会单独标出来。
2. 开工前的硬准备:MPP环境、解码器初始化与输入缓冲管理
2.1 环境准备与库的编译链接
RKMPP的源码可以直接从Rockchip官方仓库拉取,一般是一套rockchip-mpp工程。交叉编译时最省事的方式是直接用buildroot或yocto工具链,按官方文档把mpp编成动态库。如果你只是在自己开发板上跑,也可以直接装系统里自带的librkmpb-dev或者通过apt源装rockchip-mpp-dev,但开发机上我还是建议源码编译,因为能拿到完整的头文件和调试信息。
链接的时候需要关注这几个库:
target_link_libraries(your_target rockchip_mpp rockchip_mpp_rkai rga pthread dl )RGA库分两个时代。老版本接口是librga提供的rga_info_t和rga_blit,新版本接口是librga的im2d函数族,头文件是im2d.h和rga.h。新的librga实际上是一套更现代的统一API,推荐新项目直接用im2d接口,老接口虽然还能用但建议尽快迁移。我这里后面的代码实例会以新接口为主,老接口会给出对应的关键差异。
2.2 解码器创建与初始化参数
先用标准的mpp_create和mpp_init创建解码器实例:
#include "rk_mpi.h" #include "mpp_common.h" #include "mpp_buffer.h" MppCtx ctx = NULL; MppApi *mpi = NULL; MppDecCfg dec_cfg = NULL; // 创建上下文 mpp_create(&ctx, &mpi); // 初始化解码器类型,这里指定H264 mpp_init(ctx, MPP_CTX_DEC, MPP_VIDEO_CodingAVC);这段代码所有RKMPP项目都一样,真正的差异在control阶段。解码器启动前,我强烈建议显式处理H264码流是否需要split模式。如果你的输入是纯裸流,也就是直接从流媒体服务器或者摄像头那边一路读下来的字节流,包边界和帧边界不一定对齐,就需要把模式设置为按起始码拆分:
RK_U32 need_split = 1; mpi->control(ctx, MPP_DEC_SET_PARSER_SPLIT_MODE, &need_split);反过来,如果输入是FFmpeg解封装后输出的AVPacket,这个数据已经是完整帧,不需要做start code扫描,可以直接把need_split设为0,效率会高一些。很多初学者在最开始没注意这个开关,结果MPP解析出来的帧要么只有半个,要么画面花屏,排查半天。
还有一个容易被忽略的参数是解码帧的格式输出配置。RKMPP默认会输出MPP_FMT_YUV420SP即NV12,但在某些平台上,解码器可能会输出MPP_FMT_YUV420SP_10BIT,如果码流是10bit H264,那后面接RGA时格式就要小心,NV12 10bit在内存排布上和8bit完全不同。如果确认业务只需要8bit,可以在解码前用MPP_DEC_SET_OUTPUT_FORMAT去限定:
MppEncCodecCfg codec_cfg; MPP_RET ret = mpi->control(ctx, MPP_DEC_SET_OUTPUT_FORMAT, &fmt);更稳妥的做法还是等解码器真正出帧后,用mpp_frame_get_fmt读一下实际格式,按实际格式去配RGA,不要先入为主。
2.3 输入缓冲和码流送交方式
H264裸流通常是一大块连续数据,直接整块塞给解码器并不合适。工程上建议用环形缓冲或队列管理输入码流,一帧一帧地交给MPP。每次送包时用mpp_packet_init创建packet,然后把数据指针和大小填进去:
MppPacket packet = NULL; mpp_packet_init(&packet, data_ptr, data_len); mpi->decode_put_packet(ctx, packet); // 送完后记得释放packet,MPP内部会拷贝需要的数据 mpp_packet_deinit(&packet);注意一个细节:mpp_packet_init的data_ptr在decode_put_packet调用完成后,MPP并不保证还在使用它,所以可以立即释放。但如果你设置了MPP_DEC_SET_PARSER_SPLIT_MODE为0,那么MPP会假设packet内就是完整的一帧,且它可能需要引用数据到解码完成,此时不要复用data_ptr内存,直到该帧解码完成为止。为了避免这种隐式耦合,我一般倾向统一用split=1模式,凡是需要长缓存的数据都拷贝一份,虽然多了一次内存拷贝,但生命周期清晰,不容易出悬垂指针。
2.4 给码流加热身期:SPS/PPS和GOP
H264解码不是拿到第一帧就能出图。解码器必须等到SPS/PPS以及第一个IDR帧,才能开始真正输出图像。网络流通常会在开始时带好SPS/PPS,但RTSP或GOP间断流不一定每次都带。遇到长时间不出帧,第一反应应该是看码流里有没有SPS/PPS,而不是怀疑解码器配置错了。
MPP解码过程中,如果输入是纯裸流,内部会自己解析SPS/PPS,所以并不需要单独把SPS/PPS提取出来构建AVCDecoderConfigurationRecord,只要把含有SPS/PPS的字节流送进去即可。但split模式下MPP会按0x00000001起始码识别NALU,所以数据里必须有正确的起始码。
还有一个帧率控制问题。H264解码器的输出帧率由码流里的VUI信息控制,但MPP实际输出速度跟硬件能力和输入节奏有关。RTSP拉流场景下,经常是网络线程收到一个包就扔给解码器,解码器可能还没凑齐一帧,这样会导致decode_get_frame一直拿不到输出帧。我的做法是维护一个小队列,攒满一整个完整帧的NAL再送packet,这样解码器不会因为半截数据频繁状态切换。
3. 解码主循环:从MppPacket进到MppFrame出的完整链路
3.1 解码器主循环骨架
MPP的模型非常简单:一边往解码器里用decode_put_packet送压缩数据,一边用decode_get_frame把解出来的帧取出来。取不到帧不是错误,可能是数据还不足以解码出完整图形,也可能是参考帧还没凑齐。标准循环如下:
while (running) { // 1. 从输入队列取一包H264数据 // 2. 封装成MppPacket并送交解码器 MppPacket packet = NULL; mpp_packet_init(&packet, input_data, input_size); mpi->decode_put_packet(ctx, packet); // 3. 尝试拿解码帧,注意要循环拿,直到拿不到为止 MppFrame frame = NULL; MPP_RET ret = mpi->decode_get_frame(ctx, &frame); if (ret == MPP_OK && frame) { MppBuffer buffer = mpp_frame_get_buffer(frame); RK_U32 width = mpp_frame_get_width(frame); RK_U32 height = mpp_frame_get_height(frame); RK_U32 hor_stride = mpp_frame_get_hor_stride(frame); RK_U32 ver_stride = mpp_frame_get_ver_stride(frame); RK_U32 eos = mpp_frame_get_eos(frame); // 交给RGA做格式转换 convert_to_bgra(buffer, width, height, hor_stride, ver_stride); // 用完必须deinit,否则会内存泄漏 mpp_frame_deinit(&frame); } mpp_packet_deinit(&packet); }很多刚从FFmpeg转过来的同学,会习惯性去找decode_frame这种阻塞接口,MPP不是这个逻辑。MPP的decode_put_packet基本上只要缓冲区里有空间就立刻返回,decode_get_frame也只是查询当前有没有输出帧,是非阻塞的。整个解码动作发生在硬件内部,驱动通过中断等机制完成帧输出,而应用层拿到的是已经完成解码的帧。
3.2 从解码帧提取关键信息
拿到MppFrame之后,不要急着去拷贝数据,先把下面这些元信息读准:
width/height:实际显示尺寸,就是H264里的图像宽高。hor_stride/ver_stride:解码器内部缓冲区的行间距,这个值通常大于或等于width,因为硬件为了对齐会在行尾填补字节。mpp_frame_get_fmt:实际输出格式,可能是MPP_FMT_YUV420SP,也可能是MPP_FMT_YUV420SP_10BIT,甚至有可能是MPP_FMT_YUV422SP。mpp_frame_get_buffer:指向帧数据的内存缓冲区,类型是MppBuffer。
我最想强调的是hor_stride。很多RGA转换花屏/偏色的案例,根因都出在这里——从MPP拿到的NV12数据行宽不是width而是hor_stride,如果你直接用width当作行宽去读Y数据和UV数据,后面UV平面的位置就会整体偏移,画面就花了。正确做法是把它传给RGA的wstride字段,让硬件按实际stride去读取。
3.3 帧释放时机与参考帧管理
H264解码依赖参考帧,MPP内部会维护一个DPB(Decoded Picture Buffer)列表。应用层拿到的MppFrame并不完全独立,它在内部可能被引用为后续帧的参考帧。所以释放时机很重要。
正确用法是处理完当前帧的所有数据后立即调用mpp_frame_deinit(&frame),释放的只是应用层持有这个MppFrame对象,MPP内部对帧缓冲的引用计数由它自己控制。如果你强行把帧数据保存在自己构造的另一种结构体里,那就要确保在deinit之前把需要的数据复制走,或者对该帧的MppBuffer额外做一次引用。
我在项目里踩过一个很低级的错误:为了把解码帧和后续的显示控制线程共享,我把MppFrame指针直接塞进了队列,想等显示线程用完再释放。结果MPP需要这个帧做参考帧时,内部引用计数已经乱掉,后续画面出现周期性的花屏和卡顿。后来改成“解码线程内完成复制或立即转格式,队列只传转换后的BGRA数据”,问题立刻消失。
3.4 错误恢复机制
网络流难免丢包。H264码流如果丢了一个关键NALU,解码器会进入错误状态,输出几帧花屏或直接停止输出。MPP内部有错误恢复机制,但前提是应用层要正确处理:
- 检测到
decode_get_frame在长时间内持续拿不到帧,并且解码器报MPP_ERR_INVALID_PARAM这类错误码时,要把解码器reset。 - 恢复手段有两种:一是调用
mpi->control(ctx, MPP_DEC_RESET, NULL)复位解码器;二是直接把解码器销毁重建。
我的经验是,短时的丢包可以不处理,等到下一个IDR帧到来时,解码器会自动恢复同步。如果丢包严重导致长期无输出,就主动请求关键帧。在RTSP场景里,可以发PLAY方法里带SETPARAM请求或使用RTSP的ClientCommand,让对端强制重新发送关键帧。这个思路比暴力reset解码器更优雅,适合对实时性敏感的场景。
4. RGA做NV12到BGRA8888转换,配置上最容易翻车的几个点
4.1 新旧librga API怎么选
RGA的开发接口现在有两套。老接口是rga_info_t配合rga_blit,使用起来直接、流程短,但很多细节参数需要手填,写错就是花屏;新接口是im2d函数族,由im2d自动处理很多对齐和格式细节,推荐新项目使用。
老接口核心代码示例:
#include "rga.h" #include "RgaApi.h" static int rga_bgra_convert(void *src_vir, int src_fd, int width, int height, int hor_stride, int ver_stride, void *dst_vir, int dst_fd) { rga_info_t src = {0}; rga_info_t dst = {0}; src.fd = src_fd; src.virAddr = src_vir; src.mmuEnabled = 1; src.format = RK_FORMAT_YCbCr_420_SP; src.width = width; src.height = height; src.hor_stride = hor_stride; src.ver_stride = ver_stride; rga_set_rect(&src.rect, 0, 0, width, height, hor_stride, ver_stride, RK_FORMAT_YCbCr_420_SP); dst.fd = dst_fd; dst.virAddr = dst_vir; dst.mmuEnabled = 1; dst.format = RK_FORMAT_BGRA_8888; dst.width = width; dst.height = height; dst.hor_stride = width * 4; dst.ver_stride = height; rga_set_rect(&dst.rect, 0, 0, width, height, width * 4, height, RK_FORMAT_BGRA_8888); return rga_blit(&src, &dst, NULL); }新接口用起来简洁得多:
#include "im2d.h" #include "RgaApi.h" static int im2d_bgra_convert(void *src_ptr, int src_fd, int width, int height, int hor_stride, int ver_stride, void *dst_ptr, int dst_fd) { int ret = 0; // 输入图像信息 im_rect src_rect = {0, 0, width, height}; im_rect dst_rect = {0, 0, width, height}; ret = improcess(src_ptr, src_fd, hor_stride, ver_stride, RK_FORMAT_YCbCr_420_SP, dst_ptr, dst_fd, width * 4, height, RK_FORMAT_BGRA_8888, src_rect, dst_rect, IM_SYNC); return ret; }两种接口都支持传入fd或者虚拟地址。能传fd尽量传fd,尤其是解码帧的MppBuffer,它本身可以拿到fd,走dma-buf路径可以让RGA直接通过硬件访问缓冲,省掉CPU cache同步的开销。
4.2 宽高、stride、偏移量这些参数是怎么影响出图的
RGA做格式转换,最核心的是三个概念:width、height、wstride(RGA里也叫hor_stride)。width是真实图像宽度,wstride是缓冲区每行实际占用的像素数。对NV12来说,每行存储的Y数据是按wstride对齐的,而不是按width对齐。如果解码器输出hor_stride是1920,而实际图像宽度是1900,RGA如果按1920当作显示宽度处理,转换结果就会多出20个像素的余量;反过来如果RGA按1900去读,数据行尾又会对不齐,整个画面都会斜掉。
BGRA8888目标同样有对齐要求。RGB排列每像素4字节,理论上wstride = width * 4就够了。但RGA内部对wstride有对齐要求,通常是16字节对齐,某些平台要求64字节对齐,所以建议直接用(width * 4 + 15) & ~15计算:
int dst_wstride = (width * 4 + 15) & ~15; int dst_ver_stride = (height + 15) & ~15;我第一次写这段代码时偷懒直接用width * 4,跑出来的画面在特定分辨率下右边缘会出现半列的颜色错位。后来改成16字节对齐后,问题消失。
再补一个可靠的经验:MPP的解码帧stride是硬件给的,不要自己算,直接通过mpp_frame_get_hor_stride和mpp_frame_get_ver_stride读取;RGA的目标stride必须自己基于width推算并对齐。
4.3 内存来源与cache一致性问题
RGA输入的source buffer来源多种多样:MPP解码帧的MppBuffer、普通malloc内存、DMA内存池。这几种在RGA的使用上体验完全不同。
最推荐的是直接传递MPP buffer对应的fd。MppBuffer内部就是dma-buf,通过mpp_buffer_get_fd拿到的fd可以直接传给RGA的fd字段。RGA识别到fd就会走真正的硬件DMA路径,不经过CPU,也不会有cache不一致问题。
如果只能用虚拟地址,malloc出来的内存也可以给RGA,但因为RGA硬件不经过CPU缓存,需要做cache同步。新接口im2d在IM_SYNC模式下会自动处理,老接口rga_blit不一定每次都做,稳妥起见你自己cache_flush一下。项目上线后在RK3568上遇到过雪花点,排查半天其实就是malloc内存段cache没刷干净。RGA读到的是CPU缓存里的旧数据,结果转换出来的画面随机出现花点。改成从mpp_buffer池子里统一取内存后,这个现象再没出现过。
4.4 色彩空间与颜色范围
H264解码出来的NV12,其色彩空间定义通常来自SPS里的VUI信息。大多数监控摄像头和视频文件的色彩范围是BT.601 limited range,但也有不少是BT.709 full range。RGA转换时如果色彩空间配置错了,画面看起来会发灰或者发白,就好比用错误的调色公式去套每个像素,整体观感一片陈旧灰蒙。
新接口im2d支持在improcess的第四个参数设置输出格式后,再通过im_draw或imconfig去调整色彩空间矩阵。由于色彩空间配置在不同librga版本里API差异大,我建议你找一个简单的办法验证:解码出来后,把NV12原始帧直接保存成文件,放到PC上用YUV播放器看颜色对不对。如果原始颜色正常而转换后偏色,就是RGA色彩空间配置的问题;如果原始NV12就偏色,就要先检查解码器输出格式设置。
我的项目里需要统一转成BGRA给算法用,算法期望的是full range的RGB,所以我在RGA端配置了色彩空间扩展参数,从limited range转full range。这一步不做的话,算法框出来的目标框位置是正确的,但分类置信度会整体偏低,很迷惑人。
5. 实测拷问:花屏、偏移、卡顿三条线索的完整排查链路
5.1 画面整体偏移或右下角斜线:几乎全是stride问题
现象很典型:画面能出来,但整体向右下方倾斜,或者右侧有一条绿色/黑色的竖条,宽度不确定。
排查链路:
- 打印
mpp_frame_get_hor_stride和mpp_frame_get_ver_stride,看是否等于width/height。我见过很多驱动版本返回的hor_stride都带了32像素或64像素的对齐余量。 - 检查传给RGA的源图
src.hor_stride,必须等于这个值,而不是width。如果代码里写死了src.hor_stride = width,立刻改过来。 - 检查目标buffer的
dst_hor_stride是否等于(width * 4 + 15) & ~15。 - 再做一次自查:把转换出的BGRA数据在本地保存成
.bmp文件或直接送到显示器,如果显示倾斜就说明配置仍有问题;如果显示正常但上位机仍然花,说明是上位机读取BGRA时也犯了同样的stride错误。
这个链条占了RGA问题的大头,90%的概率都是这里。
5.2 颜色整体发绿或发紫:格式与色彩空间不匹配
现象:画面结构正常,但整体色调偏向绿色、紫色或者颜色非常淡。
排查链路:
- 确认MPP实际输出格式。打印
mpp_frame_get_fmt,如果是MPP_FMT_YUV420SP_10BIT而你在RGA里按8bit的RK_FORMAT_YCbCr_420_SP处理,颜色一定不对。10bit NV12是4个字节存两个像素,内存排布完全不一样。 - 确认RK_FORMAT枚举值与实际内存排布一致。NV12是
RK_FORMAT_YCbCr_420_SP,NV21是RK_FORMAT_YCbCr_420_SP的反转,实际UV顺序不同,千万别搞混。我看到过一个项目里把NV12写成NV21,整体紫绿互换,一开始还以为是算法问题。 - 确认色彩空间。BT.601和BT.709的转换矩阵不同,RGA默认使用BT.601,如果码流是BT.709,就需要设置色彩空间参数。
保存原始NV12帧是排查颜色问题最快的路子,没有之一。把解码帧的buffer地址dump到文件,用电脑上YUV播放器打开,就能快速区分是解码端颜色就不对,还是RGA转换端颜色不对。
5.3 长时间运行卡顿、内存缓慢上涨:帧泄漏与cache刷写
现象:程序刚启动很流畅,跑了一两个小时后开始掉帧,内存占用缓慢爬升,系统负载升高。
排查链路:
- 先检查帧是否泄漏。
mpp_frame_deinit漏掉的每帧都会有内存变化。统计每1万帧处理前后的/proc/meminfo或者/proc/self/status里的VmRSS,如果有持续增长,那基本可以断定是某些分支路径上没有释放MppFrame或MppPacket。 - 检查
rga_blit的调用频率。如果每次转换前都新建rga_info_t不做复用,栈没问题,但堆对象如果用了new,也要确保释放。 - 检查是否在虚拟地址模式下频繁做cache flush。这一项通常不会造成内存泄漏,但会造成性能劣化。虚拟地址模式下每次
flush都有不小的开销,高频调用会明显拖慢主循环。 - 看是否因为目标BGRA buffer反复申请释放导致内存碎片。这种场景建议用内存池:提前申请block,循环利用。
我这边曾经有个“灵异现象”:运行5小时之后,内存涨了差不多200MB,最后定位到是一个异常分支里调用mpp_frame_deinit之前直接continue跳过了释放逻辑。这种错误很隐蔽,因为正常流程下帧都能释放,只有异常分支会泄漏,排查起来非常困难。从那之后我给所有解码循环都加了一层RAII式的封装,保证无论从哪个分支退出都会释放帧。
5.4 RTSP场景下的周期性卡顿
现象:画面每几秒就卡顿一次,时间点不固定,但频率和I帧间隔高度相关。
这类问题大多不在解码本身,而在拉流线程和数据投递节奏。H264解码时,I帧数据量大,网络抖动容易导致I帧的数据没有在预期时间内到齐,解码器就会一直等数据,直到完整I帧组装完成后才突然输出一批后续帧。看起来就是卡一笔然后猛地快进。
解决办法是给输入加缓冲,让解码器有足够的数据持续消化。我用的策略是把输入缓冲控制在3到5帧的码流数据量,当缓冲低于阈值时,暂停解码输出,让网络追上来;高于阈值时再继续。这样虽然增加了一点延迟,但整体画面流畅度明显提升。实时性要求非常高的项目不建议这么做,但大多数显示类项目能接受100到200ms的额外延迟。
6. 性能实测与下一步优化方向
6.1 RK3588单路1080p30的实测数据
在我的RK3588平台上,用RKMPP解一路1080p30 H264,CPU占用率在解码线程内大约是百分之五到百分之八,这其中包括了码流读取、packet构造和RGA调用。RGA做一次1080p NV12到BGRA8888转换,硬件时间在1到2毫秒量级,整条流水线从解码器输出帧到拿到BGRA数据,延迟在5毫秒以内。这个数据对于显示、录制、算法预处理都是足够友好的。
如果用CPU软解加CPU转格式,同样的1080p30流畅度只能勉强做到,CPU占用率会接近百分之百,而且遇到复杂画面时还会掉帧。两块硬件的差距不是一个量级。
在RK3588这种平台上继续往上叠路数,我测过4路1080p30解码加4路RGA转换,CPU总占用仍然保持在百分之二十以内,主要是零碎开销。RGA是共享硬件单元,多路转换时会产生排队,但只要每路间隔分配好,还是能保持整体实时的。如果到8路以上,就需要关注RGA的带宽瓶颈。
6.2 多通道场景的调度策略
多路视频流并发时,解码任务天然是多线程的。每个视频流一个解码线程、一个RGA转换上下文,线程之间互不干扰。RGA调用本身是同步的,但也可以放到一个独立的转换线程池里做异步化,解码线程只管取帧,转换线程负责把NV12转成BGRA。这样做的好处是解码线程不会被RGA的排队时间阻塞,整体的帧间隔会更稳定。
内存方面,多路场景尤其要注意buffer复用。每路维护一个BGRA buffer池子,转换完成的帧在显示或算法消费后立即回收到池子里。我在4路项目里给每个池子预分配了5帧BGRA空间,换算下来内存占用约4路乘以1080p乘4字节乘5,大约80MB,完全可接受。
6.3 零拷贝的进一步尝试
RGA转换最大的成本不在计算,而在内存搬运。如果能做到解出来的帧不被复制,直接送往显示层或算法层,性能还能再上一个台阶。Rockchip平台上的零拷贝思路主要有两条:
- 解码帧的
MppBuffer直接作为RGA源,RGA输出到dma-buf,然后再通过drm/wayland直接显示,全程CPU只拿到fd和地址,不碰像素数据。 - 算法模块如果能直接消费NV12数据,最好不要转BGRA,这样连RGA这一层转换都能省掉,前提是模型输入支持YUV。
如果你的算法只能吃RGBA,那么RGA这层转换基本省不掉,能优化的是把转换结果输出到复用缓冲区,避免分配释放抖动。我最终在项目里保留了一个环形的BGRA buffer队列,解码线程和算法线程通过fd索引传递,性能比最初版本好了不少。
6.4 最后再分享一个排查经验
整个项目跑下来,我最大的体会是:这种硬件加速链路出问题时,一定要先分层定位。**先确认解码帧格式对不对,再把解码帧dump出来看图像对不对,最后才去调RGA的参数。**很多人一上来就改RGA配置,来回折腾半天浪费大量时间。把每一层的输入输出截图或存文件,用工具验证,再往下走,几次下来就能形成自己的排错套路。
如果你的项目里也要做RKMPP硬解加RGA转BGRA,照着前面这几步走,大部分坑都能避开。真遇到奇怪的现象,按照第5章的排查链路一步步做,很少会有解决不了的问题。