探索Linux操作系统中trap机制

前言

本学期第一次接触操作系统课程,在这之前对这方面可以说是一窍不通。本次完成作业的同时也是之前上课针对trap机制就产生的好奇进行一个探究。希望诸位对我理解不到位或者错误的地方提出批评指正。
注:为了便于理解,本博客讲解的是较为古早的Linux v0.12中有关trap的部分,即便你像我一样没学过编译原理,但只要你有一点点C语言的代码基础即可完美读懂本博客

trap简单理解

在操作系统课程的学习中我们很容易能找到一句话,也是我对其产生好奇的来源

陷阱(trap): 一条指令产生了异常,其状态同样可以恢复,但是执行异常处理程序之后恢复的位置是异常指令的下一条指令

那他到底是怎么进行恢复,哪些情况会触发这个机制呢,接下来让我们进入正题,看一下Linux操作系统有关trap的源码。

Linux trap源码

#include <string.h>
#include <linux/head.h>
#include <linux/sched.h>
#include <linux/kernel.h>
#include <asm/system.h>
#include <asm/segment.h>
#include <asm/io.h>

#define get_seg_byte(seg,addr) ({ \
register char __res; \
__asm__("push %%fs;mov %%ax,%%fs;movb %%fs:%2,%%al;pop %%fs" \
	:"=a" (__res):"0" (seg),"m" (*(addr))); \
__res;})

#define get_seg_long(seg,addr) ({ \
register unsigned long __res; \
__asm__("push %%fs;mov %%ax,%%fs;movl %%fs:%2,%%eax;pop %%fs" \
	:"=a" (__res):"0" (seg),"m" (*(addr))); \
__res;})

#define _fs() ({ \
register unsigned short __res; \
__asm__("mov %%fs,%%ax":"=a" (__res):); \
__res;})

void page_exception(void);

void divide_error(void);
void debug(void);
void nmi(void);
void int3(void);
void overflow(void);
void bounds(void);
void invalid_op(void);
void device_not_available(void);
void double_fault(void);
void coprocessor_segment_overrun(void);
void invalid_TSS(void);
void segment_not_present(void);
void stack_segment(void);
void general_protection(void);
void page_fault(void);
void coprocessor_error(void);
void reserved(void);
void parallel_interrupt(void);
void irq13(void);
void alignment_check(void);

static void die(char * str,long esp_ptr,long nr)
{
	long * esp = (long *) esp_ptr;
	int i;

	printk("%s: %04x\n\r",str,nr&0xffff);
	printk("EIP:\t%04x:%p\nEFLAGS:\t%p\nESP:\t%04x:%p\n",
		esp[1],esp[0],esp[2],esp[4],esp[3]);
	printk("fs: %04x\n",_fs());
	printk("base: %p, limit: %p\n",get_base(current->ldt[1]),get_limit(0x17));
	if (esp[4] == 0x17) {
		printk("Stack: ");
		for (i=0;i<4;i++)
			printk("%p ",get_seg_long(0x17,i+(long *)esp[3]));
		printk("\n");
	}
	str(i);
	printk("Pid: %d, process nr: %d\n\r",current->pid,0xffff & i);
	for(i=0;i<10;i++)
		printk("%02x ",0xff & get_seg_byte(esp[1],(i+(char *)esp[0])));
	printk("\n\r");
	do_exit(11);		/* play segment exception */
}

void do_double_fault(long esp, long error_code)
{
	die("double fault",esp,error_code);
}

void do_general_protection(long esp, long error_code)
{
	die("general protection",esp,error_code);
}

void do_alignment_check(long esp, long error_code)
{
    die("alignment check",esp,error_code);
}

void do_divide_error(long esp, long error_code)
{
	die("divide error",esp,error_code);
}

void do_int3(long * esp, long error_code,
		long fs,long es,long ds,
		long ebp,long esi,long edi,
		long edx,long ecx,long ebx,long eax)
{
	int tr;

	__asm__("str %%ax":"=a" (tr):"0" (0));
	printk("eax\t\tebx\t\tecx\t\tedx\n\r%8x\t%8x\t%8x\t%8x\n\r",
		eax,ebx,ecx,edx);
	printk("esi\t\tedi\t\tebp\t\tesp\n\r%8x\t%8x\t%8x\t%8x\n\r",
		esi,edi,ebp,(long) esp);
	printk("\n\rds\tes\tfs\ttr\n\r%4x\t%4x\t%4x\t%4x\n\r",
		ds,es,fs,tr);
	printk("EIP: %8x   CS: %4x  EFLAGS: %8x\n\r",esp[0],esp[1],esp[2]);
}

