Linux设备驱动中的IO模型---阻塞和非阻塞IO【转】

在前面学习网络编程时,曾经学过I/O模型 Linux 系统应用编程——网络编程(I/O模型),下面学习一下I/O模型在设备驱动中的应用。

       回顾一下在Unix/Linux下共有五种I/O模型,分别是:

a -- 阻塞I/O
b -- 非阻塞I/O
c -- I/O复用(select和poll)
d -- 信号驱动I/O(SIGIO)
e -- 异步I/O(Posix.1的aio_系列函数)

 

       下面我们先学习阻塞I/O、非阻塞I/O 、I/O复用(select和poll),先学习一下基础概念

a -- 阻塞 

       阻塞操作是指在执行设备操作时,若不能获得资源,则挂起进程知道满足可操作的条件后再进行操作;被挂起的进程进入休眠状态(放弃CPU),被从调度器的运行队列移走,直到等待的条件被满足; 

b -- 非阻塞

      非阻塞的进程在不能进行设备操作时,并不挂起(继续占用CPU),它或者放弃,或者不停地查询,直到可以操作为止;

      二者的区别可以看应用程序的调用是否立即返回!

 

      驱动程序通常需要提供这样的能力:当应用程序进行 read()、write() 等系统调用时,若设备的资源不能获取,而用户又希望以阻塞的方式访问设备,驱动程序应在设备驱动的xxx_read()、xxx_write() 等操作中将进程阻塞直到资源可以获取,此后,应用程序的 read()、write() 才返回,整个过程仍然进行了正确的设备 访问,用户并没感知到;若用户以非阻塞的方式访问设备文件,则当设备资源不可获取时,设备驱动的 xxx_read()、xxx_write() 等操作立刻返回, read()、write() 等系统调用也随即被返回

      因为阻塞的进程会进入休眠状态,因此,必须确保有一个地方能够唤醒休眠的进程,否则,进程就真的挂了。唤醒进程的地方最大可能发生在中断里面,因为硬件资源获得的同时往往伴随着一个中断

      阻塞I/O通常由等待队列来实现,而非阻塞I/O由轮询来实现。

 

 

一、阻塞I/O实现 —— 等待队列

1、基础概念

       在Linux 驱动程序中,可以使用等待队列(wait queue)来实现阻塞进程的唤醒。wait queue 很早就作为一个基本的功能单位出现在Linux 内核里了,它以队列为基础数据结构,与进程调度机制紧密结合,能够实现内核中的异步事件通知机制。等待队列可以用来同步对系统资源的访问,上一篇文章所述的信号量在内核中也依赖等待队列来实现。

      在Linux内核中使用等待队列的过程很简单,首先定义一个wait_queue_head,然后如果一个task想等待某种事件,那么调用wait_event(等待队列,事件)就可以了。

      等待队列应用广泛,但是内核实现却十分简单。其涉及到两个比较重要的数据结构:__wait_queue_head,该结构描述了等待队列的链头,其包含一个链表和一个原子锁,结构定义如下:    

 

struct __wait_queue_head 
{
	 spinlock_t lock;                    /* 保护等待队列的原子锁 */
	 struct list_head task_list;         /* 等待队列 */
};
typedef struct __wait_queue_head wait_queue_head_t;

 

 

__wait_queue,该结构是对一个等待任务的抽象。每个等待任务都会抽象成一个wait_queue,并且挂载到wait_queue_head上。该结构定义如下:

 

struct __wait_queue 
{
	unsigned int flags;
	void *private;                       /* 通常指向当前任务控制块 */

	/* 任务唤醒操作方法,该方法在内核中提供,通常为autoremove_wake_function */
	wait_queue_func_t func;             
	struct list_head task_list;              /* 挂入wait_queue_head的挂载点 */
};

 

 

    Linux中等待队列的实现思想如下图所示,当一个任务需要在某个wait_queue_head上睡眠时,将自己的进程控制块信息封装到wait_queue中,然后挂载到wait_queue的链表中,执行调度睡眠。当某些事件发生后,另一个任务(进程)会唤醒wait_queue_head上的某个或者所有任务,唤醒工作也就是将等待队列中的任务设置为可调度的状态,并且从队列中删除。

 

 

 

       使用等待队列时首先需要定义一个wait_queue_head,这可以通过DECLARE_WAIT_QUEUE_HEAD宏来完成,这是静态定义的方法。该宏会定义一个wait_queue_head,并且初始化结构中的锁以及等待队列。当然,动态初始化的方法也很简单,初始化一下锁及队列就可以了。

       一个任务需要等待某一事件的发生时,通常调用wait_event,该函数会定义一个wait_queue,描述等待任务,并且用当前的进程描述块初始化wait_queue,然后将wait_queue加入到wait_queue_head中。

