📘 摄像头模块(七):编写 V4L2 设备框架
前置:必须理解摄像头模块(六):结构体抽象与设备管理
文章目录
- [📘 摄像头模块(七):编写 V4L2 设备框架](#📘 摄像头模块(七):编写 V4L2 设备框架)
-
- [🎯 本节核心目标](#🎯 本节核心目标)
- [📂 本节涉及的文件](#📂 本节涉及的文件)
- [🧱 V4L2 初始化的"六步法"(框架图)](#🧱 V4L2 初始化的“六步法”(框架图))
- [🔍 逐个步骤详解(包含你碰到的困惑)](#🔍 逐个步骤详解(包含你碰到的困惑))
-
- [步骤 0:全局支持的格式列表](#步骤 0:全局支持的格式列表)
- [步骤 1:打开设备](#步骤 1:打开设备)
- [步骤 2:查询设备能力(VIDIOC_QUERYCAP)](#步骤 2:查询设备能力(VIDIOC_QUERYCAP))
- [步骤 3:枚举并选择像素格式(VIDIOC_ENUM_FMT)](#步骤 3:枚举并选择像素格式(VIDIOC_ENUM_FMT))
- [步骤 4:设置图像格式(VIDIOC_S_FMT)](#步骤 4:设置图像格式(VIDIOC_S_FMT))
- [步骤 5:申请缓冲区(VIDIOC_REQBUFS)并 mmap](#步骤 5:申请缓冲区(VIDIOC_REQBUFS)并 mmap)
- [步骤 6:把缓冲区放入驱动队列(VIDIOC_QBUF)--- 仅 streaming 模式](#步骤 6:把缓冲区放入驱动队列(VIDIOC_QBUF)— 仅 streaming 模式)
- [🧪 其他辅助函数(暂不深究,(九) 会讲)](#🧪 其他辅助函数(暂不深究,(九) 会讲))
- [📌 最容易犯的错误(我在学习中踩过的坑)](#📌 最容易犯的错误(我在学习中踩过的坑))
- [🧭 完整代码清单(v4l2.c 骨架,核心部分已注释)](#🧭 完整代码清单(v4l2.c 骨架,核心部分已注释))
- [✅ 本节自测题(检验是否真的掌握框架)](#✅ 本节自测题(检验是否真的掌握框架))
- [🖼️ 图解:V4L2 初始化与数据流](#🖼️ 图解:V4L2 初始化与数据流)
- [💬 总结](#💬 总结)
🎯 本节核心目标
把 📘摄像头模块(六):结构体抽象与设备管理 中定义的 VideoOpr 函数指针全部实现 ,让 V4L2 摄像头能够被我们的统一框架调用。
最终得到一个 g_tV4l2VideoOpr 结构体,并通过 V4l2Init() 注册到全局链表中。
学习重点不是记忆每个 ioctl 的参数,而是掌握 V4L2 设备初始化的"标准六步法" ------ 这是所有 V4L2 摄像头程序的通用骨架。
📂 本节涉及的文件
| 文件 | 作用 |
|---|---|
video/v4l2.c |
V4L2 操作的具体实现(核心) |
include/video_manager.h |
定义结构体,(六) 已完成 |
video/video_manager.c |
链表管理,(六) 已完成 |
你只需要关注 v4l2.c,其他文件是之前已经搭好的框架。
🧱 V4L2 初始化的"六步法"(框架图)
text
┌─────────────────────────────────────────────────────────────┐
│ V4L2 设备初始化流程 │
└─────────────────────────────────────────────────────────────┘
1. open("/dev/videoX") → 获得文件描述符 fd
│
2. VIDIOC_QUERYCAP → 查询能力,确认是视频捕获设备
│
3. VIDIOC_ENUM_FMT 循环 → 枚举格式,找到支持的像素格式(YUYV/MJPEG/RGB565)
│
4. VIDIOC_S_FMT → 设置分辨率、像素格式(读回实际生效值)
│
5. VIDIOC_REQBUFS → 向内核申请若干个缓冲区(NB_BUFFER=4)
│
mmap 每个缓冲区 → 映射到用户空间,得到虚拟地址
│
6. (streaming 模式) → VIDIOC_QBUF 把所有缓冲区放入驱动队列
│
─────── 至此摄像头已准备好,可调用 VIDIOC_STREAMON 启动采集 ───────
这个六步顺序是固定的,缺一步或顺序错了都会导致无法采集。
🔍 逐个步骤详解(包含你碰到的困惑)
步骤 0:全局支持的格式列表
c
static int g_aiSupportedFormats[] = {
V4L2_PIX_FMT_YUYV, // YUYV 4:2:2 打包格式
V4L2_PIX_FMT_MJPEG, // MJPEG 压缩格式
V4L2_PIX_FMT_RGB565 // RGB565 格式
};
static int isSupportThisFormat(int iPixelFormat)
{
int i;
for (i = 0; i < sizeof(g_aiSupportedFormats)/sizeof(g_aiSupportedFormats[0]); i++)
if (g_aiSupportedFormats[i] == iPixelFormat)
return 1;
return 0;
}
❓ 你为什么困惑 :sizeof(g_aiSupportedFormats)/sizeof(g_aiSupportedFormats[0]) 是什么意思?
✅ 解释 :sizeof(数组) 返回整个数组占用的字节数,sizeof(数组[0]) 返回一个元素的字节数。两者相除就是元素个数。
这样当你增加新的格式(比如 V4L2_PIX_FMT_NV12)时,只需在数组中添加一行,循环自动适应,不用手动修改数字 3。
这是 C 语言中遍历静态数组的常用技巧。
步骤 1:打开设备
c
iFd = open(strDevName, O_RDWR);
if (iFd < 0) return -1;
ptVideoDevice->iFd = iFd;
目的 :获得文件描述符,后续所有 ioctl 都需要它。
strDevName 通常是 /dev/video0 或 /dev/video1。
步骤 2:查询设备能力(VIDIOC_QUERYCAP)
c
struct v4l2_capability tV4l2Cap;
ioctl(iFd, VIDIOC_QUERYCAP, &tV4l2Cap);
if (!(tV4l2Cap.capabilities & V4L2_CAP_VIDEO_CAPTURE))
goto err;
目的:
- 确认设备是视频捕获设备(不是 VBI 或输出设备)。
- 同时可以检查它支持哪种 I/O 方式:
V4L2_CAP_STREAMING(内存映射) 或V4L2_CAP_READWRITE(传统读写)。
❓ 你看到的代码里为什么有两个 ioctl 中间加了一个 memset?
那是原作者的冗余写法,实际只需要一次 ioctl。你可以忽略第二组,理解为"先获取一次,清空后再获取一次"的无害操作。
步骤 3:枚举并选择像素格式(VIDIOC_ENUM_FMT)
c
struct v4l2_fmtdesc tFmtDesc;
memset(&tFmtDesc, 0, sizeof(tFmtDesc));
tFmtDesc.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
tFmtDesc.index = 0;
while (ioctl(iFd, VIDIOC_ENUM_FMT, &tFmtDesc) == 0) {
if (isSupportThisFormat(tFmtDesc.pixelformat)) {
ptVideoDevice->iPixelFormat = tFmtDesc.pixelformat;
break;
}
tFmtDesc.index++;
}
目的 :摄像头可能支持十几种格式,我们需要找出一种摄像头支持且我们程序也支持 的格式(YUYV、MJPEG 或 RGB565)。
index 从 0 开始递增,每次调用返回一种格式的描述,直到返回非 0(没有更多格式)。
❓ 为什么不直接用 VIDIOC_S_FMT 设置想要的格式?
因为有些摄像头不支持你想要的格式,必须先枚举确认。否则设置失败会导致无法采集。
步骤 4:设置图像格式(VIDIOC_S_FMT)
c
struct v4l2_format tV4l2Fmt;
memset(&tV4l2Fmt, 0, sizeof(tV4l2Fmt));
tV4l2Fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
tV4l2Fmt.fmt.pix.pixelformat = ptVideoDevice->iPixelFormat;
tV4l2Fmt.fmt.pix.width = iLcdWidth; // LCD 的宽度
tV4l2Fmt.fmt.pix.height = iLcdHeigt; // LCD 的高度
tV4l2Fmt.fmt.pix.field = V4L2_FIELD_ANY;
ioctl(iFd, VIDIOC_S_FMT, &tV4l2Fmt);
ptVideoDevice->iWidth = tV4l2Fmt.fmt.pix.width;
ptVideoDevice->iHeight = tV4l2Fmt.fmt.pix.height;
目的 :告诉驱动我们希望的分辨率和格式。驱动可能会调整(比如摄像头最大只支持 640x480,而 LCD 是 1024x600),它会修改结构体中的 width/height 为实际支持的值。必须读回这些值,后续所有操作都基于实际分辨率。
❓ 为什么设置成 LCD 的分辨率?
为了减少后期缩放的工作量。如果摄像头能直接输出与 LCD 相同的分辨率,就可以省去 PicZoom 步骤,提高效率。
❓ GetDispResolution 是什么?
它从显示模块(08.1 之前注册的 fb 设备)中取出 LCD 的宽度、高度和每像素位数。这个函数在 disp_manager.c 中实现,需要提前调用 SelectAndInitDefaultDispDev("fb")。
步骤 5:申请缓冲区(VIDIOC_REQBUFS)并 mmap
c
struct v4l2_requestbuffers tV4l2ReqBuffs;
tV4l2ReqBuffs.count = NB_BUFFER; // 4
tV4l2ReqBuffs.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
tV4l2ReqBuffs.memory = V4L2_MEMORY_MMAP;
ioctl(iFd, VIDIOC_REQBUFS, &tV4l2ReqBuffs);
ptVideoDevice->iVideoBufCnt = tV4l2ReqBuffs.count; // 实际可能少于 4
for (i = 0; i < ptVideoDevice->iVideoBufCnt; i++) {
struct v4l2_buffer tV4l2Buf;
tV4l2Buf.index = i;
tV4l2Buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
tV4l2Buf.memory = V4L2_MEMORY_MMAP;
ioctl(iFd, VIDIOC_QUERYBUF, &tV4l2Buf); // 获得每个缓冲区的物理偏移和长度
ptVideoDevice->pucVideBuf[i] = mmap(NULL, tV4l2Buf.length, PROT_READ, MAP_SHARED, iFd, tV4l2Buf.m.offset);
ptVideoDevice->iVideoBufMaxLen = tV4l2Buf.length;
}
目的:
REQBUFS:告诉内核我们要使用多少个缓冲区(通常 4 个),内核会分配相应数量的 buffers(可能更少,用返回值更新)。QUERYBUF:获取每个缓冲区的信息(长度、物理偏移)。mmap:将内核空间的缓冲区映射到用户空间,这样程序就可以直接读写这些内存。
❓ 为什么要用多个缓冲区?
实现流水线:驱动正在往 buffer 0 里写数据时,应用程序可以处理 buffer 1 里的前一帧;驱动填满 buffer 0 后切换去填 buffer 1,应用程序则处理 buffer 0。多个缓冲区可以避免丢帧。
步骤 6:把缓冲区放入驱动队列(VIDIOC_QBUF)--- 仅 streaming 模式
c
for (i = 0; i < ptVideoDevice->iVideoBufCnt; i++) {
struct v4l2_buffer tV4l2Buf;
tV4l2Buf.index = i;
tV4l2Buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
tV4l2Buf.memory = V4L2_MEMORY_MMAP;
ioctl(iFd, VIDIOC_QBUF, &tV4l2Buf);
}
目的 :将空闲缓冲区"借给"驱动,驱动采集到一帧后就会填入其中一个缓冲区,并标记为"已满"。
应用程序后面通过 VIDIOC_DQBUF 取出已满的缓冲区,处理完后再次 QBUF 归还。
❓ 如果设备只支持 read/write 模式怎么办?
代码中也有对应的分支:
- 只分配一个缓冲区(malloc)
GetFrame直接调用read(fd, buf, size)PutFrame什么都不做
这种方式简单但效率低,项目中实际使用 streaming 模式。
🧪 其他辅助函数(暂不深究,(九) 会讲)
| 函数 | 作用 | 详细讲解 |
|---|---|---|
V4l2GetFrameForStreaming |
等待数据,取出已填满的缓冲区 | (九) |
V4l2PutFrameForStreaming |
使用完后归还缓冲区 | (九) |
V4l2StartDevice |
调用 VIDIOC_STREAMON 启动采集 |
(九) |
V4l2StopDevice |
调用 VIDIOC_STREAMOFF 停止采集 |
(九) |
V4l2GetFormat |
返回当前像素格式 | 简单 |
你现在只需要知道它们存在并知道作用,不需要死记代码。
📌 最容易犯的错误(我在学习中踩过的坑)
- 忘记
mmap之前必须先REQBUFS
顺序错了QUERYBUF会失败。 - 没有读回
VIDIOC_S_FMT后的实际宽高
后面mmap长度计算错误,导致内存访问越界。 - 混淆
bytesused和缓冲区长度
MJPEG 格式每帧大小不同,bytesused是实际数据长度,缓冲区长度是上限。 poll后忘记检查返回值
超时或错误会导致无限等待或错误处理。- Streaming 模式下
QBUF和DQBUF不配对
必须保持 "取出一个,处理完,放回一个" 的循环,否则驱动会耗尽可用缓冲区。
🧭 完整代码清单(v4l2.c 骨架,核心部分已注释)
c
#include <config.h>
#include <video_manager.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <linux/videodev2.h>
#include <sys/mman.h>
#include <poll.h>
static int g_aiSupportedFormats[] = {V4L2_PIX_FMT_YUYV, V4L2_PIX_FMT_MJPEG, V4L2_PIX_FMT_RGB565};
static int isSupportThisFormat(int iPixelFormat) { /* 如上 */ }
static int V4l2InitDevice(char *strDevName, PT_VideoDevice ptVideoDevice) {
// 步骤1: open
// 打开设备 /dev/video0
iFd = open(strDevName, O_RDWR);
if (iFd < 0)
{
DBG_PRINTF("can not open %s\n", strDevName);
return -1;
}
ptVideoDevice->iFd = iFd; // 把文件描述符存起来
// 步骤2: VIDIOC_QUERYCAP//查询设备 "身份证 + 能力清单",确认是不是 V4L2 设备、是不是摄像头、支持什么采集模式The Linux Kernel Archives
memset(&tV4l2Cap, 0, sizeof(struct v4l2_capability));
iError = ioctl(iFd, VIDIOC_QUERYCAP, &tV4l2Cap);
if (iError) {
DBG_PRINTF("Error opening device %s: unable to query device.\n", strDevName);
goto err_exit;
}
// 判断是不是摄像头
if (!(tV4l2Cap.capabilities & V4L2_CAP_VIDEO_CAPTURE))
{
DBG_PRINTF("%s is not a video capture device\n", strDevName);
goto err_exit;
}
// 判断支不支持高速流(STREAMING)
if (tV4l2Cap.capabilities & V4L2_CAP_STREAMING) {
DBG_PRINTF("%s supports streaming i/o\n", strDevName);
}
// 判断支不支持低速read(READWRITE)
if (tV4l2Cap.capabilities & V4L2_CAP_READWRITE) {
DBG_PRINTF("%s supports read i/o\n", strDevName);
}
// 步骤3: VIDIOC_ENUM_FMT 循环
memset(&tFmtDesc, 0, sizeof(tFmtDesc));
tFmtDesc.index = 0; // 从第0个格式开始问
tFmtDesc.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
// 循环问:支持格式1?格式2?格式3?
while ((iError = ioctl(iFd, VIDIOC_ENUM_FMT, &tFmtDesc)) == 0)
{
if (isSupportThisFormat(tFmtDesc.pixelformat))
{
ptVideoDevice->iPixelFormat = tFmtDesc.pixelformat;
break;
}
tFmtDesc.index++;
}
if (!ptVideoDevice->iPixelFormat)
{
DBG_PRINTF("can not support the format of this device\n");
goto err_exit;
}
// 步骤4: VIDIOC_S_FMT
GetDispResolution(&iLcdWidth, &iLcdHeigt, &iLcdBpp);
memset(&tV4l2Fmt, 0, sizeof(struct v4l2_format));
tV4l2Fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
tV4l2Fmt.fmt.pix.pixelformat = ptVideoDevice->iPixelFormat;
tV4l2Fmt.fmt.pix.width = iLcdWidth;
tV4l2Fmt.fmt.pix.height = iLcdHeigt;
tV4l2Fmt.fmt.pix.field = V4L2_FIELD_ANY;
iError = ioctl(iFd, VIDIOC_S_FMT, &tV4l2Fmt);
if (iError)
{
DBG_PRINTF("Unable to set format\n");
goto err_exit;
}
// 保存最终生效的宽高
ptVideoDevice->iWidth = tV4l2Fmt.fmt.pix.width;
ptVideoDevice->iHeight = tV4l2Fmt.fmt.pix.height;
// 步骤5: VIDIOC_REQBUFS + mmap
memset(&tV4l2ReqBuffs, 0, sizeof(struct v4l2_requestbuffers));
tV4l2ReqBuffs.count = NB_BUFFER; // 申请4个缓冲区
tV4l2ReqBuffs.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
tV4l2ReqBuffs.memory = V4L2_MEMORY_MMAP;
iError = ioctl(iFd, VIDIOC_REQBUFS, &tV4l2ReqBuffs);
if (iError)
{
DBG_PRINTF("Unable to allocate buffers.\n");
goto err_exit;
}
// 保存实际得到的缓冲区数量
ptVideoDevice->iVideoBufCnt = tV4l2ReqBuffs.count;
// 步骤6: (streaming) VIDIOC_QBUF
for (i = 0; i < ptVideoDevice->iVideoBufCnt; i++)
{
memset(&tV4l2Buf, 0, sizeof(struct v4l2_buffer));
tV4l2Buf.index = i;
tV4l2Buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
tV4l2Buf.memory = V4L2_MEMORY_MMAP;
// 查询缓冲区信息
iError = ioctl(iFd, VIDIOC_QUERYBUF, &tV4l2Buf);
// 映射到用户空间
ptVideoDevice->pucVideBuf[i] = mmap(..., iFd, tV4l2Buf.m.offset);
}
ptVideoDevice->ptOPr = &g_tV4l2VideoOpr;
return 0;
err_exit:
close(iFd);
return -1;
}
static int V4l2ExitDevice(PT_VideoDevice ptVideoDevice) {
for (int i = 0; i < ptVideoDevice->iVideoBufCnt; i++)
if (ptVideoDevice->pucVideBuf[i])
munmap(ptVideoDevice->pucVideBuf[i], ptVideoDevice->iVideoBufMaxLen);
close(ptVideoDevice->iFd);
return 0;
}
/* GetFrame/PutFrame/Start/Stop 等函数省略,08.4 会详细讲 */
static T_VideoOpr g_tV4l2VideoOpr = {
.name = "v4l2",
.InitDevice = V4l2InitDevice,
.ExitDevice = V4l2ExitDevice,
.GetFormat = V4l2GetFormat,
.GetFrame = V4l2GetFrameForStreaming,
.PutFrame = V4l2PutFrameForStreaming,
.StartDevice = V4l2StartDevice,
.StopDevice = V4l2StopDevice,
};
int V4l2Init(void) {
return RegisterVideoOpr(&g_tV4l2VideoOpr);
}
✅ 本节自测题(检验是否真的掌握框架)
- 请按顺序写出 V4L2 初始化设备的六个主要步骤(用 ioctl 名称或功能描述)。
- 为什么需要
VIDIOC_ENUM_FMT而不是直接设置格式? VIDIOC_REQBUFS和mmap之间为什么必须调用VIDIOC_QUERYBUF?- 如果摄像头不支持 streaming 模式,代码会怎样处理?
- 你如何理解
ptVideoDevice->ptOPr = &g_tV4l2VideoOpr这行代码的作用?
🖼️ 图解:V4L2 初始化与数据流

💬 总结
摄像头模块(七) 课程的核心就是 V4L2 初始化的六步法 。
你现在脑中应该有了一张清晰的地图:打开 → 查询能力 → 枚举格式 → 设置格式 → 申请+映射缓冲区 → 入队。
至于 GetFrame/PutFrame 等函数,它们是数据流阶段的细节,下一节课(八)会专门讲解初始化函数中每个 ioctl 的参数含义,再下一节(九)才会深入数据传输。
保持这个框架感,后续学习就不会迷路。
🔥 本文档会持续更新,你可以随时回来查阅六步法和常见错误。如果仍有疑问,欢迎在评论区留言。