16、编程中的延迟绑定与初始化模式解析

延迟绑定与初始化模式详解

编程中的延迟绑定与初始化模式解析

延迟绑定相关模式

编译时绑定是一种延迟绑定形式,它是一种抽象技术,能根据编译时设置创建项目的不同版本。虽然会增加头文件结构和构建脚本的复杂度,但能可靠地自动构建二进制文件的多个变体。为便于构建不同版本,可在代码中实现LHeader和LConfig两种模式。

LHeader模式

LHeader(延迟头文件)模式会将实际包含哪些头文件的决策推迟到编译时,这样名称绑定也在编译时才发生。这种解耦使模块更独立、可复用,还简化了单元测试的构建。该模式最初是为了创建与编译器或平台无关的C代码,且无需在不同平台的源代码文件中使用#ifdef/#else结构。其主要机制是C/C++编译器的头文件搜索路径选项。

LHeader模式有四个主要组成部分:
1. 使用“裸”#include语句 :在创建源代码文件时,使用不指定路径的#include语句引用一个在基本头文件搜索路径中无法解析的头文件。最终在编译时包含的文件负责解析源文件中延迟的名称绑定。例如:

#include "colony_map.h" // Note: no path specified
  1. 创建延迟名称绑定 :通过定义一个预处理器符号,将其映射到另一个尚未解析的符号名称。例如:
/** Defer the definition of the raw mutex type to the
    application's 'platform'
 */
#define Cpl_System_Mutex_T          Cpl_System_Mutex_T_MAP
  1. 添加项目特定的搜索路径 :在每个项目(或每个单元测试)的基础上,添加一个项目特定的搜索路径,使部分#include引用能解析为项目特定的头文件实例。例如,使用Visual Studio编译器时,可使用 -I 命令行选项指定编译器头文件搜索路径。以下是构建PIM示例恒温器应用模拟器时使用的编译器头文件搜索路径,其中project\Storm\Thermostat\simulation\windows\vc12是构建目录:
-I\_workspaces\zoe\pim\src
-I\_workspaces\zoe\pim\projects\Storm\Thermostat\simulation\windows\vc12
  1. 创建项目特定的头文件 :创建一个项目特定的头文件来解析延迟的名称绑定。例如,位于project\Storm\Thermostat\simulation\windows\vc12目录下的colony_map.h文件内容如下:
// strapi mapping
#include "Cpl/Text/_mappings/_vc12/strapi.h"
// Cpl::System mappings
#include "Cpl/System/Win32/mappings_.h"

colony_map.h头文件中的#include语句引入了特定的映射,用于解析Cpl::Text和Cpl::System接口。例如,Cpl/Text/strapi.h文件内容如下:

/** Same as strcasecmp, but only compares up to 'n' bytes.  It has
    the same semantics as strncmp.
    Prototype:
       int strncasecmp(const char *s1, const char *s2, size_t n);
 */
#define strncasecmp         strncasecmp_MAP

而Cpl/Text/_mappings/_vc12/strapi.h在使用Visual Studio编译器构建时解析该映射:

#define strcasecmp_MAP          _stricmp
LHeader模式实现示例

Colony.core C++库实现了LHeader模式来延迟具体互斥锁类型的绑定,它依赖于PIM的文件组织和#include最佳实践:
1. 保留头文件名 :colony_map.h是保留的头文件名,其他模块、类、接口等不能使用该文件名。
2. 遵循#include最佳实践 :所有#include语句都相对于src/目录,即路径信息始终是#include语句的一部分,避免意外包含colony_map.h文件。
3. 使用NQBP构建系统 :colony. 仓库使用NQBP构建系统,该系统的所有项目和单元测试构建都在与src/目录树分开的目录树中进行。每个项目和单元测试构建脚本通过将其构建目录(可执行映像编译和链接的目录)添加到编译器的头文件搜索路径来定制,从而提供每个项目唯一的头文件搜索路径。
4.
放置项目特定的头文件 *:在每个构建目录中放置一个colony_map.h头文件实例,其内容是一系列#include语句,引用src/树中的文件,用于解析延迟的名称绑定。

src/Cpl/System/Mutex.h头文件定义了一个具有延迟绑定的具体互斥锁:

#ifndef Cpl_System_Mutex_h_
#define Cpl_System_Mutex_h_
#include "colony_map.h"
/** Defer the definition of the a raw mutex type to the application's
    'platform'
 */
#define Cpl_System_Mutex_T          Cpl_System_Mutex_T_MAP
...
#endif // end Header latch

以下是项目构建脚本中常见的头文件搜索路径示例:
| 路径 | 说明 |
| — | — |
| pim/projects/Storm/Thermostat/adafruit-grand-central-m4 | 目标构建 |
| pim/projects/Storm/Thermostat/windows/gcc/colony_map.h | #include “Cpl/System/FreeRTOS/mappings_.h” |
| pim/projects/Storm/Thermostat/simulation/linux/gcc/colony_map.h | #include “Cpl/System/Posix/mappings_.h” |
| pim/projects/Storm/Thermostat/simulation/windows/vc12/colony_map.h | #include “Cpl/System/Win32/mappings_.h” |
| pim/src/… | 源代码目录 |

LHeader模式实现注意事项

当接口A和接口B都使用LHeader模式延迟类型定义,且接口B依赖于接口A时,会出现循环头文件包含的情况,导致编译失败。在C++中这种情况通常较少出现,但在使用PIM的C代码中可能会遇到。遇到这种情况时,需遵循以下约束:
1. 保留头文件规则 :项目特定的保留头文件(如colony_map.h)只能包含对其他头文件的#include语句,且不能有头文件锁(即头文件顶部和底部不能有#ifndef MY_HEADER_FILE结构)。
2. 头文件符号检查 :由项目特定的colony_map.h头文件包含并用于解析名称绑定的头文件,在其头文件锁中应进行额外的符号检查,检查最初声明要延迟绑定名称的模块文件的头文件锁符号。例如,pim/src/Cpl/System/Win32/mappings.h文件的额外符号检查如下:

// Header latch symbol from the Mutex interface
#ifdef  Cpl_System_Mutex_h_
// Traditional header latch for my file
#ifndef Cpl_System_Win32_mappings_h_
#define Cpl_System_Win32_mappings_h_
...
#endif // end header latch
#endif // end interface latch

这些约定仅在存在嵌套延迟typedef时需要,虽然示例代码中的Cpl/System/Win32/mappings.h头文件未使用额外的接口锁,但建议始终遵循“保留头文件中无头部锁”的规则,以避免因循环头文件#include语句导致的编译失败。

LConfig模式

LConfig(延迟配置)模式是LHeader模式的特殊情况,专门用于配置。它提供包含预处理器指令或符号定义的项目特定头文件,用于定制源代码的默认行为。

LConfig模式使用全局唯一的头文件名,并依赖每个项目唯一的头文件搜索路径来选择项目特定的配置。它使用与LHeader模式相同的基本机制,但不用于解析延迟的函数和类型名称绑定。配置设置用于覆盖默认值或魔法常量,以启用或禁用条件编译的代码。Colony.core C++库使用保留文件名colony_config.h来实现LConfig模式。

以下是LConfig模式的示例:

// Symbol definition in the src/ tree
#include "colony_config.h"     // LConfig pattern reserved header file
// Actual size value be overridden at compile using LConfig
#ifndef OPTION_FOO_BAR_MAX_BUFFER_SIZE
#define OPTION_FOO_BAR_MAX_BUFFER_SIZE    128
#endif
...
uint8_t my_buffer[OPTION_FOO_BAR_MAX_BUFFER_SIZE];
...

// Configuration in the src/ tree
#include "colony_config.h"     // LConfig pattern reserved header file
...
// Defined in colony_config.h to enable tracing
#ifdef USE_CPL_SYSTEM_TRACE
...
#define CPL_SYSTEM_TRACE_MSG(sect, var_args)       do { ... } while(0)
...
#else
...
#define CPL_SYSTEM_TRACE_MSG(sect, var_args)
...
#endif // end USE_CPL_SYSTEM_TRACE

// Example colony_config.h (in a project directory)
#ifndef COLONY_CONFIG_H
#define COLONY_CONFIG_H
// Make the buffer small to simply testing
#define OPTION_FOO_BAR_MAX_BUFFER_SIZE  5
// Enable tracing (for debugging my unit test)
#define USE_CPL_SYSTEM_TRACE
#endif

