Android9.0(Pie)1号进程init的启动流程学习

   1、引言     

        众嗦粥汁,无论是电脑还是手机,亦或是其他终端设备,其从上电到完全启动都有一套复杂的流程。本来是打算使用相对简单一些的android4.4版本代码来学习这一整个流程,但是由于一部分工作生产环境从android4.4的kitkak切换到了9.0的pie,所以直接用9.0的代码来学习,学习代码和熟悉9.0环境两不误,哦耶!

        拿android系统的设备来说,由于其底层是linux内核,其从开机上电后,会先后经历加载boot、启动kernel等过程。(当然这个过程的流程也是很复杂的,由于对硬件和bootloader相关知识不太了解,所以在这里就不瞎说了。先挖个坑,等以后有相关知识储备后,再回来单独写文章填坑吧。)当kernel启动成功后,会去拉起android系统的1号进程,也就是我们这边文章的主角Init。

        //TODO Android从开机上电到拉起init之间的流程学习

        声明:文章中的代码是AOSP Android源码,代码路径会在每个代码块的第一行注明。本文是学习各位前辈大佬的博客或相关著作后,对比android源码的学习记录总结,不做任何商务用途。这里以及后续文章中参考或学习过的博文,会尽可能打上链接并且在文章开头逐一列举:


2、Init的main函数

        Init进程作为一个命令行程序,先找到它的main函数,由于该main函数相当长,所以将会根据其中各阶段的功能划分为几个模块逐一解析。

2.1、命令与参数解析

        Init作为一个命令行程序,kernel会以带绝对路径的方式启动它。通常来说init可执行文件在目标设备的系统根目录下("/init"),kernel也是优先在该目录下寻找init可执行文件。如果找不到的话,会转而去"/sbin/"等目录 下寻找并启动它。需要说明的时,此时kernel拉起的init进程还处于内核态。

// pie\system\core\init\init.cpp
int main(int argc, char** argv) {
    if (!strcmp(basename(argv[0]), "ueventd")) {    //以sbin/ueventd"拉起init时,启动ueventd
        return ueventd_main(argc, argv);
    }

    if (!strcmp(basename(argv[0]), "watchdogd")) {    //以"sbin/watchdogd"拉起init时,启动watchdogd
        return watchdogd_main(argc, argv);
    }

    if (argc > 1 && !strcmp(argv[1], "subcontext")) {    //第二个参数是"subcontext"时
        InitKernelLogging(argv);    //屏蔽init进程的标准输入、输出、错误输出
        const BuiltinFunctionMap function_map;
        return SubcontextMain(argc, argv, &function_map);    //TODO    
    }
    //开发调试阶段,init发生崩溃时重新加载bootloader,以避免循环加载错误配置
    if (REBOOT_BOOTLOADER_ON_PANIC) {
        InstallRebootSignalHandlers();
    }
}

       main函数一上来我就有点看不懂了,大家都知道C/C++程序main函数的argv[0]是命令行程序本身的绝对路径("/init"),通过basename()函数获取到程序名("init")。既然要想启动init程序,也那么basename(argv[0])的值一定是"init"。那为什么程序前两个if逻辑中,会觉得这个值会是"ueventd"或“watchdogd”,并启动相应的ueventd和watchdogd进程呢?

        迷惑之际,感觉这应该和编译脚本有关系,果然从init程序的编译脚本中发现了端倪:

// pie\system\core\init\Android.bp
…………
cc_binary {
…………
    name: "init",
    defaults: ["init_defaults"],
    required: [
        "e2fsdroid",
        "mke2fs",
        "sload_f2fs",
        "make_f2fs",
    ],
    static_executable: true,
    srcs: ["main.cpp"],
    symlinks: [
        "sbin/ueventd",
        "sbin/watchdogd",
    ],
}
…………

        从最后得到symlinks就可以看出,"sbin/ueventd"和"sbin/watchdogd"这两个程序是链接到init程序上的,这一点从目标设备也可以得到验证:

