ARTICLE DETAIL

资讯详情

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

RK3588 MPP视频编码实战:从YUV到H.264的封装类设计

RK3588 MPP视频编码实战:从YUV到H.264的封装类设计 1. 从零认识RK3588的视频编码底座MPP做嵌入式视频处理这段时间我对RK3588的MPPMedia Process Platform体会很深。如果你要在RK3588上做视频编码绕不开这个官方多媒体处理框架。简单说MPP就是瑞芯微为自家芯片统一封装的媒体处理库把底层的VEPU视频编码单元、VDPU视频解码单元、RGA图像处理加速器等硬件能力包了一层干净的调用接口。刚接触这块的朋友可能被各类名词绕晕我先用一段话讲清楚整条链路的本质摄像头或采集卡生成的YUV原始图像帧通过内存传递交给MPPMPP再调用片上的硬件编码器VEPU把YUV帧压缩成H.264码流最终以文件或网络流的形式输出。整个过程不消耗CPU算力而是由专用硬件完成这才是RK3588能轻松处理多路高清编码的核心原因。在正式开始封装类的设计之前先纠正一个常见的误解很多人以为MPP能直接把任意格式的视频丢进去压缩其实MPP严格要求输入是裸的YUV数据也就是不带任何容器头的纯图像帧。你要编码H.264就需要准备I420YUV420P或NV12格式的YUV帧。如果你的数据是从摄像头Sensor直接过来的或者是从RGB图像转换来的中间一般还要借助RGA或者CPU做一次像素格式转换。这一步看似简单但实际项目里不少坑都出在这个环节。另一个基本认知是MPP并不是一个单独的大库。RK3588上的MPP主要由librockchip_mpp和librockchip_mpp_venc两个核心库构成前者负责通用框架和内存管理后者是编码器的具体实现接口。编译部署时通常在板端使用apt安装rockchip官方发布的MPP库或者直接从瑞芯微的GitHub仓库拉源码交叉编译。我建议第一次接触的朋友直接用官方提供的rockchip_mpp编译产物配套他的头文件mpp.h和mpp_enc.h来开发省去自己适配的麻烦。在开始写Demo之前建议先在板端跑一下官方的mpi_enc_test测试程序它位于MPP源码的test/mpi_enc_test.c。这个测试程序可以验证你的系统里MPP库是否工作正常也能帮你确认编码器硬件节点是否可用mpi_enc_test -w 1920 -h 1080 -t 7 -n 100 -o /tmp/test.h264其中-t 7代表H.264编码-n 100代表编码100帧。如果这条命令能顺利生成一个可播放的H.264文件说明你的MPP环境和硬件VPU都是正常的后面再写自己的封装类就有了可靠的调试基础。接下来我会按实际项目里的开发顺序把RK3588上从YUV到H.264的完整流程拆开讲重点讨论封装类的设计、缓冲区的管理方式以及我在真实项目中踩过的坑。2. 封装类的设计目标别把业务代码和硬件细节搅在一起在RK3588上做过编码开发的人肯定有体会直接调MPP接口写出来的代码往往是一堆状态机的堆砌初始化、创建编码器、绑定缓冲区、送入帧、取出码流包、释放资源这些步骤夹杂着各种返回值和超时判断。如果整个项目只有一路编码这么写还能撑住一旦遇到多路视频源、动态分辨率切换、码率自适应调整这类场景裸调MPP接口的代码很快会变成维护噩梦。所以我的做法是写一个视频编码封装类把硬件初始化和核心数据流逻辑封装起来对外只暴露几个简洁的接口Init()、EncodeFrame()、GetPacket()、Flush()、Close()。业务层不需要关心内部用的是MPP的MppCtx还是MppBuffer只需要往类里塞YUV帧然后从类里拿H.264码流包。这样上层业务代码和硬件细节彻底解耦将来就算把编码从H.264换成H.265或者从RK3588迁移到其他芯片业务层几乎不用动。这个封装类本质上是做了三件事资源管理、状态转换、缓冲调度。资源管理是生命周期相关的包括MPP上下文、编码通道、缓冲区组的申请与释放状态转换是指编码器在初始化、编码中、暂停、冲刷等状态之间的跳转逻辑缓冲调度则是管理输入YUV帧和输出码流包在内存在怎么流转这一块最容易出错。我设计的类接口大致是这样的class RkMppEncoder { public: RkMppEncoder(); ~RkMppEncoder(); // 初始化和释放 int Init(const EncoderParams params); int Close(); // 编码一张YUV帧 int EncodeFrame(const uint8_t* yuv_data, size_t size, uint64_t pts, bool is_keyframe_request); // 取出编码后的H.264码流包 int GetPacket(uint8_t* out_data, size_t* out_size, uint64_t* pts); // 编码结束前冲刷编码器内部缓存帧 int Flush(); private: MppCtx ctx_; MppEncCfg cfg_; MppBufferGroup frame_group_; MppBufferGroup packet_group_; // 内部状态管理... };在设计这个类的时候有几个关键决策要提前做好。第一是打包格式PacketFormat。MPP可以选择输出H.264裸流MPP_ENC_PACKET_FORMAT_H264或带起始码的完整帧格式。我建议在实际项目中直接选MPP_ENC_PACKET_FORMAT_H264_BLOB原因是这个格式输出的每个MPP包就是完整的一帧带起始码封进MP4或直接走RTSP都很方便。第二是码率控制模式。MPP支持CBR、VBR、AVBR等多种模式封装类里面应该把这些模式暴露成参数而不是写死在类里。第三是输入缓冲区的来源。这个后面单独展开讲因为这是区内性能优化的关键点。封装类内部最核心的循环逻辑是拿到外部传入的YUV数据把数据拷贝到MPP管理的输入缓冲区或者用零拷贝方式直接把外部缓冲区注册成MPP缓冲区然后调用mpp_mpi_encode_put_packet把待编码帧交给编码器之后从编码器取回输出包mpp_mpi_encode_get_packet这一步拿到的就是压缩后的H.264数据。这里有个细节做完一次取包操作后必须调用mpp_packet_deinit或重置包状态否则第二次取包会拿到脏数据。3. 初始化流程里那些必须写对的位置初始化封装类是整个编码链路里最繁琐的一步。很多人在这一步出问题通常不是MPP接口调错了而是参数配置和调用顺序不对。我建议严格按照下面的顺序来搭初始化骨架。第一步是创建MppCtx并绑定编码器类型mpp_create(ctx_, MPP_CTX_ENC); MppEncCtx enc_ctx (MppEncCtx)ctx_;第二步是设置编码器的输入和输出格式。这一步必须在mpp_init之前完成因为MPP内部要根据这些参数决定初始化哪个硬件通道。如果是H.264编码帧格式可以选MPP_FMT_YUV420SP对应NV12或MPP_FMT_YUV420P对应I420我建议用NV12因为大部分摄像头Sensor输出的顺序就是NV12可以减少一次格式转换。编码类型设置为MPP_VIDEO_CodingAVC也就是H.264。mpp_enc_cfg_set_s32(cfg_, prep:width, params.width); mpp_enc_cfg_set_s32(cfg_, prep:height, params.height); mpp_enc_cfg_set_s32(cfg_, prep:format, MPP_FMT_YUV420SP); mpp_enc_cfg_set_s32(cfg_, prep:horizontal_stride, params.hor_stride); mpp_enc_cfg_set_s32(cfg_, prep:vertical_stride, params.ver_stride);这里特别提醒一下宽高和stride不要搞混。宽度是图像的可见宽度stride是硬件实际读取数据时的行字节对齐宽度。很多新手直接拿width当stride填结果编出来的视频画面出现斜纹或绿边。通常使用width 1920hor_stride 1920但如果你的硬件对对齐有要求可能需要把stride调整为1920的倍数或按照MPP返回的对齐值来设置。MPP内部有自己的对齐规则通常在16像素对齐。vertical_stride一般取height的16对齐值如果高度是1080实际可能需要传1088。这里最稳妥的做法是初始化后用mpp_enc_cfg_get读一次MPP实际对齐后的参数。第三步是设置码率控制相关的rc参数组mpp_enc_cfg_set_s32(cfg_, rc:mode, MPP_ENC_RC_MODE_CBR); mpp_enc_cfg_set_s32(cfg_, rc:bps_target, params.target_bps); mpp_enc_cfg_set_s32(cfg_, rc:bps_max, params.max_bps); mpp_enc_cfg_set_s32(cfg_, rc:bps_min, params.min_bps); mpp_enc_cfg_set_s32(cfg_, rc:frame_rate, params.fps); mpp_enc_cfg_set_s32(cfg_, rc:gop, params.gop);这里面的gop参数决定两个I帧之间的帧数或秒数。码率模式的选择直接影响画面质量。做视频会议或直播这种对延迟敏感、画面波动小的场景CBR恒定码率最稳码率不会爆涨网络带宽可控。做录像存储、对画质要求高的场景VBR会更合适画面复杂的帧多分配一些码率画面简单的帧少分配一些码率整体码率控制在目标范围内。第四步是设置编码器协议相关的codec参数组mpp_enc_cfg_set_s32(cfg_, codec:type, MPP_VIDEO_CodingAVC); mpp_enc_cfg_set_s32(cfg_, codec:profil, 100); // High Profile mpp_enc_cfg_set_s32(cfg_, codec:level, 40); // 4.0 mpp_enc_cfg_set_s32(cfg_, codec:cabac_en, 1); mpp_enc_cfg_set_s32(cfg_, codec:cabac_idc, 0);Profile和Level这两个参数要结合分辨率来选。1080p30fps用High Profile Level 4.0是主流选择兼容性好画质也有保障。如果做4K编码Level建议改成5.0或5.1否则解码端可能报level_idc不匹配。cabac_en开启CABAC熵编码压缩率比CAVLC高不少RK3588的硬件解码器支持没有任何问题。第五步是绑定输出缓冲区组和编码器上下文。这一步的作用是让MPP知道从哪里分配存放码流的内存mpp_enc_cfg_set_ptr(cfg_, codec:context, enc_ctx); mpp_init(ctx_, MPP_CTX_ENC, MPP_VIDEO_CodingAVC);到这里初始化还不算完还需要绑定三个缓冲区组frame_group用来放输入YUV帧、packet_group用来放输出H.264包、misc_group存放SPS/PPS等参数集。这三个组建议在初始化完成后紧接着创建mpp_buffer_group_get_internal(frame_group_, MPP_BUFFER_TYPE_ION); mpp_buffer_group_get_internal(packet_group_, MPP_BUFFER_TYPE_ION); mpp_enc_cfg_set_ptr(cfg_, prep:frame_group, frame_group_);如果这一步漏了绑定frame_group运行时大概率会在encode_put_frame的时候报缓冲区无效的错误。4. YUV帧的送入与取包缓冲区管理的核心逻辑编码器初始化和参数配置结束后就进入核心的帧处理环节。这一环节的代码量不大但逻辑非常密集每个接口调用背后都关系到硬件的调度和内存的周转。先说整体流程再讲细节。每编码一帧封装类需要完成以下步骤从frame_group_获取或创建一个MPP输入缓冲区把外部传入的YUV数据填充进这个缓冲区用mpp_frame_init初始化一个MppFrame结构体绑定上面的缓冲区、设置宽高、stride、pts等信息调用mpp_mpi_encode_put_frame把帧送入编码器调用mpp_mpi_encode_get_packet从编码器取回压缩后的码流包把码流包的数据拷贝到外部输出缓冲区或者直接返回包数据指针让业务层读走调用mpp_packet_deinit释放包资源或者复用包结构避免重复分配。看起来很简单但实际开发中需要仔细斟酌两个方向的选择输入数据是拷贝进MPP缓冲区还是不拷贝直接引用外部内存。拷贝方案很简单拿到YUV帧后MppBuffer in_buf nullptr; mpp_buffer_get(frame_group_, in_buf, frame_size); void* dst mpp_buffer_get_ptr(in_buf); memcpy(dst, yuv_data, frame_size);这种方案的优点是逻辑简单外部数据生命周期不用管编码器读的是自己缓冲区里的数据安全。缺点是每次编码都多一次memcpy1080p一帧YUV大概3MB数据如果码率跑满30fps每秒就要拷90MB。好在RK3588内存带宽充足这点拷贝量不影响整体性能。零拷贝方案则是在外部内存本身就是从ION或DMA-BUF分配出来的情况下直接把外部缓冲区导入MPPMppBuffer in_buf nullptr; mpp_buffer_import(frame_group_, in_buf, external_fd); mpp_frame_set_buffer(frame, in_buf);零拷贝能省掉一次内存拷贝但前提是外部数据需要满足MPP的缓冲区对齐要求而且fd来自ION/DMA-BUF分配器。如果你采集的视频帧来自RGA或V4L2设备这种方案效果很好。如果你只是从普通堆内存拿到的YUV数据那就别折腾零拷贝了直接用拷贝方案最省事。再来说取包。mpp_mpi_encode_get_packet返回的MppPacket本质上是一个指向MPP内部缓冲区的封装结构。你可以在拿到包后直接把数据指针和大小传给网络层或封装器MppPacket packet nullptr; mpp_mpi_encode_get_packet(packet); if (packet) { size_t pkt_size mpp_packet_get_length(packet); uint8_t* pkt_data (uint8_t*)mpp_packet_get_data(packet); if (pkt_size 0) { // 这里把pkt_data和pkt_size交给上层 // 如果上层需要保留数据必须拷贝走 } mpp_packet_deinit(packet); }这里有一个所有开发者都必须记住的规则MppPacket的buffer由MPP内部管理取到包后如果上层需要长时间持有这段数据必须立刻拷贝走。因为MPP的packet buffer是循环复用的处理完当前包后编码器写下一帧时可能会覆盖这块内存。另外编码器的GOP设置决定I帧间隔但MPP不会自动给你输出SPS/PPS头。在H.264编码中SPS/PPS是解码器初始化参数集的必要信息。如果你在封装类里做MP4封装可以在第一个关键帧前手动插入SPS/PPS如果走裸流输出则需要确保解码器能通过带外参数集初始化。MPP提供获取SPS/PPS的接口MppPacket sps_pps nullptr; mpp_mpi_enc_get_sps_pps(sps_pps);这个包要在编码前或编码早期获取一次保存下来。很多RK3588开发者忽略这一步导致播放器只显示首帧花屏后面突然正常——这就是因为播放器没有预先拿到SPS/PPS。5. 编码参数调试的现场经验码率、GOP和帧率如何配合光把代码跑通不算完视频编码项目的重点其实是质量调优。同样的场景参数配得好可以做到画质清晰且码率合理参数配得差画面满屏马赛克或者码率暴涨导致网络传输卡顿。这里我分享一下自己在RK3588上调参的现场经验。码率设置的核心逻辑是按像素量估算一个合理的起点。对于1080p30fps的H.264常见的基准码率是4-8Mbps。做监控类场景静态画面多4Mbps就够了做屏幕录制或游戏画面画面动态强建议给到8-10Mbps。简单估算时可以用这个公式作为起点target_bps width * height * fps * factor其中factor在0.05-0.15之间。以1080p30为例1920×1080×30×0.08≈5Mbps这是一个比较均衡的值。GOP关键帧间隔的选择要结合传输和应用场景。在实时视频通信里I帧间隔可以设短一点比如60帧2秒间隔这样随机接入延迟低丢包后恢复快在录像存储场景里I帧间隔可以拉长到120帧甚至更长因为I帧的码率通常是P帧的5-10倍间隔越长码率越稳定文件更小。但要注意GOP太长会导致长时间播放后出现漂移某些解码器在长时间无I帧的画面中会产生累积误差。我的经验是最多不超过250帧。帧率设置直接影响编码器的时序调度。如果输入帧率是30fps编码器配置里也应该设成30否则RK3588内部的码率控制器会因为时间戳不匹配而出现码率波动。实际项目中经常遇到这样一个场景摄像头输出30fps但经过算法处理后实际送入编码器的帧率可能只有15fps甚至10fps。这时候正确做法是以实际输入帧率为准更新编码器配置而不是继续用30fps。MPP支持动态修改编码参数调用方式是在送入第一帧之前重新配置mpp_enc_cfg_set_s32(cfg_, rc:frame_rate, actual_fps); mpp_mpi_control(ctx_, MPP_CMD_SET_ENC_CFG, cfg_);关于码率控制模式我再细讲讲VBR在RK3588上的表现。我做过一个实验对一段画面内容变化剧烈的视频流CBR模式下码率维持稳定但画质波动明显复杂画面出现块状噪声VBR模式下MPP允许瞬间码率突破bps_max的约束画质平滑度明显更好。如果你的场景是本地录像且存储带宽充裕建议选VBR如果是网络传输且链路带宽固定选CBR。动态码率调整也是RK3588编码器的一个实用功能。在弱网环境下你可以通过实时调整rc:bps_target让编码器降低码率在带宽恢复后再调回来。调整接口和帧率一样通过mpp_mpi_control下发。这块我用在实际项目中发现MPP响应码率调整的延迟大约在200-300ms之间也就是说调整指令下发后要过大约7-10帧才能看到实际码率变化设计上层逻辑时要留出这个余量。6. 典型故障的排查链路从野指针到黑屏花屏写完封装类兴奋地上板跑第一个demo大概率会遇到或这或那的问题。这里我把自己实际排过的问题整理成了三条典型的排查链路按从常见到不常见的顺序写出来方便大家对号入座。第一类编码一直超时返回。现象是mpp_mpi_encode_put_frame或mpp_mpi_encode_get_packet返回MPP_ERR_TIMEOUT程序卡死或反复重试。排查链路是首先确认MPP是否成功初始化可以打印ctx_指针和返回的encoder信息其次检查frame_group_里是否成功分配了缓冲区mpp_buffer_get的返回值是否为0最后检查输入帧的尺寸和stride是否匹配。我遇到过一次把hor_stride填成width*3/2的情况NV12格式一帧数据大小是width*height*3/2导致编码器从错误的位置读取数据表现为偶发性超时。第二类编码输出花屏、绿边、图像错位。这类问题九成是stide配置错误。画面上半部分正常、下半部分有偏移基本可以确定是vertical_stride不对。如果画面整体是斜向撕裂状很可能是horizontal_stride和实际内存布局不匹配。另一个隐蔽的原因是输入数据格式和prep:format不一致比如你填了MPP_FMT_YUV420SPNV12但实际传入的数据是I420MPP_FMT_YUV420P。这两种格式虽然都是YUV420但U/V平面的排布方式完全不同。判别方法很简单如果画面呈现紫绿相间的错色大概率就是格式配错了。第三类编码后视频文件无法播放或首帧花屏。这条链路通常是SPS/PPS没有正确输出或携带。用裸流输出时播放器需要先收到SPS/PPS才能初始化解码器如果文件里只有一个I帧的数据流播放器无法定位参数集。解决方法是手动用mpp_mpi_enc_get_sps_pps取出参数集写到文件头或封装进MP4的avcC结构里。另外还有一个非常有迷惑性的问题打开编码器后第一次调用mpp_mpi_encode_get_packet拿到的包大小为零或返回空指针。这不是Bug而是MPP内部的编码管线有延迟编码器需要积累几帧后才会开始输出码流。解决办法是在循环里给GetPacket增加重试机制或者先送入几帧空数据触发编码器启动。如果一帧都没有送入就去取包自然是拿不到数据的。7. 性能优化与实测数据参考做一个编码封装类跑通只是第一步真正拿到项目上还要看它能不能扛住真实负载。RK3588的编码器有专门的硬件模块多路编码场景下性能消耗相对可控。但如果你不小心在某些代码路径里做了阻塞式的复制或锁等待CPU占用会肉眼可见地升高。性能优化有几个方向可以马上见效。第一是内存复用。用mpp_buffer_group_get_internal事先申请好多个帧缓冲区在循环中轮转使用避免每次编码时动态创建和销毁缓冲区。实测下来动态创建缓冲区的耗时约为静态轮转的5倍。第二是输入拷贝优化。如果你用的是拷贝方案建议用mpp_buffer_get_ptr拿到的指针然后做高效的按行拷贝因为stride可能和实际宽度不一致不能简单memcpy整块或者干脆改造成DMA-BUF导入的零拷贝路径。第三是避免每次取包都做系统调用。mpp_mpi_encode_get_packet在高码率视频中调用频率很高如果每次调用前都加锁保护会带来额外开销。建议在确认MPP本身是线程安全的内部调用前提下只在封装类入口加一次锁而不是在每次都加锁。为了给大家一个直观的负载参考我做了几组实测数据测试场景是RK3588上单路H.264编码CBR模式GOP60ProfileHigh。输入是NV12格式的YUV帧数据源来自本地文件循环读取输出写到/dev/null避免磁盘IO干扰。分辨率帧率目标码率CPU占用率编码器线程输出码率稳定性1080p30fps5Mbps8-12%波动约±5%1080p60fps8Mbps15-20%波动约±8%4K30fps15Mbps30-35%波动约±10%这里的CPU占用率仅统计编码相关的线程不包含采集、图像处理等业务线程。可以看到RK3588的硬件编码器效率相当高4K编码CPU占用也才1/3左右这给同时跑AI算法或其他业务留下了很大余量。如果是多路编码场景比如16路D1或4路1080pMPP还支持通过MppEnc的分组模式把多个编码通道绑定到一个线程组中统一调度这样能减少内核线程数量降低上下文切换开销。多路编码时建议把每一路的frame_group_和packet_group_独立创建否则通道间缓冲区互相借用容易造成排队阻塞。8. 封装类的扩展方向H.265、ROI和编码缓存附加数据最后聊一下封装类可以继续扩展的几个方向。第一个是H.265编码支持。RK3588的硬件编码器同时支持H.264和H.265。如果你在封装类里把编码类型抽成枚举参数那么从H.264切换到H.265只需要把初始化参数里的MPP_VIDEO_CodingAVC改成MPP_VIDEO_CodingHEVC其他逻辑几乎不用动。H.265在相同画质下比H.264省码率约30-40%在4K场景下值得优先选择。第二个是ROI感兴趣区域编码。MPP支持通过MppEncRoiCfg给编码器下发矩形区域配置让指定区域用更高或更低的质量编码。这个功能在安防监控场景非常有用比如对画面中的人脸或车牌区域提高编码质量对背景区域降低码率来节省整体带宽。使用方式是调用mpp_mpi_control下发MPP_ENC_SET_ROI_CFG命令参数包括区域坐标、优先级和qp_delta。实测中对人脸区域设置qp_delta-8可以在整体码率降低20%的情况下保证人脸区域的清晰度基本不受影响。第三个是编码器产生的SEI附加数据。MPP允许通过MppEncSeiMode插入自定义SEI消息比如时间戳、GPS坐标或AI检测结果。这在视频分析和流媒体传输场景里非常实用可以把业务元数据直接跟着视频流走省掉额外的同步通道。设置SEI的代码逻辑很简单一个mpp_mpi_control的MPP_ENC_SET_SEI_CFG命令就够。但要注意SEI数据插多了会少量增加码流开销每帧建议不超过几十字节。我在实际项目里的体会是MPP这套框架虽然初看有些繁琐但把封装类写好后后续的扩展和迭代效率会非常高。尤其是当你从单路工程改成多路系统时一个设计良好的编码封装类能节省好几周的开发调试时间。9. 写在最后的几个实操建议项目收尾之前还想再罗列几条自己在RK3588编码开发中摸索出来的实操建议每一条都是用真实debug时间换来的。建议在开发阶段把MPP库的日志等级开高一点。MPP支持mpp_log_set_level和mpp_log_set_flag控制日志输出开发时打开MPP_DBG_DEBUG级别可以在mpp_mpi_encode_get_packet超时或缓冲区异常时看到底层原始状态信息定位问题的效率比瞎猜高很多。注意控制缓冲区的生命周期。我见过不少开发者把mpp_buffer_put提前写在帧送入之后以为送完帧就可以释放缓冲区了。实际上MPP是异步编码机制put_frame只是把帧放进硬件队列编码器真正读取这块缓冲区的时机在函数返回之后。如果你提前释放缓冲区编码器可能正在读取一块已被回收或者被其他线程复用的内存表现为偶发的花屏或编码crash。正确处理方式是等到拿包完成之后再释放输入缓冲区或者采用双缓冲轮转策略让缓冲区池延迟两个周期再复用。最后记得在封装类里做好时间戳管理。H.264本身不强制要求时间戳顺序递增但解码端的播放器通常依赖PTS/DTS来安排显示顺序。MPP的MppFrame里可以通过mpp_frame_set_pts设置PTS在MppPacket里通过mpp_packet_get_pts取回。我建议在编码输入侧就维护一个严格递增的PTS序号哪怕你的视频源自带时间戳也要做一次单调化处理否则直播或回放时可能出现跳帧现象。
返回列表