Linux0.11终端设备控制(哈工大操作系统实验七)

Linux0.11终端设备控制-哈工大操作系统实验七

本章主要讲解一下Linux0.11终端设备控制:键盘与显示器

实验要求

实验任务:修改 Linux 0.11 的终端设备处理代码,对键盘输入和字符显示进行非常规的控制。在初始状态,一切如常。用户按一次F12 后,把应用程序向终端输出所有字母都替换为“*”。用户再按一次F12,又恢复正常。第三次按 F12,再进行输出替换。依此类推。

实验本质:

IO的使用:让外设工作起来表现为CPU给外设的硬件寄存器发送指令,控制器完成真正的工作,向CPU发中断信号,然后CPU进行中断处理。操作系统要给用户提供一个简单视图-----文件视图,将CPU与外设链接起来。

简单来说就是,当按下F12后会进入特殊打印模式,此时所有的键盘按键输入后均为*,再次按下F12重新恢复正常状态,按键输入为正常输入值。按照惯例,还是先讲原理,再讲实现思路。

实验原理

1. 为什么按下键盘会有输入–显示器逻辑

https://cjdhy.blog.csdn.net/article/details/127737970

首先先看main函数:

void main(void) {
    ...
    mem_init(main_memory_start,memory_end);
    trap_init();
    blk_dev_init();
    chr_dev_init();
    tty_init();
    time_init();
    sched_init();
    buffer_init(buffer_memory_end);
    hd_init();
    floppy_init();
    
    sti();
    move_to_user_mode();
    if (!fork()) {init();}
    for(;;) pause();
}

其中的tty_init()就是键盘的初始化部分:
这个方法执行完成之后,我们将会具备键盘输入到显示器输出字符这个最常用的功能。

void tty_init(void)
{
    rs_init();
    con_init();
}

看来这个方法已经多到需要拆成两个子方法了。

打开第一个方法,还好。

void rs_init(void)
{
    set_intr_gate(0x24,rs1_interrupt);
    set_intr_gate(0x23,rs2_interrupt);
    init(tty_table[1].read_q.data);
    init(tty_table[2].read_q.data);
    outb(inb_p(0x21)&0xE7,0x21);
}

这个方法是串口中断的开启,以及设置对应的中断处理程序,串口在我们现在的 PC 机上已经很少用到了,所以这个直接忽略。

看第二个方法,这是重点。代码非常长,有点吓人,我先把大体框架写出。

void con_init(void) {
    ...
    if (ORIG_VIDEO_MODE == 7) {
        ...
        if ((ORIG_VIDEO_EGA_BX & 0xff) != 0x10) {...}
        else {...}
    } else {
        ...
        if ((ORIG_VIDEO_EGA_BX & 0xff) != 0x10) {...}
        else {...}
    }
    ...
}

可以看出,非常多的 if else。

这是为了应对不同的显示模式,来分配不同的变量值,那如果我们仅仅找出一个显示模式,这些分支就可以只看一个了。

啥是显示模式呢?那我们得简单说说显示,一个字符是如何显示在屏幕上的呢?换句话说,如果你可以随意操作内存和 CPU 等设备,你如何操作才能使得你的显示器上,显示一个字符‘a’呢?

我们先看一张图。
在这里插入图片描述
内存中有这样一部分区域,是和显存映射的。啥意思,就是你往上图的这些内存区域中写数据,相当于写在了显存中。而往显存中写数据,就相当于在屏幕上输出文本了。

没错,就是这么简单。

如果我们写这一行汇编语句。


   mov [0xB8000],'h'

后面那个 h 相当于汇编编辑器帮我们转换成 ASCII 码的二进制数值,当然我们也可以直接写。其实就是往内存中 0xB8000 这个位置写了一个值,只要一写,屏幕上就会是这样。
简单吧,具体说来,这片内存是每两个字节表示一个显示在屏幕上的字符,第一个是字符的编码,第二个是字符的颜色,那我们先不管颜色,如果多写几个字符就像这样。


mov [0xB8000],'h'
mov [0xB8002],'e'
mov [0xB8004],'l'
mov [0xB8006],'l'
mov [0xB8008],'o'

