1.加载用户程序

加载器与用户程序

使用加载器加载用户程序1. 第二个用户程序增加了中断

加载器

;此程序为MBR和加载器
;当前并能动态的知道操作系统哪块内存是空闲, 因此假设0x10000处是空闲的
;也不能动态的识别用户程序到底放在哪里,因此假设放在逻辑扇区号100的地方
;按道理说0x7c00+此扇区512字节 = 0x7e00. 即0000:7e00 后可以放用户程序
;但此程序中还需要用到栈, 栈段也放在此段中 0 ~ 0xffff, 虽然跟代码混在一起,但需要push的并不多.
;因此直接0xffff+1 的位置用来给用户程序

;分段读取用户程序
;由于用户程序可能超过65536字节,如果不改变段地址,偏移地址最多65536,因此无法完整读取
;所以下面采用改变段地址的方式来读取,每读一个扇区(512字节),段地址就增加0x20
;比如一开始段地址0x1000, 读取一次后, 把 0x1000+0x20 当成段地址来读取下个扇区,这样就不需要考虑偏移地址的问题

;用户程序的重定位问题
;每个SECTION在编译时候的段地址都是相对于文件首的偏移量(还会根据align来对齐)
;在加载到内存后, 段地址需要根据加载位置(当前是0x10000物理地址,即0x1000:0000)来重新计算
;比如当前的加载位置是0x1000:0x0000 
    ;假设用户section header 原段地址:0x00   ,  加载后段地址:0x1000 + 0x0000
    ;假设用户section data   原段地址:0x20   ,  加载后段地址:0x1000 + 0x20
    ;假设用户section code   原段地址:0x30   ,  加载后段地址:0x1000 + 0x30... 以此类推
;在计算完实际内存段地址后, 需要把原来的文件内段地址 修改成 内存段地址, 以便用户程序能找到实际段地址, 即需要修改用户头部


;假设用户程序放在逻辑扇区号100的地方
USER_APP_LBA EQU 100

