目录
1. 前置知识
1. 从本质上来说,文件其实就是内容加上属性。而一个文件一般都是先拥有属性,再拥有内容的。
2. 文件分为未打开的文件和打开的文件。
3. 对于未打开的文件,它们存放在哪呢?——磁盘,对于未打开的文件,我们最关注的是什么呢?我们要知道,在整个磁盘中,没有被打开的文件是相当多的,因此最值得被关注的应该是:文件如何被分门别类的放置好,以便我们快速的进行增删查改。
4. 对于打开的文件,它们是由谁来打开呢?——进程,所以研究打开的文件本质上就是研究进程和文件的关系。
之前我们学习冯诺依曼体系时,我们知道一个文件要想被打开,首先要加载到内存。对于一个进程来说,一个进程可能会打开多个文件,所以在操作系统内部,一定存在大量被打开的文件,操作系统要想管理这些文件,就要采取先描述后组织的方法。因此,在内核中,一个被打开的文件都必须有自己的文件对象,这之中包含有文件的很多属性。
2. 文件 & 文件描述符
1. C语言文件接口
①C语言写文件接口
在C语言中我们进行写文件的接口有很多,我们这里以fopen为例,其手册如下

其中,path表示要写入文件的路径,mode表示写入文件的方式,打开文件的路径和文件名时,默认会在当前路径下新建一个文件夹。那么当前路径是什么呢?——进程的当前路径,可以在进程文件中的cwd查看,举个例子,我们以下面的代码为例
#include <stdio.h>
#include <unistd.h>
int main()
{
while (1)
{
printf("this is a process, pid: %d\n", getpid());
sleep(1);
}
return 0;
}
运行后进入其进程文件有

也就是说,如果我更改当前进程的cwd,会将文件新建到修改后的cwd目录中,接下来我们测试fopen接口,测试代码如下
#include <stdio.h>
#include <string.h>
int main()
{
FILE *fp = fopen("myfile", "w");
if (!fp)
{
printf("fopen error!\n");
}
const char *msg = "hello world!\n";
int count = 5;
while (count--)
{
fwrite(msg, strlen(msg), 1, fp);
}
fclose(fp);
return 0;
}
测试结果:

②C语言读文件接口
对于读文件我们以fwrite接口为例,其文档如下

测试代码:
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
int main()
{
FILE *fp = fopen("myfile", "r");
if (!fp)
{
printf("fopen error!\n");
}
char buf[1024];
const char *msg = "hello world!\n";
while (1)
{
// 注意返回值和参数
ssize_t s = fread(buf, 1, strlen(msg), fp);
if (s > 0)
{
buf[s] = 0;
printf("%s", buf);
}
if (feof(fp))
{
break;
}
}
fclose(fp);
return 0;
}
测试结果:

注意,这里对msg使用strlen求长度时不需要+1,因为字符串以\0为结尾,是C语言的规定,和文件没有关系。
2. 文件的系统调用
①读文件
读文件的系统调用如下

我们先以第一个接口为例,它的使用方式如下
int main()
{
int fd = open("myfile.txt", O_WRONLY | O_CREAT);
if (fd < 0)
{
printf("open file error\n");
return 1;
}
return 0;
}
这里返回的int表示的是:file description(即文件描述符)简写fd,pathname不做过多解释,这里的flag表示对文件的读入方式,具体有如下等

此外,还有一个mode选项,它表示读文件时的权限,举个使用的例子
int main()
{
umask(0);
int fd = open("myfile.txt", O_WRONLY|O_CREAT, 0666);
if (fd < 0)
{
printf("open file error\n");
return 1;
}
return 0;
}
我们可以使用umask函数来设定读取文件时的权限掩码,其文档如下

②写文件
写文件的系统调用如下

其使用举例如下
int main()
{
char buffer[1024];
ssize_t s = read(0, buffer, sizeof(buffer));
if(s<0) return 1;
buffer[s] = '\0'; // C/C++使用'\0'作为字符串结尾,在这里手动设置表示结尾
printf("echo :%s\n",buffer);
return 0;
}
运行结果如下

我们成功将输入的字符串写入到了buffer数组中!
对于文件的系统调用认知,我们要知道文件其实是位于磁盘上的,而磁盘是外部设备,访问磁盘中的文件本质就是在访问硬件!而我们之前知道

因此几乎所有的库,只要是想要访问硬件设备,其内部一定会封装系统调用!
3. 访问文件的本质

在Linux内核中,由于一定会存在大量的被打开的文件,Linux需要将他们管理起来,因此在task_struct中存在一个struct file_struct* files指向当前进程相关的文件,而在file_struct中又有一个文件描述符表,这之中每个下标代表一个独特的文件描述符,每一个下标对应的空间指向一个含有打开文件的具体信息的结构体对象,它的内部包含着该文件的各个属性。接下来我们使用如下代码来验证
const char *filename = "log.txt";
int main()
{
int fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
if (fd < 0)
{
perror("open");
return 1;
}
const char *msg = "hello Linux\n";
int cnt = 5;
while (cnt)
{
write(1, msg, strlen(msg));
cnt--;
}
return 0;
}
在代码中,我们使用open打开文件后,获取文件描述符fd,这之后使用write向fd指向的文件中写入内容,运行效果如下