函数实现流程说明如下:

a -- 用当前的进程描述块(PCB)初始化一个wait_queue描述的等待任务。

b -- 在等待队列锁资源的保护下,将等待任务加入等待队列。

c -- 判断等待条件是否满足,如果满足,那么将等待任务从队列中移出,退出函数。

d --  如果条件不满足,那么任务调度,将CPU资源交与其它任务。

e -- 当睡眠任务被唤醒之后,需要重复b、c 步骤,如果确认条件满足,退出等待事件函数。

 

2、等待队列接口函数

1、定义并初始化

 

/* 定义“等待队列头” */
wait_queue_head_t my_queue;
/* 初始化“等待队列头”*/
init_waitqueue_head(&my_queue);

 

 

直接定义并初始化。init_waitqueue_head()函数会将自旋锁初始化为未锁,等待队列初始化为空的双向循环链表。

DECLARE_WAIT_QUEUE_HEAD(my_queue); 定义并初始化,可以作为定义并初始化等待队列头的快捷方式。

 

2、定义等待队列:

DECLARE_WAITQUEUE(name,tsk);

 

 

定义并初始化一个名为name的等待队列。

 

3、(从等待队列头中)添加/移出等待队列:

 

 

/* add_wait_queue()函数,设置等待的进程为非互斥进程,并将其添加进等待队列头(q)的队头中*/
void add_wait_queue(wait_queue_head_t *q, wait_queue_t *wait);
/* 该函数也和add_wait_queue()函数功能基本一样,只不过它是将等待的进程(wait)设置为互斥进程。*/
void add_wait_queue_exclusive(wait_queue_head_t *q, wait_queue_t *wait);

 

4、等待事件:

(1)wait_event()宏:

 

/**
 * wait_event - sleep until a condition gets true
 * @wq: the waitqueue to wait on
 * @condition: a C expression for the event to wait for
 *
 * The process is put to sleep (TASK_UNINTERRUPTIBLE) until the
 * @condition evaluates to true. The @condition is checked each time
 * the waitqueue @wq is woken up.
 *
 * wake_up() has to be called after changing any variable that could
 * change the result of the wait condition.
 */

#define wait_event(wq, condition)                   \

do {                                    \
	if (condition)                          \
		break;                          \
	 __wait_event(wq, condition);                    \
} while (0)

    在等待会列中睡眠直到condition为真。在等待的期间,进程会被置为TASK_UNINTERRUPTIBLE进入睡眠,直到condition变量变为真。每次进程被唤醒的时候都会检查condition的值.

(2)wait_event_interruptible()函数:

   和wait_event()的区别是调用该宏在等待的过程中当前进程会被设置为TASK_INTERRUPTIBLE状态.在每次被唤醒的时候,首先检查condition是否为真,如果为真则返回,否则检查如果进程是被信号唤醒,会返回-ERESTARTSYS错误码.如果是condition为真,则返回0.

(3)wait_event_timeout()宏:

   也与wait_event()类似.不过如果所给的睡眠时间为负数则立即返回.如果在睡眠期间被唤醒,且condition为真则返回剩余的睡眠时间,否则继续睡眠直到到达或超过给定的睡眠时间,然后返回0
(4)wait_event_interruptible_timeout()宏:
   与wait_event_timeout()类似,不过如果在睡眠期间被信号打断则返回ERESTARTSYS错误码.
(5) wait_event_interruptible_exclusive()宏
   同样和wait_event_interruptible()一样,不过该睡眠的进程是一个互斥进程.

 

5、唤醒队列

(1)wake_up()函数

#define wake_up(x)          __wake_up(x, TASK_NORMAL, 1, NULL)
/**
 * __wake_up - wake up threads blocked on a waitqueue.
 * @q: the waitqueue
 * @mode: which threads
 * @nr_exclusive: how many wake-one or wake-many threads to wake up
 * @key: is directly passed to the wakeup function
 */
