泠弦月屿 Rinzemoon
← Back to the beginning

音视频

123

2026-04-18 文章 泠時月 13 分钟 4587 字
ffmpegcpp
文件路径: content/posts/1244.md

1.什么是模块化架构?

模块化架构是一种软件设计方法,它将一个大型系统分解为若干个独立的、可替换的模块,每个模块封装了特定的功能,并通过明确的接口与其他模块交互。这种架构的核心目标是降低系统的复杂性,提高可维护性、可测试性和可复用性。

2.FFmpeg实现无拷贝解码主要依赖于以下几种机制:

基于硬件加速(最主流的方式)

这是最常见且最有效的零拷贝场景。其基本思路是让解码器输出一个“引用”或“句柄”,指向硬件设备(如GPU)内存中的数据,而不是将实际像素数据下载到系统内存中。

  • 使用硬件像素格式:解码器会输出特定的硬件像素格式(如 AV_PIX_FMT_VAAPIAV_PIX_FMT_CUDAAV_PIX_FMT_MMALAV_PIX_FMT_DRM_PRIME)。这种格式的 AVFrame 中的数据指针 (data[0], data[1]) 并非指向普通的CPU内存,而是指向一个由硬件驱动管理的特殊内存对象。
  • 获取硬件帧上下文 (AVHWFramesContext):解码上下文 (AVCodecContext) 中的 hw_frames_ctx 字段携带了关于硬件帧的重要信息,如内存池、像素格式、宽高等。其他需要处理这些帧的模块(如滤镜、编码器)可以通过此上下文来正确访问硬件帧。
  • 避免 av_hwframe_transfer_data():在传统的硬解+回读场景中,需要使用此函数将数据从GPU传输到CPU。要实现零拷贝,就需要避免调用此函数,而是直接将硬件帧传递给下一个处理阶段。
基于引用计数和内存复用

在FFmpeg内部,即使是在软件层面,也通过引用计数机制来避免不必要的拷贝。

  • 引用语义AVPacketAVFrame 本质上是容器。av_packet_ref()av_frame_ref() 函数并非复制底层数据,而是创建一个指向同一数据缓冲区的新引用,并增加其引用计数。只有当所有引用都被释放 (av_packet_unref() / av_frame_unref()) 时,底层数据才会被真正销毁。
  • 直接传递指针:在解码器-滤镜-编码器的流水线中,通过传递这些引用,可以避免在模块间复制整个帧的像素数据。

3.什么是 Pimpl 策略?

PimplPointer to Implementation,指向实现的指针)是一种 C++ 惯用法,用于将类的内部实现细节与其公共接口完全分离。其核心思想是:在头文件中只声明一个指向实际实现类的指针(通常是一个不透明指针),而将所有的私有成员函数和变量都放到这个实现类中。这样,公共头文件只暴露必要的接口,所有实现细节都被隐藏。

c++
// widget.h(公共头文件)
class Widget {
public:
    Widget();
    ~Widget();
    void doSomething();
private:
    class Impl;                // 前置声明
    std::unique_ptr<Impl> pImpl; // 指向实现的指针
};
c++
// widget.cpp(实现文件)
#include "widget.h"
#include <iostream>
#include <vector>

class Widget::Impl {
public:
    void doSomething() {
        std::cout << "内部实现" << std::endl;
    }
private:
    std::vector<int> data; // 隐藏的数据成员
};
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default; // 必须在此处定义,因为 Impl 已完整
void Widget::doSomething() { pImpl->doSomething(); }

