1. 为什么从零搭这套管线RK3568 上视频方案的选型逻辑1.1 V4L2 和 RKMPP 各管哪一段最近在RK3568板卡上做视频采集与编码需求MIPI接口接了颗OV5695摄像头要把1080p/30的画面实时硬编码成H.264存盘同时还要解回来做显示。刚起步时我也偷懒试过直接套FFmpeg反正它有h264_rkmpp这种硬件编解码器几行配置就能跑。但需求往深走就发现FFmpeg 把底层细节藏得太干净一旦涉及帧级别的 PTS 控制、buffer 复用、低延迟旁路反而无从下手。RKMPP 官方示例又只是底层 API 的堆叠代码能跑但看不出数据从哪来、到哪去。所以我把这条链路完整拆了一遍摄像头通过 CSI 接入 SoC 内置 ISPISP 做完坏点校正、自动曝光、自动白平衡之后把 YUV 帧输出到 V4L2 视频节点V4L2 负责采集RKMPP 负责调用 VPU 做硬件编解码。这三段里V4L2 是 Linux 摄像头采集的事实标准RKMPP 是瑞芯微官方操作 VPU 的用户态接口两者在各自领域都是绕不开的。有人会问为什么不用 GStreamer 一条流水线搭完GStreamer 的v4l2src ! mpph264enc确实能快速跑通但你要做的是可裁剪、可控延迟的视频处理管线时直接调 C API 能拿到每一层的 buffer方便在中间插 AI 推理、ROI 编码、自定义预处理。底层 API 读懂之后再回去看 GStreamer那些 element 内部就不再是黑盒了。1.2 VPU 能力边界与内存要求做方案选型前得先搞清楚三件事。第一编码格式RK3568 的 VPU 支持 H.264 和 H.265 编码H.264 对播放器、网络协议、存储容器的兼容性最好我最终选了 H.264。第二分辨率与帧率我验证过的 1080p30 编码、1080p30 解码完全没压力官方标称能力比这高但实际板卡的 DDR 带宽、散热、固件版本都会影响稳定值拿到板子第一件事应该是压力测试而不是直接信标称值。第三内存连续性VPU 访问内存不是通过普通虚拟地址而是通过 IOMMU 或物理地址映射用户态 malloc 出来的缓冲区大概率不满足要求必须用 MPP buffer 或从 V4L2 导出的 dma_buf。功能RK3568 VPU 规格本项目实际使用视频解码H.264 / H.265 / VP9标称 4K 级H.264 1080p30视频编码H.264 / H.265H.264 1080p30编码输入格式NV12 / NV21 / I420 等NV12解码输出格式NV12 等 YUV 帧NV12用户态接口RKMPP (libmpp)MPP_CTX_ENC / MPP_CTX_DEC1.3 我最终决定的管线架构实际搭出来的管线分两条路。正向是OV5695 sensor - ISP - /dev/video0 V4L2 节点 - dma_fd - RKMPP encoder - H.264 文件反向是H.264 文件 - RKMPP decoder - NV12 帧 - DRM 显示或保存图片。整个过程里最值得注意的设计决策是采集环节用 V4L2 的 dma_fd 直接交给 MPP而不是 mmap 后 memcpy 到普通内存。算笔账一帧 1080p NV12 数据量是 1920×1080×1.5 ≈ 3MB30fps 就是约 90MB/s 的纯内存搬运虽然 memcpy 本身不慢但这条链路上 CPU 还要跑采集线程、编码线程、文件写入能省则省。后面如果升级到 4K 或者叠加 AI 推理这几十 MB 的带宽就是压垮系统的最后一根稻草。2. V4L2 采集不是只 open read 那么简单2.1 设备节点选择与格式协商RK3568 板卡上的视频节点非常多/dev/video0 通常是 ISP 的 mainpath除此之外可能还有自拍路径节点、RGA 节点、video USB 节点。先用工具确认拓扑v4l2-ctl --list-devices输出里会有类似rkisp-vir0 (platform: rkisp)的条目下面挂着多个 video 节点。哪个节点对应哪条通路不同 SDK 不一样最靠谱的方式是看 dmesg 和设备树里compatible rockchip,rkisp的节点再对照 v4l2-ctl 列出的设备名。打开设备后先用 VIDIOC_QUERYCAP 确认能力然后设置采集格式int fd open(/dev/video0, O_RDWR | O_NONBLOCK); if (fd 0) { /* handle error */ } struct v4l2_capability cap {}; ioctl(fd, VIDIOC_QUERYCAP, cap); if (!(cap.capabilities V4L2_CAP_VIDEO_CAPTURE)) { // 这个节点不支持视频采集 } struct v4l2_format fmt {}; fmt.type V4L2_BUF_TYPE_VIDEO_CAPTURE; fmt.fmt.pix.width 1920; fmt.fmt.pix.height 1080; fmt.fmt.pix.pixelformat V4L2_PIX_FMT_NV12; fmt.fmt.pix.field V4L2_FIELD_NONE; if (ioctl(fd, VIDIOC_S_FMT, fmt) 0) { perror(VIDIOC_S_FMT); return -1; }S_FMT 之后必须回读 fmt因为驱动可能根据能力调整分辨率或像素格式。比如有些 ISP 通路只支持 16 对齐的分辨率你设置 100×100 会被改成 112×112 之类的值。1920×1080 和 1280×720 本身是 16 的倍数基本不会出问题但非对齐分辨率在这个环节就要开始注意。2.2 多缓冲队列的正确用法V4L2 的标准采集姿势不是 read()而是 buffer 队列。流程是 REQBUFS 申请缓冲区、QUERYBUF 查询每个 buffer 的信息、mmap 映射、QBUF 入队、STREAMON 开始、DQBUF 取出帧。struct v4l2_requestbuffers req {}; req.count 4; req.type V4L2_BUF_TYPE_VIDEO_CAPTURE; req.memory V4L2_MEMORY_MMAP; ioctl(fd, VIDIOC_REQBUFS, req); struct v4l2_buffer buf {}; buf.type V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.index i; buf.memory V4L2_MEMORY_MMAP; ioctl(fd, VIDIOC_QUERYBUF, buf); void *map mmap(nullptr, buf.length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, buf.m.offset);count4 是我试下来比较稳的值太少了采集线程稍微慢一点驱动没有空闲 buffer 可投递直接丢帧太多了内存占用大而且对低延迟场景不友好。4 到 6 个在 1080p 下都比较合适。采集循环里有个真实教训DQBUF 拿到的 buffer 在手上停留时间越短越好。整个队列只有 4 个 buffer如果你在采集线程里同步做编码处理速度小于 33ms/帧队列很快全被占满驱动开始丢帧。解决办法是采集线程只负责轻量收帧把实际编码工作丢给独立线程或者至少做到处理耗时严格小于一帧间隔。while (running) { struct v4l2_buffer buf {}; buf.type V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory V4L2_MEMORY_MMAP; if (ioctl(fd, VIDIOC_DQBUF, buf) 0) { if (errno EAGAIN) continue; break; } // 处理 buf.index 对应的帧 process_frame(buf.index); // 处理完立刻归还不要让 buffer 在外面等太久 ioctl(fd, VIDIOC_QBUF, buf); }2.3 导出 dma_fd为后面零拷贝埋的伏笔mmap 返回的地址能直接读像素但用户态拿不到它对应的物理内存信息VPU 也就没法直接访问。瑞芯微的 V4L2 驱动支持 VIDIOC_EXPBUF可以把 V4L2 内部的 buffer 导出成一个 dma_buf 文件描述符struct v4l2_exportbuffer exp {}; exp.type V4L2_BUF_TYPE_VIDEO_CAPTURE; exp.index buf.index; exp.flags O_RDONLY; if (ioctl(fd, VIDIOC_EXPBUF, exp) 0) { perror(VIDIOC_EXPBUF); return -1; } int dma_fd exp.fd;拿到 dma_fd 之后V4L2 侧仍然要按照 2.2 的流程把 buffer 通过 QBUF 还回队列。dma_fd 只是引用底层那块物理内存的句柄两边共享同一块内存这就是零拷贝的基础。后面 MPP 通过 import 方式拿到这个 fd编码器就能直接读采集帧。3. 数据进入 RKMPP 之前内存与像素格式的两次关键转换3.1 为什么 malloc 的 buffer 不能让 VPU 直接用我第一次调 RKMPP 时也走过弯路从 V4L2 的 mmap 里 memcpy 一份到 malloc 的数组然后把数组地址塞给 MPP结果要么直接报错返回要么编码出来是花屏严重时程序崩溃。原因在于 VPU 访问内存需要经过 IOMMU这块内存在创建时就必须映射到 IOMMU 地址空间。malloc 分配的是连续虚拟内存物理页不保证连续VPU 按连续物理地址去读读到的就是错乱数据。内存链路上有三种选择mpp_buffer_get由 MPP 自己分配内存最省事但采集数据需要 memcpy 进去多一次拷贝。V4L2 mmap memcpy 到 mpp_buffer稳定可靠适合驱动不支持 EXPBUF 的老平台。V4L2 EXPBUF 导出 dma_fd mpp_buffer_import零拷贝推荐。我最终选了第三条路。MPP 的MppBufferInfo结构里填入 fd、size、type 等信息通过mpp_buffer_import导入外部 dma_buf之后用mpp_frame_set_buffer把这个 buffer 挂到输入帧上整个采集到编码过程一次 memcpy 都不需要。3.2 格式必须对齐NV12 vs YUYVV4L2 采集输出的格式取决于 ISP 驱动配置有些固件直接输出 NV12但不少 sensor 默认输出 YUYV。而 RKMPP 编码器支持的输入格式主要是 NV12、NV21、I420 这类 YUV420 半平面/平面格式不支持 YUYV 这种 packed 格式。如果采集端拿到 YUYV就必须先转成 NV12。转换方案有两个CPU 软转纯循环从 YUYV 里抽 Y 平面和交织的 UV 平面1080p 实测耗时 5~8ms/帧非常不划算。RGA 硬件转RK3568 自带 RGA2通过 librga 的 im2d API 转换很快1080p 常规在 1ms 上下。所以选型思路是优先让 ISP 直接输出 NV12实在不行再用 RGA 兜底不要自己写转换循环。好在 Rockchip 的 ISP 驱动在设备树配套正常时通常都能直接输出 NV12这也是我在 2.1 里强调要确认像素格式的原因。3.3 用 RGA 做兜底转换的实现思路RGA 的用户态 API 在不同 SDK 里有细微差异但核心逻辑一致。一个最简单的 YUYV 转 NV12 调用#include im2d.h rga_buffer_t src wrapbuffer_virtualaddr(src_ptr, width, height, RK_FORMAT_YUYV); rga_buffer_t dst wrapbuffer_virtualaddr(dst_ptr, width, height, RK_FORMAT_NV12); IM_STATUS ret imresize(src, dst, {}, {}, IM_SYNC); if (ret ! IM_STATUS_SUCCESS) { // 转换失败需要处理 }这里注意IM_SYNC是同步模式调用返回后数据就已经可用不用再管同步问题。集成 RGA 之前先确认/dev/rga节点存在再确认 librga 的版本因为旧 SDK 的 imrga 接口和新 SDK 的 im2d 接口差别不小。转换后务必检查返回值不要假设每次都会成功这在长跑稳定性的调试中非常关键。4. RKMPP 硬件编码把自己绑定在 handle 循环里的套路4.1 初始化和参数配置编码质量全看这一下RKMPP 编码器的初始化固定分三步mpp_create创建上下文、mpp_init设置编码类型、MppEncCfg 配参数然后通过control下发。MppCtx ctx nullptr; MppApi *mpi nullptr; mpp_create(ctx, mpi); mpp_init(ctx, MPP_CTX_ENC, MPP_VIDEO_CodingAVC); MppEncCfg cfg nullptr; mpp_enc_cfg_init(cfg); mpp_enc_cfg_set_s32(cfg, prep:width, 1920); mpp_enc_cfg_set_s32(cfg, prep:height, 1080); mpp_enc_cfg_set_s32(cfg, prep:hor_stride, 1920); mpp_enc_cfg_set_s32(cfg, prep:ver_stride, 1088); mpp_enc_cfg_set_s32(cfg, prep:format, MPP_FMT_NV12); mpp_enc_cfg_set_s32(cfg, rc:mode, MPP_ENC_RC_MODE_CBR); mpp_enc_cfg_set_s32(cfg, rc:bps, 4000000); mpp_enc_cfg_set_s32(cfg, rc:bps_max, 4000000); mpp_enc_cfg_set_s32(cfg, rc:bps_min, 4000000); mpp_enc_cfg_set_s32(cfg, rc:fps, 30); mpp_enc_cfg_set_s32(cfg, rc:gop, 60); mpp_enc_cfg_set_s32(cfg, codec:type, MPP_VIDEO_CodingAVC); mpi-control(ctx, MPP_ENC_SET_CFG, cfg); mpp_enc_cfg_deinit(cfg);几个参数单独解释一下。hor_stride和ver_stride是硬件行对齐值NV12 要求 16 对齐。1920×1080 本身满足条件但如果分辨率是 1024×600600 不是 16 的倍数NV12 的 ver_stride 就要向上取到 608。直接填实际宽高有时也能跑但遇到非对齐分辨率就是花屏风险。rc:bps是目标码率。1080p30 我用 4Mbps画面质量比较均衡。运动剧烈的场景建议提到 6~8Mbps静止场景可以降到 2Mbps。rc:gop是 I 帧间隔60 表示 2 秒一个关键帧。存储点播文件可以长一点直播低延迟场景建议 30 甚至 15。rc:fps影响码控时间基准填错会导致实际码率与目标偏差很大。4.2 一帧进、一包出的编码循环MPP 编码核心循环用一句话说往 ctx 里放一帧 MppFrame从 ctx 里拿一个 MppPacket。整个过程是同步阻塞接口但 VPU 处理一帧 1080p 通常只要几毫秒远小于 33ms 的帧间隔。MppFrame frame nullptr; mpp_frame_init(frame); mpp_frame_set_width(frame, 1920); mpp_frame_set_height(frame, 1080); mpp_frame_set_fmt(frame, MPP_FMT_NV12); mpp_frame_set_pts(frame, pts); // 90kHz 时间基准 mpp_frame_set_buffer(frame, mpp_buffer); // 通过 mpp_buffer_import 或 mpp_buffer_get 得到 mpi-encode_put_frame(ctx, frame); MppPacket packet nullptr; mpi-encode_get_packet(ctx, packet); if (packet) { void *ptr mpp_packet_get_data(packet); size_t len mpp_packet_get_size(packet); // 写文件、推流或送入复用器 mpp_packet_deinit(packet); } else { // 编码器内部暂未准备好输出下一帧再试 } mpp_frame_deinit(frame);有个细节值得留意encode_get_packet返回空包并不一定表示出错可能是编码器内部还没凑够一帧的输出条件。这时候不要 break继续下一轮。我刚开始按一进一出必然成功去写结果在个别分辨率下偶发丢包排查半天才发现是这个原因。4.3 裸流保存与时间戳问题编码器输出的是 H.264 裸流第一个关键帧里自带 SPS/PPS 和 IDR直接用ffplay out.h264就能播放。但裸流没法拖动进度条因为没有索引信息所以正常工程里还需要封装成 MP4 或 TS。封装这一步可以继续用 FFmpeg 的 muxer只是把 MPP 输出的裸流数据按 AVPacket 方式喂进去逻辑不难但要注意 PTS 对齐。时间戳问题非常典型如果不给 MppFrame 设置 pts编码器也能输出帧但所有帧的 pts 都是 0 或乱序播放器会出现画面速度忽快忽慢、时间轴乱跳的现象。H.264 的 pts 单位是 90kHz我习惯用单调时钟换算int64_t pts (int64_t)(monotonic_us() * 90);也有人直接pts frame_index * 3003其中 3003 是 90000/30 的取整30fps 下够用。但一旦采集丢帧帧序号就不连续时间轴会乱。用真实时钟生成 pts哪怕中间丢了帧时间关系也是准确的。5. RKMPP 硬件解码从 H.264 流到可显示帧5.1 解码上下文初始化与格式探测解码初始化和编码类似区别是编码我们主动告诉 MPP 输入格式解码则需要从码流里解析 SPS/PPS。MPP 的 H.264 解码器拿到第一个包含关键帧的包之后会内部完成格式探测上层只需要MppCtx ctx nullptr; MppApi *mpi nullptr; mpp_create(ctx, mpi); mpp_init(ctx, MPP_CTX_DEC, MPP_VIDEO_CodingAVC);如果输入是裸流建议把 NAL 切分工作交给解码器。因为外部输入的数据包边界不一定和 NAL 边界对齐尤其从网络接受 H.264 分片时一个包可能只包含半个 NAL。这种情况下要打开 split modeRK_U32 need_split 1; mpi-control(ctx, MPP_DEC_SET_PARSER_SPLIT_MODE, need_split);这个开关不打开遇到半个 NAL 就可能解析失败。自产自销的闭环验证里编码端输出的 packet 边界基本对齐但养成打开 split mode 的习惯没有坏处。5.2 喂包与取帧的循环解码循环也是一进一出MppPacket packet nullptr; mpp_packet_init(packet, data, len); mpi-decode_put_packet(ctx, packet); mpp_packet_deinit(packet); MppFrame frame nullptr; mpi-decode_get_frame(ctx, frame); if (frame) { int w mpp_frame_get_width(frame); int h mpp_frame_get_height(frame); MppBuffer buf mpp_frame_get_buffer(frame); void *addr mpp_buffer_get_ptr(buf); // 这里 addr 指向解码后的 NV12 数据可直接显示或保存 mpp_frame_deinit(frame); }有个容易忽略的点如果 H.264 流里有 B 帧解码顺序和显示顺序不一致decode_get_frame可能暂时返回 NULL这是正常现象继续喂包即可不要当错误处理。另外不要把一个巨大的裸流文件一次性塞进decode_put_packet正确做法是按帧喂、按帧取保持节奏。5.3 一个自产自销的闭环验证为了验证整条链路我把编码端存出来的 out.h264 读回来走解码链路解码出的 NV12 帧直接存成 YUV 文件然后用 ffplay 检查ffplay -f rawvideo -pix_fmt nv12 -s 1920x1080 out.yuv这个闭环成本最低能快速排除摄像头和显示环节的干扰。如果解码后画面正常说明编码、解码两条链路都是通的剩下的显示、推流都是外围工作。6. 把整条管线跑起来后我记下的踩坑清单6.1 花屏与绿条先查对齐再查内存现象是编码出来的视频首屏花屏或半边绿。我的排查链路是先怀疑编码参数把分辨率改成 16 的倍数问题依旧打印输入帧前几行数据确认驱动输出排列是不是预期 NV12用 ffplay 直接显示采集到的原始 NV12如果原始画面就花说明 V4L2/ISP 配置有问题如果原始画面正常问题就在 MPP 输入。最后定位到hor_stride配置填了实际宽度但驱动输出的是对齐后的 stride两者不一致编码器拿到的是错位数据。这里最容易错的一个点是NV12 的bytesperline通常是 width但 plane 大小不等于 width×height。如果你用 mmap 线性地址一帧一帧存务必用bytesperline而不是 width 去计算行长度否则非 16 对齐分辨率上每一行错位几个字节画面不是花屏而是整体向右倾斜加条纹。MPP 的hor_stride应该填 V4L2 buffer 真正的行对齐值也就是round_up(width, 16)。6.2 颜色灰蒙蒙V4L2 和 VPU 的量化范围不一致现象是编码后的视频能看但颜色发灰发白、对比度不对。这个坑在存裸流直接播放之前基本不会被发现因为 V4L2 输出直接上屏可能看不出来一旦编解码循环走一圈量化范围问题就暴露了。摄像头 ISP 输出的 NV12 可能按 limited range16~235处理而 RKMPP 编码器/解码器内部默认按 full range0~255或者反过来导致灰阶被压缩或抬升。解决思路是尽量在源头统一V4L2 采集端通过v4l2-ctl --set-ctrl调整色彩空间控制项或者在编码配置里强制色域一致。如果 SDK 的 ISP 输出色彩空间能通过 v4l2_ctrl 调节优先在源头统一否则解码输出侧还要加一层 colorspace conversion。这个坑最讨厌的地方在于屏幕上播放可能完全看不出来因为显示链路有自己的转换但存成文件后一换播放器就露馅了。6.3 播放时间轴乱跳PTS 没打上的典型表现现象是 H.264 文件用 ffplay 播放画面速度忽快忽慢拖动进度条不正常。原因在 4.3 已经说过没给 MppFrame 设置 pts或者 pts 单位搞错。我见过最典型的错误是把微秒直接当 90kHz 用导致时间戳变大 90 倍播放器以为帧间隔有 3 秒。正确换算是pts_us * 90。用帧序号frame_index * 3003在固定帧率下也能跑但丢帧后时间轴会乱。项目里采集和编码分别在不同线程丢帧时队列会跳过所以我在实际代码里用的是单调时钟保证时间关系始终正确。6.4 单独调解码器不吐帧从关键帧开始喂现象是用一个普通 H.264 视频文件喂给 RKMPP 解码器前几秒没有输出帧或者直接报错。原因很简单解码器需要先等到 SPS/PPS 和 IDR 关键帧才能开始解码。如果你从文件中间段开始喂它就只能空转。另一种情况是把编码器输出连续多个 packet 合并成一次decode_put_packet也不会崩溃但解码器内部切分 NAL 依赖 split mode 开关忘了打开就会碰上半个包解析失败。我的建议是所有裸流解码场景都打开MPP_DEC_SET_PARSER_SPLIT_MODE然后确保外部输入流从关键帧开始送。判断关键帧的方法很简单H.264 NAL type 等于 5 的就是 IDR。7. 性能实测与还能继续做的优化7.1 实测数据参考我这套板卡是四核 A55、2GB DDR、Linux 内核 5.10MPP 版本与 SDK 保持一致。入口条件1080p/30V4L2 采集 NV12dma_fd 导入 RKMPPH.264 编码 CBR 4MbpsGOP60。场景CPU 占用帧率备注V4L2 采集 RKMPP 硬编码10%~15%30fps 稳定采集、编码、存盘三个线程上述链路再加一路 RKMPP 解码回显20% 左右30fps 稳定解码输出直接送 DRM 显示FFmpeg 软编 x264 同样规格90%偶尔掉到 20fps 以下实际不可用这组数据说明一个事实对 RK3568 这类中低端 SoC视频编解码必须走 VPUCPU 资源要留给 ISP、AI 和其他逻辑。数据基于我手头固件不同板卡会有差异但趋势是一致的。7.2 还能怎么继续优化整套链路调通之后我总结了几条可以继续深挖的优化方向buffer 池化不要每次编码临时mpp_buffer_get和mpp_packet_init启动阶段就申请好 4~6 组 buffer 循环复用能明显减少内存分配带来的抖动。线程绑核采集线程绑 CPU0/1编码线程绑 CPU2/3小核任务的实时性会好很多尤其在高负载场景下。减少拷贝确保整条链路只有 V4L2 到 MPP 一处 dma_fd 交接不要在中途插入 memcpy。每加一次拷贝内存带宽和 cache 污染都是实打实的损失。码控调优静止画面场景换 VBR 或调低 bps能明显节省存储运动场景适当提高 bps 并保持 CBR码率更稳定。显示端零拷贝解码输出的 NV12 帧通过 DRM/KMS 的 dma_buf 直接显示避免转 RGB565 再拷贝。叠加 AI 推理RKMPP 输出的帧通过 RGA 转 RGB 后送 NPU或者直接在 YUV 域跑轻量模型减少一次转换开销。最后再说句实在话V4L2 和 RKMPP 本身都不复杂复杂的是数据在它们之间流转时那些看不见的约定——内存归属、行对齐、颜色范围、时间基准。把这些约定逐一对齐你在 RK3568 上的视频项目基本就成功了一半。后续无论是接 RTSP 推流、接 NPU 推理还是把编码换成 H.265都只是在这条骨架上换零件。