重定向与缓冲区:C语言IO的奥秘(模拟封装glibc)
目录
重谈重定向
理解"一切皆文件"
缓冲区
什么是缓冲区?
为什么要引入缓冲区机制?
细节
模拟封装glibc->文件接口
源码
重谈重定向
重点标准错误
C语言C++都有自己的标准输入输出
./a.out > log.txt
实质上就是shell获得命令行字符串,先做分割,判断是输出重定向,确定重定向的文件,一方面重定向内容,目标文件信息保存起来,一方面未来执行fork,再进行子进程程序替换之前,先进行输出重定向
会把3号文件中的内容,覆盖到1号里面,而1不再进行标准输出,反而指向我们的file,而上层的代,向标准输出进行打印,只认文件描述符,所以最后找到1,向文件中打印
其实重定向的完整写法是
./a.out 1 > log.txt
这种写法,可以做到见名知意,就是把1重定向到新文件log.txt,但是我们平常会把1省略,默认就是向标准输出进行输出。
我们再加上向标准错误打印的代码,我们stderr,cerr是向2号打印,而标准输出和标准错误对应的都是显示器,相当于显示器文件被我们进程打开了两次
但是我们执行重定向命令时,却产生了问题
为什么我们的标准输出写到了文件里,而标准错误 却还依旧在显示器进行打印呢?
原因是因为我们在进行输出的时候,虽然我们的标准输出和标准错误都指向同一个文件,但当我进行重定向时,它的本质是把1重定向到新文件,即把新打开的文件描述符的struct_file地址拷贝到1里面,所以我们在这里重定向时,只是把1进行重定向,而2还是正常向显示器里打印
所以当我们在进行输出重定向时,为什么它只会把标准输出往log.txt中打印,因为我们只做了的标准输出的重定向,没有做标准错误的重定向,那如何对标准错误进行重定向?
./a.out 1 > log.normal 2 > log.error
所以为什么我们的程序存在标准输出和标准输入,还存在一个标准错误呢?
因为把标准输出和标准错误做分离,其实本质上占用是不同的文件描述符,虽然都对应的显示器,但是未来我们可以通过重定向能力,把常规消息和错误消息进行分离
所以在我们的任何语言里,提供标准错误,就是为了让我们用重定向的能力,方便我们日志的形成
如果我们把stderr和stdout打印到同一个文件呢?
我们发现最后文件中只有标准错误
这是因为第一次重定向把标准输出打印到文件里,第二次重定向把文件内容清空,再打印标准错误
所以我们只要用追加重定向,就能看到标准输出和标准错误了
除此之外,我们还有一种写法
前面的意思就是把log.txt打开 ,然后把3的内容覆盖到1里面
后面的取地址符&,&1是shell的语法,把1里面的内容写到2里面
读写位置
无论是文本文件,还是二进制文件,在我看来就是一个char类型的数组,只不过char类型数组,连起来就是我们的文本文件,也可能是8个bit位的二进制流,所以读写一个文件,本质是读写一个文件的开始位置以及你的偏移量,所以要有f_pos来标明当前文件的读写位置,又因为文件的读写位置是一个整数,只有一个,所以当读写同时进行时,你刚写入的内容,你想再读上来,需要更改文件读写位置,即f_pos,将其回归到最开始。
内存
操作系统管理内存的时候,也不是把内存整体使用的,而是把操作系统的空间划分为块为单位的,以4KB的大小,划分为若干代码块,所以在我们看来,一个4GB的内存,是由4GB/4KB个数据页构成的,所以在操作系统内,一定会存在很多很多的内存块,有的是正在占用的,有的是正在清理的,所以操作系统一定要管理这些内存块,怎么管理?
先描述再组织,所以在操作系统中一定存在描述内存的结构体,这个我们先提一嘴,在后续章节再讲
其实file结构体是操作系统内打开的文件,但是我们文件相关的硬属性,并没有直接在file里存,而是在另一个数据结构inode中存,这个我们在下一章文件系统学习,换句话说,我们可以通过file结构体,间接找到文件的属性和指定的文件缓冲区
理解"一切皆文件"
首先,在windows中是文件的东西,它们在linux中也是文件;其次一些在windows中不是文件的东西,比如进程、磁盘、显示器、键盘这样硬件设备也被抽象成了文件,你可以使用访问文件的方法访问它们获得信息;甚至管道,也是文件;将来我们要学习网络编程中的socket(套接字)这样的东西,使用的接口跟文件接口也是一致的。
这样做最明显的好处是,开发者仅需要使用一套 API 和开发工具,即可调取 Linux 系统中绝大部分的资源。举个简单的例子,Linux 中几乎所有读(读文件,读系统状态,读PIPE)的操作都可以用 read 函数来进行;几乎所有更改(更改文件,更改系统参数,写 PIPE)的操作都可以用 write 函数来进行。
通过函数指针访问,上层就不需要关心底层是键盘,显示器,还是鼠标,它只要调用write,read函数指针直接访问,就屏蔽了底层的硬件差异,struct file往上就是进程,骗了进程就是骗了用户,因为当前进程都是通过struct file(文件描述符)来访问所有的文件,所以只要把进程骗过去,让进程认为一切皆文件,就屏蔽了底层差异,所以我们把struct file 往上叫做一切皆文件
所以我们对虚拟文件系统,一切皆文件的理解就是在系统当中访问任何设备,只要它提供文件描述符,我就可以不再关心底层文件的差异,而直接使用struct file里的函数指针对文件进行访问。
上图中的外设,每个设备都可以有自己的read、write,但一定是对应着不同的操作方法!!但通过struct file下file_operation中的各种函数回调,让我们开发者只用file便可调取Linux系统中绝大部分的资源!!这便是“linux下一切皆文件”的核心理解。
访问不同设备的函数指针,如下
缓冲区
什么是缓冲区?
缓冲区就相当于我们现在的菜鸟驿站,别人给你寄快递的时候,你当前可能有事,来不及处理这个包裹,所以快递员就把你的包裹放到了楼下的菜鸟驿站中,给你缓冲起来,将来你取快递,只要去菜鸟驿站取就可以了。
更重要的是,如果你是快递员,你今天去打电话送快递,但是这个人根本就不在,那么你就只能等,等待的过程过长,今天就发不了几个快递,但是有菜鸟驿站就不一样了,它就不需要等任何人,只要把包裹放到菜鸟驿站就可以了,提高了快递员的快递效率,也方便了用户。
快递员就是操作系统,你就是用户,而菜鸟驿站就算是缓冲区
缓冲区是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区。
缓冲区分为用户级缓冲区和操作系统缓冲区,自己定义的缓冲区,都是内存的一段空间
为什么要引入缓冲区机制?
提高使用者的效率
读写文件时,如果不开辟对文件操作的缓冲区,直接通过系统调用对磁盘进行操作(读、写等),那么每次对文件进行一次读写操作时,都需要使用读写系统调用来处理此操作,即需要执行一次系统调用,执行一次系统调用将涉及到CPU状态的切换,即从用户空间切换到内核空间,实现进程上下文的切换,这将损耗一定的CPU时间,频繁的磁盘访问对程序的执行效率造成很大的影响。
为了减少使用系统调用的次数,提高效率,我们可以采用缓冲机制。比如从磁盘里取信息时,在对磁盘文件进行操作时,可以一次从文件中读出大量的数据到缓冲区中,以后对这部分的访问就不需要再使用系统调用了,等缓冲区的数据取完后再去磁盘中读取,这样就可以减少磁盘的读写次数。再加上计算机对缓冲区的操作大大快于对磁盘的操作,故应用缓冲区可大大提高计算机的运行速度。
又比如,使用打印机打印文档时,由于打印机的打印速度相对较慢,我们先把文档输出到打印机相应的缓冲区,打印机再自行逐步打印,这时CPU可以处理别的事情。可以看出,缓冲区就是一块内存区,它用在输入输出设备和CPU之间,用来缓存数据。它使得低速的输入输出设备和高速的CPU能够协调工作,避免低速的输入输出设备占用CPU,解放出CPU,使其能够高效率工作。
细节
我们来看一个现象
为什么会这样?
所以为什么关闭fd,就没法将printf的函数写入到文件中呢?
因为当你printf所输出的所有字符串,其实是没有写到操作系统内的,它处于语言层的缓冲区里
当我调用close代码的时候,进程也没有退出,它既没有强制刷新也没有满足刷新条件,进程也没有退出,所以数据会一直在语言层的缓冲区中,后来我们close了fd,进程退出了,C语言要刷新缓冲区,但是fd已经关掉了,所以无法将数据从语言层交付到操作系统内,因此数据也不可能被刷新到某种硬件上,我们也就看不到对应的文件内容。
怎么保证能刷新呢?
很简单 fflush(),调用刷新函数
这个时候,我们文件缓冲区的内容就显示到文件中了
结论就是FILE里会存在文件描述符fd,和缓冲区
什么叫做格式化输出?
本质上是把%d,%f这些格式化输出成字符串,然后将其放到C语言的缓冲区中
细节2
刷新条件是什么意思?
1.立即刷新 --- 无缓冲 --- 写透模式 WT
2.缓冲区满了,再刷新 --- 全缓冲
3.行刷新 --- 行缓冲
系统调用是有成本的,如果频繁调用,程序的效率会变低
要提高效率,就在语言层提供缓冲区,目的在于减少系统调用次数,就大大提高了C标准库发fputs/fprintf/fwrite等接口效率,因为它不需要过多的系统调用。
是不是写满缓冲区,再刷新,即全缓冲的效率最高?
是的,因为这样系统调用次数少
但是全缓冲一般用于普通文件
行刷新一般用于显示器,为什么,因为这样比较适合用户,它如果用全缓冲,一次刷新一大片内容,对用户的体验不好
上面这三种刷新方式,操作系统也在用,但是操作系统具体什么时候刷新,要不要强制刷新,我们不关心,由操作系统自主决定。只要把数据交给了OS,就相当于交给了硬件!
数据交给系统,交给硬件,本质上全是拷贝
计算机数据流动的本质:一切皆拷贝!!!
我们的第一份代码向显示器和文件打印的时候都是四条消息
为什么我们的第二个代码,执行的向显示器打印是四条消息,而向文件中打印是七条消息
我们发现库函数调用了两次,系统函数只调用了一次
原因是因为,当我在fork时,对应的三条库函数还在缓冲区里,最后子进程结束刷新一次,父进程结束刷新一次,一共刷新两次
而系统函数执行到该语句时,直接就写给操作系统了,fork时就不存在刷新问题了,所以只有一条
./a.out //向显示器刷新 行缓冲
./a.out > log.txt 向文件刷新 全缓冲
重定向不只是重定向,还改变了刷新方式
模拟封装glibc->文件接口
首先我们需要一个结构体来描述file
需要包含文件描述符,打开文件的方式,大小,有效元素长度,刷新方式
然后我们实现自己的fopen,fclose,fwrite,fflush,封装对应的系统调用函数就能完成
源码
mystdio.h
#pragma once#include <stdio.h>#define MAX 1024
#define NONE_FLUSH (1<<0)
#define LINE_FLUSH (1<<1)
#define FULL_FLUSH (1<<2)typedef struct IO_FILE
{int fileno;int flag;char outbuffer[MAX];int bufferlen;int flush_method;
}MyFile;MyFile *MyFopen(const char *path, const char *mode);
void MyFclose(MyFile *);
int MyFwrite(MyFile *, void *str, int len);
void MyFFlush(MyFile *);
mystdio.c
#include "mystdio.h"
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>static MyFile *BuyFile(int fd, int flag)
{MyFile *f = (MyFile*)malloc(sizeof(MyFile));if(f == NULL) return NULL;f->bufferlen = 0;f->fileno = fd;f->flag = flag;f->flush_method = LINE_FLUSH;memset(f->outbuffer, 0, sizeof(f->outbuffer));return f;
}MyFile *MyFopen(const char *path, const char *mode)
{int fd = -1;int flag = 0;if(strcmp(mode, "w") == 0){flag = O_CREAT | O_WRONLY | O_TRUNC;fd = open(path, flag, 0666);}else if(strcmp(mode, "a") == 0){flag = O_CREAT | O_WRONLY | O_APPEND;fd = open(path, flag, 0666);}else if(strcmp(mode, "r") == 0){flag = O_RDWR;fd = open(path, flag);}else{//TODO}if(fd < 0) return NULL;return BuyFile(fd, flag);
}
void MyFclose(MyFile *file)
{if(file->fileno < 0) return;MyFFlush(file);close(file->fileno);free(file);
}
int MyFwrite(MyFile *file, void *str, int len)
{// 1. 拷贝memcpy(file->outbuffer+file->bufferlen, str, len);file->bufferlen += len;// 2. 尝试判断是否满足刷新条件!if((file->flush_method & LINE_FLUSH) && file->outbuffer[file->bufferlen-1] == '\n'){MyFFlush(file);}return 0;
}
void MyFFlush(MyFile *file)
{if(file->bufferlen <= 0) return;// 把数据从用户拷贝到内核文件缓冲区中int n = write(file->fileno, file->outbuffer, file->bufferlen);(void)n;fsync(file->fileno);file->bufferlen = 0;
}