【Linux】基础IO —— 系统文件IO | 文件描述符fd | inode | 重定向原理 | 缓冲区 | 软硬链接

简介: 如果学习文件操作,只停留在语言层面,很难对文件有深刻理解。这也是一定程度导致我对它印象不深刻,每次写都要回看文档,现在要站在系统角度重新理解。的确,学了这儿我写文件操作自信多了。本文重点:深入理解文件描述符`fd`;理解文件系统中`inode`的概念;软硬链接。

@TOC
反爬链接
如果学习文件操作,只停留在语言层面,很难对文件有深刻理解。这也是一定程度导致我对它印象不深刻,每次写都要回看文档,现在要站在系统角度重新理解。的确,学了这儿我写文件操作自信多了。

本文重点:深入理解文件描述符fd;理解文件系统中inode的概念;软硬链接。

正文开始@一个人的乐队:guitar:

1. 回忆C/C++中的文件操作

1.1 C 读写文件

文件操作:

  • 首先要打开文件:打开成功,返回文件指针;打开失败,返回NULL。
  • 最后要关闭文件。
FILE *fopen(const char *path, const char *mode);
int fclose(FILE *fp);

在这之间可以进行文件读写操作。

1.1.1 C写文件

我们可以fputs/fgets以字符串形式读写;也可以fprintf/fscanf格式化读写。

int fputs(const char *s, FILE *stream);  向特定文件流写入字符串
int fprintf(FILE *stream, const char *format, ...);

如果以"w"模式打开文件,默认是文本读写,且会把原始内容清掉再写。

在这里插入图片描述

如果要以追加方式写,则要以"a" append模式打开文件 ——

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lxSA4BiT-1653188545490)(C:\Users\13136\AppData\Roaming\Typora\typora-user-images\image-20220423105205093.png)]

1.1.2 C读文件

fgets从特定文件流中按行读取,内容放在缓冲区。读取成功返回字符串起始地址,读失败返回NULL.

char *fgets(char *s, int size, FILE *stream); //size:为缓冲区大小
int fscanf(FILE *stream, const char *format, ...);

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4JBMP4iN-1653188545491)(C:\Users\13136\AppData\Roaming\Typora\typora-user-images\image-20220423105332070.png)]

feof:判断是否正常退出。

1.2 C++ 读写文件

C++面向对象的风格。以二进制读写为例 ——

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2Sc4uHag-1653188545493)(C:\Users\13136\AppData\Roaming\Typora\typora-user-images\image-20220423173012967.png)]

1.2 关于stdin stdout stderr

C语言默认会打开三个输入输出流:stdin、stdout、stderr,它们的类型都是FILE*,C语言把它们当做文件看待;站在系统角度,stdin对应的硬件设备是键盘、stdout对应显示器、stderr对应显示器,本质上我们最终都是访问硬件。C++中也有cin、cout、cerr,几乎所有语言都提供标准输入、标准输出、标准错误。

fputs既然是向文件写入,stdout既然也是FILE*类型,我们是不是可以向显示器标准输出打印了?这说明显示器被看做文件。

<img src=" title="">

注意,虽然stdout和stderr对应的硬件设备都是显示器,但是重定向时有所不同(如上图)。所以我们所谓的重定向,实际上是输出重定向,把stdout的内容重定向到文件中(缓冲区一节详谈)。

以上说明,fputs可以向一般文件(磁盘,也是硬件)或者硬件设备写入。这反映着Linux下一切皆文件!在文件描述符fd小节会再次阐述。

2. 系统文件I/O

如上我们知道,这些文件操作最终都是访问硬件(显示器、键盘、文件(磁盘))。众所周知,OS硬件的管理者。所有语言上对“文件”的操作,都必须贯穿操作系统。然而OS不相信任何人,访问操作系统,就必须要通过系统接口!!

其实我们学过的几乎所有的语言中,fopen/fclose,fread/fwrite,fputs/fgets,fgets/fputs 等底层一定需要使用OS提供的系统调用接口,下面咱们就来学习文件的系统调用接口,才能做到万变不离其宗!!

<img src=" title="">

2.1 open & close

:purple_heart: $man 2 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.
             O_RDONLY: 只读打开
             O_WRONLY: 只写打开
             O_RDWR  : 读写打开
          以上这三个常量,必须指定一个且只能指定一个
             O_CREAT : 若文件不存在,则创建它。同时需要使用mode选项,来指明新文件的访问权限
             O_APPEND: 追加写
mode:       设置默认权限信息 

返回值(int):

return the new file descriptor, or -1 if an error occurred (in which case, errno is set appropriately).
     成功: 新打开的文件描述符 
     失败: -1

:purple_heart: $man 2 close

#include <unistd.h>

int close(int fd);

我们现在就用起来,open如果以写入方式打开且文件不存在,需要或|上O_CREAT,这与C中以"w"模式打开完全等价 (为什么是或?它看上去像宏诶,马上详谈)。如果我们先不带第三个参数 ——

<img src=" title="">

可以看到权限完全是混乱的!这是因为,没有这个文件,要创建它,系统层面就必须指定权限是多少!我们采用权限设置的八进制方案——

<img src=" title="">

