C++ FFmpeg音视频录制实战:从设备采集到MP4封装
1. 项目概述为什么需要自己动手实现音视频录制在音视频应用开发中录制功能是基石。无论是视频会议、在线教育、游戏直播还是安防监控最终都需要将采集到的音视频数据持久化保存。市面上有很多现成的库和工具比如直接调用系统API或者使用一些高级框架但当你需要对录制过程进行精细控制——比如自定义编码参数、实现低延迟的同步、处理复杂的音画同步逻辑或者将录制模块深度集成到你的C应用中时自己动手基于FFmpeg实现一套录制流程就变得至关重要。FFmpeg作为音视频领域的“瑞士军刀”其libavcodec、libavformat等库提供了底层、强大且灵活的控制能力。通过C直接调用这些库你可以从驱动层拿到原始的音频采样和视频帧数据然后按照你的意愿进行编码、复用封装最终生成一个标准的MP4文件。这个过程听起来复杂但拆解开来无非是几个核心环节的串联设备采集、数据编码、音视频同步、封装写入。本教程的目标就是带你一步步走通这个流程让你不仅知道怎么调用API更理解每一步背后的原理和设计考量最终实现一个稳定、高效的同步音视频录制器。2. 环境准备与FFmpeg库集成在开始写代码之前搭建一个顺手的开发环境是第一步。对于C项目我强烈推荐使用CMake进行项目管理它能很好地处理库依赖和跨平台编译。2.1 获取与编译FFmpeg库首先你需要FFmpeg的开发库头文件和链接库。有两种主流方式使用包管理器推荐给新手快速上手在Ubuntu上你可以通过sudo apt install libavcodec-dev libavformat-dev libavutil-dev libswscale-dev来安装。在macOS上使用brew install ffmpeg。这种方式最省心但库的版本可能不是最新的。从源码编译推荐给需要特定配置或深度定制的开发者从FFmpeg官网下载源码编译时可以裁剪掉不需要的组件减少最终二进制体积。一个典型的配置命令如下./configure --prefix/usr/local/ffmpeg --enable-shared --disable-static --enable-gpl --enable-nonfree --enable-libx264 --enable-libfdk-aac make -j$(nproc) sudo make install注意--enable-libx264和--enable-libfdk-aac需要你先安装对应的编码器库。编译完成后库文件通常在/usr/local/ffmpeg/lib头文件在/usr/local/ffmpeg/include。我个人更倾向于源码编译因为你可以精确控制支持的编码格式和功能模块。例如在嵌入式环境或对安装包体积敏感的场景下裁剪掉不需要的解码器、滤镜和协议支持可以显著减小依赖库的大小。2.2 CMake项目配置假设你的项目目录结构如下MyRecorder/ ├── CMakeLists.txt ├── include/ ├── src/ │ └── main.cpp └── third_party/ # 存放FFmpeg库文件如果使用静态链接或自定义路径你的CMakeLists.txt需要正确找到FFmpeg。这里演示如何查找系统安装的FFmpegcmake_minimum_required(VERSION 3.10) project(AVRecorder) set(CMAKE_CXX_STANDARD 11) # 查找FFmpeg的各个组件 find_package(PkgConfig REQUIRED) pkg_check_modules(AVCODEC REQUIRED libavcodec) pkg_check_modules(AVFORMAT REQUIRED libavformat) pkg_check_modules(AVUTIL REQUIRED libavutil) pkg_check_modules(SWSCALE REQUIRED libswscale) # 如果需要重采样音频可能还需要 libswresample # pkg_check_modules(SWRESAMPLE REQUIRED libswresample) include_directories(${AVCODEC_INCLUDE_DIRS} ${AVFORMAT_INCLUDE_DIRS} ${AVUTIL_INCLUDE_DIRS} ${SWSCALE_INCLUDE_DIRS}) add_executable(av_recorder src/main.cpp) target_link_libraries(av_recorder ${AVCODEC_LIBRARIES} ${AVFORMAT_LIBRARIES} ${AVUTIL_LIBRARIES} ${SWSCALE_LIBRARIES})如果FFmpeg安装在自定义路径可以使用find_library和find_path手动指定。配置好CMake后在构建目录执行cmake .. make应该就能顺利生成可执行文件了。这一步的常见坑点是库路径不对或版本不匹配务必确保pkg-config能找到正确的.pc文件。3. 核心流程设计与数据结构初始化一个完整的录制流程可以抽象为一条数据处理管线。我们需要先设计好这条管线的各个节点并初始化对应的FFmpeg数据结构。3.1 录制流程总览整个程序的核心流程可以概括为以下几步初始化打开音视频输入设备如摄像头、麦克风创建输出文件上下文并为其配置音视频流。采集循环在一个主循环中分别从音频设备和视频设备读取数据包AVPacket。编码与写入将读取到的原始数据包送入对应的编码器AVCodecContext将编码后的数据包写入输出文件并处理好音视频包的时间戳PTS/DTS以实现同步。收尾工作跳出循环后刷新编码器缓冲区中可能残留的数据写入文件尾然后释放所有资源。这个流程的关键在于第三步的“同步”。音视频数据是独立采集的它们有各自的时间线采样时间、帧时间。我们必须将它们映射到同一个时间基准输出文件的time_base上否则播放时就会音画不同步。3.2 关键数据结构解析FFmpeg的操作围绕几个核心结构体展开理解它们是编码的前提AVFormatContext格式上下文。对于输入它代表一个多媒体文件或设备流对于输出它代表你要生成的文件。它是整个操作的“总管”。AVCodecContext编解码器上下文。它持有某个特定音视频流的编解码参数、状态信息。你需要为音频流和视频流分别创建。AVStream流。一个媒体文件如MP4中可以包含多条流如一条视频流、一条音频流。AVStream包含了这条流的元信息编码格式、时间基、帧率等。AVPacket数据包。编码前或解码后的数据基本单位。对于原始设备采集我们通常拿到的是未编码的AVPacket对于编码后的数据也是以AVPacket的形式传递。AVFrame帧。未编码的原始音视频数据单位。一个视频帧YUV数据或一段音频采样PCM数据。编码器吃进去的是AVFrame吐出来的是AVPacket。在录制场景中我们从设备如v4l2或dshow采集到的数据FFmpeg通常已经封装成了AVPacket但内容是未压缩的。我们的主要工作是将这些AVPacket送入编码器再将编码后的AVPacket写入输出文件。4. 输入设备采集与输出文件初始化4.1 打开音视频输入设备FFmpeg通过avformat_open_input函数打开输入源这个输入源可以是文件URL也可以是设备标识符。在Linux上视频设备通常是/dev/video0音频设备可能是hw:0。在Windows上视频设备可能是video“Integrated Camera”音频设备是audio“Microphone Array”。为了通用性我们使用FFmpeg的设备抽象格式视频-f dshow -i video“摄像头名称”(Windows) 或-f v4l2 -i /dev/video0(Linux)音频-f dshow -i audio“麦克风名称”(Windows) 或-f alsa -i hw:0(Linux)在代码中我们分别打开两个独立的AVFormatContextAVFormatContext* video_input_ctx nullptr; AVFormatContext* audio_input_ctx nullptr; // 打开视频设备 const char* video_dev_name “videoIntegrated Camera”; // Windows示例 AVDictionary* video_options nullptr; av_dict_set(video_options, “framerate”, “30”, 0); // 设置期望帧率 if (avformat_open_input(video_input_ctx, video_dev_name, av_find_input_format(“dshow”), video_options) 0) { // 处理错误 } av_dict_free(video_options); // 打开音频设备 const char* audio_dev_name “audioMicrophone Array”; AVDictionary* audio_options nullptr; av_dict_set(audio_options, “sample_rate”, “44100”, 0); // 设置采样率 if (avformat_open_input(audio_input_ctx, audio_dev_name, av_find_input_format(“dshow”), audio_options) 0) { // 处理错误 } av_dict_free(audio_options);实操心得设备名称的获取是个小坑。在Windows上你可以先通过ffmpeg -list_devices true -f dshow -i dummy命令列出所有可用的音视频设备。在代码中处理这些字符串时要注意转义和编码。另外avformat_open_input的最后一个参数是选项字典这里可以传递很多设备特有的参数比如分辨率、像素格式、音频通道数等如果设备支持FFmpeg会尝试按此配置打开。4.2 创建输出文件与配置流接下来我们创建输出文件的上下文并为其添加一条视频流和一条音频流。AVFormatContext* output_ctx nullptr; avformat_alloc_output_context2(output_ctx, nullptr, “mp4”, “output.mp4”); if (!output_ctx) { // 通常是因为不支持mp4格式可以尝试“mov”或“mkv” } // 创建视频输出流 AVStream* video_stream avformat_new_stream(output_ctx, nullptr); video_stream-id output_ctx-nb_streams - 1; // 创建音频输出流 AVStream* audio_stream avformat_new_stream(output_ctx, nullptr); audio_stream-id output_ctx-nb_streams - 1;现在有了空的流我们需要为它们配置编码器。这里我们选择最通用的H.264编码视频和AAC编码音频。// 查找视频编码器 const AVCodec* video_codec avcodec_find_encoder_by_name(“libx264”); if (!video_codec) video_codec avcodec_find_encoder(AV_CODEC_ID_H264); AVCodecContext* video_codec_ctx avcodec_alloc_context3(video_codec); // 配置视频编码参数 video_codec_ctx-height 720; video_codec_ctx-width 1280; video_codec_ctx-pix_fmt AV_PIX_FMT_YUV420P; // H.264最兼容的格式 video_codec_ctx-time_base {1, 30}; // 假设帧率30fpstime_base 1/帧率 video_codec_ctx-framerate {30, 1}; video_codec_ctx-bit_rate 2500000; // 2.5 Mbps video_codec_ctx-gop_size 30; // 关键帧间隔 // 一些针对MP4和快速启动的优化设置 video_codec_ctx-flags | AV_CODEC_FLAG_GLOBAL_HEADER; // MP4需要全局头 // 将编码器上下文与输出流关联 if (avcodec_parameters_from_context(video_stream-codecpar, video_codec_ctx) 0) { // 处理错误 } // 音频编码器配置类似 const AVCodec* audio_codec avcodec_find_encoder_by_name(“aac”); AVCodecContext* audio_codec_ctx avcodec_alloc_context3(audio_codec); audio_codec_ctx-sample_rate 44100; audio_codec_ctx-channel_layout AV_CH_LAYOUT_STEREO; audio_codec_ctx-channels 2; audio_codec_ctx-sample_fmt AV_SAMPLE_FMT_FLTP; // AAC编码常用格式 audio_codec_ctx-time_base {1, audio_codec_ctx-sample_rate}; audio_codec_ctx-bit_rate 128000; audio_codec_ctx-flags | AV_CODEC_FLAG_GLOBAL_HEADER; if (avcodec_parameters_from_context(audio_stream-codecpar, audio_codec_ctx) 0) { // 处理错误 }配置好编码器上下文后需要打开编码器并打开输出文件进行写入。// 打开视频编码器 if (avcodec_open2(video_codec_ctx, video_codec, nullptr) 0) { // 处理错误 } // 打开音频编码器 if (avcodec_open2(audio_codec_ctx, audio_codec, nullptr) 0) { // 处理错误 } // 写入文件头 if (avformat_write_header(output_ctx, nullptr) 0) { // 处理错误 }注意事项AV_CODEC_FLAG_GLOBAL_HEADER这个标志非常重要。对于MP4、MOV这类容器格式编码器的配置信息如SPS/PPS for H.264需要放在文件头部而不是每个关键帧里。设置这个标志后avcodec_open2之后可以通过output_ctx-streams[i]-codecpar-extradata获取到这些全局头信息并在写文件头时写入。如果不设置生成的MP4文件可能无法被某些播放器识别。5. 音视频数据采集、编码与同步写入这是整个程序的核心循环。我们需要同时从两个设备读取数据编码并以正确的时间戳写入同一个文件。这里最大的挑战是同步。5.1 主循环结构与数据读取我们使用一个while循环持续从设备读取数据包。由于音视频是两个独立的输入源我们需要管理两个读取过程。一种简单且有效的方法是使用av_read_frame的非阻塞模式或者使用多线程。为了简化我们先采用轮询的方式并引入一个简单的“时钟”概念来控制写入节奏。AVPacket* video_pkt av_packet_alloc(); AVPacket* audio_pkt av_packet_alloc(); int64_t video_pts 0; int64_t audio_pts 0; // 获取系统启动后的相对时间作为参考时钟 auto start_time std::chrono::steady_clock::now(); while (!stop_recording) { // 1. 尝试从视频设备读取一帧 if (av_read_frame(video_input_ctx, video_pkt) 0) { // 处理视频包... } // 2. 尝试从音频设备读取一包 if (av_read_frame(audio_input_ctx, audio_pkt) 0) { // 处理音频包... } // 短暂休眠以避免CPU空转 std::this_thread::sleep_for(std::chrono::milliseconds(1)); }从设备读取到的AVPacket其pts(Presentation Time Stamp, 显示时间戳) 和dts(Decoding Time Stamp, 解码时间戳) 通常是以设备时间基为单位的。我们需要将它们转换到编码器的时间基最后再转换到输出流的时间基。5.2 时间戳的转换与同步策略同步的本质是让音频和视频按照一个统一的时间轴来呈现。我们选择音频作为主时钟因为人对音频的断续更敏感。策略如下计算每个视频帧“应该”被呈现的时刻基于帧率。计算每个音频包“应该”被呈现的时刻基于采样率和采样数。在写入时确保视频帧的pts不落后于音频pts太多也不超前太多。如果视频快了就稍作等待或丢弃一些非关键帧如果视频慢了则可能需要追赶在录制场景下更常见的处理是尽力写入后期处理时再处理同步问题或者允许轻微的音频领先。时间戳转换是同步的关键// 假设 video_pkt 从设备读取其时间基是 video_input_ctx-streams[0]-time_base // 目标时间基是 video_codec_ctx-time_base (编码器时间基) 和 video_stream-time_base (输出流时间基) // 第一步将设备pts转换为编码器时间基 AVRational src_time_base video_input_ctx-streams[video_pkt-stream_index]-time_base; AVRational dst_time_base video_codec_ctx-time_base; video_pkt-pts av_rescale_q(video_pkt-pts, src_time_base, dst_time_base); video_pkt-dts video_pkt-pts; // 对于未编码的原始流通常dtspts video_pkt-duration av_rescale_q(video_pkt-duration, src_time_base, dst_time_base); // 第二步将编码后的包的pts从编码器时间基转换到输出流时间基 // 这一步通常在编码后写入前进行见下一小节对于音频转换逻辑类似但要注意音频的duration是以采样数为单位的转换时间基时需要使用av_rescale_q。5.3 编码与写入文件读取到的原始AVPacket需要送入编码器。FFmpeg的编码API通常需要配合AVFrame使用但当我们从某些设备如dshow,v4l2以原始格式如rgb24,yuyv422读取时拿到的是未编码的AVPacket。这时我们需要先将AVPacket转换为AVFrame然后再编码。不过更简单的方式是让FFmpeg在打开输入设备时就直接输出编码后的数据如果设备支持硬件编码但这会丧失灵活性。本教程假设我们进行软件编码因此需要转换。由于篇幅这里省略了AVPacket到AVFrame的转换细节涉及sws_scale用于视频swr_convert用于音频重采样。我们聚焦在编码和写入的逻辑上。// 假设我们已经有了一个准备好数据的 video_frame (AVFrame*) // 发送帧到编码器 if (avcodec_send_frame(video_codec_ctx, video_frame) 0) { // 发送失败处理 } // 从编码器接收编码后的包 while (true) { AVPacket encoded_pkt; av_init_packet(encoded_pkt); encoded_pkt.data nullptr; encoded_pkt.size 0; int ret avcodec_receive_packet(video_codec_ctx, encoded_pkt); if (ret AVERROR(EAGAIN) || ret AVERROR_EOF) { // EAGAIN: 编码器需要更多输入帧 // EOF: 编码器已刷新没有更多输出 av_packet_unref(encoded_pkt); break; } else if (ret 0) { // 其他错误 av_packet_unref(encoded_pkt); break; } // 编码成功现在处理 encoded_pkt // 关键步骤将包的时间戳从编码器时间基转换到输出流时间基 encoded_pkt.pts av_rescale_q(encoded_pkt.pts, video_codec_ctx-time_base, video_stream-time_base); encoded_pkt.dts av_rescale_q(encoded_pkt.dts, video_codec_ctx-time_base, video_stream-time_base); encoded_pkt.duration av_rescale_q(encoded_pkt.duration, video_codec_ctx-time_base, video_stream-time_base); encoded_pkt.stream_index video_stream-index; // 写入文件 if (av_interleaved_write_frame(output_ctx, encoded_pkt) 0) { // 写入失败处理 } av_packet_unref(encoded_pkt); }音频的编码和写入流程完全类似只是编码器上下文和流对象不同。av_interleaved_write_frame函数会负责根据包的时间戳将音频包和视频包交错写入文件这有助于播放器的缓冲和 seeking。6. 资源释放与文件收尾当停止录制时不能直接关闭文件因为编码器内部可能还有缓存的帧尤其是B帧相关的延迟。我们需要“刷新”编码器。// 停止主循环后... // 1. 刷新视频编码器 avcodec_send_frame(video_codec_ctx, nullptr); // 发送nullptr表示刷新 while (true) { AVPacket pkt; av_init_packet(pkt); int ret avcodec_receive_packet(video_codec_ctx, pkt); if (ret AVERROR_EOF) { break; } else if (ret 0) { break; } // 转换时间戳并写入 pkt.pts av_rescale_q(pkt.pts, video_codec_ctx-time_base, video_stream-time_base); pkt.dts av_rescale_q(pkt.dts, video_codec_ctx-time_base, video_stream-time_base); pkt.stream_index video_stream-index; av_interleaved_write_frame(output_ctx, pkt); av_packet_unref(pkt); } // 2. 刷新音频编码器同上略 // 3. 写入文件尾 av_write_trailer(output_ctx); // 4. 关闭输出文件 if (output_ctx !(output_ctx-oformat-flags AVFMT_NOFILE)) { avio_closep(output_ctx-pb); } // 5. 释放所有资源 avformat_free_context(output_ctx); avcodec_free_context(video_codec_ctx); avcodec_free_context(audio_codec_ctx); avformat_close_input(video_input_ctx); avformat_close_input(audio_input_ctx); av_packet_free(video_pkt); av_packet_free(audio_pkt); // 释放AVFrame等其它资源...实操心得资源泄露是C音视频编程中最常见的问题之一。务必确保每一个av_alloc、av_malloc、avcodec_alloc_context3、avformat_alloc_output_context2等分配函数都有对应的释放函数av_free、avcodec_free_context、avformat_free_context。使用valgrind或 AddressSanitizer 工具进行内存检查是非常好的习惯。另外av_packet_unref和av_frame_unref对于重复使用的包和帧至关重要它们会减少内部缓冲区的引用计数避免内存泄漏。7. 常见问题排查与性能优化在实际编码中你肯定会遇到各种问题。这里记录几个我踩过的坑和解决方案。7.1 编译与链接问题问题undefined reference toavcodec_open2‘ 等链接错误。排查这通常是链接库顺序不对或库缺失。确保CMake中target_link_libraries包含了所有必要的FFmpeg库avcodec,avformat,avutil,swscale等并且顺序正确一般基础库如avutil放在后面。使用pkg-config --libs libavcodec命令检查正确的链接参数。问题运行时Failed to open ‘/dev/video0’: No such file or directory。排查检查设备路径是否正确当前用户是否有权限访问该设备如video组。在Windows上检查设备名称字符串是否准确是否包含中文或特殊字符需要处理。7.2 运行时问题问题生成的MP4文件无法播放或播放器显示“编码器不支持”。排查检查是否写入了文件头 (avformat_write_header) 和文件尾 (av_write_trailer)。检查编码器全局头 (AV_CODEC_FLAG_GLOBAL_HEADER) 是否设置并且extradata是否正确写入流参数。用ffprobe output.mp4命令查看文件信息确认流是否存在编码格式是否正确。尝试用FFplay播放 (ffplay output.mp4)它的错误信息通常更详细。问题音画不同步视频越来越慢或越来越快。排查这是时间戳处理错误的最直接表现。检查时间基转换确保每一步的av_rescale_q使用的源时间基和目标时间基是正确的。打印出关键阶段的pts值进行对比。检查输入帧率/采样率确认从设备读取的帧率是否稳定是否与你设置的编码器帧率匹配。如果设备实际输出15帧而你按30帧去给pts视频播放速度就会变快一倍。可以使用av_dict_set在打开设备时指定帧率。主时钟选择在简单的录制程序中可以以系统运行时间作为主时钟。计算每一帧“应该”出现的时间如果实际编码写入时间晚于这个时间可以考虑在下一帧追赶或者记录一个偏移量。对于高精度同步需要更复杂的时钟同步算法。7.3 性能优化点采集线程分离将音频采集和视频采集放在两个独立的线程中可以避免其中一个设备阻塞导致另一个设备数据丢失。使用线程安全的队列将采集到的数据包传递给主编码/写入线程。编码参数调优libx264编码器有丰富的参数。preset编码速度与质量权衡和crf恒定质量因子是最常用的两个。preset ultrafast编码最快但体积大质量低preset slower则相反。crf值越小质量越高通常18-28是合理范围。根据你的应用场景实时性要求高还是文件体积要求高进行调整。零拷贝优化在AVPacket到AVFrame的转换中如果像素格式一致可以尝试引用数据而不是拷贝数据。使用av_frame_ref或设置AVFrame的data指针指向AVPacket的数据区但这需要你对FFmpeg的内存管理有深刻理解否则极易导致崩溃。硬件加速如果条件允许使用硬件编码如NVIDIA的NVENCIntel的QSV可以极大降低CPU占用。这需要FFmpeg编译时支持对应的硬件编码器并在创建编码器时指定如h264_nvenc。代码逻辑与软件编码基本一致。8. 功能扩展与进阶思路一个基础的录制器完成后你可以根据需求添加更多功能动态比特率控制根据网络状况或CPU使用率动态调整video_codec_ctx-bit_rate。注意改变比特率后可能需要重新发送编码器参数集。实时预览在录制的同时使用SDL或OpenGL将视频帧渲染到窗口上将音频通过扬声器播放出来。分段录制当文件大小或录制时长达到阈值时自动关闭当前文件创建新文件继续录制。需要处理好文件尾和文件头的写入以及时间戳的连续性新的文件pts可以从0开始或累积上一个文件的总时长。添加水印或滤镜使用libavfilter库可以在编码前轻松地为视频添加文字、图片水印或者进行色彩转换、缩放等操作。你需要构建一个FilterGraph将采集到的AVFrame送入滤镜图再从另一端取出处理后的AVFrame进行编码。推流将输出上下文 (AVFormatContext) 的目标从文件改为RTMP/RTSP等流媒体服务器地址并配置相应的协议参数你就实现了一个直播推流客户端。音视频编程就像搭积木FFmpeg提供了几乎所有你需要的积木块。理解每个核心数据结构的作用掌握数据流AVPacket/AVFrame和时间戳的转换脉络你就能组合出功能强大的应用。最开始可能会被复杂的API和神秘的时间戳困扰但多写、多调试、多读FFmpeg示例代码doc/examples目录下你会逐渐建立起直觉。遇到问题别忘了ffmpeg命令行工具本身就是一个最好的参考实现用-loglevel debug参数运行你的命令观察它的内部流程往往能给你带来启发。