此时屏幕上就会是这样。
在这里插入图片描述
是不是贼简单?那我们回过头看刚刚的代码,我们就假设显示模式是我们现在的这种文本模式,那条件分支就可以去掉好多。

代码可以简化成这个样子。

#define ORIG_X          (*(unsigned char *)0x90000)
#define ORIG_Y          (*(unsigned char *)0x90001)
void con_init(void) {
    register unsigned char a;
    // 第一部分 获取显示模式相关信息
    video_num_columns = (((*(unsigned short *)0x90006) & 0xff00) >> 8);
    video_size_row = video_num_columns * 2;
    video_num_lines = 25;
    video_page = (*(unsigned short *)0x90004);
    video_erase_char = 0x0720;
    // 第二部分 显存映射的内存区域 
    video_mem_start = 0xb8000;
    video_port_reg  = 0x3d4;
    video_port_val  = 0x3d5;
    video_mem_end = 0xba000;
    // 第三部分 滚动屏幕操作时的信息
    origin  = video_mem_start;
    scr_end = video_mem_start + video_num_lines * video_size_row;
    top = 0;
    bottom  = video_num_lines;
    // 第四部分 定位光标并开启键盘中断
    gotoxy(ORIG_X, ORIG_Y);
    set_trap_gate(0x21,&keyboard_interrupt);
    outb_p(inb_p(0x21)&0xfd,0x21);
    a=inb_p(0x61);
    outb_p(a|0x80,0x61);
    outb(a,0x61);
}

别看这么多,一点都不难。

首先还记不记得之前汇编语言的时候做的工作,存了好多以后要用的数据在内存中。
在这里插入图片描述
所以,第一部分获取 0x90006 地址处的数据,就是获取显示模式等相关信息。

第二部分就是显存映射的内存地址范围,我们现在假设是 CGA 类型的文本模式,所以映射的内存是从 0xB8000 到 0xBA000。

第三部分是设置一些滚动屏幕时需要的参数,定义顶行和底行是哪里,这里顶行就是第一行,底行就是最后一行,很合理。

第四部分是把光标定位到之前保存的光标位置处(取内存地址 0x90000 处的数据),然后设置并开启键盘中断。

开启键盘中断后,键盘上敲击一个按键后就会触发中断,中断程序就会读键盘码转换成 ASCII 码,然后写到光标处的内存地址,也就相当于往显存写,于是这个键盘敲击的字符就显示在了屏幕上。

这一切具体是怎么做到的呢?我们先看看我们干了什么。

  1. 我们现在根据已有信息已经可以实现往屏幕上的任意位置写字符了,而且还能指定颜色。

  2. 并且,我们也能接受键盘中断,根据键盘码中断处理程序就可以得知哪个键按下了。

有了这俩功能,那我们想干嘛还不是为所欲为?

好,接下来我们看看代码是怎么处理的,很简单。一切的起点,就是第四步的 gotoxy 函数,定位当前光标。

#define ORIG_X          (*(unsigned char *)0x90000)
#define ORIG_Y          (*(unsigned char *)0x90001)
void con_init(void) {
    ...
    // 第四部分 定位光标并开启键盘中断
    gotoxy(ORIG_X, ORIG_Y);
    ...
}

这里面干嘛了呢?

static inline void gotoxy(unsigned int new_x,unsigned int new_y) {
   ...
   x = new_x;
   y = new_y;
   pos = origin + y*video_size_row + (x<<1);
}

就是给 x y pos 这三个参数附上了值。

其中 x 表示光标在哪一列,y 表示光标在哪一行,pos 表示根据列号和行号计算出来的内存指针,也就是往这个 pos 指向的地址处写数据,就相当于往控制台的 x 列 y 行处写入字符了,简单吧?(所以这里pos的值就相当于定位了光标的具体位置),接下来只要讲需要输出的内容在pos位置打印即可。

然后,当你按下键盘后,触发键盘中断,之后的程序调用链是这样的。

_keyboard_interrupt:
    ...
    call _do_tty_interrupt
    ...
    
void do_tty_interrupt(int tty) {
   copy_to_cooked(tty_table+tty);
}

