第7章 UNIX进程的环境

第7章 UNIX进程的环境

7.1 引言

本章将学习以下内容:

  • 当执行程序时,其main函数是如何被调用的,命令行参数是如何传送给执行程序的;
  • 典型的存储器布局是什么样式;
  • 如何分配另外的存储空间;
  • 进程如何使用环境变量;
  • 进程终止的不同方式等;
  • 说明longjmp和setjmp函数以及它们与栈的交互作用;
  • 查看进程的资源限制。

7.2 main函数

C程序总是从main函数开始执行的。main函数的原型是:

int main(int argc,char* argv[]);
  • 参数:
    • argc:命令行参数的数目
    • argv:由指向各命令行参数的指针所组成的数组。ISOCPOSIX都要求argv[argc]是一个空指针

当内核执行 C 程序时,在调用main之前先调用一个特殊的启动例程。

  • 内核执行C程序是通过使用一个exec函数实现的
  • 可执行程序文件将此启动例程指定为程序的起始地址(这是由链接器设置的,而链接器由C编译器调用)
  • 启动例程从内核取得命令行参数和环境变量值,然后为调用main函数做好安排

7.3 进程终止

有 8 种方式使得进程终止,其中 5 种为正常终止,3 种异常终止:

  • 正常终止方式:
    • main函数返回
    • 调用exit函数
    • 调用_exit函数或者_Exit函数
    • 多线程的程序中,最后一个线程从其启动例程返回
    • 多线程的程序中,从最后一个线程调用pthread_exit函数
  • 异常终止方式:
    • 调用abort函数
    • 接收到一个信号
    • 多线程的程序中,最后一个线程对取消请求作出响应

7.3.1 exit和_exit函数

正常终止一个程序:

#include <stdlib.h>
void exit(int status);
#include <unistd.h>
void _exit(int status);
  • 参数:status:终止状态

上述两个退出函数的区别:

  • _exit_Exit函数:立即进入内核

  • exit函数:先执行一些清理处理,然后返回内核

    “清理”代表什么?见下文

  • exit_Exit是由ISOC说明的, _exit是由POSIX说明的 ,因此头文件不同

注意:

  • 上述三个函数都带有一个整型参数,称为终止状态(或称作退出状态)。大多数 UNIX 系统 shell 都提供检查进程终止状态的方法
    • 若调用上述两个函数是不带终止状态,则该进程的终止状态是未定义的
    • main执行了一个无返回值的return语句,则该进程的终止状态是未定义的
    • main没有声明返回类型为整型,则该进程的终止状态是未定义的
    • main声明返回类型为整型,并且main执行到最后一条语句时返回(隐式返回),则该进程的终止状态是 0
  • main函数返回一个整型值与用该值调用exit是等价的。即main函数中,exit(100);等价于return 100;
  • 在LINUX中,退出状态码最高是255,一般自定义的代码值为0~255,如果超出255,则返回该数值被256除了之后的余数

7.3.2 atexit函数

ANSI C规定:一个进程可以登记多至32个函数,这些函数将由exit自动调用。我们称这些函数为终止处理程序(exit handler),并用atexit函数来登记这些函数。

  • exit调用这些exit handler的顺序与它们登记的时候顺序相反。
  • 如果同一个exit handler被登记多次,则它也会被调用多次。

通常操作系统会提供多于32个exit handler的限制。可以用sysconf函数查询这个限制值

#include <stdlib.h>
int atexit(void (*func)(void));
  • 参数:
    • func:函数指针。它指向的函数的原型是:返回值为void,参数为void
  • 返回值:
    • 成功:返回 0
    • 失败:返回非 0

C程序的启动和终止

  • exit首先调用各终止处理程序,然后按需多次调用fclose,关闭所有打开流。

  • 内核执行程序的唯一方法是调用一个exec函数

  • 内核自愿终止的唯一方法是显式或者隐式(通过调用exit函数)的调用_exit或者_Exit

图7-1显示了一个C程序是如何启动的,以及它的终止的各种方式。

1697090479689

案例:终止处理程序实例

