操作系统真相还原第九章————进程和线程

实现内核进程

执行流

我们将程序计数器中的下一条指令地址所组成的执行轨称为程序的控制执行流。执行流对应于代码,达到可以是整个程序文件,即进程,小到可以是一个功能独立的代码块,即函数,线程本质就是函数。

thread流程图

本人习惯不太好,中间的代码并没有备份,被覆盖掉了。。。抱歉
在这里插入图片描述

链表实现

list.c

#include "list.h"
#include "interrupt.h"
#include "string.h"
//初始化双向链表 
void list_init(struct list* list){
    list->head.prev = NULL;
    list->head.next = &list->tail;
    list->tail.prev = &list->head;
    list->tail.next = NULL ;
}
//把链表元素elem插入元素before之前
void list_insert_before(struct list_elem* before,struct list_elem* elem){
    enum intr_status old_status = intr_disable();
    
    before->prev->next = elem ;
    elem->prev = before->prev ;
    elem->next = before;

    before->prev = elem;
    
    intr_set_status(old_status);
}
//添加元素到列表队首,类似push操作
void list_push(struct list* plist,struct list_elem* elem){
    list_insert_before(plist->head.next,elem);
}
//追加元素到链表队尾
void list_append(struct list* plist,struct list_elem* elem){
    list_insert_before(&plist->tail,elem);
}
//使元素pelem脱离链表
void list_remove(struct list_elem* pelem){
    enum intr_status old_status = intr_disable();

    pelem->prev->next = pelem->next;
    pelem->next->prev = pelem->prev;

    intr_set_status(old_status);
}
//将链表第一个元素弹出并返回
struct list_elem* list_pop(struct list* plist){
    struct list_elem* elem = plist->head.next;
    list_remove(elem);
    return elem;
}
//从链表中查找元素obj_elem
bool elem_find(struct list* plist,struct list_elem* obj_elem){
    struct list_elem* elem = plist->head.next;
    while(elem != &plist->tail){
        if(elem == obj_elem){
            return true;
        }
        elem = elem->next;
    }
    return false;
}
/*把链表plist中的每一个元素elem和arg传给回调函数func
arg给func用来判断elem是否符合条件
本函数的功能是遍历列表的所有元素,逐个判断是否有符合条件的元素
找到符合条件的元素返回元素指针,否则返回NULL*/
struct list_elem* list_traversal(struct list* plist,function func,int arg){
    struct list_elem* elem = plist->head.next;
    if(list_empty(plist)){
        return NULL;
    }
    while(elem != &plist->tail){
        if(func(elem,arg)){
            //func返回true,则认为该元素在回调函数中符合条件,命中,故停止继续遍历
            return elem;
            //若回调函数func返回true,则继续遍历
        }
        return NULL;
    }
}
//返回链表长度
uint32_t list_len(struct list* plist){
    struct list_elem* elem = plist->head.next;
    uint32_t length=0;
    while(elem != &plist->tail){
        length++;
        elem = elem->next;
    }
    return length;
}
//判断链表是否为空,空时返回true,否则返回false
bool list_empty(struct list* plist){
    return (plist->head.next == &plist->tail ? true : false);
}

list.h

#ifndef __LIB_KERNEL_LIST_H
#define __LIB_KERNEL_LIST_H
#include "global.h"

#define offset(struct_type,member)(int)(&((struct_type*)0)->member)
#define elem2entry(struct_type,struct_member_name,elem_ptr) \
    (struct_type*)((int)elem_ptr - offset(struct_type,struct_member_name))

struct list_elem {
    struct list_elem* prev;
    struct list_elem* next;
};

struct list {
    struct list_elem head;
    struct list_elem tail;
};


typedef bool (function)(struct list_elem*,int arg);

void list_init(struct list*);
void list_insert_before(struct list_elem* before,struct list_elem* elem);
void list_push(struct list* plist,struct list_elem* elem);
void list_iterate(struct list* plist);
void list_append(struct list* plist,struct list_elem* elem);
void list_remove(struct list_elem* pelem);
struct list_elem* list_pop(struct list* plist);
bool list_empty(struct list* plist);
uint32_t list_len(struct list* plist);
struct list_elem* list_traversal(struct list* plist,function func,int arg);
bool elem_find(struct list* plist,struct list_elem* obj_elem);
#endif

