RTT DEMO学习

分布式温度监控系统

分布式温度监控系统基于STM32系列芯片开发,支持采集多达6个分节点的温度数据,网关节点采集分节点的数据并通过WIFI上传云端远程实时监视,也可本地连接串口与PC端通讯,上位机实时显示分节点数据。
系统适用于家庭、办公室、教室等小面积场所的多点温度监控,无线传输距离可达 100m ~ 500m,具有功耗低,丢包率低,传输距离远等特点,是一个相当实用的 DIY 设计。

在这里插入图片描述
在这里插入图片描述
网关节点连接中国移动的 OneNet 云,PC 上网页登录 OneNet 即可实现远程监视分节点的温度数据,如下图所示。

移动端实现远程监视需要下载 OneNet 的官方 APP,登录账号即可查看设备数据。

网关节点通过串口连接 PC,并在上位机中打开串口即可实现本地监视节点数据。

Sensor框架对接

想要使用Sensor框架,需要在menuconfig中将它开启:

RT-Thread Components  --->
    Device Drivers  --->
        [*] Using Sensor device drivers

打开之后,需要使用 scons --target=mdk5 更新工程。看,Sensor 框架加入到工程当中

Sensor框架的接口分为上层接口和底层接口两种。将底层驱动对接到框架上,具体就是底层的ops接口。

在rt-thread\components\drivers\sensors中,我们可以找到ops接口的定义,有两个函数指针,fetch_data和control。

struct rt_sensor_ops{
	rt_size_t (*fetch_data)(struct rt_sensor_device *sensor, void *buf, rt_size_t len);
	rt_err_t (*control)(struct rt_sensor_device *sensor, int cmd, void *arg);
};

fetch_data 作用是获取传感器数据,control 作用是通过控制命令控制传感器,ds18b20 并不支持 control,我们只需要实现 fetch_data 就好了。

Sensor 框架当前默认支持轮询 (RT_DEVICE_FLAG_RDONLY)、中断 (RT_DEVICE_FLAG_INT_RX)、FIFO (RT_DEVICE_FLAG_FIFO_RX) 这三种打开方式。

需要在这里判断传感器的工作模式,然后再根据不同的模式返回传感器数据。我们以轮询的方式读取 ds18b20 的温度数据,那么 fetch_data 的实现如下:

static rt_size_t ds18b20_fetch_data(struct rt_sensor_device *sensor, void *buf, rt_size_t len)
{
	RT_ASSERT(buf);
	if (sensor->config.mode == RT_SENSOR_MODE_POLLING)
    {
        return _ds18b20_polling_get_data(sensor, buf);
    }
    else
        return 0;
}

具体的,_ds18b20_polling_get_data(sensor, buf) 的实现如下,其中,ds18b20_get_temperature 函数就是 ds18b20 温度传感器底层驱动的获取温度的函数。

static rt_size_t _ds18b20_polling_get_data(rt_sensor_t sensor, struct rt_sensor_data *data)
{
    rt_int32_t temperature_x10;
    if (sensor->info.type == RT_SENSOR_CLASS_TEMP)
    {
        temperature_x10 = ds18b20_get_temperature((rt_base_t)sensor->config.intf.user_data);
        data->data.temp = temperature_x10;
        data->timestamp = rt_sensor_get_ts();
    }
    return 1;
}

因为不需要 control,我们直接让 control 返回 RT_EOK 即可

static rt_err_t ds18b20_control(struct rt_sensor_device *sensor, int cmd, void *args)
{
    rt_err_t result = RT_EOK;

    return result;
}

static struct rt_sensor_ops sensor_ops =
{
    ds18b20_fetch_data,
    ds18b20_control
};

传感器设备驱动

完成Sensor的ops对接之后还要注册一个Sensor设备,这样上层才能找到这个传感器设备,进而进行控制。

设备的注册需要以下几步:
创建一个 rt_sensor_t 的结构体指针
为结构体分配内存
完成相关初始化

