操作系统真相还原——第7章 中断

  1. 中断:CPU 暂停正在执行的程序,转而去执行处理该事件的程序,当这段程序执行完毕后, CPU 继续执行刚才的程序。

  2. 通常,中断牺牲的是个体的时间,但可以实现多设备的并发,从而提高系统效率

  3. 操作系统是一个死循环,中断发生后会执行相应的中断处理程序,中断执行程序分为

    • 上半部:在关中断不被打扰下执行,通常是需要立即执行的程序
    • 下半部:在开中断下执行的,如果有新的中断发生,正在执行的中断会被换下
  4. 外部中断:来自CPU外部的硬件产生的中断,又称为硬件中断

  5. CPU提供统一的接口作为中断信号的公共线路,主要是两条信号线构成

    • INTR:该引脚收到的中断都是不影响系统运行的,可屏蔽
    • NMI:系统致命错误,通常是硬件级别的。如:内存读写错误、电源掉电、总线奇偶校验错误
  6. 内部中断

    • 软中断:由软件主动发起的中断
    • 异常:指令执行期间CPU 内部产生的错误引起的。
  7. gdb 调试程序原理

    • 调试器fork 了一个子进程,子进程用于运行被调试的程序。调试器中经常要设置断点,其原理就是父进程修改了子进程的指令,将其用int3指令替换,从而子进程调用了int3 指令触发中断
    • 断点本质上是指令的地址,调试器(父进程〉将被调试进程(子进程〉断点起始地址的第1 个字节备份好之后,原地将该指令的第1 字节修改为0xcc。这样指令执行到断点处时,会去执行机器码为0xcc 的int3 指令,该指令会触发3 号中断,从而会去执行3 号中断对应的中断处理程序
    • 将当前的寄存器和相关内存单元压栈保存,用户在查看寄存器和变量时就是从栈中获取
    • 当恢复执行所调试的进程时,中断处理程序需要将之前备份的1字节还原至断点处,然后恢复各寄存器和内存单元的值,修改返回地址为断点地址,用iret指令退出中断,返回到用户进程继续执行。
  8. 一般只要影响系统正常运行的中断都可以无视eglag中的IF位,比如

    • 导致运行错误的中断类型都会无视IF位,如NMI 、异常。
    • 由于int n 型的软中断用于实现系统调用功能,不能因为IF位为0 就不顾用户请求,所以
      为了用户功能正常,软中断必须也无视IF 位
  9. 根据异常的轻重程度

    • Fault,称为故障。当发生此类异常时CPU 将机器状态恢复到异常之前的状态,之后调用中断处理程序时,CPU将返回地址依然指向导致fault 异常的那条指令。通常中断处理程序中会将此问题修复,待中断处理程序返回后便能重试。eg:缺页异常
    • Trap,称为陷阱。软件掉进了CPU设置的陷阱中。
    • Abort,称为终止。一旦出现程序将无法运行,只能杀掉出错的进程,进行系统自保
  10. 中断机制的本质是来了一个中断信号后,调用相应的中断处理程序。中断向量号是一个整数,范围是0~255

  11. 异常和不可屏蔽中断的中断向量号是由CPU 自动提供的,而来自外部设备的可屏蔽中断号是由中断代理提供的(咱们这里的中断代理是8259A ),软中断是由软件提供的。

  12. 中断描述符表IDT是保护模式下用于存储中断处理程序入口的表。当CPU 接收一个中断时,需要用中断向量在此表中检索对应的描述符,在该描述符中找到中断处理程序的起始地址,然后执行中断处理程序。

  13. 实模式下用于存储中断处理程序入口的表叫中断向量表IVT

  14. 中断描述符表

    • 由中断描述符、任务门描述符、陷阱门描述符、调用门描述符构成
    • 所有描述符大小都是8字节
    • 任务门和任务状态段(11部K Status Segme邸, TSS )是Intel 处理器在硬件一级提供任务切换机制,所以任务门需要和TSS 配合在一起使用,在任务门中记录的是TSS 选择子
    • 中断门包含了中断处理程序所在段的段选择子和段内偏移地址。当通过此方式进入中断后,标志寄存器eflags 中的IF位自动置0,也就是在进入中断后,自动把中断关闭,避免中断嵌套。
    • 陆阱门和中断门非常相似,区别是由陷阱门进入中断后,标志寄存器eflags 中的IF位不会自动置0
  15. 相比于中断向量表,中断描述符的区别

    • 中断描述符表地址不限制,使用中断描述符表寄存器IDTR进行索引
    • 每个描述符使用8个字节描述
  16. 中断描述符表寄存器

    • 0~15位为表界限,最大范围为64KB
    • 16~47位为IDT基地址
  17. 中断处理过程

    CPU 外:外部设备的中断由中断代理芯片接收,处理后将该中断的中断向量号发送到CPU 。
    CPU 内: CPU 执行该中断向量号对应的中断处理程序。

    • 处理器根据中断向量号定位中断门描述符。中断向量号是中断描述符的索引,当处理器收到一个外部中断向量号后,它用此向量号在中断描述符表中查询对应的中断描述符,然后再去执行该中断描述符中的中断处理程序。由于中断描述符是8 个字节,所以处理器用中断向量号乘以8 后,再与IDTR 中的中断描述符表地址相加,所求的地址之和便是该中断向量号对应的中断描述符。
    • 处理器进行特权级检查。
      • 软中断,处理器要检查当前特权级CPL和门描述符DPL,CPL权限要大于等于DPL
      • 处理器要检查当前特权级CPL 和门描述符中所记录的选择子对应的目标代码段DPL,如果CPL 权限小于目标代码段DPL,即数值上CPL>目标代码段DPL,检查通过。
      • 执行中断处理程序,特权级检查通过后,将门描述符目标代码段选择子加载到代码段寄存器cs 中,把门描述符中中断处理程序的偏移地址加载到EIP ,开始执行中断处理程序。
  18. 如果中断对应的门描述符是中断门,标志寄存器eflags中的IF位被自动置0,避免中断嵌套。而任务门或陷阱门,CPU不会将IF位清0。陷阱门常用于调试,允许更高级别的中断发生。任务门用于多任务系统,必须开中断,否则系统将会退化成单任务系统

  19. 当中断处理程序执行完成返回后,通过iret 指令从棋中恢复eflags 的内容。

  20. 处理器提供了专门用于控制IF位的指令

    • 指令cli 使IF 位为0,这称为关中断
    • 指令sti 使IF位为1 ,这称为开中断。
  21. 进入中断时要把NT位和TF位置为0 。

    • TF 表示Trap Flag,也就是陆阱标志位,即进入中断后将TF 直为0 ,表示不允许中断处理程序单步执行
    • NT位表示Nest Task Flag,即任务嵌套标志位。CPU 将当前正执行的旧任务挂起,转去执行另外的新任务,待新任务执行完后, CPU 再回到旧任务继续执行。
  22. 通过iret进行任务切换

    • 将旧任务TSS 选择子写到了新任务TSS 中的“上一个任务TSS 的指针”字段中。
    • 将新任务标志寄存器eflags 中的NT 位置,表示新任务之所以能够执行,是因为有别的任务调用了它
  23. 不同特权级别下处理器使用不同的钱,至于中断处理程序使用的是哪个枝,要视它当时所在的特权级别,因为中断是可以在任何特权级别下发生的

  24. 在32 位模式下,iret指令从当前栈顶处依次弹出栈内数据分别到寄存器EIP 、CS、EFLAGS

  25. 处理器根据中断向量号找到对应的中断描述符后,拿CPL 和中断门描述符中选择子对应的目标代码段的DPL 比对,若CPL 权限比DPL 低,即数值上CPL>DPL ,这表示要向高特权级转移,需要切换到高特权级的栈。执行完成后需要切回旧栈,

  26. 在返回时需要改变特权级,将会检查数据段寄存器DS 、ES 、FS 和GS 的内容,如果在它们之中,某个寄存器中选择子所指向的数据段描述符的DPL 权限比返回后的CPL ( CS.RPL) 高,即数值上返回后的CPL>数据段描述符的DPL,处理器将把数值0 填充到相应的段寄存器。

  27. 有些中断会在栈中压入错误码,用来指示中断发生在段的位置

  28. 可编程中断控制器8259A,负责所有来自外设的中断控制和管理,进行优先级判决

  29. 8259A 只可以管理8 个中断,最多可以级联9片支持64个中断

  30. 8259A 的主要控制信号和寄存器

    • INT: 8259A 选出优先级最高的中断请求后,发信号通知CPU
    • INTA: INT Acknowledge,中断响应信号。位于8259A 中的INTA 接收来自CPU 的INTA接口的
      中断响应信号。
    • IMR: Interrupt Mask Register,中断屏蔽寄存器,宽度是8 位,决定是否屏蔽外设中断
    • IRR: Interrupt Request Register,中断请求寄存器,宽度是8 位。它的作用是接受经过IMR 寄存器过滤后的中断信号并锁存,此寄存器中全是等待处理的中断,“相当于” 5259A 维护的未处理中断信号队列。
    • PR: Priority Resolver,优先级仲裁器。当有多个中断同时发生,或当有新的中断请求进来时,将它与当前正在处理的中断进行比较,找出优先级更高的中断。
    • ISR: In-ServiceRegister,中断服务寄存器,宽度是8 位。当某个中断正在被处理时,保存在此寄存器中。
  31. 8259A 工作流程

    • 主板将中断信号送入8259A中,8259A 首先检查肌1R 寄存器中是否已经屏蔽了来自该IRQ 接口的中断信号
    • 该IRQ 对应的相应位己经被置1 ,即表示来自该IRQ 接口上的中断已经被屏蔽了,则将该中断信号丢弃,否则,将其送入IRR寄存器,将该IRQ 接口所在IRR 寄存器中对应的BIT 置1 。
    • 在某个恰当时机,优先级仲裁器PR 会从IRR 寄存器中挑选一个优先级最大(接口号最低)的中断
    • 8259A 会在控制电路中,通过INT 接口向CPU 发送时INTR 信号,当CPU完成自己的工作后,会向8259A发送一个中断响应信号,表示自己已经准备好了
    • 8259A 立即将刚才选出来的优先级最大的中断在ISR 寄存器中对应的BIT 置1,此寄存器表示当前正在处理的中断,同时要将该中断从“待处理中断队列”寄存器IRR 中去掉,也就是在IRR 中将该中断对应的BIT 置0
    • CPU 将再次发送INTA 信号给8259A,获取中断对应的向量号(用起始中断向量号+IRQ 接口号便是该设备的中断向量号)
    • 8259A 将此中断向量号通过系统数据总线发送给CPU 。CPU 从数据总线上拿到中断向量号后,用它做中断向量表或中断描述符表中的索引,找到相应的中断处理程序井去执行。
    • 进入ISR寄存器后,仍然可能被更高级别的中断替换
  32. 计算机中最基本的访存方式是使用地址来访问内存

  33. 当CPU 接收到8259A 送来的中断向量号后要将其乘以8,再加上中断描述符表的起始地址,经过内存寻址,最终定位到目标中断处理程序。

  34. 在8259A 内部有两组寄存器

    • 初始化命令寄存器组(ICW1~ICW4):设置8259A的工作状态
    • 操作命令寄存器组(OCW1~OCW3):中断屏蔽和中断结束的控制
  35. PIC 就是可编程中断控制器Progr缸田nable Interrupt Controller 的简称,而8259A 也是PIC 的一种

  36. 宏属于预处理指令,预处理指令是编译器为用户编码方便而提供的、仅被编谭器中的预处理器支持的符号,井不是处理直接支持的指令,故属于伪指令。

  37. 汇编中的宏定义

    %macro 宏 可接受参数个数
    	···
    	宏代码体
    	···
    %endmacro
    
    #eg:
    %macro mul_add 3
    	mov eax ,%1#表示第一个参数,以此类推
    	add eax ,%2
    	add eax ,%3
    %endmacro
    
    
  38. 在中断发生时,处理器要在目标榜中保存被中断进程的部分寄存器环境,这是处理器自动完成的,保存寄存器的名称及顺序是

    • 发生特权级转移,则把低特权级的栈选择子ss及栈指针esp保存到栈中
    • 压入标志寄存器eflags
    • 压入返回地址cs和eip,先压入cs,后压入eip
    • 如果此中断没有相应的错误码,至此,处理器把寄存器压栈的工作完成
  39. 用iret 指令从中断返回时栈顶必须是EIP 的值

  40. 编译器会将属性相同的section合并到一个大的segment中,使用宏编译的不同section的中断处理程序,最终可以形成一个连续的数组

  41. 中断描述符表本质上是所有中断描述符索引的数组

  42. 操作系统于硬件端口的响应程序,都有重复的拷贝,而不是使用函数调用同一份程序,是为了获得高的响应速度

  43. 涉及到堆栈操作需要进行内存与寄存器的数据交换,速度不如直接读取

  44. 内嵌函数:将所调用的函数体的内容,在该函数的调用处,原封不动地展开,这样编译后的代码中将不包含call 指令,也就不属于函数调用了,而是顺次执行。

  45. 外部时钟和内部时钟是两套独立运行的定时体系,时钟是设备通信间的节奏,用于控制和同步内部工作

  46. 内部时钟是由晶体振荡器产生的,简称晶振,它位于主板上,其频率经过分频之后就是主板的外频,处理器和南北桥之间的通信就基于外频。Intel 处理器将此外频乘以某个倍数(也称为倍频)之后便称为主频

  47. 使用定时计数器处理cpu和外设速度不匹配的情况,晶振产生的高频信号送到定时计数器进行分频操作,然后产生各种所需的信号

  48. 使用while进行CPU的空转会消耗资源,通常使用定时器进行时间控制,定时器内部会进行计数,达到该计数就会输出一个信号,可以用于时间控制。

  49. 硬件定时器是独立的,可以与处理器同时工作。

  50. 常用的可编程计数器是8253,计算的是时钟脉冲信号

    • 将初值寄存器存放的计数初值载入到计数部件自己的计数寄存器中
    • 计数器的CLK 引脚每收到一个脉冲信号,计数器执行部件(减法计数器〉便将计数值减1 ,同时将当前计数值保存在输出锁存器中
    • 当计数值减到0 时,表示定时工作结束,此时将通过OUT 引脚发出信号
  51. 8253的三个计数器

    计数器名称端口作用
    计数器00x40在个人计算机中,计数器。专用于产生实时时钟信号。它采用工作方式3 ,往此计数器写入0时则为最大计数值65536
    计数器10x41在个人计算机中,计数器1 专用于DRAM 的定时刷新控制,PC/XT 规定在2ms 内进行128次的刷新, PC/AT 规定在4ms 内进行256 次的刷新
    计数器20x42在个人计算机中,计数器2 专用于内部扬声器发出不同音调的声音,原理是给扬声器输送不同频率的方波
  52. 控制字用来设置所指定的计数器(通道)的工作方式、读写格式及数制。存储在控制字寄存器中,操作端口是0x43,是一个8位大小的寄存器

  53. SC1和SC0位是选择计数器位,可以用来索引四个计数器,但8253内部只有三个独立的计数器

    • 计数器是16位宽度的,可以通过RW1和RW0进行指定4种读写方式
    • M0~M2三位指定6种计数器的工作方式
    • 每个计数器都有两种计数方式:二进制和十进制BCD
  54. 计数器在具备两个技术条件时,在下一个时钟信号的下降沿开始计数

    • GATE 为高电平,即GATE为1 ,这是由硬件来控制的。
    • 计数初值己写入计数器中的减法计数器,这是由软件out 指令控制的。
  55. 启动方式划分

    • 软件启动是指上面硬件负责的条件l 已经完成,也就是GATE 己经为1 ,目前只差软件来完成条件2,即尚未写入计数初值,只要软件负责的条件准备好,计数器就开始启动。当处理器用out 指令往计数器写入计数初值,减法器将此初值加载后,计数器便开始计数。工作方式。、2 、3 、4 都是用软件启动计数过程。
    • 硬件启动是指上面软件负责的条件2 己经完成,即计数初值己写入计数器。目前只差硬件来完成条件1了,也就是门控信号GATE 目前还是低电平,即目前GATE=0 ,只要硬件负责的条件准备好,计数器就开始启动。GATE 引脚是由外部信号来控制的,只有当GATE 由0 变1的上升沿出现时,计数器才开始启动计数。工作方式1 、5 都是用硬件启动计数过程。
  56. 终止方式

    • 强制终止:计数器是重复计数的,当计时到期(计数值为0 ) 后,减法计数器又会重新把计数初值寄存器中的值重新载入,继续下一轮计数,比如工作方式2 和工作方式3 都是采用此方式计数,此方式常见于需要周期性发信号的场合。对于采用此类循环计数工作方式的计数器,只能通过外加控制信号来将其计数过程终止,办法是破坏启动计数的条件:将GATE 置为0 即可。
    • 自动终止:计数器是单次计数,只要定时(计数) 一到期就停止,不再进行下一轮计数,所以计数过程自然就自动终止了。比如工作方式0 、1 、4 、5 都是单次计数,完成后自动终止,在计数过程中终止可以将GATE 置为0
  57. 三个计数器的工作频率均是1.19318阳泣,即一秒内会有11 93180 次脉冲信号。每发生一次时钟脉冲信号,计数器就会将计数值减1 ,也就是1 秒内会将计数值减1193180 次1,通过采用循环计数的工作方式,设置计数器初值,周期性发出信号而操纵计数器信号频率。

  58. 8259的初始化

    • 往控制字寄存器端口0x43 中写入控制字,包括计数器的工作方式、读写格式以及数制
    • 在指定使用的计数器端口中写入计数初值,每个计数器都有各自独立的计数端口,为0x40~0x42
  59. 平坦模式下默认段基址为0

代码

  1. 编译脚本

    #!/bin/bash
    #### 分功能进行shell文本的编写
    #0.删除中间文件
    rm -rf ./hd.img &&\
    #1.新建硬盘镜像文件
    bin/bximage -hd -mode="flat" -size=60 -q hd.img &&\
    #2.setup程序的处理
    ## 将使用汇编编写的主引导记录编译成二进制文件
    nasm -I include/ -o mbr.bin ./boot/mbr.s &&\
    ## 将内核加载文件编译成二进制文件
    nasm -I include/ -o loader.bin ./boot/loader.s &&\
    ## 将主引导记录的二进制文件写入硬盘镜像文件
    dd if=mbr.bin of=hd.img bs=512 count=1 seek=0 conv=notrunc &&\
    ## 将内核加载文件的二进制文件写入硬盘镜像文件中
    dd if=loader.bin of=hd.img bs=512 count=3 seek=2 conv=notrunc &&\
    ## 清理程序
    rm -rf loader.bin mbr.bin &&\
    #3.内核程序的处理
    ## 编译print.s文件
    nasm -f elf -o print.o ./include/print.s &&\
    ## 编译kernel.s文件
    nasm -f elf -o kernel.o ./include/kernel.s &&\
    ## 编译main.c
    gcc -m32 -fno-stack-protector -I include/ -c -fno-builtin -o main.o ./kernel/main.c &&\
    ## 编译init.c
    gcc -m32 -fno-stack-protector -I include/ -c -fno-builtin -o init.o ./kernel/init.c &&\
    ## 编译interrupt.c
    gcc -m32 -fno-stack-protector -I include/ -c -fno-builtin -o interrupt.o ./kernel/interrupt.c &&\
    ## 编译timer.c
    gcc -m32 -fno-stack-protector -I include/ -c -o timer.o ./kernel/timer.c &&\
    ## 将二进制文件写入硬盘镜像并指定起始虚拟地址
    ld -m elf_i386 -Ttext 0xc0001500 -e main -o \
    kernel.bin main.o init.o interrupt.o  print.o kernel.o timer.o &&\
    ## 将内核文件写入虚拟硬盘中
    dd if=kernel.bin of=hd.img bs=512 count=200 seek=9 conv=notrunc&&\
    #4.启动bochs
    bin/bochs -f bochsrc &&\
    #5.删除中间文件
    rm -rf ./main.o ./print.o ./init.o ./interrupt.o ./kernel.o kernel.bin 
    
    
  2. 主引导记录mbr和内核加载程序loader与前面相同

  3. print.s

    TI_GDT equ  0
    RPL0  equ   0
    SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0
    section .data
    put_int_buffer    dq    0     ; 定义8字节缓冲区用于数字到字符的转换
    [bits 32]
    section .text
    ;--------------------------------------------
    ;put_str 通过put_char来打印以0字符结尾的字符串
    ;--------------------------------------------
    global put_str
    put_str:
    ;由于本函数中只用到了ebx和ecx,只备份这两个寄存器
       push ebx
       push ecx
       xor ecx, ecx		      ; 准备用ecx存储参数,清空
       mov ebx, [esp + 12]	      ; 从栈中得到待打印的字符串地址 
    .goon:
       mov cl, [ebx]
       cmp cl, 0		      ; 如果处理到了字符串尾,跳到结束处返回
       jz .str_over
       push ecx		      ; 为put_char函数传递参数
       call put_char
       add esp, 4		      ; 回收参数所占的栈空间
       inc ebx		      ; 使ebx指向下一个字符
       jmp .goon
    .str_over:
       pop ecx
       pop ebx
       ret
    ;--------------------   将小端字节序的数字变成对应的ascii后,倒置   -----------------------
    ;输入:栈中参数为待打印的数字
    ;输出:在屏幕上打印16进制数字,并不会打印前缀0x,如打印10进制15时,只会直接打印f,不会是0xf
    ;------------------------------------------------------------------------------------------
    global put_int
    put_int:
       pushad
       mov ebp, esp
       mov eax, [ebp+4*9]		       ; call的返回地址占4字节+pushad的8个4字节
       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:			       ; 每4位二进制是16进制数字的1位,遍历每一位16进制数字
       and edx, 0x0000000F		       ; 解析16进制数字的每一位。and与操作后,edx只有低4位有效
       cmp edx, 9			       ; 数字0~9和a~f需要分别处理成对应的字符
       jg .is_A2F 
       add edx, '0'			       ; ascii码是8位大小。add求和操作后,edx低8位有效。
       jmp .store
    .is_A2F:
       sub edx, 10			       ; A~F 减去10 所得到的差,再加上字符A的ascii码,便是A~F对应的ascii码
       add edx, 'A'
    
    ;将每一位数字转换成对应的字符后,按照类似“大端”的顺序存储到缓冲区put_int_buffer
    ;高位字符放在低地址,低位字符要放在高地址,这样和大端字节序类似,只不过咱们这里是字符序.
    .store:
    ; 此时dl中应该是数字对应的字符的ascii码
       mov [ebx+edi], dl		       
       dec edi
       shr eax, 4
       mov edx, eax 
       loop .16based_4bits
    ;现在put_int_buffer中已全是字符,打印之前,
    ;把高位连续的字符去掉,比如把字符000123变成123
    .ready_to_print:
       inc edi			       ; 此时edi退减为-1(0xffffffff),加1使其为0
    .skip_prefix_0:  
       cmp edi,8			       ; 若已经比较第9个字符了,表示待打印的字符串为全0 
       je .full0 
    ;找出连续的0字符, edi做为非0的最高位字符的偏移
    .go_on_skip:   
       mov cl, [put_int_buffer+edi]
       inc edi
       cmp cl, '0' 
       je .skip_prefix_0		       ; 继续判断下一位字符是否为字符0(不是数字0)
       dec edi			       ;edi在上面的inc操作中指向了下一个字符,若当前字符不为'0',要恢复edi指向当前字符		       
       jmp .put_each_num
    
    .full0:
       mov cl,'0'			       ; 输入的数字为全0时,则只打印0
    .put_each_num:
       push ecx			       ; 此时cl中为可打印的字符
       call put_char
       add esp, 4
       inc edi			       ; 使edi指向下一个字符
       mov cl, [put_int_buffer+edi]	       ; 获取下一个字符到cl寄存器
       cmp edi,8
       jl .put_each_num
       popad
       ret
    ;------------------------   put_char   -----------------------------
    ;功能描述:把栈中的1个字符写入光标所在处
    ;-------------------------------------------------------------------   
    global put_char
    put_char:
       pushad	   ;备份32位寄存器环境
       ;需要保证gs中为正确的视频段选择子,为保险起见,每次打印时都为gs赋值
       mov ax, SELECTOR_VIDEO	       ; 不能直接把立即数送入段寄存器
       mov gs, ax
    ;;;;;;;;;  获取当前光标位置 ;;;;;;;;;
       ;先获得高8位
       mov dx, 0x03d4  ;索引寄存器
       mov al, 0x0e	   ;用于提供光标位置的高8位
       out dx, al
       mov dx, 0x03d5  ;通过读写数据端口0x3d5来获得或设置光标位置 
       in al, dx	   ;得到了光标位置的高8位
       mov ah, al
    ;再获取低8位
       mov dx, 0x03d4
       mov al, 0x0f
       out dx, al
       mov dx, 0x03d5 
       in al, dx
       ;将光标存入bx
       mov bx, ax	  
       ;下面这行是在栈中获取待打印的字符
       mov ecx, [esp + 36]	      ;pushad压入4×8=32字节,加上主调函数的返回地址4字节,故esp+36字节
       cmp cl, 0xd				  ;CR是0x0d,LF是0x0a
       jz .is_carriage_return
       cmp cl, 0xa
       jz .is_line_feed
       cmp cl, 0x8				  ;BS(backspace)的asc码是8
       jz .is_backspace
       jmp .put_other	   
    ;;;;;;;;;;;;;;;;;;
     .is_backspace:		      
    ;;;;;;;;;;;;       backspace的一点说明	     ;;;;;;;;;;
    ; 当为backspace时,本质上只要将光标移向前一个显存位置即可.后面再输入的字符自然会覆盖此处的字符
    ; 但有可能在键入backspace后并不再键入新的字符,这时在光标已经向前移动到待删除的字符位置,但字符还在原处,
    ; 这就显得好怪异,所以此处添加了空格或空字符0
       dec bx
       shl bx,1
       mov byte [gs:bx], 0x20		  ;将待删除的字节补为0或空格皆可
       inc bx
       mov byte [gs:bx], 0x07
       shr bx,1
       jmp .set_cursor
    ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
    
     .put_other:
       shl bx, 1				  ; 光标位置是用2字节表示,将光标值乘2,表示对应显存中的偏移字节
       mov [gs:bx], cl			  ; ascii字符本身
       inc bx
       mov byte [gs:bx],0x07		  ; 字符属性
       shr bx, 1				  ; 恢复老的光标值
       inc bx				  ; 下一个光标值
       cmp bx, 2000		   
       jl .set_cursor			  ; 若光标值小于2000,表示未写到显存的最后,则去设置新的光标值
    					  ; 若超出屏幕字符数大小(2000)则换行处理
     .is_line_feed:				  ; 是换行符LF(\n)
     .is_carriage_return:			  ; 是回车符CR(\r)
    					  ; 如果是CR(\r),只要把光标移到行首就行了。
       xor dx, dx				  ; dx是被除数的高16位,清0.
       mov ax, bx				  ; ax是被除数的低16位.
       mov si, 80				  ; 由于是效仿linux,linux中\n便表示下一行的行首,所以本系统中,
       div si				  ; 把\n和\r都处理为linux中\n的意思,也就是下一行的行首。
       sub bx, dx				  ; 光标值减去除80的余数便是取整
    					  ; 以上4行处理\r的代码
    
     .is_carriage_return_end:                 ; 回车符CR处理结束
       add bx, 80
       cmp bx, 2000
     .is_line_feed_end:			  ; 若是LF(\n),将光标移+80便可。  
       jl .set_cursor
    ;屏幕行范围是0~24,滚屏的原理是将屏幕的1~24行搬运到0~23行,再将第24行用空格填充
     .roll_screen:				  ; 若超出屏幕大小,开始滚屏
       cld  
       mov ecx, 960				  ; 一共有2000-80=1920个字符要搬运,共1920*2=3840字节.一次搬4字节,共3840/4=960次 
       mov esi, 0xc00b80a0			  ; 第1行行首
       mov edi, 0xc00b8000			  ; 第0行行首
       rep movsd				  
    ;;;;;;;将最后一行填充为空白
       mov ebx, 3840			  ; 最后一行首字符的第一个字节偏移= 1920 * 2
       mov ecx, 80				  ;一行是80字符(160字节),每次清理1字符(2字节),一行需要移动80次
     .cls:
       mov word [gs:ebx], 0x0720		  ;0x0720是黑底白字的空格键
       add ebx, 2
       loop .cls 
       mov bx,1920				  ;将光标值重置为1920,最后一行的首字符.
     .set_cursor:   
    					  ;将光标设为bx值
    ;;;;;;; 1 先设置高8位 ;;;;;;;;
       mov dx, 0x03d4			  ;索引寄存器
       mov al, 0x0e				  ;用于提供光标位置的高8位
       out dx, al
       mov dx, 0x03d5			  ;通过读写数据端口0x3d5来获得或设置光标位置 
       mov al, bh
       out dx, al
    ;;;;;;; 2 再设置低8位 ;;;;;;;;;
       mov dx, 0x03d4
       mov al, 0x0f
       out dx, al
       mov dx, 0x03d5 
       mov al, bl
       out dx, al
     .put_char_done: 
       popad
       ret
    
  4. kernel.s

    [bits 32]
    %define ERROR_CODE nop		 ; 若在相关的异常中cpu已经自动压入了错误码,为保持栈中格式统一,这里不做操作.
    %define ZERO push 0		 ; 若在相关的异常中cpu没有压入错误码,为了统一栈中格式,就手工压入一个0
    extern put_str;
    extern idt_table;
    
    section .data		; 数据段,可读可写
    global intr_entry_table
    intr_entry_table:
    
    %macro VECTOR 2	; 宏定义,编译器会将相同属性的section合并到一个大的segment中
    section .text		; 代码段,只读可执行
    intr%1entry:		 ; 标号,表示中断处理的起始地址。每个中断处理程序都要压入中断向量号,所以一个中断类型一个中断处理程序,自己知道自己的中断向量号是多少
       %2				 ; 中断若有错误码会压在eip后面 
    ; 以下是保存上下文环境
       push ds
       push es
       push fs
       push gs
       pushad			 ; PUSHAD指令压入32位寄存器,其入栈顺序是: EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI
    
       ; 如果是从片上进入的中断,除了往从片上发送EOI外,还要往主片上发送EOI 
       mov al,0x20                   ; 中断结束命令EOI
       out 0xa0,al                   ; 向从片发送
       out 0x20,al                   ; 向主片发送
    
       push %1			 ; 不管idt_table中的目标程序是否需要参数,都一律压入中断向量号,调试时很方便
       call [idt_table + %1*4]       ; 调用idt_table中的C版本中断处理函数
       jmp intr_exit
    	
    section .data
       dd    intr%1entry	 ; 存储各个中断入口程序的地址,形成intr_entry_table数组
    %endmacro
    
    section .text
    global intr_exit
    intr_exit:	     
    ; 以下是恢复上下文环境
       add esp, 4			   ; 跳过中断号
       popad
       pop gs
       pop fs
       pop es
       pop ds
       add esp, 4			   ; 跳过error_code
       iretd
    
    
    VECTOR 0x0 ,ZERO
    VECTOR 0X1 ,ZERO
    VECTOR 0X2 ,ZERO
    VECTOR 0x3 ,ZERO
    VECTOR 0X4 ,ZERO
    VECTOR 0X5 ,ZERO
    VECTOR 0x6 ,ZERO
    VECTOR 0X7 ,ZERO
    VECTOR 0X8 ,ERROR_CODE
    VECTOR 0x9 ,ZERO
    VECTOR 0XA ,ERROR_CODE
    VECTOR 0XB ,ERROR_CODE
    VECTOR 0XC ,ERROR_CODE
    VECTOR 0XD ,ERROR_CODE
    VECTOR 0XE ,ERROR_CODE
    VECTOR 0XF ,ZERO
    VECTOR 0X10 ,ZERO
    VECTOR 0X11 ,ERROR_CODE
    VECTOR 0x12 ,ZERO
    VECTOR 0X13 ,ZERO
    VECTOR 0X14 ,ZERO
    VECTOR 0x15 ,ZERO
    VECTOR 0X16 ,ZERO
    VECTOR 0X17 ,ZERO
    VECTOR 0X18 ,ZERO
    VECTOR 0X19 ,ZERO
    VECTOR 0X1A ,ZERO
    VECTOR 0X1B ,ZERO
    VECTOR 0X1C ,ZERO
    VECTOR 0X1D ,ZERO
    VECTOR 0X1E ,ERROR_CODE                               ;处理器自动推错误码
    VECTOR 0X1F ,ZERO
    VECTOR 0X20 ,ZERO
    
    
  5. global.h:完成底层机器和有意义的描述符的转换

    #ifndef __KERNEL_GLOBAL_H
    #define __KERNEL_GLOBAL_H
    #include "stdint.h"
    
    
    #define	 RPL0  0
    #define	 RPL1  1
    #define	 RPL2  2
    #define	 RPL3  3
    
    #define TI_GDT 0
    #define TI_LDT 1
    
    #define SELECTOR_K_CODE	   ((1 << 3) + (TI_GDT << 2) + RPL0)
    #define SELECTOR_K_DATA	   ((2 << 3) + (TI_GDT << 2) + RPL0)
    #define SELECTOR_K_STACK          SELECTOR_K_DATA 
    #define SELECTOR_K_GS	           ((3 << 3) + (TI_GDT << 2) + RPL0)
    
    
    //--------------   IDT描述符属性  ------------
    #define	 IDT_DESC_P	 1 
    #define	 IDT_DESC_DPL0   0
    #define	 IDT_DESC_DPL3   3
    #define	 IDT_DESC_32_TYPE     0xE   // 32位的门
    #define	 IDT_DESC_16_TYPE     0x6   // 16位的门,不用,定义它只为和32位门区分
    #define	 IDT_DESC_ATTR_DPL0  ((IDT_DESC_P << 7) + (IDT_DESC_DPL0 << 5) + IDT_DESC_32_TYPE)
    #define	 IDT_DESC_ATTR_DPL3  ((IDT_DESC_P << 7) + (IDT_DESC_DPL3 << 5) + IDT_DESC_32_TYPE)
    
    #endif
    
    
  6. io.h:对硬件端口的操作函数
    使用inline使函数内嵌,使用static使包含该头文件的均有自己的函数备份,增加调用速度

    /**************	 机器模式   ***************
    	 b -- 输出寄存器QImode名称,即寄存器中的最低8位:[a-d]l。
    	 w -- 输出寄存器HImode名称,即寄存器中2个字节的部分,如[a-d]x。
    
    	 HImode
    	     “Half-Integer”模式,表示一个两字节的整数。 
    	 QImode
    	     “Quarter-Integer”模式,表示一个一字节的整数。 
    *******************************************/ 
    
    #ifndef __LIB_IO_H
    #define __LIB_IO_H
    #include "stdint.h"
    
    /* 向端口port写入一个字节*/
    static inline void outb(uint16_t port, uint8_t data) {
    /*********************************************************
     a表示用寄存器al或ax或eax,对端口指定N表示0~255, d表示用dx存储端口号, 
     %b0表示对应al,%w1表示对应dx */ 
       asm volatile ( "outb %b0, %w1" : : "a" (data), "Nd" (port));    
    /******************************************************/
    }
    
    /* 将addr处起始的word_cnt个字写入端口port */
    static inline void outsw(uint16_t port, const void* addr, uint32_t word_cnt) {
    /*********************************************************
       +表示此限制即做输入又做输出.
       outsw是把ds:esi处的16位的内容写入port端口, 我们在设置段描述符时, 
       已经将ds,es,ss段的选择子都设置为相同的值了,此时不用担心数据错乱。*/
       asm volatile ("cld; rep outsw" : "+S" (addr), "+c" (word_cnt) : "d" (port));
    /******************************************************/
    }
    
    /* 将从端口port读入的一个字节返回 */
    static inline uint8_t inb(uint16_t port) {
       uint8_t data;
       asm volatile ("inb %w1, %b0" : "=a" (data) : "Nd" (port));
       return data;
    }
    
    /* 将从端口port读入的word_cnt个字写入addr */
    static inline void insw(uint16_t port, void* addr, uint32_t word_cnt) {
    /******************************************************
       insw是将从端口port处读入的16位内容写入es:edi指向的内存,
       我们在设置段描述符时, 已经将ds,es,ss段的选择子都设置为相同的值了,
       此时不用担心数据错乱。*/
       asm volatile ("cld; rep insw" : "+D" (addr), "+c" (word_cnt) : "d" (port) : "memory");
    /******************************************************/
    }
    
    #endif
    
    
  7. interrupt.h

    #ifndef __KERNEL_INTERRUPT_H
    #define __KERNEL_INTERRUPT_H
    #include "stdint.h"
    typedef void* intr_handler;
    void idt_init(void);
    #endif
    
    
  8. interrupt.c

    #include "interrupt.h"
    #include "stdint.h"
    #include "global.h"
    #include "io.h"
    #include "print.h"
    
    #define PIC_M_CTRL 0x20	       // 这里用的可编程中断控制器是8259A,主片的控制端口是0x20
    #define PIC_M_DATA 0x21	       // 主片的数据端口是0x21
    #define PIC_S_CTRL 0xa0	       // 从片的控制端口是0xa0
    #define PIC_S_DATA 0xa1	       // 从片的数据端口是0xa1
    
    #define IDT_DESC_CNT 0x21      // 目前总共支持的中断数
    
    /*中断门描述符结构体*/
    struct gate_desc {
       uint16_t    func_offset_low_word;
       uint16_t    selector;
       uint8_t     dcount;   //此项为双字计数字段,是门描述符中的第4字节。此项固定值,不用考虑
       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是中断描述符表,本质上就是个中断门描述符数组
    
    
    char* intr_name[IDT_DESC_CNT];		     // 用于保存异常的名字
    
    
    /********    定义中断处理程序数组    ********
     * 在kernel.S中定义的intrXXentry只是中断处理程序的入口,
     * 最终调用的是ide_table中的处理程序*/
    intr_handler idt_table[IDT_DESC_CNT];
    
    /********************************************/
    extern intr_handler intr_entry_table[IDT_DESC_CNT];	    // 声明引用定义在kernel.S中的中断处理函数入口数组
    
    
    
    
    
    /* 初始化可编程中断控制器8259A */
    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
       
       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, lastindex = IDT_DESC_CNT - 1;
       for (i = 0; i < IDT_DESC_CNT; i++) {
          make_idt_desc(&idt[i], IDT_DESC_ATTR_DPL0, intr_entry_table[i]); 
       }
    /* 单独处理系统调用,系统调用对应的中断门dpl为3,
     * 中断处理程序为单独的syscall_handler */
       put_str("   idt_desc_init done\n");
    }
    
    
    
    /* 通用的中断处理函数,一般用在异常出现时的处理 */
    static void general_intr_handler(uint8_t vec_nr) {
       if (vec_nr == 0x27 || vec_nr == 0x2f) {	// 0x2f是从片8259A上的最后一个irq引脚,保留
          return;		//IRQ7和IRQ15会产生伪中断(spurious interrupt),无须处理。
       }
       put_str("int vector : 0x");
       put_int(vec_nr);
       put_char('\n');	
    }
    
    
    /* 完成一般中断处理函数注册及异常名称注册 */
    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_handler。
    							    // 以后会由register_handler来注册具体处理函数。
          intr_name[i] = "unknown";				    // 先统一赋值为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 Invalid 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");
    }
    
    
  9. main.c

    #include "print.h"
    #include "init.h"
    
    int main(void) {
       put_str("I am kernel\n");
       init_all();
       asm volatile("sti");
       while(1);
    }
    
    
  10. init.h

    #ifndef __KERNEL_INIT_H
    #define __KERNEL_INIT_H
    void init_all(void);
    #endif
    
  11. init.c

    #include "init.h"
    #include "print.h"
    #include "interrupt.h"
    #include "timer.h"
    /*负责初始化所有模块 */
    void init_all() {
       put_str("init_all\n");
       idt_init();	     // 初始化中断
       timer_init();
    }
    
  12. timer.h

    #ifndef __DEVICE_TIME_H
    #define __DEVICE_TIME_H
    #include "stdint.h"
    void timer_init(void);
    void mtime_sleep(uint32_t m_seconds);
    #endif
    
    
  13. timer.c

    #include "io.h"
    #include "print.h"
    #include "interrupt.h"
    
    
    #define IRQ0_FREQUENCY 	100
    #define INPUT_FREQUENCY        1193180
    #define COUNTER0_VALUE		INPUT_FREQUENCY / IRQ0_FREQUENCY
    #define COUNTER0_PORT		0X40
    #define COUNTER0_NO 		0
    #define COUNTER_MODE		2
    #define READ_WRITE_LATCH	3
    #define PIT_COUNTROL_PORT	0x43
    // 初始化工作函数frequency_set
    /*
    1. counter_port 是计数器的端口号,用来指定初值counter_value 的目的端口号。
    2. counter_no 用来在控制字中指定所使用的计数器号码,对应于控制字中的SCI 和SC2 位。
    3. rwl 用来设置计数器的读/写/锁存方式,对应于控制字中的RWl 和RWO 位。
    4. counter_mode 用来设置计数器的工作方式,对应于控制字中的M2~MO 位。
    5. count_value用来设置计数器的计数初值,由于此值是16 位,所以我们用了uint16_t 来定义它。
    */
    void frequency_set(uint8_t counter_port ,uint8_t counter_no,uint8_t rwl,uint8_t counter_mode,uint16_t counter_value)
    {
        outb(PIT_COUNTROL_PORT,(uint8_t) (counter_no << 6 | rwl << 4 | counter_mode << 1));
        outb(counter_port,(uint8_t)counter_value);
        outb(counter_port,(uint8_t)counter_value >> 8);
        return;
    } 
    
    void timer_init(void)
    {
        put_str("timer_init start!\n");
        frequency_set(COUNTER0_PORT,COUNTER0_NO,READ_WRITE_LATCH,COUNTER_MODE,COUNTER0_VALUE);
        put_str("timer_init done!\n");
        return;
    }
    
    
  14. 总结

    • static和inline的性能提高
    • 汇编编写底层端口控制函数,c语言进行调用
    • 头文件内将类似魔数的端口控制01信号重命名成有含义的常量名

遇到的小bug(半天时间,甚至重装了ld,气愤···)

image-20220731163953930

ld链接时遇到__stack_chk_fail_local错误https://blog.csdn.net/ToryYang/article/details/105742323?spm=1001.2101.3001.6650.3&utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7Edefault-3-105742323-blog-12851243.pc_relevant_multi_platform_featuressortv2dupreplace&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7Edefault-3-105742323-blog-12851243.pc_relevant_multi_platform_featuressortv2dupreplace

成功截图

value >> 8);
return;
}

void timer_init(void)
{
    put_str("timer_init start!\n");
    frequency_set(COUNTER0_PORT,COUNTER0_NO,READ_WRITE_LATCH,COUNTER_MODE,COUNTER0_VALUE);
    put_str("timer_init done!\n");
    return;
}

```
  1. 总结

    • static和inline的性能提高
    • 汇编编写底层端口控制函数,c语言进行调用
    • 头文件内将类似魔数的端口控制01信号重命名成有含义的常量名

遇到的小bug(半天时间,甚至重装了ld,气愤···)

[外链图片转存中…(img-5kDcZpCn-1677658673107)]

ld链接时遇到__stack_chk_fail_local错误https://blog.csdn.net/ToryYang/article/details/105742323?spm=1001.2101.3001.6650.3&utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7Edefault-3-105742323-blog-12851243.pc_relevant_multi_platform_featuressortv2dupreplace&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7Edefault-3-105742323-blog-12851243.pc_relevant_multi_platform_featuressortv2dupreplace

成功截图

image-20220731210616518

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

逆羽飘扬

如果有用,请支持一下。

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

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

打赏作者

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

抵扣说明:

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

余额充值