本节我们讲解基础IO的部分,将围绕以下内容进行梳理讲解:
- 复习C文件IO相关操作
- 认识文件相关系统调用接口
- 认识文件描述符,理解重定向
- 对比fd和FILE,理解系统调用和库函数的关系
- 理解文件系统中inode的概念
- 认识软硬链接,对比区别
1.C语言文件IO
C语言中的文件操作函数如下:
若想详细了解C语言中文件操作函数的具体使用方法,请跳转博文:C语言文件操作
下面我们对部分文件操作函数进行使用:
- 对文件进行写入操作示例:
#include int main(){FILE* fp = fopen("log.txt", "w");if (fp == NULL){perror("fopen");return 1;}int count = 10;while (count--){fputs("hello tom\n", fp);}fclose(fp);return 0;}
运行程序后,在当前路径下就会生成对应文件log.txt,文件当中就是我们写入的内容。
- 对文件进行读取操作示例:
#include2 3 int main()4 {5 FILE* fp = fopen("log.txt", "r");6 if (fp == NULL){7 perror("fopen");8 return 1;9 } 10 char buffer[64];//定义缓存区 11 while (fgets(buffer, sizeof(buffer), fp)/*按行读取*/ ){ 12 13 printf("%s", buffer); 14 } 15 if(!feof(fp))//判断文件正常退出 16 { 17 printf("fgets quit not normal!");18 } 19 else{20 printf("fgets quit normal!"); 21 } 22 fclose(fp); 23 return 0; 24 } 25
运行程序后,就会将我们刚才写入文件的内容读取出来,并打印在显示器上。
这些是我们学习C语言时接触的文件操作,其实,如果学习文件操作,只停留在语言层面,是很难对文件有一个比较深刻的理解的!
我们知道,任何进程在运行的时候都会默认打开三个输入输出流,即标准输入流、标准输出流以及标准错误流,对应到C语言当中就是stdin、stdout以及stderr。
其中,标准输入流对应的设备就是键盘,标准输出流和标准错误流对应的设备都是显示器。
stdin、stdout以及stderr实际上都是FILE*类型的。
当我们的C程序被运行起来时,操作系统就会默认使用C语言的相关接口将这三个输入输出流打开,之后我们才能调用类似于scanf和printf之类的函数向键盘和显示器进行相应的输入输出操作。
所以说,stdin、stdout以及stderr与我们打开某一文件时获取到的文件指针是同一个概念,这也诠释了所谓的Linux下一切皆文件的含义。
2.系统文件I/O
2.1引入
其实不止是C语言当中有标准输入流、标准输出流和标准错误流,例如C++当中也有对应的cin、cout和cerr,其他所有语言当中都有类似的概念。这种特性并不是某种语言所特有的,而是由操作系统所支持的。
我们所谓的文件操作(显示器、键盘、文件(磁盘)),其实最终访问的都是硬件,而通过下面的图我们知道:
用户想要访问硬件,是必须要经过操作系统的,我们知道,操作系统呢是不相信任何人的,访问操作系统,需要通过系统调用接口,因此操作系统有一套系统接口来进行文件的访问。
注意:我们操作文件使用的C语言接口、C++接口或是其他语言的接口,实际上是对系统接口进行了封装,我们在Linux平台下运行C代码时,C库函数就是对Linux系统调用接口进行的封装,在Windows平台下运行C代码时,C库函数就是对Windows系统调用接口进行的封装,这样做使得语言有了跨平台性,也方便进行二次开发。
2.2接口介绍
open
系统接口中使用open函数打开文件,open函数的函数原型如下:
int open(const char *pathname, int flags, mode_t mode);
open的三个参数
open的第一个参数
open函数的第一个参数是pathname,表示要打开或创建的目标文件。
- 若pathname以路径的方式给出,则当需要创建该文件时,就在pathname路径下进行创建。
- 若pathname以文件名的方式给出,则当需要创建该文件时,默认在当前路径下进行创建。(注意当前路径的含义)
open的第二个参数
open函数的第二个参数是flags,表示打开文件的方式。
其中常用选项有如下几个:
参数选项 | 含义 |
---|---|
O_RDONLY | 以只读的方式打开文件 |
O_WRNOLY | 以只写的方式打开文件 |
O_APPEND | 以追加的方式打开文件 |
O_RDWR | 以读写的方式打开文件 |
O_CREAT | 当目标文件不存在时,创建文件 |
打开文件时,可以传入多个参数选项,当有多个选项传入时,将这些选项用“或”运算符隔开。
例如,若想以只写的方式打开文件,但当目标文件不存在时自动创建文件,则第二个参数设置如下:
O_WRONLY | O_CREAT
扩展:
系统接口open的第二个参数flags是整型,有32比特位,若将一个比特位作为一个标志位,则理论上flags可以传递32种不同的标志位。
实际上传入flags的每一个选项在系统当中都是以宏的方式进行定义的:
例如,O_RDONLY、O_WRONLY、O_RDWR 和 O_CREAT
在系统当中的宏定义如下:
#define O_RDONLY 00#define O_WRONLY 01#define O_RDWR 02#define O_CREAT0100
这些宏定义选项的共同点就是,它们的二进制序列当中有且只有一个比特位是1(O_RDONLY选项的二进制序列为全0,表示O_RDONLY选项为默认选项),且为1的比特位是各不相同的,这样一来,在open函数内部就可以通过使用“与”运算来判断是否设置了某一选项。
int open(arg1, arg2, arg3){if (arg2&O_RDONLY){//设置了O_RDONLY选项}if (arg2&O_WRONLY){//设置了O_WRONLY选项}if (arg2&O_RDWR){//设置了O_RDWR选项}if (arg2&O_CREAT){//设置了O_CREAT选项}//...}
open的第三个参数
open函数的第三个参数是mode,表示创建文件的默认权限。
例如,将mode设置为0666,则文件创建出来的权限如下:
-rw-rw-rw-
但创建出来文件的权限值还会受到umask(文件默认掩码)的影响,实际创建出来文件的权限为:mode&(~umask)。
umask的默认值一般为0002,当我们设置mode值为0666时实际创建出来文件的权限为0664。
-rw-rw-r--
若想创建出来文件的权限值不受umask的影响,则需要在创建文件前使用umask函数将文件默认掩码设置为0。
umask(0); //将文件默认掩码设置为0
注意: 当不需要创建文件时,open的第三个参数可以不必设置
小贴士:
我们可以看到,上面框住的分别是对系统接口进行封装后的C接口和系统接口,实现的功能相同,但是系统接口填写的参数却相对复杂,这就体现了C接口更加友好,有一句话说的很好:不是你的生活充满阳光,而是有人替你负重前行~~
拓展
如果一个文件当前没有被打开,这个文件在哪里?
答:在磁盘
如果我创建一个空文件,该文件要不要占磁盘空间?
答:必须的,因为文件有属性(例如:路径、创建时间、名字等)属性也是数据。
因此磁盘文件=磁盘内容+磁盘属性。所以对文件的操作,就是对文件内容和属性的操作。
open的返回值
open函数的返回值是新打开文件的文件描述符。
我们可以尝试一次打开多个文件,然后分别打印它们的文件描述符。
#include #include #include#include #include int main(){umask(0);int fd1 = open("log1.txt", O_RDONLY | O_CREAT, 0666);int fd2 = open("log2.txt", O_RDONLY | O_CREAT, 0666);int fd3 = open("log3.txt", O_RDONLY | O_CREAT, 0666);int fd4 = open("log4.txt", O_RDONLY | O_CREAT, 0666);int fd5 = open("log5.txt", O_RDONLY | O_CREAT, 0666);printf("fd1:%d\n", fd1);printf("fd2:%d\n", fd2);printf("fd3:%d\n", fd3);printf("fd4:%d\n", fd4);printf("fd5:%d\n", fd5);return 0;}
运行程序后可以看到,打开文件的文件描述符是从3开始连续且递增的
我们再尝试打开一个根本不存在的文件,也就是open函数打开文件失败。
#include #include #include#include #include int main(){int fd = open("test.txt", O_RDONLY);printf("%d\n", fd);return 0;}>>-1
运行程序后可以看到,打开文件失败时获取到的文件描述符是 -1
实际上这里所谓的文件描述符本质上是一个指针数组的下标,指针数组当中的每一个指针都指向一个被打开文件的文件信息,通过对应文件的文件描述符就可以找到对应的文件信息。
当使用open函数打开文件成功时数组当中的指针++,然后将该指针在数组当中的下标进行返回,而当文件打开失败时直接返回-1,因此,成功打开多个文件时所获得的文件描述符就是连续且递增的。
而Linux进程默认情况下会有3个缺省打开的文件描述符,分别就是标准输入0、标准输出1、标准错误2,这就是为什么成功打开文件时所得到的文件描述符是从3开始进程分配的。
关于文件描述符后面会详细讲
close
系统接口中使用close函数关闭文件,close函数的函数原型如下:
int close(int fd);
使用close函数时传入需要关闭文件的文件描述符即可,若关闭文件成功则返回0,若关闭文件失败则返回-1。
write
系统接口中使用write函数向文件写入信息,write函数的函数原型如下:
ssize_t write(int fd, const void *buf, size_t count);
我们可以使用write函数,将buf位置(buf是定义的数组缓存区)开始向后count字节的数据写入文件描述符为fd的文件当中。
- 如果数据写入成功,实际写入数据的字节个数被返回。
- 如果数据写入失败,-1被返回。
对文件进行写入操作示例:
#include #include #include #include #include #include int main(){int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);if (fd < 0){perror("open");return 1;}const char* msg = "hello syscall\n";for (int i = 0; i < 5; i++){write(fd, msg, strlen(msg));}close(fd);return 0;}
运行程序后,在当前路径下就会生成对应文件,文件当中就是我们写入的内容。
read
系统接口中使用read函数从文件读取信息,read函数的函数原型如下:
ssize_t read(int fd, void *buf, size_t count);
我们可以使用read函数,从文件描述符为fd的文件读取count字节的数据到buf当中。
- 如果数据读取成功,实际读取数据的字节个数被返回。
- 如果数据读取失败,-1被返回。
对文件进行读取操作示例:
#include #include #include #include #include #include int main(){int fd = open("log.txt", O_RDONLY);if (fd < 0){perror("open");return 1;}char ch;while (1){ssize_t s = read(fd, &ch, 1);if (s <= 0){break;}write(1, &ch, 1); //向文件描述符为1的文件写入数据,即向显示器写入数据}close(fd);return 0;}
运行程序后,就会将我们刚才写入文件的内容读取出来,并打印在显示器上。
3.文件描述符fd
所有的文件操作,表现上都是进程执行对应的函数。要操作文件必须先打开文件,将文件相关的属性信息加载到内存。操作系统存在大量的进程,因此系统中会也存在大量打开的文件,那么,OS要不要把打开的文件在内存中(系统中)管理起来呢??
答:要
那么如何管理呢?
操作系统会为每个已经打开的文件创建各自的struct file结构体(文件的属性信息等),然后将这些结构体以双链表的形式连接起来,之后操作系统对文件的管理也就变成了对这张双链表的增删查改等操作。
而为了区分已经打开的文件哪些属于特定的某一个进程,我们就还需要建立进程和文件之间的对应关系。
那么进程和文件之间的对应关系是如何建立的?
我们知道,当一个程序运行起来时,操作系统会将该程序的代码和数据加载到内存,然后为其创建对应的task_struct、mm_struct、页表等相关的数据结构,并通过页表建立虚拟内存和物理内存之间的映射关系。
而task_struct当中有一个指针,该指针指向一个名为files_struct的结构体,在该结构体当中就有一个名为fd_array的指针数组,该数组的下标就是我们所谓的文件描述符。
当进程打开log.txt文件时,我们需要先将该文件从磁盘当中加载到内存,形成对应的struct file,将该struct file连入文件双链表,并将该结构体的首地址填入到fd_array数组当中下标为3的位置,使得fd_array数组中下标为3的指针指向该struct file,最后返回该文件的文件描述符给调用进程即可。
因此,我们只要有某一文件的文件描述符,就可以找到与该文件相关的文件信息,进而对文件进行一系列输入输出操作。
注意: 向文件写入数据时,是先将数据写入到对应文件的缓冲区当中,然后定期将缓冲区数据刷新到磁盘当中。
我们可以看到打开文件时返回的文件描述符默认从3开始?也就是Linux进程默认情况下会有3个缺省打开的文件描述符,分别是标准输入0, 标准输出1, 标准错误2。当某一进程创建时,操作系统就会根据键盘、显示器、显示器形成各自的struct file,将这3个struct file连入文件双链表当中,并将这3个struct file的地址分别填入fd_array数组下标为0、1、2的位置,至此就默认打开了标准输入流、标准输出流和标准错误流。
文件描述符的分配规则
文件描述符的分配规则:在files_struct数组当中,找到当前没有被使用的
最小的一个下标,作为新的文件描述符。
4.重定向
重定向的原理
输出重定向原理:
输出重定向就是,将我们本应该输出到一个文件的数据重定向输出到另一个文件中。
例如,如果我们想让本应该输出到“显示器文件”的数据输出到log.txt文件当中,那么我们可以在打开log.txt文件之前将文件描述符为1的文件关闭,也就是将“显示器文件”关闭,这样一来,当我们后续打开log.txt文件时所分配到的文件描述符就是1。
追加重定向原理:
追加重定向和输出重定向的唯一区别就是,输出重定向是覆盖式输出数据,而追加重定向是追加式输出数据。
输入重定向原理:
输入重定向就是,将我们本应该从一个文件读取数据,现在重定向为从另一个文件读取数据。
例如,如果我们想让本应该从“键盘文件”读取数据的scanf函数,改为从log.txt文件当中读取数据,那么我们可以在打开log.txt文件之前将文件描述符为0的文件关闭,也就是将“键盘文件”关闭,这样一来,当我们运行myproc时,就不是从当前键盘读取数据,而是直接从log.txt读取数据,此时log.txt文件时所分配到的文件描述符就是0。
#include #include #include #include #include int main(){close(0);int fd = open("log.txt", O_RDONLY | O_CREAT, 0666);if (fd < 0){perror("open");return 1;}char str[40];while (scanf("%s", str) != EOF){printf("%s\n", str);}close(fd);return 0;}
运行结果后,我们发现scanf函数不是从键盘读取,而是将log.txt文件当中的数据都读取出来了。
说明一下:
scanf函数是默认从stdin读取数据的,而stdin指向的FILE结构体中存储的文件描述符是0,因此scanf实际上就是向文件描述符为0的文件读取数据。
标准输出流和标准错误流对应的都是显示器,它们有什么区别?
下面代码中分别向标准输出流和标准错误流写入了两行字符串(msg1和msg2)
#include #include #include2 3 int main()4 {5 const char *msg1="hello 标准输出\n";6 write(1,msg1,strlen(msg1));7 8 const char *msg2="hello 标准错误\n";9 write(2,msg2,strlen(msg2)); 101112 return 0;13 }
直接运行程序,结果很显然就是在显示器(1,2)上输出两行字符串。
这样看起来标准输出流和标准错误流并没有区别,都是向显示器输出数据。但我们若是将程序运行结果重定向输出到文件log.txt当中,我们会发现log.txt文件当中只有向标准输出流输出的两行字符串,而向标准错误流输出的两行数据并没有重定向到文件当中,而是仍然输出到了显示器上。
实际上我们使用重定向时,重定向的是文件描述符是1的标准输出流,因此这也叫输出重定向,而并不会对文件描述符是2的标准错误流进行重定向。
那么我们可以将标准输出和标准错误都重定向到 log.txt 吗?
答案时可以的:
./redir > log.txt 2>&1
这句命令的意思是:
1.把1的内容指向到log.txt中(./redir > log.txt)
2.把1里面内容拷贝到2里( 2>&1)
3.1.2的内容都指向log.txt
使用 dup2 系统调用
在Linux操作系统中提供了系统接口dup2,我们可以使用该函数完成重定向.
我们知道,想要完成重定向,只需进行fd_array数组当中元素的拷贝即可,也就是改变数组中存储的地址。例如,我们将fd_array[3]当中的内容拷贝到fd_array[1]当中,因为C语言当中的stdout就是向文件描述符为1文件输出数据,那么此时我们就将输出重定向到了文件log.txt。
dup2的函数原型如下:
int dup2(int oldfd, int newfd);
【函数功能】:
dup2会将fd_array[oldfd]的内容拷贝到fd_array[newfd]当中,如果有必要的话我们需要先使用关闭文件描述符为newfd的文件。
【函数返回值】:
dup2如果调用成功,返回newfd,否则返回-1。
使用dup2时,我们需要注意以下两点:
- 如果oldfd不是有效的文件描述符,则dup2调用失败,并且此时文件描述符为newfd的文件没有被关闭。
- 如果oldfd是一个有效的文件描述符,但是newfd和oldfd具有相同的值,则dup2不做任何操作,并返回newfd。
例如,我们将打开文件log.txt时获取到的文件描述符和1传入dup2函数,那么dup2将会把fd_arrya[fd]的内容拷贝到fd_array[1]中,在代码中我们向stdout输出数据,而stdout是向文件描述符为1的文件输出数据,因此,本应该输出到显示器的数据就会重定向输出到log.txt文件当中。
#include #include #include #include #include int main(){int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);if (fd < 0){perror("open");return 1;}close(1);dup2(fd, 1);printf("hello printf\n");fprintf(stdout, "hello fprintf\n");return 0;}
添加重定向功能到minishell
我们之前模拟实现了简易版的myshell,现在我们向其中添加一个重定向功能。
在myshell当中添加重定向功能的步骤大致如下:
- 对于获取到的命令进行判断,若命令当中包含重定向符号>、>>或是<,则该命令需要进行处理。
- 设置type变量,type为0表示命令当中包含输出重定向,type为1表示命令当中包含追加重定向,type为2表示命令当中包含输入重定向。
- 重定向符号后面的字段标识为目标文件名,若type值为0,则以写的方式打开目标文件;若type值为1,则以追加的方式打开目标文件;若type值为2,则以读的方式打开目标文件。
- 若type值为0或者1,则使用dup2接口实现目标文件与标准输出流的重定向;若type值为2,则使用dup2接口实现目标文件与标准输入流的重定向。
#include #include #include #include #include #include #include #include #include #define LEN 1024 //命令最大长度#define NUM 32 //命令拆分后的最大个数int main(){int type = 0; //0 >, 1 >>, 2 <char cmd[LEN]; //存储命令char* myargv[NUM]; //存储命令拆分后的结果char hostname[32]; //主机名char pwd[128]; //当前目录while (1){//获取命令提示信息struct passwd* pass = getpwuid(getuid());gethostname(hostname, sizeof(hostname)-1);getcwd(pwd, sizeof(pwd)-1);int len = strlen(pwd);char* p = pwd + len - 1;while (*p != '/'){p--;}p++;//打印命令提示信息printf("[%s@%s %s]$ ", pass->pw_name, hostname, p);//读取命令fgets(cmd, LEN, stdin);cmd[strlen(cmd) - 1] = '\0';//实现重定向功能char* start = cmd;while (*start != '\0'){if (*start == '>'){type = 0; //遇到一个'>',输出重定向*start = '\0';start++;if (*start == '>'){type = 1; //遇到第二个'>',追加重定向start++;}break;}if (*start == '<'){type = 2; //遇到'<',输入重定向*start = '\0';start++;break;}start++;}if (*start != '\0'){ //start位置不为'\0',说明命令包含重定向内容while (isspace(*start)) //跳过重定向符号后面的空格start++;}else{start = NULL; //start设置为NULL,标识命令当中不含重定向内容}//拆分命令myargv[0] = strtok(cmd, " ");int i = 1;while (myargv[i] = strtok(NULL, " ")){i++;}pid_t id = fork(); //创建子进程执行命令if (id == 0){//childif (start != NULL){if (type == 0){ //输出重定向int fd = open(start, O_WRONLY | O_CREAT | O_TRUNC, 0664); //以写的方式打开文件(清空原文件内容)if (fd < 0){error("open");exit(2);}close(1);dup2(fd, 1); //重定向}else if (type == 1){ //追加重定向int fd = open(start, O_WRONLY | O_APPEND | O_CREAT, 0664); //以追加的方式打开文件if (fd < 0){perror("open");exit(2);}close(1);dup2(fd, 1); //重定向}else{ //输入重定向int fd = open(start, O_RDONLY); //以读的方式打开文件if (fd < 0){perror("open");exit(2);}close(0);dup2(fd, 0); //重定向}}execvp(myargv[0], myargv); //child进行程序替换exit(1); //替换失败的退出码设置为1}//shellint status = 0;pid_t ret = waitpid(id, &status, 0); //shell等待child退出if (ret > 0){printf("exit code:%d\n", WEXITSTATUS(status)); //打印child的退出码}}return 0;}
5.FILE
FILE当中的文件描述符
因为库函数是对系统调用接口的封装,本质上访问文件都是通过文件描述符fd进行访问的,所以C库当中的FILE结构体内部必定封装了文件描述符fd。
FILE当中的缓冲区
我们来看看下面这段代码,代码当中分别用了两个C库函数和一个系统接口向显示器输出内容,在代码最后还调用了fork函数。
#include #include int main(){//cprintf("hello printf\n");fputs("hello fputs\n", stdout);//systemwrite(1, "hello write\n", 12);fork();return 0;}
运行该程序,我们可以看到printf、fputs和write函数都成功将对应内容输出到了显示器上。
但是,当我们将程序的结果重定向到log.txt文件当中后,我们发现文件当中的内容与我们直接打印输出到显示器的内容是不一样的。
那为什么C库函数打印的内容重定向到文件后就变成了两份,而系统接口打印的内容还是原来的一份呢?
这和C语言自带的缓冲区的刷新策略有关。缓冲区有三种缓冲(刷新)方式:
- C语言中当我们直接执行可执行程序,将数据打印到显示器时所采用的就是行缓冲,因为代码当中每句话后面都有\n,所以当我们执行完对应代码后就立即将数据刷新到了显示器上。
- 而当我们将运行结果重定向到log.txt文件时,数据的刷新策略就变为了全缓冲,并不会行读取完立即从缓冲区中刷新打印到屏幕,而是先全部到了C语言自带的缓冲区当中,直到进程退出之后,才会统一刷新,当我们使用fork函数创建子进程时,由于进程间具有独立性,而之后当父进程或是子进程要刷新缓冲区内容时,本质就是对父子进程共享的数据进行了修改,此时就需要对数据进行写时拷贝,因此缓冲区当中的数据就变成了两份,一份父进程的,一份子进程的,所以当重定向到log.txt文件当中printf和puts函数打印的数据就有两份。但由于write函数是系统接口,不会经过C语言中的缓冲区这一步,也就没有进行写时拷贝,因此write函数打印的数据就只打印了一份。
那么这个缓冲区在哪里?
我们常说printf是将数据打印到stdout里面,而stdout就是一个FILE*的指针,在FILE结构体当中还有一大部分成员是用于记录缓冲区相关的信息的。
//缓冲区相关/* 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. */
也就是说,这里的缓冲区是由C语言提供,在FILE结构体当中进行维护的,FILE结构体当中不仅保存了对应文件的文件描述符还保存了用户缓冲区的相关信息。
综上:
printf, fwrite 库函数会自带缓冲区,而 write 系统调用没有带缓冲区。另外,我们这里所说的缓冲区,都是用户级缓冲区。其实为了提升整机性能,OS也会提供相关内核级缓冲区,不过不再我们讨论范围之内。
那这个缓冲区谁提供呢? printf fwrite 是库函数, write 是系统调用,库函数在系统调用的“上层”, 是对系统调用的“封装”,但是 write 没有缓冲区,而 printf fwrite有,足以说明,该缓冲区是二次加上的,又因为是C,所以由C标准库提供
6.理解文件系统
磁盘的概念
什么是磁盘?
磁盘是一种永久性存储介质,磁盘是计算机主要的存储介质,可以存储大量的二进制数据,并且断电后也能保持数据不丢失。与磁盘相对应的就是内存,内存是掉电易失存储介质,目前所有的普通文件都是在磁盘中存储的。
磁盘,磁道,扇区的概念
磁盘的表面由一些磁性物质组成,可以用这些磁性物质来记录二进制数据
磁盘的盘面被划分为一个个磁道,这样的一圈为一个磁道。
磁道是由若干扇区组成,每个扇区代表一个磁盘块,各个扇区存放的数据量都是相同的(一般每扇区是128×22=512字节),故最内侧的扇区数据量密度最高
如何在磁盘中读/写数据
首先需要把 磁头 移动到想要读/写的扇区所在磁道上,然后通过主轴带动磁盘转动,直到对应的扇区经过磁头,才能完成对扇区的读/写操作
通过上图不难发现,可根据(柱面号、盘面号、扇区号)定位任意一个磁盘块。
磁盘分区与格式化介绍
线性存储介质
理解文件系统,首先我们必须将磁盘想象成一个线性的存储介质,即将磁盘空间想象成线性存储的方式。想想磁带,当磁带被卷起来时,其就像磁盘一样是圆形的,但当我们把磁带拉直后,其就是线性的。
磁盘分区
磁盘通常被称为块设备,一般以扇区为单位,一个扇区的大小通常为512字节。我们若以大小为512G的磁盘为例,该磁盘就可被分为十亿多个扇区。
计算机为了更好的管理磁盘,于是对磁盘进行了分区。磁盘分区就是使用分区编辑器在磁盘上划分几个逻辑部分,盘片一旦划分成数个分区,不同的目录与文件就可以存储进不同的分区,分区越多,就可以将文件的性质区分得越细,按照更为细分的性质,存储在不同的地方以管理文件。
例如在Windows下磁盘一般被分为C盘和D盘两个区域。
在Linux操作系统中,我们也可以通过以下命令查看我们磁盘的分区信息:
$ ls /dev/vda* -l
磁盘格式化
当磁盘完成分区后,我们还需要对磁盘进行格式化。磁盘格式化就是对磁盘中的分区进行初始化的一种操作,这种操作通常会导致现有的磁盘或分区中所有的文件被清除。
简单来说,磁盘格式化就是对分区后的各个区域写入对应的管理信息。
其中,写入的管理信息是什么是由文件系统决定的,不同的文件系统格式化时写入的管理信息是不同的,常见的文件系统有EXT2、EXT3、XFS、NTFS等。
EXT2文件系统的存储方案
计算机为了更好的管理磁盘,会对磁盘进行分区。而对于每一个分区来说,分区的头部会包括一个启动块(Boot Block),对于该分区的其余区域,EXT2文件系统会根据分区的大小将其划分为一个个的块组(Block Group)。
注意: 启动块的大小是确定的,而块组的大小是由格式化的时候确定的,并且不可以更改。
其次,每个组块都有着相同的组成结构,每个组块都由超级块(Super Block)、块组描述符表(Group Descriptor Table)、块位图(Block Bitmap)、inode位图(inode Bitmap)、inode表(inode Table)以及数据表(Data Block)组成。
– | – |
---|---|
Super Block | 存放文件系统本身的结构信息。记录的信息主要有:Data Block和inode的总量、未使用的Data Block和inode的数量、一个Data Block和inode的大小、最近一次挂载的时间、最近一次写入数据的时间、最近一次检验磁盘的时间等其他文件系统的相关信息。Super Block的信息被破坏,可以说整个文件系统结构就被破坏了。 |
Group Descriptor Table | 块组描述符表,描述该分区当中块组的属性信息。 |
Block Bitmap | 块位图当中记录着Data Block中哪个数据块已经被占用,哪个数据块没有被占用。 |
inode Bitmap | inode位图当中记录着每个inode是否空闲可用。 |
inode Table | 存放文件属性,即每个文件的inode。 |
Data Blocks | 存放文件内容。 |
理解inode
磁盘文件由两部分构成,分别是文件内容和文件属性。文件内容就是文件当中存储的数据,文件属性就是文件的一些基本信息,例如文件名、文件大小以及文件创建时间等信息都是文件属性,文件属性又被称为元信息。
在命令行当中输入ls -l,即可显示当前目录下各文件的属性信息。
其中,各列信息所对应的文件属性如下:
在 EXT2文件系统的存储方案中,我们通过文件系统的分区可以看到,文件的属性信息和内容是分离存储的,其中保存属性信息的结构称之为inode,因为系统当中可能存在大量的文件,所以我们需要给每个文件的属性集起一个唯一的编号,即inode号。
也就是说,inode是一个文件的属性集合,Linux中几乎每个文件都有一个inode,为了区分系统当中大量的inode,我们为每个inode设置了inode编号。
在命令行当中输入ls -i,即可显示当前目录下各文件的inode编号。
注意: 无论是文件内容还是文件属性,它们都是存储在磁盘当中的。
如何理解属性和数据分开存放
将属性和数据分开存放的想法看起来很简单,但实际上是如何工作的呢?
1.我们通过touch一个新文件来看看如何工作
我们通过图来解释一下
创建一个新文件主要有一下4个操作:
- 存储属性
内核先找到一个空闲的i节点(这里是1314688)。内核把文件属性等信息记录到其中(内部可能是通过结构体存储信息)。 - 存储数据
假设该文件需要存储在三个磁盘块,内核在数据区找到了三个空闲块:300,500,800。将内核缓冲区的第一块数据复制到300,下一块复制到500,以此类推。 - 记录分配情况
文件内容按顺序300,500,800存放。内核在inode上的磁盘分布区记录了上述块列表。 - 添加文件名到目录
我们创建新的文件名为test。linux如何在当前的目录中记录这个文件?内核将入口(1314688,test)添加到目录文件(即将新创建的test文件的文件名和inode指针添加到目录文件的数据块中)。文件名和inode之间的对应关系将文件名和文件的内容及属性连接起来。
2.如何理解对文件写入信息?
- 通过文件的inode编号找到对应的inode结构。
- 通过inode结构找到存储该文件内容的数据块,并将数据写入数据块。
- 若不存在数据块或申请的数据块已被写满,则通过遍历块位图的方式找到一个空闲的块号,并在数据区当中找到对应的空闲块,再将数据写入数据块,最后还需要建立数据块和inode结构的对应关系。
贴士: 一个文件使用的数据块和inode结构的对应关系,是通过一个数组进行维护的,该数组一般可以存储15个元素,其中前12个元素分别对应该文件使用的12个数据块,剩余的三个元素分别是一级索引、二级索引和三级索引,当该文件使用数据块的个数超过12个时,可以用这三个索引进行数据块扩充。
3.如何理解删除一个文件?
- 将该文件对应的inode在inode位图当中置为无效。
- 将该文件申请过的数据块在块位图当中置为无效。
因为此操作并不会真正将文件对应的信息删除,而只是将其inode编号和数据块号 置为了无效,所以当我们删除文件后短时间内是可以恢复的。
为什么说是短时间内呢,因为该文件对应的inode号和数据块号已经被置为了无效,因此后续创建其他文件或是对其他文件进行写入操作申请inode号和数据块号时,可能会将该置为无效了的inode号和数据块号分配出去,此时删除文件的数据就会被覆盖,也就无法恢复文件了。
我们平时所谓的将文件放到回收站,本质只是将文件换了一个目录,清空回收站的操作才相当于Linux中的 rm -f 操作。
为什么我们拷贝文件的时候很慢,而删除文件的时候很快?
因为拷贝文件需要先创建文件,然后再对该文件进行写入操作,该过程需要先申请inode号并填入文件的属性信息,之后还需要再申请数据块号,最后才能进行文件内容的数据拷贝,而删除文件只需将对应文件的inode号和数据块号置为无效即可,无需真正的删除文件,因此拷贝文件是很慢的,而删除文件是很快的。
4.如何理解目录
目录当然也可以被看作为文件。
- 目录有自己的属性信息,目录的inode结构当中存储的就是目录的属性信息,比如目录的大小、目录的拥有者等。
- 目录也有自己的内容,目录的数据块当中存储的就是该目录下的文件名以及对应文件的inode指针。
注意:
每个文件的文件名并没有存储在自己的inode结构当中,而是存储在该文件所处目录文件的文件内容当中。因为计算机并不关注文件的文件名,计算机只关注文件的inode号,而文件名和文件的inode指针存储在其目录文件的文件内容当中后,目录通过文件名和文件的inode指针即可将文件名和文件内容及其属性连接起来。
7.软硬链接
软链接
引入
我们可以通过以下命令创建一个文件的软连接。
$ ln -s test test_1
通过ls -i -l命令我们可以看到,软链接文件的inode号与源文件的inode号是不同的,并且软链接文件的大小比源文件的大小要小得多(因为这里的test文件是空文件,我们并没有写入内容,所以大小为0)。
概念
软链接又叫做符号链接,软链接文件相对于源文件来说是一个独立的文件,该文件有自己的inode号,但是该文件只包含了源文件(这里的test文件)的路径名,所以软链接文件的大小要比源文件小得多。但是软链接文件只是其源文件的一个标记,当删除了源文件后,链接文件不能独立存在,虽然仍保留文件名,但却不能执行或是查看软链接的内容了。软链接就类似于Windows操作系统当中的快捷方式。
快捷方式的理解:Windows中我们下载一个应用,他一般会默认下载到C盘的某个位置,那么我们想打开这个应用,就需要一步步找到下载的路径,然后执行.exe的可执行文件来打开,这样很麻烦,那么如果有快捷方式的话,我们只需要在桌面点击应用的快捷方式就可以直接打开应用了。Linux中打开文件,需要cd到文件所在的目录下,建立软链接,可以无视路径问题直接打开文件。
硬链接
我们可以通过以下命令创建一个文件的硬链接。
$ ln redir.c redir_1
通过ls -i -l命令我们可以看到,硬链接文件的inode号与源文件的inode号是相同的,并且硬链接文件的大小与源文件的大小也是相同的,特别注意的是,当创建了一个硬链接文件后,该硬链接文件和源文件的硬链接数都变成了2。
硬链接文件就是源文件的一个别名,一个文件有几个文件名,该文件的硬链接数就是几,这里inode编号为1314684的文件有redir.c和redir_1两个文件名,因此该文件的硬链接数为2。
与软连接不同的是,当硬链接的源文件被删除后,硬链接文件仍能正常执行,只是文件的链接数减少了一个,该文件的文件名少了一个。总之,硬链接就是让多个不在或者在同在一个目录下的具有不同文件名的文件,同时能够修改同一个文件,其中一个修改后,所有与其有硬链接的文件都一起修改了。
说到硬链接,我们再解释一个问题,你们有没有注意过,我们创建一个普通文件,该普通文件的硬链接数是1,因为此时该文件只有一个文件名。那为什么我们创建一个目录后,该目录的硬链接数是2?
当目录创建后,该目录下默认会有两个隐含文件.
和..
,它们分别代表当前目录和上级目录,因此这里创建的目录有两个名字,一个是dir另一个就是该目录下的.
,所以刚创建的目录硬链接数是2。
通过命令我们可以看到dir和该目录下的
.
的inode编号是一样的,也就可以说明它们代表的实际上是同一个文件。
软硬链接的区别
- 软链接是一个独立的文件,有独立的inode,而硬链接没有独立的inode。
- 软链接相当于快捷方式,硬链接本质没有创建文件,只是建立了一个文件名和已有的inode的映射关系,并写入当前目录。
8.文件的三个时间
在Linux当中,我们可以使用命令stat 文件名来查看对应文件的信息。
这其中包含了文件的三个时间信息:
Access: 文件最后被访问的时间。
Modify: 文件内容最后的修改时间。
Change: 文件属性最后的修改时间。
当我们修改文件内容时,例如文件的大小等属性可能也会随之改变,所以一般情况下Modify的改变会带动Change一起改变,但修改文件属性一般不会影响到文件内容,所以一般情况下Change的改变不会带动Modify的改变。
我们若是想将文件的这三个时间都更新到最新状态,可以使用命令touch 文件名来进行时间更新。
- 当某一文件存在时使用touch命令,此时touch命令的作用变为更新文件信息。
– the End –
以上就是我分享的基础IO,感谢阅读!
本文收录于专栏:Linux
关注作者,持续阅读作者的文章,学习更多知识!
https://blog.csdn.net/weixin_53306029?spm=1001.2014.3001.5343
————————————————