int rt_hw_ds18b20_init(const char *name, struct rt_sensor_config *cfg)
{
	rt_int8_t result;
    rt_sensor_t sensor_temp = RT_NULL;
    
    if (!ds18b20_init((rt_base_t)cfg->intf.user_data))
    {
        /* temperature sensor register */
        sensor_temp = rt_calloc(1, sizeof(struct rt_sensor_device));
        if (sensor_temp == RT_NULL)
            return -1;

        sensor_temp->info.type       = RT_SENSOR_CLASS_TEMP;
        sensor_temp->info.vendor     = RT_SENSOR_VENDOR_DALLAS;
        sensor_temp->info.model      = "ds18b20";
        sensor_temp->info.unit       = RT_SENSOR_UNIT_DCELSIUS;
        sensor_temp->info.intf_type  = RT_SENSOR_INTF_ONEWIRE;
        sensor_temp->info.range_max  = SENSOR_TEMP_RANGE_MAX;
        sensor_temp->info.range_min  = SENSOR_TEMP_RANGE_MIN;
        sensor_temp->info.period_min = 5;

        rt_memcpy(&sensor_temp->config, cfg,
                  sizeof(struct rt_sensor_config));
        sensor_temp->ops = &sensor_ops;

        result = rt_hw_sensor_register(sensor_temp,
                                       name,
                                       RT_DEVICE_FLAG_RDONLY,
                                       RT_NULL);
        if (result != RT_EOK)
        {
            LOG_E("device register err code: %d", result);
            goto __exit;
        }

    }
    return RT_EOK;

__exit:
    if (sensor_temp)
        rt_free(sensor_temp);
    return -RT_ERROR;
}
}

传感器设备注册第一步,创建一个rt_sensor_t的结构体指针。

rt_sensor_t sensor_temp = RT_NULL;

传感器设备注册的第二步:为结构体分配内存,上述代码中是这么实现的:

sensor_temp = rt_calloc(1, sizeof(struct rt_sensor_device));
if (sensor_temp == RT_NULL)
    return -1;

传感器设备注册的第三步:完成相关初始化,上述代码中是这么实现的:

sensor_temp->info.type       = RT_SENSOR_CLASS_TEMP;
sensor_temp->info.vendor     = RT_SENSOR_VENDOR_DALLAS;
sensor_temp->info.model      = "ds18b20";
sensor_temp->info.unit       = RT_SENSOR_UNIT_DCELSIUS;
sensor_temp->info.intf_type  = RT_SENSOR_INTF_ONEWIRE;
sensor_temp->info.range_max  = SENSOR_TEMP_RANGE_MAX;
sensor_temp->info.range_min  = SENSOR_TEMP_RANGE_MIN;
sensor_temp->info.period_min = 5;

rt_memcpy(&sensor_temp->config, cfg, sizeof(struct rt_sensor_config));
sensor_temp->ops = &sensor_ops;

传感器设备注册的三个步骤完成之后,就可以放心大胆地注册传感器设备了,上述代码中是这么实现的:

rt_hw_sensor_register(sensor_temp, name, RT_DEVICE_FLAG_RDONLY, RT_NULL);

上述的“ops 接口对接”和“传感器设备注册”两个工作完成后,就可以通过 Sensor 框架中的上层接口 open/close/read/write/control,对 ds18b20 进行操作了。

在 FinSH 中输入 list_device 命令查看 ds18b20 温度传感器是否真的已经被注册上去了。

在这里插入图片描述

在线程中读取温度数据

我们通过一个线程,去实时获取 ds18b20 的温度数据。

线程的基本操作有:创建/初始化 ( rt_thread_create/rt_thread_init)、启动 (rt_thread_startup)、运行 (rt_thread_delay/rt_thread_control)、删除/脱离 (rt_thread_delete/rt_thread_detach)。

之前将ds18b20对接到ops接口并成功注册成传感器设备了,接下来就是利用Sensor框架的上层接口open/read/write等对ds18b20斤操作了。

在main函数中创建一个读取ds18b20温度数据的线程并启动它,线程入口函数read_temp_entry:

rt_thread_t ds18b20_thread, led_thread;

ds18b20_thread = rt_thread_create("18b20tem",
                                  read_temp_entry,
                                  "temp_ds18b20",
                                  512,
                                  RT_THREAD_PRIORITY_MAX / 2,
                                  20);