void do_nmi(long esp, long error_code)
{
	die("nmi",esp,error_code);
}

void do_debug(long esp, long error_code)
{
	die("debug",esp,error_code);
}

void do_overflow(long esp, long error_code)
{
	die("overflow",esp,error_code);
}

void do_bounds(long esp, long error_code)
{
	die("bounds",esp,error_code);
}

void do_invalid_op(long esp, long error_code)
{
	die("invalid operand",esp,error_code);
}

void do_device_not_available(long esp, long error_code)
{
	die("device not available",esp,error_code);
}

void do_coprocessor_segment_overrun(long esp, long error_code)
{
	die("coprocessor segment overrun",esp,error_code);
}

void do_invalid_TSS(long esp,long error_code)
{
	die("invalid TSS",esp,error_code);
}

void do_segment_not_present(long esp,long error_code)
{
	die("segment not present",esp,error_code);
}

void do_stack_segment(long esp,long error_code)
{
	die("stack segment",esp,error_code);
}

void do_coprocessor_error(long esp, long error_code)
{
	if (last_task_used_math != current)
		return;
	die("coprocessor error",esp,error_code);
}

void do_reserved(long esp, long error_code)
{
	die("reserved (15,17-47) error",esp,error_code);
}

void trap_init(void)
{
	int i;

	set_trap_gate(0,&divide_error);
	set_trap_gate(1,&debug);
	set_trap_gate(2,&nmi);
	set_system_gate(3,&int3);	/* int3-5 can be called from all */
	set_system_gate(4,&overflow);
	set_system_gate(5,&bounds);
	set_trap_gate(6,&invalid_op);
	set_trap_gate(7,&device_not_available);
	set_trap_gate(8,&double_fault);
	set_trap_gate(9,&coprocessor_segment_overrun);
	set_trap_gate(10,&invalid_TSS);
	set_trap_gate(11,&segment_not_present);
	set_trap_gate(12,&stack_segment);
	set_trap_gate(13,&general_protection);
	set_trap_gate(14,&page_fault);
	set_trap_gate(15,&reserved);
	set_trap_gate(16,&coprocessor_error);
	set_trap_gate(17,&alignment_check);
	for (i=18;i<48;i++)
		set_trap_gate(i,&reserved);
	set_trap_gate(45,&irq13);
	outb_p(inb_p(0x21)&0xfb,0x21);
	outb(inb_p(0xA1)&0xdf,0xA1);
	set_trap_gate(39,&parallel_interrupt);
}

我滴个妈妈诶,怎么上来就开大,诸位看客莫急,接下来就是逐行讲解

逐行讲解

引用头文件

#include <string.h>
#include <linux/head.h>
#include <linux/sched.h>
#include <linux/kernel.h>
#include <asm/system.h>
#include <asm/segment.h>
#include <asm/io.h>

这个部分就是简单的C语言中的引入头文件,与我们最开始所学不同的是此处是有关Linux的一些引用,例如引用我们熟悉的包含了一些字符串处理的string.h,此处我们需要关注的头文件是kernel.h,在后续讲解trap中我们会跳转到该部分。

kernel.h:包含了在Linux内核中广泛使用的各种宏、常量、数据结构和函数、

内联汇编部分

#define get_seg_byte(seg,addr) ({ \
register char __res; \
__asm__("push %%fs;mov %%ax,%%fs;movb %%fs:%2,%%al;pop %%fs" \
	:"=a" (__res):"0" (seg),"m" (*(addr))); \
__res;})

#define get_seg_long(seg,addr) ({ \
register unsigned long __res; \
__asm__("push %%fs;mov %%ax,%%fs;movl %%fs:%2,%%eax;pop %%fs" \
	:"=a" (__res):"0" (seg),"m" (*(addr))); \