void __wake_up(wait_queue_head_t *q, unsigned int mode,
            int nr_exclusive, void *key)
{
    unsigned long flags;
 
    spin_lock_irqsave(&q->lock, flags);
    __wake_up_common(q, mode, nr_exclusive, 0, key);
    spin_unlock_irqrestore(&q->lock, flags);
}
EXPORT_SYMBOL(__wake_up);

唤醒等待队列.可唤醒处于TASK_INTERRUPTIBLE和TASK_UNINTERUPTIBLE状态的进程,和wait_event/wait_event_timeout成对使用.(2)wake_up_interruptible()函数:

 

 

 

 

#define wake_up_interruptible(x)    __wake_up(x, TASK_INTERRUPTIBLE, 1, NULL)

和wake_up()唯一的区别是它只能唤醒TASK_INTERRUPTIBLE状态的进程.,与wait_event_interruptible/wait_event_interruptible_timeout/ wait_event_interruptible_exclusive成对使用。

下面看一个实例:

static ssize_t hello_read(struct file *filep, char __user *buf, size_t len, loff_t *pos)
{
/*
    实现应用进程read的时候,如果没有数据就阻塞
*/
    if(len>64)
    {
        len =64;
    }
    wait_event_interruptible(wq, have_data == 1);

    if(copy_to_user(buf,temp,len))
    {
        return -EFAULT;
    }    
    have_data = 0;
    return len;
}
static ssize_t hello_write(struct file *filep, const char __user *buf, size_t len, loff_t *pos)
{
    if(len > 64)
    {
        len = 64;
    }

    if(copy_from_user(temp,buf,len))
    {
        return -EFAULT;
    }
    printk("write %s\n",temp);
    have_data = 1;
    wake_up_interruptible(&wq);
    return len;
}

注意两个概念:

a --  疯狂兽群

      wake_up的时候,所有阻塞在队列的进程都会被唤醒,但是因为condition的限制,只有一个进程得到资源,其他进程又会再次休眠,如果数量很大,称为 疯狂兽群

b -- 独占等待

      等待队列的入口设置一个WQ_FLAG_EXCLUSIVE标志,就会添加到等待队列的尾部,没有设置设置的添加到头部,wake up的时候遇到第一个具有WQ_FLAG_EXCLUSIVE这个标志的进程就停止唤醒其他进程。

 

 

二、非阻塞I/O实现方式 —— 多路复用

1、轮询的概念和作用

      在用户程序中,select() 和 poll() 也是设备阻塞和非阻塞访问息息相关的论题。使用非阻塞I/O的应用程序通常会使用select() 和 poll() 系统调用查询是否可对设备进行无阻塞的访问。select() 和 poll() 系统调用最终会引发设备驱动中的 poll()函数被执行。

 

2、应用程序中的轮询编程

      在用户程序中,select()和poll()本质上是一样的, 不同只是引入的方式不同,前者是在BSD UNIX中引入的,后者是在System V中引入的。用的比较广泛的是select系统调用。原型如下

 

 

 

 

int select(int numfds, fd_set *readfds, fd_set *writefds, fd_set *exceptionfds, struct timeval *timeout);

    其中readfs,writefds,exceptfds分别是select()监视的读,写和异常处理的文件描述符集合,numfds的值是需要检查的号码最高的文件描述符加1,timeout则是一个时间上限值,超过该值后,即使仍没有描述符准备好也会返回。

 

struct timeval
{
    int tv_sec;    //秒
    int tv_usec;   //微秒
}

 

涉及到文件描述符集合的操作主要有以下几种:

1)清除一个文件描述符集   FD_ZERO(fd_set *set);

2)将一个文件描述符加入文件描述符集中    FD_SET(int fd,fd_set *set);

3)将一个文件描述符从文件描述符集中清除  FD_CLR(int fd,fd_set *set);

4)判断文件描述符是否被置位    FD_ISSET(int fd,fd_set *set);

最后我们利用上面的文件描述符集的相关来写个验证添加了设备轮询的驱动,把上边两块联系起来

 

3、设备驱动中的轮询编程

       设备驱动中的poll() 函数原型如下

 

 

 

