手写操作系统:中断与异常处理

项目简介

目前我们已经完成了主引导程序与二级引导的编写并进入了内核执行,在本节我们需要完成对中断和异常的处理,我们在项目下新建一个目录 kernel,用来存储系统内核的代码。我们仍然新建一个start.S文件作为内核的入口。

处理参数

在上一节中我们使用函数调用的方式跳转到系统内核执行。

void loader_kernel()
{
    //加载内核程序
    read_disk(100,500,(uint8_t *)SYS_KERNEL_LOAD_ADDR);  
    //跳转至内核程序
	uint32_t kernel_entry = load_elf((uint8_t *)SYS_KERNEL_LOAD_ADDR);
	if(kernel_entry==0)
	{
		die(1);
	}
	((void (*)(boot_info_t*))kernel_entry)(&boot_info);
}

可以看到我们将可用内存作为一个参数传入了内核,在start.S中我们需要将参数取出。这里简单介绍一下C语言的函数调用,在C语言的函数调用中参数是通过压栈的方式传递的,当我们调用函数后会从左到右压栈,然后执行call指令压入下一条指令的地址,即调用指令后的第一条指令的地址,使得被调用的函数在执行完毕后能够返回到正确的位置继续执行。

bfda69f139cc49dfb1bb5f4d9fad81f7.png

所以我们可以使用mov指令直接从栈中取出参数。

    .text
    .extern kernel_init
    .extern init_main
    .global _start
_start:
    //取参数
    mov 4(%esp),%eax
    //传参数
    push %eax
    //函数调用
    call kernel_init

我们将参数从栈中取出参数,然后再压入栈中,实现对kernel_init的传参。

重新设置GDT表

在进入保护模式前,我们设置了一个简单的GDT表。在处理中断与异常前,我们需要重新设置一个GDT表,在cpu_init() 中我们重新设置GDT表。

void kernel_init(boot_info_t* boot_info)
{   
    //初始化CPU
    cpu_init();
}

//CPU初始化
void cpu_init (void) 
{
    //设置GDT表
    init_gdt();
}

我们根据段描述符的结构定义描述符结构体。

ace49ddaf7c0497cac4f0aa429f279b1.png 

//段描述符
typedef struct global_segment_descriptor
{
    uint16_t limit15_0;		//段界限
	uint16_t base15_0;		//段基地址
	uint8_t  base23_16;		//段基地址
	uint16_t attr;			//段属性
	uint8_t  base31_24;		//段基地址
}global_segment_descriptor;

我们定义一些宏用于设置段属性。

#define SEG_G	(1<<15)			//段偏移量的粒度 字节/4KB
#define SEG_D 	(1<<14)			//CPU工作模式    16/32
#define SEG_P   (1<<7)			//段在内存中是否存在

#define SEG_DPL0 (0<<5)		//特权级0
#define SEG_DPL3 (3<<5)		//特权级1

#define SEG_S_SYSTEM	(0<<4)	 //系统段
#define SEG_S_NORMAL 	(1<<4)	 //普通段

#define SEG_TYPE_CODE 	(1<<3)	 //代码段
#define SEG_TYPE_DATA 	(0<<3)	 //数据段
#define SEG_TYPE_TSS 	(9<<0)	 //TSS段

#define SEG_TYPE_RW 	(1<<1)	 //可读写

同时我们定义一个函数用来设置段描述符

//设置全局段描述符表
void set_global_segment(int selector,uint32_t base,uint32_t limit,uint16_t attr)
{
  
    global_segment_descriptor* index= gdt_table + selector;
    if(limit > 0xFFFFF)
    {
        attr |= 0x8000;   //粒度设为1
        limit /= 0x1000;  
    }
    index->limit15_0 = limit & 0xffff;
	index->base15_0 = base & 0xffff;
	index->base23_16 = (base >> 16) & 0xff;
    index->attr = attr | (((limit >> 16) & 0xf) << 8);
	index->base31_24 = (base >> 24) & 0xff;
}

 在init_gdt中我们定义将段描述符全部清零,只设置两个段,一个段为代码段,段基地址为 0x00000000  , 段大小为 4GB 。 一个为数据段,段基地址为0x00000000,段大小为4GB。然后设置gdtr寄存器。

