Linux基础IO

一、回顾 C/C++ 的文件操作

1.对文件写(w)

示例:在这里插入图片描述在这里插入图片描述

2.对文件追加(a)

示例:在这里插入图片描述在这里插入图片描述

3.对文件读(r)

示例:
log.txt 文件里面已经有10行 hello world 内容了。在这里插入图片描述在这里插入图片描述

如果我们把 log.txt 文件删掉,打开文件就会失败。在这里插入图片描述

我们可以看一下 C++ 的文件操作:

示例:在这里插入图片描述在这里插入图片描述

二、理解 stdin、stdout、stderr

C 程序默认会打开三个标准输入输出流:stdinstdoutstderr

C++ 中也有三个对象cincoutcerr分别表示标准输入流、标准输出流、标准错误流。
一般大部分的语言都会提供这三个输入输出流。

从体系结构的角度看,它们对应的硬件设备分别是:
 ① stdin:键盘
 ② stdout:显示器
 ③ stderr:显示器

这三个输入输出流,本质上它们的类型都是 FILE * ,也就是文件流。
这意味着 C 语言把标准输入、标准输出和标准错误都当作文件来处理。

在这里插入图片描述

用户调用文件操作函数对这些流读或写的时候,最终一定是向对应的硬件设备读或写。

我们可以做实验来证明:

① 向标准输出(显示器)打印消息:在这里插入图片描述在这里插入图片描述这说明显示器也是一个文件。

② 向标准错误(显示器)打印消息:在这里插入图片描述在这里插入图片描述

我们发现,文件操作函数向一般文件或者硬件设备都能读或写,这体现了 Linux 中一切皆文件的概念。

一般文件在磁盘上,本质也是硬件。

三、系统文件 I/O

在语言层面上调的各种接口,最终都必须把数据写到文件当中,也就是最终都是访问硬件(显示器、键盘、磁盘等),而 OS 是硬件的管理者,那么所有语言层面上的对文件的操作,都必须通过 OS 来完成。

OS 不信任任何用户,所以访问 OS 需要通过系统调用。

各种语言都有文件操作函数,而且还都不一样,这是因为在语言层面上做了封装,但是在操作系统上,万变不离其宗,所有语言的文件操作函数的底层都会用到 OS 提供的系统调用。

如果学习文件操作只停留在语言层面上的话,是很难对文件有一个比较深刻的理解的!
因此,我们如果想深入理解文件操作的话,就必须理解操作系统提供的系统调用!

1. open 系统调用和 close 系统调用

open系统调用的作用:打开一个文件。

open系统调用的参数:
 ① pathname 表示你想打开的文件的路径。
 ② flags 表示打开文件的方式。
    O_CREAT:若文件不存在将创建
    O_RDONLY:只读
    O_WRONLY:只写
    O_RDWR:读和写
    O_APPEND:追加
 ③ mode 表示在创建新文件时设置对应的权限信息(若要打开的文件不存在需要创建(O_CREAT)时,则用到 mode;若要打开的文件已存在,则会忽略 mode)。

open系统调用的返回值:
 ① 返回一个文件描述符。
 ② 若出错,返回 -1。

可以通过 man 手册查看 open 系统调用:在这里插入图片描述
在这里插入图片描述

close系统调用的作用:关闭一个文件描述符。

close系统调用的参数:文件描述符(通常是把open返回的文件描述符)。

可以通过 man 手册查看 close 系统调用:在这里插入图片描述

示例:以写的方式打开一个文件在这里插入图片描述
在这里插入图片描述

必须设置文件的权限,否则新创建文件的权限将会是乱的。在这里插入图片描述
在这里插入图片描述

我们调用 C 语言的fopen("./log.txt", "w");在创建文件时的权限默认为正常权限,但是在操作系统层面上的系统调用就需要传参来指定文件权限。

很显然,用 C 语言的接口是不关心什么只写、创建和需要设置文件权限这样的概念,因为这些概念都是与操作系统强相关的,C 语言都给你做封装了,所以你也就不用再关心这些细节了。

实际上,C 语言fopen的底层调用的就是open

2.通过系统调用给内核传递标志位

现在来谈一谈系统调用中的参数 flag,其实这是用户给内核传递标志位的常见做法。

int 共有 32 个 bit,一个 bit 代表一个标志位,每个标志位代表不同的含义,所以我们就可以通过位操作的方式一次向内核传递多个标志位。

这样做的好处是:
 ① 一次可以传递多个标志位。
 ② 系统检测标志位的速度快(因为位操作在系统里是高效的)。
所以,可以看出来系统调用的设计是非常精妙的。

比如说 O_WRONLY、O_RDONLY、O_CREAT 等都只有一个比特位是 1,而且不重复,它们通常都以宏的形式出现。

在这里插入图片描述

所以,用户如果想向系统调用传递多个标志位时,只需把它们都按位或( | )。

比如传递两个标志位:O_WRONLY | O_CREAT在这里插入图片描述

然后在系统调用的内部,它就会把你传进来的 flag 和每个标志位都做按位与(&)运算,这样就可以判断该标志位是否被设置。

3. write 系统调用

write系统调用的作用:对一个文件描述符写入。

write系统调用的参数:
 ① fd 表示被写入的文件描述符。
 ② buf 表示写入数据的缓冲区。
 ③ count 表示期望写入的字节数。

write系统调用的返回值:
 ① 成功,返回实际已写入的字节数。
 ② 出错,返回 -1 。

可以通过 man 手册查看 write 系统调用:在这里插入图片描述

在我们写入文件的过程中,我们不需要写入 ‘\0’ ,因为 ‘\0’ 作为字符串的结束标志,只是 C 语言的规定,我们写入文件关心的是文件的内容,而 ‘\0’ 并非是需要写入文件的内容,所以不需要将 ‘\0’ 写入文件。

示例:在这里插入图片描述
在这里插入图片描述

4. read 系统调用

read系统调用的作用:从一个文件描述符读。

read系统调用的参数:
 ① fd 表示要读的文件描述符。
 ② buf 表示存放数据的缓冲区。
 ③ count 表示期望读取的字节数。

read系统调用的返回值:
 ① 成功,返回实际已读取的字节数。
 ② 出错,返回 -1 。

可以通过 man 手册查看 read 系统调用:在这里插入图片描述

