S3C2440 开发板实战(7):字符设备驱动框架+LED驱动

在学习驱动的时候我遇到了很多问题,所以我的学习路线是这样的:

编写驱动发现.ko文件需要放入开发板的目录中,然后就学习通过nfs创建共享文件,在配置nfs时发现网络没有连接上,所以就学习怎样配置IP地址,在传输完.ko文件后,发现自动生成不了dev nod,在经过一番寻找问题后发现是文件系统没配置好,所以我又配置了一遍mdev。最终成功!(耗时2天呀。)

正文:

一、驱动文件框架

1.1. 编写驱动文件

一个驱动文件包含以下三个部分

  • 初始化驱动:首先需要定义一个文件类(在C里相当于一个结构体),列出其需要的成员函数(空函数);然后在操作系统中注册这个文件驱动,并建立这个驱动对应的设备节点。最后修饰下初始化函数,将其通过编译成可动态加载的module。
  • 成员函数:完善初始化中加入文件类中的函数。
  • 卸载驱动:注销文件驱动,并删除这个驱动对应的设备节点。

Linux设备分成三种基本类型:

  1. 字符设备
  2. 块设备
  3. 网络设备

设备驱动程序也分为对应的三类:

  1. 字符设备驱动程序
  2. 块设备驱动程序
  3. 网络设备驱动程序

设备节点被创建在/dev下,是连接内核与用户层的枢纽,就是设备是接到对应哪种接口的哪个ID 上。 相当于硬盘的inode一样的东西,立面记录了硬件设备的位置和信息

在Linux中,所有设备都以文件的形式存放在/dev目录下,都是通过文件的方式进行访问,设备节点是Linux内核对设备的抽象,一个设备节点就是一个文件。应用程序通过一组标准化的调用执行访问设备,这些调用独立于任何特定的驱动程序。而驱动程序负责将这些标准调用映射到实际硬件的特有操作。

有很重要的一点:设备节点,驱动,硬件设备是如何关联到一起的呢?

---> 这是通过设备号实现的,包括主设备号和次设备号。当创建一个设备节点时需要指定主设备号和次设备号。应用程序通过名称通过名称访问设备,而设备号指定了对应的驱动程序和对应的设备。主设备号标识设备对应的驱动程序,次设备号由内核使用,用于确定设备节点所指设备。

其中设备类型、设备号、设备结点可以通过  ls /dev -l 查看。

接着开始细品驱动文件

1.1.1 配置驱动文件

结构体file_operation 在头文件fs.h中定义,用来存储驱动内核模块提供的对设备进行各种操作的函数的指针,相当于对设备进行config。该结构体的每个域都对应着驱动内核模块用来处理某个被请求的 事务的函数的地址。通过查看file_operation源码可以看出存储着许多内核模块中执行对应操作的地址,在应用程序中使用其中的部分成员,对应的没有调用的成员函数则会设置为NULL。

举个栗子,若是按键驱动,每个字符设备需要定义.read用来读取设备数据的函数。另一个栗子,本文里编写LED驱动,所以需要定义.write用来写寄存器(open中初始化LED write中配置LED),对其config如下:

static struct file_operations drv_fops = {
    .owner  =   THIS_MODULE,    // 相当于This指针
    .open   =   drv_open,       // 将.open 函数指针指向drv_open 函数
    .write    =    drv_write,   // 将.write函数指针指向drv_write函数
};

1.1.2 注册驱动文件

在linux 2.6之后有register_chrdev_region()或者register_chrdev()函数可以注册驱动文件。register_chrdev_region()相比于register_chrdev()更加简便。这里为了方便理解以register_chrdev()来分析:

major = register_chrdev(0,     "led_drv", &drv_fops); // register chrdev. to major 
                     //(major, "drv name",  file operations);
  • major: 生成驱动文件的主设备号。若设置为0,系统将自动分配主设备号给驱动文件,并且函数的返回值为主设备号。所以在运行完这一行程序后会在系统中生成一个设备,可以通过#cat /proc/device中查询。
  • drv_name: 驱动名
  • file_operation: 第一步中创建的文件类

