
1. 项目背景RK3588的硬件编码与MPP这层关系做RK3588上的视频服务绕不开MPPMedia Process Platform。我的项目里有一条很典型的链路ISP或者RGA把摄像头数据转成NV12送进RK3588的硬件VPU编码成H.264再封装进MP4或直接推RTSP。最开始图省事直接照抄MPP demo结果业务代码和MppCtx、MppPacket、MppBuffer搅成一团遇到mpp解码失败或者编码超时排查起来非常痛苦。后来我把编码流程整理成一个独立的封装类对外只暴露init、encode、flush这几个方法这篇文章就是把从YUV到H.264的完整封装思路和踩坑记录写下来。适合正在RK3588上做编解码需求的嵌入式工程师也适合从FFmpeg切到MPP、想搞明白底层数据怎么流转的朋友。另外说个现实场景很多做RK3588 AI盒子的人实际链路是yolov8这类模型推理完再把检测框叠加到视频帧上最后编码成H.264推流。这种场景下吞吐和延迟都有硬性要求CPU软编肯定顶不住必须走VPU。而VPU不像FFmpeg那样给你一整套流媒体封装它只负责最核心的裸编码所以“包装”这层工作就得自己做。这篇文章讲的封装类就是把这层包装沉淀下来让你在不同项目里能直接复用。1.1 RK3588的VPU到底能干多少活RK3588的VPU是双核设计解码和编码是分开的硬件单元。编码这边H.264和H.265都支持到8K30fps4K60fps更是轻松同时还能跑多路并发。官方给的典型能力是H.264/H.265编码加解码同时工作也能保持较高总吞吐这对比上一代RK3399是质的提升。老平台想做4K编码基本要靠软件硬扛到了RK3588上硬件编码器的存在感就非常强了。我在实际项目里跑过两路1080p30fps编码同时还有一路4K解码VPU占用大概一半左右CPU占用几乎可以忽略。如果换成x264软编两路1080p能吃掉六到八个核这在算力已经分出很大一部分给NPU的RK3588上完全不可接受。所以结论很直接在RK3588上做视频编码第一选择永远是MPP硬编。1.2 MPP和FFmpeg、业务代码的边界在哪很多从PC开发转过来的同事会问为什么要自己写MPP封装FFmpeg不香吗FFmpeg确实有rkmpp这套硬件加速接口用起来也方便但问题在于FFmpeg对你的输入输出格式、buffer管理、关键帧时机做了太多“自动处理”在灵活的流媒体服务和多路并发场景里这种自动处理反而成了束缚。MPP是Rockchip提供的用户态多媒体库底层通过/dev/mpp_service这个节点和内核态VPU驱动通信。它做的事情非常纯粹你给它一帧YUV它给你一个H.264或者H.265的裸流包。它不关心你的流是给播放器用的还是给录像用的也不负责推流和封装。这种设计的好处是轻、直接、可控坏处是上手曲线陡。你要自己理解MppFrame、MppPacket、MppBuffer这些对象的生命周期自己管理内存分配和释放还要处理硬件编码器背压的问题。所以我最后定下来的方案是不排斥FFmpeg做封装和推流但编码主链路直接用原生MPP自己写一个薄薄的封装类把MPP这堆概念收在类内部。业务层调接口的时候根本不知道MPP的存在这样代码清爽出了问题也容易定位。2. 动工前必须吃透的MPP核心概念2.1 编码流程里的四个核心对象第一次看MPP的示例代码很多人都会被MppCtx、MppApi、MppFrame、MppPacket、MppBuffer这一堆类型吓到。其实它们关系很清楚编码场景下核心就四个。MppCtx和MppApi是一对MppCtx是编码器上下文句柄MppApi是一组函数指针所有编解码操作都通过这个指针调用。你可以把MppApi理解成MPP给业务层的一张“函数表”这张表里有init、encode_put_frame、encode_get_packet等入口。MppFrame是输入侧对象描述一帧视频图像包括宽高、像素格式、时间戳以及实际数据所在的MppBuffer。MppPacket是输出侧对象描述一个编码后的数据包里面有data指针、length、pts还有关键帧标记。MppBuffer则是所有数据的载体。注意它不是简单malloc出来的内存而是从ION/dma-buf分配的内存这样才能被VPU硬件直接访问。所有要送进编码器的YUV数据最好都放进MppBuffer再通过MppFrame交给硬件。你从DDR拷一个malloc数组进去也行但一定要经过memcpy不能让硬件去访问一个它不认识的内存区域否则要么报错要么花屏。这四个对象的关系我用一句话总结MppFrame和MppPacket是“说明书”MppBuffer才是真正的“货物”MppApi负责把货物按说明书搬进搬出。2.2 YUV格式与对齐为什么NV12不能直接按宽高算大小这里必须单独讲一下YUV格式因为我在这个坑里摔过不止一次。RK3588编码器最常见的输入是NV12也就是YUV420SP格式先是完整的Y平面然后是一块UV交错平面。以1920x1080为例Y平面是1920乘1080个字节UV平面是1920乘540个字节总共1920乘1080乘1.5个字节。但这句话只说对了一半。硬件编码器对内存对齐有要求宽和高不能直接用1920和1080而要对齐到一个步幅stride。我在RK3588上实测宽度至少按16对齐高度按16对齐比较稳有些场景按64对齐性能更好。也就是说一个1920x1080的NV12帧实际分配内存要按hor_stride1920、ver_stride1088来算buffer size是1920乘1088乘1.5比理论值大出一截。这个差异在从RGA拿数据时尤其致命。RGA输出的dst地址和dst stride可能跟你MPP配置的stride不一致如果直接把RGA输出的buffer地址塞给MPPVPU会按自己以为的stride去读数据读出来的图像就是斜的或者花的。所以封装类里一定要把hor_stride、ver_stride、buffer size这三个参数算清楚并且保证所有环节都用同一套stride。这也是为什么我自己写封装类时统一在init阶段就把stride算好内部所有分配都基于这个值不依赖外部传入的“宽高乘1.5”。3. 封装类设计把复杂性关进小黑屋3.1 对外接口只保留业务需要的动作业务层关心的事情其实很少初始化编码器、送一帧YUV进去、拿H.264包出来、请求关键帧、程序退出时释放资源。其他什么buffer group、packet deinit、put_frame失败重试都不应该出现在业务代码里。所以我给封装类定义了一套很克制的接口init传宽高帧率码率等配置encode传一帧NV12数据和pts编码完成后通过回调把H.264包吐出来requestIDR手动请求关键帧flush在停止编码时把编码器缓冲区里残留的数据刷干净deinit释放所有资源。init的参数收敛到一个Config结构体里默认参数给常用值业务方通常只需要改宽高和码率。回调方式比返回值方式更合适。因为MPP编码是异步的encode调用后数据不会立刻全部出来可能当前帧和前一帧的数据一起返回也可能一次get_packet拿到多个NALU。用回调把数据消费逻辑放到业务层编码类自己只关注“从VPU拿到数据”这一件事。我的回调签名是std::functionvoid(const void* data, size_t len, uint64_t pts, bool keyframe)data里的数据是带起始码的Annex-B格式可以直接写文件或者丢给封装器。3.2 线程模型和内存分配策略线程模型上我的封装类不主动创建线程默认由调用方线程驱动。也就是说调用encode时函数内部会完成“送帧、取包、回调”整条链路编码耗时会暴露在调用方线程上。这样做的好处是简单、可控适合那些上层已经有线程模型的场景比如摄像头采集线程里直接调用encode。缺点是不够异步如果编码耗时不稳定可能影响采集节奏。内存分配策略我在设计时做了两个选择。第一输入buffer在init阶段从MPP的buffer group一次性分配好encode时直接memcpy进去避免每次编码都做buffer get/put也避免频繁的ION内存映射开销。第二如果外部传入的YUV本身就是来自RGA或摄像头的dma-buf可以把MppBuffer直接交给MPP省掉一次memcpy。我在类里留了一个encode(MppBuffer buf, uint64_t pts)的重载就是为了走零拷贝路径。1080p NV12一帧差不多3MBmemcpy一次大概一毫秒上下单路不敏感但四路并发时省掉这份拷贝收益很明显。4. 从YUV到H.264一步一步实现封装类4.1 初始化编码器一堆cfg参数背后的含义初始化是所有环节里门道最多的部分因为MPP把所有配置都塞进了一个MppEncCfg对象通过字符串key来设置。我第一次看到mpp_enc_cfg_set_s32(cfg, prep:width, 1920)这行代码时心里是很懵的。后来总结下来总共就三类图像预处理参数、码率控制参数、编码器行为参数。图像预处理参数以prep开头包括width、height、hor_stride、ver_stride、format。width和height是逻辑分辨率hor_stride和ver_stride是内存步幅format设成MPP_FMT_YUV420SP对应NV12。码率控制参数以rc开头mode字段设成MPP_ENC_RC_MODE_CBR就是固定码率设成VBR就是动态码率。固定码率适合网络传输码率波动小可变码率适合本地录制同等码率下画质更好。bps是目标码率bps_max和bps_min是码率波动范围我一般设成目标码率的正负百分之六左右。rc:fps必须和真实输入帧率一致否则码率控制会失真。编码器行为参数包括gop:len即两个IDR之间的帧间隔以及codec:type和codec:header_mode。header_mode设成MPP_ENC_HEADER_MODE_EACH_IDR可以让每个IDR帧前面都带上SPS/PPS对播放器最友好。完整的初始化代码大致是这样#include rk_mpi.h #include mpp_enc_cfg.h #define ALIGN16(x) (((x) 15) ~15) bool RkMppH264Encoder::init(const Config cfg) { cfg_ cfg; horStride_ ALIGN16(cfg_.width); verStride_ ALIGN16(cfg_.height); bufSize_ horStride_ * verStride_ * 3 / 2; // NV12 MPP_RET ret mpp_create(ctx_, mpi_); if (ret ! MPP_OK) return false; ret mpp_init(ctx_, MPP_CTX_ENC, MPP_VIDEO_CodingAVC); if (ret ! MPP_OK) return false; mpp_enc_cfg_init(encCfg_); // 输入图像参数 mpp_enc_cfg_set_s32(encCfg_, prep:width, cfg_.width); mpp_enc_cfg_set_s32(encCfg_, prep:height, cfg_.height); mpp_enc_cfg_set_s32(encCfg_, prep:hor_stride, horStride_); mpp_enc_cfg_set_s32(encCfg_, prep:ver_stride, verStride_); mpp_enc_cfg_set_s32(encCfg_, prep:format, MPP_FMT_YUV420SP); // 码率控制 mpp_enc_cfg_set_s32(encCfg_, rc:mode, cfg_.cbr ? MPP_ENC_RC_MODE_CBR : MPP_ENC_RC_MODE_VBR); mpp_enc_cfg_set_s32(encCfg_, rc:bps, cfg_.bitrate); mpp_enc_cfg_set_s32(encCfg_, rc:bps_max, cfg_.bitrate * 17 / 16); mpp_enc_cfg_set_s32(encCfg_, rc:bps_min, cfg_.bitrate * 15 / 16); mpp_enc_cfg_set_s32(encCfg_, rc:fps, cfg_.fps); // GOP和编码器行为 mpp_enc_cfg_set_s32(encCfg_, gop:len, cfg_.gop); mpp_enc_cfg_set_s32(encCfg_, codec:type, MPP_VIDEO_CodingAVC); mpp_enc_cfg_set_s32(encCfg_, codec:header_mode, MPP_ENC_HEADER_MODE_EACH_IDR); ret mpi_-control(ctx_, MPP_ENC_SET_CFG, encCfg_); if (ret ! MPP_OK) return false; // 编解码等待超时单位ms0表示一直阻塞 RK_S32 timeout 200; mpi_-control(ctx_, MPP_SET_INPUT_TIMEOUT, timeout); mpi_-control(ctx_, MPP_SET_OUTPUT_TIMEOUT, timeout); // 输入buffer统一从MPP的ION buffer group分配 mpp_buffer_group_get_internal(bufGroup_, MPP_BUFFER_TYPE_ION); mpp_buffer_get(bufGroup_, inputBuf_, bufSize_); mpp_frame_init(frame_); mpp_frame_set_width(frame_, cfg_.width); mpp_frame_set_height(frame_, cfg_.height); mpp_frame_set_hor_stride(frame_, horStride_); mpp_frame_set_ver_stride(frame_, verStride_); mpp_frame_set_fmt(frame_, MPP_FMT_YUV420SP); mpp_frame_set_buffer(frame_, inputBuf_); return true; }这里有个细节值得展开为什么不用MPP的默认对齐非要自己算stride因为MPP的prep:width和prep:hor_stride其实是两个独立的概念。前者告诉编码器当前帧的逻辑分辨率后者告诉它内存里一行数据占多少字节。如果只设width不设hor_strideMPP在某些版本上会自己猜一个对齐值猜对猜错和输入数据来源强相关。自己算好stride并显式设置是把不确定性变成确定性的关键一步。4.2 单帧编码put_frame和get_packet的协作关系初始化完成之后核心循环就两件事把输入帧放进去的encode_put_frame把结果取出来的encode_get_packet。但这两者不是简单的“一进一出”配平关系硬件编码器内部有流水线可能你连续put了三帧一个packet都还没出来也可能你一put进去前面攒的两帧结果一起出来了。所以正确的做法是put之后循环get把所有当前可取的packet都取干净。还有一个必须处理的返回值是MPP_ERR_BUFFER_FULL。这个错误在编码输出缓冲被填满时返回如果忽略它后面再put帧就会出现时序混乱。处理办法很简单发现BUFFER_FULL就先去get_packet腾出缓冲再重新put。我在封装类里把“取包”单独抽成一个drainPackets函数逻辑上清爽很多。int RkMppH264Encoder::encode(const void* yuv, size_t size, uint64_t pts, PacketCallback cb) { if (!yuv || size bufSize_ || !cb || !ctx_) return -1; memcpy(mpp_buffer_get_ptr(inputBuf_), yuv, bufSize_); mpp_frame_set_pts(frame_, pts); mpp_frame_set_eos(frame_, 0); MPP_RET ret mpi_-encode_put_frame(ctx_, frame_); if (ret MPP_ERR_BUFFER_FULL) drainPackets(cb); return drainPackets(cb); } int RkMppH264Encoder::drainPackets(PacketCallback cb) { int count 0; while (true) { MPP_RET ret mpi_-encode_get_packet(ctx_, packet_); if (ret ! MPP_OK || !packet_) break; void* data mpp_packet_get_data(packet_); size_t len mpp_packet_get_length(packet_); uint64_t pts mpp_packet_get_pts(packet_); RK_U32 flag mpp_packet_get_flag(packet_); bool keyframe (flag MPP_PACKET_FLAG_INTRA) ! 0; cb(data, len, pts, keyframe); mpp_packet_deinit(packet_); packet_ nullptr; count; } return count; }单帧编码的逻辑看起来不长但背后有一个关键点值得强调packet用完必须立刻deinit。MPP的packet是复用内核缓冲的你不释放它下一个get_packet可能一直拿不到新的输出现象就是编码“卡住”。我刚上手的时候漏了这步跑了几百帧之后编码延迟越来越大最后直接超时排查了很久才发现是packet泄漏。4.3 关键帧控制与SPS/PPS获取流媒体场景里关键帧是刚需。播放器seek之后必须等到IDR才能出画面录像文件也需要IDR才能做随机访问。MPP提供了一条控制命令来强制编出IDR帧int RkMppH264Encoder::requestIDR() { if (!ctx_ || !mpi_) return -1; RK_S32 idr 1; return mpi_-control(ctx_, MPP_ENC_SET_IDR_FRAME, idr); }调用requestIDR之后下一次encode_put_frame进去的帧会被编成IDR对应的输出packet会带上MPP_PACKET_FLAG_INTRA标记。这里要踩的一个坑是请求IDR之后不要立刻把前面的packet丢掉因为IDR的输出可能要在后续几次get_packet里才出来。我在项目里是这样做流的检测到keyframe为true的packet时先拼上一段SPS/PPS再一起交给封装器保证播放器收到的是一个完整的可以解码的IDR。SPS/PPS的获取有两种方式。第一种是配置header_mode为EACH_IDR这样每个IDR packet前面都自带SPS/PPS取出来直接能用省心但每个IDR会多几十字节开销一般无伤大雅。第二种是从编码器主动取一次头信息std::vectoruint8_t RkMppH264Encoder::getSpsPps() { std::vectoruint8_t out; MppPacket header nullptr; MPP_RET ret mpi_-control(ctx_, MPP_ENC_GET_HDR, header); if (ret MPP_OK header) { out.assign((uint8_t*)mpp_packet_get_data(header), (uint8_t*)mpp_packet_get_data(header) mpp_packet_get_length(header)); mpp_packet_deinit(header); } return out; }我建议无论用哪种模式都把SPS/PPS的处理逻辑写进封装类里而不是丢给业务层。因为业务层拿到的是裸流它不关心SPS/PPS从哪来只关心给播放器的数据能不能解。封装类内部把这些细节消化掉调用方拿到每个关键帧时前面都已经带好了参数集直接写MP4或者推流都不会出问题。4.4 线程退出前的flush与资源释放程序退出时编码器硬件里可能还残留着几帧数据没有输出如果不处理视频文件末尾会缺帧。MPP的做法是先送一个EOS标记的帧然后持续get_packet直到取到带EOS标记的packet为止int RkMppH264Encoder::flush(PacketCallback cb) { mpp_frame_set_eos(frame_, 1); mpi_-encode_put_frame(ctx_, frame_); while (true) { MPP_RET ret mpi_-encode_get_packet(ctx_, packet_); if (ret ! MPP_OK || !packet_) break; if (mpp_packet_get_eos(packet_)) { mpp_packet_deinit(packet_); packet_ nullptr; break; } void* data mpp_packet_get_data(packet_); size_t len mpp_packet_get_length(packet_); uint64_t pts mpp_packet_get_pts(packet_); cb(data, len, pts, false); mpp_packet_deinit(packet_); packet_ nullptr; } return 0; }flush调用完之后才能deinit。deinit的顺序也讲究我习惯按创建顺序的逆序释放先mpp_frame_deinit再mpp_buffer_put再mpp_buffer_group_put再mpp_enc_cfg_deinit最后mpp_destroy。顺序反了的话MPP可能因为buffer还被frame引用而报内存泄漏虽然不影响当前进程退出但在长时间运行、反复创建编码器的服务里这种泄漏会慢慢积累成大问题。5. 实战中碰到的坑和排查方法5.1 编码失败/解码失败的共同根因很多人在论坛上搜mpp解码失败其实编码侧遇到的很多问题和解码侧是同一套根因。我总结下来无非这么几类。第一类是数据格式不对。喂给编码器的YUV格式和你在cfg里声明的不一致比如实际传NV21却说MPP_FMT_YUV420SP出来的画面色调错乱甚至直接报错。解码侧对应的是ES流里SPS/PPS缺失。第二类是内存来源不对。用普通malloc的内存直接塞给MPP遇到IOMMU开启的内核会触发地址翻译错误现象是偶发失败或者VPU hang住。正确做法是走MppBufferGroup分配这个封装类里已经做了。第三类是对齐不对这个前面反复强调过。第四类是超时和并发问题典型表现是业务层一个线程在update buffer另一个线程在编码又没有锁VPU读到一半的数据被改了产生间歇性花屏和崩溃。排查这类问题的通用手段是开MPP调试日志。MPP支持通过环境变量调整日志级别能看到每次encode_put_frame和encode_get_packet的返回码、耗时和buffer状态。我在项目里的做法是封装类里加一个debug开关打开后把每次MPP调用的返回码和关键参数打印出来配合dmesg看VPU是否有报错。这个开关平时关着出问题时一键打开比上来就断点调试高效得多。5.2 与RGA、摄像头对接时的格式与步幅问题RK3588项目里MPP很少单独工作前面通常接RGA或者摄像头。RGA负责缩放、旋转、格式转换MPP负责编码所以两个模块之间的数据交接是事故高发区。比如OV50C40这类高像素摄像头sensorISP输出可能是一张很大的raw图或者YUV图你不太可能直接把5000万像素的图塞给编码器得先经过RGA裁剪缩放到目标分辨率再转成NV12这一步就会引入步幅不匹配问题。RGA的输出buffer有自己的stride通常由RGA驱动根据对齐要求自动计算。如果RGA配置的dst buffer stride和MPP配置的hor_stride不一样编码出来的视频就是错位的。我踩过的一次具体问题是RGA输出1920x1080 NV12但它在dst buffer里按1920 stride写入而MPP那边因为对齐策略不同认为stride是1920两边刚好一致就没问题后来我换了一块1024x768的分辨率RGA默认按1024对齐MPP却按某个更大的对齐值去读画面整体偏斜颜色还不对。解决思路很直接RGA把dst buffer的信息地址、stride、宽高、格式完整传给MPP封装类检查stride是否和自己算出来的一致不一致就报错而不是闷头编码。或者更省事一点RGA输出后先用memcpy整理成MPP对齐的buffer。虽然多一次拷贝但整个链路的确定性大大提高。多路并发的时候我宁可用一次memcpy换稳定性也不愿意在两套对齐规则之间反复试探。5.3 多路编码下的性能和VPU调试单路1080p编码对RK3588来说是小意思但多路并发就会暴露问题。我做过四路1080p30fps编码发现单路时好用的配置四路时可能因为内存带宽和VPU调度出现间歇性丢帧。丢帧现象很隐蔽不是编码报错而是get_packet偶尔拿不到数据或者拿到的帧时间戳重复、跳变。排查这类问题第一步是确认输入侧是否真的按30fps在喂帧。很多“丢帧”其实是采集线程没跟上不是编码器的锅。我的封装类里会在encode接口统计实际输入帧率在回调里统计输出帧率两边一对比就能定位问题出在编码前还是编码后。第二步是看VPU是不是被打满了。RK3588查看VPU占用没有像CPU那样现成的top接口我一般通过MPP日志里的编解码耗时来判断如果单帧编码耗时超过33ms说明VPU已经过载。第三步是检查内存带宽多路并发时ION buffer的申请和释放频率过高可能触发分配器瓶颈解决办法是每路编码器在init时就分配好buffer运行中不复用其他buffer group。性能调优里还有一个容易忽略的点码率控制模式的选择。CBR模式为了保证码率稳定在画面剧烈变化时会主动降质量多路并发时尤其明显。如果路数多且画面变化大可以考虑换成AVBR模式MPP会根据场景自动调整码率画质稳定度比纯CBR好很多。我现在的做法是网络传输场景用CBR本地多路录制场景用AVBR代码上就是rc:mode一个参数的区别封装类里已经暴露出来了。6. 开发过程中沉淀下来的几条经验6.1 新板子到手先跑官方样例再动手第一次接触MPP的工程师我强烈建议先编译运行Rockchip官方MPP仓库里的mpi_enc_test千万不要直接就开始写封装类。官方sample是验证平台状态最快捷的方式它能告诉你当前内核版本、MPP版本、VPU是否正常。我遇到过买了开发板回来VPU驱动没加载成功的情况mpi_enc_test跑起来直接报设备错误这种问题如果不先跑样例会浪费很多时间在自己代码里排查。跑通样例之后再对照着sample代码一行一行理解每个MPP调用背后的逻辑这个阶段大概需要一两天。等你能解释清楚为什么sample里要先mpp_create再mpp_init为什么要单独设置timeout为什么packet要立刻处理完deinit就可以动手写自己的封装类了。6.2 最后几个小提醒封装类写完之后还有几个值得记住的小细节。时间戳从封装类第一版就要设计好RK3588硬件编码不负责打pts所有时间戳都是你在put_frame时传进去的get_packet原样带出来。如果一开始时间戳随手填后面做音视频同步会非常痛苦。我建议pts统一用微秒为单位并且从系统时钟基准开始不要每路编码器自己从0计数否则多路合流时缝合成本极高。另外requestIDR不要调用得太频繁。有些业务方为了降低首帧延迟想每秒钟请求好几个IDR这会把码率拉得很高而且频繁强制IDR会干扰码率控制算法。我实际经验是普通视频流GOP设成2到4秒需要seek的场景在收到seek请求后立刻requestIDR一次就够了。还有一点如果你的工程同时使用了FFmpeg和原生MPP注意两边的buffer不要混着用尤其是不能把FFmpeg的AVFrame数据直接塞进MppFrame即使格式都是NV12内存来源不同也会出诡异问题。这算是我踩过最冤的坑之一写在这里给后来人提个醒。