即write接口可以通过fd找到文件索引进而找到对应文件,此外我们再做个小测试,向屏幕上打印一下fd有


可以看到分配给我们的fd是从3开始的,那么0,1,2呢?——其实C语言在一开始就已经默认打开3个输入输出流,分别是:stdin(键盘文件), stdout(显示器文件), stderr(显示器文件),为他们分配的fd分别为0,1,2,而操作系统只认文件,因此我们可以close(1)来验证,即

![]()
可以看到,在我们关闭了fd为1的文件后,printf函数打印的消息我们无法再看到。那么默认打开这三个流是C语言独有的特性吗?——不是!这其实是操作系统的特性,一个进程会默认打开键盘、显示器、显示器。那么进程为什么会默认打开这三个文件呢?——在开机的时候,键盘和显示器早就被操作系统打开了,因为一般在进行编程时,我们都需要使用键盘和显示屏。

所以C语言中的FILE是什么呢?——C库自己封装的结构体!这之中必须封装文件描述符,我们以如下代码来验证


3. 重定向与用户缓冲区
在讲重定向之前,我们需要讲讲文件描述符对应的分配规则:
从0下标开始,寻找最小的没有使用的数组位置,它的下标就是新文件的位置
我们以下面的代码为例
int main()
{
close(1);
int fd = open(myfile, O_CREAT | O_WRONLY | O_TRUNC, 0666);
if(fd < 0)
{
perror("open");
return 1;
}
//printf("fd: %d\n", fd);
const char *msg = "hello Linux\n";
int cnt = 5;
while(cnt)
{
write(1, msg, strlen(msg));
cnt--;
}
return 0;
}
上述代码运行结果如下

可以看到,当我们关闭了1号显示屏文件的文件描述符后,我们再打开myfile文件,OS自动为其分配描述符,此时我们再向fd为1的文件中写入,可以发现写入的所有内容都写入到了myfile中,由此可以验证我们刚刚所说的文件描述符的分配规则。
1. 重定向
①重定向的使用
一般来说我们可以直接在命令行中使用 > , >> , < 等符号来进行快速重定向
举几个例子

这俩符号都是输出重定向,这之中 > 是覆盖式写入,即每次写入前会将之前的内容清空再写入, >> 则是追加式写入,即每次写入直接在文章的结尾继续写入。 而我们之前使用的open函数就属于覆盖式写入,我们可以在mode选项中使用APPEND来将其写入方式修改为追加式写入。

而 < 是输入重定向,表明从myfile中读取数据,再筛选出这之中含有 "Linux" 的内容。
此外,我们还可以使用dup2在代码中来进行重定向操作,它的使用文档如下

dup2()函数使得newfd成为oldfd的复制,如果需要的话会首先关闭newfd。但是需要注意以下几点:
- 如果
oldfd不是一个有效的文件描述符,那么调用将失败,并且newfd不会被关闭。- 如果
oldfd是一个有效的文件描述符,并且newfd与oldfd的值相同,那么dup2()不会执行任何操作,并返回newfd。- 成功返回这些系统调用之一后,旧的和新的文件描述符可以互换使用。它们引用同一个打开文件描述(参见
open(2)),因此共享文件偏移和文件状态标志;例如,如果通过在任一描述符上使用lseek(2)修改了文件偏移,另一个的偏移也会改变。- 这两个描述符不共享文件描述符标志(即
close-on-exec标志)。对于重复的描述符,close-on-exec标志(FD_CLOEXEC;参见fcntl(2))是关闭的。
我们以下面的代码为例
int main()
{
int fd = open(myfile, O_CREAT|O_WRONLY|O_TRUNC, 0666);
if (fd < 0)
{
perror("open");
return 1;
}
// 重定向
dup2(fd, 1);
printf("hello Linux!\n");
printf("hello Linux!\n");
printf("hello Linux!\n");
printf("hello Linux!\n");
close(fd);
return 0;
}
运行结果如下

在代码中,我们使用dup2函数将后面的显示屏文件描述符(1)替换成fd,又因为printf函数是认为自己是在向显示屏文件描述符(1)中写入,而重定向函数又将显示屏文件描述符(1)替换成了fd,所以本应写入显示屏文件(1)中的字符串被写入到了文件描述符为fd的文件中。
②重定向的原理

重定向的原理其实就是对文件描述符进行操作,这里就是是将fd拷贝覆盖到1上,需要注意:这里的拷贝绝非文件描述符的拷贝,拷贝的是结构中的数组指针!
③重定向的几个小疑问
1. 重定向会对进程程序替换产生影响吗?