由于LConfig模式仅用于定义魔法常量和条件编译代码的预处理器指令,因此不会出现LHeader模式中可能的循环头文件包含问题。

应用初始化相关模式

应用程序运行时,初始化阶段可能简单也可能复杂。简单情况下,所有代码可随时启动,无需考虑其他代码的启动状态;复杂情况下,一个模块的初始化可能依赖于另一个模块已经启动。

初始化顺序示例

假设有三个模块:上电自检(POST)、非易失性RAM驱动程序和日志子系统,期望的启动顺序如下:
1. 上电自检(POST)。
2. 启动非易失性RAM驱动程序。
3. 启动日志模块。

start( LoggingApi& logger )
POST
start( FileApi& mediaDriver )
Logging
start()
NVRam Driver
Logging API
File Api
logs errors
Desired start-up sequence:
POST
myPost;
NVRamDriver myNvram;
Logging
myLogger;
main()
{
...
...
// Application start-up
myPost.start( myLogger );
myNvram.start();
myLogger.start( myNvram );
};

这里的挑战是上电自检模块希望使用日志子系统报告错误和警告,但上电自检模块在日志模块启动之前执行,而日志模块在非易失性RAM驱动程序启动后才能启动,形成了“鸡与蛋”的问题。

两阶段启动解决方案

一种解决方案是对三个模块进行“足够的”初始化,使它们能被其他模块引用或使用而不崩溃,然后进行第二阶段的启动,完全初始化每个模块。例如,日志模块的第一阶段初始化是初始化其内部缓冲区,以便缓存和接受日志条目,但不尝试将日志条目写入非易失性介质;第二阶段则完成初始化并开始将日志条目写入非易失性介质,包括第一和第二阶段初始化之间缓存的日志条目。

initialize( LoggingApi& logger )
start()
POST
initialize( FileApi& mediaDriver )
start()
Logging
initialize()
start()
NVRam Driver
logs errors
Revised start-up sequence:
POST              myPost;
NVRamDriver myNvram;
Logging          myLogger;
main()
{
...
...
...
// Application initialization
myPost.initialize( myLogger );
myNvram.initialize();
myLogger.initialize( myNvram );
// Application start-up
myPost.start();
myNvram.start();
myLogger.start();
};
多线程和C++静态对象初始化问题

对于多线程应用程序,启动序列会更复杂,可能遇到以下问题:
- 需要在启用线程调度器之前初始化模块或子系统,即在应用程序作为单线程运行且中断被禁用时进行初始化。
- 需要在启用线程调度器和中断后,在模块或子系统执行的线程中初始化模块或子系统。

如果使用C++并需要静态分配一个或多个对象,会遇到以下问题:
- 静态分配的C++对象的构造函数在C++/C启动运行时代码中执行,即在main()调用之前执行,析构函数在main()退出后执行。由于PIM强烈不鼓励使用动态内存,PIM应用程序会有许多静态分配的C++对象。
- C++标准不保证静态分配对象的创建顺序和析构函数的执行顺序。
- 底层操作系统和其他系统服务可能在main()开始执行后才可用。例如,运行在Arduino硬件上的PIM恒温器应用程序示例的BSP代码在main()中初始化Arduino框架并启动FreeRTOS,而这发生在静态分配对象的构造函数执行之后。

分阶段初始化建议

为解决这些启动问题,建议如下:
- C++代码构造函数限制 :对于C++代码,构造函数方法不应调用或使用其他对象的方法,也不应进行任何OSAL或系统服务调用。构造函数的作用应仅限于初始化成员变量,包括存储作为构造函数参数传递的其他类的引用和指针。

例如,以下代码中,如果Foo和Bar类的实例都是静态分配的,可能会产生错误:

class Foo
{
public:
    /// Constructor
    Foo() { ... }
    /// Reset the instance to its initial state
    void reset() { ... }
};

class Bar
{
public:
    /// Constructor
    Bar( Foo& helper ) :m_helper( helper )  // Save the reference
    {
        m_helper.reset();   // POTENTIAL ERROR;
    }
protected:
    /// Reference to my helper object
    Foo& m_helper;
};

