Linux I/O 原理解析

描述

梅开二度

在 C 语法下就早已知悉基础 IO ,其实就是耳熟能详的文件操作,说到文件操作脑子里又是一堆耳熟能详的函数接口:

文件系统

以一个简单的写入操作为例,运行程序后当前路径下会生成对应文件,文件当中就是我们写入的内容:

#include
int main()
{
FILE* fp = fopen("log.txt", "w");
if (fp == NULL){
perror("fopen");
return 1;
}
int count = 5;
while (count){
fputs("hello worldn", fp);
count--;
}
fclose(fp);
return 0;
}

文件系统

当前路径

文件操作我们打开文件时,如果 fopen 对象是一个未创建的对象,那么就会自动在当前路径生成一个该文件,这里就牵涉到一个当前路径 color{red} {当前路径}当前路径的概念。

比如我们在刚刚写入后的 log.txt 文件进行读取:

#include
int main()
{
FILE* fp = fopen("log.txt", "r");
if (fp == NULL){
perror("fopen");
return 1;
}
char buffer[64];
for (int i = 0; i < 5; i++){
fgets(buffer, sizeof(buffer), fp);
printf("%s", buffer);
}
fclose(fp);
return 0;
}

文件系统

该情况下,我们在总目录下运行可执行程序 myproc,那么该可执行程序创建的 log.txt 文件会出现在总目录下:

这是否意味着 “当前路径” 就是指的 “当前可执行程序所处的路径”?

我们不妨直接去查看他的路径对吧,我们用ps -axj | head -1&&ps -axj | grep myproc | grep -v grep可以查看可执行程序的 PID :

文件系统

然后我们再利用 PID 来查看执行路径sudo ls /proc/8189 -al,因为我在总目录 ~ 下,因此这里我使用弄了 sudo 命令进行管理员权限查找:

文件系统

这里的cwd和exe是软链接,我们下文细谈,所以实际上,当前路径不是指可执行程序所处的路径,而是指该可执行程序运行成为进程时我们所处的路径

三大输入输出流

我们一直贯彻一个理念就是 Linux 下 一切皆文件,我们肉眼可见的显示屏输出的数据,本质是电脑读取键入的字符,电脑从“电脑文件” 读取字符,电脑再对“显示器文件”进行输出

那么问题来了,在我们对这些“文件”进行读写之前,为什么我们没有一个文件打开的操作呢?

要知道打开文件一定是进程运行的时候打开的,而任何进程在运行的时候都会默认打开三个输入输出流,即标准输入流、标准输出流、标准错误流,就是 C 当中的 stdin、stdout、stderr;C++当中的 cin、cout、cerr,其他所有语言都有类似的概念。实际上这种特性并不是某种语言所特有的,而是由操作系统所支持的

其中,标准输入流对应的设备就是键盘,标准输出流和标准错误流对应的设备都是显示器。查看 man 手册我们不难发现,stdin、stdout、stderr 这仨 byd 其实就是 FILE* 类型的

extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;

我们之所以可以调用 scanf 、printf 这类的函数向键盘显示器进行输入输出操作,其实就是程序运行时,操作系统默认使用 C 的接口将这三个输入输出流打开。试想我们使用 fputs 函数时,将其第二个参数设置为 stdout,此时 fputs 函数会不会直接将数据显示到显示器上呢?

fputs("hello stdinn", stdout);

答案是肯定的,因为此时就是用 fputs 向显示器文件进行了写入操作

系统文件 I/O

相比 C,C++ 这些语言的接口,操作系统也有一套文件操作的接口,而且操作系统的接口更加贴近底层,而其他语言的接口本质上也是对操作系统的接口的封装,我们在 Linux、Windows 平台下运行 C 代码时,C 库函数就是对 Linux、Windows 系统调用接口进行的封装,这样做使得语言有了跨平台性,也方便进行二次开发

open

函数原型:

int open(const char *pathname, int flags, mode_t mode);

1.pathname 表示要打开或创建的目标文件。

若pathname以路径的方式给出,则当需要创建该文件时,就在pathname路径下进行创建。 若pathname以文件名的方式给出,则当需要创建该文件时,默认在当前路径下进行创建,注意当前路径的含义