示例:
在这里插入图片描述
在这里插入图片描述

5.文件描述符(file descriptor)

我们之前说过,open系统调用的返回值 fd 是一个文件描述符,它是一个整数。

那么我们就会很好奇,这个整数是几呢?

我们打印多个 fd 来看看:

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

我们发现打印出来的是连续的小整数,好像这些文件描述符的打开是连续的。
既然打开失败是 -1,那么 0、1、2 在哪里呢?为什么打开文件描述符时默认从 3 开始呢?

其实,0、1、2 分别对应stdinstdoutstderr。我们之前说过 C/C++ 程序运行起来变成了进程之后,OS 默认会帮进程打开三个标准输入输出流stdinstdoutstderr,对应的硬件设备分别是键盘、显示器、显示器。

在这里插入图片描述

因为这三个标准输入输出流默认被打开,所以 0、1、2 是被占用的。

系统分配的文件描述符就是 0,1,2,3,4,5,…,这些连续的小整数容易使人联想到数组的下标。

所有的文件操作都是进程执行对应的函数,换言之,就是进程对文件的操作。进程要操作文件必须得先打开文件,而打开文件的本质就是将文件相关的属性信息加载到内存!

没有被打开的文件在磁盘上。
一个空文件也是要占磁盘空间的,因为文件除了内容之外,还有属性,比如权限、文件名、修改时间等,而属性也是数据,所以占磁盘空间。
磁盘文件 = 文件内容 + 文件属性。
我们之前所学的所有的文件操作,可以分为两类:对文件内容的操作(fputs等)和对文件属性的操作(chmod等)。

系统中会存在大量的进程且一个进程可以打开多个文件,这意味着系统中可能存在更多打开的文件,于是 OS 就必须把打开的文件在内存中管理起来。