#include "ourhdr.h"
static void my_exit1(void),my_exit2(void);
int main(void) {
    if(atexit(my_exit2) != 0)
        err_sys("can't register my_exit2");
    if(atexit(my_exit1) != 0)
        err_sys("can't register my_exit1");
    if(atexit(my_exit1) != 0)
        err_sys("can't register my_exit1");
    printf("main is done\n");
    return(0);
}
static void my_exit1(void){
    printf("first exit handler\n");
}
static void my_exit2(void){
    printf("second exit handler\n");
}
  • 这段代码演示了如何使用 atexit 函数注册退出处理程序。退出处理程序将在程序退出时(通过 exit 函数或程序正常终止)自动执行。

  • 首先尝试注册 my_exit2,然后注册 my_exit1 两次。这是有效的,atexit 函数允许多次注册相同的退出处理程序。注册的顺序将影响退出处理程序的执行顺序。

  • 当程序退出时,这些退出处理程序将按照注册的顺序依次执行。

  • my_exit2 是第一个注册的退出处理程序,因此它将首先执行。

  • 然后是两个相同的 my_exit1 处理程序,它们将按照注册的顺序执行。

  • 最后,main 函数完成并打印 “main is done”。

运行这个程序时,你将看到以下输出:

second exit handler
first exit handler
first exit handler
main is done

首先执行 my_exit2,然后依次执行两次 my_exit1,最后打印 “main is done”。这展示了退出处理程序的注册和执行过程。

7.4 命令行参数

执行一个程序,调用exec的进程可将命令行参数传递给该新进程。

7.5 环境表

每个程序都会接收一张环境表。

  • 与参数表一样,环境表也是一个字符指针数组
    • 其中数组中的每个指针指向一个以null结束的 C 字符串,这些字符串称之为环境字符串
    • 数组的最后一项是null
  • 全局变量envrion包含了该指针数组的地址:extern char envrion。我们称environ为环境指针,它位于头文件unistd.h
  • 按照惯例,环境字符串由name=value这种格式的字符串组成

例如:如果该环境包含五个字符串,那么它看起来可能如图7-2所示。

1697091483178

7.6 C程序的存储空间布局

C程序的存储空间布局:C程序一直由下列几部分组成:

  • 正文段:这是由CPU执行的机器指令部分。

    • 通常正文段是可以共享的。一个程序的可以同时执行N次,但是该程序的正文段在内存中只需要有一份而不是N
    • 通常正文段是只读的,以防止程序由于意外而修改其指令
  • 初始化数据段:通常将它称作数据段。

    • 它包含了程序中明确地赋了初值的变量:包括函数外的赋初值的全局变量、函数内的赋初值的静态变量
  • 未初始化数据段:通常将它称作bss段。在程序开始执行之前,内核将此段中的数据初始化为0或者空指针。

    • 它包含了程序中未赋初值的变量:包括函数外的未赋初值的全局变量、函数内的未赋初值的静态变量
  • 栈段:临时变量以及每次函数调用时所需要保存的信息都存放在此段中。

    • 每次函数调用时,函数返回地址以及调用者的环境信息(如某些CPU 寄存器的值)都存放在栈中
    • 最新的正被执行的函数,在栈上为其临时变量分配存储空间

    通过这种方式使用栈,C 递归函数可以工作。递归函数每次调用自身时,就创建一个新的栈帧,因此某一次函数调用中的变量不影响下一次调用中的变量

  • 堆段:通常在堆中进行动态存储分配。

    • 由于历史习惯,堆位于未初始化数据段和栈段之间

下图7-3显示了这些段的一种典型安排方式:

1697091805271

注意:

  • 栈从高地址向低地址增长。堆顶和栈顶之间未使用的虚拟地址空间很大

  • 未初始化数据段的内容并不存放在磁盘程序文件中。需要存放在磁盘程序文件中的段只有正文段和初始化数据段

    因为内核在程序开始运行前将未初始化数据段设置为 0

  • size命令可以查看程序的正文段、数据段 和bss段长度(以字节为单位)

7.7 共享库

共享库是一种机制,允许多个程序共享相同的库函数的副本,而不必将这些函数的副本包含在每个可执行文件中。