__res;})

#define _fs() ({ \
register unsigned short __res; \
__asm__("mov %%fs,%%ax":"=a" (__res):); \
__res;})

看到这有些看官或许要开始骂娘了,这里看着像C语言的代码但怎么完全看不懂啊
在这里插入图片描述
没事,我最开始也是这样想的
让我们先来快速补充一个概念内联汇编
此处引用了大佬Li-Yongjun博客中的解释

内联函数
在 C 语言中,我们可以指定编译器将一个函数代码直接复制到调用其代码的地方执行。这种函数调用方式和默认压栈调用方式不同,我们称这种函数为内联函数。有点像宏。
优点:内联函数降低了函数的调用开销。
实现:指定编译器将一个函数处理为内联函数,我们只要在函数声明前加上 inline 关键字就可以了。
内联汇编
基于对上述内联函数的认知,我们大概可以想象出内联汇编到底是怎么一回事了。内联汇编相当于用汇编语句写成的内联函数。
优点:效率高。
实现:使用 asm 关键字。
关键:之所以内联汇编如此有用,主要是因为它可以操作 C 语言变量,比如可以从 C 语言变量获取值,输出值到 C 语言变量。由于这个能力,asm 用作汇编指令和包含它的 C 程序之间的接口。

补充完以上概念和知识之后,让我们接着来讲解。

#define get_seg_byte(seg,addr)

这个宏定义了一个函数get_seg_byte,它接受两个参数:一个段(seg)和一个地址(addr),
主要功能是从给定段和地址读取一个字节

而其中register char __res; 定义了一个寄存器变量__res,用于存储结果。

__asm__("push %%fs;mov %%ax,%%fs;movb %%fs:%2,%%al;pop %%fs" ...);这是内联汇编的部分。它首先保存当前FS段寄存器的值,然后将AX寄存器的值复制到FS段寄存器。接着,它从FS段中的给定地址读取一个字节到AL寄存器,最后恢复FS段寄存器的原始值。

__res;})返回读取的字节。
看看,这么一行一行的解释是不是瞬间就清晰了,接下来咱依葫芦画瓢解释解释其他宏定义。

#define get_seg_long(seg,addr)

这个宏定义了一个函数get_seg_long,它接受两个参数:一个段(seg)和一个地址(addr)。
主要功能是从给定段和地址读取一个长整数(在x86架构中为4字节)

其中register unsigned long __res;定义了一个寄存器变量__res,用于存储结果。

__asm__("push %%fs;mov %%ax,%%fs;movl %%fs:%2,%%eax;pop %%fs" ...);这是内联汇编的部分。它首先保存当前FS段寄存器的值,然后将AX寄存器的值复制到FS段寄存器。接着,它从FS段中的给定地址读取一个长整数到EAX寄存器,最后恢复FS段寄存器的原始值。

__res;}) 返回读取的长整数。

#define _fs()

这个宏定义了一个函数_fs,它不接受任何参数。
主要功能是获取当前FS段的原始值。

register unsigned short __res;定义了一个寄存器变量__res,用于存储结果。
__asm__("mov %%fs,%%ax":"=a" (__res):);这是内联汇编的部分。它将FS段的当前值移动到AX寄存器,并将AX的值复制到__res。
__res;}) 返回FS段的原始值。

这样解释完,不是瞬间清晰了吗,让我们接着往下看

中断或异常函数定义

void page_exception(void);

void divide_error(void);
void debug(void);
void nmi(void);
void int3(void);
void overflow(void);
void bounds(void);
void invalid_op(void);
void device_not_available(void);
void double_fault(void);
void coprocessor_segment_overrun(void);
void invalid_TSS(void);
void segment_not_present(void);
void stack_segment(void);
void general_protection(void);
void page_fault(void);
void coprocessor_error(void);
void reserved(void);
void parallel_interrupt(void);
void irq13(void);
void alignment_check(void);

啊,一连串又熟悉起来了,定义各种各样的函数,此处的函数每一个都是一个异常或者中断的处理程序。下面还有讲解,让咱先暂且按下不表。

die函数

