Linux基础IO

📌Linux下的文件概述

狭义上,文件指的就是磁盘上的文件,又因为磁盘是外设,对文件的操作本质上可以抽象为对外设的输入输出,简称IO。

广义上,linux下一切皆文件,因为linux的所有内容都以文件的形式进行存储,包括把硬件设备(键盘,显示器,网卡,键盘等)都抽象为文件。

linux下用结构体描述文件,所以在操作系统的角度看到的都是一个个的结构体。

对于文件操作,我们应该知道一个空文件也占用了磁盘空间,因为文件不仅仅是内容,还有其自身的属性,即文件=内容+属性。所以对文件的操作本质上无外乎两种操作,一种是操作内容,一种是操作属性。

我们以前在语言上学的文件更加关注于操作文件的内容,比如读写文件。以C语言为例,在语言层面上肯定是不足以去操作硬件的,所以C语言提供的有关文件操作的库函数底层肯定是调用了系统接口。

  • 从系统的角度来看,对文件的操作本质是进程对文件的操作,比如文件操作相关的代码要先生成可执行程序,加载到内存成为一个进程再去执行文件操作。

  • 磁盘的管理者是操作系统。(毕竟我们最开始就知道用户,也就是我们不可能直接去操作硬件,而是借助操作系统给我们的接口。

知乎大佬对于一切皆文件的理解

📌C语言中的文件IO

🌴常用的文件操作

库函数用法模糊的直接查文档即可,下面给出几个例子。

🌵fopen和fputs

FILE *fopen(const char *path, const char *mode);

image-20220702161747174

w:写入,每次写入都是写入,意味着之前的内容会被清空

a:append,追加,不清空文件内容,在文件最后追加内容,即数据增多。

int fputs(const char *s, FILE *stream); 函数作用是往文件里写字符串

例子

#include<stdio.h>
int main()
{
  FILE* fp=fopen("./log.txt","w");
  if(fp==NULL)//打开失败
  {
    perror("fopen");
    return 1;
  }
  fputs("hello world\n",fp);
  fclose(fp);
  return 0;
}

image-20220702162447972

🌵fwrite

size_t fwrite(const void *ptr, size_t size, size_t nmemb,FILE *stream);

解释一下这四个参数,第一个参数是一个指针,第二个参数是写入的每个元素的大小,第三个是写入几个元素,第四个是写到哪个文件。简单来说就是从指针指向的位置开始写入size*nmemb个字节。

比如把const char* msg="hello world"写到log.txt里面,那就是fwrite(msg,strlen(msg),1,fp)

这里有个问题,strlen要不要+1?换句话说写入的字符串里要不要包括‘\0’?答案是不需要,因为’\0’是C语言的规定,如果我们写入了’\0’就会有乱码,虽然打印文件时这个乱码可能不显示,但确实被写入了文件。

例子

#include<stdio.h>
#include<string.h>

int main()
{
  FILE* fp=fopen("./log.txt","a");
  if(fp==NULL)//打开失败
  {
    perror("fopen");
    return 1;
  }
  const char* msg="ck is a good man!\n";
  fwrite(msg,strlen(msg),1,fp);
  fclose(fp);
  return 0;
}

image-20220702163701116

🌵fgets

char *fgets(char *s, int size, FILE *stream);从文件读取size个字符,读到s中。

image-20220702165222286

每个进程都有一个内置属性cwd存储着进程的当前路径

#include<stdio.h>
#include<string.h>
#include <unistd.h>

int main()
{
  FILE* fp=fopen("./log.txt","r");
  if(fp==NULL)//打开失败
  {
    perror("fopen");
    return 1;
  }
  char  buffer[128];
  fgets(buffer,128,fp);
  printf("%s\n",buffer);
  fclose(fp);

  while(1)//加个死循环利于观察进程状态
  {
    sleep(1);
  }
  return 0;
}

image-20220702170212238

🌴C程序默认打开的文件

先以C语言为例,任何C程序都会默认打开三个文件,分别是标准输入(stdin),标准输出(stdout),标准错误(stderr).

浅谈scanf、fscanf、sscanf的使用和区别_ 博客里有简单介绍这三个文件

image-20220704091838619

问问linux里的man这三个是什么。

image-20220704092052156

我们其实一直在使用这三个文件,比如printf打印到显示器上,scanf从键盘读取数据,perror打印错误到显示器上,如果没有打开这三个文件,我们是不能直接完成对硬件的操作。

不光是C语言,其实几乎所有语言的程序都会默认打开这三个文件,比如C++的cin,cout。

🌵拓展

关于FILE
这个根据源码版本不同看到的也不同。(这里主要是说明FILE本质也就是一个结构体。)
image-20220704093140946

外设不止一种,OS该怎么去管理这么多的文件?先描述再组织。用struct file描述后操作系统再进行组织管理即可。

每个外设的读写方式都不同,用同一种类型(file)该怎样去让他们调用自己的读写呢?提供不同的方法即可。C里面则是以函数指针的形式体现。

image-20220704103548903

语言上的接口不过是对系统接口的封装,为什么语言不直接调用系统接口?语言为了保证自身生态的完整性,肯定不会去直接调用系统接口,而且直接调用的话不具备可移植性。 比如我在windows系统下调用windows的接口,在linux下肯定就行不通了。但是如果我对其进行封装,比如fopen这个函数,在linux下就调用linux系统给的接口,windows下就调用windows系统的接口。

同一个头文件不同平台下的实现可能不同。

📌Linux下的系统IO

C库函数的底层肯定是调用了系统的接口,现在我们来了解一下系统给的接口。从接口窥探文件特性

🌴open

基本用法都可以通过man命令完成

int open(const char *pathname, int flags, mode_t mode);

image-20220704105406140

返回值是文件描述符,创建失败返回-1.、

后面会详细说文件描述符,现在简单理解为这代表一个文件,有点像文件的具体路径,可以帮助系统知道要操作哪个文件。

image-20220704111657611

对参数flags的解释,决定文件以什么方式打开,本质就是一些宏

image-20220704105656076

O_APPEND是追加的意思,但是注意追加针对的是多次运行程序,而不是单次运行时的多次写入(从语文上单次运行进行多次写入也叫追加,但是意思不同)。

mode选项用来设置权限。

比如mode设置为0644,那权限就是rw-r–r–

🌴close

image-20220704112050662

🌴write

image-20220704112433457

🌴read

image-20220704113054319

🌴例子

下面的例子包括了上面四个函数的简单用法

#include <stdio.h>
#include<sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main()
{

  //open
  int fd=open("log.txt",O_RDWR|O_CREAT,0644);
  if(fd==-1)
  {
    perror("open");
    return 1;
  }
    
  //write
  const char* msg="hello world!\n";
  write(fd,msg,strlen(msg));
  //close
  close(fd);
  //通过lseek让文件读取指针回到开头
  //lseek(fd,-strlen(msg),SEEK_END);

  //open+read+close
  int fd2=open("log.txt",O_RDWR|O_CREAT,0644);
  char buffer[128];
  ssize_t len=read(fd,buffer,127);
  if(len>0)
  {
    buffer[len]='\0';
  }
  else 
  {
    perror("read");
  }

  printf("%s",buffer);
  close(fd2);

  return 0;
}

这段代码包含了写和读,写完后应该先关闭文件让数据写进文件,后面的read才能生效。如果不关闭直接读就会出现读取成功,但是读不到数据的情况。

猜测:因为代码运行到read时数据还在缓冲区内,直到进程退出或者close才会被刷新到文件内吗?
这是我一开始的猜测,调试后我发现write运行完数据确实更新到了磁盘上(进程未退出),再去读,但依旧读不到内容,所以猜测不成立

查阅资料后发现是因为写入后光标一直在最后面,导致读不到内容,需要使用lseek函数调整文件的读取指针或者关闭文件让文件的读取指针回到文件头再进行读取。使用lseek(fd,-strlen(msg),SEEK_END);

参考的资料以及相关函数的使用
文件在磁盘上,读写操作都是对磁盘的操作,数据不是直接写到磁盘上,而是先放入缓冲去内,再根据缓冲策略刷新到磁盘。

image-20220704145613172

📌文件描述符fd

我们之前知道进程描述符task_struct(PCB),内存描述符mm_struct的存在,这里又出现了一个文件描述符fd。

先给结论:进程通过文件描述符来访问文件

回顾一下之前我们已知的进程相关的知识,代码编译链接生成可执行文件,可执行文件加载到内存运行,于是就有了进程,进程创建时系统也创建了相关的数据结构用于描述进程,task_struct,task_struct里又有一个指针指向mm_struct,等等。

这个文件描述符也是一样。但文件描述符是一个整形而不是结构体,所以fd显然不足以描述一个文件。事实也确实如此,fd存在于一个名叫files_struct的结构体中,而task_struct里存了一个指向files_struct结构体的指针,files_struct里又维护了一个数组,这个数组描述的是进程与文件的对应关系,数组的下标就是fd,也就是说,进程通过fd就可以知道这是哪个文件

打开文件的过程是怎么样的?
磁盘上的文件先加载到内存,生成对应的struct file(描述文件的属性和操作),生成后链入文件双链表,再把这个结构体的地址填入struct files_struct内维护的数组,数组的下标即为fd.

数组的下标是天然的key,即天然的哈希。

image-20220704162348808

task_struct里的files_struct*的指针

image-20220704154620178

files_struct里维护的对应关系的数组

image-20220704152405429

数组类型struct file的定义

image-20220704155816911

file里描述的操作。

🌴fd的分配原则

找到数组最小的,未使用的进行分配。

比如我close(0),再open(“log.txt”,O_RDONLY),那此时分配给log.txt的fd就是0了

#include <stdio.h>
#include<sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main()
{
  close(0);
  int fd1=open("log.txt",O_RDONLY);

  int fd2=open("log.txt",O_RDONLY);
  int fd3=open("log.txt",O_RDONLY);
  int fd4=open("log.txt",O_RDONLY);
  printf("%d\n",fd1);
  printf("%d\n",fd2);
  printf("%d\n",fd3);
  printf("%d\n",fd4);
  return 0;
}

可以看到关掉0后,第一个分配给log.txt的就是0,分配的原则也符合最小的且未使用的。

image-20220704163556717

🌴fd的范围

fd既然是数组下标,那数组是静态的,那这个数组的范围是多少呢?或者说我们在单个进程里可以打开多少个文件?

可以写一个程序验证,也可以用命令(ulimit -n)。

命令验证

image-20220704154024051

自己写个程序验证一下

#include <stdio.h>
#include <stdio.h>
#include<sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>

int main()
{
  long long i;
  while(1)
  {
   i=open("log.txt",O_RDONLY);
    if(i<0)
    {
      perror("open");
      exit(0);
    }
    else 
    {
      printf("当前文件的fd为%lld\n",i);
    }
  }
  return 0;
}

image-20220704153658198

可以看到包括默认打开的三个文件(标准输入、标准输出和标准错误),一共能打开的就是100001个文件,环境不同可能结果不同。

🌴小结

  • 对于进程来说,对所有的文件进行操作使用的是同一套接口(也可以说是同一套的函数指针)。

  • 通过文件描述符fd就可以找到文件,可以知道这个文件的所有细节。

  • 这些都是在OS内部的,用户只需要关注fd

C语言里也有一个对应的fd,因为C库函数本质上还是调用系统接口所以要对应fd,C源码版本不同fd的名字也不同,我们记为fileno。

image-20220705172528431

C源码

image-20220705003957665

image-20220705003942918

image-20220705003929185

📌重定向

🌴探究重定向

将打印到显示器上的信息重定向输出到log.txt

#include <stdio.h>
#include<sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
  close(1);
  int fd=open("log.txt",O_CREAT|O_RDWR,0644); 
  printf("hello world!\n");
  close(fd);
  return 0;
}

