ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

v4l2采集到H.264编码全链路解析:缓冲、转换与延迟优化

v4l2采集到H.264编码全链路解析:缓冲、转换与延迟优化 简介面向嵌入式开发者的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.2643.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是编码器要求的像素格式如果输入不是yuv420pffmpeg会自动插入转换。-g 30设置关键帧间隔为30帧-b:v 1M限制平均码率1Mbps。这条命令验证完之后可以用ffprobe查看输出文件的编码信息确认确实是H.264格式ffprobe -show_streams -select_streams v -show_entries streamcodec_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(param, 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(param); 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单位是kbps1000就是1Mbps。X264_CSP_I420表示输入格式如果手里是NV12需要先用libyuv或手写函数转换成I420x264不直接吃NV12。编码输出的NAL单元有两种类型需要区分处理SPS/PPS和slice数据。第一次调用x264_encoder_encode或每个关键帧前输出里会带VCL之前的SPS/PPS。写文件时全写上没问题但做RTP流媒体时SPS/PPS不能和slice放在同一个RTP包里需要单独打包。这就是为什么很多流媒体服务要单独解析NAL类型并做缓存。3.4 像素格式转换NV12/YUYV到I420的常见做法v4l2摄像头输出的格式五花八门USB摄像头最常见的是YUYVCSI接口的摄像头在驱动配置后可以输出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(param, 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里有两个参数控制GOPi_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-count100 --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 framepict_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.264ffplay能直接播放但很多播放器不认没有容器信息的裸流文件。// 把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 headernal_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配置的情况下暂时停掉采集流这个特性也可以用来实现省电或动态帧率控制。本文还有配套的精品资源点击获取
返回列表