static void die(char * str,long esp_ptr,long nr)
{
	long * esp = (long *) esp_ptr;
	int i;

	printk("%s: %04x\n\r",str,nr&0xffff);
	printk("EIP:\t%04x:%p\nEFLAGS:\t%p\nESP:\t%04x:%p\n",
		esp[1],esp[0],esp[2],esp[4],esp[3]);
	printk("fs: %04x\n",_fs());
	printk("base: %p, limit: %p\n",get_base(current->ldt[1]),get_limit(0x17));
	if (esp[4] == 0x17) {
		printk("Stack: ");
		for (i=0;i<4;i++)
			printk("%p ",get_seg_long(0x17,i+(long *)esp[3]));
		printk("\n");
	}
	str(i);
	printk("Pid: %d, process nr: %d\n\r",current->pid,0xffff & i);
	for(i=0;i<10;i++)
		printk("%02x ",0xff & get_seg_byte(esp[1],(i+(char *)esp[0])));
	printk("\n\r");
	do_exit(11);		/* play segment exception */
}

有了之前的基础,让我们来大展身手理解一下这个部分

首先,这个函数接受三个参数一个字符指针str(可能用于表示错误消息),一个长整型esp_ptr(可能代表栈的指针),和一个长整型nr。

接着long * esp = (long *) esp_ptr; 将esp_ptr强制类型转换为一个长整型指针,并赋值给变量esp。
printk("%s: %04x\n\r",str,nr&0xffff); 打印错误消息和状态。
printk("EIP:\t%04x:%p\nEFLAGS:\t%p\nESP:\t%04x:%p\n",esp[1],esp[0],esp[2],esp[4],esp[3]); 打印出一些重要的寄存器值,包括EIP(Instruction Pointer,指令指针),EFLAGS,ESP(Stack Pointer,栈指针)等。
printk("fs: %04x\n",_fs()); 打印FS寄存器的值。这里需要简单提一下FS寄存器通常用于访问系统保护模式下的某些数据结构。
printk("base: %p, limit: %p\n",get_base(current->ldt[1]),get_limit(0x17)); 打印一些段寄存器的信息,包括基址和界限。

if (esp[4] == 0x17) {
		printk("Stack: ");
		for (i=0;i<4;i++)
			printk("%p ",get_seg_long(0x17,i+(long *)esp[3]));
		printk("\n");
	}

如果ESP[4]的值等于0x17,打印栈的内容。

printk("Pid: %d, process nr: %d\n\r",current->pid,0xffff & i); 打印进程ID和进程编号。

do_exit(11); 调用一个名为do_exit的函数,并传入参数11,执行退出操作。

调用die函数

以下部分并不完整,仅作简单讲解展示

void do_double_fault(long esp, long error_code)
{
	die("double fault",esp,error_code);
}

这部分调用函数就是调用die函数,仅仅传入参数不同,如何具体运行可以查看上面的die函数代码讲解部分。

do_int3函数

void do_int3(long * esp, long error_code,
		long fs,long es,long ds,
		long ebp,long esi,long edi,
		long edx,long ecx,long ebx,long eax)
{
	int tr;

	__asm__("str %%ax":"=a" (tr):"0" (0));
	printk("eax\t\tebx\t\tecx\t\tedx\n\r%8x\t%8x\t%8x\t%8x\n\r",
		eax,ebx,ecx,edx);
	printk("esi\t\tedi\t\tebp\t\tesp\n\r%8x\t%8x\t%8x\t%8x\n\r",
		esi,edi,ebp,(long) esp);
	printk("\n\rds\tes\tfs\ttr\n\r%4x\t%4x\t%4x\t%4x\n\r",
		ds,es,fs,tr);
	printk("EIP: %8x   CS: %4x  EFLAGS: %8x\n\r",esp[0],esp[1],esp[2]);
}

终于到了这个函数部分,在这个部分我们还是先来看看他传入的参数

esp: 栈指针的当前值。
error_code: 异常错误代码,对于int3中断,这个值一般是无关的。
fs, es, ds: x86架构的段寄存器值。
ebp, esi, edi, edx, ecx, ebx, eax: x86架构的通用寄存器值。

