哈工大操作系统实验2 操作系统的引导

哈工大操作系统实验2 操作系统的引导

第二个实验主要是操作系统系统引导相关的内容,涉及bootsect.s、setup.s的修改、其中的知识点:引导扇区、中断、读取磁盘内容、屏幕显示字符、获取硬件信息等。

从第二个实验,感觉难度就上来了,因为涉及到了操作系统一开始启动引导加载的部分,这块的知识如果不熟悉CPU 80x86和相关的汇编语言,之前我看清华大学的操作系统课程时,就是碰到这个问题,然后把汇编知识都补了起来。我看的一些书籍:

  • 王爽老式的《汇编语言》:汇编基础入门,以8086为主讲解实模式的汇编语言。
  • 李忠老师的《x86汇编语言:从实模式到保护模式》:讲解了8086实模式、80386保护模式,一开始实模式还好,后面保护模式难度陡增,需要一定的耐心。我也是边看边理解边整理笔记,最好全部弄懂了,可以查看我的专栏《x86汇编语言从实模式到保护模式札记》。
  • 赵炯博士的《Linux内核完全注释》:这个就是对Linux0.11注释,对了解Linux0.11整体设计思路很有帮助。

实验内容有5个,下面分享一下我自己完成实验内容的情况。实验中描述需要查看《Linux内核完全注释》第6章,实际上应该是第3章。

改写bootstrap.s中打印的信息

这个内容主要是考察引导扇区、打印信息等相关的知识。这里打印信息是使用中断的方式,关于中断相关的手册可以查看:

实验内容上的实现

实验内容中讲解了打印的知识点,并给出了完整的一个代码,将这个代码替换掉bootsect.s,编译后是可以直接运行的。

实验完整代码我加了一些注释,完整如下:

entry _start            ! 告诉编译器从这里开始运行
_start:
    mov ah,#0x03        ! 调用BIOS的10号中断读取光标位置,功能号ah=0x03,bh=0
    xor bh,bh           ! bh设置为0
    int 0x10
    mov cx,#36          ! 要打印的字符串长度为36
    mov bx,#0x0007      ! 要显示的字符属性,0x0007就是表示白底黑字
    mov bp,#msg1        ! 要打印的字符串的起始位置(段内偏移)
    mov ax,#0x07c0      ! 设置es为0x07c0,引导扇区被加载到内存的初始地址是0x7c00,段地址就是0x07c0,es:bp就是字符串的位置。
    mov es,ax       
    mov ax,#0x1301      ! 调用BIOS的10号中断打印字符串,写入字符后, 要移动光标到下一个位置。ah=13表示功能号、al=1表示字符属性使用bl的值、并且光标跟随移动。
    int 0x10                
inf_loop:               ! 无限循环,系统不会停机
    jmp inf_loop
