嵌入式Linux平台下的UVC驱动和V4L2

25 | 03 | 2021

0. 前言

在开发嵌入式系统的USB摄像头驱动的时候,有时候会出现插入了摄像头,却无法正常识别和工作的情况。这种时候,就需要修改内核代码,在内核中增加UVC驱动的支持。

在增加了驱动之后,就可以使用V4L2的接口来读取摄像头的数据了。

0.1 UVC设备简介

UVC全称为USB Video Class,即:USB视频类,是一种为USB视频捕获设备定义的协议标准。

UVC是Microsoft与另外几家设备厂商联合推出的为USB视频捕获设备定义的协议标准,已成为USB org标准之一。

支持 USB Video Class (UVC) standard 1.1可以让相机在所有的作业系统以及平台中使用(Windows, Linux, Mac etc.)。用户只需连接相机便可进行图像传输,而无需安装任何驱动程序 。

简单点说,就是只要USB摄像头是UVC摄像头,那这个摄像头的驱动就遵循一个通用的格式,可以实现免驱的操作。

在Linux系统中,UVC驱动的支持在Linux Kernel 2.4之后被增加到内核中。

但是为了让内核识别到这款摄像头,还要告诉内核这个USB的ID是UVC设备才行。

0.2 USB设备的VID和PID简介

每个USB设备都有VID(Vender ID,供应商识别码)和PID(Product ID,产品识别码),两者的长度均为2Byte。

PID和VID是主机识别USB设备时使用。

主机检测到USB设备后,首先会通过USB Class查询插入的是什么设备。

检测到插入设备的类型后,通过读取和检索VID和PID,主机就能知道当前连接的设备的类型,并能了解应该给这个USB设备加载什么样的驱动程序进行通信。

0.3 Linux下的V4L2简介

V4L2是Video for linux2的简称,为linux中关于视频设备的内核驱动。

在Linux中,视频设备是设备文件,可以像访问普通文件一样对其进行读写,摄像头在/dev/video*下,如果只有一个视频设备,通常为/dev/video0。当然,也有可能会是/dev/uvcvideo*,具体需要查看Kernel日志确定。

1. 内核中增加UVC摄像头驱动支持

为了让板子正常识别到这个摄像头,需要在内核中打开UVC摄像头的编译,并将这款摄像头的PID&VID添加到代码中。

1.1 摄像头的UID和PID的获取

  1. 将摄像头插入电脑,等待驱动程序自动安装完成。
  2. 打开设备管理器——照相机,找到新增的USB摄像头,右键属性。
  3. 在详细信息选项卡,属性选择硬件ID,就可以看到摄像头的VID和PID。

1.2 内核中打开UVC设备的支持

shell进入内核代码目录下,使用命令make menuconfig进入内核编译配置。

进入Device Drivers → Multimedia support → Media USB Adapters,找到并开启USB Video Class(UVC)如下图:

如果找不到这项,说明是相关的依赖项未打开。可以按/搜索,关键词USB_VIDEO_CLASS,能看到这项的依赖项:

将所有依赖项设置为Y(编译进内核)即可。

同样,Device Drivers → Multimedia support → V4L platform devices下,开启如下两项:

一般情况下来说这些项目都是默认开启的,检查一下即可。

1.3 内核中增加摄像头的PID和VID

打开内核代码driver/media/usb/uvc/uvc_driver.c,找到usb_device_id结构体uvc_ids。

仿照其他摄像头的代码,添加摄像头的PID和VID:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static struct usb_device_id uvc_ids[] = {
//下面是新增的摄像头的id信息,VID是0x05A3,PID是0x9601
    {
        .match_flags = USB_DEVICE_ID_MATCH_DEVICE
        | USB_DEVICE_ID_MATCH_INT_INFO,
        .idVendor             = 0x05A3,  //摄像头的VID
        .idProduct            = 0x9601,
        .bInterfaceClass      = USB_CLASS_VIDEO,
        .bInterfaceSubClass   = 1,
        .bInterfaceProtocol   = 0,
        .driver_info          = UVC_QUIRK_RESTRICT_FRAME_RATE
    },
//下面是原有的信息
    /* LogiLink Wireless Webcam */
    {
        .match_flags      = USB_DEVICE_ID_MATCH_DEVICE
        | USB_DEVICE_ID_MATCH_INT_INFO,
        .idVendor         = 0x0416,
        .idProduct        = 0xa91a,
        .bInterfaceClass  = USB_CLASS_VIDEO,
        .bInterfaceSubClass   = 1,
        .bInterfaceProtocol   = 0,
        .driver_info      = UVC_QUIRK_PROBE_MINMAX
    },
    //...
}