root@xxx:/ # ls -l /sbin/ueventd
lrwxrwxrwx 1 root root 7 1970-01-01 08:00 /sbin/ueventd -> ../init
root@xxx:/ # ls -l /sbin/watchdogd
lrwxrwxrwx 1 root root 7 1970-01-01 08:00 /sbin/watchdogd -> ../init

        这样就很好理解了,当我们启动"sbin/ueventd"或者"sbin/watchdogd"的时候,实际上拉起的是init程序,init程序的main函数解析到我们要启动的实际上是ueventd或watchdogd,就会执行相对应的ueventd_main()或watchdogd_main()函数。

        回到main函数中,当拉起init程序时所带的参数大于二并且第二个参数为"subcontext"时,先执行InitKernelLogging(argv)语句:

// pie\system\core\init\log.cpp
void InitKernelLogging(char* argv[]) {
    int fd = open("/sys/fs/selinux/null", O_RDWR);
    if (fd == -1) {
        int saved_errno = errno;
        android::base::InitLogging(argv, &android::base::KernelLogger, InitAborter);
        errno = saved_errno;
        PLOG(FATAL) << "Couldn't open /sys/fs/selinux/null";
    }
    dup2(fd, 0);    //屏蔽标准输入
    dup2(fd, 1);    //屏蔽标准输出
    dup2(fd, 2);    //屏蔽错误输出
    if (fd > 2) close(fd);
    //初始化日志系统,设置日志等级
    android::base::InitLogging(argv, &android::base::KernelLogger, InitAborter);
}

        open()函数以可读可写模式打开文件"/sys/fs/selinux/null",该函数返回的是0~255之间可用文件描述符的最小值。通过学习文章《linux之dup和dup2函数解析》可以知道,启动init进程时,该进程默认会有3个文件描述符存在:

  •         0:与进程的标准输入相关联
  •         1:与进程的标准输出相关联;
  •         2:与进程的标准错误输出相关联;

        代码中三次对dup2()函数的调用,将init进程本来用于标准输入输出的3个文件描述符,全部指向了文件"/sys/fs/selinux/null",即屏蔽了init进程的标准输入输出。简单来说就是init程序中的打印是看不到的,看不到怎么办嘞,看网上说内核空间产生的 log 通过 Linux 内核中的 log 系统进行处理,可以通过 dmesg 和/proc/kmsg 进行访问。另外,可以在/proc/进程pid/fd/目录下看到当前进程有哪些打开的文件描述符,看看init进程的情况以验证我们的说法:

root@xxx:/proc/1/fd # ls -l /proc/1/fd
total 0
lrwx------ 1 root root 64 2021-08-24 17:05 0 -> /sys/fs/selinux/null
lrwx------ 1 root root 64 2021-08-24 17:05 1 -> /sys/fs/selinux/null
lrwx------ 1 root root 64 2021-08-24 17:05 2 -> /sys/fs/selinux/null
lrwx------ 1 root root 64 2021-08-24 17:05 3 -> socket:[2189]
lrwx------ 1 root root 64 2021-08-24 17:05 4 -> anon_inode:[eventpoll]
lrwx------ 1 root root 64 2021-08-24 17:05 5 -> socket:[2190]
lrwx------ 1 root root 64 2021-08-24 17:05 6 -> socket:[2191]
lrwx------ 1 root root 64 2021-08-24 17:05 7 -> socket:[2192]
lrwx------ 1 root root 64 2020-01-01 08:00 8 -> socket:[2201]
lrwx------ 1 root root 64 2021-08-24 17:05 9 -> socket:[2203]

2.2、first stage 

        main函数在前几个if判断语句之后,逻辑走到了is_first_stage这一部分。

2.2.1、挂载文件系统,创建必须目录、设备节点

                参考《linux 文件操作函数 mount-mknod-mkdir》可知,这一部分首先做了如下的事情:

// pie\system\core\init\init.cpp
int main(int argc, char** argv) {
    bool is_first_stage = (getenv("INIT_SECOND_STAGE") == nullptr);
    if (is_first_stage) {
        boot_clock::time_point start_time = boot_clock::now();
        umask(0);    //清除屏蔽字,保证新建的目录的访问权限不受屏蔽字影响,即给予最大权限
        clearenv();    //清空init进程的环境变量
        setenv("PATH", _PATH_DEFPATH, 1);    //init进程的环境变量中添加:
        //PATH=/sbin:/system/sbin:/system/bin:/system/xbin:/odm/bin:/vendor/bin:/vendor/xbin 

        //将tmpfs文件系统挂载在/dev目录上,命名为tmpfs,设置目录权限为0755     
        mount("tmpfs", "/dev", "tmpfs", MS_NOSUID, "mode=0755");
        mkdir("/dev/pts", 0755);    //在/dev目录下创建pts目录,设置目录权限为0755
        mkdir("/dev/socket", 0755);    //在/dev目录下创建socket目录,设置目录权限为0755
        mount("devpts", "/dev/pts", "devpts", 0, NULL);    // 将devpts文件系统挂载到/dev/pts目录上
        #define MAKE_STR(x) __STRING(x)
        mount("proc", "/proc", "proc", 0, "hidepid=2,gid=" MAKE_STR(AID_READPROC));    //将proc文件系统挂载到/proc目录上
        chmod("/proc/cmdline", 0440);    //缩小Android原始命令可用权限范围
        gid_t groups[] = { AID_READPROC };    // #define AID_READPROC 3009  /* Allow /proc read access */
        setgroups(arraysize(groups), groups);
        mount("sysfs", "/sys", "sysfs", 0, NULL);    //将sysfs文件系统挂载到/sys目录上
        mount("selinuxfs", "/sys/fs/selinux", "selinuxfs", 0, NULL);    //将selinuxfs文件系统挂载到/sys/fs/selinux目录上
        mknod("/dev/kmsg", S_IFCHR | 0600, makedev(1, 11));    //创建字符设备文件/dev/kmsg
        if constexpr (WORLD_WRITABLE_KMSG) {    //开发者模式创建字符设备文件/dev/kmsg_debug
            mknod("/dev/kmsg_debug", S_IFCHR | 0622, makedev(1, 11));    
        }
        mknod("/dev/random", S_IFCHR | 0666, makedev(1, 8));    //创建字符设备文件/dev/random
        mknod("/dev/urandom", S_IFCHR | 0666, makedev(1, 9));    //创建字符设备文件/dev/urandom
        //将tmpfs文件系统也挂载在/mnt目录上,但是对读、写、执行等访问权限有特殊设置
        mount("tmpfs", "/mnt", "tmpfs", MS_NOEXEC | MS_NOSUID | MS_NODEV, "mode=0755,uid=0,gid=1000");    
        mkdir("/mnt/vendor", 0755);    //创建/mnt/vendor目录,设置目录权限为0755
        ………… 
    }          
}

        如上所示,该部分主要用于挂载和创建启动所需的文件系统和目录。需要注意的是,在编译Android系统源码时,在生成的根文件系统中, 并不存在这些目录,它们是系统运行时的目录,即当系统终止时,就会消失。 

2.2.2、重新启动/init

// pie\system\core\init\init.cpp
int main(int argc, char** argv) {
    …………
    if (is_first_stage) {
        …………
        InitKernelLogging(argv);    //屏蔽内核态init进程的标准输入、输出、错误输出,初始化kernel logging系统
        LOG(INFO) << "init first stage started!";
        if (!DoFirstStageMount()) {    //分区挂载
            LOG(FATAL) << "Failed to mount required partitions early ...";
        }
        SetInitAvbVersionInRecovery();    //TODO
        global_seccomp();    //TODO

        SelinuxSetupKernelLogging();    //注册回调,用来设置需要写入kmsg的selinux日志
        SelinuxInitialize();    加载SELinux规则

        if (selinux_android_restorecon("/init", 0) == -1) {    //按照selinux域规则,将init进程的内核域显式切换到init域
            PLOG(FATAL) << "restorecon failed of /init failed";
        }

        setenv("INIT_SECOND_STAGE", "true", 1);    //设置环境变量INIT_SECOND_STAGE=true

        static constexpr uint32_t kNanosecondsPerMillisecond = 1e6;
        uint64_t start_ms = start_time.time_since_epoch().count() / kNanosecondsPerMillisecond;
        setenv("INIT_STARTED_AT", std::to_string(start_ms).c_str(), 1);    //记录启动时间到环境变量:INIT_STARTED_AT

        char* path = argv[0];
        char* args[] = { path, nullptr };
        execv(path, args);    //    此时在selinux规则的init域,执行C空间的/init程序,使得从内核态切换到用户态

        PLOG(FATAL) << "execv(\"" << path << "\") failed";    //内核态的进程不应该退出,执行失败或崩溃时才会走到这里
    }
}

         总结一下第一阶段,做了这些事情:

  1. 挂载必需的文件系统,创建必需的目录和设备节点文件;
  2. 完成一些分区挂载,TODO;
  3. 加载selinux规则,并将域属性从内核域显式切换到init域;
  4. 启动C空间的/init程序,从内核态切换到用户态;

  2.3、second stage

 

         当用户态的init程序main函数执行进来时,环境变量INIT_SECOND_STAGE被设为true,所以第一阶段的代码将不再执行,转而走到了第二阶段。

