1. 先弄清楚这套 SDK 想让你怎么组织应用代码
1.1 从“一路 1080p 出流”的最小闭环说起
很多同事拿到 Sigmastar Alkaid SDK 压缩包之后的第一反应是解压、找 sample、改一份 venc 例程、烧进去看有没有码流出来。这种做法能快速获得正反馈,但接下来就会卡住:想加一路视频、想调帧率、想在编码前叠个字,就发现自己完全不知道数据在哪个环节、归谁管、谁负责释放。我个人的习惯是先不碰代码,花半天时间把 SDK 的目录结构和头文件目录浏览一遍,把“一个摄像头从 sensor 出图到网络发出码流”这条最小闭环在脑子里走通,再动手。
这条闭环大致是:sensor 通过 MIPI 送原始 Bayer 数据进来,VIF 负责接收,VPE 做 ISP 处理(去噪、去马赛克、3A、宽动态),然后按需缩放,再交给 VENC 编码成 H.264/H.265 码流,最后由业务层打包推出去。Alkaid SDK 的整套 API 就是围绕这条链路设计的,每一环对应一个 MI 模块,模块之间用绑定关系串起来。你只要能把这条链路在代码里搭出来并能逐级确认“这里有没有帧”,后面所有的花活都是在这条主链上做加法和减法。
这套东西面向的主要是嵌入式音视频开发的人,包括做 IPC 整机的、做电池类低功耗摄像头的、做车载和消费类视觉产品的。C 语言和 Linux 用户态编程是基本功,最好还对 V4L2、DMA、交叉编译这些有概念。没有这些背景也能看下去,我会把绕不开的概念用生活化的方式解释清楚,但指望完全零基础直接上手调通链路是不现实的。
1.2 分层全景:为什么它长成这个样子
我把 Alkaid SDK 粗略分成五层来看:硬件层、内核驱动层、用户态中间件层、统一 API 层、业务应用层。硬件层是 ISP、视频编解码器、缩放器、智能加速单元这些固定功能的模块,它们的特点是一旦流片就改不了,所以必须把尽可能多的重复计算放进硬件。内核驱动层负责寄存器操作、中断处理、DMA 搬运和内存映射,它的存在意义是把“操作硬件”这件危险的事收敛到一个可控的边界内。用户态中间件层是 MI 模块的实现主体,管 buffer、管绑定、管状态机。统一 API 层对外只暴露MI_XXX_前缀的函数和句柄,业务层只认这一层。
这个分法不是随便切的,背后是很实在的取舍。把 ISP 和编码放在硬件里,是因为 1080p@30 的像素吞吐对通用 CPU 来说是灾难,软件跑一帧要几十毫秒,硬件只要几毫秒,而且功耗差一个数量级。把 pipeline 管理和 buffer 调度放在用户态,是因为这部分逻辑变化最频繁——今天要三路,明天要拼接,后天要低延迟,放在内核里每次改动都要重新编译内核、重新验证稳定性,迭代成本高得离谱。统一 API 层的价值在于跨芯片复用,同一套业务代码换个平台只需要改配置、不改逻辑,这对产品线多的团队是刚需。
理解了这三条取舍,你就能预判很多设计。比如为什么 SDK 里到处都是MI_SYS_Bind?因为绑定的本质是让数据在内核态直接在模块间流转,避免用户态反复拷贝。再比如为什么每个模块都要求先 Create 再 Start 最后 Destroy?因为硬件资源是有限的、独占的,必须用显式的状态机来约束使用顺序,防止两个进程抢同一个编码通道。这些“为什么”比 API 手册本身有用得多,手册只告诉你参数怎么填,不告诉你为什么这么设计。
提示:如果你手上同时有多个平台的 SDK,不要试图用同一套心智模型硬套。先确认这个平台的绑定是“模块级绑定”还是“端口级绑定”,是同步还是异步,帧是引用计数还是拷贝传递。这三点决定了你后面代码的写法。
1.3 头文件和库的组织方式透露的信息
打开 SDK 的头文件目录,你会看到按模块切分的.h,命名基本都是mi_模块名.h和mi_模块名_datatype.h两件套。前者是函数声明,后者是结构体、枚举、宏。这种拆法的好处是你写业务代码时只 include 需要的 datatype 头,编译依赖干净。库文件则是libmi_模块名.so加一个总的libmi_sys,链接顺序上 sys 一般要放后面(或者用--start-group包起来),因为模块库依赖 sys 里的符号,顺序错了会报 undefined reference。
我见过不少人在这一步出问题:把 SDK 的库全拷进自己的工程,用 CMake 的target_link_libraries随手一列,结果链接过了、跑起来却在某个MI_SYS_Init里崩。原因往往是同名符号被自己工程里的另一个库抢先解析了,或者动态库版本和头文件不匹配。稳妥的做法是:编译时用 SDK 提供的 sysroot,链接时显式写-L$SDK/lib -lmi_venc -lmi_vpe -lmi_sys,运行时用LD_LIBRARY_PATH指向板子上的库目录,别图省事做静态链接——这些库内部大量依赖硬件节点的 mmap 和 ioctl,静态链接省不了什么,反而会把升级路径堵死。
2. 核心模块拆开看:谁管什么、边界在哪
2.1 MI_SYS 是总线不是库
很多人把 MI_SYS 当成一个初始化函数集合,MI_SYS_Init()调完就忘了它。实际上它是整条数据流的中枢:模块之间的绑定关系由它维护,帧 buffer 的引用计数由它管理,跨模块的帧传递由它调度。你可以把它理解成一个极简的消息总线加内存管理器,所有模块都必须向它注册自己的输入输出端口,才能参与数据流转。
这就解释了几个常见现象。第一,为什么MI_SYS_Init必须最先调用、MI_SYS_Exit必须最后调用?因为其他模块的 Create 会往总线上注册端口,总线不在就没地方挂。第二,为什么解绑的时候要按绑定的逆序来?因为上游可能还在往这个端口送帧,先解下游会让帧卡在半路,引用计数永远不归零,最后表现为内存泄漏。第三,为什么 proc 节点里有大量以模块名分组的信息?那是总线维护的连接表和每个端口的帧统计,排查问题的时候这是第一手资料。
绑定的粒度一般是 port 对 port。比如 VPE 的第 0 通道第 0 端口,绑到 VENC 的第 0 通道。代码上就是填两个结构体描述源和目的,调一次MI_SYS_Bind。这里有个容易忽略的点:源端口和目的端口的数据格式、分辨率、帧率必须匹配,或者至少能被硬件直通。VPE 输出 NV12、VENC 只吃 NV12 和 YUV420SP,你给它一个 RGB 就绑不上,绑定会返回错误码。我建议在绑定前把所有参数打进日志,出问题时一眼能看出是哪一边不匹配。
注意:绑定成功后不要在运行中随意解绑再重绑,尤其是高帧率场景。解绑瞬间在途的帧需要走完整个释放流程,紧接着重绑会让端口状态机处于一个短暂的不确定态,部分平台会直接返回忙。要改链路,正确的做法是停流、解绑、改参数、重绑、再启流。
2.2 VIF、VPE、SCL 的分工不是随便划的
VIF 只干一件事:把 MIPI 上来的数据按配置切成帧,送进内存。它不关心图像长什么样,只管时序、行首、帧首、丢帧统计。VPE 才是真正干活的地方,ISP 的大部分算法在这里,包括黑电平校正、去马赛克、降噪、边缘增强、3A 统计、宽动态合成、色彩校正。SCL 是纯几何变换,缩放、裁剪、旋转。三者分开的原因是流水线化:VIF 可以持续接收,VPE 在处理第 N 帧的时候 VIF 已经在收第 N+1 帧,SCL 又可以在 VPE 输出后立刻开始缩放,各干各的互不等待。
实际配置里最容易出问题的是 VPE 的输出端口数量。中高端平台一般支持多个输出端口,可以对同一路输入做不同的处理,比如端口 0 出 1080p 给编码,端口 1 出 640x360 给智能分析,端口 2 出 JPEG 快照。但端口不是免费的,每多一个端口就多一份 DDR 带宽和内存占用。我做过一个测算,1080p NV12 每帧大约 3.1MB,如果三个端口各缓 4 帧,光这一块就吃掉三十多兆,再加上编码器的参考帧,小内存机型很容易顶到上限。所以端口数量要按实际需求砍,不要“反正支持就都开上”。
配置参数上还有一个反直觉的点:VPE 的降噪强度不是越大越好。强降噪在静态场景下画面干净,但运动物体拖影明显,而且会把细节抹掉,导致后面的智能算法识别率下降。我的经验是按场景分档,室内静态场景可以给中高档,室外车流场景必须降到中低档并适当提高锐度,夜间再配合 3D 降噪的帧数做权衡。这些调整没有标准答案,只能靠实拍对比。
2.3 编码、叠加与智能模块各自的位置
VENC 的职责很清楚,但参数坑最多。码率控制模式一般有 CBR、VBR、FIXQP 三种。CBR 适合网络传输带宽固定的场景,码率平稳但画质随场景复杂度波动;VBR 画质稳定但码率峰值可能翻倍,弱网下容易卡;FIXQP 多用于本地存储或者调试对比。GOP 长度直接影响随机接入延迟和存储检索体验,做 NVR 类产品一般 1 到 2 秒一个 I 帧,做实时对讲要更短。还有码流 buffer 的大小,配小了编码器会丢帧,配大了内存吃紧,经验值是一秒码流量的 1.5 到 2 倍。
叠加这块,SDK 通常提供 RGN 模块或者 VPE 内置的 OSD 能力。区别在于 RGN 是独立图层,支持动态刷新,位置和透明度可调,适合时间戳、车牌框、状态文字;VPE 内置 OSD 一般是静态位图,开销更小。做动态内容时要注意刷新频率,每秒刷新 30 次和每秒刷新 1 次的 CPU 占用差很多,时间戳这种一秒变一次的东西完全没必要跟着帧率刷。我见过有人把时间戳按帧刷新,结果 CPU 占用多出十几个点,纯属浪费。
智能模块 IPU 的输入一般不是原始大图,而是缩放后的小图,比如 320x320 或者 416x416。原因很实际:智能网络的算力和内存带宽都是瓶颈,喂 1080p 进去大部分算力花在了和识别无关的像素上。所以标准做法是从 VPE 或 SCL 引一路小分辨率输出给 IPU,检测框坐标再按比例映射回大图,通过 RGN 叠加显示。这个坐标映射的缩放系数一定要用整数或者高精度浮点算清楚,用整数除法硬截断会导致框整体偏移几个像素,看起来就是“框不准”。
3. 内存与带宽:所有性能问题的根源
3.1 帧缓存的三种来源与选择逻辑
SDK 里的内存大致有三个来源:系统 CMA 预留、模块私有的内存池、以及从 MMA(多媒体内存分配器)申请的池化内存。CMA 是内核启动时就划走的一大块连续物理内存,优点是能出连续物理地址给 DMA 用,缺点是划走了就还不了,划多了系统可用内存少,划少了模块申请失败。私有内存池一般给固定尺寸的模块用,比如编码器的参考帧。MMA 是给多媒体数据用的通用池,支持不同尺寸的块,能复用。
选择逻辑其实很简单:谁能保证物理连续、谁能保证低碎片,就用谁。视频帧必须物理连续(或者至少 IOMMU 映射后对硬件连续),所以只能走 CMA 或 MMA。这里最常见的故障是“跑几个小时后申请内存失败”。原因通常是碎片化或者引用计数泄漏,而不是总量不够。排查方法是看 proc 里各个池的 used/free 和最大可用块大小,如果 free 总量还有几百兆但最大连续块只剩几兆,那就是碎片问题,要么加大池子预留,要么在初始化阶段一次性把大块申请好、运行期只做小块复用。
提示:不要在数据流运行期间反复 Create/Destroy 通道。每次 Create 都会从池里切一块,Destroy 还回去的时候不一定能和邻居合并。长时间跑下来池子就被切碎了。正确的做法是启动时把要用的通道全建好,运行期只做 Start/Stop。
3.2 带宽算清楚,很多“玄学卡顿”就没了
DDR 带宽是嵌入式视频平台最容易被忽略的硬约束。我习惯在方案评审阶段就把带宽账算一遍,公式很朴素:分辨率 × 每像素字节数 × 帧率 × 读写次数。NV12 是 1.5 字节每像素。一路 1080p@30 的 NV12 数据流,单向就是 1920×1080×1.5×30 ≈ 93MB/s。但实际占用远不止这个数,因为一帧数据在整条链路上会被读多次写多次。
| 环节 | 读写次数(近似) | 1080p@30 带宽占用 |
|---|---|---|
| VIF 写入 DDR | 写 1 次 | 约 93 MB/s |
| VPE 读取 + 写回 | 读 1 写 1 | 约 186 MB/s |
| SCL 缩放输出 | 读 1 写 1 | 约 93 MB/s(按输出分辨率折算) |
| VENC 读取 | 读 1 到 2 次 | 约 93 到 186 MB/s |
| IPU 读取小图 | 读 1 次 | 约 5 MB/s(320x320) |
把这些加起来,单路 1080p 全链路轻松超过 400MB/s,还不算 CPU 访问、文件系统、网络收发。很多平台的 DDR 有效带宽也就一两 GB/s,所以做多路方案时,第一件事是砍掉不必要的端口和环节。比如智能分析那一路,如果 VPE 本身支持输出小图,就不要在大图上再挂一个 SCL;比如快照功能,能用编码器的 JPEG 通道就别单独开一路 VPE 端口。
这里补充一句,带宽和延迟不是一回事。带宽够但延迟高,表现是端到端延迟大、按键响应慢;带宽不够,表现是丢帧、编码卡顿、甚至系统卡死。判断方法看 proc 里的丢帧计数和 DDR 占用率曲线,如果丢帧和占用率峰值同步,基本可以确定是带宽瓶颈。
3.3 双系统并存时内存怎么分
这类平台常见的一种形态是主核跑 Linux 承载业务和网络,协核跑 RTOS 承载快启链路。两颗核共享 DDR,但各自有独立的地址空间和内存池。分工上,Linux 侧拿到大部分内存给应用和文件缓存,RTOS 侧只拿固定的一小块,用于 sensor 初始化、ISP 参数、低速编码这些快启必需的东西。
这个设计带来的一个实际影响是:快启链路和 Linux 链路如果同时操作同一个硬件模块,会出问题。典型场景是 RTOS 先把 sensor 和 ISP 配好了,Linux 起来之后业务层又想重新初始化一遍,结果两边打架,画面黑掉或者花屏。正确的做法是约定好交接方式,通常是 RTOS 完成初始化后把硬件状态和关键参数通过共享内存传过去,Linux 侧做“接管”而不是“重配”。SDK 里一般有对应的接口,名字里带 handover 或者 attach 之类的字样,找一找就能找到。
还有一点,双系统之间的通信通道是有超时机制的。我遇到过 RTOS 侧因为某个中断没及时处理,导致握手超时,Linux 侧直接判定快启失败走了冷启动路径,最终表现是“偶尔开机特别慢”。这种问题日志里通常只有一行超时打印,很容易被忽略。建议在调试阶段把这个超时时间临时调大并打印详细握手日志,稳定之后再改回去。
4. 构建体系:目录结构决定了你改代码的效率
4.1 一份典型目录里每个文件夹的用途
SDK 解压后的顶层目录一般长这样:boot、kernel、sdk、project、toolchain、output。boot是引导程序,负责最基础的 DDR 初始化、把内核拉起来,一般不动。kernel是 Linux 内核源码,改驱动、加设备树节点在这里。sdk是用户态的全部代码,包括 MI 模块的实现、中间件、库、以及verify目录下的例程。project是按板级配置组织的,每个板子一个目录,里面是编译配置、分区表、开机脚本、以及需要打包进根文件系统的文件。toolchain是交叉编译器。output是编译产物,整个目录可以随时删掉重来。
理解这个结构的关键是搞明白“我改的东西属于哪一层”。改业务逻辑去sdk/verify或者自己新建目录;改 sensor 驱动去kernel的驱动目录加设备树;改开机自启去project里的启动脚本;改分区大小去project里的分区配置。我见过有人为了改个开机脚本去翻内核源码,找了半天没找到,纯属浪费时间。
sdk/verify这个目录值得单独说,里面通常按模块分了一堆小例程,每个例程只演示一个功能,代码量小、依赖少。我一般把它当“可执行的文档”用:想知道某个 API 怎么调,直接看对应的例程,比翻手册快。但要注意例程为了简洁往往省掉了很多错误处理,直接抄进产品代码是有风险的,尤其是资源释放和异常分支。
4.2 编译流程的每一步在做什么
标准流程是先 source 一个环境脚本,把交叉编译器路径、SDK 根目录、目标平台变量导进环境;然后选配置;然后编译整个工程或者单独编译某个模块;最后打包。
# 进入 SDK 根目录 cd /path/to/alkaid_sdk # 导入环境变量,这一步会把 toolchain 加进 PATH source build/envsetup.sh # 选择板级配置,具体名字看 project 目录下的 config 文件 make alkai d_demo_defconfig # 全量编译,首次编译时间较长 make all -j8 # 只编译用户态 sdk,改了业务代码之后用这个更快 make sdk -j8make all的产物会落在output目录,通常是引导程序镜像、内核镜像、设备树、根文件系统镜像和一个总的烧录镜像。全量第一次编译在普通开发机上按小时算,之后增量编译只改用户态的话几十秒到几分钟。所以日常开发要养成习惯:改内核才做全量,改业务代码只编 sdk。很多人嫌编译慢是因为一直在做全量,其实完全没必要。
这里有个细节值得提醒:-j后面的数字不是越大越好。编译内核和大型 C 工程时,并行度过高会导致内存吃紧,机器开始 swap,反而更慢。经验值是物理核数的 1 到 1.5 倍,16G 内存的机器开到 8 就比较合适。
4.3 加自己的模块怎么不破坏原有构建
最省事的做法是在sdk下新建一个目录,写自己的 Makefile,然后在顶层构建脚本里加一行把它挂进去。但更稳妥的是跟着 SDK 已有的模块模板走:找一个结构相似的模块目录,复制它,改名字和源文件列表,这样编译选项、头文件路径、库输出位置都是现成的,不容易出错。
第三方库的接入要特别注意 ABI。板子上的根文件系统用的是某个版本的 C 库和 C++ 库,你自己编的库如果用了更高版本的工具链,链接时可能能过,运行时却报找不到符号。判断方法是readelf -d看依赖的动态库版本,或者干脆用 SDK 自带的工具链重编。我在项目里立过一条规矩:所有第三方库必须用 SDK 的 toolchain 重新编译,禁止直接拿预编译的二进制文件,这条规矩救过好几次。
如果第三方库只有静态版本,链接时要确保它不依赖 pthread 之外的东西,并且注意静态库里的全局对象构造顺序。有些库在__attribute__((constructor))里做初始化,如果它在MI_SYS_Init之前跑,可能会去访问还没就绪的硬件节点,直接段错误。遇到这种问题,把库的初始化改成显式调用,别依赖构造器。
5. 从零跑通一个最小工程
5.1 开发机环境准备
操作系统建议用 Ubuntu 20.04 LTS,太新的版本某些老工具链会有兼容问题。硬盘至少留 120G,因为内核和 SDK 源码加上编译中间文件很快就能吃掉几十 G。内存 16G 起步,8G 编译时会很痛苦。
依赖包这块,除了常规的 build-essential、bison、flex 之外,还需要 libssl-dev、libncurses5-dev、uuid-dev、cmake、python3、rsync、bc、device-tree-compiler。少一个都可能在中途报错,而且报错信息往往很莫名其妙。我的做法是第一次编译前把常见依赖全装上,宁可多装。
sudo apt-get install -y build-essential bison flex libssl-dev \ libncurses5-dev uuid-dev cmake python3 rsync bc \ device-tree-compiler git wget工具链一般在 SDK 包里自带,不需要单独下载。导入环境变量后which arm-linux-gnueabihf-gcc能查到路径就说明导对了。如果查不到,检查脚本里的路径是不是写死的绝对路径,SDK 目录搬过位置就要改。
5.2 编译、打包、烧录的完整动作
配置选好之后先做一次全量编译,验证环境没问题,别一上来就改代码。编译完成后检查output目录,镜像文件大小是否正常,比如根文件系统镜像如果只有几兆,很可能是打包脚本中途失败了。
烧录方式看板子的启动介质。有 SD 卡的话最简单,把镜像写进卡里插上就能跑;有 USB 烧录模式的,用配套工具连上,选好镜像和分区表,点开始,等进度条走完。烧录工具一般会先校验芯片握手,如果连接失败,检查 USB 线是不是只能充电的那种、驱动装没装、板子是不是真的进了烧录模式(有些板子要按住某个键上电)。
第一次烧录完成后串口看日志很关键。正常启动应该能看到引导程序打印、内核解压、设备树解析、驱动加载、最后到登录提示或者应用启动。如果卡在某一步,就顺着那一步往上查。我个人的经验是,第一次点亮失败十有八九在 DDR 参数、电源时序、或者启动介质选择上,跟应用代码一点关系都没有。
5.3 一个最小出流程序的骨架
跑通例程之后,我建议自己从头写一个最简版本,把链路显式搭一遍,这样每个环节你都知道在干什么。下面这段是骨架,省略了错误处理,实际代码里每一步都要判断返回值。
#include "mi_sys.h" #include "mi_vif.h" #include "mi_vpe.h" #include "mi_venc.h" int main(void) { /* 1. 总线初始化,必须最先 */ MI_SYS_Init(); /* 2. VIF:配置 sensor 输入,绑定到 VPE */ MI_VIF_DevAttr_t vifAttr = {0}; /* 填分辨率、时序、数据格式等 */ MI_VIF_SetDevAttr(0, &vifAttr); MI_VIF_EnableDev(0); /* 3. VPE:配置 ISP 参数和输出端口 */ MI_VPE_ChannelAttr_t vpeAttr = {0}; MI_VPE_CreateChannel(0, &vpeAttr); MI_VPE_StartChannel(0); /* 4. VENC:配置编码格式、分辨率、码率 */ MI_VENC_ChnAttr_t vencAttr = {0}; /* H.264,1080p,CBR,2Mbps,GOP 30 */ MI_VENC_CreateChn(0, &vencAttr); MI_VENC_StartRecvPic(0); /* 5. 把 VPE 端口绑到 VENC,数据开始自动流转 */ MI_SYS_Bind(&vpePort, &vencPort); /* 6. 循环取码流 */ while (running) { MI_VENC_GetStream(0, &stream, 1000); /* 处理码流:写文件、推流、解码显示 */ MI_VENC_ReleaseStream(0, &stream); } /* 7. 逆序拆除 */ MI_SYS_UnBind(&vpePort, &vencPort); MI_VENC_StopRecvPic(0); MI_VENC_DestroyChn(0); MI_VPE_StopChannel(0); MI_VPE_DestroyChannel(0); MI_VIF_DisableDev(0); MI_SYS_Exit(); return 0; }这段代码里最容易被忽略的是第 6 步的ReleaseStream。取到的码流 buffer 是编码器内部的,不还回去编码器就没有可写的 buffer,很快就会 StopRecvPic 保护性停流。排查“编码跑一会儿就不出流了”这类问题,第一步就是看有没有漏掉释放。
第 7 步的拆除顺序必须严格逆序,这是硬要求。先解绑,让数据停止流转;再停编码,把在途帧消化掉;再销毁通道;最后退总线。顺序错了就会出现各种奇怪的资源泄漏或者退出卡死。我建议把拆除写成和初始化完全镜像的函数,成对调用,代码审查时一眼就能看出来配对是否正确。
6. 调试手段与问题排查实录
6.1 日志、proc 节点和串口
串口是这类平台最可靠的调试通道,波特率一般是 115200。内核日志、应用日志都从这出来,不依赖网络、不依赖图形界面。我习惯在开发阶段把日志级别调高,把每个模块的初始化、绑定、帧统计都打出来;量产前再把级别降下去,只保留错误和关键状态。日志级别一般通过环境变量或者配置文件设置,具体看 SDK 的实现。
proc 节点是另一个宝库。模块的连接关系、每个通道的收发帧数、丢帧数、buffer 使用情况,基本都能在 proc 里查到。数据流异常的时候,先看各个模块的收发帧数是否对得上:VPE 发了一万帧,VENC 只收到九千八,中间少的两百帧要么丢在绑定队列里,要么被某个环节主动丢弃了。这样一对比,问题范围立刻缩小到一两个模块。
用户态调试可以用 gdb 远程调试,板子上跑gdbserver,开发机连上去。但要注意断点会冻结线程,而视频流水线对时序敏感,断住几秒钟硬件可能已经超时复位了。所以用 gdb 调试数据流相关代码要克制,更多时候靠日志定位。性能问题用top、perf看,重点看 CPU 占用、内存占用、以及是不是有线程在空转等待。
6.2 常见问题速查表
| 现象 | 常见原因 | 排查动作 |
|---|---|---|
| 出图黑屏但无报错 | sensor 未成功初始化、MIPI 时序不符、镜头盖未取 | 看 sensor 探测日志、读 sensor ID、确认 MIPI 通道数配置 |
| 出图花屏、绿屏 | 数据格式与模块配置不符、DDR 带宽不足、时钟不稳 | 核对 NV12/RAW 配置、拉高降噪前先降分辨率测试、查时钟 |
| 编码跑一段时间自动停 | 码流 buffer 未释放、buffer 太小、带宽超限 | 检查 ReleaseStream 配对、加大码流 buffer、看丢帧计数 |
| 内存申请失败 | 池碎片化、引用计数泄漏、CMA 预留过小 | 看 proc 池信息、确认 Destroy 都执行了、加大预留 |
| 绑定返回错误 | 源目格式/分辨率不匹配、端口已被占用 | 打印双方参数逐项对比、确认端口编号 |
| 双系统握手超时 | 协核任务阻塞、共享内存未同步 | 加大超时并打详细日志、检查协核中断处理 |
| 开机偶尔很慢 | 快启路径失败回退冷启动、文件系统挂载重试 | 看引导日志时间戳、确认启动介质稳定 |
| 网络推流卡顿但本地录像正常 | 网络带宽或 socket 缓冲问题,非编码问题 | 对比本地和网络两路的码流时间戳,排查发送端 |
6.3 几个不那么容易想到的坑
第一个坑是时间戳。多路视频的帧时间戳基准必须统一,否则同步显示、录像检索、智能分析都会出问题。我遇到过一次,两路摄像头的时间戳分别由各自的硬件计数器产生,跑久了差了几十毫秒,拼接画面上人走路是重影的。解决办法是统一使用系统单调时钟作为基准,在绑定前把各路的帧时间戳校正一次。
第二个坑是时钟频率的动态调整。为了省电,有些平台会根据负载动态调 CPU 和总线频率。这在纯计算场景没问题,但在视频流水线里会导致偶发丢帧,因为降频瞬间带宽跟不上。如果产品对稳定性要求高,建议把相关频率锁在固定档位,功耗多一点但问题少很多。
第三个坑是温度。长时间高负载运行,芯片温度上来之后可能触发降频保护,表现是跑几小时后开始丢帧、帧率不稳。做整机的时候散热一定要在方案阶段就评估,别等到样机出来才发现。我看过一个结构,芯片贴着外壳但中间隔了一层厚厚的导热垫,实测温度比预期高了二十度。
第四个坑是文件系统的写入策略。码流写到 eMMC 或者 SD 卡,如果没有做缓冲和批量写,可能出现写入抖动影响实时性。录像这类场景建议用大块顺序写,避免频繁的小文件打开关闭,同时注意卡的寿命,长时间循环录制要考虑坏块管理。
7. 我在这套 SDK 上积累的几条实用经验
第一条,永远先确认“硬件能不能出图”,再谈上层功能。拿到新板子的第一件事是把 sensor 单独打通,用最简单的采集程序存几帧原始图出来看。这一步过了,后面的问题都在软件层;这一步没过,后面做什么都是白费。很多团队跳过这步直接上业务,结果排查问题时无法区分是硬件问题还是软件问题,时间全浪费在互相甩锅上。
第二条,把配置都写成可外部覆盖的参数,别硬编码。分辨率、帧率、码率、GOP、降噪强度这些东西,产品化过程中一定会反复调。如果全写在代码里,每改一次都要重新编译烧录,效率极低。我一般在启动时从一个文本配置或者环境变量里读,调试阶段改配置重启就行,一分钟能看到效果。
第三条,链路越短越稳。每多一个模块、多一次缩放、多一次拷贝,就多一个出错点,多一份带宽。做方案的时候先问一句“这一环能不能砍掉”。智能分析需要小图,能不能直接从 VPE 的第二个端口出,而不是在大图上再挂一级缩放?快照需要 JPEG,能不能用编码器的 JPEG 通道,而不是再开一路?砍掉的东西不会出问题。
第四条,留一个自检接口。产品出厂前要做老化测试,我习惯在应用里做一个自检模式:启动后自动跑一遍完整链路,检查每个模块的帧计数是否连续、时间戳是否单调、buffer 是否泄漏,跑够时间后输出一份报告。这个东西平时看着没用,出问题的时候是救命稻草,能快速定位是哪一批板子、哪个环节有问题。
这套 SDK 的架构本质上是把复杂度和不确定性尽量往硬件和内核里推,把灵活性和迭代速度留给用户态。理解了这个取向,很多看起来奇怪的设计就都能解释了:为什么绑定要显式、为什么资源要手动释放、为什么配置项那么多。它不是设计得不好,而是把该给的自由都给了,代价是你得自己管好边界。
最后分享一个小技巧:调试数据流的时候,在每一级绑定的前后各加一个帧计数打印,用固定间隔(比如每五秒)打印一次差值。正常链路各级差值应该基本一致,某个环节差值突然变大或者变小,问题就在那里。这个方法非常朴素,但在实际项目里解决过的问题比任何高级工具都多。