if (ds18b20_thread != RT_NULL)
{
    rt_thread_startup(ds18b20_thread);
}

在线程入口函数read_temp_entry中,我们通过几个步骤,就可以读取ds18b20的温度数据了:

  • 创建一个rt_sensor_data的数据结构体
  • 查找传感器设备驱动
  • 打开对应的传感器设备
  • 读取传感器设备数据
static void read_temp_entry(void *parameter){
	rt_device_t dev = RT_NULL;
	struct rt_sensor_data sensor_data;
	rt_size_t res;

	dev = rt_device_find(parameter);
	if(dev == RT_NULL){
		rt_kprintf("Can't find device:%s\n", parameter);
        return;
	}

	if (rt_device_open(dev, RT_DEVICE_FLAG_RDWR) != RT_EOK)
    {
        rt_kprintf("open device failed!\n");
        return;
    }
    rt_device_control(dev, RT_SENSOR_CTRL_SET_ODR, (void *)100);

	while (1)
    {
        res = rt_device_read(dev, 0, &sensor_data, 1);
        if (res != 1)
        {
            rt_kprintf("read data failed!size is %d\n", res);
            rt_device_close(dev);
            return;
        }
        else
        {
            rt_kprintf("temp:%3d.%dC, timestamp:%5d\n", sensor_data.data.temp / 10, sensor_data.data.temp % 10, sensor_data.timestamp);
        }
        rt_thread_mdelay(100);
    }
}

邮箱与消息队列

  • 通过ENV工具获取NRF24L01软件包,并加载到MDK工程里面。
  • 了解多线程间通信,了解IPC中邮箱和消息队列的特性,并灵活使用,实现ds18b20与nrf24l01线程之间的数据通信。
  • 修改nrf24l01软件包,实现多点通信功能。

软件包的获取

软件包可以通过env工具十分方便获取到,并加载到工程里面去,env工具的下载链接可以在官网找到。

我们需要用到 nrf24l01 的软件包,只需要在 menuconfig 中选中 nrf24l01 即可:

在这里插入图片描述
选中之后需要将该软件包获取到本地来,在env中输入pkgs --update命令回车即可。
我们在工厂目录的packages目录下,可以看到nrf24l01软件包被获取到本地来了。

在这里插入图片描述
不过软件包现在仅仅只是获取到本地,尚未加载到MDK工程当中。我们在env中输入scons --target=mdk5命令回车后,执行完该命令之后打开 MDK5 工程,发现 nrf24l01 软件包成功加载到工程里面去了。

为什么要使用邮箱?

我们需要通过NRF24L01无线模块进行数据发送与接收。

在发送节点创建一个线程,用于无线发送数据。具体的,nrf24l01 的软件包提供了哪些 API,是如何通过这些 API 实现发送功能的,可以参考该软件包的 samples,路径为:…\packages\nrf24l01-latest\examples。

同理的,我们在 main 函数中再创建一个线程,该线程是用来通过 nrf24l01 发送数据的,线程入口函数是 nrf24l01_send_entry

问题变为:

  • 温湿度传感器数据如何将温湿度数据给发送线程?
  • 线程采集温度过快,发送线程来不及发送怎么办?
  • 线程发送数据过快,温湿度线程来不及采集温度数据怎么办?

将内存池和邮箱一起配套使用。

邮箱工作原理

我们拟定一个生活场景。小区内放置有快递柜,快递柜里面有很多快递箱,快递箱里面可以存放快递,快递员把快递存放到快递箱之后,会发短信通知你过来取快递,还会告诉你编号是多少,通过编号你可以找到你的快递存放在快递柜的哪个快递箱里面。

上面这个模型中有几个名词,我们抽取出来:快递、快递柜、快递箱、快递员、你自己、短信、编号。

我们将上面这个生活场景和 IPC 中的邮箱和内存池一一对应起来:

  • 快递:采集到的温度数据。
  • 快递柜:内存池。
  • 快递箱:内存池里面的内存块。
  • 快递员:采集温湿度线程
  • 自己:发送线程
  • 短信:邮箱中的一封邮件
  • 编号:内存块地址指针