为确保Bar对象在静态分配时始终正确运行,Bar需要一个额外的方法,在main()开始执行后调用以完成所有初始化:

class Bar
{
public:
    /// Constructor
    Bar( Foo& helper ) :m_helper( helper )  // Save the reference
    {
    }
    /// Reset the instance to its initial state
    void reset() { m_helper.reset(); ... } // Safe to reset my
                                           // helper object
protected:
    /// Reference to my helper object
    Foo& m_helper;
};

例外情况是当对象在其构造函数中向容器或其他实体进行自我注册时,例如Colony.core C++库中的模型点会自我注册到模型点数据库中,这种注册机制无论对象是静态分配还是在main()开始执行后从堆中分配都能正常工作。

  • 多线程应用程序的分阶段初始化 :对于多线程应用程序,将模块和子系统的初始化至少分为两个阶段。第一阶段应在多个线程运行和中断启用之前运行,第二阶段应在模块或子系统的线程上下文中稍后运行。每个初始化阶段应有相应的停止或关闭方法,并定义命名约定,使开发人员清楚这些方法的使用场景。
    • initialize()方法 :用于在线程调度器和中断启用之前执行的初始化。
    • open()和close()方法 :分别用于触发线程内初始化和线程内关闭,这些方法是同步的线程间通信调用,具有线程感知能力,负责实现线程间通信调用的语义。
    • start()和stop()方法 :分别用于执行线程内初始化和线程内关闭,与open()和close()不同,这些方法没有线程感知能力,假设调用者负责提供正确的线程上下文。例如,一个子系统有一对open()和close()方法,其实现会调用在同一线程中执行的私有模块的start()和stop()方法。

同时,应始终确保初始化方法成对出现,即open()方法应有对应的close()方法,start()方法应有对应的stop()方法。如果模块不需要某个或多个分阶段初始化步骤,则无需实现,但限制C++构造函数操作以确保对象静态分配时正常运行的做法应作为最佳实践遵循。如果所有对象在启动时都从堆中分配,则可跳过此做法。

编程中的延迟绑定与初始化模式解析

多线程应用程序分阶段初始化详细分析
分阶段初始化流程

为了更清晰地理解多线程应用程序的分阶段初始化,我们可以通过以下流程图展示其过程:

graph LR
    classDef startend fill:#F5EBFF,stroke:#BE8FED,stroke-width:2px;
    classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
    classDef decision fill:#FFF6CC,stroke:#FFBC52,stroke-width:2px;

    A([开始]):::startend --> B(单线程初始化阶段):::process
    B --> C{是否完成单线程初始化?}:::decision
    C -->|是| D(启用线程调度器和中断):::process
    C -->|否| B
    D --> E(线程内初始化阶段):::process
    E --> F{是否完成线程内初始化?}:::decision
    F -->|是| G([应用程序正常运行]):::startend
    F -->|否| E

从这个流程图可以看出,多线程应用程序的初始化首先在单线程环境下进行,这个阶段主要完成一些对线程调度器和中断不依赖的初始化操作。只有当单线程初始化完成后,才会启用线程调度器和中断,进入线程内初始化阶段。线程内初始化会在各个模块或子系统的线程上下文中完成特定的初始化任务,确保每个模块都能在正确的线程环境中正常工作。

命名约定的重要性

采用统一的命名约定对于多线程应用程序的开发至关重要。通过明确的命名,开发人员可以快速了解每个方法的使用场景和目的,减少误解和错误。以下是一个具体的示例,展示了不同命名方法的使用:

class MyModule {
public:
    // 单线程初始化方法
    void initialize() {
        // 初始化一些不依赖线程调度器和中断的资源
        // 例如初始化内部缓冲区
        buffer_.resize(1024);
    }

    // 线程内初始化方法,具有线程感知能力
    bool open() {
        // 执行一些需要在特定线程中完成的初始化操作
        // 例如启动线程特定的服务
        threadService_.start();
        return true;
    }

    // 线程内关闭方法
    void close() {
        // 停止线程特定的服务
        threadService_.stop();
    }

