GPIO应用之按键FIFO驱动

本文详细介绍了如何使用FIFO机制设计按键驱动程序,包括按键按下、弹起、长按和自动连发的事件处理。通过裸机编程和状态机的概念,解释了FIFO的工作原理,以及如何在按键检测中应用滤波机制消除抖动。同时讨论了中断和查询方式在按键检测中的优缺点,特别是在操作系统环境下的资源消耗和稳定性考虑。
摘要由CSDN通过智能技术生成

驱动设计:

此案件驱动程序来源于安富莱,用于扫描独立按键,具有软件滤波机制,采用FIFO保存键值。可以检测如下事件:

       按键按下

       按键弹起

       长按键

       长按时自动连发

问题来了,什么是FIFO?

eg:FIFO机制和状态机一样,都是在裸机编程中非常重要的编程思想。

FIFO是先入先出的意思,即谁先进入队列,谁先出去。比如我们需要串口打印数据,当使用缓存将该数据保存的时候,在输出数据时必然是先进入的数据先出去,那么该如何实现这种机制呢?首先就是建立一个缓存空间,这里假设为10个字节空间进行说明。

从上图可知,使用FIFO就先要定义一个结构体,来储存他的三个成员。缓存区Buf[]数组,缓存区写指针Write,缓存区读指针Read。

初始状态下,Read=Write=0;

typedef struct 
{
	uint8_t Buf[10];  //缓存区
	uint8_t Write;    //缓存区写指针
	uint8_t Read;    //缓存区读指针 
	
}KEY_FIFO_T;

我们依次按下k1,k2,FIFO中的数据变为:

如果Write不等于Read,我们就认为有新的按键事件发生。

读取一个按键进行处理后,Read变量变为1,Write变量保持不变

然后我们继续读取三个按键进行处理,Read变量变为4,此时Read=Write=4。两个变量已经相等,就表示没有新的按键进行处理。 

注意: 当FIFO缓存区写满后,write会被重新赋值为0,也就是从第一个字节空间填数据进去,如果这个地址空间的数据还没有被都出去,就会被后来的数据覆盖调,这里开辟了十个字节的缓存区,一般够用。

设计FIFO按键的三个好处:

  1. 可靠地记录每一个按键事件,避免遗漏按键事件。特别是需要实现按键的按下、长按、自动连发、弹起等事件时。

  2. 读取按键的函数可以设计为非阻塞的,不需要等待按键抖动滤波处理完毕。

  3. 按键 FIFO 程序在嘀嗒定时器中定期的执行检测,不需要在主程序中一直做检测,这样可以有效地降低系统资源消耗。

按键FIFO的实现

一,定义结构体:

在我们的key.h文件中定义一个结构体类型为KEY_FIFO_T的结构体。就是前面说的那个结构体。这只是类型声明,并没有分配变量空间。

typedef struct 
{
	uint8_t Buf[10];  //缓存区
	uint8_t Write;    //缓存区写指针
	uint8_t Read;    //缓存区读指针 
	
}KEY_FIFO_T;

接着在key.c 中定义 s_tKey 结构变量, 此时编译器会分配一组变量空间。

static KEY_FIFO_T s_tkey; //按键FIFO结构体变量
二,将按键状态写入到FIFO缓存区:

当检测到按键事件发生后,就可以调用KEY_FIFO_Put函数将键值压入FIFO中。

void KEY_FIFO_Put(uint8_t Key_Code)
{
    s_tkey.Buf[s_tkey.Write]=Key_Code;
    if(++s_tkey.Write>=KEY_FIFO_SIZE)
    {
        s_tkey.Write=0;
    }

}

每压一次,就是每调用一次KEY_FIFO_Put()函数,写指针write就++一次,也就是向后移动一个空间,如果FIFO空间写满了,也就是s_tKey.Write >= KEY_FIFO_SIZE,Write会被重新赋值为 0。

三,将按键状态从FIFO中读出来:
//从缓存区数组中读取按键键值
uint8_t KEY_FIFO_Get(void)
{   uint8_t ret;
    if(s_tkey.Read==s_tkey.Write)  //表示缓存区为空
        return KEY_NONE;  //
    else {
        ret=s_tkey.Buf[s_tkey.Read];
        if(++s_tkey.Read>=KEY_FIFO_SIZE)
        {
            s_tkey.Read=0;
        }
        return ret;
    }

}

