Linux基本设备驱动阐述

一、开场白:

        大家好,今天开始我会以RK3026芯片为平台,Linux kernel 版本为3.0.36,Android版本为4.2.2,来和大家分享我的Linux驱动学习经验,如有错误的地方请大家指出。


二、驱动背景:

        今天我们介绍主题的Linux PMU驱动的编写,本次PMU型号为TPS65185。熟悉Eink屏驱动的同学应该知道,此PMU是专门为Eink屏供电设计的。其输出电压值相对固定,主要由六路组成(由于本次主题是Linux的设备驱动,所以具体PMU输出电压细节进行阐述)。以此型号PMU作为Linux驱动的初次分析,主要是因为外界对此驱动的操作较为单一(仅仅是对输出电压进行开启和关断两个动作)。到此为止驱动背景介绍到此结束。


三、驱动编写:

        1、首先基本的驱动框架先写出来,驱动的基本框架主要由四部分组成:相关Linux内核头文件的包含、模块驱动相关信息、模块驱动加载函数、模块驱动卸载函数

/* file name : tps65185.c */

#include <linux/init.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/proc_fs.h>
#include <asm/uaccess.h>

static int __init tps65185_init(void) {

    return 0;
}

static void __exit tps65185_exit(void) {
    
    return;
}

MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("linux device driver - pmu");

subsys_initcall_sync(tps65185_init);
modlue_exit(tps65185_exit);

        2、从这基本的驱动框架可知,下一步我们需要编写的是tps65185_init、tps65185_exit这两个模块装载与卸载的函数。模块的装载与卸载函数通常只做一组动作,即完成驱动在相应总线的注册register和反注册unregister动作。由于TPS65185是通过I2C总线进行数据通信的,因此首先需要完成驱动在I2C总线的注册i2c_add_driver。一般而言PMU设备是不会在生成对应的设备节点,从而避免上层对其误操作导致系统挂机。但是有时候为了调试需要其生成相应的设备节点,这种情况下一般采用proc文件系统方式来进行访问(Linux设备节点访问的方式有三种:通过proc文件系统来访问;通过传统的设备文件的方法来访问;通过devfs文件系统来访问)。综上可知PMU设备在模块装载、卸载函数中主要是完成驱动在I2C总线的注册、反注册动作。

由于本驱动涉及到相关I2C驱动,因此这里先简单介绍下I2C驱动,具体详细的Linux I2C子系统分析到下节进行详细的阐述。I2C驱动一般由两部分构成:I2C总线驱动、I2C设备驱动。I2C总线驱动的操作对象是CPU的I2C通信模块,简单来说就是通过配置CPU相关寄存器来完成对I2C模块的操作,如完成I2C总线配置初始化、I2C总线数据读写。而I2C设备驱动的操作对象则是具体的外设芯片(如TPS65185),其需要完成的是对外设芯片的初始化以及相关外设功能的驱动(如对六路输出电压的开启、关断动作)。对于一个已经移植完成的Linux Kernel而言,我们常常需要编写的则是I2C设备驱动,I2C总线驱动在Linux Kernel移植的初期就应该完成。I2C设备驱动的编写主要是完成struct i2c_driver接口的编写,此结构体定义在kernel/include/linux/i2c.h文件中,简化后如下所示:

struct i2c_driver {
	......        
	/* Standard driver model interfaces 标准驱动模块接口 */
	int (*probe)(struct i2c_client *, const struct i2c_device_id *);
	int (*remove)(struct i2c_client *);

	/* driver model interfaces that don't relate to enumeration, 驱动功耗控制接口  */
	void (*shutdown)(struct i2c_client *);
	int (*suspend)(struct i2c_client *, pm_message_t mesg);
	int (*resume)(struct i2c_client *);
	......
	struct device_driver driver;			// I2C设备驱动信息(驱动模块名称、驱动模块所有者)
	const struct i2c_device_id *id_table;	// I2C设备列表(通过匹配 id_table->name 来完成I2C设备与I2C设备驱动的映射
									// 也就是说一个I2C设备驱动可能被多个I2C设备映射,这种情况下此I2C设备驱动程序需要注意多线程访问问题)
	......
};

 i2c_driver接口需要实现的如上所示:标准驱动接口(probe、remove、suspend、resume)、id_table(I2C设备列表)、driver(I2C设备驱动模块信息)。因此我们根据上述接口对我们的驱动程序进行进一步的完善,并且下面根据此三个方面对TPS65185的设备驱动进行分析与编写。驱动框架现在改变成如下所示:

#include <linux/init.h>
#include <linux/errno.h>
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/proc_fs.h>
#include <linux/i2c.h>
#include <asm/uaccess.h>

/* 标准驱动接口 */
static int tps65185_probe(struct i2c_client * client, const struct i2c_device_id * id) {

    return 0;
}

static int tps65185_remove(struct i2c_client * client) {

    return 0;
}

static int tps65185_suspend(struct i2c_client * client, pm_message_t mesg) {

    return 0;
}

static int tps65185_resume(struct i2c_client * client) {

    return 0;
}

/* 设备列表定义 */
static const struct i2c_device_id tps65185_id_table[] = {

 };

static struct i2c_driver tps65185_driver = {
    .probe    =   tps65185_probe,
    .remove   =   tps65185_remove,
    .suspend  =   tps65185_suspend,
    .resume   =   tps65185_resume,
    .id_table =   tps65185_id_table,
    .driver   = {
        .name = "tps65185 driver",
        .owner= THIS_MODULE,
    },  
};

/* 驱动模块加载函数 */
static int __init tps65185_init(void) {
    i2c_add_driver(&tps65185_driver);
    return 0;
}

/* 驱动模块卸载函数 */
static void __exit tps65185_exit(void) {
    i2c_del_driver(&tps65185_driver);
    return;
}

MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("linux device driver - pmu");

subsys_initcall_sync(tps65185_init);
modlue_exit(tps65185_exit);

        2.1、probe接口在I2C设备驱动与I2C设备匹配成功后会被内核调用,一般此接口主要是完成设备驱动的初始化(硬件初始化、相关数据结构初始化、设备节点初始化)。在硬件具体操作方面,本次驱动的编写不想涉及具体的寄存器操作,因此对于硬件的初始化方面仅仅以接口tps65185_hw_init()代替,硬件六路电压开启仅仅以接口tps65185_hw_poweron()接口代替,硬件六路电压关闭仅仅以接口tps65185_hw_powerdown()接口代替。对于I2C设备驱动程序而言,非常重要的数据结构就是i2c_client,驱动程序定义的私有数据结构tps_platform_data有必要将其保存起来。这样做的原因有两个:一、因为在之后的I2C数据读写接口中,需要利用i2c_client成员才能完成,也就说为了保证正常的I2C数据通信我们需要利用此数据结构。二、由于标准的驱动接口中都会传递i2c_client变量,因此我们可以通过内核宏定义container_of来反向获取出驱动的私有数据结构tps_platform_data,这样一来我们就可以在避免使用全局变量的前提下,每个标准驱动接口函数都能访问到驱动的私有数据结构tps_platform_data(个人觉得这种做法会让代码更加美观)。对于设备节点初始化方面,本次驱动采用proc文件系统的方式来创建设备节点(proc_creat_data),proc_data_creat的函数原型如下所示:

struct proc_dir_entry *proc_create_data(const char *name, mode_t mode,
					struct proc_dir_entry *parent,
					const struct file_operations *proc_fops,
					void *data)

proc_creat_data的参数如上所示,name代表需要创建设备节点的名称,mode代表设备节点的文件权限,parent代码设备节点所处目录(为NULL时,设备节点在/proc目录下创建),proc_fops代表对此设备节点的文件操作接口(如open、write、read、release),data代表需要传递的私有数据结构(其会保存在proc_dir_enrey的data成员中)。proc_fops结构体定义上了上层能对底层设备进行的操作接口,一般为了调试只需要定义open、write、read、release接口,我们在open中获取出tps_platform_data成员并保存在file->private_data中方便后续操作,在write、read接口继续完成硬件设备的poweron、powerdown调试操作。综上可知,我们对probe接口的编写如下所示:

/* 具体的硬件寄存器操作接口 */
int tps65185_hw_init(struct i2c_client * client) {

    printk("tps65185 hardware init... \n");
    return 0;
}
int tps65185_hw_deinit(struct i2c_client * client) {

    printk("tps65185 hardware init... \n");
    return 0;
}
int tps65185_hw_poweron(struct i2c_client * client) {
    printk("tps65185 hardware power on... \n");
    return 0;
}

int tps65185_hw_powerdown(struct i2c_client * client) {
    printk("tps65185 hardware power down... \n");
    return 0;
}

struct tps_platform_data {
    // 添加本身驱动所需“全局”数据结构 
    struct i2c_client * client;
    struct proc_dir_entry * pde;
};

/* 设备节点操作接口 */
static int proc_tps65185_write(struct file * file, const char __user * user, size_t count, loff_t * offset) {
    struct tps_platform_data * pd = (struct tps_platform_data *)file->private_data;

    tps65185_hw_poweron(pd->client);
    return count;
}
static int proc_tps65185_read(struct file * file, char __user * user, size_t count, loff_t * offset) {
    struct tps_platform_data * pd = (struct tps_platform_data *)file->private_data;

    tps65185_hw_powerdown(pd->client);
    return 0;
}


static int proc_tps65185_open(struct inode * inode, struct file *file)
{
    struct proc_dir_entry * pde = PDE(inode);
    struct tps_platform_data * pd = (struct tps_platform_data *)pde->data;
    //struct tps_platform_data * pd = (struct tps_platform_data *)container_of(pde, struct tps_platform_data, pde);

    file->private_data = (void *)pd;
    return 0;
}
static int proc_tps65185_release(struct inode *inode, struct file *file)
{
    return 0;
}

static const struct file_operations proc_tps65185_fops = {
    .open        = proc_tps65185_open,
    .write        = proc_tps65185_write,
    .read         = proc_tps65185_read,
    .release    = proc_tps65185_release,
};

/* 标准驱动接口 */
static int tps65185_probe(struct i2c_client * client, const struct i2c_device_id * id) {
    int ret = -1;
    struct tps_platform_data * platform_data = NULL;
    
    // 相关数据结构初始化
    platform_data = (struct tps_platform_data *)kmalloc(sizeof(struct tps_platform_data), GFP_KERNEL);
    if (platform_data == NULL) {
        printk("malloc tps platform failed.\n");
        ret = -ENOMEM;
        goto release_mem_failed;
    }
    platform_data->client = client;
    
    // 创建调试用设备节点
    platform_data->pde = proc_create_data("tps65185", 0, NULL, &proc_tps65185_fops, platform_data);
    if (IS_ERR(platform_data->pde)) {
        printk("creat tps proc file failed");
        ret = -EINVAL;
        goto release_platform_data;
    }
    
    // 硬件初始化
    ret = tps65185_hw_init(platform_data->client);
    if (ret < 0) {
        printk("tps65185 hardware failed. \n");
        ret = -EINVAL;
        goto release_distroy_device_file;
    }

    return 0;

release_distroy_device_file: 
    remove_proc_entry("tps65185", NULL);
release_platform_data:
    if (platform_data != NULL) {
        kfree(platform_data);
    }

release_mem_failed:
    return ret;
}

虽然不详细设计具体的寄存器操作,但还是需要编写实现基本的I2C数据读写接口。I2C总线驱动提供了两套数据读写:普通I2C数据通信、Smbus数据通信。Smbus协议大部分是基于I2C协议规范,但是Smbus工作频率一般工作在100KHZ,其设计是专门面向智能电池管理的应用。两套数据读写的接口申明如下所示:

/* 普通I2C数据通信接口 */
int i2c_master_send(const struct i2c_client *client, const char *buf, int count);
int i2c_master_recv(const struct i2c_client *client, char *buf, int count);
int i2c_transfer(struct i2c_adapter *adap, struct i2c_msg *msgs, int num);

/* Smbus数据通信接口 */
s32 i2c_smbus_xfer(struct i2c_adapter *adapter, u16 addr, unsigned short flags,
		   char read_write, u8 command, int protocol, union i2c_smbus_data *data);