我们先关闭了显示器文件,再分配的fd,所以此时分配的fd肯定是1,所以下标为1的那个位置就指向了log.txt。我们知道printf是往显示器上打印内容,语言层默认显示器文件的fd就是1,就往1指向的文件写入。所以此时我们猜想的现象应该是:“hello world”会被写进log.txt,那么现象和我们的猜想符不符合呢?

image-20220705163951376

运行结果

image-20220705162934659

我们发现hello world并没有被写入log.txt。原因如下:大多语言是有自己的缓冲区的,比如C语言,所以printf打印的数据会被放入缓冲区,又因为log.txt是普通文件,刷新策略是全换冲,所以数据一直留在缓冲区里,等到进程退出的时候缓冲区要刷新了,但是在return之前fd已经被关闭了,所以数据不能通过fd拷贝到内核的缓冲区,自然也就不能写入文件了。

简单来说,进程退出缓冲区刷新,刷新前把fd关了,导致刷新失败

原因明了后,解决自然也就很简单了,既然是因为进程退出前fd已经被关闭了,那我们在fd关闭前我们就把数据刷新到内核缓冲区,使用强制刷新的策略。

close(1)把本来应该显示到显示器上的内容写入到了指定的文件,也即输出重定向。

image-20220705164728072

注意我们close(1)之后再改变数组下标为1的内容,即改变了指向,1还是那个1,语言层的stdout也不会受影响,也还是对应1。

