Linux设备驱动程序学习(五)——高级字符设备驱动程序

本文详细解析了Linux设备驱动程序中的llseek和ioctl方法,介绍了定位设备和控制I/O通道的实现原理,同时探讨了阻塞与非阻塞I/O的运作机制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

  在前面我们学习了字符设备驱动程序的实现,但是不知道你们有没有注意到,在前面,我们只学习了open、close、read和write的方法实现,而对于iotctl和llseek并没有讲解,而这一部分,将讲解这两个方法的实现以及阻塞和非阻塞型I/O的一些相关知识。

定位设备

  定位设备的方法:llseek
  llseek 方法实现了 lseek 和 llseek 系统调用,主要用来确定读写数据的位置。
  如果设备操作没有定义llseek 方法的话,内核默认通过修改 filp->f_pos而执行定位, file->f_pos是文件中的当前读写位置. 请注意对于 lseek 系统调用要正确工作, 读和写方法必须配合通过更新它们收到的offset 偏移量来配合。
  例子:

loff_t hello_llseek(struct file *filp, loff_t off, int whence) 
{ 
 struct scull_dev *dev = filp->private_data; 
 loff_t newpos; 
 switch(whence) 
 { 
 case FILE_BEGIN:                                      /*定位到文件头进行指针操作*/ 
   newpos = off; 
   break; 
 case FILE_CURRENT:                                   /* 定位到文件当前位置进行文件操作*/ 
   newpos = filp->f_pos + off; 
   break; 
 case FILE_END:                                         /*定位到文件尾进行文件操作*/ 
   newpos = dev->size + off; 
   break; 
 default:                                         
   return -EINVAL; 
 } 
 if (newpos < 0) 
   return -EINVAL; 
 filp->f_pos = newpos; 
 return newpos; 
}

  如果设备是不允许移位的,你不能只制止声明 llseek 操作,因为缺省的方法允许移位。应当在你的 open 方法中,通过调用 nonseekable_open 通知内核你的设备不支持 llseek :
int nonseekable_open(struct inode *inode; struct file *filp);
  上述调用会把你的filp标记为不可定位,这样,内核就不会让这种文件上的lseek调用成功。通过这种方式标记文件,我们还可以确保通过pread和pwrite系统调用也不能定位文件。
  为了完整起见,我们还应该将file_operations结构中的llseek方法设置为特殊的辅助函数no_llseek,该函数定义在<linux/fs.h>中。

ioctl系统调用

  除了读取和写入设备之外,大部分的驱动程序都需要通过设备驱动程序来执行各种类型的硬件控制。ioctl是设备驱动程序中对设备的I/O通道进行管理的函数。所谓对I/O通道进行管理,就是对设备的一些特性进行控制,例如串口的传输波特率、马达的转速等等。

ioctl实现的必要性

   ioctl主要用来来实现控制的功能。用户程序所作的只是通过命令码(cmd)告诉驱动程序它想做什么,至于怎么解释这些命令和怎么实现这些命令,这都是驱动程序要做的事情,而ioctl就是负责接收cmd命令码来实现这些命令,它保证了程序的有序和整洁。
   ioctl的实现一般是通过一个大的switch语句,根据cmd参数执行不同的操作。

ioctl函数用法解析

  用户空间的函数原型
  int ioctl(int fd , unsigned long cmd,...);

  • fd: 文件描述符
  • cmd: cmd是预先定义好的一些命令编号,对应要求ioctl执行的命令。
  • “…”: 可选参数:插入*argp,具体内容依赖于cmd

  驱动程序的函数原型
  int (*ioctl) (struct inode *inode,struct file *filp,unsigned int cmd,unsigned long arg);

  • inode与filp两个指针对应于应用程序传递的文件描述符fd,这和传递open方法的参数一样。
  • cmd :由用户空间直接不经修改的传递给驱动程序
  • arg : arg是与cmd配合使用的参数。