SDK在这里的实现方式实现源文件(sdk.cpp

c++
class MySDK::VideoProcessor::Impl {
public:
    bool open(const char* filename) {
        return true;
    }
    void start() {
        worker = std::thread([this] { run(); });
    }
    void stop() {
        // 停止并等待
        if (worker.joinable())
            worker.join();
    }
private:
    void run() { /* 处理循环 */ }
    std::thread worker;
    std::vector<uint8_t> buffer;
    // ... 其他私有成员
};
// 构造函数
MySDK::VideoProcessor::VideoProcessor() 
    : pImpl(std::make_unique<Impl>()) {}
// 析构函数必须在实现文件中定义,因为此时 Impl 是完整类型
MySDK::VideoProcessor::~VideoProcessor() = default;
// 公共方法转发
bool MySDK::VideoProcessor::open(const char* filename) {
    return pImpl->open(filename);
}
void MySDK::VideoProcessor::start() {
    pImpl->start();
}
void MySDK::VideoProcessor::stop() {
    pImpl->stop();
}

跨模块内存管理 如果 SDK 以动态库形式提供,客户和库可能使用不同的堆(例如在 Windows 上,调试版和发布版使用不同的 CRT)。直接使用 std::unique_ptr 的默认删除器可能导致崩溃,因为删除操作发生在客户模块,而 new 发生在库模块。解决方案:

  • 使用 std::shared_ptrshared_ptr 的控制块在同一 DLL 内分配,删除器也随控制块,因此可以安全跨模块。
  • 自定义删除器:为 unique_ptr 提供来自库内部的删除器,例如 std::unique_ptr<Impl, void(*)(Impl*)> 并传递库导出的删除函数。
  • 导出销毁函数:提供 void destroyVideoProcessor(VideoProcessor* p),由库负责释放。

最简洁且安全的方式是让库提供工厂函数,返回 std::unique_ptr 配合自定义删除器Pimpl 策略是 C++ 中实现“接口与实现分离”的经典手段,特别适合用于封装 SDK。它通过一个不透明指针隐藏所有内部细节,保证了 ABI 稳定、编译隔离和代码保密。在实际应用中,需注意跨模块内存管理问题,并合理设计接口以提供清晰的用户体验。正确使用 Pimpl 可以显著提升 SDK 的可维护性和健壮性。

4.如何修改CMake去完成静态库文件的产出

步骤 动态库配置 静态库配置
1. 库类型 SHARED STATIC
2. 导出宏 需要定义并应用 移除所有导出宏相关代码
3. 编译定义 添加 -DMYPLAYER_LIBRARY 删除该定义
4. 安装规则 包含 RUNTIMELIBRARYARCHIVE 仅保留 ARCHIVE
5. 公共头文件 类前加 MYPLAYER_EXPORT 类前不加任何宏
6. 生成导出头 可能需要 完全不需要

典型的打包结构如下:

c
MyPlayerSDK/
├── include/
│   └── myplayer/
│       ├── player.h
│       └── videooutput.h
├── lib/
│   ├── libmyplayer.a          # 静态库
│   ├── libmyplayer.so         # 动态库(或带版本号的 so)
│   └── pkgconfig/
│       └── myplayer.pc         # pkg-config 配置(可选,推荐)
├── examples/
│   ├── demo.cpp
│   └── Makefile.example
└── README.md

5.你的项目里锁的精度以及线程安排

  1. 队列锁(RingBuffer 内部锁)
  • 粒度粗粒度(每个队列一把互斥锁,保护整个队列状态)。
  • 适用场景:单生产者-单消费者(SPSC)或多个队列分离时,竞争较小,性能足够。
  • 优化建议
    • 若未来出现多生产者-多消费者(MPMC)或极高帧率,可考虑使用无锁队列(如 boost::lockfree::spsc_queue)或分段锁。
    • 当前设计下,各队列独立(视频包队列、音频包队列、视频帧队列、音频帧队列),锁竞争分散,可接受。
  1. 解码器锁(video_codec_mutex_, audio_codec_mutex_, swr_mutex_
  • 粒度中等粒度(每个解码器/重采样器一把锁)。
  • 作用:保护 FFmpeg 非线程安全的解码上下文和重采样器,确保同一时间只有一个线程访问。
  • 优化建议
    • 锁的持有时间应尽量短,避免在锁内执行耗时操作(如日志、队列操作)。已在 decodeVideoPacket 中只在 FFmpeg 调用时加锁,处理帧时解锁,这是合理的。
    • 对于 flush()seek() 等操作,它们会持有锁进行刷新,可能短暂阻塞解码线程,但频率低,影响小。
  1. 状态锁(保护 pause_fetch_, state_ 等)
  • 粒度细粒度(使用原子变量,无锁)。
  • 实现:将 pause_fetch_state_stop_requested_ 改为 std::atomic,实现无锁读/写,性能高。
  • 注意:当与条件变量配合时,仍需要互斥锁来保护条件变量的等待和通知,原子变量仅用于状态检查,不替代条件变量的锁。
  1. 全局锁(如 m_codecMutex 在旧代码中)
  • 粒度过粗(一把锁保护多个解码器)。
  • 问题:早期代码中 m_codecMutex 同时保护视频和音频解码器,导致视频和音频解码线程相互阻塞,浪费并行性。
  • 改进:已拆分为独立锁,粒度细化到每个资源。

6.音视频同步策略

核心公式

  • 对齐偏移av_offset = frame_pts_ms - audio_time_ms(第一帧对齐时计算)。
  • 期望音频时间expected_audio_time = frame_pts_ms - av_offset
  • 同步差值sync_diff = expected_audio_time - audio_time_ms。 差值 > 0 表示帧“提前”于音频,需要等待;差值 < 0 表示帧“落后”于音频,可能丢弃。

对齐机制

  • 仅在 sync_state_.aligned 为 false 且音频时间有效时计算一次偏移。这假设音视频起始点相同,通常正确。但若播放中途因 seek 或长时间不同步导致偏移变化,当前机制不会重新对齐。可以考虑在差值持续过大时触发重对齐。

同步决策(checkSync

条件 动作 说明
sync_diff < -drop_threshold_ms_ 关键帧:显示;非关键帧:丢弃 严重过时,非关键帧丢弃避免累积延迟
sync_diff < 0 显示 稍微落后,仍可显示(人眼可容忍)
0 <= sync_diff <= sync_threshold_ms_ 显示 在同步窗口内
sync_diff <= drop_threshold_ms_ 等待 提前但未超过丢弃阈值,等待
sync_diff > drop_threshold_ms_ 关键帧:等待;非关键帧:丢弃 严重提前,非关键帧丢弃,关键帧等待

阈值设定:通常 sync_threshold_ms_ 取 30~50ms,drop_threshold_ms_ 取 200~300ms,合理。

等待策略

  • calculateWaitTime 限制等待时间不超过帧间隔(约 33ms@30fps)且 ≤50ms,避免长时间阻塞渲染线程。
  • 等待后重新计算差值,若仍不满足则继续循环,确保帧在恰当时间显示。

7.用了什么设计模式

生产者-消费者模式,单例模式,工厂模式,Pimpl模式。

8.数据传递链路

UrlResolver 与工厂

  • 职责:根据传入的 URL(如 http://.../playlist.m3u8file:///...)解析协议类型,并通过工厂模式创建对应的解复用器实例(如 HLSDemuxerFileDemuxer)。
  • 数据传递:返回一个抽象基类指针(如 BaseDemuxer*),后续模块通过该接口操作解复用器。

Demuxer(解复用器)

  • 职责:封装 FFmpeg 的 AVFormatContext 和相关操作。提供以下接口:
    • readPacket():读取下一个 AVPacket(可能是视频、音频或字幕)。
    • 获取流信息(视频流索引、音频流索引、时间基等)。
  • 内部机制:通常 readPacket() 内部调用 av_read_frame(),这是一个阻塞调用,因此需要在独立线程中执行(你的 fetchThread)。

Fetcher 线程

  • 职责:持续从 Demuxer 拉取 AVPacket,并按照流类型分发到对应的线程安全队列。
  • 数据传递
    • 从 Demuxer 获取 std::shared_ptr<AVData>(包装了 AVPacket)。
    • 将视频包推入 video_packet_queue_,音频包推入 audio_packet_queue_
    • 当遇到 EOS 或读取结束时,向队列推入特殊标记(如 AVData::createEOS())。
  • 线程同步:使用原子标志(如 stop_requested_pause_fetch_)控制循环;通过队列的阻塞 push/pop 实现流控。

Decoder 模块(包含解码线程)

  • 职责:从各自的 Packet 队列中取包,调用 FFmpeg 解码器生成 AVFrame,并将帧推送到 Frame 队列。
  • 内部结构
    • 视频解码线程:独立循环,取视频包 → avcodec_send_packetavcodec_receive_frame → 创建 AVData 包装帧 → 推入 video_frame_queue_
    • 音频解码线程:类似,但可能涉及重采样(swr_convert)。
  • 数据传递
    • 输入:video_packet_queue_ / audio_packet_queue_
    • 输出:video_frame_queue_ / audio_frame_queue_
    • 每个帧都包含 PTS、持续时间等信息。
  • 线程安全:为每个解码器加独立锁(如 video_codec_mutex_),避免与主线程的 flush/seek 冲突。

AVSync 模块

  • 职责:管理音视频同步,对外提供同步后的帧。
  • 数据传递
    • 从视频帧队列取帧,同时获取当前音频时钟(由音频渲染线程更新)。
    • 计算同步差值,决策显示、丢弃或等待。
    • 提供接口 getNextVideoFrame(timeout_ms)getNextAudioFrame(timeout_ms),外部调用者(渲染模块)通过这些接口获取帧。
  • 关键算法:以音频为时钟,维护一个 av_offset 对齐音视频起始点,计算 sync_diff,根据阈值决策。

渲染模块

  • 视频渲染(通常在主线程或 OpenGL 上下文线程):
    • 循环调用 AVSync::getNextVideoFrame() 获取已同步的视频帧。
    • 将帧数据上传到纹理并绘制在 QOpenGLWidget 上。
  • 音频渲染(SDL2 回调线程):
    • 在回调中调用 AVSync::getNextAudioFrame() 获取音频帧(或从解码器队列取,取决设计)。
    • 将 PCM 数据填充到 SDL 缓冲区,同时更新音频时钟(setAudioClock),供同步模块使用。
  • 数据流:渲染模块是消费者,从同步模块获取帧;同步模块内部维护帧队列,并通过条件变量阻塞等待。

9.流媒体的重连策略与稳定性策略

对比维度 RTMP (Real-Time Messaging Protocol) HLS (HTTP Live Streaming)
传输方式 长连接 (TCP),持续推送数据 短连接 (HTTP),分段下载 (ts片段 + m3u8索引)
数据单位 流式数据包 (AVPacket) 文件片段 (ts文件,通常 2-10秒)
延迟特性 低延迟 (通常 2-5秒) 高延迟 (通常 10-30秒)
重连性质 连接层重连 (重建TCP/RTMP会话) 应用层重拉 (重新请求新的m3u8和ts)
中断表现 连接断开立即停止接收数据 当前ts下载失败,需请求新的ts

RTMP重连策略的核心是通过检测网络中断、执行指数退避重试、并在重连成功后恢复播放状态,以保证流媒体播放的稳定性。主要步骤包括:

  1. 超时检测:通过设置FFmpegrw_timeout参数或自定义中断回调,及时感知连接断开或长时间无数据,触发重连流程。
  2. 重连触发与退避:一旦检测到错误,播放器进入重连状态,采用指数退避算法逐步增加重试间隔(如1秒、2秒、4秒…),并加入随机抖动避免大量客户端同时重连造成服务器压力。重试达到最大次数后停止并报告失败。
  3. 资源重建:重连时需关闭旧的AVFormatContext,重新打开RTMP URL,并重新查找流信息。若流参数(如分辨率、采样率)发生变化,需重新初始化解码器;若未变,则仅刷新解码器内部缓冲。
  4. 解码器刷新与队列清空:重连成功后调用avcodec_flush_buffers清理解码器缓存,并清空音视频数据队列,防止残留的旧数据影响新流播放。
  5. 状态通知:通过信号向上层反馈重连进度(如“正在重连第几次”)和最终结果,以便UI展示提示或处理错误。

HLS(HTTP Live Streaming)基于HTTP短连接,其重连策略与RTMP长连接有本质区别。由于HLS将流分割为一个个独立的TS片段并通过M3U8索引文件管理,因此它的稳定性策略主要围绕片段的可靠下载索引的持续更新展开。

  1. 分层重试机制 播放器针对清单(M3U8)和媒体片段(TS)采用不同的重试策略。加载主索引或更新直播流的索引失败时,会按照固定间隔(例如2秒)进行重试,重试次数可配置(如5次),多次失败后判定流不可用。下载某个TS片段失败(如HTTP 404或超时)时,则立即以较短间隔(如1秒)重试该片段,重试次数有限(如3次),若该片段永久缺失则跳过它继续请求下一个片段,避免播放卡死。
  2. 智能离线检测 播放器会记录连续失败次数。当清单连续多次更新失败或片段连续缺失超过阈值时,认为直播流已永久中断,停止重试并向上层抛出错误事件,防止无限重试。
  3. 源切换与多路备份 支持配置多个备选源(如不同清晰度或CDN地址),当主源频繁失败时自动切换到备选源继续播放。切换可基于清晰度降级或CDN优先级,提高播放可用性。
  4. 数据连续性处理 重连后可能遇到时间线不连续的情况。若服务器在M3U8中插入了EXT-X-DISCONTINUITY标签,播放器需重置解码器(如调用avcodec_flush_buffers)并清空缓冲区,防止音画不同步或解码错误。
  5. 缓冲区配合 播放器的音视频缓冲(如RingBuffer)能够平滑短暂的下载中断。当缓冲数据足够时(例如剩余3秒以上),即使当前片段下载失败,播放器仍可继续播放,同时在后台静默重试,为网络恢复争取时间。
  6. 超时控制 对每个HTTP请求设置合理的超时时间(如5秒),避免因单个片段阻塞而影响整体播放。

10.你的启动延迟,音视频同步误差怎么来的。

在资源Dumex阶段,我使用Debug在控制台的数据差来确定启动延迟,也就是资源加载用时,音视频同步方面我根据滚动的qDebug来确定同步误差的。

11.你的Seek流程是怎样的

  1. 获取媒体总时长 解复用器打开输入后,从AVFormatContextduration字段可读取以微秒为单位的媒体总时长。若该值为AV_NOPTS_VALUE,表示无法获取时长(如直播流),此时Seek行为需根据协议特性调整:对于点播文件,时长有效;对于直播流,通常只能跳转到当前可用的时间窗口内(如HLS的最新片段)。
  2. 验证目标时间 上层传入的目标时间一般以毫秒为单位,需要转换为解复用器内部的时间基,并与总时长比较:
    • 小于0时,强制跳转到0毫秒(开头)。
    • 大于总时长时,对于点播文件,可跳转到总时长位置(即末尾),但通常播放器会将其限制为总时长,避免超出;对于直播流,若目标超出当前窗口,则拒绝Seek或跳转到最新可用位置。
  3. 关键帧对齐 为保证跳转后解码器能正常工作,需要将目标时间对齐到关键帧。FFmpeg的av_seek_frame函数配合AVSEEK_FLAG_BACKWARD标志,会自动定位到目标时间之前的最近关键帧。这避免了因非关键帧缺少参考帧而导致的花屏问题。
  4. 边界情况处理 若Seek发生在播放暂停或缓冲状态,需先暂停数据生产,再执行跳转;若播放已结束,则需重置内部状态后再跳转。对于不支持Seek的媒体(如某些直播协议),应直接拒绝并向上层报告错误。

二、Seek流程的完整步骤

Seek流程涉及播放器多个模块的协同,需严格按顺序执行,确保线程安全和数据一致性:

  1. 暂停数据生产 设置原子标志(如pause_fetch_state_ = Paused),通知fetch线程和解码线程停止生产数据,并通过条件变量唤醒可能阻塞的线程,使其进入等待状态。
  2. 清空所有数据队列 清空视频/音频包队列和帧队列,丢弃所有旧数据。清空后必须唤醒可能因等待数据而阻塞的消费者线程(如渲染线程),防止永久阻塞。
  3. 刷新解码器内部缓冲 通过互斥锁保护解码器上下文,调用avcodec_flush_buffers清理解码器中缓存的帧,避免残留数据影响新流。
  4. 执行解复用器跳转 在加锁保护下,使用av_seek_frame将解复用器的读取位置移动到目标关键帧。跳转参数需转换为对应流的时间基,并选择AVSEEK_FLAG_BACKWARD确保定位到关键帧。
  5. 重置同步模块 调用同步模块的reset方法,清除音视频对齐偏移和音频时钟值,使同步回到未对齐状态,等待新位置的第一帧重新建立同步关系。
  6. 恢复播放 清除暂停标志,将播放器状态置为Decoding,唤醒所有等待的工作线程。fetch线程从新位置读取数据包,解码线程处理新包并产生帧,渲染线程从同步模块获取新帧,恢复流畅播放。
© 泠時月 2026,采用 CC BY 4.0 许可,转载保留署名。

留言 · 0 段对话

扫码分享

二维码