如果返回值KEY_NONE为0,表示写入的指针和读出的指针相等,那么,按键缓冲区为空,所有的按键时间已经处理完毕。如果不相等就说明FIFO的缓冲区不为空,将Buf中的数读出给ret变量。同样,如果FIFO空间读完了,没有缓存了,也就是s_tKey.Read >= KEY_FIFO_SIZE,Read也会被重新赋值为 0。按键的键值定义在key.h 文件,下面是具体内容:

//按次序定义按键的按下,弹起,长按事件
typedef enum 
{
 KEY_NONE=0,
	
 KEY_1_DOWN,	
 KEY_1_UP,	
 KEY_1_LONG,	
	
 KEY_2_DOWN,	
 KEY_2_UP,	
 KEY_2_LONG,	

 KEY_3_DOWN,	
 KEY_3_UP,	
 KEY_3_LONG,
	
 KEY_4_DOWN,	
 KEY_4_UP,	
 KEY_4_LONG,	
  	
 KEY_5_DOWN,	
 KEY_5_UP,	
 KEY_5_LONG,

 KEY_6_DOWN,	
 KEY_6_UP,	
 KEY_6_LONG,
	
	
}KEY_ENUM;

必须按次序定义每个键的按下、弹起和长按事件,即每个按键对象占用 3 个数值。推荐使用枚举enum, 不用#define的原因是便于新增键值,方便调整顺序。使用{ } 将一组相关的定义封装起来便于理解。编译器也可帮我们避免键值重复。

四,按键检测程序分析

上面说了如何将按键的键值存入和读出FIFO,但是既然是按键操作,就肯定涉及到按键消抖处理,还有按键的状态是按下还是弹起,是长按还是短按。所以为了以示区分,我们用还需要给每一个按键设置很多参数,就需要再定义一个结构体KEY_T,让每个按键对应1个全局的结构体变量。

//检测按键是长按还是短按,为它设置一系列对应的参数

typedef struct
{
	/*下面是一个函数指针,指向判断按键是否按下的函数*/
	//1表示按下
	uint8_t (*IsKeyDownFunc)(void);
    
    uint8_t count; //滤波计数器
    uint16_t	LongCount;  //长按计数器
	uint16_t LongTime;   //按键按下持续时间,0表示按键不检测长按
	uint8_t State;    //按键当前状态
	uint8_t RepeatSpeed;  //连续按键周期
	uint8_t RepeatCount; //连续按键计数器
	
}KEY_T;

在key.c 中定义s_tBtn结构体数组变量。

static KEY_FIFO_T s_tkey; //按键FIFO结构体变量
static KEY_T s_tBtn[HARD_KEY_NUM ]= {0}; //目前有四个按键,要为每一个按键分配结构体变量,所以定义一个KEY_T结构体类型的数组变量

每个按键对象都分配一个结构体变量,这些结构体变量以数组的形式存在将便于我们简化程序代码行数。因为我的硬件有6个按键,所以在key.h里有 #define HARD_KEY_NUM   6。使用函数指针IsKeyDownFunc可以将每个按键的检测以及组合键的检测代码进行统一管理。

因为函数指针必须先赋值,才能被作为函数执行。因此在定时扫描按键之前,必须先执行一段初始化函数来设置每个按键的函数指针和参数。这个函数是void KEY_Init(void)

