宋宝华谈 ARM 的嵌入式 Linux 移植体验之四:设备驱动

宋宝华谈 ARM 的嵌入式 Linux 移植体验之四:设备驱动

设备驱动程序是操作系统内核和机器硬件之间的接口,它为应用程序屏蔽硬件的细节,一般来说,Linux 的设备驱动程序需要完成如下功能:
·设备初始化、释放;
·提供各类设备服务;
·负责内核和设备之间的数据交换;
·检测和处理设备工作过程中出现的错误。
Linux 下的设备驱动程序被组织为一组完成不同任务的函数的集合,通过这些函数使得 Windows 的设备操作犹如文件一般。在应用程序看来,硬件设备只是一个设备文件,应用程序可以象操作普通文件一样对硬件设备进行操作,如open ()、close ()、read ()、write () 等。
Linux 主要将设备分为二类:字符设备和块设备。字符设备是指设备发送和接收数据以字符的形式进行;而块设备则以整个数据缓冲区的形式进行。在对字符设备发出读/写请求时,实际的硬件 I/O 一般就紧接着发生了;而块设备则不然,它利用一块系统内存作缓冲区,当用户进程对设备请求能满足用户的要求,就返回请求的数据,如果不能,就调用请求函数来进行实际的 I/O 操作。块设备主要针对磁盘等慢速设备。
1.内存分配
由于 Linux 驱动程序在内核中运行,因此在设备驱动程序需要申请/释放内存时,不能使用用户级的 malloc/free 函数,而需由内核级的函数kmalloc/kfree() 来实现,kmalloc() 函数的原型为:

void kmalloc (size_t size ,int priority);
参数 size 为申请分配内存的字节数,kmalloc 最多只能开辟 128k 的内存;参数priority 说明若 kmalloc() 不能马上分配内存时用户进程要采用的动作:GFP_KERNEL 表示等待,即等 kmalloc() 函数将一些内存安排到交换区来满足你的内存需要,GFP_ATOMIC 表示不等待,如不能立即分配到内存则返回 0 值;函数的返回值指向已分配内存的起始地址,出错时,返回 0。
kmalloc() 分配的内存需用 kfree() 函数来释放,kfree() 被定义为:

# define kfree (n) kfree_s( (n) ,0)
其中 kfree_s () 函数原型为:

void kfree_s (void * ptr ,int size);
参数 ptr 为 kmalloc() 返回的已分配内存的指针,size 是要释放内存的字节数,若为 0 时,由内核自动确定内存的大小。
2.中断
许多设备涉及到中断操作,因此,在这样的设备的驱动程序中需要对硬件产生的中断请求提供中断服务程序。与注册基本入口点一样,驱动程序也要请求内核将特定的中断请求和中断服务程序联系在一起。在 Linux 中,用request_irq() 函数来实现请求:

int request_irq (unsigned int irq ,void( * handler) int ,unsigned long type ,char * name);
参数 irq 为要中断请求号,参数 handler 为指向中断服务程序的指针,参数type 用来确定是正常中断还是快速中断(正常中断指中断服务子程序返回后,内核可以执行调度程序来确定将运行哪一个进程;而快速中断是指中断服务子程序返回后,立即执行被中断程序,正常中断 type 取值为 0 ,快速中断type 取值为 SA_INTERRUPT),参数 name 是设备驱动程序的名称。
3.字符设备驱动
我们必须为字符设备提供一个初始化函数,该函数用来完成对所控设备的初始化工作,并调用 register_chrdev() 函数注册字符设备。假设有一字符设备"exampledev",则其 init 函数为:

void exampledev_init(void) { if (register_chrdev(MAJOR_NUM, " exampledev ", &exampledev_fops)) TRACE_TXT("Device exampledev driver registered error"); else TRACE_TXT("Device exampledev driver registered successfully"); …//设备初始化 }
其中,register_chrdev 函数中的参数 MAJOR_NUM 为主设备号,"exampledev"为设备名,exampledev_fops 为包含基本函数入口点的结构体,类型为file_operations。当执行 exampledev_init 时,它将调用内核函数register_chrdev,把驱动程序的基本入口点指针存放在内核的字符设备地址表中,在用户进程对该设备执行系统调用时提供入口地址。
较早版本内核的 file_operations 结构体定义为(代码及图示):

struct file_operations {  int (*lseek)();  int (*read)();  int (*write)();  int (*readdir)();  int (*select)();  int (*ioctl)();  int (*mmap)();  int (*open)();  void(*release)();  int (*fsync)();  int (*fasync)();  int (*check_media_change)();  void(*revalidate)(); };

较早版本内核的file_operations结构体
随着内核功能的加强,file_operations 结构体也变得更加庞大。但是大多数的驱动程序只是利用了其中的一部分,对于驱动程序中无需提供的功能,只需要把相应位置的值设为 NULL。对于字符设备来说,要提供的主要入口有:open ()、release ()、read ()、write ()、ioctl () 等。
open() 函数 对设备特殊文件进行 open() 系统调用时,将调用驱动程序的open () 函数:

int (*open)(struct inode * inode,struct file *filp);
其中参数 inode 为设备特殊文件的 inode (索引结点) 结构的指针,参数 filp 是指向这一设备的文件结构的指针。open() 的主要任务是确定硬件处在就绪状态、验证次设备号的合法性(次设备号可以用 MINOR(inode-> i_rdev) 取得)、控制使用设备的进程数、根据执行情况返回状态码(0 表示成功,负数表示存在错误) 等;
release() 函数 当最后一个打开设备的用户进程执行 close() 系统调用时,内核将调用驱动程序的 release () 函数:

void (*release) (struct inode * inode,struct file *filp) ;
release 函数的主要任务是清理未结束的输入/输出操作、释放资源、用户自定义排他标志的复位等。
read() 函数 当对设备特殊文件进行 read() 系统调用时,将调用驱动程序read() 函数:

ssize_t (*read) (struct file * filp, char * buf, size_t count, loff_t * offp);
参数 buf 是指向用户空间缓冲区的指针,由用户进程给出,count 为用户进程要求读取的字节数,也由用户给出。
read() 函数的功能就是从硬设备或内核内存中读取或复制 count 个字节到 buf 指定的缓冲区中。在复制数据时要注意,驱动程序运行在内核中,而 buf 指定的缓冲区在用户内存区中,是不能直接在内核中访问使用的,因此,必须使用特殊的复制函数来完成复制工作,这些函数在 include/asm/uaccess.h 中被声明:

unsigned long copy_to_user (void * to, void * from, unsigned long len);
此外,put_user() 函数用于内核空间和用户空间的单值交互(如 char、int、long)。
write() 函数 当设备特殊文件进行 write() 系统调用时,将调用驱动程序的 write() 函数:

ssize_t (*write) (struct file *, const char *, size_t, loff_t *);
write ()的功能是将参数 buf 指定的缓冲区中的 count 个字节内容复制到硬件或内核内存中,和 read() 一样,复制工作也需要由特殊函数来完成:

unsigned long copy_from_user(void *to, const void *from, unsigned long n);
此外,get_user()函数用于内核空间和用户空间的单值交互(如 char、int、long)。
ioctl() 函数 该函数是特殊的控制函数,可以通过它向设备传递控制信息或从设备取得状态信息,函数原型为:

int (*ioctl) (struct inode * inode,struct file * filp,unsigned int cmd,unsigned long arg);
参数 cmd 为设备驱动程序要执行的命令的代码,由用户自定义,参数 arg 为相应的命令提供参数,类型可以是整型、指针等。
同样,在驱动程序中,这些函数的定义也必须符合命名规则,按照本文约定,设备"exampledev"的驱动程序的这些函数应分别命名为 exampledev_open、exampledev_ release、exampledev_read、exampledev_write、exampledev_ioctl,因此设备"exampledev"的基本入口点结构变量exampledev_fops 赋值如下(对较早版本的内核):

struct file_operations exampledev_fops {  NULL ,  exampledev_read ,  exampledev_write ,  NULL ,  NULL ,  exampledev_ioctl ,  NULL ,  exampledev_open ,  exampledev_release ,  NULL ,  NULL ,  NULL ,  NULL } ;
就目前而言,由于 file_operations 结构体已经很庞大,我们更适合用 GNU 扩展的 C 语法来初始化 exampledev_fops:

struct file_operations exampledev_fops = {  read: exampledev _read,  write: exampledev _write,  ioctl: exampledev_ioctl ,  open: exampledev_open ,  release : exampledev_release , };
看看第一章电路板硬件原理图,板上包含四个用户可编程的发光二极管(LED),这些 LED 连接在 ARM 处理器的可编程 I/O 口(GPIO)上,现在来编写这些 LED 的驱动:

#include <linux/config.h> #include <linux/kernel.h> #include <linux/init.h> #include <linux/miscdevice.h> #include <linux/sched.h> #include <linux/delay.h> #include <asm/hardware.h> #define DEVICE_NAME "leds" /*定义led 设备的名字*/ #define LED_MAJOR 231 /*定义led 设备的主设备号*/ static unsigned long led_table[] = {  /*I/O 方式led 设备对应的硬件资源*/  GPIO_B10, GPIO_B8, GPIO_B5, GPIO_B6, }; /*使用ioctl 控制led*/ static int leds_ioctl(struct inode *inode, struct file *file, unsigned int cmd, unsigned long arg) {  switch (cmd)  {   case 0:   case 1:    if (arg > 4)    {     return -EINVAL;    }    write_gpio_bit(led_table[arg], !cmd);   default:    return -EINVAL;  } } static struct file_operations leds_fops = {  owner: THIS_MODULE, ioctl: leds_ioctl, }; static devfs_handle_t devfs_handle; static int __init leds_init(void) {  int ret;  int i;  /*在内核中注册设备*/  ret = register_chrdev(LED_MAJOR, DEVICE_NAME, &leds_fops);  if (ret < 0)  {   printk(DEVICE_NAME " can't register major number/n");   return ret;  }  devfs_handle = devfs_register(NULL, DEVICE_NAME, DEVFS_FL_DEFAULT, LED_MAJOR, 0, S_IFCHR | S_IRUSR | S_IWUSR, &leds_fops, NULL);  /*使用宏进行端口初始化,set_gpio_ctrl 和write_gpio_bit 均为宏定义*/  for (i = 0; i < 8; i++)  {   set_gpio_ctrl(led_table[i] | GPIO_PULLUP_EN | GPIO_MODE_OUT);   write_gpio_bit(led_table[i], 1);  }  printk(DEVICE_NAME " initialized/n");  return 0; } static void __exit leds_exit(void) {  devfs_unregister(devfs_handle);  unregister_chrdev(LED_MAJOR, DEVICE_NAME); } module_init(leds_init); module_exit(leds_exit);
使用命令方式编译 led 驱动模块:

#arm-linux-gcc -D__KERNEL__ -I/arm/kernel/include -DKBUILD_BASENAME=leds -DMODULE -c -o leds.o leds.c
以上命令将生成 leds.o 文件,把该文件复制到板子的 /lib 目录下,使用以下命令就可以安装 leds 驱动模块:

#insmod /lib/ leds.o
删除该模块的命令是:

#rmmod leds
4.块设备驱动
块设备驱动程序的编写是一个浩繁的工程,其难度远超过字符设备,上千行的代码往往只能搞定一个简单的块设备,而数十行代码就可能搞定一个字符设备。因此,非得有相当的基本功才能完成此项工作。下面先给出一个实例,即 mtdblock 块设备的驱动。我们通过分析此实例中的代码来说明块设备驱动程序的写法(由于篇幅的关系,大量的代码被省略,只保留了必要的主干):

#include <linux/config.h> #include <linux/devfs_fs_kernel.h> static void mtd_notify_add(struct mtd_info* mtd); static void mtd_notify_remove(struct mtd_info* mtd); static struct mtd_notifier notifier = {  mtd_notify_add,  mtd_notify_remove,  NULL }; static devfs_handle_t devfs_dir_handle = NULL; static devfs_handle_t devfs_rw_handle[MAX_MTD_DEVICES]; static struct mtdblk_dev {  struct mtd_info *mtd; /* Locked */  int count;  struct semaphore cache_sem;  unsigned char *cache_data;  unsigned long cache_offset;  unsigned int cache_size;  enum { STATE_EMPTY, STATE_CLEAN, STATE_DIRTY } cache_state; } *mtdblks[MAX_MTD_DEVICES]; static spinlock_t mtdblks_lock; /* this lock is used just in kernels >= 2.5.x */ static spinlock_t mtdblock_lock; static int mtd_sizes[MAX_MTD_DEVICES]; static int mtd_blksizes[MAX_MTD_DEVICES]; static void erase_callback(struct erase_info *done) {  wait_queue_head_t *wait_q = (wait_queue_head_t *)done->priv;  wake_up(wait_q); } static int erase_write (struct mtd_info *mtd, unsigned long pos, int len, const char *buf) {  struct erase_info erase;  DECLARE_WAITQUEUE(wait, current);  wait_queue_head_t wait_q;  size_t retlen;  int ret;  /*  * First, let's erase the flash block.  */  init_waitqueue_head(&wait_q);  erase.mtd = mtd;  erase.callback = erase_callback;  erase.addr = pos;  erase.len = len;  erase.priv = (u_long)&wait_q;  set_current_state(TASK_INTERRUPTIBLE);  add_wait_queue(&wait_q, &wait);  ret = MTD_ERASE(mtd, &erase);  if (ret) {   set_current_state(TASK_RUNNING);   remove_wait_queue(&wait_q, &wait);   printk (KERN_WARNING "mtdblock: erase of region [0x%lx, 0x%x] " "on /"%s/" failed/n", pos, len, mtd->name);   return ret;  }  schedule(); /* Wait for erase to finish. */  remove_wait_queue(&wait_q, &wait);  /*  * Next, writhe data to flash.  */  ret = MTD_WRITE (mtd, pos, len, &retlen, buf);  if (ret)   return ret;  if (retlen != len)   return -EIO;  return 0; } static int write_cached_data (struct mtdblk_dev *mtdblk) {  struct mtd_info *mtd = mtdblk->mtd;  int ret;  if (mtdblk->cache_state != STATE_DIRTY)   return 0;  DEBUG(MTD_DEBUG_LEVEL2, "mtdblock: writing cached data for /"%s/" " "at 0x%lx, size 0x%x/n", mtd->name, mtdblk->cache_offset, mtdblk->cache_size);  ret = erase_write (mtd, mtdblk->cache_offset, mtdblk->cache_size, mtdblk->cache_data);  if (ret)   return ret;  mtdblk->cache_state = STATE_EMPTY;  return 0; } static int do_cached_write (struct mtdblk_dev *mtdblk, unsigned long pos, int len, const char *buf) {  … } static int do_cached_read (struct mtdblk_dev *mtdblk, unsigned long pos, int len, char *buf) {  … } static int mtdblock_open(struct inode *inode, struct file *file) {  … } static release_t mtdblock_release(struct inode *inode, struct file *file) {  int dev;  struct mtdblk_dev *mtdblk;  DEBUG(MTD_DEBUG_LEVEL1, "mtdblock_release/n");  if (inode == NULL)   release_return(-ENODEV);  dev = minor(inode->i_rdev);  mtdblk = mtdblks[dev];  down(&mtdblk->cache_sem);  write_cached_data(mtdblk);  up(&mtdblk->cache_sem);  spin_lock(&mtdblks_lock);  if (!--mtdblk->count) {   /* It was the last usage. Free the device */   mtdblks[dev] = NULL;   spin_unlock(&mtdblks_lock);   if (mtdblk->mtd->sync)    mtdblk->mtd->sync(mtdblk->mtd);    put_mtd_device(mtdblk->mtd);    vfree(mtdblk->cache_data);    kfree(mtdblk);  } else {   spin_unlock(&mtdblks_lock);  }  DEBUG(MTD_DEBUG_LEVEL1, "ok/n");    BLK_DEC_USE_COUNT;  release_return(0); } /* * This is a special request_fn because it is executed in a process context * to be able to sleep independently of the caller. The * io_request_lock (for <2.5) or queue_lock (for >=2.5) is held upon entry * and exit. The head of our request queue is considered active so there is * no need to dequeue requests before we are done. */ static void handle_mtdblock_request(void) {  struct request *req;  struct mtdblk_dev *mtdblk;  unsigned int res;  for (;;) {   INIT_REQUEST;   req = CURRENT;   spin_unlock_irq(QUEUE_LOCK(QUEUE));   mtdblk = mtdblks[minor(req->rq_dev)];   res = 0;   if (minor(req->rq_dev) >= MAX_MTD_DEVICES)    panic("%s : minor out of bound", __FUNCTION__);   if (!IS_REQ_CMD(req))    goto end_req;   if ((req->sector + req->current_nr_sectors) > (mtdblk->mtd->size >> 9))    goto end_req;   // Handle the request   switch (rq_data_dir(req))   {    int err;    case READ:     down(&mtdblk->cache_sem);     err = do_cached_read (mtdblk, req->sector << 9, req->current_nr_sectors << 9, req->buffer);     up(&mtdblk->cache_sem);     if (!err)      res = 1;     break;    case WRITE:     // Read only device     if ( !(mtdblk->mtd->flags & MTD_WRITEABLE) )      break;     // Do the write     down(&mtdblk->cache_sem);     err = do_cached_write (mtdblk, req->sector << 9,req->current_nr_sectors << 9, req->buffer);     up(&mtdblk->cache_sem);     if (!err)      res = 1;     break;   }  end_req:  spin_lock_irq(QUEUE_LOCK(QUEUE));  end_request(res); } } static volatile int leaving = 0; static DECLARE_MUTEX_LOCKED(thread_sem); static DECLARE_WAIT_QUEUE_HEAD(thr_wq); int mtdblock_thread(void *dummy) {  … } #define RQFUNC_ARG request_queue_t *q static void mtdblock_request(RQFUNC_ARG) {  /* Don't do anything, except wake the thread if necessary */  wake_up(&thr_wq); } static int mtdblock_ioctl(struct inode * inode, struct file * file, unsigned int cmd, unsigned long arg) {  struct mtdblk_dev *mtdblk;  mtdblk = mtdblks[minor(inode->i_rdev)];  switch (cmd) {   case BLKGETSIZE: /* Return device size */    return put_user((mtdblk->mtd->size >> 9), (unsigned long *) arg);   case BLKFLSBUF:    if(!capable(CAP_SYS_ADMIN))     return -EACCES;    fsync_dev(inode->i_rdev);    invalidate_buffers(inode->i_rdev);    down(&mtdblk->cache_sem);    write_cached_data(mtdblk);    up(&mtdblk->cache_sem);    if (mtdblk->mtd->sync)     mtdblk->mtd->sync(mtdblk->mtd);     return 0;   default:    return -EINVAL;  } } static struct block_device_operations mtd_fops = {  owner: THIS_MODULE,  open: mtdblock_open,  release: mtdblock_release,  ioctl: mtdblock_ioctl }; static void mtd_notify_add(struct mtd_info* mtd) {  … } static void mtd_notify_remove(struct mtd_info* mtd) {  if (!mtd || mtd->type == MTD_ABSENT)   return;  devfs_unregister(devfs_rw_handle[mtd->index]); } int __init init_mtdblock(void) {  int i;  spin_lock_init(&mtdblks_lock);  /* this lock is used just in kernels >= 2.5.x */  spin_lock_init(&mtdblock_lock);  #ifdef CONFIG_DEVFS_FS  if (devfs_register_blkdev(MTD_BLOCK_MAJOR, DEVICE_NAME, &mtd_fops))  {   printk(KERN_NOTICE "Can't allocate major number %d for Memory Technology Devices./n", MTD_BLOCK_MAJOR);   return -EAGAIN;  }  devfs_dir_handle = devfs_mk_dir(NULL, DEVICE_NAME, NULL);  register_mtd_user(¬ifier);  #else   if (register_blkdev(MAJOR_NR,DEVICE_NAME,&mtd_fops)) {    printk(KERN_NOTICE "Can't allocate major number %d for Memory Technology Devices./n", MTD_BLOCK_MAJOR);   return -EAGAIN;  }  #endif /* We fill it in at open() time. */ for (i=0; i< MAX_MTD_DEVICES; i++) {  mtd_sizes[i] = 0;  mtd_blksizes[i] = BLOCK_SIZE; } init_waitqueue_head(&thr_wq); /* Allow the block size to default to BLOCK_SIZE. */ blksize_size[MAJOR_NR] = mtd_blksizes; blk_size[MAJOR_NR] = mtd_sizes; BLK_INIT_QUEUE(BLK_DEFAULT_QUEUE(MAJOR_NR), &mtdblock_request, &mtdblock_lock); kernel_thread (mtdblock_thread, NULL, CLONE_FS|CLONE_FILES|CLONE_SIGHAND); return 0; } static void __exit cleanup_mtdblock(void) {  leaving = 1;  wake_up(&thr_wq);  down(&thread_sem);  #ifdef CONFIG_DEVFS_FS   unregister_mtd_user(¬ifier);   devfs_unregister(devfs_dir_handle);   devfs_unregister_blkdev(MTD_BLOCK_MAJOR, DEVICE_NAME);  #else   unregister_blkdev(MAJOR_NR,DEVICE_NAME);  #endif  blk_cleanup_queue(BLK_DEFAULT_QUEUE(MAJOR_NR));  blksize_size[MAJOR_NR] = NULL;  blk_size[MAJOR_NR] = NULL; } module_init(init_mtdblock); module_exit(cleanup_mtdblock);
从上述源代码中我们发现,块设备也以与字符设备 register_chrdev、unregister_ chrdev 函数类似的方法进行设备的注册与释放:

int register_blkdev(unsigned int major, const char *name, struct block_device_operations *bdops); int unregister_blkdev(unsigned int major, const char *name);
但是,register_chrdev 使用一个向 file_operations 结构的指针,而register_blkdev 则使用 block_device_operations 结构的指针,其中定义的 open、release 和 ioctl 方法和字符设备的对应方法相同,但未定义 read 或者 write 操作。这是因为,所有涉及到块设备的 I/O 通常由系统进行缓冲处理。
块驱动程序最终必须提供完成实际块 I/O 操作的机制,在 Linux 当中,用于这些 I/O 操作的方法称为"request(请求)"。在块设备的注册过程中,需要初始化 request 队列,这一动作通过 blk_init_queue 来完成,blk_init_queue 函数建立队列,并将该驱动程序的 request 函数关联到队列。在模块的清除阶段,应调用 blk_cleanup_queue 函数。
本例中相关的代码为:

BLK_INIT_QUEUE(BLK_DEFAULT_QUEUE(MAJOR_NR), &mtdblock_request, &mtdblock_lock); blk_cleanup_queue(BLK_DEFAULT_QUEUE(MAJOR_NR));
每个设备有一个默认使用的请求队列,必要时,可使用 BLK_DEFAULT_QUEUE(major) 宏得到该默认队列。这个宏在 blk_dev_struct 结构形成的全局数组(该数组名为 blk_dev)中搜索得到对应的默认队列。blk_dev 数组由内核维护,并可通过主设备号索引。blk_dev_struct 接口定义如下:

struct blk_dev_struct {  /*  * queue_proc has to be atomic  */  request_queue_t request_queue;  queue_proc *queue;  void *data; };
request_queue 成员包含了初始化之后的 I/O 请求队列,data 成员可由驱动程序使用,以便保存一些私有数据。
request_queue 定义为:

struct request_queue {  /*  * the queue request freelist, one for reads and one for writes  */  struct request_list rq[2];  /*  * Together with queue_head for cacheline sharing  */  struct list_head queue_head;  elevator_t elevator;  request_fn_proc * request_fn;  merge_request_fn * back_merge_fn;  merge_request_fn * front_merge_fn;  merge_requests_fn * merge_requests_fn;  make_request_fn * make_request_fn;  plug_device_fn * plug_device_fn;  /*  * The queue owner gets to use this for whatever they like.  * ll_rw_blk doesn't touch it.  */  void * queuedata;  /*  * This is used to remove the plug when tq_disk runs.  */  struct tq_struct plug_tq;  /*  * Boolean that indicates whether this queue is plugged or not.  */  char plugged;  /*  * Boolean that indicates whether current_request is active or  * not.  */  char head_active;  /*  * Is meant to protect the queue in the future instead of  * io_request_lock  */  spinlock_t queue_lock;  /*  * Tasks wait here for free request  */  wait_queue_head_t wait_for_request; };
下图表征了 blk_dev、blk_dev_struct 和 request_queue 的关系:

blk_dev、blk_dev_struct和request_queue的关系
下图则表征了块设备的注册和释放过程:

块设备的注册和释放过程
5.小结
本章讲述了 Linux 设备驱动程序的入口函数及驱动程序中的内存申请、中断等,并分别以实例讲述了字符设备及块设备的驱动开发方法。
原文链接:http://dev.yesky.com/53/2529553.shtml

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值