;MBR会由BIOS自动加载到0x7c00处
;vstart=0x7c00 代码里的偏移就不需要额外+0x7c00
SECTION mbr align=16 vstart=0x7c00

    ;计算用户程序的段地址
    mov ax,[cs:USER_APP_BASE]   
    mov dx,[cs:USER_APP_BASE + 2]
    mov bx,16
    div bx
    mov ds,ax                   ;获取用户程序固定的段地址,也就是必然会加载到这个位置


    ;设置ss,sp , 假设栈段在 0x0 ~ 0xffff , 即栈顶的第一个元素是 0:0xfffe
    xor ax,ax
    mov ss,ax
    mov sp,ax


    ;先读取用户程序头部,用于分析程序需要读取几个扇区,对应着用户程序头部的字节数
    ;读取硬盘是以一个扇区来读取的, 一个扇区512字节
    ; read_sector 参数: 逻辑扇区16高位,逻辑扇区低16位, 目标段地址, 目标偏移地址
    ;参数从右往左入栈 ,相当于 read_sector(0,USER_APP_LBA,ds,0)

    push word 0                 ;目标偏移地址
    push ds                     ;目标段地址
    push word USER_APP_LBA      ;扇区低16位
    push word 0                 ;扇区高16位,只有高12位有效, LBA只有28位
    call read_sector            ;push ip ; jmp read_sector
    add sp,8            ;恢复栈 , 2*4字节


    ;ds : 0x1000
    ;根据用户程序头部的字节数算出一共需要读取几个扇区
    ;由于读取硬盘都是以一个扇区(512字节)为单位 . 如果程序长度为513字节,则需要读取2个扇区

    mov ax,[ds:0]       ;读取用户程序头部user_app_len的低16位
    mov dx,[ds:2]       ;user_app_len 高16位
    mov cx,512    
    div cx
    cmp dx,0            ;余数如果不为0,则扇区需要额外+1,但由于上面已经读过一个扇区,因此不需要加
    jnz .check_left_sector
    dec ax              ;余数=0, 则-1扇区数, 之前已经先过一个扇区

    .check_left_sector:
        cmp ax, 0       ;判断剩余扇区数量是否为0, 如果为0则说明读完
        jmp .read_done

        ;ax != 0 读取剩余扇区
        mov cx, ax              ;cx剩余扇区数量
		mov ax,ds
        mov es,ax               ;es 用于修改段地址
        mov si,USER_APP_LBA     ;此扇区号已经在上面读取过

    ;读取剩余扇区,每次把段地址+0x20(相当于实际增加512字节), 不需要考虑偏移地址的问题了
    .read_left_sector:
        mov ax,es
        add ax,0x20
        mov es,ax       ;使用新的段地址来读取
        inc si          ;从下一个扇区号开始读取

        push word 0     ;每一次的目标偏移都从0 , 因为已经从段地址增加了0x20
        push es         ;段地址
        push si         ;扇区低16位
        push word 0     ;扇区高16位
        call read_sector
        add sp,8        ;恢复栈
        loop .read_left_sector

    ;全部读完
    ;用户程序段的重定位
    ;ds 相当指向加载首地址: 0x1000
    ;把用户程序内的原文件内段地址修改成实际内存段地址
    .read_done:
        mov cx, [ds:0x1e]               ;获取重定位表数量
        mov bx, 0x0a               ;获取重定位表起始地址

        ;循环重定位, 替换成实际内存段地址
        .realloc_table:
            mov ax,[ds:bx]              ;文件内段低位
            mov dx,[ds:bx + 2]          ;文件内段高位
            push dx
            push ax
            call convert_fileSectionAddr    ;把文件内32位段地址转成加载后的内存逻辑段地址
            add sp,4
            mov [ds:bx],ax              ;把段地址写回用户程序
            mov word [ds:bx + 2],0      ;高位清0即可
            add bx,4
        loop .realloc_table

        ;入口段地址重定位, 以便跳转到用户程序
        push word [ds:0x08]
        push word [ds:0x06]            
        call convert_fileSectionAddr
        add sp,4
        mov word [ds:0x08],0
        mov word [ds:0x06],ax
    
        
        
    ;在当前栈中存放当前MBR的 ds, cs, 返回地址.   用于返回
    push ds
    push cs
    push word exit

    ;把当前栈写入用户程序,用于切换栈,返回
    mov word [ds:0x20],sp
    mov word [ds:0x22],ss
    
        
    ;跳转到用户程序
    jmp far [ds:0x04]       ;0x04 偏移 , 0x06 段地址


    exit:
    ;返回当前程序
    pop ds          ;恢复MBR的ds
    jmp $



;=====================================================================
;把文件内的32位段地址转成16位逻辑段地址
;参数: 低16位段地址, 高16位段地址
;返回: ax 16位逻辑段地址

;32位段地址中只有低20位有效
convert_fileSectionAddr:
    push bp
    mov bp,sp
    push dx
    
    mov ax,[bp + 4]  ;低16位段地址
    mov dx,[bp + 6]  ;高16位段地址
    add ax,[cs:USER_APP_BASE]   ;低位与低位相加
    adc dx,[cs:USER_APP_BASE+2] ;高位相加


    shr ax,4         ;低位除以16
    shl dx,12        ;高16位中只有低4位为有效位
    and dx,0xf000    ;确保只有高4位有效
    or ax,dx         ;合并

    
    pop dx
    mov sp,bp
    pop bp
    ret


;===================================================================================
;读取一个扇区
;参数 : 逻辑扇区16高位,逻辑扇区低16位, 目标段地址, 目标偏移地址

;需要用到的端口号 0x1f2 ~ 0x1f7 , 0x1f0
;端口 0x1f2 = 设置扇区数量
;LBA28有28位 扇区号
;0x1f3 ~ 0x1f6 设置逻辑扇区号, 这些端口都是8位端口,因此只能传送一个字节来满足28位扇区号
;0x1f6端口只有低4位是端口号,高前3位111表示LBA模式,后1位表示主盘(0)从盘(1)
;0x1f7 用于读写命令,以及读取硬盘状态
;一般情况下先检测0x1f7的状态,检测第7位(是否繁忙)和第3位(准备交换数据)的状态
;即检测 : 1000_1000b  与 0x1f7读取回来的状态, 如果 1000_1000b and 状态 = 0000_1000b 则可以读写
;0x1f0用于读写数据, 这是一个16位端口