这提供了以下几个优点:

  1. 减小可执行文件的大小: 共享库使得可执行文件变得更加轻量,因为它们不再需要包含常用的库函数的副本。这减少了磁盘占用和下载时间。

  2. 减少内存占用: 可以将库函数加载到内存中一次,多个进程可以共享相同的库函数副本,从而减少了内存占用。

  3. 动态连接: 共享库采用动态连接方法,这意味着程序在第一次执行或第一次调用某个库函数时,才会与共享库中的函数连接。这降低了程序启动时间,但增加了一些运行时开销。

  4. 库函数的更新: 共享库使得更新库函数变得更加容易,因为可以用新版本的库函数代替旧版本,而无需重新连接和编辑使用该库的程序。这对于保持系统的更新和维护很有帮助。

总之,共享库提供了一种有效的方式来减小可执行文件的大小,降低内存占用,加速程序启动,并简化库函数的更新和维护。这是许多UNIX系统中的重要特性。

举例:

比较典型的有cc(1)和ld(1)命令的可选项。作为长度方面发生变化的例子,下列可执行文件(典型的hello.c程序)先用无共享库方式创建:

1697092432657

7.8 存储器分配

malloc/calloc/realloc函数:动态分配存储空间

#include<stdlib.h>
void *malloc(size_t size);
void *calloc(size_t nobj,size_t size);
void *realloc(void *ptr,size_t newsize);
void free(void *ptr);
  • 参数:

    对于malloc函数:分配指定字节数的存储区。从存储区中的初始值不确定。

    • size:动态分配的存储空间的大小(字节数)

    对于calloc函数:为指定长度的对象分配能容纳其指定个数的存储空间。该控件中每一个(bit)都初始化为0。

    • nobj:动态分配的对象的数量
    • size:每个对象的大小(字节数)

    对于realloc函数:更改以前分配区的长度(增加或减少)。

    • ptr:由malloc/realloc返回的指针,指向一个动态分配的空间
      • 如果ptrNULL,则reallocmalloc功能相同,是分配一个指定长度为newsize字节的动态存储空间
    • newsize:调整后的动态空间的大小(字节数)
  • 返回值:

    • 成功:返回非空指针
    • 失败:返回NULL

注意:

  • 这三个分配函数所返回的指针一定是适当对齐的,使其可以用于任何数据对象
  • realloc可以增加、减少之前分配的动态存储区长度。对于增加动态存储区的情况:
    • 如果在原来动态存储区位置后面有足够的空间可以扩充,则可以在原存储区位置上向高地址扩充,无需移动任何原先的内容,并返回与传给它相同的指针值
    • 如果在原来动态存储区位置后面没有足够的空间可以扩充,则realloc分配另一个足够大的动态存储区,然后将原先的内容移动到新的存储区。然后释放原存储区,返回新分配存储区的指针
    • 因为这种存储区可能会移动位置,所以不应当使用任何指针指在该区中。
  • 这些分配函数通常使用sbrk系统调用实现。该系统调用用于扩充或者缩小进程的堆空间。
    • 大多数实现所分配的存储空间要比所要求的稍大一些,额外的空间用来记录管理信息,比如分配块的长度、指向下一个分配块的指针等等。
    • 因此在一个动态分配区的尾部之后或者在起始位置之前写操作会修改另一块的管理记录信息。这种类型的错误是灾难性的,但是由于这种错误不会立即暴露出来,因此很难被发现
  • 这三个函数返回的动态分配区必须用free()函数进行释放。
    • 如果一个进程调用了malloc函数但是没有调用free函数,则该进程占用的存储空间就会连续增加,这称作内存泄漏。
    • 内存泄漏会导致进程地址空间长度慢慢增加直到不再有空闲空间。此时过度的换页开销会导致性能下降
  • 对一块动态分配的内存,只能free一次。如果free多次则会发生错误

注意:必须用不同的变量保存realloc返回的值

char* ptr = malloc(10);
ptr = realloc(1000);//错误行为
//因为一旦realloc失败,则ptr赋值为NULL,ptr原来指向的动态内存区再也不能访问,也就无法释放,从而发生内存泄漏。

alloca函数

其调用序列与malloc相同,但是它是在当前函数的栈帧上分配存储空间,而不是在堆中。

优点:

  • 当函数返回时,自动释放它所使用的栈帧,所以不必再为释放空间而费心。

