详细介绍:Linux文件管理

前言
之前我们一直在使用文件,包括记事本、各种程序文件等,文件管理也是操纵系统一大任务中。这篇文章我们就来认识一下文件管理。
文件初识
打开一个文件,其实是将文件加载到内存中,文件=文件的内容+文件的属性,而至于加载文件的内容还是属性,就要看进程要访问啥。因此,对文件开始操作,本质是进程通过CPU访问内存中加载的文件。Linux系统中存在大量的文件,从文件位置上进行分类,分为内存级被打开的文件和磁盘文件,这里我们主要介绍内存级被打开的文件。
在Linux系统中,可以同时存在很多被打开的文件,而不同文件要处理的工作不同,因此操作系统也要对文件进行管理,管理的规则同样是"先描述,再组织"。
Linux系统中的文件操作
打开文件
在Linux系统中,也存在相关系统调用来对文件进行操作。如下所示:
其中flags为打开方式,常见的方式有:O_RDONLY、O_WRONLY和O_RDWR三种,除此之外还有O_CREAT和O_APPEND。这几个方式本质上其实都是宏,几种打开方式可以混合使用。
第二个函数的mode参数,是当要打开的文件不存在时使用的,当要打开一个不存在的文件时,会先创建对应的文件,mode可以给文件指定初始的权限。
open函数也会有一个返回值,成功时会返回文件描述符,失败会返回-1。简单使用如下:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
int fd = open("log.txt",O_WRONLY | O_CREAT);
if(fd < 0)
{
perror("open");
}
return 0;
}
运行结果如下:
我们发现log.txt文件的默认权限是乱的,我们给mode设置参数为666,再次编译运行,结果如下:
我们发现权限位664,这是因为存在umask,之前介绍过。如果我们不不想受系统umask掩码的影响,可以使用umask函数进行设置,umask声明如下:
使用umask示例如下:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
umask(0);
int fd = open("log.txt",O_WRONLY | O_CREAT,0666);
if(fd < 0)
{
perror("open");
}
return 0;
}
在没有log.txt文件时编译运行结果如下:
关闭文件
关闭文件,可以使用close()函数,函数的声明如下:
写操作
若想对文件进行写操作,可以使用write系统调用,声明如下:
第一个参数为文件描述符,第二个为要写入字符信息的起始地址,第三个参数为要写入的字符个数。返回值为成功写入的字符个数。使用示例如下:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
umask(0);
int fd = open("log.txt",O_WRONLY | O_CREAT,0666);
if(fd < 0)
{
perror("open");
}
const char* msg = "abcdefg";
write(fd,msg,strlen(msg));
close(fd);
return 0;
}
‘\0’字符是C语言中字符串的结尾标注,而write是系统调用,因此传需要写入字符的长度时,不需要考虑’\0’字符。运行结果如下:
但是我们用同样的方法向文件中写入字符串"123"时,再次打开log.txt ,文件内容如下:
为了解决此问题,在打开文件时还要以O_TRUNC的方式打开,这种打开方式在打开时都会进行清空操作。以这种方式打开,log.txt文件中只有字符串"123"。
若想要以追加写入方式打开,使用O_APPEND即可。
C语言的库函数就是通过封装相关库函数来实现相关读写文件的操作的。刚刚我们打开文件时,返回的文件描述符是从3开始,那为什么没有0,1,2呢?这是因为,0,1,2文件描述符被默认占用了,分别为标准输入、标准输出和标准错误。如下所示:
因此,向显示器输出也可以向stdout中进行写入,如下所示:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
const char* msg = "hello world";
write(1,msg,strlen(msg));
return 0;
}
运行结果如下:
stdin,stdout,stderr这三个类型都是FILE结构体类型,里面也封装了对应的文件描述符,我们可以进行查看,如下代码所示:
#include <stdio.h>
int main()
{
printf("stdin->fd:%d\n",stdin->_fileno);
printf("stdout->fd:%d\n",stdout->_fileno);
printf("stderr->fd:%d\n",stderr->_fileno);
return 0;
}

