Platform总线按键驱动分析(精华版)

本文作者通过三个星期的学习和实践,深入理解了驱动开发,尤其是平台总线上的按键驱动。文章介绍了如何将按键驱动分为kbd_driver.c和kbd_device.c两个模块,并重点探讨了按键的消抖与中断处理。中断被比喻为看电影时被叫出去处理事情,再返回继续的过程,帮助读者形象理解中断概念。

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

驱动做了三个多星期了,从helloworld到LED再到PlatformLED,现在终于到了按键驱动,对于驱动的理解深刻了不少,从完全看不懂到现在能够独立分析,进步很大,今天完成了按键驱动,做一个总结性的分享,给后面的自己看!加油!


按键驱动:

与之前不同,这次将按键驱动按照platform总线的理解,设备链表和驱动链表,做成了两个模块,一个kbd_driver.c   一个kbd_device.c    侧重点在理解按键的消抖和中断

中断:我个人理解为你正在看电影,有人叫你出去,你就得暂停出去处理,然后才能回来继续看电影,这就是一个生活中的中断。在开发板上,你也可以带入的理解。


上代码分析代码:

kbd_driver.c
#include <linux/module.h>
#include <linux/init.h>
#include <linux/platform_device.h>
#include <linux/input.h>
#include <linux/irq.h>
#include <linux/interrupt.h>
#include <mach/hardware.h>
#include <asm/gpio.h>
#include <asm/irq.h>
#include <linux/slab.h>
#include <mach/regs-gpio.h>

#include "kbd_driver.h"

/* 1HZ=100*jiffies 1*jiffies=10ms => 1HZ=100*10ms = 1s 	这是在设置时钟/
#define CANCEL_DITHERING_DELAY          (HZ/50)   /* Remove button push down dithering timer delay 20ms 	延时 */ 

typedef struct s3c_kbd_s 
{
    struct timer_list           *timers; /* every key get a cancel dithering timer 消抖时间*/
    struct input_dev            *input_dev;
    s3c_kbd_platform_data_t     *pdata;
} s3c_kbd_t;  /*---  end of struct s3c_kbd_s  ---*/


s3c_kbd_t                       *s3c_kbd = NULL;

static irqreturn_t s3c_kbd_intterupt(int irq, void *dev_id) 	//见139行,一旦发生中断后,将中断号传过来
{
    int                         i;
    int                         found = 0;
    struct platform_device      *pdev =  dev_id;
    s3c_kbd_t                   *s3c_kbd = NULL;
    
    s3c_kbd = platform_get_drvdata(pdev);
	
    for(i=0; i<s3c_kbd->pdata->nkeys; i++)	//寻找中断号
    {
        if(irq == s3c_kbd->pdata->keys[i].nIRQ)
        {
            found = 1;
            break;
        }
    }

    if(!found) /*  An ERROR interrupt */
        return IRQ_NONE;

    mod_timer(&s3c_kbd->timers[i], jiffies+CANCEL_DITHERING_DELAY);	//消抖定时器,延时		jiffies是当前时间由内核维护
	//中断的处理涉及到上半部和下半部,上半部进行响应然后离开,下半部例如定时器会在这里执行,提高效率。
    return IRQ_HANDLED;  
}

static void  cancel_dithering_timer_handler(unsigned long data)//消抖定时器处理方式:当62行延时结束后就来到这里调用这个函数,看看是否按键按下,这样就利用timer(定时器)来消抖
{
    int                      which =(int)data;
    unsigned int             pinval;  

    pinval = s3c2410_gpio_getpin(s3c_kbd->pdata->keys[which].gpio);	//获取按键引脚电平

    if( pinval ) 
    {
        //printk("s3c_kbd key[%d] code[%d] released\n", which, s3c_kbd->pdata->keys[which].code);
        input_event(s3c_kbd->input_dev, EV_KEY, s3c_kbd->pdata->keys[which].code, 0);  
    }
    else
    {
        //printk("s3c_kbd key[%d] code[%d] pressed\n", which, s3c_kbd->pdata->keys[which].code);
        input_event(s3c_kbd->input_dev, EV_KEY, s3c_kbd->pdata->keys[which].code, 1);  
    }
    
    input_sync(s3c_kbd->input_dev);
}

