前阵子接了个活儿,客户要在 RK3588 的板子上同时跑 8 路网络摄像头的画面,还要留出算力给后面的分析模型。刚开始我图省事,直接上了 OpenCV 的软解,结果 4 路还没跑满,8 核 CPU 就有 6 个核被打到 90% 以上,帧率从 25 掉到 12,风扇叫得像要起飞。后来我把解码这条链路整体换成了 RK3588 自带的 VPU 硬件解码,用 Python 通过 MPP 封装去调,CPU 占用直接掉到个位数,8 路 1080p 稳稳跑满 30 帧。这篇就把我这一路踩下来的坑、Python 调用 MPP 的完整思路、8 路并发的架构设计,以及实测数据和排查经验一次性讲清楚。内容偏实战,默认你手里已经有一块 RK3588 的板子、能跑 Ubuntu 或者 Debian 系统、会基本的 Linux 命令,Python 基础语法不熟也没关系,我会把关键地方都拆开讲。
1. RK3588 做 8 路硬解的可行性拆解
在做任何方案之前,先把“这块板子到底能不能扛住 8 路”这件事算清楚,否则后面全是白忙。很多人上来就写代码,跑不动了再回头怀疑芯片,其实 RK3588 的规格表里已经把答案写得很明白了。
1.1 芯片里的 VPU 到底能扛多少路
RK3588 是 4 个 Cortex-A76 大核加 4 个 Cortex-A55 小核的八核架构,另外还挂着独立的 NPU、GPU 和 VPU。我们这次真正要用的是 VPU,也就是视频处理单元,它是一块独立于 CPU 的硬件编解码器。官方给出的解码能力大致是 8K@60fps 的 H.265、8K@30fps 的 H.264,编码侧也能做到 8K@30fps。这个数字看着夸张,但它是硬件流水线的理论峰值,实际用起来会打折扣,原因后面会讲。
先做个简单的换算:8 路 1080p@30fps 是什么量级?1080p 一帧像素是 1920×1080,约 207 万像素。8 路、每路 30 帧,一秒就是 240 帧,总共约 5 亿像素每秒。而 8K 一帧是 7680×4320,约 3318 万像素,60 帧就是约 20 亿像素每秒。也就是说,8 路 1080p 的总像素吞吐大概只有 VPU 理论峰值的四分之一。从这个角度看,硬件层面完全撑得住,瓶颈根本不在这。
真正要小心的是“路数”和“码率分布”。8 路如果都是低码率、低复杂度的监控流,VPU 几乎是散步状态;但如果其中混了几路 4K 高分码流,或者码流里有大量 B 帧、参考帧跨度很大,VPU 的负载会明显上升。所以评估时不能只看分辨率,还要看编码档次和 GOP 结构。
1.2 软解为什么必然会崩
我一开始用 OpenCV 的cv2.VideoCapture去解 RTSP,走的其实是 FFmpeg 软解,解码全靠 CPU。H.264 软解在 A76 这种核心上,单路 1080p@30 差不多要吃掉 0.6 到 1 个核,8 路理论上就得 5 到 8 个核。注意这只是解码本身,还没算上 RTSP 收流、内存拷贝、图像后面送去做推理或者编码的额外开销。
一旦 CPU 被解码占满,问题就不只是帧率下降这么简单:整个系统的时间片都被吃掉,网络收包的线程被挤到队列末尾,丢包、花屏、卡顿全都来了。更麻烦的是,软解的输出是 CPU 内存里的图像,你如果还要把它送去做缩放、转格式、送 NPU,那又是一轮 CPU 拷贝。所以软解这条路在 8 路这个量级上,从设计上就是不成立的。
硬解的核心价值在于把“解码计算”从 CPU 手里拿走,交给专用电路。VPU 解完的帧通常直接落在 DMA 缓冲区里,不需要 CPU 参与搬运,后续如果要做缩放或者格式转换,也能走 RGA 这类硬件加速单元。CPU 这时只需要负责调度和收发包,占用自然就下来了。
1.3 为什么把 Python 放到硬解这条链路上
这里肯定有人要问:MPP 明明是 C 库,为什么不用 C++ 写?我自己的取舍是这样的。这个项目的重点不在解码本身,而在解码之后要接的算法和业务逻辑,而那部分我用 Python 写得又快又顺手。如果为了调 MPP 专门写一套 C++ 服务,再和 Python 业务做进程间通信,光是数据序列化和调试成本就上去了。
Python 调 MPP 的关键在于ctypes,它是标准库里就带的,可以直接加载.so动态库、声明函数原型、操作 C 结构体。而且ctypes.CDLL在发起 C 调用的时候会释放 GIL,这一点非常关键——意味着多个 Python 线程同时调 MPP 的 C 函数时,是可以真正并行的,不会被 Python 的全局锁串成一条线。这个特性决定了“每路一个线程”的架构在 Python 下是可行的,后面第 5 章会展开。
一句话总结这一章:RK3588 的 VPU 硬件余量足够跑 8 路,软解在架构上走不通,MPP + Python 的组合是兼顾性能和开发效率的一条务实路线。
2. MPP 框架与 Python 绑定方案选型
搞清楚“能跑”之后,接下来是“怎么调”。MPP 全称是 Rockchip Media Process Platform,是芯片厂商给 VPU、RGA 这些多媒体硬件统一封的一套中间层。它不是一个简单的函数库,而是分了好几层,理解这个分层对后面排查问题特别有帮助。
2.1 MPP 的分层结构与解码主链路
从下往上看,最底层是 OSAL,负责把 Linux 的线程、锁、内存映射这些系统调用包起来,让上层代码不直接依赖具体操作系统。再往上是 HAL,也就是硬件抽象层,负责和 VPU 驱动对话、管理寄存器、处理硬件队列。最上面是 MPI,也就是我们调用的那层 API,mpp_create、mpp_init、mpp_decode这些都是 MPI 提供的。
解码的主链路其实不长,核心就是三个对象:上下文(MppCtx)、API 句柄(MppApi)、数据包(MppPacket)。流程是先用mpp_create建上下文,再用mpp_init把它初始化成解码模式,然后在一个循环里反复做两件事——把码流数据封装成 packet 送进去解码,再把解好的 frame 取出来。真正复杂的地方不在调用顺序,而在内存管理:packet 的数据你得自己准备缓冲区,frame 取出来之后你还得知道它在哪块内存、什么格式、怎么读。
MPP 输出帧默认是 NV12 格式,也就是 YUV420SP,Y 分量一整块,UV 交错一整块。如果你后面要送 NPU 做推理,NPU 一般也是吃 NV12 或者 RGB,NV12 反而是最省事的。如果只是要显示,那基本不用转。只有当你确实需要 RGB 的时候,才考虑用 RGA 做一次硬件转换,千万别在 CPU 上用 cv2.cvtColor 一帧帧转,8 路转起来 CPU 又要爆。
2.2 Python 侧三条可行路线对比
在 Python 里碰 MPP,我实际调研过三条路,各自的适用场景差别挺大。
第一条是用官方仓库里自带的 Python 示例。rockchip 的 mpp 仓库里有一个test/mpp_py_demo目录,里面就是一个用 ctypes 写的封装和 demo,结构体定义、函数原型都给你写好了。这是最省事的起点,强烈建议先把它跑通,理解整个调用序列,再基于它改。
第二条是自己用 ctypes 从头封装。好处是完全可控,你能按自己的需求裁剪结构体,只声明用得到的字段和函数。坏处是 MPP 的结构体字段很多,有些是内部字段,声明不对会导致函数参数传错、直接崩溃或者读到垃圾数据,调试起来很痛苦。
第三条是找现成的第三方 Python 封装。社区里确实有人做过,但维护状态参差不齐,有的绑定的还是老版本 MPP,和 RK3588 上的新库对不上。用之前一定要确认它适配的 MPP 版本。
下面这张表是我当时做的对比,可以对照着选:
| 方案 | 上手成本 | 可控性 | 维护风险 | 适合场景 |
|---|---|---|---|---|
| 官方 mpp_py_demo 改 | 低 | 中 | 低 | 快速验证、单路跑通 |
| 自研 ctypes 封装 | 高 | 高 | 自己负责 | 8 路并发、需要精细控制缓冲 |
| 第三方封装库 | 低 | 低 | 高 | 简单场景、不做长期维护 |
2.3 我最终选的方案和理由
我的做法是“以官方 demo 为骨架,做定制化裁剪”。具体说,就是把 demo 里的结构体定义和函数原型拿过来作为基础,然后针对 8 路并发的需求做几处改造:一是把上下文和缓冲区封装成类,方便每个线程各持一份;二是精简掉用不到的回调,减少 Python 层的开销;三是加上自己的日志和统计,方便观察每路的解码帧率和延迟。
这里有个很重要的原则:MPP 相关的对象不要在多个线程之间共享。MppCtx 本来就不是线程安全的,一个上下文对应一路解码,各线程各管各的,互不干扰。共享的只有最底层的 VPU 硬件,而这个调度由驱动和 HAL 层自己处理,我们不用管。这个边界划清楚之后,整个架构就简单了。
提示:不要试图用一个 MppCtx 去解多路码流,哪怕你在线程里加锁也不行。MPP 的设计就是一个实例对应一路,多路就是多实例。
3. 环境搭建:从系统检查到 MPP 库编译
这一章讲落地。环境不对,后面全是玄学问题,所以我会把每一步的“为什么”也说清楚。
3.1 系统与内核、VPU 节点确认
拿到板子第一件事,确认系统里有没有 VPU 的设备节点。通常是在/dev/下面能看到mpp_service或者rkvdec之类的节点。用ls /dev | grep -i mpp和ls /dev | grep -i vdec查一下,如果什么都没有,说明内核里对应的驱动没编进去,这时候再怎么装库也没用,得先解决内核。
接着确认内核版本,用uname -a。RK3588 的厂内核和主线内核在多媒体驱动上差异不小,建议优先用板子厂商提供的内核,驱动匹配度好。如果用的是自己编的主线内核,VPU 驱动的编译选项要确认打开。
还有一个容易忽略的点:确认系统里已经装了librockchip_mpp,如果厂商的镜像里自带,那省事很多,直接ldconfig -p | grep mpp看看有没有。没有的话自己编译,就是下一步。
3.2 编译安装 rockchip-mpp
从 rockchip 的 mpp 仓库拉源码,然后走标准的 CMake 流程。我习惯建一个 build 目录,保持源码干净:
git clone https://github.com/rockchip-linux/mpp.git cd mpp mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release -DRKPLATFORM=ON -DHAVE_DRM=ON make -j8 sudo make install sudo ldconfig这几个参数值得说一下。-DRKPLATFORM=ON是打开平台相关的那部分,对 RK3588 是必须的;-DHAVE_DRM=ON打开 DRM 支持,如果你后面要用 DRM 直接显示或者走 DMA 分配内存,这个要开;-DCMAKE_BUILD_TYPE=Release别省,Debug 版本在解码这种高频调用下性能差得肉眼可见。
编译完主要产物是librockchip_mpp.so,装到系统库路径里。ldconfig是刷新动态库缓存,不做这一步 Python 加载库的时候可能找不到。
注意:编译时要确认交叉编译和本机编译别搞混。如果你是在板子上直接编,用上面的命令就行;如果是在 x86 主机上交叉编译,工具链和 sysroot 都要配对,特别是
libdrm的开发包要在 sysroot 里能找到。
3.3 Python 侧依赖与 ctypes 封装文件准备
Python 这边其实依赖很少,ctypes是标准库自带的,不需要额外装。你可能还会用到numpy来处理帧数据,如果要把帧转成数组的话。再就是opencv-python或者ffmpeg来做拉流和测试,但这个只在调试阶段用,正式跑的时候拉流我建议用更轻的方式,比如直接读文件或者用 GStreamer 收流。
封装文件我建议单独放一个mpp_wrapper.py,把库加载、函数原型声明、结构体定义、以及一个简洁的 Decoder 类都放进去。这样业务代码里只from mpp_wrapper import Decoder就行,干净。库加载的时候用ctypes.CDLL("librockchip_mpp.so.1"),注意带版本号,否则可能加载到旧版本。
如果是第一次接触 Python 环境配置,装个 pip、配好虚拟环境,避免污染系统 Python。别小看这一步,我见过有人把库装到系统 Python 里,结果和厂商镜像自带的工具冲突,排查半天。
4. 单路解码打通:从 H.264 码流到 NV12 帧
8 路的前提是 1 路能跑通。这一章把单路解码的完整调用序列拆开,每一段都说明白在干什么。
4.1 初始化三件套:mpp_create / mpp_init / 解码器配置
初始化的顺序是固定的,不能颠倒。先mpp_create拿上下文和 API 句柄,这一步会分配内部资源;然后mpp_init指定工作模式,解码用MPP_CTX_DEC,并把编码类型传进去,比如MPP_VIDEO_CodingAVC对应 H.264,MPP_VIDEO_CodingHEVC对应 H.265;最后通过 API 句柄调用解码器的控制命令,设置外部缓冲区模式之类的参数。
用 ctypes 表达的话,函数原型大概长这样:
import ctypes from ctypes import c_void_p, c_int, c_uint32, POINTER mpp = ctypes.CDLL("librockchip_mpp.so.1") mpp.mpp_create.argtypes = [POINTER(c_void_p), POINTER(c_void_p)] mpp.mpp_create.restype = c_int mpp.mpp_init.argtypes = [c_void_p, c_void_p, c_int] mpp.mpp_init.restype = c_int ctx = c_void_p() api = c_void_p() ret = mpp.mpp_create(ctypes.byref(ctx), ctypes.byref(api)) assert ret == 0, f"mpp_create failed: {ret}" ret = mpp.mpp_init(ctx, api, MPP_CTX_DEC) assert ret == 0, f"mpp_init failed: {ret}"这里每个assert都不是随手写的。MPP 的返回值是错误码,一旦初始化失败后面全是连锁反应,所以每个关键调用后都检查返回值,出问题第一时间就能定位。我在实际项目里还会把返回值翻译成可读的错误信息打日志。
初始化里最容易搞错的是编码类型。H.264 是 AVC,H.265 是 HEVC,写反了会出现解码器初始化成功但一送数据就报错或者出花屏。如果不确定码流是什么格式,可以先用ffprobe看一眼。
4.2 送包与收帧的主循环
初始化完成后,核心就是一个循环:读一段码流,封装成 packet 送进去,再从队列里取 frame。这里面有个关键点是“送”和“取”是异步的。你送一个 packet 进去,解码器不一定立刻吐出一帧,因为 H.264 有 I 帧 P 帧的依赖关系,有时需要攒够参考帧才出图。所以循环里不能假设“送一包取一帧”。
正确的做法是维护一个输入队列和一个输出队列。送包线程持续往解码器喂数据,取帧线程持续从解码器取数据,两边通过条件变量协调。如果嫌复杂,单路的时候也可以在同一线程里做“送一包、取到取不出为止”的轮询:
while running: pkt_data = read_chunk(stream) # 读一段码流 if not pkt_data: break packet = make_packet(pkt_data) # 用 ctypes 构造 MppPacket mpp.mpp_packet_set_size(packet, len(pkt_data)) ret = mpp.mpp_decode(api, ctx, packet, c_void_p(0)) mpp.mpp_packet_deinit(ctypes.byref(packet)) while True: frame = c_void_p() ret = mpp.mpp_decode_get_frame(api, ctx, ctypes.byref(frame), 0) if ret != 0 or not frame: break handle_frame(frame) mpp.mpp_frame_deinit(ctypes.byref(frame))这段代码里的mpp_decode_get_frame要在内层循环里反复调,直到取不出为止。很多新手只调一次,结果解码器内部缓冲越积越多,最后卡死。这是单路调试最常见的坑。
4.3 帧数据的地址、格式与搬运
取到的 frame 不能直接当 Python 对象用,得先搞清楚它在哪块内存、多大、什么格式。MPP 提供了一组 getter:mpp_frame_get_buffer拿内存地址,mpp_frame_get_width和mpp_frame_get_height拿宽高,mpp_frame_get_hor_stride拿水平跨度。这里有个特别容易出错的点:跨度(stride)不等于宽度。
因为 VPU 处理时会对齐,比如宽度 1920 的行,实际每行在内存里可能占 1920 或者对齐到 1920,但有些高分辨率会对齐到 2048 甚至更大。如果你按宽度去读内存而忽略 stride,图像会出现斜着的错位。正确的做法是按 stride 定位每一行的起点,只读其中的 width 个字节。
拿到 NV12 之后,如果只是写文件或者送 NPU,一般不需要转换。非要用 numpy 表示的话,可以用np.frombuffer配合正确的 stride 构造,注意 Y 分量在前、UV 交错在后,UV 的高度是 Y 的一半。这一步我建议先拿一个 I 帧验证,图像对了再往下做多路。
实操心得:第一次跑单路时,别急着接显示器,先把解出来的 NV12 写成一个裸文件,用
ffplay -f rawvideo -pix_fmt nv12 -s 1920x1080 out.yuv播一下。图像正常,说明内存读取和 stride 都对;如果出现斜条纹,八成是 stride 搞错了。
5. 从 1 路到 8 路的并发架构
单路跑通只是热身,真正的工程难度在 8 路。这一章讲我怎么设计并发的结构,以及在 Python 下要特别注意什么。
5.1 线程模型与 GIL 的真实影响
我的方案是每路一个独立的线程,每个线程持有自己的 MppCtx、自己的输入输出队列、自己的统计信息。线程之间不共享任何解码对象,只共享一些只读的配置和日志。
很多人担心 Python 的 GIL 会让多线程形同虚设。这个担心在纯 Python 计算密集的场景下是对的,但在我们这里不成立。原因前面提过,ctypes.CDLL调用 C 函数时会释放 GIL,也就是说当线程 A 正在 MPP 的 C 代码里解码时,GIL 是放开的,线程 B 完全可以进来做它自己的事情。真正的 Python 字节码执行(比如处理本地变量、拼日志)才会上锁,这部分很轻。所以在这个架构里,8 个线程基本是并行的。
反过来说,如果你用ctypes.PyDLL就完蛋了,它是不会释放 GIL 的,用错这一个字,性能直接掉一半。这是我在实际项目里强调过很多次的点,务必确认加载库用的是CDLL。
5.2 队列、缓冲深度与丢帧策略
每路一个输入队列,用来缓存从网络或文件读进来的码流。队列深度是个需要斟酌的参数。设太浅,网络一抖动就断流;设太深,延迟会积累,实时性差。
我的策略是给队列设一个有界长度,比如 8 到 16 个 packet。当队列满了,说明解码跟不上读取速度,这时候主动丢最老的 packet,而不是阻塞读线程。这里丢包要小心:H.264 里 I 帧是不能随便丢的,丢了会牵连后续一大串 P 帧。如果丢的是 P 帧,问题不大,最多卡一下;如果丢的是 I 帧,整个 GOP 都可能花。所以更稳妥的做法是只丢非关键帧,或者干脆按“丢弃整个 GOP”的思路处理。
输出侧也是同理。解码出来的 frame 如果下游消费(比如显示或推理)跟不上,会造成内存堆积。我一般给输出设一个较小的缓冲,满了就让取帧线程等一等,因为 frame 占的内存大,堆多了容易触发系统内存压力。
5.3 带宽与内存的定量估算
做 8 路之前,我算过一笔账,避免上线后才发现 DDR 带宽不够。1080p 的 NV12 一帧是 1920×1080×1.5 字节,约 3.1MB。8 路、每路 30 帧,一秒 240 帧,就是约 745MB/s。这还只是解码输出产生的写带宽,读取侧还有码流输入和后续消费读取,加起来轻松过 1GB/s。
RK3588 用的是 LPDDR4/4x 或者 LPDDR5,理论带宽在十几 GB/s 到几十 GB/s,所以整体是够的。但要注意的是,DDR 带宽是整块芯片共享的,NPU 推理、GPU 渲染、CPU 工作都在抢。如果同时还要跑 yolov8 这类模型,NPU 本身也会消耗大量带宽,这时候就要给解码留出足够余量,别把系统压到极限。
内存占用也得算。每个 MppCtx 内部会缓存若干帧,按 8 路各缓存 5 帧算,光解码缓存就是 8×5×3.1MB≈124MB。加上 Python 进程本身和码流缓冲,整个解码服务的常驻内存控制在 300MB 以内是合理的。
6. 8 路并发实测记录与调优
前面都是设计,这一章给实测数据。测试环境是我手上的一块 RK3588 开发板,16GB 内存版本,系统是 Ubuntu 22.04,内核用的厂商版本。
6.1 测试环境与码流规格
码流方面我用了两种来源做对比:一种是本地 H.264 文件循环读,方便复现和压测;另一种是真实的网络摄像头流,用来验证网络抖动下的稳定性。本地文件统一是 1080p、H.264、25fps、GOP 长度 50,平均码率 4Mbps。摄像头那批码率差异较大,从 2Mbps 到 8Mbps 都有。
验证方法是:起 8 路解码,每路统计每秒实际解出的帧数,同时用厂家提供的工具看 VPU 占用。cat /sys/kernel/debug/mpp_service/...之类的节点能看到负载,但具体路径不同内核不一样,也可以用top看 CPU,用free看内存。
6.2 实测数据:帧率、CPU、VPU 占用
跑满稳定之后的数据大概是这样的:
| 指标 | 软解 8 路 | 硬解 8 路 |
|---|---|---|
| 单路帧率 | 约 11 fps | 稳定 25 fps |
| 总的 CPU 占用 | 约 650% | 约 90% |
| VPU 占用 | 基本为 0 | 约 55% |
| 内存占用 | 约 900MB | 约 280MB |
| 端到端延迟 | 800ms 以上 | 120ms 左右 |
可以看到,硬解之后 CPU 从被吃满降到不足一个核的量,VPU 还有差不多一半余量。这个余量意味着如果你上 12 路、16 路 1080p,理论上也能扛,但需要重新评估带宽和内存。
延迟这一项差异也很关键。软解时 CPU 忙不过来,收包线程排队严重,延迟能到一秒以上,做实时业务基本没法接受。硬解之后延迟稳定在一百多毫秒,这里面主要还包含网络传输和缓冲,纯解码延迟其实更低。
6.3 三类调优手段的实际效果
调优我动过三个地方,效果都比较明显。
第一是调整 packet 的送包粒度。最初我一段读 4KB,后来改成按帧边界读取,虽然代码复杂了一点,但解码器收到的数据更规整,帧率抖动小了很多。原因在于按帧送可以避免一个 packet 里混杂半帧数据,减少解码器的等待。
第二是控制日志输出。Python 里每条print都会拿 GIL,8 路同时打日志,光日志就能吃掉可观的 CPU。我把每帧的日志去掉了,改成每秒汇总一次,CPU 占用直接降了十几个百分点。这个和 Python 环境配置、logging 设置都有关,默认的 root logger 在某些配置下开销也不小。
第三是绑定 CPU 亲和性。把解码线程和网络收流线程分到不同的大核上,避免互相抢核,减少上下文切换。用os.sched_setaffinity就能设置,简单有效。
常见问题:如果 VPU 占用一直上不去,帧率也上不来,先别怀疑代码,看看是不是码流本身就不够快,或者收流线程被别的东西卡住了。解码器是“喂多少解多少”,它不会凭空变快。
7. 踩坑记录与排查速查表
最后这一章是干货里的干货,都是我实际趟出来的坑,整理成速查表方便对照。
7.1 花屏、绿屏、首帧异常
绿屏和花屏的成因基本就那几种。绿屏通常是 UV 分量的内存没读对,或者读取时 offset 算错了,NV12 里 UV 紧跟在 Y 后面,偏移量是 width×height,按 stride 算的时候特别容易错。花屏则多半是丢包或者丢帧导致的,尤其是丢了 I 帧,后续整个 GOP 都会花。
首帧异常是另一个常见现象。有些码流第一个送进去的包不是 IDR 帧,解码器拿不到参考帧,就出不来正常画面。解决办法是在开始解码前,先丢弃数据直到遇到第一个 IDR 帧,或者让解码器进入等待关键帧的状态。我在封装里加了一个简单的判断,遇到第一个关键帧才开始正式计数。
| 现象 | 常见原因 | 排查方向 |
|---|---|---|
| 整屏绿 | UV 偏移或 stride 算错 | 检查 NV12 内存布局 |
| 局部花屏 | 丢包、丢 I 帧 | 检查丢帧策略、网络质量 |
| 首帧黑 | 起始不是 IDR 帧 | 丢弃到第一个关键帧 |
| 图像斜切 | 忽略了 stride 对齐 | 按 stride 逐行读取 |
7.2 内存泄漏与 fd 耗尽
长时间跑之后进程内存缓慢上涨,或者报“too many open files”,基本是资源没释放。MppPacket 和 MppFrame 用完必须 deinit,我在单路那节代码里每个 packet 和 frame 后面都跟了 deinit,这不是多余的。一旦漏了,MPP 内部的内存池会被撑爆。
文件描述符耗尽通常来自 DMA 缓冲区的申请没释放。如果你用了 DRM 或者 dma-heap 分配内存,用完要记得关闭对应的 fd。我建议在封装里用上下文管理器(with语句)来管理这些资源,异常路径也能保证释放。
排查工具上,ls /proc/<pid>/fd | wc -l看 fd 数量是不是持续上涨,cat /proc/<pid>/status | grep VmRSS看内存趋势。让程序跑上一两个小时再看,短时间看不出来。
7.3 VPU 占用上不去、帧率上不来
这类问题我遇到过三次,原因各不相同。第一次是送包节奏太慢,本质上是收流侧的问题,不是解码;第二次是线程被 Python 层的锁卡住,追下去发现是日志打得太多;第三次是内存拷贝过多,一帧数据在 Python 里被复制了好几遍。
优化的核心思路是“减少跨语言和跨内存边界的操作”。能用指针引用的,尽量别复制;能放进 C 层做批量处理的,别拿到 Python 层一条条处理。尤其是帧数据的处理,如果只是要看或者送 NPU,可以全程只传地址,避免把整帧搬到 Python 的 bytes 对象里。
到这里,从芯片能力评估、MPP 分层理解、Python 封装选型,到单路打通、8 路并发架构、实测数据和踩坑排查,整条链路基本讲完了。我个人在实际项目里最深的体会是:硬解的难点从来不在“调用那几个 API”,而在内存布局、stride、资源释放和并发节奏这些细节上,这些东西光看文档很难有感觉,动手跑一遍、踩几次坑,才真的记得住。