在C、C++、Python等语言中存在对文件操作的接口,通过这些接口我们可以创建文件,并实现文件内容的写入和读取,本文将介绍Linux下我们是如何进行文件操作的,并且深入底层的解析文件操作的原理。
文章目录
- 一、C语言中的文件接口
- 1.1 fopen
- 1.2 fwrite
- 1.3 fseek
- 1.4 fread
- 1.5 fclose
- 1.6 stdin & stdout & stderr
- 二、系统文件IO
- 2.1 open
- 2.2 write
- 2.3 lseek
- 2.4 read
- 2.5 close
- 三、文件描述符fd(🌟)
- 3.1 文件描述符的分配规则
- 3.2 重定向
- ① 输出重定向
- ② 追加重定向
- ③ 输入重定向
- ④ 标准输出和标准错误的区别
- ⑤ dup2接口
- ⑥ 练习
- 3.3 文件描述符和进程之间的关系
- 3.4 相关题目 ⭐
- 四、缓冲区 🌟
- 4.1 fork 导致库函数重定向打印两次
- 用户-》OS 刷新策略
- 4.2 close 导致数据写不进文件的问题
- 五、文件系统
- 5.1 inode 🌟
- 概念题
- 5.2 EXT2文件系统的存储方案
- 5.3 软硬链接 🌟
- ① 软链接
- ② 硬链接
- ③ 软硬链接的区别
- ④ 相关题目
- 5.4 总结
- 六、文件的三个时间
首先我们先来回顾一下在C语言中的文件操作接口:
一、C语言中的文件接口
1.1 fopen
FILE *fopen(const char *path, const char *mode);
- 功能:打开文件
- 返回值:打开成功返回文件流指针,失败则返回NULL
- 参数:
path,要打开文件的路径,若不指定则默认打开可执行程序所在路径下文件
mode,文件的打开方式
fopen打开文件的方式
- r 只读方式打开文件
- r+ 可读可写方式打开文件
- w 以只写方式打开文件,若文件存在则清空内容,不存在则创建文件
- w+ 可读可写方式打开文件,若文件存在则清空其内容,不存在则创建文件
- a 追加写,若文件不存在则创建文件,文件存在,文件流指针指向文件末尾进行写
- a+ 可以读,可追加写,若文件不存在则创建文件,读的位置初始化到文件头,写的位置从文件末尾开始
在实际应用中,若文件读取或写入不成功,要先查看打开方式是否正确
程序:
当
fopen
以写入的方式打开一个文件时,若该文件不存在,则会自动在当前路径创建该文件,这里所说的当前路径指的不是指可执行程序所处的路径,而是指该可执行程序运行成为进程时我们所处的路径。
1.2 fwrite
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE*stream);
- 功能:向文件写入数据
- 返回值:成功写入的文件快的个数
- 参数
ptr:要写入文件的内容
size:写入时,一个块的大小,单位字节(通常定义1个字节)
nmemb:写入多少块
stream:文件流指针
1.3 fseek
int fseek(FILE *stream, long offset, int whence)
- 函数功能:移动文件流指针位置
- 返回值:成功返回0,失败返回-1
- 参数
stream:文件流指针
offset:偏移量
whence:将文件流指针移动到什么位置
whence常用的几个参数
- SEEK_SET 文件头部
- SEEK_CUR 当前文件流指针位置
- SEEK_END 文件末尾
1.4 fread
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream)
- 函数功能:从文件中读取数据
- 返回值:成功读入的文件块的个数
- 参数
ptr:要写入的位置
size:读取文件时,一个块的大小
nmeb:读取多少块
stream:文件流指针
1.5 fclose
int fclose(FILE *fp)
关闭文件流指针,在写入或读取结束后,要在程序中关闭文件流指针,否则会造成文件句柄泄漏,持续泄露,最终会导致进程不能打开新的文件。
下面是一个完整程序,包括文件的打开,写入,移动文件流指针,读取,移动文件流指针:
#include<stdio.h>
#include<string.h>
int main()
{
//1.文件打开
FILE* fp=fopen("1.txt", "w+");
if(fp == NULL)//文件打开失败
{
perror("fopen");
}
else//文件打开成功
{
printf("Open success...\n");
}
//2.写入
const char* buffer={"Never say never."};
size_t s = fwrite(buffer, 1, strlen(buffer) ,fp);
printf("write size:%ld\n", s);
//3.移动文件流指针位置
fseek(fp, 0, SEEK_SET);
//4.读
char r_word[100] = {0};
size_t r_size = fread(r_word, 1, sizeof(r_word)-1, fp);
printf("read size:%ld\n", r_size);
printf("%s\n", r_word);
//5.关闭文件流指针
fclose(fp);
return 0;
}
1.6 stdin & stdout & stderr
打开文件一定是进程运行的时候打开的,而任何进程在运行的时候都会默认打开三个输入输出流,即标准输入流、标准输出流以及标准错误流,对应到C语言当中就是stdin、stdout以及stderr。
标准输入流对应的设备就是键盘
标准输出流和标准错误流对应的设备都是显示器。
查看man手册我们就可以发现,stdin、stdout以及stderr这三个实际上都是FILE*
类型的。
extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;
当我们的C程序被运行起来时,操作系统就会默认使用C语言的相关接口将这三个输入输出流打开,之后我们才能调用类似于scanf和printf之类的函数向键盘和显示器进行相应的输入输出操作。
也就是说,stdin、stdout以及stderr与我们打开某一文件时获取到的文件指针是同一个概念,试想我们使用fputs函数时,将其第二个参数设置为stdout,此时fputs函数会将数据打印到显示器上面。
注意: 不止是C语言当中有标准输入流、标准输出流和标准错误流,C++当中也有对应的cin、cout和cerr,其他所有语言当中都有类似的概念。实际上这种特性并不是某种语言所特有的,而是由操作系统所支持的。
fd实际上就是文件描述符,0、1、2三个描述符对应stdin,stdout,stderr。
可以查看"stdio.h"头文件中的定义:
二、系统文件IO
2.1 open
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
- 头文件
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
- 函数功能:打开文件
- 返回值:打开成功返回文件描述符,打开失败返回-1
- 参数
pathname:文件的路径
flags:打开方式
mode:创建的文件权限,传一个8进制数字(如:0664)
open的打开方式
O_RDONLY
:只读打开O_WRONLY
:只写打开O_RDWR
:可读可写
前三个选项只能指定一个O_CREAT
:文件不存在,则创建。需要使用mode选项,指明新文件的访问权限O_APPEND
:追加写,即打开时将文件流指针移动到文件末尾位置O_TRUCNC
:打开文件默认清空文件
这些方式若选取多个,使用“或”运算连接,如:"O_RDWR
| O_CREAT
"表示以可读可写方式打开,若文件不存在则创建文件。注意,文件不存在的时候一定要加上 O_CREAT
2.2 write
ssize_t write(int fd, const void *buf, size_t count)
- 函数功能:向文件写入内容
- 返回值:成功写入文件的字节数
- 参数
fd:文件描述符
buf:将buf指向的内容写入文件
count:期望写入多少字节
程序
2.3 lseek
off_t lseek(int fd, off_t offset, int whence)
- 函数功能:重新定位读/写文件偏移量
- 返回值:移动成功,返回偏移的位置,单位字节;调用失败返回-1
- 参数
fd:文件描述符
offset:偏移量,单位字节
whence:要偏移到什么位置
whence常用参数
- SEEK_SET 文件头部
- SEEK_CUR 当前文件读取或写入位置
- SEEK_END 文件尾部
2.4 read
ssize_t read(int fd, void *buf, size_t count)
- 函数功能:读取文件内容
- 返回值:从文件中读取的字节数
- 参数
fd:文件描述符
buf:读取的内容保存到buf中
count:期望读多少字节
2.5 close
int close(int fd)
关闭文件描述符
包括文件打开、写入、读写位置偏移、读取、关闭文件描述符的程序:
#include<stdio.h>
#include<unistd.h>
#include<fcntl.h>
#include<string.h>
int main()
{
//1.打开文件
int fd =open("log.txt",O_RDWR | O_CREAT,0664);
if(fd<0)//打开失败
{
perror("open");
return 0;
}
//2.写入
const char* msg="hello linux!";
write(fd, msg, strlen(msg));
//3.移动读取位置
lseek(fd, 0, SEEK_SET);
//4.读取
char buff[100]={0};
ssize_t s = read(fd, buff, sizeof(buff)-1);
buff[s] = 0;//最后一位加上\0,表示字符串结尾
printf("%s\n",buff);
//关闭文件描述符
close(fd);
return 0;
}
三、文件描述符fd(🌟)
在认识返回值fd之前,先来认识一下两个概念: 系统调用 和 库函数
上面的
fopen
fclose
fread
fwrite
都是C标准库当中的函数,我们称之为库函数(libc)。而
open
close
read
write
lseek
都属于系统提供的接口,称之为系统调用接口系统调用接口和库函数的关系,一目了然。
所以,可以认为,f#系列的函数,都是对系统调用的封装,方便二次开发
所有的文件操作,表现上都是进程执行对应的函数!是进程对文件的操作
-》要操作文件必须要先打开文件!-》将文件相关的属性信息,加载到内存
-》操作系统中会存在大量的进程,进程可以打开多个文件吗?可以,进程:文件 = 1:n -》所以系统中可能存在更多 打开的文件!
-》那么,OS要把打开的文件在内存中(系统中)管理起来 -》如何管理?先描述,再组织! -》操作系统会为每个已经打开的文件创建各自的struct file结构体包含了打开的文件的相关属性,还有链接属性,然后将这些结构体以双链表的形式连接起来,之后操作系统对文件的管理也就变成了对这张双链表的增删查改等操作。
而为了区分已经打开的文件哪些属于特定的某一个进程,我们就还需要建立进程和文件之间的对应关系。
当我们的程序运行起来,变成了进程之后-》默认情况下,操作系统会帮助我们的进程打开三个标准输入输出,他们的fd分别是:
0:标准输入 键盘 stdin
1:标准输出 显示器 stdout
2:标准错误 显示器 stderr
文件描述符就是从0开始的小整数。当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件。于是就有了file结构体。表示一个已经打开的文件对象。而进程执行open系统调用,所以必须让进程和文件关联起来。每个进程都有一个指针*files,指向一张表files_struct,该表最重要的部分就是包涵一个指针数组,每个元素都是一个指向打开文件的指针!
所以,本质上,文件描述符fd就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件。
当进程打开log.txt文件时,我们需要先将该文件从磁盘当中加载到内存,形成对应的struct file,将该struct file连入文件双链表,并将该结构体的首地址填入到fd_array数组当中下标为3的位置,使得fd_array数组中下标为3的指针指向该struct file,最后返回该文件的文件描述符给调用进程即可。
C语言程序上如何实现多态的特性?同样的方法不同的效果
如何理解一切皆文件?操作系统软件层面上虚拟化出一层file文件描述结构体,每个file结构体里面包含一批函数指针和文件相关属性,用具体的函数指针指向底层(键盘、显示器、磁盘等文件)不同的方法,所以对于上层而言看到的就是统一的接口,即只需要调用对应的接口就可以对底层进行操作而无需关注我们操作的是什么文件。
外设,IO都有读写方法,但不一定有这个功能,没有这个功能则方法就为空
思考
-
如果一个文件没有被打开,这个文件存在哪里?
在磁盘上
-
如果我创建一个空文件,该文件要不要占用磁盘空间?
必须的!文件有属性,属性也是数据。磁盘文件 = 文件内容 + 文件属性!文件操作:对文件内容操作 和 对文件的属性操作
-
磁盘文件 VS 内存文件?
当文件存储在磁盘当中时,我们将其称之为磁盘文件,而当磁盘文件被加载到内存当中后,我们将加载到内存当中的文件称之为内存文件。磁盘文件和内存文件之间的关系就像程序和进程的关系一样,当程序运行起来后便成了进程,而当磁盘文件加载到内存后便成了内存文件。
磁盘文件由两部分构成,分别是文件内容和文件属性。文件内容就是文件当中存储的数据,文件属性就是文件的一些基本信息,例如文件名、文件大小以及文件创建时间等信息都是文件属性,文件属性又被称为元信息。
文件加载到内存时,一般先加载文件的属性信息,当需要对文件内容进行读取、输入或输出等操作时,再延后式的加载文件数据。
3.1 文件描述符的分配规则
文件描述符不是固定分配的,而是按照最小未使用原则分配,当进程创建时就默认打开了标准输入流、标准输出流和标准错误流,也就是说数组当中下标为0、1、2的位置已经被占用了,此时如果我们要打开一个文件,申请到的文件描述符就只能从3开始进行分配。
但是如果我们手动关闭 标准输入fd==0(close(0))
,我们第一个得到的文件描述符就是0,关闭fd==2(close(2))
得到的文件描述符就是2
文件描述符的分配规则:给新文件分配的fd,是从fd_array中找一个最小的,没有被使用的下标fd
拓展:
其他语言的文件操作,底层都会涉及fd文件描述符
3.2 重定向
符号
- >> 追加重定型
- > 清空重定向
重定向的本质是修改files结构体中指针数组在fd下标位置上保存的地址,让该下标位置指向其他的文件。
① 输出重定向
输出重定向就是,将我们本应该输出到文件1的数据重定向输出到文件2中。
以文件log.txt和显示器为例:
如果我们想让输出到显示器当中的内容输出到log.txt文件里面,就需要先关闭 fd为1的文件(显示器文件),然后我们打开log.txt时申请到的文件描述符就是1:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
close(1);
int fd = open("log.txt", O_WRONLY | O_CREAT, 0664);
if (fd < 0){
perror("open");
return 1;
}
printf("hello world\n");
printf("hello world\n");
printf("hello world\n");
printf("hello world\n");
printf("hello world\n");
return 0;
}
这里可以看到,运行程序后,显示器上没有打印数据,数据都被写到了log.txt文件当中
操作系统关闭标准输出文件后,会打开一个 log.txt 文件(file结构体),并且让数组下标为1的位置指向该文件结构体。printf在C语言中叫做打印,本质是向标准输出打印:stdout,stdout是一个FILE*指针, FILE是C语言层面上的结构体,其中一定包含一个整数,对应在系统层面上这个文件打开时的fd。
所以,stdout当中一定有个数字对应fd==1,在我们关闭标准输出之后,现在stdout指向的“标准输出”文件就是 log.txt,重定向打印到该文件中。
printf函数是默认向stdout输出数据,而stdout指向的是一个struct FILE类型的结构体,该结构体当中有一个存储文件描述符的变量,而stdout指向的FILE结构体中存储的文件描述符就是1,因此printf实际上就是向文件描述符为1的文件输出数据。
这里可以看到,我在本次运行中没有close(fd),这涉及到后面缓冲区的问题,我们放在后面详细解说。
② 追加重定向
追加重定向与输出重定向本质相同,都是输出内容,区别在于,输出重定向会覆盖原文件的内容,即清空原文件重新写入新内容。而追加重定向是在旧数据后面添加内容,不清空文件。
这里我们只需要给文件打开的模式加上一个O_APPEND就是追加重定向
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
close(1);
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0664);//👈
if (fd < 0){
perror("open");
return 1;
}
printf("hello world\n");
printf("hello world\n");
printf("hello world\n");
printf("hello world\n");
printf("hello world\n");
return 0;
}
③ 输入重定向
输入重定向就是,我们本应该从文件1的数据读取内容,现在重定向从文件2中读取内容。
以文件log.txt和键盘为例:
原本我们通过scanf函数从键盘输入的数据中读取内容写到数组当中,即scanf函数从stdin中读取数据,而stdin对应的描述结构体指向fd为0的文件,现在我们关闭fd为0的文件(键盘文件),这时我们打开log.txt文件申请到的fd就是0,此时stdin指向的就是log.txt文件,然后我们调用scanf函数就会默认从log.txt文件当中读取内容写到数组里。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
close(0);
int fd = open("log.txt", O_RDONLY | O_CREAT, 0664);
if (fd < 0){
perror("open");
return 1;
}
char str[40];
while (scanf("%s", str) != EOF){
printf("%s\n", str);
}
close(fd);
return 0;
}
scanf函数是默认从stdin读取数据的,而stdin指向的FILE结构体中存储的文件描述符是0,因此scanf实际上就是向文件描述符为0的文件读取数据。
④ 标准输出和标准错误的区别
标准输出流和标准错误流对应的都是显示器,它们有什么区别?
如果我们只是想要打印内容,那么他们两者对应的fd文件都可以打印在显示器上。但是如果我们想要把程序运行结果重定向输出到文件log.txt当中,我们会看到文件里只有标准输出fd==1
输出的字符,标准错误fd==2
输出的内容不会写到文件里。因为重定向叫做输出重定向,重定向的是fd==1
的标准输出,不会改变标准错误。
如何让标准错误也输出到指定文件?
./myfile > log.txt 2>&1
2>&1
叫做把1文件file结构体里面的内容拷贝到2文件结构体里,所以2文件也指向 指定的文件,即:将标准错误重定向到文件描述符为1的文件
证明stdin、stdout、stderr对应的结构体里面包含指定的文件描述符fd
printf("stdin->%d\n",stdin->_fileno);//0
printf("stdout->%d\n",stdout->_fileno);//1
printf("stderr->%d\n",stderr->_fileno);//2
⑤ dup2接口
使用dup2系统调用进行重定向,man 2 dup2
查看手册,2号手册是系统调用。
不推荐使用dup和dup3
int dup2(int oldfd, int newfd);
**函数功能:**dup2的两个参数均是文件描述符,含义是将newfd的值重定向为oldfd,即newfd对应的文件指针指向原来oldfd指向的文件。
函数返回值: dup2如果调用成功,返回newfd,否则返回-1。
如果newfd本身已经有对应打开的文件信息,则会先关闭文件后再重定向(否则会资源泄露)
例如,我们将fd_array[3]当中保存的地址拷贝到fd_array[1]当中,因为C语言当中的stdout就是向文件描述符为1文件输出数据,那么此时我们就将输出重定向到了文件log.txt。dup2(3, 1)
使用dup2时,我们需要注意以下两点:
- 如果oldfd不是有效的文件描述符,则dup2调用失败,并且此时文件描述符为newfd的文件没有被关闭。
- 如果oldfd是一个有效的文件描述符,但是newfd和oldfd具有相同的值,则dup2不做任何操作,并返回newfd。
我们将打开文件log.txt时获取到的文件描述符和1传入dup2函数,那么dup2将会把fd_arrya[fd]的内容拷贝到fd_array[1]中,在代码中我们向stdout输出数据,而stdout是向文件描述符为1的文件输出数据,因此,本应该输出到显示器的数据就会重定向输出到log.txt文件当中。
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
int fd = open("log.txt", O_WRONLY | O_CREAT, 0664);
if (fd < 0){
perror("open");
return 1;
}
close(1);
dup2(fd, 1);
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
return 0;
}
⑥ 练习
1、在bash中,在一条命令后加入”1>&2”意味着()
A.标准输出重定向到标准错误输出
B.标准输入重定向到标准错误输出
C.标准输出重定向到标准输入
D.标准错误输出重定向到标准输入
答案解析
- 文件描述符中:0-标准输入;1-标准输出;2-标准输出;
- 在这条语句中,>是重定向符号 &2是在重定向使用时标准错误的一种特殊写法
- 因此 1>&2 被理解为: 将标准输出重定向到标准错误
- 选A
2、bash中,需要将脚本demo.sh的标准输出和标准错误输出重定向至文件demo.log,以下哪些用法是正确的 [多选]
A.bash demo.sh &>demo.log
B.bash demo.sh >&demo.log
C.bash demo.sh >demo.log 2>&1
D.bash demo.sh 2>demo.log 1>demo.log
答案解析
在命令的重定向中, >表示冲定性,0表示标准输入,1表示标准输出,2表示标准错误
如果需要将标准输出和标准错误输出重定向至文件demo.log;
比较典型的方式是:bash demo.sh 1>demo.log 2>&1
- 先将标准输出重定向到demo.log文件,然后将标准错误重定向到标准输出(这时候的标准输出已经是指向文件了,所以也就是将标准错误重定向到文件)
A command &> file 表示将标准输出stdout和标准错误输出stderr重定向至指定的文件file中。
B 与A选项功能雷同
C 比较典型的写法,将标准输出和标准错误都重定向到文件, >demo.log是一种把前边的标准输出1忽略的写法
D 比较直观的一种写法,不秀技,直观的将标准输入和标准错误分别重定向到文件
所以全选
3、以下对int dup2(int oldfd, int newfd);接口描述错误的是: [多选]
A.重定向后,oldfd和newfd都会操作oldfd所操作的文件
B.重定向后,oldfd和newfd都会操作newfd所操作的文件
C.重定向前,若newfd已经有打开的文件,则会关闭
D.重定向前,若oldfd已经有打开的文件,则会关闭
答案解析
int dup2(int oldfd, int newfd);
函数功能为将newfd描述符重定向到oldfd描述符,相当于重定向完毕后都是操作oldfd所操作的文件
但是在过程中如果newfd本身已经有对应打开的文件信息,则会先关闭文件后再重定向(否则会资源泄露)
根据函数功能理解,正确选项为选择错误的,因此选择B和D选项
3.3 文件描述符和进程之间的关系
执行exec*程序替换的时候,会不会影响我们曾经打开的文件?
文件是内核数据结构,由操作系统管理,不发生写时拷贝,所以不影响,比如命令行执行重定向语句,我们会建立一个子进程进行程序替换,识别到 > 符号时确认为重定向,调用dup2接口修改fd,将内容输出到打开的文件里
创建新的子进程,会创建一个新的files_struct结构体,每个进程都会拥有files结构体,这个结构体里面的内容是从父进程那里拷贝过来的,所以内容一样。但是打开的文件不会新建,所以文件不会发生改变,父子进程指向同样的文件
为什么我们的所有的进程,都会默认打开那三个标准流?
父进程打开的标准输出输入,子进程也会继承下去!
因为我们所有进程的父进程都是bash,bash打开了这三个标准流,我们创建的进程作为它的子进程就也会打开这三个标准流。
3.4 相关题目 ⭐
1、Linux下两个进程可以同时打开同一个文件,这时如下描述错误的是:
A.两个进程中分别产生生成两个独立的fd
B.两个进程可以任意对文件进行读写操作,操作系统并不保证写的原子性
C.进程可以通过系统调用对文件加锁,从而实现对文件内容的保护
D.任何一个进程删除该文件时,另外一个进程会立即出现读写失败
E.两个进程可以分别读取文件的不同部分而不会相互影响
F.一个进程对文件长度和内容的修改另外一个进程可以立即感知
答案解析
我们知道,在一个进程中打开一个文件,会在进程内生成文件的描述信息结构,并将其地址添加到pcb中的文件描述信息数组中,最终返回所在位置下标作为文件描述符
- A选项正确,进程数据独有,各自有各自的文件描述信息表,因此各自打开文件会有自己独立的描述信息添加在各自信息表的不同位置,因此fd各自也相互独立
- B选项正确,两个进程打开同一个文件,但是各有各的文件描述信息以及读写位置,互不影响,因此多个进程同时读写有可能会造成穿插覆盖的情况(原子性操作,被认为是一次性完成的操作,操作过程中间不会被打断,通常以此表示操作的安全性)
- C选项正确,文件锁就是用于保护对文件当前的操作不会被打断,就算时间片轮转,因为已经对文件加锁,其他的进程也无法对文件内容进行操作,从而保护在本次文件操作过程是安全的。
- D选项错误,删除文件实际上只是删除文件的目录项,文件的数据以及inode并不会立即被删除,因此若进程已经打开文件,文件被删除时,并不会影响进程的操作,因为进程已经具备文件的描述信息(可以编写代码进行尝试,在文件打开后,外界删除文件,然后看进程中是否还可以继续写入或读取数据)
- E选项正确,如果仅仅是读取文件内容,两个不同进程其实都有自己各自的描述信息和读写位置,因此可以同时读取文件数据而不会受到对方的影响。
- F选项正确,因为文件内容的修改是直接反馈至磁盘文件系统中的,因此当文件内容被修改,其他进程因为也是针对磁盘数据的操作,因此可以立即感知到(可以写代码尝试一个进程打开文件后,等其他进程修改了内容后然后再读取文件数据进行测试)
针对以上理解,描述不正确的只有D选项
2、以下描述正确的是
A.文件描述符和文件流指针没有任何关系
B.文件流指针结构中封装了文件描述符
C.通过open打开文件返回的FILE *fp可以直接使用read读取数据
D.通过open打开文件返回的FILE *fp可以直接使用fread读取数据
答案解析
文件流指针是标准库IO操作句柄,是一个FILE*结构体指针,其中内部封装有文件描述符,其对应的操作接口有:fopen, fread, fwrite, fseek, fclose…
文件描述符是系统调用接口操作句柄,是一个非负整数,期对应的操作接口有: open, read, write, lseek, close…
根据以上理解:
- A选项错误,文件流指针中封装有文件描述符,他们是封装包含的关系
- B选项正确
- C和D选项错误,open是系统调用接口,返回的是文件描述符,并非文件流指针
3、以下描述正确的是 [多选]
A.程序中打开文件所返回的文件描述符, 本质上在PCB中是文件描述符表的下标
B.多个文件描述符可以通过dup2函数进行重定向后操作同一个文件
C.在进程中多次打开同一个文件返回的文件描述符是一致的
D.文件流指针就是struct _IO_FILE结构体, 该结构体当中的int _fileno 保存的文件描述符, 是一对一的关系
答案解析
- A选项正确:文件描述符实际上是内核中文件描述信息表中数组下标,用户根据下标在进程pcb中的文件描述信息表中找到相应文件描述信息,实现对文件的操作;
- B选项正确:不同信息表数组下标的位置可以存放相同的文件描述信息结构指针,dup2重定向的本质原理,就是改变对应位置的文件信息而改变操作对象文件的。
- C选项错误:文件描述符表中遵循最小未使用分配规则,也就是从表中找寻最小的没有被使用的位置进行存储,因此并不保证多次打开会使用同一个文件描述符
- D选项正确:我们见到的FILE *文件流指针,其实就是
_IO_FILE
的类型重定义,其中封装包含了文件描述符,因此一个文件流指针一定对应有一个文件描述符。
4、文件描述符的理解
在进程中每打开一个文件,都会创建有相应的文件描述信息struct file,这个描述信息被添加在pcb的struct files_struct中,以数组的形式进行管理,随即向用户返回数组的下标作为文件描述符,用于操作文件
5、简述重定向的实现原理
每个文件描述符都是一个内核中文件描述信息数组的下标,对应有一个文件的描述信息用于操作文件,而重定向就是在不改变所操作的文件描述符的情况下,通过改变描述符对应的文件描述信息进而实现改变所操作的文件
四、缓冲区 🌟
4.1 fork 导致库函数重定向打印两次
下面这段代码,代码当中分别用了两个C库函数和一个系统接口write向显示器输出内容,在代码最后还调用了fork函数。
#include<fcntl.h>
#include<string.h>
int fun1() //重定向问题
{
const char* msg1 = "hello 标准输出!\n";
write(1,msg1,strlen(msg1));
printf("printf\n");
fprintf(stdout, "fprintf\n");
fputs("fputs\n", stdout);
fork();
return 0;
}
int main()
{
fun1();
return 0;
}
运行该程序,我们可以看到printf、fprintf、fputs和write函数都成功将对应内容输出到了显示器上。但是,当我们将程序的结果重定向到log.txt文件当中后,我们发现文件当中的内容与我们直接打印输出到显示器的内容是不一样的,由库函数打印的内容打印了两次。
为什么C库函数打印的内容重定向到文件后就变成了两份,而系统接口write打印的内容还是原来的一份?
printf、fprintf、fputs库函数是用户调用的接口,它们会先把数据写到C缓冲区。进程退出的时候,才会刷新FILE内部的数据并写到OS缓冲区里,然后再写到磁盘中。
stdout是一个FILE*指针,一定有与之相对应的FILE结构体,FILE结构体里面包括 文件描述符fd 和 缓冲区的信息。数据写入先拷贝到C缓冲区里然后通过fd刷新到对应的文件当中,根据文件类别采用不同的刷新策略:
用户-》OS 刷新策略
- 立即刷新(不缓冲)
- 行刷新(行缓冲\n),比如显示器打印
- 全缓冲:缓冲区满了才刷新。比如往磁盘文件中写入
OS-》硬件,同样适用
解析
当我们直接执行可执行程序,将数据打印到显示器时所采用的就是行缓冲,因为代码当中每句话后面都有\n,所以当我们执行完对应代码后就立即将数据刷新到了显示器上。
而当我们将运行结果重定向到log.txt文件时,数据的刷新策略就变为了全缓冲,此时我们使用printf、fprintf、fputs函数打印的数据都打印到了C语言自带的缓冲区当中。
之后当我们使用fork函数创建子进程时,由于进程间具有独立性,当父进程或是子进程要刷新缓冲区内容时,本质就是对父子进程共享的数据进行了修改,此时就需要对数据进行写时拷贝,至此缓冲区当中的数据就变成了两份,一份父进程的,一份子进程的,所以重定向到log.txt文件当中printf、fprintf、fputs函数打印的数据就有两份。但由于write函数是系统接口,可以将write函数看作是没有缓冲区的,write会将数据直接写进内核缓冲区里,因此write函数打印的数据就只打印了一份。
fork之前调用fflush强制刷新就不会出现重复打印
stdout就是一个
FILE*
的指针,在FILE结构体当中还有一大部分成员是用于记录缓冲区相关的信息。//缓冲区相关 /* The following pointers correspond to the C++ streambuf protocol. */ /* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */ char* _IO_read_ptr; /* Current read pointer */ char* _IO_read_end; /* End of get area. */ char* _IO_read_base; /* Start of putback+get area. */ char* _IO_write_base; /* Start of put area. */ char* _IO_write_ptr; /* Current put pointer. */ char* _IO_write_end; /* End of put area. */ char* _IO_buf_base; /* Start of reserve area. */ char* _IO_buf_end; /* End of reserve area. */ /* The following fields are used to support backing up and undo. */ char *_IO_save_base; /* Pointer to start of non-current get area. */ char *_IO_backup_base; /* Pointer to first valid character of backup area */ char *_IO_save_end; /* Pointer to end of non-current get area. */
也就是说,这里的缓冲区是由C语言提供,在FILE结构体当中进行维护的,FILE结构体当中不仅保存了对应文件的文件描述符还保存了用户缓冲区的相关信息。
4.2 close 导致数据写不进文件的问题
下面这段代码,我们是在向文件里面写入数据,所以刷新策略会改变,这个时候如果我们在进程退出前就close文件,会发生什么呢?
int fun2()//缓冲区问题
{
close(1);//关闭显示器文件,指向文件,刷新策略变成全缓冲
int fd = open("./log.txt", O_CREAT|O_WRONLY,0666);
printf("fd: %d\n", fd);
printf("hello world!\n");
fprintf(stdout,"hello world!\n");
fprintf(stdout,"hello world!\n");
fprintf(stdout,"hello world!\n");
fprintf(stdout,"hello world!\n");
close(fd);
return 0;
}
int main()
{
fun2();
return 0;
}
运行结果:
我们可以看到,文件里面没有任何数据,这是为什么?
本来写入显示器是行缓冲,现在写入文件就变成了全缓冲,这个时候C缓冲区可能还没有写满,所以不会写到文件里。
如果我们没有close,所有数据都会到C缓冲区,程序结束后就刷新到内核缓冲区里。
现在我们close,数据还来不及刷新到操作系统内部,文件描述符就被关闭了,程序退出后,数据还在用户C缓冲区里,所以文件里面没有对应的数据。
解决办法,在close前调用fflush强制将数据刷新到文件里。
补充
write是系统调用,会直接将数据写到内核缓冲区,关闭文件描述符不影响数据写入。但是printf和fprintf是用户调用,数据先写到C缓冲区,我们关闭文件描述符后,里面的内容就写不到内核缓冲区了,这是就需要我们在close前调用fflush强制将数据刷新到文件里。
五、文件系统
文件可以分为磁盘文件和内存文件,内存文件前面我们已经谈过了,下面我们来谈谈磁盘文件。
5.1 inode 🌟
在Linux操作系统中,文件的元信息和内容是分离存储的,其中保存元信息的结构称之为inode,因为系统当中可能存在大量的文件,所以我们需要给每个文件的属性集起一个唯一的编号,即inode号。
也就是说,inode是一个文件的属性集合,Linux中几乎每个文件都有一个inode,为了区分系统当中大量的inode,我们为每个inode设置了inode编号。
我们创建一个文件的时候,系统会给这个文件分配一个没有使用的inode编号,inode是一个结构体,里面包含有文件的属性和inode编号。系统还会给这个文件分配相应的数据块,inode中存储着数据块的相应信息(比如地址),通过这些信息能找到对应的数据块进行读写数据。
在命令行当中输入ls -i
,即可显示当前目录下各文件的inode编号,第一列就是inode编号。
注意: 无论是文件内容还是文件属性,它们都是存储在磁盘当中的。
Linux下,文件名在系统层面没有意义!文件名是给用户使用的!Linux中标识一个文件是通过inode编号,一个文件对应一个inode,但是一个inode可以有多个文件名(硬链接),所以本质上标识文件的不是文件名而是inode。
inode是一个结构体,里面包含文件的所有属性和inode_num编号,这个inode里面还有一个int blocks[32]来关联包含相关的文件内容的block,找文件的时候必须是通过inode编号来找。
如何判断一个inode有没有被使用?
inode_bitmap位图,从右向左,每一个bit位的位置代表inode编号,这个位置为1代表这个inode编号被占用,为0代表未被占用。 一个inode可以对应多个数据块,block_bitmap描述数据块的使用情况。
目录是文件也有自己的inode,目录也有数据(数据块block),数据块里面放什么?
我们创建的所有文件,全部都放在一个特定的目录下。所以目录数据块里面放的是文件名和inode的映射关系。
创建文件的时候先找一个没被占用的inode,然后找一个没被占用的block并在写入数据的时候写进block。
这里我们以查看 目录 day15 下面的文件 hello(inode编号为1234)为例:
cat hello.c -》 先查看day15目录文件 的data block数据块 -》得到 1234:hello.c 的映射关系 -》 拿到1234 inode编号 -》到inode table中查找该inode结构体 -》找到属于他的 blocks[]数据块组 -》访问数据块里的内容,即文件内容!
删文件时直接被inode的bitmap置0,并且把对应数据块的位图也置0即可。
概念题
1、下面关于Linux文件系统的inode描述错误的是:
A.inode和文件名是一一对应的
B.inode描述了文件大小和指向数据块的指针
C.通过inode可获得文件占用的块数
D.通过inode可实现文件的逻辑结构和物理结构的转换
解析
A选项错误,这里说的文件名指的是文件的目录项,一个文件inode是有可能有多个目录项的,比如给一个文件创建多个硬链接,因此并非一一对应
B选项正确,inode中包含了文件的各种描述信息,权限,大小,时间属性,数据块指针…等等都包含在内
C选项正确,inode中包含了文件数据所占据的存储位置的信息,因此可以获得你占用的数据块数
D选项正确,inode就像是文件的一个整体的描述,有了这个描述,上层就可以重新组织虚拟逻辑结构,通过inode映射其物理结构(简单理解可以联想类似于虚拟地址空间与物理内存之间的页表)
5.2 EXT2文件系统的存储方案
这里引用文章: Linux基础IO_2021dragon的博客-CSDN博客
计算机为了更好的管理磁盘,会对磁盘进行分区。而对于每一个分区来说,分区的头部会包括一个启动块(Boot Block),对于该分区的其余区域,EXT2文件系统会根据分区的大小将其划分为一个个的块组(Block Group)。
注意: 启动块的大小是确定的,而块组的大小是由格式化的时候确定的,并且不可以更改。
其次,每个组块都有着相同的组成结构,每个组块都由超级块(Super Block)、块组描述符表(Group Descriptor Table)、块位图(Block Bitmap)、inode位图(inode Bitmap)、inode表(inode Table)以及数据表(Data Block)组成。
- Super Block: 存放文件系统本身的结构信息。记录的信息主要有:Data Block和inode的总量、未使用的Data Block和inode的数量、一个Data Block和inode的大小、最近一次挂载的时间、最近一次写入数据的时间、最近一次检验磁盘的时间等其他文件系统的相关信息。Super Block的信息被破坏,可以说整个文件系统结构就被破坏了。
- Group Descriptor Table: 块组描述符表,描述该分区当中块组的属性信息。
- Block Bitmap: 块位图当中记录着Data Block中哪个数据块已经被占用,哪个数据块没有被占用。
- inode Bitmap: inode位图当中记录着每个inode是否空闲可用。
- inode Table: 存放文件属性,即每个文件的inode。
- Data Blocks: 存放文件内容。
注意:
- 其他块组当中可能会存在冗余的Super Block,当某一Super Block被破坏后可以通过其他Super Block进行恢复。
- 磁盘分区并格式化后,每个分区的inode个数就确定了。
如何理解创建一个空文件?
- 通过遍历inode位图的方式,找到一个空闲的inode。
- 在inode表当中找到对应的inode,并将文件的属性信息填充进inode结构中。
- 将该文件的文件名和inode指针添加到目录文件的数据块中。
如何理解对文件写入信息?
- 通过文件的inode编号找到对应的inode结构。
- 通过inode结构找到存储该文件内容的数据块,并将数据写入数据块。
- 若不存在数据块或申请的数据块已被写满,则通过遍历块位图的方式找到一个空闲的块号,并在数据区当中找到对应的空闲块,再将数据写入数据块,最后还需要建立数据块和inode结构的对应关系。
说明一下:
一个文件使用的数据块和inode结构的对应关系,是通过一个数组进行维护的,该数组一般可以存储15个元素,其中前12个元素分别对应该文件使用的12个数据块,剩余的三个元素分别是一级索引、二级索引和三级索引,当该文件使用数据块的个数超过12个时,可以用这三个索引进行数据块扩充。
如何理解删除一个文件?
- 将该文件对应的inode在inode位图当中置为无效。
- 将该文件申请过的数据块在块位图当中置为无效。
因为此操作并不会真正将文件对应的信息删除,而只是将其inode号和数据块号置为了无效,所以当我们删除文件后短时间内是可以恢复的。
为什么说是短时间内呢,因为该文件对应的inode号和数据块号已经被置为了无效,因此后续创建其他文件或是对其他文件进行写入操作申请inode号和数据块号时,可能会将该置为无效了的inode号和数据块号分配出去,此时删除文件的数据就会被覆盖,也就无法恢复文件了。
为什么拷贝文件的时候很慢,而删除文件的时候很快?
因为拷贝文件需要先创建文件,然后再对该文件进行写入操作,该过程需要先申请inode号并填入文件的属性信息,之后还需要再申请数据块号,最后才能进行文件内容的数据拷贝,而删除文件只需将对应文件的inode号和数据块号置为无效即可,无需真正的删除文件,因此拷贝文件是很慢的,而删除文件是很快的。
这就像建楼一样,我们需要很长时间才能建好一栋楼,而我们若是想拆除一栋楼,只需在这栋楼上写上一个“拆”字即可。
如何理解目录
- 都说在Linux下一切皆文件,目录当然也可以被看作为文件。
- 目录有自己的属性信息,目录的inode结构当中存储的就是目录的属性信息,比如目录的大小、目录的拥有者等。
- 目录也有自己的内容,目录的数据块当中存储的就是该目录下的文件名以及对应文件的inode指针。
注意: 每个文件的文件名并没有存储在自己的inode结构当中,而是存储在该文件所处目录文件的文件内容当中。因为计算机并不关注文件的文件名,计算机只关注文件的inode号,而文件名和文件的inode指针存储在其目录文件的文件内容当中后,目录通过文件名和文件的inode指针即可将文件名和文件内容及其属性连接起来。
5.3 软硬链接 🌟
① 软链接
格式:ln -s 文件1 文件2
, 文件2软链接文件1
unlink 文件2
,取消链接
软链接生成的文件2类似于Windows的快捷方式
通过ls -i -l
命令我们可以看到,软链接文件的inode号与源文件的inode号是不同的,并且软链接文件的大小比源文件的大小要小得多。
软链接又叫做符号链接,软链接文件相对于源文件来说是一个独立的文件,该文件有自己的inode号,但是该文件只包含了源文件的路径名,所以软链接文件的大小要比源文件小得多。
但是软链接文件只是其源文件的一个标记,当删除了源文件后,链接文件不能独立存在,虽然仍保留文件名,但却不能执行或是查看软链接的内容了。
② 硬链接
格式:ln 文件1 文件2
, 文件2硬链接文件1
通过ls -i -l
命令我们可以看到,硬链接文件的inode号与源文件的inode号是相同的,并且硬链接文件的大小与源文件的大小也是相同的,特别注意的是,当创建了一个硬链接文件后,该硬链接文件和源文件的硬链接数都变成了2。
硬链接文件就是源文件的一个别名,一个文件有几个文件名,该文件的硬链接数就是几,这里inode号为789848的文件有log.txt和log-ln两个文件名,因此该文件的硬链接数为2。
与软连接不同的是,当硬链接的源文件被删除后,硬链接文件仍能正常执行,只是文件的链接数减少了一个,因为此时该文件的文件名少了一个。
总之,硬链接就是让多个不在或者同在一个目录下的文件名,同时能够修改同一个文件,其中一个修改后,所有与其有硬链接的文件都一起修改了。
为什么创建一个目录,目录的硬链接个数是2?
目录里面的那个隐藏文件 .
也指向本目录,他们的inode相同。也就是说一个目录除了有我们给的文件名,还有一个 .
和文件名共用inode
③ 软硬链接的区别
软链接的文件有自己独立的inode,所以软链接是一个独立的文件!这个文件有自己的inode属性和数据块(保存的是链接指向的文件的所在路径+文件名)
硬链接本质上不是一个独立的文件,而是一个文件名和inode编号的映射关系,因为他自己没有独立的inode,类似于C++中的起别名。创建硬链接,本质是在特定的目录下填写一对文件名和inode映射关系!
软硬链接的本质区别是是否有独立的inode
④ 相关题目
1、Linux中包括两种链接:硬链接(Hard Link)和软连接(Soft Link),下列说法正确的是()
A.软连接可以跨文件系统进行连接,硬链接不可以
B.当删除原文件的时候软连接文件仍然存在,且指向的内容不变
C.硬链接被删除,磁盘上的数据文件会同时被删除
D.硬链接会重新建立一个inode,软链接不会
解析
软链接文件是一个独立的文件有自己的inode节点,文件中保存了源文件路径,通过数据中保存的源文件路径访问源文件
硬链接是文件的一个目录项,与源文件共用同一个inode节点,直接通过自己的inode节点访问源文件(其实本质上来说与源文件没区别)
- A正确 不同分区有可能有不同文件系统,就算系统相同,也会导致节点号有歧义冲突,因此硬链接不能跨分区建立,正确
- B错误 删除源文件,软链接文件失效
- C错误 硬链接被删除,则inode中的链接数-1,并不会直接删除文件数据,而是等链接数为0的时候才会实际删除对应文件的inode,将所占用数据块置为空闲
- D错误 硬链接与源文件共用inode
2、使用In命令将生成了一个指向文件old的符号链接new,如果你将文件old删除,是否还能够访问文件中的数据?
A.不可能再访问
B.仍然可以访问
C.能否访问取决于文件的所有者
D.能否访问取决于文件的权限
解析
ln生成符号链接文件,指的是通过 ln -s 命令生成软链接文件,
软链接文件是一个独立的文件,有自己的inode节点,这个文件数据中保存的是源文件路径,通过保存的路径访问源文件,因此源文件被删除则无法再访问,通过路径将找不到源文件,这时候软链接就会失效。
根据以上对于软链接的理解,A选项正确,其他选项都错误
5.4 总结
我们创建一个文件的时候,系统会给这个文件分配一个没有使用的inode编号,inode是一个结构体,里面包含有文件的属性和编号。系统还会给这个文件分配相应的数据块,inode中存储着数据块的相应信息(比如地址),通过这些信息能找到对应的数据块进行读写数据。
对于普通文件来说,数据块中存储的是数据内容,对于目录文件:数据块中存储的是目录文件下面 文件inode编号和文件名的映射关系。
调用文件本质是通过inode编号打开对应的数据块,按照我个人的理解,可以把文件名看作是inode编号的别名,这样也方便我们理解硬链接。
硬链接本质上不是一个独立的文件,而是一个文件名和inode编号的映射关系,因为他自己没有独立的inode,类似于C++中的起别名。创建硬链接,本质是在特定的目录下填写一对文件名和inode映射关系!所以当我们删除掉硬链接的原文件的时候,我们还是能调用硬链接出来的文件,因为我们还能找到inode编号,这里比较像引用计数,只有硬连接数变成0的时候,该文件才会真正被“删除”(本质不是删除,是把该inode变成未被使用的状态,inode位图相应位置变成0)。
软链接类似于Windows系统下面的快捷方式,如果我们把原文件删除掉,那么我们软链接出来的文件就不能执行了,因为软链接出来的文件具有独立的inode,这个inode对应的数据块里面 保存的是源文件的路径名,通过路径名能找到原文件的inode编号。但是删除了原文件,就找不到文件了。
软硬链接的本质区别是是否有独立的inode
六、文件的三个时间
在Linux当中,我们可以使用命令stat 文件名
来查看对应文件的信息。
这其中包含了文件的三个时间信息:
- Access: 文件最后被访问的时间。
- Modify: 最近一次修改文件内容的时间
- Change: 最近一次修改文件属性的时间,比如修改权限
当我们修改文件内容时,文件的大小一般也会随之改变,所以一般情况下Modify的改变会带动Change一起改变,但修改文件属性一般不会影响到文件内容,所以一般情况下Change的改变不会带动Modify的改变。
我们若是想将文件的这三个时间都更新到最新状态,可以使用命令touch 文件名
来进行时间更新。
在较新的Linux内核中,Access时间不会被立即更新,而是有一定的时间间隔,OS才会自动进行更新时间。防止和磁盘交互过于频繁导致系统运行效率降低。
注意: 当某一文件存在时使用touch命令,此时touch命令的作用变为更新文件信息。
Makefile怎么判断源文件是否修改?
通过文件的Modify时间,可执行程序的Modify时间一定比原文件更新,这个时候如果我们修改原文件,原文件的Modify时间就变成最新的了,这个时候make就能通过。反之,不行。总结:只有原文件的时间最新时才能通过make,重新编译。伪目标总是被执行,因为它不关心时间的先后问题。