完成后make整个内核,并将内核烧写入硬件平台中。

1.4 确认是否添加成功

启动平台后插入摄像头,如果出现如下uvc的打印信息,显示出了摄像头的PID&VID并挂在了input下,则说明添加成功。

1
2
3
4
5
6
7
8
9
usb 1-1: new high-speed USB device number 2 using xhci-hcd
hub 1-1:1.0: USB hub found
hub 1-1:1.0: 4 ports detected
usb 1-1.1: new high-speed USB device number 3 using xhci-hcd
uvcvideo: Found UVC 1.00 device Stereo Vision 2 (05a3:9602)
input: Stereo Vision 2 as /devices/platform/soc/12310000.xhci_1/usb1/1-1/1-1.1/1-1.1:1.0/input/input0
usb 1-1.2: new high-speed USB device number 4 using xhci-hcd
uvcvideo: Found UVC 1.00 device Stereo Vision 1 (05a3:9601)
input: Stereo Vision 1 as /devices/platform/soc/12310000.xhci_1/usb1/1-1/1-1.2/1-1.2:1.0/input/input1

这时候可以输入命令,查看/dev目录下的新增的设备节点:

1
2
3
4
5
~ # ls /dev/
#省略……
mtd2                tty23               video0
mtd2ro              tty24               video1
#省略……

可以看到新增了video0、video1两个设备节点,这两个设备节点就是新增的摄像头设备。

2. 使用V4L2对摄像头进行读写

由于之前在4412平台上有写过UVC的摄像头代码,这里直接拿来用就可以。

详细C++代码如下:

camera_demo下载

另外这里还有个C格式的代码可供参考:

camdemo.c下载

接下来以C++代码为例进行分析。

2.1 代码分析

2.1.1 main函数

先看main函数。下面的函数精简了一些无用的流程,只留下比较关键的地方。

关键代码已做注释。

可以看出,这里对摄像头的操作都是在Camera类里实现的,所以接下来要看的就是Camera类的相关代码来分析V4L2的相关操作。

另外,代码中的帧缓存image数组的定义是宽 * 高 * 2,这个定义的原因涉及到像素空间的编码格式,会在后面分析到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
int main(int argc, char **argv)
{
    char *dev_name = NULL;
    FILE *outf = 0;
    unsigned int image_size;
    int camera_type = 1;//0:ITU, 1:UVC
    int width = 640; //图像宽高设置
    int height = 480;
    dev_name = (char *)malloc(sizeof(char) * DEV_NAME_LENGTH);
    if (!dev_name)
    {
        printf("malloc mem error\\n");
        return -1;
    }
    memset(dev_name, 0, sizeof(char) * DEV_NAME_LENGTH); //设备文件名
    strcpy(dev_name, argv[1]); //摄像头的设备文件名作为argv[1]传入代码中
    outf = fopen("out.yuv", "wb"); //打开输出文件
    Camera *camera;  //Camera类,用于对摄像头进行操作
    unsigned char image[width * height * 2]; //定义一个数组作为帧缓存,数组大小是宽*高*2
//打开设备
    camera = new Camera(dev_name, width, height, camera_type);
    if (!camera->OpenDevice())
    {
        return -1;
    }
//获取图像大小
    image_size = camera->getImageSize();
    //int frames=50;
    unsigned int writesize = 0;
    for (int i = 0; i < NUM_FRAM; i++)
    {
        if (!camera->GetBuffer(image))  //获取每一帧数据
        {
            printf("fun:%s, line = %d\\n", __FUNCTION__, __LINE__);
            break;
        }
        printf("fun:%s, line = %d\\n", __FUNCTION__, __LINE__);
        writesize = fwrite(image, 1, image_size, outf); //写入文件
    }
    camera->CloseDevice(); //关闭设备
    fclose(outf);  //关闭文件
    return 0;
}

