从V4L2到H.264:嵌入式Linux视频采集编码实践
2026/9/16 12:23:56 网站建设 项目流程

简介:这是一份面向嵌入式开发者的视频采集编码示例工程,解决从Linux V4L2接口获取原始视频数据并编码为H.264格式的问题。资源基于C语言编写,核心代码覆盖V4L2设备打开、帧缓冲读取、x264编码器调用等关键环节,适合正在学习嵌入式Linux视频流处理、或需要开发监控与图传项目的开发者参考。包内共11个文件,以C源文件、头文件为主,搭配Makefile编译脚本、JSON工程配置及README说明文档,整体打包约817KB,结构清晰便于按模块阅读。目前已有283人学习,内容包括v4l2_device与x264_encoder两个模块的源码实现,并配有配置文件与编译脚本,可对照代码理解V4L2视频采集、原始帧预处理、色彩空间转换以及H.264编码参数设置的全部流程。项目体量适中,能够帮助读者快速搭建采集编码流程,并在此基础上向实际嵌入式硬件平台移植扩展。

1. V4L2采集到H.264编码:一个能跑的嵌入式参考实现

假设你手头是一块ARM Linux开发板,需要把USB摄像头拍到的画面实时变成H.264码流,又不愿意为这一点活把GStreamer整个拉进来。这个raw2H.264-master包给的就是一条最短链路:V4L2打开/dev/video0,拿到YUYVNV12原始帧,转成I420后交给x264编码器,最终输出Annex-B格式的H.264码流。它没有用libavcodec那种大而全的抽象,而是用C语言直接操作ioctl,更适合用来理解采集和编码之间到底怎么衔接。适合嵌入式Linux、驱动、流媒体入门者拆开读。

2. V4L2设备采集链路:从打开摄像头到拿到原始帧

2.1 V4L2核心ioctl与缓冲区管理

V4L2(Video4Linux2)是Linux内核给视频设备提供的统一接口,它把摄像头抽象成字符设备,所有控制都通过ioctl完成。项目里的v4l2_device.c对设备打开、能力查询、格式设置、缓冲区申请做了一层封装,最终暴露给上层的是一个能持续返回帧指针的接口。

我一般会先查询设备能力,确认驱动支持VIDIOC_STREAMONMMAP模式,代码大致是这个样子:

#include <linux/videodev2.h> #include <fcntl.h> #include <sys/ioctl.h> int v4l2_open_device(const char *path) { int fd = open(path, O_RDWR | O_NONBLOCK); if (fd < 0) return -1; struct v4l2_capability cap; if (ioctl(fd, VIDIOC_QUERYCAP, &cap) < 0) { close(fd); return -1; } if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) { fprintf(stderr, "not a capture device\n"); close(fd); return -1; } return fd; }

这段代码做了三层判断:open失败说明设备节点不存在或权限不够;VIDIOC_QUERYCAP失败说明驱动没有正确响应;V4L2_CAP_VIDEO_CAPTURE标志位不存在说明当前设备不是采集设备。项目里在v4l2_device.h中暴露的v4l2_device_open基本就是这个逻辑,只是多了一个设备名参数和一个错误码记录。

缓冲区管理上,V4L2支持MMAPUSERPTRDMABUF三种内存方式。这个示例默认走MMAP,因为它在用户态只需要维护映射地址,省去手动分配对齐内存的麻烦。申请缓冲区调用VIDIOC_REQBUFS,指定V4L2_MEMORY_MMAP和缓冲区数量,然后通过VIDIOC_QUERYBUF拿到偏移量并mmap到用户空间。这里有个容易忽略的点:REQBUFS的数量不是越多越好,嵌入式平台往往只有3个左右可用,请求10个反而可能让驱动拒绝,常见做法是请求4个然后逐个检查。

2.2 像素格式与缓冲区映射参数怎么设

