【Linux】基础I/O文件——文件描述符的引入
目录
📖一、明确基本共识
📖二、C语言文件接口回顾
📖三、三个标准输入输出流
📖四、系统文件I/O
4.1 系统调用--open
4.2 系统调用--write
4.3 系统调用--read
📖五、回顾目录3 内容
📖六、访问文件的本质
📖七、重新认识 FILE
📖八、理解关闭文件
📖九、完结
📖一、明确基本共识
1. 文件 = 内容 + 属性
文件的打开如同malloc动态开辟内存,必须是执行到时候才可以打开
那么执行到的时候就决定了是由进程打开的文件
2. 文件本身是在磁盘上的,而进程要去打开文件,因为进程是可执行程序+PCB,也就是说进程是在内存中的,但是不管是进程还是文件,最终都是要交给CPU去访问
但是根据冯诺依曼体系结构,CPU无法直接访问硬件,能跟硬件打交道的只有内存,那么为了让文件可以交给CPU去处理,因此文件必须被加载到内存中。
所以被打开的文件,是在内存中的。
没有被打开的文件,那就是在磁盘中。
3. 一个进程可以打开多个文件,
同时一个文件也可以被多个进程打开
4. 一个进程能够打开多个文件,所以在操作系统内部一定存在大量的被打开的文件,操作系统还是通过先描述,再组织的方式对打开的文件进行管理。因此每个被打开的文件都必须有自己的结构体对象,结构体其中一定包含了文件的很多属性,将这些对象以某种特殊的数据结构组织起来,最终对文件的管理,就变成了对某种数据结构的维护(增删查改)。
📖二、C语言文件接口回顾
在C语言中,我们打开文件是这样写的
// 文件打开接口
FILE *fopen(const char *path, const char *mode);
第一个参数 path
,表示要打开的文件路径,或者文件名。如果只有文件名前面没写路径,表示打开当前路径下的文件。
总的来说,当前工作路径是一个进程 PCB
中维护的一个属性。一个可执行程序在被加载到内存成为进程创建出对应的 PCB
对象的时候,PCB
对象中就维护了一个叫做 cwd
的属性,该属性就表示进程当前的工作路径。
如果 fopen
函数的第一个参数只传递了文件名,最终在打开文件的时候,操作系统会去 cwd
指向的工作路径下查找该文件。
第二个参数 mode,这个参数有很多可选项,今天只介绍个别选项
w选项:只要是以 w 选项打开的文件,在写入之前都会对文件做清空处理,然后从头开始写入。
a选项:在文件结尾进行追加写。
小Tips:重定向 >, 本质上就对应使用的是 w 选项,>> 本质上就对应使用的是 a 选项。
2.2 文件的读取写入操作
这里我们介绍一下fwrite接口
// fwrite 接口声明
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
fwrite是用来向文件中写入内容的,它的第一个参数*ptr,是一个任意类型的指针
第二个参数size表示要写入内容的总大小是多少,单位是字节
第三个参数size表示要写入内容的每一个单体的字节是多大
最后一个参数*stream代表的就是,就是用fopen这种打开文件的File*型指针
我们在Linux中来实际使用一下->:(就类似于这样使用)
int main()
{FILE *fp = fopen("log.txt", "w");if (fp == NULL){// 打开失败perror("fopen");return errno;}// 打开成功,对文件进行相关的操作// ...char* str = "Hello Linux!";fwrite(str, strlen(str), 1, fp);// 操作结束,关闭文件fclose(fp);return 0;
}
我们需要注意到,在文件写入时候,我们的strlen并没有写成strlen(str)+1,这是为什么?
字符串不是有一个'\0'吗?
strlen 计算出来的字符串长度是不包含结尾的 \0,有的小伙伴认为要把 \0 也写到文件里面,但是 \0 真的需要写入文件嘛?其实 \0 并不需要写入文件中,因为字符串以 \0 结尾只是 C 语言这么规定的,但在文件中,我们把一个字符串写入文件后,可能通过其它的语言去读取该文件,我们并不希望读到与该字符串无关的内容 \0
这里我们把'\0'加进去看一看效果,如图->:
\0
也是字符,只不过不可显,在被写入到文件后,vim
编辑器会把它识别成 ^@
,对 Hello Linux
来说,^@
就是多余的无用字符。我们不希望它在文件中出现。
📖三、三个标准输入输出流
C语言默认会打开三个输⼊输出流,分别是stdin, stdout, stderr
仔细观察发现,这三个流的类型都是FILE*,也就是⽂件指针,所以它们三个本质上都是文件
但是,为什么会默认打开这三个?我们看下文
#include <stdio.h>
extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;
📖四、系统文件I/O
1.C语言封装系统调用接口
我们回想一下这张图
我们最开始写的文件,都是在磁盘上的,但是磁盘属于硬件设备,操作系统不允许我们直接访问硬件设备,所以操作系统设置了系统调用接口,那么在系统调用接口上又封装了一层,也就是我们的C语言库函数,所以,C语言封装了系统调用接口
所以,我们在C语言中使用的fopen
、printf
、fprintf
、fscanf
等函数都一定是封装了系统调用。
接下来,我们来讲解一下系统调用函数
4.1 系统调用--open
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
-
第一参数
pathname
:表示要打开或创建的目标文件 -
第二个参数 flags:标志位选项。O_RDPPNLY:只读打开;O_WRONLY:只写打开;O_RDWR:读写都打开。这三个常量,必须指定一个且只能指定一个。
其次是,O_CREAT:若文件不存在,则创建它。需要使用 mode 参数,来指明新文件的访问权限。O_APPEND:追加写;O_TRUNC:文件打开的时候先清空。
-
第三个参数
mode
:新创建文件的默认权限,要考虑权限掩码,可以配合umask
系统调用接口来设置自己想要的效果。umask
系统调用产生的效果就只对当前进程创建的文件有关。 -
返回值:成功,返回新打开的文件描述符,关于文件描述符是什么,将在后文为大家介绍;失败,返回-1。
小Tips:open
函数具体使用哪个,和具体的应用场景有关,如目标文件不存在,需要 open
创建,则第三个参数表示创建文件的默认权限。
如果不需要创建新文件,使用两个参数的 open
。
4.1.1 比特位级别的标志位传递方式
#define ONE (1<<0) // 1
#define TWO (1<<1) // 2
#define FOU (1<<2) // 4
#define EIG (1<<3) // 8void show(int flags)
{if(flags & ONE) printf("function1\n");if(flags & TWO) printf("function2\n");if(flags & FOU) printf("function3\n");if(flags & EIG) printf("function4\n");return;
}int main()
{printf("--------------------------------------\n");show(ONE);printf("--------------------------------------\n");show(ONE | TWO);printf("--------------------------------------\n");show(ONE | TWO | FOU );printf("--------------------------------------\n");show(ONE | TWO | FOU | EIG);printf("--------------------------------------\n");return 0;
}
小Tips:这种比特位级别的标志位传递方式,使用户可以在函数调用的时候采用按位或的方式传递多个选项实现不同的功能。open
函数的第二个参数就是采用这种方式就是这样。
举个例子->:
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>int main() {int fd = open("test.txt", O_RDWR | O_CREAT, 0666);if (fd == -1) {perror("open");return 1;}close(fd);return 0;
}
在这个例子中,O_RDWR | O_CREAT
就是将两个标志位进行按位或组合,传递给 open
函数的 flags
参数,表示同时具备读写和创建文件的功能。这和你代码中通过标志位组合来控制不同功能的方式是类似的。
4.2 系统调用--write
#include <unistd.h>ssize_t write(int fd, const void *buf, size_t count);
-
第一个参数
fd
:表示待写入文件的文件描述符。 -
第二个参数
buf
:指向待写入的文件内容。 -
第三个参数
count
:待写入内容的大小,单位是字节。 -
返回值:实际上写入的字节数。
4.2.1 模拟实现open-- w 选项
这里模拟的是fopen中的w选项,也就是打开文件时是一个空的文件
int main()
{umask(0); // 将权限掩码设置成全0int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666); // 以读的方式打开,若文件不存在就创建,打开文件时清空if(fd < 0){printf("open file\n");return errno;}const char* str = "aaa";ssize_t ret = write(fd, str, strlen(str));close(fd);return 0;
}
4.2.2 模拟实现open-- a 选项
这里模拟的是fopen中的a选项,也就是打开文件时是可以追加写入并不清空文件的
int main()
{umask(0); // 将权限掩码设置成全0int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666); // 以读的方式打开,若文件不存在就创建,以追加的方式进行写入if(fd < 0){printf("open file\n");return errno;}const char* str = "aaa";ssize_t ret = write(fd, str, strlen(str));close(fd);return 0;
}
4.3 系统调用--read
我们上面提到了系统调用接口的open,write,分别是打开文件和写入文件
接下来我们学习一下读的文件
#include <unistd.h>ssize_t read(int fd, void *buf, size_t count);
-
第一个参数
fd
:要读取文件的文件描述符。 -
第二个参数
buf
:指向一段空间,该空间用来存储读取到的内容。 -
第三个参数
count
:参数二指向空间的大小。
使用例子->:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>#define BUFFER_SIZE 100int main() {int fd = open("example.txt", O_RDONLY);char buffer[BUFFER_SIZE];ssize_t bytes_read = read(fd, buffer, BUFFER_SIZE);close(fd);return 0;
}
总结:C语言中的fopen
、printf
、fprintf
、fscanf
等函数都一定是封装了系统调用。
📖五、回顾目录3 内容
我们在目录3中提到了,C语言默认会打开三个输⼊输出流,分别是stdin, stdout, stderr
那么,刚才我们又提到C语言是封装了系统调用的
所以,C语言打开的三个输入输出流,其实就是操作系统默认打开的三个输入输出流
📖六、访问文件的本质
我们之前提到过,操作系统内核,它具有进程管理系统和文件管理系统
总结:一个被打开的文件,加载到内存,操作系统会为该文件创建一个 struct file 结构体对象,因此操作系统对文件的管理本质上就是对 struct file 结构体对象的管理,操作系统会将当前所有被打开文件的 struct file 对象以双链表的形式组织起来,后续会把 struct file 对象交给一个struct files_struct结构体的对象保管(struct files_struct结构体中有一个指针数组,这个指针数组专门用来存放struct file的对象)
接着,进程的 PCB 对象中有一个 struct files_struct 类型的指针,它指向的就是 struct files_struct 的对象(也就是说每一个进程都有各自的 struct files_struct)
struct files_struct 对象里面记录了当前进程所打开的所有文件的信息,其中维护了一个 struct file* 类型的指针数组,数组的内容就指向了当前进程所打开的文件结构体对象,简言之就是指向了当前进程打开的文件。
我们将这个指针数组就叫做文件描述符表,数组的下标就叫做文件描述符(因此文件描述符一定大于0)。
open 函数的返回值其实就是文件描述符,即只要当前进程打开一个新文件,操作系统就会按照从前往后(从0往后)的顺序从该进程的文件描述符表中分配一个数组下标,同时,该下标对应的内存空间中存储的就是该文件结构的地址。此后要对该文件进行任何操作,只需要知道它对应的数组下标即可。
小Tips: 1. 文件描述符表的前三个位置进程会默认加载三个输入输出流
2. 文件描述符的生命周期随进程
我们看这样一段代码->:
#include <fcntl.h>
#include <unistd.h>
int main()
{umask(0); // 将权限掩码设置成全0int 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);return 0;
}
小Tips:通过结果可以看出,进程新打开的文件,其下标只能从3开始,这是因为 C 程序在运行起来的时候操作系统会默认帮我们打开三个流
标准输入流 stdin 对应键盘文件,下标为0;
标准输出流 stdout 对应显示器文件,下标为1;
标准错误流 stderr 对应显示器文件,下标为2。
从这里可以的出一个结论,默认打开三个标准输入输出流并不是 C 语言的特性,而是操作系统的特性,所有语言编写的程序运行起来后都会打开。操作系统为什么要帮我们打开呢?因为电脑在开机的时候,键盘和显示器就已经被打开了,我们在编程的时候,一般都会用键盘输入和通过显示器查看结果。
文件描述符对应的分配规则:从0下标开始,寻找最小的没有使用的数组位置,它的下标就是新打开文件的文件描述符。(即,我们给指针数组中0的位置用close关掉后,那fd就会默认从1开始)
我们举个简单的代码例子->:
int main()
{umask(0); // 将权限掩码设置成全0close(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);return 0;
}
📖七、重新认识 FILE
FILE
是 C 语言库中自己封装的一个结构体,在 C 语言中,通过 FILE
对象去描述文件。可以确定,FILE 中一定封装了文件描述符。如下面代码,FILE
中的 _fileno
属性就是文件描述符。
#include <fcntl.h>
#include <unistd.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;
}
📖八、理解关闭文件
一个文件可以被多个进程同时打开,最常见的比如键盘文件,显示器文件。
在 struct file 对象中有一个 f_count 字段,叫做当前文件的引用计数,记录了当前这个文件被多少个进程打开了,进程的关闭文件就是调用 close 系统调用,将对应下标里面的内容置为 NULL,这是进程系统需要执行的工作。
置空后操作系统会把该文件描述对应文件结构体对象中的 f_count 字段减减,然后判断 f_count 是否为0,如果不为0就什么也不干,如果为0,操作系统才将对应的 struct file 对象回收,这是文件系统执行的工作。
📖九、完结
创作不易,留下你的印记!为自己的努力点个赞吧!