引言
文件,是Linux系统中最重要的角色之一,是Linux系统的底层逻辑,‘>’、‘>>’这些符号你可能已经用过无数次,但你可曾有想过,为什么?为什么重定向能改变数据的去向?文件描述符是什么?Shell在背后敲敲做了什么?这一篇文章,我们不止满足于会用,更要揭开文件系统神秘的面纱,从最基础的I/O开始。
1、简单了解文件
我们最熟悉的文件,是磁盘上那些有名字、有内容的东西。它们占据存储空间,可以被打开、读取、修改、删除。磁盘是我们常说的外设,它既是输出设备也是输入设备,对文件的所有操作,本质上都是对外设的输入和输出,简称I/O。
从操作系统的角度理解文件操作。访问文件,需要先打开文件,谁打开文件?是进程打开的文件,文件操作的本质是进程对文件的操作。文件的读写本质不是通过我们之前使用的C/C++的库函数来操作的,而是通过文件相关的系统调用来实现的,之前那些库函数能起作用的原因是它们封装了底层操作系统的文件系统调用。
2、系统文件I/O
在之前的文章中,我们曾经有简单提到过三个输入输出流,分别是stdin、stdout、stderr,它们分别对应:stdin,标准输入,键盘文件;stdout,标准输出,显示器文件;stderr,标准错误,显示器文件。这三个文件是我们的程序每次执行时编译器默认帮我们打开的,在后面的内容中它们三个还要出现。
open(系统调用)

open是Linux系统里的一个系统调用,它的作用是打开一个文件,pathname表示要打开文件的名字;flags是标记位;mode表示权限位。
flags,标记位,表示用什么方式打开文件,常见的标记选项有O_RDONLY(只读),O_WRONLY(只写),O_RDWR(读写),O_CREAT(若文件不存在就创建),O_APPEND(追加),O_TRUNC(清空)。标记位是怎么起作用的呢?每一个标记位都是一个位图结构的整形值,不同的标记位会在不同的比特位上显示1,其余位置都为零,这样当我们在使用时,只要用按位或符号 ‘ | ’ 将多个不同的标记位链接起来,就能同时实现多个标记位的效果。
mode,权限位,和我们在权限那一篇文章中学习的一样,它表示如果要打开的文件不存在,需要新建文件时,新建文件的起始权限,传参形式也和我们之前讲的权限码相同,一个四位数的八进制数,第一位为0表示为八进制数,后三位分别表示拥有者,所属组和其他用户的权限等级,需要注意的是用这种方式新建文件时,权限掩码也会生效。
open这个系统调用的返回值为一个整形,在Linux系统中它被称为文件描述符,在后续我们想对打开的文件进行读写操作时,都要通过传文件描述符来进行,返回-1则表示打开文件失败。除了open之外,Linux系统中还要read、write、close等系统调用,分别表示读文件,写文件和关闭文件。
fopen
在曾经学习C语言时,我们曾使用过fopen等函数来实现对文件的操作,这一系列函数都是封装了open、write、read、close这些系统调用。此外,相信很多人都还记得fopen函数的返回值类型为FILE*类型,这个返回类型是什么呢?FILE实际是C语言提供的一个结构体,里面封装了这个文件的各种信息,包括文件描述符。
为什么要在语言层进行这样的封装呢?因为每个操作系统中对应的各种文件接口都是不一样的,而一个语言为了能在多个操作系统,多个平台中都能使用,往往在库里面就会把所有常见的平台的接口全部封装起来,并通过条件编译的方式,在哪个操作系统就使用哪个操作系统的对应接口,这样才能保证一份代码可以在不同的操作系统里实现一样的效果,这就是代码的可移植性问题。
文件描述符
上文我们提到多次的文件描述符到底是什么呢?其实文件描述符本身并不复杂,它就是数组的下标。
当我们在程序中要打开一个文件时,操作系统就要为这个文件创建一个数据结构体struct file,里面存储着文件的各种属性,包括权限码、读写位置、读写选项、缓冲区等,如果今天要打开很多个文件,每个文件都有一个struct file与磁盘上的文件对应,每个文件之间以链表形式链接起来,就像我们之前所说的进程链表一样。
在每一个进程内部,除了我们之前提到过的各种表,还会创建一个文件描述符表struct files_struct,里面有一个数组,一个指针数组,这个指针数组放的就是该进程打开的各个文件的struct file 的指针,所以各个文件的文件操作符对应的就是这个指针数组的下标。
我们创建一个新文件,写一段简单的代码,打开一个文件,可以看到打开的文件描述符是3,为什么是3呢?如果文件描述符表示数组下标的话,前面的0、1、2哪去了?
#include<iostream>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
using namespace std;
int main()
{
int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
cout << fd << endl;
return 0;
}

