❤️前言
大家好!今天这篇博客和大家聊一聊关于Linux下的基础IO。
正文
在阅读本篇博客之前,请大家先回顾一下C语言文件操作的一些方法,这里可以看看我之前记录的一些内容:
【C语言】C语言成长之路之文件操作_MO_lion的博客-CSDN博客https://blog.csdn.net/MO_lion/article/details/128730282?spm=1001.2014.3001.5501
除此以外,我们要引入C语言默认会打开的三个输入输出流,它们分别是stdin、stdout、stderr,它们三个是FIFE*类型的文件指针。
我们知道,在Linux系统下一切事物都可以视作文件进行处理,那么键盘和显示屏也可以看做是两个文件,大家可以猜猜为什么C语言要默认打开文件指针呢?其实就是因为这三个文件指针指向的就是输入输出设备文件,这样我们便可以对键盘和显示屏进行读写,举例来说,我们用printf和scanf就是用到这些文件指针对设备文件进行写入和读取。
系统级IO
要操作文件,我们不仅仅可以使用语言带有的文件接口,也可以使用操作系统提供的系统调用接口。
系统调用接口
可能你会对系统调用接口感到疑惑,它是什么呢?让我们来看下面这张图:
通过上面的这张图片,我们可以看出系统调用接口与库函数的关系,我们可以认为C语言中的文件操作函数都是对系统调用的封装,其目的是让我们用户更规范安全的操作文件。
系统调用的意义与上述的封装的意义很相似。操作系统在管理资源的时候并不相信任何用户的操作,它采取的是一刀切的方式来减少可能发生的危险操作,不让用户直接操作底层数据,但是又要让用户进行上层的操作,于是操作系统便在用户和底层数据之间加上了一层系统调用接口。系统调用接口的存在能很好地规范用户的操作,保护底层的数据。
接着,我们用系统调用接口演示一遍简单的文件读写操作:
写文件:
// 简单的写文件
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
umask(0); // 将权限掩码设置为0
// file descriptor: 文件描述符
int fd = open("lion", O_CREAT|O_WRONLY, 0666);
if(fd < 0){
perror("open");
return -1;
}
const char* message = "I like Linux!\n";
write(fd, message, strlen(message));
close(fd);
return 0;
}
上述程序成功的创建出一个文件并向其中写入了一段字符:
读文件:
// 读文件
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
// file descriptor: 文件描述符
int fd = open("lion", O_RDONLY);
if(fd < 0){
perror("open");
return -1;
}
char buf[1024] = {0};
read(fd, buf, 1023);
printf("%s", buf);
close(fd);
return 0;
}
运行结果:
现在我们根据上面的程序对文件读写的系统调用接口进行介绍:
打开文件open
用man指令看看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: 打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行“或”运算,构成flags。mode:创建文件时,该文件的权限参数。
参数:
O_RDONLY: 只读打开
O_WRONLY: 只写打开
O_RDWR : 读,写打开
这三个常量,必须指定一个且只能指定一个
O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限
O_APPEND: 追加写返回值:
成功:新打开的文件描述符
失败:-1
文件描述符
在继续了解其他系统调用之前,我们先来聊聊open正常返回的返回值文件描述符(file descriptor)。
文件描述符fd是一个int类型的整数,顾名思义,它在每个系统调用接口的使用中扮演代表某个文件的角色,就类似于C语言中的FIFE*指针。那么这样简单的int类型整数是如何做到代表一个文件的呢?
我们可以打印出fd的值,会发现它们的值都是一些接近0的小整数。这些值的意义是什么呢?
这里我们又要引出一个新的知识,当每个进程要对某些文件做管理时,会有一个文件描述符表对所有的文件结构进行对应映射,也就是说,通过这个文件描述符表就可以让进程找到对应的文件并操作它。文件描述符表的映射是借由什么完成的呢?其实就是依靠数组下标,这里的数组下标指的就是文件描述符了。
具体的内核存储结构是这样的:当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件,于是就有了file结构体表示一个已经打开的文件对象。而进程执行open系统调用,所以必须让进程和文件关联起来。每个进程控制块task_struct都有一个指针*files, 指向一张表files_struct,该表最重要的部分就是包涵一个指针数组,每个元素都是一个指向打开文件的指针!所以,本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件。我们可以参照下图进行想象:
其他常用的IO系统调用接口:
除了上面给出的系统调用接口的简单信息,大家也可以通过man指令的2号选项进行系统调用接口的文档信息查找。
系统默认打开的文件描述符
当进程要对文件进行操作时,就需要使用文件描述符,因为所有IO库函数的底层都是封装了系统调用的,使用系统调用就需要传入文件描述符。而我们又知道,Linux下一切事物都可以看做文件,也就是说:键盘和显示器也要看做文件!那么当我们在进行对键盘和显示器文件的输入输出时,也就需要文件描述符,但是在平时对键盘和显示屏做输入输出时,我们其实并没有手动的创建过文件描述符,那么这些用于操作IO设备的文件描述符是从哪里来的呢?
其实,当我们创建一个进程以后,操作系统会默认帮我们打开三个文件描述符分别占用文件描述符表的前三个位置,代表下标0、1、2(这三个数字也对应C语言中打开的三个标准输入输出的FIFE*指针stdin、stdout、stderr),这三个文件描述符分别代表的是标准输入、标准输出、标准错误输出,它们一般也对应着这三个物理设备:键盘、显示器、显示器。
其实这里我们也可以验证上层语言的库函数与系统操作的一些关系,如果我们访问C语言默认打开的三个文件指针:
printf("stdin->fd: %d\n", stdin->_fileno); printf("stdout->fd: %d\n", stdout->_fileno); printf("stderr->fd: %d\n", stderr->_fileno);
我们就会发现对应的输出结果是:
输出结果表明系统和C语言默认打开的东西产生了关联,这与我们之前所说库函数是由系统调用封装而成的观点不谋而合。
当我们掌握了这样的信息,那我们也就可以自己使用系统调用接口进行键盘读入和显示屏输出。我们可以运行一下以下的代码:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
// file descriptor: 文件描述符
int fd = open("lion", O_RDONLY, 0666);
if(fd < 0){
perror("open");
return -1;
}
//之前所输入的信息
//const char* message = "I like Linux!\n";
// 用户级缓冲区
char buf[1024] = {0};
read(fd, buf, 1023);
// 我们可以看看这两个操作的效果:
write(1, buf, strlen(buf));
printf("%s", buf);
close(fd);
return 0;
}
运行结果如下:
大家也可以使用read来读取0号文件(键盘)的信息,这里就不再演示。
文件描述符的分配规则
我们现在了解了一些文件描述符的概念和使用方式,那么当我们在一个进程中打开一个新出现的文件,这个文件的文件描述符是如何分配的呢?
我们直接使用代码进行实验:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
int fd = open("myfile", O_RDONLY);
if(fd < 0){
perror("open");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}
运行结果是得到了一个值为3的文件描述符,先前说过,系统默认缺省的占用了0、1、2三个文件描述符,这里得到的值为3,我们可以合理的猜测fd是直接按照表的顺序从上往下给的值。那么当我们再关闭了之前的文件继续试着打开新的文件:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
close(0);
//close(2);
int fd = open("myfile", O_RDONLY);
if(fd < 0){
perror("open");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}
结果是fd:0。也就是说当我们一开始就将对应键盘的文件描述符0给关闭掉,新打开的文件就直接占据了原来的0的位置。那么现在我们就可以得出结论:文件描述符的分配规则就是在files_struct数组当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符。
那么现在我们可以完善一下上面的进程控制文件的图片:
重定向
按照之前的知识,当我们让一个新文件占据显示器的文件描述符,这时候所有原本写入显示器的信息将会被写入至这个新的文件中,让我们运行代码检验一下这样的现象:
//一段伪代码:
// 关闭显示器文件,打开新的文件
close(1);
int fd = open("lion", O_WRONLY|O_CREAT, 00644);
if(fd < 0){
perror("open");
return 1;
}
printf("fd: %d\n", fd);
fflush(stdout);
close(fd);
运行结果如下:
我们将本应该输入到屏幕上的信息输入到了文件lion中,这种现象就叫做输出重定向!不知道大家是否知道命令行代码中的重定向指令,其实重定向指令就是在进程运行时将对于IO设备的输入输出改成对于某个其他文件的输入输出。
我们将上述过程以图片形式展现,如下图所示:
除此之外,让我们重新简单了解一下命令行指令中的一些重定向,这里参考chatgpt的回答:
除了上面的“通过关闭显示器文件然后用新的文件来占用显示器文件的文件fd1”所产生的输出重定向以外,我们其实主要通过别的方式来完成重定向的动作,这需要一些系统调用接口来完成,它们分别是:
- `open()`:用于打开文件。在重定向中,`open()` 被用于打开指定的文件,并返回一个文件描述符。
- `close()`:用于关闭文件。在重定向中,`close()` 被用于关闭不再需要的文件描述符。
- `dup()`和 `dup2()`:用于复制文件描述符。在重定向中,`dup()` 可以用来复制文件描述符,使得多个文件描述符指向同一个文件。`dup2()` 则可以用于将一个文件描述符复制到另一个特定的文件描述符,从而实现重定向。其中,比较常用的是dup2,我们主要了解的就是它。
使用 dup2 系统调用
在继续了解重定向中的系统调用的运作方式之前,我们首先来简单的看看dup2的文档解释。
简单来说,我们可以向dup2中输入两个文件描述符 oldfd 和 newfd ,dup2会将newfd代表的文件改变成oldfd所代表的文件,也就是说,当我们使用了dup2以后,这两个文件描述符将会同时代表原来oldfd所描述的文件。从文件描述符作为数组下标的概念上来说,就是下标本身的值不变,而将下标对应的值都改为oldfd所指向的元素值。
举例图如下:
-
输出重定向 (
>
,>>
):- 使用
open()
打开指定文件,获得一个文件描述符。 - 使用
dup2()
将标准输出的文件描述符(1)复制到新打开的文件描述符上,使得标准输出被定向到文件。
- 使用
-
输入重定向 (
<
):- 使用
open()
打开指定文件,获得一个文件描述符。 - 使用
dup2()
将标准输入的文件描述符(0)复制到新打开的文件描述符上,使得标准输入被定向到文件。
- 使用
-
错误输出重定向 (
2>
,2>>
):- 类似于输出重定向,但是文件描述符是标准错误输出的(2)。
-
将标准错误和标准输出合并 (
2>&1
):- 使用
dup2()
将标准错误输出的文件描述符(2)复制到标准输出的文件描述符上,使得标准错误和标准输出指向同一个位置。
- 使用
这些操作的组合和顺序使得在Linux系统中能够对输入输出进行高度的控制,从而实现重定向的功能。这是Linux操作系统中强大而灵活的特性之一。
加深对“一切皆文件”的理解
我们曾经提出过“Linux下一切皆文件”的观点,那么在这篇关于系统IO的博客中,这个观点又可以如何进行重申呢?
今天我们通过对于进程的文件管理进行对“一切皆文件”进行重新审视,我们知道进程可以通过文件描述符fd对文件进行访问和操作,那么便可以以此入手:
通过上图,我们可以看见在虚拟文件系统VFS(vitrual file system)对各种不同事物、外部设备的统一描述下,所谓“一切皆文件”的大致实现方式。从这以后,我们对于“一切皆文件”的理解不再只局限于此结论的应用方面,还有底层实现机制方面的理解。
研究语言层IO
用户缓冲区的概念与深刻理解
通过上面的学习我们对底层的系统调用有了一定的认识,现在重新返回到对于应用层设计的一些研究认识。
让我们先来看下面的一段代码:
const char* fstr = "hello fwrite!\n";
const char* str = "hello write!\n";
printf("hello printf!\n");
fprintf(stdout,"hello printf!\n");
fwrite(fstr,strlen(fstr),1,stdout);
write(1,str, strlen(str));
这段代码中调用了三个C语言库中的输出函数和一个系统调用接口write,并将一些字符输入到了屏幕上面,我们将其运行起来:
结果不出所料,毕竟从刚刚学习编程的时候开始,我们就知道程序中的语句按照从上到下的顺序执行。那么我们现在想要将这里的输出全部都重定向到一个文件中:
这是怎么回事?为什么程序的最后一个输出语句反而第一个写入到了文件中呢?我想大家的脑海中应该会冒出这样的问题,那么我们现在就正式引入用户缓冲区的知识。
当我们使用C语言提供的输出函数向一些文件进行输出打印时,其实字符串首先并不是直接被输出到了对应的文件中,而是暂时被存在了一个C语言为我们提供的缓冲区中,这个缓冲区有两种缓冲方式,分别是行缓冲和全缓冲。(除此之外,系统调用接口write不受C语言的管束,只要使用者规定了向什么文件中写入,我们可以看做它会直接向其中进行写入操作。)
行缓冲我们曾经在编写进度条程序的时候遇到过,它的意思是缓冲区会一直不刷新直到有换行符被写入到缓冲区中,缓冲区才会被刷新。我们知道缓冲区对于显示器文件的刷新方式就是行缓冲,而当时我们编写进度条的时候并不想换行,于是采用了fflush函数直接刷新缓冲区中的数据至显示器文件中。
全缓冲是缓冲区对于除了显示器文件以外的其他普通文件的刷新方式,这种方式是只有当缓冲区被写满时才会进行刷新。
除此之外,缓冲区的刷新的本质可以看做调用了系统调用接口write,将缓冲区的数据直接写入到某个文件中。从中我们可以看出缓冲区的设计就是一种对于系统调用的封装和规范。
引入缓冲区的知识以后,让我们将目光重新放回到上面的两种不同输出情况,我们大致可以猜出造成运行结果不同的原因跟写入文件的不同缓冲方式有关。
第一次操作中,由于我们直接向显示器中打印信息,缓冲区刷新模式是行缓冲,于是所有的输出语句都是当被写入到缓冲区之后就直接刷新到了显示器上。而第二次操作进行了输出重定向,将对显示器文件的输出重定向为向普通文件的输出,于是缓冲区的刷新方式便由行缓冲改变成了全缓冲,那么你肯定就有这样的疑问了?难道是这三条字符串将缓冲区给写满了吗?答案并不是这样,毕竟如果是这样,也不会造成这两种结果输出顺序上的不一致。那么这三条字符串是怎么被刷新到屏幕上的呢?让我们继续对此进行研究。
我们在原有代码的基础上加上一个fork函数,创建出一个子进程:
const char* fstr = "hello fwrite!\n";
const char* str = "hello write!\n";
printf("hello printf!\n");
fprintf(stdout,"hello printf!\n");
fwrite(fstr,strlen(fstr),1,stdout);
write(1,str, strlen(str));
fork();
并对这段程序进行上面两种情况的输出:
我们发现第二种情况非常特殊,不仅C接口的输出在write之后,其中的字符串还被打印了两遍。对比这两段代码,我们可以猜测这里的不同一定与子进程中的某个动作有关,多出的三行打印应该就是子进程造成的。而我们看到fork以后我们并没有主观上的对其做任何的操作,那么子进程之后还做了什么样的动作呢?很显然,子进程最后直接退出了,毕竟这时除了退出已经没有任何的动作。
现在便揭晓特殊情况的原因,那就是除了缓冲区本身的刷新规则以外,当一个进程退出时,缓冲区也会进行一次刷新。(这里具体的实现可以看做C语言进程调用了退出函数exit(),而exit()由单纯执行结束进程功能的系统调用_exit()和刷新缓冲区的函数fflush()组合而成)
这样我们便可以分析上面种种情况的成因:我们将四个输出分别编号为1~4。2号输出的成因:首先直接执行write的输出,然后当进程退出时,缓冲区刷新,由于全缓冲而暂时存在于缓冲区的数据最后被刷新到了屏幕上。4号成因,除二号相同的过程以外,子进程会复制一份于父进程相同的缓冲区,然后当最后退出时刷新到显示器上。
至此上面的四种情况以及C语言提供的缓冲区规则已经基本分析完毕。缓冲区的规则我们大致清楚了,现在来看看它本身的一些性质。
1.为什么要有缓冲区呢?第一个是缓冲区可以提高用户层输出的效率。当我们直接使用系统调用将数据传输到外设上时对比在内存中的读取是比较慢的,那么我们如果多次直接调用系统调用来进行输出就会造成很多的资源浪费,这就像是山上的蓄水池和山脚下的溪流一样,我们将许多将要输出的数据存起来一次性进行输出会提高输出效率。
第二个是可以配合格式化,什么是格式化呢?我们使用C语言printf时肯定听说过格式化输出,格式化操作也就是将本身没有特殊意义的各种字符赋予不同的意义、进行不同的处理,比如字符串输出,我们就以'\0'作为输出的结尾。打比方说,我们输出一个数字,就要用%d来确定它是以整数形式进行输出的,但如果我们都直接使用系统调用接口进行输出,那也就没有什么类型差异了,输出的都是字符,这不利于我们对于数据的处理。
2.缓冲区存在于哪里?缓冲区本质上的作用是C语言设计出来要对文件的输入输出做缓冲,那么它就不可避免的与C语言的文件结构体FIFE扯上关系。事实上,一个FIFE对应的文件的缓冲区的相关字段和维护信息就是被存储在FIFE本身中。FIFE本身就是需要占用一段空间的,当我们调用fopen打开一个文件的过程就是在语言层为我们malloc出一个空间用于存放FIFE对象并初始化对应的信息(文件fd、缓冲区字段等)。
3.缓冲区属于用户本身,不属于操作系统。在语言层面上的所有东西都是属于用户的,缓冲区是属于用户,FIFE对象也是属于用户的。
🍀结语
今天的博客就分享到这,谢谢大家!