static int s3c_kbd_probe(struct platform_device *pdev)		//probe()函数传参调用platform_device相应的设备信息,在总线上device和driver name匹配时调用
{
    int                         i = 0;
    int                         rv = -ENOMEM;//还记得后面的if语句吗?这里是负值,如果不能正确执行就返回这个值哦!
    struct input_dev            *input_dev = NULL;
    s3c_kbd_platform_data_t     *pdata = pdev->dev.platform_data;//*pdata指向设备信息	从这里看出,驱动和设备的信息被隔离开

    /* malloc s3c_kbd struct	给按键分配结构体  */
    s3c_kbd = kmalloc(sizeof(s3c_kbd_t), GFP_KERNEL);
    if( !s3c_kbd )
    {
        printk("error: s3c_kbd_probe kmalloc() for s3c_kbd failure\n");
        goto fail;
    }
    memset(s3c_kbd, 0, sizeof(s3c_kbd_t));//void *memset(void *s,int c,size_t n)   总的作用:将已开辟内存空间 s 的首 n 个字节的值设为值 c。内存空间初始化

    /* malloc cancel dithering timer for every key */
    s3c_kbd->timers = (struct timer_list *) kmalloc(pdata->nkeys*sizeof(struct timer_list), GFP_KERNEL);//在linux/gfp.h中定义的一个宏,是分配内核空间的内存时的一个标志位。
    if( !s3c_kbd->timers )
    {
        printk("error: s3c_kbd_probe kmalloc() for s3c_kbd timers failure\n");
        goto fail;
    }
    memset(s3c_kbd->timers, 0, pdata->nkeys*sizeof(struct timer_list));

    /* malloc input_dev for keyboard */
    input_dev=input_allocate_device();//分配结构体,填充设备名等
    if( !input_dev )
    {
        printk("error: s3c_kbd_probe input_allocate_device() failure\n");
        goto fail;
    }

    /* setup input_dev  */
    input_dev->name = pdev->name;
    input_dev->dev.parent = &pdev->dev;
    input_dev->id.bustype = BUS_HOST;
    input_dev->id.vendor = 0x0001;
    input_dev->id.product = 0x0001;
    input_dev->id.version = 0x0100;

    set_bit(EV_KEY,input_dev->evbit);  //extern __inline__ int set_bit(int nr,long * addr);   将addr的第nr位置为1
    set_bit(EV_REP,input_dev->evbit); 

    /* Initialize all the keys and interrupt  初始化所有按键、中断 */
    for(i=0; i<pdata->nkeys; i++)
    {
        set_bit(pdata->keys[i].code, input_dev->keybit);
        s3c2410_gpio_cfgpin(pdata->keys[i].gpio, pdata->keys[i].setting);//将按键设为中断模式 
        irq_set_irq_type(pdata->keys[i].nIRQ, IRQ_TYPE_EDGE_BOTH);/*IRQ_TYPE_EDGE_BOTH (IRQ_TYPE_EDGE_FALLING | IRQ_TYPE_EDGE_RISING)中断的触发类型:上下沿都触发
																	更多的触发方式参考:http://blog.chinaunix.net/uid-25445243-id-4052877.html	或 http://blog.sina.com.cn/s/blog_640029b30100uw2f.html*/
        
        rv = request_irq(pdata->keys[i].nIRQ/*中断号*/, s3c_kbd_intterupt, IRQF_DISABLED/*表示处理这个中断时禁止其他中断,即这是一个快速中断*/, pdev->name, pdev);//上面设置完中断后这里申请(安装)中断,一旦有中断发生,就调用这个中断程序(也叫中断服务处理程序)
        if( rv )
        {
            printk("error: request IRQ[%d] for key<%d> failure\n", pdata->keys[i].nIRQ, i);
            rv = -EBUSY;
            goto fail;
        }
        
        //printk("s3c_kbd request IRQ[%d] for key<%d> ok\n", pdata->keys[i].nIRQ, i);

         /* Initialize all the keys cancel dithering timer 	初始化所有按键消抖定时器*/
        setup_timer(&s3c_kbd->timers[i], cancel_dithering_timer_handler, i);
    }

    /* register input device 在内核中注册设备 */
    rv = input_register_device(input_dev);
    if( rv )
    {
        printk("error: s3c_kbd_probe input_register_device error!\n");
        goto fail;
    }

    /* set s3c_kbd as private data in pdev */
    s3c_kbd->input_dev = input_dev;
    s3c_kbd->pdata = pdata;
    platform_set_drvdata(pdev, s3c_kbd);

    printk("s3c_kbd_probe ok\n");
    return 0;

fail:
    while(i--)
    {
        disable_irq(pdata->keys[i].nIRQ);
        free_irq(pdata->keys[i].nIRQ, pdev);
        del_timer( &s3c_kbd->timers[i] );
    }

    if(input_dev)
    {
        input_free_device(input_dev);
    }

    if(s3c_kbd && s3c_kbd->timers)
    {
        kfree(s3c_kbd->timers);
    }

    if(s3c_kbd)
    {
        kfree(s3c_kbd);
    }
    printk("s3c_kbd_probe failed\n");

    return -ENODEV;
}

