如果学习文件操作,只停留在语言层面,很难对文件有深刻理解。这也是一定程度导致我对它印象不深刻,每次写都要回看文档,现在要站在系统角度重新理解。的确,学了这儿我写文件操作自信多了。
本文重点:深入理解文件描述符fd ;理解文件系统中inode 的概念;软硬链接。
文章挺长的,这是我写过最长的文章hh,写了快一个月了,这一个月穿插了很多事儿,我试图精简过好几次了,可是每次过一遍字儿就越多哈哈,我总是怕语熵太高让人抓不到逻辑。
正文开始@一个人的乐队🎸
1. 回忆C/C++中的文件操作
1.1 C 读写文件
文件操作:
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模式打开文件 ——
1.1.2 C读文件
fgets从特定文件流中按行读取,内容放在缓冲区。读取成功返回字符串起始地址,读失败返回NULL.
char *fgets(char *s, int size, FILE *stream);
int fscanf(FILE *stream, const char *format, ...);
feof:判断是否正常退出。
1.2 C++ 读写文件
C++面向对象的风格。以二进制读写为例 ——
1.2 关于stdin stdout stderr
C语言默认会打开三个输入输出流:stdin、stdout、stderr,它们的类型都是FILE* ,C语言把它们当做文件看待;站在系统角度,stdin对应的硬件设备是键盘、stdout对应显示器、stderr对应显示器,本质上我们最终都是访问硬件。C++中也有cin、cout、cerr,几乎所有语言都提供标准输入、标准输出、标准错误。
fputs既然是向文件写入,stdout既然也是FILE*类型,我们是不是可以向显示器标准输出打印了?这说明显示器被看做文件。
注意,虽然stdout和stderr对应的硬件设备都是显示器,但是重定向时有所不同(如上图)。所以我们所谓的重定向,实际上是输出重定向,把stdout的内容重定向到文件中(缓冲区一节详谈)。
以上说明,fputs可以向一般文件(磁盘,也是硬件)或者硬件设备写入。这反映着Linux下一切皆文件!在文件描述符fd 小节会再次阐述。
2. 系统文件I/O
如上我们知道,这些文件操作最终都是访问硬件(显示器、键盘、文件(磁盘))。众所周知,OS是硬件的管理者。所有语言上对“文件”的操作,都必须贯穿操作系统。然而OS不相信任何人,访问操作系统,就必须要通过系统接口!!
其实我们学过的几乎所有的语言中,fopen/fclose,fread/fwrite,fputs/fgets,fgets/fputs 等底层一定需要使用OS提供的系统调用接口,下面咱们就来学习文件的系统调用接口,才能做到万变不离其宗!!
2.1 open & close
💜 $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
💜 $man 2 close
#include <unistd.h>
int close(int fd);
我们现在就用起来,open如果以写入方式打开且文件不存在,需要或| 上O_CREAT,这与C中以"w"模式打开完全等价 (为什么是或?它看上去像宏诶,马上详谈)。如果我们先不带第三个参数 ——
可以看到权限完全是混乱的!这是因为,没有这个文件,要创建它,系统层面就必须指定权限是多少!我们采用权限设置的八进制方案——
之前我们在语言层面,创建时就是一个正常权限,我根本就不关心什么只写、创建、权限这些与系统强相关的概念。语言为我们做了封装,我用就好了。所以呀,哪里有那么多岁月静好,只不过有人替你负重前行~ ??
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 这个文件查看 ——
2.2 write & read
💜 $ man 2 write
write向文件描述符写入
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
参数:
buf: 用户缓冲区
count: 期望写的字节数
返回值:实际写入的字节数
注意小细节,写入文件的过程中,不需要写入\0 !因为\0 是C语言层面上规定字符串的结束标志,而写入文件关心的是字符串的内容,不需要\0 标定字符串结束。
💜 $ man 2 read
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
参数:
buf: 读到的内容放在用户层缓冲区中,也就是自己定义缓冲区
count: 期望读多少个字节
返回值:实际读多少个字节
读文件时文件已经存在,不涉及创建及权限的问题,那么用两个参数的open打开文件即可 ——
注:我们把读到的内容当做一个长字符串处理,写入时不写\0 ,读也就不会读到,因此需要在末尾添加\0 ,以字符串打印出来。
3. 文件描述符fd
open函数的返回值是所谓的文件描述符,既然类型为int,我就好奇它的值是多少呢?
如果我们连续打开若干文件,会发现打印3456… 我们知道打开文件失败返回-1,那么012去哪了呢?012消失的原因,要么是不让用,要么是被别人占用。
事实上,当我们的程序运行起来变成进程,默认情况下,OS会帮助我们打开三个标准输入输出,012其实分别对应的就是标准输入、标准输出、标准错误。刚刚我们还提到语言上的stdin标准输入、stdout标准输出、stderr标准错误,对应硬件设备也是键盘、显示器、显示器,冥冥之中,这一定是有关联的,不过我们暂时先不考虑语言和系统上如何对应。
这样文件描述符被分配为01234678… 这样从0开始,连续的小整数,会让我们联想到数组下标!
验证:你一直口口声声的跟我说012代表标准输入、标准输出、标准错误,那么现在来小小的验证一下。一个进程默认打开012,那是不是就可以从0向1中write啦,当然了也可以从0向2中写 ——
那12都能向显示器打印,有什么区别?缓冲区详谈。当然也可以从0读,还是注意把返回值s处置0 ——
3.1 file descriptor
众所周知,所有的文件操作都是进程执行对应的函数,即本质上是进程对文件的操作。
🔸 如果一个文件没有被打开,这个文件是在磁盘上。如果我创建一个空文件,该文件也是要占用磁盘空间的,因为文件的属性早就存在了(包括名称、时间、类型、大小、权限、用户名所属组等等),属性也是数据,所谓“空文件”是指文件内容为空。
即磁盘文件 = 文件内容 + 文件属性。事实上,我们之前所学的所有文件操作都可以分为两类:对文件内容的操作 + 对文件属性的操作(fseek、ftell、rewind、chmod、chgrp等等).
🔸 要操作文件,必须打开文件(C语言fopen、C++打开流、系统上open),本质上,就是文件相关的属性信息从磁盘加载到内存。
操作系统中存在大量进程,进程可以打开多个文件,即进程 : 文件 = 1 : n ,系统中可能存在着更多的打开的文件(暂时不考虑一个文件被多个进程打开的特殊情况)。那么,OS要不要把打开的文件在内存中(系统中)管理起来呢?那么就要上管理的六字真言:先描述,再组织!
我们的操作系统是C语言写的,内核就有这样一个结构体来描述 ——
struct file
{
};
🔹 打开的这么多文件,怎么知道哪些是我们进程的呢?操作系统为了让进程和文件之间产生关联,进程在内核创建struct files_struct 的结构,这个结构包含了一个数组 struct file* fd_array[] ,也就是一个指针数组,把表述文件的结构体地址填入到特定下标中。
那么现在就能解释了为什么打开文件返回的是3:新打开一个文件本质是内核会为我们描述struct file结构,再把struct file地址填入到fd_array[]数组下标去,因为012已经被占用了,于是填到3号下标,因此在上层可以拿到3.
这也解释了为什么write和read这样的系统调用接口为什么一定要传入文件描述符fd:执行系统调用接口是进程执行的,通过进程PCB,找到自己打开的文件列表,通过fd索引数组找到对应的文件,从而对文件进行操作。
💜 结论:文件描述符fd,本质是内核中进程和打开文件关联的数组下标
我们可以看看源代码 ——
3.2 理解一切皆文件
对于键盘显示器等等这些外设,一定都有比如像read、write读写方法,因为由冯诺依曼体系结构知,外设是要和内存打交道IO的。这可能有些奇怪,比如键盘能读我知道,但能写吗?难道我键盘安安静静的自己就开始动了?!注意,我们有统一的读写方法,但不代表非要每一个都实现,比如键盘就可以没有写方法,即方法为空。
因为它们的硬件结构不同,这些方法在底层实现是完全不一样的!这些方法都是在硬件的驱动层完成的。那又是如何做到一切皆文件的呢?Linux中做了软件的虚拟层vfs(虚拟文件系统),会统一维护每一个打开文件的结构体struct file.
回忆C++中的多态,我们可以编写一个父类(甚至是纯虚的,相当于定义一个接口类),子类继承父类,重写函数。我们让父类指针指向不同的子类对象,就会调用对应的方法。那么在C语言中,可以通过函数指针,做到调用同一个方法,指向不同对象时可以执行不同的方法,从而实现多态的性质。
我们在每个struct file当中包含上一大坨的函数指针,这样,在struct file上层看来所有的文件都是调用统一的接口;在底层我们通过函数指针指向不同硬件的方法。
同样在继承体系中,我甚至也不关心你到底是那个子类,比如,动物基类Animal被猫狗鸡鸭鹅都继承了,里面有一个eat方法,基类指针指向猫就调用猫的eat,基类指针指向狗就调用狗的eat… 这样看去我们就实现了“一切皆动物”,可以理解为C++的多态是漫长的软件开发摸索中实现**“一切皆…”**的高级版本/语言版本。
在源代码中,struct file就有这样一个结构体指针,它特别像C++中的虚函数表,一堆函数指针,指向底层硬件的操作方法 ——
这样,文件操作 == 进程进行文件操作,通过进程PCB找到文件描述符表,找到对应文件,再由具体操作方法刷新到硬件上,整条逻辑链儿就完整了。
3.3 文件描述符的分配规则
观察如下代码,可以看到,我把0关掉后,再打开文件是分配的文件描述符就是0,把1关掉分配的就是1 ——
💜 文件描述符的分配规则:每次给新文件分配的fd ,是从fd_array[]中找一个最小的、未被使用的作为新的fd .
这其实很好理解,打开的文件要和进程产生关联,就要线性遍历数组中找一个未被使用的下标,填入文件地址。
4. 重定向原理
4.1 输出重定向
细心的你可能已经发现了,刚刚我关闭了0关闭了2,唯独没有关闭1标准输出,那现在我们关闭它。按照文件描述符的分配规则,再打开文件fd 就应该分配的是刚刚关闭的1,那么是不是就该把fd:1打印在屏幕上呢?
却发现,诶?!怎么没有向显示器打印,而是全部打印到文件中呢?(其实已经可见printf底层是向1打印😍)
本来应该显示到显示器中,却被“显示”文件内部,这种行为我们早就知道叫做输出重定向。咱们无意之间居然完成了一次重定向操作,为什么是这样呢?
这是因为,一上来close(1)断开了与显示器文件的联系,相当于置NULL。对于打开新文件的log.txt,由文件描述符分配规则,1又指向log.txt,这很好理解。
那就要思考printf底层是在做什么?它是C中的打印,事实上,它本质是向标准输出(stdout)打印 ——
🌺 关于FILE
stdout的类型是FILE* ,是一个文件指针我们都知道,可是我们从来没有关心过FILE 到底是什么,?我们勇敢推测它就是C语言层面的结构体。
struct FILE
{
};
那么C语言的接口和这些系统调用接口是什么关系呢?
printf、fprintf、cin —— 语言层(都是在语言层面上的封装的函数和对象)
↓ 都是向硬件写入,则一定要调用系统调用接口
open、write、read - fd —— 系统层
以C语言中调用fwrite写为例,这好像是向文件流中写,实际上底层是通过文件描述符写到磁盘上 ——
由此我们可以推知,在C语言FILE中一定包含了fd ,同样可以大胆猜测C++中,cin、cout、cerr这些流对象属性中也一定包含文件描述符——
struct FILE
{
};
回到咱们上文一直说的,语言上in/out/err和系统上的012若隐若现的联系实际上就是一一对应包含的 ——
stdin 标准输入,键盘 --包含--> 0: 标准输入,键盘
stdout 标准输出,显示器 --包含--> 1: 标准输出,显示器
stderr 标准错误,显示器 --包含--> 2: 标准错误,显示器
🔸 现在你就能解释为什么关闭1再打开文件反倒是向文件写入了,printf是向stdout中打印,stdout类型是FILE*,FILE就是一个结构体,包含了一个整数,和系统上的1对应,它只关心1这个数字,不关心数组1下标指向什么鬼文件,现在我们指向log.txt,因此不再向显示器写而而是是向log.txt写。这就是重定向的原理。
来看看C语言中FILE 的定义 ——
typedef struct _IO_FILE
关注其中的_filno ,不同的语言封装的不太一样 ——
struct _IO_FILE {
int _flags;
#define _IO_file_flags _flags
char* _IO_read_ptr;
char* _IO_read_end;
char* _IO_read_base;
char* _IO_write_base;
char* _IO_write_ptr;
char* _IO_write_end;
char* _IO_buf_base;
char* _IO_buf_end;
char *_IO_save_base;
char *_IO_backup_base;
char *_IO_save_end;
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
#if 0
int _blksize;
#else
int _flags2;
#endif
_IO_off_t _old_offset;
#define __HAVE_COLUMN
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};
那我们就可以把结构体指针指向的内容打印出来,证明一下 ——
实际上在如下输出重定向中:echo也是一个命令,把echo进程的显示器文件关掉,再把log.txt文件打开,于是输出的内容被打印到log.txt中 ——
4.2 追加重定向
追加重定向与输出重定向唯一的差别就是在打开方式上,增加O_APPEND选项。
4.3 输入重定向
输入重定向就是把本来应该从键盘获取内容变成从文件中获取。
char *fgets(char *s, int size, FILE *stream);
4.4 dup2
如上我通过关闭文件然后再打开文件这样重定向,但是情况不会总是这样理想。
比如两个文件描述符13都已经被打开,如何实现重定向呢?我们勇敢的推测,既然在语言层调用时接口函数只认1,那么只需要把文件描述符表的3中的内容拷贝到1中 ,就实现了原本应向显示器文件写入,而现在向log.txt写入。
dup2就是用来做这个操作的。
#include <unistd.h>
int dup2(int oldfd, int 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完成一系列重定向 ——
💛 输出重定向
dup2(fd, 1); 本来应该显示到显示器的内容,写入到文件
注意,系统层面,open打开文件时带了选项O_TRUNC,以清空原来内容。而在C语言中"w"也会先把原始文件清空,说明上层封装了这个选项。
💛 追加重定向
只需在输出只写的基础上添加O_APPEND选项
💛 输入重定向
dup2(fd, 0); 原本从键盘读,现在从文件中读。
💜 思考:执行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,会以父进程的为模板初始化自身,因此它们的文件描述符表就是两份完全一样的内容,但是这些打开的文件不会新建,即父子指向同一份文件。
因此父进程如果曾经打开了标准输入、标准输出、标准错误,意味着子进程也会继承下去 (咱们之前就见过父子进程同时向显示器打印的状况,因为父子指向同一份文件)。
这就是为什么我们所有的进程都会默认打开标准输入、标准输出、标准错误,就是因为我们命令行上所有的进程的父进程都是bash ,也就是命令行解释器,那它当然需要打开标准输入输入指令,当然要打开标准输出打印结果,当然要提示错误信息,所以在命令行上启动的所有子进程最终都打开了同一个文件(引用计数)。
关于修改时会不会发生写时拷贝,写时拷贝是通过页表实现的,拷贝的是页表右侧物理内存中的内容,而这种内核数据结构一般是由操作系统直接修改的,与写时拷贝无关。
5. 缓冲区
咱们在学习过程中经常听到缓冲区,但是对于缓冲区的理解是非常肤浅的。缓冲区是什么?缓冲区在哪里?为什么要有缓冲区?语言级和内核中的缓冲区区别在哪里呢?
5.1 引入
引入:如下程序你可以看到,标准输出和标准错误都能向显示器打印,那区别在哪里呢?
现在咱们把它重定向到log.txt中,发现只有标准输出写到了log.txt中,这也好理解,因为> 叫做输出重定向,即把本来应该显示到1号文件描述符的内容写到指定文件中,2号并没有发生改变依旧指向标准错误。
那我想把标准输入和标准输出都进行重定向怎么办呢?咱们可以在命令行上这样写,可以理解为把文件列表中1的内容拷贝到2中去。
但这不是今天的重点。
5.2 语言级缓冲区
你可能已经发现了刚刚咱们输入重定向时有一点点的不严谨,打开文件并没有close(fd),那现在close一下;在刚刚代码的基础上,也close(1),它们做的事儿是类似的 ——
注:不close的时候是这个样子的 ——
前后对比,它们都在呈现了一个现象,close后C语言的写入接口重定向后没有被刷新出来。这怎么回事儿?别急,咱来认识一下缓冲区。
事实上,咱们之前所说的缓冲区,都是指语言级别/用户级的缓冲区,就是由C语言提供的缓冲区。
在C语言中咱们printf/fprintf向stdout写入,本质都是写入(拷贝)到C语言缓冲区中,定期会把C语言缓冲区中的内容拷贝到到内核缓冲区,操作系统再把数据更新到硬件上。
💛 那么C语言缓冲区是如何写入内核缓冲区的呢?相当于把数据写入文件中,则一定需要fd 。
💛 那么既然你说是C语言提供的缓冲区,它在哪里?咱们前文说过FILE中一定封装了一个fd ,其实同时它还维护了与C缓冲区相关的内容,我们再把它的定义贴出来看一看,注意看其中一大坨指针,回想咱们在STL中模拟实现容器时,也用过类似用指针的表示区间的方法 ——
struct _IO_FILE {
int _flags;
#define _IO_file_flags _flags
char* _IO_read_ptr;
char* _IO_read_end;
char* _IO_read_base;
char* _IO_write_base;
char* _IO_write_ptr;
char* _IO_write_end;
char* _IO_buf_base;
char* _IO_buf_end;
char *_IO_save_base;
char *_IO_backup_base;
char *_IO_save_end;
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
#if 0
int _blksize;
#else
int _flags2;
#endif
_IO_off_t _old_offset;
#define __HAVE_COLUMN
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};
所以可不要小瞧了FILE啊,咱们使用stdin/out/err和自己打开的文件时,拿到FILE*即拿到一个FILE。你printf/fprintf都是先写到了文件缓冲区中,即暂存在FILE结构体的内部,并不会直接刷新到外设。
💛 那么何时把FILE中的数据刷新到内核中呢?
💛 用户到内核的刷新策略:(os → 硬件也同样适用,只不过咱们现在不关心)
-
立即刷新(不缓冲) -
行刷新(行缓冲\n),比如显示器就是这种刷新策略 -
全缓冲(缓冲区满了,才刷新),比如向磁盘文件写入
那么当发生重定向(显示器 -> log.txt)时,变为向文件中写入,隐含着行缓冲变为了全缓冲策略!
再来解释如上现象(这你得想着上面那张图):printf/fprintf写入,这一批消息被拷贝到用户缓冲区中,C缓冲区可能并没有被写满,因此这部分内容没有立即被刷新到内核中(全缓冲策略)。那么是close是怎么影响刷新的就关系到咱们看到的现象了:
左图:没有调用close,则在进程退出时,把数据刷新到内核,OS把数据同步到硬件上所以你也看见了。
右图:在进程退出之前,先调用了close,即把文件描述符关了,咱们的数据还在用户缓冲区里,却没有地方去刷新了!因此你什么都没看到。
(可以在关闭之前 fflush(stdout) 强制刷新)
5.3 内核缓冲区
咱们继续看5.1小节的引入代码 ——
重定向后,发现标准错误没有重定向,因为我们是重定向1号文件描述符向log.txt打印,2不受影响照样向显示器写且刷新策略是行刷新,这都没问题。
问题是,重定向后,文件中只有write写入的内容,而printf/fprintf 经过重定向后都没有被写到文件中,这是为啥呢?
printf/fprintf 经过重定向后为什么没有被写到文件中,咱们5.2一直在讲这件事儿,重定向时,原本应该显示到显示器上的内容显示到文件中,潜台词就是行缓冲变为全缓冲,还没来得及刷新就close(1)了,就刷新不到了。
而write是系统调用,没有通过C语言的缓冲区暂存,而是直接写到内核缓冲区,因此关闭文件描述符并不影响。归根结底就是有没有C语言缓冲区的问题。
继续小小修改如上代码,fork创建子进程,我们就是往显示器上打印,如期打印;那我重定向到文件中 ——
我们惊奇的发现重复出现的是使用C接口的时候,而系统调用接口并不受影响!!
没有重定向时,每条消息都有\n ,也就是./redir向显示器打印时,在fork之前这批消息早就被刷新到硬件上了,因此不会打印两份;
而当你重定向时,不再向显示器打印,而是写入到文件中,潜台词就是刷新策略变了,行刷新变为了全缓冲;printf/fprintf/fputs是先写到buffer中,可能没有写满,所以不会立即刷新到内核;这个buffer是C语言上的缓冲区,父进程的缓冲区也是父进程的空间。fork创建子进程,进程退出时要刷新,刷新本质就是一种写入,为了维护代码数据独立性有写时拷贝,父进程子进程谁先刷新时就会发生写时拷贝。因为父子进程都对各自缓冲区的内容刷新,于是就看到了文件中库函数输出的内容输出了两份。
那为什么write没有两份呢?因为它是一个系统调用接口,直接向内核中写,不会存在C缓冲区没来得及刷新的情况。这再次印证了这个缓冲区buffer不在操作系统内,而是在用户层。
如果我在fork之前,强制fflush刷新,就没有这么些事儿了。
所谓的stdout(FILE*)、cin/cout(iostream、fstream所谓的流),说人话就是,类中会包含缓冲区。所以为什么C++中还要给我们提供这个货std::endl,它就是相当于一个\n,会刷新C++流当中的信息到显示器中。
6. 理解文件系统
上文咱们一直在谈论打开的文件,那如果一个文件没有被打开呢?它静静的躺在磁盘上。那咱们现在就要了解一下磁盘上的文件系统。首先要了解磁盘结构,这有助于咱们形象理解“把数据刷新到磁盘”这种话,而不是一听而过。
6.1 了解磁盘结构
磁盘是计算机中的一个机械设备(当然我们目前不考虑SSD、FLASH卡、USB)。
这个磁盘的盘片就像光盘一样,数据就在盘片上放着,只不过光盘是只读的,磁盘是可读可写的。
机械硬盘的寻址的工作方式:盘片不断旋转,磁头不断摆动,定位到特定位置。(我说我的移动硬盘怎么这么响,当时我还以为电脑要报废了)
类比磁带,我们可以把磁盘盘片想象成线性结构。
站在OS角度,我们就认为磁盘是线性结构,要访问某一扇区,就要定位数组下标LBA(logic block address);要写到物理磁盘上,就要把LBA地址转化成磁盘的三维地址(磁头,磁道,扇区)。这种关系类似于我们之前的虚拟地址空间和物理内存。
6.2 文件系统与inode
文件在磁盘上是如何被保存的?文件是在磁盘中的,而磁盘现在被我们想象成一个线性结构。
💜 磁盘空间很大,管理成本高。类比管理我们的国家,我们把土地划分成了块儿,但是光划分了还不行,还要给每个小土地上配上合适的管理班子。因此我们就对大磁盘 ——
? ① 分区:大磁盘 → 小空间,化整为零
? ② 格式化:给每个分区写入文件系统。像生活中的,给陕西省写入陕西省政府领导班子:) 好想去西安看我的老哥哥们
所以现在我们以一个小区域为例,理论上,我能把这100G的小空间管理好,其他空间就复刻我就好啦,因为硬件都是标品,当然了不同分区也可以写入不同的文件系统,现在几乎所有的操作系统都支持多文件系统,但我们先不考虑这个。
每个分区最开始都可以有Boot Block ,是与启动相关的,供启动时查找分区。我们再把剩下的空间继续拆解分组,Block group 0 ,Block group 1 … 那么问题就又变成了如果我能管理好Block Group 0,就能管好1~n这些,因此研究文件系统,就变成研究这一个Block Group 0.
💜 众所周知,文件 = 文件内容 + 文件属性,其中文件内容放在Data blocks中,属性放在inode Table中。
把属性和数据分开存放的想法看似简单,但它们是怎么关联的呢?我们日常好像都是用文件名访问文件,但是在Linux下,在系统层面上,文件名包括后缀没有意义,它是给用户使用的。Linux中真正标识一个文件,是通过文件的inode 编号,一个文件,一个inode(属性集合);一个inode也都有自己的编号。
那么要创建文件就要在inode Table中申请一个未被使用的inode,填入属性;文件中还有内容,inode还用数组存储了相关联的blocks块编号,我们可以简单地理解成 ——
struct inode
{
int inode_num;
int blocks[32];
};
注意区分 inode 和 inode编号。
💜 那么你怎么在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对应的呢?
💜 目录是文件吗?是的!那我创建目录时都做了什么?
既然目录是文件,那么在磁盘上,目录一定有自己的inode ——
那目录的inode中放些什么呢?目录的大小、权限、(链接数)、拥有者、所属组等等 …
那目录有数据块儿block放什么呢?你发现,你所创建的所有文件,其实全部一定放在一个特定的目录下。
用户要用文件名(字符串儿),而系统要的是inode,因此目录数据块儿存的是文件名和inode的映射关系。
我们来总结一下,如下这一系列操作,在系统层面都做了什么 ——
-
创建文件:遍历inode Bitmap位图中找0,申请一个未被使用的inode,填入属性信息。并把这个映射关系写到当前目录的Data blocks中。 -
查看目录:根据目录inode找到与其映射的文件名 -
向文件写入:遍历block Map找到若干未被使用的块儿,将该文件的inode与这些blocks建立映射关系,再向blocks中写入内容。 -
查看文件内容:cat hello.c → 查看当前目录lesson15的data Blocks数据块儿 → 找到映射关系:文件名儿对应的inode编号 → 在inode Table中找到inode → 找到对应的blocks[] → 打印文件内容。 查看文件属性类似。
💜 删除文件做了什么?
不需要改文件的属性inode Table和数据data Blocks,只需要把对应inode编号位在Bitmap中由1置0;再根据属性把使用的数据块儿们也在Bitmap中把它由1置0。所以拷贝一个文件需要一会儿,但是删除很快。
(嘘~ 如果你在Linux系统中,不小心rm -rf误删了文件,最好的做法就是什么也不做!可以尝试debugfs恢复文件,即由0置1。你在windows下删除文件到回收站,其实只不过是转移了目录,在回收站中删掉才是相当于1置0了)
6.3 文件时间 acm
💛 Acess:文件最近一次被访问的时间
我们却发现,实际操作下来文件Acess时间可能没有变化。因为在较新的Linux内核中(看来我的不是较新的),Acess时间不会被立即更新,而是过一定的时间间隔,OS才自动进行更新。因为访问文件是比较频繁的,可能存在刷盘问题,让你的Linux系统变得很慢。
那Change和Modify有什么区别呢?在我Linux的第一篇文章就挖过这个坑~
💛 Change:最近一次修改文件属性的时间
💛 Modify:最近一次修改文件内容的时间
当我们修改文件内容时,有可能修改文件内容的属性,比如文件大小。
Makefile(gcc) 怎么判定源文件是否被修改过( make is up to date.),从而指导系统的源文件要不要编译?就是通过对比源文件和生成的可执行程序的Modify时间。
.PHONY 定义伪目标总是可以被执行,本质就是不关心时间谁新谁旧,直接编译。
7. 软硬链接
好嘞,我们只有理解了inode才能理解软硬链接。
7.1 软链接
💛 建立软链接
ln -s log.txt log_soft
💛 删除链接,可以rm,但是更建议
unlink log_soft
💜 可是啥时候会用到软链接呢?
对于一些执行路径非常深的程序,我们可以通过软链接快速找到它。
现在我不断回退,退到所谓工作目录上来,这时如果我还想运行test.sh就比较麻烦,那么我们就可以通过建立软链接的方式 ——
相当于windows下的创建快捷方式。
7.2 硬链接
💛 那么我们现在不带-s
ln log.c log_hard
我们观察发现,软链接是有自己独立inode的,即软链接是一个独立文件!有自己的inode属性集也有自己的数据块儿 (保存的是它所指向文件的路径 + 文件名)
而硬链接自己没有独立的inode,根本就不是一个独立的文件,本质是在特定目录下,添加一个文件名和inode编号的映射关系。
所谓的链接数,就是硬链接数,即有几个文件指向这个inode.
这个数字存在于inode属性中,建立硬链接就是对ref++;删除某文件即对ref–。直到ref == 0时,这个文件才被干掉,这就是引用计数。
struct inode
{
int inode_num;
int blocks[32];
int ref;
};
💜 啥时候会用到硬链接?
我们发现创建一个文件的硬链接数是1,这好理解;那创建一个目录,默认的硬链接数为什么是2呢?
所以我们在任何目录下的这两个隐藏文件,其中. 和我们的 dir 对应的inode是一个!我们曾一直说过. 表示当前目录,.. 表示上一级目录,这就是硬链接最直观的应用场景。
如果我在dir中再创建一个目录,发现dir的硬链接数变为3了,为什么呢?就是因为subdir中的.. 也是dir的硬链接。
下次更新可能要等到一个月后了,我在备考,最近大家都超忙!!但是我跟我室友比我就是一个大闲人哈哈,小学期恢复更新强度@一个人的乐队🎸
|