文件描述符
在Linux系统中,文件描述符为结构体struct file。这个结构体中直接或间接地包含了文件的大部分属性,每打开一个文件,内核中就会创建一个struct files_struct对象,并被操作系统组织起来。同时每个结构体会有一个指针,指向一个缓冲区,需要加载文件的内容和属性时,就会将内容和属性放到对应的缓冲区中,若要对文件的内容进行修改,要先在缓冲区进行修改,然后写回到磁盘中的对应位置。对文件做任何操作,都要先将文件加载到内存中。
而task_struct结构体中,会有一个指针*files_struct,这个指针会指向对应的结构体,结构体中会有一个数组struct file* fd_array[],每打开一个文件,会将对应文件的地址存入数组中,而对应的下标就是文件描述符,因此,文件描述符的本质就是数组下标。
整个过程大致如下所示:
在Linux系统中,访问打开的文件,操作系统只认文件描述符,因此,无论什么语言要访问文件,对应结构体中都要有文件描述符这个属性,并且最终的操作都会转化成系统调用。
当分配文件描述符时,会先 分配值最小的并且没有被使用的fd。如下所示,下面代码先关闭了fd为0的文件描述符,再依次打开三个文件。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
close(0);
int fd1 = open("log1.txt",O_WRONLY | O_CREAT | O_TRUNC,0666);
printf("fd1:%d\n",fd1);
int fd2 = open("log2.txt",O_WRONLY | O_CREAT | O_TRUNC,0666);
printf("fd2:%d\n",fd2);
int fd3 = open("log3.txt",O_WRONLY | O_CREAT | O_TRUNC,0666);
printf("fd3:%d\n",fd3);
return 0;
}
运行结果如下:
重定向
理解
若我们在上述代码中,先将文件描述符为1关闭掉,然后运行程序,我们发现,显示器没有输出,同时log1.txt文件中有内容,如下所示:
这是因为文件描述符为1对应的是stdout,stdout关闭则无法向显示器输出,而文件描述符的变化是操作系统层面的,语言层无法感知,语言层中的printf只认文件描述符1这个数字,因此关闭了stdout,但依旧使用printf函数输出时,会向1对应的文件中进行写入,只不过1此时已经不是stdout,而是log1.txt文件,这就是输出重定向的本质。输出重定向的本质其实就是更改数组特定下标内的内容。而如果打开文件时将清空方式变为追加方式,那么就可以模拟追加重定向。
若我们关闭stdin,并且将原来stdin的文件描述符分配给一个新文件,那么就可以从新文件中获取输入。如下所示:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
close(0);
int fd1 = open("log1.txt",O_RDONLY);
printf("fd1:%d\n",fd1);
char buffer[64];
fgets(buffer,sizeof(64),stdin);
printf("%s\n",buffer);
return 0;
}
运行结果如下:
这就是输入重定向。
dup2
而若要对已经打开的文件进行重定向,可使用dup2函数,声明如下:
这个函数拷贝的是将oldfd数组下标的内容拷贝到newfd中,最后只剩下oldfd,若想要进行输入重定向,要将fd的内容拷贝到1中,oldfd为fd,newfd为1,使用示例如下:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
//close(0);
int fd1 = open("log1.txt",O_RDONLY);
printf("fd1:%d\n",fd1);
dup2(fd1,0);
char buffer[64];
fgets(buffer,sizeof(buffer),stdin);
printf("%s\n",buffer);
return 0;
}
而Linux系统中重定向相关操作都是基于这个函数进行实现的。
理解"一切皆文件"
对于每个外设设备,比如键盘、显示器等,每个外设设备都会实现自己的驱动方法,用来实现读写操作,每一种外设的访问方法肯定都不一样,没启动一个硬件设备,操作系统也会将外设启动看做一个文件打开的过程,也会产生对应的文件描述符,只不过,在文件描述符结构体中,会有函数指针,包括读方法的函数指针和写方法的函数指针,函数指针指向对应的外设设备底层的读方法和写方法。因此,虽然每个外设的读写方法实现不一样,但是在应用层访问文件时都用的文件描述符,会通过文件描述符中的函数指针访问对应设备的读写方法,这样就使得访问相关接口达成一致。而Linux系统中,文件描述符中的函数指针表就是实现"一切皆文件"的底层原理。如下所示:
而这里也是多态的一种体现。
缓冲区
缓冲区理解
读写文件时,如果不通过文件操作的缓冲区,直接通过系统调用对磁盘进行操作,那么每对文件进行一次读写操作时,都需要使用读写相关的系统调用来处理,都会涉及到CPU状态的切换,这会损耗一定的时间,并且对程序的执行效率造成很大的影响。
为了减少使用系统调用的次数,就可以采用缓冲机制。比如我们从磁盘里读取信息,可以在磁盘文件进行操作时,可以依次从文件中读取大量的数据到缓冲区中,以后对这部分的访问就不需要使用系统调用,等缓冲区的数据提取完后再去磁盘中读取,这样就可以减少磁盘的读写次数,再加上计算机对缓冲区的操作大于对磁盘的操作,故应用缓冲区可以大大提高计算机的运行速度。
每一个进程,打开文件时都有一个对应的文件缓冲区,对文件的读写操作都会先使用文件缓冲区,随后操作系统会自动刷新到磁盘中。
刷新
在C语言中,封装了IO相关函数的同时,也封装了输入缓冲区和输出缓冲区,当用户要写入一个或多个字符串时,会先将字符串写入输出缓冲区中,当用户不写了,关闭文件时,再一次将输出缓冲区的内容写到指定文件中,以此来提高效率,C语言中的缓冲区是一种语言级的缓冲区,这个缓冲区在每一个文件的FILE对象中。
下面这段代码:
#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_WRONLY | O_CREAT | O_TRUNC,0666);
printf("fd:%d\n",fd);
close(fd);
return 0;
}
如果我们加上close(fd)中,那么对用log.txt中没有任何内容,这是因为,对应缓冲区的内容在进程结束的时候会自动刷新,而close()是一个系统调用,相应缓冲区的内容还没来得及通过write写入到操作系统中,文件描述符就被关闭了,所以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_WRONLY | O_CREAT | O_TRUNC,0666);
printf("fd:%d\n",fd);
fflush(fd);
close(fd);
return 0;
}
就会将相应的内容写入到log.txt文件中。fflush刷新的本质就是强制性将文件描述符中缓冲区的内容通过write系统调用写入到内核中。刷新的本质就是从语言缓冲区中,通过write系统调用,将数据拷贝到特定文件的内核缓冲区中。 这一过程是将数据交给了操作系统,但是不一定直接写到了对应的硬件中。
如果我们目标文件是显示器,语言缓冲区刷新策略为行刷新(也称行缓冲),这是因为,人的阅读习惯是以行为单位的;其它普通文件,一般是全缓冲,即缓冲区写满了,才会进行刷新。
我们再来看如下代码:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
printf("hello printf\n");
fprintf(stdout,"hello fprintf\n");
const char* s = "hello fputs\n";
fputs(s,stdout);
const char* ss = "hello write\n";
write(1,ss,strlen(ss));
fork();
return 0;
}
当我们直接运行和重定向时,结果如下:
出现这种问题的原因,是因为一开始在显示器上输出,是行刷新,都会显示。但是重定向后,我们发现,用C库函数的都重复出现,用系统调用的都只出现了一次。对于系统调用,fork之前,就已经将数据写入到内存缓冲区中,所以无论是直接向显示器上直接写,还是重定向,都不会重复;而C库函数,直接向显示器中写时,由于显示器是行刷新,fork之前,就已经写到了显示器中,而重定向时,就是向普通文件写,缓冲方式是全缓冲,缓冲区写满了才刷新,但这个例子中肯定没有写满,调用fork时,使用C库函数写入的数据仍然保存在stdout的输出缓冲区中,fork之后,子进程会复制父进程的数据,也会赋值stdout的输出缓冲区。而fork之后就是return 0,意味着父子进程都会退出,此时,父子进程都会调用fclose()函数来关闭stdout
,父子进程都要刷新stdout,数据要刷新两次。因此,当这个程序对应的进程结束时,父进程和子进程都会将自己stdout的输出缓冲区中的数据写入内核缓冲区中,并进一步写入log.txt文件中,也就会重复。
强制刷新
只要把数据从用户缓冲区拷贝到了内核文件缓冲区,就相当于交给了操作系统和硬件。但是,客观上来,只是写到了file对应的文件内核缓冲区,所以,这个缓冲区中的数据,会由操作系统自动刷新,并写入到磁盘中,所以操作系统也有自己的刷新策略,这个刷新策略是操作系统自己决定的。若用户想让操作系统立即刷新,那们就可以使用相关系统调用将语言缓冲区中的数据立即刷新到磁盘上,这个函数为fsync()函数,声明如下:
stderr
之前我们使用的stdin和stdout都是默认打开的,这是因为大部分程序都是对数据就行加工处理,将stdin和stdout默认打开方便我们进行debug。
而程序在运行过程中,不仅仅有正常的信息,还可能有错误的信息,stderr是专门来处理错误信息的,如下代码所示:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
printf("这是一个正常消息\n");
fprintf(stdout,"这是一个正常的日志消息");
const char* s1 = "这是一个正常消息,write\n";
write(1,s1,strlen(s1));
fprintf(stderr,"这是一个错误消息\n");
const char* s2 = "这是一个错误消息,write\n";
write(2,s2,strlen(s2));
perror("perror,hello\n");
return 0;
}
运行该程序并重定向至某一文件,结果如下:
我们发现正常输出的都重定向到了对应文件,而错误输出直接输出在了显示器上,没有重定向到对应文件中。这是因为,重定向只是改变了标准输出stdout对应的输出文件,并没有改变stderr对应的输出文件。若想要重定向stderr,如下所示:
这条语句是将err.txt文件的文件描述符打开后,将对应的内核中的文件对象拷贝到2号下标中,这是显示器打印的都是正常消息,错误消息被写入err.txt中。若想同时将错误和正确语句保存起来,如下所示:
若不想分开,如下所示:
这条语句,就是先将ok.txt中文件描述符的内容拷贝至1号下标中,再将1号下标中的内容拷贝到下2号下标中。等于1号下标和2号下标中的内容都是ok.txt的文件描述符。
浙公网安备 33010602011771号