2.flags 表示打开文件的方式。

flags 的可调用参数有如下这些:

flags 可以同时传入多个参数选项,这些选项用 “或” 运算符连接。例如以只写的方式打开文件时,文件不存在就应该自动创建文件,则参数设置如下

O_WRONLY | O_CREAT

我们基于与运算的最根本原因是因为: 这些宏定义选项的共同点就是它们的二进制序列当中有且只有一个比特位是 1 color{red} {这些宏定义选项的共同点就是它们的二进制序列当中有且只有一个比特位是 1}这些宏定义选项的共同点就是它们的二进制序列当中有且只有一个比特位是1,除了 O_RDONLY 序列为全 0,表示他为默认选项,且为 1 的比特位是各不相同的,这样一来函数内部就可以通过使用与运算来判断是否设置了某一选项

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选项
}
//...
}

3.mode,表示创建文件的默认权限,在不创建文件时,此选项可以不设置。

我们将mode设置为 0666,则文件创建出来的权限如下,按理说本来应该是 :

文件系统

但是不要忘了,Linux 系统设有 umask 权限掩码,文件的真正权限计算方法是:mode &( ~umask),umask 的默认值应该是 0002,所以在我们自己设置的权限下应该减去 umask 得到 0664,即:

当然,如果想绕开 umask ,直接使用我们第一手的设置,那么我们可以直接将 umask 进行置 0 操作

umask(0);

open 返回值

open 的返回值其实是新打开文件的文件描述符 fd,我们这里尝试一次打开多个文件,然后分别打印它们的文件描述符:

#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:%dn", fd1);
printf("fd2:%dn", fd2);
printf("fd3:%dn", fd3);
printf("fd4:%dn", fd4);
printf("fd5:%dn", fd5);
return 0;
}

文件系统

我们又知道系统是无法打开一个不存在的文件 fd 会返回 -1,打开成功时如图所示每个文件的 fd 从 3 开始且都是连续递增的,那么问题来了:0~2 哪里去了?

所谓的文件描述符本质上是一个指针数组的下标,指针数组当中的每一个指针都指向一个被打开文件的文件信息,通过对应文件的文件描述符就可以找到对应的文件信息

open函数打开文件成功时数组当中的指针个数增加,然后返回该指针在数组中的下标,而当文件打开失败时直接返回 -1,因此,成功打开多个文件时所获得的文件描述符就是连续且递增的

而 Linux 进程默认情况下会有 3 个缺省打开的文件描述符,分别就是标准输入0、标准输出1、标准错误2,这就是为什么成功打开文件时所得到的文件描述符会从3开始

close

系统接口中使用close函数关闭文件,close函数的函数原型如下:

int close(int fd);

若关闭文件成功则返回 0,若关闭文件失败则返回 -1

write

系统接口中使用write函数向文件写入信息,write函数的函数原型如下:

ssize_t write(int fd, const void *buf, size_t count);

write函数将 buf 位置开始向后 count 字节的数据写入文件描述符为 fd 的文件当中;如果数据写入成功,返回写入数据的字节个数,如果数据写入失败,返回 -1。

read

系统接口中使用read函数从文件读取信息,read函数的函数原型如下:

ssize_t read(int fd, void *buf, size_t count);

read 函数从文件描述符为 fd 的文件读取 count 字节的数据到 buf 位置当中。如果数据读取成功,实际读取数据的字节个数被返回;如果数据读取失败,返回 -1

文件描述符fd

我们知道文件只能在进程执行时才能打开,且一个进程可打开多个文件,系统中存在大量的进程,这就表示系统可以在任何时刻存在大量已经打开的文件

Linux 思想面对批量的处理时总会采取 “先描述后组织” 的思想,系统会为大量的文件描述一个 file struct 的结构体,里面存放着这些文件的主要信息,然后将结构体以双链表的形式进行组织,相当于将文件的管理具象成对双链表的增删查改。

但是在大量进程和大量已打开的文件里,我们要找到每个文件的归属进程系统就应该建立对应关系

对应关系

当一个程序运行起来时,操作系统会将该程序的代码和数据加载到内存,然后为其创建对应的task_struct、mm_struct、页表等相关的数据结构,并通过页表建立虚拟内存和物理内存之间的映射关系