2.1.2 Camera类 – 构造函数和OpenDevice函数

构造函数的代码如下。这里并没有进行摄像头的打开操作,只是对一些参数进行了赋初值操作。

1
2
3
4
5
6
7
8
9
10
11
12
Camera::Camera(char *DEV_NAME, int w, int h, int camera_type)
{
    memcpy(dev_name,DEV_NAME,strlen(DEV_NAME));
    io = IO_METHOD_MMAP;//IO_METHOD_READ;//IO_METHOD_MMAP; //摄像头读取方式为MMAP
    cap_image_size=0;
    width=w;
    height=h;
    if(1 == camera_type)
    {
        c_camera_type = 1;
    }
}

OpenDevice函数的逻辑很简单,依次执行open_device()函数、init_device()函数和start_capturing()函数。如果任何函数返回了错误,就直接跳出。

a. open_device()函数

open_device()函数的逻辑很简单。首先先用stat函数获取设备节点的文件信息,获取成功后判断文件是否为字符型设备,如果均为是,则使用Open函数进行打开,获取文件描述符。

b. init_device()函数

接下来看init_device函数。

这个函数关键是下面几行代码:

1
2
3
4
5
6
7
8
9
10
11
struct v4l2_capability cap;
struct v4l2_cropcap cropcap;
struct v4l2_crop crop;
v4l2_input input;
xioctl(fd, VIDIOC_QUERYCAP, &cap); //获取设备支持的V4L2功能
ioctl(fd, VIDIOC_S_INPUT, &input); //设置设备输入

struct v4l2_format fmt;
CLEAR (fmt);
xioctl(fd, VIDIOC_G_FMT, &fmt); //获取设备信息
init_mmap(); //使用MMAP形式读取,在这里初始化

获取设备支持的V4L2功能主要是看结构体v4l2_capability的capabilities成员是否有下面两个标志位:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) //该设备是否是V4L2视频采集设备?
{
    fprintf(stderr, "%s is no video capture device\\n",dev_name);
    return false;
}
switch (io)
{
case IO_METHOD_READ:
    if (!(cap.capabilities & V4L2_CAP_READWRITE)) //如果读取模式为read模式,是否支持READWRITE接口?
    {
        fprintf(stderr, "%s does not support read i/o\\n",dev_name);
        return false;
    }
    break;
case IO_METHOD_MMAP:
case IO_METHOD_USERPTR:
    if (!(cap.capabilities & V4L2_CAP_STREAMING)) //如果以mmap或用户指针模式读取,是否支持STREAMING?
    {
        fprintf(stderr, "%s does not support streaming i/o\\n", dev_name);
        return false;
    }
    break;
}

获取设备信息主要看下面几个信息:

1
2
3
4
5
6
7
xioctl(fd, VIDIOC_G_FMT, &fmt)
printf("    fmt.fmt.pix.width = %d\\n", fmt.fmt.pix.width); //宽
printf("    fmt.fmt.pix.height = %d\\n", fmt.fmt.pix.height); //高
printf("    fmt.fmt.pix.sizeimage = %d\\n", fmt.fmt.pix.sizeimage); //图像大小
printf("    fmt.fmt.pix.bytesperline = %d\\n", fmt.fmt.pix.bytesperline); //每行字节数
printf("    fmt.fmt.pix.pixelformat = %d\\n", fmt.fmt.pix.pixelformat); //像素空间格式
printf("-#-#-#-#-#-#-#-#-#-#-#-#-#-\\n");

这里的像素空间格式是编码格式,例如YUV420/RGB888之类的。

在这里可以看到,摄像头的像素空间是YUYV,宽640,高480。

YUV是指亮度参量和色度参量分开表示的像素格式,YUYV编码格式就是将YUV按照4:2:2进行编码,即每个像素2字节,这也说明了为何上面的缓存大小为宽*高*2。

初始化MMAP,主要流程是先使用v4l2_requestbuffers结构体和ioctl的VIDIOC_REQBUFS申请缓冲区,然后再获取缓冲区的参数并使用mmap进行内存的映射。