采集参数集中在struct v4l2_format里面,最关键的几个字段是widthheightpixelformatfield。项目里的config.h一定预留了这些宏,比如CAMERA_WIDTHCAMERA_HEIGHTV4L2_PIX_FMT_YUYV

struct v4l2_format fmt = {0}; fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; fmt.fmt.pix.width = 640; fmt.fmt.pix.height = 480; 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; }

这里必须注意:VIDIOC_S_FMT是设置和返回实际格式,驱动可能会因为不支持而改写宽高或像素格式。所以设置完要读回fmt.fmt.pix,对比widthpixelformat是否和期望一致,否则后续编码会拿到完全不对的尺寸。field设成V4L2_FIELD_NONE表示逐行扫描,隔行设备需要额外做去隔行,但这个项目针对的是USB摄像头和CSI摄像头,基本都可以按逐行处理。

接下来是VIDIOC_REQBUFSmmap。一个常见的参数组合是4个缓冲区,每个缓冲区大小用fmt.fmt.pix.sizeimage协商出来。要注意sizeimage在某些驱动里并不是严格的width*height*bpp,比如YUV422格式下可能是width*height*2加上对齐填充。因此项目里应该用这个字段来分配,而不是自己按公式硬算。

下面是一个常见的RAW帧格式对照表,判断摄像头输出和x264输入的差距:

格式排列方式每像素位数x264直接输入
YUYVY0 U0 Y1 V0 交替16不支持,需转换
NV12Y平面 + UV交错平面12需转换为I420
I420/YU12Y平面 + U平面 + V平面12直接支持

x264内部常用X264_CSP_I420,也就是YUV420 planar,三个平面分别连续存放。如果V4L2给出的是NV12,就需要做一个UV interleaved到planar的拷贝,这个转换在v4l2_device.cmain.c里会有单独的小函数。转换时别忽略y_stride和uv_stride,摄像头行对齐可能不是16像素,直接memcpy整行会把花屏问题带进编码器。

2.3 采集循环与丢帧处理

采集开始后,最外层的循环就是不断从驱动拿帧再还回去。标准流程是先VIDIOC_STREAMON,然后对每个空闲缓冲区调用VIDIOC_QBUF入队,再阻塞在poll上等待数据就绪,最后VIDIOC_DQBUF出队拿到一帧。

while (running) { fd_set fds; FD_ZERO(&fds); FD_SET(fd, &fds); struct timeval tv = {2, 0}; int ret = select(fd + 1, &fds, NULL, NULL, &tv); if (ret <= 0) continue; struct v4l2_buffer buf = {0}; buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; if (ioctl(fd, VIDIOC_DQBUF, &buf) < 0) { if (errno == EAGAIN) continue; break; } process_frame(buffers[buf.index].start, buf.bytesused); ioctl(fd, VIDIOC_QBUF, &buf); }

select的2秒超时是防止驱动异常时死等。DQBUF拿到buf.index后,立刻用该索引去查之前mmap的地址,然后交给后续编码线程。处理完必须马上VIDIOC_QBUF还给驱动,否则缓冲区耗尽,摄像头会自动丢帧。有些驱动没有把bytesused对齐到行,编码时如果按整帧大小读,可能读到上一帧的残留数据,这一步需要结合v4l2_pix_formatbytesperline做边界处理。

丢帧不只是发生在缓冲区不足的时候。如果编码线程平均耗时超过帧间隔,采集线程却始终保持全速运行,用户态队列就会溢出。我在类似项目里的做法是:当DQBUF返回成功但当前帧时间戳和上一帧间隔小于预期时,不做编码,直接将该缓冲区重新入队。这样编码器永远拿到的是节奏稳定的帧,而不是突然来一帧突然丢一帧。

3. x264编码器接入:把YU12帧变成H.264码流

3.1 x264_param_default_preset的参数权衡

x264库是H.264编码的事实标准,项目里的x264_encoder.c本质上做的是“图像帧进、NAL包出”的胶水工作。初始化编码器的第一步是准备好x264_param_t,调用x264_param_default_preset来填充参数。

