序言
在编程的世界里,文件操作是不可或缺的一部分。无论是数据的持久化存储、日志记录,还是简单的文本编辑,文件都扮演着至关重要的角色。然而,当我们通过编程语言如 C、Java
等轻松地进行文件读写时,背后隐藏的复杂机制和底层细节往往被我们所忽略。
本文将带着大家理解文件操作在底层的样子,原来文件不仅仅是被简单地读入或写入内存中。
1. 系统调用接口 🔁 用户编程接口
操作系统(简称 OS
)是计算机系统的核心软件,它管理计算机的硬件和软件资源,为上层应用程序提供一个稳定、统一的运行环境。
如果将你的电脑比作财产的话,那么操作系统就是你的管家,而且还是一个强势的管家。
1.1 系统调用接口
1. 系统调用接口的概念
当你想要访问你的计算机的软硬件资源时,必须符合操作系统的规定,而系统调用就是操作系统为用户空间程序提供的一种服务接口。
2. 为什么要存在系统调用接口
我的电脑我想干嘛就干嘛呀! 😤 为什么操作系统管的这么严呀,还要按照他的要求来使用,到底谁是主人呀!
操作系统的存在就是避免用户不合法的行为(比如:错误的修改数据,不正确的使用)导致争整个系统的崩溃!所以正是想要用户得到一个良好的运行环境,才约束用户的行为。
3. 系统调用接口的作用
系统调用是计算机操作系统中非常核心的概念,它的作用包含但不限于如下内容:
- 硬件保护与隔离:系统调用作为用户程序和硬件设备之间的中介,确保用户程序不能直接访问硬件,从而保护硬件资源免受非法访问和破坏。
- 资源管理与分配:操作系统通过系统调用来管理CPU时间、内存、文件和设备等系统资源,确保资源的公平分配和有效利用。
- 实现操作系统功能:系统调用是操作系统实现各种功能(如进程管理、内存管理、文件系统、网络通信等)的基础。
1.2 用户编程接口
1. 用户编程接口的概念
用户操作接口接口(API
)是操作系统或库函数提供给程序员的接口,在 C
语言环境中,API
通常以库函数的形式出现,这些函数封装了系统调用的细节,为程序员提供了更为简便、易用的编程接口。
2. 为什么要存在用户编程接口
API
提供了一种对系统调用抽象和封装。通过将复杂的系统细节隐藏起来,API
只暴露用户需要的功能和接口,使得用户(包括程序员)可以更容易地理解和使用这些功能。这种抽象和封装降低了直接与系统底层交互的难度和复杂性。
3. 编程接口的跨平台性
除了简化用户对系统调用的使用,编程接口还具有一个非常重要的性质,那就是 跨平台性
。
系统调用接口对应不同的系统如 Linux, Windows,Mac等
,实现的细节肯定是不一致的。所以你在 Linux
系统下使用了系统调用的程序,在 Mac
下就不一定能运行。
但是 API
通过特定的方法(如条件编译配置)可以实现在不同的平台下都能正常运行。
2. 利用系统调用接口进行文件操作🐧
在使用 C
语言进行文件操作时,我们利用 fopen
函数以特定的方式打开一个文件,利用 fread
读取文件或者是利用 fwrite
写文件,最后利用 fclose
函数关闭文件。
这都是将系统接口进行封装过后的 API
,今天我们尝试直接使用系统接口,这更加接近底层,更好的帮助我们引出后续的内容。😀
2.1 open
函数
系统提供的 open
函数有两个版本:
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
下面多出的 mode
参数用于指定新创建文件的权限
,所以我们使用下面那个的版本。我们看看他整体的参数:
pathname
:指向要打开的文件的路径名flags
: 用于指定打开文件的方式以及其他选项(如是否创建文件)。这些选项可以通过位或操作组合起来。mode
: 指定新创建文件的权限 。- 返回值为一个
int
,叫做文件描述符(fd
)
我们在这里详细介绍 flags
参数 以及 mode
参数:
flags
参数 — 位图
某些函数需要我们传递标志位,该函数根据标志位执行特定的功能,就比如:
1 #include <iostream>
2 using namespace std;
3
4
5 int Func(int num, int flag1, int flag2, int flag3){
6 if(flag1 == 1){
7 num = num + 1;
8 }
9 if(flag2 == 1){
10 num = num - 1;
11 }
12 if(flag3 == 1){
13 num = num * 2;
14 }
15
16 return num;
17 }
18
19
20 int main(){
21 int num = 10;
22 int flag1 = 1, flag2 = 0, flag3 = 1;
23
24 num = Func(num, flag1, flag2, flag3);
25 cout << "num = " << num << endl;
26
27 return 0;
28 }
我们根据 3 个标志位质的不同执行不同的逻辑,但现在只是 3 个标志位,如果是 10 个,20 个,那我们也设置相同数量的形参吗,这太麻烦,也太浪费空间了。
那怎么办呢?位图
。一个 int
变量在该环境下是 32 位,是否可以表示为 32 种状态呢?为了简单,就拿 8 位举例:
#define FLAG1 1 // 0000 0001
#define FLAG2 2 // 0000 0010
#define FLAG3 4 // 0000 0100
不同的状态对应的位置我就取 1 ,如果这 3 个状态我都想要呢?那就利用 |
或对他们进行运算:
FLAG1 | FLAG2 | FLAG3 // 0000 0111
可以看到,这就代表这三个状态我都表示,并且不同的状态之间是相互不干扰的。
根据这个方法,我们更新上述代码:
1 #include <iostream>
2 using namespace std;
3
4 #define FLAG1 1 // 0000 0001
5 #define FLAG2 2 // 0000 0010
6 #define FLAG3 4 // 0000 0100
7
8 // 利用 & 操作,判断是否选取该状态
9 int Func(int num, int flags){
10 if(flags & FLAG1){
11 num = num + 1;
12 }
13 if(flags & FLAG2){
14 num = num - 1;
15 }
16 if(flags & FLAG3){
17 num = num * 2;
18 }
19
20 return num;
21 }
22
23
24 int main(){
25 int num = 10;
26
27 num = Func(num, FLAG1 | FLAG3);
28 cout << "num = " << num << endl;
29
30 return 0;
31 }
flags
参数就采用了位图的方式,我将列举我们常用的选项:
O_RDONLY
:以只读方式打开文件。O_WRONLY
:以只写方式打开文件。O_RDWR
:以读写方式打开文件。O_CREAT
:如果文件不存在,则创建它。需要 mode 参数来指定新文件的权限。O_TRUNC
:如果文件已存在且为只写或读写模式打开,则清空文件内容。O_APPEND
:以追加方式打开文件,数据会被写入到文件尾。
mode
参数 — 文件权限
关于文件权限的知识在这里就不展开细说了,这样的话篇幅太长了,也不易消化😖。我专门有一篇文章 👉点击查看 详细地介绍了文件权限相关的知识,大家有兴趣可以看一下哈。
前置知识结束了,现在我们可以进入正题了,我们以写的方式创建一个新的文件:
16 // 写方式打开文件,并且文件不存在就创建一个,文件的权限是 rw-rw-r--
17 int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
18 if(fd == -1) perror("open");
注意:这里就可以看出系统调用接口和编程接口的区别,在 C语言中的 fopen 函数,当文件不存在时会为我们自动创建一个文件,而系统调用接口就需要我们指定。
2.2 write
函数
write
函数的参数就稍显简单一些:
ssize_t write(int fd, const void *buf, size_t count);
fd
: 要写入数据的文件描述符。buf
:指向要写入数据的缓冲区的指针。count
:要写入文件的字节数。- 返回值:写入错位时返回 -1
我们就直接写入内容吧:
20 // 写入指定信息
21 const char* msg = "Its a test!!!\n";
22 ssize_t ret = write(fd, msg, strlen(msg));
23 if(ret == -1) perror("write");
2.3 运行结果
系统按照我们的需求创建了一个指定权限的文件:
并且为我们写入了相应的内容:
3. 提出几个为什么
我们需要根据现有的现象来发现问题,不断地问自己为什么,这才能提升自己。
3.1 fd
文件描述符是什么?
我们通过接受 open
函数返回的 fd
文件描述符,并将 fd
传入到 write
函数的参数中,就可以向指定文件写入内容,这是为什么呢?怎么就可以根据一个 fd
的整数向指定文件写入内容呢?
3.2 open
函数的返回值?
我们先看一段代码:
21 int main(){
22 int fd1 = open("log1.txt", O_WRONLY | O_CREAT, 0666);
23 printf("fd1: %d.\n", fd1);
24
25 int fd2 = open("log2.txt", O_WRONLY | O_CREAT, 0666);
26 printf("fd2: %d.\n", fd2);
27
28 int fd3 = open("log3.txt", O_WRONLY | O_CREAT, 0666);
29 printf("fd3: %d.\n", fd3);
30
31 int fd4 = open("log4.txt", O_WRONLY | O_CREAT, 0666);
32 printf("fd4: %d.\n", fd4);
33 return 0;
34 }
这段代码的输出结果是:
fd1: 3.
fd2: 4.
fd3: 5.
fd4: 6.
我们知道 open
函数的返回值是 fd
,但为什么他们的值是连续的?并且唯独缺少 0, 1, 2
,这是巧合吗?如果一次运行时这样,有理由怀疑是巧合。但是,我们多次运行还是这样,那就肯定不是了。
4. struct file
结构体
当一个文件没有被任何进程调度时,该文件就静静的躺在你的磁盘中。但是当文件被调度时,就会被送往内存中,所以内存中的文件就只是文件的内容吗?肯定不是!同一时间,内存中肯定存在着大量需要操作的文件,操作系统需要高效的管理文件那怎么办呢?
使用 结构体 + 双链表
的结构,类似于管理进程的结构 ,在这篇文章,有较详细的说明👉点击查看。
4.1 struct file
中的内容
该结构体中包含了文件的基本信息,包括:
f_op
:指向一个file_operations
结构体的指针,该结构体包含了指向文件操作函数的指针,如read、write、open、release
等。f_count
:表示有多少进程或文件描述符引用了这个文件。当f_count
降到 0 时,文件将被关闭。f_flags
:包含文件的标志,如O_RDONLY、O_WRONLY、O_RDWR
等,表示文件的打开模式。f_mode
:表示文件的访问模式(只读、只写、读写)。f_pos
:当前的文件偏移量,表示下一次读写操作将从哪个位置开始。f_owner
:包含文件的所有者信息,用于权限检查。- …
4.2 相关的内核级别的缓存区
还有一个空间叫做 缓冲区
尽管不直接受到该结构体的管理,但是和结构体紧密相关:
可以看出该 缓存
就是一块在内存中的空间,那有啥用呢?用处可大了!
当用户请求读取文件时,内核会首先检查缓冲区中是否已经缓存了所需的数据
。如果是,则直接从缓冲区中读取数据,避免了磁盘访问的延迟
。如果不是,内核会从磁盘读取数据到缓冲区中,并更新缓冲区的内容。
当用户更新文件内容时,内核并不会直接将该内容立马更新到磁盘上,而是先放在缓冲区等到累积到一定的量,在一次写入磁盘,减少 I/O
操作。
所以对于内存这种告诉设备来说,内核级缓冲区通过减少对磁盘等低速设备的直接访问次数,显著提高了文件I/O操作的性能。
4.3 理解 Linux
一切皆文件
相信大家肯定听过一句话,Linux 中一切皆文件。
但是对于我们的键盘,鼠标,显示屏来说,他们确实是实实在在的硬件呀!我该怎么把它看作文件呢?
属性 + 方法
对于任何的硬件设备都离不开两个概念 属性 + 方法
,但是本章节我们主要关注该结构体的 方法
。Linux
将一切的对象都视作一个文件,那么就拿键盘举例吧,请问键盘这个文件有什么方法呢?无非就是 读方法
或者是 写方法
!键盘你能读我理解,写方法
又是什么呢?键盘确实没有写方法,该方法置空不就好啦!
所以我们可以这么看待硬件:
对于操作系统来说,对于普通文件的管理使用的是一个 struct file
,里面包含文件的基本属性以及读写方法等,对于硬件我也可以一同看待呀!也还不是硬件的基本属性以及读写方法:
所以说当操作系统调用一个文件的时候,才不关心该文件本质是硬件还是啥,我该使用读方法就使用读方法,改写就使用写方法,反正所有文件的操作函数接口是一致的。
这就是使用 struct
封装的好处,尽管底层千差万别,但是上层的调用都是一致的!
5. fd
含义以及文件描述符表
上面说到,操作系统会将系统调度的所有文件的 struct
利用双链表的形式管理起来。我们的一个进程可能同一时间调度多个文件,又该如何管理呢?
5.1 文件描述符表
进程会将自己所控制的所有文件的 struct
结构体的指针放在一个文件描述表中:(注:该表中包含其他信息,但在本文中不关注。
)
该表包含一个结构体指针数组,每一个存储的元素就是你所控制文件结构体的指针,该表可以在进程的 PCB
(在 Linux
下叫做 struct_task
)中找到。
5.2 fd
的含义
到这里就不难理解 fd
的含义了,他为你想要操作文件的结构体指针在结构体指针数组里面的下标。
依靠他,就能找到想要操作文件的位置,环环相扣!
5.3 fd
的分配方式
那进程新加入文件描述符表的文件,怎么分配 fd
呢?会从上到下遍历该表,哪个位置是空闲的就放到哪个位置。
6. 解答疑惑
有了一定的知识基础之后,我们尝试解决在 3
中提出的问题。
6.1 fd
文件描述符是什么?
进程新使用的文件会将该文件的结构体指针放入进程描述符表的结构体指针数组中,该 fd
就是指针放入结构体指针数组的下标位置。有点绕口哈😇
6.2 open
函数的返回值?
为什么 open
函数的返回值缺少 0, 1, 2
呢?因为这几个文件描述符已经被标准输入(stdin
)、标准输出(stdout
)和标准错误输出(stderr
)在进程启动时占用。
0
通常被分配给标准输入键盘
(stdin
)1
通常被分配给标准输出显示器
(stdout
)2
通常被分配给标准错误输出显示器
(stderr
)
怎么证明呢?
证明 1:
14 int main(){
15
16 const char* msg = "Its a test!!!\n";
17 ssize_t ret = write(1, msg, strlen(msg));
18
19 return 0;
20 }
我们直接向 1
对应的文件写入信息,运行,成功在显示器打印:
Its a test!!!
证明 2:
13 int main(){
14
15 umask(0);
16
17 close(1);// 关闭显示器文件
18
19 // 写方式打开文件,并且文件不存在就创建一个,文件的权限是 rw-rw-r--
20 int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
21 if(fd == -1) perror("open");
22 printf("fd = %d.\n", fd);
23
24
25 return 0;
26 }
在这里我们关闭 1 对应的文件,之后我们打开一个文件,并打印。
最后我们发现,并没有输出任何内容到屏幕上,反而内容在 log.txt
文件上?
大家思考一下这是为什么?
7. 总结
在这篇文章中介绍了部分文件系统的内容,希望大家有所收获!