缺点:

  • 某些系统在函数已被调用后不能增加栈帧长度,于是也就不能支持alloca函数。

7.9 环境变量

环境字符串的形式是:name=value。UNIX内核并不查看这些字符串,这些字符串的具体意义由各应用程序解释

getenv函数:获取环境变量的值:

#include<stdlib.h>
char *getenv(const char*name);
  • 参数:
    • name:环境变量名
  • 返回值:
    • 成功:指向与name关联的value的指针
    • 失败:返回NULL

注意:

  • 虽然我们也可以通过environ全局变量访问到环境字符串,但是推荐使用getenv函数
  • 常用的环境变量名有:
    • "HOME"home目录
    • "LANG":语言
    • "LOGNAME":登录名
    • "PATH":搜索路径
    • "PWD":当前工作目录的绝对路径名
    • "SHELL":用户首选的SHELL
    • "TERM":终端类型
    • "TMPDIR":在其中创建临时文件的目录路径名
    • "TZ":时区信息

POSIX.1和XPG3定义了某些环境变量。表7 - 1列出了由这两个标准定义并受到SVR4和4.3+BSD支持的环境变量。

在这里插入图片描述

除了取环境变量值,有时也需要设置环境变量,或者是改变现有变量的值,或者是增加新的环境变量。表7-2列出了由不同的标准及实现支持的各种函数。

1697094046698

在表7-2中,中间三个函数的原型是:

#include <stdlib.h>
int putenv(const char *str);
int setenv(const char *name,const char* value,int rewrite);
void unsetenv(const char* name);
  • 参数:

    对于putenv函数:

    • str:形式为name=value的字符串,将其放置到进程的环境表中。如果name已经存在,则先删除其原来的语义

    对于setenv函数:

    • name:环境变量名
    • value:环境变量的值
    • rewrite:指定覆写行为。
      • 如果它为0,则如果name在环境表中已存在,则直接返回而不修改。同时也不报错
      • 如果它非0,则如果name在环境表中已存在,则首先删除它现有的定义,然后添加新的定义

    对于unsetenv函数:

    • name:环境变量名
  • 返回值:

    对于 putenv函数:

    • 成功:返回0
    • 失败:返回非0

    对于setenv/unsetenv函数:

    • 成功: 返回 0
    • 失败: 返回 -1

注意:

  • unsetenv是从环境表中删除name的定义。如果name不存在,则也不算出错
  • 这些函数内部操作环境表非常复杂,下面是原理:
    • 如果修改一个现有的name
      • 如果新的value长度少于或等于现有value的长度,则只需要将新字符串复制到原字符串所用的空间即可
      • 如果新的value长度大于现有value的长度,则必须调用malloc为新字符串分配空间,然后将新字符串复制到该空间,接着使环境表中对name的指针指向新分配区并释放旧分配区
    • 如果增加一个新的name
      • 如果这是第一次增加一个新的name
        • 则必须调用malloc为新指针表分配空间
        • 然后将原来的环境表复制到新分配区
        • 并将新的name=value字符串的指针存放到该指针表的表尾,
        • 然后将一个空指针存放在其后
        • 然后使environ指向新指针表
        • 最后释放旧的指针表
      • 如果这不是第一次增加一个新的name,则可知以前已经调用了malloc
        • 则只需要调用realloc,以分配比原空间多存放一个指针的空间
        • 并将新的name=value字符串的指针存放到该指针表的表尾,
        • 然后将一个空指针存放在其后
    • 如果删除一个name:则只需要先在环境表中找到该指针,然后将所有的后续指针都向环境表的首部依次顺序移动一个位置即可

7.10 setjmp和longjmp函数

在C语言中, goto语句是不能够跨越函数的。如果想执行跨函数跳转功能,则使用setjmplongjmp,它们称作非局部goto

  • 非局部是指:这不是普通的C语言goto语句在一个函数内实施的跳转,而是在栈上跳过若干调用帧,返回到当前函数调用路径上

setjmp/longjmp函数:非局部goto