x264_param_t param; x264_param_default_preset(&param, "veryfast", "zerolatency"); param.i_width = 640; param.i_height = 480; param.i_fps_num = 30; param.i_fps_den = 1; param.i_csp = X264_CSP_I420; param.rc.i_rc_method = X264_RC_ABR; param.rc.i_bitrate = 1000; param.i_keyint_max = 30; param.i_threads = 1;

veryfast预设意味着用极少的计算换吞吐量,对嵌入式CPU比较友好;zerolatency取消帧重排,编码器不会为了双向预测等未来帧,这对实时传输至关重要。i_threads设为1是为了避免帧级并行带来的延迟和内存开销,如果主板有多个核心并且不是硬实时场景,也可以设成2。

参数表里,i_fps_num/fps_den必须和V4L2实际帧率一致,否则码率控制会按错误的时间基计算。i_csp必须是X264_CSP_I420,所以第2章采集到的YUV数据要先做格式转换。i_keyint_max控制IDR帧间隔,30代表1秒一个关键帧,方便客户端快速开始解码,但会牺牲一点码率。i_bitrate是码率控制的目标值,单位kbps,在编码循环中需要最后再调用一次x264_encoder_headers来获取SPS/PPS。

还有一个隐藏参数容易被忽略:param.b_repeat_headers。建议设为1,让每个关键帧前面都带上SPS/PPS。这样播放器从文件中间开始播也能解码,对于RTSP拉流这种场景尤其重要。核心参数汇总如下:

参数推荐值说明
i_threads1减少并行延迟
i_keyint_max301秒关键帧
rc.i_methodABR简单码率控制
b_repeat_headers1关键帧附带SPS/PPS

3.2 图像帧封装与编码器交互

x264接受的不是裸的内存指针,而是x264_picture_t。这个结构描述了图像格式、平面指针和时间戳。需要把第2章拿到帧数据填充进去:

x264_picture_t pic_in, pic_out; x264_picture_alloc(&pic_in, X264_CSP_I420, 640, 480); pic_in.img.i_plane = 3; pic_in.img.plane[0] = y_plane; pic_in.img.plane[1] = u_plane; pic_in.img.plane[2] = v_plane; pic_in.img.i_stride[0] = width; pic_in.img.i_stride[1] = width / 2; pic_in.img.i_stride[2] = width / 2; pic_in.i_pts = frame_count++;

如果V4L2输出的是NV12或YUYV,就不能直接塞进pic_in.img.plane。项目一般在main.c里完成转换,转换时要注意i_stride数组,它表示每个平面每一行占用的字节数。很多摄像头bytesperline大于width,因为硬件要求行对齐,比如宽640的NV12帧bytesperline可能是672。这种情况下把整个帧按width做转换,会出斜纹或绿色边缘。

编码调用很简单:

x264_nal_t *nals; int i_nals = 0; int frame_size = x264_encoder_encode(encoder, &nals, &i_nals, &pic_in, &pic_out); if (frame_size > 0) { for (int i = 0; i < i_nals; i++) { fwrite(nals[i].p_payload, 1, nals[i].i_payload, out_fp); } }

x264_encoder_encode一次会输出一个或多个NAL单元,其中可能有SPSPPSIDR或普通P帧。项目里是把所有NAL直接写进文件或socket,这里要注意i_payload不等于frame_sizeframe_size是编码后总大小,真正的数据分布在nals数组里,必须逐个写。

3.3 编码输出的封装与调试

x264默认输出的是Annex-B格式,也就是每个NAL前面有00 00 00 01起始码,可以直接存储为.h264文件,也可以通过live555FFmpeg封装。如果要转成.mp4,一般把裸流交给ffmpeg -i raw.h264 -c copy out.mp4,不需要重新编码,保留原始码流。

调试时我习惯先把码流写到文件,再用ffprobe验帧:

ffprobe -v error -select_streams v:0 -show_packets output.h264

正常输出里可以看到flags=K的包,对应关键帧。如果发现第一个关键帧迟迟不出现,检查i_keyint_max是否设置成功;如果PTS乱跳,检查递增的frame_count是否被重置;如果文件大小远大于目标码率,检查i_bitrate的单位——x264用的是kbps,不是bps。项目里的README.md一般会写明输出路径和验证命令,这个包的README只有简短说明,所以我通常自己用上面的方法确认编码器没有空转。

4. 从Makefile到主板:编译、部署与常见坑

4.1 交叉编译与Makefile调整

压缩包根目录的Makefile直接控制整个项目的构建,里面默认的CCgcc,如果目标机器是ARM开发板,就需要改成交叉编译器。项目里sources很干净,只有main.cv4l2_device.cx264_encoder.c,头文件也齐全,没有复杂的生成步骤。

一个适用于arm-linux-gnueabihf的Makefile片段:

CC := arm-linux-gnueabihf-gcc CFLAGS := -O2 -Wall -I. -I$(X264_ROOT)/include LDFLAGS := -L$(X264_ROOT)/lib -lx264 -lpthread -lm TARGET := raw_to_h264 OBJS := main.o v4l2_device.o x264_encoder.o $(TARGET): $(OBJS) $(CC) -o $@ $(OBJS) $(LDFLAGS) clean: rm -f $(OBJS) $(TARGET)

这里有几个点要说明。-lx264要求目标板上有对应的libx264库文件,优先选择静态库libx264.a,这样部署时不用带着.so到处拷贝。如果x264源码本身也需要编译,用./configure --host=arm-linux-gnueabihf --enable-static --disable-opencl --disable-avs生成Makefile,注意--disable-avs不是必须的,看清configure帮助就好。编译时如果报undefined reference to x264_encoder_open,大概率是链接库顺序错误,把-lx264放到目标文件后面就行。

Makefile里的CFLAGS不建议用-g -O0,因为帧处理循环会非常慢。用-O2既能保持代码可读,又能让x264编码路径跑满。.vscode/c_cpp_properties.json在项目里是给IDE解析头文件用的,不影响构建,但如果你用不同交叉编译链,需要同步修改里面的compilerPath,否则排查花屏问题时看到的宏定义可能对不上。

4.2 运行时的V4L2权限与驱动问题

拿到编译好的raw_to_h264之后,最常见的运行错误是open /dev/video0: Permission denied。开发板在rootfs下通常不会自动加udev规则,需要手动把当前用户加入video组,或者直接在/etc/udev/rules.d/放一条规则。

sudo usermod -a -G video $USER newgrp video

如果设备节点存在但打开后返回Invalid argument,要用v4l2-ctl确认摄像头支持的分辨率和格式。例如:

v4l2-ctl -d /dev/video0 --list-formats-ext

这个命令会列出设备全部格式、每种格式下支持的分辨率。如果项目config.h写的是V4L2_PIX_FMT_YUYV但摄像头只支持NV12VIDIOC_S_FMT会因为协商失败返回EINVAL。解决方式是先读回驱动实际支持的格式,把config.h改成对应的宏。还有一个隐蔽问题:某些CSI摄像头驱动需要先设置media-ctl链路才能采集,这属于平台相关,只能参考芯片SDK。

运行时的mmap失败多数和内存碎片有关。V4L2请求连续内存失败时,可以改用USERPTR模式,自己在用户态分配对齐内存,并将指针传给驱动。但该项目没有实现USERPTR分支,所以如果遇到这种情况,优先检查ulimit -l限制和内核预留CMA大小。

4.3 编码花屏、延迟和内存泄漏定位