#define GDT_TABLE_SIZE 256
//GDT表
static global_segment_descriptor  gdt_table[GDT_TABLE_SIZE];

//初始化GDT
void init_gdt(void) {
	// 全部清空
    for (int i = 0; i < GDT_TABLE_SIZE; i++) 
    {
        set_global_segment(i, 0, 0, 0);
    }
    //设置为平坦模式
    //设置代码段  特权级0  代码段 存在 可读写 32位
    set_global_segment(KERNEL_CS,0,0xFFFFFFFF,SEG_P | SEG_DPL0 | SEG_S_NORMAL | SEG_TYPE_CODE | SEG_TYPE_RW | SEG_D | SEG_G);
    //设置数据段  特权级0  数据段 存在 可读写 32位
    set_global_segment(KERNEL_DS,0,0xFFFFFFFF,SEG_P | SEG_DPL0 | SEG_S_NORMAL | SEG_TYPE_DATA | SEG_TYPE_RW | SEG_D | SEG_G);

    //设置寄存器
    lgdt((uint32_t)gdt_table,sizeof(gdt_table));
}

在重新设置GDT表后,我们就要对中断与异常进行处理。

保护模式中断与异常的处理 

与全局描述符表(GDT)类似,保护模式使用中断描述符表来定义中断和异常的处理程序。操作系统需要设置IDT,并为每个中断或异常定义一个中断处理程序(也称为中断服务例程,ISR)。

在保护模式下,中断和异常可以通过中断门或陷阱门来处理。中断门用于处理外部硬件中断,而陷阱门用于处理软件生成的中断(如INT指令)和异常。目前我们只会使用中断门,中断门结构如下:

1018a22d1bee4ba8a478dc4aa463c957.png

我们根据中断门描述符定义出中断描述符结构:

//中断描述符
typedef struct  gate_descriptor
{
	uint16_t offset15_0;	//偏移量低16位
	uint16_t selector;		//段选择子
	uint16_t attr;			//属性
	uint16_t offset31_16;	//偏移量高16位
}gate_descriptor;

IDT表最大只能有256个表项,0到255号中断向量分别对应不同的中断和异常。每个中断向量都有一个对应的IDT表项,当该中断或异常发生时,CPU会根据中断号判断应该执行哪个中断服务例程或异常处理程序。 

前32个中断号是保留的,其中0到19号是硬件中断,20到31号是软件中断。除了一些特定的中段号,其他中断号可以自由分配。

  • 0x00 - Divide Error(除以零错误)
  • 0x01 - Debug Exception(调试异常)
  • 0x02 - NMI (Non-Maskable Interrupt, 非屏蔽中断)
  • 0x03 - Breakpoint Exception(断点异常,通常由INT 3指令触发)
  • 0x04 - Overflow(溢出异常,通常由INTO指令触发)
  • 0x05 - Bound Range Exceeded(界限检查异常)
  • 0x06 - Invalid Opcode (Invalid Instruction, 无效操作码异常)
  • 0x07 - Device Not Available (设备不可用异常, 通常与协处理器有关)
  • 0x08 - Double Fault(双重故障)
  • 0x09 - Coprocessor Segment Overrun (协处理器段超限)
  • 0x0A - Invalid TSS(无效的任务状态段异常)
  • 0x0B - Segment Not Present(段不存在异常)
  • 0x0C - Stack Fault(栈故障)
  • 0x0D - General Protection Fault(一般保护故障)
  • 0x0E - Page Fault(页故障)
  • 0x10 - x87 FPU Floating-Point Exception (x87浮点单元异常)
  • 0x11 - Alignment Check(对齐检查异常)
  • 0x12 - Machine Check (机器检查异常, 通常与硬件错误有关)
  • 0x13 - SIMD Floating-Point Exception (SIMD浮点异常)

处理异常

和设置GDT表相同,我们同样定义宏和设置描述符的函数。

//设置门描述符
void set_gate_descriptor(gate_descriptor* desc,uint16_t selector,uint32_t offset,uint16_t attr)
{
    desc->selector=selector;
    desc->attr=attr;
    desc->offset15_0=offset & 0xffff;
    desc->offset31_16=(offset >> 16) & 0xffff;
}

