1. RK3588视频处理链路:为什么我最终选了RKMPP+RGA这套组合
在RK3588这类平台上做视频处理,最绕不开的就是H264硬解和像素格式转换这两件事。我最早接触这个需求是在做多路视频拼接产品时,解码端需要从网络流中解出H264数据,然后把画面交给AI模块做分析,同时还要在本地显示。最开始偷懒,直接拿FFmpeg软解,RK3588的CPU虽然不算弱,但一路1080P30跑下来就占掉三四个核,到了四路同时解码直接卡到没法看。后来换成RKMPP硬解,CPU占用直接降到5%以内,效果立竿见影。
但硬解之后还会遇到一个新问题:RKMPP解码输出的格式默认是NV12,而很多上层应用,比如QT渲染、OpenCV处理、某些AI推理框架,直接吃的是BGRA8888或RGBA8888。NV12到BGRA8888的转换,如果又丢给CPU去做查表转换,那等于把刚刚省下来的性能又浪费回去了。所以我在项目中引入了RGA(Rockchip Graphics Acceleration)统一处理格式转换,把解码输出到应用可用格式这条链路完全交给硬件。
这篇文章就把我在RK3588上基于RKMPP+RGA做H264硬解码与BGRA8888格式转换的完整实践记录下来,包括解码器初始化、码流分包、帧数据管理、RGA转换、内存生命周期管理,以及我实际踩过的坑。适合正在RK3399、RK3566、RK3588等瑞芯微平台上做视频处理、想要短时间内把硬解链路跑通的朋友参考。
1.1 核心需求拆解:一个典型的解码转换链路
拆开来看,这条链路需要解决三个问题:
- 从数据源拿到H264的裸流(ES流)或封装流,拆出完整的编码帧。
- 把编码帧喂给RKMPP解码器,由VPU硬件完成解码,拿到NV12格式的图像帧。
- 通过RGA把NV12帧转换成BGRA8888,交给上层使用。
这里面每一步都有细节。解码器要处理I帧、P帧的依赖关系,MPP在解码时不是一包一包立刻出图的,而是内部维护了一套参考帧管理机制。RGA则要搞定缓冲区的物理地址映射和格式转换参数配置。如果这两块的机制没有吃透,跑起来会出现花屏、卡顿、黑屏、甚至直接崩溃的问题。
我这一版实现的重点放在解码正确性和格式转换效率上。之所以用RKMPP而不是直接用FFmpeg的hwaccel,是因为RK3588对MPP的支持是最完整和直接的,各路厂商的开发包也都是推荐直接用MPP API;而FFmpeg的hwaccel在部分BSP上存在版本同步问题,调试起来多一层隔阂。RGA这边,我选择走im2d接口(libRGA),而不是直接操作rga_info结构体,前者封装程度更高,参数语义更清晰,后续维护也省心。
1.2 链路设计的两个关键考量
这条链路有两个容易被忽视的设计点,先说清楚:
第一是解码线程和转换线程的关系。我采用的是“解码线程 + 转换线程 + 应用回调”的三段式结构。解码线程从网络或文件读取数据,分包后送入MPP,取出解码后的MppFrame;转换线程负责把拿到的MppFrame通过RGA转成BGRA8888;转换完成后通过回调接口通知上层应用取走buffer。这样做的原因是MPP解码本身是异步的,取帧频率和送入码流的频率并不完全一致,如果放在同一个线程里同步执行,遇到B帧多的码流,送帧和取帧节奏容易互相干扰,导致解码器内部缓冲堆积。
第二是内存需要物理连续。MPP解码输出的frame buffer和RGA要操作的目标buffer,都必须位于物理连续的内存区域。在Linux用户态,最简单的方式是用MPP自己提供的mpp_buffer接口来申请解码侧buffer,RGA侧的输出buffer则可以通过Rockchip的dma-buf堆或者ION来分配。如果你随手malloc一块内存,传给RGA的时候大概率会失败,因为RGA硬件访问内存依赖MMU映射,普通用户态虚拟地址它根本认不了。我在调试阶段就被这个问题卡过两天,后面会专门讲。
2. RKMPP解码器初始化:初始化参数背后的机制
MPP的解码流程说简单也简单,就是创建上下文、配置参数、循环送包取帧。但每一步都有它自己的讲究。初始化部分,我直接贴出来调通的代码,然后逐个参数解释。
#include <rockchip/mpp_mpi.h> #include <rockchip/mpp_frame.h> #include <rockchip/mpp_packet.h> #include <rockchip/rk_mpi.h> MppCtx ctx = NULL; MppApi *mpi = NULL; MppPollType timeout = MPP_POLL_NON_BLOCK; // 1. 解码器上下文创建 mpp_create(&ctx, &mpi); // 2. 设置解码类型H264 MPP_RET ret = mpi->control(ctx, MPP_DEC_SET_PARSER_SPLIT_MODE, &need_split); if (ret != MPP_OK) { mpp_err("failed to set parser split mode\n"); } // 3. 初始化解码上下文 ret = mpp_init(ctx, MPP_CTX_DEC, MPP_VIDEO_CodingAVC); if (ret != MPP_OK) { mpp_err("mpp_init failed\n"); mpp_destroy(ctx); return -1; }MPP_DEC_SET_PARSER_SPLIT_MODE这个参数特别关键,它有0和1两种取值。取0的话,MPP默认你送入的每个MppPacket都是完整的一帧数据,内部不再做分帧解析;取1的话,MPP内部会启用parser模块,自动从一段连续码流中切割出帧边界。我建议一定要开成1,因为网络流拿到手时经常是切片过的,一个包可能只包含半个帧甚至包含多个帧,让MPP自己切割帧省去你处理字节流的麻烦。但是要提醒的是,开启split mode之后,送入的码流必须是按解码顺序(DTS顺序)连续送入的,不能乱序跳包。
还有一个经常踩的坑是MPP_DEC_SET_OUTPUT_BLOCK,如果不设置的话,取帧默认是非阻塞模式,也就是mpi->dequeue返回的时候可能没有帧可拿。写多线程解码时,建议显式设置成非阻塞并用轮询+等待的方式取帧,而不是依赖默认行为,因为不同BSP版本对默认阻塞模式的实现有差异。
2.1 解码器上下文初始化的完整流程
mpp_init完成之后工作还没完,还要设置几个解码参数才能开始正式送码流:
// 4. 配置分组解码模式 MppDecCfg cfg = NULL; mpp_dec_cfg_init(&cfg); mpp_dec_cfg_set_u32(cfg, "base:grp:enable", 1); mpp_dec_cfg_set_u32(cfg, "base:grp:frames", 16); mpp_dec_cfg_set_u32(cfg, "base:grp:rows", 2); mpi->control(ctx, MPP_DEC_SET_CFG, cfg); mpp_dec_cfg_deinit(cfg); // 5. 准备解码分组 MppBufferGroup frm_grp = NULL; mpp_buffer_group_get_internal(&frm_grp, MPP_BUFFER_TYPE_ION); mpi->control(ctx, MPP_DEC_SET_EXT_BUF_GROUP, frm_grp); // 6. 设置取帧超时时间 RK_U64 timeout = 1000; // 1s mpi->control(ctx, MPP_SET_OUTPUT_TIMEOUT, &timeout);这步有个概念容易混淆:"base:grp:enable"设置为1,表示使用分组解码模式,也就是MPP内部的reference frame管理和输出frame管理走同一套分组buffer。这样MPP在循环解码时能复用输出缓冲区,减少内存分配和拷贝,实测下来比非分组模式解码性能更高。"base:grp:frames"表示分组内的帧数量,要根据分辨率设置。我做过一个粗略的经验表:
| 分辨率 | 建议frames数量 | 说明 |
|---|---|---|
| 720P及以下 | 8~12 | 帧小,buffer占用少,设太低影响解码效率 |
| 1080P | 16 | 1080P典型值,兼顾内存占用和解码流畅度 |
| 4K | 24~32 | 4K帧大,B帧多的时候需要更多buffer做重排 |
解码连续视频流时如果frames数量太小,会出现MPP内部没有可用buffer,表现为dequeue拿不到帧或者decoder卡住,但又不是死锁,就是吞吐突然降为0。这个现象排查起来非常恶心,我后面会在问题篇里专门讲。
2.2 包管理:怎么把H264裸流正确喂给MPP
解码器初始化完成之后,就是送包取帧的循环了。这里我把核心逻辑抽取出来,带注释的版本:
// 送包 MppPacket packet = NULL; // 假设输入数据在buf中,长度len mpp_packet_init(&packet, buf, len); mpp_packet_set_pts(packet, pts); mpp_packet_set_length(packet, len); // 清零EOS标记,防止历史状态残留 mpp_packet_set_eos(packet, 0); ret = mpi->decode_put_packet(ctx, packet); if (ret != MPP_OK) { // 缓冲区满了,需要取帧后再送 av_log(NULL, AV_LOG_WARNING, "decode_put_packet failed"); } // 取帧 MppFrame frame = NULL; ret = mpi->decode_get_frame(ctx, &frame); if (frame) { // 成功拿到解码帧,处理MppFrame handle_mpp_frame(frame); mpp_frame_deinit(&frame); } mpp_packet_deinit(&packet);这里有几个经验值值得写下来。首先是送包失败的处理逻辑。我曾经写过一版不加判断、无脑往解码器里塞包的代码,结果在B帧较多的码流上偶发丢帧,画面表现是一卡一卡的。原因就是decode_put_packet返回MPP_ERR_BUFFER_FULL时,你没有及时取帧腾出buffer,导致发送端直接把数据放弃了。正确做法是返回buffer full之后先decode_get_frame清缓冲,再重新put_packet。
其次是pts的处理。MPP的H264解码器会在解码过程中根据SPS/PPS和时间戳信息重建出帧的pts,如果你的输入码流本身没有pts,MPP也会给你一个基于帧序号的默认pts。我建议在送包时务必显式设置pts,这样上层做同步显示时才有据可依。我们曾做过一个项目,忽略了pts传递,结果解码出的视频画面内容和音频完全对不上。后来排查才发现是解码侧pts全部为0,同步逻辑拿到的时间戳全一样。
2.3 取帧后的第一步:认识MppFrame的格式细节
成功拿到MppFrame之后,首先要做的事是读它携带的格式信息。不要默认它一定是NV12,尤其是码流里包含不同分辨率的SPS切换时,MPP输出的分辨率、格式都会动态变化。
void handle_mpp_frame(MppFrame frame) { // 格式信息 MppFrameFormat fmt = mpp_frame_get_fmt(frame); RK_U32 width = mpp_frame_get_width(frame); RK_U32 height = mpp_frame_get_height(frame); RK_U32 h_stride = mpp_frame_get_hor_stride(frame); RK_U32 v_stride = mpp_frame_get_ver_stride(frame); RK_S64 pts = mpp_frame_get_pts(frame); MppBuffer buf = mpp_frame_get_buffer(frame); // 实际数据地址 void *data = mpp_buffer_get_ptr(buf); int fd = mpp_buffer_get_fd(buf); // 如果格式是通用NV12(YUV420SP),可以用RGA转换 if (fmt == MPP_FMT_NV12) { // 交给RGA处理 rga_convert_nv12_to_bgra(data, fd, width, height, h_stride, v_stride, pts); } }如果你之前接触过V4L2,会知道compressed format下硬件输出经常带着stride align,sps中声明的是1920x1080,但实际硬件输出是1920x1088甚至1920x1152。这是因为VPU硬件为了对齐内存访问,会给每行填充额外的像素。用mpp_frame_get_hor_stride拿到的才是硬件实际的行宽度,数据读取和RGA转换时必须用这个stride而不是width,否则画面会斜切或者有绿边。RGA转换时源图像的wstride参数要填hor_stride,而dst的wstride填你希望的目标地址行宽,如果目标是BGRA8888且不做缩放,一般就是width*4。
3. RGA转换:从NV12到BGRA8888的不传之秘
MPP把解码帧交出来之后,重头戏就是RGA格式转换了。RK3588的RGA模块已经发展到RGA2和RGA3两个硬件单元,其中RGA3不支持格式转换,只做缩放和旋转;支持格式转换的是RGA2,但在软件层libRGA的im2d接口已经把底层透明化了,不用自己区分RGA版本,只需要调用imconvert函数并传入正确的ImageInfo即可。
初次接触RGA的朋友最容易在缓冲区这块踩坑。RGA的输入输出必须使用dma_fd或者物理地址,官方API支持三种传递方式:
- 通过fd(dma-buf fd,强烈推荐)
- 通过物理地址(需要打开/dev/mem或者使用ION的物理地址API)
- 通过虚拟地址(仅部分版本支持,且性能差)
在Linux用户态,主流做法是申请一块dma-buf,拿到它的fd,然后传给RGA。MPP解码出来的MppBuffer本身就是一个dma-buf,mpp_buffer_get_fd可以直接拿到fd,完美对接。因此我这里的目标buffer也是通过Rockchip dma-buf堆申请:
#include <im2d.h> #include <rga.h> #include <rockchip/rk_rga.h> #include <RockchipRga.h> #include <drm/drm_fourcc.h> // A. 申请目标BGRA buffer // 方法1: 用dma-buf heap #include <linux/dma-heap.h> int heap_fd = open("/dev/dma_heap/system", O_RDWR); struct dma_heap_allocation_data alloc_data = {0}; alloc_data.len = width * height * 4; alloc_data.fd_flags = O_CLOEXEC; ioctl(heap_fd, DMA_HEAP_IOCTL_ALLOC, &alloc_data); int dst_fd = alloc_data.fd; void *dst_map = mmap(NULL, alloc_data.len, PROT_READ | PROT_WRITE, MAP_SHARED, dst_fd, 0);3.1 使用libRGA的im2d接口做转换
接下来是RGA转换的核心代码。如果是在Android平台上,通常会直接用RockchipRga的封装类;但如果像我一样在Ubuntu、Buildroot或Debian这样的Linux系统上开发,建议直接用im2d的C接口,头文件是im2d.h和rga.h。
static int rga_nv12_to_bgra(int src_fd, int dst_fd, int src_w, int src_h, int src_h_stride, int dst_w, int dst_h) { int ret = 0; rga_buffer_t src_img, dst_img; im_rect src_rect, dst_rect; memset(&src_img, 0, sizeof(src_img)); memset(&dst_img, 0, sizeof(dst_img)); // 源图像:NV12格式,fd传入 src_img = wrapbuffer_fd_t(src_fd, src_w, src_h, src_h_stride, src_h, RK_FORMAT_YCbCr_420_SP); // 注意目标图像的wstride:BGRA8888是按4字节对齐的 // 目标buffer的size需要是 dst_w * dst_h * 4 dst_img = wrapbuffer_fd_t(dst_fd, dst_w, dst_h, dst_w * 4, dst_h, RK_FORMAT_BGRA_8888); // 整幅图转换,rect传空表示全图 memset(&src_rect, 0, sizeof(src_rect)); memset(&dst_rect, 0, sizeof(dst_rect)); // 执行转换 ret = imconvert(src_img, src_rect, dst_img, dst_rect); if (ret != IM_STATUS_SUCCESS) { printf("RGA convert failed, ret=%d\n", ret); return -1; } return 0; }注意wrapbuffer_fd_t的第一个参数是fd,不是虚拟地址。这是最推荐的用法。如果你拿到的是MppBuffer的虚拟地址,可以用wrapbuffer_virtualaddr_t,但性能不如fd方式,因为RGA驱动内部需要额外做地址映射。实测下来性能差异不大,但fd方式更干净,不会出现地址映射失败的问题。
另外有个容易忽略的细节:imconvert的src_h和wrapbuffer_fd_t里的src_h要一致,但目标wrapbuffer_fd_t的wstride要传入dst_w4,因为BGRA8888每个像素占4字节,你想让RGA按每行dst_w4字节去寻址目标内存。如果这里填成dst_w,画面会变成斜的,因为RGA按错误的行宽写入目标内存。
3.2 理解RGA的格式支持与性能参数
RGA支持的颜色空间转换远比我们需要的多,NV12转BGRA8888属于YUV到RGB的转换,还有NV16、NV24、YUYV、RGB565、ARGB8888这些常用格式。在做方案选型时,我整理过RK3588 RGA2实际支持的常用转换矩阵:
| 源格式 | 目标格式 | 是否支持 | 性能备注 |
|---|---|---|---|
| NV12/YCbCr420SP | BGRA8888 | 支持 | 1080P转1080P实测<2ms |
| NV12/YCbCr420SP | RGBA8888 | 支持 | 同上 |
| NV12/YCbCr420SP | RGB565 | 支持 | 内存减半,但色彩精度损失 |
| NV12/YCbCr420SP | NV12 | 支持 | 可做格式一致性的二次缩放 |
| RGB888 | BGRA8888 | 支持 | 少见但有效 |
| NV16/YCbCr422SP | BGRA8888 | 支持 | 色彩过渡更自然 |
| YUYV422 | BGRA8888 | 支持 | 常见于摄像头输出 |
RGA的带宽表现很好,在RK3588上执行1080P NV12到BGRA8888的全图转换,实际耗时大约在1~2ms,几乎可以忽略不计。4K分辨率大概在4~6ms。这里说的都是单次imconvert调用,不包含buffer申请和释放的开销。这说明性能目标完全不需要担心,重点还是把链路打通。
3.3 执行转换时容易忽略的同步问题
RGA硬件在执行转换时是异步的,imconvert调用返回之后,硬件可能还在读取源数据、写入目标数据。如果你的业务逻辑紧接着就去读目标buffer,会读到脏数据。正确的做法有两种:
第一种:在imconvert之后再调用一次imsync或imfill,等待RGA完成。就是我们上面写的最简单的那种方式,imconvert返回IM_STATUS_SUCCESS就认为硬件已经完成。实际上im2d接口在默认配置下是同步等待的,内部调用会等待命令队列完成。但如果你设置了IM_SYNC_ASYNC标志,就需要自己调用imsync等待。
第二种:显式等待,用im2d的query功能:
int ret = imconvert(src_img, src_rect, dst_img, dst_rect); if (ret != IM_STATUS_SUCCESS) { // 失败处理 } else { // 使用im2d的sync接口等待完成 imsync(); }我建议是一律使用同步模式,除非你确实要做pipeline双缓冲流水线。同步模式在单路视频场景下完全够用,代码还简单,不容易出bug。多路场景下异步模式收益明显,但需要配合fence机制使用,复杂度高很多,新手不建议一开始就上。
3.4 目标buffer怎么设计:一块还是多块
在视频链路中,解码是持续出帧的,每帧都需要一个目标BGRA buffer。你有两种选择:
一种是一帧一分配,每帧解码出来后临时申请一块dma-buf,转完释放。这种方式实现最简单,但性能较差,因为dma-buf申请和mmap/unmap都有固定开销。实测在4K@60场景下,每帧分配一次dma-buf大约增加0.5~1ms的CPU开销。
另一种是预分配buffer池。启动时一次性申请N块BGRA buffer(N取决于解码帧率和显示延迟要求,一般取4~8块),每帧轮转使用。这种方式CPU开销几乎为0,而且RGA硬件可以复用映射关系,性能更稳定。我实际项目中使用的是后者:解码线程从buffer池取一块,RGA转换写进去,然后交给上层使用,上层消费完毕后再放回池中。
这里要特别注意buffer池的并发安全。我的做法是用一个互斥锁+条件变量管理空闲队列和忙队列,解码线程需要空闲buffer时从队列中出队,上层消费完由回调函数归还。如果buffer池耗尽,解码线程就等待条件变量,而不是丢帧。这样就保证了不会因为buffer不足导致画面卡顿或数据覆盖。
4. 线程模型与内存生命周期:解码转码链路设计中的隐藏功课
把MPP解码和RGA转换分别跑通之后,真正让整个链路稳定工作的是线程模型和内存生命周期设计。这部分如果不认真设计,系统会在运行几十分钟后突然崩掉或者画面卡顿,而且非常难复现。
我的设计思路是这样的:
- 一个输入线程:负责从数据源读取H264数据,分包后送入MPP。
- 一个解码线程:负责从MPP取MppFrame,交给RGA转换,转换完成后发布结果。
- 上层应用通过轮询或回调机制获取BGRA帧。
输入线程和解码线程通过一个环形队列连接。输入线程从数据源读数据,封装成Packet放入队列;解码线程从队列取出Packet送入MPP,同时不断从MPP取帧。环形队列的容量设为解码帧率的三倍左右,比如30FPS的视频,队列容量设90格,这样既能吸收网络抖动和码流突发,又不至于占用太多内存。
4.1 为什么不能把所有功能塞进一个线程
也许你会想,单线程同步循环不行吗?读数据、送包、取帧、转换、通知,在一个线程里按顺序跑。
我在初始版本里确实这样干过,在我自己的测试Demo上一切正常,但一旦接入RTSP源,问题就冒出来了。RTSP源的网络抖动会导致数据到达时间不均匀,如果此时正好赶上网络卡顿,解码线程里的送包操作会阻塞在等待MPP内部buffer可用上,导致后面的取帧和转换全部停顿。表现就是画面会突然卡住几秒钟,然后连续追上好几帧,视觉效果上就是"顿挫感"。
拆成多线程之后,网络抖动的吸收被环形队列消化了。输入线程只管往队列里写,解码线程照常从MPP取帧。即使网络出现几秒钟的抖动,队列也能保证解码线程一直有数据可处理;解码线程即便暂时没什么可解,也不会影响输入线程继续接收网络数据。这种"生产者-消费者"模型是多路视频场景下的标准解法。
4.2 帧缓冲区生命周期与所有权转移
缓冲区生命周期是本项目中最容易出bug的地方,我把设计原则写清楚:
- MPP解码出的MppFrame,只负责从MPP取帧到RGA转换完成这一段。它属于解码线程,解码线程在RGA转换完成之前不能释放它。
- RGA转换完成之后,MppFrame可以立刻归还MPP解码器,也就是调用mpp_frame_deinit并让MPP复用其内部buffer。这一步不用等待上层应用消费完,因为转换已经拷贝完成,源数据不再需要了。
- 目标BGRA buffer,从buffer池中取出后,直到上层应用消费完毕归还之前,所有权都属于"输出帧"。转换线程只需保证把RGA数据写进去,后续读取完全由上层负责。
在代码层面,我定义了一个结构体来统一描述输出帧:
typedef struct { int fd; // dma-buf fd void *map; // mmap后的虚拟地址 size_t size; // buffer大小 int width; int height; int wstride; // 通常=width*4 uint64_t pts; int is_used; // 是否被上层占用 } BgraFrame;上层每次调用提取函数时,只允许访问is_used == 0的帧;提取后将is_used置1,消费完成后调用释放函数将is_used置0。这里用原子操作或互斥锁保护好is_used字段。
5. 常见问题与排查技巧实录
这部分是我最想分享的,全是实际踩过的坑和排查思路。我按症状分类,给出排查路径。
5.1 解码花屏或绿屏:多半是码流分帧问题
花屏的常见原因有几个,由高到低排查:
- 送入MPP的不是完整帧。检查看是否开了split mode,没开就必须保证每个packet完整。
- SPS/PPS没有正确传递。在接入RTSP或流媒体时,SPS/PPS通常只出现在关键帧前,如果网络抖动导致关键帧丢失,后面所有依赖该关键帧的P帧都会解码失败。用MPP的split mode再配合自我注入SPS/PPS包,能显著减少花屏概率。
- 分辨率变化时,之前申请的buffer池大小不够。MPP内部对分辨率变化有处理逻辑,但如果你预分配的buffer池没有及时扩容,解码输出的新分辨率帧可能写不进去。
我第一次遇到花屏时,在H264送到MPP前先做了一次完整的帧边界解析,把每个NALU拆开然后重组完整帧再送入,结果还是花。排查到最后发现是SPS/PPS信息只在首帧出现,之后的码流遇到I帧却没有SPS/PPS,MPP没法正确初始化解码上下文。解决方案是维护一份最新的SPS/PPS,每次遇到关键帧时在码流前补上这两个NALU。
5.2 RGA转换出来的图像颜色偏绿或发紫
这个问题的症状非常经典:用解码帧直接在屏幕上显示没问题,但RGA转成BGRA后,图像整体偏绿或者偏紫。原因基本都是NV12数据访问方式不对。
NV12的特点是一大片Y平面加上交错排布的U、V平面(UV交错)。在内存里,Y分量在前,UV分量在后。某些平台的NV12布局是Y平面之后紧跟UV交错,但也有一些平台在Y平面之后会有额外的行对齐填充。如果你的代码在读UV平面时没跳过对齐填充,UV数据就会错位,色彩自然就乱了。
解决办法是严格按照ver_stride计算UV平面的偏移。正确的NV12数据长度是h_stride * v_stride * 3 / 2,其中UV平面从h_stride * v_stride开始(注意,这里用的必须是h_stride,而不是width)。如果MPP输出的帧是1920x1080,但h_stride是1920,v_stride可能也是1088,那么Y平面大小是19201088,UV平面从19201088开始,总大小是192010883/2。使用width来计算的后果就是整个图像上下半部分错位。
5.3 解码器在B帧多的码流中出现"卡死"
卡死这个现象很迷惑,因为程序没有崩溃,只是decode_get_frame长时间拿不到新帧。我遇到过一次,排查了很久才定位到问题。
原因是MPP解码器内部可用的buffer被占满了,而占满的buffer对应的frame还没有被取走。也就是说,解码器在等待你把旧帧取走,而你的取帧逻辑因为某些原因没有执行。最典型的场景是你在单线程里做了"先送包再取帧"的操作,当送包把buffer用完后,后面的取帧确实执行了,但因为之前的包没有及时取帧,MPP内部buffer满了,送包又失败,整体进入一种循环等待的死锁状态。
解决方式很简单:送包失败返回buffer full时,无条件先取一帧,如果取不到帧,说明当前确实暂时没有可出的帧,sleep几毫秒后重试。这样既能让MPP腾出buffer,又不会丢掉应该取出的帧。
5.4 RGA转换在连续跑了几分钟后变慢
这个问题比较隐蔽,出现在我的一个多路项目中:RGA转换前几次非常快,但跑了几分钟后明显变慢,帧率掉到原来的一半。
排查发现是dma-buf分配和释放过于频繁导致的。最初版本的实现是每一帧都mmap一个目标buffer,转换完成后munmap并释放fd。内核的dma-buf heap分配和mmap虽然不算太慢,但反复分配会产生大量内核页表操作和物理页cache操作,时间长了会有冷页分配导致的性能抖动。改成预分配buffer池之后,性能曲线变得非常平直。
这也是我对所有做视频链路的开发者的建议:凡是涉及dma-buf、ION这类内核资源,能复用就一定复用,不要在每帧里重复申请释放。在内核态分配内存的代价远比你想象的贵。
5.5 常见问题速查表
| 症状 | 可能原因 | 排查建议 |
|---|---|---|
| 播放画面全绿 | 解码失败或SPS/PPS缺失 | 检查split mode,检查是否注入SPS/PPS,抓H264文件本地断点排查 |
| 画面斜切/上下错位 | stride不一致 | 确认使用h_stride而不是width,确认RGA源wstride填的是h_stride |
| 颜色偏紫/偏绿 | NV12 UV偏移计算错误 | 检查UV平面起始地址是否等于h_stride*v_stride |
| RGA调用返回错误 | buffer不是dma-buf | 确认buffer通过dma heap/ION申请,拿到fd传给RGA |
| 解码线程阻塞 | MP P内部buffer已满 | 修改变送包逻辑,为取帧等待留出路径 |
| 长时间运行变卡 | 频繁申请释放dma-buf | 改造成预分配buffer池 |
| 多路解码CPU高 | 有平台软解代码混入 | 确认解码路径真正走MPP,检查RKMPP日志,通过mpi->poll确认硬件解码实际生效 |
6. 写在最后的实际操作体会
这个项目做下来,我最大的体会是:RKMPP和RGA本身并不是特别难用,难的是它们之间以及它们和你业务代码之间的内存、线程、时序关系。如果只是按照API demo把解码跑通,那非常快;真正花时间的是把链路做稳、做快、做可维护。
最后再分享一个小技巧:在开发调试阶段,给MPP打开日志输出会节省大量时间。在初始化时通过MPP_DEC_SET_INFO_CHANGE_CB设置分辨率变化回调,再在回调里把新的宽高、stride打印出来,可以及时发现分辨率切换导致的buffer问题。另外,RKMPP还内置了mpp_err和mpp_log接口,用起来很顺手,建议在每条关键路径上加上日志输出,别等出问题再盲猜。
这个链路后续还可以扩展的方向很多:比如把RGA的缩放能力加进来,一次性完成解码+缩放+格式转换;或者引入多路并发解码,每路一个MPP实例,共享同一个RGA做转换。因为RGA本身是硬件单元,多路轮询执行时只要不超出带宽限制,性能衰减非常小。我在一个四路1080P的项目中就采用了单RGA服务多路解码的方案,整体效果比之前每路各开一个软转换线程要好很多。希望这篇实践记录对正在做同样工作的朋友有帮助。