如图所示,进程替换修改的是进程部分(绿色区域),而重定向操作修改的是内核部分(蓝色区域),这样也同时做到了内存和文件的解耦。因此进程历史打开的文件与进行的各种重定向关系都和未来的进程程序替换无关!
2. 1和2都是显示器文件,它们有什么区别?
1 和 2 实质上的区别在于,1 被用于输出正常的处理结果,而 2 用于记录错误信息。他们的设计目的在于分隔正常的应用程序输出和错误信息,这样即使在输出被重定向的情况下,错误信息仍然可以被直接显示到终端。我们可以在自己的目录下创建log并分别可以记录正常信息,错误信息,与合并信息。举个例子

注:2>&1表示把2的内容输出重定向到1中,这样就可以将正常信息和错误信息都输出到all.log中。
3. 如何理解 “Linux中一切皆文件”的说法
在计算机上,所有的操作均由进程来执行,而对文件进行操作依赖于进程(以进程为载体)。
我们在Linux内核中查看有
可以看到这之中,有一个struct file_operations *f_op,我们转到它的定义有

可以看到这之中存放的是一个个的函数指针。而在内核中体现为

每一个进程自己的调用时可以大概按如下调用方式来使用
ssize_t read(fd)
{
task_struct->files->fd_array[fd]->f_ops->write();
}
这样就可以根据指针的不同,来做到访问不同的设备。如

在这样一个体系中,我们可以将file_struct认为是一个基类,而file_operations则可以认为是一个虚函数表,通过指针的不同可以做到同一个基类在访问write方法时,可以访问到不同的设备,也就是实现了面向对象语言中的多态。Linux通过这样的方式与VFS(虚拟文件系统)等,将其管理的所有资源均转化为文件来进行操作。
2. 用户缓冲区
①exit() 与 _exit()
我们使用两个函数来引入这个话题:exit() 与 _exit() ,我们以下面的代码为例
exit():
int main()
{
printf("hello printf");
exit(0);
return 0;
}
运行有

_exit():
int main()
{
printf("hello printf");
_exit(0);
return 0;
}
运行有

但是当我们使用fflush函数时,有
int main()
{
printf("hello printf");
fflush(stdout);
_exit(0);
return 0;
}

②prinf() 与 wirte()
我们以下面的代码的两段代码为例
1:
int main()
{
const char* fstr = "hello fstr";
const char* str = "hello str";
printf("hello printf");
fprintf(stdout, "hello fprintf");
write(1, str, strlen(str));
fwrite(fstr, strlen(str), 1, stdout);
return 0;
}
运行结果如下

2:
int main()
{
const char* fstr = "hello fstr\n";
const char* str = "hello str\n";
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
write(1, str, strlen(str));
fwrite(fstr, strlen(str), 1, stdout);
return 0;
}
运行有

根据结果我们可以得出结论,这四个函数在都不加 "\n" 时,显示器文件的刷新方案为 “行刷新” ,即在printf函数内,只有遇到 "\n" 时,才会将数据刷新。如果我们在代码结尾加上fork,再次运行并将结果重定向到log.txt中,有

可以看到,在结果中C语言调用的打印函数都打印了两次,而系统调用的打印函数只打印了一次,由此我们可以推断,C语言接口打印两次一定与fork有关!
③用户缓冲区
接下来我们再做几个测试
使用write,不加 "\n" ,close(1)
int main()
{
// const char* fstr = "hello fstr\n";
const char* str = "hello str";
// printf("hello printf\n");
// fprintf(stdout, "hello fprintf\n");
write(1, str, strlen(str)+1);
// fwrite(fstr, strlen(str)+1, 1, stdout);
close(1);
return 0;
}
运行有

使用prinf, fprintf, fwrite,不加 '\n',close(1)
int main()
{
// const char* fstr = "hello fstr\n";
const char* str = "hello str";
printf("hello printf");
fprintf(stdout, "hello fprintf");
// write(1, str, strlen(str));
fwrite(str, strlen(str)+1, 1, stdout);
// sleep(5);
close(1);
return 0;
}
运行有

可以看到,第一种情况有结果,第二种情况显示与写入的结果都为空,这里我们可以画图说明

实际上,如果C语言提供的接口如果参与在操作系统内的缓冲区的话,那么在close(1)时,会找到文件对象,并将其结果刷新出来,而不会没有显示结果。因此,C语言为其本身设计了一个 C语言所提供的缓冲区 ,这个缓冲区一定不在操作系统,不是系统级别的缓冲区。这个缓冲区我们将其称为用户缓存区。
④一些相关问题
1. 缓冲区刷新问题
一般来说,缓冲区有三种刷新策略
a. 无缓冲 —— 直接刷新
b. 行缓冲 —— 不刷新,直到碰到'\n'
c. 全缓冲 —— 缓冲区满了,才刷新
我们拿快递站来举几个例子,无缓冲就是来一个包裹就发送一个包裹出去,行缓冲就是来的包裹先存放着满足特定条件时再一起发送出去,全缓存就是来的包裹将快递站充满时再一起发送出去。而在实际的体验中,我们可以看到显示器使用的就是行缓冲策略,而向普通文件进行写入时使用的则是全缓冲策略,此外,在进程退出的时候也会对缓冲区进行刷新。我们可以用下面这个图来大概表示缓冲区的情况