    // 线程内初始化方法,无线程感知能力
    void start() {
        // 执行一些与线程上下文无关的初始化操作
        // 例如加载配置文件
        loadConfig();
    }

    // 线程内关闭方法
    void stop() {
        // 清理一些与线程上下文无关的资源
        clearConfig();
    }

private:
    std::vector<char> buffer_;
    ThreadService threadService_;

    void loadConfig() {
        // 加载配置文件的具体实现
    }

    void clearConfig() {
        // 清理配置文件的具体实现
    }
};

在这个示例中, initialize() 方法用于单线程初始化, open() close() 方法用于线程内的有感知初始化和关闭, start() stop() 方法用于线程内的无感知初始化和关闭。通过这种清晰的命名约定,开发人员可以很容易地理解每个方法的作用和使用时机。

延迟绑定与初始化模式的综合应用案例
案例背景

假设我们正在开发一个嵌入式系统,该系统包含多个模块,如传感器数据采集模块、数据处理模块和数据存储模块。系统需要支持不同的硬件平台和配置,同时要确保在多线程环境下稳定运行。

延迟绑定模式的应用

为了支持不同的硬件平台和配置,我们可以使用延迟绑定模式中的 LHeader 和 LConfig 模式。

  • LHeader 模式 :通过 LHeader 模式,我们可以根据不同的平台选择不同的头文件来解析延迟的名称绑定。例如,对于传感器数据采集模块,我们可以定义一个延迟绑定的传感器类型:
// src/SensorModule.h
#ifndef SensorModule_h_
#define SensorModule_h_
#include "colony_map.h"

/** Defer the definition of the sensor type to the application's 'platform' */
#define SensorType_T          SensorType_T_MAP

// 其他代码...

#endif // end Header latch

在不同的平台目录下,我们可以创建不同的 colony_map.h 文件来解析这个延迟绑定。例如,在 Windows 平台下:

// projects/Windows/colony_map.h
#include "SensorModule/Windows/SensorMappings.h"
// projects/Windows/SensorModule/Windows/SensorMappings.h
#define SensorType_T_MAP  WindowsSensorType
  • LConfig 模式 :使用 LConfig 模式可以根据不同的项目配置来定制系统的行为。例如,我们可以通过 colony_config.h 文件来配置数据采集的频率:
// src/SensorModule.c
#include "colony_config.h"

#ifndef SENSOR_SAMPLING_FREQUENCY
#define SENSOR_SAMPLING_FREQUENCY  10 // 默认采样频率为 10Hz
#endif

void sensorSamplingLoop() {
    while (1) {
        // 采集传感器数据
        sampleSensorData();
        // 根据配置的采样频率进行延时
        delay(1000 / SENSOR_SAMPLING_FREQUENCY);
    }
}
// projects/HighFrequency/colony_config.h
#ifndef COLONY_CONFIG_H
#define COLONY_CONFIG_H
// 提高采样频率到 20Hz
#define SENSOR_SAMPLING_FREQUENCY  20
#endif
初始化模式的应用

对于多线程环境下的初始化,我们采用分阶段初始化的方法。以下是各个模块的初始化代码示例:

// 传感器数据采集模块
class SensorModule {
public:
    void initialize() {
        // 单线程初始化,如初始化传感器驱动
        sensorDriver_.initialize();
    }

    void open() {
        // 线程内初始化,如启动传感器数据采集线程
        sensorThread_.start();
    }

    void close() {
        // 线程内关闭,如停止传感器数据采集线程
        sensorThread_.stop();
    }

    void start() {
        // 线程内初始化,如开始采集数据
        sensorDriver_.startSampling();
    }

    void stop() {
        // 线程内关闭,如停止采集数据
        sensorDriver_.stopSampling();
    }

private:
    SensorDriver sensorDriver_;
    Thread sensorThread_;
};

// 数据处理模块
class DataProcessingModule {
public:
    void initialize() {
        // 单线程初始化,如初始化数据处理算法
        processingAlgorithm_.initialize();
    }

    void open() {
        // 线程内初始化,如启动数据处理线程
        processingThread_.start();
    }

    void close() {
        // 线程内关闭,如停止数据处理线程
        processingThread_.stop();
    }

    void start() {
        // 线程内初始化,如开始处理数据
        processingAlgorithm_.startProcessing();
    }