// pie\system\core\init\init.cpp
int main(int argc, char** argv) {
    …………
    InitKernelLogging(argv);    屏蔽内核态init进程的标准输入、输出、错误输出,初始化kernel logging系统
    LOG(INFO) << "init second stage started!";

    keyctl_get_keyring_ID(KEY_SPEC_SESSION_KEYRING, 1);    //设置进程会话密钥

    close(open("/dev/.booting", O_WRONLY | O_CREAT | O_CLOEXEC, 0000));    //创建 /dev/.booting文件,表示正在启动到后台fw加载器等

    property_init();    //初始化property系统

    process_kernel_dt();    //处理DT属性,如果参数同时在命令行和DT中传递,则在DT中设置的属性总是优先于命令行设置的属性
    process_kernel_cmdline();    //处理命令行属性

    export_kernel_boot_props();    //根据kernel ro.boot.开头的几个属性,将内核属性设置到系统属性
    //根据环境变量设置一些属性
    property_set("ro.boottime.init", getenv("INIT_STARTED_AT"));    
    property_set("ro.boottime.init.selinux", getenv("INIT_SELINUX_TOOK"));

    const char* avb_version = getenv("INIT_AVB_VERSION");
    if (avb_version) property_set("ro.boot.avb_version", avb_version);

    //这些环境变量只有init进程才会用到,后续fork其他进程时会复制init环境变量,其他进程用不到这些环境变量。所以前面保存到属性后,就直接清除掉。
    unsetenv("INIT_SECOND_STAGE");
    unsetenv("INIT_STARTED_AT");
    unsetenv("INIT_SELINUX_TOOK");
    unsetenv("INIT_AVB_VERSION");

    //selinux相关的日志系统设置、上下文环境转换……
    SelinuxSetupKernelLogging();
    SelabelInitialize();
    SelinuxRestoreContext();

    epoll_fd = epoll_create1(EPOLL_CLOEXEC);    //创建epoll句柄
    if (epoll_fd == -1) {
        PLOG(FATAL) << "epoll_create1 failed";
    }

    sigchld_handler_init();    //装载子进程信号处理器

    //如果init没有CAP_SYS_BOOT功能,那么它是在容器中运行的。在这种情况下,接收SIGTERM将导致系统关闭。
    if (!IsRebootCapable()) {
        InstallSigtermHandler();
    }

    property_load_boot_defaults();    //加载指定文件中的默认属性值
    export_oem_lock_status();    //设置相关属性
    start_property_service();    //启动property服务,监听处理android系统中的属性操作
    set_usb_controller();    //从“/sys/class/ UDC”中读取并设置使用中的UDC控制器
    …………
}

         这一阶段所作的事如上面代码中的注释所示,除了做一些日志、进程密钥、环境变量、selinux相关的设置外,有两个对系统来说比较关键的东西,那就是属性系统和子进程信号处理相关的内容。

2.3.1、属性系统

        关于属性系统,我之前有写过两篇Android4.4 kitkak版本中属性系统的文章,有兴趣的话可以浏览一下:《Android4.4 property机制学习》《《Android4.4 property机制学习》补充篇——属性树与其在内存中的存储结构》。虽说从4.4到9.0经历的版本变化比较大,但基本原理是没怎么变化的,有时间的话再回来补一下变化的内容。

