操作系统真象还原实验记录之实验二十四:创建并挂载文件系统

本文介绍了一个实验过程,包括如何在指定分区上创建文件系统,并将其挂载以便操作系统使用。实验涵盖了文件系统的基本概念、创建步骤以及挂载分区的方法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

操作系统真象还原实验记录之实验二十四:创建并挂载文件系统

1.文件系统基础知识

在这里插入图片描述

在这里插入图片描述
一个分区就对应着windows下的一个盘,

一开始购买的笔记本,第一次开机后会发现只有一个c盘,意味着这个盘有一块固定的地方永远存着操作系统,loader.s知道os的位置,整个盘只有一个分区,mbr、loader.s也已经被填入这个分区对应位置,同时这个分区文件系统已经创建同时被挂载。这个盘也被称为启动盘。
因此要把裸盘变成一个启动盘,里面的MBR.s、loader.s需要提前写入,os要提前刻在盘中,刻的位置要和loader.s配合好。然后分区表也要提前写好,也就是一个分区表项。

本操作系统启动盘是seven60.img,有MBR.S,loader.s,但是未分区。
另一个盘seven80.img有分区。
未分区意味着没法创建文件系统。所以seven60.img只能用来跑和存操作系统,创建seven80.img的文件系统。

对这个c盘分区成d盘、e盘、f盘,意味着修改了分区表,增加了三个分区,同时每个分区创建了文件系统,而且都挂载了,也就是它们的超级块都在内存。
所以c、d、e、f用的虽然是同一个文件系统windows,但是里面的实时数据是完全不一样的,也就是他们的超级块是一样的,操作inode的内核函数是一样的,但是各种位图,inode数组是不一样的。所以c盘里的文件和d盘是完全隔离的。

创建文件系统,也就是填写每个分区的超级块,以及根目录的两个目录项。

每个分区都可以有自己的操作系统、文件系统。

根目录文件地址规定死,记录在超级块中。

在这里插入图片描述
在这里插入图片描述

目录文件里有目录项,一个目录项对应一个文件,它的inode指针就是指向该文件。
这个文件可以是目录文件,里面继续装FCB,也可以是记事本这样的真正的文件。

inode里面有直接、一级、二级等索引指针,指向磁盘扇区这几乎是408必考。

2.创建文件系统在这里插入图片描述

创建文件系统本质就是填写上述图的相关元信息,
本次实验创建了1个主分区,5个逻辑分区的文件系统,
也就是在内存写好了超级块结构体所有信息,
空闲块位图出了第一个位”置1“用作根目录,最后一个扇区多余的部分全部置1,其余全部置0,
inode位图只占一个扇区,共4096个inode结点,所以只需要第一位置1表0号inode结点指向根目录即可,
inode数组写好0号inode结点即可,
最后依次写入磁盘,
机完成创建文件系统。

2.1 super_block.h

#ifndef __FS_SUPER_BLOCK_H
#define __FS_SUPER_BLOCK_H
#include "stdint.h"

/* 超级块 */
struct super_block {
   uint32_t magic;		    // 用来标识文件系统类型,支持多文件系统的操作系统通过此标志来识别文件系统类型
   uint32_t sec_cnt;		    // 本分区总共的扇区数
   uint32_t inode_cnt;		    // 本分区中inode数量
   uint32_t part_lba_base;	    // 本分区的起始lba地址

   uint32_t block_bitmap_lba;	    // 块位图本身起始扇区地址
   uint32_t block_bitmap_sects;     // 扇区位图本身占用的扇区数量

   uint32_t inode_bitmap_lba;	    // i结点位图起始扇区lba地址
   uint32_t inode_bitmap_sects;	    // i结点位图占用的扇区数量

   uint32_t inode_table_lba;	    // i结点表起始扇区lba地址
   uint32_t inode_table_sects;	    // i结点表占用的扇区数量

   uint32_t data_start_lba;	    // 数据区开始的第一个扇区号
   uint32_t root_inode_no;	    // 根目录所在的I结点号
   uint32_t dir_entry_size;	    // 目录项大小

   uint8_t  pad[460];		    // 加上460字节,凑够512字节1扇区大小
} __attribute__ ((packed));
#endif

