1. 音视频同步这件事,为什么在嵌入式录像里格外难搞
做过嵌入式录像的人都有一个共同体会:视频画面单独跑没问题,音频单独录也没问题,可一旦要把两路合到一起,唇形对不上、声音比画面快半拍、录久了越偏越多,这些问题就全冒出来了。我前后在几个基于 ARM-Linux 的录像项目里踩过这个坑,从最早用 OV7670 这类不带 FIFO 的传感器直接怼,到后来上 V4L2 拿时间戳、自己搭音频采集链路,最后真正把 A/V 对齐,靠的不是什么高深算法,而是把音频链路拆成了三级 FIFO,让每一级各管一段节奏。
先把话说清楚:这篇讲的“音视频同步”,指的是在嵌入式设备上同时采集摄像头视频和麦克风/线路音频,最终封装成一份播放时唇形一致的录像文件。核心手段是时间戳对齐加三级 FIFO 缓冲。适合谁看?正在做嵌入式 Linux 录像、行车记录、工业视觉记录、教学录播这类项目的开发者,尤其是被 A/V 漂移折磨过、又不想上重型框架的人。如果你还在用“采集完再统一对齐”的粗暴思路,那大概率会和我早期一样,录十分钟偏出去好几百毫秒。
为什么嵌入式场景特别难?因为这里没有 PC 上那么富裕的 CPU 和内存,也没有成熟的媒体框架帮你兜底。视频这边,V4L2 的 buffer 是有限的,摄像头出帧节奏受曝光、分辨率、MIPI/LVDS 带宽影响;音频这边,ALSA 的 period 和 buffer 大小直接决定你多久拿一次数据,采样率还未必和硬件晶振完全一致。两边各自有各自的时钟,各自有各自的抖动,你硬把它们往一个时间轴上塞,不对齐才是正常的。所以真正要解决的不是“怎么对齐”,而是“怎么让两路数据在进入封装前,各自都有一个稳定、可预期的时间基准”。
三级 FIFO 的思路,本质上就是把“采集抖动”和“封装节奏”解耦。第一级贴着硬件,负责吸收驱动层的突发;第二级做时间戳重排和速率微调;第三级面向封装器,提供一个匀速、稳定的数据流。每一级 FIFO 的深度、触发水位、超时策略都不一样,这也是为什么不能简单用一个大队列糊弄过去。下面我按实际项目里的设计顺序,把整套方案拆开讲。
2. 整体设计:为什么是三级 FIFO,而不是一个大缓冲
2.1 从一次“录久了就偏”的翻车说起
最早那版方案特别朴素:视频线程往一个队列里塞帧,音频线程往另一个队列里塞 PCM,封装线程从两个队列各取一份,按到达顺序写文件。测试五分钟没问题,录到二十分钟,声音开始比画面快,越到后面越明显。当时第一反应是“队列太小丢数据了”,于是把队列从 8 帧加到 64 帧,结果漂移没解决,延迟反而上去了,实时预览卡得没法看。
后来用示波器和日志一起抓,才发现问题根本不在队列大小。视频帧的到达间隔是 33ms 上下,但抖动能有 ±8ms;音频 period 是 10ms 一次,可 ALSA 偶尔会一次给你两三个 period 的数据。两个线程各自按“我拿到就写”的逻辑走,时间轴自然越拉越歪。更关键的是,音频的采样时钟来自板载晶振,视频的帧率来自传感器和 SoC 的时钟域,两者天生就有 ppm 级的频差,这个频差在长时间录制里会累积成肉眼可见的偏移。
所以问题的本质是:你需要一个机制,既能吸收短时抖动,又能补偿长时频差,还不能引入过大延迟。一个 FIFO 做不到这三件事,因为它只能吸收抖动,没法做速率调整。三级 FIFO 的分工就清楚了:第一级吸收抖动,第二级做重排和速率观测,第三级做匀速输出和频差补偿。
2.2 三级 FIFO 各自的分工与选型逻辑
我把三级 FIFO 的职责列成一张表,方便对照理解。这里的“级”不是指串联的管道,而是指数据从硬件到封装器经过的三个缓冲阶段,每一级解决一类问题。
| 层级 | 位置 | 主要职责 | 典型深度 | 触发策略 |
|---|---|---|---|---|
| 一级 FIFO | 驱动/采集线程内 | 吸收硬件突发,防止丢帧丢样 | 视频 4~8 帧,音频 4~8 个 period | 水位过半即唤醒消费者 |
| 二级 FIFO | 同步线程内 | 时间戳重排,观测两路速率差 | 视频 8~16 帧,音频 16~32 period | 按时间戳排序,超时强制出队 |
| 三级 FIFO | 封装线程前 | 匀速输出,做频差微调 | 视频 2~4 帧,音频 4~8 period | 按目标时间轴节拍取数据 |
一级 FIFO 贴着硬件,深度不能大。视频这边,V4L2 的 buffer 数量本来就有限,你再多缓冲只会增加端到端延迟;音频这边,ALSA 的 period 太大,采集延迟就上去了,实时性差。所以一级 FIFO 的原则是“够吸收一次突发就行”,一般视频 4 到 8 帧、音频 4 到 8 个 period 足够。
二级 FIFO 是整套方案的核心。它不只是缓冲,更是一个“观测站”。在这里我给每一帧视频、每一段音频都打上基于同一时钟源的时间戳,然后按时间戳排序。排序的目的不是重新排列数据顺序,而是让同步线程能清楚看到“当前视频时间轴走到哪、音频时间轴走到哪、两者差多少”。这个差值就是后续做速率微调的依据。二级 FIFO 深度可以稍大,因为它允许一定的重排窗口,但也不能无限大,否则延迟和内存都受不了。
三级 FIFO 面向封装器,它的任务是“匀速喂数据”。封装器最怕的就是数据忽多忽少,所以三级 FIFO 要做的就是把二级送来的、已经对齐好的数据,按目标时间轴的节拍稳定输出。如果发现音频比视频快了,就在这里做微调——注意,微调不是丢数据,而是通过重采样或者调整读取节奏来慢慢拉平,避免出现“咔”的一声。
2.3 时间戳对齐的基准怎么选
三级 FIFO 要工作,前提是两路数据有可比的时间戳。这里有个关键选择:用哪个时钟做基准?常见的有三种:系统单调时钟、视频帧的 PTS、音频采样的累计样本数换算时间。
我最终选的是系统单调时钟作为统一基准,理由是它不受任何一路硬件节奏影响,是一个独立、稳定的参考。具体做法是:视频帧在出队时,用 V4L2 的 buffer 时间戳(如果驱动支持)或者采集时刻的单调时钟打标;音频在每个 period 到达时,用同一个单调时钟打标,同时记录累计样本数。这样每一段音频既有“到达时间”,也有“理论播放时间”,两者一对比就能算出音频链路的实际速率。
注意:有些 V4L2 驱动返回的时间戳是单调时钟,有些是实时时钟,还有些干脆不填。上板第一件事就是确认时间戳来源,用
v4l2-ctl --stream-mmap --stream-count=10 --verbose看每帧的 timestamp 字段,别想当然。
选单调时钟还有一个好处:它不会因为系统对时、时区调整而跳变。录像这种长时间运行的任务,最怕基准时钟中途跳一下,那前面所有的对齐都白做了。
3. 核心细节:每一级 FIFO 到底怎么实现
3.1 一级 FIFO:贴着硬件做突发吸收
一级 FIFO 的实现要贴着具体的采集接口。视频这边,我用的是 V4L2 的 mmap 方式,申请 4 个 buffer,VIDIOC_QBUF全部入队,然后VIDIOC_DQBUF取帧。这里有个细节:DQBUF 返回的 buffer 里带时间戳,但这个时间戳是“帧被采集”的时刻,不是“帧被取走”的时刻。如果你在 DQBUF 之后才打自己的时间戳,那就把驱动层的抖动也算进去了。正确做法是优先用驱动时间戳,只有在驱动不提供时才用 DQBUF 时刻兜底。
音频这边,ALSA 的snd_pcm_readi一次读一个 period。period 大小怎么定?我一般取 10ms 对应的样本数。比如 48kHz 采样、双声道、16 位,10ms 就是 480 帧,即 1920 字节。period 太小,中断太频繁,CPU 吃不消;period 太大,采集延迟高,同步窗口就粗。10ms 是一个在嵌入式和实时性之间比较平衡的值,实测下来大多数 ARM 板子都能扛住。
一级 FIFO 的唤醒策略也值得说。视频线程和音频线程各自往一级 FIFO 里塞数据,同步线程从里面取。如果每来一帧就唤醒一次同步线程,上下文切换太频繁;如果等 FIFO 满了再唤醒,延迟又太大。我的做法是设一个水位线,比如视频 FIFO 到 4 帧、音频 FIFO 到 4 个 period 就唤醒。这样既不会频繁切换,又能保证同步线程及时拿到数据。
/* 一级 FIFO 的水位唤醒示意,伪代码 */ #define VIDEO_FIFO_WATERMARK 4 #define AUDIO_FIFO_WATERMARK 4 void on_video_frame_ready(frame_t *f) { fifo_push(&video_fifo_l1, f); if (fifo_count(&video_fifo_l1) >= VIDEO_FIFO_WATERMARK) { pthread_cond_signal(&sync_cond); } } void on_audio_period_ready(pcm_t *p) { fifo_push(&audio_fifo_l1, p); if (fifo_count(&audio_fifo_l1) >= AUDIO_FIFO_WATERMARK) { pthread_cond_signal(&sync_cond); } }这里有个坑:水位唤醒要配合超时。如果视频那边因为光线暗、曝光时间长,帧率掉下来了,音频 FIFO 可能一直等不到视频数据,同步线程就饿死了。所以pthread_cond_timedwait必须带超时,比如 50ms,超时后即使水位没到也要处理已有数据。
3.2 二级 FIFO:时间戳重排与速率观测
二级 FIFO 是同步线程的主战场。同步线程被唤醒后,把一级 FIFO 里的数据搬到二级 FIFO,同时做两件事:一是给每份数据打上统一基准的时间戳,二是按时间戳排序。
排序这件事听起来简单,做起来有讲究。视频帧的时间戳是采集时刻,音频 period 的时间戳也是采集时刻,但音频一个 period 覆盖 10ms,它的时间戳应该代表这段音频的起始时刻还是中间时刻?我选的是起始时刻,因为这样和视频帧的“曝光开始时刻”语义一致。如果你用中间时刻,那音频天然就比视频“晚”了 5ms,这个偏差在唇形同步上刚好处于可察觉的边缘。
排序窗口不能无限大。我的做法是维护一个按时间戳排序的链表,新数据插入时找到合适位置,同时检查队首数据的时间戳是否已经比“当前视频时间轴”落后太多。如果落后超过一个阈值,比如 200ms,说明这帧视频或这段音频已经太旧了,再等下去只会增加延迟,这时候就强制出队,交给三级 FIFO 处理。
速率观测是二级 FIFO 的另一个核心任务。具体怎么观测?我维护两个累计量:视频累计帧数和音频累计样本数,以及它们各自对应的基准时间跨度。每隔一段时间,比如每秒一次,计算视频的实际帧率和音频的实际样本率,和标称值对比。如果音频实际样本率比标称值高了 0.1%,那就说明音频时钟偏快,长时间录下去音频会越来越超前。
/* 速率观测示意 */ typedef struct { uint64_t base_time_us; /* 基准时间,微秒 */ uint64_t video_frames; /* 累计视频帧数 */ uint64_t audio_samples; /* 累计音频样本数 */ double nominal_fps; /* 标称帧率 */ double nominal_sample_rate; /* 标称采样率 */ } rate_observer_t; void observe_rate(rate_observer_t *obs, uint64_t now_us) { uint64_t elapsed = now_us - obs->base_time_us; if (elapsed < 1000000) return; /* 每秒统计一次 */ double actual_fps = obs->video_frames * 1e6 / elapsed; double actual_sr = obs->audio_samples * 1e6 / elapsed; /* 与标称值对比,得到偏差比例,供三级 FIFO 微调 */ double fps_dev = actual_fps / obs->nominal_fps - 1.0; double sr_dev = actual_sr / obs->nominal_sample_rate - 1.0; /* 重置统计窗口 */ obs->base_time_us = now_us; obs->video_frames = 0; obs->audio_samples = 0; }这个偏差比例就是三级 FIFO 做微调的依据。注意,偏差要平滑处理,不能拿单次统计结果直接去调,否则会震荡。我一般用滑动平均,窗口取 5 到 10 秒,这样调出来的节奏比较稳。
3.3 三级 FIFO:匀速输出与频差补偿
三级 FIFO 面向封装器,核心目标是“匀速”。封装器(比如自己写的 MP4 muxer 或者 FFmpeg 的 API)最理想的情况是:视频按固定帧率来,音频按固定样本率来,时间戳严格递增。三级 FIFO 要做的就是制造这种理想情况。
具体做法是:同步线程根据二级 FIFO 给出的对齐结果,按目标时间轴的节拍往三级 FIFO 里放数据。视频这边,如果目标帧率是 30fps,那就每 33.33ms 放一帧;音频这边,按目标采样率换算,每 10ms 放一个 period。如果实际数据比目标快了,就稍微等一下;如果慢了,就从二级 FIFO 里多取一点补上。
频差补偿在这里落地。假设观测到音频时钟偏快 0.1%,那目标采样率就微调为标称值乘以 1.001,让音频“稍微慢一点消费”,从而和视频拉平。这个微调不是改硬件采样率,而是在读取节奏上做文章。对于音频,更精细的做法是重采样,但重采样计算量大,嵌入式上要谨慎。我的经验是,ppm 级的偏差用读取节奏微调就够了,只有偏差大到千分之一以上才考虑重采样。
提示:三级 FIFO 的深度要小,视频 2 到 4 帧、音频 4 到 8 个 period 即可。深度太大,微调的响应就慢,频差补偿会滞后。
三级 FIFO 还要处理“数据不足”的情况。如果二级 FIFO 里暂时没有对齐好的数据,三级 FIFO 不能空转,否则封装器会以为流结束了。这时候要么插入静音帧(音频)和重复帧(视频),要么让封装器等待。我倾向于让封装器等待,因为插帧会引入额外的时间戳问题,处理不好反而更乱。
4. 实操过程:从采集到封装的完整链路
4.1 环境准备与关键参数确认
动手之前,先把环境摸清楚。我用的是 ARM-Linux 平台,内核里 V4L2 和 ALSA 都是标配。第一步是确认摄像头支持哪种采集方式。OV7670 这类不带 FIFO 的传感器,通常通过并口或者 MIPI 接入,驱动层可能不支持时间戳,这时候就得自己在 DQBUF 时打单调时钟。带 FIFO 的传感器或者 MIPI 摄像头,驱动一般会填时间戳,优先用驱动的。
确认命令很简单:
# 查看视频设备支持的格式和帧率 v4l2-ctl -d /dev/video0 --list-formats-ext # 抓 10 帧看时间戳 v4l2-ctl -d /dev/video0 --stream-mmap --stream-count=10 --verbose # 查看音频设备参数 arecord -D hw:0,0 --dump-hw-params /dev/null音频这边重点看支持的采样率、声道数、period 范围。--dump-hw-params会列出 buffer size 和 period size 的最小值、最大值,选一个居中的值,别贴着边界选。
时间戳基准统一用CLOCK_MONOTONIC。在代码里用clock_gettime(CLOCK_MONOTONIC, &ts)取时间,单位换算成微秒,方便后续计算。
4.2 视频采集线程的实现要点
视频线程的职责很单一:不断 DQBUF,拿到帧后打时间戳,塞进一级 FIFO。这里有几个实操细节。
第一,buffer 数量。V4L2 的 buffer 不是越多越好。我一般申请 4 个,刚好够驱动流水线运转。申请太多,DQBUF 拿到的帧可能是好几帧之前的,时间戳虽然对,但端到端延迟大了。
第二,DQBUF 的超时。VIDIOC_DQBUF默认是阻塞的,如果摄像头掉线,线程就卡死了。用select或者poll加超时,超时后检查设备状态,必要时重新初始化。
第三,时间戳的取舍。如果驱动时间戳可用,直接用;如果不可用,用 DQBUF 返回时刻的单调时钟。但要注意,DQBUF 时刻比实际采集时刻晚,晚多少取决于驱动和 buffer 数量。这个偏差是固定的,可以在标定时测出来,后续统一补偿。
/* 视频采集线程核心循环,伪代码 */ while (running) { struct v4l2_buffer buf = {0}; buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; if (ioctl(fd, VIDIOC_DQBUF, &buf) < 0) { if (errno == EAGAIN) continue; handle_error(); } frame_t *f = malloc(sizeof(frame_t)); f->data = buffers[buf.index].start; f->size = buf.bytesused; if (buf.timestamp.tv_sec || buf.timestamp.tv_usec) { f->ts_us = buf.timestamp.tv_sec * 1000000ULL + buf.timestamp.tv_usec; } else { f->ts_us = now_monotonic_us(); } f->index = buf.index; fifo_push(&video_fifo_l1, f); maybe_wake_sync_thread(); }注意,buffer 是 mmap 出来的,DQBUF 之后数据还在,但 QBUF 回去之后驱动可能覆写。所以塞进 FIFO 的应该是 buffer 索引或者拷贝出来的数据。如果 FIFO 深度不大,用索引就行,同步线程处理完再 QBUF;如果 FIFO 深度大,就得拷贝,但拷贝有开销,要权衡。
4.3 音频采集线程与 period 策略
音频线程用 ALSA 的snd_pcm_readi读 period。参数设置上,我用的是:
- 采样率:48000Hz
- 格式:S16_LE
- 声道:2
- period size:480 帧(10ms)
- buffer size:4 个 period(40ms)
period size 和 buffer size 的关系要搞清楚。buffer size 是环形缓冲的总大小,period size 是一次中断触发读取的量。buffer size 至少要是 period size 的两倍,否则容易 overrun。我取 4 倍,留足余量。
/* ALSA 参数设置示意 */ snd_pcm_hw_params_t *params; snd_pcm_hw_params_alloca(¶ms); snd_pcm_hw_params_any(handle, params); snd_pcm_hw_params_set_access(handle, params, SND_PCM_ACCESS_RW_INTERLEAVED); snd_pcm_hw_params_set_format(handle, params, SND_PCM_FORMAT_S16_LE); snd_pcm_hw_params_set_channels(handle, params, 2); unsigned int rate = 48000; snd_pcm_hw_params_set_rate_near(handle, params, &rate, 0); snd_pcm_uframes_t period = 480; snd_pcm_hw_params_set_period_size_near(handle, params, &period, 0); snd_pcm_uframes_t buffer = period * 4; snd_pcm_hw_params_set_buffer_size_near(handle, params, &buffer); snd_pcm_hw_params(handle, params);读取时用snd_pcm_readi,返回实际读到的帧数。如果返回负值,用snd_pcm_recover恢复。每次读到数据后,记录累计样本数和单调时钟时间戳,塞进一级 FIFO。
音频这边有个容易忽略的点:snd_pcm_readi返回的帧数可能不等于 period size。ALSA 在某些情况下会返回部分数据,这时候累计样本数要按实际返回的算,不能按 period size 算,否则速率观测就偏了。
4.4 同步线程:三级 FIFO 的调度中枢
同步线程是整个方案的大脑。它被一级 FIFO 的水位唤醒,把数据搬到二级 FIFO,做时间戳排序和速率观测,然后按目标节拍往三级 FIFO 放数据。
调度逻辑我写成状态机:空闲、填充、输出三个状态。空闲时等水位;填充时把一级数据搬到二级并排序;输出时按节拍从二级取数据放三级。状态切换由水位和时间共同驱动。
/* 同步线程主循环,伪代码 */ while (running) { pthread_mutex_lock(&sync_mutex); pthread_cond_timedwait(&sync_cond, &sync_mutex, &timeout); /* 阶段一:一级到二级 */ drain_l1_to_l2(&video_fifo_l1, &video_fifo_l2); drain_l1_to_l2(&audio_fifo_l1, &audio_fifo_l2); /* 阶段二:时间戳排序与超时出队 */ sort_l2_by_timestamp(&video_fifo_l2); sort_l2_by_timestamp(&audio_fifo_l2); force_dequeue_if_stale(&video_fifo_l2, now_monotonic_us()); force_dequeue_if_stale(&audio_fifo_l2, now_monotonic_us()); /* 阶段三:速率观测 */ observe_rate(&rate_obs, now_monotonic_us()); /* 阶段四:按节拍输出到三级 FIFO */ pump_to_l3(&video_fifo_l2, &video_fifo_l3, &rate_obs); pump_to_l3(&audio_fifo_l2, &audio_fifo_l3, &rate_obs); pthread_mutex_unlock(&sync_mutex); }pump_to_l3是核心。它根据目标时间轴和当前三级 FIFO 的水位,决定这次放多少数据。如果三级 FIFO 快空了,就多放一点;如果快满了,就少放或不放。同时根据速率观测的偏差,微调放数据的节拍。
4.5 封装线程与时间戳写入
封装线程从三级 FIFO 取数据,写进 MP4 或者自定义容器。这里的关键是时间戳的写入。视频帧的 PTS 用它的采集时间戳减去起始时间戳;音频的 PTS 用累计样本数除以采样率换算。两者都基于同一个起始点,这样播放器才能正确对齐。
起始时间戳怎么定?我取第一帧视频和第一段音频中较早的那个,作为时间轴零点。后续所有时间戳都相对这个零点计算。
/* 时间戳换算示意 */ uint64_t video_pts_us = frame->ts_us - timeline_start_us; uint64_t audio_pts_us = (audio_total_samples * 1000000ULL) / sample_rate;封装器写入时,视频和音频的 PTS 要严格递增。如果发现音频 PTS 比上一段小了,说明速率微调过头了,要回退。这个检查在三级 FIFO 输出时做,比在封装器里做更及时。
5. 常见问题与排查技巧实录
5.1 唇形对不上,先查时间戳基准是否统一
最常见的现象是唇形对不上,声音比画面快或者慢。排查第一步:确认两路时间戳是不是同一个基准。我遇到过视频用实时时钟、音频用单调时钟的情况,两者差了一个纪元,对齐结果完全是乱的。用日志把两路时间戳打出来,看它们的差值是否稳定。如果差值一直在变,说明基准不统一或者有一路时间戳不可靠。
第二步:检查音频时间戳的语义。前面说过,音频 period 的时间戳应该代表起始时刻。如果代码里用的是读取完成时刻,那音频天然就晚了。这个偏差是固定的,可以通过在时间戳上减去一个 period 的时长来补偿。
第三步:看漂移是线性的还是突变的。线性漂移说明是频差,靠三级 FIFO 的速率微调解决;突变说明是丢帧或者丢样,要查采集链路。
5.2 音频出现“咔”声,多半是 FIFO 水位震荡
“咔”声是音频数据不连续的表现。在三级 FIFO 方案里,最常见的原因是水位震荡:三级 FIFO 一会儿空一会儿满,输出节奏忽快忽慢。解决办法是给水位加滞回区间,比如低水位 2 个 period、高水位 6 个 period,低于低水位才补数据,高于高水位才停,中间区域保持不动。
另一个原因是速率微调太激进。如果每次观测到偏差就立刻调整,音频读取节奏会抖。用滑动平均平滑偏差,调整步长限制在每次 0.01% 以内,让节奏慢慢变化。
注意:调试音频问题时,先把速率微调关掉,确认基础链路没问题,再逐步打开微调。否则你分不清是链路问题还是微调问题。
5.3 视频丢帧导致同步崩掉
视频丢帧在嵌入式上很常见,尤其是分辨率高、带宽紧张的时候。丢帧本身不可怕,可怕的是丢帧后时间戳不连续,同步线程按时间戳排序时会出现空洞。如果音频还在正常走,视频时间轴就落后了,三级 FIFO 会一直等视频数据,音频越积越多。
我的处理方式是:视频丢帧后,不试图“补帧”,而是让视频时间轴接受这个空洞。同步线程在排序时,如果发现视频时间戳跳跃超过一个帧间隔的 1.5 倍,就认为丢帧了,直接推进视频时间轴到下一个有效帧,同时通知三级 FIFO 调整节拍。音频这边不受影响,继续按自己的节奏走。这样虽然视频少了几帧,但整体时间轴还是对齐的。
排查丢帧用v4l2-ctl --stream-mmap --stream-count=300 --verbose看 DQBUF 的 sequence 字段,如果 sequence 不连续,就是驱动层丢了。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决思路 |
|---|---|---|---|
| 唇形逐渐偏移 | 两路时钟频差 | 统计每秒实际帧率/样本率 | 三级 FIFO 速率微调 |
| 唇形固定偏移 | 时间戳语义不一致 | 打印两路时间戳差值 | 统一时间戳基准和语义 |
| 音频咔声 | 三级 FIFO 水位震荡 | 记录水位变化曲线 | 加滞回区间,平滑微调 |
| 视频卡顿后不同步 | 丢帧导致时间轴空洞 | 检查 DQBUF sequence | 接受空洞,推进时间轴 |
| 录制启动时不同步 | 起始时间戳未对齐 | 检查 timeline_start | 取两路较早时间戳为零点 |
| 长时间录制内存上涨 | FIFO 只进不出 | 监控各级 FIFO 深度 | 加超时强制出队 |
5.5 几个我踩过的坑
第一个坑:一级 FIFO 用指针存 buffer 索引,但同步线程处理慢,buffer 被驱动覆写了。后来改成同步线程处理完立刻 QBUF,并且一级 FIFO 深度限制在 buffer 数量以内。
第二个坑:速率观测窗口太短,偏差抖动大,三级 FIFO 跟着抖。后来把窗口拉到 10 秒,偏差就稳了。
第三个坑:音频 period 设置成 5ms,中断太频繁,CPU 占用高,反而影响了视频采集。改回 10ms 后整体就稳了。
第四个坑:时间戳用 32 位微秒存储,录到 71 分钟就溢出了。改成 64 位后解决。这个坑很隐蔽,因为前一个小时都正常。
6. 一些参数调优的经验值
三级 FIFO 这套方案,参数没有绝对的最优值,但有一些经验范围可以参考。视频一级 FIFO 深度 4 到 8 帧,二级 8 到 16 帧,三级 2 到 4 帧;音频一级 4 到 8 个 period,二级 16 到 32 个 period,三级 4 到 8 个 period。这些值在 1080p30 加 48kHz 立体声的场景下实测比较稳。
速率观测窗口 5 到 10 秒,微调步长每次不超过 0.01%,滞回区间低水位 2 个 period、高水位 6 个 period。超时强制出队阈值 200ms,超过这个值的数据基本没有同步价值了,留着只会增加延迟。
时间戳统一用 64 位微秒,基准用CLOCK_MONOTONIC。起始时间戳取两路较早者。音频时间戳语义用 period 起始时刻。
这套参数不是拍脑袋来的,是在几块不同的 ARM 板子上反复调出来的。换平台或者换分辨率,可能需要微调,但大方向不变。我个人的体会是,三级 FIFO 的价值不在于缓冲本身,而在于它把“抖动吸收”“速率观测”“匀速输出”这三件事分开了,每一件都能独立调试。如果你现在还在用一个大队列硬扛,不妨试试拆成三级,很多之前想不通的漂移问题会一下子变得清晰。