之前我们在语言层面,创建时就是一个正常权限,我根本就不关心什么只写、创建、权限这些与系统强相关的概念。语言为我们做了封装,我用就好了。所以呀,哪里有那么多岁月静好,只不过有人替你负重前行~ :heart:

fopen("./log.txt", "w");
int fd = open("./log.txt", O_WRONLY | O_CREAT, 0644);

那第二个参数flags(int)为什么要把模式|在一起呢?这是一种用户层给内核传递标志位的常用做法。

int有32个bit位,一个bit代表一个标志,就可以传递多个标志位且位运算效率较高。这些O_RDONLY、O_WRONLY、O_RDWR 都是只有一个比特位是1的数据,并且相互不重复,这样|在一起,就能传递多个标志位。

在操作系统内部就会做&这样的操作来检测标志位是否被设置为1 ——

if(O_WRONLY & flags)
{
    //...
}

我们可以来打开/usr/include/bits/fcntl-linux.h这个文件查看 ——

<img src=" title="">

2.2 write & read

:purple_heart: $ man 2 write

write向文件描述符写入

#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);
参数:
    buf: 用户缓冲区
    count: 期望写的字节数
返回值:实际写入的字节数

<img src=" title="">

注意小细节,写入文件的过程中,不需要写入\0!因为\0是C语言层面上规定字符串的结束标志,而写入文件关心的是字符串的内容,不需要\0标定字符串结束。

:purple_heart: $ man 2 read

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);
参数:
    buf: 读到的内容放在用户层缓冲区中,也就是自己定义缓冲区
    count: 期望读多少个字节
返回值:实际读多少个字节

读文件时文件已经存在,不涉及创建及权限的问题,那么用两个参数的open打开文件即可 ——

<img src=" title="">

注:我们把读到的内容当做一个长字符串处理,写入时不写\0,读也就不会读到,因此需要在末尾添加\0,以字符串打印出来。

3. 文件描述符fd

open函数的返回值是所谓的文件描述符,既然类型为int,我就好奇它的值是多少呢?

<img src=" title="">

如果我们连续打开若干文件,会发现打印3456.... 我们知道打开文件失败返回-1,那么012去哪了呢?012消失的原因,要么是不让用,要么是被别人占用。

<img src=" title="">

事实上,当我们的程序运行起来变成进程,默认情况下,OS会帮助我们打开三个标准输入输出,012其实分别对应的就是标准输入、标准输出、标准错误。刚刚我们还提到语言上的stdin标准输入、stdout标准输出、stderr标准错误,对应硬件设备也是键盘、显示器、显示器,冥冥之中,这一定是有关联的,不过我们暂时先不考虑语言和系统上如何对应。

这样文件描述符被分配为01234678.... 这样从0开始,连续的小整数,会让我们联想到数组下标!

验证:你一直口口声声的跟我说012代表标准输入、标准输出、标准错误,那么现在来小小的验证一下。一个进程默认打开012,那是不是就可以从0向1中write啦,当然了也可以从0向2中写 ——

<img src=" title="">

那12都能向显示器打印,有什么区别?缓冲区详谈。当然也可以从0读,还是注意把返回值s处置0 ——

<img src=" title="">

3.1 file descriptor

众所周知,所有的文件操作都是进程执行对应的函数,即本质上是进程对文件的操作

:small_orange_diamond: 如果一个文件没有被打开,这个文件是在磁盘上。如果我创建一个空文件,该文件也是要占用磁盘空间的,因为文件的属性早就存在了(包括名称、时间、类型、大小、权限、用户名所属组等等),属性也是数据,所谓“空文件”是指文件内容为空。

磁盘文件 = 文件内容 + 文件属性。事实上,我们之前所学的所有文件操作都可以分为两类:对文件内容的操作 + 对文件属性的操作(fseek、ftell、rewind、chmod、chgrp等等).

:small_orange_diamond: 要操作文件,必须打开文件(C语言fopen、C++打开流、系统上open),本质上,就是文件相关的属性信息从磁盘加载到内存。

操作系统中存在大量进程,进程可以打开多个文件,即进程 : 文件 = 1 : n ,系统中可能存在着更多的打开的文件(暂时不考虑一个文件被多个进程打开的特殊情况)。那么,OS要不要把打开的文件在内存中(系统中)管理起来呢?那么就要上管理的六字真言:先描述,再组织

我们的操作系统是C语言写的,内核就有这样一个结构体来描述 ——

struct file
{
    // 文件相关的的属性信息
    // 链接属性,把它们组织起来
};

:small_blue_diamond: 打开的这么多文件,怎么知道哪些是我们进程的呢?操作系统为了让进程和文件之间产生关联,进程在内核创建struct files_struct 的结构,这个结构包含了一个数组 struct file* fd_array[] ,也就是一个指针数组,把表述文件的结构体地址填入到特定下标中。

<img src=" title="">

那么现在就能解释了为什么打开文件返回的是3:新打开一个文件本质是内核会为我们描述struct file结构,再把struct file地址填入到fd_array[]数组下标去,因为012已经被占用了,于是填到3号下标,因此在上层可以拿到3.

