总结篇 字符 设备(二)

文章详细介绍了Linux驱动中字符设备的使用,包括设备树的pinctrl和gpio子系统的应用,以及应用层如何通过VFS访问驱动层。内容涵盖了设备号的静态和动态创建,字符设备结构体初始化,以及注册和卸载设备驱动的过程。此外,还讨论了内存映射、设备节点的创建,以及platform驱动的probe和remove函数等关键概念。
摘要由CSDN通过智能技术生成

使用了设备树的pinctrl和gpio子系统

应用层访问驱动层过程

 过程大致如下:

在虚拟文件系统VFS中查找对应与字符设备对应struct inode节点
遍历散列表cdev_map,根据inod节点中的cdev_t设备号找到cdev对象
创建struct file对象
初始化struct file对象,将struct file对象中的file_operations成员指向struct cdev对象中的file_operation成员
回调open函数

简介

1、字符设备是Linux驱动中最基本的一类设备驱动,字符设备就是一个个字节,按照字节流进行读写操作的设备。(例:按键,电池等,IIC,SPI,LCD)。这些设备的驱动就叫字符设备驱动。

在Linux下一切皆为文件,驱动加载成功以后会在“/dev”目录下生成一个相对应的设备节点(文件),应用程序通过对这个“/dev/xxx”的文件进行操作,这个xxx是具体的驱动文件名字。比如/dev/led,可以通过read来读取当前灯的状态(开或者关),write可以写数据,用来控制灯开或者关,open和close就是打开或者关闭这个led驱动

在 Linux 内核文件 include/linux/fs.h 中

有个叫做 file_operations 的结构体,此结构体就是 Linux 内核驱动操作函数集合(上面那几个函数都在里面)

字符设备驱动框架

一、注册模块加载与卸载函数


module_exit(leddriver_exit);//注册模块卸载函数


static void __exit leddriver_exit(void)
  {
    i2c_del_driver(&leddriver_driver);//关联到驱动结构体
  }
module_init(xxx_init); //注册模块加载函数

static int __init xxx_init(void)//入口函数关联到驱动结构体
{
 
  int ret = 0;

  ret = i2c_add_driver(ap3216c_driver);

}

insmod drv.ko  //加载驱动模块

rmmod  drv.ko   //卸载驱动模块

后面使用这两个将之加载


用来指定加载驱动时要执行的模块加载函数;xxx_init

module_init 宏用于定义模块加载时要执行的初始化函数。当模块加载到内核中时,内核会在初始化过程中调用这个初始化函数,以执行特定的设置、分配资源、注册设备等操作。

我们知道Linux很庞大,驱动-只是它启动过程的一小部分,还有很多如内存管理、调度、算法等等。那么每次需要添加一个设备的驱动就要在启动的main函数中加初始化,就很不灵活。同时内核系统庞大,多人协同不方便,修改内核启动代码容易出错。所以在内核中利用宏来处理我们所定义的初始化代码,然后在Linux内核启动过程中统一 一个地方来调用我们定义的初始化代码,做到灵活,统一可控的代码结构
 

二、 (内存映射) 

将物理地址转换成虚拟地址

三、.注册字符设备驱动

1、注册驱动设备号到内核(设备号注册内核中,/proc/)

2、生成驱动设备节点(与用户交互,/dev/)

两种创建设备号的方法

静态创建(设置好设备号)

register_chrdev_region(dev_t from, unsigned count, const char *name);

from: 自定义的 dev_t 类型设备号 表示设备号的起始值(主+次)

count: 申请设备的数量(次设备)
name: 申请的设备名称
函数返回值:申请成功返回 0,申请失败返回负数

int major = 200;
int minor = 0;
dev_t devid = MKDEV(major, minor);        // 第一个参数是主设备号,第二个参数是次设备号
register_chrdev_region(devid, 1, "test");

动态创建(主设备号自动分配,次设备号指定起始范围)

alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count,const char *name);
dev 是设备号(主+次)
baseminor 次设备的起始地址
count  注册数量
name  设备的名字

可以申请一段连续的多个设备号,这些设备号的主设备号一样,但是次设备号不同,次设备号以baseminor为起始地址地址开始递增。一般baseminor为0,也就是说次设备号从0开始。


        2、字符设备结构体初始化