1.1.3 创建设备节点

①不怕麻烦版本:在系统中调用mknod命令创建设备节点,命令格式为mknod name { b | c } Major Minor ,b表示块设备,c表示字符设备。

②高端版本:搭配mdev文件系统,在初始化函数中创建设备节点,使用自动创建的前提是用户空间移植了udev。在驱动的初始化代码里调用class_create为该设备创建一个class,再为每个设备调用device_create创建对应的设备。在移除模块时,要调用device_destroy 和class_destroy相应的删除自动创建的设备和类。

很简单就两步:一、定义一个类指向Step1中文件类;二、创建文件类对应的设备节点(主设备号&次设备号)

static struct class *drv_class;
static struct class_device    *drv_class_dev;
-------------------------------------------------------------------------------------------

   

drv_class = class_create(THIS_MODULE, "leddrv");//(.owner, 类名)
if(IS_ERR(drv_class))
{
    printk( "ERROR creat key class");
}
//(指向文件类的类名, 父类节点,  (主设备号,次设备号),    ,设备节点名字)
drv_class_dev = class_device_create(drv_class, NULL, MKDEV(major, 0), NULL, "led"); 
if(drv_class_dev == NULL)
{
   printk("ERROR creat dev");
}

1.1.4 成员函数

Step1中调用的函数,以最简单printk为例(只进行打印操作):

static int drv_open(struct inode *inode, struct file *file)
{
    printk("drv_open\n");     
    return 0;
}
static ssize_t drv_write(struct file *file, const char __user *buf, size_t count, loff_t * ppos)
{
    printk("drv_write\n");
    return 0;
}

1.1.5 卸载驱动

两部分:卸载驱动&删除设备节点

 1.1.6 模块化驱动文件

首先修饰init函数和exit函数,相当于告诉内核这个函数为初始化函数和退出函数

module_init(first_drv_init); //When install drv, system will find init function
module_exit(first_drv_exit);   // Uinstall drv

需要在驱动文件中加入模块许可声明(适合linux2.4&2.6)否则insmod驱动时将不能与/proc/kallsyms中的符号正常连接:

MODULE_LICENSE("GPL");

则我们的驱动框架为:

//drv_frame.c

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/init.h>
#include <linux/delay.h>
#include <asm/uaccess.h>
#include <asm/irq.h>
#include <asm/io.h>
#include <asm/arch/regs-gpio.h>
#include <asm/hardware.h>
#include <linux/device.h>

static struct class *drv_class;
static struct class_device    *drv_class_dev;

static int drv_open(struct inode *inode, struct file *file) //设备节点,参数项
{
    printk("drv_open\n");     
    return 0;
}

static ssize_t drv_write(struct file *file, const char __user *buf, size_t count, loff_t * ppos)
{
    printk("drv_write\n");
    return 0;
}

static struct file_operations drv_fops = {
    .owner  =   THIS_MODULE,    /* 这是一个宏,推向编译模块时自动创建的__this_module变量 */
    .open   =   drv_open,     
    .write    =    drv_write,       
};
int major;
static int drv_init(void)
{
    major = register_chrdev(0, "led_drv", &drv_fops); // register chrdev. to major (major, "drv name", file operations);
    drv_class = class_create(THIS_MODULE, "leddrv");
    if(IS_ERR(drv_class))
    {
        printk( "ERROR creat key class");
    }
    drv_class_dev = class_device_create(drv_class, NULL, MKDEV(major, 0), NULL, "led");
    if(drv_class_dev == NULL)
    {
        printk("ERROR creat dev");
    }
    return 0;
}

static void drv_exit(void)
{
    unregister_chrdev(major, "led_drv"); // 卸载

    class_device_unregister(drv_class_dev);
    class_destroy(drv_class);
}

module_init(drv_init); //When install drv, system will find init function
module_exit(drv_exit);   // Uinstall drv

MODULE_LICENSE("GPL");

1.2. 配置mdev文件系统

上文也提到了,构建设备节点是需要mdev协助的,mdev其实是udev的简化版,通过读取内核信息来创建设备文件,要使用mdev,需要内核支持sysfs文件系统,若想减少对flash的读写还得支持tmpfs文件系统(可有可没有,我tmpfs挂载不上),其配置方法如下:

配置开机自动运行文件/etc/init.d/rcS ,添加以下几句命令:

mount -a
mkdir /dev/pts
mount -t devpts devpts /dev/pts
echo /sbin/mdev > /proc/sys/kernelhotplug
mdev -s

其中mount -a命令是将文件/etc/fstab中的设备进行挂载,所以这里应该添加sysfs和tmpfs文件系统的挂载:

# device       mount-point       type      options     dump      fsck order
proc           /proc             proc      defaults    0         0
tmpfs          /tmp              tmpfs     defaults    0         0
tmpfs          /dev              tmpfs     defaults    0         0
sysfs          /sys              sysfs     defaults    0         0

挂载的文件系统可以通过以下指令进行观察:

#cat /proc/mounts

1.3. 应用程序

接着就是随便编写一个应用程序(应用程序用来控制文件)

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


int main(int argc, char **argv)
{
    int fd;
    int val = 1;
    fd = open("/dev/led", O_RDWR);//设备节点  读写打开
    if (fd < 0)
    {
        printf("can't open!\n"); 
    }
    write(fd, &val, 4);
    return 0;
}

由于open函数的返回值为一个int类型,当文件打开出错时会返回-1。如果为大于0的值,那么这个值代表的就是文件描述符。所以有更一般的写法:

if((fd=open("/dev/ttys0",O_RDWR | O_NOCTTY | O_NDELAY)<0)
{
    perror("open");
}

1.4. 编译驱动文件以及测试文件

1.4.1 makefile

KERN_DIR = ~/work/system/linux-2.6.22.6

all:
    make -C $(KERN_DIR) M=`pwd` modules 

clean:
    make -C $(KERN_DIR) M=`pwd` modules clean
    rm -rf modules.order

obj-m    += drv_led.o

其中KERN_DIR代表内核的位置,所以底下的make是根据linux内核中的makefile进行编译,注释如下:

  • -C:指定进入指定的目录即KERN_DIR,是内核源代码目录,调用该目录顶层下的Makefile,目标为modules。 M=$(shell pwd) | `pwd`选项让该Makefile在构造modules目标之前返回到模块源代码目录并在当前目录生成obj-m指定的xxx.o目标模块。
  • obj-m += xxx.o:指定当前目录要生成的目标模块,然后modules目标指向obj-m变量中设定的模块。
     

1.4.2 测试文件编译

$arm-linux-gcc -o test_fun test_fun.c

1.5. 测试

命令&注释如下

1.5.1 注册驱动

在命令段通过insmod对编译出的.ko文件进行加载。

#insmod /mnt/drv_frame.ko

这时可以通过以下命令,判断驱动初始化是否成功:

#lsmod  //查看是否创建设备节点
#ls /dev -l //查看所有的驱动(驱动名 主设备号 次设备号)
#cat /proc/device   //查看驱动是否注册成功

1.5.2 执行应用程序

如果驱动初始化没有问题就可以直接开始运行应用程序:

#./mnt/drv_fun

运行成功会看见系统调用了drv_open()函数以及drv_write()函数打印出来的值:

drv_open

drv_write

1.5.3 注销驱动

rmmod drv_led    //.ko文件名

这一步会注销驱动并且删除设备节点。若要单独删除设备节点可以直接rm -r dev/led 指令

二、LED驱动程序

2.1. 虚拟内存

在裸机程序中对于LED的点亮是直接通过物理地址进行配置的,但是在驱动程序中是通过虚拟地址这个中介对物理地址进行操作。从物理地址向虚拟地址映射可以通过ioremap(address, length)进行映射。在卸载时可以通过iounmap(address)进行去映射。

2.2. 框架添加LED驱动程序

除了对地址的映射,其他的操作和裸机配置LED操作一样

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/init.h>
#include <linux/delay.h>
#include <asm/uaccess.h>
#include <asm/irq.h>
#include <asm/io.h>
#include <asm/arch/regs-gpio.h>
#include <asm/hardware.h>
#include <linux/device.h>

static struct class *leddrv_class;
static struct class_device    *leddrv_class_dev;

volatile unsigned long *gpfcon = NULL;
volatile unsigned long *gpfdat = NULL;

static int led_drv_open(struct inode *inode, struct file *file)
{
    *gpfcon &= ~((3<<8) | (3<<10) | (3<<12));
    *gpfcon |=  ((1<<8) | (1<<10) | (1<<12));     
    //printk("first_drv_open\n");     
    return 0;
}

static ssize_t led_drv_write(struct file *file, const char __user *buf, size_t count, loff_t * ppos)
{
    int val;
    copy_from_user(&val, buf, count);          // copy buf from user space to val   
    if(val == 1)
        *gpfdat &= ~((1<<4) | (1<<5) | (1<<6));
    else
        *gpfdat |=  ((1<<4) | (1<<5) | (1<<6));
    //copy_to_user()
    //printk("led_drv_write\n");
    return 0;
}

static struct file_operations led_drv_fops = {
    .owner  =   THIS_MODULE,    /* 这是一个宏,推向编译模块时自动创建的__this_module变量 */
    .open   =   led_drv_open,     
    .write    =    led_drv_write,       
};
int major;
static int led_drv_init(void)
{
    major = register_chrdev(0, "led_drv", &led_drv_fops); // 注册, 告诉内核// register chrdev. to major (major, "drv name", file operations);

    leddrv_class = class_create(THIS_MODULE, "leddrv");
    if(IS_ERR(leddrv_class))
        printk( "ERROR creat key class");

    leddrv_class_dev = class_device_create(leddrv_class, NULL, MKDEV(major, 0), NULL, "led"); /* /dev/xyz */
    if(leddrv_class_dev == NULL)
        printk("ERROR creat dev");
    gpfcon = (volatile unsigned long *)ioremap(0x56000050, 16); //remap LED GPFCPN
    gpfdat = gpfcon + 1;                                        //remap LED GPFDAT
    return 0;
}

static void led_drv_exit(void)
{
    unregister_chrdev(major, "led_drv"); // 卸载

    class_device_unregister(leddrv_class_dev);
    class_destroy(leddrv_class);
    iounmap(gpfcon);           // unmap virtual address
}

module_init(led_drv_init); //When install drv, system will find init function
module_exit(led_drv_exit);   // Uinstall drv

MODULE_LICENSE("GPL");

PS:从用户空间的参数在驱动程序中成员函数如果要使用,则需要通过copy_form_user()和copy_to_user()将用户空间的参数传递过来。用法:

1、Copy_to_user( to, &from, sizeof(from))

To:用户空间函数  (可以是数组)

From:内核空间函数(可以是数组)

sizeof(from):内核空间要传递的数组的长度

2、Copy_from_user(&from , to , sizeof(to) )

To:用户空间函数  (可以是数组)

From:内核空间函数(可以是数组)

sizeof(from):内核空间要传递的数组的长度

2.3. 应用程序

2.3.1 main函数的参数

一般的main函数都是不带参数的,因此main 后的括号都是空括号。实际上,main函数可以带参数,这个参数可以认为是 main函数的形式参数。C语言规定main函数的参数只能有两个, 习惯上这两个参数写为argc和argv。因此,main函数的函数头可写为: main (argc,argv)C语言还规定argc(第一个形参)必须是整型变量,argv( 第二个形参)必须是指向字符串的指针数组。加上形参说明后,main函数的函数头可以写为:

main (int argc,char *argv[])

main函数的参数值是从操作系统命令行上获得的。运行一个可执行文件时,在命令行键入文件名,再输入实际参数即可把这些实参传送到main的形参中去。

  命令行下运行可执行文件的一般形式为:可执行文件名 参数 参数……,命令行中的参数个数原则上未加限制。argc参数表示了命令行中参数的个数(注意:文件名本身也算一个参数),argc的值是在输入命令行时由系统按实际参数的个数自动赋予的。例如有命令行为./a.out a b b d a,由于文件名a.out本身也算一个参数,所以共有6个参数,因此argc取得的值为6。argv参数是字符串指针数组,其各元素值为命令行中各字符串(参数均按字符串处理)的首地址。 指针数组的长度即为参数个数。数组元素初值由系统自动赋予。

2.3.2 测试函数

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
// 用法:
// led_drv on
// led_drv off

int main(int argc, char **argv)
{
    int fd;
    int val = 1;
    fd = open("/dev/led", O_RDWR);    //允许读写
    if (fd < 0)
        printf("can't open!\n");
    if(argc != 2)   // 参数数量
    {
        printf("error parterner\n");   // 提示信息
        printf("%s <on|off>\n",argv[0]);
        return 0;
    }
    if(strcmp(argv[1],"on") == 0)
        val = 1;
    else
        val = 0;

    write(fd, &val, 4);   
    return 0;
}

2.3. 测试

在执行应用程序之前都相同,执行应用程序时:

#drv_fun on    //打开LED
#drv_fun off   //关闭LED

三、LED驱动程序 pro

这一节里实现分别控制每一个LED,办法很多以下列出几种常用的办法:

  1. 通过主函数传入val控制LED
  2. 通过查看次设备号控制LED

为了学习,使用控制次设备号来控制LED。

3.1. 完善驱动程序

3.1.1 引用主设备号&次设备号

设备号的使用应该始终使用<linux/kdev_t.h>中定义的宏

  • 获取主设备号:MAJOR(dev_t dev) 和
  • 获取次设备号:MINOR(dev_t dev)
  • 转换成dev_类型:MKDEV(int major,int minor)      PS: 在class_device_create()函数中有使用

3.1.2 file结构体

获得设备号需要知道当前文件对应的设备节点的位置(dev/led1),这一信息体现在file结构体中,在vim中从file通过一步一步g+]可以得到inode的位置所以在write和open函数中使用MIONR()如下所示:

int minor = MINOR(file -> f_dentry -> d_inode -> i_rdev);   //write struct file *file

int minor = MINOR(inode -> i_rdev);                         //open  struct inode *inode

 

3.1.3 完整驱动框架

思想:基以上两点,首先在初始化函数中初始化四个设备节点(次设备号不同),通过在write和open中分析此设备号,进而判断程序动作。

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/init.h>
#include <linux/delay.h>
#include <asm/uaccess.h>
#include <asm/irq.h>
#include <asm/io.h>
#include <asm/arch/regs-gpio.h>
#include <asm/hardware.h>
#include <linux/device.h>
#include <linux/kdev_t.h>
#include <linux/compiler.h>
#include <linux/err.h>

#define DEV_NAME "leddrv"  // device name
#define LED_MAJOR 252   // major device number

static struct class *leddrv_class;
static struct class_device    *leddrv_class_dev[4];

volatile unsigned long *gpfcon = NULL;
volatile unsigned long *gpfdat = NULL;

static int led_drv_open(struct inode *inode, struct file *file)
{
    int minor = MINOR(inode -> i_rdev);
    switch(minor)
    {
        case 0:
        {
           *gpfcon &= ~((3<<4*2) | (3<<5*2) | (3<<6*2));
           *gpfcon |=  ((1<<4*2) | (1<<5*2) | (1<<6*2));
            break;
        }
        case 1:
        {
           *gpfcon &= ~(3<<4*2);
           *gpfcon |=  (1<<4*2);
            break;
        }
        case 2:
        {
            *gpfcon &= ~(3<<5*2);
            *gpfcon |=  (1<<5*2);
            break;
        }
        case 3:
        {
            *gpfcon &= ~(3<<6*2);
            *gpfcon |=  (1<<6*2);
            break;
        }
    }
//    *gpfcon &= ~((3<<8) | (3<<10) | (3<<12));
//    *gpfcon |=  ((1<<8) | (1<<10) | (1<<12));     
    //printk("first_drv_open\n");     
    return 0;
}