而我们也知道,在C语言中操作文件的句柄是FILE结构体,那么这个FILE属于用户还是操作系统呢?——答案是凡是属于语言层次的都属于用户。而文件操作一定与文件描述符fd有关,因此我们可以推测,FILE里一定含有打开文件的缓冲区字段和相应的维护信息。而这里的缓冲区字段应该是属于用户缓冲区的!
2. 补充问题
首先,我们提出一个疑问:既然进程退出时会自动刷新,那么为什么要有缓冲区的存在?
我认为有两点:
其一,缓冲区可以合理的解决效率问题(这里的效率问题主要指的是用户的效率问题)。什么意思呢?举个例子,当我们寄快递时,如果我们采取的是自己将快递亲手送到对方手中,那么这将会大大影响我们的效率,所以一般来说,更好的做法就是我们将快递交给快递公司帮我们寄,这样一来,效率就会大大提升。同理,在有了缓冲区后,我们只需要将数据交给缓冲区,它到了特定的时候会自动调用;
其二,缓冲区可以配合C语言中的格式化。举个例子,在下面这个代码中
int a = 123;
printf("hello %d\n", a);
"hello %d\n" 会被转换成 "hello 123",这样就可以将其以文件流(不断地写入就像不断向缓冲区中流入)的形式写入到文件中。
3. 使用fork函数后C接口为什么会打印两次?
先前的代码与结果如下
int main()
{
const char* fstr = "hello fstr\n";
const char* str = "hello str\n";
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
write(1, str, strlen(str));
fwrite(fstr, strlen(str)+1, 1, stdout);
fork();
return 0;
}

首先,我们需要知道在我们使用
./out > log.txt
此时是向文件中打印,因此刷新方案由行缓冲变为了全缓冲,即遇到"\n"时不再刷新,而是等缓冲区被写满了才刷新,因此在调用fprintf等接口时并没有刷新。再让我们看看整个调用过程

观察整个过程,我们可以发现,在父进程退出时,父进程会试图清空缓存区,因此在父进程对缓冲区进行刷新的时候发生了写实拷贝,即拷贝了缓冲区,因此在子进程退出时,子进程也会清空自己的缓存区,最终体现出来的结果就是在使用了fork函数后,C语言的接口打印了两次。
4. 未打开的文件
一个文件尚未被打开时存放在哪呢?——存储在磁盘中!因此,我们要研究未打开的文件可以从如下几个方面进行研究
a. 路径问题
b. 存储问题
c. 获取问题(得到属性 + 文件内容)
d. 效率问题
注:本文描述的文件系统为Linux中的Ext2文件系统
在前置知识中我们已经知道了文件 = 内容 + 属性,而在Linux中文件在磁盘中存储时是将属性和文件内容分开存储的,即在Linux中
1. 磁盘上存储文件 = 存文件内容 + 存文件属性
2. 文件内容 —— 数据块
3. 文件属性 —— inode
1. 认识硬件——磁盘
磁盘是现在唯一的一个机械设备,它也是一个外设。长相大概与我们以前熟悉的光盘相似,即

磁盘的图片则如下

注:
1. 磁头是一个盘面一个,即如果有3个盘片则有6个盘面,也就是6个磁头;
2. 磁头与盘面是不接触的,等比举例的话就相当于一架飞机离地1m高;
3. 磁头的工作原理与吸铁石类似,如下图所示

即通过磁头将盘片上的1w个小区域逆转,从而写入1w个01序列;
4. 销毁磁盘,需要出厂商手动设置接口
2. 对硬件进行抽象的理解
①磁盘的存储构成

磁盘的存储构成如图所示,数据存储在扇区之中,因而磁盘被访问的最基本单元为扇区(512byte/4KB)。所以我们可以将磁盘始终由无数个扇区构成的存储介质,而我们要将数据存储到磁盘中,首先要解决的问题就是如何定位到一个扇区,根据左侧的图我们大概可以推测,定位过程如下
1. 存储到哪个盘面(Cyinder)? -> 定位使用哪个磁头
2. 存储到哪个磁道(Header)? -> 磁头左右摆动确定
3. 存储到哪个扇区(Sector)? -> 盘面旋转确定
上面的寻址方式也叫做CHS寻址方式。可以明显看到,我们需要使用磁头来定位这样就会在效率上产生问题,主要有两方面的影响:1. 盘面的自转;2. 磁头的左右摆动。 所以只要运动的次数越少,那么效率一定就会更高,因此在软件设计上,设计者一定要有意识地将相关数据放在一起!
②磁盘的逻辑结构
相信我们曾经都见过磁带的运转方式,即