🌴关于C语言的缓冲区

C语言里的FILE结构体里面除了有fileno,其余的大多都是缓冲区的操作。

image-20220705170600172

在这张图中,我们上面举的例子就是数据一直留在了语言缓冲区,没有刷新到内核缓冲区,自然写不到磁盘上。

缓冲区有自己的缓冲策略,一般普通文件是全换冲,往屏幕上写是行缓冲

C语言的缓冲区我们也叫做用户层的缓冲区,为什么我们需要用户层的缓冲区,直接写入内存的缓冲区不行吗?这样设计实现了用户和内核,内核和磁盘的解耦。

在用户层面 数据直接给用户的缓冲区就不管了 ,如果不这样,得从用户层直接切换到内核,对用户来说效率不高。在用户看来,数据给了用户层的缓冲区后自然有人(OS)给它拷贝到内核的缓冲区,这样实现了用户层和内核的解耦

对于操作系统来说,只要把数据拷到自己的缓冲区就完事了,刷新缓冲区写入之类的都交给了磁盘驱动,由此又实现了解耦.

操作系统的缓冲区一般不需要我们关心,因为内核缓冲区主要是负责和硬件的交互。我们更应该关心语言层的缓冲区

🌵C接口与系统接口

#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
  printf("hello printf\n");
  fprintf(stdout,"hello fprintf\n");
  fputs("hello fputs\n",stdout);

  const char* msg="hello write\n";
  write(1,msg,strlen(msg));

  return 0;
}

