linux操作系统:系统调用,公司成立后就可以接项目了

引入

系统进入用户态之后,就可以开始接项目了。那怎么接呢?

  • 一般都是通过办事大厅(中断门)
  • 如果觉得直接去办事大厅不够方便,Linux也提供了glibc这个中介。它更熟悉系统调用的细节,并且可以封装成更加友好的接口,可以直接用

glibc对系统调用的封装

什么是glibc

  • 很多库函数(比如字符串操作函数)不会使用任何系统调用。另一方面,还有些库函数构建与系统调用层之上。
  • 例如,库函数 fopen()就利用系统调用 open()来执行打开文件的实际操作。往往,设计库函数是为了提供比底层调用更为方便的调用接口
  • glibc相当于一个中介,它更熟悉系统调用的细节,并且可以封装成更加友好的接口,可以直接用
    • 标准 C 语言函数库的实现随 UNIX 的实现而异。GNU C 语言函数库(glibc, http://www. gnu.org/software/libc/)是 Linux 上最常用的实现。
    • Linux 同样支持各种其他 C 语言函数库,其中包括应用于嵌入式设备领域、受限内存条件下的 C 语言函数库。uClibc(http://www.uclibc.org/)和 dietlibc(http://www.fefe. de/dietlibc/)便是其中的两个例子

确定系统的glibc的版本

有时,需要确定系统所安装的 glibc 版本。

在 shell 中:

  • 可以直接运行 glibc 共享库文件—将其视为可执行文件—来获取 glibc 版本。这会输出各种文本信息,其中也包括了 glibc 的版本号
$ ldd hik  | grep libc
	libc.so.6 => /lib64/libc.so.6 (0x00007fc173270000)
$ /lib64/libc.so.6 
GNU C Library (GNU libc) stable release version 2.17, by Roland McGrath et al.
  • getconf GNU_LIBC_VERSION
$ getconf GNU_LIBC_VERSION
glibc 2.17

应用程序可通过测试常量和调用库函数这两种方法,来确定系统所安装的 glibc 版本:

  • 从版本2.0开始,glibc 定义了两个常量:__GLIBC____GLIBC_MINOR__,供程序在编译时(在#ifdef语句中)测试使用。在安装有 glibc 2.12 版本的系统上,以上两个常量的值分别为 2 和 12
  • 然而,如果程序在 A 系统上编译,而在 B 系统(安装了不同版本的 glibc)上运行,这两个常量作用就有限了。为应对这种可能,程序可以调用函数 gnu_get_libc_version(),来确定运行时的 glibc 版本
$ man 3 gnu_get_libc_version
 NAME
       gnu_get_libc_version,  gnu_get_libc_release  -  get  glibc  version and
       release

SYNOPSIS
       #include <gnu/libc-version.h>

       const char *gnu_get_libc_version(void);
       const char *gnu_get_libc_release(void);

DESCRIPTION
       The function gnu_get_libc_version() returns a  string  that  identifies
       the glibc version available on the system.

       The  function  gnu_get_libc_release()  returns  a  string indicates the
       release status of the glibc version available on the system.  This will
       be a string such as stable.

VERSIONS
       These functions first appeared in glibc in version 2.1.

ATTRIBUTES
       For   an   explanation   of   the  terms  used  in  this  section,  see
       attributes(7).

       ┌────────────────────────┬───────────────┬─────────┐
       │Interface               │ Attribute     │ Value   │
       ├────────────────────────┼───────────────┼─────────┤
       │gnu_get_libc_version(), │ Thread safety │ MT-Safe │
       │gnu_get_libc_release()  │               │         │
       └────────────────────────┴───────────────┴─────────┘
CONFORMING TO
       These functions are glibc-specific.
EXAMPLE
       When run, the program below will produce output such as the following:

           $ ./a.out
           GNU libc version: 2.8
           GNU libc release: stable

   Program source

       #include <gnu/libc-version.h>
       #include <stdlib.h>
       #include <stdio.h>

       int
       main(int argc, char *argv[])
       {
           printf("GNU libc version: %s\n", gnu_get_libc_version());
           printf("GNU libc release: %s\n", gnu_get_libc_release());
           exit(EXIT_SUCCESS);
       }

举个例子

我们以最常用的系统调用open,打开一个文件为线索,看看系统调用是怎么实现的(主要剖析了从glibc如何调用到open,没有讲解open怎么实现的)

首先,在用户态进程里面调用glibc 里面的open函数

int open(const char *pathname, int flags, mode_t mode)

在 glibc 的源代码中,有个文件 syscalls.list,里面列着所有 glibc 的函数对应的系统调用,就像下面这个样子:

# File name Caller  Syscall name    Args    Strong name Weak names
open		-	open		Ci:siv	__libc_open __open open

另外,glibc 还有一个脚本 make-syscall.sh,可以根据上面的配置文件,对于每一个封装好的系统调用,生成一个文件。这个文件里面定义了一些宏,例如

#define SYSCALL_NAME open。

glibc 还有一个文件 syscall-template.S,使用上面的宏定义了这个系统调用的调用方式。

T_PSEUDO (SYSCALL_SYMBOL, SYSCALL_NAME, SYSCALL_NARGS)
    ret
T_PSEUDO_END (SYSCALL_SYMBOL)
 
#define T_PSEUDO(SYMBOL, NAME, N)		PSEUDO (SYMBOL, NAME, N)

这里的 PSEUDO 也是一个宏,它的定义如下:

#define PSEUDO(name, syscall_name, args)                      \
  .text;                                      \
  ENTRY (name)                                    \
    DO_CALL (syscall_name, args);                         \
    cmpl $-4095, %eax;                               \
    jae SYSCALL_ERROR_LABEL

里面对于任何一个系统调用,会调用 DO_CALL。这也是一个宏,这个宏 32 位和 64 位的定义是不一样的。

32位的系统调用过程

我们先来看 32 位的情况(i386 目录下的 sysdep.h 文件)。

/* Linux takes system call arguments in registers:
	syscall number	%eax	     call-clobbered
	arg 1		%ebx	     call-saved
	arg 2		%ecx	     call-clobbered
	arg 3		%edx	     call-clobbered
	arg 4		%esi	     call-saved
	arg 5		%edi	     call-saved
	arg 6		%ebp	     call-saved
......
*/
#define DO_CALL(syscall_name, args)                           \
    PUSHARGS_##args                               \
    DOARGS_##args                                 \
    movl $SYS_ify (syscall_name), %eax;                          \
    ENTER_KERNEL                                  \
    POPARGS_##args
  • 这里,我们将请求参数放在寄存器里面,根据系统调用的名称,得到系统调用号,放在寄存器 eax 里面
  • 然后执行 ENTER_KERNEL。 ENTER_KERNEL 又是什么呢?
 # define ENTER_KERNEL int $0x80
  • int 就是 interrupt,也就是“中断”的意思。int $0x80 就是触发一个软中断,通过它就可以陷入(trap)内核。
  • 内核启动时有个trap_init()过程,里面有这样的代码:
set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32);
  • 这是一个软中断的陷入门。当接收到一个系统调用的时候,entry_INT80_32就被调用了
ENTRY(entry_INT80_32)
        ASM_CLAC
        pushl   %eax                    /* pt_regs->orig_ax */
        SAVE_ALL pt_regs_ax=$-ENOSYS    /* save rest */
        movl    %esp, %eax
        call    do_syscall_32_irqs_on
.Lsyscall_32_done:
......
.Lirq_return:
	INTERRUPT_RETURN
  • 通过 push 和 SAVE_ALL 将当前用户态的寄存器,保存在 pt_regs 结构里面。

进入内核之前,保存所有的寄存器,然后调用 do_syscall_32_irqs_on。它的实现如下:

  • 将系统调用号从 eax 里面取出来
  • 然后根据系统调用号,在系统调用表中找到相应的函数进行调用
  • 并将寄存器中保存的参数取出来,作为函数参数。
static __always_inline void do_syscall_32_irqs_on(struct pt_regs *regs)
{
	struct thread_info *ti = current_thread_info();
	unsigned int nr = (unsigned int)regs->orig_ax;
......
	if (likely(nr < IA32_NR_syscalls)) {
		regs->ax = ia32_sys_call_table[nr](
			(unsigned int)regs->bx, (unsigned int)regs->cx,
			(unsigned int)regs->dx, (unsigned int)regs->si,
			(unsigned int)regs->di, (unsigned int)regs->bp);
	}
	syscall_return_slowpath(regs);
}

根据宏定义,#define ia32_sys_call_table sys_call_table,系统调用就是放在这个表里面。至于这个表是如何形成的,我们后面讲。

当系统调用结束之后,在 entry_INT80_32 之后,紧接着调用的是 INTERRUPT_RETURN,我们能够找到它的定义,也就是 iret。

#define INTERRUPT_RETURN                iret
  • iret 指令将原来用户态保存的现场恢复回来,包含代码段、指令指针寄存器等。
  • 这时候用户态进程恢复执行。

总结:

在这里插入图片描述

64位系统调用过程

我们再来看 64 位的情况(x86_64 下的 sysdep.h 文件)。

  • 和之前一样,还是将系统调用名转换为系统调用号,放在寄存器rax中。
  • 这里是真正进行调用,不是用中断了,而是改用syscall指令了
  • 通过注释可以看到,传递参数的寄存器也改变了
/* The Linux/x86-64 kernel expects the system call parameters in
   registers according to the following table:
    syscall number	rax
    arg 1		rdi
    arg 2		rsi
    arg 3		rdx
    arg 4		r10
    arg 5		r8
    arg 6		r9
......
*/
#define DO_CALL(syscall_name, args)					      \
  lea SYS_ify (syscall_name), %rax;					      \
  syscall

syscall指令还使用了一种特殊的寄存器,叫做特殊模块寄存器(model specific registers,简称MSR)。这种寄存器是CPU为了完成某些特殊控制功能为目的的寄存器,其中就有系统调用。

  • 在系统初始化的时候,trap_init除了初始化上面的中断模式,还会调用cpu_init->syscall_init,这里面有这样的代码:
wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);
  • rdmsr和wrmsr是用来读写特殊模块寄存器的。MSR_LSTAR就是这样一个特殊的寄存器,当syscall指令调用的时候,会从这个寄存器里面拿出函数地址来调用,也就是调用entry_SYSCALL_64。
ENTRY(entry_SYSCALL_64)
        /* Construct struct pt_regs on stack */
        pushq   $__USER_DS                      /* pt_regs->ss */
        pushq   PER_CPU_VAR(rsp_scratch)        /* pt_regs->sp */
        pushq   %r11                            /* pt_regs->flags */
        pushq   $__USER_CS                      /* pt_regs->cs */
        pushq   %rcx                            /* pt_regs->ip */
        pushq   %rax                            /* pt_regs->orig_ax */
        pushq   %rdi                            /* pt_regs->di */
        pushq   %rsi                            /* pt_regs->si */
        pushq   %rdx                            /* pt_regs->dx */
        pushq   %rcx                            /* pt_regs->cx */
        pushq   $-ENOSYS                        /* pt_regs->ax */
        pushq   %r8                             /* pt_regs->r8 */
        pushq   %r9                             /* pt_regs->r9 */
        pushq   %r10                            /* pt_regs->r10 */
        pushq   %r11                            /* pt_regs->r11 */
        sub     $(6*8), %rsp                    /* pt_regs->bp, bx, r12-15 not saved */
        movq    PER_CPU_VAR(current_task), %r11
        testl   $_TIF_WORK_SYSCALL_ENTRY|_TIF_ALLWORK_MASK, TASK_TI_flags(%r11)
        jnz     entry_SYSCALL64_slow_path
......
entry_SYSCALL64_slow_path:
        /* IRQs are off. */
        SAVE_EXTRA_REGS
        movq    %rsp, %rdi
        call    do_syscall_64           /* returns with IRQs disabled */
return_from_SYSCALL_64:
	RESTORE_EXTRA_REGS
	TRACE_IRQS_IRETQ
	movq	RCX(%rsp), %rcx
	movq	RIP(%rsp), %r11
    movq	R11(%rsp), %r11
......
syscall_return_via_sysret:
	/* rcx and r11 are already restored (see code above) */
	RESTORE_C_REGS_EXCEPT_RCX_R11
	movq	RSP(%rsp), %rsp
	USERGS_SYSRET64
  • 这里先保存了很多寄存器到 pt_regs 结构里面,例如用户态的代码段、数据段、保存参数的寄存器,然后调用 entry_SYSCALL64_slow_pat->do_syscall_64。
__visible void do_syscall_64(struct pt_regs *regs)
{
        struct thread_info *ti = current_thread_info();
        unsigned long nr = regs->orig_ax;
......
        if (likely((nr & __SYSCALL_MASK) < NR_syscalls)) {
                regs->ax = sys_call_table[nr & __SYSCALL_MASK](
                        regs->di, regs->si, regs->dx,
                        regs->r10, regs->r8, regs->r9);
        }
        syscall_return_slowpath(regs);
}
  • 在 do_syscall_64 里面,从 rax 里面拿出系统调用号,然后根据系统调用号,在系统调用表 sys_call_table 中找到相应的函数进行调用,并将寄存器中保存的参数取出来,作为函数参数

所以,无论是 32 位,还是 64 位,都会到系统调用表 sys_call_table 这里来。

在研究系统调用表之前,我们看 64 位的系统调用返回的时候,执行的是 USERGS_SYSRET64。定义如下:

#define USERGS_SYSRET64				\
	swapgs;					\
	sysretq;

这里,返回用户态的指令变成了 sysretq。

总结

在这里插入图片描述

系统调用表是怎么形成的

32 位的系统调用表定义在面 arch/x86/entry/syscalls/syscall_32.tbl 文件里。例如 open 是这样定义的:

5	i386	open			sys_open  compat_sys_open

64 位的系统调用定义在另一个文件 arch/x86/entry/syscalls/syscall_64.tbl 里。例如 open 是这样定义的:

2	common	open			sys_open

从上面可以看出:

  • 第一列的数字是系统调用号。可以看出,32位和64位的系统调用号是不一样的。
  • 第三列是系统调用的名字
  • 第四列是系统调用在内核中的实现参数。
  • 第三列和第四列都是以sys_开头的

系统调用在内核中的实现函数要有一个声明。声明往往在 include/linux/syscalls.h 文件中。例如 sys_open 是这样声明的:

asmlinkage long sys_open(const char __user *filename,
                                int flags, umode_t mode);

真正的实现这个系统调用,一般在一个.c 文件里面,例如 sys_open 的实现在 fs/open.c 里面,但是你会发现样子很奇怪。

SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
{
        if (force_o_largefile())
                flags |= O_LARGEFILE;
        return do_sys_open(AT_FDCWD, filename, flags, mode);
}

SYSCALL_DEFINE3 是一个宏系统调用最多六个参数,根据参数的数目选择宏。具体是这样定义的:

#define SYSCALL_DEFINE1(name, ...) SYSCALL_DEFINEx(1, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE2(name, ...) SYSCALL_DEFINEx(2, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE4(name, ...) SYSCALL_DEFINEx(4, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE5(name, ...) SYSCALL_DEFINEx(5, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE6(name, ...) SYSCALL_DEFINEx(6, _##name, __VA_ARGS__)
 
 
#define SYSCALL_DEFINEx(x, sname, ...)                          \
        SYSCALL_METADATA(sname, x, __VA_ARGS__)                 \
        __SYSCALL_DEFINEx(x, sname, __VA_ARGS__)
 
 
#define __PROTECT(...) asmlinkage_protect(__VA_ARGS__)
#define __SYSCALL_DEFINEx(x, name, ...)                                 \
        asmlinkage long sys##name(__MAP(x,__SC_DECL,__VA_ARGS__))       \
                __attribute__((alias(__stringify(SyS##name))));         \
        static inline long SYSC##name(__MAP(x,__SC_DECL,__VA_ARGS__));  \
        asmlinkage long SyS##name(__MAP(x,__SC_LONG,__VA_ARGS__));      \
        asmlinkage long SyS##name(__MAP(x,__SC_LONG,__VA_ARGS__))       \
        {                                                               \
                long ret = SYSC##name(__MAP(x,__SC_CAST,__VA_ARGS__));  \
                __MAP(x,__SC_TEST,__VA_ARGS__);                         \
                __PROTECT(x, ret,__MAP(x,__SC_ARGS,__VA_ARGS__));       \
                return ret;                                             \
        }                                                               \
        static inline long SYSC##name(__MAP(x,__SC_DECL,__VA_ARGS__)

如果我们把宏展开之后,实现如下,和声明的是一样的:

asmlinkage long sys_open(const char __user * filename, int flags, int mode)
{
 long ret;
 
 
 if (force_o_largefile())
  flags |= O_LARGEFILE;
 
 
 ret = do_sys_open(AT_FDCWD, filename, flags, mode);
 asmlinkage_protect(3, ret, filename, flags, mode);
 return ret;

声明和实现都好了。接下来,需要根据syscall_32.tbl 和 syscall_64.tbl生成自己的unistd_32.h 和 unistd_64.h。生成方式在arch/x86/entry/syscalls/Makefile中。

这里面会使用两个脚本,其中第一个脚本 arch/x86/entry/syscalls/syscallhdr.sh,会在文件中生成 #define __NR_open;第二个脚本 arch/x86/entry/syscalls/syscalltbl.sh,会在文件中生成 __SYSCALL(__NR_open, sys_open)。这样,unistd_32.h 和 unistd_64.h 是对应的系统调用号和系统调用实现函数之间的对应关系

在文件 arch/x86/entry/syscall_32.c,定义了这样一个表,里面 include 了这个头文件,从而所有的 sys_ 系统调用都在这个表里面了。

__visible const sys_call_ptr_t ia32_sys_call_table[__NR_syscall_compat_max+1] = {
        /*
         * Smells like a compiler bug -- it doesn't work
         * when the & below is removed.
         */
        [0 ... __NR_syscall_compat_max] = &sys_ni_syscall,
#include <asm/syscalls_32.h>
};

同理,在文件 arch/x86/entry/syscall_64.c,定义了这样一个表,里面 include 了这个头文件,这样所有的 sys_ 系统调用就都在这个表里面了。

/* System call table for x86-64. */
asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
	/*
	 * Smells like a compiler bug -- it doesn't work
	 * when the & below is removed.
	 */
	[0 ... __NR_syscall_max] = &sys_ni_syscall,
#include <asm/syscalls_64.h>
};

为什么要有系统调用

  • 普通进程工作在用户空间,内核进程工作在内核空间
    • 普通进程式没有办法访问内核空间的
    • 用户空间不可与硬件交互,内核可以与硬件交互
  • 内核提供了一些API,用户进程通过调用这些API(这个行为叫做系统调用)来访问内核空间,从而操控系统或者内核数据
  • 系统调用和普通函数调用的区别:系统调用是向内核康健发出一个明确请求,而普通函数只是定义了如何获取一个给定的服务。
    在这里插入图片描述
  • 为了保证操作系统的稳定和安全,内核提供了两个状态,内核态和用户态。大部分事件CPU处于用户态,这时CPU只能访问用户空间。当CPU调用系统API时,内核会先转为内核态,然后让CPU执行对应的内核函数。当内核函数执行完成之后,内核会切换回用户态,并将执行结果返回给用户进程。
    在这里插入图片描述

无论何时,只要执行了系统调用或者库函数,检查调用的返回状态以及确定调用是否成功,这是一条编程铁律

总结

  • 系统调用是受控的内核入口,借助于这一机制,进程可以请求内核以自己的名义去执行某些动作。
  • 以应用程序编程接口(API)的形式,内核提供有一系列服务供程序访问。这包括创建新进程、执行IO、为进程间调用管道等。手册页syscalls(2)列出了Linux系统调用

在深入系统调用的运作方式之前,务必关注以下几点

  • 系统调用将处理器从用户态切换到核心态,以便CPU访问受到保护的内核内存
  • 系统调用的组成是固定的,每个系统调用都由一个唯一的数字来标识(程序通过名称来标识系统调用,对着以编号方案往往一无所知)。
  • 每个系统调用可辅之以一套参数,对用户空间(也即进程的虚拟地址空间)与内核空间之间(相互)传递的消息加以规范

从编程角度来看,系统调用与 C 语言函数的调用很相似。然而,在执行系统调用时,其幕后会历经诸多步骤。

下面以硬件平台x86-32为例,按照事件发生的顺序对这些步骤进行分析:

  • 应用程序通过调用C函数库的外壳(wrapper)函数,来发起调用。
    • 在linux中,每个系统调用都被赋予一个系统调用号。通过这个独一无二的调用号就可以关联到具体的系统调用。
    • 由于用户空间和内核空间使用不同的栈空间,因此系统调用的参数需要使用寄存器进行传递。在x86系统上,ebx、ecx、edx、esi、edi按照顺序存放前5个参数。如果参数大于等于6个,需要用一个单独的寄存器存放指向所有这些参数在用户空间地址的指针
    • 返回值也通过寄存器传递。在x86系统上,它存放在eax寄存器中。如果系统调用产生的大量数据不能通过返回机制传递给用户进程,那么必须通过指定的内存区交换数据。当然,该内存区必须在用户空间中,使得用户应用层能够访问
    • 在内核访问自身的内存区时,虚拟地址和物理内存页之间的映射总是存在的。但用户空间中的情况有所不同,页可能被换出,甚至可能尚未分配物理内存页。
    • 因而内核不能简单的反引用用户空间的指针,而必须采用特定的函数,确保目标内存区已经在物理内存中,为确保这种约定,用户空间指针通过_user属性标记,以支持 C check tools 对源代码的自动化检查。
    • 大多数情况下,用户在用户空间和内核空间之间复制数据的函数使用copy_to_user() 和 copy_from_user(),但还有更多的变体。
    • 注意 copy_to_user() 和 copy_from_user() 都有可能引起阻塞。当包含用户数据的页被换出到硬盘上而不是在物理内存上的时候,这种情况就会发生。此时,进程就会休眠,直到缺页处理程序将该页从硬盘重新换回物理内存。
  • 用户会触发软中断指令(int 0x80),通知内核要进行系统调用,内核得到通知后,会从用户态切换到核心态,并执行对应的中断处理(int 0x80)程序,即 system_call(),它做了如下事情:
    • 在内核栈中保存寄存器值
    • 审核系统调用编号的有效性
    • 在系统调用表中找到并调用对应的系统调用:
      • 如果有参数的的话检查参数有效性
      • 执行必要的任务(对特定参数中指定地址处的值进行修改,以及在用户内存和内核内存间传递数据)
      • 将结果返回给system_call()
      • 从内核栈中恢复给寄存器值,并将系统调用返回值置于栈中
      • 返回至外壳函数,同时将处理器切换回用户态
  • 如果返回值表明调用有错误,外壳函数会使用该值来设置全局变量errno。然后,外壳函数会返回到调用程序,并同时返回一个整数值,以表明系统调用是否成功
    • 几乎每个系统调用和库函数都会返回某类状态值,用以表明调用成功与否。要了解调用是否成功,必须坚持对状态值进行检查。若调用失败,那么必须采取相应行动。至少,程序应该显示错误消息,警示有意想不到的事件发生。
    • 不检查状态值,少敲几个字,听起来的确诱人(尤其是见识到了不检查状态值的UNIX/Linux 程序以后),但实际却得不偿失。认定系统调用或库函数“不可能失败”,不对状态返回值进行检查,这会浪费掉大把的程序调试时间
    • 少数几个系统函数在调用时从不失败。例如,getpid()总能成功返回进程的 ID,而_exit()总能终止进程。无需对此类系统调用的返回值进行检查。

为调试程序,或是研究程序的运作机制,可使用strace 命令,对程序发起的系统调用进行跟踪
在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值