2.3.2、子进程信号处理

        这一部分理解的不是很明白,就不瞎说了,有兴趣可以参考《Android 10.0系统启动之init进程-[Android取经之路]》的第五模块——信号处理,讲的很详细的,后面有更深入的理解后再来补充。

2.4、*.rc文件

        Android系统除了Init这个1号进程外,还拥有众多其他的重要进程,这些进程都是直接或间接由init进程fork而来。但一个一个手动去启动这些进程就太捞了,于是有了.rc配置文件的出现,通过配置文件更加合理和方便的控制进程的启动与管理。关于.rc文件的语法,大家可以参考《Android系统init进程启动及init.rc全解析》

// pie\system\core\init\init.cpp
int main(int argc, char** argv) {
    …………
    const BuiltinFunctionMap function_map;    
    Action::set_function_map(&function_map);    //在Action类中保存function_map对象,该对象记录了命令与函数之间的对应关系
    subcontexts = InitializeSubcontexts();    //初始化上下文

    ActionManager& am = ActionManager::GetInstance();    //获取ActionManager实例
    ServiceList& sm = ServiceList::GetInstance();    //获取ServiceList实例
    LoadBootScripts(am, sm);    // 加载.rc配置文件,将Action和Service读取到对应的Queue和List中去

    if (false) DumpState();
    am.QueueEventTrigger("early-init");    //触发Action队列中以early-init为触发器的那些Action
    am.QueueBuiltinAction(wait_for_coldboot_done_action, "wait_for_coldboot_done");
    am.QueueBuiltinAction(MixHwrngIntoLinuxRngAction, "MixHwrngIntoLinuxRng");
    am.QueueBuiltinAction(SetMmapRndBitsAction, "SetMmapRndBits");
    am.QueueBuiltinAction(SetKptrRestrictAction, "SetKptrRestrict");
    am.QueueBuiltinAction(keychord_init_action, "keychord_init");
    am.QueueBuiltinAction(console_init_action, "console_init");
    am.QueueEventTrigger("init");//触发Action队列中以init为触发器的那些Action,主要是触发所有boot Action
    am.QueueBuiltinAction(MixHwrngIntoLinuxRngAction, "MixHwrngIntoLinuxRng");

    std::string bootmode = GetProperty("ro.bootmode", "");
    if (bootmode == "charger") {    //如果是关机充电模式,则触发charger,否则正常启机模式下触发late-init
        am.QueueEventTrigger("charger");    //charger模式不挂载文件系统或启动核心系统服务
    } else {
        am.QueueEventTrigger("late-init");
    }
    //此时系统属性已经加载完毕,所以触发.rc文件中以属性值为触发器的那些Action
    am.QueueBuiltinAction(queue_property_triggers_action, "queue_property_triggers");
    …………
}

        如上所示,这里主要就是去解析.rc文件,将.rc文件中的Action顺序读取到Queue中,将Service读取到List中,然后通过触发器去启动这些Action和Service。需要说明的是,这里的触发只是根据对应的阶段事件,将对应Action顺序存入队列中,真正执行这些Action的动作在下面的2.5模块。这里先看一下LoadBootScripts()函数:

//pie\system\core\init\init.cpp
static void LoadBootScripts(ActionManager& action_manager, ServiceList& service_list) {
    Parser parser = CreateParser(action_manager, service_list);

    std::string bootscript = GetProperty("ro.boot.init_rc", "");
    if (bootscript.empty()) {
        parser.ParseConfig("/init.rc");
        if (!parser.ParseConfig("/system/etc/init")) {
            late_import_paths.emplace_back("/system/etc/init");
        }
        if (!parser.ParseConfig("/product/etc/init")) {
            late_import_paths.emplace_back("/product/etc/init");
        }
        if (!parser.ParseConfig("/odm/etc/init")) {
            late_import_paths.emplace_back("/odm/etc/init");
        }
        if (!parser.ParseConfig("/vendor/etc/init")) {
            late_import_paths.emplace_back("/vendor/etc/init");
        }
    } else {
        parser.ParseConfig(bootscript);
    }
}

        其首先通过CreateParser()函数创建parser用于解析.rc文件,具体来说就是通过AddSectionParser()函数,根据rc文件的语法规则,将不同模块语句的首个单词字符串,与其对应类型的parser解析器实例建立key-value的键值关系,存储在std::map类型的关联容器中,用于后续对rc文件不同类型语句的解析。(这里的键值对应关系,指rc文件中出现在文件头部用于导入其他rc文件的import语句、Action结构首部出现的on语句、Service结构首部出现的service语句,与它们对应的解析类实例之间的对应关系。)