//初始化各个按键的指针参数
void KEY_FIFO_Init(void)
{
    uint8_t i;

    /* 对按键FIFO读写指针清零 */
    s_tkey.Read = 0;
    s_tkey.Write = 0;

    /* 给每个按键结构体成员变量赋一组缺省值 */
    for (i = 0; i < HARD_KEY_NUM; i++)
    {
        s_tBtn[i].LongTime = KEY_LONG_TIME;/* 长按时间 0 表示不检测长按键事件 */
        s_tBtn[i].count = KEY_FILTER_TIME/ 2;	/* 计数器设置为滤波时间的一半 */
        s_tBtn[i].State = 0;/* 按键缺省状态,0为未按下 */
        s_tBtn[i].RepeatSpeed = 6;/* 按键连发的速度,0表示不支持连发 */
        s_tBtn[i].RepeatCount = 0;/* 连发计数器 */
    }
    /* 判断按键按下的函数 */
    s_tBtn[0].IsKeyDownFunc = IsKey1Down;
    s_tBtn[1].IsKeyDownFunc = IsKey2Down;
    s_tBtn[2].IsKeyDownFunc = IsKey3Down;
    s_tBtn[3].IsKeyDownFunc = IsKey4Down;
    s_tBtn[4].IsKeyDownFunc = IsKey5Down;
    s_tBtn[5].IsKeyDownFunc = IsKey6Down;
}

我们知道按键会有机械抖动,你以为按键按下就是低电平,其实在按下的一瞬间会存在机械抖动,如果不做延时处理,可能会出错,一般如果按键检测到按下后再延时50ms检测一次,如果还是检测低电平,才能说明按键真正的被按下了。反之按键弹起时也是一样的。所以我们程序设置按键滤波时间50ms, 因为代码每10ms扫描一次按键,所以按键的单位我们可以理解为10ms,滤波的次数就为5次。这样只有连续检测到50ms状态不变才认为有效,包括弹起和按下两种事件,即使按键电路不做硬件滤波(没有电容滤波),该滤波机制也可以保证可靠地检测到按键事件。

判断按键是否按下,用一个HAL_GPIO_ReadPin就可以搞定。

//按键检测
uint8_t IsKey1Down(void)
{
    if(HAL_GPIO_ReadPin(KEY1_GPIO_Port,KEY1_Pin)==GPIO_PIN_RESET)
        return 1;

    else
        return 0;
}
uint8_t IsKey2Down(void)
{
    if(HAL_GPIO_ReadPin(KEY2_GPIO_Port,KEY2_Pin)==GPIO_PIN_RESET)
        return 1;
    else
        return 0;
}
uint8_t IsKey3Down(void)
{
    if(HAL_GPIO_ReadPin(KEY3_GPIO_Port,KEY3_Pin)==GPIO_PIN_RESET)
        return 1;
    else
        return 0;
}
uint8_t IsKey4Down(void)
{
    if(HAL_GPIO_ReadPin(KEY4_GPIO_Port,KEY4_Pin)==GPIO_PIN_RESET)
        return 1;
    else
        return 0;
}
uint8_t IsKey5Down(void)
{
    if(HAL_GPIO_ReadPin(KEY4_GPIO_Port,KEY4_Pin)==GPIO_PIN_RESET)
        return 1;
    else
        return 0;
}
uint8_t IsKey6Down(void)
{
    if(HAL_GPIO_ReadPin(KEY4_GPIO_Port,KEY4_Pin)==GPIO_PIN_RESET)
        return 1;
    else
        return 0;
}
五,按键扫描

按键扫描函数KEY_Scan()每隔 10ms 被执行一次。RunPer10ms函数在 systick中断服务程序中执行。我这里因为用到了操作系统,所以直接创建一个10ms为周期的任务调用函数,当然你也可以使用定时器。

void Soft_Init(void)
{
	 KEY_FIFO_Init();
	
	xTaskCreate(    vDisplay_Task, //任务函数
		"vDisplay_Task",  //任务名
				128,     //STACK大小
				NULL,    //任务参数
				2,        //任务优先级
				NULL);    //任务句柄
	while (1)
	{
		vTaskDelay(20);
		
		//RunPer10ms();
		 KEY_Scan();
		// printf("写入\r\n");
	} 
}
void KEY_Scan(void)
{

    uint8_t i;
    for (i = 0; i < HARD_KEY_NUM; i++)
    {

        KEY_Detect(i);

    }

}

每隔10ms所有的按键GPIO均会被扫描检测一次。KEY_Detect函数实现如下:

void KEY_Detect(uint8_t i)
{

    KEY_T *pBtn;

    pBtn =&s_tBtn[i];  //读取相应的结构体地址赋值给结构体变量pBtn

    if(pBtn->IsKeyDownFunc()) {  //这里执行的是按键按下的处理


        //进行50ms滤波,滤除不正常的初始值状态,按键滤波前给count一个初值,前面说按键初始化的时候已经设置了 Count = KEY_FILTER_TIME/2
        if(pBtn->count<KEY_FILTER_TIME)
        {

            pBtn->count=KEY_FILTER_TIME;

        }
        //实现KEY_FILTER_TIME的延迟
        else if (pBtn->count<2*KEY_FILTER_TIME)
        {

            pBtn->count++;
        }
        else     //判断按键是否长按,进行处理  
        {
            /*
            **********************************************************************************
            这个 State 变量是有其实际意义的,如果按键按下了,这里就将其设置为 1,如果没有按下这个
            变量的值就会一直是 0,这样设置的目的可以有效的防止一种情况的出现:比如按键 K1 在某个
            时刻检测到了按键有按下,那么它就会做进一步的滤波处理,但是在滤波的过程中,这个按键
            按下的状态消失了,这个时候就会进入到上面第二步 else 语句里面,然后再做按键松手检测滤波
            ,滤波结束后判断这个 State 变量,如果前面就没有检测到按下,这里就不会记录按键弹起。
            **********************************************************************************
            */

            
            if(pBtn->State==0) {  //首次按下
                pBtn->State=1;
                KEY_FIFO_Put((uint8_t)(3*i+1));
            }
            if (pBtn->LongTime > 0) /*LongTime初始值是100。单位10ms,持续1秒,认为支持长按事件*/
            {
                if (pBtn->LongCount < pBtn->LongTime) /*LongCount长按计数器。单位10ms,持续1秒,认为长按事件*/
                {
                    /* 发送按钮持续按下的消息 */
                    if (++pBtn->LongCount == pBtn->LongTime)/*LongCount等于LongTime(100),10ms进来一次,进来了100次也就是说按下时间为于1s*/
                    {
                        /* 键值放入按键FIFO */
                        KEY_FIFO_Put((uint8_t)(3 * i + 3));
                    }
                }
                else/*LongCount大于LongTime(100),也就是说按下时间大于1s,触发长按连发事件*/
                {
                    if (pBtn->RepeatSpeed > 0)/* RepeatSpeed连续按键周期,控制发送长按按键长按的速度 */
                    {
                        if (++pBtn->RepeatCount >= pBtn->RepeatSpeed)
                        {
                            pBtn->RepeatCount = 0;
                            /* 长按键后,每隔60ms发送1个按键。因为长按也是要发送键值得,10ms进来一次,进来了6次,也就是说发送时间为60ms。*/
                            KEY_FIFO_Put((uint8_t)(3 * i + 1));
                        }
                    }
                }
            }
        }
    }



    //这里面执行的是按键松手的处理或者按键没有按下的处理
    else {
        if(pBtn->count>KEY_FILTER_TIME)
        {
            pBtn->count=KEY_FILTER_TIME;
        }
        else if(pBtn->count!=0)
        {
            pBtn->count--;
        }
        else
        {
            if(pBtn->State==1)
            {
                pBtn->State=0;
                KEY_FIFO_Put((uint8_t)(3*i+2));
            }


        }

        pBtn->LongCount = 0;
        pBtn->RepeatCount = 0;

    }
}

这个函数还是比较难以理解的,主要是结构体的操作。所以好好学习结构体,不要见了结构体就跑。

分析:首先读取相应按键的结构体地址赋值给结构体指针变量pBtn ,因为程序里面每个按键都有自己的结构体,只有通过这个方式才能对具体的按键进行操作。(在前面我们使用软件定时器时也使用了这中操作,在滴答定时器的中断服务函数中)。

KEY_T *pBtn;

    pBtn =&s_tBtn[i];  //读取相应的结构体地址赋值给结构体变量pBtn

然后接着就是给按键滤波前给Count设置一个初值,前面说按键初始化的时候已经设置了Count =5/2。然后判断是否按下的标志位,如果按键按下了,这里就将其设置为 1,如果没有按下这个变量的值就会一直是 0。这里可能不理解是就是按键按下发送的键值是3 * i + 1。按键弹起发送的键值是3 * i + 2,按键长按发送的键值是3 * i + 3。也就是说按键按下发送的键值是1和4和7。按键弹起发送的键值是2和5和8,按键长按发送的键值是3和6和9。看下面这个枚举enum你就明白了。

