目录
前言:
一、文件是什么?
二、打开文件的方式总览
三、将内容打印到显示器上的几种方法
四、open函数
1.起始权限
2.open函数的返回值
3.write函数
4.O_TRUNC(截取)
5.O_APPEND(追加)
五、文件描述符
六、文件描述符和进程的关联
七、如何理解一切皆文件?
八、 文本写入 VS 二进制写入
九、 文件内核级缓冲区
1.如何写入文件?
2.如何读取文件?
3.如何修改文件内容?
十、文件重定向
1.dup2函数
2.追加重定向
3.输入重定向
4.>/>>/<重定向符号
十一、语言级缓冲区(用户级缓冲区)
1.fclose和close的区别(文件刷新机制)
2.fsync函数
十二、深度理解缓冲区
总结:
前言:
我们将开启新篇章,文件!进程我们已经学的十有六七了,想再学习进程就需要学习关于文件的知识了,本章内容先从文件的操作开始讲起,之后由浅入深的学习文件系统。开始吧!
一、文件是什么?
文件 = 内容 + 属性
访问一个文件是进程在访问,当代码运行到fopen时文件才会打开。文件必须加载到内存当中。文件必须加载到内存当中。
OS有是如何管理文件的呢?——先描述,再组织!
在内核中,文件 = 文件内核结构 + 文件内容
我们研究打开的文件是在研究进程和文件的关系。
文件分为两种形式:
1.被打开的文件 --- 内存
2.没有被打开的文件 --- 磁盘
二、打开文件的方式总览
打开文件以“w”方式,是写入,每次从头开始写;以“a”方式打开时追加。
任何一个程序打开的时候都会打开3个文件流(后面详细讲):
stdin(标准输入 键盘)
stdout(标准输出 显示器)
stderr(标准错误 显示器)
他们的类型都是FILE*,也就是C语言把他们封装了。进程默认会打开三个输入输出流。
三、将内容打印到显示器上的几种方法
因为我们用户不能直接访问硬件,必须通过OS,OS提供系统调用,C文件接口底层都对系统调用进行了封装。
四、open函数
fopen函数底层封装的都是open这个系统调用接口,我们来研究一下这个接口的参数:
其中flags是一个标记位,它有这些参数:
这就相当于C语言封装的r,rw等打开方式的概念。
但是这个函数只能传入一个参数,这些选项都是宏,可以组合使用。一个int是32位,也就是说每个位都有一个标记,也就是位图。
我们写一个程序来理解位图:
#include<stdio.h>#define ONE (1<<0) #define TWO (1<<1) #define THREE (1<<2)void PrintTest(int flags) {if (flags & ONE){printf("one\n");}if (flags & TWO){printf("two\n");}if (flags & THREE){printf("three\n");} }int main() {printf("===============================\n");PrintTest(ONE);printf("===============================\n");PrintTest(TWO);printf("===============================\n");PrintTest(THREE);printf("===============================\n");PrintTest(ONE | THREE);printf("===============================\n");PrintTest(ONE | TWO | THREE);printf("===============================\n");return 0; }
1.起始权限
我们来观察以下程序的执行结果(注意这里调用open函数没有传入第三个参数):
所以使用系统调用时,需要告诉OS新建文件的默认起始权限。创建文件和权限是OS的两个分支功能,没有关系。所以要先指明创建文件方式,之后指明权限。也就是open的第三个参数mode。
所以建议打开文件时,如果没有创建就用三参数的open;创建过之后就用两参数的open。
这是为什么?大家是否记得umask(权限掩码),也就是和其实权限按位与之后取反得到的权限(详情请见:【Linux】权限 | yum软件包管理器_linux给新用户yum权限-CSDN博客)。
我们当然可以在程序中更改:
但是此时系统中的umask修改了吗?
可以发现并没有,可以确定每个PCB中都有一个umask,默认从系统中来,我们可以对其修改。
所以我们自己可以写一个类似touch的命令:
2.open函数的返回值
我们接下来来看open函数的返回值:
打开失败返回-1,错误码被设置。我们将创建的文件返回值打印出来。
这里为什么是3呢?这里我们稍后讲解。
3.write函数
一个文件能打开就能关闭,读取,写入。
4.O_TRUNC(截取)
如果需要覆盖内容重新打印, 这就需要用到另一个宏了:O_TRUNC(截取)。
所以再次修改代码:
5.O_APPEND(追加)
如果不想清空,可以再次追加O_APPEND参数。
所以C封装的就是这些函数。
五、文件描述符
前三个文件描述符是进程打开默认打开的,stdin(0),stdout(1),stderr(2)。我们现在可以通过向其文件描述符中写入内容。
我们再从键盘(0文件描述符)中读取数据并打印。
read的返回值是字符串的长度。
这里有一个小知识点,当我们使用write函数的时候,最后一个参数也就是长度要不要+1,加上字符串结尾的'\0'呢?
因为字符串以'\0'结尾是C语言的规定,和文件没有关系。
六、文件描述符和进程的关联
接下来我们先来看看文件描述符和进程的关联:
接下来我们来看当一个程序执行到打开文件的时候,它是如何加载的,文件描述符有是什么东西? 也就是说,每个进程都有一个对应的文件描述符表,并都会默认打开3个输入输出流。
文件就好像PCB,也会被OS先描述再组织。
一般情况下,不同进程的这三个文件描述符所指向的地址在概念上是类似的,但在具体实现中他们并不一定是完全相同的物理地址或虚拟地址。
struct file* fd_array[N]被称为文件描述符表,每个进程都有这个表。
在系统层面,fd(文件描述符)是访问文件的唯一方式。我们使用C语言是使用FILE*来完成的,这是C语言提供的,这是什么东西?
任何语言都会对文件描述符进行封装。
七、如何理解一切皆文件?
对于每个硬件都有自己的驱动,OS对软硬件进行管理。每一种硬件在OS内部都是一种结构体。
struct device
{int type;int vender;int id;int status;...
};
这些硬件都是外设,他们对应的属性一样,但是值不同。但是根据冯诺依曼原理,有两个最核心的方法--读写(IO)。
不同外设,IO方法一定不一样!
比如键盘,当做一个文件最重要的就是读方法(read()),但是我们有必要向键盘写内容,有这个方法没有参数即可。
比如显示器,我们其实只会向显示器上写内容,从键盘读取内容,所以其读方法为空。
因为C中结构体中不能有方法,所以在struct file中,我们使用函数指针调用驱动提供的方法。
所以我们要是用这些方法,就不需要关心底层的方法。直接使用函数指针调用即可。
struct file在系统层面,做了一层软件的封装,所以一切皆文件!
这一套机制,叫做vfs(虚拟文件系统)。
通过文件描述符让用户看到struct file,所有用户行为都是进程,所以对于进程角度是一切尽文件。
在软件的上层有统一的struct file,在底层有不同的外设,这种技术就是多态!
八、 文本写入 VS 二进制写入
当我们想使用系统调用直接向显示器中写入整数,是无法直接写入的,必须现对其进行格式化。
当在键盘中,输入1234时,输入的是‘1’,'2','3','4'这些字符,而不是1234这个数字。
这就是为什么C为什么有那么多的格式化函数,类似于printf、sprintf、fprintf这些格式化的函数。如果只是一些系统调用,这些操作只能由我们自己实现,打印数字,字符等等等。这就方便了用户操作。
所以在计算机中,没有文本写入和二进制写入的区别,只有二进制。但是在语言层,有这样的区别。
我们之前使用的都是Linux的系统接口,如果换了系统,这些接口就不能使用(open/close等)。但是C语言对于其他的OS也对这些接口进行了封装,所以统一使用fprintf,这就提高了语言的可移植性。
这些系统都安装了glibc库,这个库也有源代码,这个库会有很多版本(linux、windows等)。
注意struct device和struct file是两个不同但又存在一定关联的结构体。
struct device:是 Linux 设备模型的核心结构体之一,用于在内核中表示一个物理设备或虚拟设备。它包含了设备的基本信息,如设备名称、设备类型、设备所属的总线等,还维护了设备与驱动程序之间的关联,是设备管理和驱动开发的基础。
struct file:用于表示一个打开的文件或设备文件。当用户空间的程序调用open()系统调用打开一个文件或设备文件时,内核会创建一个struct file结构体实例来跟踪这个打开的文件的状态和相关信息,例如文件的偏移量、访问模式、文件操作函数集等。
九、 文件内核级缓冲区
1.如何写入文件?
struct file中有操作表,也就是函数指针集合。也有一个东西叫做文件的内核缓冲区。
当打开log.txt时,会创建一个新的struct file实例对象。
比如此时执行write(3, "hello", ...)时,PCB找到文件对象,首先将内容拷贝到这个文件的内核缓冲区,之后系统调用file->ops->write()将文件缓冲的内容刷新到外设中。
将内核数据刷新到外设由OS自主决定,所以write本质是拷贝函数,把数据从用户拷贝到内核。 所以平时我们在word写的时候要保存,因为我们只是写在了缓冲区。
2.如何读取文件?
打开文件后,又是如何读取的呢?我们先以read系统调用说明,我们会将读取的内容写在一个buffer的缓冲区中(read(3, buffer, ... ))。此时文件内容没有在对应的文件缓冲区中,就回去磁盘上找,加载到对应的缓冲区中,之后拷贝到buffer(自己创建的缓冲区)中。
3.如何修改文件内容?
当我们要修改文件时,可能会把所有文件内容加载到缓冲区中,之后拷贝到buffer中修改,最后拷贝到文件缓冲区中。所以,修改的本质也是先读取,后写入!
缓冲区的存在是为了提高效率!
十、文件重定向
我们先来观察一个现象(这里先把0号(键盘)文件描述符关掉):
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>int main()
{close(0);int fd1 = open("log1.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);int fd2 = open("log2.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);int fd3 = open("log3.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);int fd4 = open("log4.txt", 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;
}
之后把2文件描述符关掉:
可以发现是使用关闭后最小的并且没有被分配的文件描述符开始占据。
此时我们把1号文件描述符给关了,再次观察结果:
可以发现没有显示结果。
这里先解释一下:
(注:图中文件描述符3也指向log1.txt)
我们知道有fprintf和printf函数。fprintf可以指定向那个文件描述符输出,而printf函数其实就是向标准输出流输出,底层也就是封装的1号文件描述符,此时我们通过系统调用close函数直接把1stdout关闭了,分配的1文件描述符指向log1.txt文件,所以printf函数应该向log1.txt文件中输出内容。但是却并没有打印出来,我们在代码中添加一行fflush函数再次观察。
我们先不关心fflush的原因,结果也确实如我们所料,这种技术叫做输出重定向。
1.dup2函数
这种重定向方法非常不优雅,还有一个系统调用接口:
这个系统调用会把文件描述符的指向改变,会把没有指向的文件关闭,多个文件描述符指向同一个文件。我们直接演示:
可以把第一个参数想象成活的更久的文件描述符。
2.追加重定向
而什么又叫做追加重定向呢?我们将打开的文件不以O_TRUNC方式打开,以O_APPEND方式打开。对,就这么简单。
3.输入重定向
我们直接将从键盘读取文件改为从log.txt读取文件:
所以重定向底层其实就是调用dup2(或dup)。
如果想恢复,只能提前备份。
4.>/>>/<重定向符号
我们之前使用过>做过重定向。
我们这次在之前未完成的myshell里面完善一下功能,实现重定向(关于myshell的代码请看:【Linux】实现一个简易shell | 内键命令(八)-CSDN博客)。
在解析命令的时候,这个命令可能是“ls -a -l -n > file.txt”,所以我们先从后往前找,找到重定向符号,之后开始做重定向操作,我们可以定义几个宏:
// 与重定向有关的全局环境变量
#define NoneRedir 0 //没有重定向
#define InputRedir 1 //输入重定向
#define OutputRedir 2 //输出重定向
#define AppRedir 3 //追加重定向//定义全局变量
int redir = NoneRedir;
char* filename = nullptr;
因为我们输入的时候可能会有很多空格,所以我们要把这些空格都给跳过, 使用isspace判断空格:
我们这里定义一个宏把重定向符号后面的空格都删除:
#define TrimSpace(pos) do{\while(isspace(*pos)) {\pos++;\}\
}while(0)
void ParseCommandLine(char command_buffer[], int len) //3.分析命令
{(void)len;//为保证安全每次命令行解析都对参数列表进行清空memset(gargv, 0, sizeof(gargv)); gargc = 0;//重定向//每次执行完都重新把重定向重置redir = NoneRedir;filename = nullptr;//"ls -a -l" > file.txtint end = len - 1;while(end >= 0){if (command_buffer[end] == '<'){//输入重定向redir = InputRedir;command_buffer[end] = 0;filename = &command_buffer[end + 1];TrimSpace(filename);break;}else if (command_buffer[end] == '>'){if (command_buffer[end - 1] == '>'){//追加重定向redir = AppRedir;command_buffer[end] = 0;command_buffer[end - 1] = 0;filename = &command_buffer[end] + 1;TrimSpace(filename);} else {//输出重定向redir = OutputRedir;command_buffer[end] = 0;filename = &command_buffer[end + 1];TrimSpace(filename);}break;}end--;}const char* sep = " "; //定义分隔符gargv[gargc++] = strtok(command_buffer, sep); //对字符串切分while((bool)(gargv[gargc++] = strtok(nullptr, sep)));//这里会多加一个1,所以自减1gargc--;
}
我们可以把程序预编译一下观察程序代码:
此时还没有完成重定向,只是完成准备工作,因为在重定向时,是子进程完成,所以我们应该在fork函数中完成重定向。程序替换的时候,是不会影响重定向的。
我们将代码的每个功能都封装为函数。
bool ExecuteCommand() //4.执行命令
{//创建子进程 让子进程执行命令pid_t id = fork();if (id < 0) return false;if (id == 0){//1.重定向让子进程做//2. 程序替换会不会影响重定向?不会if (redir == InputRedir){if (filename){//此时文件一定要存在 输入重定向int fd = open(filename, O_RDONLY, 0666);if (fd < 0){exit(2);}dup2(fd, 0);}else {exit(1);}}else if (redir == OutputRedir){if (filename){int fd = open(filename, O_CREAT | O_TRUNC | O_WRONLY, 0666);if (fd < 0){exit(4);}dup2(fd, 1);}else {exit(3);}}else if (redir == AppRedir) {if (filename){int fd = open(filename, O_CREAT | O_APPEND | O_WRONLY, 0666);if (fd < 0){exit(6);}dup2(fd, 1);}else {exit(5);}}//子进程//1.执行命令//execvp(gargv[0], gargv);execvpe(gargv[0], gargv, genv);//2.退出exit(1);}int status = 0;pid_t rid = waitpid(id, &status, 0); //以阻塞等待方式if (rid > 0){if(WIFEXITED(status)){lastcode = WEXITSTATUS(status);}else {lastcode = 100;}return true;}return false;
}
但是这种方式并不是很优雅,我们把这些功能都拆开来,每个功能是一个函数(这里是建议读者不要这样做,可以拆分为函数,这里就不做演示了)。
十一、语言级缓冲区(用户级缓冲区)
我们直接观察代码:
这段代码如果我们不加上fflush的话,log1.txt中没有内容,这和缓冲区有关。
当我们写C时,打开一个文件会先写一个FILE*类型的指针,之后无论是printf、fprintf、scanf、fputs等都和FILE有关,当我们向文件写入内容后,其实并不会直接把内容拷贝给文件内核缓冲区,因为调用系统调用,也是有成本的(时间或空间)。
我们自己写程序时,应该有写到过内联函数,其本质就是为了提高效率,所以调用系统调用其实比你调用自己的函数成本还要高,所以要减少。
所以在用户中也会有用户级缓冲区,当其有足够多的内容时,才会把数据复制到内核级文件缓冲区中。用户级缓冲区在FILE结构体中。
当向显示器文件写入的时候,可以行刷新(\n);普通文件,缓冲区写满再刷新;或者不缓冲。
1.fclose和close的区别(文件刷新机制)
fclose是C的,当关闭文件时,会刷新缓冲区中的内容到内核级缓冲区;而close是系统调用,不会将内容刷新,当进程退出时,并没有刷新,所以可以使用fflush刷新。
当进程退出时,会将用户缓冲区的内容刷新到内核缓冲区,但是若close是在进程退出前使用,则不会刷新。
所以我们再close前使用fflush。
但是现在还是有问题,我们说显示器文件是行刷新,比如:
因为行刷新是针对显示器,而我们重定向以后是普通文件,只是写满缓冲区才刷新。
2.fsync函数
但是在内核级缓冲区刷新到外设,一般是由操作系统决定的。但是有一个系统调用可以完成fsync(int fd):
此函数用于将文件描述符(file descriptor)关联的文件数据和元数据(metadata)强制刷新到磁盘。
我们先来观察一段代码:
#include<stdio.h>
#include<string.h>
#include<unistd.h>int main()
{//C库函数printf("hello printf\n");fprintf(stdout, "hello fprintf\n");const char* message = "hello fwrite\n";fwrite(message, 1, strlen(message), stdout);//系统调用const char* w = "hello write\n";write(1, w, strlen(w));fork();return 0;
}
重定向以后,可以发现把C库函数的打印都打印了两遍,而系统调用的打印只打印了一遍。
首先我们解释一下这里的重定向: ./a.out向fd = 1(显示器)中打印结果,但是 > 重定向,将fd = 1的指向改变为test.txt。此时刷新原则已经变了。
在重定向的时候,因为重定向改变了刷新原则,C的函数打印并没有直接打印,而系统调用的write已经将内容写入了内核缓冲区所以首先被打印出来;fork创建子进程,共享代码,最后父子进程结束,各自执行fflush,发生写实拷贝,所以C函数内容被打印两次。
但是为啥直接执行不重定向就是4行内容?因为向显示器打印直接就是行刷新,fork执行前内容已经在内核缓冲区了。
如果我们把C函数的\n都去掉,再次观察结果:
十二、深度理解缓冲区
为了深度理解缓冲区,我们自己实现一个小项目。
先定义一个头文件my_stdio.h,里面声明一个类似C的FILE结构体mFILE,其中声明刷新方式,容量,大小,文件描述符,和缓冲区。并给出各种方法的声明:
my_stdio.h头文件内容:
#pragma once
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>#define SIZE 1024#define FLUSH_NONE 0 //不刷新
#define FLUSH_LINE 1 //行刷新
#define FLUSH_FULL 2 //满刷新struct IO_FILE
{int flag; // 刷新方式int fileno; //文件描述符char outbuffer[SIZE];int cap; //容量int size; //大小
};typedef struct IO_FILE mFILE;mFILE* mfopen(const char* filename, const char* mode);
int mfwrite(const void* ptr, int num, mFILE* stream);
void mfflush(mFILE* stream);
void mfclose(mFILE* stream);
之后用my_stdio.c文件实现各种方法, 这里先实现mfopen方法:
mFILE* mfopen(const char* filename, const char* mode)
{int fd = -1;if (strcmp(mode, "r") == 0){fd = open(filename, O_RDONLY);}else if (strcmp(mode, "w") == 0){fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);}else if (strcmp(mode, "a") == 0){fd = open(filename, O_CREAT | O_WRONLY |O_APPEND, 0666);}if (fd < 0) return NULL;mFILE* mf = (mFILE*)malloc(sizeof(mFILE));if (!mf) {close(fd);return NULL;}mf->fileno = fd;mf->flag = FLUSH_LINE; //默认行刷新mf->cap = SIZE;mf->size = 0;return mf;
}
这里要差一嘴,当一个同学要抄我们的程序作业时,代码一样,他该怎么抄呢?
我们知道,一个可执行性程序是要通过编译链接完成的,我们可以先把.c文件编译生成.o文件,之后把头文件给他,他自己写一个mian.c来完成,接下来举一个例子。
比如此时就用我们my_stdio.c生成了一个my_stdio.o的文件,我们将其和头文件打包给了张三。
大家关于这部分也就先听个热闹,我们会在后面讲解链接,到时候至少让你知道怎样操作,至于原理嘛……看书吧,少年!(程序员自我修养)
这里给出my_stdio.c的全部内容:
#include"my_stdio.h"mFILE* mfopen(const char* filename, const char* mode)
{int fd = -1;if (strcmp(mode, "r") == 0){fd = open(filename, O_RDONLY);}else if (strcmp(mode, "w") == 0){fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);}else if (strcmp(mode, "a") == 0){fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);}if (fd < 0) return NULL;mFILE* mf = (mFILE*)malloc(sizeof(mFILE));if (!mf) {close(fd);return NULL;}mf->fileno = fd;mf->flag = FLUSH_LINE; //默认行刷新mf->cap = SIZE;mf->size = 0;return mf;
}void mfflush(mFILE* stream)
{if (stream->size > 0){write(stream->fileno, stream->outbuffer, stream->size);stream->size = 0;}
}int mfwrite(const void* ptr, int num, mFILE* stream)
{// 1.拷贝memcpy(stream->outbuffer + stream->size, ptr, num);stream->size += num;// 2.检测是否要刷新if (stream->flag == FLUSH_LINE && stream->size > 0 && stream->outbuffer[stream->size - 1] == '\n'){//当遇到\n 缓冲区中有数据 刷新方式是行刷新时刷新mfflush(stream);}return num;
}void mfclose(mFILE* stream)
{if (stream->size > 0){//刷新mfflush(stream);}close(stream->fileno);
}
之后给出main.c所有代码:
#include"my_stdio.h"
#include<unistd.h>int main()
{mFILE* fp = mfopen("./log.txt", "a");if (fp == NULL){return 1;}int cnt = 5;while(cnt){char buffer[64];snprintf(buffer, sizeof(buffer), "hello message, munber is: %d\n", cnt);cnt--;mfwrite(buffer, strlen(buffer), fp);sleep(1);}return 0;
}
此时main函数中因为是动态在文件中追加,每隔1s向文件中追加内容。所以我们再打开一个终端,写一个脚本语句动态观察文件内容变化。
while :; do cat log.txt; sleep 1; done
当我们写入的字符串没有'\n',会在最后进程关闭时刷新到文件中,这里不再做演示 。
总结:
我们本篇的内容十分重磅,包括文件的各种操作、接口,知道了什么是文件缓冲区和内核缓冲区,也知道了什么是重定向和文件内容刷新方式,文件描述符等。那么下一篇,则是也和文件有关的文件系统(你这家伙,力竭了吗?加油啊)。