关于mmap的介绍,可以查看我之前写的文章用MMAP读写Linux的寄存器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/* These definitions are defined in camera.h *
struct buffer {
    void * start;
    size_t length;//buffer's length is different from cap_image_size
};
struct buffer * buffers ;
*/


bool Camera::init_mmap(void)
{
    struct v4l2_requestbuffers req;
    CLEAR (req);
    req.count = 4;
    req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    req.memory = V4L2_MEMORY_MMAP;

    if (-1 == xioctl(fd, VIDIOC_REQBUFS,&req))    //获取device memory的信息
    {
        return false;
    }
    if (req.count < 2)  //设备没有足够的缓存大小
    {
        fprintf(stderr,"Insufficient buffer memory on %s\n", dev_name);
        return false;
    }
    buffers = (buffer *)calloc(req.count, sizeof(*buffers)); //给buffers结构体申请缓存空间
    if (!buffers)
    {
        fprintf(stderr, "Out of memory\n");
        return false;
    }
    for (n_buffers = 0; n_buffers < req.count;++n_buffers)
    {
        struct v4l2_buffer buf;
        CLEAR (buf);
        buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
        buf.memory = V4L2_MEMORY_MMAP;
        buf.index = n_buffers;
        if (-1 == xioctl(fd, VIDIOC_QUERYBUF, &buf))  //获取每个Buffer的device memory信息,供mmap函数使用
        {
            errno_exit("VIDIOC_QUERYBUF");
        }
        buffers[n_buffers].length = buf.length;

                //使用mmap函数将每个Buffer对应的device memory映射到用户空间
        buffers[n_buffers].start = mmap(
                                       NULL /* start anywhere */, buf.length,
                                       PROT_READ | PROT_WRITE /* required */,
                                       MAP_SHARED /* recommended */, fd, buf.m.offset);  
        if (MAP_FAILED == buffers[n_buffers].start)
        {
            return false;
        }
    }
    return true;
}

c. start_capturing()函数

我们是使用MMAP形式对摄像头数据进行读写的。在这种情况下,V4L2会在内存中构造一个Buffer队列。摄像头采集回来数据之后,会从队列中按顺序取出空白的Buffer并写入,而用户要处理数据时,只需要将V4L2写好的Buffer提取出来,处理完毕后再将Buffer作为空白Buffer入队即可。

这个函数主要做以下两件事情:

  1. 将init_mmap中申请到的内存使用VIDIOC_QBUF进行入队操作,告诉设备这些缓冲区可以进行数据的写入。
  2. 使用VIDIOC_STREAMON开始获取图像。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
bool Camera::start_capturing(void)
{
    unsigned int i;
    enum v4l2_buf_type type;
    for (i = 0; i < n_buffers; ++i)
    {
        struct v4l2_buffer buf;
        CLEAR (buf);
        buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
        buf.memory = V4L2_MEMORY_MMAP;
        buf.index = i;
        if (-1 == xioctl(fd, VIDIOC_QBUF, &buf)) //将申请到的内存用VIDIOC_QBUF放入队列,告诉设备这个地方可以存放图像数据
        {
            return false;
        }
    }
    type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    if (-1 == xioctl(fd, VIDIOC_STREAMON, &type))  //VIDIOC_STREAMON开始获取图像信息
    {
        return false;
    }
    return true;
}

2.1.3 Camera类 – GetBuffer函数

这个函数是具体获取摄像头采集到的图像数据的函数。

从代码中可以看出,这里的逻辑是将摄像头的文件描述符加入集合fds后,用select函数循环等待摄像头数据就绪,再通过read_frame函数获取一帧的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
bool Camera::GetBuffer(unsigned char *image)
{
    fd_set fds;
    struct timeval tv;
    int r;
    FD_ZERO(&fds);  //清空fd_set集合fds
    FD_SET(fd, &fds); //将摄像头的文件描述符加入集合fds中
    /* Timeout. */
    tv.tv_sec = 2;
    tv.tv_usec = 0;
    r = select(fd + 1, &fds, NULL, NULL, &tv);  //select函数循环判断该集合中是否有文件可被读写。
                                                //参数:maxfdp = fd + 1:int maxfdp是一个整数值,是指集合中所有文件描述符的范围,即所有文件描述符的最大值加1,不能错!
                                                //      fds:要循环判断的文件描述符集合
                                                //      tv:超时时间,这里设置为2秒

    if (-1 == r)         //返回值小于0,select错误
    {
        errno_exit("select");
    }

    if (0 == r)         //返回值为0,等待时间超时的时候仍然没有文件可以进行读写
    {
        fprintf(stderr, "select timeout\n");
        exit(EXIT_FAILURE);
    }
    read_frame(image);  //这里才正式开始读取摄像头数据
}