//按次序定义按键的按下,弹起,长按事件
typedef enum 
{
 KEY_NONE=0,
	
 KEY_1_DOWN,	
 KEY_1_UP,	
 KEY_1_LONG,	
	
 KEY_2_DOWN,	
 KEY_2_UP,	
 KEY_2_LONG,	

 KEY_3_DOWN,	
 KEY_3_UP,	
 KEY_3_LONG,
	
 KEY_4_DOWN,	
 KEY_4_UP,	
 KEY_4_LONG,	
  	
 KEY_5_DOWN,	
 KEY_5_UP,	
 KEY_5_LONG,

 KEY_6_DOWN,	
 KEY_6_UP,	
 KEY_6_LONG,
	
	
}KEY_ENUM;
六,实验演示
while(1)
	{
		
		uint8_t key_code;
		key_code=KEY_FIFO_Get();
		 //printf("key_code=%d\r\n",key_code);
		if(key_code!=KEY_NONE)
		{
			switch(key_code)
			{
				
				case KEY_1_DOWN:			/* K1键按下 */
					printf("K1键按下\r\n");
					break;
				case KEY_1_UP:				/* K1键弹起 */
					printf("K1键弹起\r\n");
					break;
				case KEY_1_LONG:				/* K1键弹起 */
					printf("K1键弹起\r\n");
					break;
				case KEY_2_DOWN:			/* K2键按下 */
					printf("K2键按下\r\n");
					break;
				case KEY_2_UP:				/* K2键弹起 */
					printf("K2键弹起\r\n");
					break;
				case KEY_2_LONG:				/* K2键弹起 */
					printf("K2键长按\r\n");
					break;
				case KEY_3_DOWN:			/* K3键按下 */
					printf("K3键按下\r\n");
					break;
				case KEY_3_UP:				/* K3键弹起 */
					printf("K3键弹起\r\n");
					break;	
				case KEY_3_LONG:				/* K3键弹起 */
					printf("K3键长按\r\n");
					break;	
				default:
					/* 其它的键值不处理 */
					break;
				
				
				
			
				
				
				
			}
			
			
		}
		//BSP_IO_T(LED1);
		//BSP_IO_T(LED2);
	}

检测按键有中断方式和GPIO查询方式两种。这里推荐用GPIO查询方式。

1.从裸机的角度分析

  • 中断方式:中断方式可以快速地检测到按键按下,并执行相应的按键程序,但实际情况是由于按键的机械抖动特性,在程序进入中断后必须进行滤波处理才能判定是否有效的按键事件。如果每个按键都是独立的接一个 IO 引脚,需要我们给每个 IO 都设置一个中断,程序中过多的中断会影响系统的稳定性。中断方式跨平台移植困难。

  • 查询方式:查询方式有一个最大的缺点就是需要程序定期的去执行查询,耗费一定的系统资源。实际上耗费不了多大的系统资源,因为这种查询方式也只是查询按键是否按下,按键事件的执行还是在主程序里面实现。

