简介:压缩包提供一套基于FFmpeg API的摄像头视频与麦克风音频采集工程,面向熟悉DirectShow但希望简化采集流程的C++开发者。作者先用ffmpeg.exe命令行演示如何枚举DShow设备并采集图像,再给出自研采集程序的功能与用法,最后按采集、编码、封装、录制四个模块拆解实现思路。整个工程涵盖设备选择对话框、视频显示窗口、音视频输入输出流、统计视图等完整界面与逻辑;压缩包内含162个文件,以102个头文件和11个cpp源文件为核心,9个lib与9个dll支撑运行时链接,另含sln、vcproj等VS工程配置、exe演示程序及pdb调试信息,整体约22.75MB。已有1304人学习浏览。对于希望绕过DShow框架细节、直接通过FFmpeg完成音视频采集并录制或推流的开发者,这份可编译、可跟读的完整案例能减少踩坑,快速上手扩展自己的项目,且代码结构清晰,便于二次开发。
1. 用 FFmpeg API 采集摄像头视频和麦克风音频:命令行之外的实时音视频开发
市面上多数开发者接触 FFmpeg 是从命令行开始的,一条ffmpeg -f v4l2 -i /dev/video0就能出图。但走进采集类项目时,命令行那套就撑不住了:你说不清设备枚举失败是哪个参数造成的,没法在帧到达时插入自己的处理回调,更别提在音频和视频两个流之间做统一时钟校准。这份资源把 FFmpeg 采集链路从命令行搬到了 API 层,核心是 libavdevice 家族配合 libavformat、libavcodec 的调用方式,源码包是 C 语言工程,覆盖 Linux 与 Windows 两套采集后端。它解决的是「设备侧实时取流 → 音视频同步 → 编码封装」这条硬链路,适合正在做安防、在线教育、取证类桌面工具或嵌入式终端的开发者。看懂这套代码之后,你会发现边角问题大多出在设备参数协商和 PTS 映射上,而不是协议本身。
2. 设备层选型:avdevice 体系下的 v4l2 与 dshow 取舍
2.1 先理解采集后端的形态差异
FFmpeg 命令行能读摄像头,本质上是 libavdevice 提供的输入格式在工作。Linux 下是v4l2,Windows 下是dshow,macOS 下走avfoundation。这份资源重点落在 v4l2 与 dshow 双端实现,目录里分别有input_v4l2.c和input_dshow.c两个入口文件。
从 API 视角来看,这三者都遵循同一个抽象:AVInputFormat提供read_header和read_packet回调,你的任务就是通过avformat_open_input把它激活。v4l2 输入格式对应的名称是v4l2,dshow 对应的是dshow。别用av_find_input_format("video4linux2"),那是早期版本的别名,现在的库版本会返回空指针。
AVInputFormat *fmt = av_find_input_format("v4l2"); if (!fmt) { fprintf(stderr, "v4l2 input format not found\n"); return -1; }这段代码的语义很简单:av_find_input_format按字符串匹配注册表里的输入格式,匹配不到就返回 NULL。常见做法是紧接着打印av_version_info()确认链接到的库版本,很多采集问题其实是本地装了多个 FFmpeg 版本导致的头文件与动态库错配。
2.2 设备名与 AVDictionary 参数协商
在 API 层打开 Linux 设备,设备名直接传/dev/video0之类的路径,Windows 的 dshow 设备名要写成video=USB Camera或audio=Microphone。打开之前,最关键的操作是把参数塞进AVDictionary:
AVDictionary *opts = NULL; av_dict_set(&opts, "video_size", "640x480", 0); av_dict_set(&opts, "pixel_format", "yuyv422", 0); av_dict_set(&opts, "framerate", "30", 0); av_dict_set(&opts, "fflags", "nobuffer", 0); int ret = avformat_open_input(&ctx, dev_name, fmt, &opts); av_dict_free(&opts);这里video_size和framerate是摄像头驱动层直接消费的参数,v4l2 后端会通过VIDIOC_S_FMT系统调用写入驱动。pixel_format很关键:多数廉价摄像头默认输出 MJPG,但你需要原始帧做算法处理时必须协商为yuyv422或nv12,前提是驱动支持。fflags=nobuffer是一个通用读包标志,让av_read_frame尽量少积压,对实时显示场景很重要。参数协商失败时,avformat_open_input会返回负的 AVERROR,常见错误码是EINVAL与EBUSY。
2.3 枚举设备的两种做法
v4l2 在 Linux 下可以通过v4l2-ctl --list-devices查设备列表,但代码里更通用的做法是用 FFmpeg 自带的avdevice_list_input_sources枚举。这个 API 的好处是跨平台——Windows 下用 dshow 后端枚举时返回的不只是视频设备,还会列出音频输入:
AVDeviceInfoList *dev_list = NULL; int ret = avdevice_list_input_sources(fmt, NULL, NULL, &dev_list); for (int i = 0; i < dev_list->nb_devices; i++) { AVDeviceInfo *dev = dev_list->devices[i]; printf("device[%d]: %s (%s)\n", i, dev->device_name, dev->device_description); } avdevice_free_list_devices(&dev_list);AVDeviceInfoList内部会填充device_name和device_description两个字段,前者用于传给avformat_open_input,后者是给人看的描述文本。Windows 上如果枚举结果为空,多半是 DirectShow 注册表里没有可用设备,这也是虚拟摄像头软件装完需要重启才能被识别的原因。
3. 采集主循环落地:从 av_read_frame 到编码输出
3.1 视频帧的读取与格式转换
打开设备成功之后,主循环的核心是反复调用av_read_frame。这个函数从设备驱动读出原始数据包,音频和视频都走同一个出口,通过pkt->stream_index区分流类型:
while (running) { AVPacket *pkt = av_packet_alloc(); int ret = av_read_frame(ctx, pkt); if (ret < 0) { av_packet_free(&pkt); break; } if (pkt->stream_index == video_stream_idx) { // 对 pkt->data 做处理 } else if (pkt->stream_index == audio_stream_idx) { // 音频包处理 } av_packet_unref(pkt); av_packet_free(&pkt); }注意av_read_frame返回的是原始压缩包,v4l2 设备如果协商成 MJPG 输出,pkt->data里是一帧 JPEG 数据,要拿去显示或做算法,就得交给avcodec解码。如果协商的是yuyv422,那pkt->data里是未压缩的原始图像帧,可以直接做后续处理,这也是低延迟场景里常见的选择——省掉了解码环节,代价是 USB 带宽占用高。
3.2 音视频编码的接入点
采集的原始音频大概率是 PCM f32le 或 s16le,视频是 yuyv422 或 nv12。要输出成 MP4,就必须分别编码为 AAC 与 H.264。编码器的创建是套路化的:
AVCodecContext *video_enc_ctx = NULL; const AVCodec *encoder = avcodec_find_encoder(AV_CODEC_ID_H264); video_enc_ctx = avcodec_alloc_context3(encoder); video_enc_ctx->width = 640; video_enc_ctx->height = 480; video_enc_ctx->pix_fmt = AV_PIX_FMT_YUV420P; video_enc_ctx->time_base = (AVRational){1, 30}; video_enc_ctx->framerate = (AVRational){30, 1};上面的代码里pix_fmt用的是AV_PIX_FMT_YUV420P,这说明从设备直接读到的 yuyv422 数据要经过sws_scale转一次格式再喂给编码器。硬件编码器可能只接受 NV12,务必用avcodec_get_supported_config或直接看编码器封装里的pix_fmt数组确认。time_base是编码器打分用的时间精度,一般设置为帧率的倒数。
音频编码端的参数相对隐蔽一些,编码器名称是aac,采样格式要指定为AV_SAMPLE_FMT_FLTP,而不是设备原始输出格式。下面这段是在音频编码器设置里容易翻车的点:
audio_enc_ctx->sample_fmt = AV_SAMPLE_FMT_FLTP; audio_enc_ctx->sample_rate = 44100; audio_enc_ctx->channel_layout = AV_CH_LAYOUT_STEREO; audio_enc_ctx->bit_rate = 128000; audio_enc_ctx->time_base = (AVRational){1, 44100};采样格式FLTP是平面浮点格式,AAC 编码器的内部输入约定就是这个,麦克风采集到的 s16 交错数据不能直接送进去,必须先用swr_convert做重采样与格式转换。这一步漏掉的话,编码器返回的 AVERROR 会直接告诉你invalid argument,但不会告诉你缺了格式转换。
3.3 输出封装:MP4 的时间基映射
把编码后的音视频包写进 MP4,靠avformat_alloc_output_context2创建封装器。MP4 不需要整体重编码,脚本里只需要做一件事:写入AVPacket时让pts从编码器时间基换算到封装器时间基:
AVRational enc_time_base = video_enc_ctx->time_base; AVRational mux_time_base = mux_ctx->streams[video_stream_idx]->time_base; pkt->pts = av_rescale_q(pkt->pts, enc_time_base, mux_time_base); pkt->dts = pkt->pts; pkt->duration = av_rescale_q(pkt->duration, enc_time_base, mux_time_base);av_rescale_q是 FFmpeg 里做时间基换算的万能公式,它内部采用有理数运算,不存在浮点误差。这里已经把dts强制与pts对齐,因为实时摄像头采集场景里不方便做 B 帧重排,用baseline等级的 H.264 编码器通常不会出现dts滞后,属于安全的默认策略。
4. 音视频同步与设备占用:常见踩坑排查
4.1 摄像头打开失败,返回 EBUSY 或 EACCES
现象:无论传/dev/video0还是/dev/video1,avformat_open_input都返回-16或-13,命令行下用 ffplay 打开也是黑屏。
原因:v4l2 设备被某个进程独占。常见的是浏览器测试页或抓拍软件没有释放设备,v4l2 本身不允许多个进程同时打开同一节点。另外/dev/video0的用户权限不足时,非 root 用户会得到 EACCES。
解决:先执行v4l2-ctl --list-devices确认哪个节点对应物理摄像头,然后用fuser -v /dev/video0找出并释放占用进程。如果是权限问题,usermod -a -G video 用户名后重新登录,不要用 chmod 777 临时改权限,重启后会被重置。
4.2 图像卡顿和延迟越来越大
现象:程序跑了几分钟后画面延迟从几百毫秒涨到数秒,内存占用持续增长。
原因:av_read_frame循环里没有及时释放AVPacket的引用计数。很多人只写了av_packet_alloc却忘了av_packet_unref或av_packet_free,FFmpeg 在老版本里并不会自动清理一个包里的引用。另一个原因是编码器没有开启delay模式控制,编码器内部积压了过多帧。
解决:主循环里每次读完包立即av_packet_unref;在while循环结束后统一av_packet_free。编码端在avcodec_send_frame之后循环avcodec_receive_packet直到返回值是EAGAIN为止,避免编码器缓存堆积。
4.3 音画不同步,先快后慢
现象:录制出来的 MP4 里,口型对不上,前十几秒同步,越往后音频越滞后。
原因:视频的pts和音频的pts是基于不同时钟源递增的。v4l2 摄像头的时间戳来源于内核系统时钟,麦克风的 dshow 时间戳来源于 Windows 多媒体时钟。直接拼接等于拿两根不同频率的尺子量同一段布。
解决:用av_gettime_relative()作为统一时钟源。读取到视频包时记录当前时间,把pts重写为av_gettime_relative() - start_time;音频包也走同样的逻辑。重新采样成同一时钟后,音画才能在容器层对齐。这样做之后仍然有轻微偏移,就检查音频帧大小是否是编码器的frame_size整数倍,麦克风驱动一次送过来 512 个样本,但 AAC 一次要 1024 个,必须走swr_convert或缓冲拼接后再avcodec_send_frame。
4.4 编码器报错 AVERROR_INVALIDDATA,且只发生在推流时
现象:本地采集正常,投到 RTMP 服务后播放器提示无法解码。
原因:本地录制时 MP4 封装器会帮你写入 SPS/PPS 头,但裸流推给 RTMP 时服务端需要你在每个关键帧前带上这两个参数。很多采集程序只avformat_write_header一次,后续编码器更新参数就丢掉了。
解决:在avformat_write_header之后,主动从avcodec_parameters里取出extradata,推流前在每个关键帧包上附加这段数据。FFmpeg 5.x 之后推荐用法是通过av_packet_side_data_add把AV_PKT_DATA_NEW_EXTRADATA附到关键帧包上。
4.5 高帧率下 GPU 编码器延迟反而变大
现象:同样的摄像头 30fps,软编码延迟 200ms,切到 NVIDIA 硬件编码后延迟反而到了 1s。
原因:硬件编码器默认有较大的surfaces队列,为了吞吐量牺牲了流水线延迟。开发工具里看到的参数叫async_depth,它表示驱动层最多提前缓冲多少帧等待编码完成,默认值可能高达 16。这在视频剪辑场景没问题,实况直播场景就不可接受了。
解决:在编码器私有选项里把该参数压到最小。FFmpeg 的 H.264 NVDEC 编码器提供-async_depth 2选项,建议测试时从4起步。同时总延迟还受解码器端影响,采集端如果设备输出 MJPG 还叠加了解码时间,也必须一并考虑。
5. 进阶优化:基于 AVBufferRef 的环形缓冲与零拷贝取流
完成基础链路之后,多数人下一页面对的性能瓶颈集中在拷贝上。设备驱动把帧送到 FFmpeg 内部是第一次拷贝,av_read_frame把AVPacket递给你是第二次,编码前的sws_scale是第三次。三次拷贝足够让 CPU 占用率停留在高位。
一种成熟的做法是用AVBufferRef持有缓冲区的生命周期,不搬数据只搬引用。改造后的代码里,摄像头后端直接把pkt->buf塞进环形缓冲区的节点,消费线程取出引用交给编码器,而不是复制pkt->data:
typedef struct RingItem { AVBufferRef *buf_ref; int64_t pts; int stream_idx; } RingItem; int push_to_ring(AVPacket *pkt) { AVBufferRef *ref = av_buffer_ref(pkt->buf); RingItem item = { .buf_ref = ref, .pts = pkt->pts, .stream_idx = pkt->stream_index }; ring_buffer_push(&item); return 0; }这段代码的思路是让AVBufferRef成为帧数据的所有权凭证。av_buffer_ref的操作本质上是引用计数加一,内部数据内存不会被复制,只有计数变化。消费端在编码完成时调用av_buffer_unref释放引用。整套流程下来,数据在整个链路里只存在一份物理内存,配合 v4l2 的 MMAP 模式甚至可以做到用户态零拷贝。
用AVBufferRef方案有个前置条件:设备的每个AVPacket必须携带独立的buf字段,v4l2 后端默认满足这个条件,但某些硬件平台的驱动会复用同一个缓冲区,此时av_buffer_ref后共享计数一旦减到零,另一路还在访问的线程就会踩到释放后的内存。遇到平台相关翻车事故,回退方案是改为av_packet_move_ref转移所有权,彻底放弃多线程共享同一帧的思路。
整套资源的代码里,主循环线程只负责av_read_frame和把包送入环形缓冲,编码线程独立消费,线程间通过无锁队列传递RingItem,耦合度降到最低。这样一个架构的好处是后期换硬件编码器或接入 GPU 推理管线时,不需要改设备层代码。
我在自己的项目里曾经为音画同步砸进去了整整两周,后来发现问题是驱动层 USB 控制器把音频和视频时间戳分配了重叠的序号,不是代码问题。从那以后我每次做采集前都强制走一遍 v4l2-ctl 检查设备能力、确认像素格式、验证帧率,再做代码层开发。这套流程帮我提前排掉了大半的低级问题。希望这份资源也能帮你绕开那些我已经踩平的坑。
本文还有配套的精品资源,点击获取