【Linux我做主】从 fopen 到 open:Linux 文件 I/O 的本质与内核视角

从 fopen 到 open:Linux 文件 I/O 的本质与内核视角

从 fopen 到 open:Linux 文件 I/O 的本质与内核视角

github地址

有梦想的电信狗

前言

在 Linux 中,我们每天都在使用 fopenprintfwrite 这些看似"普通"的接口进行文件操作,但很少真正思考:

当我写下一行 fopen("log.txt","w"),内核里究竟发生了什么?

文件并不是简单的"磁盘上的一串字节",而是被操作系统精心管理的一类核心资源。

磁盘文件 → 内存结构 → 进程关联 → 文件描述符 → 系统调用,背后是一整套进程管理与文件管理协同运作的机制。

本文将从最常见的 fopen 出发,逐层剖析其背后的 open / write 系统调用,再深入到内核中:

  • 进程是如何"持有"文件的
  • 文件描述符为什么是一个整数
  • FILE*fd 的真实关系
  • 标准输入输出是如何建立的

希望你在读完本文后,不再只会"用文件",而是真正理解文件 I/O 在操作系统中的本质


一、文件的共识原理

  1. 文件 = 文件内容 + 文件属性

    文件不仅仅是字节序列,还包含名称、大小、权限、时间戳等属性信息,都保存在磁盘上

  2. 文件分为"打开的文件"和"未打开的文件"

    • 未打开的文件:静态地存放在磁盘中。
    • 打开的文件 :被某个进程打开并访问,并在内核中建立对应的数据结构进行管理。
  3. 打开文件的是进程:文件 I/O 的本质就是研究进程与文件的关系

    • 文件被打开,本质是进程执行了诸如 fopen 这样的代码,因此文件是由进程打开的

    • 研究打开的文件,本质就是研究进程与文件的关系

    • 每一个文件的打开操作,实质上都是某个进程向内核申请建立"文件打开对象",从而形成进程与文件之间的联系。因此:

      • 文件 I/O 并不是直接对磁盘读写,而是进程通过内核暴露的接口间接访问文件。

      • 所有打开文件的信息都由 OS 内核维护。

  4. 未打开的文件本质上是磁盘上的数据:问题核心是=="文件的存储与组织"==

    未打开的文件数量庞大,因此必须在磁盘上进行良好的组织:

    • 如何分类?(目录结构 / inode 索引)

    • 如何定位?(索引节点、块号)

    • 如何快速查找?(目录项 + 文件系统)

    • 存储的本质:让文件在磁盘上"放得下、放得好、找得快"。

  5. 文件被打开后必须先加载到内存:进程与打开文件必然是一对多的关系(1:N)

    当进程启动时,操作系统会默认为其打开三个文件流:

    • stdin

    • stdout

    • stderr

    随着程序运行,一个进程可能打开更多文件 ,因此:进程 : 打开文件 = 1 : N

    文件被加载到内存,文件的属性一定被加载到了内存,文件的内容是否加载,取决于代码有没有访问文件的内容


  1. 一个进程可以打开多个文件,操作系统内一定存在大量被打开的文件。内核必须管理大量"被打开的文件":核心思想是"先描述,再组织"
    内核中,每一个被打开的文件,都必须用一个结构体记录自身状态,这就是 文件打开对象 (如 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。举例:默认umask0022,因此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语言 fopenw模式 对open的封装
  • C语言 fopena模式 对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函数时,必须传入数组下标fdwrite函数会将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


结语

fopenopen,从 FILE*fd,从用户态到内核态,我们看到的并不是两个孤立的接口,而是:

进程、内核、文件系统共同构成的一套统一资源管理模型。

文件 I/O 的核心并不是"读写磁盘",而是:
内核如何用数据结构描述文件、用表结构管理关系、用系统调用提供访问能力。

当你理解了这些,就会发现:

  • 文件 ≠ 字节
  • 打开 ≠ 访问
  • fd ≠ 魔法数字

而是一套可被追踪、可被验证、可被推演的系统设计


以上就是本文的所有内容了,如果觉得文章对你有帮助,欢迎 点赞⭐收藏 支持!如有疑问或建议,请在评论区留言交流,我们一起进步

分享到此结束啦
一键三连,好运连连!

你的每一次互动,都是对作者最大的鼓励!


征程尚未结束,让我们在广阔的世界里继续前行! 🚀

相关推荐
HAPPY酷2 小时前
C++ 音视频项目与 UE5 渲染与电影制作的关系
c++·ue5·音视频
熊猫钓鱼>_>2 小时前
【开源鸿蒙跨平台开发先锋训练营】Day 21:深度探索智能图片处理与极致性能优化
react native·华为·性能优化·开源·交互·harmonyos·鸿蒙应用
WW、forever2 小时前
【服务器-R环境配置】导出配置文件并重建
运维·服务器·r语言
之歆2 小时前
Linux 启动流程、GRUB、init、SysV 服务与内核初步
linux·运维·服务器
2501_915921432 小时前
Fastlane 结合 AppUploader 来实现 CI 集成自动化上架
android·运维·ci/cd·小程序·uni-app·自动化·iphone
倚肆2 小时前
Kafka TopicBuilder 主题配置详解
java·服务器·kafka
天空属于哈夫克32 小时前
企微外部群自动化触达,破解私域增长难题
运维·自动化·企业微信
匀泪2 小时前
云原生(Keepalived 核心功能配置与实验)
服务器·云原生
陆业聪2 小时前
世界模型:让机器学会「脑补」
linux·服务器·unix