read_sector:
    push bp
    mov bp,sp           ;bp + 2 是IP的位置, bp + 4 是第一个参数
    push ds
    push bx
    push si
    push di
    push ax
    push dx
    push cx

    mov di,[bp + 4]     ;扇区高16位
    mov si,[bp + 6]     ;扇区低16位
    mov ax,[bp + 8]     ;目标段地址
    mov ds,ax
    mov bx,[bp + 10]    ;目标偏移地址

    mov dx, 0x1f2       ;设置扇区数量
    mov al,1
    out dx,al           

;设置扇区号
    inc dx              ;0x1f3
    mov ax,si           ;扇区低16位
    out dx,al           ;由于是8位端口, 先把al的传送过去

    inc dx              ;0x1f4
    mov al,ah
    out dx,al           ;低16位中的高8位

    inc dx              ;0x1f5
    mov ax,di
    out dx,al           ;高16位中的低8位

    inc dx              ;0x1f6
    mov al,0xe0         ;LBA模式,从主盘读取 1110_0000b
    or al,ah            ;ah中剩余的高4位
    out dx,al

    inc dx             ;0x1f7
;设置0x1f7 用于读取硬盘
    mov al,0x20          
    out dx,al

;读取0x1f7, 查看当前硬盘状态
    .read_disk_status:
        in al,dx            ;8位端口, 读取当前状态
        and al, 1000_1000b  ;检测是否可以读取硬盘了
        cmp al,0x08         ;如果状态是 0000_1000b则可以读取了
        jnz .read_disk_status

;开始读取硬盘, 端口0x1f0
    mov dx,0x1f0
    mov cx,256          ;循环256次,一次读取2个字节

    .begin_read:
        in ax,dx        
        mov [ds:bx], ax     ;把读取的2个字节复制到ds:bx处
        add bx,2
        loop .begin_read


    pop cx
    pop dx
    pop ax
    pop di
    pop si
    pop bx
    pop ds
    mov sp,bp
    pop bp
    ret
    



;假设用户程序放在这个内存地址
USER_APP_BASE dd 0x10000

times 510-($-$$) db 0
;0x55,0xaa 为MBR固定标识
dw 0xaa55

用户程序1

	;当前是用户程序
;建立一个头部
;头部内的各种信息都是加载器需要的
;align=16 此段以16字节对齐, vstart=0 段内的偏移地址以0开始, 不写vstart比如最后一段,则段内标号以文件首开始算
;SECTION 用于把各自的数据分开便于代码管理. 如果你喜欢把数据,栈和用户指令混在一起也OK,只要你自己能分清


;头部, 用来给加载器足够的信息 来加载此程序
SECTION user_header align=16 vstart=0
    ;当前程序的字节数, 用于加载器来识别需要读取几个扇区
    ;dd 4个字节,  用户程序可能会超过2^16个字节

    user_app_len dd pro_end         ;0x0 对应user_header的偏移

    ;程序入口, 以便加载器完成后,jmp到此程序
    ;start是偏移地址
    ;section.user_code.start 获取段地址, 用法:seciton.某个段.start
    ;段地址使用dd 是因为 此段地址是文件中的汇编地址, 并非内存中的
    ;比如当前程序非常长,假设此代码段的位置超出65536字节, dw 就无法保存了
    user_app_entry dw start         ;0x04
                   dd  section.user_code.start  ;0x06

    
    ;重定位表
    ;由于这些段地址都是文件内的段地址,当加载器读取到内存后,需要重新计算每个段在内存中的位置
    ;这样才能正确访问到每个段的具体地址
    ;因此需要先给出一张表来写入当前文件中的段地址
    ;由于user_end 这个段在程序中不需要访问,用不到,因此不需要去重定位
    ;再次强调, 重定位的目的是为了在程序运行中能访问这些段
    realloc_table_start:
        s_user_code dd section.user_code.start    ;0x0a
        s_user_code_2 dd section.user_code_2.start ;0x0e
        s_user_data dd section.user_data.start      ;0x12
        s_user_stack dd section.user_stack.start    ;0x16
        s_user_header dd section.user_header.start  ;0x1a
    realloc_table_end:

    ;重定位表项数量
    ;每个重定位的段占用4个字节
    realloc_table_len dw (realloc_table_end - realloc_table_start ) / 4 ;0x1e

    ;保存加载器的stack , 偏移(sp) : 段地址(ss)
    mbr_stack dw 0,0                                ;0x20,0x22