多线程调度

在这里插入图片描述
interrupt.c

#include "interrupt.h"
#include "stdint.h"
#include "global.h"
#include "io.h"
#include "print.h"

#define PIC_M_CTRL 0x20
#define PIC_M_DATA 0x21
#define PIC_S_CTRL 0xa0
#define PIC_S_DATA 0xa1

#define IDT_DESC_CNT 0x21 //目前支持的中断数
/*中断门描述符结构体*/

#define EFLAGS_IF  0x00000200
#define GET_EFLAGS(EFLAG_VAR)   asm volatile("pushfl; popl %0":"=g"(EFLAG_VAR))
struct gate_desc{
    uint16_t func_offset_low_word;
    uint16_t selector;
    uint8_t  dcount;            //此项为双字计数字段,是门描述符中的第四字节
    //此项固定值
    uint8_t  attribute;         
    uint16_t func_offset_high_word;
};
//静态函数声明,非必须
static void make_idt_desc(struct gate_desc* p_gdesc,uint8_t attr,intr_handler function);
static struct gate_desc idt[IDT_DESC_CNT];//idt是中断描述符表
                                        //本质上就是个中断门描述符数组
intr_handler idt_table[IDT_DESC_CNT];//声明引用定义在kernel.S中的中断处理函数入口数组
char* intr_name[IDT_DESC_CNT];
//定义中断处理程序数组,在kernel.S中定义的intXXentry。
//只是中断处理程序的入口,最终调用的是ide_table中的处理程序
extern intr_handler intr_entry_table[IDT_DESC_CNT];
/*初始化可编程中断控制器 */
static void pic_init(void){
    outb(PIC_M_CTRL,0x11);      //ICW1:边沿触发,级联8259,需要ICW4
    outb(PIC_M_DATA,0x20);      //ICW2:初始中断向量号为0x20
    //也就是IR[0-7]为0x20~0x27
    outb(PIC_M_DATA,0x04);      //ICW3:IR2接从片
    outb(PIC_M_DATA,0x01);      //ICW4:8086模式,正常EOI

    outb(PIC_S_CTRL,0x11);      //ICW1:边沿触发,级联8259,需要ICW4
    outb(PIC_S_DATA,0x28);      //ICW2:起始中断向量号为0x28
                                //也就是IR[8-15]为0x28~0x2F 
    outb(PIC_S_DATA,0x02);      //ICW3:设置从片连接到主片的IR2的引脚
    outb(PIC_S_DATA,0x01);      //ICW4:8086模式,正常EOI
/*打开主片IR0,也就是目前只接受时钟产生的中断*/
    outb(PIC_M_DATA,0xfe);
    outb(PIC_S_DATA,0xff);

    put_str("pic_init done\n");
}
static void make_idt_desc(struct gate_desc* p_gdesc,uint8_t attr,intr_handler function){
    p_gdesc->func_offset_low_word=(uint32_t)function & 0x0000FFFF;
    p_gdesc->selector=SELECTOR_K_CODE;
    p_gdesc->dcount=0;
    p_gdesc->attribute=attr;
    p_gdesc->func_offset_high_word=((uint32_t)function &0xFFFF0000)>>16;
}
/* 初始化中断描述符表*/
static void idt_desc_init(void){
    int i;
    for(i=0;i<IDT_DESC_CNT;i++){
        make_idt_desc(&idt[i],IDT_DESC_ATTR_DPL0,intr_entry_table[i]);
    }
    put_str("idt_desc_init done \n");
}
static void general_intr_handler(uint8_t vec_nr){
    if(vec_nr==0x27||vec_nr==0x2f){
        //IRQ7和IRQ15会产生伪中断
        //0x2f是从从片8529A上的最后一个IRQ引脚
        return;
    }
    set_cursor(0);
    int cursor_pos=0;
    while(cursor_pos < 320){
        put_char(' ');
        cursor_pos++;
    }
    set_cursor(0);
    put_char('a');
    put_str("!!!!!!!!!!!         excetion message begin !!!!!!!!!!!!\n");
    set_cursor(88);
    put_str(intr_name[vec_nr]);
    if(vec_nr == 14){       //若为Pagefault,将缺失的地址打印出来并悬浮
        int page_fault_vaddr = 0;
        asm ("movl %%cr2,%0" : "=r" (page_fault_vaddr));
        //cr2是存放造成page_fault的地址
        put_str("\npage fault addr is ");
        put_int(page_fault_vaddr);
    }
    put_str("\n!!!!!!!         excetion message end     !!!!!!!\n");
    //能进入中断处理程序就表示已经处在关中断情况下
    //不会出现调度进程的情况,故下面的死循环不会在被中断
    while(1);
}
/* 
    完成一般中断处理函数注册及异常名称注册 
*/
static void exception_init(void){
    int i;
    for(i=0 ;i < IDT_DESC_CNT;i++){
    /*
        idt_table数组中的函数是在进入中断后根据中断向量号调用的
        见kernel/kernel.S的call[idt_table + %1*4]
    */
        idt_table[i]=general_intr_handler;
        //默认为general_intr_hander,以后哦会由register_hander来注册具体函数
        intr_name[i]="unknown";
    }
    intr_name[0]="#DE Divide Error";
    intr_name[1]="#DB Debug Exception";
    intr_name[2]="NMI Interrupt";
    intr_name[3]="#BP Breakpoint Exception";
    intr_name[4]="#OF Overflow Exception";
    intr_name[5]="#BR BOUND Range Exceeded Exception";
    intr_name[6]="#UD Invalid Opcode Exception";
    intr_name[7]="#NM Device Not Available Exception";
    intr_name[8]="#DF Double Fault Exception";
    intr_name[9]="Coprocessor Segment Overrun";
    intr_name[10]="#TS Invaild TSS Exception";
    intr_name[11]="#NP Segment Not Present";
    intr_name[12]="#SS Stack Fault Exception";
    intr_name[13]="#GP General Protection Exception";
    intr_name[14]="#PF Page-Fault Exception";
    // intr_name[15] 第15项是intel保留项,未使用
    intr_name[16]="#MF x87 FPU Floating-Point Error";
    intr_name[17]="#AC Alignment Check Exception";
    intr_name[18]="#MC Machine-Check Exception";
    intr_name[19]="#XF SIMD Floating-Point Exception";
}