cdev两个重要结构体

1、ops设备操作结构体

2、设备号

cdev_init(&newchrled.cdev, &newchrled_fops)
 
第一个参数是字符设备结构体(也就是字符设备的操作函数)

第二个参数是操作函数结构体

   3、字符设备注册进内核(cdev添加进内核并且绑定设备号)

cdev_add(&newchrled.cdev, newchrled.devid, NEWCHRLED_CNT);

//第一个参数是设备结构体字符设备指针
//第二个参数是设备号
//第三个是设备个数

本质是将cdev写入内核中的一个哈希表中,表中同一个主设备号的会在一条链表上

cat /proc/devices :可以查看已经注册的驱动程序,但是只显示主设备号

4、自动创建设备节点

下面是创建设备文件,先创建出一个类,再调用device_create 函数创建出/dev/目录下对应的设备节点,这样在加载模块的时候,用户空间的udev就会自动响应device_create函数去创建设备节点 

也就是/dev/led这种     

     创建出一个逻辑类

newchrled.class = class_create(THIS_MODULE, NEWCHRLED_NAME);

      创建出设备节点

newchrled.device = device_create(newchrled.class, NULL, newchrled.devid, NULL, NEWCHRLED_NAME);

1、每个设备节点都有一个innode,indode是linux文件管理系统维护的一个结构体

i_rdev是该设备的设备号,i_cdev是该设备的cdev结构体

2、每一个打开的文件都会有一个file结构体,要是一个文件打开多次就会有多个file结构体,所有的file都会指向一个inode结构体

4、注册模块卸载函数

module_exit(xxx_exit); //注册模块卸载函数

几个重要的结构体

1、字符设备结构体

内核用struct cdev结构体来描述一个字符设备,并通过struct kobj_map类型的散列表cdev_map来管理当前系统中的所有字符设备


//字符设备结构体
struct cdev {
	struct kobject kobj;
	struct module *owner;
	const struct file_operations *ops;
	struct list_head list;
	dev_t dev;
	unsigned int count;
};

设备结构体,存放一些很杂的东西

struct gpioled_dev{
    dev_t devid;            /* 设备号      */
    struct cdev cdev;        /* cdev   字符设备结构体*/
    struct class *class;    /* 类         */
    struct device *device;    /* 设备      */
    int major;                /* 主设备号      */
    int minor;                /* 次设备号   */
    struct device_node    *nd; /* 设备节点 */
    int led_gpio;            /* led所使用的GPIO编号        */
};

2、设备操作结构体

应用层代码的write之后的函数会通过系统函数调用到设备操作结构体中的write函数

static struct file_operations gpioled_fops = {
	.owner = THIS_MODULE,
	.open = led_open,
	.read = led_read,
	.write = led_write,
	.release = 	led_release,
};

驱动层次的操作硬件函数

tatic ssize_t led_write(struct file *filp, const char __user *buf,
size_t cnt, loff_t *offt)
 {
    int retvalue;
    unsigned char databuf[1];
    unsigned char ledstat;
 
    retvalue = copy_from_user(databuf, buf, cnt);
   if(retvalue < 0) {
    printk("kernel write failed!\r\n");
    return -EFAULT;
    }
 
    ledstat = databuf[0]; /* 获取状态值 */

    if(ledstat == LEDON) {
    led_switch(LEDON); /* 打开 LED 灯 */
    } else if(ledstat == LEDOFF) {
    led_switch(LEDOFF); /* 关闭 LED 灯 */
    }
    return 0;
     }

read和write函数时,需要使用copy_to_user函数以及copy_from_user函数来进行数据访问,写入/读取成功函数返回0,失败则会返回未被拷贝的字节数。

平台总线框架

将驱动框架 与硬件资源分离,

编写platform驱动需要的一些东西

0、寄存器地址定义、因为这里是用地址映射,用虚拟地址进行操作 //传统字符设备驱动

1、设备结构体

2、设备具体操作函数

3、字符设备驱动操作集(file_operations)

4、platform驱动的probe函数,驱动与设备匹配后此函数就会执行(注册字符设备驱动,初始化设备(寄存器地址映射、设备))