对于这256个中断与异常, 为了简单起见,我们采用统一的处理方式,打印寄存器信息然后执行 hlt指令停机。

static void dump_core_regs (exception_information * frame) 
{
    // 打印CPU寄存器相关内容
    print_log("IRQ: %d, error code: %d.", frame->num, frame->error_code);
    print_log("CS: %d\nDS: %d\nES: %d\nSS: %d\nFS:%d\nGS:%d",
               frame->cs, frame->ds, frame->es, frame->ds, frame->fs, frame->gs
    );
    print_log("EAX:0x%x\n"
                "EBX:0x%x\n"
                "ECX:0x%x\n"
                "EDX:0x%x\n"
                "EDI:0x%x\n"
                "ESI:0x%x\n"
                "EBP:0x%x\n"
                "ESP:0x%x\n",
               frame->eax, frame->ebx, frame->ecx, frame->edx,
               frame->edi, frame->esi, frame->ebp, frame->esp);
    print_log("EIP:0x%x\nEFLAGS:0x%x\n", frame->eip, frame->eflags);
}


static void do_default_handler (exception_information* frame, const char * message) 
{
    print_log("--------------------------------");
    print_log("IRQ/Exception happend: %s.", message);
    dump_core_regs(frame);
    
    print_log("--------------------------------");
    while(1)
    {
        hlt();
    }
}

但我们的IDT表项不能直接指向处理函数,函数的返回使用的ret指令,ret 指令用于从子程序返回到调用它的程序。它用于普通的函数调用,在这里我们应该使用 iret 指令。iret 指令用于从中断服务例程或异常处理程序返回。它不仅返回控制流,还会恢复了中断或异常发生时保存的寄存器状态。 iret 会从堆栈中弹出并恢复EFLAGS、CS、EIP寄存器的值。

这里我们的解决办法是在start.S中调用函数,然后使用 iret 返回,在设置段描述符时,我们指向汇编程序,为了方便,我们使用宏定义复用代码。

.macro exception_handler name num with_err_code
    .text
    .extern do_handler_\name
    .global exception_handler_\name
exception_handler_\name:
    //保存寄存器
    .if \with_err_code==0
        push $0
    .endif
    push $\num
    pusha
    push %ds
    push %es
    push %fs
    push %gs

    push %esp
    call do_handler_\name
    pop %esp
    pop %gs
    pop %fs
    pop %es
    pop %ds
    popa

    add $(2*4) , %esp

    iret
.endm

exception_handler unknown,-1,0
exception_handler divzero,0,0
exception_handler Debug, 1, 0
exception_handler NMI, 2, 0
exception_handler breakpoint, 3, 0
exception_handler overflow, 4, 0
exception_handler bound_range, 5, 0
exception_handler invalid_opcode, 6, 0
exception_handler device_unavailable, 7, 0
exception_handler double_fault, 8, 1
exception_handler invalid_tss, 10, 1
exception_handler segment_not_present, 11, 1
exception_handler stack_segment_fault, 12, 1
exception_handler general_protection, 13, 1
exception_handler page_fault, 14, 1
exception_handler fpu_error, 16, 0
exception_handler alignment_check, 17, 1
exception_handler machine_check, 18, 0
exception_handler smd_exception, 19, 0
exception_handler virtual_exception, 20, 0
void do_handler_unknown (exception_information * frame) 
{
	do_default_handler(frame, "Unknown exception.");
}

void do_handler_divzero(exception_information * frame) 
{
	do_default_handler(frame, "Device Error.");
}

void do_handler_Debug(exception_information * frame) 
{
	do_default_handler(frame, "Debug Exception");
}

void do_handler_NMI(exception_information * frame) 
{
	do_default_handler(frame, "NMI Interrupt.");
}

void do_handler_breakpoint(exception_information * frame) 
{
	do_default_handler(frame, "Breakpoint.");
}

void do_handler_overflow(exception_information * frame) 
{
	do_default_handler(frame, "Overflow.");
}

void do_handler_bound_range(exception_information * frame) 
{
	do_default_handler(frame, "BOUND Range Exceeded.");
}

void do_handler_invalid_opcode(exception_information * frame) 
{
	do_default_handler(frame, "Invalid Opcode.");
}