2.从OS的角度分析

  • 中断方式:在 OS 中要尽可能少用中断方式,因为在RTOS中过多的使用中断会影响系统的稳定性和可预见性。只有比较重要的事件处理需要用中断的方式。

  • 查询方式:对于用户按键推荐使用这种查询方式来实现,现在的OS基本都带有CPU利用率的功能,这个按键FIFO占用的还是很小的,基本都在1%以下。

  • 19
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
1、设计按键FIFO的优点   要介绍实现按键FIFO的优点,首先要了解FIFO的一些基本概念。FIFO即First In First Out,是一种先进先出的数据缓存方式,例如在超市购物之后我们会提着满满的购物车来到收银台排在结账队伍的最后等待付款,先排队的客户先付款离开,后面排队的只有等待前面付款离开才能进行付款。说白了FIFO就是这样一种先进先出机制,先存入的数据在读取时最先被读取到。   设计按键FIFO注意有三个方面的优点(来自于安富莱电子Eric2013大佬总结):   1、可以有效记录按键事件的发生,特别是系统要实现记录按键按下、松开、长按时,使用FIFO来实现是一种不错的选择方式。   2、系统是阻塞的,这样系统在检测到按键按下的情况,由于机械按键抖动的原因不需要在这里等待一段时间,然后在确定按键是否正常按下。   3、按键FIFO程序在系统定时器中定时检测按键状态,确认按键按下后将状态写入FIFO中,不一定在主程序中一直做检测,这样可以有效降低系统资源的消耗。 2、按键的硬件设计   按键的原理图如上图所示,对于KEY0~KEY2这三个按键,一端接地,另一端连接stm32GPIO端口。当按键按下时相应的IO口被拉低,如果把GPIO口配置为输入模式,此时读取相应的IO口电平,就可以检测到按键是否被按下。对于KEY_UP按键则是与前面三个按键相反,IO口配置为输入模式时,读取到高电平时表示按键按下。因为机械固有的物理特性,按键按下内部弹簧片在瞬间接触的时候会有力学的回弹,造成2-8毫秒内信号不稳定,所以在设计检测机械按键是否按下的程序时,应考虑到按键消抖问题。
在设备树中配置GPIO按键驱动需要完成以下几个步骤: 1. 定义GPIO节点 在设备树中需要定义GPIO节点,节点包含GPIO的编号、使用模式、中断类型等信息。例如: gpio-keys { compatible = "gpio-keys"; pinctrl-names = "default"; pinctrl-0 = <&gpio_keys_pins>; #address-cells = <1>; #size-cells = <0>; power { label = "Power button"; gpios = <&gpio1 0 GPIO_ACTIVE_LOW>; linux,code = <KEY_POWER>; debounce-interval = <50>; interrupt-parent = <&gpio1>; interrupts = <0 IRQ_TYPE_EDGE_FALLING>; }; }; 2. 定义中断控制器节点 中断控制器节点是一个必要的节点,它描述了中断控制器的类型、中断号等信息。例如: gpio_keys_pins: gpio_keys_pins { gpio-key1 { gpio-hog; gpios = <&gpio1 0 GPIO_ACTIVE_LOW>; output-low; line-name = "Power Button"; }; }; 3. 配置中断控制器 在设备树中需要配置中断控制器,使其能够正确的处理GPIO中断。例如: gpio1: gpio@4804c000 { compatible = "ti,omap4-gpio"; reg = <0x4804c000 0x1000>; interrupts = <34>; gpio-controller; #gpio-cells = <2>; interrupt-controller; #interrupt-cells = <2>; interrupt-parent = <&intc>; }; 4. 在驱动中解析设备树 在Linux内核驱动中需要解析设备树,获取GPIO节点的信息,从而正确的控制GPIO。例如: static int gpio_keys_probe(struct platform_device *pdev) { struct device_node *np = pdev->dev.of_node; struct gpio_keys_drvdata *ddata; struct input_dev *input_dev; struct gpio_desc *desc; int i, err; ddata = devm_kzalloc(&pdev->dev, sizeof(*ddata), GFP_KERNEL); if (!ddata) return -ENOMEM; input_dev = devm_input_allocate_device(&pdev->dev); if (!input_dev) return -ENOMEM; input_dev->name = "gpio-keys"; input_dev->id.bustype = BUS_GPIO; for_each_child_of_node(np, desc) { const char *label; err = of_property_read_string(desc, "label", &label); if (err) continue; err = of_property_read_u32(desc, "linux,code", &ddata->keycodes[i]); if (err) continue; ddata->key_count++; err = gpiod_direction_input(desc); if (err < 0) continue; ddata->gpio_descs[i] = desc; input_set_capability(input_dev, EV_KEY, ddata->keycodes[i]); i++; } input_dev->keycode = ddata->keycodes; input_dev->keycodesize = sizeof(ddata->keycodes[0]); input_dev->keycodemax = ddata->key_count; ddata->input_dev = input_dev; platform_set_drvdata(pdev, ddata); err = input_register_device(input_dev); if (err) { dev_err(&pdev->dev, "Failed to register input device: %d\n", err); return err; } return 0; } 以上就是在Android系统中配置GPIO按键驱动的设备树方法。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值