msg1:                                           ! 定义要打印的字符串,加上回车、换行长度为36.byte   13,10                               ! 回车(13)、换行(10.ascii  "Hello OS world, my name is LZJ"    ! 两对回车(13)、换行(10.byte   13,10,13,10
.org 510                                        ! 引导扇区一共512字节。这句话表示下面语句从地址510(0x1FE)开始,所以0xAA55位于启动扇区的第510511字节中。
boot_flag:
    .word   0xAA55                              ! 引导扇区最后两个字节必须为55和AA。因为是采用低端字节序,所有高位AA的要写在前面,就是0xAA55了。
  1. 将实验代码复制到虚拟机:

图片

这里可以直接复制左侧的代码,复制到到虚拟机后,会有\r\n的字符,通过文本编辑器的替换功能,可以直接替换成换行,替换的字符串(\\r\\n -> \n)

  1. 实验内容中使用编译器和链接器处理成bootsect可执行位文件。

用 cd 命令进入 linux-0.11\boot,使用如下命令进行编译:

$ as86 -0 -a -o bootsect.o bootsect.s
$ ld86 -0 -s -o bootsect bootsect.o
  1. 通过dd工具移除32位的头,转成Image文件:
$ dd bs=1 if=bootsect of=Image skip=32

如下图:

图片

Minix 可执行文件头部 是什么 ?
MINIX,全称为Microkernel-based Operating System,是一种小型的类UNIX操作系统。 它最初由Andrew S. Tanenbaum于1987年开发,主要用于教学和科研领域。 MINIX的设计目标是提供一个简单、可扩展的操作系统,用于学习和研究操作系统的基本原理和工作机制。

  1. 最后运行这个Image文件。
# 当前的工作路径为 /home/shiyanlou/oslab/linux-0.11/boot/

# 将刚刚生成的 Image 复制到 linux-0.11 目录下
$ cp ./Image ../Image

# 执行 oslab 目录中的 run 脚本
$ ../../run

运行效果如下图:

图片

因为make工具本身就是对每个文件编译链接过程的的自动化处理,不用一个一个文件去编译。所以也可以直接进入linux0.11目录后用make all直接编译运行。

Linux0.11中修改

实验内容的实现是直接重新写了一个bootsect.s,能直接在Linux0.11基础上修改就更酷了。

落到Linux0.11中,修改的位置参考如下:

图片

字符串的长度:

图片

我把要显示的内容修改成了下面这样:

.ascii "Hello OS world, my name is CJB"	; 要打印的信息,总共个30字符。

加上换行和回车,长度总共为36:

mov	cx,#36	

编译运行:

图片

修改成功,虽然只是修改一个显示信息,但也是在一个伟大的操作系统上修改的,感觉挺棒的。_

用标号相减动态计算长度

每改一次字符串,就要修改长度,就很麻烦。如果修改了字符串,cx的长度能自己计算就好了。想起汇编中可以用标号相减的方式来得到中间内容的长度。于是我就尝试了一下,成功实现。

在msg1声明后这添加一个msg1_end的标号:

图片

长度修改为两个标号相减:

图片

最后编译运行,成功。运行的效果和上面的是一样的。就是在修改要显示字符串后,不用再修改长度了。

放一个logo

实验内容中有说可以尝试放入一个logo,网上找了一些资料,没有这方面的信息,不太了解怎么做。设想的思路是:图片一直加载到Image包里,用汇编读取logo图片的内容,根据图片的格式在页面上显示对应颜色的点,其实就是编写一些图片渲染器,在Linux0.11的环境下。

另外如果了解一些计算机图形学的内容,应该可以有一些思路。

bootsect 正确读入 setup

这个内容主要是考察如何载入其他程序。步骤分为三步:

  1. 编写setup.s程序,程序作用是打印一个信息。
  2. 修改bootsect.s程序,可以加载setup.s程序,并执行。
  3. 编译程序,需要修改build.c,移除arg[3]相关的代码。

编写setup.s

把bootsect.s拿来修改一下,就是setup.s程序了。我补充了一些注释:

entry _start            ! 告诉编译器从这里开始运行
_start:
    mov ah,#0x03        ! 调用BIOS的10号中断读取光标位置,功能号ah=0x03,bh=0
    xor bh,bh           ! bh设置为0
    int 0x10
    mov cx,#25          ! 要打印的字符串长度为25
    mov bx,#0x0007      ! 要显示的字符属性,0x0007就是表示白底黑字
    mov bp,#msg2        ! 要打印的字符串的起始位置(段内偏移)
    mov ax,cs           ! 设置es为代码段地址cs,当运行到setup.s时,cs就是这个代码的段地址,cs:bp就是字符串的位置。
    mov es,ax           ! es=cs,es:bp也就是字符串的位置。
    mov ax,#0x1301      ! 调用BIOS的10号中断打印字符串,写入字符后, 要移动光标到下一个位置。ah=13表示功能号、al=1表示字符属性使用bl的值、并且光标跟随移动。
    int 0x10                
inf_loop:               ! 无限循环,系统不会停机
    jmp inf_loop
msg2:                                           ! 定义要打印的字符串,加上回车、换行长度为36.byte   13,10                               ! 回车(13)、换行(10.ascii  "Now we are in setup"               ! 两对回车(13)、换行(10.byte   13,10,13,10
.org 510                                        ! 因为不是引导扇区,所以这句和下面一句可以没有,实验提供的代码有这两句。
boot_flag:
    .word   0xAA55

修改bootsect.s程序

bootsect.s加载setup.s,然后执行setup.s。

SETUPLEN=2              ! 表示setup.s程序占用的扇区数是2个
SETUPSEG=0x07e0         ! 表示setup.s程序加载到内存的段地址是0x07e0,内存的地址就是0x7e00。
entry _start            ! 告诉编译器从这里开始运行
_start:
    mov ah,#0x03        ! 调用BIOS的10号中断读取光标位置,功能号ah=0x03,bh=0
    xor bh,bh           ! bh设置为0
    int 0x10
    mov cx,#36          ! 要打印的字符串长度为36
    mov bx,#0x0007      ! 要显示的字符属性,0x0007就是表示白底黑字
    mov bp,#msg1        ! 要打印的字符串的起始位置(段内偏移)
    mov ax,#0x07c0      ! 设置es为0x07c0,引导扇区被加载到内存的初始地址是0x7c00,段地址就是0x07c0,es:bp就是字符串的位置。
    mov es,ax       
    mov ax,#0x1301      ! 调用BIOS的10号中断打印字符串,写入字符后, 要移动光标到下一个位置。ah=13表示功能号、al=1表示字符属性使用bl的值、并且光标跟随移动。
    int 0x10

! ----------------------- 这里就是新增加的内容 ----------------------- !
! 使用 BIOS 中断 INT 0x13 将 setup 模块从磁盘第 2 个扇区开始读到 0x7e00 开始处,共读 2 个扇区。如果读出错,则复位驱动器,并重试,没有退路。
! setup.s程序在内存中的位置就是紧跟在bootsect.s之后
load_setup:
    mov dx,#0x0000              ! 设置驱动器和磁头:软盘、磁头0
    mov cx,#0x0002              ! 设置扇区号和磁道: 磁道0、扇区2
    mov bx,#0x0200              ! 设置读入的内存地址:0x07c0*0x10+0x0200=0x7e000x07c0表示当前代码段地址、0x200表示bootsect.s占用空间。
    mov ax,#0x0200+SETUPLEN     ! ah=0x02读磁盘扇区到内存;al=SETUPLEN需要读取的扇区数
    int 0x13                    ! 调用 BIOS 中断 INT 0x13 读取内容。
    jnc ok_load_setup           ! 如果没有问题则跳转到ok_load_setup    
    mov dx,#0x0000              ! 有问题则执行到这里,这行和下面两行进行软驱复位
    mov ax,#0x0000              ! 功能号ah=0x00表示复位软驱
    int 0x13                    ! 调用BIOS 13号中断复位
    jmp load_setup              ! 跳转到load_setup继续尝试读取
ok_load_setup:                  ! 加载setup.s成功后,则跳转到setup.s执行
    jmpi    0,SETUPSEG          ! jmpi的格式:jmpi 段内偏移,段地址
! ----------------------- 这里就是新增加的内容end ----------------------- !

! 因为跳转到setup.s执行,这边无限循环就没有意义了,所以可以去掉。
! inf_loop:               ! 无限循环,系统不会停机
!    jmp inf_loop

msg1:                                           ! 定义要打印的字符串,加上回车、换行长度为36.byte   13,10                               ! 回车(13)、换行(10.ascii  "Hello OS world, my name is LZJ"    ! 两对回车(13)、换行(10.byte   13,10,13,10
.org 510                                        ! 引导扇区一共512字节。这句话表示下面语句从地址510(0x1FE)开始,所以0xAA55位于启动扇区的第510511字节中。
boot_flag:
    .word   0xAA55                              ! 引导扇区最后两个字节必须为55和AA。因为是采用低端字节序,所有高位AA的要写在前面,就是0xAA55了。

编译运行

这次要编译两个文件,通过make工具进行编译。

在 Ubuntu 下,进入 linux-0.11 目录后,使用下面命令(注意大小写):

# 当前的工作路径为 /home/shiyanlou/oslab/linux-0.11/

$ make BootImage

报错了:

图片

报错的原因是因为 make 根据 Makefile 的指引执行了 tools/build.c,它是为生成整个内核的镜像文件而设计的,没考虑我们只需要 bootsect.s 和 setup.s 的情况。

build.c 从命令行参数得到 bootsect、setup 和 system 内核的文件名,将三者做简单的整理后一起写入 Image。其中 system 是第三个参数(argv[3])。

  • 当 “make all” 或者 “makeall” 的时候,这个参数传过来的是正确的文件名,build.c 会打开它,将内容写入 Image。
  • 而 “make BootImage” 时,传过来的是字符串 “none”。所以,改造 build.c 的思路就是当 argv[3] 是"none"的时候,只写 bootsect 和 setup,忽略所有与 system 有关的工作,或者在该写 system 的位置都写上 “0”。

make BootImage这个命令的定义我查自己电脑上下载的Linux0.11源码中的Makefile文件的时候没有发现这个BootImage,后来在实验环境提供的源码中的Makefile看到了BootImage的定义,也是很囧哈。

实验环境提供的Makefile文件:

图片

我自己电脑上下载的Linux0.11源码,我的源码是从Linux官网下载的。

图片

知道原因后,解决办法就是将build.c中将arg[3]相关的部分注释掉即可。

图片

再次编译运行:

# 当前的工作路径为 /home/shiyanlou/oslab/linux-0.11/

# 编译
$ make BootImage

# 执行 oslab 目录中的 run 脚本
$ ../run

运行效果:

图片

这里涉及到了两个文件,使用make工具编译的好处就是可以一次性编译完成非常方便。

setup 获取硬件参数

setup.s中通过中断获取硬件参数,并将获得硬件参数放在内存的 0x90000 处。原版 setup.s 中已经完成了光标位置、内存大小、显存大小、显卡参数、第一和第二硬盘参数的保存。

实验中获取光标位置、内存大小和硬盘参数:

  • 用 ah=#0x03 调用 0x10 中断可以读出光标的位置;
  • 用 ah=#0x88 调用 0x15 中断可以读出内存的大小;
  • 磁盘参数表:
  • PC 机中 BIOS 设定的中断向量表中 int 0x41 的中断向量位置(4*0x41 = 0x0000:0x0104)存放的并不是中断程序的地址,而是第一个硬盘的基本参数表。
  • 第二个硬盘的基本参数表入口地址存于 int 0x46 中断向量位置处。

每个硬盘参数表有 16 个字节大小。下表给出了硬盘基本参数表的内容:

图片

实现代码:

mov    ax,#INITSEG   ! INITSEG=0x9000
! 设置 ds = 0x9000
mov    ds,ax
mov    ah,#0x03
! 读入光标位置
xor    bh,bh
! 调用 0x10 中断
int    0x10
! 将光标位置写入 0x90000.
mov    [0],dx       ! [0]相当于[ds:0],即0x90000位置,写入2个字节

! 读入内存大小位置
mov    ah,#0x88     ! 通过15号中断读取内存大小
int    0x15
mov    [2],ax       ! [2]相当于[ds:2],即0x90002位置,写入2个字节。

!0x41 处拷贝 16 个字节(磁盘参数表)ds:si -> es:di
mov    ax,#0x0000       ! 设置ds=0x0000
mov    ds,ax
lds    si,[4*0x41]      ! 设置si
mov    ax,#INITSEG
mov    es,ax            ! 设置es=0x9000
mov    di,#0x0004       ! 设置di 
mov    cx,#0x10         ! 重复0x10(16)! 重复16次
rep
movsb

setup 正确显示硬件参数

上一节获取的参数都是一些无符号整数,需要将其在屏幕上显示出来。

以十六进制方式显示比较简单。这是因为十六进制与二进制有很好的对应关系(每 4 位二进制数和 1 位十六进制数存在一一对应关系),显示时只需将原二进制数每 4 位划成一组,按组求对应的 ASCII 码送显示器即可。简单说的就是将无符号整数的每一位转成字符(ASCII码),进行显示。

ASCII 码与十六进制数字的对应关系为:

  • 0x30 ~ 0x39 对应数字 0 ~ 9,所以如果数字为09,只需加上0x30即可,即可得到字符09;
  • 0x41 ~ 0x46 对应数字 a ~ f,所以如果数字为af,只需加上0x37即可,即可得到字符af。

实现代码:

print_hex:      !16 进制方式打印栈顶的16位数
    mov cx,#4   ! 4 个十六进制数字
    mov dx,(bp) !(bp)所指的值放入 dx 中,如果 bp 是指向栈顶的话
print_digit:
    rol dx,#4       ! 循环以使低 4 比特用上 !! 取 dx 的高 4 比特移到低 4 比特处。就是将dx整体向左移动4位,然后高4位依次移入低4位,
    mov ax,#0xe0f   ! ah=0xe功能号表示要打印字符,al=0x0f8个字节。
    and al,dl       ! 取 dl 的低 4 比特值。
    add al,#0x30    ! 给 al 数字加上十六进制 0x30
    cmp al,#0x3a    ! 判断al是否大于等于0x3a,如果大于等于0x3a,那么al就是a~f。
    jl  outp        ! 是一个不大于十的数字
    add al,#0x07    ! 是a~f,要多加 7
outp:
    int 0x10
    loop print_digit
    ret
! 这里用到了一个 loop 指令;
! 每次执行 loop 指令,cx 减 1,然后判断 cx 是否等于 0! 如果不为 0 则转移到 loop 指令后的标号处,实现循环;
! 如果为0顺序执行。
!
! 另外还有一个非常相似的指令:rep 指令,
! 每次执行 rep 指令,cx 减 1,然后判断 cx 是否等于 0。
! 如果不为 0 则继续执行 rep 指令后的串操作指令,直到 cx 为 0,实现重复。


print_nl:   ! 打印回车换行
    mov ax,#0xe0d   ! CR
    int 0x10
    mov al,#0xa     ! LF
    int 0x10
    ret

关于上面例子数字转化为ASCII码,我画了一个图有助于理解。

图片

完整的获取硬件参数和显示代码如下:

INITSEG  = 0x9000
entry _start
_start:
! Print "NOW we are in SETUP"
    mov ah,#0x03        ! 读入光标位置
    xor bh,bh
    int 0x10
    mov cx,#25          ! "NOW we are in SETUP"字符串长度是19+3个换行+3个回车=25
    mov bx,#0x0007      ! bh=00表页码;bl=07表显示属性:0000 0111,即前景色(字体颜色)白色
    mov bp,#msg2        ! 指向要显示的字符串
    mov ax,cs
    mov es,ax           ! 显示字符串开始的地址:es:bp
    mov ax,#0x1301      ! write string, move cursor
    int 0x10

    mov ax,cs
    mov es,ax
! init ss:sp
    mov ax,#INITSEG
    mov ss,ax           ! 栈段0x9000
    mov sp,#0xFF00      ! 栈指针0xFF00

! Get Params,将内容存储到 0x90000x90020x9004~0x9022
    mov ax,#INITSEG
    mov ds,ax
    mov ah,#0x03
    xor bh,bh
    int 0x10            ! 获取光标位置,并存入0x9000处。
    mov [0],dx          
    mov ah,#0x88
    int 0x15            ! 读取扩展内存大小,并存入0x9002处。
    mov [2],ax
    mov ax,#0x0000      !0x41 处拷贝 16 个字节(磁盘参数表),0x9000:0x0004 = 0x0000:0x0104
    mov ds,ax           ! (es)x16 + (di) = (ds)*16 + (si)
    lds si,[4*0x41]     ! 因为CS:IP总共4个字节,每个中断号占用4个字节的空间。
    mov ax,#INITSEG     
    mov es,ax
    mov di,#0x0004
    mov cx,#0x10
    rep
    movsb

! Be Ready to Print
    mov ax,cs           ! es地址设置为当前代码段地址
    mov es,ax
    mov ax,#INITSEG     ! ds地址设置为0x9000,从这里开始输出
    mov ds,ax

! Cursor Position,输出当前光标位置信息
    mov ah,#0x03
    xor bh,bh
    int 0x10
    mov cx,#18          ! 一个回车+一个换行+"Cursor position:" 16个字符=18
    mov bx,#0x0007
    mov bp,#msg_cursor
    mov ax,#0x1301
    int 0x10
    mov dx,[0]          ! 输出光标位置信息,光标位置信息存储在0x90000~0x90001处,读入到dx中,后面print_hex函数都是从dx进行处理
    call    print_hex
! Memory Size,输出当前扩展内存参数
    mov ah,#0x03
    xor bh,bh
    int 0x10
    mov cx,#14          ! 一个回车+一个换行+"Memory Size:" 12个字符=14
    mov bx,#0x0007
    mov bp,#msg_memory
    mov ax,#0x1301
    int 0x10
    mov dx,[2]          ! 输出内存大小信息,光标位置信息存储在0x90000~0x90001处,读入到dx中,后面print_hex函数都是从dx进行处理
    call    print_hex
! Add KB,输出KB单位
    mov ah,#0x03
    xor bh,bh
    int 0x10
    mov cx,#2           ! "KB"2个字符=2,这里不换行。
    mov bx,#0x0007
    mov bp,#msg_kb
    mov ax,#0x1301
    int 0x10
! Cyles,输出柱面数
    mov ah,#0x03
    xor bh,bh
    int 0x10
    mov cx,#7
    mov bx,#0x0007
    mov bp,#msg_cyles
    mov ax,#0x1301
    int 0x10
    mov dx,[4]
    call    print_hex
! Heads,磁头数
    mov ah,#0x03
    xor bh,bh
    int 0x10
    mov cx,#8
    mov bx,#0x0007
    mov bp,#msg_heads
    mov ax,#0x1301
    int 0x10
    mov dx,[6]
    call    print_hex
! Secotrs,扇区数
    mov ah,#0x03
    xor bh,bh
    int 0x10
    mov cx,#10
    mov bx,#0x0007
    mov bp,#msg_sectors
    mov ax,#0x1301
    int 0x10
    mov dx,[12]
    call    print_hex

inf_loop:           ! 循环运行,操作系统都不退出的。
    jmp inf_loop

print_hex:          !打印16进制的数字,参数:dx表示要打印的数字。
    mov    cx,#4
print_digit:
    rol    dx,#4
    mov    ax,#0xe0f
    and    al,dl
    add    al,#0x30
    cmp    al,#0x3a
    jl     outp
    add    al,#0x07
outp:
    int    0x10
    loop   print_digit
    ret
print_nl:
    mov    ax,#0xe0d     ! CR
    int    0x10
    mov    al,#0xa     ! LF
    int    0x10
    ret

msg2:               
    .byte 13,10
    .ascii "NOW we are in SETUP"
    .byte 13,10,13,10
msg_cursor:
    .byte 13,10
    .ascii "Cursor position:"
msg_memory:
    .byte 13,10
    .ascii "Memory Size:"
msg_cyles:
    .byte 13,10
    .ascii "Cyls:"
msg_heads:
    .byte 13,10
    .ascii "Heads:"
msg_sectors:
    .byte 13,10
    .ascii "Sectors:"
msg_kb:
    .ascii "KB"

.org 510
boot_flag:
    .word 0xAA55

重新编译运行:

# 当前的工作路径为 /home/shiyanlou/oslab/linux-0.11/

# 编译
$ make BootImage

# 执行 oslab 目录中的 run 脚本
$ ../run

运行后查看效果:

图片

实验报告

有时,继承传统意味着别手蹩脚。x86 计算机为了向下兼容,导致启动过程比较复杂。请找出 x86 计算机启动过程中,被硬件强制,软件必须遵守的两个“多此一举”的步骤(多找几个也无妨),说说它们为什么多此一举,并设计更简洁的替代方案。

  1. 采用段地址和偏移地址寻址的方式。

因为早期8086CPU有20位地址总线,达到 1MB寻址能力。但是 8086CPU 是16位结构,表现出的寻址能力只有 64KB。8086CPU 采用一种在内部用两个16位地址合成的方法来形成一个20位的物理地址。就是:段地址*0x10+偏移地址 的方式。现在的CPU基本都是已经64位的,寻址能力为:2^64=160亿GB,远远超过现在的内存了。

改进的方式是:

  • 平坦模型(Flat Mode):在平坦模式下,将内存只分一个段,段地址为0,直接使用一个64位的线性地址进行寻址。这简化了内存管理,提高了程序的性能和可移植性;
  • 分页机制:现代x86处理器支持分页机制,通过页表将虚拟地址映射到物理地址。这提供了更好的内存保护和更高的内存使用效率。分页机制与平坦模式相结合,可以进一步简化内存管理并提高性能。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

晴空闲雲

感谢家人们的投喂

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

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

打赏作者

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

抵扣说明:

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

余额充值