void do_handler_device_unavailable(exception_information * frame) 
{
	do_default_handler(frame, "Device Not Available.");
}

void do_handler_double_fault(exception_information * frame) 
{
	do_default_handler(frame, "Double Fault.");
}

void do_handler_invalid_tss(exception_information * frame) 
{
	do_default_handler(frame, "Invalid TSS");
}

void do_handler_segment_not_present(exception_information * frame) 
{
	do_default_handler(frame, "Segment Not Present.");
}

void do_handler_stack_segment_fault(exception_information * frame) 
{
	do_default_handler(frame, "Stack-Segment Fault.");
}

void do_handler_general_protection(exception_information * frame) 
{
	do_default_handler(frame, "General Protection.");
}

void do_handler_page_fault(exception_information * frame) 
{
	do_default_handler(frame, "Page Fault.");
}

void do_handler_fpu_error(exception_information * frame) 
{
	do_default_handler(frame, "X87 FPU Floating Point Error.");
}

void do_handler_alignment_check(exception_information * frame) 
{
	do_default_handler(frame, "Alignment Check.");
}

void do_handler_machine_check(exception_information * frame) 
{
	do_default_handler(frame, "Machine Check.");
}

void do_handler_smd_exception(exception_information * frame) 
{
	do_default_handler(frame, "SIMD Floating Point Exception.");
}

void do_handler_virtual_exception(exception_information * frame) 
{
	do_default_handler(frame, "Virtualization Exception.");
}

完成了处理函数我们便可以对IDT表进行设置。

int irq_install(int irq_num,uint32_t handler)
{
    if(irq_num >= IDT_TABLE_SIZE)
    {
        return -1;
    }
 
    set_gate_descriptor(idt_table + irq_num, (KERNEL_CS << 3), (uint32_t) handler,
                  GATE_P_PRESENT | GATE_DPL0 | GATE_TYPE_IDT);
    
    return 0;
}

void irq_init()
{
    //初始化IDT表项
    for(int i=0;i<IDT_TABLE_SIZE;i++)
    {
        set_gate_descriptor(idt_table+i,KERNEL_CS << 3,(uint32_t)exception_handler_unknown,GATE_P_PRESENT | GATE_DPL0 | GATE_TYPE_IDT);
    }

    //设置除零异常
    irq_install(IRQ0_DE, (uint32_t)exception_handler_divzero);
	irq_install(IRQ1_DB, (uint32_t)exception_handler_Debug);
	irq_install(IRQ2_NMI, (uint32_t)exception_handler_NMI);
	irq_install(IRQ3_BP, (uint32_t)exception_handler_breakpoint);
	irq_install(IRQ4_OF, (uint32_t)exception_handler_overflow);
	irq_install(IRQ5_BR, (uint32_t)exception_handler_bound_range);
	irq_install(IRQ6_UD, (uint32_t)exception_handler_invalid_opcode);
	irq_install(IRQ7_NM, (uint32_t)exception_handler_device_unavailable);
	irq_install(IRQ8_DF, (uint32_t)exception_handler_double_fault);
	irq_install(IRQ10_TS, (uint32_t)exception_handler_invalid_tss);
	irq_install(IRQ11_NP, (uint32_t)exception_handler_segment_not_present);
	irq_install(IRQ12_SS, (uint32_t)exception_handler_stack_segment_fault);
	irq_install(IRQ13_GP, (uint32_t)exception_handler_general_protection);
	irq_install(IRQ14_PF, (uint32_t)exception_handler_page_fault);
	irq_install(IRQ16_MF, (uint32_t)exception_handler_fpu_error);
	irq_install(IRQ17_AC, (uint32_t)exception_handler_alignment_check);
	irq_install(IRQ18_MC, (uint32_t)exception_handler_machine_check);
	irq_install(IRQ19_XM, (uint32_t)exception_handler_smd_exception);
	irq_install(IRQ20_VE, (uint32_t)exception_handler_virtual_exception);
    //设置idtr寄存器 
    lidt((uint32_t)idt_table,sizeof(idt_table));

    init_pic();
}