文件系统

首先在 task_struct 里有一个指针,他指向一个名为 file_struct 的结构体,在这个结构体里面又有一个 fd_array 的指针数组,这个数组的下标就是我们所谓的文件描述符。比如进程打开 log.txt 时会先加载进内存形成 struct file ,然后将 struct file 放入一个文件的双链表里,struct file 的首地址再放入链表中下标为 3 处的地方,最后返回他的文件描述符即可。

文件系统

向文件写入数据时,是先将数据写入到对应文件的缓冲区当中,然后定期将缓冲区数据刷新,数据才能进入磁盘。

那么为什么进程创建时会默认打开0、1、2 呢?

我们知道操作系统能够识别硬件,操作系统能管理硬件也意味着键盘,显示器这些东西都有自己对应的 struct_file ,将这 3 个 struct_file 放入双链表,就会对应填入到下标为 0,1,2 的位置,就默认打开了标准输入流、输出流、错误流。

内存文件

磁盘文件和内存文件之间的关系就像程序和进程的关系一样,当程序运行起来后便成了进程,而当磁盘文件加载到内存后便成了内存文件。

磁盘文件分为了文件内容和文件属性两部分,也将文件属性叫做元信息,文件加载到内存时,一般先加载文件的属性信息,当需要对文件内容进行读取、输入或输出等操作时,再加载文件数据

分配规则

我们还是用最开始的代码做解释:

#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:%dn", fd1);
printf("fd2:%dn", fd2);
printf("fd3:%dn", fd3);
printf("fd4:%dn", fd4);
printf("fd5:%dn", fd5);
return 0;
}

然而文件描述符是从最小的 0 开始且未被分配的开始分配的,比如我关闭了 0,2 的流,那么新打开 3 个文件就是不是 3,4 5 而是 0,2,3 了

close(0);
close(2);//关闭描述符为 0,2 的文件

重定向

原理

到这里其实不难理解重定向的原理是修改文件描述符下标对应的 struct file* 内容,比如我们说过的输出重定向就是将一个本应该输出到一个文件的数据输出到另一个文件

比如想让本应该输出到显示器的数据输出到 log.txt 文件当中,那么可以在打开 log.txt 文件之前将文件描述符为 1 的文件关闭,也就是将“显示器文件”关闭,这样一来,当我们后续打开 log.txt 文件时所分配到的文件描述符就是 1

#include
#include
#include
#include
#include
int main()
{
close(1);
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);//输出重定向
if (fd < 0){
perror("open");
return 1;
}
printf("hello worldn");
printf("hello worldn");
printf("hello worldn");
printf("hello worldn");
printf("hello worldn");
fflush(stdout);

close(fd);
return 0;
}

文件系统

这里 printf 是默认向 stdout 输出数据的,而 stdout 指向的 FILE 结构体中存储的文件描述符就是1,因此 printf 实际上就是向文件描述符为1的文件输出数据。C 的数据并不是立马写到了内存操作系统里面,而是写到了缓冲区当中,所以使用 printf 打印完后需要使用 fflush 将缓冲区当中的数据刷新到文件中

可以看出,我执行 file 程序时并没有任何结果,但是打印 log.txt 时却得到了我想要的结果,因此就证明了上面的观点:

但是又有一个问题:标准输出流和标准错误流对应的都是显示器,它们有什么区别吗?

答案是有的, 我们以代码为例:

#include
int main()
{
printf("hello printfn"); //stdout
perror("perror"); //stderr

fprintf(stdout, "stdout:hello fprintfn"); //stdout
fprintf(stderr, "stderr:hello fprintfn"); //stderr
return 0;
}

结果一定会成功的输出四行内容,然后再对他进行重定向到 log.txt 中:

文件系统

很明显这里 log.txt 文件当中只有向标准输出流输出的两行字符串,而向标准错误流输出的两行数据并没有重定向到文件当中,而是仍然输出到了显示器上。实际上我们使用重定向时,是对输出流进行了重定向,而对错误流无影响 color{red} {实际上我们使用重定向时,是对输出流进行了重定向,而对错误流无影响}实际上我们使用重定向时,是对输出流进行了重定向,而对错误流无影响。