2.2 inode.h

#ifndef __FS_INODE_H
#define __FS_INODE_H
#include "stdint.h"
#include "list.h"
#include "ide.h"

/* inode结构 */
struct inode {
   uint32_t i_no;    // inode编号

/* 当此inode是文件时,i_size是指文件大小,
若此inode是目录,i_size是指该目录下所有目录项大小之和*/
   uint32_t i_size;

   uint32_t i_open_cnts;   // 记录此文件被打开的次数
   bool write_deny;	   // 写文件不能并行,进程写文件前检查此标识

/* i_sectors[0-11]是直接块, i_sectors[12]用来存储一级间接块指针 */
   uint32_t i_sectors[13];
   struct list_elem inode_tag;
};


#endif

假如根目录下有一个文件一个文件夹
文件和文件夹(目录)都一定有一个它上一级的目录项dir_entry,一个与它对应的inode
dir_entry里面有它的名字、类型和它在inode数组的索引i_no,它的inode有具体存储它的扇区地址
当它是目录时,存的就是下一级的目录项dir_entry
初始化根目录占第一个扇区,里面存有两个下一级目录项“./”和". ./",均指向0号inode,也就是根目录本身。
根目录没有上一级目录项,但是有0号inode。

2.3 dir.h

#ifndef __FS_DIR_H
#define __FS_DIR_H
#include "stdint.h"
#include "inode.h"
#include "fs.h"
#include "ide.h"
#include "global.h"

#define MAX_FILE_NAME_LEN  16	 // 最大文件名长度

/* 目录结构 */
struct dir {
   struct inode* inode;   
   uint32_t dir_pos;	  // 记录在目录内的偏移
   uint8_t dir_buf[512];  // 目录的数据缓存
};

/* 目录项结构 */
struct dir_entry {
   char filename[MAX_FILE_NAME_LEN];  // 普通文件或目录名称
   uint32_t i_no;		      // 普通文件或目录对应的inode编号
   enum file_types f_type;	      // 文件类型
};
#endif

2.4 fs.h

#ifndef __FS_FS_H
#define __FS_FS_H
#include "stdint.h"

#define MAX_FILES_PER_PART 4096	    // 每个分区所支持最大创建的文件数
#define BITS_PER_SECTOR 4096	    // 每扇区的位数
#define SECTOR_SIZE 512		    // 扇区字节大小
#define BLOCK_SIZE SECTOR_SIZE	    // 块字节大小

/* 文件类型 */
enum file_types {
   FT_UNKNOWN,	  // 不支持的文件类型
   FT_REGULAR,	  // 普通文件
   FT_DIRECTORY	  // 目录
};
#endif

2.5 fs.c之partition_format