开启定时中断 

 在上面我们已经设置了IDT表,下面我们要打开定时中断,为以后的任务切换做准备。定时器属于外部设备,我们需要先设置8259A中断控制器,才能打开时钟中断,步骤如下:

  • 初始化8259A:首先需要对8259A进行初始化。我们需要·发送初始化命令字(ICWs)来设置8259A的工作方式。
  • 发送初始化命令字ICW1:首先,需要发送初始化命令字1(ICW1),设置级联方式、中断触发方式等。ICW1的最低位通常设置为1,表示这是ICW1。
  • 发送初始化命令字ICW2:接着发送ICW2来设置中断向量表的起始偏移地址。定时器中断通常有一个固定的中断向量。
  • 发送初始化命令字ICW3(如果使用级联):如果系统中有多个8259A芯片级联,ICW3用于设置主从关系。
  • 发送初始化命令字ICW4:最后发送ICW4来选择特定的操作模式,例如,可以选择全嵌套模式或特殊全嵌套模式。
  • 设置操作命令字OCW1:通过操作命令字1(OCW1),可以设置中断屏蔽寄存器(IMR)。将定时器中断对应的位清零以允许中断请求被CPU接收。
  • 配置定时器:在8259A设置完成后,我们需要配置定时器芯片(例如8253或8251),设置其计数器、控制方式和频率,以产生定时中断。

关于8259A芯片,笔者不是很熟悉,大家可以参考这篇博客详解8259A_8259a级联-CSDN博客

4256cea585c94e26aaaf8a3b336388de.png 

 8259A一个芯片可以管理8个中断,在x86架构下使用了级联技术将两个8259A芯片连在一起控制多个中断。一个为主片一个为从片。我们的定时中断由实时时钟产生,即从片的IRQ8。

下面我们对8259A进行初始化:

//PIC控制器相关的端口及位配置
#define PIC0_ICW1			0x20
#define PIC0_ICW2			0x21
#define PIC0_ICW3			0x21
#define PIC0_ICW4			0x21
#define PIC0_OCW2			0x20
#define PIC0_IMR			0x21

#define PIC1_ICW1			0xa0
#define PIC1_ICW2			0xa1
#define PIC1_ICW3			0xa1
#define PIC1_ICW4			0xa1
#define PIC1_OCW2			0xa0
#define PIC1_IMR			0xa1

#define PIC_ICW1_ICW4		(1 << 0)		// 1 - 需要初始化ICW4
#define PIC_ICW1_ALWAYS_1	(1 << 4)		// 总为1的位
#define PIC_ICW4_8086	    (1 << 0)        // 8086工作模式

#define IRQ_PIC_START		0x20			// PIC中断起始号

#define PIC0_OCW2_EOI       (1 <<5)

//初始化中断控制器
void init_pic()
{
     // 边缘触发,级联,需要配置icw4, 8086模式
    outb(PIC0_ICW1, PIC_ICW1_ALWAYS_1 | PIC_ICW1_ICW4);

    // 对应的中断号起始序号0x20
    outb(PIC0_ICW2, IRQ_PIC_START);

    // 主片IRQ2有从片
    outb(PIC0_ICW3, 1 << 2);

    // 普通全嵌套、非缓冲、非自动结束、8086模式
    outb(PIC0_ICW4, PIC_ICW4_8086);

    // 边缘触发,级联,需要配置icw4, 8086模式
    outb(PIC1_ICW1, PIC_ICW1_ICW4 | PIC_ICW1_ALWAYS_1);

    // 起始中断序号,要加上8
    outb(PIC1_ICW2, IRQ_PIC_START + 8);

    // 没有从片,连接到主片的IRQ2上
    outb(PIC1_ICW3, 2);

    // 普通全嵌套、非缓冲、非自动结束、8086模式
    outb(PIC1_ICW4, PIC_ICW4_8086);

    // 禁止所有中断, 允许从PIC1传来的中断
    outb(PIC0_IMR, 0xFF & ~(1 << 2));
    outb(PIC1_IMR, 0xFF);
}

这里其他设置我们不用关心,我们将芯片的工作模式设置为非自动结束,即在处理完中断后需要手动发送EOI信号,这个信号会通知PIC中断已经处理完毕。在发送信号前,PIC不会响应同一中断源的中断请求。同时我们将起始中断号设置为0x20,则时钟中断的中断号为0x20。同时因为我们没有对中断的处理,我们禁用了所有中断。