这也解释了为什么write和read这样的系统调用接口为什么一定要传入文件描述符fd:执行系统调用接口是进程执行的,通过进程PCB,找到自己打开的文件列表,通过fd索引数组找到对应的文件,从而对文件进行操作。

:purple_heart: 结论:文件描述符fd,本质是内核中进程和打开文件关联数组下标

我们可以看看源代码 ——

<img src=" title="">

3.2 理解一切皆文件

对于键盘显示器等等这些外设,一定都有比如像read、write读写方法,因为由冯诺依曼体系结构知,外设是要和内存打交道IO的。这可能有些奇怪,比如键盘能读我知道,但能写吗?难道我键盘安安静静的自己就开始动了?!注意,我们有统一的读写方法,但不代表非要每一个都实现,比如键盘就可以没有写方法,即方法为空。

<img src=" title="">

因为它们的硬件结构不同,这些方法在底层实现是完全不一样的!这些方法都是在硬件的驱动层完成的。那又是如何做到一切皆文件的呢?Linux中做了软件的虚拟层vfs(虚拟文件系统),会统一维护每一个打开文件的结构体struct file.

回忆C++中的多态,我们可以编写一个父类(甚至是纯虚的,相当于定义一个接口类),子类继承父类,重写函数。我们让父类指针指向不同的子类对象,就会调用对应的方法。那么在C语言中,可以通过函数指针,做到调用同一个方法,指向不同对象时可以执行不同的方法,从而实现多态的性质。

我们在每个struct file当中包含上一大坨的函数指针,这样,在struct file上层看来所有的文件都是调用统一的接口;在底层我们通过函数指针指向不同硬件的方法。

<img src=" title="">

同样在继承体系中,我甚至也不关心你到底是那个子类,比如,动物基类Animal被猫狗鸡鸭鹅都继承了,里面有一个eat方法,基类指针指向猫就调用猫的eat,基类指针指向狗就调用狗的eat...... 这样看去我们就实现了“一切皆动物”,可以理解为C++的多态是漫长的软件开发摸索中实现“一切皆...”的高级版本/语言版本。

在源代码中,struct file就有这样一个结构体指针,它特别像C++中的虚函数表,一堆函数指针,指向底层硬件的操作方法 ——

<img src=" title="">

这样,文件操作 == 进程进行文件操作,通过进程PCB找到文件描述符表,找到对应文件,再由具体操作方法刷新到硬件上,整条逻辑链儿就完整了。

3.3 文件描述符的分配规则

观察如下代码,可以看到,我把0关掉后,再打开文件是分配的文件描述符就是0,把1关掉分配的就是1 ——

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BEyzI2IV-1653188545494)(C:\Users\13136\AppData\Roaming\Typora\typora-user-images\image-20220503213047536.png)]

:purple_heart: 文件描述符的分配规则:每次给新文件分配的fd,是从fd_array[]中找一个最小的、未被使用的作为新的fd.

这其实很好理解,打开的文件要和进程产生关联,就要线性遍历数组中找一个未被使用的下标,填入文件地址。

4. 重定向原理

4.1 输出重定向

细心的你可能已经发现了,刚刚我关闭了0关闭了2,唯独没有关闭1标准输出,那现在我们关闭它。按照文件描述符的分配规则,再打开文件fd就应该分配的是刚刚关闭的1,那么是不是就该把fd:1打印在屏幕上呢?

<img src=" title="">

却发现,诶?!怎么没有向显示器打印,而是全部打印到文件中呢?(其实已经可见printf底层是向1打印:heart_eyes:)

<img src=" title="">

本来应该显示到显示器中,却被“显示”文件内部,这种行为我们早就知道叫做输出重定向。咱们无意之间居然完成了一次重定向操作,为什么是这样呢?

这是因为,一上来close(1)断开了与显示器文件的联系,相当于置NULL。对于打开新文件的log.txt,由文件描述符分配规则,1又指向log.txt,这很好理解。

<img src=" title="">

那就要思考printf底层是在做什么?它是C中的打印,事实上,它本质是向标准输出(stdout)打印 ——

<img src=" title="">

🌺 关于FILE

stdout的类型是FILE*,是一个文件指针我们都知道,可是我们从来没有关心过FILE到底是什么,?我们勇敢推测它就是C语言层面的结构体

struct FILE
{
  //...  
};

那么C语言的接口和这些系统调用接口是什么关系呢?

    printf、fprintf、cin   —— 语言层(都是在语言层面上的封装的函数和对象) 
                ↓ 都是向硬件写入,则一定要调用系统调用接口
    open、write、read - fd —— 系统层

以C语言中调用fwrite写为例,这好像是向文件流中写,实际上底层是通过文件描述符写到磁盘上 ——

<img src=" title="">

由此我们可以推知,在C语言FILE中一定包含了fd ,同样可以大胆猜测C++中,cin、cout、cerr这些流对象属性中也一定包含文件描述符——

struct FILE
{
    //一定包含了一个整数,是对应在系统层面这个打开文件对应的fd
};

回到咱们上文一直说的,语言上in/out/err和系统上的012若隐若现的联系实际上就是一一对应包含的 ——

 stdin  标准输入,键盘    --包含-->     0: 标准输入,键盘 
 stdout 标准输出,显示器  --包含-->  1: 标准输出,显示器
 stderr 标准错误,显示器  --包含-->  2: 标准错误,显示器