5、remove()(卸载字符设备驱动,取消寄存器地址映射)

6、匹配列表(如果使用设备树的话通过此匹配表进行驱动匹配)

7、platform平台驱动结构体(其中包含name(其中name移动要和设备字段相对应),匹配列表,probe和remove)

8、驱动模块的加载/卸载

platform驱动结构体(probe、remove、匹配项)

static struct i2c_driver ap3216c_driver = {
	.probe = ap3216c_probe,
	.remove = ap3216c_remove,
	.driver = {
			.owner = THIS_MODULE,
		   	.name = "ap3216c"    /无设备树匹配函数
		   	.of_match_table = ap3216c_of_match, //设备树匹配函数
		   },
	.id_table = ap3216c_id,
};
static int led_probe(struct platform_device *dev)
{	
	int i = 0;
	int ressize[5];
	u32 val = 0;
	struct resource *ledsource[5];

	printk("led driver and device has matched!\r\n");
	/* 1、获取资源 */
	for (i = 0; i < 5; i++) {
		ledsource[i] = platform_get_resource(dev, IORESOURCE_MEM, i); /* 依次MEM类型资源 */
		if (!ledsource[i]) {
			dev_err(&dev->dev, "No MEM resource for always on\n");
			return -ENXIO;
		}
		ressize[i] = resource_size(ledsource[i]);	
	}	

	/* 2、初始化LED */
	/* 寄存器地址映射 */
 	IMX6U_CCM_CCGR1 = ioremap(ledsource[0]->start, ressize[0]);
	SW_MUX_GPIO1_IO03 = ioremap(ledsource[1]->start, ressize[1]);
  	SW_PAD_GPIO1_IO03 = ioremap(ledsource[2]->start, ressize[2]);
	GPIO1_DR = ioremap(ledsource[3]->start, ressize[3]);
	GPIO1_GDIR = ioremap(ledsource[4]->start, ressize[4]);
	
	val = readl(IMX6U_CCM_CCGR1);
	val &= ~(3 << 26);				/* 清除以前的设置 */
	val |= (3 << 26);				/* 设置新值 */
	writel(val, IMX6U_CCM_CCGR1);

	/* 设置GPIO1_IO03复用功能,将其复用为GPIO1_IO03 */
	writel(5, SW_MUX_GPIO1_IO03);
	writel(0x10B0, SW_PAD_GPIO1_IO03);

	/* 设置GPIO1_IO03为输出功能 */
	val = readl(GPIO1_GDIR);
	val &= ~(1 << 3);			/* 清除以前的设置 */
	val |= (1 << 3);			/* 设置为输出 */
	writel(val, GPIO1_GDIR);

	/* 默认关闭LED1 */
	val = readl(GPIO1_DR);
	val |= (1 << 3) ;	
	writel(val, GPIO1_DR);
	
	/* 注册字符设备驱动 */
	/*1、创建设备号 */
	if (leddev.major) {		/*  定义了设备号 */
		leddev.devid = MKDEV(leddev.major, 0);
		register_chrdev_region(leddev.devid, LEDDEV_CNT, LEDDEV_NAME);
	} else {						/* 没有定义设备号 */
		alloc_chrdev_region(&leddev.devid, 0, LEDDEV_CNT, LEDDEV_NAME);	/* 申请设备号 */
		leddev.major = MAJOR(leddev.devid);	/* 获取分配号的主设备号 */
	}
	
	/* 2、初始化cdev */
	leddev.cdev.owner = THIS_MODULE;
	cdev_init(&leddev.cdev, &led_fops);
	
	/* 3、添加一个cdev */
	cdev_add(&leddev.cdev, leddev.devid, LEDDEV_CNT);

	/* 4、创建类 */
	leddev.class = class_create(THIS_MODULE, LEDDEV_NAME);
	if (IS_ERR(leddev.class)) {
		return PTR_ERR(leddev.class);
	}

	/* 5、创建设备 */
	leddev.device = device_create(leddev.class, NULL, leddev.devid, NULL, LEDDEV_NAME);
	if (IS_ERR(leddev.device)) {
		return PTR_ERR(leddev.device);
	}

	return 0;
}

led_remove