void copy_to_cooked(struct tty_struct * tty) {
    ...
    tty->write(tty);
    ...
}

// 控制台时 tty 的 write 为 con_write 函数
void con_write(struct tty_struct * tty) {
    ...
    __asm__("movb _attr,%%ah\n\t"
      "movw %%ax,%1\n\t"
      ::"a" (c),"m" (*(short *)pos)
      :"ax");
     pos += 2;
     x++;
    ...
}

前面的过程不用管,我们看最后一个函数 con_write 中的关键代码。

asm 内联汇编,就是把键盘输入的字符 c 写入 pos 指针指向的内存,相当于往屏幕输出了。

之后两行 pos+=2 和 x++,就是调整所谓的光标。

你看,写入一个字符,最底层,其实就是往内存的某处写个数据,然后顺便调整一下光标。

由此我们也可以看出,光标的本质,其实就是这里的 x y pos 这仨变量而已。

我们还可以做换行效果,当发现光标位置处于某一行的结尾时(这个应该很好算吧,我们都知道屏幕上一共有几行几列了),就把光标计算出一个新值,让其处于下一行的开头。
就一个小计算公式即可搞定,仍然在 con_write 源码处有体现,就是判断列号 x 是否大于了总列数。

void con_write(struct tty_struct * tty) {
    ...
    if (x>=video_num_columns) {
        x -= video_num_columns;
        pos -= video_size_row;
        lf();
  }
  ...
}

static void lf(void) {
   if (y+1<bottom) {
      y++;
      pos += video_size_row;
      return;
   }
 ...
}

相似的,我们还可以实现滚屏的效果,无非就是当检测到光标已经出现在最后一行最后一列了,那就把每一行的字符,都复制到它上一行,其实就是算好哪些内存地址上的值,拷贝到哪些内存地址,就好了。

这里大家自己看源码寻找。

所以,有了这个初始化工作,我们就可以利用这些信息,弄几个小算法,实现各种我们常见控制台的操作。

或者换句话说,我们见惯不怪的控制台,回车、换行、删除、滚屏、清屏等操作,其实底层都要实现相应的代码的。

所以 console.c 中的其他方法就是做这个事的,我们就不展开每一个功能的方法体了,简单看看有哪些方法。

// 定位光标的
static inline void gotoxy(unsigned int new_x, unsigned int new_y){}
// 滚屏,即内容向上滚动一行
static void scrup(void){}
// 光标同列位置下移一行
static void lf(int currcons){}
// 光标回到第一列
static void cr(void){}
...
// 删除一行
static void delete_line(void){}

2. 当键盘输入一条命令时–键盘执行逻辑

我们就从按下键盘上的 ‘c’ 键开始说起。
根据上文我们可知,首先,得益 tty_init 中讲述的一行代码。

// console.c
void con_init(void) {
    ...
    set_trap_gate(0x21,&keyboard_interrupt);
    ...
}

我们成功将键盘中断绑定在了 keyboard_interrupt 这个中断处理函数上,也就是说当我们按下键盘 ‘c’ 时,CPU 的中断机制将会被触发,最终执行到这个 keyboard_interrupt 函数中。

// keyboard.s
keyboard_interrupt:
    ...
    // 读取键盘扫描码
    inb $0x60,%al
    ...
    // 调用对应按键的处理函数
    call *key_table(,%eax,4)
    ...
    // 0 作为参数,调用 do_tty_interrupt
    pushl $0
    call do_tty_interrupt
    ...

随后,在 key_table 中寻找不同按键对应的不同处理函数,比如普通的一个字母对应的字符 ‘c’ 的处理函数为 do_self,该函数会将扫描码转换为 ASCII 字符码,并将自己放入一个队列里,我们稍后再说这部分的细节。

接下来,就是调用 do_tty_interrupt 函数,见名知意就是处理终端的中断处理函数,注意这里传递了一个参数 0。

我们接着探索,打开 do_tty_interrupt 函数。

// tty_io.c
void do_tty_interrupt(int tty) {
    copy_to_cooked(tty_table+tty);
}

void copy_to_cooked(struct tty_struct * tty) {
    ...
}