unsigned int(*poll)(struct file *filp, struct poll_table * wait);

第一个参数是file结构体指针,第二个参数是轮询表指针,poll设备方法完成两件事:

a -- 对可能引起设备文件状态变化的等待队列调用poll_wait()函数,将对应的等待队列头添加到poll_table,如果没有文件描述符可用来执行 I/O, 则内核使进程在传递到该系统调用的所有文件描述符对应的等待队列上等待。

b -- 返回表示是否能对设备进行无阻塞读、写访问的掩码。

位掩码:POLLRDNORM, POLLIN,POLLOUT,POLLWRNORM

设备可读,通常返回:(POLLIN | POLLRDNORM)

设备可写,通常返回:(POLLOUT | POLLWRNORM)

       

poll_wait()函数:用于向 poll_table注册等待队列

 

 

 

 

 void poll_wait(struct file *filp, wait_queue_head_t *queue,poll_table *wait)  

      poll_wait()函数不会引起阻塞,它所做的工作是把当前进程添加到wait 参数指定的等待列表(poll_table)中。

     真正的阻塞动作是上层的select/poll函数中完成的。select/poll会在一个循环中对每个需要监听的设备调用它们自己的poll支持函数以使得当前进程被加入各个设备的等待列表。若当前没有任何被监听的设备就绪,则内核进行调度(调用schedule)让出cpu进入阻塞状态,schedule返回时将再次循环检测是否有操作可以进行,如此反复;否则,若有任意一个设备就绪,select/poll都立即返回。

具体过程如下:

a -- 用户程序第一次调用select或者poll,驱动调用poll_wait并使两条队列都加入poll_table结构中作为下次调用驱动函数poll的条件,一个mask返回值指示设备是否可操作,0为未准备状态,如果文件描述符未准备好可读或可写,用户进程被会加入到写或读等待队列中进入睡眠状态。

b -- 当驱动执行了某些操作,例如,写缓冲或读缓冲,写缓冲使读队列被唤醒,读缓冲使写队列被唤醒,于是select或者poll系统调用在将要返回给用户进程时再次调用驱动函数poll,驱动依然调用poll_wait 并使两条队列都加入poll_table结构中,并判断可写或可读条件是否满足,如果mask返回POLLIN | POLLRDNORM或POLLOUT | POLLWRNORM则指示可读或可写,这时select或poll真正返回给用户进程,如果mask还是返回0,则系统调用select或poll继续不返回

     

下面是一个典型模板:

static unsigned int XXX_poll(struct file *filp, poll_table *wait)
{
    unsigned int mask = 0;
        struct XXX_dev *dev = filp->private_data;     //获得设备结构指针
    ...
    poll_wait(filp, &dev->r_wait, wait);    //加读等待对列头
    poll_wait(filp ,&dev->w_wait, wait);    //加写等待队列头
    
    if(...)//可读
    {
          mask |= POLLIN | POLLRDNORM;    //标识数据可获得
     }
    if(...)//可写
    {
          mask |= POLLOUT | POLLWRNORM;    //标识数据可写入
     }
    ..
    return mask;
}

 

 

4、调用过程:

Linux下select调用的过程:

1、用户层应用程序调用select(),底层调用poll())
2、核心层调用sys_select() ------> do_select()

  最终调用文件描述符fd对应的struct file类型变量的struct file_operations *f_op的poll函数。
  poll指向的函数返回当前可否读写的信息。
  1)如果当前可读写,返回读写信息。
  2)如果当前不可读写,则阻塞进程,并等待驱动程序唤醒,重新调用poll函数,或超时返回。

3、驱动需要实现poll函数
当驱动发现有数据可以读写时,通知核心层,核心层重新调用poll指向的函数查询信息。

poll_wait(filp,&wait_q,wait) // 此处将当前进程加入到等待队列中,但并不阻塞

在中断中使用wake_up_interruptible(&wait_q)唤醒等待队列。

4、实例分析

 

1、memdev.h

 

/*mem设备描述结构体*/
struct mem_dev                                     
{                                                        
  char *data;                      
  unsigned long size; 
  wait_queue_head_t inq;  
};

#endif /* _MEMDEV_H_ */

2、驱动程序 memdev.c