/*完成有关中断的所以初始化工作*/
void idt_init(){
    put_str("idt_init start\n");
    idt_desc_init();        //初始化中断描述符表
    exception_init();
    pic_init(             );//初始化8259A
    /*加载idt*/
    uint64_t idt_operand=(sizeof(idt)-1)|((uint64_t)(uint32_t)idt << 16);
    asm volatile("lidt %0" :: "m"(idt_operand));
    put_str("idt_init done\n");
}

/*开中断并返回开中断前的状态*/
enum intr_status intr_enable(){
    enum intr_status old_status;
    if(INTR_ON == intr_get_status()){
        old_status = INTR_ON;
        return old_status;
    }else{
        old_status = INTR_OFF;
        asm volatile("sti");
        return old_status;
    }
}
/*关中断,并且返回关中断的状态*/
enum intr_status intr_disable(){
    enum intr_status old_status;
    if(INTR_ON == intr_get_status()){
        old_status = INTR_ON;
        asm volatile("cli":::"memory"); //关闭中断,cli指令将IF位置0
        return old_status;
    }
    old_status=INTR_OFF;
    return old_status;
}
/*将中断状态设置为status*/
enum intr_status intr_set_status(enum intr_status status){
    return status & INTR_ON ? intr_enable() : intr_disable();
}
/*获取当前中断状态*/
enum intr_status intr_get_status(){
    uint32_t eflags=0;
    GET_EFLAGS(eflags);
    return (EFLAGS_IF & eflags) ? INTR_ON : INTR_OFF ;
}
/*在中断处理程序数组第vector_no个元素中,注册安装中断处理程序function*/
void register_handler(uint8_t vector_no,intr_handler function){
    /*idt_table 数组中的函数是在进入中断后根据中断向量号调用的*/
    idt_table[vector_no] = function ;
}

thread.c