    void stop() {
        // 线程内关闭,如停止处理数据
        processingAlgorithm_.stopProcessing();
    }

private:
    DataProcessingAlgorithm processingAlgorithm_;
    Thread processingThread_;
};

// 数据存储模块
class DataStorageModule {
public:
    void initialize() {
        // 单线程初始化,如初始化存储设备
        storageDevice_.initialize();
    }

    void open() {
        // 线程内初始化,如启动数据存储线程
        storageThread_.start();
    }

    void close() {
        // 线程内关闭,如停止数据存储线程
        storageThread_.stop();
    }

    void start() {
        // 线程内初始化,如开始存储数据
        storageDevice_.startStoring();
    }

    void stop() {
        // 线程内关闭,如停止存储数据
        storageDevice_.stopStoring();
    }

private:
    StorageDevice storageDevice_;
    Thread storageThread_;
};

// 主函数中的初始化流程
int main() {
    SensorModule sensorModule;
    DataProcessingModule dataProcessingModule;
    DataStorageModule dataStorageModule;

    // 单线程初始化阶段
    sensorModule.initialize();
    dataProcessingModule.initialize();
    dataStorageModule.initialize();

    // 启用线程调度器和中断

    // 线程内初始化阶段
    sensorModule.open();
    dataProcessingModule.open();
    dataStorageModule.open();

    sensorModule.start();
    dataProcessingModule.start();
    dataStorageModule.start();

    // 应用程序正常运行

    return 0;
}

通过这种综合应用延迟绑定和初始化模式的方法,我们可以使系统更加灵活、可配置,并且能够在多线程环境下稳定运行。

总结

延迟绑定模式(LHeader 和 LConfig)和初始化模式(分阶段初始化)在编程中具有重要的作用。延迟绑定模式可以根据不同的编译时设置和项目配置创建项目的不同版本,提高代码的可复用性和可维护性。初始化模式则可以解决多线程应用程序和 C++ 静态对象初始化过程中遇到的各种问题,确保系统在启动过程中稳定可靠。

在实际开发中,我们应该根据项目的需求合理运用这些模式。例如,对于需要支持多种硬件平台和配置的项目,使用延迟绑定模式可以方便地进行定制;对于多线程应用程序,采用分阶段初始化可以避免各种初始化顺序和线程安全问题。同时,遵循相关的命名约定和代码规范,如限制 C++ 构造函数的操作、确保初始化方法成对出现等,能够提高代码的质量和可维护性。

通过本文的介绍和示例,希望读者能够更好地理解和应用延迟绑定与初始化模式,提升编程能力和项目开发效率。

【电能质量扰动】基于ML和DWT的电能质量扰动分类方法研究(Matlab实现)内容概要:本文研究了一种基于机器学习(ML)和离散小波变换(DWT)的电能质量扰动分类方法,并提供了Matlab实现方案。首先利用DWT对电能质量信号进行多尺度分解,提取信号的时频域特征,有效捕捉电压暂降、暂升、中断、谐波、闪变等常见扰动的关键信息;随后结合机器学习分类器(如SVM、BP神经网络等)对提取的特征进行训练分类,实现对不同类型扰动的自动识别准确区分。该方法充分发挥DWT在信号去噪特征提取方面的优势,结合ML强大的模式识别能力,提升了分类精度鲁棒性,具有较强的实用价值。; 适合人群:电气工程、自动化、电力系统及其自动化等相关专业的研究生、科研人员及从事电能质量监测分析的工程技术人员;具备一定的信号处理基础和Matlab编程能力者更佳。; 使用场景及目标:①应用于智能电网中的电能质量在线监测系统,实现扰动类型的自动识别;②作为高校或科研机构在信号处理、模式识别、电力系统分析等课程的教学案例或科研实验平台;③目标是提高电能质量扰动分类的准确性效率,为后续的电能治理设备保护提供决策依据。; 阅读建议:建议读者结合Matlab代码深入理解DWT的实现过程特征提取步骤,重点关注小波基选择、分解层数设定及特征向量构造对分类性能的影响,并尝试对比不同机器学习模型的分类效果,以全面掌握该方法的核心技术要点。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值