邮箱和内存池的使用,其实和收快递是一样的:
在程序的一开始,即main函数中,我们创建一个邮箱和一个内存池。

  • 在温湿度线程里,首先每当线程采集到一个温度数据,就在内存池里面申请一个内存块,把本次采集到的温度数据放到内存块里,再把内存块的地址放在邮箱里,最后把邮件发送出去。
  • 在发送线程里:首先发送线程接收邮件,然后根据邮箱中存放的地址,知道了当前温度数据存放在哪个内存块里面,也就是说,发送线程找到当前温度数据。最后用完这个内存块要及时释放掉。
tmp_msg_mb = rt_mb_create("temp_mb0", MB_LEN, RT_IPC_FLAG_PRIO);
tmp_msg_mp = rt_mp_create("temp_mp0", MP_LEN, MP_BLOCK_SIZE); 

static void read_temp_entry(void *parameter)
{
    struct tmp_msg *msg;
    rt_device_t dev = RT_NULL;
    rt_size_t res;

    dev = rt_device_find(parameter);
    if (dev == RT_NULL)
    {
        rt_kprintf("Can't find device:%s\n", parameter);
        return;
    }

    if (rt_device_open(dev, RT_DEVICE_FLAG_RDWR) != RT_EOK)
    {
        rt_kprintf("open device failed!\n");
        return;
    }
    rt_device_control(dev, RT_SENSOR_CTRL_SET_ODR, (void *)100);

    while (1)
    {
        res = rt_device_read(dev, 0, &sensor_data, 1);
        if (res != 1)
        {
            rt_kprintf("read data failed!size is %d\n", res);
            rt_device_close(dev);
            return;
        }
        else
        {
            //申请一块内存,要是内存池满了,就挂起等待
            msg = rt_mp_alloc(tmp_msg_mp, RT_WAITING_FOREVER);
            msg->timestamp = sensor_data.timestamp;
            msg->int_value = sensor_data.data.temp;
            rt_mb_send(tmp_msg_mb, (rt_ubase_t)msg);
            msg = NULL;
        }
        rt_thread_mdelay(100);
    }
}

在上述代码中,该线程采集到一个温度数据之后,就会在内存池中申请内存块:

msg = rt_mp_alloc(tmp_msg_mp, RT_WAITING_FOREVER);

将温度数据存放到刚刚申请到的内存块里面:

msg->int_value = sensor_data.data.temp;

将这个存放着温度数据的内存块的地址给邮箱,然后发送邮件:

rt_mb_send(tmp_msg_mb, (rt_ubase_t)msg);

nrf24l01_thread 线程的入口函数是 nrf24l01_send_entry,如下:

static void nrf24l01_send_entry(void *parameter)
{
    struct tmp_msg *msg;
    struct hal_nrf24l01_port_cfg halcfg;
    nrf24_cfg_t cfg;
    uint8_t rbuf[32 + 1] = {0};
    uint8_t tbuf[32] = {0};

    nrf24_default_param(&cfg);
    halcfg.ce_pin = NRF24L01_CE_PIN;
    halcfg.spi_device_name = NRF24L01_SPI_DEVICE;
    cfg.role = ROLE_PTX;
    cfg.ud = &halcfg;
    cfg.use_irq = 0;
    nrf24_init(&cfg);

    while (1)
    {
        rt_thread_mdelay(100);

        if (rt_mb_recv(tmp_msg_mb, (rt_ubase_t*)&msg, RT_WAITING_FOREVER) == RT_EOK)
        {
            if (msg->int_value >= 0)
            {
                rt_sprintf((char *)tbuf, "temp:+%3d.%dC, ts:%d",
                           msg->int_value / 10, msg->int_value % 10, msg->timestamp);
            }
            else
            {
                rt_sprintf((char *)tbuf, "temp:-%2d.%dC, ts:%d",
                           msg->int_value / 10, msg->int_value % 10, msg->timestamp);
            }
            rt_kputs((char *)tbuf);
            rt_kputs("\n");
            rt_mp_free(msg); /* 释放内存块 */
            msg = RT_NULL;   /* 请务必要做 */
        }
        if (nrf24_ptx_run(rbuf, tbuf, rt_strlen((char *)tbuf)) < 0)
        {
            rt_kputs("Send failed! >>> ");
        }
    }
}