static void partition_format(struct partition* part) {
/* 为方便实现,一个块大小是一扇区 */
   uint32_t boot_sector_sects = 1;	  
   uint32_t super_block_sects = 1;
   uint32_t inode_bitmap_sects = DIV_ROUND_UP(MAX_FILES_PER_PART, BITS_PER_SECTOR);	   // I结点位图占用的扇区数.最多支持4096个文件
   uint32_t inode_table_sects = DIV_ROUND_UP(((sizeof(struct inode) * MAX_FILES_PER_PART)), SECTOR_SIZE);
   uint32_t used_sects = boot_sector_sects + super_block_sects + inode_bitmap_sects + inode_table_sects;
   uint32_t free_sects = part->sec_cnt - used_sects;  

/************** 简单处理块位图占据的扇区数 ***************/
   uint32_t block_bitmap_sects;
   block_bitmap_sects = DIV_ROUND_UP(free_sects, BITS_PER_SECTOR);
   /* block_bitmap_bit_len是位图中位的长度,也是可用块的数量 */
   uint32_t block_bitmap_bit_len = free_sects - block_bitmap_sects; 
   block_bitmap_sects = DIV_ROUND_UP(block_bitmap_bit_len, BITS_PER_SECTOR); 
/*********************************************************/
   
   /* 超级块初始化 */
   struct super_block sb;
   sb.magic = 0x19590318;
   sb.sec_cnt = part->sec_cnt;
   sb.inode_cnt = MAX_FILES_PER_PART;
   sb.part_lba_base = part->start_lba;

   sb.block_bitmap_lba = sb.part_lba_base + 2;	 // 第0块是引导块,第1块是超级块
   sb.block_bitmap_sects = block_bitmap_sects;

   sb.inode_bitmap_lba = sb.block_bitmap_lba + sb.block_bitmap_sects;
   sb.inode_bitmap_sects = inode_bitmap_sects;

   sb.inode_table_lba = sb.inode_bitmap_lba + sb.inode_bitmap_sects;
   sb.inode_table_sects = inode_table_sects; 

   sb.data_start_lba = sb.inode_table_lba + sb.inode_table_sects;
   sb.root_inode_no = 0;
   sb.dir_entry_size = sizeof(struct dir_entry);

   printk("%s info:\n", part->name);
   printk("   magic:0x%x\n   part_lba_base:0x%x\n   all_sectors:0x%x\n   inode_cnt:0x%x\n   block_bitmap_lba:0x%x\n   block_bitmap_sectors:0x%x\n   inode_bitmap_lba:0x%x\n   inode_bitmap_sectors:0x%x\n   inode_table_lba:0x%x\n   inode_table_sectors:0x%x\n   data_start_lba:0x%x\n", sb.magic, sb.part_lba_base, sb.sec_cnt, sb.inode_cnt, sb.block_bitmap_lba, sb.block_bitmap_sects, sb.inode_bitmap_lba, sb.inode_bitmap_sects, sb.inode_table_lba, sb.inode_table_sects, sb.data_start_lba);

   struct disk* hd = part->my_disk;
/*******************************
 * 1 将超级块写入本分区的1扇区 *
 ******************************/
   ide_write(hd, part->start_lba + 1, &sb, 1);
   printk("   super_block_lba:0x%x\n", part->start_lba + 1);

/* 找出数据量最大的元信息,用其尺寸做存储缓冲区*/
   uint32_t buf_size = (sb.block_bitmap_sects >= sb.inode_bitmap_sects ? sb.block_bitmap_sects : sb.inode_bitmap_sects);
   buf_size = (buf_size >= sb.inode_table_sects ? buf_size : sb.inode_table_sects) * SECTOR_SIZE;
   uint8_t* buf = (uint8_t*)sys_malloc(buf_size);	// 申请的内存由内存管理系统清0后返回
   
/**************************************
 * 2 将块位图初始化并写入sb.block_bitmap_lba *
 *************************************/
   /* 初始化块位图block_bitmap */
   buf[0] |= 0x01;       // 第0个块预留给根目录,位图中先占位
   uint32_t block_bitmap_last_byte = block_bitmap_bit_len / 8;
   uint8_t  block_bitmap_last_bit  = block_bitmap_bit_len % 8;
   uint32_t last_size = SECTOR_SIZE - (block_bitmap_last_byte % SECTOR_SIZE);	     // last_size是位图所在最后一个扇区中,不足一扇区的其余部分

   /* 1 先将位图最后一字节到其所在的扇区的结束全置为1,即超出实际块数的部分直接置为已占用*/
   memset(&buf[block_bitmap_last_byte], 0xff, last_size);
   
   /* 2 再将上一步中覆盖的最后一字节内的有效位重新置0 */
   uint8_t bit_idx = 0;
   while (bit_idx <= block_bitmap_last_bit) {
      buf[block_bitmap_last_byte] &= ~(1 << bit_idx++);
   }
   ide_write(hd, sb.block_bitmap_lba, buf, sb.block_bitmap_sects);

/***************************************
 * 3 将inode位图初始化并写入sb.inode_bitmap_lba *
 ***************************************/
   /* 先清空缓冲区*/
   memset(buf, 0, buf_size);
   buf[0] |= 0x1;      // 第0个inode分给了根目录
   /* 由于inode_table中共4096个inode,位图inode_bitmap正好占用1扇区,
    * 即inode_bitmap_sects等于1, 所以位图中的位全都代表inode_table中的inode,
    * 无须再像block_bitmap那样单独处理最后一扇区的剩余部分,
    * inode_bitmap所在的扇区中没有多余的无效位 */
   ide_write(hd, sb.inode_bitmap_lba, buf, sb.inode_bitmap_sects);

/***************************************
 * 4 将inode数组初始化并写入sb.inode_table_lba *
 ***************************************/
 /* 准备写inode_table中的第0项,即根目录所在的inode */
   memset(buf, 0, buf_size);  // 先清空缓冲区buf
   struct inode* i = (struct inode*)buf; 
   i->i_size = sb.dir_entry_size * 2;	 // .和..
   i->i_no = 0;   // 根目录占inode数组中第0个inode
   i->i_sectors[0] = sb.data_start_lba;	     // 由于上面的memset,i_sectors数组的其它元素都初始化为0 
   ide_write(hd, sb.inode_table_lba, buf, sb.inode_table_sects);
   
/***************************************
 * 5 将根目录初始化并写入sb.data_start_lba
 ***************************************/
   /* 写入根目录的两个目录项.和.. */
   memset(buf, 0, buf_size);
   struct dir_entry* p_de = (struct dir_entry*)buf;

   /* 初始化当前目录"." */
   memcpy(p_de->filename, ".", 1);
   p_de->i_no = 0;
   p_de->f_type = FT_DIRECTORY;
   p_de++;

   /* 初始化当前目录父目录".." */
   memcpy(p_de->filename, "..", 2);
   p_de->i_no = 0;   // 根目录的父目录依然是根目录自己
   p_de->f_type = FT_DIRECTORY;

   /* sb.data_start_lba已经分配给了根目录,里面是根目录的目录项 */
   ide_write(hd, sb.data_start_lba, buf, 1);

   printk("   root_dir_lba:0x%x\n", sb.data_start_lba);
   printk("%s format done\n", part->name);
   sys_free(buf);
}

