Linux:文件系统的初步认识
目录
一、文件的初步理解
广义的文件
狭义的文件
文件为什么有"内容+属性"
为什么说"所有操作都是IO"
二、C语言中的文件接口
三、系统调用的文件接口
位图的理解
open函数接口
四、文件和进程的关系
五、linux下一切皆文件
一、文件的初步理解
广义的文件
就像你手机里的所有应用都能用"文件"这个词概括:
- 键盘/鼠标这些输入设备 ≈ 你在屏幕上敲字的"输入文件"
- 显示器/音箱这些输出设备 ≈ 屏幕上跳动的"视频文件"
- U盘/SD卡这些存储设备 ≈ 你平时拷贝的"文档文件"
- 网络摄像头/麦克风 ≈ 实时传输的"直播文件"
在Linux系统中,所有这些东西都被统一看作是"特殊类型的文件",放在/dev目录里管理。
狭义的文件
真正存在硬盘里的文件就像图书馆的书:
- 每本书有固定书架位置(硬盘分区)
- 书的内容印在纸上(文件数据)
- 书的借阅卡记录着谁在什么时候借过(文件属性:权限/修改时间等)
当你用Word编辑文档时,其实是在通过操作系统这个图书管理员,向硬盘这个大书库借阅"数据书"并登记"借阅记录"。
文件为什么有"内容+属性"
想象一本空笔记本:
- 内容就是白纸上的字(实际文件数据可能不存在)
- 属性就是笔记本的封面标题、页码标签、借阅人签名(元数据)
即使笔记本是空的,图书馆也会为它保留一个位置和记录,这就是为什么新建的空文件还会占用磁盘空间的原因。
为什么说"所有操作都是IO"
就像你在图书馆翻书:
- 打开文件 ≈ 向图书管理员申请查看某本书
- 读取内容 ≈ 翻阅书页获取信息
- 保存修改 ≈ 把书页内容抄回新笔记本再归还
整个过程都需要通过图书管理员(操作系统内核)协调,这就是IO(输入输出)的本质——人和存储设备之间的沟通桥梁。
【小结】
不管是在什么平台下进行操作文件,无非只能对两个方面进行操作,对于内容做操作,和对于属性做操作,而内容和属性实际上都是数据,举一个最简单的例子,一个空文件占用内存吗?答案是占用的,因为文件在内存中不仅要存储内容,它还要存储对应的属性。
如何去访问一个文件呢?
想要访问一个文件,首先要找到它,文件在哪?文件一般而言都是存储在磁盘中的,而磁盘是属于外部设备,由冯诺依曼体系可以知道,想要访问外部设备,一定要先加载到内存中,加载到内存中才能让CPU对文件进行访问,那么这些操作是交给谁来做?当然是操作系统。
访问文件的过程?
想要访问文件,首先要把这个文件打开,那么谁来打开?如何打开?打开前和打开后对于文件而言有什么变化呢?该如何理解打开的这个过程呢?
谁打开文件?答案是进程,说操作系统也不为过。进程可以对文件进行打开的操作
如何打开?在系统调用中有专门的接口用以打开文件,下面对这句进行更深入的解释
如何理解打开的过程?打开前,对于文件来说就是磁盘上的一个数据而已,但是在打开后,会把文件加载到内存中,其次可以对文件进行各种的管理。
进程可以打开多个文件?
当然可以,不仅如此,文件被加载到内存中,操作系统作为内存当中的管理者,对文件的操作是必不可少的,既然要管理,管理的一定就是文件的各种属性,所以根据前面对于进程的经验来看,对于文件的描述是肯定必不可少的,也就是说,文件也会和进程一样,有专门的“task_struct”来对它进行描述。
进程想要打开这个文件,就要委托给管理者来帮它打开,而操作系统作为管理者会提供多种多样的系统调用接口,来供给进程完成它想要完成的操作。
二、C语言中的文件接口
#include <stdio.h>
FILE *fopen(const char *path, const char *mode);
FILE *fdopen(int fd, const char *mode);
FILE *freopen(const char *path, const char *mode, FILE *stream);
1.fopen
以 w 方式运行:【w是写的形式写入,并且会清空文件的内容】
#include<stdio.h> #include<stdlib.h> // 测试文件的各种接口 void testCfile1() { // 以w的方式打开文件 FILE* fp = fopen("log.txt", "w"); if(fp == NULL) { perror("fopen fail\n"); exit(1); } fclose(fp); } int main() { testCfile1(); return 0; }
2. 以 a 方式进行
// 测试文件的各种接口 void testCfile2() { // 以a的方式打开文件 FILE* fp = fopen("log.txt", "a"); if(fp == NULL) { perror("fopen fail\n"); exit(1); } fclose(fp); }
此时没有清空了,是因为a的append,追加的意思,就是继续在原文件的基础上添加内容。
3. 以 r 方式进行
// 测试文件的各种接口 void testCfile2() { // 以a的方式打开文件 FILE* fp = fopen("log.txt", "r"); if(fp == NULL) { perror("fopen fail\n"); exit(1); } char line[64]; while(fgets(line, sizeof line, fp) != NULL) { fprintf(stdout, "%s", line); } }
![]()
4. 以环境变量进行打印
#include<stdio.h> #include<stdlib.h> #include<unistd.h> #include<string.h> int main(int agrc, char *agrv[]) { if (agrc != 2) { printf("agrc error!\n"); return 1; } FILE* fd = fopen (agrv[1], "r"); if (fd == NULL) { perror("fopen"); return 2; } char line[64]; while(fgets(line, sizeof line, fd) != NULL) { fprintf(stdout, "%s", line); } return 0; }
5. 在程序中对文件进行写入:
// 测试文件的各种接口 void testCfile2() { // 以a的方式打开文件 FILE* fp = fopen("log.txt", "a"); if(fp == NULL) { perror("fopen fail\n"); exit(1); } //进行文件操作 const char* s1 = "hello fwrite\n"; fwrite(s1, strlen(s1), 1, fp); const char* s2 = "hello fprintf\n"; fprintf(fp, "%s", s2); const char* s3 = "hello fputs\n"; fputs(s3, fp); fclose(fp); }
6.输出信息到显示器的方法
#include <stdio.h> int printf(const char *format, ...); int fprintf(FILE *stream, const char *format, ...); int sprintf(char *str, const char *format, ...); int snprintf(char *str, size_t size, const char *format, ...);
#include <stdio.h> #include <string.h> int main() { const char *msg = "hello fwrite\n"; fwrite(msg, strlen(msg), 1, stdout); printf("hello printf\n"); fprintf(stdout, "hello fprintf\n"); return 0; }
![]()
7.三个标准输入输出流
#include <stdio.h>
extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;//返回类型都是文件指针
我们可以直接使用 fwrite 这样的接口,向显示器写数据的原因是因为 C 进程一运行,stdout 就默认打开了。同理 fread 能从键盘读数据的原因是 C 进程一运行,stdin 就默认打开了。也就是说 C 接口除了对普通文件进行读写之外(需要手动打开),还可以对 stdin、stdout、stderr 进行读写(不需要手动打开)。
scanf -> 键盘、printf -> 显示器、perror -> 显示器
如果不默认打开,那么我们是不能直接调用这些接口的,所以默认打开的原因是便于我们直接上手,且大部分编码都会有输入输出的需求。也就是说,scanf、printf、perror 这样的库函数,底层一定使用了 stdin、stdout、stderr 文件指针来完成对应不同的功能。此外还有一些接口和 printf、scanf 很像,它们本身是把使用的过程暴露出来,比如 fprintf(stdout, “%d, %d, %c\n”, 9, 17, a)。
仅仅是我们的C语言程序是这样嘛?
这里可以肯定的是,不仅是 C 进程运行会打开 stdin、stdout、stderr,其它语言几乎都是这样的,C++ 是 cin、cout、cerr。所以我们可以发现一个现象,不管是学习什么语言,第一个程序永远是 "Hello World!"。这里说几乎所有语言都这样也就意味着不仅仅是语言层提供的功能了。比如一条人山人海的路从头到尾只有个别商贩在摆摊,那么我们认为这是商贩的个人行为,当地的管理者是排斥这种行为的;但如果一整路从头到尾都有商贩在摆摊,那么我们就认为是当地的管理者支持这种行为的。同样,不同语言彼此之间并没有进行过任何商量,而最终都会默认打开,所以这不仅仅是语言支持,也一定是操作系统支持的。
【注意】
const char* s1 = "hello fwrite\n";
fwrite(s1, strlen(s1) + 1, 1, fp);
这个 +1 操作是错误的,虽然字符串最后以\0结尾,但这只是C语言层面上的,只有C语言认,其他地方不认。否则将会乱码!
【小结】
虽然没有 ./ 指定路径,但它还是在当前路径下新建文件了。因为每个进程都有一个内置的属性 cwd(可以在 /proc 目录下查找对应进程的属性信息),cwd 可以让进程知道自己当前所处的路径。这也就解释了在 VS 中不指明路径,它也能新建对应的文件在对应的路径的原因。所有,进程在哪个路径运行,新建的文件就在哪个路径。
三、系统调用的文件接口
SYNOPSIS
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
int creat(const char *pathname, mode_t mode);
RETURN VALUE
open() and creat() return the new file descriptor, or -1 if an error occurred (in which case, errno is set appropriately).
flags:
访问模式(核心参数)
必须且仅能选择以下 互斥 选项之一:
O_RDONLY
:只读模式(文件指针指向文件起始处)
O_WRONLY
:只写模式(文件指针指向文件末尾)
O_RDWR
: 读写双向模式(文件指针可随机移动)
open 函数具体使用哪个和具体应用场景相关,如目标文件不存在,需要 open 创建,则第三个参数表示创建文件的默认权限, 否则,使用两个参数的 open。
- fopen fclose fread fwrite 都是 C 标准库当中的函数,称之为库函数(libc)。
- open close read write lseek 都属于系统提供的接口,称之为系统调用接口。
语言都要对系统接口做封装,本质是兼容自身语法特性,系统调用使用成本较高且不具备可移植性,那么在封装后就可以在语言层屏蔽操作系统的底层差异,从而实现语言本身的可移植性。如果所有语言都用 open 这一套接口, 那么这套接口在 Windows 下是不能运行的,所以我们写的程序是不具备可移植性的,而 fopen 能在 Windows 和 Linux 下运行的原因是 C 语言对 open 进行了封装,也就是说这些接口会自动根据平台来选择底层对应的文件接口。同样的,fopen 在 Windows 和 Linux 中头文件的实现也是不同的 。
位图的理解
#include <stdio.h>
#define Print1 1 // 0001
#define Print2 (1<<1) // 0010
#define Print3 (1<<2) // 0100
#define Print4 (1<<3) // 1000
void Print(int flags)
{
if(flags & Print1) printf("hello 1\n");
if(flags & Print2) printf("hello 2\n");
if(flags & Print3) printf("hello 3\n");
if(flags & Print4) printf("hello 4\n");
}
int main()
{
Print(Print1);
Print(Print1 | Print2);
Print(Print1 | Print2 | Print3);
Print(Print3 | Print4);
Print(Print4);
return 0;
}
对于上述代码就是所谓位图的理解,当传入一个参数flag之后,位图中的每一个二进制位都代表不同的值,而这个值就代表着不同的系统调用的功能,上面的函数如果直观的从main函数来看,就是传入Print1,就调用Print1的功能,以此类推,因此在系统调用的接口中也和上述的原理类似,传入了什么类型的系统调用参数,那么就执行对应的系统调用。
open函数接口
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
int fd = open("log.txt", O_WRONLY | O_CREAT);
if(fd < 0)
{
perror("open");
return 1;
}
printf("open success, fd: %d\n", fd);
return 0;
}
报红了,为什么呢?原因是因为,没有设置对应的权限值,创建一个文件,却没有给予它对应的权限,这样创建出来的文件,Linux系统不知道它是要干什么的,因此会标红提示用户进行处理,解决方案也很简单,只需要在第三个参数中给他传递对应的权限大小的参数即可。
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);//rw-rw-rw-
我们可以看到log.txt的权限是rw-r--r--,并不是rw-rw-rw-,这是因为Linux系统内部有其对应的权限掩码,创建的任何文件都会被权限掩码屏蔽掉对应的权限,可以在函数前设置umask(0)来解决这个问题。
细心的你发现,上个程序中,不仅打印了open success, 还打印了fd:3,这个3 是什么呢?这是因为open调用成功,返回的一个值。但是为什么不是从0,1,2,开始呢?而是从3开始。因为它们已经被使用了,C 程序运行起来会默认打开三个文件(stdin、stdout、stderr),所以 0、1、2 分别与之对应。正是因为有这三个文件,使用者才能对于任意一个进程都可以进行数据的写入和读取,这是这三个文件带来的功能。
解释完3的意义,那C语言的库函数和系统调用之间究竟有什么关系呢?如何理解呢?
fopen函数是C语言库中给使用者准备好的库函数,这个库函数实际上内部封装的系统调用就是open这个系统调用,那么下面就走进内存的视角,来看进程和文件究竟是一种什么样子的关系?
四、文件和进程的关系
画图理解:
进程创建时必然生成对应的进程控制块(PCB),其中包含指向进程文件描述符表(files_struct)的指针。该表以数组形式管理文件描述符(FD),其索引0/1/2/3分别对应标准输入、输出、错误等标准资源。每个文件描述符实际指向内核的file结构体,通过指针间接引用底层文件对象。这种设计实现了进程与文件的分离:进程仅维护用户空间的FD索引数组,内核则管理真实的文件对象,二者通过指针数组建立映射关系。值得注意的是,文件描述符是在执行open()系统调用时创建(而非数据读取阶段),作为进程与内核文件系统交互的通信门道,其生命周期随close()调用或进程终止结束。
LInux内核源码中的显示:
在 Linux 系统中,file_operations 结构体通过函数指针数组定义了文件操作的统一接口,这些指针根据底层硬件驱动的不同指向具体的实现方法(如磁盘 I/O 或网络设备操作)。这种设计使得上层应用无需关注硬件差异——无论是操作磁盘还是其他设备,只需通过统一的函数指针调用即可。对进程而言,所有文件(包括设备文件、普通文件等)都被抽象为统一的 struct file 对象,进程通过文件描述符(fd)索引的文件描述符表间接访问该结构体,从而实现对不同资源的统一操作。这种分层抽象机制将硬件驱动的具体实现细节完全屏蔽在上层,用户只需通过标准的系统调用(如 read/write)和 fd 编号即可完成文件操作,实现了“一切皆文件”的设计哲学。
五、linux下一切皆文件
那么到底该如何去理解呢?
在 Linux 系统中,“一切皆文件”的核心思想体现在对硬件设备和抽象资源的统一管理机制中:所有资源(包括普通文件、硬件设备、网络接口等)都被抽象为具有相同操作接口的“文件”。每个硬件设备或资源都会被分配一个唯一的文件描述符(FD),其对应的 file_operations 结构体通过函数指针集定义了统一的读写方法。这些指针根据具体硬件的驱动实现指向不同的底层操作函数(如磁盘的读写方法与网卡的收发方法),从而实现对硬件差异的抽象屏蔽。这种设计允许进程通过统一的文件描述符接口(如 read()
和 write()
)访问任意资源,无需区分文件类型或硬件种类。
虚拟文件系统(VFS)在此过程中扮演关键角色,它将硬件设备的文件描述符映射为统一的虚拟文件系统层,向上层提供一致的文件操作视图。这一机制类似于面向对象编程中的多态——通过函数指针的重定向,不同的硬件驱动可以实现同一组接口的不同子类行为。例如,键盘的输入操作通过文件描述符的 read()
方法触发对应的硬件中断处理,而显示器的输出则调用显卡驱动的 write()
函数。
进程默认打开的标准输入(stdin)、标准输出(stdout)和标准错误(stderr)本质上是三个特殊的文件描述符(对应文件描述符 0、1、2),它们分别指向终端设备或内核缓冲区。这种设计简化了应用程序开发,因为开发者无需显式管理硬件资源,只需通过标准的文件操作接口即可完成输入输出,充分体现了 Linux 系统“统一接口、分层抽象”的设计哲学。