static int led_remove(struct platform_device *dev)
{
	iounmap(IMX6U_CCM_CCGR1);
	iounmap(SW_MUX_GPIO1_IO03);
	iounmap(SW_PAD_GPIO1_IO03);
	iounmap(GPIO1_DR);
	iounmap(GPIO1_GDIR);

	cdev_del(&leddev.cdev);/*  删除cdev */
	unregister_chrdev_region(leddev.devid, LEDDEV_CNT); /* 注销设备号 */
	device_destroy(leddev.class, leddev.devid);
	class_destroy(leddev.class);
	return 0;
}

Pinctrl子系统与GPIO子系统(设备树)

设备树(基本信息与配置信息)

设备树中是一些设备的信息,比如分辨率、 CPU 架构、主频、外设寄存器地址范
围,比如 UART、IIC 等等。
DTS是设备树源码,DTB是前者编译得到的二进制文件
dts 可重用部分
dtsi 每个芯片的特别部分((使用头文件引用dts这种适合所有平台的共有设置))


pinctrl 与gpio子系统(对设备树中众多的信息进行了管理)

pinctrl 负责管理引脚的功能,gpio子系统管理gpio的输入,输出属性

pinctrl   管理设备树中的引脚的配置信息主要是复用信息,gpio子系统管理设备树中gpio引进的配置信息(输入,输出,输出值)

在驱动代码中可以通过这些子系统的函数,获得来着设备树的关键配置信息。


设备树与驱动代码

在启动过程中,引导加载器(bootloader)会加载设备树文件,并将其传递给内核。
内核在启动时会解析设备树,提取其中的硬件配置信息,并根据这些信息完成相应的设备的初始化和驱动的加载

pinctrl、gpio子系统在驱动程序中的应用

#include <linux/types.h>
#include <linux/kernel.h>
#include <linux/delay.h>
#include <linux/ide.h>
#include <linux/init.h>
#include <linux/module.h>
#include <linux/uaccess.h>
#include <linux/cdev.h>			// cdev_init
#include <linux/device.h>		// device_create
#include <linux/err.h>			// IS_ERR
#include <asm/io.h>				// ioremap、iounmap
#include <linux/of.h>			// 获取设备树属性 API
#include <linux/of_address.h>	// of_ioremap
#include <linux/of_gpio.h>		// of_get_named_gpio
 
#define CHRDEVBASE_NAME "chrdevbase" 	/* 设备名 */
 
/* 寄存器虚拟地址 */
static void __iomem* CCM_CCGR1;
static void __iomem* IOMUXC_SW_MUX_CTL_PAD_GPIO1_IO03;
static void __iomem* IOMUXC_SW_PAD_CTL_PAD_GPIO1_IO03;
static void __iomem* GPIO1_GDIR;
static void __iomem* GPIO1_DR;
 
static u32 val;
 
enum LED_STAT {
	LED_ON,
	LED_OFF
};
 
struct chrdev_led_t{
	struct class* class;		/* 设备节点所属类 */
	struct device* driver_node;	/* 驱动文件节点 */
 
	struct cdev dev;		/* 字符设备 */
	dev_t devid;			/* 设备号 */
	int major;				/* 主设备号 */
	int minor;				/* 次设备号 */
 
	struct device_node* gpioNode;	/* 设备树节点 */
	int gpioNum;					/* gpio 引脚编号 */
};	
static struct chrdev_led_t chrdev_led;
 
/*
 * @description 	: 打开设备
 * @param – pinode 	: 传递给驱动的 inode
 * @param - pfile 	: 设备文件,file 结构体有个叫做 private_data 的成员变量
 * 一般在 open 的时候将 private_data 指向设备结构体。
 * @return 			: 0 成功;其他 失败
 */
static int chrdevbase_open(struct inode *pinode, struct file *pfile)
{
    /* 用户实现具体功能 */
	printk("open chrdevbase\n");
	pfile->private_data = &chrdev_led;
    return 0;
}
 
/*
 * @description 	: 从设备读取数据
 * @param - pfile	: 要打开的设备文件(文件描述符)
 * @param - buf 	: 返回给用户空间的数据缓冲区
 * @param - cnt 	: 缓冲区长度
 * @param - offset	: 相对于文件首地址的偏移
 * @return 			: 读取的字节数,如果为负值,表示读取失败
 */