partition_format这个函数用于在内存填写超级块、空闲块位图、inode位图,inode数组。然后写入磁盘。
超级块、inode位图大小都是1个扇区,可以用作在partition_format里声明局部变量,这样他们占用的就是栈空间,勉强够用。
但是空闲块位图有几百扇区,所以要用sys_malloc来申请内存。

超级块结构体的填写中,空闲块位图大小计算逻辑如下:
一个分区最多4096个文件,所以支持4096个inode结点,这样inode位图大小就是1扇区、inode数组大小也固定了。
根目录属于空闲块区域。
所以剩余空闲块数a就等于分区总块数 - 1 - 1 - 1- inode数组块数。
以a再来计算空闲块位图大小A
A = a / 4096 向上取整。
这样A一定会有多余位。
再用a - A得到剩余空闲块数b
再利用b来计算空闲块位图大小B
B = b / 4096 向上取整。
B也会有多余位。
综上,
也就是说,把b当成可用空闲块数,把B当成空闲块位图。
显然b + B < a的,会有一点浪费,但是B仍然会有多余位需要处理成1。

处理完超级块,还初始化了inode位图第一位,inode数组第一个指向根目录起始扇区地址。然后填写了根目录两个
目录项 “.” 、“…”。

2.6 filesys_init

#include "fs.h"
#include "super_block.h"
#include "inode.h"
#include "dir.h"
#include "stdint.h"
#include "stdio-kernel.h"
#include "list.h"
#include "string.h"
#include "ide.h"
#include "global.h"
#include "debug.h"
#include "memory.h"
#include "file.h"
#include "console.h"
#include "keyboard.h"
#include "ioqueue.h"