static ssize_t led_drv_write(struct file *file, const char __user *buf, size_t count, loff_t * ppos)
{
    int val;
    copy_from_user(&val, buf, sizeof(buf));          // copy buf from user space to val   
    
    int minor = MINOR(file -> f_dentry -> d_inode -> i_rdev);
    switch(minor)
    {
        case 0:
        {
            if(val == 1)                                                                  
                *gpfdat &= ~((1<<4) | (1<<5) | (1<<6));                     
            else                                                                                        
                *gpfdat |=  ((1<<4) | (1<<5) | (1<<6));
            break;
        }
        case 1:
        {
               if(val == 1)                                                                  
                *gpfdat &= ~(1<<4);                     
            else                                                                                        
                *gpfdat |=  (1<<4);
            break;
        }
        case 2:
        {
            if(val == 1)
                *gpfdat &= ~(1<<5);
            else
                *gpfdat |=  (1<<5);
            break;
        }
        case 3:
        {
            if(val == 1)                                                                  
                *gpfdat &= ~(1<<6);                     
            else                                                                                        
                *gpfdat |=  (1<<6);
            break;
        }
    }
/*    if(val == 1)
        *gpfdat &= ~((1<<4) | (1<<5) | (1<<6));
    else
        *gpfdat |=  ((1<<4) | (1<<5) | (1<<6));*/
    //copy_to_user()
    //printk("led_drv_write\n");
    return 0;
}

static struct file_operations led_drv_fops = {
    .owner  =   THIS_MODULE,    /* 这是一个宏,推向编译模块时自动创建的__this_module变量 */
    .open   =   led_drv_open,     
    .write    =    led_drv_write,       
};
int major;
static int led_drv_init(void)
{
    major = register_chrdev(0, DEV_NAME, &led_drv_fops); // register chrdev. to major (major, "drv name", file operations);

    //To file_operations class
    leddrv_class = class_create(THIS_MODULE, "leddrv");
    if(IS_ERR(leddrv_class))   // error
    {
        printk( "ERROR creat key class");
    }

    //creat device 
    leddrv_class_dev[0] = class_device_create(leddrv_class, NULL, MKDEV(major, 0), NULL, "ledall"); 
    int minor;
    for(minor = 1; minor < 4; minor++)
    {
        leddrv_class_dev[minor] = class_device_create(leddrv_class, NULL, MKDEV(major, minor), NULL, "led%d", minor);
        if(unlikely(IS_ERR(leddrv_class_dev[minor])))
            return PTR_ERR(leddrv_class_dev[minor]);
    }
    
    //remap phcical address
    gpfcon = (volatile unsigned long *)ioremap(0x56000050, 16); //remap LED GPFCPN
    gpfdat = gpfcon + 1;                                        //remap LED GPFDAT
    printk("initialized complete\n");
    return 0;
}

static void led_drv_exit(void)
{
    unregister_chrdev(major, DEV_NAME); // 卸载
    int minor;
    for(minor = 0; minor < 4; minor ++)
    {
        class_device_unregister(leddrv_class_dev[minor]);
    }
    class_destroy(leddrv_class);
    iounmap(gpfcon);           // unmap virtual address
}

module_init(led_drv_init); //When install drv, system will find init function
module_exit(led_drv_exit);   // Uinstall drv

MODULE_LICENSE("GPL");

3.2. 完善应用程序

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

// led_drv <on|off> <dev>

void print_tips(char *app_name)
{
    printf("Tips:\n");
    printf("%s <on|off> <dev>", app_name);
    printf("eg.\n");
    printf("%s <on|off> /dev/ledall\n", app_name);
    printf("%s <on|off> /dev/led1\n", app_name);
    printf("%s <on|off> /dev/led2\n", app_name);
    printf("%s <on|off> /dev/led3\n", app_name);
}
int main(int argc, char **argv)
{
    int fd;
    int val = 1;
    char *dev_list = argv[2];
    fd = open(dev_list, O_RDWR);
    if (fd < 0)
    {
        printf("can't open!\n");
    }
    if(argc != 3)   // input  
    {
        print_tips(argv[0]);
        return 0;
    }
    if(strcmp(argv[1],"on") == 0)
        val = 1;
    else
        val = 0;

    write(fd, &val, 4);
    return 0;
}

后面就和之前的操作一样了 看码!!

 

 

 

 

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值