#include<setjmp.h>
int setjmp(jmp_buf env);
void longjmp(jmp_buf env,int val);
  • 参数:

    对于setjmp函数:

    • env是一个特殊类型jmp_buf
      • 它是某种形式的数组,其内容由setjmp函数填写
      • 它必须和配对的longjmp使用通过一个env
    • jmp_buf类型就是某种形式的数组,其中存放的是在调用longjmp时能用来恢复栈状态的所有信息
    • 简单地说,env参数就是在setjmplongjmp之间传递状态信息

    对于longjmp函数:

    • env:它就是setjmp所设置的env。它就像是一个锚点,从而跳转时知道跳到哪个位置

    • val:用于标识本次longjmp

      • 因此某个setjmp可能有多个longjmp对应。因此这些jmp之间可以用val分辨。setjmp就知道是从哪个longjmp跳转过来的

      • longjmpval参数就是setjmp的返回值

        但是setjmp的返回值不一定是longjmpval参数

  • setjmp的返回值:

    • 如果直接调用,则返回 0
    • 如果从longjmp返回,则为 非0 (其实就是所跳转的那个longjmpval参数)

注意:

  • 假设在执行setjmp之前,有变量包括:全局变量global_var、局部静态变量static_var、以及自动变量auto_var,则跨longjmp这些变量都不会回滚到setjmp之前的状态
  • 使用非局部goto时,在声明自动变量的函数已经返回后,不能引用这些自动变量。因为它们已经被释放了

7.11 进程资源限制getrlimit和setrlimit函数

#include <sys/time.h>
#include <sys/resource.h>
int getrlimit(int resource, struct rlimit * rlptr);
int setrlimit(int resource, const struct rlimit* rlptr);
  • 参数:
    • resource:指定资源
    • rlptr:指向struct rlimit的指针。在getrlimit中,它返回资源限制值;在setrlimit中,它存放待设置的资源限制值
  • 返回值:
    • 成功:返回 0
    • 失败: 返回非 0

其中struct rlimit为:

struct rlimit{
	rlim_t rlim_cur; //软限制:当前的限制值
	rlim_t rlim_max; //硬限制:最大值
};

注意:

  • 在更改资源限制时,有三条规则:

    • 任何进程都可将一个软限制值更改为小于或者等于其硬限制值
    • 任何进程都可以降低其硬限制值,但是它必须大于或者等于其软限制值

    这种降低,对普通用户而言不可逆,因为普通用户不可提高其硬限制值

    • 只有超级用户进程才能够提高其硬限制值
  • 常量RLIM_INFINITY指定了一个无限量的限制

  • resource可以取下列的常量值之一:

    • RLIMIT_AS:进程总的可用存储空间的最大长度(字节)

      这会影响到sbrk函数和mmap函数

    • RLIMIT_COREcore文件的最大字节数。如果为0,则阻止创建core文件

    • RLIMIT_CPU:CPU时间的最大量值(秒),如果超过此软限制时,向该进程发送SIGXCPU信号

    • RLIMIT_DATA:数据段的最大字节长度(包括初始化数据、非初始以及堆的总和)

    • RLIMIT_FSIZE:可以创建的文件的最大字节长度。当超过此软限制时,向该进程发送SIGXFSX信号

    • RLIMIT_MEMLOCK:一个进程使用mlock能够锁定在存储空间中的最大字节长度

    • RLIMIT_MSGQUEUE:进程为POSIX消息队列可分配的最大存储字节数

    • RLIMIT_NICE:为了影响进程的调度优先级,nice值可设置的最大限制

    • RLIMIT_NOFILE:每个进程能打开的最多文件数。更改此限制将影响到sysconf函数在参数_SC_OPEN_MAX中返回的值

    • RLIMIT_NPROC:每个实际用户ID可以拥有的最大子进程数。更改此限制将影响到sysconf函数在参数_SC_CHILD_MAX中返回的值

    • RLIMIT_RSS:最大驻内存集字节长度

    • RLIMIT_SIGPENDING:一个进程可排队的信号的最大数量。这个限制是sigqueue函数实施的

    • RLIMIT_STACK:栈的最大字节长度

  • 资源限制会由子进程继承

7.12 本章小结

理解在UNIX环境中C程序的环境是理解UNIX进程控制特征的先决条件。

本章说明了一个进程是如何起动和终止的,如何向其传递传数表和环境。

