目录
前言
C语言文件操作回顾
文件的打开与关闭
文件的增删改查
文件系统调用
比特位方式的标志位传递原理
访问文件的本质
文件描述符fd
理解文件描述符fd
三个流的理解
文件描述符的分配规则
重定向再理解
输出重定向
输入重定向
如何理解一切皆文件
理解为什么会有面向对象
前言
文件操作是 基础IO 学习的第一步,我们在 C语言 进阶中,就已经学习了文件相关操作,比如 fopen 和 fclose,语言层面只要会用就行,但对于系统学习者来说,还要清楚这些函数是如何与硬件进行交互的
文件由什么构成?一般文件放在哪里?
文件 = 内容 + 属性
未使用的文件位于 磁盘,而使用中的文件 属性 会被加载至内存中
本文讨论的是已被加载至内存文件的相关操作
系统是如何区分文件的?
文件可以同时被多次使用,OS 为了管理好文件,会像使用 task_struct 管理进程一样,通过 struct file 存储文件属性进行管理
struct file 结构体包含了文件的各种属性和链接关系
可以用以下的例子去理解:
快递(文件) 分为被人(进程)取走的快递(打开的文件)和没被取走的快递(没打开的文件),被人取走的快递研究的是人和快递的关系(进程和文件的关系) ,而没被人取走的快递,他会被暂时安防在菜鸟驿站(磁盘)
他的数量很多(文件非常多) 所以我们打算去取的时候其实我们是会收到一个取件码的(查找该文件的信息) 然后我们根据这个号码比方说3-1113 我们会找到这个区域 然后再去找号码
所以最关键的是快递如何被按照区域划分好(对文件分门别类地存储) 这样才能方便人去取(方便我们快速找到文件并对文件的增删查改)
- 语言层面的文件操作就是直接使用库函数,而事实上,文件操作是系统层面的问题,就像进程管理一样,系统也会通过
先描述,再组织
的方式对文件进行管理、操作
C语言文件操作回顾
文件的打开与关闭
fopen与fclose
FILE * fopen ( const char * filename, const char * mode );
参数mode:
- w 只写,如果文件不存在,会新建,文件写入前,会先清空内容
- a 追加,在文件末尾,对文件进行追加写入,追加前不会清空内容
- r 只读,打开已存在的文件进行读取,若文件不存在,会打开失败
- w+、a+、r+ 读写兼具,区别在于是否会新建文件,只有 r+ 不会新建
若文件打开失败,会返回空 NULL
,可以在打开后判断是否成功
注意: 若参数1直接使用文件名,则此文件需要位于当前程序目录下,如果想指定目录存放,可以使用绝对路径
int fclose ( FILE * stream );
关闭已打开文件,只需通过 FILE* 指针进行操作即可
注意: 只能对已打开的文件进行关闭,若文件不存在,会报错
#include <stdio.h>int main()
{// 打开文件的路径和文件名,默认在当前路径下新建一个文件?FILE *fp = fopen("log.txt", "w");if(fp == NULL){perror("fopen");return 1;}fclose(fp);return 0;
}
为什么我们默认会新建在当前路径,凭什么?
当前路径,其实就是进程的路径,因为进程在执行的过程中,他需要知道自己从哪来,也要知道如果自己产生一些临时性的文件时应该放在哪里,所以他需要一个默认路径cwd。表明的是他当前的工作目录。
因为进程PCB结构体内部有一个cwd属性,如果我们更改了进程的,cwd属性,就可以将文件新建到别的地方!!
先被加载到内存的是文件的属性还是文件的内容?
当你fopen的时候,其实就需要创建一个文件的内核数据结构,里面包含了文件的一些必要属性,所以是属性先被加载进去了!! 至于内容需不需要被加载,关键看你有没有通过一些接口来对该文件进程增删查改!!
文件的增删改查
#include <stdio.h>
#include <unistd.h>
#include <string.h>int main()
{// chdir("/home/whb");printf("Pid: %d\n", getpid());// 打开文件的路径和文件名,默认在当前路径下新建一个文件?FILE *fp = fopen("log.txt", "w");if(fp == NULL){perror("fopen");return 1;}const char *message = "hello Linux message";// strlen(message) + 1 ? 为什么?fwrite(message, strlen(message), 1, fp);fclose(fp);sleep(1000);return 0;
}
- w:在写入之前,会对文件进行清空处理!! 所以我们可以知道echo重定向方式写入也会先清空文件,所以底层必然也是w形式!!
- a:在文件的结尾追加写!!
fwrite(message, strlen(message)+1, 1, fp);为什么这里的strlen要+1?
字符串在 C 语言中以 \0
(空字符)结尾,它用于标识字符串的结束。虽然 strlen(message)
计算的是不包含 \0
的长度,但如果你想要完整写入整个字符串(包括 \0
),你需要加 1。如果我们+1带上/0,此时会打出一个乱码,但是这个乱码是什么并不重要,重要的是我们发现/0也是一个可以被写进去的字符!!
结论:因为字符串以/0结尾,是C语言的规定,跟文件、跟操作系统没有任何关系!!
文件系统调用
我们上面说了,未使用文件是在磁盘上的。所以我们想要访问文件其实就是访问硬件,因此几乎所有的库想要访问硬件设备,就必须封装系统调用!!
我们来看open的man手册
参数pathname是文件名,参数flags是打开的模式,而mode是权限设置 因此第一个open是用来打开一个已经存在的文件,而第二个open打开的是新建的文件(因为我们需要给新建的文件设置权限!)
- O_RDONLY:只读模式
- O_WRONLY:只写模式
- O_RDWR:读写模式
- O_CREAT:如果文件不存在则创建它
- O_EXCL:如果文件已存在,则调用 open 失败
- O_APPEND:每次写入数据时都附加到文件末尾
- O_TRUNC:如果文件存在并且是写模式,则清空文件
- O_CLOEXEC:在执行 exec() 时关闭文件描述符
位操作原理:
- 这些文件状态标志使用位来表示。例如,
O_RDONLY
是一个标志,它占据一个特定的位,类似地,O_WRONLY
和O_RDWR
也分别占据不同的位。 - 位操作:通过组合这些标志,我们能够灵活地设置文件打开时的行为。例如,如果想要以可读写模式打开文件,并且如果文件不存在就创建它,可以使用
O_RDWR | O_CREAT
来组合两个标志。
关于文件权限的传递,我们要记得因为有粘滞位umask 所以我们想要设置的权限可能并不是我们最终想要的,所以我们要向用umask把该进程的粘滞位变成0!!
比特位方式的标志位传递原理
状态的组合方式有很多种,但是为什么操作系统只用一个int类型就可以表明这些情况??
#define ONE (1<<0) // 1
#define TWO (1<<1) // 2
#define THREE (1<<2) // 4
#define FOUR (1<<3) // 8void show(int flags)
{if(flags & ONE) printf("hello function1\n");if(flags & TWO) printf("hello function2\n");if(flags & THREE) printf("hello function3\n");if(flags & FOUR) printf("hello function4\n");
}
int main()
{printf("\n");show(ONE);printf("\n");show(TWO);printf("\n");show(ONE | TWO);printf("\n");show(ONE | TWO | THREE);printf("\n");show(ONE | THREE);printf("\n");show(THREE | FOUR);printf("\n");
}
通过位图的方式一次向一个调用传递多个标记位,这是操作系统传递参数的一种方式!本质上是在外部用 | 的方式组合 在内部的方式用& 的方式检测!!
访问文件的本质
以前我们学C语言的时候,fopen的返回值是一个FILE* 那个时候我们知道这个是C库封装的一个结构体,但是为什么系统调用解决open的返回值是一个整形呢???
因为一个进程可能打开多个文件,那么我们想要快速地找到任意一个文件,如果仅仅是用链表的方式组织,确实太慢了!!
所以在PCB结构体内部,其实有一个file_struct*指针,该指针指向一个file_struct结构体,该结构体就是操作系统给该进程提供的一个文件描述符表,里面除了一些必要的字段信息,还有一个存放file*指针的指针数组,这些file*指针分别指向一个个被该进程打开的文件!
所以fd我们称之为文件描述符,他的本质就是文件描述符表的下标,我们可以通过这个下标里存储的file指针找到我们想操作的被打开的文件!!
file结构体里面有什么呢??
肯定直接或者间接(间接的意思是可能内部还有别的结构对象)包含如下属性:
- 在磁盘的什么位置
- 基本的属性:权限、大小、读写位置、谁打开的
- 文件的内核缓冲区
- 引用计数(因为一个文件可能会被多个进程打开,所以当一个进程关闭该文件的时候不代表这个文件的结构体就被释放了,而是要引用计数为0时才释放)
- file* next:链表节点结构,可以跟其他的文件链接成双链表的形式做管理!
文件描述符fd
理解文件描述符fd
一个进程在打开的时候默认会打开3个文件:标准输入文件(键盘)、标准输出文件(显示器)、标准错误文件(显示器)…… 他们的fd分别为 0 1 2
有没有觉得很眼熟??因为我们在学C语言的时候也知道C程序会默认打开3个流!有什么关系??
标准输入流、标准输出流、标准错误流其实并不是C语言的特性!!而是操作系统的特性!!
FILE* 是什么??如何理解?
FILE* 是一个C库自己封装的结构体,由于系统调用接口用的是fd文件描述符来对指定的文件进行操作,所以我们可以知道FILE结构体内部必然封装着文件描述符fd!
为什么一定要打开这三个流呢??
因为我们的电脑开机的时候,我们的操作系统就默认检测到了显示器、键盘这类的设备,所以进程打开的时候就必然需要有这些,因为我们程序员天然需要通过键盘、显示器来观察结果。
三个流的理解
看下面的代码
int main()
{printf("stdin->fd: %d\n", stdin->fileno());printf("stdout->fd: %d\n", stdout->fileno());printf("stderr->fd: %d\n", stderr->fileno());
}
输出结果
stdin->fd: 0
stdout->fd: 1
stderr->fd: 2
如果我们先把文件操作符1关闭,看看会出什么结果
int main()
{// 关闭文件描述符 1,即 stdoutclose(1);printf("stdin->fd: %d\n", stdin->_fileno());printf("stdout->fd: %d\n", stdout->_fileno());printf("stderr->fd: %d\n", stderr->_fileno());
}
我们把printf换成fprint再次打印printf的返回值
int main()
{close(1); // 关闭 stdout(文件描述符 1)int n = printf("stdin->fd: %d\n", stdin->_fileno());printf("stdout->fd: %d\n", stdout->_fileno());printf("stderr->fd: %d\n", stderr->_fileno());fprintf(stderr, "printf ret: %d\n", n);
}
printf ret:13
我们发现,如果把1号文件描述符关闭,就无法出结果了,但是读取的结果还是正确的。原因是:printf是C库函数 底层封装的时候默认向 stdout的1号描述符里写入,所以如果你把1号给关了,printf底层调用write这个函数会失败,但是printf本身并不知道,所以他是有返回值的。而 fprintf 的优点是可以指定我们想要输出的流,所以当我们想stderr的2号描述符写入的时候,恰好也是指向显示器文件,所以就会被打印出来!!
侧面可以证明 文件的file结构体里必然存在引用计数!! 因为在1号描述符关闭之后,显示器文件并没有被关闭,所以close底层的操作就是对count计数--,然后将文件描述符表的指针置空,但是显示器文件还是打开着的,因为2号描述符还指向显示器文件!!
总结:任何一门语言对文件描述符的封装不一样,是千变万化的,比如在C++中可能还会有继承和多态的体系。但是万变不离其中,在底层都是使用的操作系统提供的系统调用接口,对fd文件描述符进行封装。 所以底层理解了,其实任何语言都是学习他的应用而已!!
文件描述符的分配规则
在学习文件描述符的规则之前,我们先回想下刚才讲的 open 和 write 系统调用接口
最小未使用原则:
- 进程在分配文件描述符时,会查询其内部的文件描述符表(内核中的文件指针数组)
- 选择分配最小的、当前未被使用的文件描述符给新打开的文件或流
当我们关闭1之后,我们执行程序发现,并没有在屏幕上产生输入
我们来修改一下代码:
// 各种头文件
int main()
{close(1);int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);if(fd < 0){perror("open");return 1;}printf("fd:%d\n", fd);printf("stdout:%d\n", stdout->_fileno);fflush(stdout);close(fd);return 0;
}
我们在刷新之后发现,本来要打印在屏幕上的fd竟然出现在了log.txt里面,如果我们先把1关掉,再打开文件,那么给它分配的文件描述符就是1,但是为什么会将内容写到文件里面呢?
- printf 默认会将输出写入 标准输出流(stdout)。在文件描述符中,标准输出流的文件描述符是 1。所以,当你调用 printf 时,数据会通过文件描述符 1 输出到终端。,所以打印的内容就被重定向到了log.txt中
- 为什么是刷新之后有,不刷新就没有呢?-> 因为在没有刷新时,内容是储存在缓冲区的,刷新之后才会出现
重定向再理解
在Linux中,重定向是一种将命令的标准输入(stdin)、标准输出(stdout)或标准错误(stderr)重新指向文件或其他命令的技术。这种机制允许用户将命令的输出保存到文件中,或者将文件的内容作为命令的输入。重定向通过使用特定的符号来实现,这些符号主要包括 >、>>、< 和 2>
输出重定向
我们在关闭1后发现,本来应该输出到显示器上的内容,输出到了文件 myfile 当中,其中,fd=1。这种现象叫做输出重定向
重定向的本质其实就是修改文件特征fd的下标内容。新文件描述符覆盖旧的文件描述符
难道我们必须要先把1号文件关闭了再打开新的文件才能完成重定向吗??
其实本质上就是将新文件的指针覆盖掉原来1号位置的指针就行了,系统提供了一个接口叫dup来帮助我们解决这个问题!!所以输出重定向和追加重定向底层肯定使用了dup接口
dup2是一个系统调用,用于复制一个现有的文件描述符到另一个文件描述符的位置,同时关闭目标文件描述符(如果它之前已打开)。这个调用主要用于重定向标准输入、标准输出或标准错误流到文件或其他I/O设备
他是用oldfd覆盖掉newfd
int main()
{//int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);int fd = open("log.txt", O_RDONLY | O_CREAT | O_TRUNC, 0666);if(fd < 0){perror("open");return 1;}//dup2(fd, 1); //printf("hello world\n");//fprintf(stdout, "hello wrold\n");dup2(fd, 0); char buf[1024]; fread(buf, 1, sieof(buf), stdin);printf("%s\n, buf"):close(fd);return 0;}
进程替换会影响文件的重定向吗??
输入重定向
是指将程序的标准输入从键盘重定向到一个文件,或者将命令的输入从标准输入流(通常是键盘)重定向到文件、管道等。它通常在命令行或脚本中使用。
read的使用:
int main()
{close(0);int fd = open("log.txt", O_RDONLY);if(fd < 0){perror("open");return 1;}char buf[1024];fread(buf, 1, sizeof(buf), stdin);printf("%s\n", buf); close(fd);return0;}
本来
fread
是要求我们从键盘输入的,但是他直接从文件里面读取了,那么到底什么是重定向,我们来画图了解一下
为什么要有stderr?
1. 将程序的运行结果分别重定向到两个不同的文件
这样我们可以把运行结果放到我们的正常文件里,然后把错误的一些信息放到我们的错误文件里,方便我们观察,这就是为什么要有stderr的原因。
这才是重定向的本质写法,只不过我们平常不写fd的话默认就是将1号文件的内容重定向。
2. 将两个文件的结果都写到一个文件
这个意思是1号文件的地址变成了all.log 然后2也被写入到原来1的位置,所以最后其实都被写到了all.log文件里面
如何理解一切皆文件
计算机上进行的所有操作,所有任务都会被系统检测成进程,所以进程是操作系统帮助用户完成任务的主要渠道,几乎没有之一
因此我们目前对文件的所有操作其实都依赖于进程操作!!
而我们所有的外设都需要提供相应的读写方法 (跟文件有点类似)!所以我们会尝试把外设也当成是文件来处理,在使用的时候在内核层面搞一个file结构体, 但是这样会遇到一个问题就是,并不是所有的外设都有读写方法的!!比如说键盘只有写方法,而显示器只有读方法,那我们的file要如何做区分呢??
所以操作系统还给这些文件提供了一个 方法 结构体 里面保存了读方法和写方法的指针
这俩函数指针指向的是各个外设的读写方法。
就比如说我当前想要打开显示器,在创建file结构体的时候顺便创建了方法结构体,里面的读写的函数指针分别指向显示器的读方法和写方法,所以因为显示器只有写方法,读方法是空,于是在调用的时候就自然区分得出来了
其实这就是VFS 虚拟文件系统,所以可以理解Linux一切皆文件。
其实我们还可以发现 这个文件其实就是基类,而外设就是派生类,然后指针指向什么就调用什么对象,这就是多态,只不过Linux必须用C语言写,所以只能用函数指针来完成这个工作!!
理解了Linux的一切皆文件后,懂得了文件操作的底层,即使以后在使用其他语言的文件操作时对接口不熟,但只要给时间查一下,很快就会懂得怎么用了!!(没有太多的恐惧),这就是理论知识所带给我们的力量和底气!!
理解为什么会有面向对象
如果面向对象是C++的专属,那么他只能算是一种特性,但是当你发现大部分主流语言都是支持面向对象的,那么这就说明面向对象不是一两门语言的特性,而是历史的必然!!
为什么会有人能凭空想出来面向对象的语言呢??
因为人们在经过大量的工程实验后,发现我们总是或多或少要使用一些多态的特性,比如说写操作系统的人必然也是有可能开发语言的人,他在写的时候就意识到Linux里面很多虚拟化的东西,要不是你必须拿C去写,我早就发明出一门面向对象的语言了,直接搞个基类派生类出来就很快了!! ——>因为很多地方需要对软件做分层,设置出各种虚拟化的场景(比如刚刚提到的文件虚拟系统就是,只不过Linux必须用C写,否则肯定用C++写更方便) ——>封装、继承、多态!