static ssize_t chrdevbase_read(struct file *pfile, char __user *buf, size_t cnt, loff_t *offset)
{
    /* 用户实现具体功能 */
	struct chrdev_led_t* pdev = pfile->private_data;
	
	const char* msg = "hello, user";
	int ret = copy_to_user(buf, msg, cnt);
	if(ret == 0)
	{
		printk("kernel send data ok!\n");
	}
	else
	{
		printk("kernel send data failed!\n");
	}
 
    return 0;
}
 
/*
 * @description 	: 向设备写数据
 * @param - pfile	: 要打开的设备文件(文件描述符)
 * @param - buf 	: 要给设备写入的数据(用户缓冲区)
 * @param - cnt 	: 要写入的数据长度
 * @param - offset	: 相对于文件首地址的偏移
 * @return 			: 写入的字节数,如果为负值,表示写入失败
 */
static ssize_t chrdevbase_write(struct file *pfile, const char __user *buf, size_t cnt, loff_t *offset)
{
    // 获取模块数据
	struct chrdev_led_t* pdev = pfile->private_data;
 
	printk("write chrdevbase\n");
	u8 databuf[1];
	u8 ledstat;
	u32 ret = 0;
	
	// 将数据从用户缓冲区拷贝到内核缓冲区
	ret = copy_from_user(databuf, buf, cnt);
	if(ret != 0)
		return 0;
 
	ledstat = buf[0] - '0';
	printk("led state: %d\n", ledstat);
	gpio_set_value();
	if (ledstat == LED_ON)
	{
		gpio_set_value(pdev->gpioNum, 0);
	}
	else if(ledstat == LED_OFF)
	{
		gpio_set_value(pdev->gpioNum, 1);
	}
    return cnt;
}
 
/*
 * @description 	: 关闭/释放设备
 * @param - pfile	: 要关闭的设备文件(文件描述符)
 * @return 			: 0 成功;其他 失败
 */
static int chrdevbase_release (struct inode *pinode, struct file * pfile)
{
    /* 用户实现具体功能 */
	printk("close chrdevbase\n");
 
    return 0;
}
 
/*
 * 设备操作函数结构体
 */
static struct file_operations chrdevbase_fops = {
	.owner = THIS_MODULE, 
	.open = chrdevbase_open,
	.read = chrdevbase_read,
	.write = chrdevbase_write,
	.release = chrdevbase_release,
};
 
/*
 * @description	: 驱动入口函数 
 * @param 		: 无
 * @return 		: 0 成功;其他 失败
 */
static int __init chrdevbase_init(void)
{
	u32 ret = 0;
	const char* outstr;
	u32 regData[10];
 
	/* 通过设备树获取到寄存器地址 */
	// 获取节点
	chrdev_led.gpioNode = of_find_node_by_path("/gpio-led"); 
	if(chrdev_led.gpioNode == NULL)
	{	
		printk("node cannot be found!\n");
		return -1;
	}
	// 读取gpio编号
	chrdev_led.gpioNum = of_get_named_gpio(chrdev_led.gpioNode, "led-gpio", 0);
	if (chrdev_led.gpioNum < 0)
	{
		printk("gpio property fetch failed!\n");
		return -1;
	}
	printk("led-gpio num = %d\r\n", chrdev_led.gpioNum);
 
	// 配置 GPIO1_IO03 为输出且高电平,默认关闭LED
	ret = gpio_direction_output(chrdev_led.gpioNum, 1);
	if (ret < 0)
	{
		printk("gpio set failed!\n");
		return -1;
	}
 
	/* 1. 注册设备号 */
	if (chrdev_led.major)
	{
		chrdev_led.devid = MKDEV(chrdev_led.major, 0);
		ret = register_chrdev_region(chrdev_led.devid, 1, CHRDEVBASE_NAME);
	}
	else
	{
		ret = alloc_chrdev_region(&chrdev_led.devid, 0, 1, CHRDEVBASE_NAME);
		chrdev_led.major = MAJOR(chrdev_led.devid);
		chrdev_led.minor = MINOR(chrdev_led.devid);
	}
 
	/* 2. 初始化字符设备 */
	chrdev_led.dev.owner = THIS_MODULE;
	cdev_init(&chrdev_led.dev, &chrdevbase_fops);					// 初始化字符设备
	/* 3. 将字符设备添加到内核 */
	cdev_add(&chrdev_led.dev, chrdev_led.devid, 1);			// 将字符设备添加到内核
 
	/* 自动创建设备节点 */
	// 设备节点所属类
 	chrdev_led.class = class_create(THIS_MODULE, CHRDEVBASE_NAME);
	if (IS_ERR(chrdev_led.class))
	{
		return PTR_ERR(chrdev_led.class);
	}
	// 创建驱动文件节点
	chrdev_led.driver_node = device_create(chrdev_led.class, NULL, chrdev_led.devid, NULL, CHRDEVBASE_NAME);
	if (IS_ERR(chrdev_led.driver_node))
	{
		return PTR_ERR(chrdev_led.driver_node);
	}
	
	printk("chrdevbase init!\n");
	return 0;
}
 