虽然这两者都不是由内核进行解释的,但内核起到了从exec的调用者将这两者传递给新进程的作用。

本章也说明了C程序的典型存储器布局,以及一个进程如何动态地分配和释放存储器。

习题

7.1 在80386系统上,无论使用SVR4或4.3+BSD,如果执行一个输出“hello, world”但不调用exit或return,则程序的返回代码为13(用shell检查),解释其原因。

原因:printf的返回值变为main函数的返回值,不同的系统上编译此类程序由不同的终止码。当然,并不是所有的系统都会出现该情况。

7.2 程序7-1中的printf函数的结果何时才被真正输出?

当程序处于交互运行方式时,标准输出设备通常处于行缓存方式,所以当输出换行符时,上次的结果才被真正输出。如果标准输出设备被定向到一个文件而处于玩全缓存方式,则当标准I/O清理操作执行时,结果才真正被输出。

7.3 是否有方法不使用(a)参数传递(b)全局变量这两种方法,将main中的参数argc,argv传递给它所调用的其他函数?

由于argc和argv不像environ一样保存在全局变量中,所以在大多数UNIX系统中没有其他办法。

7.4 在有些UNIX系统中执行程序时,为什么访问不到其数据段的0单元?

当C程序复引用一个空指针出错时,执行该程序的进程将终止,于是可以利用这种方法终止进程。

7.5 用C语言的typedef为终止处理程序定义了一个新的数据类型Exitfunc,使用该类型修改atexit的原型。

定义如下:

typedef void Exitfunc(void);

int atexit(Exitfunc func);

7.6 如果用calloc分配一个long型的数组,数组的初始值是否为0?如果用calloc分配一个指针数组,数组的初始值是否为空指针?

calloc将分配的内存空间初始化为0。到那时ANSI C并不保证0值与浮点0或空指针的值相同。

7.7 在7.6节结尾处size命令的输出结果中,为什么没有给处堆和栈的大小?

只有通过exec函数执行一个程序时,才会分配堆和对堆栈。

7.8 为什么7.7节中两个文件的大小(879 443和8 378)不等于它们各自文本和数据大小的和?

可执行文件包含了用于调试core文件的符号表信息,用strip(1)可以删除这些信息,对两个a.out文件执行这条命令,它们的大小减为98304和16384

7.9 为什么7.7节中对于一个简单的程序,使用共享库以后其可执行文件的
大小变化如此巨大?

没有使用共享库时,可执行文件的大部分都被标准I/O库所占用。

7.10 在7.10节中我们已经说明为什么不能将一个指针返回给一个自动变
量,下面的程序是否正确?

int f1(int val){  
}
int num = 0;
int *ptr = &num;
if(val == 0) {
    int val;
    val = 5;
    ptr = &val;
}
return (*ptr+1);

,数组的初始值是否为空指针?**

calloc将分配的内存空间初始化为0。到那时ANSI C并不保证0值与浮点0或空指针的值相同。

7.7 在7.6节结尾处size命令的输出结果中,为什么没有给处堆和栈的大小?

只有通过exec函数执行一个程序时,才会分配堆和对堆栈。

7.8 为什么7.7节中两个文件的大小(879 443和8 378)不等于它们各自文本和数据大小的和?

可执行文件包含了用于调试core文件的符号表信息,用strip(1)可以删除这些信息,对两个a.out文件执行这条命令,它们的大小减为98304和16384

7.9 为什么7.7节中对于一个简单的程序,使用共享库以后其可执行文件的
大小变化如此巨大?

没有使用共享库时,可执行文件的大部分都被标准I/O库所占用。

7.10 在7.10节中我们已经说明为什么不能将一个指针返回给一个自动变
量,下面的程序是否正确?

int f1(int val){  
}
int num = 0;
int *ptr = &num;
if(val == 0) {
    int val;
    val = 5;
    ptr = &val;
}
return (*ptr+1);

这段代码不正确。因为在if语句中定义了自动变量val,所以当if中的复合语句结束时,该变量就不存在了,但是在if语句之外又用指针引用已经不存在的自动变量val。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

霜晨月c

谢谢老板地打赏~

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

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

打赏作者

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

抵扣说明:

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

余额充值