设备是一台基于 ARM SoC 的便携摄像机。录像链路本身并不复杂:视频从 Sensor 进来,经 ISP 交给 H.265 硬编码器;音频从麦克风进来,做降噪和 AGC,再软编成 AAC。两路码流汇入 FFmpeg,交错封装成标准 MP4。
下面记录开发过程中遇到的两个问题。一是音频采集线程承担了过多工作,被自己拖垮;二是分段录像时音视频逐段错位,最后定位到两个「首帧时间戳」的作用域不一致。
链路与几个关键参数
先把关键参数列出来,后面的换算都基于它们:
音频: mic → PCM 采集 → (NS/AGC/APM) → AAC 编码 → ┐
├─→ Muxer(FFmpeg) → MP4
视频: sensor → ISP → VENC(H.265) → 码流 → ┘
| 参数 | 取值 | 说明 |
|---|---|---|
| 采样率 | 16000 Hz | 单声道、S16_LE,即 32 KB/s |
| 采集帧长 | 10 ms | 160 样本 / 320 字节 |
| AAC 帧长 | 1024 样本 | 16 kHz 下是 64 ms,对应 2048 字节 PCM |
| 音频流时间基 | 1/16000 | 时间戳单位是 1 个采样点 |
| 视频流时间基 | 1/1000000 | 时间戳单位是 1 微秒 |
这两个时间基并不相同,视频按微秒计时,音频按采样点计时。所以比较音视频时长之前必须先做一次换算,否则数值无法直接对比。
第一个问题:采集线程承担了过多工作
最初的音频收流逻辑很直接,所有工作都在一个函数里完成:取一帧 PCM,跑降噪和 AGC,写入环形缓冲,再按 1024 个样本切帧做 AAC 编码,最后在回调中直接写 MP4。
int pull_audio_data(callback)
{
capture_pcm(); // 取一帧 PCM
apm_process(); // 降噪 / AGC / 回声处理
ring_write(pcm, len); // 写入环形缓冲
return encode_from_ring(callback); // 切帧 + AAC 编码 + 回调
}
问题出在最后一步。回调一路走到封装器,那里有两处会阻塞:
wait_video_ahead(); // 音视频同步时可能要等视频
av_interleaved_write_frame(ctx, pkt); // 阻塞式写盘
于是采集线程在一帧内实际承担了三类工作:CPU 密集的降噪与编码、等待视频、写磁盘。
音频采集是硬实时的,麦克风每 10 ms 推一帧,取慢了就会溢出丢采样点。上述三件事任意一项出现抖动,都会反过来阻塞采集。表现出来是录音断续、开头丢音频,严重时整段音视频都会漂移。
根因是让实时线程承担了非实时的工作。
改造:把音频拆成三级 FIFO
线程模型
改造思路很直接:把采集、处理、写盘拆开,各自独立成线程,中间用队列连接。视频侧也做了同样的处理。
flowchart TD
subgraph Audio["音频:3 个线程 + 2 个队列"]
A1["① 采集线程<br/>取一帧 PCM"] -->|写入| F1[("PCM 队列<br/>200 帧 / 64KB")]
F1 -->|取出| A2["② 处理线程<br/>降噪 / AGC,再切帧编码 AAC<br/>(内部用 16KB 环形缓冲对齐帧长)"]
A2 -->|写入| F2[("AAC 队列<br/>200 帧 / 64KB")]
F2 -->|取出| A3["③ 音频写线程"]
end
subgraph Video["视频:2 个线程 + 1 个队列"]
V1["编码器取流线程"] -->|写入| F3[("视频队列<br/>framerate*4 帧 / 16MB+")]
F3 -->|取出| V2["视频写线程"]
end
A3 --> M["Muxer 交错封装"]
V2 --> M
改造后,采集线程只负责取一帧 PCM 就入队,降噪、编码、写盘都交给了下游线程。
四个线程都单独命名,排查时用 pidstat -p <pid> -t 查看各线程的 CPU 占用,就能判断是采集、处理、音频写还是视频写发生了阻塞。
有界阻塞队列的几个设计点
三级之间使用同一个手写的有界阻塞队列,接口大致如下:
// 编码/采集线程 -> 写盘线程 之间的有界阻塞队列。
// push 时深拷贝,因此调用方可以在 push 返回后立即释放或复用输入缓冲区。
class BlockingFifo {
struct Item {
std::vector<uint8_t> data;
EncodedPacket pack; // 编码包信息(时间戳、帧类型等)
bool has_pack = false;
uint32_t len = 0;
};
// ...
bool push_data(const uint8_t* data, uint32_t len);
bool push_video(const EncodedPacket* pack, const uint8_t* data, uint32_t len);
bool pop(Item& out, int timeout_ms = -1);
void finish(); // 生产者声明“不再有新数据”,消费者清空后退出
private:
std::deque<Item> queue_;
std::mutex mutex_;
std::condition_variable cv_not_empty_;
std::condition_variable cv_not_full_;
size_t max_frames_, max_bytes_, used_bytes_;
bool finished_;
};
实现时有几个点值得说明。
一是同时限制帧数与字节数。只限帧数挡不住大关键帧,只限字节数又挡不住小帧堆积。视频队列就是这么设的:帧数取 framerate * 4,字节数取 max(bitrate/8 * 4, 16MB)。
二是 push 时深拷贝,这一点在嵌入式场景中容易出错。编码器输出的码流包,其数据指针指向硬件 buffer,一旦 release_stream() 就会被编码器回收复用。如果队列只保存指针,写线程拿到的将是被下一帧覆盖的数据。因此入队时要拷贝一份,并把包内指针改到拷贝后的地址:
item.data.resize(len);
memcpy(item.data.data(), data, len);
if (pack) {
item.pack = *pack;
item.pack.addr = item.data.data();
item.pack.len = len;
item.has_pack = true;
}
三是单帧超限的兜底。I 帧有可能大于 max_bytes,此时若一直等待「队列不满」会形成死锁。实现中单独处理,改为等待「队列为空」:
if (len > max_bytes_) {
// 单帧超过队列字节上限时,允许它占满队列,但必须等待队列为空。
cv_not_full_.wait(lock, [this]() { return finished_ || queue_.empty(); });
}
四是停止录像时的退出语义。生产者先调用 finish() 置位并唤醒两个条件变量;消费者在 pop 中拿到「已结束且为空」的信号后,把队列里剩余的数据写完再退出。这样既不会尾部丢帧,也不会遗留未退出的线程——此前就出现过一次写线程没收干净的问题。
PCM 为什么还要经过一层环形缓冲
处理线程输出的 PCM 不直接编码,而是先写入一个 16 KB 的环形缓冲,编码循环再按 1024 样本一帧地取出。
static int encode_from_ring(callback)
{
const int SAMPLES_PER_FRAME = 1024;
const int BYTES_PER_FRAME = SAMPLES_PER_FRAME * 1 * 2; // 单声道 s16 = 2048
while (1) {
if (ring_available() < BYTES_PER_FRAME)
break; // 不够一帧,留着下次
ring_read(frame_data, BYTES_PER_FRAME);
aac_encode(frame_data, out_buf);
callback(out_buf, out_bytes);
}
}
这里要解决的是帧长不对齐的问题:采集一帧是 10 ms(320 字节),编码一帧是 64 ms(2048 字节),两个边界天然错位。环形缓冲就是那个水位,攒够一整帧再编码。16 KB 约 0.5 秒音频,足以吸收调度抖动,延迟也在可接受范围内。
顺带调整的两处
一是关掉编码器的高复杂度选项。这类选项以编码复杂度换音质,在实时链路中只会抬高处理线程的 CPU 占用。
二是理顺收尾顺序:先对 PCM 队列 finish() 并 join() 处理线程,顺带 flush 掉缓冲中不足一帧的尾巴;再对 AAC 队列 finish() 并 join() 写线程。顺序反了或漏掉,最后几十毫秒音频就会缺失。
第二个问题:分段录像音视频错位
FIFO 改造之后,实时性稳定了,但分段录像的音视频会逐段错位。这部分排查花的时间最多。
两条流的时间戳来源不同
封装器给两条流打时间戳的方式完全不同。
视频直接使用编码器硬件给出的 PTS,减去本段首帧,得到相对段内起点的微秒数:
pkt->pts = frame->pts - segment_first_pts;
音频没有硬件时间戳可用,是按帧数推算的:
pkt->pts = audio_frame_index * SAMPLES_PER_FRAME + AUDIO_PTS_OFFSET;
也就是说,视频的时间戳是「测」出来的,音频的是「数」出来的。后面很多问题都源于此。
音频为什么天然超前视频
录像启动时,两条链路的就绪时间不同步。
音频侧在设备初始化完成后,采集线程立即开始工作。视频侧要慢一些:编码器启动、请求一个 IDR,还要等首个同时带 VPS/SPS/PPS 的 I 帧(缺少参数集时 FFmpeg 会写出空的 hvcC,表现为黑屏有声)。这期间还要做 3A 收敛判断,不满足就丢帧,最多丢 20 帧。
于是在「视频首帧尚未到达」的这段时间内,AAC 帧已经持续编码出来。这些帧如果照写,文件开头就会多出一段只有声音没有画面的内容,整段音视频随之错开。
对齐的做法
最基本的做法是首帧锚定:视频以本段第一个 I 帧的 PTS 为零点,保证每段 MP4 都从 0 开始。
再加一道闸门,音频在视频首帧到达之前直接丢弃:
if (session_first_pts == NOPTS) {
return; // 视频还没开始,这帧音频没有对应画面
}
这两条对第一段是够用的。真正麻烦的是第二段往后。
segment_first_pts 是封装器对象的成员,每开一个新分段就重置为无效值;而 session_first_pts 是录像级别的变量,只在录像开始时重置。因此从第二段起,session_first_pts 已经是有效值,上面那道闸门等于失效——新段还在等 I 帧、视频一帧未写,音频已经写进去了。每段开头音频都超前一段「视频预热时长」,段数多了累积起来就很明显。
修法是给这段预热时间记账。视频每跳过一个非 I 帧,就记一笔 1/framerate 的欠账:
// 视频侧:本段还在等首个 I 帧,但本次录像已经有有效首帧(说明是分段场景)
if (frame->type != I_FRAME) {
if (session_first_pts != NOPTS) {
audio_drop_debt_us += 1000000 / framerate;
}
return;
}
音频侧则还账,每丢弃一个 AAC 帧冲抵 64 ms:
// 音频侧:按欠账丢弃对应时长的 AAC 帧
if (audio_drop_debt_us > 0) {
audio_drop_debt_us -= 64000;
return;
}
30fps 下每帧欠 33.3 ms,一个 AAC 帧是 64 ms(1024 样本 @ 16 kHz),欠账还清时音频正好追回到与视频对齐。该欠账值在每次录像开始时清零。
此外还有一道写入节流,用于正常录制期间。写音频之前先比较两条流已写的时长,只有视频更长才放行音频:
video_ahead_cv.wait(lock, [this]() {
if (!recording || audio_sync_abort) return true; // 停了就别等了
int64_t video_duration_us = last_video_pts - segment_first_pts;
if (video_duration_us <= 0) return false;
int64_t audio_duration_us = rescale(
audio_frame_index * SAMPLES_PER_FRAME, audio_tb, US_TIME_BASE);
return video_duration_us > audio_duration_us;
});
这里的等待必须带上「录像停止 / 本段切换」的退出条件,否则一旦条件不满足,写线程将无法退出。视频每写一帧,用 notify_one() 唤醒它。
另外还留了一个备用的运行期纠偏:偏差超过 200 ms 时,直接按视频时长反推音频帧号重新对齐。这段代码目前未挂到调用路径上,属于备用手段,实际生效的是前面几道。
小结
如果你也在做嵌入式音视频录制,以下几点可供参考:
- 实时线程只做实时的工作,编码、写盘、等待都下移到下游。
- 有界队列应同时限制帧数与字节数;push 必须深拷贝,硬件 buffer 的生命周期不可控。
- 生产者与消费者都要有退出语义:
finish()配合带超时的pop,再join(),既防尾部丢帧也防线程残留。 - 明确每个时间戳的作用域。「本段首帧」与「本次录像首帧」是两回事,跨段复用的状态必须在切段时显式处理。
- 音频比视频早启动、或视频预热丢帧时,把这段缺失的时长算清楚并从音频中扣除,比事后纠偏更稳妥。
- 段首值得单独设计,等 I 帧和 3A 收敛都集中在这里。