#include <linux/module.h>
#include <linux/types.h>
#include <linux/fs.h>
#include <linux/errno.h>
#include <linux/mm.h>
#include <linux/sched.h>
#include <linux/init.h>
#include <linux/cdev.h>
#include <asm/io.h>
#include <asm/system.h>
#include <asm/uaccess.h>

#include <linux/poll.h>
#include "memdev.h"

static mem_major = MEMDEV_MAJOR;
bool have_data = false; /*表明设备有足够数据可供读*/

module_param(mem_major, int, S_IRUGO);

struct mem_dev *mem_devp; /*设备结构体指针*/

struct cdev cdev; 

/*文件打开函数*/
int mem_open(struct inode *inode, struct file *filp)
{
    struct mem_dev *dev;
    
    /*获取次设备号*/
    int num = MINOR(inode->i_rdev);

    if (num >= MEMDEV_NR_DEVS) 
            return -ENODEV;
    dev = &mem_devp[num];
    
    /*将设备描述结构指针赋值给文件私有数据指针*/
    filp->private_data = dev;
    
    return 0; 
}

/*文件释放函数*/
int mem_release(struct inode *inode, struct file *filp)
{
  return 0;
}

/*读函数*/
static ssize_t mem_read(struct file *filp, char __user *buf, size_t size, loff_t *ppos)
{
  unsigned long p =  *ppos;
  unsigned int count = size;
  int ret = 0;
  struct mem_dev *dev = filp->private_data; /*获得设备结构体指针*/

  /*判断读位置是否有效*/
  if (p >= MEMDEV_SIZE)
    return 0;
  if (count > MEMDEV_SIZE - p)
    count = MEMDEV_SIZE - p;
    
  while (!have_data) /* 没有数据可读,考虑为什么不用if,而用while */
  {
        if (filp->f_flags & O_NONBLOCK)
            return -EAGAIN;
    
    wait_event_interruptible(dev->inq,have_data);
  }

  /*读数据到用户空间*/
  if (copy_to_user(buf, (void*)(dev->data + p), count))
  {
    ret =  - EFAULT;
  }
  else
  {
    *ppos += count;
    ret = count;
   
    printk(KERN_INFO "read %d bytes(s) from %d\n", count, p);
  }
  
  have_data = false; /* 表明不再有数据可读 */
  /* 唤醒写进程 */
  return ret;
}

/*写函数*/
static ssize_t mem_write(struct file *filp, const char __user *buf, size_t size, loff_t *ppos)
{
  unsigned long p =  *ppos;
  unsigned int count = size;
  int ret = 0;
  struct mem_dev *dev = filp->private_data; /*获得设备结构体指针*/
  
  /*分析和获取有效的写长度*/
  if (p >= MEMDEV_SIZE)
    return 0;
  if (count > MEMDEV_SIZE - p)
    count = MEMDEV_SIZE - p;

  /*从用户空间写入数据*/
  if (copy_from_user(dev->data + p, buf, count))
    ret =  - EFAULT;
  else
  {
    *ppos += count;
    ret = count;
    
    printk(KERN_INFO "written %d bytes(s) from %d\n", count, p);
  }
  
  have_data = true; /* 有新的数据可读 */
    
    /* 唤醒读进程 */
    wake_up(&(dev->inq));

  return ret;
}

/* seek文件定位函数 */
static loff_t mem_llseek(struct file *filp, loff_t offset, int whence)
{ 
    loff_t newpos;

    switch(whence) {
      case 0: /* SEEK_SET */
        newpos = offset;
        break;

      case 1: /* SEEK_CUR */
        newpos = filp->f_pos + offset;
        break;

      case 2: /* SEEK_END */
        newpos = MEMDEV_SIZE -1 + offset;
        break;

      default: /* can't happen */
        return -EINVAL;
    }
    if ((newpos<0) || (newpos>MEMDEV_SIZE))
        return -EINVAL;
        
    filp->f_pos = newpos;
    return newpos;

}
unsigned int mem_poll(struct file *filp, poll_table *wait)
{
    struct mem_dev  *dev = filp->private_data; 
    unsigned int mask = 0;
    
   /*将等待队列添加到poll_table */
    poll_wait(filp, &dev->inq,  wait);
 
    
    if (have_data)         mask |= POLLIN | POLLRDNORM;  /* readable */

    return mask;
}