查看read_frame函数的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int Camera::read_frame(unsigned char *image)
{
    struct v4l2_buffer buf;
    CLEAR (buf);
    buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    buf.memory = V4L2_MEMORY_MMAP;

    if (-1 == xioctl(fd, VIDIOC_DQBUF, &buf))    //将有帧数据的buf出队
    {
        switch (errno)
        {
            case EAGAIN: //重试错误,直接return 0重试即可
                return 0;
            case EIO:  //可以忽略错误EIO,但是这里不忽略
            default:
                errno_exit("VIDIOC_DQBUF");
        }
    }
    assert(buf.index < n_buffers); //buffer的索引号应该小于总的buffer个数
    memcpy(image, buffers[0].start, cap_image_size); //复制一帧数据到image中
    if (-1 == xioctl(fd, VIDIOC_QBUF, &buf))  //将处理结束后的buf放回到队伍中
    {
        errno_exit("VIDIOC_QBUF");
    }
    return 1;
}

可以看出,这个函数主要做了下面三件事:

  1. 将有图像信息的buffer从队列中取出
  2. 处理buffer里的图像数据
  3. 将用完的buffer作为空白的Buffer放回队列中

2.1.4 Camera类 – CloseDevice函数

该函数和OpenDevice的过程相反,主要执行下面这些操作:

a. 停止摄像头采集数据

1
2
3
type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
if (-1 == xioctl(fd, VIDIOC_STREAMOFF, &type))
    errno_exit("VIDIOC_STREAMOFF");

b. 反映射内存(munmap)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Camera::uninit_device(void)
{
    unsigned int i;
    switch (io)
    {
        case IO_METHOD_MMAP:
            for (i = 0; i < n_buffers; ++i)
                if (-1 == munmap(buffers[i].start,buffers[i].length)) //munmap反映射内存
                {
                    errno_exit("munmap");
                }
            break;
    }
    free(buffers);
}

c. 关闭文件

1
2
3
4
5
void Camera::close_device(void) {
    if (-1 == close(fd))
        errno_exit("close");
    fd = -1;
}

至此,通过V4L2读取摄像头的数据的大概流程就梳理得差不多了。

2.2 测试

首先打开脚本build.sh,将编译工具改为自己硬件平台的交叉编译工具链的g++工具。

之后执行./build.sh,会生成可执行文件camera。

在板子上执行下面的命令:

1
./camera /dev/video0 640x480 #/dev/video0是设备节点的文件名

成功执行后,同目录下会生成out.yuv文件。

使用YUVPLAYER打开文件,设置好YUV类型、分辨率和帧率后,就能看到采集回来的图像。

YUV播放器

到此,已经完成了从摄像头读取数据的操作。

当然如果有FFMPEG/OpenCV库,大可不必使用那么麻烦的方式去读取摄像头的数据。

例如,有了OpenCV库,就可以这样读取摄像头:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <opencv2/opencv.hpp>
using namespace cv;
 
int main(){
    VideoCapture cap;
    cap.open(0); //打开摄像头,序列号是/dev/videoi,i是0,1,2,cap.open(i)
 
    if(!cap.isOpened())
        return -1;
 
    Mat frame;
    while(1)
    {
        cap>>frame;//等价于cap.read(frame);
        if(frame.empty())
            break;
        imshow("video", frame);
        if(waitKey(20)>0)//按下任意键退出摄像头  因电脑环境而异,有的电脑可能会出现一闪而过的情况
            break;
    }
    cap.release();
    destroyAllWindows();//关闭所有窗口
    return 0;
}