image-20220707164634718

此时在代码里添加一句fork(),再重定向写到log.txt里会发生什么?

image-20220707170238591

大多时候写时拷贝拷贝的是用户层的数据。内核数据的拷贝要看这个数据属于谁,是进程还是OS?是进程的就会拷贝,OS的就不会。

🌴细节导致重定向失败

命令重定向默认改的是fd为1的指针指向,如果我们使用的是fd为2,会导致重定向失败,看下面这个例子。

#include <stdio.h>
#include<sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main()
{
  fprintf(stderr,"hello world!\n");
  return 0;
}

重定向改的是fd为1指向的文件,如果我们将数据写入到stderr(fd为2),重定向虽然改变了fd为1的指向,fd为2的文件相关输出不受影响,所以仍然打印hello world.

image-20220705173045706

📌dup2

🌴初识dup2

之前的重定向 ,先关闭再打开,比较麻烦,采用dup2实现数据的覆盖达到重定向的目的

我们之前实现重定向是先关闭1,再打开别的文件实现重定向,那有没有更快的方法呢?实际上我们直接把数据拷贝到fd为1的那块空间上实现覆盖即可。

image-20220705173823211

要实现上面的过程,系统给了个接口dup2。

image-20220705174449567

返回值:成功返回新的文件描述符,错误返回-1.