由图像来看,我们要是将其延展开来,逻辑上来说可以将其视为线性的。因此,同理我们可以将磁盘在逻辑上视为线性的,即

这样一来,任意一个扇区都天然地对应一个下标,我们就可以根据这个下标来反推出CHS形式的扇区的物理位置,举个例子
现有一个每个盘面有2w个扇区,每个盘面有50个磁道,每个磁道有400个扇区的磁盘,请问下标为28888的扇区的物理地址是什么?
解:
28888 / 20000 = 1 -> 该扇区位于第2盘面
28888 % 20000 = 8888
8888 / 400 = 22 -> 该扇区位于第23号磁道
8888 % 400 = 88 -> 该扇区为88号扇区
就这样我们根据下标找到了该扇区的物理地址,这样将逻辑扇区地址 => 物理地址, 我们将这个逻辑扇区地址称为LBA地址,又叫逻辑块地址。
3. 文件系统
现在有一个800GB的磁盘,我们要如何管理好它呢?

既然将10GB管理好就能管理好800GB(分治思想),那么我们如何管理这10GB呢?

这里我们先介绍后面几个部分
首先是Block Bitmap, 这里使用了位图的数据结构将比特位的位置与块号相映射。一个比特位表示该块有没有被使用。知道了这个之后,实际上在删文件的时候就是将bitmap的对应位置置零即可,因此删除速度会远远大于下载时的拷贝速度。
然后是inode Bitmap, 这里的比特位与inode映射,0/1表示该inode是否有效。
再然后是inode Table, 首先我们要知道一个inode含有单个文件的所有属性, 它占有128个字节。一般而言,一个文件对应一个inode。而既然是inode Table就表示这里存储着很多的inode => 每个inode有唯一编号, 每个编号对应着一个数据块。
最后是Data Blocks, 它是存放文件内容的区域,以块的形式呈现。一般而言,每个块只能有只能有自己的数据。文件系统的块大小常见为4KB。
在Linux中,文件属性并不包括文件名,只认inode编号,且在Linux中,标识文件用的是inode编号。
①inode
既然inode存在如此之多,因此我们也需要对其进行先描述后组织,即我们可以大概描述一下inode,即
struct inode
{
inode number;
文件类型;
权限;
引用计数;
拥有者;
所属组;
int blocks[NUM];
}
这里我们具体讲一下这个blocks,如图

这个blocks中,每个元素表示存放这这个文件存储的数据块的块号,出现了误差后,我们可以通过找到inode编号来找到block内容来进行恢复。
再让我们回到文件系统中,我们接着介绍剩下几个部分

首先是Super Block, 这个部分保存了文件系统的基本信息:从哪到哪为 Group Description Table的范围,以及后面每个部分的范围,因此其保存了整个分区的基本使用情况,如
一共有多少组
每组的大小为多少
每组的inode数量
每组的block数量
每组的起始inode
文件系统的类型和名称
而上面这些不必在每个group中存在。如果Super Block部分出现了异常,那么整个200GB就会挂掉吗?——并不会,一旦有多个Super Block 挂掉,也可以从其他的group里读取。
然后是Group Description Table, 它统一管理描述整个分组的基本内容。
再然后就是我们先前提到的Block Bitmap 与 inode Bitmap, 这两个位图哪怕没有用过也需要提前将其设置好,即
每一个分区在被使用前,都必须提前先将部分文件系统的属性信息提前设置进对应分区中,方便我们后续使用这个分区或分组,也叫做格式化,也就是重新写入所有属性,将inode Table 和 Data Blocks清空
最后便是inode Table 与 Data Blocks, 这两个分区内的编号对操作系统来说一定可以被解释为扇区(即逻辑扇区地址 => 物理地址)
②几个小问题
在了解了文件系统的构成后,我们可以知道在Linux系统中,一个文件对应一个inode,每一个inode都有自己的inode编号(inode的属性是以分区为单位的,因此不能跨分区),然后我们可以提出几个问题
1. 新建一个文件时,操作系统需要做什么?
首先要做的应该是根据目录确定范围,应该新建在那个分组里,然后找一个未使用的inode设置好,如果要写入内容就根据内容大小申请不同的块(找到未使用的块)并直接访问,再将块编号放入inode的block[]中。
2. 删除一个文件时,操作系统需要做什么?
我们之前提过,删除的意思是改变状态,因此删除一个文件将其状态设置为允许被覆盖即可,具体来说就是根据inode中block[]序号,修改对应的 Block Bitmap即可,再修改inode Bitmap。
3. 查找一个文件时,操作系统需要做什么?
找到该文件的inode即可。
4. 修改一个文件时,操作系统需要做什么?
查找到该文件的inode后,去其中找到block[]将内容拼接好并返回。
5. 如何理解目录?
我们说了这么多与inode相关的知识,但是我们站在使用者的角度来说,从来没有关心过inode而是使用的文件名,那么它们之间有何联系呢?——首先我们得知道inode表示的是文件的所有属性,而文件名并不属于inode的内属性。要解释这个关系我们就不得不提到目录了!
我们已经知道了在Linux中,一切都是文件!因此目录也是文件,它也有属于自己的inode,也有自己的属性。既然有属性,那么目录有内容吗?——有,那要不要数据块呢?——需要!那数据块里面存放什么呢?——该目录下,文件的文件名和对应文件inode的映射关系。了解了这些之后,也可以回答我们之前的一些疑惑了
①为什么同一目录下不能有同名文件?
key值只能有一个。
②在目录下,没有w权限,我们无法创建文件?
就算能够创建好,我们也无法将映射关系写入进去,而Linux不会做无意义的事,因此无法创建。
③在目录下,没有r权限,我们无法查看文件?
拿不到inode,无法读取文件。
④在目录下,没有x权限,我们无法进入这个目录?
cd目录,需要找到这个目录,并对当前的环境变量作更新,但是进入前会先进行判断,若此时的权限没有x,那么就无法修改环境变量,因此被拦住了,无法进入目录中。
既然目录是文件,也有inode编号,那我们如何找到它的上一级inode呢?——先递归到根目录,从根目录向下寻找,最终能找到 => 访问任何文件,都需要携带路径。但是这样从根目录向下递归效率似乎有点低,因此Linux会将常用路径缓存到dentry缓存中。
4. 软硬链接
我们首先看大概看一下如何使用,
软链接
ln -s redirect_and_userbuffers.c soft-link
硬链接
ln redirect_and_userbuffers.c hard-link
我们查看有