这个函数几乎什么都没做,将 keyboard_interrupt 时传入的参数 0,作为 tty_table 的索引,找到 tty_table 中的第 0 项作为下一个函数的入参,仅此而已。
ty_table 是终端设备表,在 Linux 0.11 中定义了三项,分别是控制台、串行终端 1 和串行终端 2。

// tty.h
struct tty_struct tty_table[] = {
    {
        {...},
        0,          /* initial pgrp */
        0,          /* initial stopped */
        con_write,
        {0,0,0,0,""},       /* console read-queue */
        {0,0,0,0,""},       /* console write-queue */
        {0,0,0,0,""}        /* console secondary queue */
    },
    {...},
    {...}
};

我们用的往屏幕上输出内容的终端,就是 0 号索引位置处的控制台终端,所以我将另外两个终端定义的代码省略掉了。

tty_table 终端设备表中的每一项结构,是 tty_struct,用来描述一个终端的属性。

struct tty_struct {
    struct termios termios;
    int pgrp;
    int stopped;
    void (*write)(struct tty_struct * tty);
    struct tty_queue read_q;
    struct tty_queue write_q;
    struct tty_queue secondary;
};

struct tty_queue {
    unsigned long data;
    unsigned long head;
    unsigned long tail;
    struct task_struct * proc_list;
    char buf[TTY_BUF_SIZE];
};

说说其中较为关键的几个。

termios 是定义了终端的各种模式,包括读模式、写模式、控制模式等,这个之后再说。

void (*write)(struct tty_struct * tty) 是一个接口函数,在刚刚的 tty_table 中我们也可以看出被定义为了 con_write,也就是说今后我们调用这个 0 号终端的写操作时,将会调用的是这个 con_write 函数,这不就是接口思想么。

还有三个队列分别为读队列 read_q,写队列 write_q 以及一个辅助队列 secondary。

这些有什么用,我们通通之后再说,跟着我接着看。

// tty_io.c
void do_tty_interrupt(int tty) {
    copy_to_cooked(tty_table+tty);
}

void copy_to_cooked(struct tty_struct * tty) {
    signed char c;
    while (!EMPTY(tty->read_q) && !FULL(tty->secondary)) {
        // 从 read_q 中取出字符
        GETCH(tty->read_q,c);
        ...
        // 这里省略了一大坨行规则处理代码
        ...
        // 将处理过后的字符放入 secondary
        PUTCH(c,tty->secondary);
    }
    wake_up(&tty->secondary.proc_list);
}

展开 copy_to_cooked 函数我们发现,一个大体的框架已经有了。

在 copy_to_cooked 函数里就是个大循环,只要读队列 read_q 不为空,且辅助队列 secondary 没有满,就不断从 read_q 中取出字符,经过一大坨的处理,写入 secondary 队列里。
在这里插入图片描述
否则,就唤醒等待这个辅助队列 secondary 的进程,之后怎么做就由进程自己决定。

我们接着看,中间的一大坨处理过程做了什么事情呢?

这一大坨有太多太多的 if 判断,但都是围绕着同一个目的,我们举其中一个简单的例子。

#define IUCLC   0001000
#define _I_FLAG(tty,f)  ((tty)->termios.c_iflag & f)
#define I_UCLC(tty) _I_FLAG((tty),IUCLC)

void copy_to_cooked(struct tty_struct * tty) {
    ...
    // 这里省略了一大坨行规则处理代码
    if (I_UCLC(tty))
        c=tolower(c);
    ...
}

简单说,就是通过判断 tty 中的 termios,来决定对读出的字符 c 做一些处理。

在这里,就是判断 termios 中的 c_iflag 中的第 4 位是否为 1,来决定是否要将读出的字符 c 由大写变为小写。

这个 termios 就是定义了终端的模式。

struct termios {
    unsigned long c_iflag;      /* input mode flags */
    unsigned long c_oflag;      /* output mode flags */
    unsigned long c_cflag;      /* control mode flags */
    unsigned long c_lflag;      /* local mode flags */
    unsigned char c_line;       /* line discipline */
    unsigned char c_cc[NCCS];   /* control characters */
};

比如刚刚的是否要将大写变为小写,是否将回车字符替换成换行字符,是否接受键盘控制字符信号如 ctrl + c 等。