:small_orange_diamond: 现在你就能解释为什么关闭1再打开文件反倒是向文件写入了,printf是向stdout中打印,stdout类型是FILE*,FILE就是一个结构体,包含了一个整数,和系统上的1对应,它只关心1这个数字,不关心数组1下标指向什么鬼文件,现在我们指向log.txt,因此不再向显示器写而而是是向log.txt写。这就是重定向的原理。

来看看C语言中FILE的定义 ——

typedef struct _IO_FILE

关注其中的_filno,不同的语言封装的不太一样 ——

//在/usr/include/libio.h
struct _IO_FILE {
 int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags
 //缓冲区相关
 /* The following pointers correspond to the C++ streambuf protocol. */
 /* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
 char* _IO_read_ptr; /* Current read pointer */
 char* _IO_read_end; /* End of get area. */
 char* _IO_read_base; /* Start of putback+get area. */
 char* _IO_write_base; /* Start of put area. */
     char* _IO_write_ptr; /* Current put pointer. */
 char* _IO_write_end; /* End of put area. */
 char* _IO_buf_base; /* Start of reserve area. */
 char* _IO_buf_end; /* End of reserve area. */
 /* The following fields are used to support backing up and undo. */
 char *_IO_save_base; /* Pointer to start of non-current get area. */
 char *_IO_backup_base; /* Pointer to first valid character of backup area */
 char *_IO_save_end; /* Pointer to end of non-current get area. */
 struct _IO_marker *_markers;
 struct _IO_FILE *_chain;
 int _fileno; //封装的文件描述符
#if 0
 int _blksize;
#else
 int _flags2;
#endif
 _IO_off_t _old_offset; /* This used to be _offset but it's too small. */
#define __HAVE_COLUMN /* temporary */
 /* 1+column number of pbase(); 0 is unknown. */
 unsigned short _cur_column;
 signed char _vtable_offset;
 char _shortbuf[1];
 /* char* _save_gptr; char* _save_egptr; */
 _IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

那我们就可以把结构体指针指向的内容打印出来,证明一下 ——

<img src=" title="">

实际上在如下输出重定向中:echo也是一个命令,把echo进程的显示器文件关掉,再把log.txt文件打开,于是输出的内容被打印到log.txt中 ——

<img src=" title="">

4.2 追加重定向

追加重定向与输出重定向唯一的差别就是在打开方式上,增加O_APPEND选项。

<img src=" title="">

4.3 输入重定向

输入重定向就是把本来应该从键盘获取内容变成从文件中获取。

char *fgets(char *s, int size, FILE *stream); //详见1.1节

<img src=" title="">

<img src=" title="">

4.4 dup2

如上我通过关闭文件然后再打开文件这样重定向,但是情况不会总是这样理想。

比如两个文件描述符13都已经被打开,如何实现重定向呢?我们勇敢的推测,既然在语言层调用时接口函数只认1,那么只需要把文件描述符表的3中的内容拷贝到1中 ,就实现了原本应向显示器文件写入,而现在向log.txt写入。

<img src=" title="">

dup2就是用来做这个操作的。

#include <unistd.h>

int dup2(int oldfd, int newfd); //oldfd->newfd

dup2() makes newfd be the copy of oldfd, closing newfd first if necessary, but note the following:
*  If oldfd is not a valid file descriptor, then the call fails, and newfd is not closed.
*  If oldfd is a valid file descriptor, and newfd has the same value as oldfd, then dup2() does nothing, and returns newfd.

阅读如上英文说明可知,拷贝的是fd对应内容,最终相当于全部变成old. 下面我们就可以通过dup2完成一系列重定向 ——

:yellow_heart: 输出重定向

dup2(fd, 1);  本来应该显示到显示器的内容,写入到文件

<img src=" title="">

注意,系统层面,open打开文件时带了选项O_TRUNC,以清空原来内容。而在C语言中"w"也会先把原始文件清空,说明上层封装了这个选项。

:yellow_heart: 追加重定向

只需在输出只写的基础上添加O_APPEND选项

<img src=" title="">

:yellow_heart: 输入重定向

dup2(fd, 0);  原本从键盘读,现在从文件中读。

<img src=" title="">

:purple_heart: 思考:执行exec*程序替换的时候,会不会影响我们曾经打开的所有文件呢?绝对不会!因为替换的是代码和数据,不会影响进程内核的数据结构,想下图就行啦。

在命令行上的重定向,先会进行字符串分析,发现>便会先dup2,再进行程序替换,此时echo的内容便会重定向到log.txt中。因为程序替换不会替换打开的文件。

[bts@VM-24-5-centos fd]$ echo "have a nice day!" > log.txt
相当于 fork -> child -> dup2(fd, 1) -> exec*()

那子进程会不会与父进程共享文件描述符呢?答案是一定要形成自己的files_struct结构体,因为这个结构是属于进程的,父进程有一份,凭啥不给子进程呢?我们知道子进程内核的数据结构task_struct,会以父进程的为模板初始化自身,因此它们的文件描述符表就是两份完全一样的内容,但是这些打开的文件不会新建,即父子指向同一份文件。

<img src=" title="">

因此父进程如果曾经打开了标准输入、标准输出、标准错误,意味着子进程也会继承下去 (咱们之前就见过父子进程同时向显示器打印的状况,因为父子指向同一份文件)。

这就是为什么我们所有的进程都会默认打开标准输入、标准输出、标准错误,就是因为我们命令行上所有的进程的父进程都是bash,也就是命令行解释器,那它当然需要打开标准输入输入指令,当然要打开标准输出打印结果,当然要提示错误信息,所以在命令行上启动的所有子进程最终都打开了同一个文件(引用计数)。

关于修改时会不会发生写时拷贝,写时拷贝是通过页表实现的,拷贝的是页表右侧物理内存中的内容,而这种内核数据结构一般是由操作系统直接修改的,与写时拷贝无关。

5. 缓冲区

咱们在学习过程中经常听到缓冲区,但是对于缓冲区的理解是非常肤浅的。缓冲区是什么?缓冲区在哪里?为什么要有缓冲区?语言级和内核中的缓冲区区别在哪里呢?

5.1 引入

引入:如下程序你可以看到,标准输出和标准错误都能向显示器打印,那区别在哪里呢?

现在咱们把它重定向到log.txt中,发现只有标准输出写到了log.txt中,这也好理解,因为>叫做输出重定向,即把本来应该显示到1号文件描述符的内容写到指定文件中,2号并没有发生改变依旧指向标准错误。

<img src=" title="">

那我想把标准输入和标准输出进行重定向怎么办呢?咱们可以在命令行上这样写,可以理解为把文件列表中1的内容拷贝到2中去。

<img src=" title="">

但这不是今天的重点。

5.2 语言级缓冲区

你可能已经发现了刚刚咱们输入重定向时有一点点的不严谨,打开文件并没有close(fd),那现在close一下;在刚刚代码的基础上,也close(1),它们做的事儿是类似的 ——

<img src=" title="">

注:不close的时候是这个样子的 ——

<img src=" title="">

前后对比,它们都在呈现了一个现象,close后C语言的写入接口重定向后没有被刷新出来。这怎么回事儿?别急,咱来认识一下缓冲区

事实上,咱们之前所说的缓冲区,都是指语言级别/用户级的缓冲区,就是由C语言提供的缓冲区。

在C语言中咱们printf/fprintf向stdout写入,本质都是写入(拷贝)到C语言缓冲区中,定期会把C语言缓冲区中的内容拷贝到到内核缓冲区,操作系统再把数据更新到硬件上。

<img src=" title="">

:yellow_heart: 那么C语言缓冲区是如何写入内核缓冲区的呢?相当于把数据写入文件中,则一定需要fd

:yellow_heart: 那么既然你说是C语言提供的缓冲区,它在哪里?咱们前文说过FILE中一定封装了一个fd,其实同时它还维护了与C缓冲区相关的内容,我们再把它的定义贴出来看一看,注意看其中一大坨指针,回想咱们在STL中模拟实现容器时,也用过类似用指针的表示区间的方法 ——

//在/usr/include/libio.h
struct _IO_FILE {
 int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags
 //缓冲区相关
 /* The following pointers correspond to the C++ streambuf protocol. */
 /* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
 char* _IO_read_ptr; /* Current read pointer */
 char* _IO_read_end; /* End of get area. */
 char* _IO_read_base; /* Start of putback+get area. */
 char* _IO_write_base; /* Start of put area. */
 char* _IO_write_ptr; /* Current put pointer. */
 char* _IO_write_end; /* End of put area. */
 char* _IO_buf_base; /* Start of reserve area. */
 char* _IO_buf_end; /* End of reserve area. */
 /* The following fields are used to support backing up and undo. */
 char *_IO_save_base; /* Pointer to start of non-current get area. */
 char *_IO_backup_base; /* Pointer to first valid character of backup area */
 char *_IO_save_end; /* Pointer to end of non-current get area. */
 struct _IO_marker *_markers;
 struct _IO_FILE *_chain;
 int _fileno; //封装的文件描述符
#if 0
 int _blksize;
#else
 int _flags2;
#endif
 _IO_off_t _old_offset; /* This used to be _offset but it's too small. */
#define __HAVE_COLUMN /* temporary */
 /* 1+column number of pbase(); 0 is unknown. */
 unsigned short _cur_column;
 signed char _vtable_offset;
 char _shortbuf[1];
 /* char* _save_gptr; char* _save_egptr; */
 _IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

所以可不要小瞧了FILE啊,咱们使用stdin/out/err和自己打开的文件时,拿到FILE*即拿到一个FILE。你printf/fprintf都是先写到了文件缓冲区中,即暂存在FILE结构体的内部并不会直接刷新到外设

:yellow_heart: 那么何时把FILE中的数据刷新到内核中呢?