static int s3c_kbd_remove(struct platform_device *pdev)
{
    int                         i = 0;
    s3c_kbd_t                   *s3c_kbd = platform_get_drvdata(pdev);

    for(i=0; i<s3c_kbd->pdata->nkeys; i++)
    {
        del_timer( &s3c_kbd->timers[i] );
        disable_irq(s3c_kbd->pdata->keys[i].nIRQ);
        free_irq(s3c_kbd->pdata->keys[i].nIRQ, pdev);
    }

    input_unregister_device(s3c_kbd->input_dev);

    kfree(s3c_kbd->timers);
    kfree(s3c_kbd);

    printk("s3c_kbd_remove ok\n");

    return 0;
}

static struct platform_driver s3c_keyboard_driver = {		//总线按键驱动结构体
     .probe      = s3c_kbd_probe,//probe中文译为探测,一旦发现相应设备就调用这个函数
     .remove     = s3c_kbd_remove,//一旦设备移除就调用该函数
     .driver     = {
         .name       = "s3c_kbd",		//要和device  一致
         .owner      = THIS_MODULE,
     },
};

static int __init s3c_keyboard_drv_init(void)
{
    int            rv;

    rv = platform_driver_register(&s3c_keyboard_driver);//在驱动链表上注册
    if(rv)
    {
        printk("s3c keyboard platform driver register failure\n");
        return rv;
    }

    printk("s3c keyboard platform driver register ok\n");
    return 0;
}

static void __exit s3c_keyboard_drv_exit(void)
{
    printk("s3c keyboard driver exit\n");

    platform_driver_unregister(&s3c_keyboard_driver);
    return ;
}

module_init(s3c_keyboard_drv_init);
module_exit(s3c_keyboard_drv_exit);

MODULE_DESCRIPTION("FL2440 board keyboard input driver platform_driver");
MODULE_AUTHOR("fanmaolin");
MODULE_LICENSE("GPL");
MODULE_ALIAS("platform:FL2440 keyboard driver");


kbd_device.c
#include <linux/module.h>
#include <linux/init.h>
#include <linux/platform_device.h>
#include <linux/input.h>
#include <mach/hardware.h>
#include <asm/gpio.h>
#include <asm/irq.h>
#include <mach/regs-gpio.h>
#include "kbd_driver.h"

/*按键硬件信息*/
static s3c_kbd_info_t  s3c_kbd_gpios[] = {
    [0] = {
        .code = KEY_1,
        .nIRQ = IRQ_EINT0,// irq是interrupt request 的缩写,中断请求,这里应该是该按键的中断请求号
        .gpio = S3C2410_GPF(0),//根据原理图设置
        .setting = S3C2410_GPF0_EINT0,//中断模式
    },
    [1] = {
        .code = KEY_2,
        .nIRQ = IRQ_EINT2,
        .gpio = S3C2410_GPF(2),
        .setting = S3C2410_GPF2_EINT2,
    },
    [2] = {
        .code = KEY_3,
        .nIRQ = IRQ_EINT3,
        .gpio = S3C2410_GPF(3),
        .setting = S3C2410_GPF3_EINT3,
    },
    [3] = {
        .code = KEY_4,
        .nIRQ = IRQ_EINT4,
        .gpio = S3C2410_GPF(4),
        .setting = S3C2410_GPF4_EINT4,
    },
};

