> 🍃 本系列为Linux的内容,如果感兴趣,欢迎订阅🚩
> 🎊个人主页:【小编的个人主页】
>小编将在这里分享学习Linux的心路历程✨和知识分享🔍
>如果本篇文章有不足,还请多多包涵!🙏
> 🎀 🎉欢迎大家点赞👍收藏⭐文章
> ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 🖕 👇 ☝️ 👍
目录
🐼前言
我们在C语言阶段学习过对文件的操作,比如打开文件,向文件中写入,追加等等...在学习权限时,我们也天天谈文件,今天让我们来揭开文件大厦的神秘面纱吧!
🐼文件背景
我们在学习之前,形成几点共识:
文件 = 文件内容+文件属性。所以在之前我们说一个0KB的文件也是要占磁盘空间的!并且对文件操作一定是对文件内容或者文件属性做操作!
访问一个文件,就必须要把对应的文件打开。想想我们在想访问一个文件时,会使用fopen打开一个文件时,为什么要打开文件呢?根冯诺依曼体系,在外设的文件,想被CPU访问,就要把文件加载到内存中。
如果一个文件,没有被打开,那么它就在磁盘上。所以我们的文件分为被打开的文件和在磁盘上没有被打开的文件两类。本次我们主要探寻被打开的文件。
那谁打开呢?是不是我们的bash创建子进程,在执行到fopen时,打开了这个文件,这一定会牵扯到外设(磁盘),那谁有权利访问外设呢?操作系统。操作系统不会让用户直接访问外设,所以fopen底层一定封装了系统调用。因此打开文件实际上是我们的进程通过fopen通过系统调用来完成对文件的打开的!对文件的操作本质是进程对文件的操作
因此在操作系统中,一定同时存在大量的被进程打开的文件,那这么多文件当然要被管理起来吗?是的!怎么管理?先描述,再组织,所以一定会存在关于管理文件打开的数据结构,就像管理进程task_struct一样!
进而我们可以得出结论,在往后打开文件的学习中,也一定是进程和文件的关系!
🐼回顾C文件接口
打开文件
我们会使用C语言库提供的fopen打开任何一个文件,其返回值如图:
比如我们写一个程序来帮助我们打开文件:
#include<stdio.h>
int main()
{
printf("我是一个进程,pid:%d\n",getpid());
FILE *fp = fopen("log.txt","w");
if(!fp)
{
perror("fopen failed:");
return 1;
}
while(1)
{
sleep(1);
}
fclose(fp);
return 0;
}
在我们当前目录下确实新建了一个log.txt的文件没错
那现在就引出一个问题 ,为什么打开文件默认会在当前路径下新建文件呢?
其实并不是文件知道当前路径,而是进程!我们的进程在创建时就会有自已的cwd,我们可以通过ls /proc/进程id -l查看当前进程的运行信息:
所以打开文件,就必须先找到文件,要找到文件,就必须知道文件名+路径,这就是为什么我们的进程就要有cwd,本质上是进程打开文件,进程知道自已在哪里,即便文件不带路径,进程也知道。由此OS就能知道要创建的文件放在哪里。甚至我们可以使用chdir修改当前路径,那么我们新建的文件就会新建到指定目录下!这个我们在学习进程时演示了,这里就不做了。
这样我们还能理解一点,在#include<>,#include"",编译器怎么知道在哪个路径下包谁,是因为我们的编译器在启动时也是一个进程啊!也会有自已的cwd买当然就知道路径了!变相说,cwd是进程的属性,而打开文件本质是进程打开文件,所以cwd也是查找文件的默认路径。
还有许多打开文件方式比如以,"r","r+","w+",“a+”,不管以哪种方式打开,其实,我们都可以把文件(文本文件/二进制文件)当做一个"一维数组",只不过行和行之间了不起多一个'\n'罢了,而打开方式的位置其实就是数组下标!
向文件写入
向文件写入我们打开文件一般用"w","a"等方式
向文件写入,我们可以使用,fprintf(),fputs(),fwrite(),fputc(),putchar()等等
我们这里以fwirte的方式写入一个字符串
并且我以"w"的方式打开文件,在做向文件写入操作时,是覆盖性的,也就是会清空之前的文件内容。
既然这样,我们就可以通过命令行简单实现一个清空文件的小demo了
#include<stdio.h>
2 #include<string.h>
3
4 int main(int argc,char* argv[])
5 {
6 if(argc != 2)
7 {
8 printf("Usage: %s->filename\n",argv[0]);
9 }
10
11
12 //printf("我是一个进程,pid:%d\n",getpid());
13 FILE *fp = fopen(argv[1],"w");
14 if(!fp)
15 {
16 perror("fopen failed:");
17 return 1;
18 }
19
20 //const char* str = "hello world";
21 //fwrite(str,strlen(str),1,fp);
22 // while(1)
23 // {
24 // sleep(1);
25 // }
26 fclose(fp);
27 return 0;
28 }
结果:
既然可以以"w"打开文件(会清空),我们也同样可以以"a"方式打开文件,在文件末尾追加:
比如在打开方式以"a"方式 向文件写入
#include<stdio.h>
#include<string.h>
int main(int argc,char* argv[])
{
//if(argc != 2)
//{
// printf("Usage: %s->filename\n",argv[0]);
//}
//printf("我是一个进程,pid:%d\n",getpid());
FILE *fp = fopen("log.txt","a");
if(!fp)
{
perror("fopen failed:");
return 1;
}
const char* str = "hello world\n";
fwrite(str,strlen(str),1,fp);
// while(1)
// {
// sleep(1);
// }
fclose(fp);
return 0;
}
效果:
还记不记得我们用echo > 进行输出重定向,比如 echo "hello world" > log.txt:如图
而重定向到log.txt,就必须要先打开这个文件,是进程帮我们打开的,所以我们可以大胆猜测在echo输出重定向时,不就是将左边的内容调用fopen(filename,"w")打开,然后写入到右边,并且是覆盖行的写入,相当于我们C语言中的"w"方式打开文件。而以echo>>方式不就相当于我们C语言中的以"a"追加方式打开写入嘛!
这样就对应起来了。
读文件
读文件我们打开方式以"r",这里再实现一个小demo,比如想实现cat命令的效果:
#include<stdio.h>
#include<string.h>
int main(int argc,char* argv[])
{
if(argc != 2)
{
printf("Usage: %s->filename\n",argv[0]);
}
printf("我是一个进程,pid:%d\n",getpid());
FILE *fp = fopen(argv[1],"r");
if(!fp)
{
perror("fopen failed:");
return 1;
}
while(1)
{
char buf[1024];
buf[0] = 0;
size_t s = fread(buf, 1, sizeof(buf)-1, fp);
if(s > 0){
printf("%s", buf);
}
else if(feof(fp)){
break;
}
}
fclose(fp);
return 0;
}
输出:
🐼输出到显示器的多种做法
首先,我们需要明确一点:无论是向显示器输出一串数字,例如 1234567
,还是通过键盘输入内容,实际上操作的都是字符串。例如,数字 1234567
在显示器上显示时,实际上是逐个字符 "1"
, "2"
, "3"
, "4"
等依次显示的。同样,当我们通过键盘输入时,输入的也是字符组成的字符串。因此,显示器和键盘都被归类为字符设备。
在这种情况下,我们使用 printf
和 scanf
进行格式化输入输出时,实际上是对字符串进行了格式化处理,比如把字符串转整数或者把整数转字符串等。这些函数将字符串转换成我们期望的格式,例如将数字格式化为十进制、十六进制或浮点数形式,或者将字符串按照特定的格式输出。所以我们现在能懂”格式”了嘛
而二进制文件和文本文件的区别在于,二进制文件存储的是原始的数据形式,不需要进行格式化。二进制文件的内容直接反映了数据的二进制表示,其存储方式是由数据本身的属性决定的,而不是像文本文件那样需要将数据转换为可读的字符形式。换句话说,二进制文件保留了数据的原始结构和格式,而文本文件则侧重于以人类可读的方式存储和展示数据。
我们还需要知道,在我们进程启动的时候,系统会默认打开三个输入输出流:stdin,stdout,stderr,其实就是打开了三个文件。他们的类型都是FILE*的,他们三个并且是全局的。如图:
其中我们把stdin叫做标准输入流,其实就是键盘文件。stdout叫做标准输出流(显示器文件),stderr叫做标准错误流(显示器文件),可是为什么要有这三个流呢?它们是什么呢?具体是怎么做的呢?
因为我们的进程在创建出来时,都是创建出来拿到数据完成某种任务的,在这期间会享有CPU的资源,让CPU做计算的,而计算结果需不需要输出呢?大部分都需要,如果有错误,需不需要知道呢?需要。而进程也要获取数据。因此,在进程被创建出来时,默认会打开三个文件。这个过程省略可以吗?可以是可以,可以手动打开这三个文件,但是太麻烦了,因为我们的进程大部分都要输入数据,把结果输出给我们。
下面我们来演示一下向显示器文件输出的多种方法:
#include<stdio.h>
#include<string.h>
int main()
{
const char* str1 = "hello printf\n";
printf(str1);
const char* str2 = "hello fprintf\n";
fprintf(stdout,str2);
const char* str3 = "hello fputs\n";
fputs(str3,stdout);
const char* str4 = "hello fwrite\n";
fwrite(str4,strlen(str4),1,stdout);
return 0;
}
输出
通过上面这个例子,我们好像是向显示器文件写入,其实就是向stdout写入,sdtout也是个文件啊,他的类型是FILE*
🐼系统IO
🐸open的使用
我们上面提到,我们打开文件,其实是我们的进程打开文件,而进程通过c语言接口fopen打开文件,但是对于磁盘这样的外设,进程有权利访问吗?没有,只有操作系统有权利访问硬件。所以fopen底层一定封装了打开文件的系统调用,它是谁啊?2号手册系统调用open
其中第一个参数依旧为文件路径+文件名,和fopen的第一个参数含义一样。
第二个参数为叫做打开文件的方式,我们下面会谈
而如果文件不存在,我们需要设定它的第三个参数,第三个参数是给文件设定权限的。所以如果文件本身是存在的,那么就用两个参数的open就行。
所以其实系统调用才是打开文件最底层的方案,并不是我们所学的fopen,fopen封装了open。不过,在学习系统文件IO之前,先要了解下如何给函数传递标志位,我们看第二个参数的标志位在系统中有哪些,它的选项可能有这些,下图:
我们只知道第二个参数的类型是一个整数, 而一个int有32个比特位,一个比特位表示一个标志位。而上面的这些大写字母其实就是一个个宏,代表一个标志位,只不过设计成位图形式的标志位。下面我们手动实现一个使用位图来传递标志位的功能:
#include<stdio.h>
#define VERSON1 (1<<0) //1
#define VERSON2 (1<<1) //2
#define VERSON3 (1<<2) //4
#define VERSON4 (1<<3) //8
#define VERSON5 (1<<4) //16
void ShowVerson(int flag)
{
if(flag & VERSON1)
{
printf("VERSON1\n");
}
if(flag & VERSON2)
{
printf("VERSON2\n");
}
if(flag & VERSON3)
{
printf("VERSON3\n");
}
if(flag & VERSON4)
{
printf("VERSON5\n");
}
if(flag & VERSON5)
{
printf("VERSON5\n");
}
}
int main()
{
ShowVerson(VERSON1);
printf("-------------\n");
ShowVerson(VERSON1 | VERSON2);
printf("-------------\n");
ShowVerson(VERSON1 | VERSON2 | VERSON3);
printf("-------------\n");
ShowVerson(VERSON1 | VERSON2 | VERSON3 | VERSON5);
return 0;
}
输出结果:
基于上面这个小代码,我们open第二个参数的标志位也是一样的功能,如果想使用其功能,直接进行位操作即可!
下面我们先简单写一个使用系统调用open打开一个文件的小程序:
#include<stdio.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
int fd = open("log.txt",O_WRONLY);
if(fd<0)
{
perror("open failed:\n");
return 1;
}
close(fd);
return 0;
}
现象:
我们使用系统调用close()关闭open打开的文件,但是发现我们当前目录下并没有创建一个一个log.txt的文件,不是打开了吗?为什么没有新建呢?是因为这是系统调用不是C语言,在C语言中如果我们的文件不存在会新建一个文件,但是系统调用标志位是只读的,不会帮我们新建文件,所以我们需要给标志位再设一个O_CREAT的功能
可是我们新建的文件为什么没有权限呢,权限不应该是会自动设置吗,因为这是系统调用啊,我们需要自已单独设置权限!
那我们就设置呗,假设我们将文件权限设置为666:
现象:
我们已经显示设置权限了为666,可是文件权限为什么是664呢?是因为,在Linux中,有权限掩码umask我们需要手动将umask设置为0;使用umask接口
#include<stdio.h>
2 #include<string.h>
3 #include<sys/types.h>
4 #include<sys/stat.h>
5 #include<fcntl.h>
6
7 int main()
8 {
9 umask(0);
10 int fd = open("log.txt",O_WRONLY | O_CREAT,0666);
11 if(fd<0)
12 {
13 perror("open failed:\n");
14 return 1;
15 }
16
E> 17 close(fd);
18 return 0;
19 }
20
这样我们才实现了一个效果等同于C库函数fopen的打开文件,可是真的效果一样吗?
我们已经能打开文件了,下面我们使用系统调用write进行写入:
比如我像文件中写入hello world
输出: 没问题,那么如果我再像文件中写入"12345"呢?发现并没有像C语言"w"打开文件时会清空掉原本文件内容,而是在文件开头覆盖写入:
那如果我们想打开文件时做清空,需要再加一个标志位O_TRUNC
这样我们就做到了和fopen一样的效果,模拟fopen("log.txt","w")我们上面也说了,fopen是对系统调用的封装,所以,fopen一定封装了open,而"w"选项就是上图所对应的标志位。
既然能模拟以"w"方式打开文件,那我们再模拟一下以"a"方式打开文件.
我们只需要改动上面代码的一个标志位,把O_TRUNC改成O_APPEND即可完成追加功能
这样我们就能通过系统调用open来完成对库函数fopen("log.txt","a")一样的效果!
🐸open的返回值
在谈论open的返回值之前,先谈谈库和系统调用,我们之前就知道,系统调用和库是上下层关系。如图:
那现在有一个问题,为什么C语言要封装文件操作接口。不封装可以吗?
首先,次要原因是直接使用系统调用太麻烦了,代价太高了,在我们上述的使用过程中我们就能感受到。但是最主要的原因是,文件在磁盘上,只有操作系统才能访问外设,而为了保护操作系统,如果想访问硬件就必须要有系统调用所以才给有系统调用,那为什么C语言还要进行一层封装呢?先问一个问题,就是Linux和windows对文件的系统调用会是一样的吗???
答案是不一样!!!
在不同的操作系统中,系统调用确实存在显著差异。例如,Linux和Windows的系统调用在文件接口方面各有不同,这是由于每个平台的开发者以及底层操作系统的设计者不同所导致的。因此,系统调用的差异似乎是不可避免的。
然而,如果在系统调用层之上构建一层抽象库,例如在每个平台上设计一套统一的接口来调用底层的系统调用,那么就可以实现跨平台的兼容性。以C语言为例,通过设计一个通用的文件操作接口(如fopen
),开发者可以在不同平台上使用相同的代码来访问文件,而无需关心底层系统调用的具体实现。这种思想同样适用于其他编程语言,如Java和Python。通过这种方式,开发者可以使用同一套语言和接口在多个平台上进行开发,从而极大地提高开发效率和代码的可移植性。而可移植性其本质就是每一套语言不希望流失平台的用户,不希望自已的语言无可移植性,增加其语言的竞争力,所以在其系统上开发一层库,方便其用户使用!如图:
而我们学习系统调用的原因就在于,它是所有语言操作外设的根,只要理解了一门语言系统调用比如对文件的操作,其他语言对文件的操作就手到擒来了!
那如何做到跨平台性的?
既然每个平台的系统调用不同,但要对上层提供统一的使用接口。那么就必须对每个平台都实现一份其系统调用和库的兼容接口,也就是每个平台的库都是不同的,虽然我们使用同一套操作在Linux和windows中,但是其标准库的实现是不同的!比如C标准库。都有其系统对应的实现方案,这样我们也就能理解了为什么一门语言想实现一个新功能的时间之久?因为它要对每个平台都要实现一份代码!进行对应操作系统进行对系统调用的封装。
我们既然了解了库和系统调用的关系,那我们现在谈谈open的返回值,先看open返回值的描述:
当文件打开失败,返回-1;当文件打开成功,返回一个叫做文件描述符的东西,它是什么啊?我们现在只知道它是一个整数,一次打开多个文件看看返回值:
#include<stdio.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
umask(0);
int fd1 = open("log.txt1",O_WRONLY | O_CREAT | O_APPEND,0666);
int fd2 = open("log.txt2",O_WRONLY | O_CREAT | O_APPEND,0666);
int fd3 = open("log.txt3",O_WRONLY | O_CREAT | O_APPEND,0666);
int fd4 = open("log.txt4",O_WRONLY | O_CREAT | O_APPEND,0666);
printf("fd1: %d\n",fd1);
printf("fd2: %d\n",fd2);
printf("fd3: %d\n",fd3);
printf("fd4: %d\n",fd4);
close(fd1);
close(fd2);
close(fd3);
close(fd4);
return 0;
输出结果:
我们打开四个文件,open的返回值输出了3,4,5,6,表明打开成功了。
我们共识中说,想要访问一个文件,必须先打开该文件,并且一个进程可以打开多个文件。而这么多被打开的文件,要被操作系统管理起来!所以在操作系统内部一定存在大量的数据结构来管理这些被打开的文件,在Linux中,被打开的文件是被struct file管理起来的,而struct file中一定直接或间接包含了文件的内容和属性, 而所有被打开的文件在os中以双链表的形式管理起来,进而对文件的管理就转换成了对链表的增删查改!这都没错,但是,既然文件是进程打开的,既然进程和文件的比例是1:n的关系
那么那么多文件,是被哪个进程打开的呢?需不需要考虑哪个进程和哪个被打开文件struct file的关系呢?当然需要!
所以在进程(task_struct)存在一个struct files_struct* files的指针指向一个struct files_struct(文件描述符表)的结构体,其中文件描述符表中有一个struct files_fd* arrary[]的指针数组,存放每打开一个文件的地址,也就是在这个指针数组的元素就指向被打开的文件的地址!其中,数组下标fd我们就叫做该文件的文件描述符,这样进程和被打开文件的关系就对应起来了。
所以我们之前用close(fd),write(fd,...)它怎么知道是关闭哪个文件,向哪个文件写入,是因为在进程中通过数组下标fd就能在struct file*_fd arrary中找到其对应打开文件的地址!所以文件描述符的本质是数组下标!既然进程打开文件的发起者,通过系统调用的方式间接通过fd获取到struct file的,那么os管理进程,也要管理文件,所以这么多被打开的文件,操作系统管理文件,只认fd!
上述话如图所示:
我们可以看一下struct file结构体的字段:
那现在有一个问题,就是既然fd是文件描述符表的数组下标,但是为什么不是从0开始,而是从3开始呢?那0,1,2,去哪里了?
我们上面说了,在我们进程启动时,默认会打开3个标准输入输出流,stdin,stdout,stderr
而既然他们也是文件,那么一定也有自已的struct file,以及相关联的fd,操作系统当然也要管理他们了,并且基于上面为什么要默认打开这三个文件的目的。没错,在进程启动时,os会自动打开三个文件,stdin,stdout,stderr,他们默认占据文件描述符表的0,1,2下标。当我们再打开文件时,就是从3开始了。而他们三个的返回值都是FILE*类型,FILE是一个结构体,那既然我们没有见到在表层见到fd,但是他们三个也确实是文件啊,所以我们就可以推测出,,FILE类型的结构体中一定有一个字段,是文件描述符:fd!
感谢你耐心地阅读到这里,你的支持是我不断前行的最大动力。如果你觉得这篇文章对你有所启发,哪怕只是一点点,那就请不吝点赞👍,收藏⭐️,关注🚩吧!你的每一个点赞都是对我最大的鼓励,每一次收藏都是对我努力的认可,每一次关注都是对我持续创作的鞭策。希望我的文字能为你带来更多的价值,也希望我们能在这个充满知识与灵感的旅程中,共同成长,一起进步。如果本篇文章有错误,还请大佬多多指正,再次感谢你的陪伴,期待与你在未来的文章中再次相遇!⛅️🌈 ☀️