/*文件操作结构体*/
static const struct file_operations mem_fops =
{
  .owner = THIS_MODULE,
  .llseek = mem_llseek,
  .read = mem_read,
  .write = mem_write,
  .open = mem_open,
  .release = mem_release,
  .poll = mem_poll,
};

/*设备驱动模块加载函数*/
static int memdev_init(void)
{
  int result;
  int i;

  dev_t devno = MKDEV(mem_major, 0);

  /* 静态申请设备号*/
  if (mem_major)
    result = register_chrdev_region(devno, 2, "memdev");
  else  /* 动态分配设备号 */
  {
    result = alloc_chrdev_region(&devno, 0, 2, "memdev");
    mem_major = MAJOR(devno);
  }  
  
  if (result < 0)
    return result;

  /*初始化cdev结构*/
  cdev_init(&cdev, &mem_fops);
  cdev.owner = THIS_MODULE;
  cdev.ops = &mem_fops;
  
  /* 注册字符设备 */
  cdev_add(&cdev, MKDEV(mem_major, 0), MEMDEV_NR_DEVS);
   
  /* 为设备描述结构分配内存*/
  mem_devp = kmalloc(MEMDEV_NR_DEVS * sizeof(struct mem_dev), GFP_KERNEL);
  if (!mem_devp)    /*申请失败*/
  {
    result =  - ENOMEM;
    goto fail_malloc;
  }
  memset(mem_devp, 0, sizeof(struct mem_dev));
  
  /*为设备分配内存*/
  for (i=0; i < MEMDEV_NR_DEVS; i++) 
  {
        mem_devp[i].size = MEMDEV_SIZE;
        mem_devp[i].data = kmalloc(MEMDEV_SIZE, GFP_KERNEL);
        memset(mem_devp[i].data, 0, MEMDEV_SIZE);
  
      /*初始化等待队列*/
     init_waitqueue_head(&(mem_devp[i].inq));
     //init_waitqueue_head(&(mem_devp[i].outq));
  }
   
  return 0;

  fail_malloc: 
  unregister_chrdev_region(devno, 1);
  
  return result;
}

/*模块卸载函数*/
static void memdev_exit(void)
{
  cdev_del(&cdev);   /*注销设备*/
  kfree(mem_devp);     /*释放设备结构体内存*/
  unregister_chrdev_region(MKDEV(mem_major, 0), 2); /*释放设备号*/
}

MODULE_AUTHOR("David Xie");
MODULE_LICENSE("GPL");

module_init(memdev_init);
module_exit(memdev_exit);

3、应用程序 app-write.c

#include <stdio.h>

int main()
{
    FILE *fp = NULL;
    char Buf[128];
    
    
    /*打开设备文件*/
    fp = fopen("/dev/memdev0","r+");
    if (fp == NULL)
    {
        printf("Open Dev memdev Error!\n");
        return -1;
    }
    
    /*写入设备*/
    strcpy(Buf,"memdev is char dev!");
    printf("Write BUF: %s\n",Buf);
    fwrite(Buf, sizeof(Buf), 1, fp);
    
    sleep(5);
    fclose(fp);
    
    return 0;    

}

 

4、应用程序 app-read.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/select.h>
#include <sys/time.h>
#include <errno.h>

int main()
{
    int fd;
    fd_set rds;
    int ret;
    char Buf[128];
    
    /*初始化Buf*/
    strcpy(Buf,"memdev is char dev!");
    printf("BUF: %s\n",Buf);
    
    /*打开设备文件*/
    fd = open("/dev/memdev0",O_RDWR);
    
    FD_ZERO(&rds);
    FD_SET(fd, &rds);

    /*清除Buf*/
    strcpy(Buf,"Buf is NULL!");
    printf("Read BUF1: %s\n",Buf);

    ret = select(fd + 1, &rds, NULL, NULL, NULL);
    if (ret < 0) 
    {
        printf("select error!\n");
        exit(1);
    }
    if (FD_ISSET(fd, &rds)) 
        read(fd, Buf, sizeof(Buf));            
    
    /*检测结果*/
    printf("Read BUF2: %s\n",Buf);
    
    close(fd);
    
    return 0;    
}
posted @ 2016-09-21 12:36  请给我倒杯茶  阅读(1708)  评论(0编辑  收藏  举报