• V4L2学习笔记【转】


    转自:http://www.zhimengzhe.com/linux/69415.html

    最近在做类似于飞思卡尔的项目,要用到摄像头,摄像头接在一块树莓派上,但树莓派上跑的是Linux系统。因为对Linux并不熟悉,身边也没有比较熟悉Linux的朋友,几次想尝试最终都因为遇到大多问题找不原因放弃了。这次又开始学习Linux,同样遇到一堆的问题,但硬着头皮,一个一个的找资料解决。

    Video for Linuxtwo(Video4Linux2)简称V4L2,是V4L的改进版。V4L2是linux操作系统下用于采集图片、视频和音频数据的API接口,配合适当的视频采集设备和相应的驱动程序,可以实现图片、视频、音频等的采集。在远程会议、可视电话、视频监控系统和嵌入式多媒体终端中都有广泛的应用。

    要刚接触到V4L2,有太多的东西要学。以下大部是摘抄前辈博客或者网上其它地方找到的自认为有帮助的内容,记录下来。

    一、首先熟悉需要用到的函数

    ioctl函数说明 
    ioctl是设备驱动程序中对设备的I/O通道进行管理的函数。所谓对I/O通道进行管理,就是对设备的一些特性进行控制,例如串口的传输波特率、马达的转速等等。 
    用户程序可以使用ioctl函数通过命令码(cmd)来实现控制功能,至于怎么解释这些命令和怎么实现这些命令,这都是驱动程序要做的事情。而在驱动程序中实现的ioctl函数体内,有一个switch{case}结构,每一个case对应一个命令码,做出一些相应的操作,怎么实现这些操作,这是每一个程序员自己的事情。

    头文件:    
        #inlcude<ioctl.h>
    函数定义:
        int ioctl(int fd, ind cmd, …); 
    返回值:
        成功返回0,出错返回-1且errno设为某特定值
    参数:
         fd:open()返回的文件描述符
         cmd:是用户程序对设备的控制命令
         …:省略号表示补充参数,一般最多一个,有或没有是和cmd的意义相关的。
    
    cmd介绍:
    控制命令很多,这里只介绍图像采集时用到的命令
    VIDIOC_QUERYCAP     /* 获取设备支持的操作 */  
    VIDIOC_G_FMT        /* 获取设置支持的视频格式 */  
    VIDIOC_S_FMT        /* 设置捕获视频的格式 */  
    VIDIOC_REQBUFS      /* 向驱动提出申请内存的请求 */  
    VIDIOC_QUERYBUF     /* 向驱动查询申请到的内存 */  
    VIDIOC_QBUF         /* 将空闲的内存加入可捕获视频的队列 */  
    VIDIOC_DQBUF        /* 将已经捕获好视频的内存拉出已捕获视频的队列 */  
    VIDIOC_STREAMON     /* 打开视频流 */  
    VIDIOC_STREAMOFF    /* 关闭视频流 */  
    VIDIOC_QUERYCTRL    /* 查询驱动是否支持该命令 */  
    VIDIOC_G_CTRL       /* 获取当前命令值 */  
    VIDIOC_S_CTRL       /* 设置新的命令值 */  
    VIDIOC_G_TUNER      /* 获取调谐器信息 */  
    VIDIOC_S_TUNER      /* 设置调谐器信息 */  
    VIDIOC_G_FREQUENCY  /* 获取调谐器频率 */  
    VIDIOC_S_FREQUENCY  /* 设置调谐器频率 */ 

    open函数说明

     头文件: 
         #include <fcntl.h>
     函数定义: 
         int open(const char *pathname,int oflag, ...)
     返回值: 
         成功返回最小的未被使用的文件描述符,否则返回-1;
     参数说明:
        pathname:待打开或创建文件的路径名。
        oflag:指定文件的打开或创建模式,打开或创建文件时,至少使用下述三个常量中的一个。
        O_RDONLY    只读模式
        O_WRONLY    只写模式
        O_RDWR      读写模式
        以下常量是选用的:
        O_APPEND    每次写操作都写入文件的末尾。
        O_CREAT     如果指定文件不存在,则创建这个文件。
        O_EXCL      如果要创建的文件已存在,则返回-1,并且修改errno的值。
        O_TRUNC     如果文件存在,并且以只写或读写方式打开,则清空文件全部内容。
        O_NOCTTY    如果路径名指向终端设备,不要把这个设备用作控制终端。
        O_NONBLOCK  如果路径名指向指向FIFO/块文件/字符文件,则把文件的打开和后继I/O设置为非阻塞模式。使用非阻塞模式调用视频设备,即使尚未捕获到信息,驱动依旧会把缓存(DQBUFF)里的内容返回给应用程序。
        以下三个常量同样是选用的,它们用于同步输入输出。
        O_SDYNC     等待物理I/O结束后再write。在不影响读取新写入的数据的前提下,不等待文件属性更新。
        O_RSYNC     read等待所有写入丗一区域的写操作完成后再进行。
        O_SYNC      等待物理I/O结束后再write,包括更新文件属性的I/O。

    mmap函数说明

    头文件:
        #include <mman.h>
    函数定义:
        void *mmap(void *start, size_t length, int prot, int flags,int fd, off_t offset);
    返回值:
        执行成功返回被映射区的指针, 执行失败返回MAP_FAILED[其值为(void *)-1]
        EACCES:访问出错
        EAGAIN:文件已被锁定,或者太多的内存已被锁定
        EBADF:fd不是有效的文件描述词
        EINVAL:一个或者多个参数无效
        ENFILE:已达到系统对打开文件的限制
        ENODEV:指定文件所在的文件系统不支持内存映射
        ENOMEM:内存不足,或者进程已超出最大内存映射数量
        EPERM:权能不足,操作不允许
        ETXTBSY:已写的方式打开文件,同时指定MAP_DENYWRITE标志
        SIGSEGV:试着向只读区写入
        SIGBUS:试着访问不属于进程的内存区
    
    参数:
    
        start:映射区的开始地址。
        length:映射区的长度。
        prot:期望的内存保护标志,不能与文件的打开模式冲突。是以下的某个值,可以通过or
            PROT_EXEC //页内容可以被执行
            PROT_READ //页内容可以被读取
            PROT_WRITE //页可以被写入
            PROT_NONE //页不可访问
    
        flags:指定映射对象的类型,映射选项和映射页是否可以共享。它的值可以是一个或者多个以下位的组合体
    
            MAP_FIXED //使用指定的映射起始地址,如果由start和len参数指定的内存区重叠于现存的映射空间,重叠部分将会被丢弃。如果指定的起始地址不可用,操作将会失败。并且起始地址必须落在页的边界上。
            MAP_SHARED //与其它所有映射这个对象的进程共享映射空间。对共享区的写入,相当于输出到文件。直到msync()或者munmap()被调用,文件实际上不会被更新。
            MAP_PRIVATE //建立一个写入时拷贝的私有映射。内存区域的写入不会影响到原文件。这个标志和以上标志是互斥的,只能使用其中一个。
            MAP_DENYWRITE //这个标志被忽略。
            MAP_EXECUTABLE //同上
            MAP_NORESERVE //不要为这个映射保留交换空间。当交换空间被保留,对映射区修改的可能会得到保证。当交换空间不被保留,同时内存不足,对映射区的修改会引起段违例信号。
            MAP_LOCKED //锁定映射区的页面,从而防止页面被交换出内存。
            MAP_GROWSDOWN //用于堆栈,告诉内核VM系统,映射区可以向下扩展。
            MAP_ANONYMOUS //匿名映射,映射区不与任何文件关联。
            MAP_ANON //MAP_ANONYMOUS的别称,不再被使用。
            MAP_FILE //兼容标志,被忽略。
            MAP_32BIT //将映射区放在进程地址空间的低2GB,MAP_FIXED指定时会被忽略。当前这个标志只在x86-64平台上得到支持。
            MAP_POPULATE //为文件映射通过预读的方式准备好页表。随后对映射区的访问不会被页违例阻塞。
            MAP_NONBLOCK //仅和MAP_POPULATE一起使用时才有意义。不执行预读,只为已存在于内存中的页面建立页表入口。
        fd:有效的文件描述词。如果MAP_ANONYMOUS被设定,为了兼容问题,其值应为-1。
        offset:被映射对象内容的起点。

    v4l2_capability结构

    ioctl (fd, VIDIOC_QUERYCAP, &cap) 

    命令通过结构 v4l2_capability 获取设备支持的操作模式:

     struct v4l2_capability
    {
         __u8 driver[16];    //驱动名。
         __u8 card[32];      // Device名
         __u8 bus_info[32];  //在Bus系统中存放位置
         __u32 version;      //driver 版本
         __u32 capabilities; //能力集
         __u32 reserved[4];
    };

    v4l2_format结构

     struct v4l2_format
     {
         enum v4l2_buf_type type; 
         union
         {
            struct v4l2_pix_format    pix;
            struct v4l2_window        win;
            struct v4l2_vbi_format    vbi;
            struct v4l2_sliced_vbi_format  sliced;
            __u8   raw_data[200];
         } fmt;
     };
    
    enum v4l2_buf_type 
    {  
        V4L2_BUF_TYPE_VIDEO_CAPTURE        = 1,  //视频捕捉模式
        V4L2_BUF_TYPE_VIDEO_OUTPUT         = 2,  
        V4L2_BUF_TYPE_VIDEO_OVERLAY        = 3,  
        ...  
        V4L2_BUF_TYPE_PRIVATE              = 0x80,  
    };  

    v4l2_pix_format结构

    
     struct v4l2_pix_format
     {
        __u32       width;         // 宽,必须是16的倍数
        __u32       height;        // 高,必须是16的倍数
        __u32       pixelformat;   // 视频数据格式(常见的值有 V4L2_PIX_FMT_YUV422P | V4L2_PIX_FMT_RGB565)
        enum v4l2_field         field;   //扫描方式,(逐行扫描,隔行扫描)
        __u32                   bytesperline;   //一行图像占用的字节数
        __u32                   sizeimage;      //图像占用的总字节数
        enum v4l2_colorspace    colorspace;     //颜色空间
        __u32                   priv;           // private data, depends on pixelformat
     };

    v4l2_requestbuffers结构

    struct v4l2_requestbuffers
    {
        __u32               count;  // 缓存数量,也就是说在缓存队列里保持多少张照片
        enum v4l2_buf_type  type;   // 数据流类型,必须永远是V4L2_BUF_TYPE_VIDEO_CAPTURE
        enum v4l2_memory    memory; // V4L2_MEMORY_MMAP 或V4L2_MEMORY_USERPTR
        __u32               reserved[2];
        }; 

    关于视频采集方式

    操作系统一般把系统使用的内存划分成用户空间和内核空间,分别由应用程序管理和操作系统管理。应用程序可以直接访问内存的地址,而内核空间存放的是 供内核访问的代码和数据,用户不能直接访问。v4l2捕获的数据,最初是存放在内核空间的,这意味着用户不能直接访问该段内存,必须通过某些手段来转换地 址。

    一共有三种视频采集方式:使用read、write方式;内存映射方式和用户指针模式。

    read、write方式:在用户空间和内核空间不断拷贝数据,占用了大量用户内存空间,效率不高。

    内存映射方式:把设备里的内存映射到应用程序中的内存控件,直接处理设备内存,这是一种有效的方式。上面的mmap函数就是使用这种方式。

    用户指针模式:内存片段由应用程序自己分配。这点需要在v4l2_requestbuffers里将memory字段设置成V4L2_MEMORY_USERPTR。

    处理采集数据

    V4L2有一个数据缓存,存放req.count数量的缓存数据。数据缓存采用FIFO的方式,当应用程序调用缓存数据时,缓存队列将最先采集到的 视频数据缓存送出,并重新采集一张视频数据。这个过程需要用到两个ioctl命令,VIDIOC_DQBUF和VIDIOC_QBUF:

    struct v4l2_buffer buf;
    memset(&buf,0,sizeof(buf));
    buf.type=V4L2_BUF_TYPE_VIDEO_CAPTURE;
    buf.memory=V4L2_MEMORY_MMAP;
    buf.index=0;
    
    //读取缓存
    if (ioctl(cameraFd, VIDIOC_DQBUF, &buf) == -1)
    {
        return -1;
    }
    
    //Deal_Frame(...) //处理帧数据
    
    //重新放入缓存队列
    if (ioctl(cameraFd, VIDIOC_QBUF, &buf) == -1) 
    {
        return -1;
    }

    二、编程思路

    在Linux下,所有外设都被看成一种特殊的文件,成为“设备文件”,可以象访问普通文件一样对其进行读写。一般来说,采用V4L2驱动的摄像头设备文件是/dev/video0。V4L2支持两种方式来采集图像:内存映射方式(mmap)和直接读取方式(read)。V4L2在include/linux/videodev.h文件中定义了一些重要的数据结构,在采集图像的过程中,就是通过对这些数据的操作来获得最终的图像数据。Linux系统V4L2的能力可在Linux内核编译阶段配置,默认情况下都有此开发接口。 
    而摄像头所用的主要是capature了,视频的捕捉,具体linux的调用可以参考下图。 
    V4L2学习笔记

    应用程序通过V4L2进行视频采集的原理

    V4L2支持内存映射方式(mmap)和直接读取方式(read)来采集数据,前者一般用于连续视频数据的采集,后者常用于静态图片数据的采集,本文重点讨论内存映射方式的视频采集。

    应用程序通过V4L2接口采集视频数据分为五个步骤:

    1、打开视频设备文件,进行视频采集的参数初始化,通过V4L2接口设置视频图像的采集窗口、采集的点阵大小和格式;

    2、申请若干视频采集的帧缓冲区,并将这些帧缓冲区从内核空间映射到用户空间,便于应用程序读取/处理视频数据;

    3、将申请到的帧缓冲区在视频采集输入队列排队,并启动视频采集;

    4、驱动开始视频数据的采集,应用程序从视频采集输出队列取出帧缓冲区,处理完后,将帧缓冲区重新放入视频采集输入队列,循环往复采集连续的视频数据;

    5、停止视频采集。

    具体的程序实现流程可以参考下面的流程图: 
    V4L2学习笔记

    其实其他的都比较简单,就是通过ioctl这个接口去设置一些参数。最主要的就是buf管理。他有一个或者多个输入队列和输出队列。

    启动视频采集后,驱动程序开始采集一帧数据,把采集的数据放入视频采集输入队列的第一个帧缓冲区,一帧数据采集完成,也就是第一个帧缓冲区存满一帧数据后,驱动程序将该帧缓冲区移至视频采集输出队列,等待应用程序从输出队列取出。驱动程序接下来采集下一帧数据,放入第二个帧缓冲区,同样帧缓冲区存满下一帧数据后,被放入视频采集输出队列。

    应用程序从视频采集输出队列中取出含有视频数据的帧缓冲区,处理帧缓冲区中的视频数据,如存储或压缩。

    最后,应用程序将处理完数据的帧缓冲区重新放入视频采集输入队列,这样可以循环采集,如图所示。 
    V4L2学习笔记

    每一个帧缓冲区都有一个对应的状态标志变量,其中每一个比特代表一个状态

      V4L2_BUF_FLAG_UNMAPPED 0B0000
      V4L2_BUF_FLAG_MAPPED 0B0001
      V4L2_BUF_FLAG_ENQUEUED 0B0010
      V4L2_BUF_FLAG_DONE 0B0100

      缓冲区的状态转化如图所示。

    V4L2学习笔记

    三、程序代码 
    运行环境: 
    系统: ubuntu-14.04 
    OPENCV: opencv-2.4.9 
    开发平台: Qt5.5.1 
    摄像头: Microsoft_LifeCam_Studio http://detail.zol.com.cn/webcams/index257694.shtml

    /****************************/
    /*V4L2视频采集测试程序        */
    /****************************/
    
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <assert.h>
    #include <iostream>
    
    #include <getopt.h>
    
    #include <fcntl.h>
    #include <unistd.h>
    #include <errno.h>
    #include <malloc.h>
    #include <sys/stat.h>
    #include <sys/types.h>
    #include <sys/time.h>
    #include <sys/mman.h>
    #include <sys/ioctl.h>
    
    #include <asm/types.h>
    #include <linux/videodev2.h>
    
    #include <cv.h>
    #include <opencv2/highgui/highgui.hpp>
    
    
    #define CLEAR(x) memset(&(x), 0, sizeof(x));
    
    struct buffer
    {
        void *start;
        size_t length;
    };
    
    static char *dev_name    = "/dev/video0";
    
    int main(int argc, char **argv)
    {
        /* 非阻塞式打开摄像头设备 */
        int fd = open(dev_name, O_RDWR | O_NONBLOCK, 0);
    
        if (fd < 0)
        {
            perror("Can't open device");
            exit(EXIT_FAILURE);
        }
    
        /* 查询视频设备支持格式 */
        struct v4l2_fmtdesc fmtdesc;
        CLEAR(fmtdesc);
    
        fmtdesc.index = 0;
        fmtdesc.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    
        printf("Support format:
    ");
        while (ioctl(fd, VIDIOC_ENUM_FMT, &fmtdesc) == 0)
        {
            fmtdesc.index++;
            printf("pixelformat = ''%c%c%c%c''
    description = ''%s''
    ", fmtdesc.pixelformat & 0xFF, (fmtdesc.pixelformat >> 8) & 0xFF, (fmtdesc.pixelformat >> 16) & 0xFF, (fmtdesc.pixelformat >> 24) & 0xFF, fmtdesc.description);
        }
    
        /* 查询设备的输出格式并打印输出 */
        struct v4l2_format fmt;
        CLEAR(fmt);
    
        fmt.type                = V4L2_BUF_TYPE_VIDEO_CAPTURE;// 设置视频捕获模式
        fmt.fmt.pix.width       = 640;
        fmt.fmt.pix.height      = 480;
        fmt.fmt.pix.pixelformat = V4L2_FIELD_INTERLACED;    // 隔行扫描的方式
    
        /* 设置图像格式 */
        ioctl(fd, VIDIOC_S_FMT, &fmt);
    
        /* 申请视频缓冲区 */
        struct v4l2_requestbuffers req_buf;
        CLEAR(req_buf);
        req_buf.count = 10; // 缓冲区大小
        req_buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
        req_buf.memory = V4L2_MEMORY_MMAP; // 缓冲区类型设置为MMAP型
    
        if (-1 == ioctl(fd,VIDIOC_REQBUFS,&req_buf))
        {
            perror("While requestion buffers
    ");
            exit(EXIT_FAILURE);
        }
    
        if (req_buf.count < 5)
        {
            fprintf(stderr,"Can't get enough buffers!
    ");
            exit(EXIT_FAILURE);
        }
    
        unsigned int nbuffer = req_buf.count;
    
        /* 申请用户缓冲区 */
        struct buffer *usr_buf = (struct buffer *)calloc(nbuffer, sizeof(*usr_buf));
    
        if (!usr_buf)
        {
            perror("Can't allocate memory for usr_buf
    ");
            exit(EXIT_FAILURE);
        }
    
        /* 将申请到的帧缓冲映射到用户空间 */
        for (nbuffer = 0; nbuffer < req_buf.count; ++nbuffer)
        {
            struct v4l2_buffer buf;
            CLEAR(buf);
            buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
            buf.memory = V4L2_MEMORY_MMAP;
            buf.index = nbuffer;
    
            // 查询序号为nbuffer的缓冲区,得到其起始物理地址和大小
            if (-1 == ioctl(fd,VIDIOC_QUERYBUF,&buf))
            {
                perror("While querying buffer");
                exit(EXIT_FAILURE);
            }
    
            // 映射内存到用户空间
            usr_buf[nbuffer].length = buf.length;
            usr_buf[nbuffer].start = mmap(
                    NULL,
                    buf.length,
                    PROT_READ | PROT_WRITE,
                    MAP_SHARED,
                    fd,
                    buf.m.offset
                 );
    
            if (MAP_FAILED == usr_buf[nbuffer].start)
            {
                perror("While mapping memory");
                exit(EXIT_FAILURE);
            }
    
            // 将申请到的帧缓冲放入缓存队列
            if (-1 == ioctl(fd, VIDIOC_QBUF, &buf))
            {
                return -1;
            }
        }
    
        /* 打开采集视频 */
        enum v4l2_buf_type type;
        type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    
        if (-1 == ioctl(fd, VIDIOC_STREAMON, &type))
        {
            perror("While opening stream");
            exit(EXIT_FAILURE);
        }
        printf("
    Video stream on
    
    ");
    
        /* 与内核交换缓冲区 */
        unsigned int i = 0;
    
        cvNamedWindow("Capture",CV_WINDOW_AUTOSIZE);
    
        // 这一段涉及到异步IO
        while (true)
        {
            fd_set fds;
            struct timeval tv;
            int r;
    
            // 将指定的文件描述符集清空
            FD_ZERO(&fds);
    
            // 在文件描述符集合中增加一个新的文件描述符
            FD_SET(fd, &fds);
    
            /*Timeout.*/
            tv.tv_sec = 2;
            tv.tv_usec = 0;
    
            // 判断是否可读(即摄像头是否准备好),tv是定时
            r = select(fd + 1, &fds, NULL, NULL, &tv);
    
            if (-1 == r)
            {
                if( EINTR == errno)
                {
                    continue;
                }
                printf("select err
    ");
            }
    
            if (0 == r)
            {
                fprintf(stderr,"select timeout
    ");
                break;
            }
    
            struct v4l2_buffer buf;
            CLEAR(buf);
            buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
            buf.memory = V4L2_MEMORY_MMAP;
            buf.index = i;
    
            /* 从视频采集输入队列取出帧缓冲区 */
            if (-1 == ioctl(fd, VIDIOC_DQBUF, &buf))
            {
                perror("While getting buffers data");
                break;
            }
    
            /* 将帧内容赋值给CvMat格式的数据 */
            CvMat cvmat=cvMat(fmt.fmt.pix.height,fmt.fmt.pix.width,CV_8UC3,usr_buf[buf.index].start);
    
            /* 解码,这一步将数据转换为IplImage格式 */
            IplImage *img =cvDecodeImage(&cvmat, 1);
    
            cvShowImage("Capture",img);
    
            cvWaitKey(30); //注意这里要加时延
    
            cvReleaseImage(&img);
    
            /* 把用用完的缓冲帧放回队列 */
            if (-1 == ioctl(fd, VIDIOC_QBUF, &buf))
            {
                perror("While returning buffers data");
                break;
            }
    
            i = (i+1) & nbuffer;
        }
    
        /* 关闭视频流 */
        type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
        if (-1 == ioctl(fd,VIDIOC_STREAMOFF,&type))
        {
            perror("STREAMOFF fail
    ");
            exit(EXIT_FAILURE);
        }
    
        /* 断开映射 */
        for (unsigned int i = 0; i < nbuffer; i++)
        {
            if (-1 == munmap(usr_buf[i].start, usr_buf[i].length))
            {
                exit(-1);
            }
        }
        free(usr_buf);
    
        /* 关闭设备 */
        if (-1 == close(fd))
        {
            perror("Fail to close fd");
            exit(EXIT_FAILURE);
        }
        exit(EXIT_SUCCESS);
    }

    四、运行结果 
    V4L2学习笔记 
    今天下午采集640x480成功,但刚才运行发现还有问题,不知道为什么,正在找原因。

    五、参考博客 
    http://blog.csdn.net/g_salamander/article/details/8107692(这篇写得非常好,很工整) 
    http://blog.csdn.net/eastmoon502136/article/details/8190262(这篇侧重编程思路) 
    http://www.51xdn.net/czxt/Linux/20151011/26781.html(这篇讲了v4l2采集帧转化为IplImage图像) 
    http://www.cnblogs.com/lixiaoming90/archive/2012/08/25/2657019.html(这篇也不错)

    以上就是V4L2学习笔记的全文介绍,希望对您学习和使用linux系统开发有所帮助.

  • 相关阅读:
    反转链表
    fatal error LNK1104: 无法打开文件“lua51.lib”
    《cocos2d-x游戏开发之旅》问题2016-10-7
    c++中sizeof的用法
    cocos2d-x-3.0beta2创建项目遇到“UnicodeDecodeError: 'ascii' codec can't decode byte 0xd7 in position 9: ordinal not in range(128)”的问题
    C++中的explicit关键字的用法
    c++中双冒号的作用
    构造函数与析构函数
    61. Binary Tree Inorder Traversal
    60-Lowest Common Ancestor of a Binary Search Tree
  • 原文地址:https://www.cnblogs.com/sky-heaven/p/9562836.html
Copyright © 2020-2023  润新知