user_header_end:


;代码段
SECTION user_code align=16 vstart=0

    ;入口
    start:
    ;当跳转进来的时候, ds 指向 0x1000:0000, ss 指向 mbr 的栈, 因此要把ds,es,ss 全部设置为当前程序自己的
    ;自己的栈, 数据段都在头部段中, 已经由加载器修改完成了

    ;注意, 当前ds指向的是自己的头部段, 也就是0x1000
    mov ax,[ds:s_user_stack]    ;自己的栈
    mov ss,ax
    mov sp,user_stack_top       ;栈顶

    mov ax, [ds:s_user_header]  ;自己头部
    mov es,ax

    
    mov ax,[ds:s_user_data]     ;自己的数据段
    mov ds,ax

    

    ;显示自己数据段的一条信息
    push ds
    push data_msg
    call show_msg
    add sp,4


    ;段间跳转: jmp user_code_2 : start_2
    ;利用retf , 相当于 pop ip , pop cs
    push word [es:s_user_code_2]          ;段地址
    push word start_2                     ;偏移地址
    retf
    
    ;用户程序退出,返回到MBR中
    ;切换到MBR的栈, MBR栈中存放ip,cs 用于返回
    user_code_exit:
    mov ax,[es:mbr_stack]
    mov bx,[es:mbr_stack + 2]
    mov ss, bx
    mov sp,ax
    retf

    

;显示字符串, 需以0结尾
;参数: 字符串偏移地址, 字符串段地址
show_msg:
    push bp
    mov bp, sp
    push bx
    push es
    push ds
    push ax
    push si

    mov bx,[bp + 6]     ;段地址
    mov ds,bx

    mov bx, [bp + 4]    ;偏移地址
	
	mov ax,0xb800
    mov es,ax       ;显存首地址
    xor si,si
    
    .show_msg_loop:
        xor ax,ax
        mov al,[ds:bx]  ;检测是否是0
        or al,al         ; 自己or自己 => 自己, 由此来判断是否是0 , 以及显示此字符
        jz .show_msg_done
        mov [es:si],al
        mov byte [es:si + 1],0x07    ;添加字符属性
        inc bx
        add si,2
        jmp .show_msg_loop

    .show_msg_done:
    pop si
    pop ax
    pop ds
    pop es
    pop bx
    mov sp,bp
    pop bp
    ret


user_code_end:

;第二个代码段
SECTION user_code_2 align=16 vstart=0

    start_2:
        push word [es:s_user_code]      ;user_code 段地址
        push user_code_exit                  ;user_code 偏移
        retf                            ;pop ip , pop cs

user_code_2_end:


;数据段
SECTION user_data align=16 vstart=0
    data_msg db 'fuckme',0
user_data_end:

;栈段
SECTION user_stack align=16 vstart=0
    resw 128        ;resb 256           ;保留字节数 , res(b)字节 , res(w)字 , res(d)双字
user_stack_top:

;此段没有vstart=0. 因此标号pro_end从文件首开始计算
SECTION user_end align=16
pro_end:

用户程序2

	;用户中断程序
;默认加载器为之前的mbr_loader.asm, 本程序还是被默认加载到0x10000, 扇区号100

;重写 int 9 中断程序
;原 int 9 中断用于接受键盘字符并产生对应的acsii码,存入键盘缓冲区
;新的int 9 中断程序在 user_code_interrupt 段中
;程序使用了bios提供的0x10中断的子程序0xe(显示字符) , 0x16中断的子程序0x00(从键盘缓冲区获取一个字符)
;9号中断和0x16号中断是一对, 9号往键盘缓冲区里存放字符, 0x16获取字符
;下面的程序相当于在9号中断拦了一下