该线程接收ds18b20_thread 线程发送过来的邮件,并收到了温度数据:

rt_mb_recv(tmp_msg_mb, (rt_ubase_t*)&msg, RT_WAITING_FOREVER)

将温度数据发送出去:

nrf24_ptx_run(rbuf, tbuf, rt_strlen((char *)tbuf))

用完的内存块释放掉

rt_mp_free(msg);
msg = RT_NULL;

还有两个问题没有解答:

如果ds18b20_thread 线程采集温度数据过快,nrf24l01_thread 线程来不及发送,怎么办?

如果nrf24l01_thread 线程发送数据过快,ds18b20_thread 线程来不及采集温度数据,怎么办?

这两个问题其实就是解决供过于求和供不应求的问题。

申请内存块、接收邮件的代码上有RT_WAITTING_FOREVER

内存池满了的时候,再也申请不到内存块了,这时候申请内存块的线程就会阻塞,并挂起,然后MCU就去做别的事情了。
发送线程不断发送内存池中的温度数据,并释放掉内存块。等一有内存块可以申请了,发送线程被唤醒,又会往里面塞数据了。

同理的,如果内存池是空的,里面没有数据,接收邮件里面的 RT_WAITING_FOREVER 会使得nrf24l01_thread 线程阻塞,并挂起,然后 MCU 就会去干别的事情去了,在 ds18b20_thread 线程中采集温度,并申请内存块塞数据进去,内存块一旦有数据,就会发邮箱,另外一边一有邮箱收到了,就又开始工作了。

消息队列

消息队列一般来说,不需要搭配内存池一起使用,因为消息队列创建的时候会申请一段固定大小的内存出来,作用其实和邮箱+内存池是一样的。

每个消息队列对象包含着多个消息框,每个消息框可以存放一条消息,每个消息框的大小是一样的,存放的消息大小不能超过消息框的大小,即可以相同,可以小于。

tmp_msg_mq = rt_mq_create("temp_mq", MQ_BLOCK_SIZE, MQ_LEN, RT_IPC_FLAG_PRIO);

在传感器线程中转载数据并发送消息队列:

msg.int_value = sensor_data.data.temp;
rt_mq_send(tmp_msg_mq, &msg, sizeof msg);

在线程中接收消息队列

rt_mq_recv(tmp_msg_mq, &msg, sizeof msg, RT_WAITING_FOREVER);

文件系统

DFS是RTT提供的虚拟文件系统组件,全称为Device File System,即设备虚拟文件系统,文件系统的名称使用类似UNIX文件、文件夹的风格。

功能:

  • 为应用程序提供统一的POSIX文件和目录操作接口:read、write、poll、select等。
  • 支持多种类型的文件系统,如FatFS、RomFS、DevFS等,并提供普通文件、设备文件、网络文件描述符的管理。
  • 支持多种类型的存储设备,如SD CARD、SPI Flash等。

在这里插入图片描述

在SPI Flash上使用文件系统

RTT已经将libc那套文件系统接口对接到DFS上了,在env工具中开启libc和DFS即可,本次教程使用libc的那套接口进行文件的打开/关闭、读取/写入。

在这里插入图片描述
在这里插入图片描述

OneNet连云

首先,要想清楚本次任务的整个工程需要依赖什么工具才能正常工作,简单来说,我们需要将 ESP8266 对接到 OneNet 云,而 ESP8266 通过 AT Device 控制的,所以现在目标是明确的:开启 AT 和 ESP8266,并配置 OneNet 软件包中的相关参数。

开启 AT 和 ESP8266,ESP8266 的 WIFI 账号和密码需要写对,不然连不上网,自然就对接不上 OneNet 了:
在这里插入图片描述

OneNet软件包工作原理

OneNet软件包数据的上传和命令的接收是基于MQTT实现的,OnetNet的初始化其实就是MQTT客户端的初始化,初始化完成后,MQTT客户端会自动连接OneNet平台。

数据的上传其实就是往特定的topic发布消息。当服务器有命令或者响应需要下发时,会将消息推送给设备。