可以看到,软链接是一个独立的文件(inode不同),而硬链接不是一个独立的文件,因为它没有独立的inode。
①如何理解软链接
首先我们知道软链接是一个独立的文件,它有独立的inode,也有独立的数据块,它的数据块里保存的是指向目标文件的路径 => 类似于Windows的快捷方式。我们可以使用
unlink 删除名
来删除软链接。
②如何理解硬链接
所谓的硬链接,本质上就是在特定目录的数据中新增文件名和指向的文件inode编号的映射关系,我们可以用C++中的取别名操作来理解它。而硬链接我们一般将其应用在进行路径定位,与我们之前学习过的目录中的 . 与 .. 有关,如图

其中, . 表示当前目录, .. 表示上级目录,但是在进行路径定位时可能会出现环路问题,如图

即,有可能会在查找过程中出现环路问题,因此Linux不允许对目录进行硬链接。那么为什么又会有 ./.. 作为当前目录和上级目录的硬链接呢?——系统自动设置的,且在查找时,不会对 . 与 .. 进行搜索(避免出现环路问题)。
在此我补充一下,对于任意一个文件,无论是目录还是普通文件,都有其inode,每一个inode内部都有一个叫做引用计数的计数器,这个计数器表示有多少个文件指向我,当计数器为0时,文件将会被删除。在目录中保存的是文件名 : inode编号的映射关系。
③为什么要有软硬链接
为什么要有软链接:
1. 当一个文件藏得太深时,可以直接在外部创建一个软链接指向它
2. 我们可以将这个软链接放在bin目录中,以后就可以在任意地方运行这个程序
为什么要有硬链接:

因此我们可以得到一个关系式,硬链接数 - 2 => 当前目录下的目录数。
5. 补充知识
①物理内存与磁盘
首先我们需要知道内存都是为了临时存取,我们可以将其视作一个巨大的缓冲区,如图

对于4KB内容来说,分四次载入1KB还是一次性载入4KB哪个快些呢?——当然是一次性载入,因此Linux采取的是将物理内存以4KB为最小单位进行切割来便于载入数据。那么为什么是4KB呢?
1. 在硬件层面来说,可以减少I/O次数 => 减少访问外设次数
2. 在软件层面来说,这是基于局部性原理的预加载机制
注:局部性原理:访问了代码后,其周围的代码大概率也会访问。
②操作系统如何管理内存?
既然内存有这么多,我们要想将其管理起来就要“先描述,后组织”,如图

我们使用page来描述内存,我们可以简单计算一下4GB的内存有多少个页,即
4GB/4(4KB)/1024(KB) = 1048576
在一个4GB大小的内存中,有100多万个page,因此我们就可以使用struct page mem_array[1048576]; 来对其进行管理,这样对内存的管理就变成了对数组的管理。经过这样的管理每一个page都天然地有一个下标,这个下标我们称之为页号。所以,我们要想访问一个内存,只需要先直接找到这个4KB对应的page就能在系统中找到对应的物理页框。
注: 所有申请内存的动作,实际上都是在访问内存page数组!
③Linux中一个进程打开一个文件发生了什么?
在Linux中,我们每一个进程,打开的每一个文件都要有自己的inode属性和自己的文件页缓冲区,我们画图来理解

④基数树/基树(字典树)
我们先来模拟一下C语言调用fclose与fopen的过程

我们来单独看下这个多叉树,即