  • \n
  • 进程退出时(今天的重点)

:yellow_heart: 用户到内核的刷新策略:(os → 硬件也同样适用,只不过咱们现在不关心)

  • 立即刷新(不缓冲)
  • 行刷新(行缓冲\n),比如显示器就是这种刷新策略
  • 全缓冲(缓冲区满了,才刷新),比如向磁盘文件写入

那么当发生重定向(显示器 -> log.txt)时,变为向文件中写入,隐含行缓冲变为了全缓冲策略!

再来解释如上现象(这你得想着上面那张图):printf/fprintf写入,这一批消息被拷贝到用户缓冲区中,C缓冲区可能并没有被写满,因此这部分内容没有立即被刷新到内核中(全缓冲策略)。那么是close是怎么影响刷新的就关系到咱们看到的现象了:

<img src=" title="">

左图:没有调用close,则在进程退出时,把数据刷新到内核,OS把数据同步到硬件上所以你也看见了。

右图:在进程退出之前,先调用了close,即把文件描述符关了,咱们的数据还在用户缓冲区里,却没有地方去刷新了!因此你什么都没看到。

(可以在关闭之前 fflush(stdout) 强制刷新)

5.3 内核缓冲区

咱们继续看5.1小节的引入代码 ——

<img src=" title="">

重定向后,发现标准错误没有重定向,因为我们是重定向1号文件描述符向log.txt打印,2不受影响照样向显示器写且刷新策略是行刷新,这都没问题。

问题是,重定向后,文件中只有write写入的内容,而printf/fprintf 经过重定向后都没有被写到文件中,这是为啥呢?

printf/fprintf 经过重定向后为什么没有被写到文件中,咱们5.2一直在讲这件事儿,重定向时,原本应该显示到显示器上的内容显示到文件中,潜台词就是行缓冲变为全缓冲,还没来得及刷新就close(1)了,就刷新不到了。

write是系统调用,没有通过C语言的缓冲区暂存,而是直接写到内核缓冲区,因此关闭文件描述符并不影响。归根结底就是有没有C语言缓冲区的问题。


继续小小修改如上代码,fork创建子进程,我们就是往显示器上打印,如期打印;那我重定向到文件中 ——

<img src=" title="">

我们惊奇的发现重复出现的是使用C接口的时候,而系统调用接口并不受影响!!

没有重定向时,每条消息都有\n,也就是./redir向显示器打印时,在fork之前这批消息早就被刷新到硬件上了,因此不会打印两份;

而当你重定向时,不再向显示器打印,而是写入到文件中,潜台词就是刷新策略变了,行刷新变为了全缓冲;printf/fprintf/fputs是先写到buffer中,可能没有写满,所以不会立即刷新到内核;这个buffer是C语言上的缓冲区,父进程的缓冲区也是父进程的空间。fork创建子进程,进程退出时要刷新,刷新本质就是一种写入,为了维护代码数据独立性有写时拷贝,父进程子进程谁先刷新时就会发生写时拷贝。因为父子进程都对各自缓冲区的内容刷新,于是就看到了文件中库函数输出的内容输出了两份。

那为什么write没有两份呢?因为它是一个系统调用接口,直接向内核中写,不会存在C缓冲区没来得及刷新的情况。这再次印证了这个缓冲区buffer不在操作系统内,而是在用户层。

如果我在fork之前,强制fflush刷新,就没有这么些事儿了。

<img src=" title="">

所谓的stdout(FILE*)、cin/cout(iostream、fstream所谓的流),说人话就是,类中会包含缓冲区。所以为什么C++中还要给我们提供这个货std::endl,它就是相当于一个\n,会刷新C++流当中的信息到显示器中。

6. 理解文件系统

上文咱们一直在谈论打开的文件,那如果一个文件没有被打开呢?它静静的躺在磁盘上。那咱们现在就要了解一下磁盘上的文件系统。首先要了解磁盘结构,这有助于咱们形象理解“把数据刷新到磁盘”这种话,而不是一听而过。

6.1 了解磁盘结构

磁盘是计算机中的一个机械设备(当然我们目前不考虑SSD、FLASH卡、USB)。

这个磁盘的盘片就像光盘一样,数据就在盘片上放着,只不过光盘是只读的,磁盘是可读可写的。

机械硬盘的寻址的工作方式:盘片不断旋转,磁头不断摆动,定位到特定位置。(我说我的移动硬盘怎么这么响,当时我还以为电脑要报废了)

<img src=" title="">

类比磁带,我们可以把磁盘盘片想象成线性结构

站在OS角度,我们就认为磁盘是线性结构,要访问某一扇区,就要定位数组下标LBA(logic block address);要写到物理磁盘上,就要把LBA地址转化成磁盘的三维地址(磁头,磁道,扇区)。这种关系类似于我们之前的虚拟地址空间和物理内存。

<img src=" title="">

6.2 文件系统与inode

文件在磁盘上是如何被保存的?文件是在磁盘中的,而磁盘现在被我们想象成一个线性结构

:purple_heart: 磁盘空间很,管理成本高。类比管理我们的国家,我们把土地划分成了块儿,但是光划分了还不行,还要给每个小土地上配上合适的管理班子。因此我们就对大磁盘 ——

​ ① 分区:大磁盘 → 小空间,化整为零

​ ② 格式化:给每个分区写入文件系统。像生活中的,给陕西省写入陕西省政府领导班子:) 好想去西安看我的老哥哥们

所以现在我们以一个小区域为例,理论上,我能把这100G的小空间管理好,其他空间就复刻我就好啦,因为硬件都是标品,当然了不同分区也可以写入不同的文件系统,现在几乎所有的操作系统都支持多文件系统,但我们先不考虑这个。

<img src=" title="">

每个分区最开始都可以有Boot Block ,是与启动相关的,供启动时查找分区。我们再把剩下的空间继续拆解分组Block group 0 ,Block group 1 ...... 那么问题就又变成了如果我能管理好Block Group 0,就能管好1~n这些,因此研究文件系统,就变成研究这一个Block Group 0.


:purple_heart: 众所周知,文件 = 文件内容 + 文件属性,其中文件内容放在Data blocks中,属性放在inode Table中。

<img src=" title="">

把属性和数据分开存放的想法看似简单,但它们是怎么关联的呢?我们日常好像都是用文件名访问文件,但是在Linux下,在系统层面上,文件名包括后缀没有意义,它是给用户使用的。Linux中真正标识一个文件,是通过文件的inode编号,一个文件,一个inode(属性集合);一个inode也都有自己的编号。

那么要创建文件就要在inode Table中申请一个未被使用的inode,填入属性;文件中还有内容,inode还用数组存储了相关联的blocks块编号,我们可以简单地理解成 ——

// 包含所有文件的属性
struct inode
{ 
    //数据
    int inode_num;
    int blocks[32]; //[102, 103, ....]
};

注意区分 inode 和 inode编号。

:purple_heart: 那么你怎么在inode Table申请一个未被使用的inode呢,同样的,你也要在Data blocks中也要申请若干数据块儿,难道你要遍历所有结构体吗?no no no. 于是我们有inode Bitmap 和 block Bitmap,位图。以inode Bitmap为例 ——

... 0000 1010

从右向左 ——