#include "thread.h"
#include "stdint.h"
#include "string.h"
#include "global.h"
#include "memory.h"
#include "interrupt.h"
#include "switch.h"
#define PG_SIZE 4096

struct task_struct* main_thread;    //主线程PCB 
struct list thread_ready_list;      //就绪队列
struct list thread_all_list;        //所有任务队列
static struct list_elem* thread_tag;    //用于保存队列中的线程节点

extern void switch_to(struct task_struct* cur,struct task_struct* next);

/*获取当前线程的pcb指针*/
struct task_struct* running_thread(){
    uint32_t esp;
    asm("mov %%esp,%0" : "=g"(esp));
    /*取esp整数部分,即pcb起始地址*/
    return (struct task_struct*)(esp & 0xfffff000);
}
/*由kernel_thread去执行function(func_arg)*/
static void kernel_thread(thread_func* function,void* func_arg)
/*执行前要开中断,避免后面的时钟中断被屏蔽,而无法调度其他线程*/
{
    intr_enable();
    function(func_arg);
}
/*初始化线程栈thread_stack,将待执行的函数和参数放到thread_stack中对应位置*/
void thread_create(struct task_struct* pthread,thread_func function,void* func_arg){
    /*先预留中断使用栈空间,可见thread.h定义的结构*/
    pthread->self_kstack -= sizeof(struct intr_stack);

    pthread->self_kstack -= sizeof(struct thread_stack);
    struct thread_stack* kthread_stack = (struct thread_stack*)pthread->self_kstack;
    kthread_stack->eip = kernel_thread;
    kthread_stack->function = function;
    kthread_stack->func_arg = func_arg;
    kthread_stack->ebp = kthread_stack->ebx = \
    kthread_stack->esi = kthread_stack->edi = 0;
}
/*初始化线程基本信息*/
void init_thread(struct task_struct* pthread,char* name,int prio){
    memset(pthread,0,sizeof(*pthread));
    strcpy(pthread->name,name);

    if(pthread == main_thread){
        //由于把main函数也封装成一个线程,并且它一直是执行的,故将其直接设为TASK_RUNNING
        pthread->status = TASK_RUNNING;
    }else
    {
        pthread->status = TASK_READY;
    }
    /*先预留中断使用栈空间,可见thread.h定义的结构*/
    pthread->self_kstack = (uint32_t*)((uint32_t)pthread + PG_SIZE);
    pthread->priority = prio ;
    pthread->ticks = prio;
    pthread->elapsed_ticks = 0;
    pthread->pgdir = NULL ;
    pthread->stack_magic = 0x19870916;
}
/*创建一优先级为prio的线程,线程名为name,线程名为name,
线程所执行的函数是function(func_arg)*/
struct task_struct* thread_start(char* name,\
                                    int prio, \
                                    thread_func function, \
                                    void *func_arg){
/*pcb都位于内核空间,包括用户进程的pcb也是在内核空间*/
    struct task_struct* thread = get_kernel_pages(1);
    init_thread(thread,name,prio);
    thread_create(thread,function,func_arg);

    ASSERT(!elem_find(&thread_ready_list,&thread->general_tag));

    list_append(&thread_ready_list,&thread->general_tag);

    ASSERT(!elem_find(&thread_all_list,&thread->all_list_tag));
    list_append(&thread_all_list,&thread->all_list_tag);
    return thread;

}
static void make_main_thread(void){
    main_thread = running_thread();
    init_thread(main_thread,"main",31);

    ASSERT(!elem_find(&thread_all_list,&main_thread->all_list_tag));
    list_append(&thread_all_list,&main_thread->all_list_tag);
}
void schedule(){
    ASSERT(intr_get_status()==INTR_OFF);

    struct task_struct* cur = running_thread();
    if(cur->status == TASK_RUNNING){
        //此线程只是cpu时间到了,将其加入就绪队尾
        ASSERT(!elem_find(&thread_ready_list,&cur->general_tag));
        list_append(&thread_ready_list,&cur->general_tag);
        cur->ticks = cur->priority;
        cur->status = TASK_READY;
    }else{
        /*此线程需要某事件发生后才能继续上CPU运行
        不需要将其加入队列,因为当前线程不在就绪队列中*/
    }
    ASSERT(!list_empty(&thread_ready_list));
    thread_tag = NULL;  //thread_tag清空 
    /*将thread_ready_list队列中的第一个就绪线程弹出,准备将其调度上CPU*/

    thread_tag = list_pop(&thread_ready_list);
    struct task_struct* next = elem2entry(struct task_struct,\
    general_tag, thread_tag);
    next->status = TASK_RUNNING;
    switch_to(cur,next);
}
void thread_init(void){
    put_str("thread_init start \n");
    list_init(&thread_ready_list);
    list_init(&thread_all_list);
    /*当前main函数创建为线程*/
    make_main_thread();
    put_str("thread_init done\n");
}