大家通过函数的名称也就能知道其具体的用法了,虽然严格来说Smbus比较适合应用于本次驱动的场景,但是考虑到大家更加熟悉常用的I2C通信方式,本次驱动采用i2c_transfer接口来完成I2C的数据读写接口。具体接口代码实现如下所示,代码比较简单就不进行阐述:

/* 基本I2C数据读写接口 */
static int i2c_write_reg(struct i2c_client * client, const char reg_addr, const char val) {
    int stat = -1;
    char i2c_tx_buf[2] = {reg_addr, val};
    struct i2c_msg msgs[] = {
        {
            .addr  = client->addr,
	    .flags = 0,
	    .len   = sizeof(i2c_tx_buf) / sizeof(i2c_tx_buf[0]),
	    .buf   = i2c_tx_buf,
	    .scl_rate = 400*1000,   //400KHZ
	}
    };
	
    stat = i2c_transfer(client->adapter, msgs, ARRAY_SIZE(msgs));
    if (stat < 0) {
        pr_err("tps65185: I2C send error: %d\n", stat);
    } else if (stat != ARRAY_SIZE(msgs)) {
	pr_err("tps65185: I2C send N mismatch: %d\n", stat);
	stat = -EIO;
    } else {
	stat = 0;
    }

    return stat;
}

static int i2c_read_reg(struct i2c_client * client, const char reg_addr, char * const val) {
    int stat = -1;
    char tmp_reg_addr = reg_addr;   
    struct i2c_msg msgs[] = {
        {   // tx_msg for register address
            .addr  = client->addr,
            .flags = 0,
            .len   = 1,
            .buf   = &tmp_reg_addr,
            .scl_rate = 400*1000,
        },
        {   // rx_msg for register data
            .addr  = client->addr,
            .flags = I2C_M_RD,
            .len   = 1,
            .buf   = val,
            .scl_rate = 400*1000,    //400KHZ
        }
    };

    if(val == NULL) {
        return -1;
    }

    stat = i2c_transfer(client->adapter, msgs, ARRAY_SIZE(msgs));
    if (stat < 0) {
	pr_err("tps65185: I2C read error: %d\n", stat);
        pr_err("tps65185: I2C addr %x %s\n", client->addr, __FILE__); 
    } else if (stat != ARRAY_SIZE(msgs)) {
	pr_err("tps65185: I2C read N mismatch: %d\n", stat);
	stat = -EIO;
    } else {
        stat = 0;
    }

    return stat;
}

        2.2、remove接口在模块卸载的时候会被内核调用,其与probe相对应完成设备驱动的反初始化(硬件反初始化、销毁相关数据结构、删除相关设备节点)。其工作量非常小,具体代码如下所示:

static int tps65185_remove(struct i2c_client * client) {
	struct tps_platform_data * pd = (struct tps_platform_data *)container_of(client, struct tps_platform_data, client);

	// 硬件反初始化操作
	tps65185_hw_deinit(pd->client);

	// 删除设备节点
	remove_proc_entry("tps65185", NULL);

	// 销毁相关数据结构
	kfree(pd);
	pd = NULL;

    return 0;
}

        2.3、suspend接口在内核进入休眠的时候被内核调用,此接口主要功能是为了降低模块功耗,其实现方式一般由两种(设备断电处理或者设备进入低功耗状态)。resume接口在内核退出休眠的时候被内核调用,其与suspend动作相对应(如suspend让设备进入低功耗状态,resume则对设备进入正常工作状态)。这两个函数都仅仅是具体的硬件功耗操作,所以就不给出具体实现。

        2.4、在I2C设备驱动程序中,device_driver数据结构主要是被I2C总线驱动程序所用。这部分设计的主要知识点是Linux kernel驱动框架中的注册动作,这个我还不是很理解,所以先不给予的介绍。但是一般在驱动程序编写的过程中,我们只需要初始化其name成员和owner成员。name成员是作为本设备驱动的唯一标志,不同设备驱动的name必须不同。因为驱动在注册的过程中,内核会通过name名称来查询已经挂载在内核驱动列表中驱动是否由相同的名称。如果存在相同的名称,那本次驱动的注册将会失败,内核提示”Error: Driver '%s' is already registered, aborting...“。具体device_driver内容也如下所示:

static struct i2c_driver tps65185_driver = {
    .probe    =   tps65185_probe,
    .remove   =   tps65185_remove,
    .suspend  =   tps65185_suspend,
    .resume   =   tps65185_resume,
    .id_table =   tps65185_id_table,
    .driver   = {
        .name = "tps65185 driver",
        .owner= THIS_MODULE,
    },  
};

        2.5、我们现在编写的是设备驱动程序,而驱动需要和设备匹配成功后,上层对设备的操作才能调用到对应设备驱动。不同总线驱动其设备与驱动相互匹配的方法是不一样的,我们现在编写的I2C总线设备驱动,此总线的设备与驱动匹配方法,是通过对比设备的名称name与驱动的设备列表中的name是否一致来完成。举例来说,如果有一个I2C设备A的名称为"tps65185",并且I2C设备驱动B的设备列表中的名称也存在"tps65185",这样一来设备A才能与驱动B相互匹配,上层对设备A的操作才能由驱动B来完成。I2C驱动设备列表i2c_device_id的结构体和I2C设备名称相关数据结构如下所示:

/* I2C驱动设备列表数据结构 */
struct i2c_device_id {
	char name[I2C_NAME_SIZE];          /* 字符数组,存放设备名称 */
	kernel_ulong_t driver_data	            /* Data private to the driver */
	__attribute__((aligned(sizeof(kernel_ulong_t))));
};

/* I2C设备数据结构  */
struct i2c_board_info {
    char    type[I2C_NAME_SIZE];    // 本设备名称
    ......
    unsigned short    addr;            // 设备器件地址
    ......
};

由上可知,I2C设备与驱动的匹配主要是通过对比 name 与 type 两个字符串是否相同来完成。具体为什么I2C设备与I2C驱动的匹配是通过上述的方式来进行的,我们下节通过添加LOG和分析内核源码来进行详细的阐述(主要是通过在I2C驱动的probe函数中通过添加dump_stack()来进行调试)。tps65185驱动的设备列表的编写如下所示:

/* 设备列表定义 */
static const struct i2c_device_id tps65185_id_table[] = {
    {.name = "tps65185", .driver_data = 0},
};
驱动中设备列表中定义了名称为"tps65185"的设备,因此与之相应的I2C设备的名称也必须为"tps65185"。I2C设备的注册是通过i2c_register_board_info接口来完成的,其接口定义即相关数据结构定义如下:

/* 设备信息数据结构 */
struct i2c_board_info {
    char        type[I2C_NAME_SIZE];
    unsigned short    flags;
    unsigned short    addr;
    void        *platform_data;
    ......
    int        irq;
};

/* 设备注册接口定义 */
int __init i2c_register_board_info(int busnum, 
                                      struct i2c_board_info const *info, unsigned len)
i2c_register_board_info是通过将内核定义的设备信息i2c_devinfo添加至i2c总线的设备树__i2c_board_list中,从而完成设备在i2c总线的注册过程。而i2c_devinfo的定义主要由两个成员构成,busnum总线ID号及i2c_board_info设备信息。busnum一般代表着使用CPU内部I2C模块的编号,RK3026内部I2C总共由4路,由此可知busnum最大能为4。i2c_board_info则代表这具体的I2C设备的信息,其中重要的数据结构如上所示:type代表着设备的名称(与驱动的id_table对应)、flags代表着此设备I2C的通信属性、addr代表着I2C设备的通信地址、platform_data用于传递一些私有的设备信息、irq代表着设备的中断管教(具体看I2C的总线驱动怎么解析)。tps65185的设备信息填充如下所示:

static struct i2c_board_info __initdata i2c0_info[] = {
	{
		.type	        = "tps65185",
		.addr	        = 0x1d,
		.flags	        = 0,
		.platform_data = NULL,
	},
};
设备信息的注册函数i2c_register_board_info一般是放在MECHINE_START中的.init_mechine成员函数中。但是为了方便测试时候管理,也可将设备的注册i2c_board_info放于驱动的subsys_initcall_sync中,因此本驱动的subsys_initcall_sync函数改变成如下所示:

/* 设备信息 */
static struct i2c_board_info __initdata i2c0_info[] = {
	{
		.type	        = "tps65185",
		.addr	        = 0x1d,
		.flags	        = 0,
		.platform_data 	= NULL,
	},
};

/* 驱动模块加载函数 */
static int __init tps65185_init(void) {
    i2c_register_board_info(0, i2c0_info, ARRAY_SIZE(i2c0_info));	    /* 设备的注册一定要早于驱动的注册,因为只有在驱动注册的时候才会去调用总线的mach成员函数,从而来搜索总线的设备树上时候有与之匹配的设备。*/
    i2c_add_driver(&tps65185_driver);
    return 0;
}

        3、现在来说tps的设备驱动及设备都已经全部编写完毕了,剩下的就是将此驱动程序编译进内核,来进行相关的测试了。首先在kernel/drivers目录下创建一个文件夹test/,将驱动文件tps65185.c移动至test/目录下。在test/目录下创建两个文件Makefile和Kconfig。

drivers/test/Kconfig内容如下所示:

# drivers/test/Kconfig
# driver test configuration
#
menuconfig DRIVERS_TEST
    bool "drivers test"
    help
        Say Y here, and a list of supported test_drivers will be displayed.
        This option doesn't affect the kernel.
        If unsure, say Y.

if DRIVERS_TEST

config TPS65185
    tristate "tps65185 test"
    default y
    help
        tps65185 driver test option
endif

drivers/test/Makefile内容如下所示:

# drivers/test/Makefile
obj-$(CONFIG_TPS65185)  += tps65185.o

driver/Kconfig修改如下:

menu "Device Drivers"
source "drivers/test/Kconfig"
source "drivers/base/Kconfig"
......... 

driver/Makefile修改如下:

#
# Makefile for the Linux kernel device drivers.
#
# 15 Sep 2000, Christoph Hellwig <hch@infradead.org>
# Rewritten to use lists instead of if-statements.
#
obj-$(CONFIG_DRIVERS_TEST)  += test/
obj-y               += gpio/
.........

至于为什么要做上面的修改,有兴趣的同学可以去了解下Linux Kbuild就知道,这里就不进行解释了。修改完成后回到Linux kernel的目录,执行make menuconfig命令后。进入"Device Drivers --->",选中并进入"drivers test  --->"选项,选中"tps65185 test --->"选项,退出menuconfig并保存,编译kernel成功后烧写机器。机器运行成功后通过串口连接机器,通过对/proc/tps65185设备节点进行cat和echo动作,进行设备驱动的测试,测试结果如下所示。驱动调用逻辑正常,测试结束。

root@android:/ # 
root@android:/ # cat /proc/tps65185 
<4>[ 90.796038] tps65185 hardware power down... 
root@android:/ # 
root@android:/ # echo 1 > /proc/tps65185 
<4>[ 101.572136] tps65185 hardware power on...

        4、到此为止,一个简单基本的Linux设备驱动编写、测试完成。基本Linux设备驱动的编写思路还是比较清晰的,主要是按照规范完善相关内核接口即可,本次驱动所涉及的内核接口主要有两部分:模块的加载和卸载函数接口、I2C设备驱动接口。本次编写的tps65185驱动,虽然能够通过proc文件系统的方式进行基本的接口调试,但是如果删除了此调试设备节点后,此驱动是无法和上层进行交互的。而之前也有提到过,PMU设备一般是不是生成设备节点的。这样一来就引入了一个问题,即在不生成设备节点的情况下,驱动如何才能正常工作。其次本驱动涉及了I2C设备驱动的编写,而I2C总线驱动方面并没有解释。实际I2C设备驱动开发中,肯定对I2C设备驱动及I2C总线驱动都要熟悉才能比较好完成I2C驱动的开发。因此后面的文章会对上述两个问题展开解析,《Linux I2C子系统阐述》及《Linux Regulator电源管理子系统阐述》。

本篇到此结束,谢谢大家的读阅。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值