image-20220705174629581

image-20220705174849949

例子

输出重定向

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
  int fd=open("log.txt",O_CREAT|O_RDWR,0x0644);
  if(fd<0)
  {
    perror("open");
    return 1;
  }
  dup2(fd,1);
  const char* msg="hello world!\n";
  write(1,msg,strlen(msg));
  return 0;
}

image-20220707171950685

输入重定向

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
  int fd=open("log.txt",O_CREAT|O_RDWR,0x0644);
  if(fd<0)
  {
    perror("open");
    return 1;
  }

  dup2(fd,0);

  char buffer[128];
  ssize_t len=read(0,buffer,127);
  if(len>0)
  {
    buffer[len]='\0';
    printf("%s",buffer);
  }
  
  close(fd);
  return 0;
}

image-20220708155701358

程序替换的时候会不会影响重定向对应的数据结构。

不会程序替换是直接把磁盘上的内容和数据替换物理内存上的,并不会影响struct files_struct,struct file等。

📌初识文件系统

Linux系统的文件系统包含的东西是很多的,这里只是简单了解其表面。

打开的文件需要被管理,在磁盘上未打开的也需要被管理,合起来就形成了OS的文件系统。

要引出文件系统需要做很多准备工作,我们得从硬盘开始讲起。

先看看机械硬盘长什么样子,网上找的一张图

硬盘工作的样子👉透明探索版硬盘 来看看磁头寻道的样子_哔哩哔哩_bilibili

image-20220708161233831

纵切图是这样的

image-20220708161648257

硬盘的最小单位是扇区,一个扇区512字节(0.5KB),系统一次会读多个扇区,即读取一个块(多个扇区),一般是读4KB,也就是8个扇区。

扇区就是那一圈一圈的,上面是存储颗粒,存储着信息,需要读取信息的时候磁头会去找信息所在的磁道。

那操作系统是怎么管理磁盘上的信息的,操作系统把磁盘上的信息抽象成线性的(想想磁带,一开始是卷起来的,我们把它拉直不就是线性的了),抽象成线性后发现数据可能有几百个G,或者几个T,数据很大,那就将其分区进行管理。比如100个G给划分成10个10个G大小的空间进行管理。

抽象成线性的,即维护磁道上的扇区与线性结构对应的那一块,这个工作一般由驱动完成,

操作系统不可能直接从机械硬盘的盘面的某磁道上的某几个扇区读取信息的,硬盘种类那么多,比如现在的SSD固态硬盘,结构都不同,难不成我们还得直接换一个系统…所以肯定是抽象成系统能理解的方式,比如抽象成一个线性的数组,这就将硬盘描述出来了,再对这个数组进行管理即可。

Linux有多个文件系统。

下面用几张图简要说明各个分区的作用和关系,看图前你需要先了解什么是inode

🌴inode简介

  • inode是文件属性的集合

  • inode编号唯一标识一个文件,inode编号在目录文件的Block块中(百度的)

  • 文件名不过是利于我们操作,并不唯一标识文件

  • 目录的工作之一就是映射inode编号和文件名,所以一个目录下不能有同名文件,目录是一个独立的文件,有自己的inode和特定的数据块

  • inode可以和特定数据块产生关联

  • 内核的file里维护了一个inode数组。

    image-20220708223407019

image-20220708223233997

🌵查看inode信息

stat命令

image-20220708231021407

最后状态改变的时间可以理解为属性改变的时间,最后修改的时间可以看做内容最后修改的时间

漏了一个,Device是文件所在设备号,以十六进制和十进制显式。

AMC这三个时间,在Makefile判断当前可执行程序是否是最新时有用到。