获取数据流、数据点,发布命令则是基于HTTP Client实现的,通过POST或GET将相应的请求发送给OneNet平台,OneNet 将对应的数据返回,这样,我们 就能在网页上或者手机 APP 上看到设备上传的数据了。

OneNet软件包提供了一个接口onenet_mqtt_init,供用户去初始化MQTT,只有当MQTT初始化成功之后,才能后续的操作,如上传数据到OneNet服务器。

我们创建两个线程去工作,onenet_mqtt_init_thread线程用于初始化 MQTT 客户端,onenet_upload_data_thread 线程去上传数据给云。那么问题来了,onenet_upload_data_thread 线程怎么知道 MQTT 初始化成功了呢?这里使用信号量去通知。

要用信号量之前,第一步当然是创建一个信号量:

mqttinit_sem = rt_sem_create("mqtt_sem", RT_NULL, RT_IPC_FLAG_PRIO);

初始化MQTT成功后,释放信号量。

static void onenet_mqtt_init_entry(void *parameter)
{
    uint8_t onenet_mqtt_init_failed_times;

    /* mqtt初始化 */
    while (1)
    {
        if (!onenet_mqtt_init())
        {
            /* mqtt初始化成功之后,释放信号量告知onenet_upload_data_thread线程可以上传数据了 */
            rt_sem_release(mqttinit_sem);
            return;
        }
        rt_thread_mdelay(100);
        LOG_E("onenet mqtt init failed %d times", onenet_mqtt_init_failed_times++);
    }
}

上传线程要一直等待信号量的到来。

static void onenet_upload_data_entry(void *parameter)
{
    /* 永久等待方式接收信号量,若收不到,该线程会一直挂起 */
    rt_sem_take(mqttinit_sem, RT_WAITING_FOREVER);
    /* 后面用不到这个信号量了,把它删除了,回收资源 */
    rt_sem_delete(mqttinit_sem);

    while (1)
    {
        /* 这里是上传数据到OneNet的代码*/
        /* 这里要怎么写,后面会有教程说明 */
    }
}
static void onenet_upload_data_entry(void *parameter)
{
    struct recvdata *buf_mp;

    /* 永久等待方式接收信号量,若收不到,该线程会一直挂起 */
    rt_sem_take(mqttinit_sem, RT_WAITING_FOREVER);
    /* 后面用不到这个信号量了,把它删除了,回收资源 */
    rt_sem_delete(mqttinit_sem);

    while (1)
    {
        if (rt_mb_recv(tmp_msg_mb, (rt_ubase_t*)&buf_mp, RT_WAITING_FOREVER) == RT_EOK)
        {
            /* 500ms上传一次数据 */
            rt_thread_delay(rt_tick_from_millisecond(500));
            /* 上传发送节点1的数据到OneNet服务器,数据流名字是temperature_p0 */
            if (onenet_mqtt_upload_digit("temperature_p0", buf_mp->temperature_p0) != RT_EOK)
                rt_kprintf("upload temperature_p0 has an error, try again\n");
            else
                printf("onenet upload OK >>> temp_p0:%f\n", buf_mp->temperature_p0);

            rt_mp_free(buf_mp); /* 释放内存块 */
            buf_mp = RT_NULL;   /* 请务必要做 */
        }
    }
}

如果说,我们底下有多个发送节点采集温度,接收节点会收到多个发送节点的温度数据,而只有 一个 ESP8266,怎么在上传数据给 OneNet 的时候区分这些不同节点的数据?这里其实只需要建立不同的数据流就好了,每一个节点的数据为一个数据流,rt_err_t onenet_mqtt_upload_digit(const char *ds_name, const double digit) 函数的第一个参数就是数据流的名称,起不同名字就是不用的数据流了,如:

/* 上传发送节点1的数据到OneNet服务器,数据流名字是temperature_p0 */
onenet_mqtt_upload_digit("temperature_p0", buf_mp->temperature_p0);
/* 上传发送节点2的数据到OneNet服务器,数据流名字是temperature_p1 */
onenet_mqtt_upload_digit("temperature_p1", buf_mp->temperature_p1);

  • 15
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

饼干饼干圆又圆

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值