目录
文件回顾
看一下这段代码:
#include <stdio.h>
int main()
{
FILE* pf = fopen("log.txt","w");
if(pf ==NULL)
{
perror("fopen");
return 1;
}
fclose(pf);
return 0;
}
当运行这段代码时,会在当前目录创建一个log.txt文件,那么怎么知道在当前路径下呢,原因是在环境变量中存在pwd,只需要将pwd的内容和文件名粘贴在一起就可以。我们要进行文件操作,前提是我们的程序跑起来了,文件打开和关闭是CPU在执行我们的代码,因此,打开文件本质是进程打开文件!! 那么文件没有被打开的时候,在哪里呢?在磁盘上!进程能打开很多文件吗?可以!系统中可以存在很多进程!很多情况下,在OS内部,一定存在大量的被打开的文件!那么,OS要不要把这些打开的文件进行管理呢?当然要,先描述,后组织 !由此,我们可以预言一下,每一个被打开的文件,在OS内部,一定要存在对应的描述文件属性的结构体,类似PCB。每打开一个文件,都要创建一个结构体,把这些结构体以链表的形式管理起来,对文件的管理就变成了对链表的增删查改。
当我们创建一个新的文件时,显示的文件大小为0,
但是这个文件要不要占据磁盘空间呢?要的!文件=属性+内容,上面显示大小为0实际上是内容为0,我们上面说的每次打开一个文件都会创建一个结构体,这个结构体放的一定是文件的属性。
#include <stdio.h>
int main()
{
FILE* pf = fopen("log.txt","w");
if(pf ==NULL)
{
perror("fopen");
return 1;
}
fprintf(pf,"helloworld,%d,%s,%lf\n",10,"ghs",3.14);
fclose(pf);
return 0;
}
在上面这段程序中,使用到了fopen函数,并且以"w"方式打开,对于fopen函数:
1.如果要打开的文件不存在,就在当前路径下,新建指定的文件
2.如果要打开的文件存在,默认打开文件的时候,就会默认把目标文件清空
如果在使用fopen函数时,以"a"方式打开,就会在原有文件内容上追加。
那么,我们之前好像遇到过这样的代码:
echo "hello ghs" > log.txt
其中,'>'叫做输出重定向,其本质就是向文件中写入,把应该向显示器打印的内容打印到磁盘当中,重定向一定是文件操作,
上面的代码中,先对log.txt中写入"hello ghs",再写入"666666",但是最终文件中只有"666666",每次写入内容都是新的,使用'>'时,都是先清空,再写入,那这样不就是上面以'w'的方式来把文件打开吗?那按照上面以'w'方式打开文件的理解,
> 111.txt
这样就可以创建一个文件,因为'>'会被OS解释为以'w'方式打开,因此,可以通过'>'方式新建一个文件。
如果log.txt原本是有内容,那么
> log.txt
log.txt会被清空,同样是因为'>'会被OS解释为以'w'方式打开,不存在就创建,存在就清空。
另外,当我们使用'>>'时,这个也会被解释为打开文件,是以'a'方式打开。
理解文件
a.操作文件的本质:进程在操作文件。
b.文件在没有被打开的时候,是放在磁盘上的,而磁盘本质上是一个外部设备,外设是一个硬件,所以,向文件中写入本质上是向硬件写入。但是,用户没有权利直接写入,因为OS是硬件的管理者,必须通过OS写入,但是我们没有通过OS写入啊,我们用的是fopen、fwrite、fread、fprintf、scanf、printf、cin、cout等进行操作,所以OS必须给我们提供系统调用(OS不相信任何人),可是,我从来没用过OS提供的系统调用呀?我用的都是C语言提供的,所以我们用的C/C++/...都是对系统调用接口的封装 !所以,访问文件,除了使用语言提供的函数,也可以使用系统调用啊!
事实上,C/C++/其他语言访问文件的方式有些不一样!
先用和认识系统调用的文件操作
首先是open函数:
open函数的第一个参数是文件名,第二个参数是标记位,返回值是文件描述符(file descriptor),失败则返回-1,第三个参数是文件的起始权限(如果打开未创建的文件,那么使用第二个open函数;如果打开已创建的文件,需要设置mode参数),我们还不是很理解这个函数,我们先来用一下这个函数:
#include "stdio.h"
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
//system call
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);//第二个参数的意思是以写方式打开,不存在就创建
if (fd < 0)
{
perror("open");
return 1;
}
return 0;
}
在这个程序中,我们设置初始的权限为0666,但是运行这个程序后,发现生成的log.txt的权限是rw-rw-r--,和我们设置的不一样,这是由于系统默认权限掩码umask的存在,默认权限掩码是0002,所以,如果想按照我们自己设置的初始权限,先可以把umask设置为0000:
umask(0);
那么,在修改了权限掩码之后,有这样一个问题:用系统默认的权限掩码还是自己设置的呢?就近原则!自己没有设置就用系统默认的,有自己设置的就用自己设置的。
除了上面这个问题外,最让我们困惑的其实是上面代码open第二个参数的设置是什么意思?我们先来向这样一个问题,在之前如果我们想给一个函数传递一个标记位,那么就是设置一个int flag;如果想给一个函数传递两个标记位,那么就是设置int flag1;int flag2;int func(...,int flag1,int flag2,..),那如果传10个8个标志位,也要设置10个8个参数吗?显然不太合理!上面open函数的第二个参数可是一个int类型,它有32个bit,所以其实可以用比特位进行标志位的传递,这是OS设计很多系统调用接口的常见方式!这32个比特位其实是一张位图,上面程序open第二个参数传递的其实是宏,为了更好理解,下面自己来设计一个传递位图标记位的函数:
#define ONE 1 // 1 0000 0001
#define TWO (1<<1) // 2 0000 0010
#define THREE (1<<2) // 4 0000 0100
#define FOUR (1<<3) // 8 0000 1000
void print(int flag)
{
if(flag & ONE)
printf("one\n"); //替换成其他功能
if(flag & TWO)
printf("two\n");
if(flag & THREE)
printf("three\n");
if(flag & FOUR)
printf("four\n");
}
int main()
{
print(ONE);
printf("\n");
print(TWO);
printf("\n");
print(ONE|TWO);
printf("\n");
print(ONE|TWO|THREE);
printf("\n");
print(ONE|FOUR);
printf("\n");
}
那么,在理解了这段程序后,上面open的第二个参数O_WRONLY、O_CREAT是只有一个比特位为1的宏,彼此之间宏值不重复,所以我们现在理解函数的成本就变成了认识更多的选项,而大部分的选项都是见名知意的。
再看一个系统调用write:
fd是open的返回值,buf是要写的内容,count是写入的字节数大小。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
umask(0);
//system call
int fd = open("log.txt",O_WRONLY | O_CREAT,0666);
if(fd < 0)
{
perror("open");
return 1;
}
const char* message = "hello Linux file\n";
write(fd,message,strlen(message));
close(fd);
return 0;
}
添加write这一行代码之后,形成的log.txt文件内容结果是:
然后,将message的内容换位"aaaa",重新编译运行:
发现在原来的文件基础上从头开始替换,原因是O_WRONLY默认不清空文件,为了实现覆盖写,可以在第二个参数再加O_TRUNC,
int fd = open("log.txt",O_WRONLY | O_CREAT | O_TRUNC,0666);
上面参数组合的含义是:写方式打开,不存在就创建,存在就先清空!
提示:为了证明O_TRUNC的作用,可以预先在log.txt中写入一些内容,同时注释掉write函数,运行程序后发现log.txt文件被清空了,这就证明了O_TRUNC的作用的作用。
讲到这里,我们很自然想到上面文件回顾中fopen以'w'方式打开文件也是类似的效果,那么,它和刚才的系统调用有什么关系呢?
我们之前还学过'a'-追加写入,那么为了达到这样的效果,我们可以增加O_APPEND参数,
int fd = open("log.txt",O_WRONLY | O_CREAT | O_APPEND,0666);
那么,它和以'a'方式打开文件又是什么关系呢?
上面一共提到了需要掌握的四个参数:O_WRONLY(以写方式打开)、O_CREAT(不存在就创建)、O_TRUNC(存在就清空)、O_APPEND(追加写入)。
到目前为止,我们已经学完了open函数的参数、选项、标记位,但是其返回值一直没有说,那么返回值到底是什么呢?
int main()
{
int fd1 = open("log1.txt",O_WRONLY | O_CREAT | O_APPEND,0666);
printf("fd1:%d\n",fd1);
int fd2 = open("log2.txt",O_WRONLY | O_CREAT | O_APPEND,0666);
printf("fd2:%d\n",fd2);
int fd3 = open("log3.txt",O_WRONLY | O_CREAT | O_APPEND,0666);
printf("fd3:%d\n",fd3);
int fd4 = open("log4.txt",O_WRONLY | O_CREAT | O_APPEND,0666);
printf("fd4:%d\n",fd4);
return 0;
}
运行这段代码:
打印出来的结果是3、4、5、6。奇怪的是,怎么不见0、1、2呢?
0:标准输入 键盘
1:标准输出 显示器
2:标准错误 显示器
同时,在C语言当中,运行程序时会默认打开3个文件流,如下图:
它们对应的类型都叫做FILE*,和C语言的fopen返回值类型一样,这说明什么呢?这说明在C语言中,我们把键盘、显示器也当做文件来看的,如果我们想对键盘和显示器操作的话,也可以使用C语言中stdin、stdout、stderr,那么系统中的0、1、2和语言中的stdin、stdout、stderr有什么关系呢?
上面打印的结果没有0、1、2,并不是0、1、2没有用,而是被占用了,那直接用write往1里写是不是也可以呢?1是程序启动默认打开的,1是显示器,是不是就直接往显示器上打呢?
const char* message = "Hello Linux file!\n";
write(1,message,strlen(message));
现在很好奇的是,为什么向一个数字(文件描述符)里写,就可以向文件里写呢?文件描述符的本质是什么呢?文件映射关系的数组的下标!
无论读写,都必须在合适的时候,让OS把文件的内容读到文件缓冲区中。
那么,open在干什么呢?
1.创建file
2.开辟文件缓冲区的空间,加载文件数据
3.查进程的文件描述符表
4.file地址,填入对应的表下标中
5.返回下标
write、read函数本质是拷贝函数!
可是,0、1、2分别对应键盘、显示器、显示器,这些可都是硬件啊,和普通的磁盘上文件不一样啊,那该如何理解呢?本质上就要理解一切皆文件!那如何理解硬件也是文件呢?
硬件主要有键盘、显示器、鼠标、网卡、磁盘等,虽然这些硬件各异,但是他们都是IO设备,我们关心的无非就是属性和操作方法,它们的属性都有名字、类别、状态,但是它们的值不一样;另外,对于各种硬件,它们都有自己的读方法、写方法,虽然每种设备底层的实现方法肯定不一样,但是把他们的返回值和参数设置成类似的,
在OS内,系统在访问文件时,只认文件描述符fd!那如何理解C语言通过FILE*访问文件呢?FILE是一个C语言提供的结构体类型,里面一定要封装文件fd!那我们来证明一下:
int main()
{
printf("stdout->fd:%d\n",stdout->_fileno);
printf("stdout->fd:%d\n",stdin->_fileno);
printf("stdout->fd:%d\n",stderr->_fileno);
FILE* pf1 = fopen("log1.txt","w");
if(pf1 == NULL) return 1;
printf("fd:%d\n",pf1->_fileno);
FILE* pf2 = fopen("log2.txt","w");
if(pf2 == NULL) return 1;
printf("fd:%d\n",pf2->_fileno);
FILE* pf3 = fopen("log3.txt","w");
if(pf3 == NULL) return 1;
printf("fd:%d\n",pf3->_fileno);
FILE* pf4 = fopen("log4.txt","w");
if(pf4 == NULL) return 1;
printf("fd:%d\n",pf4->_fileno);
return 0;
}
从上面的运行结果可知,所有的C语言上的文件操作函数,本质底层上都是对系统调用的封装!
那么现在我们既可以使用系统调用,也可以使用语言提供的文件方法,但是推荐使用语言提供的文件方法,否则如果使用系统调用,代码不具备跨平台性。
重定向
我们先来看一个函数stat:
stat就是状态的意思,用来获取指定文件对应的属性,可以通过文件描述符fd获取,也可以通过文件路径获取,重点的是buf这个参数,这是一个输出型参数。
我们之前说过,文件=内容+属性,要么是对文件内容做操作,要么是对文件属性做操作,stat这个函数明显就是对属性做操作, 比如我们想要获取文件的大小,可以这样做:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
const char* filename = "log.txt";
int main()
{
struct stat st;
int n = stat(filename,&st);
if(n<0) return 1;
printf("file size:%lu\n",st.st_size);
return 0;
}
我们也可以使用系统调用读取文件里的内容:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
const char* filename = "log.txt";
int main()
{
struct stat st;
int n = stat(filename,&st);
if(n<0) return 1;
printf("file size:%lu\n",st.st_size);
char* file_buffer = (char*)malloc(st.st_size+1);
n = read(fd,file_buffer,st.st_size);
if(n>0)
{
file_buffer[n] = '\0';
printf("%s",file_buffer);
}
return 0;
}
下面我们再来看一段代码:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
const char* filename = "log.txt";
int main()
{
close(0);
int fd = open(filename,O_CREAT | O_WRONLY | O_TRUNC, 0666);
if(fd < 0)
{
perror("open");
return 1;
}
printf("fd:%d\n",fd);
fprintf(stdout,"fprintf,fd:%d\n",fd);
fflush(stdout);
close(fd);
return 0;
}
在这段代码中,把0-文件描述符关闭掉了,运行结果:
在这段代码中,如果把2-文件描述符关闭掉,运行结果:
所以,这里给出一个结论:
文件描述符的分配规则: 进程查自己的文件描述符表,分配最小的没有被使用的fd。
在这段代码中,如果把1-文件描述符关闭掉,运行结果:
什么都没打印出来。这是为什么呢?
在我们运行一个程序时,默认打开标准输入(stdin->0)、标准输出(stdout->1)、标准错误(stderr->2),如果手动把1(标准输出)关闭,再打开一个文件,此时这个文件的fd就是1了,可是printf和fprintf这两个函数只认stdout,stdout里面封装的数字依旧是1,可是内核中1号下标不是指向之前的显示器了,而是指向一个新打开的文件了,此时printf和fprintf就会打印到新打开的文件当中了,如下图:
上面这种本来应该打印到显示器上的,却打印到了文件中,这种技术就是之前学习过的重定向 ,因此,重定向的本质是在内核中改变文件描述符表特定下标的内容,和上层无关!
上面那段代码中,如果把fflush(stdout);这行代码去掉,生成的log.txt文件中什么都没有,这是为啥呢?在C语言中,stdin、stdout、stderr都是struct FILE*类型的,指向的结构体中除了_fileno外,还有语言级别的缓冲区,printf和fprintf都是写到了语言级别的缓冲区里,然后C语言把stdout里面缓冲区的数据通过1号文件描述符刷新到内核文件缓冲区里,然后外设才能看到对应的数据。所以,我们现在应该理解,为什么fflush(stdout)传递的参数是stdout了!因为这个刷新根本不是把内核文件缓冲区的内容刷新到外设上,而是把语言级别的缓冲区通过文件描述符写到对应的内核当中,这是fflush要干的事情!
所以,回归到上面的问题,不加fflush,生成的log.txt文件中什么都没有,这是因为
printf("fd:%d\n",fd);
fprintf(stdout,"fprintf,fd:%d\n",fd);
这两行代码虽然把内容写到了语言的缓冲区里,但是在我正准备进程return之前要刷新的时候,直接(close(fd))直接把文件描述符关了,所以根本没有办法通过文件描述符把语言的缓冲区里的内容刷新到内核中,所以最终就丢失了,就没有写到log.txt中。
现在把close注释掉,
这样不就可以在return的时候把语言缓冲区的数据刷新到内核中了吗!log.txt中就有内容了。
那么我们每次重定向的时候,都要像上面程序那样写吗?我们认识一个新的函数dup2,
这个函数可以在底层帮我们做两个文件描述符下标对应的数组内容之间值拷贝,让newfd成为oldfd的拷贝,比如:
显示器对应的文件描述符是1,新打开的log.txt对应的f文件描述符是fd,现在我的需求是标准输出重定向(本来显示器打印的内容->log.txt),对应代码是dup2(fd,1),把fd下标数组的内容拷贝到1下标数组里,
const char* filename = "log.txt";
int main()
{
int fd = open(filename,O_CREAT | O_WRONLY | O_TRUNC,0666);
dup2(fd,1);
printf("hello world\n");
fprintf(stdout,"hello world\n");
return 0;
}
改变上面文件打开方式的代码:
int fd = open(filename,O_CREAT | O_WRONLY | O_APPEND,0666);
这就是追加重定向:
缓冲区的理解
在计算机中,我们既有用户级缓冲区,又有内核级缓冲区,无论哪种缓冲区,都有两种作用:解耦、提高效率。所谓解耦,就是用户只需要把数据交给内核文件缓冲区就行,无需和硬件打交道,不需要关心怎么刷新到外设,这就是用户和硬件解耦;提高效率,一方面指的是提高使用者效率,用户只需要调用printf和fprintf把数据放到语言缓冲区里即可,而无需关注怎么刷新到内核中;另一方面指的是刷新IO的效率,调用操作系统,是有成本的,所以尽量少调用,效率就高了,当多次调用printf和fprintf时,可能数据都暂时存在语言缓冲区里,没有刷新到内核,当语言缓冲区的数据积攒到一定量后,只调用一次系统调用,就可以把大量数据刷新到内存里,这样效率就提高了。
所以,我们来总结一下缓冲区的理解:
1.是什么:缓冲区就是一段内存空间
2.为什么:给上层提供高效的IO体验,间接提高整体的效率
3.怎么办:
a.刷新策略(针对用户级缓冲区,内核缓冲区我们不关心)
1.立即刷新(相当于无缓存)。fflush(stdout)-->语言级别;int fsync(int fd)-->系统级别,刷新到外设
2.行刷新。显示器,照顾用户的查看习惯。
3.全缓冲。缓冲区写满,才刷新,普通文件。
b.特殊情况
进程退出,系统会自动刷新
强制
下面对比了两组代码:
每打开一个文件都有对应的缓冲区!!
C语言为什么要在FILE中提供用户级缓冲区呢?
这里我们需要记住一个结论:为了减少底层调用系统调用的次数,让使用C语言的IO函数(printf、fprintf)效率更高。
stderr
我们知道,C语言会默认打开三个流:标准输入流stdin--0、标准输出流stdout--1、标准错误流stderr--2。
我们写的程序,本质都是在对数据进行处理(计算/存储/...),对此,我们有三个问题,数据从哪里来、数据去哪里、用户要不要看到这个过程。标准输入流stdin--0是为了从用户那里获取数据,标准输出流stdout--1是为了用户在计算过程中动态看到结果。因为历史原因,用户一直需要知道数据从哪来、数据去哪里这两个问题,所以默认打开0和1,方便用户动态获取数据和查看数据。
我们来看一段程序:
我们可以看到,1和2都会打印到显示器上,说明1和2指向同一个显示器文件,此时,我们再输入一下命令:
我们很好奇,1对应的输出语句已经重定向到了log.txt了,可是2对应的输出语句为啥还是在显示器文件上呢?原因就是," > "这个符号是标准输出重定向,只会更改1号fd里面的内容,其对应的过程如下:
那到底为什么会有2呢?我们平时在写程序时,会有两类消息:一类是正确的,一类是错误的。正确的消息我们往1里打印,错误的消息我们往2里打印,未来我们只需要做一次重定向,就可以把正确的信息和错误的信息在文件层面上就可以分来了。现在,我们看下面的程序:
这里面有正确的消息,也有错误的消息,调试代码时我们希望看到错误的消息,但是这一大堆消息混在一起我们不好找错误消息,我们可以用" > "将常规消息重定向到log.txt中,剩下的消息就是正确的消息:
这样就很明显地看到代码中犯了哪些错误。其实,完整的重定向应该是这样:
实际上,我们也可以将错误消息重定向到另一个文件:
把1和2的消息分开了。示意图如下:
如果我们想把1和2的消息都放在一个文件中,该怎么做呢?
示意图如下:
我们再来看这样几行代码:
运行之后,我们发现其打印结果并没有重定向到log.txt中,其实,perror();的本质是向2打印,而printf()本质是向1中打印,因此,这样就可以通过一次重定向把正确消息放到文件中,而把perror等错误消息打印到显示器上。我们在C++中,可以使用cout和cerr达到这样的效果:
cout就类似于printf,cerr就类似于perror,所以在C++中我们打印错误要用cerr,方便我们把错误信息过滤出来。
以上我们讨论的都是被打开的文件,然而存在很多的文件,被打开的文件只是少量的!没有被打开的文件,在哪里存放着呢? 在磁盘上!我们打开这个文件前,需要在磁盘上找到这个文件,是通过文件路径+文件名找到的,
磁盘文件
看看物理磁盘
这是一张磁盘拆开的照片。发光的一面叫做盘片,伸到里面尖角的叫做磁头,每一面都有一个磁头,
我们知道计算机只认识二进制,也就是0和1,0和1在硬件上是被规定出来的,在不同硬件上的规定可能不同(高/低电平,磁铁的南北极)。
磁盘的存储结构
磁盘是一个机械设备,可以认为磁盘由无数个南北极构成,在磁盘上写0和1本质上是通过磁头改变南北极,磁盘上一圈一圈的是磁道,一个磁道又被划分为一个个扇区,磁盘读写的基本单位是扇区:512字节,4KB,
如何找到一个指定位置的扇区?能找到一个扇区,就可以找到任何一个扇区
a.找到指定的磁头(Header)
b.找到指定给的磁(柱面)(Cylinder)
c.找到指定的扇区(Sector)
通过这三步找到指定扇区的方法叫做CHS定址法。
学到这里,我们就知道为什么盘片是高速旋转,而磁头是左右摆动的?磁头在进行左右摆动时是在寻找当前面的哪一个磁道(定位磁道),而盘片高速旋转时,是找到磁道上的某一个扇区(定位扇区)。我们知道,文件=内容+属性,它们其实都是二进制数据,所以,文件就是在磁盘中占有几个扇区的问题!
对磁盘存储进行逻辑抽象
既然通过CHS定址法我们已经可以对扇区进行定位了,那为什么还要进行抽象呢?因为OS直接用CHS,硬件改变,OS也要改变,耦合度太高,为了方便内核进行磁盘管理。
那么,具体怎么计算呢?假设一面有1000个扇区,10个磁道,也就是一个磁道有100个扇区,那么,现在拿到了扇区的下标index,index/1000就可以H,然后index%1000=tmp~[0,999],也就是在第H面的第tmp个扇区,然后tmp/100=C就可以确定在哪个磁道,然后tmp%100=S就知道在哪个扇区,通过这样的计算流程,只需要知道磁道的下标,就能确定CHS。这样,文件=很多个sector的数组的下标!
一般而言,OS未来和磁盘交互的时候,基本单位是4KB 8个连续的扇区(一个数据块大小),
当知道块号后,*8就知道这个块第一个扇区的下标,也就知道每个扇区的下标了,再用上面的CHS定址法就行。所以,对于OS而言,未来我们读取数据,可以以块为单位了 !!!一个扇区大小是512字节,一个块有8个扇区,所以一个块是4KB。只要知道一个起始,和磁盘的总大小,那么有多少块、每个块的块号、如何转换到对应的多个CHS地址,全都知道了!!其实,上面所说的块号有一个名称叫做LBA(逻辑块地址),这样,就可以得到一个LBA数据,LBA blocks[N],这其实就是先描述后组织,对磁盘的管理就变成对数组的管理,这样,文件=很多个LBA地址。
实际上,磁盘空间还是非常大的,也很难管理,所以就要分区,把一个区管理好,就能把所有区管理好了。什么叫做分区,其实我们只要知道了这个区的起始LBA和结束LBA不就行了吗。分区在我们的电脑上也很常见,我们的C盘/D盘/E盘就是分区之后的结果。
分区后,我们只要记住每个区的起始块号和结束块号是什么,就能确定每个分区的范围。在分完区后,每个区还是有点大,所以我们还要继续分组 ,比如每个组是10GB,那么就有20个组,只要把每个组管理好,就能把所有组管理好,问题转换为管理好一个组,这其实是分治思想。
我们之前知道,文件=内容+属性,内容是数据,属性也是数据,所以,文件在磁盘中存储,本质是:文件的内容+文件的属性数据。Linux文件系统特点是:文件内容和文件属性分开存储。
在每个组中,Data blocks叫做数据区,它是在一个组中占据空间最大的区域,由一个个4KB的数据块组成,只存储文件的内容。
Block Bitmap叫做块位图,其中记录了Data Block中哪个数据块已经被占用,哪个数据块没有被占用,一个块4KB=32768bit,如果Data blocks有10w个数据块,位图有4个数据块(13w多个比特位)就能表示这10w个数据块的使用状态。Block Bitmap中比特位的位置,表示块号,比特位的内容,表示该块是否被占用。所以,只要统计Block Bitmap中有多少个0、多少个1就能知道数据区中多少个数据块被占用。
inode Table叫做i节点表,存放文件属性如文件大小、所有者、最近修改时间等,其基本单位也是块。Linux中文件的属性是一个大小固定的集合体。其实就是一个struct结构,一般叫做struct inode:
cpp
struct inode//文件的属性
{
int size; //文件大小
mode_t mode;//文件权限
int creater;//文件的创建者
int time; //文件创建时间
...
int inode_number;
int datablocks[N];
...
}
每个文件的大小可能千差万别,但是文件的属性大小是一样的,只是属性的值不一样。在Linux中,struct inode的大小一般是128字节。inode Table里也是一个个块,一个块大小是4KB,一个块能存放下4*1024/128=32个struct inode。一个文件对应一个inode, 如果我们要有1w个文件,就需要10000/32=312个块来存文件属性。
inode位图:每个bit表示一个inode是否空闲可用。比特位的位置表示第一个inode(inode number),比特位的内容,表示是否被占用。
但是,struct inode里不包含文件名!那如何找到这个文件呢?在内核层面,每一个文件都要有inode number!我们通过inode号标识一个文件!可以通过-i指令查看其inode号。
那么,假设上层能够拿到inode号,先查inode位图,如果比特位为1,说明是这个位置合法的,然后再去inode Table里找到对应inode,然后就找到了inode属性。我们可以通过inode号找到inode属性,现在的问题是,怎么找到对应的文件内容呢?其实,在struct inode中,还有datablocks[N]这个数组,这个数组会包含这个数组占据了哪些数据块,因为每个数据块都有块号,因此,datablocks[N]这个数组就可以存这个块和哪几个块对应,比如,这个文件和块号为0、1、2、3、4、5的数据块对应,那么datablocks[N]这个数组的内容就依次填写0、1、2、3、4、5。所以,只要知道了inode号,就能知道文件的内容和属性。
GDT,Group Descripter Table,块组描述符,描述这一个块的基本情况,比如这个块多大,一共多少个inode,一共多少个datablocks,已经有多少个inode被使用了,还有多少个datablocks没有被使用,都会记录在GDT中。
Super Block,超级块,描述整个分区的基本情况。你没听错,就是在一个组里的超级块存放了整个分区的情况,比如,一共分了多少个组,每个块组的的基本使用是什么样子,总共有多少block和inode,已经使用了多少block和inode。很好奇的是,Super Block怎么能在块组0里呢?不应该子在最前面单独一块吗?其实,超级块并不是每一个分组都有的,但是也不是在整个分区里只有一份,一般会在2~3个分组里存在,并且这几个分组里的超级块的内容要保持一致,为什么要这么干呢?主要原因是Super Block表示整个分区的使用情况,磁盘是个机械设备,磁盘通过磁头和盘片定位旋转,万一磁头把Super Block对应的扇区刮花了,数据就乱了,整个分区就挂掉了,那就是几百G不能用了。所以出于效率考虑,没必要每个分组都存Super Block,但是它也不止在一个分组里,它在其它分组里还有主要是让我们的文件系统有更好的健壮性,万一某个组的Super Block被刮花了,没关系,我们在其他组也能找到Super Block。
在每一个分区内部分组,然后写入文件系统的管理数据,这个过程叫格式化!!!格式化的本质是在磁盘中写入文件系统!!!
至此,我们已经把文件系统的框架搭建起来,然后我们继续讨论细节:
我们寻找文件的时候,都必须先得找到文件的inode编号,inode编号是以分区为单位进行分配的,并不是以分组为单位,一个分区内部所有的inode号都不能重复,两个分区之间inode号可能会出现重复,所以inode号不能跨分区访问!!
Super Block和GDT会记录下来该分区分组的inode的范围,比如,块组0的inode的范围是0-10000,块组1的inode的范围是10001-20000,依次类推,我们某一个分组的inode号是在一个范围内(比如0-10000)。假设现在我有一个inode号=100010,首先要确定在哪个分区,然后拿着10010在0-10000、10001-20000、20001-30000...这些区间去卡,发现10010在10001-20000之间。
现在假设我们有一个inode编号为50的文件,在这个分区内取寻找它落在哪个区间,发现它落在了0-10000这个分组里,然后50-0(0是这个组起始inode),然后再inode Bitmap里从右向左数50个bit,看它是0是1,从而确定这个inode是不是一个合法inode,然后去inode Table里去找第50个元素,从而找到了文件的属性。再比如现在有一个inode编号为10010的文件,发现卡在了10001-20000之间,那就在组1中,然后用10010-10001=9,然后去inode Bitmap去找第9个就可以,确定这个inode是不是一个合法inode后,然后去inode Table里去找第9个元素,从而找到了文件的属性。
datablock也是按照这样的方式分组的,也有start块号和end块号。当inode映射到某一组后,优先在这个组内部使用它的数据块,一般我们不要跨组访问。
inode属性里有datablocks[N]这个数组,这个数组是多大呢?我们先来说一下,之前我们讨论的文件系统是ext2,是一个比较入门级的,实际上还有ext3和ext4,ext2中的datablocks的N是15,这就意味着,这个inode只能映射datablocks中15个数据块,也就是60KB,这是不是不太对呀???怎么可能所有文件这么小???事实上,datablocks这个数组前12个元素[0,11]是直接一一映射到数据块的,第12号元素不是直接映射,其对应的数据块不保存文件的数据,其对应的数据块中也类似于里面有一个索引数组,再指向其他的很多个数据块,但是这样映射下来也不是很大啊。那么再来看第13号元素,其对应的数据块也是存了索引数组,然后这个索引数组指向的位置也不存数据,也是存索引数组,这样多级映射之后,数据块就很大了,就可以有更大的文件!!!
以上我们都是在已经拿到inode号的基础上,才能找到对应的文件属性和内容,那现在的问题是,怎么拿到文件的inode呢?我们一直用的是文件名啊!!!并且inode属性是不包含文件名的。所以,这就有点尴尬了。
现在,我们需要先谈一下目录。目录其实也是文件,也有文件属性和文件内容,也有自己的inode,
我们发现,普通文件和目录文件的属性都一样,只是属性值不同。普通文件的内容可能是C代码、C++代码、日志等,那目录文件的内容是什么呢???事实上,目录的内容是文件名和inode编号的映射关系!!!
因此,当我们需要访问文件时,需要根据文件名在其所在的目录下找到对应的inode编号,然后根据文件名和inode编号的映射关系,就找到了文件名对应的inode号!
了解到这样的知识后,我们可以来解释这样几个问题:
1.一个目录下不能建立同名文件,因为inode和文件名互为键值,要通过自身能够唯一地找到彼此。
2.查找文件的顺序是根据文件名去找对应的inode编号,然后在所在的分区中确认inode的范围,确定在哪个组里,找到inode Bitmap,确定合法后,再找inode Table,找到对应的属性,再根据inode中属性中的datablock,把对应的数据块全部搞到内存里。
3.我们进入目录需要x权限,现在这不是我们的重点。当我们把目录的r权限去掉之后,我们可以创建文件,但是不能查看文件。目录的r,本质是是否允许我们读取目录的内容,也就是文件名和inode号的映射关系!目录w,当我们要新建文件时,最后一定要向当前所处的目录内容写入文件名和inode的映射关系。
现在我们正面回答上面的问题,怎么拿到文件的inode呢?因为我们一定处在一个目录里面,只要拿到了目录的内容,系统就会根据输入的文件名和inode的映射关系找到该文件对应的inode,然后确定在哪个分区,哪个分组,找到所有的inode属性。
4.如何理解一个文件的增删查改呢?
增:新建文件,就是在特定的分区申请一个inode号,根据inode确定在哪个分组里,然后再inode BItmap里继续从低向高找哪一个比特位为0,然后在inode Table里去分配一个inode空间,把属性一写,然后再去block bitmap里去申请比特位,把内容写到数据块里,然后把块和inode属性建立映射关系,然后把inode号返回,再建立文件名和inode的映射关系。
改 :根据文件名来改。查:也是根据文件名来查。
删:只需要找到inode号在inode Bitmap里的位图,由1置0,然后在Block Bitmap里把对应的数据块位图依次清0,就相当于文件被删掉了。因此,删除一个文件并不需要删除文件的属性和内容,只要对应的位图结构由1置0即可。
现在,还有最后一点问题,我要找到指定的文件,就要找到该文件所在的目录,并把它打开,根据文件名和inode的映射关系,找到目标文件的inode。那么问题来了,如何找到文件所在的目录?就需要先根据目录的名字找到目录的inode,依次类推,最终找到根目录,而根目录的inode是规定出来的,在我们系统开机的时候就确定了。所以,要想找到一个文件,需要进行逆向的路径解析,这是由OS自己做的。这就是为什么我们在linux中,定位一个文件,在任何时候,都要有路径的原因!!
那么,逆向的路径解析这个工作每次都要进行吗?那倒不必!linux系统会对我们常用的路径结构进缓存。
事实上,我们拿到一个inode号,不是先确定它在哪个分组,更前提得是文件在哪个分区!!!
我们使用的云服务器一般只有一个盘(/dev/vda),vda虚拟出来一个分区vda1,在linux中要访问一个分区其实需要挂载这个分区,就是把磁盘分区和一个目录进行关联,
未来进入一个分区本质就是进入这个目录。
我们访问的文件,一定直接或者间接带有路径。一个文件其实在访问之前,都是先有目录的!!那么,我只要对比一下路径的前缀就能确定在哪个分区了,文件在哪个目录里,就知道在哪个分区下了!!所以,目录本身除了可以定位文件,还可以确定是在哪个分区下的!!
Linux内核在被使用的时候,一定存在大量的解析完毕的路径,要不要对访问的路径做管理呢?先描述,再组织!使用struct dentry进行描述。
cpp
struct dentry
{
//路径解析的信息,一个文件一个dentry
}
软硬链接
见一见软硬链接
我们看到,软链接后得到的新文件具有独立的inode(1837947),是一个独立的文件。
当我们建立硬链接后,我们发现硬链接得到的文件的inode和被链接文件的inode一样,此外,文件被链接前后,其某个属性由1->2。
软链接特征及用处
通过以上分析,我们可以得到以下结论:
1.软链接是一个独立的文件,因为有独立的inode number;
2.硬链接不是一个独立的文件,因为没有独立的inode number,用的是目标文件的inode;
3.属性中有一列叫硬链接数 ,是一个引用计数,就是上面由1->2的那个数。这也是文件的磁盘级引用计数,表示有多少个文件名字符串通过inode number指向这个文件,这也是inode的一个属性!
软链接文件的属性第一个字母是l,表示link;由于软链接是一个独立的文件,文件就有内容+属性,软链接的内容是目标文件所对应的路径字符串,其实,软链接类似windows中的快捷方式。那这样说的话,如果我们把软链接删掉,会不会影响目标文件呢?不会!(因为删除快捷方式不会把软件删掉啊!)那我们把目标文件删掉,快捷方式还有用吗?没用了!删除后,查看软链接会闪烁!
那软链接有什么用呢????
例如,我们在./bin/a/b/c/myls这里有一个可执行文件myls,但是很明显这个可执行文件藏的很深,不容易找到,那么就可以在当前文件对myls做一个软链接,
直接运行这个软链接文件就可以运行myls,所以,软链接其实就是快捷方式!!!
再比如,我们有一个很深路径的文件my.conf:
这个文件的路径很深,我们不便查找,所以,我们可以给这个文件加一个软链接,即快捷方式,
这样就可以很方便使用软链接访问这个文件。
在linux库中,我们可以看到:
使用软链接对一些库做了快捷方式,这样,当更新库时,只需要把后面的实际库修改,而无需修改软链接名称,以后可以继续使用这个软链接名称。
硬链接特征及用处
我们上面已经知道,硬链接不是一个独立的文件,那硬链接是什么?我们从上面看到,硬链接和原来的文件对应同一个inode编号,当删除原来的文件后,我们发现这个文件并没有被删除,还可以通过硬链接访问到,引用计数退为1:
其实,硬链接就是一个文件名和inode的映射关系,建立硬链接,就是在指定目录下,添加一个新的文件名和inode number的映射关系!所以,可以有很多个文件名指向同一个inode号。硬链接没有新建文件,因此就不会有新的inode,就不会有对应新的数据块,当有硬链接创建时,对应的引用数据+1。当增加一个硬链接时,引用计数+1,当删除一个inode号对应的文件名时,引用计数-1。上面先建立硬链接然后删除原来的文件,只剩下硬链接,原来的文件仍然可以访问到,这不就是重命名吗!!
通过上面的学习,我们知道,定位一个文件,只有两种方式:
1.通过路径(软链接)
2.直接找到目标文件的inode(硬链接)
但是,无论哪种方式,最终还是要通过inode number找的!
那么硬链接有什么用处呢?
由于目录也是文件,它也有对应的inode。我们发现,新建立的文件的引用计数是1,而新建目录的引用计数是2,这是为什么呢?我们知道,dir和inode是一个映射关系,所以它的引用计数至少是1,同时,任何一个目录里面都有隐藏的.和..,
.表示当前路径,因为它的inode是1837947,和dir的一样,所以.相当于dir的重命名,这就是有两个文件名指向同一个inode,所以引用计数是2。
现在,我在dir里面再建立一个otherdir,
我们发现,dir的引用计数现在变为3,为什么呢?
我们发现,otherdir的..对应inode也是1837947,所以,又有一个文件和这个inode映射,因此,就不难解释为什么引用计数变为3了~这就是为什么cd..可以回退上级目录,因为..指向上级目录,因此**,任何一个目录,刚开始新建的时候,引用计数一定是2,目录A内部,新建一个目录,会让A目录的引用计数自动+1** 。那么根据这个结论,我们还可以知道,一个目录内部的目录数为:A引用计数-2。
所以,我们总结一下硬链接的用处:
1.构建Linux的路径结构,让我们可以使用.和..来进行路径定位。
那么,在Linux中,可以给目录建立硬链接吗?不可以!我们来看一下:
那这是为什么呢?
假设可以给目录建立硬链接,那如果在lesson23里建立了根目录/的硬链接root.hard,如果某一天要查找test.c这个文件,那从根目录深度优先往里找,一直找到了root.hard,而它的属性是d,那就会跳到根目录,这样就形成了路径环绕,因此,为了避免形成路径环绕,不可以给路径建立硬链接。
那有人可能有这样的疑问,dir和dir中的.以及和otherdir中的..不也是路径环绕吗?不会的!因为.和..文件名是固定的,所有的系统指令在设定的时候,几乎都知道.和..是干什么的。
2.硬链接一般用来做文件备份。
我们还可以建立一个backup目录,在这个文件里放需要备份文件的硬链接,这样即使原文件被删除了,我也可以通过硬链接继续访问这个文件!
到此,我们的文件系统已经学完,我们来总结一下文件就是两种:
1.打开的文件:和内核、内存有关
2.没有被打开的文件:和磁盘有关、文件系统有关
我们现在再来看文件操作,通过调用fopen、open系统调用打开一个文件,第一个参数都是先要给文件路径,根据文件路径(文件名)找到inode编号,在磁盘当中定位分区、确定在哪个分组,然后根据inode编号找到文件属性,然后在内核中创建struct file,把inode和struct file关联起来,此时就有了文件的属性,根据加载进来的inode中的那个和数据块有映射关系的属性,在指定的分区分组里拿数据块,加载到文件的内核缓冲区里,让用户通过进程的方式、通过文件描述符的方式从内核拷贝到用户,不就看到文件了吗!后来对文件进行写入时,使用文件描述符和FILE*,使用C++的文件流,把数据写到磁盘里,其实就是先把用户级数据处理完,放到语言级别的缓冲区里,通过文件描述符拷贝到文件的内核级缓冲区,该修改属性就修改属性,该修改内容就修改内容,所谓的刷新就是把属性写回inode,inode一共才128字节,直接全覆盖就行了,然后写回文件的内容,如果少了就释放几个数据块,如果多了就申请几个数据块,重新修改位图,构建映射关系。
另一个问题,我们之前使用文件操作时,会有文本写入和二进制写入,但是在OS上只有二进制写入,那文本写入是什么呢?文本写入其实是语言层的概念,那从磁盘上面的读进来的二进制怎么变成"hello world"这样的文本信息呢?实际上,数据从底层读到缓冲区里,再做语言级的解释就行了!比如,int a =1234567;这个数再大也就占4字节,如果把a直接写入文件,那就是二进制写入,所以占4个字节;如果把1234567转化成"1234567"字符串,这就是以文本写入,那这个转换是谁做的呢?是语言本身的函数在做!比如,fprintf(fp,"hello world:%d\n",a),它就是把原来4字节的a转换成了7字符的字符串"1234567"写入到文件中,所以说文本写入是语言层的概念。再比如,printf("%d",a),本质上是向显示器文件中去打印,打印的其实是7个字符,可是在内存中a是4字节,这其实是printf做的转换!