dup2

要完成重定向我们只需对 fd_array 数组当中元素的拷贝即可,Linux 中对于重定向给出了一个接口:==dup2 ==,我们可以使用这个接口完成重定向:

int dup2(int oldfd, int newfd);

dup2 会将 fd_array[oldfd] 的内容拷贝到 fd_array[newfd] 当中,如果有必要的话我们需要先使用关闭文件描述符为 newfd 的文件,dup2 函数返回值如果调用成功返回 newfd,否则返回 -1。

需要的是:

  1. 如果 oldfd 不是有效的文件描述符,则 dup2 调用失败,并且此时文件描述符为 newfd 的文件没有被关闭
  2. oldfd 是一个有效的文件描述符,但是 newfd 和 oldfd 具有相同的值,则 dup2 不做任何操作,并返回 newfd

比如:

#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 printfn");
fprintf(stdout, "hello fprintfn");
return 0;
}

文件系统

就像这样,数据会被传到 log.txt 里面。

重定向模拟实现

在我们自己实现 shell 的基础上,是可以自己实现重定向功能的。对于获取到的命令进行判断,若命令当中包含重定向符号 >、>> 或是 <,则该命令需要进行处理

设置 type 变量,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] = '�';

//实现重定向功能
char* start = cmd;
while (*start != '�'){
if (*start == '>'){
type = 0; //遇到一个'>',输出重定向
*start = '�';
start++;
if (*start == '>'){
type = 1; //遇到第二个'>',追加重定向
start++;
}
break;
}
if (*start == '<'){
type = 2; //遇到'<',输入重定向
*start = '�';
start++;
break;
}
start++;
}
if (*start != '�'){ //start位置不为'�',说明命令包含重定向内容
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){
//child
if (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
}
//shell
int status = 0;
pid_t ret = waitpid(id, &status, 0); //shell等待child退出
if (ret > 0){
printf("exit code:%dn", WEXITSTATUS(status)); //打印child的退出码
}
}
return 0;
}

效果如图:

文件系统

FILE 的文件描述符

访问文件的本质都是通过文件描述符进行访问的,而且库函数又是对系统接口的封装,所以在库函数中的 FILE 结构体也必定存在文件描述符 fd

我们在 / u s r / i n c l u d e / s t d i o . h color{red} {/usr/include/stdio.h}/usr/include/stdio.h 头文件中可以看到下面这句代码,也就是说 FILE 实际上就是struct _IO_FILE 结构体的一个别名。

typedef struct _IO_FILE FILE;

接下来转到 struct _IO_FILE 结构体的定义,其中我们可以看到一个名为_fileno的成员,这个成员实际上就是封装的文件描述符

struct _IO_FILE {
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags

//缓冲区相关 /* 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. */

struct _IO_marker *_markers;

struct _IO_FILE *_chain;

int _fileno; //封装的文件描述符
#if 0 int _blksize;
#else int _flags2;
#endif _IO_off_t _old_offset; /* This used to be _offset but it’s too small. */

#define __HAVE_COLUMN /* temporary / / 1+column number of pbase(); 0 is unknown. */ unsigned short _cur_column; signed char
_vtable_offset; char _shortbuf[1];

/* char* _save_gptr; char* _save_egptr; */

_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE };

那我们再来聊聊文件函数的底层:

fopen 函数会为用户在上层调申请 FILE 结构体,返回 FILE* 结构体指针,底层上调用 open 函数获取文件的 fd,并将 fd 交给 _fileno 填充,这样就完成了文件的打开操作。其他的比如 fread、fwrite、fputs、fgets ,都会先根据我们传入的文件指针找到对应的FILE结构体,然后在FILE结构体当中找到文件描述符,最后通过文件描述符对文件进行的一系列操作

我们以三种输出函数为例:

#include
#include
int main()
{
//c
printf("hello printfn");
fputs("hello fputsn", stdout);
//system
write(1, "hello writen", 12);
fork();
return 0;
}

文件系统

看到这结果是不是觉得淦!好怪。按照代码逻辑的话,这里应该只会打印出三个句子对应三个函数,但是为什么这里有两个函数出现了两次呢?