;--------------------中断过程----------------------
;中断码对应中断向量表的每个一个入口地址,中断向量表从0000:0000 ~ 0000:3ff 共1024个字节
;一个中断码对应一个表项(入口), 一个表项占用4个字节:偏移:段地址, (低地址)偏移 , (高地址)段地址, 最多可以有256个中断
;简单来说内存布局: 
    ;0号中断 -对应-> [0x0000:0x0000]偏移地址,[0x0000:0x0002]段地址
    ;1号中断 -对应-> [0x0000:0x0004]偏移地址,[0x0000:0x0006]段地址
    ;2号中断 -对应-> [0x0000:0x0008]偏移地址,[0x0000:0x000a]段地址
;因此可以根据中断号(码) 来获取对应的入口地址 :
;中断码1的入口地址存放在 : 偏移地址:1 * 4 , 段地址: 1*4 +2
;中断码N的入口地址 : 偏移地址 N * 4 , 段地址 N*4 + 2

;中断过程, 比如中断码N发生中断
;1.pushf        标志寄存器入栈
;2.TF=0,IF=0    TF是单步中断(1是执行单步中断,0是否定),IF是屏蔽中断(0是屏蔽中断,1是接受中断,因为当前已经中断)
;3.push cs , push ip
;4.根据中断码N,跳转到对应的入口地址, 相当于 ip=N*4, cs=N*4+2

;中断返回:
;中断程序执行完后需要返回到原程序中, 使用 : iret (interrupt return)
;iret执行的伪指令是: pop ip, pop cs, popf (弹出标志寄存器)

;新的int 9中由于接管了原int 9, 但只是把字符拿出来看看, 具体工作还要原int 9去做
;因此新int 9还需要调用原int 9, 即需要把原int 9的地址保存起来
;由于新int 9已经是一个中断程序 ,  9 *4 , 9*4+2 的地址已经被替换成自己的了
;无法再去int 9 调用原中断,因此需要模拟 int 中断调用
;模拟顺序 : pushf , 把TF IF置0 , push cs ,push ip 
;把这些步骤简化 : pushf , tf if =0 , call far [原段地址:原偏移]
;------------------------------------------

;此程序被加载到的逻辑段地址
USER_APP_LOADED_SECTION_ADDR EQU 0x1000

;用户头
;具体注释在user_app.asm 中已经写了,这边省略
section user_header vstart=0 align=16
    user_app_len dd  pro_len    ;程序字节数 0x00
    
    ;入口
    user_app_entry dw start     ;偏移地址   0x04
                   dd section.user_code.start   ;文件段地址 0x06
    
    ;重定位表
    realloc_table_start:
            s_user_code dd section.user_code.start  ;0x0a
            s_user_data dd section.user_data.start  ;0x0e
            s_user_stack dd section.user_stack.start    ;0x12
            s_user_header dd section.user_header.start  ;0x16

            ;中断程序在此代码段中
            s_user_code_interrupt dd section.user_code_interrupt.start  ;0x1a
    realloc_table_end:

    ;重定位表长度
    realloc_table_len dw (realloc_table_end - realloc_table_start)/4 ;0x1e

    ;保存mbr栈,用于返回
    mbr_stack dw 0,0        ;0x20,0x22

user_header_end:


;代码段
section user_code vstart=0 align=16

    ;进入后, ds 指向当前的头部 0x1000:0000 
    ;需要修改ss,ds,es.
    start:
        ;修改成自己的栈
        mov ax, [ds:s_user_stack]
        mov ss,ax
        mov sp,user_stack_top

        ;es指向自己的头部
        mov ax,[ds:s_user_header]
        mov es,ax

        ;ds指向自己的数据段
        mov ax,[ds:s_user_data]
        mov ds,ax

        ;================================
        ;安装新的int 9 中断程序
        ;int 9 替换为user_code_interrupt段中的程序
        
        ;屏蔽中断 ,以防在替换中断向量表的时候有中断
        cli
        mov bx,[es:s_user_code_interrupt]   ; 中断程序的段地址
        push es

        mov ax,0 
        mov es,ax               ;es段指向0 .  中断向量表在0x0000:0x0000 ~ 0x03ff

        ;保存原int 9的段和偏移, 在新int 9中还需要调用原int 9
        push word [es:9*4]     
        pop word [ds:int9_addr]         ;保存偏移地址
        push word [es:9*4 + 2]
        pop word [ds:int9_addr + 2]     ;保存段地址

        ;把原int 9的入口替换成自己的
        mov word [es:9*4], int_start         ;替换偏移
        mov word [es:9*4+2], bx              ;替换段地址

        pop es
        sti

        ;================================

        ;show_msg使用中断号来显示字符
        push ds
        push welcome_msg
        call show_msg
        add sp,4

        ;下面使用0x16号中断的0x00号子程序从键盘缓冲区中获取一个字符
        .recv_keys:
            mov ah,0x00         ;参数
            int 0x16            ;使用16号中断, 参数ah:0x00
            cmp ah,0x39           ;如果是空格键0x39则退出
            jz .user_code_exit

            mov ah,0x0e         ;参数
            mov bl,0x07         ;字符属性
            int 0x10            ;参数al是从0x16接受回来的acsii
            jmp .recv_keys


    .user_code_exit:
        ;还原int 9中断入口
        cli
        push es
        mov ax,0
        mov es,ax

        push word [ds:int9_addr]
        pop word [es:9*4]           ;还原偏移

        push word [ds:int9_addr]
        pop word [es:9*4 + 2]       ;还原段地址
        
        pop es
        sti


        mov ax,[es:mbr_stack]   ;偏移
        mov bx,[es:mbr_stack + 2] ;段地址
        mov ss,bx
        mov sp,ax
        retf


