历时一个月,我的基于ffmpeg的简单播放器也算是正式完工了,所以写一篇博客这个来记录一下
关于RAII
完整代码见https://github.com/teng-super/mini-player/blob/main/include/ffmpeg_raii.h
首先是关于封装,raii是c++的重要思想之一,意思是资源获取即初始化,我一开始对它的理解比较浅,只觉得它是把free或close之类的释放函数放进析构函数里,等对象离开作用域时自动释放资源。后来在写 FFmpeg 代码时才慢慢意识到,RAII 更准确的含义是:把资源的生命周期绑定到对象的生命周期,让对象的构造、析构机制替我们管理资源。这点在 FFmpeg 项目里尤其重要。FFmpeg 是 C 风格库,大量资源都需要手动成对管理,比如avformat_open_input对应avformat_close_input,avcodec_alloc_context3对应avcodec_free_context,av_packet_alloc对应av_packet_free,av_frame_alloc对应av_frame_free。如果每一处都手写释放逻辑,那么只要中间出现错误返回、提前退出或者异常路径,就很容易漏掉某个释放步骤,最后造成内存泄漏。这也引出了如valgrind这样的内存泄漏检测工具,这个后面再说。
为了降低这种手动释放资源带来的风险,C++11 引入了更现代的智能指针体系,比如std::unique_ptr、std::shared_ptr和std::weak_ptr。在我的播放器项目里,FFmpeg 资源大多具有明确的独占所有权,因此我主要使用std::unique_ptr来管理这些资源。
关于unque_ptr
不过ffmpeg是c风格库,它的资源释放函数并不一定能直接匹配std::unique_ptr默认的delete行为。比如avformat_open_input需要对应的avformat_close_input释放,其他的对像如编解码器上下文,packet和frame等都是如此,所以我采取了自定义删除器的方式,如下文:
struct AVFormatContextDeleter { void operator()(AVFormatContext* ctx) const { if (ctx) { avformat_close_input(&ctx); } } }; using AVFormatContextPtr = std::unique_ptr<AVFormatContext, AVFormatContextDeleter>;这里面有非常多的细节,也是我刚开始学习时非常困惑的对象,首先关于unique_ptr的格式,他的常见写法不只是
std::unique_ptr<T>更完整地看,它其实是:
std::unique_ptr<T, Deleter>其中T表示它管理的资源类型,Deleter表示资源释放时要调用的删除策略。如果不显式指定Deleter,unique_ptr默认会使用std::default_delete<T>,也就是在析构时调用delete ptr。但 FFmpeg 的资源不是通过delete释放的,比如AVFormatContext要调用avformat_close_input,AVPacket要调用av_packet_free,AVFrame要调用av_frame_free。因此,我必须为这些资源指定自己的删除器。
回到我的代码:
using AVFormatContextPtr = std::unique_ptr<AVFormatContext, AVFormatContextDeleter>;这行代码的意思就是:定义一个专门管理AVFormatContext的智能指针类型。它持有的是AVFormatContext*,但析构时不会调用delete,而是调用我写的AVFormatContextDeleter,最终执行avformat_close_input(&ctx)。这样一来,FFmpeg 的 C 风格资源就被包装成了符合 C++ RAII 思想的对象。
FFmpeg的二级指针思想
不过这里又引出了另一个让我一开始很困惑的点:为什么 FFmpeg 里很多释放函数都要传入二级指针?
比如avformat_close_input的参数不是AVFormatContext*,而是AVFormatContext**;av_packet_free和av_frame_free也是类似风格。也就是说,我们调用时通常不是这样写:
av_packet_free(pkt);而是这样写:
av_packet_free(&pkt);这背后的原因是:如果函数只接收一级指针,那么它只能释放这个指针指向的资源,却无法修改调用者手里的指针变量。释放完成后,调用者原来的指针变量仍然保存着旧地址,这个地址已经失效了,也就是所谓的悬空指针。
不过这里也要注意,二级指针并不能从根本上消灭所有悬空指针问题。它只能把“传进去的那个指针变量”置空。如果同一块资源的地址在别处还有副本,那么那些副本依然会变成悬空指针。所以更根本的解决方式仍然是明确所有权,让同一份资源尽量只有一个明确的拥有者。
这正好解释了为什么我在项目里要用std::unique_ptr来包装 FFmpeg 资源。unique_ptr本身强调独占所有权,而 FFmpeg 的释放函数负责真正释放底层资源。两者结合之后,就能把“谁拥有资源、什么时候释放、怎么释放”这三件事统一起来,代码也就更安全、更容易维护。
不过,在后续的一些类里,我并没有把所有指针都写成智能指针。比如在AudioPlayer中,我保存了一个指向AudioDecoder的原始指针:
AudioDecoder* decoder_ = nullptr; // 不拥有这里之所以没有使用std::unique_ptr<AudioDecoder>,是因为AudioPlayer并不负责创建和销毁AudioDecoder。它只是借用外部已经存在的AudioDecoder,从里面取解码后的音频帧,再经过重采样和 FIFO 缓冲后交给 SDL 音频回调播放。
这其实涉及 C++ 里一个非常重要的工程原则:指针不只表示“指向某个对象”,还隐含了“是否拥有这个对象”的语义。如果一个类负责某个资源的生命周期,那么它就应该用 RAII 方式管理这个资源,比如使用std::unique_ptr、自定义 deleter,或者把资源封装成成员对象,在析构函数中释放。反过来,如果一个类只是临时访问或借用另一个对象,并不负责释放它,那么使用原始指针或引用反而更清晰。
也就是说,智能指针不是为了替代所有原始指针,而是为了表达所有权。std::unique_ptr表示独占所有权,std::shared_ptr表示共享所有权,而普通裸指针在很多工程代码里表示非拥有关系,也就是“我可以使用它,但我不负责销毁它”。
在我的项目里,AudioDecoder的生命周期由主程序统一管理,AudioPlayer只保存一个非拥有指针来访问它。因此这里使用原始指针是合理的。真正需要 RAII 封装的,是AVFormatContext、AVCodecContext、AVPacket、AVFrame、SwsContext、SwrContext这类必须手动释放的底层资源。
统一错误处理
在完成 RAII 封装之后,我又单独写了一个ffmpeg_error.h,用来统一处理 FFmpeg 的错误信息。这样做的原因很简单:FFmpeg 的大多数 API 都是 C 风格接口,函数调用失败时通常不会抛出异常,而是返回一个负数错误码。
如果直接打印这个错误码,看到的可能只是类似-1094995529这样的数字。这个数字对调试几乎没有帮助,因为我很难从它本身看出到底是文件打不开、解码器初始化失败,还是输入数据格式有问题。所以需要借助 FFmpeg 提供的av_strerror,把错误码转换成可读字符串。
我的封装如下:
//把ffmpeg的报错记录可视化 inline std::string FFmpegErrorString(int errnum){ char buf[AV_ERROR_MAX_STRING_SIZE]={0};//AV_ERROR_MAX_STRING_SIZE是 FFmpeg 库中定义的一个宏 //用于指定存储错误字符串的缓冲区的最大大小。 av_strerror(errnum,buf,sizeof(buf)); return std::string(buf); } // 检查 FFmpeg 调⽤结果,出错时打印信息 // ⽤法:CheckFFmpeg(avformat_open_input(...), "Failed to open input") // 返回值:成功为 true,失败为 false inline bool CheckFFmpeg(int ret, const std::string& context) { if (ret < 0) { std::cerr << "[FFmpeg error] " << context << ": " << FFmpegErrorString(ret) << " (code=" << ret << ")" << std::endl; return false; } return true; }这里的FFmpegErrorString负责把 FFmpeg 的错误码转换成字符串。AV_ERROR_MAX_STRING_SIZE是 FFmpeg 定义的错误字符串缓冲区大小,av_strerror会把错误码对应的说明写入这个缓冲区,最后再转换成std::string返回。
而CheckFFmpeg则负责统一检查函数返回值。FFmpeg 中很多函数遵循同一种约定:返回值小于 0 表示失败,返回值大于或等于 0 表示成功。所以我把这个判断逻辑封装起来,避免在代码里到处写重复的if (ret < 0)。
这个函数里还有一个context参数,它用来说明当前是哪一步调用失败了。比如:
CheckFFmpeg( avformat_open_input(&raw, path.c_str(), nullptr, nullptr), "avformat_open_input" );如果这里出错,日志不会只打印一个错误码,而是会告诉我是哪一个 FFmpeg API 失败了,以及对应的错误原因。这样排查问题时就清楚很多。
不过这里也有一个必须注意的细节:不是所有负数返回值都代表“真正的错误”。在 FFmpeg 的解码流程里,AVERROR(EAGAIN)和AVERROR_EOF经常是正常控制流的一部分。比如EAGAIN表示解码器当前还需要更多输入数据,EOF表示流已经结束。这类情况不能直接交给CheckFFmpeg当作普通错误打印,而应该在具体的解码逻辑里单独判断。
还有就是关于inline内联函数,在旧的c++标准里是用于把定义的函数在主文件里展开从而提高编译效率,但是在新版c++里,编译器会根据情况自己决定是否内联,所以inline在这里更重要的是解决头文件中函数定义的重复定义问题。它允许同一个函数定义出现在多个翻译单元中,只要定义完全一致,就不违反单一定义规则。说人话翻译单元就是编译时头文件被展开的那一坨代码,而这个头文件又可以出现在多个.cpp文件里