资讯详情 树莓派4B USB摄像头C语言采集:V4L2完整编程指南
📅 2026/10/5 1:14:46
上周有位读者私信我树莓派4B上插了个USB摄像头用Python的OpenCV读画面轻轻松松一旦换成C语言就完全不知道从哪里下手V4L2到底是个什么东西这个问题我太熟了。很多人第一次接触Linux视频采集都是从“摄像头不出图”开始的设备节点明明存在v4l2-ctl也能看到格式但自己写代码就是各种报错根本原因在于没把V4L2这套“套路”吃透。V4L2是Linux内核里视频设备的统一抽象层所有USB摄像头UVC设备在Linux下默认都被它接管。你不需要写内核驱动只需要按照它规定的流程调用接口就能完成打开设备、协商格式、申请缓冲区、取帧、存图这一整套操作。这篇文章我会把完整流程拆开揉碎从系统准备讲到代码实现再把我实际调试中踩过的坑全部列出来。文中的C代码可以直接编译运行在树莓派4B上保存一帧JPEG图像到本地换到RK3588、Jetson或者其他Linux板卡上主流程同样适用。1. 树莓派上做摄像头采集为什么绕不开V4L21.1 USB摄像头和CSI摄像头走的是完全不同的路树莓派的官方摄像头模块Camera Module走的是CSI接口在Raspberry Pi OS里默认由libcamera框架接管调试方式偏向media controller那一套。USB摄像头则完全不一样它内部是一个标准的UVC设备插上USB口之后内核里的uvcvideo驱动会自动接管并生成一个或多个/dev/video*设备节点。这意味着对应用开发者来说USB摄像头是一个“天然的V4L2视频采集设备”。内核驱动已经把USB传输、协议解析、固件交互这些脏活累活干完了你只需要按照V4L2的规范去操作/dev/video0这个字符设备就行。很多初学者会在这一步被搞晕是因为看了太多CSI摄像头libcamera的资料又看到有人说“要用V4L2”两套概念搅在一起越查越乱。这里建议先分清楚CSI摄像头在树莓派上一般走libcamera或者内核media框架USB摄像头走的是v4l2应用接口。本文只讲后者。如果你用的是官方CSI摄像头并且想绕开libcamera直接操作V4L2节点也不是不行但新版系统里已经不太推荐调试成本会很高。1.2 V4L2不是软件包而是一套内核标准接口V4L2全称Video4Linux2是Linux内核里视频设备驱动与应用层之间的标准接口。它定义了一套ioctl命令、数据结构和缓冲区管理机制驱动按这套标准注册自己的能力应用按这套标准去调用双方不需要知道对方的具体实现。打个比方你去快递柜取件不管你买的是书、衣服还是手机取件流程永远是“输密码→开柜门→拿东西”。V4L2就是那个取件流程摄像头就是快递柜里的商品。UVC摄像头是常见商品CSI摄像头也是但应用层看到的取件方式是一致的。正是因为这套抽象你在树莓派4B上写的代码几乎原封不动就能跑到RK3588开发板、全志H616板子、Jetson Nano上。这也是我在后台看到“rk3588实现usb摄像头转成rtsp流”这类搜索词时一直建议大家先掌握V4L2应用编程的原因——平台可以随便换但只要还是LinuxV4L2这层就跑不掉。1.3 为什么选USB摄像头来入门成本低是最直接的原因。几十块钱就能买到一个720p的USB摄像头即插即用不需要排线不需要对引脚图。另一个重要原因是带宽和CPU压力。树莓派4B的USB 3.0口理论带宽远高于CSI但实际跑USB摄像头时高分辨率未压缩格式比如YUYV消耗的带宽非常夸张CPU还需要处理大量数据。好在绝大多数USB摄像头支持MJPEG硬件压缩摄像头内部直接输出JPEG帧CPU不需要做编码只要把数据存下来或转发即可。这对树莓派4B这种CPU不算富裕的板子来说是相当友好的。树莓派4B的40Pin排针上确实有CSI口但接USB摄像头完全不需要关心引脚方向、排线正反这些问题插上就能用。如果只是做一个入门验证项目USB摄像头是效率最高的选择。2. 上电之前系统准备与摄像头识别2.1 系统烧录与登录方式树莓派4B上跑Raspberry Pi OSBookworm或更新版本即可。用Raspberry Pi Imager烧录时建议在“进阶设置”里提前配置好WiFi、SSH和默认用户名密码这样可以省去接显示器和键盘的麻烦。烧录完成、插上USB摄像头、上电之后直接用SSH登录。如果你手头没有网线也没有显示器只能通过串口登录的话先确认USB转串口芯片的驱动是否正常。常见的CH340、CP2102、FT232这些芯片在Raspberry Pi OS里一般不需要额外装驱动但Windows/Mac端可能需要对应驱动。这一步如果卡住板子通电了也进不了系统后续全部无从谈起。进入系统后可以先更新软件源和软件包sudo apt update sudo apt upgrade -y sudo apt install -y v4l-utils build-essentialv4l-utils提供v4l2-ctl工具后面排查摄像头格式和参数时非常好用。build-essential提供gcc和编译所需的基础头文件。2.2 确认摄像头出现在 /dev/video0插上摄像头之后先不要急着写代码。用下面几条命令确认设备状态lsusb这条命令会列出USB总线上的设备。找到类似0c45:6366这种vendor:product格式的设备基本就是摄像头。ls /dev/video*USB摄像头通常会出现/dev/video0有时还会出现/dev/video1。video1一般是metadata节点采集视频不要用它确认主节点是video0即可。v4l2-ctl --list-devices这条命令会输出设备名称和对应的节点格式大概长这样HD USB Camera: HD USB Camera (usb-0000:01:00.0-1.2): /dev/video0 /dev/video1最后一条命令是关键v4l2-ctl --list-formats-ext -d /dev/video0它会打印摄像头支持的像素格式、分辨率和帧率矩阵。比如ioctl: VIDIOC_ENUM_FMT Type: Video Capture [0]: MJPG (Motion-JPEG, compressed) Size: Discrete 1920x1080 Interval: Discrete 0.033s (30.000 fps) Size: Discrete 1280x720 Interval: Discrete 0.033s (30.000 fps) [1]: YUYV (YUYV 4:2:2) Size: Discrete 640x480 Interval: Discrete 0.033s (30.000 fps)这里的信息直接决定了你代码里该填什么像素格式、什么分辨率。很多初学者代码写好了但VIDIOC_S_FMT报错就是因为用了摄像头不支持的格式组合先查支持列表能省很多事。如果ls /dev/video*根本没有节点用dmesg | tail看看内核日志。常见原因USB线供电不足、USB口接触不良、摄像头本身损坏。树莓派4B的USB口供电能力有限如果有多个外设抢电优先把摄像头插到靠近电源输入的那个USB口或者用带外部供电的USB Hub。2.3 权限问题提前解决如果不处理权限你可能会在open(/dev/video0, O_RDWR)这一步直接遇到Permission denied。ls -l /dev/video0正常情况下输出类似crw-rw---- 1 root video 81, 0 ...设备属于video组当前用户只要不在video组里就无法读写。解决办法是把用户加进video组然后重新登录sudo usermod -aG video $USER如果你用的是默认的pi用户在新版系统里需要先创建用户再用同样的方式添加组权限。临时调试时也可以直接加sudo运行程序但做长期项目时建议用组权限或者写udev规则而不是一直sudo。3. V4L2编程前必须理解的四个核心概念3.1 应用编程不是写驱动先澄清一个常见的误会像“stlink驱动”“ch340串口驱动”这类搜索词指的是给调试器、USB转串口芯片装系统驱动跟摄像头驱动是两码事。而“v4l2驱动框架”是指内核里drivers/media/v4l2-core那套驱动代码。但本文要做的是V4L2应用层编程。你是调用内核已经写好的驱动不是自己写驱动。应用层代码不涉及内核模块、设备树、驱动注册这些内容只是通过open/ioctl/mmap这些系统调用去操作驱动提供的接口。先把应用层流程跑通再去看内核驱动是效率最高的学习路径。如果一上来就扎进驱动源码很容易被video_device、vb2_queue这些概念淹没最后什么都没学会。3.2 ioctl是控制摄像头的那把钥匙设备节点本质上是一个字符设备文件。open之后可以像普通文件一样操作但视频采集不能只靠read/write因为摄像头不是普通的字节流设备它需要设置分辨率、像素格式、帧率这些是用ioctl系统调用完成的。ioctl接受三个参数文件描述符、请求码、指向结构体的指针。V4L2规范里定义了大量的VIDIOC_XXX请求码比如VIDIOC_QUERYCAP查询设备能力VIDIOC_S_FMT设置采集格式VIDIOC_REQBUFS申请缓冲区VIDIOC_QBUF把缓冲区放入队列VIDIOC_DQBUF从队列取出已填充的缓冲区VIDIOC_STREAMON/VIDIOC_STREAMOFF开始/停止采集每个请求码对应的参数结构体都不同有的是查询能力有的是设置格式有的是管理缓冲区。写V4L2程序本质就是按正确顺序调用这些ioctl。3.3 格式协商像素格式、分辨率、帧率是绑在一起的摄像头内部的传感器和ISP不是万能的它支持一组固定的输出模式。每个模式包含三个关键参数像素格式YUYV未压缩YUV 4:2:2、MJPEGJPEG压缩帧、NV12YUV 4:2:0等分辨率如640x480、1280x720、1920x1080帧率如30fps、25fps、15fpsVIDIOC_S_FMT就是应用层提出“我想要什么格式”驱动和硬件会尽量匹配。但要注意驱动不保证你提什么就给什么。比如你请求1920x1080 MJPEG摄像头不支持时S_FMT可能返回错误也可能静默改成最近支持的格式。因此严谨的程序在S_FMT之后必须回读fmt结构体确认实际协商出来的宽高和像素格式。我给新手的建议是写代码之前一定先跑一遍v4l2-ctl --list-formats-ext把摄像头支持矩阵抄下来再决定代码里填什么值。这个习惯能避免一大半的格式问题。3.4 缓冲队列与mmap生产者与消费者模型V4L2采集视频时摄像头驱动是生产者不间断地把图像数据填充到缓冲区里应用程序是消费者需要一帧就取一帧。这个模型通过四个ioctl串起来ioctl作用类比VIDIOC_REQBUFS申请N个缓冲区跟仓库申请N个货架VIDIOC_QUERYBUF查询缓冲区信息为mmap做准备查询货架位置VIDIOC_QBUF把空缓冲区放回驱动队列告诉仓库“用这个货架装货”VIDIOC_DQBUF从已填充队列取出一个缓冲区跟仓库领走装好货的货架内存映射模式MMAP是V4L2最常用的缓冲模式。驱动在内核空间分配缓冲区应用通过mmap把这块内核内存映射到用户空间取帧时直接读取省去了从内核到用户态的数据拷贝。在树莓派4B这种内存带宽有限的设备上少一次拷贝就是实打实的性能提升。理解了这四个ioctlV4L2的核心框架就通了。剩下的只是把代码按顺序写出来。4. 完整采集代码从打开设备到落盘一张JPEG4.1 整体流程导读下面是完整流程open打开设备节点QUERYCAP确认设备能力S_FMT设置MJPEG格式REQBUFS申请4个缓冲区QUERYBUFmmap完成内存映射QBUF把所有缓冲区放入队列STREAMON开始采集循环执行DQBUF取帧、处理、QBUF还缓冲最后STREAMOFF停止采集、munmap取消映射、close关闭设备。代码里我选用了MJPEG像素格式因为绝大多数USB摄像头支持硬件JPEG压缩应用层拿到手就是一张完整的JPEG图像直接写文件即可不需要任何格式转换。这是树莓派4B上采集效率最高的路径。4.2 可直接编译运行的完整代码/* * v4l2_capture.c * 树莓派4B USB摄像头V4L2 MMAP 模式采集一帧 MJPEG 图像 * * 用法: ./v4l2_capture [设备节点] [输出jpg] * 默认: ./v4l2_capture /dev/video0 capture.jpg */ #include stdio.h #include stdlib.h #include string.h #include errno.h #include fcntl.h #include unistd.h #include sys/ioctl.h #include sys/mman.h #include sys/select.h #include linux/videodev2.h #define WIDTH 640 #define HEIGHT 480 #define BUFFER_NUM 4 struct frame_buffer { void *start; size_t length; }; static struct frame_buffer buffers[BUFFER_NUM]; static int buffer_count 0; static int fd -1; static int xioctl(int fh, unsigned long request, void *arg) { int r; do { r ioctl(fh, request, arg); } while (r -1 errno EINTR); return r; } static int init_device(void) { struct v4l2_capability cap; struct v4l2_format fmt; struct v4l2_requestbuffers req; struct v4l2_buffer buf; unsigned int i; if (xioctl(fd, VIDIOC_QUERYCAP, cap) 0) { perror(VIDIOC_QUERYCAP); return -1; } if (!(cap.capabilities V4L2_CAP_VIDEO_CAPTURE)) { fprintf(stderr, 该设备不支持视频采集\n); return -1; } if (!(cap.capabilities V4L2_CAP_STREAMING)) { fprintf(stderr, 该设备不支持流式 IO\n); return -1; } printf(Driver : %s\n, cap.driver); printf(Card : %s\n, cap.card); printf(Bus : %s\n, cap.bus_info); 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_MJPEG; fmt.fmt.pix.field V4L2_FIELD_NONE; if (xioctl(fd, VIDIOC_S_FMT, fmt) 0) { perror(VIDIOC_S_FMT); return -1; } printf(Format : %dx%d, fourcc %c%c%c%c\n, fmt.fmt.pix.width, fmt.fmt.pix.height, fmt.fmt.pix.pixelformat 0xFF, (fmt.fmt.pix.pixelformat 8) 0xFF, (fmt.fmt.pix.pixelformat 16) 0xFF, (fmt.fmt.pix.pixelformat 24) 0xFF); memset(req, 0, sizeof(req)); req.count BUFFER_NUM; req.type V4L2_BUF_TYPE_VIDEO_CAPTURE; req.memory V4L2_MEMORY_MMAP; if (xioctl(fd, VIDIOC_REQBUFS, req) 0) { perror(VIDIOC_REQBUFS); return -1; } buffer_count req.count; printf(Buffers: %d\n, buffer_count); for (i 0; i buffer_count; i) { memset(buf, 0, sizeof(buf)); buf.type V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory V4L2_MEMORY_MMAP; buf.index i; if (xioctl(fd, VIDIOC_QUERYBUF, buf) 0) { perror(VIDIOC_QUERYBUF); 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 (buffers[i].start MAP_FAILED) { perror(mmap); return -1; } if (xioctl(fd, VIDIOC_QBUF, buf) 0) { perror(VIDIOC_QBUF); return -1; } } return 0; } static int start_streaming(void) { enum v4l2_buf_type type V4L2_BUF_TYPE_VIDEO_CAPTURE; if (xioctl(fd, VIDIOC_STREAMON, type) 0) { perror(VIDIOC_STREAMON); return -1; } return 0; } static int stop_streaming(void) { enum v4l2_buf_type type V4L2_BUF_TYPE_VIDEO_CAPTURE; return xioctl(fd, VIDIOC_STREAMOFF, type); } static int capture_to_file(const char *path, int timeout_ms) { struct v4l2_buffer buf; fd_set fds; struct timeval tv; FILE *fp NULL; int ret -1; memset(buf, 0, sizeof(buf)); buf.type V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory V4L2_MEMORY_MMAP; FD_ZERO(fds); FD_SET(fd, fds); tv.tv_sec timeout_ms / 1000; tv.tv_usec (timeout_ms % 1000) * 1000; if (select(fd 1, fds, NULL, NULL, tv) 0) { perror(select); return -1; } if (xioctl(fd, VIDIOC_DQBUF, buf) 0) { perror(VIDIOC_DQBUF); return -1; } fp fopen(path, wb); if (!fp) { perror(fopen); goto out; } fwrite(buffers[buf.index].start, 1, buf.bytesused, fp); if (ferror(fp)) { perror(fwrite); fclose(fp); goto out; } fclose(fp); printf(Saved : %u bytes - %s\n, buf.bytesused, path); ret 0; out: xioctl(fd, VIDIOC_QBUF, buf); return ret; } int main(int argc, char **argv) { const char *dev (argc 1) ? argv[1] : /dev/video0; const char *out (argc 2) ? argv[2] : capture.jpg; int i; fd open(dev, O_RDWR | O_NONBLOCK); if (fd 0) { perror(open); return 1; } if (init_device() 0) { close(fd); return 1; } if (start_streaming() 0) { close(fd); return 1; } if (capture_to_file(out, 5000) 0) fprintf(stderr, 采集失败\n); stop_streaming(); for (i 0; i buffer_count; i) { if (buffers[i].start buffers[i].start ! MAP_FAILED) munmap(buffers[i].start, buffers[i].length); } close(fd); return 0; }4.3 代码逐段拆解关键步骤为什么这么写xioctl封装ioctl调用被信号打断时会返回-1并设置errnoEINTR这是正常现象不应该当错误处理。xioctl里加一层循环遇到EINTR继续重试可以让整个程序稳定很多。生产环境的摄像头程序基本都这么写。VIDIOC_QUERYCAP能力校验打开设备之后第一件事就是查询能力确认这个设备支持视频采集和流式IO。如果不检查等后面S_FMT失败再回头排查定位问题的时间会多出好几倍。代码里打印出driver、card、bus_info这三个字段也能帮你确认当前操作的是不是预期的摄像头。VIDIOC_S_FMT格式协商这里请求的是V4L2_PIX_FMT_MJPEG640x480。为什么用MJPEG因为摄像头内部直接输出压缩后的JPEG帧树莓派4B不用再做任何编码工作CPU占用极低。field设置成V4L2_FIELD_NONE表示逐行扫描这是绝大多数USB摄像头的正常配置。VIDIOC_REQBUFS申请缓冲区代码请求4个缓冲区。这个数量不是瞎猜的太少会导致驱动来不及填充新帧应用取帧时经常空等太多会增加内存占用和延迟。4个在树莓派4B上是很均衡的配置。另外驱动不一定允许你要多少就给多少所以S_FMT和REQBUFS之后都要回读实际结果buffer_count记录的才是真正申请到的数量。MMAP映射每个缓冲区都通过mmap映射到用户空间映射标志用MAP_SHARED这是V4L2 MMAP模式的标准做法。映射完成后立刻把这个缓冲区通过VIDIOC_QBUF放回驱动队列意思是“这个货架空着可以用它装货”。DQBUF取帧取帧前先用select等待设备文件描述符可读并设置了5秒超时。这样即使摄像头掉线或者不出数据程序也不会死等。DQBUF成功后会得到buf.index和buf.bytesused前者告诉你数据在哪个缓冲区后者告诉你这一帧有多少字节。对MJPEG格式来说这些字节就是完整的JPEG图像直接fwrite就能得到一张可打开的照片。缓冲区归还一帧处理完之后必须再次调用VIDIOC_QBUF把缓冲区还给驱动。如果忘记这一步驱动可用的缓冲区会越来越少采集几帧之后就再也取不到数据了。这是V4L2代码里最常见的隐蔽bug。4.4 如果摄像头不支持MJPEG怎么办少部分USB摄像头只支持YUYV格式不支持MJPEG。遇到这种情况S_FMT直接用V4L2_PIX_FMT_MJPEG会报错。解决办法是把pixelformat改成V4L2_PIX_FMT_YUYV然后把采集到的数据做一次YUV到RGB/JPEG的转换。YUYV是一种未压缩格式每个像素都有亮度Y每两个像素共享一组色度UV。数据量比MJPEG大得多而且需要软件转换CPU占用会明显上升。转换公式参考// YUYV - RGBu/v为当前像素对应的色度分量 r clamp((298 * (y - 16) 409 * (v - 128) 128) 8, 0, 255); g clamp((298 * (y - 16) - 100 * (u - 128) - 208 * (v - 128) 128) 8, 0, 255); b clamp((298 * (y - 16) 516 * (u - 128) 128) 8, 0, 255);如果用C代码手写这个转换640x480一帧要做30万次运算树莓派4B全核跑也能接受但1080p就会吃力。建议优先选MJPEG摄像头或者用OpenCV的COLOR_YUV2RGB_YUYV来做转换。5. 编译、运行与我在实测中踩过的坑5.1 编译和第一次运行把上面的代码保存为v4l2_capture.c执行gcc -O2 -o v4l2_capture v4l2_capture.c ./v4l2_capture /dev/video0 capture.jpg如果一切正常终端会打印类似下面的信息Driver : uvcvideo Card : HD USB Camera: HD USB Camera Bus : usb-0000:01:00.0-1.2 Format : 640x480, fourcc MJPG Buffers: 4 Saved : 31142 bytes - capture.jpg然后用file capture.jpg验证一下文件类型正常会输出JPEG image data, JFIF standard。如果能打开图片恭喜V4L2的最小闭环已经跑通了。如果编译报错找不到linux/videodev2.h通常是linux-libc-dev没装执行sudo apt install linux-libc-dev再编译。5.2 踩坑实录open 阶段就报 Permission denied这个问题我在2.3已经预告过。出现open: Permission denied时先看ls -l /dev/video0的所属组把用户加进video组并重新登录。如果还不行检查是不是用了/dev/video1这种metadata节点换回/dev/video0。5.3 踩坑实录S_FMT 返回 Invalid argumentVIDIOC_S_FMT: Invalid argument几乎都是因为请求了摄像头不支持的格式组合。典型场景摄像头只支持YUYV你请求MJPEG或者640x480没问题但同一像素格式下1280x720不支持。排查手段就一条跑v4l2-ctl --list-formats-ext -d /dev/video0把像素格式、分辨率、帧率三项全部对齐之后再试。另外S_FMT之后一定要回读fmt结构体有的驱动会静默改掉你的参数不看回读值就容易踩坑。5.4 踩坑实录DQBUF 一直返回 Resource temporarily unavailable出现VIDIOC_DQBUF: Resource temporarily unavailable第一反应是设备没有数据可读。原因分几类摄像头线松了、供电不稳驱动没有真正开始传数据缓冲区数量太少异常丢帧后所有缓冲区都被占用没有新的空缓冲可以填充非阻塞模式下DQBUF没有数据就返回EAGAIN需要配合select或poll等待我在代码里就是用select等待可读事件同时在STREAMON之前把初始的QBUF全部执行到位。如果初始化时就忘了预先QBUF后面怎么等都不会有数据回来这个顺序问题非常隐蔽。5.5 踩坑实录拿到图片但颜色绿得没法看图像偏绿、偏紫、出现彩色条纹基本就是像素格式不匹配。比如驱动实际协商成YUYV但代码按MJPEG解析并直接写文件。不要光看S_FMT请求时填的参数要在S_FMT之后打印fmt.fmt.pix.pixelformat确认实际值。另外MJPEG帧直接写capture.jpg没问题YUYV帧直接当JPEG写出来那肯定是一张打不开或全绿的图。5.6 踩坑实录1080p 画面卡顿、帧率上不去先看摄像头在USB 2.0还是USB 3.0口上。树莓派4B的USB 3.0口和USB 2.0口带宽差距巨大高分辨率未压缩格式在USB 2.0上根本跑不满帧率。再换MJPEG格式试试带宽占用瞬间降一个量级。如果格式、接口都没问题还有可能是树莓派供电不足。USB摄像头启动和自动对焦时瞬时电流不小如果整个板子用的充电器功率不够摄像头会出现周期性断连。解决办法换一个5V 3A以上的正经电源或者给摄像头接带外部供电的USB Hub。5.7 踩坑实录插了两个摄像头分不清哪个是哪个多个USB摄像头接入时/dev/video0、/dev/video1的顺序不一定是固定的重启之后可能交换。不要写死节点路径。可以用v4l2-ctl --list-devices看设备名称也可以用/sys/class/video4linux/video0/name确认。更稳妥的做法是写udev规则根据摄像头的物理USB路径或者序列号生成固定软链SUBSYSTEMvideo4linux, KERNELvideo*, ATTRS{idVendor}0c45, ATTRS{idProduct}6366, SYMLINKmy_camera保存到/etc/udev/rules.d/99-camera.rules然后sudo udevadm control --reload之后就能通过/dev/my_camera稳定访问指定摄像头。6. 跑通单帧之后向视频流与更多场景延伸6.1 从单帧到持续视频流把capture_to_file改成循环调用就能得到持续的视频流。核心就是反复执行DQBUF→处理→QBUF再配合帧率统计while (running) { if (capture_one_frame(frame_data) 0) { process_frame(frame_data); // 你的算法/显示/编码逻辑 frame_count; if (frame_count % 30 0) { printf(fps %.2f\n, frame_count / elapsed_seconds); } } }注意处理帧的时间不要超过摄像头帧间隔否则缓冲区队列会堆积延迟越拉越大。在树莓派4B上做实时视频处理时建议把分辨率控制在720p以内并且优先用MJPEG格式。6.2 用 ffmpeg 或 GStreamer 转 RTSP 推流V4L2应用层读到的数据可以直接交给ffmpeg或GStreamer封装成RTSP流。在RK3588、树莓派4B这类板子上最简单的推流命令是ffmpeg -f v4l2 -input_format mjpeg -video_size 1280x720 -framerate 30 \ -i /dev/video0 -c:v copy -f rtsp rtsp://0.0.0.0:8554/live-input_format mjpeg让ffmpeg按MJPEG格式读取摄像头-c:v copy不转码直接封装RTSPCPU占用极低。如果播放器不支持MJPEG over RTSP就加一步软编码ffmpeg -f v4l2 -input_format mjpeg -video_size 1280x720 -framerate 30 \ -i /dev/video0 -c:v libx264 -preset ultrafast -tune zerolatency \ -f rtsp rtsp://0.0.0.0:8554/live树莓派4B软编码1080p很吃力720pultrafast基本可用。GStreamer也可以做同样的事gst-launch-1.0 v4l2src device/dev/video0 ! \ image/jpeg,width1280,height720,framerate30/1 ! jpegdec ! \ x264enc speed-presetultrafast tunezerolatency ! \ rtspclientsink locationrtsp://0.0.0.0:8554/live这些工具底层读的都是V4L2节点跟你用C代码采集数据是同一个入口。自己写代码的好处是能完全控制每一帧的处理逻辑很多AI部署场景就是这么干的。6.3 从V4L2应用走向字符设备驱动开发如果你搜过“v4l2驱动框架”“字符设备驱动框架”“linux驱动开发”说明你已经开始往底层钻了。建议路线是先把应用层这套接口吃透再去看内核里的videodev2.h头文件接着读drivers/media/v4l2-core/目录下的v4l2-device.c、videobuf2-core.c这些核心文件。V4L2驱动框架本质上是字符设备驱动框架在视频领域的具体实现。理解了V4L2应用层的“设备节点→ioctl→缓冲区”这条路再去看驱动的注册、ops回调、vb2队列会顺畅很多。不要反过来一开始就扎进驱动源码没有应用层概念的支撑驱动代码里那些结构体和回调函数会让你连该看哪一行都不知道。最后聊一点体感。V4L2这套代码框架是固定的跑通一次之后你会发现换任何一块Linux开发板、换任何一个UVC摄像头主流程都不用改。真正折腾人的从来不是那个ioctl序列而是USB线、电源、带宽、格式匹配这些看起来跟“代码”无关的小事。我后来做项目每次拿到新摄像头第一件事永远是插上跑一遍v4l2-ctl --list-formats-ext确认支持矩阵再动代码。这个小习惯至少帮我省掉了好几天排查格式问题的冤枉时间。