现在对于一个文件内容有10mb大小的文件,我们是如何存放它的内容的呢?

⑤C语言将文件写入磁盘,一共发生了几次拷贝?
我们还是通过画图的方式来帮助我们理解,如图

即,共计发生了三次拷贝,分别为:文件拷贝到用户缓冲区,用户缓冲区拷贝到内核缓冲区,内核缓冲区拷贝到磁盘中。
5. 动静态库
①库制作者角度
在Linux中,静态库的后缀为 xxx.a,动态库的后缀为 xxx.so,在这里我们设计一个库,如图

我们先将其生成为静态库,bash指令如下
ar -rc libmymath.a add.o sub.o
作为一个库的制作者,我们要想将自己的库给别人使用有两种方式:
1. 提供源代码
2. 把源代码打包成库 + 给出头文件 xxx.h (头文件作为库的使用说明书必须要有)
在一般情况下我们都采取第二种方式,我们根据上述思路写出makefile,即
lib=libmymath.a
$(lib):mymath.o
ar -rc $@ $^
mymath.o:mymath.c
gcc -c $^
.PHONY:clean
clean:
rm -rf *.a *.o lib
.PHONY:output
output:
mkdir -p lib/include
mkdir -p lib/mymathlib
cp *.h lib/include
cp *.a lib/mymathlib
注:这里使用的ar实际可以将多个文件打包成静态库,如图

我们编译后有

②库使用者角度
main.c如下
#include "mymath.h"
int main()
{
int a = 1, b = 2;
printf("%d", add(a, b));
return 0;
}
我们尝试着使用gcc来编译,即
gcc main.c
有

提示我们找不到对应文件,因此我们可以使用
gcc main.c -I lib/include/
这里的-I表示要到哪个指定路径去寻找,我们执行有

这里弹出的是链接式报错,如果没有指定库,gcc会去默认库里寻找,因此我们还需要加上一部分
gcc main.c -I lib/include/ -L lib/mymathlib/
但是只添加库的路径,我们的程序还是不知道会用到哪个库,所以还是会报错
因此我们还需要指明这里要用到哪个库,即
gcc main.c -I lib/include/ -L lib/mymathlib/ -lmymath
即使用-l 加上对应库去掉前后缀的名字即可,运行有

为什么我们之前使用gcc时,没有产生上述的情况呢?——其实是之前使用的库都是C/C++自带的库,而这里我们使用的是第三方库,因此必须指明链接的是哪个库!
在这里我们补充几点
1. 在使用第三方库的时候,我们必须要使用gcc -l的命令;
2. errno的本质:errno它是一个全局变量,表示调用某一个函数失败的原因;
3. 如果系统中只提供了静态库,那么gcc只能对该库进行静态链接;
4. 若系统中需要链接多个库,则可以使用gcc -l在其后依次写明即可。
如果我们不想这么麻烦的进行gcc的输入,我们可以使用 cp 命令将库与头文件拷贝到 usr/include目录下,这一步也就是我们一般在网上下载第三方库时需要进行等待的原因,那就是在进行库的安装,即将头文件与库文件拷贝到默认路径下。除了前面的方法,我们还可以将对我们的库文件建立一个软链接并放到 usr/include中。
说完了静态库再让我们来看看动态库,我们再设计两个库,即

与生成静态库不同,我们只需要使用gcc就可以达到目的,makefile如下
lib=libmymethod.so
$(lib):myprint.o mylog.o
gcc -shared -o $@ $^
myprint.o:myprint.c
gcc -fPIC -c $^
mylog.o:mylog.c
gcc -fPIC -c $^
.PHONY:clean
clean:
rm -rf *.so *.o lib
.PHONY:output
output:
mkdir -p lib/include
mkdir -p lib/lib
cp *.h lib/include
cp *.so lib/lib
注:这里的gcc 的 -fPIC选项表示生成位置无关码, -shared 表示生成动态库,我们执行后有

可以看到,我们生成出来的动态库是绿色的?——我们已经知道,绿色表示可执行文件,那么动态库可以直接执行吗?——不行,动态库是可执行文件的原因其实是,要想将其加载到内存,就必须是可执行文件!而动态库与静态库相似,我们也需要使用 -l 指明使用哪个动态库,即
gcc main.c -I lib/include -L lib/lib -lmymethod
在编译后,我们运行文件会发生

让我们观察一下这个报错信息,它说loading shared libraries时发生错误,没有找到对应的动态库。但是我们在gcc中不是告诉了程序所用的动态库在哪个目录是哪个库吗?——gcc属于编译器,我们只告诉了编译器,而没有告诉加载器。因此,我们有下面的一些解决方法:
1. 直接使用cp命令将该库拷贝到系统默认的库路径, 如:/lib64,/usr/lib64等
2. 在系统默认的库路径下建立软链接
3. 将自己的库所在的路径,添加到系统的环境变量LD_LIBRARY_PATH中,举个例子