不难发现,这里重复的两个函数都是 C 库函数,我们就要牵扯到三种缓冲方式了:

无缓冲
行缓冲(对显示器进行刷新数据)
全缓冲(对磁盘文件写入数据)

直接执行可执行程序,将数据打印到显示器时所采用的就是行缓冲,因为代码当中每句话后面都有 n,所以当我们执行完对应代码后就立即将数据刷新到了显示器上

如果将运行结果重定向到 log.txt 文件时,数据的刷新策略就变为了全缓冲,此时使用 printf 和 fputs 打印的数据都打印到了C语言自带的缓冲区当中,之后 fork 创建子进程时,由于进程间具有独立性,而之后当父进程或是子进程对要刷新缓冲区内容时,本质就是对父子进程共享的数据进行了修改,此时就需要对数据进行写时拷贝,至此缓冲区当中的数据就变成了两份,一份父进程的,一份子进程的,所以重定向到 log.txt 文件当中 printf 和 puts 函数打印的数据就有两份。但由于 write 是系统接口,我们可以将 write 看作是没有缓冲区的,因此 write 打印的数据就只打印了一份

这个缓冲区是谁提供的?

他是 C 自带的,如果说这个缓冲区是操作系统提供的,那么 printf、fputs 和 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 结构体当中不仅保存了对应文件的文件描述符还保存了用户缓冲区的相关信息

操作系统有缓冲区吗?

答案是一定的,其实我们数据并不是直接刷新到显示器和磁盘上的,而是先刷新到操作系统的缓冲区里面,再经过缓冲区加载到显示器和磁盘上,当然这里我们先不关心操作系统的刷新策略。

文件系统

因为操作系统是进行软硬件资源管理的软件,所以要将数据刷新到具体外设硬件上,就必须要经过操作系统,看一下层状结构图也许会更清楚:

文件系统

inode

磁盘文件由两部分构成,分别是文件内容和文件属性。比如文件名、文件大小以及文件创建时间等信息都是文件属性,文件属性又被称为元信息

在命令行当中输入ls -l,即可显示当前目录下各文件的属性信息,各种文件属性排列如下:

文件系统

在 Linux 操作系统中,文件的元信息和内容是分离存储的,其中保存元信息的结构称之为 i n o d e color{red} {其中保存元信息的结构称之为 inode}其中保存元信息的结构称之为inode,因为系统当中可能存在大量的文件,所以我们需要给每个文件的属性搞一个唯一的编号,即 inode 号

也就是说,inode 是一个文件的属性集合,Linux 中几乎每个文件都有一个 inode,为了区分系统当中大量的 inode,我们为每个 inode 设置了 inode 编号,ls -i 命令即可查看当前目录下的文件和他的 inode 编号:

文件系统

无论是文件内容还是文件属性,他们都是存储在磁盘里面的

磁盘

盘是一种永久性存储介质,在计算机中,磁盘几乎是唯一的机械设备。与磁盘相对应的就是内存,内存是掉电易失存储介质,目前所有的普通文件都是在磁盘中存储的,磁盘在冯诺依曼体系结构当中既可以充当输入设备,又可以充当输出设备:

文件系统

寻址方案

对磁盘读写时,一般有以下 3 个步骤:

确定读写信息的盘面
确定读写信息的柱面
确定读写信息的扇区

分区与存储介质

要理解文件系统,我们必须要先将磁盘结构理解为线性的存储介质,比如说小学英语的磁带,你扯出磁带条条时有没有想过复读机读取磁带的信息,放完重来必要做倒带的操作才能从头开始,这就非常贴切线性结构了。

文件系统

磁盘分区

磁盘也被称为块设备,以扇区为单位,一个扇区的大小通常为512字节。如果以大小为 512G 的磁盘为例,该磁盘就可被分为十亿多个扇区:

文件系统

计为了更好的管理磁盘,磁盘进行了分区,原理类似于将整个国家划分为省市区县进行管理,使用分区编辑器在磁盘上划分几个逻辑部分,盘片一旦划分成数个分区,不同的目录与文件就可以存储进不同的分区,分区越多,文件的性质区分越细,Windows 磁盘就被分为 C 盘和 D 盘