;使用bios中断[中断号为0x10]来显示信息字符
;0x10中断号的0x0e 功能用于显示 :
    ;ah指定功能号:0x0e , al:要显示的字符,bl:字符属性
;参数: 偏移, 段地址
show_msg:
    push bp
    mov bp,sp
    push bx
    push ds
    push ax
    push si

    
    mov si,[bp + 6] ;段地址
    mov ds,si
    mov si,[bp + 4] ;偏移

    mov ah,0x0e     ;指定功能号0x0e 用于显示字符
    mov bl,0x07     ;字符属性
    .show_msg_loop:
        mov al,[ds:si]
        cmp al,0
        jz .show_msg_done

        int 0x10        ;调用0x10中断
        inc si
        jmp .show_msg_loop


    .show_msg_done:
    pop si
    pop ax
    pop ds
    pop bx
    mov sp,bp
    pop bp
    ret



user_code_end:

;数据段
section user_data vstart=0 align=16
    welcome_msg db 'welcome',0

    ;存放原int9的偏移,段地址
    int9_addr dw 0,0    ; 偏移 , 段地址

user_data_end:

;新的int 9 中断代码段
section user_code_interrupt vstart=0 align=16
    ;中断入口
    int_start:
    push es
    push ax
    push bx
    push cx

    ;新的int 9 中断从端口0x60中读取一个字符
    ;如果是esc键,则替换屏幕颜色. esc的扫描码是0x1

    ;从端口读取字符
    in al,0x60
    mov bl,al

    ;调用原int 9去处理, 此时已经在中断中,因此模拟 int 9的调用过程
    ;pushf , tf if =0 , call far 
    pushf

    ;由于在中断的时候, tf,if 已经设置成0了, 因此这步可以省略
    ;把tf,if =0, tf,if在第9和第8位
    ;修改标志寄存器
    ;pushf
    ;pop ax
    ;and ah,1111_1100b
    ;push ax
    ;popf

    ;检查es是否还指向自己的头部段, 需要通过es:s_user_data 获取自己的数据段, 原int 9的地址放在这里
    mov ax,es
    cmp ax,USER_APP_LOADED_SECTION_ADDR
    je .get_user_data
    mov ax,USER_APP_LOADED_SECTION_ADDR
    mov es,ax

    .get_user_data:
        mov ax,[es:s_user_data]
        mov es,ax               ; es 指向自己的数据段

    ;调用原int 9
    call far [es:int9_addr]

    ;是否是esc键
    cmp bl,0x01
    jne .int_done

    mov ax,0xb800
    mov es,ax       ;指向显存
    mov bx,1
    mov cx,2000
    ;改变2000个字符属性
    .change_color:
        inc byte [es:bx]
        add bx,2
        loop .change_color


    
    .int_done:
    pop cx
    pop bx
    pop ax
    pop es

    iret
user_code_interrupt_end:

;栈段
section user_stack vstart=0 align=16
    resb 256
user_stack_top:

section user_end align=16
pro_len:
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值