这些模式不是 Linux 0.11 自己乱想出来的,而是实现了 POSIX.1 中规定的 termios 标准,具体可以参见:

https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap11.html#tag_11

在这里插入图片描述
好了,我们目前可以总结出,按下键盘后做了什么事情。

一、读队列 read_q 里的字符是什么时候放进去的?

还记不记得最开始讲的 keyboard_interrupt 函数,我们有一个方法没有展开讲。

// keyboard.s
keyboard_interrupt:
    ...
    // 读取键盘扫描码
    inb $0x60,%al
    ...
    // 调用对应按键的处理函数
    call *key_table(,%eax,4)
    ...
    // 0 作为参数,调用 do_tty_interrupt
    pushl $0
    call do_tty_interrupt
    ...

就是这个 key_table,我们将其展开。

// keyboard.s
key_table:
    .long none,do_self,do_self,do_self  /* 00-03 s0 esc 1 2 */
    .long do_self,do_self,do_self,do_self   /* 04-07 3 4 5 6 */
    ...
    .long do_self,do_self,do_self,do_self   /* 20-23 d f g h */
    ...

可以看出,普通的字符 abcd 这种,对应的处理函数是 do_self,我们再继续展开。

// keyboard.s
do_self:
    ...
    // 扫描码转换为 ASCII 码
    lea key_map,%ebx
    1: movb (%ebx,%eax),%al
    ...
    // 放入队列
    call put_queue

可以看到最后调用了 put_queue 函数,顾名思义放入队列,看来我们要找到答案了,继续展开。

// tty_io.c
struct tty_queue * table_list[]={
    &tty_table[0].read_q, &tty_table[0].write_q,
    &tty_table[1].read_q, &tty_table[1].write_q,
    &tty_table[2].read_q, &tty_table[2].write_q
};

// keyboard.s
put_queue:
    ...
    movl table_list,%edx # read-queue for console
    movl head(%edx),%ecx
    ...

可以看出,put_queue 正是操作了我们 tty_table 数组中的零号位置,也就是控制台终端 tty 的 read_q 队列,进行入队操作。

答案揭晓了,那我们的整体流程图也可以再丰富起来。
在这里插入图片描述
二、放入 secondary 队列之后呢?

按下键盘后,一系列代码将我们的字符放入了 secondary 队列中,然后呢?

这就涉及到上层进程调用终端的读函数,将这个字符取走了。

上层经过库函数、文件系统函数等,最终会调用到 tty_read 函数,将字符从 secondary 队列里取走。

// tty_io.c
int tty_read(unsigned channel, char * buf, int nr) {
    ...
    GETCH(tty->secondary,c);
    ...
}

取走后要干嘛,那就是上层应用程序去决定的事情了。

假如要写到控制台终端,那上层应用程序又会经过库函数、文件系统函数等层层调用,最终调用到 tty_write 函数。

// tty_io.
int tty_write(unsigned channel, char * buf, int nr) {
    ...
    PUTCH(c,tty->write_q);
    ...
    tty->write(tty);
    ...
}

这个函数首先会将字符 c 放入 write_q 这个队列,然后调用 tty 里设定的 write 函数。

终端控制台这个 tty 我们之前说了,初始化的 write 函数是 con_write,也就是 console 的写函数。

// console.c
void con_write(struct tty_struct * tty) {
      ...
}

这个函数之前的 tty_init 提到了,最终会配合显卡,在我们的屏幕上输出我们给出的字符。
那我们的图又可以补充了。
在这里插入图片描述
核心点就是三个队列 read_q,secondary 以及 write_q。

其中 read_q 是键盘按下按键后,进入到键盘中断处理程序 keyboard_interrupt 里,最终通过 put_queue 函数字符放入 read_q 这个队列。

secondary 是 read_q 队列里的未处理字符,通过 copy_to_cooked 函数,经过一定的 termios 规范处理后,将处理过后的字符放入 secondary。(处理过后的字符就是成"熟"的字符,所以叫 cooked,是不是很形象?)

