Linux基础IO
1. 回顾C语言的代码
回顾C语言的文件操作:
c
#include <stdio.h>
int main()
{
FILE *fp = fopen("log.txt", "w");
if (fp == NULL)
{
perror("fopen");
return 1;
}
fclose(fp);
return 0;
}
运行:

上面的代码中,如果我们当前目录没有该文件,就会创建文件,那么它是怎么知道当前目录是什么呢??因为环境变量,它可以直接从环境变量中获取我们当前所处的工作路径。
所以,我们要进行文件操作,前提是我们的程序跑起来了,所谓文件的打开和关闭,本质是CPU在执行我们当前进程的代码,打开文件,是从当前进程的环境变量的工作目录打开文件。
我们在C语言中的学习可以知道,打开文件时,以w方式打开一个文件,就是打开并清空一个文件,如果没有文件就创建一个文件,然后从头开始写,而以a方式打开一个文件,就是打开一个文件,并从这个文件尾部开始进行写入。
关于这里,有点似曾相识,我们在之前的学习中说过这样两个东西:>和>>,也是就是输出重定向和追加重定向,这两个一个是清空文件内容并从头写入,一个是从文件尾部追加写入,那么也就是说,这两个操作符必然伴随着文件操作,而>就类似于C语言中的w方式打开文件,>>就类似于C语言中的以a方式打开文件,而上面说过,w方式打开文件时,如果没有文件则创建文件,有文件则清空文件,>也是支持这两个效果的,所以我们创建文件或清空文件就可以直接输入命令:> filename。
2. 文件理解提炼(初步理解)
- 打开文件,是进程在打开文件。
- 文件没有被打开的时候,文件被存在磁盘上。
- 一个进程能打开很多文件,在很多情况下OS内部一定存在着大量被打开的文件,而这些被打开的文件肯定是要被OS管理起来的,而怎么管理?先描述,再组织。每一个被打开的文件,肯定存在对应的描述文件属性的结构体,类似于PCB。
- 如果我们创建一个空的文件,不往里面写任何内容,它是否需要占据空间?文件=属性+内容,虽然我们没有往里写内容,但是还是有文件属性的,所以需要占据空间。
3. 理解文件
基于之前的知识:
- 操作文件的本质是进程在操作文件
- 文件存在于磁盘中,而磁盘是硬件(外设),也就是说像文件中写入就是向硬件中写入,而用户没有权限直接向硬件中写入,而是需要通过OS写入,我们使用的C语言的文件操作接口,本质上底层就是对系统调用接口的封装。
3.1 文件操作的系统调用
这里介绍最常用的四个接口。
3.1.1 open
打开文件:

参数:
- pathname:文件名,如果带路径,则打开指定路径下的文件,如果不带路径,在当前路径下打开文件。
- flags:标记位,这个标记位实际上是一个位图,每个比特位可以代表一个标志位,用这样的方法就可以一个整数传递多个标记位,对于这里的标记位,系统中定义了一些宏,每个宏代表不同的功能,我们只需要把多个对应的宏进行与操作(|),然后传递,就可以达到传递多个标记位的操作。这里介绍一些常用的标记位:
- O_WRONY:只读
- O_WEONLY:只写
- O_RDWR:读写
- O_APPEND:追加写
- O_CREAT:若文件不存在,则创建文件,需要配合第三个参数mode来指定文件权限,否则文件权限会乱码
- O_TRUNC:如果文件已经存在,且是以写方式打开,清空文件
- mode:如果文件不存在,需要新建文件,mode用于指定该文件的权限,这里设置的权限在实际创建文件时仍然会减去umask(权限掩码)的权限,如果不想受到系统中权限掩码的限制,可以在程序中调用umask系统调用(具体可以使用man手册查看),在当前进程动态的设置权限掩码。
返回值:
- 打开成功,返回文件描述符
- 打开失败,返回-1
3.1.2 close
关闭文件:

参数:
- fd:文件描述符
返回值:
- 关闭成功:返回0
- 关闭失败:返回-1,错误码被设置
3.1.3 write
写入文件:

参数:
- fd:文件描述符
- buf:指定缓冲区
- count:缓冲区的字节数大小
返回值:
- 写入成功:返回写入数据的长度
- 写入失败:返回-1,错误码被设置
3.1.4 read

参数:
- fd:文件描述符
- buf:将文件中的数据读入到这个缓存区
- count:读取的最大字节数
返回值:
- 读取成功:返回成功读取到的字节数
- 读取失败:返回-1,错误码被设置
3.1.5 stat
用于获取指定文件的属性。