/* 在磁盘上搜索文件系统,若没有则格式化分区创建文件系统 */
void filesys_init() {
   uint8_t channel_no = 0, dev_no, part_idx = 0;

   /* sb_buf用来存储从硬盘上读入的超级块 */
   struct super_block* sb_buf = (struct super_block*)sys_malloc(SECTOR_SIZE);

   if (sb_buf == NULL) {
      PANIC("alloc memory failed!");
   }
   printk("searching filesystem......\n");
   while (channel_no < channel_cnt) {
      dev_no = 0;
      while(dev_no < 2) {
	 if (dev_no == 0) {   // 跨过裸盘hd60M.img
	    dev_no++;
	    continue;
	 }
	 struct disk* hd = &channels[channel_no].devices[dev_no];
	 struct partition* part = hd->prim_parts;
	 while(part_idx < 12) {   // 4个主分区+8个逻辑
	    if (part_idx == 4) {  // 开始处理逻辑分区
	       part = hd->logic_parts;
	    }
	 
	 /* channels数组是全局变量,默认值为0,disk属于其嵌套结构,
	  * partition又为disk的嵌套结构,因此partition中的成员默认也为0.
	  * 若partition未初始化,则partition中的成员仍为0. 
	  * 下面处理存在的分区. */
	    if (part->sec_cnt != 0) {  // 如果分区存在
	       memset(sb_buf, 0, SECTOR_SIZE);

	       /* 读出分区的超级块,根据魔数是否正确来判断是否存在文件系统 */
	       ide_read(hd, part->start_lba + 1, sb_buf, 1);   

	       /* 只支持自己的文件系统.若磁盘上已经有文件系统就不再格式化了 */
	       if (sb_buf->magic == 0x19590318) {
		  printk("%s has filesystem\n", part->name);
	       } else {			  // 其它文件系统不支持,一律按无文件系统处理
		  printk("formatting %s`s partition %s......\n", hd->name, part->name);
		  partition_format(part);
	       }
	    }
	    part_idx++;
	    part++;	// 下一分区
	 }
	 dev_no++;	// 下一磁盘
      }
      channel_no++;	// 下一通道
   }
   sys_free(sb_buf);
}

遍历所有通道(本次实验就一个)
跳过Seven60.img主盘,遍历了Seven80.img的所有分区,分别建立了文件系统并打印。

每个分区先ide_read读出第二个扇区即超级块,如果超级块魔数存在,表示超级块存在,那么文件系统存在,否则调用partition_format来给分区创建文件系统,完成分区的格式化。

2.7 实验结果

在这里插入图片描述
在这里插入图片描述
第二次运行,读每个分区的超级块于内存,发现超级块已经有了魔数,
说明所有的分区信息已经写入磁盘,也就是文件系统已经创建。

3. 挂载分区

正常情况下安装操作系统,无论是Windows还是Linux,安装的过程都是先选择将os安装到哪一个分区上,然后选择以什么文件系统来格式化该分区。Windows通常是fat32、ntfs;Linux通常是ext2或ext3。该分区格式化出文件系统后才能安装操作系统。
我们实验的Seven.img没有文件系统,loader.s就开始安装os了。
因此这次实验挂载分区,只需要选择待挂载的分区,将该分区文件系统的元信息从硬盘上读到内存,如果有写操作,还要及时将元信息同步写回磁盘。

3.1 filesys_init增加

void filesys_init(){
	...略
   /* 确定默认操作的分区 */
   char default_part[8] = "sdb1";
   /* 挂载分区 */
   list_traversal(&partition_list, mount_partition, (int)default_part);
}

首先回顾一下list_traversal

struct list_elem* list_traversal(struct list* plist, function func, int arg) {
   struct list_elem* elem = plist->head.next;
/* 如果队列为空,就必然没有符合条件的结点,故直接返回NULL */
   if (list_empty(plist)) { 
      return NULL;
   }

   while (elem != &plist->tail) {
      if (func(elem, arg)) {		  // func返回ture则认为该元素在回调函数中符合条件,命中,故停止继续遍历
	 return elem;
      }					  // 若回调函数func返回true,则继续遍历
      elem = elem->next;	       
   }
   return NULL;
}

list_traversal,遍历plist中所有元素,直到func(elem, arg)返回true。
在这次试验,就是遍历5个分区,将每个分区指针作为elem,默认分区名"sdbl"作为mount_partition的参数。

3.2 mount_partition函数

struct partition* cur_part;	 // 默认情况下操作的是哪个分区

