历时一个月我的基于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_inputavcodec_alloc_context3对应avcodec_free_contextav_packet_alloc对应av_packet_freeav_frame_alloc对应av_frame_free。如果每一处都手写释放逻辑那么只要中间出现错误返回、提前退出或者异常路径就很容易漏掉某个释放步骤最后造成内存泄漏。这也引出了如valgrind这样的内存泄漏检测工具这个后面再说。为了降低这种手动释放资源带来的风险C11 引入了更现代的智能指针体系比如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_ptrAVFormatContext, AVFormatContextDeleter;这里面有非常多的细节也是我刚开始学习时非常困惑的对象首先关于unique_ptr的格式他的常见写法不只是std::unique_ptrT更完整地看它其实是std::unique_ptrT, Deleter其中T表示它管理的资源类型Deleter表示资源释放时要调用的删除策略。如果不显式指定Deleterunique_ptr默认会使用std::default_deleteT也就是在析构时调用delete ptr。但 FFmpeg 的资源不是通过delete释放的比如AVFormatContext要调用avformat_close_inputAVPacket要调用av_packet_freeAVFrame要调用av_frame_free。因此我必须为这些资源指定自己的删除器。回到我的代码using AVFormatContextPtr std::unique_ptrAVFormatContext, 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_ptrAudioDecoder是因为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文件里