Linux 也是可以查看文件的分区信息:

ls /dev/vda* -l

格式化

磁盘分区完成后就会进行格式化,格式化后每个分区 inode 数就会被确定下来,所以说格式化是对分区进行初始化的一种操作,会导致所有资源被清除,本质上是对分区后各个区域写入管理信息

其中的管理信息内容是由文件系统决定的,不同的文件系统格式化时管理信息是不同的,常见的文件系统有 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: 文件内容

因为 super block 极为重要,所以一般在其他块组中会存在冗余,方便损坏后拷贝恢复

此时我们就可以理解文件创建了:

  1. 先通过遍历 inode 位图找到一个空闲的 inode
  2. 再在 inode 表当中找到对应的 inode,并将文件的属性信息填充进 inode 结构中。
  3. 将该文件的文件名和inode指针添加到目录文件的数据块中

文件写入也是同理:

  1. 通过 inode 编号找到对应的 inode 结构
  2. 通过 inode 结构找到存储该文件内容的数据块,并将数据写入数据块
  3. 若不存在数据块或申请的数据块已被写满,则通过遍历块位图的方式找到一个空闲的块号,并在数据区当中找到对应的空闲块,再将数据写入数据块,最后还需要建立数据块和 inode 结构的对应关系(对应关系是通过数组进行维护的,该数组一般可以存储 15 个元素,其中前 12 个元素分别对应文件使用的 12 个数据块,剩余的三个元素分别是一级索引、二级索引和三级索引,当该文件使用数据块的个数超过12个时,可以用这三个索引进行数据块扩充)

文件删除也是同理:

其实删除并不会真正将文件信息删除,而只是将其 inode 和数据块号置为无效,所以删除文件后短时间内是可以恢复的。

短时间内是个什么意思呢,因为文件对应的 inode 号和数据块号被置为了无效,后续创建其他文件或是对其他文件进行写入操作申请 inode 号和数据块号时,可能会将该无效了的 inode号和数据块号分配出去,此时删除文件的数据就会被覆盖,也就无法恢复文件了

这也就就是了为什么拷贝文件的时候很慢,而删除文件的时候很快

文件目录也是同理:

Linux下一切皆文件,目录当然也会被看作为文件。目录有自己的属性信息,他 inode 结构中存储的是目录的属性信息,比如目录的大小、目录的拥有者等;目录的数据块当中存储的就是该目录下的文件名以及对应文件的 inode 指针。

注意: 文件名并没有存储在自己的 inode 结构当中,而是存储在该文件所处目录文件的文件内容当中。因为系统并不关心文件名,他只关心文件的 inode ,而文件名和 inode 指针存储在其目录文件的文件内容当中后,目录通过文件名和文件的 inode 指针即可将文件名和文件内容及其属性连接起来

软链接

文件软链接的创建可以通过这个命令:

ln -s myproc myproc-s

效果如下:

文件系统

我们可以通过ls -i可以看到软链接的 inode 与源文件的 inode 是不同的,并且软链接的大小比源文件的大小要小得多!

文件系统

删除源文件后软链接文件不能独立存在,虽然仍保留文件名,但却不能执行或是查看软链接的内容

硬链接

文件硬链接的创建可以通过这个命令:

ln myproc myproc-h

效果如下:

文件系统

文件系统

我们可以通过ls -i可以看到硬链接的 inode 与源文件的 inode 是相同的,并且硬链接文件的大小与源文件的大小也是相同的,特别注意的是,当创建了一个硬链接文件后,该硬链接文件和源文件的硬链接数都变成了 2

文件系统

所以硬链接文件就是源文件的一个别名 color{red} {源文件的一个别名}源文件的一个别名,一个文件有几个文件名,该文件的硬链接数就是几,这里 inode 为 659031 的文件有 myproc 和 myproc-h 两个文件名,因此该文件的硬链接数为 2

与软连接不同的是,当硬链接的源文件被删除后,硬链接文件仍能正常执行,只是文件的链接数减少了一个,但是硬链接可以同步修改多个不在或者同在一个目录下的文件名,其中一个修改后,所有与其有硬链接的文件都会一起被修改

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分