/* keyboard platform device private data */
static s3c_kbd_platform_data_t s3c_kbd_data = {
    .keys = s3c_kbd_gpios,//按键硬件信息
    .nkeys = ARRAY_SIZE(s3c_kbd_gpios),/*ARRAY_SIZE函数用来求s3c_kbd_gpios[]这个数组有多少个元素,用它来自动获取设备数,
	该函数的宏定义为:#define ARRAY_SIZE(x) (sizeof(x)/sizeof(x[0])) 	sizeof(x)是求该结构体的大小然后除以sizeof(x[0])第一个的大小		就能得到该数组有多少个元素*/
};

static void platform_kbd_release(struct device * dev)//如果在驱动卸载(rmmod)时依然想做其他事就把代码放到这里,我们现在什么都不干,一般为空函数
{
        return;
}

static struct platform_device s3c_keyboard_device = {
    .name    = "s3c_kbd",//设备名,通过这个在总线上找到driver
    .id      = 1,//一般情况下id都为1
    .dev     =
    {//platform_data成员用来存放设备的硬件信息
        .platform_data = &s3c_kbd_data,//在Platform总线上找到彼此后由设备告诉驱动硬件信息,见53行
        .release = platform_kbd_release,//在rmmod时要用到,见58行定义
    },
};


static int __init s3c_keyboard_dev_init(void)
{
    int            rv;

    rv = platform_device_register(&s3c_keyboard_device);//在platform上注册设备,调用这个函数时相当于告诉cpu这个设备已经插上
    if(rv)//http://c.biancheng.net/cpp/html/38.html	if函数的用法参考,在这里当注册成功时rv为error,深入内核看
platform_device_register可以发现,所以当注册失败时会返回真值,执行if语句
{ printk("S3C keyboard platform device register failure\n"); return rv; } printk("S3C keyboard platform device register ok\n"); return 0;}static void __exit s3c_keyboard_dev_exit(void){ printk("S3C keyboard device exit\n"); platform_device_unregister(&s3c_keyboard_device); return ;}module_init(s3c_keyboard_dev_init);module_exit(s3c_keyboard_dev_exit);MODULE_DESCRIPTION("FL2440 board keyboard input driver platform_device");MODULE_AUTHOR("fanmaolin");MODULE_LICENSE("GPL");MODULE_ALIAS("platform:FL2440 keyboard device");


kbd_driver.h
#ifndef  _KBD_DRIVER_H_
#define  _KBD_DRIVER_H_

/* keyboard hardware informtation structure definition */
typedef struct s3c_kbd_info_s
{
    int                     code;      /* input device key code  */
    int                     nIRQ;      /* keyboard IRQ number*/
    unsigned int            setting;   /* keyboard IRQ Pin Setting*/
    unsigned int            gpio;      /* keyboard GPIO port */
} s3c_kbd_info_t;

/* keyboard platform device private data structure */
typedef struct s3c_kbd_platform_data_s
{
    s3c_kbd_info_t         *keys;
    int                    nkeys;
} s3c_kbd_platform_data_t;

#endif   /* ----- #ifndef _KBD_DRIVER_H_  ----- */


下面是带你飞的干货时段

之前在platform_led中分析过platform总线把设备和驱动放在一个双向链表上,设备链表和驱动链表,他们在链表上找到彼此,然后绑定。

先来分析kbd_device.c

看一段驱动代码,要先从module_init下手,就是几乎在最后边的那个,向上找到s3c_keyboard_dev_init,首先rv = platform_device_register(&s3c_keyboard_device);,在驱动链表上注册设备,(可以理解为设备告诉总线我来了,赶紧让驱动过来得意),你会看到他调用了s3c_keyboard_device,向上找到它,

static struct platform_device s3c_keyboard_device = {
    .name    = "s3c_kbd",//设备名,通过这个在总线上找到driver
    .id      = 1,//一般情况下id都为1
    .dev     =
    {//platform_data成员用来存放设备的硬件信息
        .platform_data = &s3c_kbd_data,//在Platform总线上找到彼此后由设备告诉驱动硬件信息,见53行
        .release = platform_kbd_release,//在rmmod时要用到,见58行定义
    },
};

