文章目录
- 📖 前言
- 1. 文件的预备知识
- 2. 复习C语言的文件操作
- 3. Linux系统级文件接口
- 3.1 open、 close、 read、 write 接口:
- 3.2 内核当中实现的映射关系:
- 3.3 如何理解Linux下一切皆文件:
📖 前言
本章开始,我们将进入Linux文件相关的学习与操作,从复习回顾C语言的文件操作接口,再从操作系统角度出发,学习系统调用接口。再了解虚拟文件系统,内核管理文件的数据结构。最后通过学习的操作文件的系统接口来模拟实现C语言的文件操作接口,最后对之前实现的shell进程改进。目标已经确定,接下来就要搬好小板凳,准备开讲了…🙆🙆🙆🙆
1. 文件的预备知识
- 文件 = 文件内容 + 文件属性
- 文件属性也是数据,即便你创建一个空文件,也要占据磁盘空间。
- 文件操作 = 文件内容的操作 + 文件属性的操作
- 有可能,在操作文件的过程中,既改变内容,又改变属性。
- 属性可能随着内容的变化可能在变化。
- 所谓的 “打开” 文件,究竟在干什么?
- 将文件的属性或内容加载到内存中!
- —— 这是由冯·诺依曼体系决定的!CPU只能在内存中对文件进行读写操作。
- 打开文件不是目的,访问文件才是目的。
-
- 例如:将文件内容中小写字母改成大写字母,是先将文件内容读到内存里,再把buff里面所有的内容改成大写,再写回到文件中。
- 程序被加载到内存中后磁盘中还有吗?
- 是的程序被加载到内存中后,磁盘中仍然存在程序文件。
- 程序文件是存储在磁盘上的二进制文件,它包含了程序的代码、数据和资源等信息。
- 当程序被加载到内存中时,操作系统会将程序文件中的代码和数据复制到内存中,程序在内存中执行。
- 但是,如果需要重新启动程序或者重新加载程序,操作系统仍然需要从磁盘中读取程序文件。
- 因此,程序文件仍然存在于磁盘中,直到被删除或者替换为止。
- 如果程序压根没运行,这个程序就在磁盛上单独的一份。
- 进程对应着磁盘上的一个程序,程序被加载到内存里的时候,在内存里有一份,在磁盘里也有一份。
- 是不是所有的文件,都会处于被打开的状态?
- 绝对不是!没有被打开的文件,在哪里?
-
- 只在磁盘上静静的存储着!
- 打开的文件(内存文件)和磁盘文件
- 下面讲的所有内容都是打开文件。
- 软硬链接,inode的时候再讲磁盘文件。
- 通常我们打开文件,访问文件,关闭文件,是谁在进行相关操作?
- fopen,fclose, fread, fwrite …
- 当我们的文件程序运行起来的时候,才会执行对应的代码。
- 然后才是真正的对文件进行相关的操作。
- 真正的是进程对文件进行操作!
- 目前学习文件就变成了:进程和打并文件的关系
- 进程在内存,打开文件也在内存,所以是内存级的关系。
- 文件的本质是进程和打开文件之间的关系。
- 小结:
- 对文件的操作:
-
- 只有将程序编成可执行程序之后,加载到内存里的时候。
-
- 变成了一个进程并且被CPU调度,开始执行自己编写的代码,这个时候才开始进行文件操作。
-
- 所以当文件程序运行起来的时候,才会执行对应的代码,然后才是对文件的操作。
-
- 所以通常说对文件的操作这句话,应该准确的说是:程序对应的进程对文件的操作。
- 把文件打开在做什么:
-
- 打开一个文件最终的目标是通过CPU执行用户代码,来完成对应文件操作。
-
- 所以如果要是对数据进行操作,尤其是通过CPU来对数据进行操作(通过代码的方式)的话,就必须要求数据也要在内存当中。
-
- 这个数据指的是文件的属性或者内容,一定要加载到内存里,这是由体系结构决定的(冯.诺依曼)。
- 访问一个文件就得先打开,打开就得在内存里。
- 程序要打开文件,必须先把程序变成进程。
- 所有文件操作的本质都是在研究进程和打开文件的关系。
2. 复习C语言的文件操作
- 什么是当前路径
- 在之前我们讲进程概念的时候我们讲过,【对当前路径的理解复习-传送门】。
- 每个进程都有个工作路径,调用
chdir
可以更改工作路径。 - 更改当前路径用
chdir
就可以更改,哪个进程调用这个函数就更改哪个进程的当前路径。 -
- 一个进程在运行之前是会把自己所在的路径保存在自己的PCB当中。
-
- 所以该进程当前在哪个路径,它自己是知道的。
-
- 所以当前路径最准确的说法是 进程的当前所处的工作路径。
在源代码路径下是不对的,更准确的说法是在当前进程所对应的路径下。只不过默认的一个进程的工作路径是在,当前自己所处的路径,不过路径是可以改的。所以cd、pwd查看的时候,这个进程路径变化或者不变,,说白了就是在更改自己进程的当前路径。
在C语言中,fopen
函数用于打开文件,并返回一个指向文件的指针。下面是常见的几个选项:
1. "r":以只读方式打开文件。文件必须已经存在才能成功打开。
2. "w":以写入方式打开文件。如果文件不存在,则创建新文件;如果文件已存在,则清空文件内容。
3. "a":以追加方式打开文件。如果文件不存在,则创建新文件;如果文件已存在,则在文件末尾追加内容。
4. "rb":以二进制只读方式打开文件。类似于 "r",但以二进制模式读取文件。
5. "wb":以二进制写入方式打开文件。类似于 "w",但以二进制模式写入文件。
6. "ab":以二进制追加方式打开文件。类似于 "a",但以二进制模式追加内容。
这些选项可以根据需要选择,用于读取、写入或追加文件的内容。此外,还可以使用其他选项和模式来进行更高级的文件操作,例如对文件进行读写结合(“r+”、“w+”、“a+”)或者以二进制方式进行读写操作(“rb+”、“wb+”、“ab+”)。请注意,打开文件后应该使用
fclose
函数关闭文件指针,以释放相关资源。
通过fopen打开文件,r选项,再通过fgets按行读取文件:
#include <stdio.h>
#include <unistd.h>int main()
{//1. 默认这个文件会在哪里形成呢?//2. r, w, r+, w+, a, a+//(r+ 和 w+ 都叫做既读又写,只不过w+多了一个功能,就是文件不存在会自动创建)//3. 关注一下文件清空的问题FILE* fp = fopen("log.txt", "r"); if(fp == NULL){perror("fopen");return 1;}char buffer[64] = { 0 };while(fgets(buffer, sizeof(buffer), fp) != NULL){printf("echo : %s\n", buffer);}fclose(fp);return 0;
}
- a:追加写入,不断的往文件中新增内容 -> 追加重定向
代码演示:
#include <stdio.h>
#include <unistd.h>int main()
{ FILE* fp = fopen("log.txt", "a"); //写入if(fp == NULL){perror("fopen");return 1;}const char* msg = "Hello World";int cnt = 1;while(cnt <= 5){fprintf(fp, "%s: %d\n", msg, cnt++);}fclose(fp);return 0;
}
- 当我们以w方式打开文件,准备写入的时候,其实文件已经先被清空了
以w打开的时候文件就被清空了,如果不存在就创建。
- 回归理论:
- 当我们向文件写入的时候,最终是不是向磁盘写入?是的!
- 磁盘是硬件吗?就是硬件!
- 只有谁有资格向硬件写入呢?操作系统!
- 能绕开操作系统吗?不能!
-
- 因为操作系统是软硬件资源的管理者。
-
- 所有的上层访问文件的操作,都必须贯穿操作系统。
-
- 只有操作系统可以间接的向硬件当中写入,必须得经过操作系统,并且不能绕过操作系统。
-
- 因为操作系统本身就是硬件的管理者。
- 操作系统是如何被上层使用的?
-
- 必须使用操作系统提供的相关系统调用!
所有的语言都对系统接口做了封装,封装了系统接口。
- 为什么要封装?
- 原生系统接口,使用成本比较高!
- 语言不具备跨平台性!
- 封装是如何解决跨平台问题的呢?
- C语言的解决办法:穷举所有的底层接口 + 条件编译!
- 其他语言用的可能就是多态解决。
- 不同的语言用不同的方式对系统调用进行封装。
- 所以就导致了不同语言对文件操作的接口都不同。
所有的跨平台的语言,必须通过自己的方案,对所有的系统接口做相关封装,设计好自己对应语言当中的IO接口。
如果直接使用OS接口:
具有上下级的关系:
任何一个语言,只要在同一个平台下跑,底层接口是不变的(Linux, Window…)。
我们为什么要学习系统级接口的原因:
- 文件接口更接近于操作系统,这样理解语言层面的接口会很简单。
- 在一个平台当中,这些接口是不变的。
- 只要将不变的接口学了, 在学习其他语言的时候会更容易理解。
3. Linux系统级文件接口
3.1 open、 close、 read、 write 接口:
open、 close、 read、 write
四个系统调用接口。
open函数的介绍:
两个同名函数,这是同名函数,难道是函数重载吗?我们来看看GPT的回答:
第二个参数标记位,通过宏来实现的:
- O_RONLY,O_WRONLY,O_RDWR,O_APPEND,O_CREAT…
- 系统传递标记位,是用位图结构来进行传递的!【位图复习-传送门】
- 每一个宏标记,一般只需要有一个比特位是1,并且和其他宏对应的值,不能重叠。
演示一下:
#include <stdio.h>#define PRINT_A 0x1 //0000 0001
#define PRINT_B 0x2 //0000 0010
#define PRINT_C 0x4 //0000 0100
#define PRINT_D 0x8 //0000 1000
#define PRINT_DFL 0x0//等同于系统级open
void Show(int flags)
{if(flags & PRINT_A) printf("hello A\n");if(flags & PRINT_B) printf("hello B\n");if(flags & PRINT_C) printf("hello C\n");if(flags & PRINT_D) printf("hello D\n");if(flags == PRINT_DFL) printf("hello Default\n");
}int main()
{printf("PRINT_DFL:\n");Show(PRINT_DFL);printf("PRINT_A\n");Show(PRINT_A);printf("PRINT_B\n");Show(PRINT_B);printf("PRINT_A 和 PRINT_B\n");Show(PRINT_A | PRINT_B);printf("PRINT_C 和 PRINT_D\n");Show(PRINT_C | PRINT_D);printf("PRINT all:\n");Show(PRINT_A | PRINT_B | PRINT_C | PRINT_D);return 0;
}
上述代码,模拟了用宏做标记为的实现。
返回值:
- 返回的是个整数,文件描述符
- -1是发生错误,出错之后error会设置
O_TRUNC
是open
函数中的一个标志位,表示截断(truncate
)文件。- C语言在
w
方式打开文件的时候,会清空的! - 同样的道理,
O_APPEND
是C语言中a
方式打开文件,追加!
O_TRUNC标志的含义是,如果指定的文件已经存在,那么在打开该文件的同时会将其内容清空(即截断文件)。如果指定的文件不存在,则会创建一个新的空文件。
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <unistd.h>
#include <unistd.h>
#include <unistd.h>int main()
{//有点像就近原则umask(0);//fopen("log.txt", "w"); //底层的调用的是open, O_WRONLY | O_CREAT | O_TRUN和这些选项,还要设置属性int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);if(fd < 0){perror("open");return 1;}printf("fd : %d\n", fd);int cnt = 0;//const char* str = "Hello World\n";const char* str = "aaa";while(cnt < 2){//不能带\0,这是C语言的用法, 文件不认识\0write(fd, str, strlen(str));cnt++;}return 0;
}
创建一个文件的话,这个文件要受到Linux权限的约束的。
- 所以要打开一个之前并不存在的文件的话,不能用两个参数的open,我们要用三个参数的open。
- 最后的mode代表所创建文件的权限。
不然权限是乱的,如图所示:
加了权限参数之后,发现是664,为什么不直接是0666呢?
这个和umask有关,我们要在一开始将umask设置成0。
不然不同的地方使用这段代码,umask可能都不同,需要我们认为控制,若不这样,很可能造成不同地方跑同样的代码,创造出来的文件权限不同。
为什么我们write往文件里写的时候,写的长度为什么不带上最后的‘\0’呢?
- 我们先来看一下write接口:
因为这是刻意为之,因为文件是不认是C语言的‘\0’。
在Linux中,有几个默认打开的文件是常见的:
- 标准输入(stdin):文件描述符为0,通常与用户交互,通过键盘输入数据。
- 标准输出(stdout):文件描述符为1,通常将程序输出的内容显示在终端上。
- 标准错误(stderr):文件描述符为2,用于输出程序的错误消息或诊断信息。
我们可以通过read接口,读取键盘输入的内容:
int main()
{char buffer[1024];ssize_t s = read(0, buffer, sizeof(buffer) - 1);if(s > 0){buffer[s] = '\0';printf("echo: %s", buffer);}return 0;
}
验证0,1,2就是标准IO:
//验证0,1,2就是标准IO
int main()
{const char* str = "Hello World!\n";write(1, str, strlen(str));write(2, str, strlen(str));return 0;
}
验证0, 1, 2 和stdin, stdout, stderr的对应关系:
//0,1,2和stdin, stdout, stderr的对应关系
int main()
{printf("stdin: %d\n",stdin->_fileno);printf("stdout: %d\n",stdout->_fileno);printf("stderr: %d\n",stderr->_fileno);return 0;
}
close接口,关闭文件:
close是真的将文件销毁了吗?
- 调用 close 函数后,文件仍然存在,并且可以通过重新打开或使用其他文件操作函数来再次访问。
- close 操作只是将文件与当前程序的连接断开,而不是销毁文件本身。
- 类似于智能指针中的做法。
3.2 内核当中实现的映射关系:
为什么fd是从3开始的?
- 因为0,1,2 已经被默认打开了。
我们看到stdin,stdout,stderr
三个都是FILE的指针,在我们之前学习C语言知道,FILE是个结构体,是描述文件的一个结构体。
- FILE* 是文件指针,里面封装了多个成员。
- 通过上面演示的现象说明,FILE结构体内必定封装了fd。
0,1, 2, 3, 4, 5…其实是数组下标!凡是用fd返回的,用的都是系统接口,操作系统提供的返回值!
open/ read/write/close - 要么是得到fd要么用到fd。
- 内核数据结构详解~
- 一个进程可不可以打开多个文件?当然可以!
- 所以在内核中,进程:打开的文件 = 1 : n
-
- 所以系统在运行中,有可能会存在大量的被打开的文件!
- OS要不要对这些被打开的文件进行管理呢?
-
- 操作系统如何管理这些被打开的文件呢?
-
- 一定是先描述,再组织!!
一个文件被打开,在内核中,要创建该被打开的文件的内核数据结构 — 先描述!
- 进程如何和打开文件建立映射关系呢?
- 内核当中实现的映射关系:
由图小结:
- 其一:
-
- 所以打开文件时是先创建一个
struct file
。
- 所以打开文件时是先创建一个
-
- 并且在当前文件描述符表里面分配一个没有被使用的下标,。
-
- 将地址填入表中,并将数组对应的下标返回给用户。
- 其二:
-
- 当用户再次调用
read, write
等,一定传入了fd。
- 当用户再次调用
-
- 只需要找到特定进程,找到fd,根据特定的文件描述符再索引到数组。
-
- 最后找到文件对象,就可以对它进行相关操作了。
- 内核当中:对被打开的文件的管理,转化成为了对链表的增删改查!
- 所以一个进程将来想访问某一个文件,只需要知道该文件在这个映射表当中的数组下标。
- 进程和文件之间的关系和语言没有关系!!
0,1, 2 -> stdin, stdout, stderr -> 键盘,显示器,显示器(这些都是硬件!)也用上面将的struct file
来标识对应的文件吗??是的!!
3.3 如何理解Linux下一切皆文件:
如何知道这些struct file
对应的操作方法是不一样的?
- Linux是C语言写的,虽然不支持结构体中封装函数,但是可以用函数指针。
- 通过函数指针的方式,让函数指针指向特定的方法。
- 打开
struct file
的时候识别底层文件的类型拿到底层文件的读写方法,用自己的函数指针指向就可以了。 - 上层来使用看的时候就认为是一切皆文件。
- virtual file system 虚拟文件系统:
OS内的内存文件系统以统一的视角看待所有的设备!!
- 如果要打开文件,就在内核给硬件创建
struct file
。 - 然后初始化的时候,将函数指针指向具体的设备。
- 但是在内核中存在的永远都是
struct file
,然后将struct file
关联起来。 - 所以一个进程看所有的文件都以统一的方式来看待。
- 当我们访问一个
file
的时候,具体指向底层哪个对应的设备完全取决于对应的读写方法指向的哪个方法。 - 对应的设备,对应的读写方法一定是不一样的!
上层使用同一个对象,指针指向不同的对象,最终就能调用不同的方法,可以理解为多态的前身。