那么 OS 又如何管理打开的文件呢?先描述再组织!

 ① 先描述:用内核数据类型 struct file { // 打开文件的相关属性信息 }; 来描述一个打开的文件。
 ② 再组织:会以特定的数据结构(比如说双链表)将 struct file 结构体组织起来。

既然这么多打开的文件被 OS 维护,如何将一个进程与它所打开的文件关联起来?

进程的 PCB 里有个指针(struct files_struct *files)指向内核中的一种数据类型 struct files_struct ,而 struct files_struct 里面包含了一个指针数组 struct file *fd_array[ ] 。

每打开一个文件,就会在内存中形成对应的结构体 struct file ,然后把 struct file 的地址按数组下标由小到大地依次写入到数组 struct file *fd_array[ ] 里,这样就把进程和它所打开的文件关联了起来,而对应的数组下标就是文件描述符,它会被返回给上层用户。

在这里插入图片描述
进程对文件的操作都是通过文件描述符来找到对应文件的。
进程执行writeread等涉及文件操作的系统调用时,用户必传参数 fd ,于是进程通过 PCB 找到它打开的文件列表,然后在数组根据 fd 直接索引,找到 fd 对应的 struct file 地址,然后根据该地址就能找到对应的 struct file ,至此就可以对文件进行相关操作了。

文件描述符 fd 本质是内核中使进程和打开的文件相关联的数组下标

进程打开的文件所对应的文件描述符会从 3 开始,是因为在进程启动时操作系统默认会帮进程打开三个标准输入输出流,分别占用了文件描述符 0、1、2 。

验证:
① 可以从文件描述符 0(标准输入流)读取:在这里插入图片描述在这里插入图片描述

② 可以对文件描述符 1(标准输出流)写入:
在这里插入图片描述在这里插入图片描述

③ 可以对文件描述符 2(标准错误流)写入:在这里插入图片描述在这里插入图片描述

6.进一步理解“一切皆文件”

外设和内存在数据层面上打交道,无非就是 IO ,站在内存和系统的角度,就是读写,即外设统一有 IO ,每种外设都有对应的读方法和写方法。

但这并不意味着每种外设都非得把所有的方法实现,比如键盘的写方法为空,显示器的读方法为空。

我们能够在宏观上理解到,在各种硬件的驱动层中这些接口所对应的方法一定是对应着不同的软硬件设备,在底层的实现上一定是不一样的(因为硬件的结构不一样)。

在这里插入图片描述
那么如何做到一切皆文件呢?

系统中有一层软件虚拟层,叫做 VFS(虚拟文件系统)。

VFS 中的 struct file 内包含了一批函数指针,这些函数指针直接指向底层方法。如果 struct file 代表什么外设文件,函数指针就指向那个外设所对应的方法。

于是调用同一个方法,将来指向不同的对象时就可以执行不同的方法,在 C 语言上实现多态思想。

在上层看来,统一以 struct file 的方式来看待所有文件,要读就调 read 方法,要写就调 write 方法,根本就不需要关心到底是什么文件。

这就是所谓的一切皆文件!

7.文件描述符的分配规则

我们先来做几个实验:

① 正常打开文件:在这里插入图片描述在这里插入图片描述

② 若先关闭文件描述符 0 ,再打开文件:在这里插入图片描述在这里插入图片描述

③ 若先关闭文件描述符 2 ,再打开文件:在这里插入图片描述在这里插入图片描述

④ 若先关闭文件描述符 0 和 2 ,再打开文件:
在这里插入图片描述在这里插入图片描述

不难发现,给新打开的文件分配 fd 的规则是:从 fd_array[ ] 中找一个最小的且没有被使用的 fd 作为新的 fd 。

实际上就是系统对 fd_array[ ] 数组线性遍历,找到一个最小的且没有被使用的位置,把 struct file 的地址写进去,然后再把对应的数组下标(文件描述符)返回给上层。

8.文件流和文件描述符

C 语言的接口跟系统调用是什么关系呢?

在系统中,语言层和系统层就是天然的上下层关系。
在这里插入图片描述

语言层的printffprintfcin等函数或对象最终都要向文件写入,由于文件是硬件,所以最终一定是操作系统帮你去操作,所以所有的这些接口毫无意外地必须向下调用系统调用(比如openwrite等),而只要调用这些系统调用,就毫无意外地要使用 fd 。

下面以stdoutprintf为例来说明:
FILE 是一个结构体,而stdout的类型是 FILE * ,是一个结构体指针,它指向 FILE 。
调用printf时,本质是向stdout进行写入,也就是向 FILE 进行写入。因为其底层必须得调用系统调用,所以 struct FILE 里一定包含了一个整数,这个整数对应在系统层面打开的文件的文件描述符。

实际上,文件描述符 0、1、2 和 C 语言上的stdinstdoutstderr(C++ 上的cincoutcerr),它们之间的关系是一一对应的。

stdin指向的 FILE 内的 fd 一定是 0 ,stdout指向的 FILE 内的 fd 一定是 1 ,stderr指向的 FILE 内的 fd 一定是 2 。

在这里插入图片描述

验证:
① FILE 结构体的定义:
/usr/include/stdio.h中:在这里插入图片描述

/usr/include/libio.h中:在这里插入图片描述

② stdin、stdout、stderr 三个文件流对应的文件描述符:在这里插入图片描述在这里插入图片描述

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

所以,我们以前写过的C语言的文件操作fopenfwritefreadfclose等,它们一定是通过 FILE * 来找到对应的 FILE 里的 fd ,进而操控底层的文件。比如说fwrite好像对文件流进行写入,实际上在底层它把你要写的数据,通过文件描述符写到磁盘上。

示例①:在这里插入图片描述
在进程启动时,OS 会帮进程默认打开标准输入流、标准输出流和标准错误流,分别对应键盘文件、显示器文件和显示器文件。
执行 close(1),就是关掉文件描述符 1 ,意思是去掉 fd_array[ ] 和显示器文件的关联,让该进程不再指向显示器文件。
然后 open 打开文件,OS 给 log.txt 文件形成 struct file,然后根据文件描述符的分配规则,会把该 struct file 的地址写入到 fd_array[1] ,此时就指向了它。
在这里插入图片描述
调用 printf ,printf 是往 stdout 写入的,而 stdout 指向的结构体 FILE 里封装了文件描述符 fd ,这个 fd 是 1 。
于是通过 stdout 找到 FILE ,进而找到 FILE 内的 fd ,进而找到 log.txt 文件对应的 struct file ,然后对它写入。
这就是输出重定向的原理。


这就是为什么结果没有输出到显示器上,而是输出到 log.txt 文件的原因。
在这里插入图片描述

示例②:
不关闭文件描述符 1 ,然后打开文件:在这里插入图片描述在这里插入图片描述

关闭文件描述符 1 ,然后打开文件:在这里插入图片描述在这里插入图片描述
其实上面的例子跟使用 printf 打印没有任何差别。

C++ 中的 cin、cout、cerr 都是流式对象,在 Linux 上运行时,对应的类里面必定封装了文件描述符。
其它语言也都是类似的,上层再怎么变,底层都是不变的。

总结一下:
因为 IO 相关函数与系统调用对应,并且库函数封装系统调用,所以本质上,访问文件都是通过 fd 访问的。
又因为 IO 相关函数访问文件时会使用 FILE * 文件指针,所以 C 库当中的 FILE 结构体内部,必定封装了 fd 。

9. Q & A

(1)进程在执行程序替换时,会不会影响我们曾经打开的所有的文件呢?

绝对不会。因为程序替换只是替换进程的代码和数据,并不影响 files_struct 。

在这里插入图片描述

(2)子进程会继承父进程的文件描述符吗?

子进程的初始化是以父进程为模板的,files_struct 属于内核数据类型,它里面填的基本上都是父进程 files_struct 里的内容,也就是以父进程为模板进行初始化。所以子进程会继承父进程的文件描述符,父子进程指向的都是同一批文件。

在这里插入图片描述

(3)为什么我们所有的进程,都会默认打开三个标准输入输出流?

在命令行上启动的所有进程,它们的父进程都是 bash ,而 bash 是一定会打开三个标准输入输出流的(因为 bash 需要获取用户的输入、打印结果和打印错误信息),所以 bash 的子进程也就默认继承下去了。

(4)一个文件可被多个进程打开,那么这个文件什么时候被释放呢?

采用引用计数的方式来判断。
struct file 结构体里有个计数器,表示当前有多少个进程指向该文件。
若有进程打开该文件,就代表有进程指向它了,计数器就会++;若有进程关闭该文件,就代表有进程不指向它了,计数器就会-- 。
只有当计数器减到 0 时,这个文件才会被释放。

四、重定向

1.输出重定向

输出重定向:把本应该输出到显示器文件上的内容,输出到了其它文件当中。

示例:
在这里插入图片描述

实现方法:先把该进程的文件描述符 1 关掉,然后再把指定文件打开(根据文件描述符的分配规则,会被分配 1),最后把结果输出到文件描述符 1 对应的文件中。

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

2.追加重定向

追加重定向:把本应该输出到显示器文件上的内容,追加到了其它文件当中。

示例:在这里插入图片描述

实现方法:先把该进程的文件描述符 1 关掉,然后再把指定文件打开(根据文件描述符的分配规则,会被分配 1),最后把结果追加到文件描述符 1 对应的文件中。

其实在系统调用层面上,和写入唯一的区别就是多了一个 O_APPEND 选项。在这里插入图片描述在这里插入图片描述

3.输入重定向

输入重定向:本应该从键盘文件读取内容,变为从其它文件读取内容。

示例:
cat命令默认从键盘文件读取内容,然后把内容输出到显示器文件上。
在这里插入图片描述
cat输入重定向:从指定文件读取内容,然后把内容输出到显示器文件上。在这里插入图片描述

实现方法:先把该进程的文件描述符 0 关掉,然后再把指定文件打开(根据文件描述符的分配规则,会被分配 0),最后从文件描述符 0 对应的文件中读取内容。

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

4.关于标准输出流和标准错误流

既然stdoutstderr都输出到显示器上,那么它们有什么区别呢?

我们可以做以下几个实验来观察现象。

实验①:
在这里插入图片描述在这里插入图片描述

实验②:在这里插入图片描述在这里插入图片描述

实验③:
在这里插入图片描述在这里插入图片描述

实际上,Linux 输出重定向(>)的本质是把文件描述符 1 对应的文件从显示器文件改为了其它文件(只是把标准输出重定向到其它文件,所以“ > ”叫做输出重定向)。
也就是说只是改变了文件描述符 1 对应的文件,并没有改变文件描述符 2 对应的文件(显示器文件)。
所以,我们看到了上述现象。

换言之,虽然stdoutstderr对应的设备都是显示器,但是它们是不一样的,前者对应文件描述符 1 ,后者对应文件描述符 2 。

若要把所有输出内容都写入到同一个文件中,可以使用命令:command > filename 2>&1(比如./myfile > log.txt 2>&1)。
在这里插入图片描述
意思是先把标准输出重定向到指定文件,再把标准错误重定向到标准输出,这样标准输出和标准错误都对应同一个文件。

5. dup2 系统调用

我们可以调用dup2系统调用来完成重定向,不一定非得要手动关闭标准输入输出流来完成。

dup2系统调用:关闭 newfd ,然后使 newfd 成为 oldfd 的拷贝。意思是关闭 newfd ,然后把 fd_array[oldfd] 里的内容赋给 fd_array[newfd] ,使得 newfd 和 oldfd 都对应同一个 struct file(有两个文件描述符对应同一个文件)。

dup2系统调用的参数:
 ① oldfd 是重定向的目标。
 ② newfd 是被重定向的。

图解 dup2 系统调用:在这里插入图片描述

dup2系统调用的作用:在文件已经打开的情况下完成重定向。

可以通过 man 手册查看 dup2 系统调用:
在这里插入图片描述

(1)实现输出重定向

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

(2)实现追加重定向

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

(3)实现输入重定向

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

五、缓冲区

我们先来看下面的两个实验:

实验①:在这里插入图片描述在这里插入图片描述

实验②:在这里插入图片描述在这里插入图片描述

上面两个实验的区别是有没有 close(fd) 语句。

为什么存在 close(fd) ,上面的内容就没有打印到 log.txt 文件当中呢?

原因是 C 语言也给我们提供了缓冲区(用户级缓冲区)
在这里插入图片描述
OS 也有缓冲区,叫做文件的内核缓冲区(针对外设设置的一个缓冲区,用于提高操作效率)。

所有 C 语言的这些printffprintffwritefputs等函数其实是直接把数据写到 C 语言的缓冲区里就完事了。

C 语言的缓冲区在哪里?

C 语言的缓冲区在 struct FILE 里面。

struct FILE 除了封装了一个文件描述符之外,其实它里面还定义了 C 语言的缓冲区。

在这里插入图片描述
这里一大堆的指针变量都与 C 语言缓冲区(文件的用户缓冲区)相关。

调用printf等函数实际上都把数据写到了文件的用户级缓冲区里面,也就是 FILE 结构体内部。(printf等函数最终并没有把数据刷新到外设上)
在这里插入图片描述

然后在合适的时候调用系统调用(比如write)通过文件描述符再把数据从 C 语言的缓冲区刷新到文件的内核缓冲区,然后由 OS 定期把数据刷新到外设上。

合适的时候是指:
 ① 若打印的目标文件是显示器文件,则遇到“ \n ”刷新(行刷新)。
 ② 进程退出时,会把 FILE 内部(C语言缓冲区)的数据刷新到内核缓冲区。(进程还未退出时,数据可能暂时还在C语言缓冲区中)

数据从用户缓冲区刷新到内核缓冲区的刷新策略:
 ① 立即刷新(不缓冲)。
 ② 行刷新(行缓冲,遇到“ \n ”才刷新),比如写入的目标文件是显示器文件
 ③ 全缓冲(缓冲区满了才刷新),比如写入的目标文件是磁盘文件
上述的刷新策略对数据从内核缓冲区刷新到硬件上也是同样适用的。

有了上面的铺垫,让我们再来看看那两个实验:

实验①:在这里插入图片描述在这里插入图片描述由于从显示器文件重定向到了 log.txt 文件,意味着刷新方式也会发生变化:由本来的行缓冲变为了全缓冲。
调用的打印函数都会把数据输出到用户缓冲区中(由于是全缓冲,用户缓冲区可能并没有被写满,所以也就意味着这部分数据并没有被刷新到文件当中),然后又因为没有 close(fd) ,所以当进程退出时,数据最终从用户缓冲区刷新到了内核缓冲区,然后 OS 也把数据刷新到了 log.txt 文件上,所以我们在 log.txt 文件中看到了打印数据。

实验②:在这里插入图片描述在这里插入图片描述由于从显示器文件重定向到了 log.txt 文件,意味着刷新方式也会发生变化:由本来的行缓冲变为了全缓冲。
调用的打印函数把数据写到用户缓冲区(由于是全缓冲,用户缓冲区可能并没有被写满,所以也就意味着这部分数据并没有被刷新到文件当中),然后在进程退出前调用了 close(fd) 关闭了该文件描述符,但数据还在用户缓冲区内,于是最终没有办法把数据刷新到内核缓冲区,继而没有刷新到文件上,所以最终 log.txt 文件中没有这部分内容。

但是我们可以在 close(fd) 前调用fflush函数强制把数据从用户缓冲区刷新到内核缓冲区,继而刷新到文件上,这样在后续关闭文件描述符也就不影响了。

fflush 函数将 C 语言缓冲区的数据刷新到内核缓冲区中,然后 fflush 函数底层也会调用相关接口来把内核缓冲区的数据刷新到外设中。

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

再来看看下面这个实验:

在这里插入图片描述

①直接运行程序:
在这里插入图片描述对于前两条系统调用打印的消息,写入的目标文件是显示器文件,刷新策略是行刷新,所以它们都刷新到显示器上。
对于后两条 C 语言函数打印的消息,写入的目标文件是显示器文件,刷新策略是行刷新,所以它们在 close(fd) 前就把消息刷新到了内核缓冲区进而刷新到显示器上。

②输出重定向(>)运行程序:在这里插入图片描述标准输出本来对应显示器文件,但现在被重定向到了 log.txt 文件,行缓冲变为了全缓冲,write 是系统调用,会直接把消息写到内核缓冲区,所以后续文件描述符的关闭对它是没有影响的,最后消息再从内核缓冲区刷新到 log.txt 文件。
写入到标准错误的消息没有被重定向,还是对应显示器文件,刷新策略是行刷新,最终会刷新到显示器上。
C 语言函数只是把消息写到用户缓冲区(由于发生了重定向,刷新方式变为了全缓冲,缓冲区未满就不会刷新),然后因为后面关闭了文件描述符,所以最终消息没有刷新到 log.txt 文件。

系统调用是直接向操作系统写入的,没有使用 C 语言的缓冲区,所以在写入后调用close关闭文件描述符并不影响数据刷新到对应的磁盘文件。
而 C 语言函数是写在 C 语言缓冲区里的,所以在写入后调用close关闭文件描述符会使得数据没有刷新到对应的磁盘文件。

C 语言的 fclose 函数会强制把 C 语言缓冲区的数据刷新到内核缓冲区,所以调用了 fclose 函数最终会把数据刷新到文件上。
我们可以通过 close 系统调用和 fclose 函数的对比来验证:
① close 系统调用:
在这里插入图片描述在这里插入图片描述

② fclose 函数:
在这里插入图片描述在这里插入图片描述

我们最后再来看看下面这个实验:

在这里插入图片描述在这里插入图片描述
没问题,符合预期,由于写入的目标文件是显示器文件,刷新方式是行缓冲,所以父进程的用户缓冲区在 fork 前就没有数据了。

但当我们把标准输出重定向到其它文件时:

在这里插入图片描述有几条消息是重复出现的,而且重复出现的消息都是使用 C 语言接口打印的,但是系统调用并没有此现象。

其实用 C 语言接口打印的消息重复出现是因为写实拷贝导致的重复刷新。

由于从显示器文件重定向到了 log.txt 文件,意味着刷新方式也会发生变化:由原来的行缓冲变为了全缓冲。
C 语言接口把数据写到了 C 语言缓冲区(由于发生了重定向,刷新方式变为了全缓冲,缓冲区未满就不会刷新),严格来说是父进程的缓冲区。(父进程缓冲区里的数据也是父进程的数据)
fork时,父进程就会创建出子进程,于是就有父子进程。
由于进程在退出时会把用户缓冲区的数据刷新到系统(进程对数据进行写入),父子进程谁先写入谁就会先发生写时拷贝,于是父子进程数据各自一份,最后父子进程退出时都把各自用户缓冲区的数据刷新到了系统,所以我们看到了 C 语言接口打印的消息会有两份。

C 语言接口打印的消息重复出现的原因是写实拷贝,fork虽然在各种打印函数之后,但是也有可能产生影响。

为了尝试解决这个问题,我们可以考虑在fork前调用fflush函数强制把父进程缓冲区的数据刷新到系统。

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

父进程在fork前就调用了fflush函数把用户缓冲区的数据刷新到了系统,于是fork时,父进程缓冲区里就没数据了,也就不存在写时拷贝了,也就不会出现那种现象。

在重定向后,调用系统调用不会有消息的重复打印,而调用 C 语言函数却有。
实际上,这也验证了 C 语言缓冲区(用户缓冲区)并不在操作系统内,而在用户层。

cincoutiostreamfstream,流也是对象,会包含缓冲区,以字节为单位读/写,C/C++ 给我们提供了缓冲区。C++ 的std::endl类似 C 的 ‘\n’ ,作用是将用户缓冲区的数据刷新到系统中。

六、理解文件系统

1.机械硬盘

文件 = 文件内容 + 文件属性
即使创建了一个空文件,这个空文件也是要占磁盘空间的,因为它是有文件属性的,文件属性也是数据。
如果一个磁盘文件没有被打开,它的内容和属性就在磁盘上。

在这里插入图片描述
以下讨论的均是传统的机械硬盘(不讨论现在的固态硬盘等):

在这里插入图片描述
每个盘片包含两个面,每个盘面都对应地有一个读写磁头。

在这里插入图片描述

我们的数据都放在盘片上,磁头可以摆动,盘片不断在转动,这样磁头就可以定位到盘片上的特定位置。

在盘片同心圆的一圈上有很多小段区域,称为扇区,它存储着数据,这一圈称为一个磁道,在同一圆心上的盘片的相同半径上的磁道合起来形成一个柱面。

磁盘写入的基本单位是扇区,一个扇区的大小一般是 512 字节。

我们如果要找磁盘盘片上的某一个扇区的位置,需要先找到哪一个盘面(磁头),然后再找到哪一个磁道,最后找这个磁道上的哪一个扇区。

磁盘和操作系统进行 IO 数据交互时的基本单位是 4 KB(即八个扇区的大小),为什么是4KB呢?
这跟物理内存和磁盘的数据交互有关,而且 IO 效率本来就低(如果单次数据交互的空间太小,就会带来高频次的 IO 数据交互,会带来 IO 效率的降低)。

我们可以把盘片想象为线性的结构(好像磁带那样):
在这里插入图片描述
把磁盘想象成一个大数组,我们如果要访问扇区的话,定位到这个扇区对应的下标就可以。
LBA(Logical Block Address,逻辑区块地址),对应一个扇区的地址,它是抽象出来的地址。
若要对磁盘写入的话,需要把 LBA 转化为磁盘地址,有点类似虚拟地址和物理地址的映射关系。

2.磁盘和文件

因为磁盘很大,所以需要对它进行管理。
在这里插入图片描述
使用磁盘需要经历两个步骤:
 ① 分区:把大磁盘划分为小空间(笔记本电脑在物理上一般只有一个磁盘,分区后就有 C 盘、D 盘等)。
 ② 格式化:给分区写入文件系统。

在操作系统层面上,只要把一个分区管好了,其它分区也就能管好。
于是我们只需研究一个分区:
在这里插入图片描述

 ① Boot Block:与启动相关。
 ② Block group:文件系统会根据分区的大小划分为数个 Block group ,而每个 Block group 都有着相同的结构组成。

只要把一个 Block group 管好了,其它的 Block group 也就能管好。
于是我们只需研究一个 Block group:
在这里插入图片描述

 ① Super Block:存放整个分区的相关文件系统信息。记录的信息主要有:block 和 inode 的总量、未使用的 block 和 inode 的数量、一个 block 和 inode 的大小、最近一次挂载的时间、最近一次写入数据的时间、最近一次检验磁盘的时间等其他文件系统的相关信息。若 Super Block 的信息被破坏,可以说整个文件系统结构就被破坏了,因此为了预防这种情况,每个 Block group 都有 Super Block 的空间位置,相当于备份。
 ② Group Descriptor Table:与 Block group 相关,描述这个组的相关信息。
 ③ Block Bitmap:描述 Data blocks 中 block 的使用情况,每个 bit 表示一个 block 是否被占用。
 ④ inode Bitmap:描述 inode Table 中 inode 的使用情况,每个 bit 表示一个 inode 是否被占用。
 ⑤ inode Table:里面有很多数据块,每个数据块称之为一个 inode(有对应的 inode 编号),存放文件属性。在这里插入图片描述 ⑥ Data blocks:里面有很多数据块,每个数据块称之为一个 block(有对应的 block 编号),存放文件内容。

创建一个文件时,会在 inode Bitmap 里找一个没被占用的 inode 编号,然后把文件的属性写入在 inode Table 中inode 编号对应的 inode 。

往文件写入内容,会在 Block Bitmap 里找一个(或多个)没被使用的 block 编号,然后把 block 编号写入对应的 inode 中(把两者关联起来),然后再把内容写入到 block 里。

一个文件对应一个 inode ,每个 inode 都有自己的编号。
在 Linux 中,真正在系统层面上标定一个文件的不是文件名(文件名是给用户使用的),而是文件的 inode 编号。

所以,以后访问文件内容时,系统会先根据 inode 编号找到 inode ,然后在 inode 里找到对应的 block 编号,于是就找到并访问 block 里的数据。

这些就是 Linux 特有的 EXT 系列文件系统的核心思想。

用户只知道文件名,但系统标定一个文件用的是 inode 编号,那么系统如何将这两者建立关系呢?

这跟下面要讲的目录有关。

目录也是文件,也有文件属性和文件内容,也有对应的 inode 和 block 。

使用stat 目录名命令可以查看目录的文件属性信息:
在这里插入图片描述

我们所创建的所有文件,一定都在一个特定的目录下。
在目录的 block 里,放的就是文件名和 inode 编号的映射关系
在一个目录下创建一个文件,本质上是把这个新建文件的文件名和 inode 编号的映射关系写入到目录的 block 里。

在系统层面上,解释下面指令的操作:

在这里插入图片描述

 ① 创建文件:在 inode Bitmap 里找一个没被占用的 inode 编号,然后把文件的属性写入 inode 编号对应的 inode 。同时也会把这个文件名和它的 inode 编号在当前目录下新建一个映射关系。
 ② 对文件写入:查看 Block Bitmap ,找没被使用的 block ,然后把 block 编号写入对应的 inode(把两者关联起来),再把数据写入对应的 block 里。
 ③ 通过cat 文件名命令查看文件内容:先找当前目录的 block 里该文件名和 inode 编号的映射关系,然后通过 inode 编号在 inode Table 里找对应的 inode ,然后通过 inode 找到对应的 block ,最后打印 block 里的文件内容。
 ④ ls -al命令:先找当前目录的 block 里所有的文件名和 inode 编号的映射关系,然后再把每个文件的 inode 里的文件属性拿到,打印出来。
 ⑤ rm 文件名:先找当前目录的 block 里该文件名和 inode 编号的映射关系,然后在 inode Bitmap 里把对应的比特位由原来的 1 置为 0 ,然后通过 inode 找到对应的 block 编号,在 Block Bitmap 里把对应的比特位由原来的 1 置为 0 。

也就是说,删除了文件,它原来的属性数据和内容数据还在,还可以恢复回来。
恢复文件的原理就是通过被删文件的 inode 编号,在 inode Bitmap 中把对应的比特位由 0 置 1 ,然后通过 inode 找到对应的 block 编号,在 Block Bitmap 中把对应的比特位由 0 置 1 。
关于如何知道被删文件的 inode 编号以及如何恢复等问题是有 Linux 特定的工具来处理的。
如果不小心误删了文件,最好的做法是什么都别做,让专门人士来恢复,或者如果自己会的话,也可以自己尝试恢复。(若还在创建文件,操作系统极有可能把刚删了的文件的那部分空间重新使用)

因此,对磁盘文件进行各种操作,其实都是先找当前目录的 block 里该文件名和 inode 编号的映射关系,然后找到对应的 inode(文件属性),通过 inode 可以找到对应的 block(文件内容),这样文件的属性和内容就全找到了。

3.软链接

软链接是一种文件形态,它特别像 Windows 系统中的快捷方式。
使用ln -s 目标文件路径 软链接名称命令来建立一个软链接。

在这里插入图片描述

删除一个软链接,使用unlink 软链接命令,不太建议使用rm 软链接命令。

在这里插入图片描述

对于一些路径特别深的程序,我们一般可以创建一个软链接快速找到它。

如果我们想要执行在某个路径下的程序或脚本,就只能通过输入一连串路径的方式来找到目标文件,这样太麻烦了。
在这里插入图片描述

于是我们可以创建一个软链接:
在这里插入图片描述之后我们就可以直接通过软链接来找到对应的程序或脚本。

4.硬链接

使用ln 目标文件路径 硬链接名称命令来建立一个硬链接。

在这里插入图片描述

硬链接和软链接有什么区别呢?

软硬链接的本质区别在于是否具有独立 inode 。

在这里插入图片描述

我们发现软链接和它指向文件的 inode 编号是不同的,而硬链接和它指向文件的 inode 编号是相同的。

其实,软链接是有自己独立的 inode 的,软链接是一个独立文件,有自己的 inode(文件属性),也有自己的 block(文件内容),block 里保存的是指向文件的所在路径 + 文件名。
硬链接根本不是一个独立的文件,因为它没有独立的 inode ,其实它是一个文件名和 inode 编号的映射关系。

创建硬链接,本质是在特定的目录下创建一个文件名和 inode 编号的映射关系。

文件的硬链接数:在这里插入图片描述

硬链接数也是文件属性,它在 inode 里,表示该 inode 与多少个文件名存在映射关系(引用计数)。
所以,创建或删除一个硬链接,其实就是对该文件 inode 的硬链接数++或-- 。当硬链接数减到 0 时,这个文件才会被删掉。

为什么一个新创建目录的硬链接数是 2 呢?
因为新创建目录的 inode 与两个文件名存在映射关系。

在这里插入图片描述
新创建的目录对应有两个文件名,分别是创建该目录时的名称和该目录下的隐藏文件 .(表示当前目录),它们都对应该目录的 inode ,所以它的硬链接数(引用计数)是 2 。

如果我们在该目录下又创建了另一个目录,它的硬链接数就会 +1 。

在这里插入图片描述
这是因为该目录下新建目录下的隐藏文件 . .(表示上一级目录)与该目录的 inode 编号建立了映射关系。

在这里插入图片描述
所以它的硬链接数(引用计数)是 3 。

总结软硬链接的区别:

 ① 硬链接就是文件名和 inode 编号的映射关系,也就是说可以让多个文件名对应于同一个 inode 。软链接就是一个普通文件,只是 block 里的内容有点特殊,里面存放的是指向文件的所在路径 + 文件名。
 ② 删除一个硬链接并不影响其他有着相同 inode 编号的文件。删除软链接并不影响被指向的文件,但如果把被指向的文件删除了,则相关的软链接就无效了。
 ⑤ 硬链接不能对目录进行创建,只可对文件创建。软链接可对文件或目录创建。

5.文件的三个时间

使用stat 文件名命令可以查看文件的属性信息:在这里插入图片描述

  • Access:文件最后访问时间。
  • Modify:文件内容最后修改时间。
  • Change:文件属性最后修改时间。

(1)Access

Access 表示最后一次访问(读取)文件的时间。

在这里插入图片描述
通过cat命令查看文件内容,发现它的 Access 时间并没有变化
通过vim命令修改文件内容,发现它的 Access 时间也没有变化。

Access 是最后一次访问时间,但我们发现实际操作下来,Access 时间却没有变化?

文件的访问就是文件的读取,在较新的 Linux 内核中,Access 时间不会被立即更新,而是有一定的时间间隔定期地更新。

因为 Modify 和 Change 实际上是较低频被改动的时间,而 Access 是最高频被改动的时间,因为读取文件或查看文件这个操作其实是最高频的,如果频繁地去更新 Access 时间,就可能会导致系统中出现大量的刷盘问题(和磁盘有过度频繁的交互)而使得 Linux 系统效率的降低,所以在较新的 Linux 内核版本中把这个操作给优化了。

(2)Modify

Modify 表示最后一次修改文件内容的时间。

通过重定向把内容追加到文件,修改了文件内容。在这里插入图片描述

(3)Change

Change 表示最后一次修改文件属性的时间。

在这里插入图片描述

当修改文件内容时,有可能也会修改文件属性,比如说可能会更改文件的大小属性。
所以 Modify 和 Change 的时间同时发生改变也很正常。

在这里插入图片描述

++++++++++++++++++++

在这里插入图片描述

makefile 在make时,如果源文件的内容没有被修改,那么可执行程序就不会给你再次生成。如果被修改过了,才会重新去编译源文件,再次生成可执行程序。

那么 makefile 怎么知道你的源文件是否被修改过呢?它看的就是源文件的 Modify 时间,只要 Modify 时间被更新了,那么它就认为源文件被修改过。

makefile 就是通过对比源文件的 Modify 时间可执行程序的时间来决定是否再次执行输入的make命令。

把源文件进行首次make编译时,生成的可执行程序的时间一定是比源文件的时间要晚的。

 ① 若可执行程序的时间比源文件的 Modify 时间要晚,说明源文件的内容是经过修改后才生成可执行程序的,就不会再次执行输入的make命令。
 ② 若源文件的 Modify 时间比可执行程序的时间要晚,说明在生成了可执行程序后又对源文件的内容做了修改,就会再次执行输入的make命令重新编译生成可执行程序,生成的可执行程序的时间就会比源文件的 Modify 时间要晚。

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

touch 文件名命令叫做更新文件的时间,它会把文件的三个时间(Access、Modify、Change)强制更新,即使文件没有做任何内容上的修改。

也就是说,对源文件使用touch 文件名命令,即使源文件没有做任何内容上的修改,但由于它的 Modify 时间发生了更新,因此可以通过make命令再重新编译一次生成可执行程序。
在这里插入图片描述

makefile 的.PHONY伪目标代表总是可以被执行,本质就是不关心文件的时间,直接执行,即总是可以被执行。
++++++++++++++++++++

七、动静态库

1.基本认识

所谓的库其实就是一个存在于系统中的磁盘文件,本质跟用户创建的磁盘文件是一样的。
库文件都安装在/usr/lib下或/usr/lib64/下。

在 Linux 中,库文件一般分为两种:动态库(也称为共享库)和静态库
 ① 如果是动态库,库文件以 .so 作为后缀。
 ② 如果是静态库,库文件以 .a 作为后缀。

在 Windows 系统中,动态库以 .dll 作为后缀,静态库以 .lib 作为后缀。

库文件的命名:libxxx.so(动态库)或 libxxx.a(静态库)。
库的真实名称:去掉 lib 前缀,去掉 .a 或 .so 后缀,剩下的就是库的名称 xxx 。

比如:库文件 libc-2.17.so 的名称就是 c-2.17 。

我们可以使用ldd 可执行程序名命令来查看一个可执行程序依赖的动态库的信息。

在这里插入图片描述
比如我们查看这个 C 库 libc.so.6(动态库)的信息,它在系统的路径是 /usr/lib64/libc.so.6 ,我们查看一下,发现它是一个软链接,它指向了一个 C 标准库:libc-2.17.so 。
在这里插入图片描述

我们可以通过file 文件名命令来查看一个可执行程序是动态链接还是静态链接。

那么动态库和静态库的差别是什么呢?

 ① 动态链接就是当程序需要执行库函数的代码时,直接跳转到动态库当中去执行。动态库可以在多个程序间共享,所以可执行程序的体积往往比较小,但如果对应的动态库缺失了,程序就运行不了了。

 ② 静态链接就是在编译时就把库中相关的内容拷贝到可执行程序里。所以可执行程序的体积往往比较大,不过一旦形成可执行程序就不再依赖库了,即使库不存在也不影响。

gcc 默认使用动态链接编译。

在这里插入图片描述

若需要 gcc 使用静态链接编译,需带上 -static 选项。

在这里插入图片描述

一个可执行程序有可能既动态链接又静态链接,也就是既依赖动态库又依赖静态库。

其实在 Linux 系统中,有相当大的一部分命令是用 C 语言写的。

比如 ls 命令:在这里插入图片描述
比如 top 命令:在这里插入图片描述
这些程序在系统中都共有同一个 C 库。
可想而知,若把 C 库删掉,不仅自己的程序不能运行了,有可能连系统命令都执行不了,它通常会告诉你:对不起,库文件找不到,程序运行失败。

库文件本身就是二进制文件,那我们如何得知一个库给我们提供了什么方法呢?
库文件虽然是二进制的,可是头文件不是。头文件是文本文件,会说明库中暴露出来的方法的基本使用。

实际上,一套完整的库需包括三种文件:
 ① 库文件。
 ② 头文件。
 ③ 说明文档。

我们在 C/C++ 中,为什么通常把声明和实现分别放进头文件和源文件,而不是直接放进同一个文件里呢?
其中一个原因是头文件只放声明能方便制作库!

库的存在一方面是为了方便使用,而另一方面是为了安全,我们不想把源文件给别人,而只想把库给别人。
我们把库给别人使用的时候,给的是头文件和库文件,这相当于用库文件对源文件做了一个封装保护。

如何使用库?
将库文件和头文件加入到自己的项目中。

如何制作库?
 ① 先把所有的源文件编译生成对应的 .o 文件。
 ② 再把所有的 .o 文件使用 ar 或 gcc 来进行打包。(制作动静态库的本质)
 ③ 交付头文件和库文件。

下面我们将用源文件 add.c 和 sub.c 来制作库文件:

在这里插入图片描述

2.静态库

(1)制作静态库

 ① 先把所有的源文件编译生成对应的 .o 文件。
 ② 然后使用 ar 将所有的 .o 文件打包在一起,形成一个库文件。

ar -rc 静态库名称 .o文件名称命令的作用是把 .o 文件打包形成一个静态库。

创建 makefile 文件(目标是生成静态库):在这里插入图片描述

在这里插入图片描述

我们可以使用ar -tv 静态库名称命令查看静态库内包含的 .o 文件:在这里插入图片描述
我们也可以查看 C 静态库内包含的 .o 文件(其包含的 .o 文件远不止图片上列出的):
在这里插入图片描述

 ③ 交付头文件和库文件。

在这里插入图片描述

(2)使用静态库

生成的静态库在 lib 目录下。在这里插入图片描述

创建 makefile 文件(目标是生成可执行程序):
在这里插入图片描述gcc 选项说明:
 ① -I:指明头文件的搜索路径。
 ② -L:指明库文件的搜索路径。
 ③ -l:指明要链接的库文件的名称。

给图片加上注释后,即:
在这里插入图片描述

在这里插入图片描述

//

我们之前也用了库啊,为什么在用 gcc 编译时就没有指明这些选项呢?

因为我们之前用的库在系统的默认路径/usr/lib64/usr/lib,而其头文件在系统默认路径/usr/include等,gcc 是能识别这些路径的。

换言之,如果在用 gcc 编译链接自己的库时不想带上这些选项,可以把对应的库文件和头文件拷贝到系统的默认路径下,不过严重不推荐这种做法。

//

3.动态库

动态库的制作和使用跟静态库基本一致,不过有些地方稍有差异。

(1)制作动态库

 ① 先把所有的源文件编译生成对应的 .o 文件。
 ② 然后使用 gcc 将所有的 .o 文件打包在一起,形成一个库文件。

创建 makefile 文件(目标是生成动态库):在这里插入图片描述gcc 选项说明:
 ① -fPIC表示生成位置无关码。
 ② -shared表示生成一个动态链接的共享库。

在这里插入图片描述

 ③ 交付头文件和库文件。

在这里插入图片描述

(2)使用动态库

生成的动态库在 lib 目录下。在这里插入图片描述

创建 makefile 文件(目标是生成可执行程序):在这里插入图片描述gcc 选项说明:
 ① -I:指明头文件的搜索路径。
 ② -L:指明库文件的搜索路径。
 ③ -l:指明要链接的库文件的名称。

给图片加上注释后,即:
在这里插入图片描述

在这里插入图片描述

可以编译生成可执行程序,但是程序运行时就报错了。
为什么一运行就报错呢?因为动态库没找到。

我们的确告知了编译器在编译时库文件在哪里,但程序在运行时还需要有一个加载动态库的过程。
程序在编译链接时需要找到动态库,在运行时也需要找到动态库
换言之,为了程序在运行时能链接到动态库,我们还需要进一步告知系统我们的动态库在哪里。

为什么链接静态库的程序在运行时没有这个报错呢?
因为在程序编译的链接阶段,静态库的代码就被拷贝进了程序中,所以程序在运行时不需要找静态库。

所以我们有两种常见做法。

第一种做法:用export命令将你的动态库所在路径添加到环境变量LD_LIBRARY_PATH当中。

环境变量 LD_LIBRARY_PATH 指明在程序启动之后动态库的搜索路径。

在这里插入图片描述
在指明路径时不需要再指明库的名称了,因为在编译时已经告知了库的名称了。
从哪可以看出来呢?从之前它知道库名称但是没找到可以看出来。

再次使用ldd命令查看可执行程序依赖的动态库的信息。
这次可执行程序就能够找到动态库,可以正常运行了。

在这里插入图片描述

export命令的效力仅限于本次登录操作,下次登录就没了。若想让该环境变量永久生效,需要修改配置文件,但不建议这样做。

第二种做法:在/etc/ld.so.conf.d/路径下配置动态库的搜索路径。

/etc/ld.so.conf.d/是系统默认的动态库搜索路径。

新建一个我们的配置文件,然后写入你的动态库所在路径,然后再使用ldconfig命令(若权限不够加上 sudo )更新缓存。

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

若我们删掉之前写入的配置文件,然后再使用ldconfig命令更新缓存,就会又找不到我们的动态库了。在这里插入图片描述
在这里插入图片描述

还有第三种做法:把动态库文件直接拷贝到系统默认的库路径下(严重不推荐这种做法)。

4.总结

如何制作库?
 ① 先把所有的源文件编译生成对应的 .o 文件。
 ② 再把所有的 .o 文件使用 ar 或 gcc 来进行打包。(制作动静态库的本质)
 ③ 交付头文件和库文件。

如何使用库?
将库文件和头文件加入到自己的项目中。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值