外部中断的屏蔽可以通过写入操作命令字 OCW1,将中断屏蔽寄存器(IMR)中的Di位置1,以达到对 IRii=0~7)中断请求的屏蔽。也可以设置eflag的 IF位,当IF位被设置(1)时,CPU允许接收可屏蔽中断。如果IF位被清除(0),CPU将忽略所有可屏蔽中断请求,直到该位再次被设置。我们这里编写了一些函数控制中断的打开与屏蔽。

//开启特定中断
void  int_open(int int_num)
{
    if(int_num < IRQ_PIC_START)
    {
        return;
    }

    int_num -= IRQ_PIC_START;
    if(int_num < 8)
    {
        uint8_t mask = inb(PIC0_IMR) & ~ (1<<int_num);
        outb(PIC0_IMR, mask);
    }
    else
    {
        int_num -= 8;
        uint8_t mask= inb(PIC1_IMR) & ~ (1<<int_num);
        outb(PIC1_IMR, mask);
    }
}

//开启特定中断
void  int_close(int int_num)
{
    if(int_num < IRQ_PIC_START)
    {
        return;
    }

    int_num -= IRQ_PIC_START;
    if(int_num < 8)
    {
        uint8_t mask = inb(PIC0_IMR) | (1<<int_num);
        outb(PIC0_IMR, mask);
    }
    else
    {
        int_num -= 8;
        uint8_t mask= inb(PIC1_IMR)  | (1<<int_num);
        outb(PIC1_IMR, mask);
    }
}


//关闭全局中断
void close_global_int()
{
    cli();
}


//打开全局中断
void open_global_int()
{
    sti();
}

接下来我们需要完成对时钟的设置。


#define PIT_OSC_FREQ                1193182				// 定时器时钟基本时钟频率hz

// 定时器的寄存器和各项位配置
#define PIT_CHANNEL0_DATA_PORT       0x40              //数据端口
#define PIT_COMMAND_MODE_PORT        0x43              //命令端口

#define PIT_CHANNEL ( 0 << 6 )
#define PIT_LOAD_LOHI ( 3 << 4 )
#define PIT_MOOD3  (3 << 1)

static void init_pit()
{
    //1000ms/PIT_OSC_FREQ         周期:10ms
    //每个节拍 reload_count-- reload_count=0 产生中断
    uint32_t reload_count = PIT_OSC_FREQ * OS_TICKS_MS / 1000;      
    outb(PIT_COMMAND_MODE_PORT,PIT_CHANNEL | PIT_LOAD_LOHI | PIT_MOOD3);
    outb(PIT_CHANNEL0_DATA_PORT,reload_count & 0xff);
    outb(PIT_CHANNEL0_DATA_PORT,( reload_count >> 8 ) & 0xff);
    
    irq_install(IRQ0_TIMER,(uint32_t)exception_handler_time);

    //打开中断
    int_open(IRQ0_TIMER);
}

void time_init()
{
    sys_tick=0;
    init_pit();
}

 对于这里的代码不是很理解的同学可以参考这篇博客微机原理-可编程计数器/定时器 8253/8254及其应用_8253计时器初始化时。在方波发生器下处置是多少-CSDN博客

我们这里设置的是每10ms产生一次中断。接下来我们编写定时中断处理程序,这里我们简单处理,后续会加入进程切换的代码。

static uint32_t sys_tick;   

void do_handler_time(exception_information* frame)
{
    sys_tick++;
    pic_send_eoi(IRQ0_TIMER);
}


//发送EOI信号
void pic_send_eoi(int int_num)
{
    int_num -= IRQ_PIC_START;
    if(int_num >= 8)
    {
        outb(PIC1_OCW2,PIC0_OCW2_EOI);
    }

    outb(PIC0_OCW2,PIC0_OCW2_EOI);
}

现在我们已经完成了对定时中断的处理,本节的任务完成。在下节我们会进行引入多进程与进程的管理。

代码仓库

x86_os: 从零手写32位操作系统 (gitee.com)

 

  

 

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

knight-n

你的鼓励将是我创作的最大动力

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

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

打赏作者

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

抵扣说明:

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

余额充值