嗯,这一段我已经注释的很清楚了

然后我们来看一下s3c_kbd_data,

static s3c_kbd_platform_data_t s3c_kbd_data = {
    .keys = s3c_kbd_gpios,//按键硬件信息
    .nkeys = ARRAY_SIZE(s3c_kbd_gpios),/*ARRAY_SIZE函数用来求s3c_kbd_gpios[]这个数组有多少个元素,用它来自动获取设备数,
	该函数的宏定义为:#define ARRAY_SIZE(x) (sizeof(x)/sizeof(x[0])) 	sizeof(x)是求该结构体的大小然后除以sizeof(x[0])第一个的大小		就能得到该数组有多少个元素*/
};
注意ARRAY_SIZE函数

还有s3c_kbd_gpios,一大串

platform_kbd_release,我们在这里啥都不干

然后呢,就是退出时调用module_exit


下面来看kbd_driver.c

还是先从module_init入手去看s3c_keyboard_drv_init,再去看s3c_keyboard_driver

static struct platform_driver s3c_keyboard_driver = {		//总线按键驱动结构体
     .probe      = s3c_kbd_probe,//probe中文译为探测,一旦发现相应设备就调用这个函数
     .remove     = s3c_kbd_remove,//一旦设备移除就调用该函数
     .driver     = {
         .name       = "s3c_kbd",		//要和device  一致
         .owner      = THIS_MODULE,
     },
};
对比之前device的代码,你会发现.name是一样的,你说他们是怎么在总线上找到彼此呢?就是通过这个哦!害羞找到之后要干点啥呢!!!当然不会啥都不干啦!

注意probe函数

static int s3c_kbd_probe(struct platform_device *pdev)。。。。
一直到
 if(s3c_kbd)
    {
        kfree(s3c_kbd);
    }
    printk("s3c_kbd_probe failed\n");


    return -ENODEV;
}

搞了一个空的struct input_dev一会要分配,s3c_kbd_platform_data_t     *pdata = pdev->dev.platform_data;//*pdata指向设备信息从这里看出,驱动和设备的信息被隔离开

你还记得kbd_device里的platform_data吗?接下来就是分配内核空间、填充信息一类的事情了

注意这一段,这就是按键驱动与led驱动最大的不同点,中断,消抖(因为硬件的因素,不可避免出现抖动,程序要避免这种bug采用定时器,这里面有很多概念的引入)

 /* Initialize all the keys and interrupt  初始化所有按键、中断 */
    for(i=0; i<pdata->nkeys; i++)
    {
        set_bit(pdata->keys[i].code, input_dev->keybit);
        s3c2410_gpio_cfgpin(pdata->keys[i].gpio, pdata->keys[i].setting);//将按键设为中断模式 
        irq_set_irq_type(pdata->keys[i].nIRQ, IRQ_TYPE_EDGE_BOTH);/*IRQ_TYPE_EDGE_BOTH (IRQ_TYPE_EDGE_FALLING | IRQ_TYPE_EDGE_RISING)中断的触发类型:上下沿都触发
																	更多的触发方式参考:http://blog.chinaunix.net/uid-25445243-id-4052877.html	或 http://blog.sina.com.cn/s/blog_640029b30100uw2f.html*/
        
        rv = request_irq(pdata->keys[i].nIRQ/*中断号*/, s3c_kbd_intterupt/*中断名字*/, IRQF_DISABLED/*表示处理这个中断时禁止其他中断,即这是一个快速中断*/, pdev->name, pdev);//上面设置完中断后这里申请(安装)中断,一旦有中断发生,就调用这个中断程序(也叫中断服务处理程序)
        if( rv )
        {
            printk("error: request IRQ[%d] for key<%d> failure\n", pdata->keys[i].nIRQ, i);
            rv = -EBUSY;
            goto fail;
        }
        
        //printk("s3c_kbd request IRQ[%d] for key<%d> ok\n", pdata->keys[i].nIRQ, i);

         /* Initialize all the keys cancel dithering timer 	初始化所有按键消抖定时器*/
        setup_timer(&s3c_kbd->timers[i], cancel_dithering_timer_handler, i);
    }