接着来看看内部的解释:
__asm__("str %%ax":"=a" (tr):"0" (0));: 是一行内嵌汇编代码,用于读取任务寄存器(TR)的值并将其存储在变量tr中。
接下来的几行printk函数用于打印各寄存器的值。可以让我们看到发生中断时寄存器的状态。
printk("EIP: %8x CS: %4x EFLAGS: %8x\n\r",esp[0],esp[1],esp[2]); 这行代码打印出发生中断时的指令指针(EIP)、代码段寄存器(CS)以及标志寄存器(EFLAGS)的值。

trap_init函数

void trap_init(void)
{
	int i;

	set_trap_gate(0,&divide_error);
	set_trap_gate(1,&debug);
	set_trap_gate(2,&nmi);
	set_system_gate(3,&int3);	/* int3-5 can be called from all */
	set_system_gate(4,&overflow);
	set_system_gate(5,&bounds);
	set_trap_gate(6,&invalid_op);
	set_trap_gate(7,&device_not_available);
	set_trap_gate(8,&double_fault);
	set_trap_gate(9,&coprocessor_segment_overrun);
	set_trap_gate(10,&invalid_TSS);
	set_trap_gate(11,&segment_not_present);
	set_trap_gate(12,&stack_segment);
	set_trap_gate(13,&general_protection);
	set_trap_gate(14,&page_fault);
	set_trap_gate(15,&reserved);
	set_trap_gate(16,&coprocessor_error);
	set_trap_gate(17,&alignment_check);
	for (i=18;i<48;i++)
		set_trap_gate(i,&reserved);
	set_trap_gate(45,&irq13);
	outb_p(inb_p(0x21)&0xfb,0x21);
	outb(inb_p(0xA1)&0xdf,0xA1);
	set_trap_gate(39,&parallel_interrupt);
}

终于到了最后的init函数部分

首先我们能看到这部分代码使用了两个主要的函数,分别是set_trap_gateset_system_gate函数,这两个函数分别是设置编号为n的中断陷阱的处理程序为func以及置编号为n的系统门(system gate)的处理程序为func。

函数中的一系列set_trap_gate和set_system_gate调用都是为了将特定的中断类型与特定的处理程序关联起来。这些中断类型包括但不限于除以0错误(divide_error)、调试(debug)、非屏蔽中断(nmi)等。

对于那些没有明确指定处理程序的陷阱,这里将它们设置为保留(&reserved),这意味着它们当前没有被使用。

接着对硬件进行了一点配置,分别是outb_p(inb_p(0x21)&0xfb,0x21); outb(inb_p(0xA1)&0xdf,0xA1);
第一条命令禁用了IRQ2(对应于0x21),第二条命令禁用了IRQ13(对应于0xA1)

最后将并行中断处理程序(parallel_interrupt)设置为39号中断的处理程序。

以上就是绝大部分讲解,接下来是我自己因为好奇刨了一些kernel源码找到的自己好奇的一些函数实现。

set_trap_gate和set_system_gate函数

该函数的定义在前文提到的system.h头文件中,具体信息如下

#define _set_gate(gate_addr,type,dpl,addr) \
__asm__ ("movw %%dx,%%ax\n\t" \
	"movw %0,%%dx\n\t" \
	"movl %%eax,%1\n\t" \
	"movl %%edx,%2" \
	: \
	: "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \
	"o" (*((char *) (gate_addr))), \
	"o" (*(4+(char *) (gate_addr))), \
	"d" ((char *) (addr)),"a" (0x00080000))
	
#define set_trap_gate(n,addr) \
	_set_gate(&idt[n],15,0,addr)

#define set_system_gate(n,addr) \
	_set_gate(&idt[n],15,3,addr)

这个部分重点就是_set_gate函数,他接受四个参数,分别是gate_addr(门描述符的地址),type(门的类型),dpl(描述符特权级),和addr(处理程序的地址)。
__asm__ ("movw %%dx,%%ax\n\t": 这一行是一个内嵌汇编语句。将DX寄存器的值移动到AX寄存器。
"movw %0,%%dx\n\t": 将gate_addr的值移动到DX寄存器。
"movl %%eax,%1\n\t": 这一行将AX寄存器的值移动到参数type指定的寄存器。
"movl %%edx,%2": 这一行将DX寄存器的值移动到参数dpl指定的寄存器。
在这里插入图片描述

: \: "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \: 这一行指定了输入参数的值。它计算出一个值,该值将用于设置门描述符的某些字段。具体来说,这个值由以下几部分组成:0x8000(一个常数),dpl左移13位(相当于乘以2^13
),和type左移8位(相当于乘以2^8)。
"o" (*((char *) (gate_addr))), \: 这一行指定了输出参数的值,即门描述符的地址。
"o" (*(4+(char *) (gate_addr))), \: 这一行指定了另一个输出参数的值,即门描述符的下一个字段的地址。
"d" ((char *) (addr)),"a" (0x00080000)): 这些行指定了输入和输出寄存器的值。具体来说,它们将addr的值加载到寄存器,并指定一个常数0x00080000作为输入参数的值。

