深入了解linux系统—— 基础IO(下)
前言
在基础IO(上)中,我们了解了文件相关的系统调用;以及文件描述符是什么,和操作系统是如何将被打开的文件管理起来的。
本篇文章来继续学习文件相关的知识
重定向
在了解重定向之前,我们先来看这样的一段代码:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{ close(0); close(1); close(2);int fd1 = open("log1.txt",O_CREAT | O_WRONLY | O_TRUNC,0666); int fd2 = open("log2.txt",O_CREAT | O_WRONLY | O_TRUNC,0666); int fd3 = open("log3.txt",O_CREAT | O_WRONLY | O_TRUNC,0666);printf("fd1 : %d\n",fd1); printf("fd2 : %d\n",fd2); printf("fd3 : %d\n",fd3); return 0;
}
通过观察我们可以发现,我们关闭了0
、1
、2
(也就是标准输入、标准输出、标准错误);
然后打开了三个文件,这三个文件的文件描述符就是0
、1
、2
。
我们看到的现象就是:
printf
并没有将数据输出到显示器文件中,而是将数据输出到了文件描述符1
所对应的文件中。
我们大致可以理解:
在系统层面:文件描述符
0
所对应的文件就是标准输入、文件描述符1
所对应的文件就是标准输出、文件描述符2
所对应的文件就是标准错误。
重定向是什么
相信对于重定向肯定没有那么陌生;
重定向又分为输出重定向>
、追加重定向>>
和输入重定向<
。
对于追加重定向和输入重定向这里就不演示了;
简单来说:在这里重定向就是修改程序原来的输入/输出文件
重定向的原理
知道什么是重定向,那重定向是如何实现的呢?
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main()
{ close(1); int fd = open("log.txt",O_CREAT | O_WRONLY | O_TRUNC, 0666); if(fd < 0) return -1; printf("hello world"); return 0;
}
在上述代码中我们将文件描述符1
对应的文件(也就是显示器文件)关了,再打开文件log.txt
,它的文件描述符就是1
;
此时再向标准输出中输出数据,我们就会发现数据没有输出到我们的显示器文件中,而是输出到我们文件描述符1
对应的文件log.txt
文件中。
那这不就是一种重定向吗!
原本要输出到显示器文件中的数据,我们让他输出到指定文件中,这不就是输出重定向吗!!!
这样我们通过代码理解了重定向,已经它是如何实现的;但是这样关闭文件再打开文件,这操作未免也有点太挫了吧。有没有其他方法
系统调用dup2
当然除了dup2
还存在系统调用dup
和dup3
;这里主要看dup2
。
通过参数我们可以发现dup2
存在两个参数:oldfd
和newfd
那dup2
是做什么的呢?
dup2
的作用就是使用oldfd
位置的值去覆盖newfd
位置的值,这样做来达到修改file_array
数组中的指针指向。
通过查看man
手册也可以发现:让newfd
变成oldfd
的拷贝,有必要的话就先关闭newfd
指向的文件。
int main()
{ int fd = open("log.txt",O_CREAT | O_WRONLY | O_TRUNC,0666); if(fd < 1) return -1; printf("hello\n"); dup2(fd,1); printf("hello\n"); return 0;
}
实现重定向只需要修改0/1/2
文件描述符的内容即可。
重定向的本质
在上述中,我们了解了通过代码去实现重定向操作,但是在系统层面,dup2
做了什么呢?
又是如何支持重定向的呢?
我们知道每一个进程都有自己的文件描述符表,这个文件描述符表中存在一个数组file_array
存放着进程打开的文件。
我们还知道,0
指向的是标准输入、1
指向标准输入、2
指向标准错误;
那输出重定向的本质是什么呢?
这个就比较简单了,其本质就是将我们新的文件的地址,覆盖式的填充到文件描述符
1
的位置。那这样原本要输出到显示器文件的内容,就不再输出到显示器文件了,而是输出到了新的文件中。
所以,究其根本重定向就是修改了fd_array
数组中的指针指向。
理解一切皆文件
我们知道:
Linux
下一切皆文件;在
windows
中是文件的东西,在Linux
下也是文件;一些在windows
中不是文件的东西(磁盘,显示器,键盘等)这样的硬件硬件设备在Linux
中也被抽象成了文件;这样可以使用访问文件的方法去访问这些硬件。
这样将一切都抽象为文件,都可以看做是文件;我们就只需一套API
和开发工具就可以调取Linux
中的绝大部分资源。
在Linux
操作系统中几乎所有(读文件,读系统状态,读PIPE
)读操作都可以使用read
函数来进行;几乎所有修改(修改文件,修改系统参数,写PIPE
)操作都可以使用write
函数进行。
一直在说Linux
下一切皆文件,那它到底是什么,应该如何去理解呢?
我们知道操作系统是一款管理软硬件资源的软件,那操作系统是不是也要将硬件管理起来呢?
肯定要将硬件资源管理起来的,如何管理呢?先描述,后组织
在Linux
操作系统内核当中,存在一个struct device
,它描述了硬件设备的相关信息;然后将这些硬件设备对应的struct device
管理起来即可(将所有的struct device
放入到一个链表当中)。
我们再想一下,我们对应硬件设备的访问,无非就是读和写,也就是I/O
,那我们只需知道啊硬件设备如何进行I/O
操作不就可硬件设备统一读写操作吗
在我们的
struct file
中还存在着两个函数指针write
和read
;它们就表示了如何对设备进行读写操作。对于不同的设备,它们有不同的读写方式;但是我们进行读写操作时,不关心你是如何读写的。只需要拿到你的读写方法
write
和read
即可。
这样就算文件有文件的读写方法,磁盘有磁盘的读写方法,键盘有键盘的读写方法;我们不关心如何去读写操作,只需拿到其对应的write
和read
;然后通过调用write
和read
就可以统一I/O
操作。
此外,我们知道我们访问资源,本质上是进程去访问资源,那进程访问资源,通过write
和read
就可以访问任何软硬件资源
所以
Linux
下一切皆文件本质上就是,进程在访问一切资源时都可以想访问文件那样去访问;这样一切资源在进程看来都是文件了。
缓冲区
是什么
缓冲区是内存空间的一部分;简单来说就是在内存中预留一定的存储空间,这些存储空间用来缓存输入/输出数据。
缓冲区又分为输入缓冲区和输出缓冲区(根据其对应的设备是输入设备还是输出设备决定)。
为什么
为什么要存在缓冲区呢,我们直接通过系统调用进行读写操作不行吗?
我们要知道,如果没有缓冲区,我们直接通过系统调用进行读写操作;这样我们每次读写时都要通过系统调用来处理此操作;而我们的系统调用也是有成本的。
每执行一次系统调用,都要涉及到
CPU
状态的转换(从用户空间切换到内核空间,实现进程上下文切换),要损耗一定的时间;如果我们频繁访问磁盘对程序的执行效率影响还是很大的。
所以为了减少使用系统调用,提高程序运行的效率,就采取缓冲区机制;
这样在对磁盘文件操作时,就会从文件中读取大量数据到文件缓冲区当中,这样我们就可以先对缓冲区的数据操作,操作完(读取完缓冲区内容)再读取/写入磁盘;这样我们就减少了磁盘的读写次数。所以我们缓冲区的存在就是为了提供计算机的运行速度。
语言级缓冲区和文件缓冲区
我们之前了解的缓冲区,它是语言层提供给我们的缓冲区;
而文件缓冲区是操作系统在打开文件时会给被打开的文件分配一块缓冲区。
int main()
{printf("hello\n");const char* msg = "abcdef";write(1,msg,strlen(msg));close(1);return 0;
}
int main()
{printf("hello");const char* msg = "abcdef";write(1,msg,strlen(msg));close(1);return 0;
}
上面两段代码,唯一区别就是
printf
在输出时一个输出了\n
,一个没有输出\n
。我们在程序的末尾关闭了
1
文件(显示器文件)可以看到,输出\n
,内容就输出到了显示器文件上;而不输出\n
,最终内容没有输出到显示器文件上。而我们使用
write
系统调用,就算显示器文件被关闭了,内容还是可以写到显示器文件中。
我们知道,C
语言库函数都是封装了系统调用,那使用write
系统的调用就可以把内容写到文件中,而库函数却没有;
这也证实了语言层为我们提供了缓冲区。
而又因为标准输出是行缓冲,遇到换行就会调用系统调用,将缓冲区内容写入到显示器文件的缓冲区中;
所以
printf
输出带\n
时,内容就会刷新到文件缓冲区当中。
而我们这里探讨的主要是语言层的缓冲区;而文件缓冲区暂时不研究
我们认为:数据写入到文件缓冲区中就相当于写入到了文件当中。(文件缓冲区刷新是操作系统做的事情)
缓冲类型
缓冲类型,也就是缓冲区什么时候刷新
全缓冲区:这种缓冲方式当整个缓冲区被写满时,才会进行
I/O
系统调用操作。一般情况下对于磁盘文件(普通文件)的操作都是使用全缓冲的方式。
行缓冲:当输入输出中遇到了换行符,就会执行系统调用操作。(缓冲区满了,没有遇到换行符也会刷新;缓冲区大小默认是
1024
)无缓冲:也就是立即刷新,不对字符进行缓存,直接调用系统调用。
标准错误
stderr
通过就是无缓冲的,这样错误信息能够立即刷新出来。
缓冲区刷新
那我们知道了存在语言层的缓冲区,那这一缓冲区内容什么时候被写入到文件缓冲区中呢?
- 强制刷新;如
fflush
。- 当刷新条件满足(全缓冲、行缓冲、无缓冲)。
- 进程退出
我们先来看以下代码:
int main()
{ const char* msg1 = "hello printf\n"; const char* msg2 = "hello fprintf\n"; const char* msg3 = "hello write\n";printf("%s",msg1); fprintf(stdout,"%s",msg2); write(1,msg3,strlen(msg3));fork(); return 0;
}
这段代码还是非常好理解的,printf
、fprintf
都向标准输出中输出内容;write
向1
文件中输出内容。
此时都是
printf
和fprintf
向显示器中写入是行缓冲,遇到\n
就会刷新缓冲区;所以在程序执行到
fork
时,父进程的缓冲区中是没有内容的,创建的子进程缓冲区是拷贝父进程的,也是没有内容的。
我们尝试往普通文件中写入:(也就是程序运行的结果我们重定向到其他文件中)
通过对比我们发现,往显示器文件写入时,write
、printf
和fprintf
都输出了一次;
而重定向输出到普通文件中write
输出了一次,printf
和fprintf
输出了两次(准确来说是刷新了两次缓冲区)。
原因:
我们知道
printf
和fprintf
向显示器中写入是行缓冲,所以printf
和fprintf
输出的内容会按行刷新到显示器文件缓冲区中;而write
是直接写入到显示器文件的缓冲区中;所以当往显示器文件写入时,程序运行到
fork
时,父进程的缓冲区是空的;(子进程的缓冲区来源于父进程)子进程退出时会刷新缓冲区,但是缓冲区是空的。而向普通文件写入时,就是全缓冲(也就是缓冲区满才刷新);程序运行到
fork
时,printf
和fprintf
输出的内容还在缓冲区当中,所以子进程退出时会刷新一遍缓冲区,而父进程退出时也会刷新一遍缓冲区;这样printf
和fprintf
输出的内容就会被刷新两次,也就是输出两次。而write
是将内容直接写入到文件的缓冲区当中,所以只输出了一次。
简单设计libc
库
我们知道在C
语言层面,我们使用文件操作并不是直接使用的文件描述符,而是使用FILE*
文件指针。
那FILE
是什么呢?
我们知道C
语言库函数底层肯定是封装了系统调用的;而系统调用对于文件的操作只认文件描述符,所以FILE
中肯定要包含文件描述符。
除此之外呢,也要存在其他信息,比如:缓冲区刷新方式,缓冲区,文件大小等等。
我们这里简单设计一下FILE
,模拟实现一下文件操作函数:
首先先来看一下,MYFILE中包含哪些内容,以及要实现哪些函数:
#pragma once
#define MAX 1024 //缓冲区大小
#define NONE_FLUSH 001 //0001
#define LINE_FLUSH 002 //0010
#define FULL_FLUSH 004 //0100
typedef struct IO_FILE{int fileno;//文件描述符int flag; char outbuff[MAX]; //缓冲区 int bufflen; //缓冲区内容长度 int flush_buff;
}MYFILE;
MYFILE* MyOpen(const char* pathname, const char* mode);
void MyClose(MYFILE* file);
int MyWrite(MYFILE* file, void* str, int len);
void MyFlush(MYFILE* file);
首先看
MYFILE
,其中包含文件描述符fileno
,文件打开方式flag
,缓冲区outbuff
,缓冲区数据个数bufflen
,缓冲区刷新方式flush_buff
。然后我们简单实现打开文件
MyOpen
,关闭文件MyClose
,文件写入MyWrite
,刷新缓冲区MyFlush
。
打开文件MyOpen
这个实现起来相对是比较简单的了;
不过我们要注意文件的打开方式
mode
,文件缓冲区的初始化。
MYFILE* BuyFile(int fd, int flag)
{MYFILE* myfile = (MYFILE*)malloc(sizeof(MYFILE));myfile->fileno = fd;myfile->flag = flag;myfile->bufflen = 0;myfile->flush_buff = LINE_FLUSH; //初始化缓冲区 memset(myfile->outbuff, 0, sizeof(myfile->outbuff)); return myfile;
}
MYFILE* MyOpen(const char* pathname, const char* mode)
{ //确定文件的打开方式 int fd = -1; int flag = 0; if(strcmp(mode, "w") == 0) { flag = O_CREAT | O_WRONLY | O_TRUNC; fd = open(pathname, flag, 0666); } else if(strcmp(mode, "a") == 0) { flag = O_CREAT | O_WRONLY | O_APPEND; fd = open(pathname, flag, 0666); } else if(strcmp(mode, "r")) { flag = O_RDONLY; fd = open(pathname, flag); } else{ //??? } if(fd < 0) return NULL;//打开文件失败 return BuyFile(fd,flag);
}
刷新缓冲区MyFlush
这里我们先看文件缓冲区的刷新,因为我们关闭文件时也要刷新缓冲区,写入时也要判断是否刷新缓冲区
这里实现的是缓冲区的行刷新
这里缓冲区刷新,我们不仅可以把缓冲区内容写入到文件缓冲区中,还可以使用fsync
函数做一下数据同步。
fsync
函数的主要作用就是将文件描述符引用的文件的所有修改后的核心数据传输到磁盘设备,这样确保即使系统崩溃或者重新启动,所有更改的信息也能保存到磁盘中。
void MyFlush(MYFILE* file)
{ if(file->bufflen <= 0) return;write(file->fileno, file->outbuff, file->bufflen);file->bufflen = 0;fsync(file->fileno);
}
文件写入MyWrite
文件写入实现起来也是非常简单的,我们要做的就是将要写入的内容,先拷贝到缓冲区当中;然后再判断是否需要刷新缓冲区即可。
int MyWrite(MYFILE* file, void* str, int len)
{ //将数据拷贝到缓冲区当中 //防止缓冲区溢出if(file->bufflen + len > MAX) MyFlush(file); memcpy(file->outbuff + file->bufflen,str,len); file->bufflen += len; if(file->flush_buff & NONE_FLUSH)//无缓冲 MyFlush(file); else if((file->flush_buff & LINE_FLUSH) && (file->outbuff[file->bufflen-1] == '\n' || file->bufflen == MAX))MyFlush(file); else if((file->flush_buff & FULL_FLUSH) && (file->bufflen == MAX))//全缓冲 MyFlush(file); return 0;
}
关闭文件MyClose
在关闭文件之前,我们需要先刷新一下缓冲区,将缓冲区内容写入到文件缓冲区当中。
void MyFlush(MYFILE* file)
{ if(file->bufflen <= 0) return; write(file->fileno, file->outbuff, file->bufflen); file->bufflen = 0; fsync(file->fileno);
}