手上正好有一块树莓派4B,想接个USB摄像头做图像采集,很多人第一反应是去翻内核源码、写驱动模块,结果折腾一星期连图像都没看到。说实话,在树莓派上用V4L2驱动USB摄像头这件事,绝大部分情况压根不需要你动内核,UVC驱动早就集成在系统里了,你真正要干的其实是两件事:确认设备被正确枚举,然后通过V4L2用户态接口把图像数据拿回来。这篇文章我就把完整链路掰开揉碎讲清楚,从硬件接线到系统配置,从V4L2的核心机制到可编译运行的完整C代码,再到我自己踩过的各种坑,一次性给你打通。适合刚接触树莓派、Linux下视频采集,或者想弄明白V4L2框架到底怎么用的朋友,照着操作就能跑出第一张照片。
1. 项目背景与整体思路
1.1 为什么选树莓派4B加USB摄像头
树莓派4B是目前玩嵌入式视觉最顺手的平台之一,四核Cortex-A72处理器,1GB到8GB内存可选,算力足够跑常规的图像处理任务,而且GPIO、CSI、USB、HDMI这些外设接口一应俱全。搭配USB摄像头更是省事:不用拆排线、不用对齐引脚,插上就能用。相比CSI接口的官方摄像头模组,USB摄像头在成本、通用性、焦距选择上都更灵活,尤其适合快速原型验证和教学演示。
不过USB方案的代价就是数据要走USB总线,带宽和延迟都比CSI差一些。树莓派4B的USB 2.0和USB 3.0接口同时存在,但绝大多数USB摄像头都是USB 2.0的,满速480Mbps,实际可用带宽大约40MB/s。以720p分辨率、YUV422格式、30帧来算,一帧数据量大约是1280×720×2字节,约1.76MB,30帧就是52.8MB/s,已经超过USB 2.0的可用带宽了。所以实际部署时通常选MJPEG格式,压缩后每帧只有几十到几百KB,带宽压力大幅降低。这个选型思路后面代码里也会体现。
1.2 到底要不要自己写“驱动”
标题里带了“驱动”两个字,这是最容易误导新手的地方。实际上,Linux内核里对USB摄像头的支持非常成熟,UVC(USB Video Class)驱动已经是内核标准模块,树莓派官方系统里默认编译进去了。当你插入摄像头时,内核会自动识别设备并加载uvcvideo模块,你压根不需要自己写内核驱动。
那为什么网上还有那么多“写驱动”的教程?一种情况是某些非标摄像头没有遵循UVC协议,需要厂商提供专用驱动;另一种情况是作者把“调用V4L2接口”也叫做“驱动”,严格来说这是用户态编程,不是内核态驱动开发。我建议你先把用户态的V4L2流程跑通,如果确实遇到非UVC设备,再去研究内核驱动模块的编写。这篇文章聚焦的是V4L2编程框架,也就是应用层如何与内核驱动交互。
1.3 V4L2在整个视频采集链路中的位置
V4L2的全称是Video for Linux 2,它是Linux内核里视频设备的标准抽象接口。摄像头驱动在内核里把硬件数据整理好,V4L2作为中间层暴露一组标准API给用户态程序,你只需要操作open、ioctl、mmap、poll这些通用系统调用,就能控制摄像头采集、设置分辨率、读取图像数据。这个设计让应用层的代码可以跨设备复用:今天用树莓派的USB摄像头,明天换一块USB工业相机,只要对方支持UVC或V4L2,你的采集程序几乎不用改。
换句话说,V4L2就是Linux视频领域的“统一插座”,驱动是插座里的电线,你的程序是插头。理解了这层关系,你再看网上一堆V4L2教程,就会觉得脉络清晰很多。
2. 硬件准备与系统环境搭建
2.1 硬件清单与选型建议
- 树莓派4B主板一块,建议2GB内存以上版本。
- 系统存储卡,16GB以上Class 10的TF卡,读写速度直接影响系统流畅度。
- 5V 3A USB-C电源适配器,树莓派4B对供电要求比较高。
- 免驱USB摄像头,优先选UVC协议支持的型号。
- 键鼠、HDMI显示器,或者直接用SSH远程登录也行。
摄像头选型是很多人忽视的坑。建议不要买那种非常便宜、包装上只写“免驱”但没有品牌型号的摄像头,它们往往用的是老旧的中星微芯片或者芯片方案不公开的型号,虽然部分也能被UVC驱动识别,但兼容性很不稳定。优先选罗技C270、C920这类经典型号,或者任何标注支持UVC协议的工业摄像头,它们在Linux下都是即插即用。分辨率方面,如果你是做入门实验,640×480或者1280×720足够了,不要被4K宣传带跑,树莓派4B处理4K图像会非常吃力。
2.2 系统烧录与基础配置
操作系统我用的是Raspberry Pi OS,基于Debian的官方系统,比较省心。烧录直接用官方Raspberry Pi Imager工具,选择系统镜像后写入TF卡,同时可以在Imager里预设SSH开启、WiFi连接、用户名密码,这样板子开机后就能直接SSH进去,不用再接键鼠和显示器。
开机后用sudo raspi-config进配置界面,确认Camera接口和I2C等外设状态,但需要注意:Camera接口对应的是CSI摄像头,USB摄像头不受这个开关控制,所以即使Camera选项为Disable也不影响USB摄像头的使用。接着跑一下系统更新:
sudo apt update sudo apt full-upgrade -y顺便装上后面编译代码需要的工具链和测试软件:
sudo apt install -y build-essential v4l-utils git cmakev4l-utils里的v4l2-ctl和v4l2-compliance是非常好用的调试工具,后面排查问题会频繁用到。
2.3 检查摄像头是否被系统识别
插上USB摄像头,然后在终端执行:
lsusb正常输出里会有一行类似Bus 001 Device 004: ID 046d:0825 Logitech, Inc. Webcam C270的记录,这就是你的摄像头。如果这一行都没看到,优先检查线材、接口、供电,而不是看驱动。
再查内核日志:
dmesg | grep -i uvc能看到uvcvideo: Found UVC 1.00 device这类信息,说明UVC驱动已经加载成功。也可以通过lsmod | grep uvc确认模块状态。如果一切正常,你会看到/dev/video0设备节点:
ls -l /dev/video*出现video0之后,用v4l2-ctl快速验证一下摄像头能不能出图:
v4l2-ctl --list-devices v4l2-ctl --list-formats-ext -d /dev/video0第二条命令会列出摄像头支持的所有像素格式和分辨率、帧率组合。这一步拿到的东西很关键,后面代码里设置的格式必须在这个列表里,否则ioctl会直接报错。
3. V4L2核心机制解析
3.1 设备节点与驱动的关系
当你插入USB摄像头,内核里的UVC驱动会注册一个V4L2子设备,用户态看到的抽象就是/dev/video0。这个节点不是普通的字符设备那么简单,它有两大作用:一是通过ioctl系统调用下发控制命令,比如设置格式、申请缓冲区、启动采集;二是通过read、write、mmap等接口传输图像数据。
更复杂一点的路由架构里,设备可能同时暴露/dev/video0和/dev/video1,其中一个负责图像采集,另一个负责输出或者元数据,具体要看驱动实现。但对于普通USB摄像头,/dev/video0就是你的采集入口。还有个小知识点:/dev/v4l-subdev0这类节点属于内核子设备接口,用户态程序通常不会直接操作它,调试的时候偶尔会用到。
3.2 像素格式与分辨率的选择
V4L2里最常用的三种像素格式是YUYV、MJPEG和H264。YUYV是未压缩的原始YUV数据,每像素2字节,画质最好但数据量巨大;MJPEG是Motion JPEG压缩,每帧独立编码,解码简单,对CPU占用低,非常适合树莓派;H264压缩率最高,但解码需要额外算力,而且USB摄像头出来的H264流往往有编码延迟,不太适合实时预览。
分辨率的选择要综合考虑摄像头能力、带宽和处理性能。参考前面算过的带宽账,720p下选YUYV可以达到15到20帧,选MJPEG能轻松到30帧。如果你的应用需要逐帧处理像素,比如做人脸识别、颜色检测,那么MJPEG格式解码后和YUYV在像素内容上没有本质区别,只是你要额外做一步解压。我的建议是:先按摄像头默认格式跑通流程,再根据实际需求调整。
3.3 缓冲队列机制
V4L2采集模式分为两种:读模式和流式模式。读模式用的是read系统调用,驱动内部帮你完成缓冲和拷贝,代码简单但效率低;流式模式需要你主动申请缓冲区,用mmap映射到用户态,然后通过入队出队循环拿帧,效率高,是工业应用的主流做法。
流式模式的核心就是缓冲队列。你在驱动里申请N个缓冲区,把缓冲区放入驱动维护的队列,驱动从硬件收到一帧数据后填到队列头部缓冲区,应用从队列取出已填满的缓冲区处理数据,处理完再放回队列供驱动使用。这个环形流水线设计的好处是:摄像头始终有可用缓冲,不用等应用处理完上一帧才开始下一帧,最大限度避免丢帧。缓冲区个数,也就是nbuffers,一般设4到6个,太少容易丢帧,太多浪费内存、增加延迟。
4. 完整代码实现与详细讲解
4.1 基础框架与设备打开
下面这套代码我按“能跑、能看懂、能扩展”三个原则来写。它做的事情不复杂:打开摄像头,设置640×480的YUYV格式,申请4个缓冲区,启动采集,连续采集10帧并把每帧保存成BMP文件。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <unistd.h> #include <errno.h> #include <sys/ioctl.h> #include <sys/mman.h> #include <linux/videodev2.h> #define DEVICE_PATH "/dev/video0" #define WIDTH 640 #define HEIGHT 480 #define BUFFER_COUNT 4 struct buffer_info { void *start; size_t length; }; static struct buffer_info buffers[BUFFER_COUNT]; static int fd = -1; static int xioctl(int fd, unsigned long request, void *arg) { int ret; do { ret = ioctl(fd, request, arg); } while (-1 == ret && EINTR == errno); return ret; }代码里的xioctl是一个简陋的包装,目的是自动重试被信号中断的ioctl调用。这是V4L2编程的第一个常见坑:ioctl返回EINTR时不能直接当错误处理,应该重新调用。当然更完善的做法是用select或poll等待设备可读,避免忙等,后面编码实战部分会提到。
打开设备用open即可:
fd = open(DEVICE_PATH, O_RDWR | O_NONBLOCK); if (fd < 0) { perror("open device failed"); return 1; }O_NONBLOCK标志很关键,它让read和poll在设备没有数据时立即返回,而不是阻塞线程,方便你在循环里做超时控制或取消处理。
4.2 查询设备能力与设置采集格式
打开设备后要做两件事:查询设备能力,确认设备支持视频采集;设置采集格式,告诉驱动你想要什么类型的数据。
struct v4l2_capability cap; memset(&cap, 0, sizeof(cap)); if (-1 == xioctl(fd, VIDIOC_QUERYCAP, &cap)) { perror("VIDIOC_QUERYCAP failed"); return 1; } if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) { fprintf(stderr, "device does not support video capture\n"); return 1; }设备能力检查通过后,接下来设置视频采集格式:
struct v4l2_format fmt; memset(&fmt, 0, sizeof(fmt)); fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; fmt.fmt.pix.width = WIDTH; fmt.fmt.pix.height = HEIGHT; fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV; fmt.fmt.pix.field = V4L2_FIELD_NONE; if (-1 == xioctl(fd, VIDIOC_S_FMT, &fmt)) { perror("VIDIOC_S_FMT failed"); return 1; }这里有几个点值得注意。fmt.type必须设成V4L2_BUF_TYPE_VIDEO_CAPTURE,如果你用的是V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE,那对应的是多平面格式,缓冲区设置方式完全不同。pixelformat对应当前像素格式,我这里用的是YUYV,也就是每个像素占用2字节、每4个Y分量共享一组UV分量的打包格式。field设为V4L2_FIELD_NONE表示逐行扫描,USB摄像头基本都是这个模式。
设置完格式后,建议把fmt重新读一遍:
if (-1 == xioctl(fd, VIDIOC_G_FMT, &fmt)) { perror("VIDIOC_G_FMT failed"); return 1; } printf("Driver set to %dx%d, format 0x%08x\n", fmt.fmt.pix.width, fmt.fmt.pix.height, fmt.fmt.pix.pixelformat);有些摄像头不支持你请求的精确分辨率,驱动可能会自动调整为就近的合法值。如果你不重新读取,后面申请的缓冲区大小可能和实际不匹配,轻则显示花屏,重则申请失败。记住一个经验:和硬件打交道,请求后一定要确认实际结果。
4.3 申请缓冲区与内存映射
这一步是V4L2采集的精髓所在。先通过VIDIOC_REQBUFS请求内核分配缓冲区:
struct v4l2_requestbuffers req; memset(&req, 0, sizeof(req)); req.count = BUFFER_COUNT; req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; req.memory = V4L2_MEMORY_MMAP; if (-1 == xioctl(fd, VIDIOC_REQBUFS, &req)) { perror("VIDIOC_REQBUFS failed"); return 1; } if (req.count < 2) { fprintf(stderr, "insufficient buffer memory\n"); return 1; }注意req.count既是输入也是输出。你请求4个,驱动可能只分配2个,所以申请完必须检查实际分配的数。memory选择V4L2_MEMORY_MMAP,这是最常用、也是效率比较高的一种模式,驱动把内核缓冲区直接映射到用户空间,省去了内存拷贝。
接下来逐个查询缓冲区信息并做映射:
for (int i = 0; i < BUFFER_COUNT; 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 (-1 == xioctl(fd, VIDIOC_QUERYBUF, &buf)) { perror("VIDIOC_QUERYBUF failed"); return 1; } buffers[i].length = buf.length; buffers[i].start = mmap(NULL, buf.length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, buf.m.offset); if (MAP_FAILED == buffers[i].start) { perror("mmap failed"); return 1; } }这里查询每个缓冲区,得到它的长度和物理偏移,然后mmap映射到用户空间。映射标志用MAP_SHARED而不是MAP_PRIVATE,因为驱动要直接往这块内存写数据,私有映射的内存无法共享。buf.length在映射后保存下来,后面分析帧数据长度时会用到。
4.4 入队、启动采集与读取帧
缓冲区映射完成后,把它们全部放入驱动队列,然后开始采集:
for (int i = 0; i < BUFFER_COUNT; 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 (-1 == xioctl(fd, VIDIOC_QBUF, &buf)) { perror("VIDIOC_QBUF failed"); return 1; } } enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE; if (-1 == xioctl(fd, VIDIOC_STREAMON, &type)) { perror("VIDIOC_STREAMON failed"); return 1; }这里VIDIOC_QBUF是“把缓冲区还给驱动”的操作,驱动拿到空缓冲区后开始往里填数据。所有缓冲区入队后,VIDIOC_STREAMON通知驱动“我准备好了,开始干活”。从这一刻起,摄像头采集数据会持续送到缓冲区。
采集循环就是一个典型的出队—处理—入队过程:
for (int frame = 0; frame < 10; frame++) { struct v4l2_buffer buf; memset(&buf, 0, sizeof(buf)); buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; if (-1 == xioctl(fd, VIDIOC_DQBUF, &buf)) { perror("VIDIOC_DQBUF failed"); return 1; } // 处理图像数据 process_image(buffers[buf.index].start, buf.bytesused, frame); if (-1 == xioctl(fd, VIDIOC_QBUF, &buf)) { perror("VIDIOC_QBUF after processing failed"); return 1; } }VIDIOC_DQBUF出队一个缓冲区,驱动把填满的图像数据交给你,buf.bytesused告诉你当前帧的实际字节数,buf.index告诉你数据在哪个缓冲区。你处理完数据后,必须执行VIDIOC_QBUF把这个缓冲区重新放回驱动队列,否则驱动可用缓冲区越来越少,很快就没有空缓冲可用了,采集就会中断。这个循环是V4L2编程里最核心的节拍,务必刻在脑子里。
前面open时加了O_NONBLOCK,所以VIDIOC_DQBUF在没有数据时会返回EAGAIN错误,代码里会直接当成出错退出。实际项目里你应该用select或poll先等待缓冲区就绪,再执行出队操作,这样能避免CPU空转、也能处理超时场景。
4.5 保存为BMP图像
光拿到原始YUV数据还不够直观,为了验证采集成功,我把每一帧保存成BMP文件。YUYV转BMP需要做色彩空间转换,下面这个函数把YUYV像素转成RGB:
static void yuyv_to_rgb24(unsigned char *yuyv, unsigned char *rgb, int width, int height) { int size = width * height; for (int i = 0, j = 0; i < size; i += 2, j += 4) { int y0 = yuyv[j]; int u = yuyv[j + 1] - 128; int y1 = yuyv[j + 2]; int v = yuyv[j + 3] - 128; int r = y0 + 1.402 * v; int g = y0 - 0.344 * u - 0.714 * v; int b = y0 + 1.772 * u; rgb[i * 3 + 0] = r > 255 ? 255 : (r < 0 ? 0 : r); rgb[i * 3 + 1] = g > 255 ? 255 : (g < 0 ? 0 : g); rgb[i * 3 + 2] = b > 255 ? 255 : (b < 0 ? 0 : b); r = y1 + 1.402 * v; g = y1 - 0.344 * u - 0.714 * v; b = y1 + 1.772 * u; rgb[(i + 1) * 3 + 0] = r > 255 ? 255 : (r < 0 ? 0 : r); rgb[(i + 1) * 3 + 1] = g > 255 ? 255 : (g < 0 ? 0 : g); rgb[(i + 1) * 3 + 2] = b > 255 ? 255 : (b < 0 ? 0 : b); } }这里用到的转换公式是BT.601标准,系数属于行业惯例。实际项目里如果想追求性能,可以查表替代浮点运算,或者直接用CPU SIMD指令优化,但对理解原理来说这个版本足够了。BMP文件的结构比较简单,54字节的文件头加上像素数据,注意BMP的像素行需要4字节对齐,不过640×480正好满足对齐条件,省了不少事。
static void save_bmp(const char *filename, unsigned char *rgb, int width, int height) { int data_size = width * height * 3; int file_size = 54 + data_size; unsigned char header[54] = {0}; header[0] = 'B'; header[1] = 'M'; header[2] = file_size & 0xff; header[3] = (file_size >> 8) & 0xff; header[4] = (file_size >> 16) & 0xff; header[5] = (file_size >> 24) & 0xff; header[10] = 54; header[14] = 40; header[18] = width & 0xff; header[19] = (width >> 8) & 0xff; header[20] = (width >> 16) & 0xff; header[21] = (width >> 24) & 0xff; header[22] = height & 0xff; header[23] = (height >> 8) & 0xff; header[24] = (height >> 16) & 0xff; header[25] = (height >> 24) & 0xff; header[26] = 1; header[28] = 24; FILE *fp = fopen(filename, "wb"); if (!fp) { perror("fopen failed"); return; } fwrite(header, 1, 54, fp); fwrite(rgb, 1, data_size, fp); fclose(fp); }最后在main函数里把这些串起来,采集10帧,每帧保存成一个文件:
int main(void) { fd = open(DEVICE_PATH, O_RDWR | O_NONBLOCK); if (fd < 0) { perror("open device failed"); return 1; } if (init_device() < 0) { cleanup(); return 1; } start_capture(); printf("capture 10 frames...\n"); for (int frame = 0; frame < 10; frame++) { struct v4l2_buffer buf; memset(&buf, 0, sizeof(buf)); buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; while (1) { if (-1 == xioctl(fd, VIDIOC_DQBUF, &buf)) { if (errno == EAGAIN) { usleep(5000); continue; } perror("VIDIOC_DQBUF failed"); cleanup(); return 1; } break; } unsigned char *rgb = malloc(WIDTH * HEIGHT * 3); yuyv_to_rgb24((unsigned char *)buffers[buf.index].start, rgb, WIDTH, HEIGHT); char filename[64]; snprintf(filename, sizeof(filename), "frame_%02d.bmp", frame); save_bmp(filename, rgb, WIDTH, HEIGHT); free(rgb); if (-1 == xioctl(fd, VIDIOC_QBUF, &buf)) { perror("VIDIOC_QBUF failed"); cleanup(); return 1; } } stop_capture(); cleanup(); printf("done.\n"); return 0; }实际上我为了代码结构清晰,把初始化、开始采集、停止、清理分别封装成了函数。你可以把上面的模块按自己习惯组织,核心流程不变:查询能力、设置格式、申请缓冲、映射、入队、开始采集、出队处理、入队、结束后停流并解除映射。编译命令很简单:
gcc -O2 -o v4l2_capture v4l2_capture.c然后以root权限运行(摄像头设备节点通常需要root权限):
sudo ./v4l2_capture运行之后当前目录会多出frame_00.bmp到frame_09.bmp,用图片查看器打开,如果能看到画面,恭喜你,整条V4L2链路已经通了。
5. 常见问题与排查技巧实录
5.1 设备节点找不到或没有video0
这个问题处理过不少次,大多数情况不是驱动问题,而是设备权限或系统未识别。先看lsusb和dmesg,确认设备已被USB层枚举。如果lsusb有设备但/dev/video0没出现,大概率是UVC驱动没加载,手动加载一下:
sudo modprobe uvcvideo如果模块加载报错,检查内核版本和摄像头协议。还有一种比较隐蔽的情况:某些摄像头在启动时初始化慢,开机后需要等几秒才出现设备节点,自动脚本如果立刻访问就会失败。可以在代码里加一个重试逻辑,循环判断设备节点是否存在,超时再报错。此外,权限问题也很常见:可以临时用sudo运行程序,或者把当前用户加入video组,一劳永逸:
sudo usermod -aG video $USER修改后需要重新登录生效。
5.2 mmap失败或VIDIOC_REQBUFS返回ENOMEM
VIDIOC_REQBUFS返回内存不足一般是两个原因:请求缓冲区个数太多,或者系统可用内存紧张。树莓派4B内存够大,通常不会因为总量不足而失败,更多是摄像头驱动限制,比如某些硬件只支持2个缓冲,你请求了6个。这时候把req.count改小到2或3,如果还是失败,那就是驱动或者硬件问题了。
mmap失败最常见的原因是映射长度参数不对,或者拿到的buf.offset不合法。检查一下你是不是在VIDIOC_QUERYBUF成功后立刻映射,之间最好不要穿插其他ioctl调用。还有些老摄像头需要先STREAMON再mmap,但UVC设备一般不需要,遇到这种情况可以调整一下顺序试试。
5.3 画面偏绿或颜色不对
画面偏绿是YUYV转RGB时最容易出现的现象。检查两件事:第一,是否把Y和UV分量顺序搞反,YUYV四个字节的顺序是Y0、U、Y1、V,不是Y0、V、Y1、U,顺序反了画面偏洋红或绿色;第二,转换公式里的色度系数是否正确。有些摄像头输出的实际格式是UYVY而不是YUYV,两者字节顺序差很多,你需要在设置格式前先确认摄像头实际支持的格式,或者代码里打印一下pixelformat字段,看看内核驱动到底给你调成了什么格式。
如果是MJPEG格式抓到的数据,那就另说了:你拿到的是一段JPEG压缩数据,必须先解压成YUV或RGB才能显示或保存为普通图片。直接当裸数据写文件会得到一堆乱码。解决办法是用libjpeg或者OpenCV的imdecode,让它帮你解压。
5.4 采集帧率上不去或画面卡顿
帧率达不到标称值,先别急着骂摄像头,算一笔账:640×480的YUYV帧是614KB,30帧就是18MB/s,USB 2.0接口实际可用带宽大约在30MB/s上下,这个组合还有余量。但如果你用1080p的YUYV,每帧接近4MB,30帧就是120MB/s,远超USB 2.0上限,帧率自然掉到7到8帧。解决办法是切到MJPEG格式,压缩后帧负载只有几十KB到几百KB,30帧毫无压力。
除了格式,你的应用代码也可能成为瓶颈。比如在采集循环里做大量的文件写入、打印日志、复杂图像处理,都会拖慢出队入队速度。把耗时操作挪到单独的线程里,或者用环形缓冲把采集和处理解耦,是工业项目里的常规方案。
另外,select和poll配合VIDIOC_DQBUF性能会好一些,因为阻塞等待不会空转CPU。代码里用usleep(5000)轮询属于土办法,能跑但不够优雅,追求精细控制的朋友建议改成poll。
6. 扩展方向与进阶指南
6.1 用MJPEG格式进一步降低带宽占用
把代码里的V4L2_PIX_FMT_YUYV换成V4L2_PIX_FMT_MJPEG,bytesused会从60多万字节骤降到几万甚至几千字节。但注意MJPEG是一帧一幅独立JPEG图,你需要引入JPEG解码库。最简单的做法是用OpenCV,它自带MJPEG解码能力,只需要把CAP_PROP_FOURCC设置为MJPG。如果不想用OpenCV,libjpeg-turbo是非常轻量的选择,解码速度很快,树莓派上跑1080p MJPEG也能达到实时。
6.2 结合RTP推流实现网络摄像头
采集到MJPEG或H264数据之后,可以通过RTP协议封装,推给VLC或者网页播放器,这就把树莓派变成了一台网络摄像头。需要用到GStreamer或者FFmpeg,FFmpeg的命令行可以非常简单:
ffmpeg -f v4l2 -input_format mjpeg -video_size 1280x720 -framerate 30 -i /dev/video0 -f rtsp -rtsp_transport tcp rtsp://0.0.0.0:8554/live这条路本质上和你写的V4L2采集程序不冲突,FFmpeg内部就是通过V4L2接口拿数据,只是它把编码、封装、推流全部做完了。自己写代码的话,采集部分完全复用前面这套流程,后面加编码器和网络发送逻辑就行。
6.3 从单帧采集到实时图像处理
这篇文章的代码停在“采集10帧保存图片”,实际很多项目要的是连续处理视频流。把for循环改成while(1),每次出队处理完再入队,就是一个最简实时处理框架。树莓派4B上跑OpenCV的人脸检测、颜色追踪、运动检测,输入源都可以直接用V4L2方式打开。有一点提醒:处理慢于帧率时,缓冲区队列会被占满,新帧进不来,表现为画面掉帧,解决办法是丢旧帧保新帧,或者干脆用多线程排队。
我个人在实际操作中的体会是,V4L2这套框架最大的价值不在于某个函数怎么写,而在于你理解了“设备是资源、缓冲区是流水线”这个模型。把这个模型吃透,以后不管换什么摄像头、跑到什么平台,都能很快上手。最后再分享一个小技巧:调试阶段多利用v4l2-ctl命令行工具,它能帮你快速确定是摄像头问题、驱动问题还是自己代码的问题,省下大量毫无头绪的排查时间。