以上就是所有的有关trap指令的相关讲解。

运行效果评价和改进建议

在这个早期版本的Linux内核代码中,他并未对中断和异常做出不同的划分,而是统一写在了一起,容易造成维护困难以及混乱。
与此同时相关指令和异常也并不完全。
并且传递参数的时候也容易因为没有指明是寄存器传递还是栈传递造成一些问题。
不过这个版本的代码已经是90年代的了,在这里拿出来也只是为了理解更为简单,在后续版本中我们能很清晰的看到这些缺点的改善。

  • 8
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
### 回答1: Linux trap是一种命令,用于在shell脚本设置陷阱,以便在发生特定事件时执行某些操作。它可以用于捕获和处理信号,以及在脚本执行期间处理错误和异常情况。使用trap命令可以提高脚本的可靠性和稳定性。 ### 回答2: Linux trap 指的是在 Linux 操作系统使用 trap 命令来捕获和处理信号的机制。在 Linux ,信号是一种进程间通信的方式,用于通知进程发生的事件,如键盘输入、程序异常等。 trap 命令可以用来为进程注册一个信号处理函数。当进程接收到指定的信号时,会执行注册的处理函数来处理这个信号。trap 命令的语法如下: trap [命令] [信号] 其,命令表示要执行的处理函数或命令,信号表示要捕获处理的信号。 通过使用 trap 命令,我们可以实现对不同信号的处理和控制。例如,我们可以使用 trap 命令来设置一个处理函数,当进程接收到断信号(SIGINT)时,执行清理操作,并退出程序。 另外,trap 命令还可以用来屏蔽或忽略某些信号,或者在某个特定的时刻捕获和处理信号等。这种灵活的信号处理机制能够帮助我们实现更加健壮和可靠的程序。 总之,Linux trap 命令是一种用于捕获和处理信号的机制。通过使用 trap 命令,我们可以为进程注册信号处理函数,以实现对不同信号的响应和处理,从而提高程序的稳定性和可靠性。 ### 回答3: Linux trap 是用于捕捉信号的命令,也可以称之为信号陷阱。在Linux,进程可以通过发送信号与其他进程进行通信或者进行进程管理。而trap命令则可以用来定义一个函数或者命令,在收到指定信号时执行特定的操作。 使用trap命令可以在Shell脚本捕获指定的信号,比如SIGINT(断信号)或者SIGTERM(终止信号),从而在收到这些信号时进行相应的处理。可以使用trap命令为指定信号设置处理函数或者指定命令,也可以使用trap命令屏蔽或者忽略指定的信号。 一个经典的用法是在脚本使用trap命令来捕获SIGINT信号(通过按下Ctrl+C产生),以实现在脚本执行过程可以通过按下Ctrl+C终止脚本执行的功能。另外,通过trap命令,还可以实现在收到指定信号之前或之后执行一些清理工作的功能。 例如,可以使用trap命令来定义一个函数,用于在脚本执行被断时打印提示信息并执行清理工作,如关闭打开的文件等。然后使用trap命令将这个函数关联到SIGINT信号。这样,当脚本收到SIGINT信号时,会自动执行这个函数,实现了断处理的逻辑。 总而言之,Linux trap 是一种用于捕捉信号的命令,可以在Shell脚本通过设置处理函数或者指定命令,实现对收到指定信号时的处理。它为Shell脚本提供了更加灵活的信号处理方式,可以增强脚本的稳定性和可靠性。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值