  • 比特位的位置含义:inode编号(第几位)。
  • 比特位的内容含义:特定inode“是否”被使用(0/1)。

于是创建文件,要快速申请到一个未占用的inode,就要遍历位图,找到为0的inode位置;block数据块同理,遍历到若干个为0的数据块位置,填入到blocks数组中构建映射关系。

最后再了解一下这两个字段 ——

Super Block:表示着Block Group 0 中空间的使用情况,包括文件系统类型信息。存放文件系统本身的结构信息。记录的信息主要有:bolck 和 inode的总量,未使用的block和inode的数量,一个block和inode的大小,最近一次挂载的时间,最近一次写入数据的时间,最近一次检验磁盘的时间等其他文件系统的相关信息。Super Block的信息被破坏,可以说整个文件系统结构就被破坏了。

Group Descriptor Table:表示组相关的信息,包括块组描述符、描述块组属性信息、所占空间、起始终止位置。

(Linux特有的EXT系列的文件系统)


inode不保存文件名,那么文件名是怎样和inode对应的呢?

:purple_heart: 目录是文件吗?是的!那我创建目录时都做了什么?

既然目录是文件,那么在磁盘上,目录一定有自己的inode ——

<img src=" title="">

那目录的inode中放些什么呢?目录的大小、权限、(链接数)、拥有者、所属组等等 .......

目录有数据块儿block放什么呢?你发现,你所创建的所有文件,其实全部一定放在一个特定的目录下

用户要用文件名(字符串儿),而系统要的是inode,因此目录数据块儿存的是文件名和inode的映射关系


我们来总结一下,如下这一系列操作,在系统层面都做了什么 ——

<img src=" title="">

