1. 先搞清楚这套框架的分层与数据流
做嵌入式视频这块,MPP(Media Processing Platform)这个名字绕不开。它本质上是一套跑在视频处理SoC上的多媒体软件框架,把芯片里那些硬核的编解码器、显示控制器、图像处理单元全部抽象成统一的API,让你在应用层用C代码就能把一整条视频链路串起来。我接触过的项目里,从行车记录仪、工业相机到自助终端的屏显方案,只要涉及"解码一路码流、再送到屏幕上显示"这种需求,基本都要在MPP的VO(Video Output)和VDEC(Video Decoder)这两个模块上折腾很久。
这篇想聊的就是这两块。VO负责把图像数据按正确的时序推到显示接口上,VDEC负责把H.264/H.265码流解成YUV原始帧。单独看每个模块的文档都不复杂,真正折磨人的是两者对接起来之后的那一堆细节:时间戳对不上、帧率不匹配导致缓冲堆积、颜色发绿、画面撕裂、内存池开小了直接解码超时。我踩过的坑里,至少有一半不是因为API用错,而是因为没想明白数据在这条链路上是怎么流动的。
适合谁来读?如果你已经能跑通MPP的Hello World,能创建通道、能点亮屏幕,但一到实际项目就各种异常,那这篇正好对得上。如果你是刚接手这块代码的新人,建议先把系统初始化和VB(Video Buffer)内存池那部分看懂,剩下的可以边调试边回来看。下面所有的代码片段都是示意性的骨架,参数值需要按你手上的芯片型号和屏参去核对,这一点务必记牢。
1.1 从应用到硬件的四层结构
MPP的分层其实很直白,从上往下数:应用层调MPP API,MPP API往下走是内核态的驱动层,驱动层再往下是芯片里的硬件单元。应用层看到的是HI_MPI_VO_xxx、HI_MPI_VDEC_xxx这样一组函数,驱动层负责把命令翻译成寄存器操作,硬件单元则各司其职——解码器有解码器的DMA,显示控制器有显示控制器的扫描输出逻辑。
这个分层带来的第一个认知是:API调用返回成功,不代表硬件已经干完活了。比如你调HI_MPI_VDEC_SendStream返回0,只说明码流已经进了驱动的输入队列,解码器可能还在处理上一帧。同理,HI_MPI_VO_EnableChn返回成功只代表通道使能了,屏幕上的第一帧什么时候出来,取决于你什么时候把数据喂进去。很多新手在这里翻车,写完初始化代码就死等画面出现,等不到就以为接口调错了,实际上是数据源根本没启动。
第二个认知是:层与层之间的数据传递是有副本和引用之分的。视频这种数据量,1080p一帧YUV420就是3MB出头,4K直接翻四倍,谁都不会傻到在层间来回拷贝。所以MPP里大量使用物理地址和句柄来传递帧,你在应用层拿到的是一个VIDEO_FRAME_INFO_S结构,里面挂着物理地址、虚拟地址、 stride、宽高这些信息。理解这一点,后面调内存、调绑定才不会一头雾水。
第三个认知比较隐蔽:硬件单元之间可以绕过CPU直接对接。这就是所谓的"绑定"(Bind)机制。你把VDEC的某个通道输出绑到VO的某个通道输入,解码出来的帧会由硬件或者驱动内部直接送到显示通道,完全不经过你的应用层。好处是省CPU、省内存带宽、延迟低;坏处是中间过程你插不上手,出问题的时候只能靠日志和状态查询去猜。
1.2 为什么VO和VDEC要放在一起讲
单独的VO文章通常会教你点屏:配置时序、开视频层、开通道、灌一张测试图。单独的VDEC文章通常会教你解码:建通道、送码流、取帧、存文件。这两件事分开做都能跑通,但一旦把它们串成"解码上屏",问题就集中爆发了。
原因在于两者的节奏天生不一样。解码器的输出节奏由码流决定,一个GOP里I帧解得快、P帧要看参考帧、B帧还要重排序,输出时间戳可能是跳变的;而显示器的节奏是死的,60Hz就是每16.67毫秒扫一帧,一帧都不能少,也一帧都不能多。这两个节奏对不上,就会出现三种典型症状:解码快了显示跟不上,帧在缓冲里堆积,延迟越滚越大;解码慢了显示等不到数据,画面卡顿或者重复上一帧;两者抖动都很厉害,画面就开始撕裂。
所以真正要解决的问题不是"怎么调API",而是"怎么让两个不同节奏的模块平稳协作"。这就涉及到帧缓冲队列的深度、丢帧策略、时间戳对齐、双缓冲切换时机这些设计层面的东西。我后面会花大篇幅讲这些,因为这才是实际项目里真正耗时间的部分。
2. VO:显示时序与图层配置的关键细节
VO这块,最容易让人困惑的是它的三级模型:设备(Dev)、视频层(Video Layer)、通道(Chn)。文档上写得很清楚,但第一次看还是会懵,不知道什么时候该用哪一级。
我的理解方式是这样的:设备对应一个物理显示接口,比如一路HDMI、一路MIPI DSI、一路BT1120并口,你有几个屏幕就开几个设备。视频层对应一块画布,它的尺寸就是实际输出的分辨率,比如1920x1080。通道对应画布上的一个显示区域,你可以在同一块画布上开多个通道,每个通道显示不同的内容,位置和大小各自配置,还可以控制谁盖在谁上面。
这个模型的好处是灵活,坏处是配置项多。只用一路全屏显示的话,一个设备加一个视频层加一个通道就够了,配置路径很短;但要做画中画、要做多路拼接、要做OSD叠加,就必须把三级的属性都理清楚。
2.1 设备、视频层、通道三级模型的区别
先看设备。设备的属性里最关键的是接口类型和时序。接口类型决定了你用哪种物理连接方式,时序决定了行场同步信号的极性、前后肩的长度、像素时钟频率。这两样配错,屏幕要么不亮,要么亮起来但图像偏移、位置不对、闪烁。
配置设备有一套固定的动作序列:先SetPubAttr设定公共属性,然后Enable使能设备,时序相关的部分如果芯片支持单独设置时钟,还要把时钟源选对。这里有个细节经常被忽略——设备使能之前必须先初始化系统,也就是HI_MPI_SYS_Init,否则底层时钟和中断都没注册好,使能会返回失败。我见过有人把这句写在设备初始化之后,调试了半天以为是时序参数的问题。
再看视频层。视频层的属性主要是画布尺寸、像素格式、显示位置。画布尺寸一般等于输出分辨率,像素格式常见的是YUV420的半平面(semi-planar)格式,也就是Y分量一块、UV交错一块。这里要特别注意stride的概念:画布宽度是1920,但stride可能是1920也可能是2048,取决于硬件对内存对齐的要求。如果你的数据源stride和画布stride不一致,画面就会出现斜条纹——这是非常经典的一个坑,我在两个项目里都遇到过。
最后是通道。通道的属性包括显示区域(矩形)、优先级、是否立刻显示。显示区域的坐标是相对画布左上角的,宽高可以小于画布尺寸,也可以超出(超出部分会被裁剪)。优先级决定叠加顺序,值大的盖在上面。还有一个容易忽略的点:通道可以配置帧率控制,也就是FrameRate,如果你把一路25帧的源送进一个配置成60帧的通道,硬件会做重复帧插值;反之如果源是60帧而通道配成30帧,就会丢帧。这个参数配错,画面看起来就是"一顿一顿"的。
2.2 显示时序参数的选取与计算
时序参数是VO里最硬核的部分,也是我最不想凭感觉乱填的部分。以常见的1080p60为例,一套标准的时序大致长这样:有效像素1920,有效行数1080;行同步前肩(HFP)、行同步后肩(HBP)、行同步宽度(HSW)三个参数加起来构成水平消隐区;场同步前肩(VFP)、场同步后肩(VBP)、场同步宽度(VSW)构成垂直消隐区。
像素时钟的计算公式是:
pixel_clock = (HTotal * VTotal * frame_rate) // 其中 HTotal = HActive + HFP + HBP + HSW // VTotal = VActive + VFP + VBP + VSW拿1080p60的一组典型值算一下:HTotal = 1920 + 88 + 148 + 44 = 2200,VTotal = 1080 + 4 + 36 + 5 = 1125,那么像素时钟 = 2200 × 1125 × 60 ≈ 148.5 MHz。这个数字如果和屏体规格书上的标称值对不上,说明你的消隐参数抄错了,或者帧率不是严格的60。
为什么要算这个?因为很多平台的时钟配置是一个枚举值列表,你得挑最接近的那个,而不是随便填个数字。挑错了几十KHz,短时间内可能看不出来,但长时间运行会累积成帧同步漂移,表现是画面每隔几分钟轻微抖一下。工业场景下这种抖动是要命的,因为上位机做图像分析的时候会误判。
注意:时序参数一定以屏体或接收端的规格书为准,不要抄别人的工程配置。不同批次的屏、不同厂商的驱动板,消隐参数可能都不一样,抄错了排查成本极高。
还有一个实际问题:部分平台的VO支持从时序参数自动反推时钟,部分不支持。不支持的平台你必须自己把时钟配准,然后靠试。我一般的做法是先把HTotal/VTotal调到和规格书完全一致,然后微调帧率到实测稳定为止,用示波器量一下场同步信号的实际周期来验证。
2.3 帧缓冲与抗撕裂的实际做法
撕裂(tearing)这个现象的成因很简单:显示控制器正在扫描第N帧的时候,你把它正在读的那块缓冲区给改了,于是屏幕上半部分是旧帧、下半部分是新帧,接缝处就是一条明显的水平错位。
解决办法无非两种:双缓冲/多缓冲切换,或者垂直同步(VSync)等待。
多缓冲的思路是准备两块或三块缓冲区,写入的时候写空闲的那块,写完了在下一个垂直消隐期把显示指针切过去。理想的切换时机是场消隐期间,这时候扫描已经结束,切换不会造成任何视觉上的断层。MPP里一般通过"刷新"类的接口配合帧管理来实现,具体做法是先GetFrame拿到一块可写的缓冲,填好数据后Refresh推送上去,硬件在合适时机切换。
VSync等待则更直接:在提交新帧之前,先等一个垂直同步信号。很多平台提供HI_MPI_VO_XXX里带等待语义的接口,或者提供一个查询当前扫描位置的接口,你轮询到接近场消隐的时候再提交。这种做法的缺点是会阻塞提交线程,如果帧率本来就低于显示刷新率,阻塞时间会很长,影响整体吞吐。
我的实际选择是:显示链路优先用绑定+硬件帧管理,让硬件自己处理切换;只有在必须由应用层直接往VO通道灌数据的时候,才手动做双缓冲。前者省心,后者可控。混合使用的情况也有,比如解码主画面走绑定,OSD或者实时叠加的统计信息走手动刷新,这时候就要注意把OSD通道的刷新频率降下来(比如1秒一次),不要跟着视频帧率走,否则CPU扛不住。
还有一个隐蔽的撕裂来源:视频层和通道的尺寸不一致触发了硬件缩放。缩放本身没问题,但如果缩放算法是线性的、且输入输出帧率不同步,就可能出现半帧延迟导致的错位。排查方法很简单,把源分辨率改成和通道显示区域完全一致,如果撕裂消失,那就是缩放链路的问题,回头去查时钟和帧率配置。
2.4 OSD叠加与缩放通道
OSD(On-Screen Display)在VO里通常有两种实现路径。一种是用硬件提供的图形层,也就是独立于视频层的另一个图层,专门放ARGB或者CLUT格式的位图,硬件直接做alpha混合。另一种是把OSD画进视频帧的缓冲区,也就是软件叠加,解码出来的YUV帧你先在内存里把文字和图标画上去,再送显示。
硬件OSD的优点是省CPU、混合质量好、不占用视频通道带宽,缺点是需要平台支持、格式限制多、动态更新有开销。软件OSD的优点是自由度高、想怎么画怎么画,缺点是要做YUV色彩空间的绘制,文字边缘会有锯齿,而且每帧都要重画一遍。
我的经验是:静态不变的UI元素(边框、logo、标题栏)用硬件OSD,动态变化的数值(帧率、时间戳、进度条)也优先用硬件OSD但降低刷新频率;只有复杂图形或者需要和视频内容做像素级交互的时候,才走软件叠加。中间还有一个折中方案,就是把动态内容做在一个小尺寸的ARGB图层上,只更新这块小图层的区域,硬件混合的时候开销很小。
缩放通道这块,要注意的是缩放能力和通道数量是有硬件上限的。有些平台一个视频层只支持有限个缩放通道,超出之后要么报错,要么退化成不支持缩放。配置之前最好查一下能力集,或者在初始化失败的时候打印错误码,别硬着头皮往下走。
3. VDEC:码流喂入与回帧的完整链路
VDEC的模型比VO简单:建通道、送码流、取帧。但"简单"只是接口层面,实际运行时的问题几乎全在节奏控制和内存管理上。
解码通道创建的时候要指定编码类型(H.264/H.265/JPEG/MJPEG)、分辨率上限、输出像素格式、参考帧数量、码流缓冲区大小。这几个参数没有一个是能随便填的。分辨率上限填小了,遇到大分辨率码流直接报错;参考帧数量填少了,高压缩比码流的解码质量会下降甚至报错;码流缓冲区小了,遇到码率突发的码流会丢包。
3.1 解码通道的创建与参数协商
编码类型好理解,按码流实际格式填。这里有个坑:H.264和H.265在同样的分辨率下,解码器需要的内部缓存不一样,H.265通常更大。如果你的板子上DDR容量紧,又要在4K下解H.265,就必须提前算好内存账,别指望默认参数能跑通。
分辨率上限的填法有两种思路。一种是精确填实际分辨率,省内存;一种是按最大可能分辨率填,留余量。我一般倾向后者,因为实际项目里码流来源经常变,今天1080p明天可能就切到4K了,通道反复销毁重建的开销和风险都不小。但如果内存真的很紧,那就老老实实按实际值填,并且在切换码流之前先查一遍参数,不匹配就重建通道。
输出像素格式一般选YUV420的semi-planar,和VO的格式对齐,这样绑定时不用做格式转换。如果VO那边只吃NV12而解码输出是NV21,绑定时可能会有额外的转换开销,甚至直接不支持。这种细节在联调阶段最容易浪费时间,建议一开始就把两边的格式统一。
参考帧数量这个参数,文档上通常给一个默认值和取值范围。别急着用默认值,因为默认值往往是按通用场景设的,你的码流如果是低延迟监控流(没有B帧、GOP短),参考帧需求就小;如果是蓝光级别的电影流,参考帧需求就大。填小了会出现解码错误或者花屏,填大了浪费内存。我的做法是先用默认值跑,跑通了再根据内存占用往下压,压到出现错误的上一档停手。
码流缓冲区大小需要按码率估算。假设码率是8Mbps,缓冲区要能容纳至少几百毫秒的数据来应对突发,那就是8Mbps × 0.3s ÷ 8 ≈ 300KB,实际配置一般会放大到1MB左右留余量。太小了送码流会阻塞,太大了浪费内存。
3.2 SendStream的发送节奏与背压处理
HI_MPI_VDEC_SendStream这个接口的行为,很多人理解得不对。它把码流送进驱动的输入队列,返回成功只代表入队成功,不代表解码完成。队列是有深度的,深度满了之后这个接口会阻塞或者返回"缓冲满"的错误码(取决于你用的超时参数)。
这就引出了背压(backpressure)的概念。如果你的码流来源是网络或者文件,读取速度远快于解码速度,那么很快就会把队列填满。正确做法是在送码流之前先看队列水位,或者干脆用"送一帧、取一帧"的同步模式。我见过的最糟糕的写法是在一个紧密循环里不停SendStream,判断返回值是失败就sleep 1毫秒继续,结果CPU占满、解码反而更慢。
更合理的结构是生产者-消费者模型:一个线程负责从源头读码流、解析NAL单元、打时间戳,然后送进队列;主线程负责取解码帧、上屏或者存文件。两个线程之间用一个有限深度的环形缓冲解耦,缓冲满了就让生产者等,空了就让消费者等。这样两边各跑各的节奏,互相不会拖累。
时间戳这块也要提一句。如果码流里带PTS,送码流的时候要一并传下去,取帧的时候能拿到对应的PTS,做音视频同步或者自定义的显示节奏控制都靠它。如果码流里没有PTS,就得自己按帧率推算,这时候要注意可变帧率的情况——监控流经常是可变帧率的,按固定帧率推算会越来越偏。
3.3 回帧方式:GetFrame与GetUserFrame的取舍
取解码帧的接口一般有两个:一个是从内部帧池里拿一帧(拿到的帧带引用计数,用完必须释放),另一个是从用户自己的缓冲里拿(数据直接拷进你提供的内存)。
内部帧池的方式效率高,零拷贝,但必须严格配对释放。拿了不还,帧池很快耗尽,解码器就会卡住等缓冲。这个错误在异常退出的分支里特别容易出现——解码出错直接return,忘了把已经拿到的帧还回去,跑几分钟就死。我的习惯是把取帧和释放写成成对的宏或者封装的函数,配合goto cleanup的错误处理结构,从代码结构上杜绝漏释放。
用户缓冲的方式拷贝开销大,但内存完全可控,适合那种"解码一帧处理完就扔掉"的流水线。1080p一帧的拷贝大概几毫秒,帧率不高的时候可以接受,4K高帧率就别用了。
还有一个细节:拿到的帧是只读的还是可写的。如果绑定了VO,帧的所有权在硬件手里,你只能读不能改;如果要改(比如叠加OSD),就得用可写的方式取,改完再还回去或者推给显示。这个所有权语义每个平台实现略有差异,一定要在文档里确认清楚,不要凭经验假设。
3.4 参考帧与码流缓冲的内存账
算内存这件事,很多项目栽在这上面。一块典型的板子DDR可能是512MB或者1GB,操作系统和应用程序占掉一部分,剩下的给多媒体。解码器要的内存包括:码流缓冲、参考帧缓存、输出帧缓存、内部工作缓冲。
粗略估算一下1080p H.265的情况:一帧YUV420大概3.1MB,参考帧按4帧算就是12.4MB,输出帧如果走绑定,VO那边还要几帧缓冲,加上码流缓冲1MB、内部工作缓冲若干MB,一路解码加上屏大概要30MB到50MB。四路就是150MB到200MB。这个数字在512MB的板子上还算能接受,但如果你同时还要跑图像识别、还要开网络服务,就紧张了。
更麻烦的是VB内存池是预分配的。MPP体系里通常要在初始化阶段配置好若干个不同大小的内存块,后续所有模块都从这个池子里取。池子配小了,运行到一半报"分配失败";配大了,其他模块没得用。我一般的做法是按照"最坏情况"算总账,然后留20%的余量,同时打开内存统计接口,在压测阶段观察峰值占用,再反过来调整。
提示:调试阶段一定要把内存统计打出来,包括每个池子的总块数、空闲块数、峰值占用。这些数据比任何日志都直观,能省掉大量猜测。
4. 绑定时序链路与完整实操
到这一步,前面的模块都能单独跑通了,接下来是把它们串起来。绑定机制的思路前面提过:让硬件之间直接对接,数据不经过应用层。好处是延迟低、CPU占用少,坏处是灵活性差、出错难查。
4.1 系统初始化与VB内存池配置
整个流程的第一步永远是初始化。标准的启动顺序大概是:先配置VB池,再HI_MPI_SYS_Init,然后按需初始化各个模块(VO、VDEC、VPSS等),最后创建通道、建立绑定、启动业务。
为什么VB要最先配?因为很多模块在创建通道的时候就会从池子里申请内存,池子没配好,创建直接失败。VB配置的核心是描述一组内存块:每块多大、有多少块。常见的配法是有几种规格——大块给4K帧、中块给1080p帧、小块给音频或者小图像。块大小要按实际帧大小加上头信息和对齐开销来算,不能刚刚好。
举个例子,1080p NV12一帧是1920×1080×1.5 = 3110400字节,但如果硬件要求stride对齐到256,实际一行是2048字节,那么一帧就是2048×1080×1.5 = 3317760字节。差了6%多,按前者配就会在边界情况下分配失败。这种小账一定要算清楚。
// VB池配置示意,块大小需按stride对齐后的实际值填写 VB_CONF_S stVbConf; memset(&stVbConf, 0, sizeof(stVbConf)); stVbConf.u32MaxPoolCnt = 2; stVbConf.astCommPool[0].u32BlkSize = 2048 * 1080 * 3 / 2; // 1080p NV12 stVbConf.astCommPool[0].u32BlkCnt = 12; stVbConf.astCommPool[1].u32BlkSize = 1024 * 512 * 2; // OSD ARGB1555 stVbConf.astCommPool[1].u32BlkCnt = 4; HI_MPI_VB_SetConf(&stVbConf); HI_MPI_VB_Init(); HI_MPI_SYS_Init();块数量的估算也有讲究。VO显示一般要2到3帧,VDEC输出如果走绑定可能要多留几帧给队列,加上应用层可能的其他用途,12块是个比较稳妥的起点。压测下来如果从没出现"空闲块为0"的告警,可以往下减。
4.2 VDECHN到VO的绑定过程
绑定的接口一般是HI_MPI_SYS_Bind,参数是源模块ID、源通道号、目的模块ID、目的通道号。看起来简单,但绑定的时机和数据格式匹配是两个容易出错的地方。
时机上,通常要求两端都已经创建并使能,才能建立绑定。有的平台允许先绑后建,有的不允许,这个要查文档。我习惯的做法是先把VO的通道打开、把VDEC的通道建好,确认两边单独都能工作,再执行绑定,绑完之后立刻查询一次绑定状态确认成功。
格式匹配上,前面强调过:VDEC的输出格式必须和VO通道期望的输入格式一致。如果平台支持硬件转换就没问题,不支持就得在中间插一个VPSS(视频处理子系统)做格式和分辨率的适配。多插一级会多几毫秒延迟和多一点内存,但换来的是灵活性,可以做缩放、可以做格式转换、可以做多路复用。
// 绑定示意 MPP_CHN_S stSrc, stDst; stSrc.enModId = HI_ID_VDEC; stSrc.s32DevId = 0; stSrc.s32ChnId = 0; stDst.enModId = HI_ID_VO; stDst.s32DevId = 0; stDst.s32ChnId = 1; s32Ret = HI_MPI_SYS_Bind(&stSrc, &stDst); if (s32Ret != HI_SUCCESS) { printf("bind failed: 0x%x\n", s32Ret); }绑定成功之后,整个数据流就自动跑起来了:码流送进VDEC,解出来的帧自动进VO的通道队列,显示控制器按自己的节奏取帧上屏。这中间你不需要任何干预,唯一要操心的是送码流的节奏和队列水位。
4.3 完整代码骨架与启动顺序
把前面的东西串成一个可运行的骨架,大致长这样:
int main(void) { // 1. 配置并初始化VB if (InitVbPool() != 0) return -1; // 2. 系统初始化 HI_MPI_SYS_Init(); // 3. VO初始化:设时序、开设备、配视频层、开通道 if (InitVo(0) != 0) return -1; // 4. VDEC初始化:建通道 if (InitVdec(0) != 0) return -1; // 5. 绑定 if (BindVdecToVo(0, 0) != 0) return -1; // 6. 启动送码流线程 pthread_create(&tid, NULL, StreamThread, NULL); // 7. 主循环:监控状态、处理信号 while (g_running) { sleep(1); PrintStats(); } // 8. 有序退出 g_running = 0; pthread_join(tid, NULL); HI_MPI_SYS_UnBind(&stSrc, &stDst); HI_MPI_VDEC_DestroyChn(0, 0); HI_MPI_VO_DisableChn(0, 1); HI_MPI_VO_DisableVideoLayer(0, 0); HI_MPI_VO_Disable(0); HI_MPI_SYS_Exit(); HI_MPI_VB_Exit(); return 0; }这个顺序不是随便排的。初始化必须从底层往上,退出必须从上往下,逆序释放。反了就会出问题:比如先退出SYS再销毁通道,通道销毁时访问已释放的资源,行为不可预期;比如先Exit VB再销毁通道,通道内部释放内存的时候直接崩。
我遇到过一次特别典型的翻车:退出流程没写全,程序Ctrl+C退出后,再启动就报VB初始化失败。原因是上一次的VB没释放,物理内存还被占着。这种问题在开发阶段不明显,一到现场部署就是"重启才能用"。所以退出流程一定要写完整,并且用信号处理函数兜住Ctrl+C。
4.4 退出与资源回收的坑
说到退出,这块值得单独拎出来讲,因为它是最容易被忽略、也最容易埋雷的地方。
第一,送码流线程必须能被打断。如果线程阻塞在SendStream上(队列满),你设了个标志位让它退出,它根本看不到标志位。解决办法是用带超时的发送接口,超时后检查标志位,或者用条件变量唤醒。
第二,所有取出来的帧必须还回去。退出前遍历一遍,把没释放的帧释放掉。这个错误在小流量测试时看不出来,压测几小时才爆发。
第三,通道销毁前先解绑。绑定的两端存在依赖关系,直接销毁一端,另一端可能还挂着一个失效的句柄。虽然多数平台做了保护,但保险起见还是按顺序来。
第四,VB退出前确认所有模块都停干净了。有个技巧是先调一次HI_MPI_VB_Exit,看返回码;如果失败说明还有模块占着内存,这时候可以遍历查询各模块状态定位是谁没退。
5. 常见问题排查实录
这一节是我这些年攒下来的排查笔记,按症状归类,遇到问题可以直接对号入座。
5.1 黑屏、花屏、绿屏的定位路径
黑屏是最常见也最好排的。先确认三件事:设备使能了没有、时序对不对、有没有数据送进来。我的顺序是先灌一张纯色测试图到VO通道,如果有图,说明VO链路没问题,问题在VDEC或者绑定;如果没图,那就是VO本身的问题,往时序和时钟上查。
灌测试图这个手段非常好用,很多平台提供直接填充颜色的接口,或者你手动申请一块帧缓冲填满Y值再刷新上去。这一步能把问题域缩小一半。
花屏通常是解码环节的问题,可能的根因有几个:码流缓冲区太小导致丢包、参考帧数量不够、码流本身损坏、内存越界。排查方法是把解出来的帧存成文件,用PC端的工具验证,看是原始解码就错了,还是显示环节错了。如果存出来的文件正常,那问题就在VO或者绑定;如果文件本身就花,那就是VDEC这边。
绿屏这个最典型,八成是像素格式不匹配。YUV数据被当成RGB解释,或者UV分量的顺序搞反了(NV12当成NV21),画面就会偏绿或者偏紫。排查方法是找一块纯色区域,量一下实际输出值和期望值,反推格式。还有一个可能是内存没有清零,新申请的缓冲区里是脏数据,显示出来就是随机颜色,这种在画面边缘特别明显。
5.2 卡顿、撕裂、延迟的根本原因
卡顿表现为画面一顿一顿。先测帧率,如果解码帧率低于预期,看是不是码流本身帧率就低,或者是不是送码流的线程被阻塞了。如果解码帧率正常但显示卡,那就在VO侧,看通道的帧率配置、看帧缓冲是不是频繁在等。
还有一种隐蔽的卡顿:周期性卡一下,间隔固定。这种往往是内存或者队列的边界效应,比如队列满了等一次、或者垃圾回收触发一次。用时间戳打点,把每一帧的进出时间都记下来,画出间隔分布,一眼就能看出规律。
撕裂前面讲过了,核心是切换时机。补一句:如果撕裂只在画面运动剧烈时出现,那多半是帧间差分大导致视觉上更明显,根因还是切换时机不对,不要被表象误导。
延迟这个最麻烦,因为它是累积的。整条链路的延迟包括:采集/网络接收缓冲、解码器内部缓冲、解码输出队列、VO通道队列、显示缓冲。每一级都可能藏几百毫秒。定位方法是在码流里打时间戳,在显示的帧上叠一个当前时间的OSD,两者的差值就是端到端延迟。然后逐级减少缓冲深度,找到延迟和流畅度的平衡点。
我的经验值是:监控类应用延迟控制在200毫秒以内比较舒服,交互类应用要压到100毫秒以内,再低就得考虑绕开某些缓冲环节了。缓冲深度不是越大越好,很多人一看卡顿就加大队列,结果延迟飙到一两秒,体验反而更差。
5.3 问题速查表
| 症状 | 高概率原因 | 快速验证方法 |
|---|---|---|
| 完全黑屏 | 设备未使能 / 时钟未配 | 灌纯色测试图,看是否有输出 |
| 图像偏移、超出屏幕 | 时序消隐参数错误 | 用示波器量行场同步周期 |
| 斜条纹 | 源stride与画布stride不一致 | 打印两边stride值对比 |
| 画面偏绿/偏紫 | YUV格式或UV顺序不匹配 | 输出纯色块,测量像素值 |
| 画面撕裂 | 缓冲切换不在消隐期 | 降低帧率观察是否消失 |
| 解码报错、超时 | VB内存不足 / 参考帧数不够 | 打印VB池空闲块数 |
| 运行一段时间后卡死 | 取帧未释放,帧池耗尽 | 统计取帧与释放的次数差 |
| 启动第二次失败 | 上次退出未释放VB | 检查退出流程完整性 |
| 帧率不稳、忽快忽慢 | 送码流节奏未做背压 | 打印输入队列水位曲线 |
5.4 几条不太好写进文档的经验
第一,先让链路以最低配置跑通,再逐项加功能。我见过太多人一上来就把多路、缩放、OSD、录像全打开,出问题之后根本不知道是哪一环。正确的做法是先一路解码直通显示,跑稳了再加OSD,再加第二路,每加一项都回归测试一次。
第二,日志要打关键状态,不要打流水账。每一帧都打一行日志,跑起来之后日志本身就成了性能瓶颈。我的做法是每秒打一行汇总:解码帧数、显示帧数、丢帧数、VB空闲块、队列水位。这几个数字足够定位90%的问题。
第三,压测一定要做,而且要长时间。短时间跑不出内存泄漏和队列累积的问题。我一般的标准是连续跑24小时,中途人为制造网络抖动、码流切换、分辨率变化,看系统能不能自恢复。
第四,不同批次的硬件可能行为不一样。同样的代码在A批板子上跑得好,B批就出问题,这种事在嵌入式里太常见了。所以关键参数最好做成可配置的,出问题的时候能现场调整,而不是重新编译烧录。
6. 内存与性能调优的一点实操体会
最后聊点调优的东西。这条链路跑通之后,接下来要面对的就是"能不能更省、能不能更快"。
CPU占用方面,走绑定链路的话应用层几乎是零负担,主要开销在送码流线程。如果发现CPU偏高,先看送码流的循环里有没有忙等,再看有没有不必要的内存拷贝。我遇到过一版代码,为了"安全"每次都把码流拷一份再送,白白多了一次几MB的拷贝,去掉之后CPU直接降了十几个点。
内存方面,最容易省的是VB池的块数量。压测时观察峰值占用,如果峰值只用到配置的一半,那就砍一半。但要注意留余量应对码流切换时的瞬时需求,我一般留30%左右。
带宽方面,如果多路解码同时上屏,DDR带宽会成为瓶颈。这时候可以考虑降低输出帧率(比如解码60帧但显示30帧)、或者用硬件缩放先降分辨率再显示。有些平台支持解码后直接输出小尺寸,能省不少带宽,值得查一下能力集。
还有一个不太被提及的点:温度。解码器和显示控制器都是发热大户,长时间满负荷跑,芯片温度上去之后可能会降频,表现就是跑几个小时后帧率莫名下降。如果遇到这种"越跑越慢"的现象,先摸一下散热片,再查一下温度传感器的读数,别一头扎进代码里找bug。
我在实际项目里最深的体会是:这套框架的API本身不难,难的是对"数据在什么时刻、以什么形式、存在哪里"这件事建立起清晰的心里模型。模型建对了,看日志就能猜到问题在哪;模型没建对,就只能靠试。所以每次接手新平台,我都会先花半天时间把数据流图画出来,标清楚每一级的缓冲深度和格式,后面调试效率会高很多。这个习惯看着笨,但真的省时间。