static bool mount_partition(struct list_elem* pelem, int arg) {
   char* part_name = (char*)arg;
   struct partition* part = elem2entry(struct partition, part_tag, pelem);
   if (!strcmp(part->name, part_name)) {
      cur_part = part;
      struct disk* hd = cur_part->my_disk;

      /* sb_buf用来存储从硬盘上读入的超级块 */
      struct super_block* sb_buf = (struct super_block*)sys_malloc(SECTOR_SIZE);

      /* 在内存中创建分区cur_part的超级块 */
      cur_part->sb = (struct super_block*)sys_malloc(sizeof(struct super_block));
      if (cur_part->sb == NULL) {
	 PANIC("alloc memory failed!");
      }

      /* 读入超级块 */
      memset(sb_buf, 0, SECTOR_SIZE);
      ide_read(hd, cur_part->start_lba + 1, sb_buf, 1);   

      /* 把sb_buf中超级块的信息复制到分区的超级块sb中。*/
      memcpy(cur_part->sb, sb_buf, sizeof(struct super_block)); 

      /**********     将硬盘上的块位图读入到内存    ****************/
      cur_part->block_bitmap.bits = (uint8_t*)sys_malloc(sb_buf->block_bitmap_sects * SECTOR_SIZE);
      if (cur_part->block_bitmap.bits == NULL) {
	 PANIC("alloc memory failed!");
      }
      cur_part->block_bitmap.btmp_bytes_len = sb_buf->block_bitmap_sects * SECTOR_SIZE;
      /* 从硬盘上读入块位图到分区的block_bitmap.bits */
      ide_read(hd, sb_buf->block_bitmap_lba, cur_part->block_bitmap.bits, sb_buf->block_bitmap_sects);   
      /*************************************************************/

      /**********     将硬盘上的inode位图读入到内存    ************/
      cur_part->inode_bitmap.bits = (uint8_t*)sys_malloc(sb_buf->inode_bitmap_sects * SECTOR_SIZE);
      if (cur_part->inode_bitmap.bits == NULL) {
	 PANIC("alloc memory failed!");
      }
      cur_part->inode_bitmap.btmp_bytes_len = sb_buf->inode_bitmap_sects * SECTOR_SIZE;
      /* 从硬盘上读入inode位图到分区的inode_bitmap.bits */
      ide_read(hd, sb_buf->inode_bitmap_lba, cur_part->inode_bitmap.bits, sb_buf->inode_bitmap_sects);   
      /*************************************************************/

      list_init(&cur_part->open_inodes);
      printk("mount %s done!\n", part->name);

   /* 此处返回true是为了迎合主调函数list_traversal的实现,与函数本身功能无关。
      只有返回true时list_traversal才会停止遍历,减少了后面元素无意义的遍历.*/
      return true;
   }
   return false;     // 使list_traversal继续遍历
}

mount_partition函数
如果该分区pelem的分区名不是默认分区名,那么继续遍历,否则让全局变量cur_part这个分区指针指向该分区,并开始挂载。

回忆一下分区结构

/* 分区结构 */
struct partition {
   uint32_t start_lba;		 // 起始扇区
   uint32_t sec_cnt;		 // 扇区数
   struct disk* my_disk;	 // 分区所属的硬盘
   struct list_elem part_tag;	 // 用于队列中的标记
   char name[8];		 // 分区名称
   struct super_block* sb;	 // 本分区的超级块
   struct bitmap block_bitmap;	 // 块位图
   struct bitmap inode_bitmap;	 // i结点位图
   struct list open_inodes;	 // 本分区打开的i结点队列
};

我们知道,主板一共两个通道,一个通道两个硬盘。
我们的实验模拟了一个通道,主盘Seven.img未分区,从盘Seven80.img有一个主分区,5个逻辑分区。
然后,我们的内存是定义了通道、磁盘、分区的结构体的。并且有全局变量通道数组
上次实验,我们对内存的每个分区都调用了partition_scan,
partition_scan遍历了每个分区的分区表项,
将五个分区结构体里面的前五个成员变量进行了初始化。
所以剩下三个:
struct super_block* sb; // 本分区的超级块
struct bitmap block_bitmap; // 块位图
struct bitmap inode_bitmap; // i结点位图
还未填写。
其实上述三个信息,在创建文件系统中已经记录在了磁盘上每个分区中。
所以这次挂载文件系统,就是读出默认分区的信息,填写内存中默认分区结构体这三个元素。
也就是说,内存里六个分区结构体,哪一个分区结构体剩下三个信息被补充完整,哪个分区的文件系统就被挂载。

要补充这三个元素:
mount_partition申请了三块内存存储超级块、空闲块位图、inode结点位图。

最后被挂载的分区,是由全局变量cur_part指明的。

3.3 实验结果

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值