
我是在做一批边缘视频巡检盒子时被逼着动手的。板子就那么大内存多少都有数跑Linux已经吃掉大半还要在里头塞一个能做音视频采集、解码、抽帧、音频降噪、叠加OSD的引擎。刚开始我图省事直接挂了个FFmpeg的动态库方案裁剪了一轮又一轮最后压到8MB多一点但跑起来内存轻轻松松破两百MBCPU占用在双路1080p解码时直接顶到80%以上这还没算处理逻辑。产品经理给的功耗和成本红线就卡在那儿逼着我把整个引擎拆开重新设计最后才有了这个叫uclAV的东西——全名Ultra-Compact Lightweight Audio/Video Processing Engine超紧凑轻量音视频处理引擎。这个引擎解决的问题非常明确在资源受限的嵌入式环境里把一个完整的音视频处理链路——从采集、解码、像素格式转换、缩放、音频重采样、混音、静音检测到编码输出——控制在静态链接体积不超过2MB、常驻内存不超过40MB、单帧端到端延迟不超过30ms。它不是要替代FFmpeg也不是要对标GStreamer而是专治那种“功能很全但浑身是肉”“跑起来吃内存吃到系统卡死”的通用型方案。这篇文章我会把整个引擎的架构设计、模块取舍、内存优化、音视频同步、实测数据还有我踩过的几个大坑全部摊开来讲适合正在做嵌入式音视频应用的工程师以及想理解“轻量级音视频处理到底怎么做”的人参考。1. 不是所有场景都该上FFmpeg这个引擎到底要解决什么矛盾先说清楚一件事我并不是FFmpeg黑。相反FFmpeg确实是目前音视频领域的瑞士军刀但瑞士军刀有个问题——你只需要一把小刀片的时候它连开瓶器和指甲锉一起都给你塞兜里了。对PC端应用这种“多带点功能没坏处”的思路毫无问题可一旦跑到嵌入式设备上每1MB的二进制体积和每10MB的内存占用都直接折算成硬件成本、功耗和散热压力。1.1 传统方案的三座大山体积、内存、延迟我最初用FFmpeg裁剪库做原型的时候其实做了不少功课disable掉不需要的编解码器、协议、滤镜只保留h264解码、aac解码、rawvideo和swscale。configure参数写了一大串最后链接出来的动态库是8.6MB我咬咬牙接受了。但实际跑起来内存占用还是让我倒吸一口凉气模块内存占用说明FFmpeg解码上下文约12MBH.264 Level 4.18帧参考帧池Swscale转换行缓冲约8MB每路1080p转换时的临时缓冲音频重采样约3MBAAC 48kHz转16kHz内部延迟缓冲自带业务处理约10MB业务自己申请的框、队列、缓存Linux系统基线约85MB外设驱动、网络栈、图形栈最小化后加起来轻松超过120MB这还是单路视频的场景。但板子内存只有256MB系统本身要跑业务流程、网络通信、掉线重连剩余空间非常紧张。更麻烦的是延迟FFmpeg的解码管线为了性能默认会做比较深的帧缓冲和线程池调度我从推流到拿到第一帧可显示的图像实测延迟就在80ms到120ms之间徘徊而且抖动明显。对实时交互类场景这个延迟不可接受。1.2 “轻量”不是简单的裁剪而是重新设计后面我想明白了一个关键点在FFmpeg这个框架里做“瘦身”天花板是极其有限的。因为FFmpeg为了通用性所有的数据流都必须经过AVFrame、AVPacket、AVCodecContext这些抽象层每一层都有额外的结构体开销、引用计数开销、以及为了兼容各种边界条件而存在的冗余逻辑。这不是说你ban掉几个模块就能瘦下来的事情而是它的骨髓里就带着“通用”两个字。所以我决定从头写一套针对固定场景的引擎——这就是uclAV的起点。本质上它就是一个“单接口、多后端、零抽象泄漏”的C语言实现输入格式固定为裸H.264(H.265)流和裸AAC/PCM流不碰MP4、FLV、TS这些容器格式处理管线固定为“解码 → 像素转换/缩放 → 业务处理(OSD/裁剪/镜像) → 编码输出”和“解码 → 重采样 → 音频处理 → 编码输出”两条链不提供任何动态加载机制和插件体系所有模块都是编译期静态链接。这才敢提“静态体积不超过2MB”这个指标。由于没有了容器层、协议层和动态插件注册机制整个引擎的代码路径是确定的内存在初始化阶段就能精确预算出来——这也是为什么它适合做嵌入式常驻进程而不是通用转码工具。2. 模块化裁剪的实战手册每个模块怎么选型、怎么砍定了“不搞通用”的设计原则之后接下来的问题就是具体每个模块到底用什么实现怎么把“体积”和“功能”这两个矛盾体平衡好。我把这一层称为“能力规划”因为它不是简单把代码拆成几个文件而是要从需求出发反向决定每个组件要不要存在、以什么形态存在。2.1 解码器选型FFmpeg剥离解码栈 vs 自研解码器视频解码是整个引擎里最核心也最复杂的模块。我不可能为了一个巡检盒子去从零写H.264熵解码和运动补偿那是几万行代码加无数合规测试才能搞定的技术债。我的方案是把FFmpeg源码中的解码器部分单独剥离出来做成静态库而完全弃用FFmpeg的demuxer、muxer、filter框架。怎么剥离我基于FFmpeg 5.x的源码树做了一套精简构建配置# 精简后的ffmpeg configure仅保留解码器和基础工具函数 ./configure \ --disable-all \ --disable-everything \ --disable-programs \ --disable-doc \ --disable-network \ --disable-avdevice \ --disable-avfilter \ --disable-avformat \ --disable-swresample \ --disable-postproc \ --disable-avcodec-dlopen \ --disable-encoders \ --disable-decoders \ --disable-hwaccels \ --disable-parsers \ --disable-demuxers \ --disable-muxers \ --disable-protocols \ --disable-bsfs \ --enable-avcodec \ --enable-decoderh264 \ --enable-decoderhevc \ --enable-decoderaac \ --enable-decoderpcm_s16le \ --enable-parserh264 \ --enable-parserhevc \ --enable-parseraac \ --enable-protocolfile这套配置编出来的libavcodec.a用strip之后体积在1.1MB左右。它保留了H.264/HEVC的软解能力、AAC解码、PCM解码以及对应的parser但砍掉了所有封装、协议、滤镜、编码器、硬件加速接口。这里有个坑我必须强调--disable-everything之后连swresample都没有但avcodec内部的部分函数会依赖libavutil的数学表和日志系统所以libavutil不能全ban我保留了它的大部分基础工具。音频解码我一开始也想直接用FFmpeg的AAC解码器但它有一个比较大的问题FFmpeg的AAC解码器为了支持HE-AAC和错误隐藏内部状态比较大内存占用高。后来测试发现在我们的实际场景中音频流都是从对讲麦克风或录音笔采上来的AAC-LC采样率固定48kHz或16kHz不需要HE-AAC的频带复制。所以音频解码这部分我直接用了一个轻量的AAC-LC纯C解码器——大概只有8000行代码静态链接后体积只有130KB。代价是它不支持ADTS流里的DRM数字版权管理和声道配置动态切换但我们的输入协议里明确约定声道数固定为单声道或双声道所以完全够用。2.2 容器层和协议层干脆就不要了传统FFmpeg方案里demuxer负责把封装格式拆成AVPacket。但在uclAV中输入输出全部采用裸流协议。也就是说网络或本地存储层传给引擎的就是已经剥离好封装的裸H.264 NALU流和裸AAC帧流。这样设计有一个直接好处省掉解析容器带来的内存和CPU开销。MP4的box解析虽然不算重但它要做sample table索引、时间戳映射、关键帧表维护这些都是额外的代码路径。而我们的业务场景是“实时流直接进引擎”不需要本地文件回放所以封装层完全可以交给上游的推流端板子上的采集模块直接输出裸流或下游编码输出后由专门的封装模块处理。当然这也意味着uclAV不是万能的。如果你要做的产品需要直接读SD卡里的MP4并抽帧那裸流协议就不合适要么在上游加一个轻量mp4 demuxer要么走FFmpeg路线。这是一个重要的边界条件后面我会单开一节说清楚它的适用边界。2.3 静态链接和工具链的配合要在2MB体积目标内把解码器、音频处理、图像处理、编码器全部装下光是裁剪代码还不够编译工具链也得配合。我用的Toolchain是aarch64-linux-gnu-gcc 9.3配合musl libc而不是glibc原因有两个glibc的动态链接开销和静态链接膨胀在嵌入式上相当可观而musl是高度模块化的静态链接时只搬入实际用到的symbol体积小很多musl的malloc实现mallocng内存碎片控制比glibc更好这对长期运行的音视频进程非常关键。另外我开启了链接时优化LTO和gc-sections。-ffunction-sections -fdata-sections编译选项配合--gc-sections链接选项可以把没有被直接调用的解码分支彻底从最终bin里去掉。最终引擎全静态链接后正文段.text加只读数据.rodata实测在1.6MB左右加上可写数据段、帧缓冲池和零散的堆整颗bin映射到内存中的总镜像也就2.1MB。这里有个非常明显的对比用同样工具链编出来的剥离版FFmpeg即便裁剪过后至少也有6MB以上差距主要就来自框架层的函数分发表、AVOption制度、以及大量“可能用到所以保留”的边界处理代码。3. 零拷贝流水线与内存池设计让每一帧都在内存里“滑过去”功能裁剪只是让引擎“瘦”真正让它在跑起来时保持“轻”的是内存和帧数据的管理策略。传统方案里一帧数据从解码到编码输出中间要拷贝至少四到八次。uclAV整个链路的设计目标是最核心的视频路径上从解码器输出到编码器输入最多只允许一次逻辑拷贝且多数场景是零拷贝。3.1 先算一笔账传统方案里一帧要拷贝几次我以一个1080p YUV420P帧为例一帧原始数据的大小是1920*1080*1.5 ≈ 3.1MB一颗帧拷贝操作看起来没多少但在30fps下就是93MB/s的纯内存搬运。而嵌入式设备内存带宽本来就有限双路视频实时处理时拷贝开销会被无限放大阶段典型的做法额外拷贝次数解码器输出到处理层FFmpeg给AVFrame上层拿原始指针0指针引用像素转换swscale默认申请输出的缓冲并写入1业务变换镜像/裁剪/OSD应用自建新帧并逐行处理1送入编码器编码器要一个打包对齐的输入缓冲1编码器内部参考帧管理编码器做DPB参考帧拷贝1-2也就是说顺利的话一帧也得经历3到4次整帧拷贝不顺利可能五六次。在内存带宽不高、cache还小的SoC上这直接反映为算力浪费和发热。3.2 帧池、引用计数与写时拷贝的妥协uclAV引入了一个极简的“帧池”机制。引擎在初始化阶段就根据自己的配置——分辨率、帧率、预期并发路数——一次性分配好N个帧缓冲区默认N 4 线程数每个帧缓冲区由一块连续内存和元数据结构组成。元数据结构里维护引用计数reference count生产者/消费者队列状态帧宽高、像素格式、时间戳指向池内存的基地址指针解码器输出AVFrame时我们不是复制像素数据而是直接把AVFrame的data指针指向帧池中的这块内存。这样解码器写数据就是直接写进帧池缓冲区。后续的缩放、格式转换步骤我封装了一个uc_convert()函数它会把转换结果写到同一个帧池里的另一块空闲帧区或者如果只是裁剪而不需要缩放直接用偏移指针操作原内存连拷贝都省了。这里有一个非常实操的细节YUV420P的平面地址对齐问题。用NEON或SIMD优化的缩放、裁剪、混合代码几乎都要求行宽、平面起始地址16字节或64字节对齐。所以帧池分配的时候我用的不是裸malloc而是posix_memalign并且对每一行的stride做了手动对齐计算。如果不做这个后面想优化帧处理性能改起来会非常痛苦。3.3 线程模型三线程比很多线程更稳很多人做音视频处理时会习惯性地每模块一个线程解码线程、转换线程、音频线程、编码线程、采集线程——最后线程数量爆炸锁竞争严重。uclAV的线程模型非常简单只有三个常驻线程采集/读流线程一个阻塞式读入裸流解析出编码帧把它投递给解码工作线程 解码处理线程一个从队列取编码帧解码执行像素转换/裁剪/OSD再送编码器 音频处理线程一个独立跑音频解码、重采样、静音检测、混音为什么只有三个线程原因在于音视频处理的主要耗时在编解码器内部而编解码器内部本身已经有多线程优化ffmpeg解码器开-threads 2或4外层再拆线程并不会提升解码吞吐只会增加上下文切换和锁等待。用三个固定线程后每个线程的职责边界非常清晰我用两个无锁队列做线程间通信一个给视频帧一个给音频帧。实测在四核A53的CPU上三个线程加主程序的占用率大约是1.2核比原先FFmpeg方案的2.5核好得多。4. 音视频协同的工程实现同步问题永远是慢刀子割肉很多人都说音视频同步是最复杂的部分我觉得这话只说对了一半。真正复杂的是当你的资源不够时怎么让音频和视频在时间轴上“大体对齐”、且可接受的“大体”到底是多少。在一个轻量级引擎里你没有FFmpeg提供的完整时钟同步框架可用所有的同步策略都得自己设计。我的做法是“主时钟控制、副媒体跟随、硬性丢帧保持延迟”。4.1 音频侧为什么不加锁而用单写单读环形队列音频处理有个天然特点音频数据量小、连续性要求高、单帧可容忍的延迟极低。在嵌入式Linux环境下录音设备通常通过ALSA或tinyalsa回调方式提供音频数据而ALSA的PCM回调运行在中断/高优先级上下文中如果你在这个回调里加互斥锁去同步给另一个线程一旦锁被占用时间稍长就可能造成音频缓冲区欠载underrun表现为爆音和断音。uclAV里音频处理线程和采集回调之间我用的不是锁而是一个单生产者单消费者SPSC环形队列生产者是ALSA回调消费者是音频处理线程。SPSC队列通过内存屏障保证无锁安全只要有正确的读写索引发布/获取就不需要mutex。在实际设计中有三个要点队列大小必须能吸收采集侧的最大突发我设置为2048个采样点也就是48kHz下约42ms音频读索引和写索引都用volatile加上__sync_synchronize()做内存屏障防止编译器乱序消费者线程不能直接用阻塞读而必须用poll或epoll等待ALSA的pollfd事件这样队列空转时线程不会占满CPU。4.2 视频侧跳帧不是随便跳的得看关键帧视频的延迟控制主要是通过跳帧实现的。任何一个H.264/H.265解码器内部都有一个参考帧DPB队列和一整套帧排序逻辑。从解码到输出显示天然会引入一到三帧的延迟。但在实时业务里一定会有“解码速度跟不上输入速度”的瞬间比如码率突然飙高、CPU被其他任务抢占。如果这时候不做任何处理视频延迟会像滚雪球一样越积越大最后画面比音频慢几百毫秒体验完全崩掉。uclAV的策略是“延迟超限时丢非参考帧优先”。维护一个滑动窗口计算当前解码线程里积压的帧数。如果积压超过阈值默认3帧下一次从输入队列取编码帧时就跳过那些非关键帧的B/P帧直到遇到下一个IDR或关键帧才开始重放。这种跳帧策略能快速把延迟压回去而且因为丢的是非参考帧后续正常帧的解码质量不会受影响。这里有个容易犯的错误在编码端做跳帧而不是在解码输入端做跳帧。如果你控制不了上游推流端那只能在解码输入做如果你既是推流端又是处理端比如从相机sensor过来的raw数据自编码那就应该在编码器侧做帧率控制编码器丢帧远比解码端丢帧干净因为编码器知道帧间预测结构它可以直接拒绝编码非参考帧而不破坏码流结构。4.3 唇音同步与时钟补偿同步的核心是选主时钟。uclAV的主时钟用的是音频PTS。原因很直接在嵌入式设备上音频中断的时钟源通常来自声卡的晶体振荡器或SoC的内部定时器相比视频的帧率PTS漂移更小连续性和稳定性更好。实现上维护了一个偏移量av_clock_offset// 视频帧到达时计算它与当前音频PTS的时间差 int64_t diff video_pts - audio_pts; if (diff 8000) { // 视频比音频快超过8ms // 插入重复帧或延迟显示具体看buffer状态 schedule_display_with_delay(diff); } else if (diff -8000) { // 视频落后音频优先丢帧追赶 skip_nonref_frame(); }这个8ms的阈值是我经过多次主观对比测试得到的它既能避免频繁触发跳帧导致的画面顿挫又能保证唇音不同步不容易被感知。在一些低成本方案里主时钟可能没有这么稳定经常出现“累积漂移”——音频时钟是标称44.1kHz实际43.8kHz视频时钟标称30fps实际29.7fps。这种情况必须定期做时钟重同步每隔30秒重新计算主时钟与副媒体之间的平均偏差把这个偏差补偿到后续的PTS换算系数里。否则跑十分钟音视频会差出几百毫秒你根本看不出来到底是哪里出的问题。5. 实测数据与压测结果这东西到底表现怎么样再怎么吹架构最终要拿数据说话。我找了三块典型的开发板做测试分别代表低端、中端和高端边缘设备板卡CPU内存场景全志V3sARM Cortex-A7 1.2GHz 单核64MB低端IPC/门铃RK3288Cortex-A17 四核1.8GHz2GB中端边缘计算盒子RK3588Cortex-A76A55 八核4GB高端多路视频分析测试条件是输入两路1080p30的H.264裸流一路通过引擎解码后做缩放、人物检测框叠加、再编码为720p H.264输出另一路只是解码抽帧不做编码输出。音频输入一路48kHz立体声AAC引擎做重采样到16kHz单声道、静音检测、音量标准化后编码为AAC输出。5.1 体积和内存表现引擎静态链接后的binary总大小如下用size命令读取并strip目标板可执行文件大小常驻内存完整业务ARMv7V3s1.8MB38MBARMv8RK32881.6MB41MBARMv8RK35881.7MB44MB内存大头在于解码器的参考帧池1080p H.264解码默认需要16帧参考帧每帧3MB这就是48MB的理论上限但因为只保留8帧实际参考帧加上帧池复用实际压低到大概20MB。其他模块加起来不到10MB。这个数据对比FFmpeg方案的120MB优势非常明显。5.2 CPU占用和频控板卡双路1080p解码CPU占用单路1080p解码编码CPU占用全志V3s95%极限无法完成算力不足RK328855%40%RK358818%25%在V3s这种单核A7上单路1080p解码已经接近极限再加编码肯定跑不动这很合理。但在RK3288上双路解码加一路再编码的总占用能控制在95%以内足够腾出核心跑业务算法这是我比较满意的结果。RK3588这种级别则完全游刃有余四路视频同时处理也不会有压力多余算力还能跑轻量级检测模型。5.3 抓出来的两个典型的坑第一个坑是解码器参考帧池的首帧H.264 SPS/PPS丢失。裸流输入时如果网络或采集端在关键帧之前没有附带SPS/PPS解码器会一直无法初始化成功表现为长时间黑屏。FFmpeg的demuxer会帮你从容器里提取并缓存SPS/PPS但裸流协议没有这个机制。我的解决办法是输入协议里固定要求“每个关键帧必须携带SPS/PPS”并在解码器初始化时从第一帧解析出参数集缓存后面每收到一个关键帧就更新一次缓存。第二个坑更隐蔽出现在音频重采样加静音检测的组合链路。静音检测需要在重采样之前做还是之后做一开始我在重采样之后做结果发现重采样器内部有滤波延迟导致静音判断会晚几毫秒触发造成噪音尾部没被抑制。后来我把静音检测移动到了重采样之前直接在原始采样域做能量计算再用重采样输出的编码时机来映射静音区间问题就消失了。这种“模块顺序导致的微妙时序问题”不实测根本发现不了。6. 适用边界判断这个引擎适合你的场景吗最后必须把适用边界说清楚因为包括我自己在内工程师在看到一个漂亮的数据之后很容易把方案往所有场景硬套。我把它整理成一张决策表遇到哪一种需求就选哪一条路线。需求特征推荐路线原因固定输入裸流、固定输出裸流uclAV体积小、延迟低、内存可控需要解析MP4/FLV/TS/MKV文件直接用FFmpeg的demuxer链容器解析复杂自研成本高需要支持多种编码协议、封装格式灵活切换直接用FFmpeg/GStreamer通用性远超自研需要动态加载插件如新编码器热插拔不使用uclAV我们故意不支持动态加载固定产品形态资源紧张追求极致性价比uclAV 值得尝试可以省下硬件升级和散热成本音视频处理是辅助功能只需偶尔抽帧存图FFmpeg CLI 足够没必要投成本做嵌入集成从时间投入看做这样一个引擎的完整周期大约需要三到四个月。如果你只是给某一款产品用且FFmpeg方案已经能满足性能那么不值得投入但如果你要做的是一系列资源敏感的嵌入式音视频产品底层引擎的收益会随产品数量线性放大。开发过程中我还发现轻量引擎的调试难度比通用方案更高。通用方案有海量现成日志和错误提示自研引擎出错时只能靠单步调试、内存分析、波形对比一点点查。所以至少需要准备三样工具一套可重复生成测试流的脚本、一个能dump每帧YUV/音频PCM的调试接口、以及一套自动化回归测试专门验证不同分辨率、码率、时长下的输出一致性。没有这三个基础建议先别碰自研引擎。我在最后一版优化里做得比较满意的一件事是把引擎的初始化时间从120ms优化到35ms——主要是提前预热了各线程的栈空间、帧池里所有缓冲区的cache并把解码器参数集换成预解析好的副本省掉了启动时同步等待解码器sps解析的过程。这个优化在设备冷启动、快速重启场景下很管用。如果你手头也有类似场景建议初始化阶段尽量把可预热的资源全部预热系统调用该缓存的缓存能省出好几个肉眼可见的启动周期。最后分享一个习惯上的小建议做嵌入式音视频引擎这类底层软件一定不要只追求某个单项指标的极致而是要在体积、内存、延迟、开发速度四个维度上做均衡。数据漂亮不算本事能稳定运行在几百台设备上、几个月不出问题才是真的靠谱。