文件描述符从3开始的原因,其实从上文我们的讲解可以看的出来,因为我们的程序在启动时就自动打开了标准输入、标准输出、标准错误三个文件,它们三个就分别占据了0、1、2这三个位置。

重定向的原理
在经过了上文这么多的铺垫,我们终于可以进入今天的主题,重定向的底层原理了,我相信大家在完成了上文的学习后应该已经大致猜到了重定向的原理是什么,接下来我们就来详细了解一下。
首先我们要知道文件描述符的分配原则是什么,我们还是先通过一个小实验来试一下,我们来试一下把0这个位置的文件关掉会怎么样。关掉文件的函数为close,需要包头文件unistd.h。
#include<iostream>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
using namespace std;
int main()
{
close(0);
int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
cout << fd << endl;
return 0;
}

我们可以发现,当我们关掉0后再打开log文件,log文件的文件描述符就变成了0。所以文件描述符的分配原则就是,最小的,没有被使用的文件描述符分配给用户。
相信很多人以及大概了解重定向的原理了。再上面我们说过,标准输入,标准输出,标准错误这三个文件分别占用了0、1、2这三个文件描述符,当我们将标准输出对应的显示器关掉,也就是close(1)时,1这个位置就不再指向显示器文件,当我们再打开一个普通文件时,又因为文件描述符的分配原则,就将1这个位置分配给了该文件,这个过程就叫做重定向。
因为在操作系统中,操作系统认定的输出对象不是固定的显示器文件,而是只认文件操作符1,哪个文件操作符为1,就往这个文件里打印。所以实际上重定向改变的是文件描述符表对应的数组下标里面的指针指向。
当我们改为将1位置的文件关闭,再编译运行后会发现,这回显示器上没有打印出任何信息,再查看log文件的文件内容,果然1被打印到了log文件里。

此外,还可以通过系统调用来进行重定向,dup2(oldfd,newfd),其中oldfd是指你想要改变的文件的文件操作符,newfd是你想改变的目标文件描述符。
stderr
之前我们反复提及过stderr,知道它是标准错误,对应的也是显示器文件,那我们能不能将它也重定向呢?
我们试一下同时将标准输出和标准错误同时打印,打印标准错误的函数是cerr,并将其重定向进log文件内:
#include<iostream>
using namespace std;
int main()
{
cout << "stdout" << endl;
cerr << "stderr" << endl;
return 0;
}

我们可以看到,只有stdout成功被重定向进了log文件,stderr却没有成功,这是为什么呢?其实是因为我们平常使用的重定向符 ‘>’默认重定向的文件描述符为1,完整写法其实是 ‘1>’,表明将1重定向,如果我们像重定向标准错误,可以显示的写出 ‘2>’,表示将2重定向。
相信很多人也一直有一个疑问,就是为什么要存在一个标准错误呢?为什么又有cout又要有cerr,这意味着我们将来在使用时,可以通过重定向的能力,将常规信息和错误信息进行分离,让错误信息不会污染正常信息,也可以更清晰的查看错误信息。
3、结语
这节课我们完成了对Linux中文件的初步认识,了解了重定向的基本原理,虽然这篇文章的主要篇幅不在讲解重定向上,但是通过上文的铺垫,相信大家在学习重定向相关知识时非常容易理解,这就是为什么我要花这么多的篇幅用于讲解其他知识的原因,希望这篇文章能让大家对文件系统和重定向操作有了更深的认识。



