字符设备驱动三

七、 字符设备驱动程序之poll机制(第九课)

对于系统的调用,基本都可以在它的名字前加上 "sys_" 前缀,这就是内核中对应的函数。比如系统调用 open、read、write、poll,与之对应的内核函数为:sys_open、sys_read、sys_write、sys_poll

sys_poll 函数位于 fs/select.c 文件中


poll_initwait 函数的作用是初始化一个 poll_wqueues* 类型的变量 table




__pollwait 这个函数把我们当前进程挂入我们驱动程序里定义的一个队列里

接着分析 (do_poll) 函数


break的条件:1、count非零(驱动程序中的 ev_press 变量为1时,也就是中断发生,驱动程序的函数返回非0值);2、超时;3、有信号等待处理

否则进入休眠

poll机制驱动程序(forth_chrdev.c)

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/init.h>
#include <linux/delay.h>

#include <linux/irq.h>
#include <linux/poll.h>

#include <asm/uaccess.h>
#include <asm/irq.h>
#include <asm/io.h>
#include <asm/arch/regs-gpio.h>
#include <asm/hardware.h>
/*
 * eint 0  GPF0
 * eint 2  GPF2
 * eint 11 GPG3
 * eint 19 GPG11
 */
static struct class *forth_chrdev_class;
static struct class_device *forth_chrdev_class_dev;

static volatile unsigned char key_val;

static DECLARE_WAIT_QUEUE_HEAD(button_waitq);

/* 中断事件标志, 中断服务程序将它置1,s3c24xx_buttons_read将它清0 */
static volatile int ev_press = 0;

struct pin_desc{
        unsigned int pin;
        unsigned int key_val;
    };
struct pin_desc pins_desc[4] = {
        {S3C2410_GPF0,  0x01},
        {S3C2410_GPF2,  0x02},
        {S3C2410_GPG3,  0x03},
        {S3C2410_GPG11, 0x04},
    };