print.S

之前我是照着Love 6博主写的,发现有点不严谨,改动如下

  1. set_cursor 我们并没有入栈操作,所以后面不应该有popad
  2. 传参是通过栈传递的,我们需要将栈中参数放入ebx
  3. 为了方便我把set_cursor拎出来了
TI_GDT equ 0
RPL0 equ 0
SELECTOR_VIDEO equ (0x0003<<3)+TI_GDT+RPL0
SELECTOR_DATA	equ (0X0002<<3) + TI_GDT + RPL0
[bits 32]
section .data
put_int_buffer  dq  0
section .text
;打印以零结尾的字符串 
;输入:栈中参数为打印的字符串
global put_str
put_str:
   push ebx
   push ecx
   xor ecx,ecx
   mov ebx,[esp+12]
.goon:
   mov cl,[ebx]
   cmp cl,0
   jz .str_over
   push ecx
   call put_char
   add esp,4
   inc ebx
   jmp .goon
.str_over:
   pop ecx
   pop ebx
   ret 

;--------------------将小端字节序的数字变成对应ASCII后倒置 
;输入:栈中参数为待打印的数字
;输出:在屏幕上打印16位进制数字不会打印‘0x’
;打印十进制15时,打印f
;
global put_int
put_int:
   pushad
   mov ebp,esp
   mov eax,[ebp+4*9]       ;call返回地址占四字节+pushad的8字节 
   mov edx,eax
   mov edi,7               ;在put_int_buffer中的初始偏移量 
   mov ecx,8               ;32位数字中,16进制的数字位数为8位 
   mov ebx,put_int_buffer
;将32位数字按照16进制从低到高逐个处理 
;共处理8位16进制数字 
.16based_4bits:
   and edx,0x0000000F
   cmp edx,9
   jg .is_A2F
   add edx,'0'
   jmp .store 
.is_A2F:
   sub edx,10

   add edx,'A'
;每位数字转化为对应字符后,应该按照大端顺序存储
;存储到缓冲区put_int_buffer
.store:
   mov [ebx+edi],dl 
   dec edi 
   shr eax,4
   mov edx,eax
   loop .16based_4bits
;现在put_int_buffer中已经全是字符,打印前去掉高位的0 
.ready_to_print:
   inc edi
.skip_prefix_0:
   cmp edi,8

   je .full0
.go_on_skip:
   mov cl,[put_int_buffer+edi]
   inc edi
   cmp cl,'0'
   je .skip_prefix_0
   dec edi 
   jmp .put_each_num

.full0:
   mov cl,'0'
.put_each_num:
   push ecx
   call put_char
   add esp,4
   inc edi 
   mov cl,[put_int_buffer+edi]
   cmp edi,8
   jl .put_each_num
   popad
   ret
global put_char
;--------------------put_char----------------
;把栈中的一个字符写入光标所在处 
put_char:
   pushad  ;备份32位寄存器环境 
   ;需要保证gs中为正确的视频段选择子 
   ;为保险起见,每次打印时都为gs赋值
   mov ax,SELECTOR_VIDEO ;不能直接把立即数送入段寄存器
   mov gs,ax
;;;;;;;;;;;;;;;获取当前光标位置

   mov dx,0x03d4    ;索引寄存器
   mov al,0x0e       ;用于提供光标位置的高八位 
   out dx,al
   mov dx,0x03d5       ;通过读写数据端口获得或设置光标位置
   in al,dx           ;得到光标位置的高八位 
   shl ax,0x8