4. 在 /etc/ld.so.confd 建立自己的动态库路径的配置文件,然后重新ldconfig即可
对于一般的实际情况来说,我们用的库都是别人成熟的库,所以采取第一种方式:直接拷贝到系统默认路径,也就是安装到系统中。
③动态库是如何被加载的?
首先,我们已经认识到两点
1. 动态库在进程运行时,是需要被加载的(静态库不需要)
2. 常见的动态库被所有的可执行程序(需要动态链接的),都要使用该动态库
因此, 动态库在系统中加载之后,会被所有的进程共享,我们将其称为共享库。一个共享库会被多个与之相关的进程共享!那么这样的共享是怎么做到的呢?换而言之,动态库是如何被加载的呢?我们继续通过画图来理解它,如图

我们现在来模拟一下,调用动态库的方法时会发生什么?

我们的代码在正常向下执行时(①),遇到了动态库中的函数(即Print()),此时识别到它是动态库3.so的一个方法,因此我们尝试去寻找动态库3.so(②),3.so通过物理地址(③)与页表映(④)射到进程地址空间中(⑤),随后我们将进程地址空间中的地址代入到代码区中即可(⑥)。因此,我们可以得出一个结论:从此往后,我们执行任何代码都是在我们的进程地址空间中进行的!即我们只需要在虚拟空间内就能完成库的调用!而系统在运行时,一定会存在多个动态库,而由于有页表的存在,所有库的加载情况OS都知道,因此操作系统一定会将其先描述,再组织以进行统一的管理。
注:不同进程在使用同一个库的时候有影响吗?——发生写实拷贝!
④关于地址
之前我们已经深入谈到过进程地址空间,我们也知道了什么是虚拟地址,什么是物理地址。那么,对于CPU来说,它读到的指令里面用的地址,是什么地址呢?——虚拟地址!因此我们在使用gcc进行编译时,需要使用gcc -fPIC,即生成与地址无关码,所以编译器也需要考虑OS的问题。
1. 程序未加载前(程序)
首先我们需要了解一下,在程序编译好后,未加载前程序内部有地址吗?——有的!举个例子,它可以将0-4GB以平坦模式展开,如图

这之中,不同的地址又分为不同的区域,即

这些地址天然来说就已经是虚拟地址,即我们所谓的逻辑地址!
2. 程序加载后的地址(进程)
在谈及这个之前,我们需要了解一些前置知识。
CPU在执行指令时,是按照一条一条指令来执行的,而每条指令都有其对应的长度。那么CPU如何执行这些指令呢?——在内置中设置一些二进制序列,用来表示一些操作,如:move, sub, push等。在CPU中一定已经提前预置了很多的基础指令。因此对于CPU来说,它是知道哪些是数据,哪些是指令的!
我们还是用画图来理解这一过程,即

那么对于这个进程来说,我们的CPU如何执行第一条指令呢?

如图,计算机会将入口地址entry存入到EIP/PC寄存器中,然后进程开始由上而下执行。那么这个entry是物理地址吗?——不是,我们前面已经提到过,这里的代码已经是转换为平坦模式的虚拟地址!接下来,我们以下面这段代码为例
#include <stdio.h>
void func()
{
printf("hello world!\n");
}
int main()
{
int a = 2;
a += 2;
func();
return 0;
}
我们查看反汇编有


所以,进程在执行的时候所找到的物理地址实际上是已经转换为逻辑地址的额虚拟地址!
3. 动态库的地址
首先我们先介绍点前置知识,如图

对于进程地址空间来说,地址是由下至上递增的,而对于一个地址的定位,我们一般有两种方式:即相对地址与绝对地址(逻辑地址),举个例子
简单来说,就是根据参考系的不同,得出来的相对地址是不同的,那么我们回到动态库,如图

如果在代码中突然遇到了需要调用xxxlib.so的函数(如myprint)时,有

但是动态库被加载到固定地址空间中的位置是不可能的!因为动态库可以在虚拟内存中的任意位置加载!除此以外还有一个问题,如果我们动态库太大了,那么我们具体将其映射到哪呢?
为了解决上述的问题,我们的Linux为其设计了单独的调用方式,即
在共享区中存放动态库的初始地址即可,如(xxxlib.so:start -> 0x 90 00)
在调用函数的时候,先在共享区找到调用库的初始地址,然后再将此地址加上固定的偏移量就可以找到我们对应的函数,如(myprint() -> xxxlib.so:start(0x 90 00) + 0x 11 22)
综上,动态库在形成时,需要让自己内部的函数不要采用绝对编址,只表示每个函数在库中的偏移量即可!所以,这里的0x 11 22 表示的是在库中的偏移量。这也是我们为什么要在生成动态库时需要带上 -fPIC 选项,它表示直接用偏移量对库中函数进行编址。
注:为什么静态库不谈论加载?不谈与位置无关?——因为它是直接拷贝到程序中的,即它的位置是确定的!
2109

被折叠的 条评论
为什么被折叠?



