简介:面向嵌入式开发者的V4L2视频采集与H.264编码示例工程,基于C语言实现,帮助解决Linux环境下从摄像头获取原始数据并实时编码为H.264码流的问题。工程共11个文件,以.c源码和.h头文件为主,覆盖v4l2设备操作、x264编码器封装、主程序流程等模块;同时包含makefile、JSON配置文件及README说明,便于在嵌入式平台编译、阅读和二次开发。整个压缩包仅817KB,轻量且结构清晰,适合学习V4L2接口调用、H.264编码库集成以及C语言底层视频处理的开发者。目前已有283人学习。通过该项目可快速掌握设备初始化、帧采集、编码参数配置、主循环调度等关键环节,既能作为入门实战样例,也可直接作为监控、无人机图传等嵌入式视频应用的基础框架。
1. v4l2采集到H.264编码,这条链路到底卡在哪
摄像头采集和视频编码,单看每一环都不算难:v4l2无非是打开设备、设置格式、申请缓冲区、把帧读出来;H.264编码也有现成的库和工具。但把两件事串成一条流水线时,问题就来了——v4l2给出来的是裸帧,通常是YUV或RGB,而H.264编码器要的是压缩前的视频帧,两者之间还隔着像素格式转换、帧率控制、缓冲区同步这些环节。很多人第一次做这个课题,代码写完了,画面出不来,或者出来的是绿屏、花屏、卡顿,往往就是栽在这些衔接细节上。
这个标题对应的实际需求很典型:嵌入式设备上接一个USB或CSI摄像头,用v4l2拿到原始图像,再编码成H.264文件或网络流。它涵盖了一条完整的视频采集编码链路,涉及设备枚举、格式协商、内存映射、帧读取、格式转换、编码器参数设置、编码输出处理。对新手来说,这是理解Linux视频子系统的好入口;对熟手来说,这里面值得反复推敲的是缓冲区模型和编码延迟控制。下面按一条可落地的主线展开:先讲清楚v4l2采集侧的Buffer机制,再给出一个可运行的最小采集编码方案,然后把H.264编码参数逐个拆开,最后谈延迟、丢帧、花屏这类实战里躲不开的问题。
2. v4l2采集原始数据的工作原理与Buffer模型
2.1 v4l2设备节点和采集流程的基本认识
Linux下摄像头设备节点通常是/dev/video0、/dev/video1这样的字符设备。v4l2不是一套用户态API,而是内核里video4linux2框架暴露出来的ioctl接口集合。用户态程序通过open()打开设备,然后通过一系列ioctl命令和内核驱动对话。整个采集流程可以归纳为四步:查询设备能力、设置采集格式、申请缓冲区、启动采集循环读取帧。
第一步是打开设备并查询capability,确认这是一个视频采集设备而不是输出设备。第二步设置采集格式,包括图像宽高、像素格式、帧率。第三步申请缓冲区,这里有两种模式,read/write方式和mmap内存映射方式,绝大多数采集场景用mmap。第四步把缓冲区入队,启动流,然后在循环里等待帧到达、取出帧、处理完再重新入队。
这里有个容易忽略的点:v4l2的buffer是内核和用户态共享的,采集到的帧数据直接写入内核分配的缓冲区,用户态通过mmap映射到自己的地址空间。这意味着零拷贝读取,但也意味着用户态处理完一帧后必须及时把buffer归还给内核,否则内核没有空闲buffer可写,就会丢帧。理解了这个模型,后面很多性能问题就都解释得通了。
2.2 用VIDIOC_S_FMT协商分辨率和像素格式
设置采集格式是整条链路里第一个容易出错的地方。常见做法是先调用VIDIOC_S_FMT,把想要的宽度、高度、像素格式告诉驱动,然后读回驱动实际采用的值。为什么要读回?因为摄像头传感器和驱动不一定支持你请求的格式,驱动会选择最接近的格式并修改结构体返回,所以必须用返回后的值去做后续操作。
struct v4l2_format fmt; memset(&fmt, 0, sizeof(fmt)); fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; fmt.fmt.pix.width = 1920; fmt.fmt.pix.height = 1080; fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV; fmt.fmt.pix.field = V4L2_FIELD_NONE; if (ioctl(fd, VIDIOC_S_FMT, &fmt) < 0) { perror("VIDIOC_S_FMT"); return -1; } // 必须以驱动返回的值为准 printf("driver set %ux%u, fmt=%c%c%c%c\n", fmt.fmt.pix.width, fmt.fmt.pix.height, (fmt.fmt.pix.pixelformat >> 0) & 0xff, (fmt.fmt.pix.pixelformat >> 8) & 0xff, (fmt.fmt.pix.pixelformat >> 16) & 0xff, (fmt.fmt.pix.pixelformat >> 24) & 0xff);代码里先清零结构体,指定采集类型为VIDEO_CAPTURE,然后请求1920x1080的YUYV格式。V4L2_PIX_FMT_YUYV是打包格式,一个像素用两个字节表示,Y分量和UV分量交错排列。FIELD_NONE表示逐行扫描,绝大多数CMOS传感器都支持。调用VIDIOC_S_FMT之后,驱动可能会因为传感器不支持1080p而回退到720p,或者因为控制器限制把YUYV改成NV12,所以读回值是必须的。
这里要特别说明像素格式的选择。编码器通常更喜欢NV12或I420这种平面格式,而摄像头传感器原生输出往往是YUYV或MJPEG。如果你直接从v4l2拿YUYV送给H.264编码器,很多编码器不支持这种打包格式,就需要在中间做一次转换。转换有两种路径:一是用libyuv之类的库做CPU转换,二是让v4l2驱动直接输出NV12——如果驱动支持的话,后者的性能要好得多,因为转换发生在ISP硬件里。所以设置格式时,优先尝试V4L2_PIX_FMT_NV12,拿不到再退化到YUYV配合libyuv。
2.3 mmap缓冲区的申请和VIDIOC_QBUF/VIDIOC_DQBUF循环
缓冲区申请用VIDIOC_REQBUFS,指定缓冲区的数量和类型。数量通常取4,太少容易丢帧,太多占用内存。申请完之后,用VIDIOC_QUERYBUF获取每个buffer的内核地址偏移和长度,然后mmap到用户空间。
struct v4l2_requestbuffers req; memset(&req, 0, sizeof(req)); req.count = 4; req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; req.memory = V4L2_MEMORY_MMAP; if (ioctl(fd, VIDIOC_REQBUFS, &req) < 0) { perror("VIDIOC_REQBUFS"); return -1; } void *buffers[4]; size_t buf_len[4]; for (int i = 0; i < 4; i++) { struct v4l2_buffer buf; memset(&buf, 0, sizeof(buf)); buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; buf.index = i; if (ioctl(fd, VIDIOC_QUERYBUF, &buf) < 0) { perror("VIDIOC_QUERYBUF"); return -1; } buffers[i] = mmap(NULL, buf.length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, buf.m.offset); buf_len[i] = buf.length; // 入队,交给内核填充数据 if (ioctl(fd, VIDIOC_QBUF, &buf) < 0) { perror("VIDIOC_QBUF"); return -1; } } enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE; ioctl(fd, VIDIOC_STREAMON, &type);REQBUFS的count值决定内核分配多少个buffer,这里用4。QUERYBUF拿到的是用户态mmap所需的偏移量,buf.length是buffer大小。mmap的MAP_SHARED标志是必须的,因为内核驱动会往这段内存写数据,MAP_PRIVATE会导致写时复制,拿不到采集数据。每个buffer在mmap完成后立即QBUF入队,这样内核一开始就有4个可以写入的空闲buffer。
STREAMON之后,采集循环就是反复调用DQBUF取出已填充的buffer,处理完再QBUF还回去。DQBUF是阻塞调用,有帧到达才返回,返回时buf里带有bytesused表示实际数据长度、timestamp表示时间戳、sequence表示帧序号。帧序号对排查丢帧很关键——如果sequence不连续,说明中间有帧被内核丢弃了。
2.4 采集循环与帧时间戳的使用
采集循环的骨架是所有v4l2应用的共同模式,但很多人把它写得太简单,忽略了DQBUF的阻塞特性和buffer归还的及时性。如果下一帧已经在等待写入了,而你的处理逻辑还没归还buffer,驱动只能丢帧。所以处理逻辑必须快,或者引入独立的处理线程。
struct v4l2_buffer buf; memset(&buf, 0, sizeof(buf)); buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; if (ioctl(fd, VIDIOC_DQBUF, &buf) < 0) { perror("VIDIOC_DQBUF"); continue; } // 此时buffers[buf.index]就是完整的一帧数据 uint8_t *frame_data = buffers[buf.index]; size_t frame_size = buf.bytesused; uint32_t frame_seq = buf.sequence; struct timeval ts = buf.timestamp; // 把这一帧交给编码线程,编码完成后必须QBUF encode_frame(frame_data, frame_size); ioctl(fd, VIDIOC_QBUF, &buf);sequence字段可以用于检测丢帧:记录上一次的seq,如果当前seq减上一次seq大于1,说明中间丢了几帧。timestamp来自内核时钟,可以用来计算两帧之间的实际间隔,判断采集帧率是否稳定。bytesused对某些格式可能小于buffer长度,尤其MJPEG格式,所以必须用它来取有效数据长度,而不是直接用mmap的映射长度。
采集线程里只做DQBUF、转交数据、QBUF这三件事,不要在采集线程里做耗时操作。编码、写文件、网络发送都应该放到其他线程,否则一旦处理速度跟不上采集帧率,buffer就会耗尽,每次DQBUF都会返回上一次超时未处理的buffer,接收到的帧时间戳会越来越乱。
3. 用x264把v4l2裸帧编码成H.264
3.1 为什么选择x264而不是硬件编码器
拿到裸帧之后,编码这一步的选型直接决定整个项目的复杂度和性能。标题说的是H.264编码,主流的软件方案就是x264,它以稳定、参数丰富、文档齐全著称,几乎成了H.264软件编码的事实标准。如果你的平台上有硬件编码器,比如树莓派的h264_v4l2m2m、瑞芯微的mpp、全志的cedar,性能会更好,但这些硬件编码器的API各不相同,换平台就要重写。x264的另一个优势是纯CPU编码,任何Linux平台都能编译运行,对学习这个题目来说是最合适的切入点。
软件编码的性能问题是绕不开的。1080p30的H.264编码,x264在preset非常快时大约需要一颗主频1.5GHz以上的ARM核心,如果在低端嵌入式芯片上跑,可能需要把分辨率降到720p或帧率降到15。用ultrafast preset配合zerolatency参数,编码延迟可以压到几十毫秒,满足大多数本地预览场景。后面会具体说参数怎么设置。
x264的使用方式有两种:一是通过libx264的API在C/C++代码里调用,二是调用命令行工具ffmpeg。做采集编码一体化程序,用API是正路;如果只是验证链路,ffmpeg命令行更省事。下面先给ffmpeg的最小验证命令,再给API接入的方法。
3.2 ffmpeg验证v4l2到H.264的最短链路
在写任何代码之前,先用ffmpeg验证一遍摄像头采集和编码链路是否通,能省下大量排查时间。命令里用v4l2作为输入设备,指定像素格式和帧率,交给libx264编码,输出到文件。
ffmpeg -f v4l2 -input_format yuyv422 -video_size 640x480 -framerate 30 \ -i /dev/video0 -c:v libx264 -preset ultrafast -tune zerolatency \ -pix_fmt yuv420p -g 30 -b:v 1M test.h264这条命令里,-input_format yuyv422告诉v4l2驱动以YUYV格式输出,-video_size和-framerate设置分辨率和帧率。-c:v libx264选择软件编码器,-preset ultrafast牺牲压缩率换速度,-tune zerolatency关闭编码器的延迟缓冲,适合实时场景。-pix_fmt yuv420p是编码器要求的像素格式,如果输入不是yuv420p,ffmpeg会自动插入转换。-g 30设置关键帧间隔为30帧,-b:v 1M限制平均码率1Mbps。
这条命令验证完之后,可以用ffprobe查看输出文件的编码信息,确认确实是H.264格式:
ffprobe -show_streams -select_streams v -show_entries stream=codec_name,width,height,avg_frame_rate test.h264如果ffmpeg这条路能跑通,说明摄像头驱动、v4l2设备节点和编码器本身都没问题,问题只在你的C代码里。
3.3 C代码接入libx264编码器的参数表与调用流程
C程序接入x264的流程比v4l2略繁琐但更规整。首先要初始化编码器句柄,设置宽高、帧率、比特率、关键帧间隔等参数,然后为每一帧创建一个x264_picture_t结构体,填入YUV数据,调用x264_encoder_encode得到压缩后的H.264数据,最后x264_encoder_headers取出参数集SPS/PPS。SPS/PPS很关键,解码器没有它们就无法解码,如果做流媒体还得在关键帧前面附带它们。
x264_param_t param; x264_param_default_preset(¶m, "ultrafast", "zerolatency"); param.i_width = 640; param.i_height = 480; param.i_fps_num = 30; param.i_fps_den = 1; param.i_keyint_max = 30; param.rc.i_bitrate = 1000; param.i_log_level = X264_LOG_INFO; x264_t *enc = x264_encoder_open(¶m); x264_picture_t pic_in, pic_out; x264_picture_alloc(&pic_in, X264_CSP_I420, 640, 480); // 假设已经通过v4l2拿到一帧NV12或YUYV并转换为I420 uint8_t *y_plane = pic_in.img.plane[0]; uint8_t *u_plane = pic_in.img.plane[1]; uint8_t *v_plane = pic_in.img.plane[2]; fill_yuv420_from_v4l2_frame(frame_data, y_plane, u_plane, v_plane, 640, 480); int nals = 0; x264_nal_t *nal = NULL; int frame_size = x264_encoder_encode(enc, &nal, &nals, &pic_in, &pic_out); for (int i = 0; i < nals; i++) { fwrite(nal[i].p_payload, 1, nal[i].i_payload, outfile); }x264_param_default_preset一行把大部分参数设置成ultrafast和zerolatency组合的默认值。i_width、i_height、i_fps_num、i_fps_den分别对应摄像头实际输出的宽高和帧率。i_keyint_max设定最大关键帧间隔,直播场景通常设成帧率的整数倍,例如30帧设30,也就是一秒一个关键帧。rc.i_bitrate单位是kbps,1000就是1Mbps。X264_CSP_I420表示输入格式,如果手里是NV12,需要先用libyuv或手写函数转换成I420,x264不直接吃NV12。
编码输出的NAL单元有两种类型需要区分处理:SPS/PPS和slice数据。第一次调用x264_encoder_encode或每个关键帧前,输出里会带VCL之前的SPS/PPS。写文件时全写上没问题,但做RTP流媒体时SPS/PPS不能和slice放在同一个RTP包里,需要单独打包。这就是为什么很多流媒体服务要单独解析NAL类型并做缓存。
3.4 像素格式转换:NV12/YUYV到I420的常见做法
v4l2摄像头输出的格式五花八门,USB摄像头最常见的是YUYV,CSI接口的摄像头在驱动配置后可以输出NV12,而x264和绝大多数编码器都要求I420。这三种格式的排列方式不同,转换代码不复杂,但写错一个偏移量,出来的画面就是花屏或者颜色错乱。
YUYV是打包格式,内存顺序是Y0 U0 Y1 V0 Y2 U2 Y3 V2,也就是每两个Y像素共享一对UV。NV12是平面格式,先是一整块Y平面,然后是一整块UV交错平面,UV平面的尺寸是Y平面的四分之一。I420是Y平面加U平面加V平面,三个平面各占一块连续内存。
void yuyv_to_i420(const uint8_t *src, uint8_t *dst, int width, int height) { uint8_t *y = dst; uint8_t *u = dst + width * height; uint8_t *v = dst + width * height * 5 / 4; int uv_stride = width / 2; for (int j = 0; j < height; j += 2) { for (int i = 0; i < width; i += 2) { const uint8_t *p0 = src + (j * width + i) * 2; const uint8_t *p1 = src + ((j + 1) * width + i) * 2; y[j * width + i] = p0[0]; y[j * width + i + 1] = p0[2]; y[(j + 1) * width + i] = p1[0]; y[(j + 1) * width + i + 1] = p1[2]; int uv_index = (j / 2) * uv_stride + i / 2; u[uv_index] = (p0[1] + p1[1]) / 2; v[uv_index] = (p0[3] + p1[3]) / 2; } } }这段转换做了2x2像素块的平均采样,Y分量直接取四个像素各自的值,U和V取两行像素的平均。性能上,每帧做一次这样的转换在640x480下微不足道,但到了1080p就需要注意优化,可以用NEON或者SIMD指令加速。libyuv库把这些转换都优化过了,项目里引入libyuv是常见做法,接口类似libyuv::YUY2ToI420,效率和可靠性都比手写好得多。
转换时机也要考虑:如果编码帧率和采集帧率一致,每帧都要转换;如果为了降低编码负载做了帧率抽稀,比如采集30fps但编码15fps,那就采两帧转一帧,转换逻辑要写在编码线程而不是采集线程,避免在主线程上做多余工作。
4. 编码缓冲、延迟与画质的调试思路
4.1 编码延迟的来源与zerolatency的作用
H.264编码器为了实现高压缩率,会引入B帧和参考帧重排,这会导致输出顺序和输入顺序不一致,从而产生延迟。默认preset下,x264会根据参数自动决定是否使用B帧和多少条参考帧。做实时应用时,这个延迟不可接受,所以要用tune zerolatency来强制关闭B帧,并把参考帧数量降到1,让编码器按输入顺序一帧一帧输出。
延迟不只是编码器的问题。v4l2采集本身也可能引入延迟,drivers在VIDIOC_S_FMT时设置的帧率如果和实际传感器输出不一致,DQBUF的等待时间会比预期长。另一个延迟来自缓冲区排队:如果用户态处理慢,内核里的帧会积压,播放端看到的画面会越来越旧。排查延迟问题,先看DQBUF返回的时间戳和当前系统时间的差值,如果差值持续增大,说明链路有堆积。
x264_param_t param; x264_param_default_preset(¶m, "veryfast", "zerolatency"); param.i_bframe = 0; param.i_sync_lookahead = 0; param.rc.i_lookahead = 0; param.i_rc_method = X264_RC_ABR; param.rc.i_vbv_max_bitrate = 2000; param.rc.i_vbv_buffer_size = 2000;这段参数把B帧彻底关掉,sync_lookahead和rc_lookahead也清零,目的是让编码器不做前瞻缓冲,每收到一帧立即输出编码结果。VBV缓冲也要设置,否则码率控制为了追平均码率可能在码率尖峰时填入大量数据,接收端缓冲不够就会卡顿。VBV的buffer size设置得越小,码率越平稳,但画质波动也会变大,需要根据实际带宽和端到端延迟要求来权衡。
4.2 固定码率还是固定质量,实时场景怎么选
码率控制模式的选择直接决定画面质量和带宽占用。x264支持CQP固定量化参数、ABR平均码率、CRF恒定质量三种主要模式。CQP适合实验室对比,生产环境很少用;ABR适合有带宽上限的直播和存储场景;CRF适合本地录制,因为它在保证视觉质量的前提下自动分配码率。
实时传输场景里ABR更常见,因为链路带宽是确定的,编码器必须把码率控制在这个范围内。但ABR有个坑:在画面剧烈变化时,为了控制码率会提高量化参数,导致画面模糊和块效应。为了缓解这个问题,可以给ABR配上VBV maxrate,让编码器在码率峰值时不超限,同时把中短期码率波动控制在一定范围。CRF模式下则没有码率上限的概念,如果推流到公网,很容易打满上行带宽。
param.rc.i_rc_method = X264_RC_ABR; param.rc.i_bitrate = 1000; param.rc.i_vbv_max_bitrate = 1200; param.rc.i_vbv_buffer_size = 1200; param.rc.f_rate_tolerance = 1.0;rate_tolerance控制编码器对平均码率的宽容度,值越大瞬时码率偏离越多,画质波动也越明显。实时场景设在0.8到1.2之间比较合理,低于0.8码率控制会频繁调整量化参数,造成画质忽好忽坏。
4.3 关键帧间隔对延迟和丢帧恢复的影响
关键帧I帧是H.264里可以独立解码的帧,不依赖任何其他帧。解码器如果中途开始接收,必须等到下一个I帧才能出画面,否则全是花屏或黑屏。I帧间隔越小,起播速度越快,随机seek越方便,但码率开销越大。I帧的大小通常是P帧的5到10倍,如果码率控制设了上限,I帧会挤占后面P帧的码率预算。
实时通信场景I帧间隔通常设为1到2秒,也就是25fps下gop设为25或50。局域网监控可以放宽到4秒。关键帧间隔不要设成奇数,因为场景切换和多码流同步时,整秒对齐的GOP更容易处理。x264里有两个参数控制GOP:i_keyint_max是最大间隔,i_keyint_min是最小间隔,表示如果画面变化剧烈,编码器可能在达到min之前就主动插入I帧。
x264在zerolatency模式下会自动关闭场景切换检测,因为场景检测需要前瞻,这会导致运动剧烈的画面里I帧间隔拉满到keyint_max。如果做的是摄像头监控,画面上一直有人走动,建议利用x264_encoder_intra_refresh参数做帧内刷新,把I帧打散到多个P帧里,避免码率尖峰。
5. 花屏、绿屏、丢帧的排查与验证方法
5.1 从v4l2格式协商到编码器输入的排查顺序
花屏和绿屏是采集编码开发里最常遇到的现象,原因从采集端到编码端都有可能。排查时从源到宿逐层确认:先确认v4l2输出的像素格式和实际数据布局一致,再确认编码器收到的数据格式和参数声明一致,最后看解码器播放是否正常。
绿屏最常见的原因是编码器收到了全零的UV数据或者UV平面数据错位。YUYV转I420时如果UV采样位置不对,颜色就会整体偏色或呈绿色。花屏则多数是分辨率或stride不匹配——摄像头的stride可能不等于宽度乘以像素字节数,某些驱动会做行对齐,比如1280宽度的YUYV行可能占2560字节但实际有效数据是2560字节,如果直接memcpy到I420转换函数就会错位。用v4l2-ctl确认实际格式是最快的验证手段:
v4l2-ctl -d /dev/video0 --get-fmt-video这个命令返回驱动当前实际采用的宽度、高度和像素格式,如果和你代码里请求的不一样,就会直接导致转换函数的解析错误。v4l2-ctl还能统计帧率和丢帧数:
v4l2-ctl -d /dev/video0 --stream-mmap --stream-count=100 --stream-to=/tmp/raw.yuv把100帧裸数据dump到文件里,然后用ffplay播放这个raw文件,验证一下采集侧数据本身是否正常,再回来查转换和编码。
5.2 编码结果完整性的三种验证手段
编码完的H.264文件是否可解、解码后画面是否正常,需要从三个层面验证。第一层是格式验证,ffprobe能读出codec_name、profile、level这些信息,如果codec_name不是h264,说明编码器没有按预期工作。第二层是完整解码验证,ffmpeg把h264解码成yuv文件并统计是否有丢帧。第三层是视觉验证,直接抽关键帧存成图片检查是否花屏。
ffmpeg -v error -i test.h264 -f null -这条命令只做解码不输出内容,如果文件里有损坏的帧,ffmpeg会输出错误信息。没有任何输出说明文件结构完好。要检查每一帧是否都能解出画面,可以解码成rawvideo后用ffplay播放,播放时拖到任意位置暂停,看画面是否有马赛克或绿色条纹。
更细的验证是做码流分析,用ffprobe输出每一帧的类型和大小,检查关键帧间隔是否符合参数设置,I帧分布是否均匀:
ffprobe -show_frames -select_streams v -show_entries frame=pict_type,pkt_size test.h264如果pict_type里I帧出现的间隔远大于设置的keyint_max,说明编码器没有按预期插入关键帧,通常是zerolatency模式把场景切换检测关闭导致的。
5.3 丢帧的两个源头:v4l2内核缓冲区不足与编码跟不上
采集链路的丢帧有两个完全不同的源头,混淆了会让排查绕远路。第一个源头在内核侧:v4l2的buffer数量太少,或者用户态DQBUF到QBUF之间的耗时太长,导致驱动没有空闲buffer可用,只能丢帧。这种情况在近距离观察DQBUF返回的sequence字段就能确认,如果相邻两帧的sequence跳变,就是采集侧丢的。
第二个源头在编码侧:编码线程处理一帧耗时超过了帧间隔,编码队列不断积压,队列满了以后后续的帧只能被丢弃。这种情况下DQBUF的sequence是连续的,但最终写入文件或推流的帧数少于采集帧数。区分这两个源头,可以在编码线程的入口打一个帧计数,和DQBUF的sequence对一下,数量不一致就说明编码侧丢帧。
v4l2采集buffer太少导致的丢帧,把REQBUFS的count从4加到8或16通常就能缓解。编码侧丢帧则要优化编码参数或降低分辨率。还有一种常见情况是编码线程的锁粒度太大,采集线程和编码线程共用一个互斥锁,编码时锁住整个buffer队列,采集线程等锁导致QBUF不及时。正确的做法是采集线程只维护一个空闲和已填充的buffer双队列,processing用无锁环形队列或者极短锁区间的队列。
5.4 用时间戳验证端到端延迟的测量方法
端到端延迟指从光线进入摄像头到解码器输出画面之间的时间。要测量这个时间,常规做法是在镜头前放一个秒表或者手机屏幕播放计时器,然后通过解码后的视频画面读取秒表读数,和真实时间做差。这个方法的误差大约在一帧左右,但仍然能识别出链路里是否存在异常积压。
更精确的方法是采集线程记录DQBUF返回的内核时间戳,编码线程记录x264_encoder_encode完成的时间,两者相减得到编码延迟。这个延迟包括采集队列等待时间和编码本身的时间,正常情况下应该在20到100毫秒之间,如果超过200毫秒,大概率是队列积压或者编码线程阻塞了。
struct timespec enc_start, enc_end; clock_gettime(CLOCK_MONOTONIC, &enc_start); int frame_size = x264_encoder_encode(enc, &nal, &nals, &pic_in, &pic_out); clock_gettime(CLOCK_MONOTONIC, &enc_end); double enc_ms = (enc_end.tv_sec - enc_start.tv_sec) * 1000.0 + (enc_end.tv_nsec - enc_start.tv_nsec) / 1e6;这段代码测出的enc_ms如果稳定地大于帧间隔就危险了,说明编码速度跟不上采集速度。帧间隔的倒数就是设置的帧率,比如30fps对应33.3ms。如果编码耗时在30ms左右波动,偶尔超过50ms,整体链路就会时快时慢,画面看起来不流畅甚至卡顿。这种情况下不要盲目调preset到ultrafast,先看是不是像素转换或内存拷贝占了太多时间——O(1)的memcpy在大分辨率下很慢,用帧池复用buffer可以减少一次拷贝。
6. 把采集编码封装成一个可复用的视频服务模块
6.1 模块边界划分与线程模型
做了一个能跑的采集编码程序之后,下一步自然是把它整理成可以复用的模块。模块的边界应该这样划:采集线程负责v4l2设备的打开、格式协商、buffer管理,输出的是裸帧;编码线程负责格式转换和H.264编码,输出的是NAL单元;这两个线程之间用一个有界队列解耦。队列的容量要设置成v4l2 buffer数量的一半,避免编码线程落后太多时队列积压导致内存无限增长。
线程模型用两个线程就够:一个采集线程,一个编码线程。不要在采集线程里做libyuv转换,1080p的YUYV转I420每帧大约要消耗3到5毫秒CPU,加到采集线程会挤压DQBUF到QBUF的时间窗口。转换应该放在编码线程的开头,这样采集线程只做最少的拷贝和入队操作。
队列读写需要用互斥锁和条件变量。写侧是采集线程,读侧是编码线程,如果编码线程处理不过来,队列满了就丢弃最旧的一帧还是阻塞等待,取决于应用场景。实时预览场景丢旧帧更合理,因为图像时效性比完整性重要;录制场景则应该阻塞或把帧计数递增,让后期知道缺失了多少帧。
6.2 编码器的动态参数更新与码率自适应
H.264编码器允许在运行过程中动态修改部分参数,不需要重新打开编码器。最常见的动态调整是码率:网络带宽变化时,通过x264_encoder_reconfig修改rc.i_bitrate和vbv参数,编码器会在后续帧中自动调整量化参数,逐步过渡到新码率。这个过程是平滑的,不会在调整瞬间出现画质骤变或码率骤降。
x264_param_t new_param; memcpy(&new_param, &cur_param, sizeof(x264_param_t)); new_param.rc.i_bitrate = new_bitrate_kbps; new_param.rc.i_vbv_max_bitrate = new_bitrate_kbps * 1.2; new_param.rc.i_vbv_buffer_size = new_bitrate_kbps * 1.2; x264_encoder_reconfig(enc, &new_param);reconfig能改的参数包括码率、帧率分子分母、关键帧间隔、量化参数范围,但改不了分辨率、色彩空间和profile这些在x264_encoder_open时确定的参数。想在运行中切换分辨率,只能重建编码器,但重建会导致解码端需要新的SPS/PPS,如果是RTP流,需要重新发送参数集。
x264_encoder_reconfig是线程安全的,可以在编码线程运行时从外部调用。帧率自适应不推荐频繁调整,因为IPC摄像头通常有固定的输出帧率,强行改编码帧率会导致时间戳和实际播放节奏错位,表现为画面播放速度忽快忽慢。
6.3 NAL单元的正确切分和流封装
x264输出的数据是一段连续的NAL单元流,每个NAL由起始码00 00 00 01或00 00 01分隔。从x264_encoder_encode的返回值里能直接拿到NAL数组和个数,但每个NAL包含起始码,所以写MP4时要把起始码去掉,只有裸流文件或RTP负载才保留起始码。
H.264裸流和MP4容器里的存储方式不同:裸流文件里每个NAL带起始码,解码器靠起始码定位;MP4里每个sample存储一个完整帧而不带起始码,SPS/PPS放到容器头部的avcC box里。如果直接用fwrite把NAL数组全部写进文件,得到的文件是裸流H.264,ffplay能直接播放,但很多播放器不认没有容器信息的裸流文件。
// 把x264输出的NAL按类型拆开处理 for (int i = 0; i < nals; i++) { uint8_t nal_type = nal[i].p_payload[4] & 0x1f; if (nal_type == 7 || nal_type == 8) { // SPS(7)和PPS(8)需要存入参数集缓存 save_parameter_set(nal[i].p_payload + 4, nal[i].i_payload - 4); } else { // slice数据,直接写入输出队列 write_to_output(nal[i].p_payload + 4, nal[i].i_payload - 4); } }这里去掉起始码是因为p_payload前四个字节是00 00 00 01,第5个字节才是NAL header,nal_type是header的低5位。如果做RTSP或SIP协议栈,SPS/PPS要单独缓存并在每次会话协商时发给对端;做普通文件存储,把SPS/PPS和关键帧数据一起交给容器封装器,比如用FFmpeg的libavformat写MP4,封装器会自己处理avcC。
6.4 可靠性措施:设备掉线重连与缓冲状态监控
摄像头设备在嵌入式环境里掉线是常态,USB设备拔插后/dev/video0可能变成/dev/video1,设备节点漂移会导致程序无法自动恢复。常见做法是程序周期性地检查设备文件是否存在,不存在则等待并重试open;如果open失败但文件存在,可能是设备总线错误或者驱动异常,可以先VIDIOC_STREAMOFF再重新走一遍格式协商流程。
while (running) { fd = open("/dev/video0", O_RDWR); if (fd < 0) { sleep(1); continue; } setup_v4l2_device(fd); run_streaming_loop(fd); close(fd); // 出错后回到循环入口,重新探测 sleep(1); }重新连接后必须重新做格式协商、申请buffer、mmap这一整套流程,因为之前的mmap映射已经失效,buffer也已经被释放。编码器则不需要重建,新采集的分辨率和像素格式如果没变,编码器可以直接接着用;如果摄像头换了型号导致分辨率变化,编码器必须重建并重新发送SPS/PPS。
缓冲状态监控可以用poll和select轮询实现,DQBUF设非阻塞模式配合poll超时,能检测出驱动卡死的情况。如果一轮poll超时,不要立刻重连,先重试几次,连续超时超过阈值再执行设备重连,避免摄像头偶发的时钟丢帧触发不必要的重建开销。v4l2的VIDIOC_STREAMON/VIDIOC_STREAMOFF可以在不改动buffer配置的情况下暂时停掉采集流,这个特性也可以用来实现省电或动态帧率控制。
本文还有配套的精品资源,点击获取