从 fopen 到 open:Linux 文件 I/O 的本质与内核视角
- [从 fopen 到 open:Linux 文件 I/O 的本质与内核视角](#从 fopen 到 open:Linux 文件 I/O 的本质与内核视角)
- github地址
- 前言
- 一、文件的共识原理
- 二、C语言文件操作接口的细节
-
- [1. fopen](#1. fopen)
-
- 参数介绍
- w模式使用演示
- w模式新建文件时的路径问题
-
- 当前路径是什么?为什么在当前路径新建?
- [chdir 可以改变进程的工作路径后,在新路径创建文件](#chdir 可以改变进程的工作路径后,在新路径创建文件)
- [2. fopen 的 w 模式和 fwrite](#2. fopen 的 w 模式和 fwrite)
- [3. fopen 的其他模式](#3. fopen 的其他模式)
- [4. 输出信息到显示器的几种方法](#4. 输出信息到显示器的几种方法)
- 三、系统调用级别的文件操作
-
- [1. 访问文件的硬件本质](#1. 访问文件的硬件本质)
- [2. 系统调用级别的文件操作接口](#2. 系统调用级别的文件操作接口)
- [3. 结论:库函数一定封装了系统调用](#3. 结论:库函数一定封装了系统调用)
- 四、访问文件的软件本质
-
- [1. 文件描述符的本质是数组下标](#1. 文件描述符的本质是数组下标)
- [2. 验证文件描述符是数组下标](#2. 验证文件描述符是数组下标)
- [3. 下标 0 1 2在哪里呢](#3. 下标 0 1 2在哪里呢)
- [4. FILE* 和 文件描述符](#4. FILE* 和 文件描述符)
- [5. 总结升华](#5. 总结升华)
- 结语
从 fopen 到 open:Linux 文件 I/O 的本质与内核视角
github地址
前言
在 Linux 中,我们每天都在使用 fopen、printf、write 这些看似"普通"的接口进行文件操作,但很少真正思考:
当我写下一行
fopen("log.txt","w"),内核里究竟发生了什么?
文件并不是简单的"磁盘上的一串字节",而是被操作系统精心管理的一类核心资源。
从 磁盘文件 → 内存结构 → 进程关联 → 文件描述符 → 系统调用,背后是一整套进程管理与文件管理协同运作的机制。
本文将从最常见的 fopen 出发,逐层剖析其背后的 open / write 系统调用,再深入到内核中:
- 进程是如何"持有"文件的
- 文件描述符为什么是一个整数
FILE*与fd的真实关系- 标准输入输出是如何建立的
希望你在读完本文后,不再只会"用文件",而是真正理解文件 I/O 在操作系统中的本质。
一、文件的共识原理
-
文件 = 文件内容 + 文件属性
文件不仅仅是字节序列,还包含名称、大小、权限、时间戳等属性信息,都保存在磁盘上。
-
文件分为"打开的文件"和"未打开的文件"
- 未打开的文件:静态地存放在磁盘中。
- 打开的文件 :被某个进程打开并访问,并在内核中建立对应的数据结构进行管理。
-
打开文件的是进程:文件 I/O 的本质就是研究进程与文件的关系:
-
文件被打开,本质是进程执行了诸如
fopen这样的代码,因此文件是由进程打开的 -
研究打开的文件,本质就是研究进程与文件的关系
-
每一个文件的打开操作,实质上都是某个进程向内核申请建立"文件打开对象",从而形成进程与文件之间的联系。因此:
-
文件 I/O 并不是直接对磁盘读写,而是进程通过内核暴露的接口间接访问文件。
-
所有打开文件的信息都由 OS 内核维护。
-
-
-
未打开的文件本质上是磁盘上的数据:问题核心是=="文件的存储与组织"==
未打开的文件数量庞大,因此必须在磁盘上进行良好的组织:
-
如何分类?(目录结构 / inode 索引)
-
如何定位?(索引节点、块号)
-
如何快速查找?(目录项 + 文件系统)
-
存储的本质:让文件在磁盘上"放得下、放得好、找得快"。
-
-
文件被打开后必须先加载到内存:进程与打开文件必然是一对多的关系(1:N)
当进程启动时,操作系统会默认为其打开三个文件流:
-
stdin -
stdout -
stderr
随着程序运行,一个进程可能打开更多文件 ,因此:进程 : 打开文件 = 1 : N
文件被加载到内存,文件的属性一定被加载到了内存,文件的内容是否加载,取决于代码有没有访问文件的内容
-

- 一个进程可以打开多个文件,操作系统内一定存在大量被打开的文件。内核必须管理大量"被打开的文件":核心思想是"先描述,再组织"
内核中,每一个被打开的文件,都必须用一个结构体记录自身状态,这就是 文件打开对象 (如 Linux 的struct file)。
一个文件打开对象必须包含:
- 文件属性(读写位置、状态、访问模式等)
- 指向下一个对象的指针,用于组织管理
内核会将所有文件打开对象组织成链式结构,例如:
c
struct file_object {
// 文件属性;
struct file_object* next;
};
最终操作系统通过双链表或其他数据结构组织所有已打开文件,对这些对象进行:
- 增:打开文件
- 删:关闭文件
- 查:根据文件描述符查找
- 改:调整读写位置、权限等
至此,"管理大量打开的文件"就转化成了对这些链表结点(或红黑树等更高效结构)的管理问题。
二、C语言文件操作接口的细节
1. fopen
参数介绍

参数一 :要打开的文件路径,可传入绝对路径或相对路径。相对路径的起点是当前可执行程序的路径
参数二 :打开文件的模式
返回值 :FILE* ,文件指针类型,也叫文件句柄
w模式使用演示
cpp
#include <stdio.h>
int main()
{
// 打开文件的路径和文件名,默认在当前路径下新建一个文件
FILE* fp = fopen("log.txt", "w"); // 以 w 方式打开文件时,该文件不存在时,会自动创建
if (fp == NULL)
{
perror("fopen fail\n");
return 1;
}
fclose(fp);
return 0;
}

w模式新建文件时的路径问题
由以上执行结果可知 :fopen在 w 模式时,如果当前文件不存在,在当前路径新建一个同名文件。
那么会有以下两个问题:
-
当前路径是什么?为什么是在当前路径新建?
-
chdir可以改变进程的工作路径,改变之后,是否可以在新路径新建文件?
以上问题我们逐个解答。
当前路径是什么?为什么在当前路径新建?
**当前路径,是进程的当前工作路径 cwd **
每个运行起来的进程都有自己的当前工作路径cwd,不指定路径时,默认在当前进程的路径中新建文件
- 使用如下代码测试
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("PID: %d\n", getpid());
// 打开文件的路径和文件名,默认在当前路径下新建一个文件
FILE* fp = fopen("log.txt", "w"); // 以 w 方式打开文件时,该文件不存在时,会自动创建
if (fp == NULL)
{
perror("fopen fail\n");
return 1;
}
fclose(fp);
sleep(100);
return 0;
}
-
在
/proc/目录下,会包含当前正在运行的进程 pid 为名的目录,使用ls -l查看进程的pid文件夹,里面会有进程的各项属性信息,其中就有cwd当前进程的工作目录 -
正是因为有进程当前的工作目录cwd的存在,当我们使用fopen以w的方式打开不存在的文件时,当没有写文件的绝对路径时,操作系统默认将进程的当前工作目录cwd和要创建的文件名进行拼接,作为创建文件的路径

chdir 可以改变进程的工作路径后,在新路径创建文件
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
chdir("/home/changan_memory");
printf("PID: %d\n", getpid());
// 打开文件的路径和文件名,默认在当前路径下新建一个文件
FILE* fp = fopen("log.txt", "w"); // 以 w 方式打开文件是,该文件不存在时,会自动创建
if (fp == NULL)
{
perror("fopen fail\n");
return 1;
}
fclose(fp);
sleep(100);
return 0;
}

- 需要注意的是:chdir 更改路径时,一定要有相应的权限,普通用户不能改到root用户的文件夹下
2. fopen 的 w 模式和 fwrite
w模式的特性


- 第一次写入
hello Linux message

- 第二次写入
abcde

为什么先写入第一次写入
hello Linux message,第二次写入abcde后,文件内容没有变成abcde Linux message,而是变成了abcde
原因如下:
w模式 : Truncate file to zero length or create text file for writing. The stream is positioned at the beginning of the file.
- 将文件截断为零长度或创建文本文件以供写入。
- 流定位在文件的开头。
因此:只要以 w 方式打开了文件,文件的内容就会被清空,且流定位到文件的开头
该特性在echo中的体现

观察以上现象:
- 每次执行
echo重定向时,文件中的内容都会被清空 - 仅使用符号
>也会清空内容>重定向:以w方式打开文件>>重定向:以a方式打开文件
结论:
echo重定向向文件中写数据时, 一定是先以 w 模式打开文件,再写入内容,因此会将打开的文件内容给清空
只要以 w 方式打开了文件,文件的内容就会被清空,且流定位到文件的开头
因此:仅仅执行以下代码,文件的内容也会被清空

小注意事项:
cpp
const char* message = "abcde";
// 写入时是否要将 '\0' 写入? strlen(message) 是否要+1 ?
// 字符串以 '\0' 结尾, 是C语言的规定, 和操作系统管理文件无关. 写入时只需要将字符串的内容写入即可
// 因此 strlen(message) 不需要+1
fwrite(message, strlen(message), 1, fp);
3. fopen 的其他模式

fopen的其他模式翻译解释:
r:打开文本文件以进行读取。流的位置位于文件的开头。r+:以读写方式打开。流的位置在文件的开头。w:将文件截断为零长度或创建文本文件以供写入。流定位在文件的开头。w+:可供读取和写入操作。如果文件不存在,则会创建该文件;否则会将其截断。流会定位在文件的开头位置。a:支持追加操作(在文件末尾进行写入)。若文件不存在,则会创建该文件。流会定位在文件的末尾。a+:支持读取和追加(在文件末尾进行写入)。若文件不存在,则会自动创建。输出内容总是附加到文件末尾。对于 POSIX 标准,在使用此模式时并未明确说明初始读取位置是什么。对于 glibc 来说,读取时的初始文件位置在文件开头,但对于 Android、BSD 和 MacOS 来说,读取时的初始文件位置在文件末尾。
4. 输出信息到显示器的几种方法
C程序在启动时,默认会帮助我们打开三个输入输出流
stdin:标准输入,Linux中一般对应键盘文件stdout:标准输出(默认是显示器),Linux中一般对应显示器文件stderr:标准错误 ,Linux中一般对应显示器文件

如果我们想向显示器输出,或者从标准输入中读取,直接向这些流文件写入即可
cpp
const char* message = "hello Linux";
// 方法一: 直接向显示器文件中写入
fwrite(message, strlen(message), 1, stdout);
fprintf(stdout, "%s: %d\n", message, 1234);
fprintf(stderr, "%s: %d\n", message, 1234); // 向 stderr 中写入,也能输出到显示器中
// 方法二: 调用 printf 等IO函数
printf("%s: %d\n", message, 1234);
总结:
- 在C语言看来,输出内容到显示器和向显示器文件中写入没有区别
三、系统调用级别的文件操作
1. 访问文件的硬件本质
先给出如下结论:
- 文件是存储在磁盘上的,磁盘是外部设备,访问磁盘文件其实是访问硬件!
- 因此:访问任何磁盘文件的硬件本质,都是在访问磁盘

由计算机系统的结构层次和操作系统相关知识可知 :几乎所有的库,只要是访问硬件设备,必定要封装系统调用
printf/fprintf/fscanf/fwrite/fread/fgets/gets/fopen:这些操作文件的库函数,一定封装了系统调用接口
2. 系统调用级别的文件操作接口
open
参数介绍
cpp
// 用于打开已存在的文件,不会创建文件,不能指定权限
int open(const char *pathname, int flags);
// 可以通过传参控制是否创建文件以及创建文件的权限
int open(const char *pathname, int flags, mode_t mode);
int open(const char *pathname, int flags);:通常用于打开已经存在的文件
int open(const char *pathname, int flags, mode_t mode);:通常用于一个文件不存在,需要先创建再打开时使用,且可以指定文件创建时的权限
参数说明:
-
pathname:文件路径 -
flags:打开文件的模式 ,介绍flags常用的传值- O_RDONLY:只读
- O_WRONLY:只写
- O_RDWR:读和写
- O_CREAT:打开文件不存在时创建
- O_APPEND:追加
- O_TRUNC:打开时清空文件内容
-
mode:以什么权限创建文件,仅当flags包含O_CREAT时有效;- 权限说明:
mode参数指定的是文件的 "默认权限" ,最终权限会被umask(权限掩码,往期文章中提到过)修正,公式为:最终权限 = mode & ~umask。举例:默认umask为0022,因此mode=0666时,最终权限为0644。
- 权限说明:

返回值 :成功返回一个整数,称为文件描述符。失败时返回**-1**
理解比特位级别的标志位传参方式
open函数flags的传参也是采用类似如下的方式
cpp
#define ONE (1 << 0) // 1
#define TWO (1 << 1) // 2
#define FOUR (1 << 2) // 4
#define EIGHT (1 << 3) // 8
void show(int flags)
{
if (flags & ONE)
printf("hello function1: %d\n", (flags & ONE));
if (flags & TWO)
printf("hello function2: %d\n", (flags & TWO));
if (flags & FOUR)
printf("hello function4: %d\n", (flags & FOUR));
if (flags & EIGHT)
printf("hello function8 : %d\n", (flags & EIGHT));
}
int main()
{
printf("-----------------------------\n");
show(ONE);
printf("-----------------------------\n");
show(TWO);
printf("-----------------------------\n");
show(ONE | TWO);
printf("-----------------------------\n");
show(ONE | TWO | FOUR);
printf("-----------------------------\n");
show(ONE | FOUR);
printf("-----------------------------\n");
show(FOUR | EIGHT);
printf("-----------------------------\n");
}
使用注意事项
cpp
int fd = open("log.txt", O_WRONLY); // 打开文件失败,因为该接口不会自动创建文件
int fd = open("log.txt", O_WRONLY | O_CREAT); // 创建文件成功,但文件的权限不合适
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666); // 指定文件权限为666, 创建出来却是664
// 正确使用方法
// 先将umask 置零,创建出的文件权限即为所指定的数字
umask(0);
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666); // 直接指定文件权限为666
umask(0):将当前进程的umask码设置为0,仅在当前进程中生效,不影响系统中的umask,方便指定创建文件时的权限
系统调用open的使用
cpp
int main()
{
umask(0);
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
if (fd < 0)
{
perror("open file fail");
}
close(fd);
return 0;
}
write
参数解析
cpp
ssize_t write(int fd, const void *buf, size_t count);
- fd: 对应文件描述符
- buf: 缓冲区
- count: 写入次数
- ssize_t :返回写入的字节数
write函数的使用
cpp
int main()
{
umask(0);
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
if (fd < 0)
{
perror("open file fail");
}
const char* message = "hello Linux";
write(fd, message, strlen(message));
close(fd);
return 0;
}
-
系统调用
write函数,并不像C语言的fwrite函数那样,每次以写方式打开文件时会清空文件,而是会对文件中的内容进行覆盖写入 -
如果想要实现每次打开打开文件写入时清空文件内容,需要在open时改变文件的打开方式
cpp
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
// 以上三个参数结合起来,可以实现,每次打开文件时,不存在时创建文件,先清空文件中的内容,再进行写入
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
// 以上三个参数结合起来,可以实现,打开文件,不存在时创建文件,在文件中追加写入
由此可见 :O_TRUNC 参数和 O_APPEND 是矛盾的
3. 结论:库函数一定封装了系统调用
cpp
FILE* fp = fopen("log.txt", "w");
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666); // C语言 fopen 的w模式 对open的封装
FILE* fp = fopen("log.txt", "a");
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666); // C语言 fopen 的a模式 对open的封装
以上分别是C语言库函数和系统调用的使用方法:
- C语言
fopen的w模式 对open的封装 - C语言
fopen的a模式 对open的封装 - C语言 的结构体
FILE对文件描述符fd的封装
其他文件操作库函数也一定进行了类似的封装
最终结论 :不论是什么编程语言,文件操作的接口可能不同,但只要在操作系统上运行,一定都封装了文件操作的系统调用
其他和系统相关的接口也是如此
四、访问文件的软件本质
cpp
FILE* fp = fopen("log.txt", "w");
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666); // C语言 fopen 的w模式 对open的封装
FILE* fp = fopen("log.txt", "a");
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666); // C语言 fopen 的a模式 对open的封装
- 观察以上调用,分别是C语言库函数的调用和系统调用
- 既然库函数封装了系统调用,那么返回值
FILE*和 文件描述符fd 又有什么关系呢
1. 文件描述符的本质是数组下标

每个文件被打开,都会在操作系统内核中,创建一个内核数据结构,strcut file
strcut file 是操作系统内,描述一个被打开的文件的信息的内核数据结构。
进程的PCB一定建立了和该进程打开的文件的关系,一个进程会打开n个文件
- 调用
write函数时,必须传入数组下标fd ,write函数会将fd传递给进程,进程根据file_struct*指针找到文件描述符表 ,再通过数组下标,索引到对应的打开文件,进而对文件进行操作
进程管理和文件管理关联,是通过数组下标关联的

2. 验证文件描述符是数组下标
cpp
int main()
{
umask(0);
int fd1 = open("log1.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd2 = open("log2.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd3 = open("log3.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd4 = open("log4.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd1 < 0 || fd2 < 0 || fd3 < 0 || fd4 < 0)
{
perror("open file fail");
}
printf("fd1: %d\n", fd1);
printf("fd2: %d\n", fd2);
printf("fd3: %d\n", fd3);
printf("fd4: %d\n", fd4);
close(fd1);
close(fd2);
close(fd3);
close(fd4);
return 0;
}

3. 下标 0 1 2在哪里呢
既然返回值是数组下标,刚开始从3开始,失败时返回-1 ,那么下标0 1 2在哪里呢?
我们观察到0 1 2刚好是三个,自然联想到以下内容:
C程序在启动时,默认会帮助我们打开三个输入输出流
stdin:标准输入 ,Linux中一般对应键盘文件stdout:标准输出 (默认是显示器),Linux中一般对应显示器文件stderr:标准错误 ,Linux中一般对应显示器文件
在C语言层面他们的类型是FILE*,但在操作系统层面,操作系统只认识文件描述符fd
先给出结论再进行验证:
-
Linux进程默认情况下会有3个缺省打开的文件描述符 ,分别是标准输入0, 标准输出1, 标准错误2。 -
0,1,2对应的物理设备一般是:键盘,显示器,显示器
接下来第四点中验证一下 0 1 2 是什么文件
4. FILE* 和 文件描述符
cpp
FILE* fp = fopen("log.txt", "w");
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
FILE和文件描述符fd有什么关系呢?
结论如下:
FILE是C语言库自己封装的结构体,由于操作系统访问文件时,只认识文件描述符 ,这是操作系统决定的。因此,FILE结构体里,必定封装了文件描述符fd
证明FILE结构体中必定封装了文件描述符
cpp
int main()
{
printf("stdin->fd: %d\n", stdin->_fileno);
printf("stdout->fd: %d\n", stdout->_fileno);
printf("stderr->fd: %d\n", stderr->_fileno);
return 0;
}

结论 :可以看到,经验证,默认打开的三个输入输出流结构体,他们的文件描述符正好是0 1 2
5. 总结升华
任何语言,想在操作系统中访问文件,语言提供的接口必定要封装fd
-
键盘文件 :
fd = 0 -
显示器文件 :
fd = 1 -
显示器文件 :
fd = 2
我们之前说:C语言程序启动时,默认会打开 0 1 2号文件
现在需要对其纠正 :程序启动时默认打开 stdin stdout stderr,不是C语言的特性,而是操作系统的特性。任何语言的程序启动时,都会默认打开键盘和显示器。只不过C语言中将他们封装成了 stdin stdout stderr这三个结构体
-
库函数封装了系统调用
-
FILE 结构体封装了 文件描述符fd
结语
从 fopen 到 open,从 FILE* 到 fd,从用户态到内核态,我们看到的并不是两个孤立的接口,而是:
进程、内核、文件系统共同构成的一套统一资源管理模型。
文件 I/O 的核心并不是"读写磁盘",而是:
内核如何用数据结构描述文件、用表结构管理关系、用系统调用提供访问能力。
当你理解了这些,就会发现:
- 文件 ≠ 字节
- 打开 ≠ 访问
- fd ≠ 魔法数字
而是一套可被追踪、可被验证、可被推演的系统设计。
以上就是本文的所有内容了,如果觉得文章对你有帮助,欢迎 点赞⭐收藏 支持!如有疑问或建议,请在评论区留言交流,我们一起进步
分享到此结束啦
一键三连,好运连连!你的每一次互动,都是对作者最大的鼓励!
征程尚未结束,让我们在广阔的世界里继续前行!🚀