cmd命令的格式

  四个位字段:type,number,direction,size

  • type: 幻数(magic number),它占8位。个人理解幻数就是一个标志,代表一个(类)对象。
  • number:序数,即顺序编号,它也占8位。
  • direction:如果相关命令涉及到数据的传输,则这个位段表示数据传输的方向,可用的值包括_IOC_NONE(没有数据传输),_IOC_READ(读)、_IOC_WRITE(写)、_IOC_READ | _IOC_WRITE(双向传输数据)。
  • size:所涉及的用户数据大小。这个位段的宽度与体系结构有关,通常是13或14位。

  在函数中定义cmd命令编号:

<linux/ioctl.h>中包含的<asm/ioctl.h>头文件定义了一些构造命令编号的宏:
_IO(type, nr),用于构造无数据传输的命令编号。
_IOR(type, nr, datatype),用于构造从驱动程序中读取数据的命令编号。
_IOW(type, nr, datatype),用于构造向设备写入数据的命令编号。
_IOWR(type, nr, datatype),用于双向传输命令编号。
其中,type和number位段从以上宏的参数中传入,size位段通过对datatype参数取sizeof获得。

  另外,<asm/ioctl.h>头文件中还定义了一些用于解析命令编号的宏:

_IOC_TYPE(cmd):获取cmd的幻数
_IOC_NR(cmd):获取cmd的序数
_IOC_DIR(cmd):获取cmd的数据传输方向
_IOC_SIZE(cmd):获取cmd的用户数据大小
从任何一个系统调用返回时,返回值为正都是受保护的,而负值则被认为是一个错误,并被用来设置用户空间中的errno变量。对非法的ioctl命令一般会返回-EINVAL。

预定义命令

  在使用ioctl命令编号时,一定要避免与预定义命令重复,否则,命令冲突,设备不会响应
  下列ioctl命令对任何文件(包括设备特定文件)都是预定义的:

FIOCTLX  设置执行时关闭标志
FIONCLEX  清除执行时关闭标志
FIOASYNC  设置或复位文件异步通知
FIOQSIZE  返回文件或目录大小
FIONBIO  文件非阻塞型IO,file ioctl non-blocking i/o

  使用ioctl的附加参数:arg

  • arg是个整数,那简单,直接用
  • arg是个指针,麻烦点,需检测后才能用

  使用指针,首先得保证指针指向的地址合法。因此,在使用这个指针之前,我们应该使用<asm/uaccess.h>中声明函数:

 int  access_ok(int type,const void *addr,unsigend long size);  //返回值为1(成功)或0(失败),如果返回失败,驱动程序通常返回-EFAULT给调用者来验证地址的合法性。
  • type: VERIFY_READ 或是 VERIFY_WRITE,取决于是读取还是写入用户空间内存区。
  • addr: 一个用户空间的地址
  • size: 如果要读取或写入一个int型数据,则为sizeof(int)

如果在该地址处既要读取,又要写入,则应该用:VERIFY_WRITE,因为它是VERIFY_READ的超集。

  例子:

/*方向是一个位掩码,而VERIFY_WRITE用于R/W*传输。“类型”是针对用户空间而言,而access_ok面向内核,因此,读取和写入,恰好相反*/
int err = 0;
if(_IOC_DIR(cmd) & _IOC_READ)
    err=!access_ok(VERIFY_WRITE,(void __user *)arg,_IOC_SIZE(cmd));
else   if(_IOC_DIR(cmd) & _IOC_WRITE)
    err=!access_ok(VERIFY_READ,(void __user *)arg,_IOC_SIZE(cmd));
if(err) return -EFAULT;

  注意:首先, access_ok不做校验内存存取的完整工作; 它只检查内存引用是否在这个进程有合理权限的内存范围中,且确保这个地址不指向内核空间内存。其次,大部分驱动代码不需要真正调用 access_ok,而直接使用put_user(datum, ptr)和get_user(local, ptr),它们带有校验的功能,确保进程能够写入给定的内存地址,成功时返回 0, 并且在错误时返回 -EFAULT。