花屏是所有编码项目最头疼的问题。先分清是“采集端原图就花”还是“编码后花”。把第2章的process_frame里直接fwrite一帧YUV到文件,用ffplay -f rawvideo -pixel_format yuyv422 -video_size 640x480 frame.yuv看原始图。如果原图正常,那就是格式转换或stride问题。x264编码时i_stride必须和bytesperline一致,很多作者会用width代替,导致每行数据错位。解决方法是把v4l2_pix_formatbytesperline打印出来,然后在填充x264_picture_t时传给i_stride

下面是一张快速定位表,按出现频率排序:

现象最常见原因先检查哪里
画面斜纹stride与bytesperline不一致打印bytesperline
偏色/绿边像素格式转换错YUYV与NV12搞混
间歇性卡顿DQBUF后未及时QBUF检查环形队列
整体延迟高bframes或lookahead未关确认zerolatency

延迟方面,如果从采集到输出码流之间有200ms以上的延迟,先看x264_param_t里的i_bframeszerolatency预设下应该是0。再看rc.i_lookahead,这个值控制编码器向前看多少帧,在实时场景建议设0。最后用strace跟踪调用耗时,如果VIDIOC_DQBUF阻塞时间很长,说明驱动层积压帧,此时编码线程可能已经跑不动了,需要降低分辨率或码率。

内存泄漏排查看两个地方:x264_picture_alloc分配的图像缓冲是否在循环外只分配一次,循环结束后调用x264_picture_clean;V4L2缓冲区在程序退出时是否执行了munmapVIDIOC_REQBUFS(0)。另外DQBUF每成功的帧如果没有配对QBUF,驱动侧缓冲区池会慢慢耗尽,表现为编码器帧率抖降,然后select超时退出。用valgrind --leak-check=full ./raw_to_h264跑几十秒,如果看到definitely lost增长,优先级最高。

5. 进阶:把裸码流推进RTSP推流与时间戳对齐

5.1 时间戳来源与对齐

V4L2的struct v4l2_buffer自带timestamp字段,类型是struct timeval,但它在不同驱动下可能是CLOCK_MONOTONICCLOCK_REALTIME。直接把它当作编码PTS会出问题,因为x264只认单一时间基。我习惯在采集线程里用clock_gettime(CLOCK_MONOTONIC)维护一个计数器,每采集到一帧就把当前纳秒值换算成90kHz单位,作为pic_in.i_pts,换算公式是纳秒乘以9再除以100000,得到的整数就是RTP标准时间戳。这样一路走下来,推流端的音画同步才有基准。

5.2 接上live555或自写RTSP推流

项目本身没有推流功能,但编码器输出的是标准Annex-B码流,接推流框架很容易。用live555做RTSP Server时,需要把x264的NAL交给H264VideoRTPSink,关键是把SPS/PPS从码流里剥离出来单独成帧。常见做法是解析00 00 00 01之后的NAL type,type为7和8的分别保存,然后在continuePlaying里通过getNextFrame函数逐个发送。

if ((nal_type & 0x1F) == 7) { memcpy(sps, nal_data, nal_size); sps_len = nal_size; }

如果不做SPS/PPS分离,直接把整个H.264文件丢给VideoOpenFile,播放端很可能只出一个黑屏。另外记得为每个编码帧设置fDurationInMicroseconds = 33000,对应30fps,RTP打包器才知道时间戳间隔。

5.3 验证编码器性能的小工具

验证不一定要等推流。在裸码流阶段,可以用ffprobe确认帧类型和PTS:

ffprobe -v trace -i output.h264 2>&1 | grep -E "keyframe|pict_type"

要测实际编码吞吐,用time跑1分钟,对比帧计数:

time ./raw_to_h264 -c 1800

记录real时间和编码成功帧数,除以real就是平均吞吐。如果吞吐低于目标帧率,优先降低i_threadslookahead,再考虑换编码预设。

最后还有一个技巧:在编码器输出端加一个环形缓冲,采集线程与编码线程解耦,这样V4L2的帧率抖动不会直接传导到推流端。缓冲区大小设3帧就够,太大反而增加延迟。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询