static irqreturn_t button_irq(int irq, void *dev_id)
{
    struct pin_desc *pindesc = (struct pin_desc *)dev_id;
    unsigned char pinval;
    printk("irq = %d

", irq);
    pinval = s3c2410_gpio_getpin(pindesc->pin);
    if(pinval)
    {
        /* 松开 */
        key_val = pindesc->key_val;
    }
    else
    {
        /* 按下 */
        key_val = 0x80|pindesc->key_val;
    }
       wake_up_interruptible(&button_waitq);   /* 唤醒休眠的进程 */
        ev_press = 1;
    return IRQ_HANDLED;
}


static int forth_chrdev_open(struct inode *inode, struct file *file)
{
    printk("forth_chrdev_open

");
    request_irq(IRQ_EINT0,   button_irq, IRQT_BOTHEDGE, "S2", &pins_desc[0]);
    request_irq(IRQ_EINT2,   button_irq, IRQT_BOTHEDGE, "S3", &pins_desc[1]);
    request_irq(IRQ_EINT11, button_irq, IRQT_BOTHEDGE, "S4", &pins_desc[2]);
    request_irq(IRQ_EINT19, button_irq, IRQT_BOTHEDGE, "S5", &pins_desc[3]);

    return 0;
}


static ssize_t forth_chrdev_read(struct file *file, const char __user *buf, size_t count, loff_t * ppos)
{
    if (count != 1)
        return -EINVAL;

    /* 如果没有按键动作, 休眠 */
    wait_event_interruptible(button_waitq, ev_press);

    copy_to_user(buf, &key_val, 1);
    ev_press = 0;

    return 1;
}

int forth_chrdev_release(struct inode *inode, struct file *file)
{
    printk("forth_chrdev_release

");
    free_irq(IRQ_EINT0,   &pins_desc[0]);
    free_irq(IRQ_EINT2,   &pins_desc[1]);
    free_irq(IRQ_EINT11, &pins_desc[2]);
    free_irq(IRQ_EINT19, &pins_desc[3]);

    return 0;
}

static unsigned int forth_chrdev_poll(struct file *file, poll_table *wait)
{
    unsigned int mask = 0;
    //不会立即休眠,只是把当前进程挂接进去,也就是执行指针指向后的__pollwait函数。
    poll_wait(file, &button_waitq, wait);

    if (ev_press)
        mask |= (POLLIN | POLLRDNORM);

    return mask;
}


static struct file_operations forth_chrdev_fops = {
    .owner    =  THIS_MODULE,    /* 这是一个宏,推向编译模块时自动创建的__this_module变量 */
    .open     =  forth_chrdev_open,
    .read     =  forth_chrdev_read,
    .release  =  forth_chrdev_release,
    .poll      =  forth_chrdev_poll,    //这个.poll所指向的函数最终是在(do_pollfd)函数中的(file->f_op->pll)函数中被调用
    };


int major;
static int forth_chrdev_init(void)
{
    major = register_chrdev(0, "forth_chrdev", &forth_chrdev_fops);  //注册
    forth_chrdev_class = class_create(THIS_MODULE, "forthchrdev");
    forth_chrdev_class_dev = class_device_create(forth_chrdev_class, NULL, MKDEV(major, 0), NULL, "forth_chrdev");

    return 0;
}

static void forth_chrdev_exit(void)
{
    unregister_chrdev(major, "forth_chrdev");                    //卸载
    class_device_unregister(forth_chrdev_class_dev);
    class_destroy(forth_chrdev_class);
}

module_init(forth_chrdev_init);
module_exit(forth_chrdev_exit);

MODULE_LICENSE("GPL");

poll机制测试程序(forth_chrdev_test.c)

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>

#include <poll.h>

int main(int argc, char **argv)
{
    int fd,ret;
    volatile unsigned char key_val;

    struct pollfd fds[1];

    fd = open("/dev/forth_chrdev",O_RDWR);
    if(fd < 0)
    printf("can't open!
");
    fds[0].fd       = fd;
    fds[0].events = POLLIN;    //POLLIN There is data to read.

    while(1)
    {
        //poll可以多个文件查询,此处只查询一个文件
        ret = poll(fds, 1, 5000);    //poll的返回条件有2:超时时间到;中断发生
        if(ret == 0)    //超时时间到则返回0
        {
            printf("timeout

");
        }
        else
        {
            read(fd,&key_val,1);
            printf("key_val = 0x%x

", key_val);
            printf("

");
        }
    }
    return 0;
}

测试:

  1. 若五秒没有中断发生,则打印(timeout)字符串。
  2. 若任意时刻中断发生,则立刻响应中断

总结poll机制:

  1. poll>sys_poll>do_sys_poll>poll_initwait,poll_initwait 函数注册了函数 __pollwait,它就是我们驱动程序执行poll_wait时,真正被调用的函数。
  2. 接下来执行 file->f_op->poll,也就是我们驱动程序里自己实现的poll函数,它会调用 poll_wait函数,把自己挂入某个队列里,这个队列(button_waitq)是我们的驱动自己定义的,在挂入之前还会判断一下设备是否就绪。
  3. 进程被唤醒的条件有:一、超时时间到,二、被驱动程序唤醒。
  4. 如果进程被唤醒则返回,返回值为0表示超时时间到,返回非0则表示被驱动程序唤醒。

八、 字符设备驱动程序之异步通知(第十课)

获取按键值有三种方式:1.查询(耗资源);2.中断(虽然会休眠,但是如果一直没有按键按下的话会一直等待,永远不会返回);3.poll机制(指定超时时间返回)
这三种方法有共同点:都是应用程序主动的读
那么有没有一种方法,当有按键按下了会有驱动程序来提醒应用程序来读呢?
答:使用异步通知(用signal来实现),进程之间通过命令来发信号

    kill -5 pid:向pid发送5这个信号

sigal.c

测试:

  1. 后台运行程序并查看pid
  2. 向应用程序的pid进程发送信号值(USR1=10)


    向应用程序的pid进程发送这个信号值就可以执行我们自己实现的函数

信号要点:

  1. 应用程序注册信号处理函数
  2. 谁发?(驱动函数发)
  3. 发给谁?(发给应用程序,所以应用程序需要告诉驱动程序pid)
  4. 怎么发?(驱动程序的中断处理函数里会调用 kill_fasync 函数)

目标:按下按键时,驱动程序通知应用程序

首先搜索一下(kill_fasync),看看别人是怎么用的


查看一下这个结构是怎么定义的

这个结构被定义出来了,但是在什么地方被初始化的呢?由于这个结构被定义的 static 关键字,所以只在本文件能被使用,在本文件中搜索一下这个(rtc_async_queue)结构

同样这个(rtc_fasync)函数也被限制该文件内使用,搜索一下这个函数在什么地方被调用


1. 应用程序调用F_SETOWN命令之后就会把应用程序的pid告诉驱动程序,这个命令的处理是由内核帮我们做的。
2. F_SETFL命令是指每当flag被改变后,驱动程序中的fasync函数就会被调用。

  1. 应用程序会调用这个函数来告诉驱动程序我自己的pid
  2. 应用程序去读出flag
  3. 修改flag,加上fasync

异步通知程序(fifth_chrdev.c)

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/init.h>
#include <linux/delay.h>

#include <linux/irq.h>
#include <linux/poll.h>

#include <asm/uaccess.h>
#include <asm/irq.h>
#include <asm/io.h>
#include <asm/arch/regs-gpio.h>
#include <asm/hardware.h>
/*
 * eint 0  GPF0
 * eint 2  GPF2
 * eint 11 GPG3
 * eint 19 GPG11
 */
static struct class *fifth_chrdev_class;
static struct class_device *fifth_chrdev_class_dev;

static volatile unsigned char key_val;

static struct fasync_struct *button_async;

struct pin_desc{
        unsigned int pin;
        unsigned int key_val;
    };
struct pin_desc pins_desc[4] = {
        {S3C2410_GPF0,  0x01},
        {S3C2410_GPF2,  0x02},
        {S3C2410_GPG3,  0x03},
        {S3C2410_GPG11, 0x04},
    };

static irqreturn_t button_irq(int irq, void *dev_id)
{
    struct pin_desc *pindesc = (struct pin_desc *)dev_id;
    unsigned char pinval;
    printk("irq = %d

", irq);
    pinval = s3c2410_gpio_getpin(pindesc->pin);
    if(pinval)
    {
        /* 松开 */
        key_val = pindesc->key_val;
    }
    else
    {
        /* 按下 */
        key_val = 0x80|pindesc->key_val;
    }
        //button_async这个结构中包含应用程序的pid号
        kill_fasync (&button_async, SIGIO, POLL_IN);

    return IRQ_HANDLED;
}


static int fifth_chrdev_open(struct inode *inode, struct file *file)
{
    printk("fifth_chrdev_open

");
    request_irq(IRQ_EINT0,   button_irq, IRQT_BOTHEDGE, "S2", &pins_desc[0]);
    request_irq(IRQ_EINT2,   button_irq, IRQT_BOTHEDGE, "S3", &pins_desc[1]);
    request_irq(IRQ_EINT11, button_irq, IRQT_BOTHEDGE, "S4", &pins_desc[2]);
    request_irq(IRQ_EINT19, button_irq, IRQT_BOTHEDGE, "S5", &pins_desc[3]);

    return 0;
}


static ssize_t fifth_chrdev_read(struct file *file, const char __user *buf, size_t count, loff_t * ppos)
{
    if (count != 1)
        return -EINVAL;

    copy_to_user(buf, &key_val, 1);

    return 1;
}

int fifth_chrdev_release(struct inode *inode, struct file *file)
{
    printk("fifth_chrdev_release

");
    free_irq(IRQ_EINT0,   &pins_desc[0]);
    free_irq(IRQ_EINT2,   &pins_desc[1]);
    free_irq(IRQ_EINT11, &pins_desc[2]);
    free_irq(IRQ_EINT19, &pins_desc[3]);

    return 0;
}

//当应用程序调用这个接口“fcntl(fd, SETFL, oflags | FASYNC)”时,函数“fasync_helper”就会被调用
static int fifth_chrdev_fasync(int fd, struct file *filp, int on)
{
    printk("fifth_chrdev_fasync

");
    //这个函数获取应用程序的pid并放入(button_async)这个结构
    return fasync_helper(fd, filp, on, &button_async);
}

static struct file_operations fifth_chrdev_fops = {
    .owner    =  THIS_MODULE,    /* 这是一个宏,推向编译模块时自动创建的__this_module变量 */
    .open     =  fifth_chrdev_open,
    .read     =  fifth_chrdev_read,
    .release  =  fifth_chrdev_release,
    .fasync   =  fifth_chrdev_fasync,
    };


int major;
static int fifth_chrdev_init(void)
{
    major = register_chrdev(0, "fifth_chrdev", &fifth_chrdev_fops);  //注册
    fifth_chrdev_class = class_create(THIS_MODULE, "fifthchrdev");
    fifth_chrdev_class_dev = class_device_create(fifth_chrdev_class, NULL, MKDEV(major, 0), NULL, "fifth_chrdev");

    return 0;
}

static void fifth_chrdev_exit(void)
{
    unregister_chrdev(major, "fifth_chrdev");                    //卸载
    class_device_unregister(fifth_chrdev_class_dev);
    class_destroy(fifth_chrdev_class);
}

module_init(fifth_chrdev_init);
module_exit(fifth_chrdev_exit);

MODULE_LICENSE("GPL");

异步通知测试程序(fifth_chrdev_test.c)

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>

#include <signal.h>
#include <sys/types.h>
#include <unistd.h>

static int fd;
void my_signal_fun(int signum)
{
    unsigned char key_val;
    read(fd, &key_val, 1);
    printf("key_val = 0x%x
", key_val);
}


int main(int argc, char **argv)
{
    volatile unsigned char key_val;
    signal(SIGIO, my_signal_fun);
    int Oflags;
    fd = open("/dev/fifth_chrdev",O_RDWR);
    if(fd < 0)
    {
        printf("can't open!
");
        return -1;
    }

    fcntl(fd, F_SETOWN, getpid());

    Oflags = fcntl(fd, F_GETFL);

    fcntl(fd, F_SETFL, Oflags | FASYNC);

    while(1)
    {
        sleep(1000);
    }
    return 0;
}

测试:

  1. 修改Makefile
  2. 编译并拷贝到(first_fs)目录
  3. 加载模块(insmod ./fifth_chrdev.ko)
  4. 运行测试程序(./fifth_chrdev_test)


所占资源:

九、 字符设备驱动程序之同步互斥阻塞(第十一课)

目的:同一时刻,只能有一个APP来打开同一个驱动程序

方法一:用(canopen)变量做一个标记


第一步:设置一个静态的全局变量

第二步:一旦打开该驱动就让这个变量自减,自减后若等于0表示没有打开过,可以继续往下走;若有人打开过了,则变量自减之后等于负1,就会进入if里面把变量自加之后返回 "EBUSY"。

第三步:在关闭这个设备的时候,在函数内让变量加1即可。

但实质上有漏洞:因为我们linux是多任务的系统
Linux是多任务系统,A程序执行过程中,有可能被切换成B程序执行这种情况。
从汇编的角度看"canopen--",其实是被分成了3步去执行:1.读出,2.修改,3.写回。在这个分部执行的过程中很有可能中间会被切换出去

解决办法:使用原子操作

方法二:原子操作

原子操作指的是在执行过程中不会被别的代码路径所中断的操作。

    常用原子操作函数举例:
    atomic_t v = ATOMIC_INIT(0);     //定义原子变量v并初始化为0
    atomic_read(atomic_t *v);        //返回原子变量的值
    void atomic_inc(atomic_t *v);    //原子变量增加1
    void atomic_dec(atomic_t *v);    //原子变量减少1
    int atomic_dec_and_test(atomic_t *v); //自减操作后测试其是否为0,为0则返回true,否则返回false。

第一步:修改代码,将 "canopen" 变量定义成"原子" 变量

第二步:把打开设备文件函数的判断语句改为原子的测试函数

第三步:把关闭设备文件函数的变量自加改为原子自加

这样就不会发生"A程序执行中被B程序切换出去"的情况了,原子操作中间是不会被打断的。

测试:

在同一时刻,只有一个应用程序可以打同一个驱动程序,其余应用程序就无法打开这个驱动程序了

方法三:信号量

信号量(semaphore)是用于保护临界区的一种常用方法,只有得到信号量的进程才能执行临界区代码。当获取不到信号量时,进程进入休眠等待状态。
在操作之前要申请(获取)信号量,若申请不到要么就返回要么就休眠等待;若申请到了就可以继续往下操作,操作完了之后得释放掉信号量,这时若有其他应用程序在等待信号量,就会去唤醒哪个应用程序。

    定义信号量:
    struct semaphore sem;
    初始化信号量
    void sema_init (struct semaphore *sem, int val);
    void init_MUTEX(struct semaphore *sem);//初始化为0

    static DECLARE_MUTEX(button_lock);     //定义互斥锁,也可以用以上3行代码自己定义然后初始化

    获得信号量:
    void down(struct semaphore * sem);//第二次无法获取到信号量的就会在这里陷入休眠,只有第一个应用程序去释放掉信号量的时候才会被唤醒,这个将死的程序不能被杀死(如果杀死这个将死程序,当杀死一个应用程序的时候,这两个程序都会被杀死)
    int down_interruptible(struct semaphore * sem);//若获取不到信号量就会休眠,但是这个休眠状态是可以被打断的,可以被结束掉
    int down_trylock(struct semaphore * sem);//试图取获取信号量,若获取不到就会返回,就不会陷入休眠
    释放信号量:
    void up(struct semaphore * sem);

第一步:定义并初始化一个互斥变量

这个宏会定义和初始化这个变量

第二步:在驱动程序的open函数中获取信号量

第三步:在驱动程序的release函数中释放信号量

测试:
第一次运行时应用程序调用open会获取一次信号量

再次执行应用程序调用同一个驱动程序时会处于将死(D)状态

方法四:阻塞(O_RDWR)和非阻塞(O_NONBLOCK)[默认为阻塞]

阻塞操作:是指在执行设备操作时若不能获得资源则挂起进程,直到满足可操作的条件后再进行操作。被挂起的进程进入休眠状态,被从调度器的运行队列移走,直到等待的条件被满足。
比如读取一个按键值,当前没有按键按下的话,那么就一直等待按键被按下了才返回。

非阻塞操作:进程在不能进行设备操作时并不挂起,它或者放弃,或者不停地查询,直至可以进行操作为止。
比如读取一个按键值当前没有按键值可读取也立刻返回,单返回一个错误。

    格式:fd = open("设备名称", O_RDWR | O_NONBLOCK);//非阻塞

在应用程序是通过open设备时,若传入的参数 "| O_NONBLOCK" 就是非阻塞操作,若不传入这个标记时,默认为阻塞操作。
那么,对于阻塞或者非阻塞驱动程序会进行处理,这个"O_NONBLOCK"标记在上面的"file"这个变量中获取。这个结构时内核提供的。

在驱动程序的open函数中判断阻塞方式

在驱动程序的read函数中也得判断阻塞方式

驱动程序(sixth_chrdev.c)

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/init.h>
#include <linux/delay.h>

#include <linux/irq.h>

#include <asm/uaccess.h>
#include <asm/irq.h>
#include <asm/io.h>
#include <asm/arch/regs-gpio.h>
#include <asm/hardware.h>
/*
 * eint 0  GPF0
 * eint 2  GPF2
 * eint 11 GPG3
 * eint 19 GPG11
 */
static struct class *sixth_chrdev_class;
static struct class_device *sixth_chrdev_class_dev;

static volatile unsigned char key_val;

static DECLARE_WAIT_QUEUE_HEAD(button_waitq);

/* 中断事件标志, 中断服务程序将它置1,s3c24xx_buttons_read将它清0 */
static volatile int ev_press = 0;

static DECLARE_MUTEX(button_lock);     //定义互斥锁

struct pin_desc{
        unsigned int pin;
        unsigned int key_val;
    };
struct pin_desc pins_desc[4] = {
        {S3C2410_GPF0,  0x01},
        {S3C2410_GPF2,  0x02},
        {S3C2410_GPG3,  0x03},
        {S3C2410_GPG11, 0x04},
    };

static irqreturn_t button_irq(int irq, void *dev_id)
{
    struct pin_desc *pindesc = (struct pin_desc *)dev_id;
    unsigned char pinval;
    //printk("irq = %d

", irq);
    pinval = s3c2410_gpio_getpin(pindesc->pin);
    if(pinval)
    {
        /* 松开 */
        key_val = pindesc->key_val;
    }
    else
    {
        /* 按下 */
        key_val = 0x80|pindesc->key_val;
    }
        wake_up_interruptible(&button_waitq);   /* 唤醒休眠的进程 */
        ev_press = 1;

    return IRQ_HANDLED;
}


static int sixth_chrdev_open(struct inode *inode, struct file *file)
{
    printk("sixth_chrdev_open

");
    if(file->f_flags & O_NONBLOCK)
    {
        if (down_trylock(&button_lock))
            return -EBUSY;
    }
    else
    {
        /* 获取信号量 */
        down(&button_lock);
    }
    request_irq(IRQ_EINT0,   button_irq, IRQT_BOTHEDGE, "S2", &pins_desc[0]);
    request_irq(IRQ_EINT2,   button_irq, IRQT_BOTHEDGE, "S3", &pins_desc[1]);
    request_irq(IRQ_EINT11, button_irq, IRQT_BOTHEDGE, "S4", &pins_desc[2]);
    request_irq(IRQ_EINT19, button_irq, IRQT_BOTHEDGE, "S5", &pins_desc[3]);

    return 0;
}


static ssize_t sixth_chrdev_read(struct file *file, const char __user *buf, size_t count, loff_t * ppos)
{
    if (count != 1)
        return -EINVAL;
    if(file->f_flags & O_NONBLOCK)
    {
        if (!ev_press)
            return -EAGAIN;
    }
    else
    {

        /* 如果没有按键动作, 休眠 */
        wait_event_interruptible(button_waitq, ev_press);
    }

    copy_to_user(buf, &key_val, 1);
    ev_press = 0;

    return 1;
}

int sixth_chrdev_release(struct inode *inode, struct file *file)
{
    printk("sixth_chrdev_release

");
    free_irq(IRQ_EINT0,   &pins_desc[0]);
    free_irq(IRQ_EINT2,   &pins_desc[1]);
    free_irq(IRQ_EINT11, &pins_desc[2]);
    free_irq(IRQ_EINT19, &pins_desc[3]);
    up(&button_lock);
    return 0;
}


static struct file_operations sixth_chrdev_fops = {
    .owner    =  THIS_MODULE,    /* 这是一个宏,推向编译模块时自动创建的__this_module变量 */
    .open     =  sixth_chrdev_open,
    .read     =  sixth_chrdev_read,
    .release  =  sixth_chrdev_release,
    };


int major;
static int sixth_chrdev_init(void)
{
    major = register_chrdev(0, "sixth_chrdev", &sixth_chrdev_fops);  //注册
    sixth_chrdev_class = class_create(THIS_MODULE, "sixthchrdev");
    sixth_chrdev_class_dev = class_device_create(sixth_chrdev_class, NULL, MKDEV(major, 0), NULL, "sixth_chrdev");

    return 0;
}

static void sixth_chrdev_exit(void)
{
    unregister_chrdev(major, "sixth_chrdev");                    //卸载
    class_device_unregister(sixth_chrdev_class_dev);
    class_destroy(sixth_chrdev_class);
}

module_init(sixth_chrdev_init);
module_exit(sixth_chrdev_exit);

MODULE_LICENSE("GPL");

测试程序(sixth_chrdev_test.c)

#include <sys/types.h>
#include <sys/stat.h>
#include <stdio.h>
#include <fcntl.h>

#include <sys/types.h>
#include <unistd.h>

int fd;

int main(int argc, char **argv)
{
    volatile unsigned char key_val;
    int ret;

    fd = open("/dev/sixth_chrdev",O_RDWR | O_NONBLOCK);//非阻塞:O_NONBLOCK
    if(fd < 0)
    {
        printf("can't open!
");
        return -1;
    }

    while(1)
    {
        ret = read(fd, &key_val, 1);
        printf("ret : %d,  key_val = 0x%x
", ret, key_val);
        sleep(5);
    }
    return 0;
}

测试(非阻塞):
第一次运行应用程序时会成功获取到信号量,但是会不停的读取按键值,若没有按键按下则返回"-EAGAIN"

第二次运行应用程序就会无法打开

<wiz_tmp_tag id="wiz-table-range-border" contenteditable="false" style="display: none;">

原文地址:https://www.cnblogs.com/luosir520/p/11446964.html