put_user(datum,ptr);
__put_user(datum,ptr);

  使用时,速度快,不做类型检查,使用时可以给ptr传递任意类型的指针参数,只要是个用户空间的地址就行,传递的数据大小依赖于ptr参数的类型。

  put_user vs __put_user:使用前做的检查,put_user多些,__put_user少些,
  一般用法:实现一个读取方法时,可以调用__put_user来节省几个时钟周期,或者在复制多项数据之前调用一次access_ok,像上面代码一样。
  get_user(datum.ptr);
  __get_user(datum,ptr);
  接收的数据被保存在局部变量local中,返回值说明其是否正确。同样,__get_user应该在操作地址被access_ok后使用。

权限控制

  驱动程序必须进行附加的检查以确认用户是否有权进行请求的操作,基于权能的系统抛弃了那种要么全有,要么全无的特权分配方式,而是把特权操作划分成了独立的组
  使用方法:

<linux/capability.h>
//调用int capable(int capability);
//在执行一项特权之前,应先检查其是否具有这个权利
if (! capable (CAP_SYS_ADMIN))
 return -EPERM;
权限参数:
CAP_DAC_OVERRIDE/*越过在文件和目录上的访问限制(数据访问控制或 DAC)的能力。*/
CAP_NET_ADMIN /*进行网络管理任务的能力, 包括那些能够影响网络接口的任务*/
CAP_SYS_MODULE /*加载或去除内核模块的能力*/
CAP_SYS_RAWIO /*进行 "raw"(裸)I/O 操作的能力. 例子包括存取设备端口或者直接和 USB 设备通讯*/
CAP_SYS_ADMIN /*截获的能力, 提供对许多系统管理操作的途径*/
CAP_SYS_TTY_CONFIG /*执行 tty 配置任务的能力*/

  使用例子:

switch(cmd) {       
  case SCULL_IOCSQUANTUM:    
     if (! capable (CAP_SYS_ADMIN))          //判断有无权限,没有退出
     return -EPERM;
     retval = __get_user(scull_quantum, (int __user *)arg);//_get_user
     break;
}

阻塞与非阻塞I/O

  阻塞操作是指在执行设备操作时,若不能获得资源,则挂起进程直到满足可操作的条件后再进行操作。被挂起的进程进入睡眠状态,被从调度器的运行队列移走,直到等待的条件被满足。而非阻塞操作的进程在不能进行设备操作时,并不挂起,它要么放弃,要么不停地查询,直至可以进行操作为止。
  驱动程序通常需要提供这样的能力:

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

  在阻塞访问时,不能获取资源的进程将进入休眠,它将CPU资源“礼让”给其他进程。因为阻塞的进程会进入休眠状态,所以必须确保有一个地方能够唤醒休眠的进程,否则,进程就真的“寿终正寝”了。唤醒进程的地方最大可能发生在中断里面,因为在硬件资源获得的同时往往伴随着一个中断。而非阻塞的进程则不断尝试,直到可以进行I/O。
   除了在打开文件时可以指定阻塞还是非阻塞方式以外,在文件打开后,也可以通过ioctl()和 fcntl()改变读写的方式,如从阻塞变更为非阻塞或者从非阻塞变更为阻塞。

阻塞和非阻塞操作

  操作系统中睡眠、阻塞和挂起的区别(都是对于线程的操作):

  • 挂起线程:就是你主动叫线程休息,需要它的时候再主动去调用。
  • 线程睡眠:就是让线程去休息,通过唤醒标志在某一时刻唤醒过来。
  • 线程阻塞:线程因为缺少它需要的一些必要条件而导致无法进行下去,必须给它提供好这些条件,它才能主动调用。

  全功能的 read 和 write 方法涉及到进程可以决定是进行非阻塞 I/O还是阻塞 I/O操作。明确的非阻塞 I/O 由 filp->f_flags 中的 O_NONBLOCK 标志来指示(定义在<linux/fcntl.h> ,被<linux/fs.h>自动包含)。

  其实不一定只有read 和 write 方法有阻塞操作,open也可以有阻塞操作。
  如果指定了O_NONBLOCK标志,read和write的行为就会有所不同。如果在数据没有就绪时调用read或是在缓冲区没有空间时调用write,则该调用简单的返回-EAGAIN。
  非阻塞型操作会立即返回,使得应用程序可以查询数据。在处理非阻塞型文件时,应用程序调用stdio函数必须非常小心,因为很容易就把一个非阻塞返回误认为EOF,所以必须始终检查errno。
  只有read,write,open文件操作受非阻塞标志的影响,例子:

static ssize_t scull_p_read (struct file *filp,char __user *buf,size_t count,loff_t *f_pos)
{
struct scull_pipe *dev = filp->private_data;
if(down_interruptible(&dev))
     return -ERRESTARTSYS;
while(dev->rp == dev->wp){     /*无数据可读*/
up(&dev->sem);         /*释放锁*/
if(filp->f_flags & O_NONBLOCK)
         return -EAGAIN;
PDEBUG("\“ %s ”\  reading: going to sleep\n",current->comm);
if(wait_event_interruptible(dev-inq,(dev->rp!=dev-wp)))
        return -ERESTARTSYS;
}
/*数据已就绪,返回*/
if(dev->wp > dev->rp)
    count=min(count,(size_t) (dev->wp - dev->rp));
else  /*写入指针回卷,返回数据直到dev->end*/
    count = min(count,(size_t)(dev->end - dev->rp))
if(copy_to_user(buf,dev->rp,count)){
up(&dev->sem);
return -EFAULT;
}
dev->rp += count;
if(dev->rp == dev->end)
    dev->rp = dev->buffer;    /*回卷*/
up(&dev->sem);
/*最后,唤醒所有写入者并返回*/
wake_up_interruptible(&dev->outq);
PDEBUG("\%s\" did read %li bytes \n ",current->comm,(long)count");
return count;
}

  while循环在拥有设备信号量时测试缓冲区。如果其中有数据,则可以立即将数据返回给用户而不需要休眠,这样,整个循环体就被跳过了。相反,如果缓冲区为空,则必须休眠。但在休眠之前必须释放设备信号量,因为如果在拥有该信号量时休眠,任何写入者都没有机会来唤醒。在释放信号量之后,快速检测用户请求的是否是非阻塞I/O,如果是,则返回,否则调用wait_event_interruptible。

阻塞型I/O

  前面实现了驱动程序的read和write方法,那么在read和write方法中,如果设备的数据不可用时,那进行read操作就会产生问题,同样如果设备的输出缓冲区已经满了的话,那么执行write也会导致错误,所以在这种情况下,我们就需要让驱动程序阻塞该进程,将其置入休眠状态,直到后续请求可以回应才继续。
  休眠:当一个进程被置入休眠时,它会被标记为一种特殊状态并从调度器运行队列中移走,直到某些情况下修改了这个状态,才能运行该进程。
  安全进入休眠两原则

  • 永远不要在原子上下文中进入休眠。(原子上下文:在执行多个步骤时,不能有任何的并发访问。这意味着,驱动程序不能在拥有自旋锁,seqlock,或是RCU锁时休眠,即使禁止了中断也不行)。在拥有信号量时休眠是合法的,但是必须检查拥有信号量时休眠的代码,如果代码在拥有信号量时休眠,那么任何其它等待信号量的线程也会休眠,因此任何拥有信号量而休眠的代码必须很短,还要确保同游信号量不会阻塞最终会唤醒我们的那个进程。
  • 对唤醒之后的状态不能做任何假定,因此必须检查以确保我们等待的条件真正为真。
临界区和原子上下文
  • 原子上下文:一般说来,具体指在中断,软中断,或是拥有自旋锁的时候。
  • 临界区:每次只允许一个进程进入临界区,进入后不允许其它进程访问。

  要休眠进程,必须有一个前提:有人能唤醒进程,而起这个人必须知道在哪儿能唤醒进程,这里,就引入了“等待队列”这个概念。
  等待队列:就是一个进程链表(是一个休眠进程链表),其中包含了等待某个特定事件的所有进程。
  等待队列头:wait_queue_head_t,定义在<linux/wait.h>

  定义方法:

静态   DECLARE_QUEUE_HEAD(name)
动态   wait_queue_head_t  my_queue;  //结构体
       init_waitqueue_head(&my_queue);  //方法

  队列头具体结构:

struct __wait_queue_head {
       spinlock_t lock;
       struct list_head task_list;
};
typedef struct __wait_queue_head wait_queue_head_t;   
简单休眠

  linux最简单的休眠方式是wait_event(queue,condition)及其变种,在实现休眠的同时,它也检查进程等待的条件。四种wait_event形式如下:

wait_event(queue,condition);                /*不可中断休眠,不推荐*/
wait_event_interruptible(queue,condition);     /*推荐,返回非零值意味着休眠被中断,且驱动应返回-ERESTARTSYS*/
wait_event_timeout(queue,condition,timeout);  /*有限的时间的休眠,若超时,则不管条件为何值返回0*/
wait_event_interruptible_timeout(queue,conditon,timeout);   /*类似上面,不过可中断*/

  唤醒休眠进程的函数:wake_up

void wake_up(wait_queue_head_t  *queue);
void wake_up_interruptible(wait_queue_head  *queue);

  注意:一般用wake_up唤醒wait_event,用wake_up_interruptible唤醒wait_event_interruptible

高级休眠

  进程休眠步骤:

  1. 分配并初始化一个wait_queue_t结构,然后将其加入到对应的等待队列
  2. 设置进程的状态,将其标记为休眠在 <linux/sched.h> 中定义有几个任务状态:TASK_RUNNING意思是进程可运行。有 2 个状态指示一个进程是在睡眠:TASK_INTERRUPTIBLE 和 TASK_UNTINTERRUPTIBLE。
  3. 最后一步:释放处理器。但之前我们必须首先检查休眠等待的条件。如果不做这个检查,可能会引入竞态:如果在忙于上面的这个过程时有其他的线程刚刚试图唤醒你,你可能错过唤醒且长时间休眠。因此典型的代码下:
    if (!condition)
        schedule();

  如果代码只是从 schedule 返回,则进程处于TASK_RUNNING 状态。 如果不需睡眠而跳过对 schedule 的调用,必须将任务状态重置为 TASK_RUNNING,还必要从等待队列中去除这个进程,否则它可能被多次唤醒。

手工休眠

  在早期的Linux内核版本中,特殊休眠需要程序员手工处理休眠的步骤,这个过程很冗长且乏味,但是如果想要进行手工休眠,现在也有比较简单的步骤:

① 建立并初始化一个等待队列入口
  方法1
DEFINE_WAIT(my_wait);
  方法2

wait_queue_t my_wait;
init_wait(&my_wait);

② 将我们的等待队列入口添加到队列中,并设置进程的状态
void prepare_to_wait(wait_queue_head_t *queue,wait_queue_t *wait,int state);
  queue和wait分别是等待队列头和进程入口,state是进程的新状态,它应该是TASK_INTERRUPTIBLE(可中断休眠)或者TASK_UNINTERRUPTIBLE(不可中断休眠)
③ 在调用prepaer_to_wait之后,进程即可调用schedule,当然在这之前,应确保仍有必有等待。一旦schedule返回,就到了清理时间了
void finesh_wait(wait_queue_head_t *queue,wait_queue_t *wait);

独占等待

  当一个进程调用 wake_up 在等待队列上,所有的在这个队列上等待的进程被置为可运行的。 这在许多情况下是正确的做法。但有时,可能只有一个被唤醒的进程将成功获得需要的资源,而其余的将再次休眠。这时如果等待队列中的进程数目大,这可能严重降低系统性能。为此,内核开发者增加了一个“独占等待”选项。它与一个正常的睡眠有 2 个重要的不同:

  • 当等待队列入口设置了 WQ_FLAG_EXCLUSEVE 标志,它被添加到等待队列的尾部;否则,添加到头部。
  • 当 wake_up 被在一个等待队列上调用, 它在唤醒第一个有 WQ_FLAG_EXCLUSIVE 标志的进程后停止唤醒.但内核仍然每次唤醒所有的非独占等待。

采用独占等待要满足 2 个条件:

  • 希望对资源进行有效竞争;
  • 当资源可用时,唤醒一个进程就足够来完全消耗资源。

  使一个进程进入独占等待,可调用:
void prepare_to_wait_exclusive(wait_queue_head_t *queue, wait_queue_t *wait, int state);
   注意:无法使用 wait_event 和它的变体来进行独占等待。

poll和select

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

  当poll函数返回时,会给出一个文件是否可读写的标志,应用程序根据不同的标识读写相应的文件,实现非阻塞的读写。这些系统调用功能相同:允许进程来决定它是否可读或写一个或多个文件而不阻塞。这些调用也可阻塞进程直到任何一个给定集合的文件描述符可用来读或写。这些调用都需要来自设备驱动中的poll方法的支持。poll返回不同的标识,告诉主进程文件是否可以读写,其原型:

   <linux/poll.h>
   unsigned int (*poll) (struct file *filp,poll_table *wait);

实现poll和select

  实现这个设备方法分两步:

  1. 在一个或多个可指示查询状态变化的等待队列上调用poll_wait,如果没有文件描述符可用来执行I/O,内核使这个进程在等待队列上等待所有的传递给系统调用的文件描述符,驱动通过调用函数poll_wait增加一个队列到poll_wait结构,原型:
    void poll_wait(struct file *,wait_queue_head_t *,poll_table*);

  2. 返回一个位掩码:描述可能不必阻塞就立刻进行的操作,几个标志(通过<linux/poll.h>定义)用来指示可能的操作:

    POLLIN        //如果设备无阻塞的读,就返回该值
    POLLRDNORM    //通常的数据已经准备好,可以读了,就返回该值。通常的做法是会返回(POLLLIN|POLLRDNORA)
    POLLRDBAND    //如果可以从设备读出带外数据,就返回该值,它只可在linux内核的某些网络代码中使用,通常不用在设备驱动程序中
    POLLPRI       //如果可以无阻塞的读取高优先级(带外)数据,就返回该值,返回该值会导致select报告文件发生异常,以为select八带外数据当作异常处理
    POLLHUP       //当读设备的进程到达文件尾时,驱动程序必须返回该值,依照select的功能描述,调用select的进程被告知进程时可读的。
    POLLERR       //如果设备发生错误,就返回该值。
    POLLOUT       //如果设备可以无阻塞地些,就返回该值
    POLLWRNORM    //设备已经准备好,可以写了,就返回该值。通常地做法是(POLLOUT|POLLNORM)
    POLLWRBAND    //于POLLRDBAND类似

与read与write的交互

  正确实现poll调用的规则:

  • 从设备读取数据
    ① 如果在输入缓冲中有数据,read 调用应当立刻返回,即便数据少于应用程序要求的,并确保其他的数据会很快到达。 如果方便,可一直返回小于请求的数据,但至少返回一个字节。在这个情况下,poll 应当返回 POLLIN|POLLRDNORM。
    ② 如果在输入缓冲中无数据,read默认必须阻塞直到有一个字节。若O_NONBLOCK 被置位,read 立刻返回 -EAGIN 。在这个情况下,poll 必须报告这个设备是不可读(清零POLLIN|POLLRDNORM)的直到至少一个字节到达。
    ③ 若处于文件尾,不管是否阻塞,read 应当立刻返回0,且poll 应该返回POLLHUP。
  • 向设备写数据
    ① 若输出缓冲有空间,write 应立即返回。它可接受小于调用所请求的数据,但至少必须接受一个字节。在这个情况下,poll应返回 POLLOUT|POLLWRNORM。
    ② 若输出缓冲是满的,write默认阻塞直到一些空间被释放。若 O_NOBLOCK 被设置,write 立刻返回一个 -EAGAIN。在这些情况下, poll 应当报告文件是不可写的(清零POLLOUT|POLLWRNORM). 若设备不能接受任何多余数据, 不管是否设置了 O_NONBLOCK,write 应返回 -ENOSPC(“设备上没有空间”)。
    ③ 永远不要让write在返回前等待数据的传输结束,即使O_NONBLOCK 被清除。若程序想保证它加入到输出缓冲中的数据被真正传送, 驱动必须提供一个 fsync 方法。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值