  • 创建文件:遍历inode Bitmap位图中找0,申请一个未被使用的inode,填入属性信息。并把这个映射关系写到当前目录的Data blocks中。
  • 查看目录:根据目录inode找到与其映射的文件名
  • 向文件写入:遍历block Map找到若干未被使用的块儿,将该文件的inode与这些blocks建立映射关系,再向blocks中写入内容。
  • 查看文件内容:cat hello.c → 查看当前目录lesson15的data Blocks数据块儿 → 找到映射关系:文件名儿对应的inode编号 → 在inode Table中找到inode → 找到对应的blocks[] → 打印文件内容。 查看文件属性类似。

:purple_heart: 删除文件做了什么?

不需要改文件的属性inode Table和数据data Blocks,只需要把对应inode编号位Bitmap由1置0;再根据属性把使用的数据块儿们也在Bitmap中把它由1置0。所以拷贝一个文件需要一会儿,但是删除很

(嘘~ 如果你在Linux系统中,不小心rm -rf误删了文件,最好的做法就是什么也不做!可以尝试debugfs恢复文件,即由0置1。你在windows下删除文件到回收站,其实只不过是转移了目录,在回收站中删掉才是相当于1置0了)

6.3 文件时间 acm

:yellow_heart: Acess:文件最近一次被访问的时间

我们却发现,实际操作下来文件Acess时间可能没有变化。因为在较新的Linux内核中(看来我的不是较新的),Acess时间不会被立即更新,而是过一定的时间间隔,OS才自动进行更新。因为访问文件是比较频繁的,可能存在刷盘问题,让你的Linux系统变得很慢。

那Change和Modify有什么区别呢?在我Linux的第一篇文章就挖过这个坑~

:yellow_heart: Change:最近一次修改文件属性的时间

<img src=" title="">

:yellow_heart: Modify:最近一次修改文件内容的时间

当我们修改文件内容时,有可能修改文件内容的属性,比如文件大小。

<img src=" title="">


Makefile(gcc) 怎么判定源文件是否被修改过( make is up to date.),从而指导系统的源文件要不要编译?就是通过对比源文件生成的可执行程序Modify时间

<img src=" title="">

<img src=" title="">

.PHONY定义伪目标总是可以被执行,本质就是不关心时间谁新谁旧,直接编译。

7. 软硬链接

好嘞,我们只有理解了inode才能理解软硬链接。

7.1 软链接

:yellow_heart: 建立软链接

ln -s log.txt log_soft

:yellow_heart: 删除链接,可以rm,但是更建议

unlink log_soft

<img src=" title="">

:purple_heart: 可是啥时候会用到软链接呢?

对于一些执行路径非常深的程序,我们可以通过软链接快速找到它。

<img src=" title="">

现在我不断回退,退到所谓工作目录上来,这时如果我还想运行test.sh就比较麻烦,那么我们就可以通过建立软链接的方式 ——

<img src=" title="">

相当于windows下的创建快捷方式

<img src=" title="">

7.2 硬链接

:yellow_heart: 那么我们现在不带-s

ln log.c log_hard

我们观察发现,软链接有自己独立inode的,即软链接是一个独立文件!有自己的inode属性集也有自己的数据块儿 (保存的是它所指向文件的路径 + 文件名)

<img src=" title="">

硬链接自己没有独立的inode,根本就不是一个独立的文件,本质是在特定目录下,添加一个文件名和inode编号的映射关系


所谓的链接数,就是硬链接数,即有几个文件指向这个inode.

<img src=" title="">

这个数字存在于inode属性中,建立硬链接就是对ref++;删除某文件即对ref--。直到ref == 0时,这个文件才被干掉,这就是引用计数

struct inode
{ 
    //数据
    int inode_num;
    int blocks[32]; //[102, 103, ....]
    int ref;
};

:purple_heart: 啥时候会用到硬链接?

我们发现创建一个文件的硬链接数是1,这好理解;那创建一个目录默认的硬链接数为什么是2呢?

<img src=" title="">

所以我们在任何目录下的这两个隐藏文件,其中.和我们的 dir 对应的inode是一个!我们曾一直说过.表示当前目录,..表示上一级目录,这就是硬链接最直观的应用场景。

如果我在dir中再创建一个目录,发现dir的硬链接数变为3了,为什么呢?就是因为subdir中的..也是dir的硬链接。

<img src=" title="">

下次更新可能要等到一个月后了,我在备考,最近大家都超忙!!但是我跟我室友比我就是一个大闲人哈哈,小学期恢复更新强度@一个人的乐队:guitar:

相关文章
|
1天前
|
人工智能 Linux
Linux查找大文件的方法
Linux查找大文件的方法
|
3天前
|
固态存储 Ubuntu Linux
Linux(29) 多线程快速解压缩|删除|监视大型文件
Linux(29) 多线程快速解压缩|删除|监视大型文件
11 1
|
3天前
|
Ubuntu Linux 数据安全/隐私保护
Linux(24) 如何在Ubuntu中操作rootfs.img文件
Linux(24) 如何在Ubuntu中操作rootfs.img文件
9 0
|
8天前
|
安全 Linux 开发工具
Linux中可引起文件时间戳改变的相关命令
【4月更文挑战第12天】Linux中可引起文件时间戳改变的相关命令
18 0
|
10天前
|
Linux Shell 开发工具
Linux文件常用操作
Linux文件常用操作(几乎覆盖所有日常使用)
84 0
|
11天前
|
Linux 内存技术 Perl
【ZYNQ】制作从 QSPI Flash 启动 Linux 的启动文件
【ZYNQ】制作从 QSPI Flash 启动 Linux 的启动文件
|
14天前
|
存储 固态存储 Java
文件IO讲解
文件IO讲解
30 0
|
存储 Linux 缓存
linux中文件描述符fd和文件指针flip的理解
整理自:http://www.cnblogs.com/Jezze/archive/2011/12/23/2299861.html 简单归纳:fd(file descriptor)只是一个整数,在open时产生。
1386 0
|
5天前
|
机器学习/深度学习 缓存 监控
linux查看CPU、内存、网络、磁盘IO命令
`Linux`系统中,使用`top`命令查看CPU状态,要查看CPU详细信息,可利用`cat /proc/cpuinfo`相关命令。`free`命令用于查看内存使用情况。网络相关命令包括`ifconfig`(查看网卡状态)、`ifdown/ifup`(禁用/启用网卡)、`netstat`(列出网络连接,如`-tuln`组合)以及`nslookup`、`ping`、`telnet`、`traceroute`等。磁盘IO方面,`iostat`(如`-k -p ALL`)显示磁盘IO统计,`iotop`(如`-o -d 1`)则用于查看磁盘IO瓶颈。
|
2天前
|
监控 Linux Windows
50个必知的Linux命令技巧,你都掌握了吗?(下)
50个必知的Linux命令技巧,你都掌握了吗?(下)