然后,进程通过 tty_read 从 secondary 里读字符,通过 tty_write 将字符写入 write_q,最终 write_q 中的字符可以通过 con_write 这个控制台写函数,将字符打印在显示器上。

这就完成了从键盘输入到显示器输出的一个循环,也就是本回所讲述的内容。
整个流程也可以用PPT上的图来表示:

在这里插入图片描述
键盘中断初始化

键盘中断的初始化在boot/main.c文件的init()函数中,在这个函数中会调用tty_init()函数对显卡的变量等进行设置(如:video_size_row,vide_mem_start,video_port_reg,video_port_val,video_mem_end等等),并对键盘中断0x21设置,将中断过程指向keyboard_interrupt函数(这是通过set_trap_gate(0x21, &keyboard_interrupt)语句来实现的。

键盘中断发生

当键盘中断发生时,intb $0x60,%al 指令会取出键盘的扫描码,放在al寄存器中,然后查key_table表进行扫描码处理,key_table表中定义了相关所有扫描码对应键的处理函数(如do_self,func,alt,ctrl,none等),比如f1-f12键的处理则要先运行一段处理函数func,调用sched.c中定义的show_stat函数显示当前进程情况,默认情况linux-0.11中所有的功能键都进行这样的动作,然后下一步将控制键以及功能键进行转义,比如ctrl_a将会被转义为^a,f1会被转义为esc[[A,f2被转义为esc[[B,而其他键也经过类似处理,处理完毕后将跳到put_queue,将扫描码放在键盘输入队列中,当然如果没有对应的扫描码被找到,则会通过none标签直接退回,不会被放入队列。然后再调用do_tty_interrupt函数进行最后的处理。do_tty_interrupt直接调用copy_to_cooked函数对队列中的字符进行判断处理,最后调用con_write函数将其输出到显卡内存。在此同时,也会将字符放入辅助队列中,以备缓冲区读写程序使用。

字符输出流程

无论是输出字符到屏幕上还是文件中,系统最终都会调用write函数来进行,而write函数则会调用sys_write系统调用来实现字符输出,如果是输出到屏幕上,则会调用tty_write函数,而tty_write最终也会调用con_write函数将字符输出;如果是输出到文件中,则会调用file_write函数直接将字符输出到指定的文件缓冲区中。所以无论是键盘输入的回显,还是程序的字符输出,那只需要修改con_write最终写到显存中的字符就可以了。但如果要控制字符输出到文件中,则需要修改file_write函数中输出到文件缓冲区中的字符即可。

实验实现

先说实验思路,根据李老师在课程里的讲述

  1. 当用户按下F12时,相应的中断函数应该会跳转执行F12对应的功能处理函数 fun12
  2. 根据实验要求,按下F12会进行f12模式切换,这个模式可以用一个flag_12表示,当flag = 1则正常,flag = 0则全部打印*。那么模式切换函数就应当放在fun12中
  3. 接下来根据上述的讲解我们就只需要修改con_write函数即可,当flag表示我们处在特殊字符时,那么write_q中取出的正常字符,我们都转换成星号即可。如果是正常模式,那么就不执行星号的转换就可以了
    所以就只需要实现F12函数以及修改con_write函数:

https://blog.csdn.net/weixin_43166958/article/details/104194920

/*fun f12*/
int switch_show_char_flag = 0;
void press_f12_handle(void)
{
	if (switch_show_char_flag == 0)
	{
		switch_show_char_flag = 1;
	}
	else if (switch_show_char_flag == 1)
	{
		switch_show_char_flag = 0;
	}
}

con_write函数的部分修改如下:

case 0:
				if (c>31 && c<127) {
					if (x>=video_num_columns) {
						x -= video_num_columns;
						pos -= video_size_row;
						lf();
					}

  				if (switch_show_char_flag == 1)
  				{
  					if((c>='A'&&c<='Z')||(c>='a'&&c<='z')||(c>='0'&&c<='9'))
  						c = '*';
  				}
					
					__asm__("movb attr,%%ah\n\t"
						"movw %%ax,%1\n\t"
						::"a" (c),"m" (*(short *)pos)
						);
					pos += 2;
					x++;

这样实验就完成了,整体的实验不难,需要了解键盘与显示器之间的逻辑关系。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值