c
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
int stat(const char *path, struct stat *buf);
int fstat(int fd, struct stat *buf);
int lstat(const char *path, struct stat *buf);
参数:
- path:指定文件路径
- fd:指定文件描述符
- buf:输出型参数,将会填充指定文件的属性到该指针执行的结构体中
返回值:
- 成功:返回0
- 失败:返回-1,错误码被设置
3.2 文件描述符
在上面介绍的系统调用中,有一个东西叫做文件描述符,这个文件描述符是一个整数,而这个整数是什么?我们可以打印出来看一下:
c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
int fd1 = open("log1.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("fd1: %d\n", fd1);
int fd2 = open("log2.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("fd2: %d\n", fd2);
int fd3 = open("log3.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("fd3: %d\n", fd3);
return 0;
}
运行:

我们可以看到,按顺序打开的文件描述符从3开始递增,为什么不是0/1/2?
因为在一个进程中,会默认打开:标准输入(stdin)、标准输出(stdout)、标准错误(stderr),它们分别对应着0、1、2,而在Linux中,万物皆文件,这三个标准流分别对应着键盘、显示器、显示器,自然也是作为文件被打开的,怎么证明?我们可以直接向1中写入信息,看是否是打印到显示屏上的:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main()
{
const char* message = "hello Linux\n";
write(1, message, strlen(message));
return 0;
}
运行:

可以看到,我们并没有使用printf,依旧把信息打印到屏幕上了。这也证明了我们上面说的。
但是这个文件描述符是一个整数,我们为什么可以向一个整数里面输入内容?这个文件描述符究竟代表什么?
在OS中,肯定会打开很多文件,对于这些文件,OS肯定是要将它们管理起来的,怎么管理?先描述,再组织。对于每个文件,都会有一个struct_file结构体,保存着被打开的文件的属性等信息,OS管理这些文件,就只需要将这些结构体用链表串起来,对这些文件的管理,就是对链表的增删查改。
而对于每个struct_file都会指向内存中的一块文件的内核级缓存,里面存放着文件的数据。
对于每个进程,它们都可能会打开多个文件,而进程中会维护一个files_struct结构体,其中这个结构体中包含着一个指针数组fd_array,这个指针数组存放着指向被打开文件的struct_file的指针,对于这个数组中的每个指针,都会有自己对应的下标(从0开始),而我们上面的文件描述符,本质上就是这个数组的下标,而我们只要通过这个下标就可以访问到我们打开的文件。
而open系统调用做了什么:
- (若文件不存在)创建文件
- 开辟文件缓存区空间,加载文件数据到缓存区空间
- 在进程的文件描述符表中找到最后一个位置
- 将文件的指针填入对应的位置
- 返回该位置下标
3.3 理解Linux一切皆文件
前面说过,Linux管理硬件都是把硬件看成一个一个文件,但是这些硬件各不相同,怎么把它们看成文件来处理的?
对于像键盘、显示器、鼠标、网卡、磁盘这样的硬件,我们都称之为IO设备,对于这些设备来说,要管理起来无非就是管理它们的属性和操作方法。
对于属性来说,就是这些硬件的名称之类的信息,这里不重点讨论,这里把重点放在操作方法。
以读写为例,对于这些硬件来说,它们都会提供读方法、写方法,但是对于键盘这样的设备来说,读方法就是用于读取键盘输入数据的方法,而键盘不需要写入,那么它的写方法就被设为空,相反的我们不需要从显示器读取数据,那么显示器的读方法被置为空。
OS需要将硬件像文件一样管理起来,也就是需要对每个硬件提供一套struct file结构体,将它们的属性和方法存储在其中,而在这个结构体中,有一套函数指针,分别对应着各种方法,使用这种方式管理硬件,只需要将其中的函数指针,指向对应的底层硬件提供的方法,此时如果我们想要访问一个硬件,就只需要调用struct file中函数指针指向的相对应的方法即可,此时我们访问所有的硬件就可以以文件的方式来访问,无需关心其底层差异,这就是Linux中的一切皆文件。
3.4 理解C语言中的FILE指针
经过上面的说明,我们就知道,在系统调用中对于一个文件来说,OS是只认文件描述符这个整数的,那么C语言中为什么是FILE*?
FILE是C语言提供的一个结构体,其中封装了底层的文件描述符,在C语言访问文件的时候,实际上还是拿着这个FILE结构体中的文件描述符去调系统调用,而这个文件描述符FILE结构体中是一个叫做_fileno的变量。
怎么证明?打印出来看看。我们可以直接使用FILE指针来访问该结构体中的_fileno变量:
c
#include <stdio.h>
int main()
{
printf("stdin:%d\n", stdin->_fileno);
printf("stdout:%d\n", stdout->_fileno);
printf("stderr:%d\n", stderr->_fileno);
FILE* fp1 = fopen("test1.txt", "w");
printf("fd1:%d\n", fp1->_fileno);
FILE* fp2 = fopen("test2.txt", "w");
printf("fd2:%d\n", fp2->_fileno);
FILE* fp3 = fopen("test3.txt", "w");
printf("fd3:%d\n", fp3->_fileno);
fclose(fp1);
fclose(fp2);
fclose(fp3);
return 0;
}

这不仅说明FILE结构体中封装了文件描述符fd,也能看到默认打开的三个文件描述符0、1、2,同时我们也可以看到文件描述符的分配规则就是递增分配的。
那么为什么系统调用可以直接操作文件,C语言还要封装一层?
在不同的操作系统中,系统调用可能会有不同,比如Linux打开文件的系统调用是open,而其他的操作系统就不一定是这样,这么一来,在Linux下写的代码换一个平台,也就跑不起来(代码不具备跨平台性),但是我们使用C语言提供的文件操作的接口,哪怕是在Linux下写的代码,放在别的操作系统也能跑,怎么做到的?在C语言的标准库中,对每个不同的平台都进行了系统调用的封装,对于每种平台都提供了调用该平台的库,虽然我们使用的都是C语言接口,但是放在不同的平台上时,它们调用的就是各自平台的系统调用。
同样的,在C++中也有默认打开的cin、cout、cerr,这里面也肯定封装了对应的文件描述符,这里就不再演示。
3.5 Linux的终端
我们可以通过XShell连接同一个Linux,打开多个窗口,而对于这些窗口,就是不同的终端,我们使用printf时,在哪个终端下执行的代码就会打印到哪个终端中,这是怎么做到的,他为什么不打印到别的终端里。
对于一个进程,在/proc目录下,会存在它的一些信息,其中就包括打开的文件,也就在该进程对应的目录下的fd目录中,现在写一个简单的代码,让它一直运行。
c
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
while (1)
{
printf("pid:%d\n", getpid());
sleep(1);
}
return 0;
}

此时我们可以启动另一个窗口查看这个进程的信息,现在来看它默认的开的文件描述符:

我们可以发现,当前默认打开的文件就是/dev/pts/0。
然后,如果我把打开的第二个窗口也执行这个程序,然后在第三个窗口查看第二个窗口执行的进程的默认打开的文件:

可以发现,我们刚刚打开的第二个窗口是对应的文件就是/dev/pts/1。
那么如果我们在第二个终端中打开/dev/pts/0并向里面写入,理论上就可以打印在第一个终端中,验证一下:
c
#include <stdio.h>
2 #include <string.h>
3 #include <sys/types.h>
4 #include <sys/stat.h>
5 #include <unistd.h>
6 #include <fcntl.h>
7
8 int main()
9 {
10 int fd = open("/dev/pts/0", O_WRONLY | O_APPEND);
11 const char *message = "hello file\n";
12 while (1)
13 {
14 write(fd, message, strlen(message));
15 sleep(1);
16 }
34
35 return 0;
36 }

同时,如果我们使用echo加上重定向,也可以向其他终端写入信息:

4. 重定向和缓冲区
4.1 文件描述符的分配规则
来看一段代码:
c
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
int main()
{
int fd1 = open("log1.txt", O_RDWR | O_CREAT | O_TRUNC, 0666);
printf("fd1:%d\n", fd1);
int fd2 = open("log2.txt", O_RDWR | O_CREAT | O_TRUNC, 0666);
printf("fd2:%d\n", fd2);
int fd3 = open("log3.txt", O_RDWR | O_CREAT | O_TRUNC, 0666);
printf("fd3:%d\n", fd3);
close(0);
int fd4 = open("log4.txt", O_RDWR | O_CREAT | O_TRUNC, 0666);
printf("fd4:%d\n", fd4);
close(fd1);
close(fd2);
close(fd3);
close(fd4);
return 0;
}

从这段代码的运行结果可以分析:当分配文件描述符时,该进程会查找自己的文件描述符表,分配自己最小的没有被使用的下标fd。
4.2 初步看现象
先看代码:
c
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
int main()
{
close(1);
int fd = open("log.txt", O_RDWR | O_CREAT | O_TRUNC, 0666);
printf("printf, fd:%d\n", fd);
fprintf(stdout, "fprintf, fd:%d\n", fd);
fflush(stdout);
close(fd);
return 0;
}
上面代码运行后不会有任何输出结果,这很好理解,我们我们使用printf输出和使用fprintf向stdout输出之前,我们已经把stdout这个输出流给关掉了(也就是关掉了默认的1号文件),当然也就不会有内容被打印到屏幕上。
但是我们产看log.txt发现,内容居然被打印到了log.txt上:

原因在于,实际上printf就是向stdout(1号文件)输出,而我们把1号文件(stdout)关闭以后,又打开了一个文件,此时1号文件的位置是空缺的,根据文件描述符的分配规则,我们打开的log.txt文件就变成了1号文件描述符指向的文件,也就是说,此时的stdout指向的文件就变成了log.txt,那么我们再向stdout写入文件,也就是写入到了log.txt。
这种操作十分眼熟,这不就和我们前面见过的echo "helloc world" > log.txt一模一样的吗?echo "helloc world"本来是向屏幕上打印信息,然后我们重定向到log.txt,它就向log.txt文件中打印信息了。
而我们上面的代码,就是重定向。
4.3 理解重定向
重定向的本质,在内核数据结构中改变文件描述符表。
就上面的操作来说,就是将文件描述符表的1号文件描述符指向的内容从屏幕改成了log.txt,对于上层来说,还是向1号文件描述符写入。
4.4 内核级缓存区和语言级缓存区
在上面的代码中,有一行fflush(stdout);,我们理解的这个函数的作用,就是将缓冲区的内容刷新到文件中,但是事实上,我们被打开的文件,在它的内核中有一个文件缓冲区(我们称为内核文件缓冲区),实际上fflush并不是将这个缓冲区的内容刷新到文件,而是在语言层的FILE结构体中,也存在一个语言层的文件缓冲区,fflush是将语言层的文件缓冲区中的内容刷到内核文件缓冲区当中。
怎么证明,我们修改一下上面的代码:
c
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
int main()
{
printf("printf, fd:%d", stdout->_fileno);
close(1);
int fd = open("log.txt", O_RDWR | O_CREAT | O_TRUNC, 0666);
fflush(stdout);
close(fd);
return 0;
}


我们可以发现, 执行代码后,依旧没有输出,而是又输出到了log.txt中,但是我们明明是在关闭stdout之前打印的内容,这个打印的内容应该在stdout的缓冲区里面才对,但实际上,这个内容是在语言级的stdout的缓冲区中,当我们关闭stdout并打开新的文件后,此时的stdout指向了新的文件,然后将缓冲区的内容刷到新的文件的内核缓冲区中,所以就打印到了我们的log.txt中。
4.5 dup2
为了方便我们重定向的操作,不用每次重定向都关闭文件在打开文件这么麻烦,我们只需要使用dup2系统调用就可以完成重定向的操作。

参数:
- 将oldfd指向的内容拷贝到newfd指向的内容中
- 例:
dup2(3, 1)(假设3是我们打开的文件的文件描述符),作用就是将1重定向到3指向的文件描述符,此时1号和3号文件描述符都指向我们新打开的文件描述符。
返回值:
- 成功:返回新的文件描述符
- 失败:返回-1,错误码被设置
4.6 缓冲区的理解
4.6.1 缓冲区是什么
缓冲区本质上就是一段内存空间,用于存放临时的信息。
4.6.2 为什么要存在缓冲区
为了提高效率和解耦。
举个例子,假如小A给小B分别在深圳和北京,在小B生日的时候,小A需要给小B送生日礼物,此时有两种解决方案:
- 小A坐火车到北京,给小B送礼物,然后再回深圳。
- 小A将礼物交给楼下的快递,填好地址付完钱后就什么都不用管了,礼物由快递公司将不同的快递集合起来统一发出。
显然是第二种方式的成本更低且效率更高,并且做到了解耦(将礼物交给快递公司就不必再管后续的事情)。
同样的,在计算机中,每次对文件写入数据,都是有额外的开销的,减少写入的次数就可以节省额外的开销,并且有了缓存区,语言层的东西就只需要交给内核的缓存区就行了,后续的刷新到文件之类的操作,语言层就无需在意了,这也就是为什么我们没有学习OS的知识的时候仍然可以使用C语言来进行文件操作的原因。
4.6.3 缓冲区是怎么做的
刷新策略
- 立即刷新:无缓存,马上刷新
- 行刷新:如显示器(照顾用户的查看习惯)
- 全缓冲:将缓冲区写才刷新,如普通文件
特殊情况
- 进程退出:系统自动刷新
- 强制刷新:使用对应接口强制刷新,如fflush(将C语言中缓冲区的内容刷新到系统)、fsync(将内核缓冲区的内容刷新到外设)
4.7 stdin、stdout、stderr
我们写的程序本质上都是对数据进行处理(计算、存储),那么需要处理的数据从哪里来?到哪里去?为了方便用户使用,就默认打开了这三个文件用于输入和输出信息。
既然stdout和stderr都是将信息输出到屏幕上,那么为什么要分两个?这么做的意义是什么?
先来看一段简单的代码:
c
#include <stdio.h>
int main()
{
fprintf(stdout, "hello stdout\n");
fprintf(stderr, "hello stderr\n");
return 0;
}

运行发现,没毛病,符合我们上面说的,stdout和stderr都是指向了我们当前的屏幕。
但是如果我们使用一个输出重定向:

可以发现,原来向stdout打印的信息被重定向到了log.txt中,而向stderr打印的信息还是在屏幕上,这是因为输出重定向是将1号文件,也就是stdout重定向到我们指定的文件。
这样做,我们就可以将向stdout和stderr打印的信息分离开来。
在我们平常写的程序中,通常有两类信息,分别是正确的信息(debug信息、流程信息等)和错误的信息(报错信息),我们可以将这两种信息分别向stdout和stderr中去打印,当我们需要查看的时候,如果此时我只想要看到报错信息,我们就可以对输出结果进行重定向,此时我们就可以将我们不想要看的信息分离出去,当我们的程序打印的信息非常非常多的时候,这种方法就可以帮助我们很方便的去分离一些信息。
上面的方法是将stdout重定向到指定的文件,那么是否可以将stderr也重定向到指定文件中?

如上图,我们也可以指定一个程序某个文件描述符重定向到指定的文件,如果像我们之前的用法,不指定文件描述符,默认情况下就是将1号文件(stdout)重定向到指定文件中。
如果我们想要把它们两个信息重定向到同一个文件里,我们直接将它们两个指定成同一个文件是不行的:

必须使用下面这种写法:

【补充】:C语言中使用perror函数打印的信息,就是向2号文件(stderr)打印,printf就是向1号文件中打印(stdout)
5. 文件系统
5.1 简单介绍物理磁盘
对应计算机中没有打开的文件,是被存储在物理磁盘上的。
而物理磁盘中存储的都是二进制信息,以机械硬盘为例,机械硬盘里面光盘,我们叫做盘片,盘片上的两面都是可以读写数据的,一个磁盘中可能存在多片这样的盘片,盘片的每一个面都会有一个磁头,磁头用来对盘片进行读写信息,通过盘片的旋转和磁头的左右移动,就可以读取到盘片的所有位置。


这种机械硬盘是一个机械设备,在工作的时候,盘片高速旋转,磁头左右摆动,就可以快速的读写数据。
这里的快速是相对于用户的速度,磁盘是一个外设,对于机械硬盘来说,它还是一个机械设备,就意味着它相对于计算机内部的内存、CPU来说,它还是要慢很多的。
5.2 磁盘的存储结构
对于磁盘的盘片,盘片上非常多很小的类似于磁铁的东西,磁铁是有南极和北极的,对于南极和北极,就能很好的对应0和1,来表示二进制数据,存取数据的时候,只需要将对应的"磁铁"的南北极进行更改。
对于磁盘的结构,我们可以抽象为下图:

对于一个圆形的磁盘,我们从内到外将它分为一圈一圈的区域,每一圈被称为一个磁道,另外的,对于整个磁盘,我们将它多个大小相同的扇形,对于每个扇形区域,我们称为一个扇区(扇区有很多个,为了方便观察,图中没有画那么多),对于这些摞在一起的盘片,相同位置的磁道被称为一个柱面。
对于一个盘片,一个盘片对应着很多个磁道,每个磁道又对应着很多扇区。
磁盘的读写基本单位是扇区,对于每个扇区,可以存储512字节的数据(以512字节为例),在读写时必须以一个扇区为基本单位,这意味着,哪怕你只读写1个字节的大小,也是需要把这512个字节全部读取出来,然后修改,再将这512个字节进行写入。
那么如何找到一个指定位置的扇区(CHS寻址方式):
- 确认在哪一个盘片的哪一面(确认使用哪一个磁头进行读写)
- 找到指定的磁道(柱面),将磁头移动到对应的磁道
- 找到指定的扇区,旋转盘片,就可以通过磁头对指定的扇区进行写入
而文件的存储和读取问题,实际上就是在磁盘中占据哪些扇区的问题,只要找到对应的扇区,然后进行读写。
5.3 对磁盘的存储进行逻辑抽象
用计算机处理现实中的数据,先要对现实中的数据进行逻辑抽象,再存入计算机进行处理,同样的,OS对磁盘进行管理也是要对磁盘进行逻辑抽象的。
并且如果OS直接使用CHS来寻址,耦合度太高,换一个不同规格的硬盘,就要重新进行修改,所以这是为了降低耦合度,同时方便内核进行磁盘管理。
那么如何将磁盘的这种结构进行逻辑抽象?对于磁盘的这些扇区,将这些扇区看成首尾相连接起来,就变成了类似于磁带的结构,这样一个磁盘就被抽象成了类似于一条磁带这样的一条很长的线性结构,对于这样的线性结构,每个单位就是一个扇区。
这不就类似于一个数组吗?对于这个数组,每个元素就是一个扇区。OS对磁盘进行读取,就是将访问这个数组的下标转换成CHS寻址的地址,然后去访问磁盘。
那么这里是怎么转化的?为了方便计算和理解,这里编造一些简单的数据,假设一共有5个盘片,每个盘片2个面,一个盘片有10个磁道,每个磁道有10个扇区,那么抽象出来的数组就有1000个元素,对于这些盘片的面、磁道和扇区我们都分别从0开始编号,对于下标index,对应的扇区就是:第index/100个面的第index%100/10个磁道的第index%10个扇区。
5.4 对于抽象后的磁盘进行管理
虽然一个扇区是512字节,但是对于很多稍大的文件来说,512字节是远远不够的,如果每次使用512字节进行交互,文件一大,效率是非常低的,所以一般而言,OS和磁盘交互的时候,基本单位是4KB,也就是8个扇区,对于这样的每4KB的基本单位,我们称之为一个"块",对于每个块,都有自己的编号(从0开始),OS访问磁盘的时候,只需要拿到块号,然后乘上8,就可以找到对应的扇区的下标,读取连续的8个扇区,就是一个块,这样的块,我们称之为LBA(逻辑区块地址,Logical Block Address)。
对于我们的一块磁盘,是很大的,比如512GB、1024GB,根据我们上面的抽象,就变成了一个很大的数组,这个数组里面每个元素装着一个块,但是这也还是太大了,这时我们想将这个磁盘分成不同的几个区域进行管理,就可以对磁盘进行分区,就像我们的计算机中,将磁盘分成C盘,D盘,......我们只需要对其中一个磁盘管理好,将管理的这一套方案复制到其他磁盘中,我们也就能将其他的磁盘也管理好。
那么我们怎么管理一个分区?对于一个分区,比如200GB,还是有点大,我们先将它分为很多个小的分组,现在只需要研究如何管理每一个分组,就可以管理好这一整个分区。
对于上面的思想,就是分治思想(类似于管理一个大的国家不好管,就先分成多个省,省再分成市,市再分为区/县,......)。
5.5 对于每个分组的管理
对于文件,有两个部分组成,分别是文件内容和文件属性,在Linux中,文件的属性和内容是分开进行存储的。
对于每个分组的结构,如下图所示:

每个block group,就是一个分组,在分组之下还有几个不同的区域,分别有不同的功能:
- Super Block:超级块,存放着整个分区的信息,如当前分区分了多少个block group、总共有多少inode和block、最近一次写入时间等,超级块并非每个block group都有,根据不同的文件系统,在2-3个block group中存在一个超级块,每个超级块中的内容必须保持一致。这个设计是为了使文件系统具有健壮性,万一某个超级块挂掉了,还有其他的超级块可以用,不至于让整个分区都挂掉。
- Group Descriptor Table:块组描述图,其中记录着这个block group的使用情况,如使用了多少inode?有多少inode没有被使用?使用了多少data block?多少data block没被使用?
- Block Bitmap:块位图,在数据块中有多少个块,这个位图的大小就有多少个比特,这里分配的大小也是4KB的整数倍,也就是n个块,其中这个位图中,第几个比特位就代表Data blocks中的第几个块,当这个块被使用,就将对应的比特位置1即可。
- Inode Bitmap:inode位图,比特位的位置代表第几个inode,标记当前inode是否被使用。
- Inode Table:inode表,基本单位也是块(4KB),在Linux中,每个文件的属性是一个大小固定的集合体,每一个文件的集合体就是一个inode,而一个inode的大小是128字节,也就是一个块中可以存放32个inode。
- Data blocks:数据块,用于存放文件内容(在整个分组中占据空间最大的区域),而在这部分内容里,就是一个个4KB大小的块,每个块都有自己的块号,对于这些块,里面只存储文件的内容。(对于一个文件,哪怕文件内容大小并不是4KB的整数倍,如7KB,也是需要占有两个块的,只不过最后一个块没有完全用上)。
格式化:在每个分区内部分组,然后写入文件系统的管理数据。
其中,对于每个文件,都有自己的文件名,但是,在inode中是不包含文件名的,而我们标记一个文件,使用的是inode号,怎么查看,使用ls命令带上-i选项即可查看:

我们只能通过inode号找到一个文件的inode,在inode中有一个数组,而这个数组中,存放着当前文件使用的块的块号,我们可以通过块号到对应的块中去找到当前文件的内容。
inode号是以分区为单位进行分配,而不是以分组为单位进行分配的,两个分区中可能会有相同的inode号,Super Block和Group Descriptor Table会记录当前分区和分组的inode号分配情况。data block也是类似。
上面说过,每个文件的inode大小是一样的,这样的话,inode中用于存储文件内容块号的数组(int datablocks[])的大小肯定也是固定的,如果一个文件特别大,这个datablocks中记录不下怎么办?
在datablocks中有一部分元素是直接指向存储文件内容的数据块,对于这部分的寻址我们称为直接寻址,还有一部分元素,它们虽然也是指向数据块,但是这个数据块中也形成了类似于datablocks的结构,用于存放指向文件内容数据块的块号,这种我们称为间接映射,这种结构可以有多级,就可以存下很大的文件。
我们平时使用的都是文件名,怎么映射到的inode?
这里就涉及到目录,目录也是文件,也有自己的文件属性和文件内容,文件属性和其他普通文件类似,重点在于文件内容,目录的文件内容就是文件名和inode号的映射关系,文件名和inode号之间是一一对应的关系。
而删除一个文件,就是通过文件名拿到inode,通过inode找到Block Bitmap和Inode Bitmap将对应位置置为0即可,也就是说删除一个文件并没有把内容全部删除,而是宣布该数据块内容无效,这也就是为什么你复制一个非常大的文件需要一定时间,而删除它几乎只要一瞬间。
5.6 分区与挂载
在不同的分区里,可能存在相同的inode号,那我们应该去哪个分区找到这个inode?
在Linux中,关于不同的的分区,是需要挂载起来才能使用的,进入这个分区,就是进入这个分区挂载到的目录,我们可以通过df -h来查看当前挂载的分区:

在这里演示的系统下就是将/dev/mapper/centos-root这个分区挂载到了/下,所以我们进入/就是进入了这个分区,我们进入了当前分区后,在这个分区里做的操作都是对于这个分区的,也就是说我们在这里通过inode号找到的inode就是在当前分区找到的。
接下来模拟一下挂载的过程:
-
使用
dd if=/dev/zero of=disk.img bs=1M count=10命令创建一个大小为10M,内容为全0的空文件,用来模拟一个分区

-
将这个文件格式化
mkfs.ext4 disk.img
-
创建一个目录用于挂载该分区

-
使用
sudo mount disk.img mymnt将disk.img挂载到mymnt
而进入mymnt目录就是进入了这个分区,在这个目录下的所有操作就是对这个分区的操作。
而我们进行文件的操作时,只需要对比该文件目录的前缀和系统中已经挂载的分区,就可以知道该文件属于哪个分区下。
根据上面的描述,如果每次操作一个文件都要进行一次解析,就会非常麻烦,非常影响效率,为了提高效率,Linux系统内部会缓存当前已经解析好的路径结构,对这些结构管理起来,对于每个文件,OS中存在着一个对应struct dentry结构体进行管理,对于多个struct dentry使用链表串联起来,我们再次操作文件的时候,OS就可以从已经缓存的路径中寻找。
6. 软硬链接与动静态库
6.1 创建软硬连接
6.1.1 建立软连接
ln -s 目标文件名 软连接名

6.1.2 建立硬链接
ln 目标文件名 软连接名

6.2 特征
通过上面创建的链接和ls看到的信息我们可以得到以下结论:
- 软连接是一个独立的文件,它有独立的inode号,软连接文件的内容就是目标文件对应的路径字符串,我们可以直接通过软连接访问目标文件,类似于Windows下的快捷方式,当目标文件被删除时,软连接将直接失效。
- 硬链接不是一个独立的文件,它没有独立的inode号,它的inode号与目标文件的inode号相同,前面说过,在目录文件中,文件内容就是一对对的文件名和inode号的映射关系,硬链接本质上就是一个文件名和inode号的映射关系。
- 上面图片的文件属性中,在权限后面有一个数字,代表这个文件的硬连接数,建立一个硬链接,就会将这个文件的引用计数加1,当我们删除一个文件时,会将一个文件的引用计数减1,当一个文件的引用计数被减到0的时候,这个文件才会真正被删除。
6.3 什么是软硬连接,软硬连接有什么用
6.3.1 软连接的作用
软连接的作用和Windows的快捷方式几乎是一摸一样的,就是当一个可执行文件在一堆文件里面或在深度较深的目录中,我们不方便找但又经常需要使用的时候,就可以在我们常用的目录下,创建一个软连接,使用这个可执行程序的时候,通过这个软连接即可。
6.3.2 硬链接的作用
我们创建目录的时候,可以看到:

目录默认就是有两个引用计数的,这是为什么?
因为我们在创建目录的时候,在这个空目录中,默认就会有两个目录文件,分别是.和..,其中.代表这个文件本身,也就是指向这个目录文件的,.就是这个目录文件的一个硬链接,同时..指向的是上级目录,也就是上级目录的硬链接,所以每当创建一个新的目录的时候,上级目录的引用计数也会加1
结论:
- 任何一个目录,初始引用计数一定是2
- 在目录A中新建一个目录B,目录A的引用计数会加1
- 一个目录中的目录数量,就是它的引用计数-2
【注意】在Linux系统中,不允许为目录创建硬链接,原因是为了避免形成路径环绕。
硬链接还可以用来进行文件备份,当创建硬链接后,删除原来的文件,并不会让这个文件真正删除,还是可以通过这个硬链接访问。
7. 动态库和静态库
7.1 C标准库
写一段简单的代码:
c
#include <stdio.h>
int main()
{
printf("hello world!\n");
return 0;
}
在这个简单的代码中,我们使用的printf函数并不是我们自己写的,而是在库里面调用的,而这个库就是C标准库。
在编译后,我们可以使用ldd命令查看当前可执行文件链接了哪些库

上面的库中libc.so.6就是C标准库,它的位置是在/lib64/libc.so.6,我们可以查看这个文件的属性:

我们可以发现,这个文件就是一个软链接,指向真正的C标准库。
对于动静态库:
在Linux下,.so后缀的库代表动态库,.a后缀的库代表静态库
在windows下,.dll后缀的库代表动态库,.lib后缀的库代表静态库
7.2 库的制作和使用
首先来写几段简单的代码:
c
// myfunc.h
#pragma once
#include <stdio.h>
void Print();
c
// myfunc.c
#include "myfunc.h"
void Print()
{
printf("hello world!\n");
}
c
// mymath.h
#pragma once
int Add(int, int);
int Sub(int, int);
c
// mymath.c
#include "mymath.h"
int Add(int x, int y)
{
return x + y;
}
int Sub(int x, int y)
{
return x + y;
}
7.2.1 静态库的制作和使用
-
形成对应的.o文件

-
使用
ar -rc lib库名.a .o文件
-
一般我们会将库和头文件打包到一起,方便查看和管理

-
将库安装到当前系统(将头文件和库文件拷贝到系统的头文件和库文件的列表中)

-
尝试编写代码
c#include <stdio.h> #include <myfunc.h> #include <mymath.h> int main() { Print(); printf("%d+%d=%d\n", 1, 2, Add(1, 2)); return 0; }编译报错:

原因:编译器不认识第三方库,在编译时要添加选项
-l库名,注意,这里的库名不是上面的libmyc.a,而是去掉前缀和后缀的myc,即gcc main.c -lmyc
-
同时也可以不安装到系统里,直接使用:
我们在编译时只需要加上几个选项
gcc 源文件 -I 头文件路径 -L 库路径 -l库名即可。
-
如果不想在编译时指定头文件的位置,也可以修改头文件的引用:
c#include <stdio.h> #include "mylib/include/myfunc.h" #include "mylib/include/mymath.h" int main() { Print(); printf("%d+%d=%d\n", 1, 2, Add(1, 2)); return 0; }
7.2.2 动态库的制作和使用
-
生成.o文件,此时需要加一个选项
fPIC
-
将.o文件打包
直接使用
gcc -shared .o文件 -o lib动态库名.so
-
把动态库放进我们上面的指定位置(mylib/lib/)
-
我们先使用和静态库一样的编译方法试一试,由于我们动态库和静态库放在同一个目录中,编译时将会优先使用动态库的方式进行编译,除非我们加上-static选项,可以使用ldd命令查看该可执行程序链接的动态库

-
此时我们发现,虽然链接上了,但是还是无法运行,这是因为我们的程序在运行的时候需要找到加载并运行的动态库。外面在编译时加的选项是告诉编译器需要在这个路径下找到库,而我们运行是操作系统来运行的,操作系统并不知道动态库在这个路径下面,它只会去默认路径(/lib64)下找库,没有找到动态库就无法运行。
-
解决办法:
方法一:将动态库拷贝到/lib64

方法二:在/lib64下建立一个软连接指向我们的库

方法三:将我们的库所在目录的路径添加到环境变量LD_LIBRARY_PATH(用于搜索动态库的环境变量)
内存级添加(重新登陆就没了):

修改配置文件:
将该路径添加到用户家目录下的.bashrc配置文件中,然后重新登陆即可

方法四:
在/etc/ld.so.conf.d目录下增加一个配置文件(以.conf结尾):

在配置文件中添加我们的库的路径:

使用ldconfig命令让配置文件生效即可:

7.2.3 动静态库的链接规则
编译时的链接规则:
-
在不加-static选项时,有动态库则优先链接动态库,没有动态库的情况下才链接静态库
-
在加-static选项时,强制链接静态库,在没有静态库的情况下,哪怕有动态库也直接报错
7.3 库的本质
库的本质上就是把所有的.o文件进行打包。
7.4 库的作用
我们可以直接调用别人开发好的库来进行我们自己的开发,不用从零开始搓轮子,提高开发效率。
7.5 动态库加载---可执行程序和地址空间
7.5.1 未被加载到内存的程序是否有"地址"?
我们的可执行程序和库本质上就是一个文件,开始时存在磁盘里。
我们运行可执行程序时,需要先将磁盘中的可执行程序加载到内存中,此时如果我们的可执行程序链接了动态库,也需要把动态库加载到内存中。
可执行程序在运行的过程中需要动态库在内存中的地址才能将跳到动态库里去执行代码,所以动态库在加载之后,也需要通过当前进程的页表,将地址映射到该进程地址空间中堆栈之间的共享区。
动态库在加载到内存后,是可以被很多个进程共享的,并不是只为一个进程服务,所以当内存中已经加载了相应的库以后,新的进程就不会再加载一份动态库,而是直接将内存中已经存在的动态库通过页表映射到自己的地址空间,这样就能实现多个进程共享一个动态库,所以动态库也被称为共享库。
那么现在又有一个问题,我们的代码编译后,在没有加载运行的情况下,该代码中是否存在"地址"?
写一段简单的代码看一下:
c
#include <stdio.h>
int Sum(int top)
{
int ret = 0;
for (int i = 1; i <= top; i++)
ret += i;
return ret;
}
int main()
{
int top = 100;
int result = Sum(top);
printf("result: %d\n", result);
return 0;
}
编译后使用objdump -S a.out命令进行反汇编(展示部分):

我们发现里面的代码都是有地址的,我们的代码在编译后都是进行了编址的。
7.5.2 可执行程序的加载
我们在Linux形成的程序,一般是ELF格式的可执行程序,二进制可执行程序是有自己的固定格式的,如elf可执行程序的头部,属性等。
当我们的代码编译后,我们的函数名是否还存在?不存在了,而是变成了地址。
其中每一条语句都有自己的地址。
此时程序没有被加载到内存中,它是如何编址的?
地址种类有相对编址和绝对编址:
相对编址就是给函数的头部编一个地址,然后里面的语句都是跟在这个地址后面的,只需要使用函数头的地址加上偏移量就可以找到每一条语句。
绝对编址就是像上面一样,每一条语句都从0开始分配一个绝对的地址,这种编址模式也叫平坦模式。
对于上面的代码的编址,就是逻辑地址,在平坦模式下,我们也可以将它看为虚拟地址,它们是等价的。
在我们运行程序时,会有一个叫加载器的东西先将动态库加载到内存中,在我们程序的ELF头部信息存放着一些信息,只需要使用ELF+加载器就可以获取到各个区域的起始地址和结束地址(如main函数的起始地址)。
而在进程加载的时候,需要创建自己的地址空间,而地址空间里面需要存放各个区域的起始地址和结束地址,此时代码还没有加载到内存当中,地址空间的构建就需要从可执行程序的ELF头部信息里获取这个程序的信息(各个区域的起始和结束地址)。
所以,对于虚拟地址空间,并不是操作系统独有的概念,而是需要操作系统、加载器、编译器同时支持。
此时,在虚拟地址空间中,存放的是编译时就已经决定的虚拟地址,而程序加载到内存后,肯定会有实际上的物理地址,这两者之间需要一一对应起来,这时就需要页表进行映射。
随后,CPU就可以拿到main函数入口的虚拟地址地址开始运行,将虚拟地址通过页表转化为物理地址执行代码。
7.5.3 动态库的加载
动态库的编译和加载跟上面的可执行程序是类似的。
在运行程序的时候,运行到库中的函数时,发现库还没有加载(页表里找不到对应的地址,发生缺页中断),此时就找到库,加载到物理内存中,此时动态库就有了物理地址,然后将库的物理地址填充到页表中,此时程序就可以继续运行下去。
在动态库里的编址,是从0开始的绝对编址,当加载库的时候,在进程的地址空间中,分配一段共享区用来放库的地址,而这里给库分配的第一个地址就是这个库的起始地址,因为库是从0开始编址的,只需要拿到库在编址时的逻辑地址作为偏移量,再加上这里的起始地址,就可以找到对应的语句拿到页表中转换成物理地址去执行。
所以库函数的调用,也是在地址空间内来回跳转。
库在内存中由OS维护,先描述再组织,链表连接。