ls -i命令也可以看到文件的inode编号

image-20220708231329264

复习下打印的信息

image-20220708232043275

漏了个,修改时间前面的一组信息表示文件大小

🌴表述逻辑上下层关系的图

下面都是在系统的角度,并不过分关心硬件(硬件没学

image-20220708172633581

文件=属性+数据,我们根据inode编号可以找到inode结点,读取文件信息,就可以找到文件数据的存储位置。

linux上的inode编号是什么 - greamrod - 博客园 (cnblogs.com)

🌴结合现有知识解释问题

  • touch一个文件,系统做了什么工作?

image-20220708233658992

这个过程内存和硬盘肯定还有交互,这里我们不深究。

到这我们也不过是对整个文件系统有个大体的认识,深入探讨知识就太多了,涉及的知识也是庞大的。

  • 删除文件不过是将inode和block位图里的1置为0,而不是去操作块里真正存储的数据,所以我们删数据可能只要几秒,甚至可以通过特殊手段把删掉的数据还原。

📌软硬链接

🌴软链接

ln -s命令建立软链接

image-20220708235849822

作用是指向特定文件,充当索引的作用,并且因为有自己独立的inode可以跨文件系统。

比如有一个工具在别的目录下,我们要拿过来用就可以建立软链接。但是软链接是依赖源文件的,有点像指针,源文件被删软链接也用不了

🌴硬链接

简单来说就类似于快捷方式,没有自己独特的inode编号,与源文件共用inode结点。

image-20220709000216471

image-20220709000303192

硬链接使用的一个例子就是相对路径,像相对路径里的./和…/里的.都是硬链接的使用。

image-20220709000615240

软硬链接的本质区别就在于软链接有独特的inode,而硬链接没有。体现出来就是软链接的inode编号与源文件不同,而硬链接的inode编号与源文件相同

甚至我们可以通过更改硬链接来改变源文件的内容

image-20220709001854029

📌总结

  • 理解一切皆文件:Linux里面所有的内容都是以文件形式存储,Linux系统把所有的外设都抽象成文件进行操作(看待外设都变成了看待struct file),所以物理层面不管是不是文件,在Linux里面都是文件。

  • 一切皆文件就是系统层面的多态,针对不同的外设调用不同的读写方法,C语言的方式实现多态就是函数指针,也即struct file里面维护的struct operation*。

  • 库函数的底层肯定是调用了系统接口,因为库函数做不到和外设打交道,可以做到的显然是操作系统

  • 进程启动会默认0,1,2三个文件,分别代表标准输入,标准输出,标准错误,对应C语言的stdin,stdout,stderr。对应C++的cin,cout,cerr。

  • C里的FILE本质上也只是一个结构体,里面有一个fileno对应系统层面的文件描述符fd,除开fileno外基本都是一些关于缓冲区的操作

  • fd本质就是进程和文件对应关系的数组的下标,通过fd可以找到struct file,从而拿到了文件的所有细节,比如file里除了基本的文件路径等属性,file里也维护了指向inode结点的指针,从而拿到了更多 的属性。

  • C语言的文件IO是需要经过C语言自己的缓冲区的,而系统IO直接通过内核缓冲区写到内存上。

  • 系统IO函数与C语言库函数用不熟的直接查文档即可

  • 重定向的本质就是改变了fd指向的内容。

  • 文件系统里的inode编号唯一标识文件,inode结点存储了文件的所有属性,inode编号与block编号的映射让我们很快可以找到文件的数据存在哪里,以及创建文件的

  • 软链接看做快捷方式,硬链接看做文件的别名,两者本质就在于是否有独立的inode.

  • 操作系统管理的本质就是先描述再组织,学到这对这六个字理解更加深刻,比如把外设描述成struct file,把属性集合描述成struct inode,管理外设和属性等就都变成了管理struct结构体。

  • 16
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 12
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

喜欢乙醇的四氯化碳

你的鼓励将是我创作的最大动力

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

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

打赏作者

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

抵扣说明:

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

余额充值