这句话很重要:

  rv = request_irq(pdata->keys[i].nIRQ/*中断号*/, s3c_kbd_intterupt/*中断名字*/, IRQF_DISABLED/*表示处理这个中断时禁止其他中断,即这是一个快速中断*/, pdev->name, pdev);//上面设置完中断后这里申请(安装)中断,一旦有中断发生,就调用这个中断程序(也叫中断服务处理程序)


cancel_dithering_timer_handler

static void  cancel_dithering_timer_handler(unsigned long data)//消抖定时器处理方式:当62行延时结束后就来到这里调用这个函数,看看是否按键按下,这样就利用timer(定时器)来消抖
{
    int                      which =(int)data;
    unsigned int             pinval;  

    pinval = s3c2410_gpio_getpin(s3c_kbd->pdata->keys[which].gpio);	//获取按键引脚电平

    if( pinval ) 
    {
        //printk("s3c_kbd key[%d] code[%d] released\n", which, s3c_kbd->pdata->keys[which].code);
        input_event(s3c_kbd->input_dev, EV_KEY, s3c_kbd->pdata->keys[which].code, 0);  
    }
    else
    {
        //printk("s3c_kbd key[%d] code[%d] pressed\n", which, s3c_kbd->pdata->keys[which].code);
        input_event(s3c_kbd->input_dev, EV_KEY, s3c_kbd->pdata->keys[which].code, 1);  
    }
    
    input_sync(s3c_kbd->input_dev);
}



看 s3c_kbd_intterupt

static irqreturn_t s3c_kbd_intterupt(int irq, void *dev_id) 	//见139行,一旦发生中断后,将中断号传过来
{
    int                         i;
    int                         found = 0;
    struct platform_device      *pdev =  dev_id;
    s3c_kbd_t                   *s3c_kbd = NULL;
    
    s3c_kbd = platform_get_drvdata(pdev);
	
    for(i=0; i<s3c_kbd->pdata->nkeys; i++)	//寻找中断号
    {
        if(irq == s3c_kbd->pdata->keys[i].nIRQ)
        {
            found = 1;
            break;
        }
    }

    if(!found) /*  An ERROR interrupt */
        return IRQ_NONE;

    mod_timer(&s3c_kbd->timers[i], jiffies+CANCEL_DITHERING_DELAY);	//消抖定时器,延时		jiffies是当前时间由内核维护
	//中断的处理涉及到上半部和下半部,上半部进行响应然后离开,下半部例如定时器会在这里执行,提高效率。
    return IRQ_HANDLED;  
}


再然后就是。。。module_exit


分析完毕,补充几个概念。


中断的上半部和下半部:

上半部的功能是响应中断。当中断发生时,它就把设备驱动程序中中断处理例程的下半部挂到设备的下半部执行队列中去,然后继续等待新的中断到来。这样一来,上半部的执行速度就会很快,它就可以接受更多它负责的设备所产生的中断了。上半部之所以快,是因为它是完全屏蔽中断的,如果它没有执行完,其他中断就不能及时地处理,只能等到这个中断处理程序执行完毕以后。所以要尽可能多的对设备产生的中断进行服务和处理,中断处理程序就一定要快。

    下半部的功能是处理比较复杂的过程。下半部和上半部最大的区别是可中断,而上半部却不可中断。下半部几乎完成了中断处理程序所有的事情,因为上半部只是将下半部排到了它们所负责的设备中断的处理队列中去,然后就不做其它的处理了。下半部所负责的工作一般是查看设备以获得产生中断的事件信息,并根据这些信息(一般通过读设备上的寄存器得来)进行相应的处理。下半部是可中断的,所以在运行期间,如果其它设备产生了中断,这个下半部可以暂时的中断掉,等到那个设备的上半部运行完了,再回头运行这个下半部。

参见http://blog.csdn.net/newtonnl/article/details/46004451



request_irq

在linux内核中用于申请中断的函数是request_irq(),详情参见http://blog.chinaunix.net/uid-25445243-id-4052877.html


消抖:通过timer(定时器)处理,定时器就在下半部,比如延时50ms,这个时间要根据不同的硬件测试才能获得。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值