/*
 * @description	: 驱动出口函数
 * @param 		: 无
 * @return 		: 无
 */
static void __exit chrdevbase_exit(void)
{
	/* 取消虚拟地址和物理地址的映射 */
	iounmap(CCM_CCGR1);
	iounmap(IOMUXC_SW_MUX_CTL_PAD_GPIO1_IO03);
	iounmap(IOMUXC_SW_PAD_CTL_PAD_GPIO1_IO03);
	iounmap(GPIO1_GDIR);
	iounmap(GPIO1_DR);
	
	/* 注销字符设备 */
	unregister_chrdev_region(chrdev_led.devid, 1);		// 注销设备号
	cdev_del(&chrdev_led.dev);							// 卸载字符设备
	
	device_destroy(chrdev_led.class, chrdev_led.devid);	// 删除节点
	class_destroy(chrdev_led.class);					// 删除类
	printk("chrdevbase exit!\n");
}
 
/* 
 * 将上面两个函数指定为驱动的入口和出口函数 
 */
module_init(chrdevbase_init);
module_exit(chrdevbase_exit);
 
/* 
 * LICENSE和作者信息
 */
MODULE_LICENSE("GPL");
MODULE_AUTHOR("author_name");

设备树节点解析过程

驱动内核中存在类似结构体,等到设备树解析的时候,系统会自动创建实例生成各种信息结构体

一、设备树节点转化为platform_device的过程

1、dts文件转化过程


dts文件编译成为dtb文件之后供给内核解析,设备树中的每个节点都会转化为device_node节点,其中满足某些条件的节点将会被转化为platform_device节点


2、dts中的节点转化为platform_device节点的条件


只需包含下面的任意一个条件就能转化为platform_device节点
(1)根节点下的含有compatible属性的子节点,compatible属性是用于匹配驱动
如:节点key1和节点led将会被转回为platform_device节点

(2)如果节点中的compatible属性包含了"simple-bus"或者"simple-mdf"或者"isa"或者"arm,amba-bus",并且该节点的子节点包含compatible属性,那么该子节点就能转化为platform_device节点(IIC、SPI节点下的子节点即使满足条件也不应被转化为platform_device节点,应该交由对应的总线驱动程序去处理,而不是platform总线)


二、设备树节点转化为platform_device后匹配驱动的过程
1、转化之后节点信息的保存
设备树节点在转化为platform_device 节点之后
信息保存在 patform_device -> dev -> of_note节点中

of_node是一个device_node结构体,里面存放着节点的name、type和properties(属性链表)


2、匹配驱动
在platform_driver->driver中,有一个of_match_table成员,是一个of_device_id的结构体数组,每一个数组元素都存放着name、type和compatible用于匹配设备树节点生成的platform_device

匹配时:
(1)匹配patform_device -> dev -> of_note->properties(属性)->compatible属性的值 与platform_driver->driver->of_match_table中每一个节点->compatible成员的值 ,成功则关联
(2)匹配patform_device -> dev -> of_note->type 与platform_driver->driver->of_match_table中每一个节点->type成员的值 ,成功则关联
(3)匹配patform_device -> dev -> of_note->name 与 platform_driver->driver->of_match_table中每一个节点->name成员的值 ,成功则关联

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值