搞嵌入式视频处理的朋友,手里大概率都摸过RK3588这块芯片。八核CPU、6 TOPS算力的NPU,还有一路硬解一路硬编的VPU,单论多媒体能力在国产板卡里确实能打。但很多人拿到开发板第一件事就是跑YOLOv8、搞SLAM,真正想把手头的USB摄像头或者MIPI CSI信号编码成H.264/H.265视频流时,反而卡在MPP这套Rockchip官方媒体库上。API文档写得干巴巴,示例代码东一块西一块,网上能搜到的资料翻来覆去就是那几个demo。这篇就专门聊聊RK3588上用MPP做视频编码的完整路径,从怎么搭环境到最终怎么把裸流封装成能播放能推流的文件,全程基于我这段时间在RK3588板子上实际跑通的代码和踩过的坑。
先说清楚这篇文章适合谁看。如果你的目标是把RK3588的摄像头数据编码成视频流,然后拿来推RTSP、存MP4文件,或者接了AI识别后需要把结果视频落盘,那这篇文章就是给你准备的。如果你只是想看MPP的API长什么样、每个结构体有哪些字段,官方文档和头文件比我讲得详细。我这里只讲工程实践,讲怎么把视频编码这件看起来简单、做起来一堆细节的事情真正跑通。
1. 环境搭建:交叉编译还是板端编译,怎么选更省心
1.1 三种环境配置方式的取舍
先说结论:如果只是做编码功能验证,优先在板子上直接编译MPP;如果要做应用层开发、需要频繁改代码,建议用交叉编译链,把编译和烧录分开,开发效率高很多;如果只是调接口跑通流程,甚至可以直接用Rockchip官方提供的预编译库,省去编译环节。
我在RK3588上实测下来,板端编译MPP其实没那么可怕。RK3588的主频摆在那里,8核A76+A55,跑编译任务完全能接受。我第一次编译MPP,用的Ubuntu 22.04的系统镜像,四核并行编译,大概三分钟就能编完整个库。但如果你的项目要频繁修改MPP源码去调试底层行为,那板端编译的效率就太低了,一次修改编译链接要等一两分钟,迭代体验很差,这时候还是老老实实配交叉编译环境。
交叉编译有几个注意点。你的交叉编译工具链版本最好和板端系统匹配,不然很容易出现头文件里的某些结构体大小不一致、运行时莫名其妙崩溃的问题。我用的是aarch64-linux-gnu-gcc 9.3版本,配Ubuntu 22.04的板端系统,一直没出过问题。
板端直接编译最简单的做法是直接从官方仓库拉源码:
git clone https://github.com/rockchip-linux/mpp.git cd mpp mkdir build && cd build cmake .. make -j4 sudo make install编译完成后会在/usr/local/lib目录下生成librockchip_mpp.so、librockchip_mpp_ai.so等几个关键库文件。这里提醒一句,务必检查板端/usr/lib/aarch64-linux-gnu/目录下是否已经存在系统自带的MPP库,如果有,要让我们的链接器优先找到我们自己编译的版本,不然版本不一致很容易出诡异问题。最简单的检查方式:
ldconfig -p | grep rockchip_mpp如果系统里自带的库版本比较老,而你用的头文件是新的,编码时可能API名称都对不上,链接直接报错。这种情况下可以手动指定链接路径,或者干脆卸载系统自带的MPP库。
1.2 解码器固件和VPU驱动检查
环境搭好之后,先别急着写代码。先确认一件事:VPU驱动和固件是否正常加载。RK3588的视频编解码单元VEPU和VDPU,在Linux下依赖Rockchip的rkvdec、rkvenc驱动以及对应的固件文件。Ubuntu的Rockchip社区镜像一般已经把这些都配好了,但有些自制的根文件系统会漏掉。
检查方法:
ls /dev/rkvenc* /dev/rkvdec* ls /lib/firmware/rk3588_* dmesg | grep -i vpu如果/dev下面能看到rkvenc和rkvdec节点,基本驱动就没什么问题。dmesg里如果出现固件加载失败或者超时的信息,多半是固件没放对位置,或者设备树里VPU节点被禁用了。RK3588在设备树里关于VPU的节点一般长这样:
&rkvdec_ccu { status = "okay"; }; &rkvdec { status = "okay"; };这个和摄像头、HDMI输出一样,如果在其它板子上移植系统,设备树里status = "disabled"是常见坑,开启后要重新编译设备树、刷boot分区,折腾一轮下来基本能定位清楚。
需要说明的是,这套环境对系统镜像版本比较敏感。Rockchip官方更新过MPP的API和固件,建议你拉MPP源码时尽量选一个和板端内核版本匹配的release分支,而不要用最新的主干。我个人经历过一次mpi_enc_test能跑但自己的代码一调用就段错误的案例,后来发现是MPP版本和Hantro驱动固件版本不匹配,换成release版本问题就消失了。
2. MPP编码核心机制:先搞懂这几个概念再写代码
2.1 MPP的编码链路和核心对象
MPP不是一个简单的编码函数库,它是一套完整的媒体处理框架。里面封装了输入buffer的管理(MppBuffer)、任务队列(MppTask)、编码器上下文(MppCtx)、码流包(MppPacket)等一系列对象。初次接触时很容易被这套抽象绕晕。
我用个生活化的类比来解释。想象一套定制西装的流程:你把自己的身体尺寸写到一张表上,这相当于MppEncPrepCfg,告诉编码器输入数据的长宽、像素格式、帧率;然后你和裁缝约定西装的版型,是全衬还是半衬、单排扣还是双排扣,这相当于MppEncRcCfg,对应码率控制策略、量化参数、GOP结构;接下来裁缝给你量体裁衣,你提供布料(输入帧),他给你剪裁缝合(编码),最后你拿到成品西装(码流包)。如果加工过程中你供料太快或者太慢,裁缝就得调整节奏,这就是MPP内部的任务调度。
在代码层面,核心对象就是这四个:
MppCtx:编码器上下文,一个编码会话的所有状态都维护在这里MppApi:MPP提供的操作系统抽象层接口,封装了buffer分配、线程、锁等基础能力MppBuffer:用来承载帧数据的内存块,MPP会自己管理物理连续内存MppPacket:编码输出的码流包,里面装着H.264/H.265的原始NALU
编码调用趟流程简化之后是这样:
// 初始化编码器上下文 MppCtx ctx = NULL; MppApi *mpi = NULL; mpp_create(&ctx, &mpi); // 设置编码器类型为H.264 MPP_RET ret = mpi->control(ctx, MPP_CMD_SET_CODEC_TYPE, &type_h264); // 准备编码参数 MppEncPrepCfg prep_cfg; prep_cfg.width = 1920; prep_cfg.height = 1080; prep_cfg.fmt = MPP_FMT_YUV420SP; // ... 更多参数设置 mpi->control(ctx, MPP_CMD_SET_PREP_CFG, &prep_cfg); // 初始化编码器 mpi->control(ctx, MPP_CMD_SET_CNT_CFG, &cnt_cfg); // 开始编码 mpi->control(ctx, MPP_CMD_START, NULL); // 循环:输入帧 -> 等编码 -> 取码流 while (!stop) { // 从输入buffer队列取一个可用的编码输入 MppBuffer fd_buf = NULL; // 填充帧数据到 fd_buf MppTask task = NULL; mpp_buffer_import(&fd_buf, &fd); // 把帧数据提交给编码器 mp_input_put_buf(frame_buf); mpi->poll(ctx, MPP_POLL_BLOCK); mpi->dequeue(ctx, &task); // 从task里拿到编码后的码流包 MppPacket packet = NULL; mpp_task_get_packet(task, &packet); // 处理码流包(写文件、推流、喂给封装器) mpp_packet_read(packet, &data, &size); }这里每个步骤都藏着不少细节。下面我挑最影响成败的几个点展开讲。
2.2 输入帧格式与内存对齐:90%的人第一坑
MPP编码器的输入数据是YUV格式,最常用的是NV12(YUV420SP)和YUV420P。RK3588的VPU硬件对输入图像的行对齐和大小对齐有硬性要求。简单来说,宽度和高度必须是16的整数倍,有些情况下甚至要求64对齐。如果摄像头输出的分辨率是1920x1080,这本身就是16的倍数,没问题。但如果你用的是1280x720,720也是16的倍数,也没问题。真正的坑在于那些非常规分辨率,比如1280x960,960是64的倍数,没问题;但如果来个640x480,也没问题;320x240,也没问题——关键是要养成习惯,在代码里做对齐判断或者指定对齐参数。
MPP的PrepCfg结构体里有个hor_stride和ver_stride字段,这两个字段就是用来告诉编码器内存中Y数据平面的真实stride。比如你的图像宽度是1920,但内存里每行有1920+128=2048字节(有些内存分配器为了对齐会加padding),那么hor_stride就要填2048,而不是1920。
MppEncPrepCfg prep_cfg; memset(&prep_cfg, 0, sizeof(prep_cfg)); prep_cfg.width = 1920; prep_cfg.height = 1080; prep_cfg.hor_stride = 1920; // 如果内存行对齐为64,1920正好是64*30 prep_cfg.ver_stride = 1088; // 1080对齐到16是1088? 不对,1080本身是16的倍数? 8*135=1080,所以是8的倍数但不是16的倍数 prep_cfg.fmt = MPP_FMT_YUV420SP;1080这个数值需要注意。1080除以16等于67.5,并不是整数,所以标准的1080p对齐后的高度是1088,1920x1080的帧在内存中实际占用1920x1088。很多摄像头驱动和ISP输出时已经做了对齐,但你从V4L2拿到的buffer信息里可能带着bytesperline字段,这个就是行字节数。在填充MPP的stride参数时,一定要用bytesperline作为hor_stride,而不是自己的宽度值,否则画面会歪掉或者干脆编码出来是花的。
如果你用mpp_buffer_get申请MPP自己管理的内存,MPP会自动按VPU要求对齐,你只需要把申请到的buffer的size信息回填到input buffer结构里即可。但如果你用的是外部导入的buffer(比如从V4L2的DMA-BUF直通),那必须自己保证对齐。
2.3 GOP结构和码率控制:明白MPP帮你做了什么,还要自己做什么
MPP的编码参数里有两个关键的配置块:MppEncRcCfg(码率控制)和MppEncGopCfg(GOP结构)。这两个决定编码质量、码流稳定性和延迟。
码率控制主要分CBR(固定码率)和VBR(可变码率)。实时推流、存储监控一般用CBR,码率稳定,不会突然飙高导致网络拥塞。本地录像、追求画质可以用VBR。
MppEncRcCfg rc_cfg; rc_cfg.mode = MPP_ENC_RC_MODE_CBR; rc_cfg.bps = 1920 * 1080 * 4; // 目标码率4Mbps rc_cfg.bps_max = 1920 * 1080 * 5; rc_cfg.bps_min = 1920 * 1080 * 3; rc_cfg.rc_drop_mode = MPP_ENC_RC_DROP_FRAME_DISABLED; // 不丢帧这里有个经验值:1080p30的H.264视频,CBR模式码率设为4-8Mbps画质就足够清晰了。RK3588的VPU在码率控制上的算法比较成熟,不需要你去手动抠量化参数,你只需要告诉它目标码率和最大最小范围即可。
GOP配置影响I帧间隔和编码延迟:
MppEncGopCfg gop_cfg; gop_cfg.gop_mode = MPP_ENC_GOP_MODE_NORMALP; gop_cfg.gop_len = 30; // 30帧一个I帧,1秒一个关键帧 gop_cfg.vi_len = 0;MPP_ENC_GOP_MODE_NORMALP就是标准的P帧间预测模式,I帧间隔30帧(1秒),适合大多数场景。如果要做低延迟直播,可以用MPP_ENC_GOP_MODE_TSVC或者直接设置gop_len=1,这样每帧都是I帧,码率会显著上升,但延迟最低。插一句,实际测试中RK3588的MPP编码一个1080p的I帧大约10-15毫秒,P帧大约3-5毫秒,30帧视频软硬结合处理完全扛得住。
3. 动手实操:从V4L2取流到MPP编码的完整编码流程
3.1 整体架构:一条流水线式的设计
工程上我建议把视频采集和编码分成两个线程或者两个模块。采集模块负责从V4L2设备节点拿帧,编码模块负责丢给MPP。两者之间用一个环形buffer队列解耦。这样做的原因有三个:一是采集帧率波动不会直接影响编码;二是当编码速度跟不上采集速度时,可以决定是丢帧还是阻塞;三是逻辑清晰,排查问题方便。
我的实现大致是这个结构:
typedef struct { int v4l2_fd; struct buffer *buffers; // V4L2 DMA buffer MppCtx enc_ctx; MppApi *mpi; MppBufferGroup frame_group; // MPP编码输入buffer组 int frame_count; } Encoder; Encoder enc; // 初始化V4L2 v4l2_init(&enc, "/dev/video0", 1920, 1080, V4L2_PIX_FMT_NV12); // 初始化MPP mpp_init_encoder(&enc, MPP_VIDEO_CodingAVC, 1920, 1080); // 启动编码循环 pthread_create(&tid, NULL, encode_thread, &enc); // 启动采集循环 capture_thread(&enc);中间的数据流转用V4L2的DMA buffer导出fd,然后导入到MPP的buffer import机制。这样最省内存,也最省CPU拷贝时间。非要说性能差距,比内存拷贝的方式能少用将近一个核的CPU占用,对整体系统资源影响很大,尤其当你还要跑AI模型的时候。
3.2 关键代码:MPP初始化与参数配置
MPP初始化是所有操作的前提。我把最核心的初始化函数完整贴一遍,每一行注释都写清楚。
static int mpp_init_encoder(Encoder *enc, MppCodingType type, int width, int height) { MPP_RET ret; MppCtx ctx = NULL; MppApi *mpi = NULL; // 1. 创建编码器上下文 ret = mpp_create(&ctx, &mpi); if (ret != MPP_OK) { printf("mpp_create failed, ret=%d\n", ret); return -1; } enc->enc_ctx = ctx; enc->mpi = mpi; // 2. 设置编码类型 MppEncCodecCfg codec_cfg; memset(&codec_cfg, 0, sizeof(codec_cfg)); codec_cfg.coding = type; // MPP_VIDEO_CodingAVC ret = mpi->control(ctx, MPP_CMD_SET_CODEC_TYPE, &type); if (ret != MPP_OK) { printf("set codec type failed, ret=%d\n", ret); return -1; } // 3. 配置预处理参数 MppEncPrepCfg prep_cfg; memset(&prep_cfg, 0, sizeof(prep_cfg)); prep_cfg.width = width; prep_cfg.height = height; prep_cfg.hor_stride = width; prep_cfg.ver_stride = height; // 实际工程里要做对齐 prep_cfg.fmt = MPP_FMT_YUV420SP; // NV12 ret = mpi->control(ctx, MPP_CMD_SET_PREP_CFG, &prep_cfg); if (ret != MPP_OK) { printf("set prep cfg failed, ret=%d\n", ret); return -1; } // 4. 配置码率控制 MppEncRcCfg rc_cfg; memset(&rc_cfg, 0, sizeof(rc_cfg)); rc_cfg.mode = MPP_ENC_RC_MODE_CBR; rc_cfg.bps = width * height * 4; rc_cfg.bps_max = width * height * 5; rc_cfg.bps_min = width * height * 2; rc_cfg.rc_drop_mode = MPP_ENC_RC_DROP_FRAME_DISABLED; ret = mpi->control(ctx, MPP_CMD_SET_RC_CFG, &rc_cfg); if (ret != MPP_OK) { printf("set rc cfg failed, ret=%d\n", ret); return -1; } // 5. 配置GOP MppEncGopCfg gop_cfg; memset(&gop_cfg, 0, sizeof(gop_cfg)); gop_cfg.gop_mode = MPP_ENC_GOP_MODE_NORMALP; gop_cfg.gop_len = 30; gop_cfg.vi_len = 0; ret = mpi->control(ctx, MPP_CMD_SET_GOP_CFG, &gop_cfg); if (ret != MPP_OK) { printf("set gop cfg failed, ret=%d\n", ret); return -1; } // 6. 初始化编码器任务 MppEncCntCfg cnt_cfg; memset(&cnt_cfg, 0, sizeof(cnt_cfg)); cnt_cfg.stream_buff_size = width * height * 2; // SPS/PPS+I帧预留空间 ret = mpi->control(ctx, MPP_CMD_SET_CNT_CFG, &cnt_cfg); if (ret != MPP_OK) { printf("set cnt cfg failed, ret=%d\n", ret); return -1; } // 7. 启动编码器 ret = mpi->control(ctx, MPP_CMD_START, NULL); if (ret != MPP_OK) { printf("start encode failed, ret=%d\n", ret); return -1; } // 8. 创建编码输入内存组 ret = mpp_buffer_group_get_internal(&enc->frame_group, MPP_BUFFER_TYPE_ION); if (ret != MPP_OK) { printf("get buffer group failed, ret=%d\n", ret); return -1; } return 0; }这个过程看似长了,但每一步环环相扣。stream_buff_size是给编码输出的码流buffer预分配的空间,如果设置的太小,编码I帧的时候容易因为空间不足而编码失败。经验公式是width * height * 2,对于1080p来说就是4MB左右,足够容纳一个超高码率的I帧了。
3.3 编码一帧数据的完整实现
初始化完成后,每次编码一帧的逻辑相对固定。V4L2采集线程拿到一帧NV12数据后,通知编码线程处理。
static int encode_frame(Encoder *enc, int dma_fd, size_t dma_size) { MPP_RET ret; MppBuffer frame_buf = NULL; // 将V4L2的DMA-BUF导入MPP ret = mpp_buffer_import(&frame_buf, &dma_fd); if (ret != MPP_OK) { printf("import buffer failed, ret=%d\n", ret); return -1; } // 构造输入任务 MppTask task = NULL; ret = enc->mpi->poll(enc->enc_ctx, MPP_POLL_BLOCK); if (ret != MPP_OK) { mpp_buffer_put(frame_buf); return -1; } ret = enc->mpi->dequeue(enc->enc_ctx, &task); if (ret != MPP_OK || task == NULL) { printf("dequeue task failed, ret=%d\n", ret); mpp_buffer_put(frame_buf); return -1; } // 把帧数据挂到任务上 MppMeta meta = mpp_task_get_meta(task); mpp_meta_set_buffer(meta, KEY_INPUT_FRAME, frame_buf); mpp_meta_set_packet(meta, KEY_OUTPUT_PACKET, NULL); // 提交任务 ret = enc->mpi->enqueue(enc->enc_ctx, task); if (ret != MPP_OK) { printf("enqueue task failed, ret=%d\n", ret); mpp_buffer_put(frame_buf); return -1; } // 等待编码完成 ret = enc->mpi->poll(enc->enc_ctx, MPP_POLL_BLOCK); if (ret != MPP_OK) { printf("poll enc done failed, ret=%d\n", ret); return -1; } // 取回编码结果 ret = enc->mpi->dequeue(enc->enc_ctx, &task); if (ret != MPP_OK || task == NULL) { printf("dequeue result failed, ret=%d\n", ret); return -1; } MppPacket packet = NULL; mpp_task_get_packet(task, &packet); if (packet) { void *data = NULL; size_t size = 0; mpp_packet_read(packet, &data, &size); // 这里把 data 和 size 交给上层处理 handle_encoded_data(data, size, is_key_frame(packet)); } // 释放任务,归还buffer enc->mpi->enqueue(enc->enc_ctx, task); mpp_buffer_put(frame_buf); // 注意,这一步不是真正释放,而是归还给buffer group return 0; }这段代码有以下细节要特别留意。
第一,poll和dequeue是配对的。poll负责等待编码器完成一个任务的信号,然后dequeue把完成的任务取出来。提交任务时也是先把任务从编码器内部队列里dequeue出来、配置好再enqueue回去。这是MPP异步设计的核心,理解了这个循环,后面无论做多少路编码都是这个套路。
第二,mpp_buffer_import导入的只要传一个fd,MPP内部会通过该fd关联到DMA-BUF。V4L2的buffer释放时机要掌握好。必须在MPP没有引用这个buffer的时候才能把fd归还给V4L2,否则会出现编码数据错乱或者崩溃。我见过不少case,V4L2的buffer被应用层提前重新入队后,MPP还在编码,结果画面出现重复帧或者花屏。
第三,KEY_OUTPUT_PACKET不一定要手动设置,编码器完成任务后会自动把包挂到task的meta上。但如果你的编码器需要拿到每个包的特征,比如MppPacket的pts、dts信息,得在输入帧的meta里提前设置好PTS:
mpp_meta_set_s64(meta, KEY_OUTPUT_PTS, pts);MPP自己不带时间戳管理,所有时间戳都要应用层自己维护。这里有个常见误区:很多新手以为只要送帧进去,编码器就会自动分配PTS,等到封装MP4时才发现所有帧的PTS都是0,播放器要么卡死要么乱跳。PTS要在提交编码任务前自己set到meta里,然后编码完成后从输出packet的meta里读出来:
MppMeta out_meta = mpp_packet_get_meta(packet); RK_S64 pts = 0; mpp_meta_get_s64(out_meta, KEY_OUTPUT_PTS, &pts);3.4 关键帧判断和码流拼接
H.264编码器输出的码流里,每个packet可能包含一个或者多个NALU。关键帧(IDR帧)的packet一般会包含SPS、PPS、IDR三种NALU,这是最容易被忽视的。如果你直接把一个packet里所有的数据交给封装的muxer,封装器可能不认识这种格式。
MPP提供一个特性:MPP_ENC_HEADER_MODE。默认情况下,SPS/PPS在每个IDR帧前会自动附带,这适合直接存储成裸流文件(.h264)。但如果你要用FFmpeg的muxer封装成MP4,通常希望SPS/PPS只在第一个packet里出现一次,后续IDR帧只带IDR数据。可以通过:
MppEncHeaderMode header_mode = MPP_ENC_HEADER_MODE_EACH_IDR; mpi->control(enc, MPP_ENC_SET_HEADER_MODE, &header_mode);或者:
header_mode = MPP_ENC_HEADER_MODE_IDR;具体用哪种,取决于你的封装器和播放器需求。如果用FFmpeg的h264_mp4toannexb filter做转换,那baseline的每个IDR都带SPS/PPS反而更省事,转什么格式都不会出错。如果直接拿来推RTSP,一般要求SPS/PPS单独送一次,后续只在IDR前重置。这个我们在下一节细聊。
4. 高效封装:裸流怎么变成能播的MP4/RTSP
4.1 直接写H264裸流文件:最简单原始的方式
最快速的验证方式是把编码输出的所有packet按顺序直接写入一个.h264文件。这种方式不需要任何额外的封装器,文件体积大一点,但是大部分播放器(VLC、ffplay、PotPlayer)都能直接播放,前提是编码器设置了MPP_ENC_HEADER_MODE_EACH_IDR,或者第一个packet里包含SPS/PPS。
写文件的逻辑非常简单:
FILE *fp = fopen("output.h264", "wb"); while (running) { // encode_frame 内部回调 handle_encoded_data handle_encoded_data(data, size, is_key_frame); } fclose(fp);需要注意的是,如果你用默认的header mode,第一个packet之前可能不会主动输出SPS/PPS。正确做法是在编码开始前,通过:
MppPacket header_packet = NULL; mpi->control(ctx, MPP_CMD_GET_HDR_INFO, &header_packet);主动从编码器里把头信息(SPS/PPS)拿出来,写到文件开头。然后再进入正常的编码循环。这个头信息不止在写文件时要用,推流时更是必不可少。
4.2 封装MP4:FFmpeg muxer怎么和MPP配合
把裸流封装成MP4其实有两种思路。
第一种思路是用FFmpeg的libavformat库,在应用层把编码出来的H.264数据包喂给muxer。这种做法的好处是复用性强,代码稍微复杂一点,但如果你的应用本身就要用FFmpeg做推流或者后续处理,这套体系能打通。基本流程:
AVFormatContext *fmt_ctx = NULL; avformat_alloc_output_context2(&fmt_ctx, NULL, "mp4", "output.mp4"); AVStream *stream = avformat_new_stream(fmt_ctx, NULL); stream->codecpar->codec_id = AV_CODEC_ID_H264; stream->codecpar->width = 1920; stream->codecpar->height = 1080; stream->codecpar->format = AV_PIX_FMT_YUV420P; // 关键是设置 extradata,这里放SPS/PPS stream->codecpar->extradata = av_mallocz(sps_pps_size + AV_INPUT_BUFFER_PADDING_SIZE); memcpy(stream->codecpar->extradata, sps_pps_data, sps_pps_size); stream->codecpar->extradata_size = sps_pps_size; avformat_write_header(fmt_ctx, NULL); // 每一帧编码完成时,构造 AVPacket AVPacket pkt; av_new_packet(&pkt, data_size); memcpy(pkt.data, data, data_size); pkt.pts = pts; pkt.dts = dts; pkt.stream_index = stream->index; // 关键帧标记 pkt.flags |= is_key_frame ? AV_PKT_FLAG_KEY : 0; av_interleaved_write_frame(fmt_ctx, &pkt); av_packet_unref(&pkt); av_write_trailer(fmt_ctx);这里有个extradata 大坑。MP4格式要求SPS/PPS放在容器的extradata里,而不是每个IDR帧都带。所以从MPP拿到SPS/PPS之后,要先存起来,写文件头之前设置到codecpar上。如果顺序搞反了,或者拿到的SPS/PPS不完整,封装出来的MP4在部分播放器上能播,部分播放器上花屏或者不出画面。
第二种思路是直接使用Rockchip官方提供的muxer示例。MPP仓库里其实自带了一个mpi_enc_test,里面有通过FFmpeg封装MP4的参考写法。但实际用下来,官方示例代码不够工程化,很多细节比如PTS的换算、DTS的生成逻辑都没有完整处理好。我建议还是自己基于FFmpeg的API实现,可控性高。
封装MP4时还涉及一个帧率换算问题。MPP编码出来的pts是微秒还是毫秒,需要和AVStream的time_base保持一致。经验做法是:
stream->time_base = (AVRational){1, 1000000}; // 微秒 // pkt.pts 直接填微秒值这样最简单,不需要复杂的换算。但要注意,编码出的帧率如果是30fps,那么相邻两帧的pts差值应该是33333微秒(即1/30秒),这个值要在应用层自己计算并累加,MPP不会帮你算好。
4.3 封装RTSP流:MPP成品直接喂给RTSP server
如果要做实时推流,RTSP是目前兼容性最好的方案。RK3588做RTSP推流,社区里常见做法是把MPP编码后的数据交给Live555或者直接用FFmpeg的RTSP muxer。
我自己用过Live555的H264VideoRTPSink,也有个更简单的方案:用FFmpeg的rtspoutput format,代码量最少,稳定性也不差。直接这样:
AVFormatContext *rtsp_ctx = NULL; avformat_alloc_output_context2(&rtsp_ctx, NULL, "rtsp", "rtsp://192.168.1.100:8554/live"); // 创建流 AVStream *stream = avformat_new_stream(rtsp_ctx, NULL); // 同样的extradata设置 // 打开 avio_open(&rtsp_ctx->pb, "rtsp://192.168.1.100:8554/live", AVIO_FLAG_WRITE); avformat_write_header(rtsp_ctx, NULL); // 后续每帧 AVPacket 直接写 av_interleaved_write_frame(rtsp_ctx, &pkt);这里有个体验上的技巧:RTSP stream的extradata里SPS/PPS是必须的,但不要在每次IDR帧前重复设置,因为很多RTSP客户端在首次连接时只信任SDP中的参数。换句话说,H.264 over RTP 的payload格式(RFC 6184)要求SPS/PPS在SDP里传递,数据包里是否携带取决于封装器配置。用FFmpeg的rtsp muxer时,它会把extradata解析后写入SDP,所以extradata只需要设置一次。
4.4 时间戳管理:封装过程中最容易被忽略的细节
不管做MP4还是RTSP,时间戳乱了整个视频就废了。MPP的编码任务不负责管理PTS/DTS,全部由应用层给定。而MPP编码器内部有帧重排机制(B帧),如果你开了B帧,那么编码器输出的packet顺序和输入帧顺序不一定一样,需要在提交输入前尽量精确地设置PTS。
我的时间戳分配方案是这样的:
- 从V4L2采集到一帧时,记录系统单调时间戳
mono_ts - 把这个时间戳直接作为该帧的PTS(微秒单位)
- 编码器输出packet时,从输出的meta里取出对应的PTS
- 封装器直接使用这个PTS,time_base设为1/1000000
这样做有一个好处:如果采集帧率抖动,PTS能真实反映采集时刻,播放进度比较自然。缺点是这样产生的VFR(可变帧率)流体积会稍大,播放器兼容性没问题,只是某些老播放器对VFR MP4支持不好,会快进或卡顿。
如果要做CFR(恒定帧率)流,最稳妥的方式是自行按固定间隔累加PTS,比如30fps就是33333微秒递增,不管采集到的帧实际什么时候到。如果采集帧率低于编码帧率,你会面临丢帧或者复制帧的选择,一般情况下我建议直接丢帧,因为重复帧会浪费码率且让动作变糊。RK3588的VPU编码速度足够快,从摄像头采集到编码后输出,延迟大约在30-50毫秒,RTSP播放端看到的延迟大约100-200毫秒,完全满足监控、直播场景需求。
5. 性能调优与常见问题:实测数据背后的坑
5.1 RK3588实时编码能达到什么水平
我在正点原子RK3588板子上,用USB摄像头(1080p30输入)做整套流水线测试,加上YOLOv8目标检测线程同时跑,MPP编码线程的CPU占用大约在15%-25%之间(基于top的%CPU指标),内存占用大约150MB(主要是YUV帧缓存和码流缓存)。1080p30的H.264编码,码率4Mbps,肉眼观察画面没有明显的卡顿和花屏。
如果做4K编码,RK3588的硬编能力也是够的。但要注意,V4L2采集4K YUV数据的内存带宽压力会大很多,DMA-BUF导入导出路径必须走通,否则内存拷贝会占掉大量CPU时间。实测4K30编码时,编码线程CPU占用会升到35%左右,系统整体还能流畅运行。
对于多路编码,RK3588的VPU资源是共享的。同时跑两路1080p30完全没有压力,跑四路1080p30在GOP长度较大的情况下也能撑住,但码率控制精度会下降。如果要做多路,建议把各路设置为独立线程、独立MppCtx,多路之间不要共享buffer,防止锁竞争导致帧率抖动。
5.2 常见错误排查速查表
我把自己开发中踩过和帮别人排查过的典型问题汇总成了一张表,基本覆盖了RK3588 MPP编码的绝大多数问题。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| mpp_create返回失败 | 驱动未加载或设备节点缺失 | 检查/dev/rkvenc*、/dev/rkvdec*,再查dmesg |
| 编码出来的视频是花屏/绿屏 | 输入YUV格式或stride配置错误 | 确认V4L2输出格式是NV12,hor_stride用bytesperline |
| 画面颠倒或左右镜像 | V4L2或者ISP输出了旋转后的帧,但未告知MPP | 检查V4L2的V4L2_CID_ROTATE或者用MPP的旋转参数 |
| 每一帧都很大,码率不受控 | 码率控制参数没设置成功或编码器还没就绪就送帧 | 检查MPP_CMD_SET_RC_CFG返回码,编码器MPP_CMD_START之后再做循环 |
| 播放MP4文件时一直转圈或卡顿 | PTS设置错误或缺失 | 确保每帧都有唯一且单调递增的PTS |
| 视频播放正常但没有声音 | 项目只有视频,没做音频采集 | 如果需要音频,要另起音频编码线程,和视频用同一时钟域同步 |
| 长时间运行后内存持续增长 | buffer没有正确释放或者环形队列溢出 | 用valgrind或者/proc/pid/status监测VMSize,重点检查mpp_buffer_import和put的配对 |
| 编码4096x2160时报参数不支持 | 分辨率超过VPU限制或格式错误 | 检查MPP是否支持该分辨率,部分固件对4K的格式要求是NV12_MST或特定对齐 |
5.3 丢帧策略:编码速度跟不上采集时怎么办
实时视频系统里,编码速度跟不上采集速度是很常见的情况。我在跑YOLOv8推理的时候遇到过,采集帧率30fps,一旦AI推理占用过多CPU或者VPU竞争,编码一帧的耗时偶尔会飙到60毫秒,导致编码线程积压。
处理这种情况的原则是:优先保证实时性,不要为了不漏帧而堆积延迟。我采用的方式是设置一个输入队列,最多允许积压2帧。如果队列满了,直接丢弃最旧的那一帧,把最新的一帧塞进去。这样编码线程永远处理的是最近的数据,端到端延迟能稳定在一个可接受的范围内。
if (frame_queue.size() >= 2) { // 丢弃最旧的帧,归还buffer给V4L2 v4l2_queue_buffer(enc->v4l2_fd, &enc->buffers[old_frame_index]); // 放入最新帧 frame_queue.pop(); } frame_queue.push(new_frame);这里特别强调:丢帧时务必把buffer及时归还给V4L2,否则V4L2的buffer池会耗尽,采集线程会阻塞。不少人在这个环节吃过亏,总想着“等一等就追上进度了”,结果等来的是采集线程彻底卡死。
5.4 内存对齐和小技巧补充
最后补充几个实际调试中的小技巧。
第一,如何在开发板上查看VPU工作状态?可以直接cat /proc/rkvenc/info或者cat /sys/kernel/debug/rkvenc/info,依赖内核配置。正常情况下能看到编码器的硬件握手信息、工作计数。网上经常有帖子问怎么查VPU版本,其实这个文件就够用了。
第二,mpp_buffer_import时最好对dma_fd做个有效性校验。V4L2在流状态切换时可能会短暂返回无效fd,不校验直接传入MPP会导致段错误。传之前用fcntl(fd, F_GETFD)检查一下即可,不加这个校验的代码在长期运行后总有概率崩一次,而且不好复现。
第三,如果要在RK3588上做低延迟推流,可以试试MPP的MPP_ENC_CFG_CHANGE支持,编码过程中动态调整码率。
MppEncRcCfg new_rc_cfg; new_rc_cfg.bps = 2 * 1024 * 1024; // 降到2Mbps mpi->control(ctx, MPP_CMD_SET_RC_CFG, &new_rc_cfg);这个功能在做网络自适应时很有用,但要注意,修改MppEncPrepCfg中的分辨率等参数时,必须重新配置整个编码器并重启,做不到随便切换。分辨率、像素格式这类参数必须在初始化阶段就定死。
第四,关于NV12和NV21。MPP编码器要求输入NV12格式,但很多摄像头或者图像处理库输出的是NV21或者YV12。转换是不可避免的,可以用NEON优化,也可以用Rockchip的RGA硬件做格式转换。RGA是RK3588自带的2D图形加速器,色彩空间转换和缩放都不占CPU,性能非常好。如果不想引入RGA的复杂度,可以用一个简单的查表法做YUV分量重排,1080p一帧大约花2-3毫秒,在可接受范围内。
6. 一个完整的编码+封装示例:从推理输出到存储文件
6.1 把YOLOv8的输出叠加到视频并编码保存
现在很多人在RK3588上做YOLOv8目标检测,检测结果需要可视化,也就是在原始帧上画框,然后把画好框的视频编码保存或推流。这个场景我也跑通了,分享下完整链路。
链路是:摄像头采集帧 -> RK3588 NPU推理(YOLOv8)-> 用RGA在原始帧上叠加检测框和标签 -> 把处理后的帧送到MPP编码 -> 封装成MP4或RTSP推流。
这中间有两个技术点。第一,画框不要用CPU逐像素操作,1080p帧上画几百个矩形框,CPU操作大概会占用10%左右的CPU资源,在已经跑AI的板子上不划算。正确做法是用RGA的ROP操作,或者直接OpenCV的rectangle函数,实测消耗其实可控,因为OpenCV底层也做了优化。真正费时间的反而是字体渲染。
第二,编码的输入帧就是原始帧加上画框结果。如果你用的是OpenCV的Mat格式,Mat的data指针直接指向一块连续内存,只要确保它的行对齐和MPP要求一致,可以直接用mpp_buffer_import导入这块内存对应的dma_fd。如果内存不是DMA内存,就需要用mpp_buffer_get申请MPP管理的内存,然后用memcpy把Mat内容拷贝进去。后者会多一次内存拷贝,但代码简单,不容易出错。
我这里给出一个快速实现的伪代码流程:
// 1. 拿到NPU推理结果 std::vector<DetectResult> results = model.infer(frame); // 2. 在frame上画框 for (auto &r : results) { cv::rectangle(frame, r.bbox, cv::Scalar(0, 255, 0), 2); cv::putText(frame, r.label, r.bbox.tl(), cv::FONT_HERSHEY_SIMPLEX, 0.6, cv::Scalar(0, 255, 0), 1); } // 3. 将frame的数据填充到MPP输入buffer uint8_t *dst = (uint8_t *)mpp_buffer_get_ptr(frame_buf); memcpy(dst, frame.data, width * height * 3 / 2); // NV12 // 4. 提交编码任务 enqueue_frame_to_mpp(frame_buf, pts);注意这里有个细节:OpenCV的cvtColor默认输出是BGR顺序,要编码成H.264必须先把BGR转换成NV12,否则颜色通道会全部错乱。
cv::Mat yuv; cv::cvtColor(frame, yuv, cv::COLOR_BGR2YUV_YV12); // 转换成NV12需要做U/V平面的交织一个省事的办法是用OpenCV的COLOR_RGBA2YUV_YUY2或者直接走RGA,但最简单的方案还是在采集端就把摄像头输出格式设置成NV12,这样推理前做一次颜色转换,画框后不需要再转。
6.2 多路编码的线程模型
如果项目要做多路摄像头同时编码,我的线程架构是每路一个采集线程加一个编码线程,公共部分(比如NPU推理)单独一个线程。各路之间通过队列解耦,每个线程有自己的MppCtx和buffer group。
这里有个容易犯的错误:把两路编码放到同一个线程里串行处理。虽然VPU是硬件共享的,但MPP的API调用本身有锁和内部队列,串行调用也能跑,但一旦一路处理阻塞(比如另一路的V4L2 buffer耗尽),另一路就会被拖慢。独立线程能有效降低这种互相影响。
实测两路1080p30同时编码,每一路的帧间隔抖动大约是±3ms,完全可以接受。四路1080p30也能跑通,但VPU已经接近满载,个别时候会有队列积压,需要在上层做丢帧处理。四路以上的需求,建议降低每路的码率或者分辨率,或者考虑用H.265编码,同样画质下码率能省一半。
6.3 端到端延迟优化思路
如果要做低延迟场景(比如无人机图传、远程控制),有几个方向实测有效。
- 编码参数上,关闭B帧。B帧引入重排延迟,直接设GOP模式为
MPP_ENC_GOP_MODE_NORMALP,把vi_len设置为0即可。 - 关闭RC丢帧里的lookahead。MPP的
_drop_mode选项里如果开了MPP_ENC_RC_DROP_FRAME_PSNR,会引入额外延迟,设为DISABLED。 - V4L2采集端的buffer数量减少到2-3个,减少buffer排队延迟。
- 推流协议上,RTSP over TCP比UDP延迟略高但更稳定,UDP延迟低但容易丢包花屏。实时性要求高的场景用UDP,要求稳定的用TCP。
- 编码完直接通过FFmpeg的
-tune zerolatency选项做RTSP推流(如果走FFmpeg),会优化编码器延迟表现。
我这里不展开讲延迟数字,因为不同系统、不同分辨率和码率差别很大。但按上述思路调完,1080p30的端到端延迟从摄像头采集到播放器显示,可以控制在100毫秒到150毫秒这个区间,已经是比较理想的状态了。
7. 写在最后的经验之谈
做RK3588的MPP视频编码,对我个人来说最有价值的体会其实不是API本身,而是对整个系统的理解方式。MPP用好了,只是把VPU硬件的能力释放出来了,但真正决定你这个视频系统稳不稳定、延迟低不低的,是buffer怎么流转、内存怎么管理、时间戳怎么维护、遇到积压怎么取舍。细节特别多,但每解决一个问题,你对这个平台的控制力就强一分。
最后再分享一个小技巧。如果你刚开始接触MPP,不建议直接看MPPI_ENC_TEST这种复杂示例,代码路径太长,功能点太多,容易看晕。建议先从最简单的mpp_enc_test跑通,然后去读它的encode_one_frame函数,把main函数里初始化到编码的那几条mpi->control调用梳理清楚,然后自己写一个最小程序:打开摄像头、拿一帧NV12、编码一帧写文件。整个流程跑通之后,你再往里面加封装、加推流、加AI推理,每一步都能独立验证。遇到问题也更容易定位——是采集的问题、编码的问题,还是封装的问题,一步一查,心里有数。
希望这篇能帮你少走一些弯路。RK3588这个平台潜力很大,MPP只是其中一块积木,把这块积木玩明白了,后续做视频分析、流媒体服务、多路拼接,都能顺很多。