跳到主要内容Linux 文件 I/O 本质与内核视角:从 fopen 到 open | 极客日志C
Linux 文件 I/O 本质与内核视角:从 fopen 到 open
Linux 文件 I/O 涉及用户态库函数与内核态系统调用的交互。本文对比了 stdio 层的 fopen 与 syscall 层的 open,解析了文件描述符 fd 作为数组下标的本质,以及 FILE 结构体对 fd 的封装。阐述了进程如何通过内核数据结构管理打开的文件,以及标准输入输出流(stdin/stdout/stderr)对应的默认文件描述符 0、1、2。通过代码示例展示了 w 模式截断特性及权限控制机制,揭示了操作系统资源管理的统一模型。
从 fopen 到 open:Linux 文件 I/O 的本质与内核视角
前言
在 Linux 中,我们每天都在使用 fopen、printf、write 这些看似'普通'的接口进行文件操作,但很少真正思考:
当我写下一行 fopen("log.txt","w"),内核里究竟发生了什么?
文件并不是简单的'磁盘上的一串字节',而是被操作系统精心管理的一类核心资源。
从 磁盘文件 → 内存结构 → 进程关联 → 文件描述符 → 系统调用,背后是一整套进程管理与文件管理协同运作的机制。
本文将从最常见的 fopen 出发,逐层剖析其背后的 open / write 系统调用,再深入到内核中:
- 进程是如何'持有'文件的
- 文件描述符为什么是一个整数
FILE* 与 fd 的真实关系
- 标准输入输出是如何建立的
希望你在读完本文后,不再只会'用文件',而是真正理解文件 I/O 在操作系统中的本质。
一、文件的共识原理
-
文件 = 文件内容 + 文件属性
文件不仅仅是字节序列,还包含名称、大小、权限、时间戳等属性信息,都保存在磁盘上。
-
文件分为'打开的文件'和'未打开的文件'
- 未打开的文件:静态地存放在磁盘中。
- 打开的文件:被某个进程打开并访问,并在内核中建立对应的数据结构进行管理。
-
打开文件的是进程:文件 I/O 的本质就是研究进程与文件的关系:
- 文件被打开,本质是进程执行了诸如
fopen 这样的代码,因此文件是由进程打开的
- 研究打开的文件,本质就是研究进程与文件的关系
- 每一个文件的打开操作,实质上都是某个进程向内核申请建立'文件打开对象',从而形成进程与文件之间的联系。因此:
- 文件 I/O 并不是直接对磁盘读写,而是进程通过内核暴露的接口间接访问文件。
- 所有打开文件的信息都由 OS 内核维护。
-
未打开的文件本质上是磁盘上的数据:问题核心是'文件的存储与组织'
未打开的文件数量庞大,因此必须在磁盘上进行良好的组织:
- 如何分类?(目录结构 / inode 索引)
- 如何定位?(索引节点、块号)
- 如何快速查找?(目录项 + 文件系统)
- 存储的本质:让文件在磁盘上'放得下、放得好、找得快'。
-
文件被打开后必须先加载到内存:进程与打开文件必然是一对多的关系(1:N)
当进程启动时,操作系统会默认为其打开三个文件流:随着程序运行,一个进程可能打开更多文件,因此:进程 : 打开文件 = 1 : N。文件被加载到内存,文件的属性一定被加载到了内存,文件的内容是否加载,取决于代码有没有访问文件的内容。
-
一个进程可以打开多个文件,操作系统内一定存在大量被打开的文件。内核必须管理大量'被打开的文件':核心思想是'先描述,再组织'
内核中,每一个被打开的文件,都必须用一个结构体记录自身状态,这就是 文件打开对象(如 Linux 的 struct file)。
- 文件属性(读写位置、状态、访问模式等)
- 指向下一个对象的指针,用于组织管理
struct file_object {
struct file_object * next;
};
最终操作系统通过双链表或其他数据结构组织所有已打开文件,对这些对象进行:
- 增:打开文件
- 删:关闭文件
- 查:根据文件描述符查找
- 改:调整读写位置、权限等
至此,'管理大量打开的文件'就转化成了对这些链表结点(或红黑树等更高效结构)的管理问题。
二、C 语言文件操作接口的细节
1. fopen
参数介绍
参数一:要打开的文件路径,可传入绝对路径或相对路径。相对路径的起点是当前可执行程序的路径
w 模式使用演示
#include <stdio.h>
int main() {
FILE* fp = fopen("log.txt", "w");
if(fp == NULL) {
perror("fopen fail\n");
return 1;
}
fclose(fp);
return 0;
}
w 模式新建文件时的路径问题
由以上执行结果可知:fopen 在 w 模式时,如果当前文件不存在,在当前路径新建一个同名文件。
- 当前路径是什么?为什么是在当前路径新建?
chdir 可以改变进程的工作路径,改变之后,是否可以在新路径新建文件?
当前路径是什么?为什么在当前路径新建?
每个运行起来的进程都有自己的当前工作路径 cwd,不指定路径时,默认在当前进程的路径中新建文件
#include <stdio.h>
#include <unistd.h>
int main() {
printf("PID: %d\n", getpid());
FILE* fp = fopen("log.txt", "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 可以改变进程的工作路径后,在新路径创建文件
#include <stdio.h>
#include <unistd.h>
int main() {
chdir("/home/changan_memory");
printf("PID: %d\n", getpid());
FILE* fp = fopen("log.txt", "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 方式打开了文件,文件的内容就会被清空,且流定位到文件的开头
const char* message = "abcde";
fwrite(message, strlen(message), 1, fp);
3. fopen 的其他模式
r:打开文本文件以进行读取。流的位置位于文件的开头。
r+:以读写方式打开。流的位置在文件的开头。
w:将文件截断为零长度或创建文本文件以供写入。流定位在文件的开头。
w+:可供读取和写入操作。如果文件不存在,则会创建该文件;否则会将其截断。流会定位在文件的开头位置。
a:支持追加操作(在文件末尾进行写入)。若文件不存在,则会创建该文件。流会定位在文件的末尾。
a+:支持读取和追加(在文件末尾进行写入)。若文件不存在,则会自动创建。输出内容总是附加到文件末尾。对于 POSIX 标准,在使用此模式时并未明确说明初始读取位置是什么。对于 glibc 来说,读取时的初始文件位置在文件开头,但对于 Android、BSD 和 MacOS 来说,读取时的初始文件位置在文件末尾。
4. 输出信息到显示器的几种方法
C 程序在启动时,默认会帮助我们打开三个输入输出流
stdin:标准输入,Linux 中一般对应键盘文件
stdout:标准输出(默认是显示器),Linux 中一般对应显示器文件
stderr:标准错误,Linux 中一般对应显示器文件
如果我们想向显示器输出,或者从标准输入中读取,直接向这些流文件写入即可
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);
printf("%s: %d\n", message, 1234);
- 在 C 语言看来,输出内容到显示器和向显示器文件中写入没有区别
三、系统调用级别的文件操作
1. 访问文件的硬件本质
- 文件是存储在磁盘上的,磁盘是外部设备,访问磁盘文件其实是访问硬件!
- 因此:访问任何磁盘文件的硬件本质,都是在访问磁盘
由计算机系统的结构层次和操作系统相关知识可知:几乎所有的库,只要是访问硬件设备,必定要封装系统调用
printf/fprintf/fscanf/fwrite/fread/fgets/gets/fopen:这些操作文件的库函数,一定封装了系统调用接口
2. 系统调用级别的文件操作接口
open
参数介绍
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 的传参也是采用类似如下的方式
#define ONE (1<<0)
#define TWO (1<<1)
#define FOUR (1<<2)
#define EIGHT (1<<3)
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");
}
使用注意事项
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);
umask(0);
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
umask(0):将当前进程的 umask 码设置为 0,仅在当前进程中生效,不影响系统中的 umask,方便指定创建文件时的权限
系统调用 open 的使用
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
参数解析
ssize_t write(int fd, const void* buf, size_t count);
- fd: 对应文件描述符
- buf: 缓冲区
- count: 写入次数
- ssize_t:返回写入的字节数
write 函数的使用
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 时改变文件的打开方式
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. 结论:库函数一定封装了系统调用
FILE* fp = fopen("log.txt", "w");
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
FILE* fp = fopen("log.txt", "a");
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
- C 语言
fopen 的 w 模式 对 open 的封装
- C 语言
fopen 的 a 模式 对 open 的封装
- C 语言 的结构体
FILE 对文件描述符 fd 的封装
最终结论:不论是什么编程语言,文件操作的接口可能不同,但只要在操作系统上运行,一定都封装了文件操作的系统调用
其他和系统相关的接口也是如此
四、访问文件的软件本质
FILE* fp = fopen("log.txt", "w");
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
FILE* fp = fopen("log.txt", "a");
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
- 观察以上调用,分别是 C 语言库函数的调用和系统调用
- 既然库函数封装了系统调用,那么返回值
FILE* 和 文件描述符 fd 又有什么关系呢
1. 文件描述符的本质是数组下标
每个文件被打开,都会在操作系统内核中,创建一个内核数据结构,struct file。
struct file 是操作系统内,描述一个被打开的文件的信息的内核数据结构。
进程的 PCB 一定建立了和该进程打开的文件的关系,一个进程会打开 n 个文件。
- 调用
write 函数时,必须传入数组下标 fd,write 函数会将 fd 传递给进程,进程根据 file_struct* 指针找到文件描述符表,再通过数组下标,索引到对应的打开文件,进而对文件进行操作
2. 验证文件描述符是数组下标
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 对应的物理设备一般是:键盘,显示器,显示器
4. FILE* 和 文件描述符
FILE* fp = fopen("log.txt", "w");
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
FILE 是 C 语言库自己封装的结构体,由于操作系统访问文件时,只认识文件描述符,这是操作系统决定的。因此,FILE 结构体里,必定封装了文件描述符 fd
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 ≠ 魔法数字
微信扫一扫,关注极客日志
微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
- JSON美化和格式化
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online