// pie\system\core\init\parser.h
std::map<std::string, std::unique_ptr<SectionParser>> section_parsers_;

// pie\system\core\init\parser.cpp
void Parser::AddSectionParser(const std::string& name, std::unique_ptr<SectionParser> parser) {
    section_parsers_[name] = std::move(parser);
}

// pie\system\core\init\init.cpp
Parser CreateParser(ActionManager& action_manager, ServiceList& service_list) {
    Parser parser;
    parser.AddSectionParser("service", std::make_unique<ServiceParser>(&service_list, subcontexts));
    parser.AddSectionParser("on", std::make_unique<ActionParser>(&action_manager, subcontexts));
    parser.AddSectionParser("import", std::make_unique<ImportParser>(&parser));
    return parser;
}

        接着可以看到如果通过属性ro.boot.init_rc指定了rc文件的路径,则直接去解析属性指定的rc文件。否则(在Android7.0版本之后,为了方便维护,允许各个小模块编写自己独立的.rc配置文件,然后在编译打包结束后,烧录时复制到目标机器的"/system/etc/init"、"/product/etc/init"、"/odm/etc/init"、"/vendor/etc/init"这些目录下。)需要先解析目标设备根目录下的init.rc文件,然后去这几个目录下去收集.rc文件,并通过ParseConfig()函数解析它们。解析过程稍显复杂,有兴趣可以在pie\system\core\init\parser.cpp中看详细的解析过程,我们这里只需要知道这个函数干了什么就好。

2.5、while循环

        main函数走到最后,这里就剩下一个while循环了:

// pie\system\core\init\init.cpp
int main(int argc, char** argv) {
    …………
    while (true) {
        int epoll_timeout_ms = -1;
        //如果更改sys.powerctl属性,则通过HandlePowerctlMessage()函数处理该属性值所代表的shutdown或reboot动作
        if (do_shutdown && !shutting_down) {    
            do_shutdown = false;
            if (HandlePowerctlMessage(shutdown_command)) {
                shutting_down = true;
            }
        }
        if (!(waiting_for_prop || Service::is_exec_service_running())) {
            am.ExecuteOneCommand();    //开始顺序执行队列中的Actions
        }
        if (!(waiting_for_prop || Service::is_exec_service_running())) {
            if (!shutting_down) {
                //如果是第一次执行,则去顺序启动所有的service,否则重启那些非oneshot类型的、并且已经挂掉的service
                auto next_process_restart_time = RestartProcesses();

                if (next_process_restart_time) {    //在设定的重启时间不阻塞,及时重启进程
                    epoll_timeout_ms = std::chrono::ceil<std::chrono::milliseconds>(
                                           *next_process_restart_time - boot_clock::now())
                                           .count();
                    if (epoll_timeout_ms < 0) epoll_timeout_ms = 0;
                }
            }

            if (am.HasMoreCommands()) epoll_timeout_ms = 0;    //还有action没处理完时不阻塞,及时处理
        }
        // 没有事件到来的话,最多阻塞epoll_timeout_ms时间
        epoll_event ev;
        int nr = TEMP_FAILURE_RETRY(epoll_wait(epoll_fd, &ev, 1, epoll_timeout_ms));
        if (nr == -1) {
            PLOG(ERROR) << "epoll_wait failed";
        } else if (nr == 1) {    //有事件到来,执行对应动作
            ((void (*)()) ev.data.ptr)();
        }
    }

    return 0;
}

 一图总结全文如下:

3、结语

        由于能力有限,在不同的学习阶段理解和认知能力有许多不同,所以就先写目前所能理解的这些。以后回看的时候,再纠正问题和补充文中的TODO部分,欢迎补充和指正不足。

  • 3
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值