;低八位 
   mov dx,0x03d4
   mov al,0x0f
   out dx,al
   mov dx,0x03d5
   in al,dx
;将光标存入bx
   mov bx,ax
;栈中获取待打印字符 
   mov ecx,[esp + 36] ;pushad 压入8*4=32字节+主调函数的四字节返回地址

   cmp cl,0xd
   jz .is_carriage_return
   cmp cl,0xa
   jz .is_line_feed

   cmp cl,0x8 
   jz .is_backspace
   jmp .put_other
;;;;;;;;;;;;;;
;当为空格是,光标向前移动一格显存位置
;
;
.is_backspace:
   dec bx          ;光标左移动一位
   shl bx,1        ;表示光标对应现存中的偏移字节
   mov byte [gs:bx],0x20  ;待删除的字节补0
   inc bx
   mov byte [gs:bx],0x07
   shr bx,1
   jmp .cursor_set
;
.put_other:
   shl bx,1        ;光标位置用两字节表示 
   mov [gs:bx],cl  
   inc bx              
   mov byte [gs:bx],0x07   ;字符属性
   shr bx,1                ;恢复老的光标值 
   inc bx                  ;下一个光标值
   cmp bx,2000
   jl .cursor_set          ;若光标值小于2000表示未写到最后
                           ;若超出屏幕大大小则进行换行处理

.is_line_feed:               ;LF换行符
.is_carriage_return:         ;回车符CR 
;如果是CR,光标移动到行首
   xor dx,dx
   mov ax,bx
   mov si,80

   div si

   sub bx,dx

.is_carriage_return_end:
   add bx,80
   cmp bx,2000
.is_line_feed_end:
   jl .cursor_set

.roll_screen:
   cld
   mov ax,SELECTOR_DATA 	;我不放心 就初始化了一下
   mov es,ax      
   mov di,es                  
   mov ecx,920
   
   mov ecx,960

   mov esi,0xc00b80a0
   mov edi,0xc00b8000

   rep movsd

   mov ebx,3840
   mov ecx,80

.cls:
   mov word [gs:ebx],0x0720
   add ebx,2
   loop .cls 
   mov bx,1920
.cursor_set:
   mov dx,0x03d4
   mov al,0x0e
   out dx,al
   mov dx,0x03d5
   mov al,bh
   out dx,al
   
   mov dx,0x03d4
   mov al,0x0f
   out dx,al 
   mov dx,0x03d5
   mov al,bl
   out dx,al 
.put_char_done:
   popad
   ret 
global set_cursor
set_cursor:
   mov ebx,dword [esp + 4]
   mov dx,0x03d4
   mov al,0x0e
   out dx,al
   mov dx,0x03d5
   mov al,bh
   out dx,al
   
   mov dx,0x03d4
   mov al,0x0f
   out dx,al 
   mov dx,0x03d5
   mov al,bl
   out dx,al
   ret

switch.S

[bits 32]
section .text
global switch_to
switch_to:
    ;栈此处是返回地址
    push esi
    push edi
    push ebx
    push ebp

    mov eax,[esp + 20]  ;得到栈中的参数cur,cur=[esp + 20]
    mov [eax],esp       ;保存栈顶指针esp,task_struct的self_kstack字段
                        ;self_kstack在task_struct中的偏移为0 
                        ;所以直接往thread开头处存四字节 
;-------------------以上是备份当前线程的环境,下面是恢复下一个线程的环境-----------
    mov eax,[esp + 24]  ;得到栈中的参数next,next =[esp + 24]
    mov esp,[eax]       ;pcb的第一个成员是self_kstack成员 
    ;它用来记录0级栈顶指针,换上cpu时用来恢复0级栈 
    ;0级栈中保存了进程或线程所有信息,包括3级栈指针

    pop ebp
    pop ebx
    pop edi 
    pop esi 
    ret     ;返回上面switch_to下面的那句注释的返回地址 
            ;未由中断进入,第一次执行时会返回到kernel_thread

switch.h
为了方便我加了一个头文件

#ifndef __THREAD_SWITCH_H
#define __THREAD_SWITCH_H
void switch_to(struct task_struct* cur,struct task_struct* next);
#endif```

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值