【Linux系统编程】文件操作基础:C标准库、系统调用、fd是什么和fd与FILE*的关系

Linux文件操作基础:从C标准库到系统调用


🌈 say-fall:个人主页 🚀 专栏:《手把手教你学会C++》 | 《系统深入Linux操作系统》 | 《数据结构与算法》 💪 格言:做好你自己,才能吸引更多人,与他们共赢,这才是最好的成长方式。


📝 前言

在C语言初学阶段,我们都写过 fopenfreadfprintf 这些函数。但你有没有想过:这些函数底层到底是怎么和操作系统打交道的?为什么 fopen 返回的是一个 FILE * 指针,而 Linux 的 open 系统调用返回的却是一个 整数

对比项 C标准库 Linux系统调用
打开函数 fopen() 返回 FILE * open() 返回 int
读写函数 fread / fwrite / fprintf read / write
操作对象 文件流指针(带缓冲区) 文件描述符(纯整数)
所属层级 用户态封装 内核态接口

🔍 问题来了: FILE * 和那个整数之间到底是什么关系?为什么 Linux 要设计一个"整数"来代表文件?

答案就是------文件描述符(File Descriptor)!它是用户态与内核态之间的桥梁,是 Linux "一切皆文件"哲学的落地基石。

通过本文,你将掌握:

技能 应用场景
理解文件的组成与操作本质 建立正确的文件操作心智模型
掌握C语言文件操作全貌 日常开发中使用标准库IO
理解系统调用 open 与文件描述符 系统编程、性能优化
深入内核三级表结构 理解 fd 分配规则、fork/dup 共享机制

文章目录

  • Linux文件操作基础:从C标准库到系统调用
    • [📝 前言](#📝 前言)
    • [一、🌲 什么是文件](#一、🌲 什么是文件)
      • [1.1 文件 = 文件内容 + 文件属性](#1.1 文件 = 文件内容 + 文件属性)
      • [1.2 文件操作的本质](#1.2 文件操作的本质)
    • [二、📂 回顾C语言的文件操作](#二、📂 回顾C语言的文件操作)
      • [2.1 三个经典文件流](#2.1 三个经典文件流)
      • [2.2 打开与关闭](#2.2 打开与关闭)
      • [2.3 字符输入/输出](#2.3 字符输入/输出)
      • [2.4 字符串输入/输出](#2.4 字符串输入/输出)
      • [2.5 格式化输入/输出](#2.5 格式化输入/输出)
      • [2.6 二进制块输入/输出](#2.6 二进制块输入/输出)
      • [2.7 文件定位](#2.7 文件定位)
      • [2.8 状态检查与错误处理](#2.8 状态检查与错误处理)
      • [2.9 缓冲控制](#2.9 缓冲控制)
      • [2.10 C文件操作函数总结](#2.10 C文件操作函数总结)
    • [三、🔍 C与系统调用的桥梁](#三、🔍 C与系统调用的桥梁)
      • [3.1 fopen的底层是什么?](#3.1 fopen的底层是什么?)
      • [3.2 open系统调用](#3.2 open系统调用)
      • [3.3 flags参数------位图与宏](#3.3 flags参数——位图与宏)
      • [3.4 open返回值------文件描述符](#3.4 open返回值——文件描述符)
    • [四、📁 文件描述符------用户态与内核态的桥梁](#四、📁 文件描述符——用户态与内核态的桥梁)
      • [4.1 文件描述符是什么?](#4.1 文件描述符是什么?)
      • [4.2 从整数fd到磁盘数据------完整跳转路径](#4.2 从整数fd到磁盘数据——完整跳转路径)
      • [4.3 文件描述符的核心作用](#4.3 文件描述符的核心作用)
    • [五、⚙️ 内核三级表结构------fd的本质](#五、⚙️ 内核三级表结构——fd的本质)
      • [5.1 第一级:进程级文件描述符表](#5.1 第一级:进程级文件描述符表)
      • [5.2 第二级:系统级打开文件表(struct file)](#5.2 第二级:系统级打开文件表(struct file))
      • [5.3 第三级:文件系统inode表](#5.3 第三级:文件系统inode表)
    • [六、🛠️ 文件描述符与FILE*的关系](#六、🛠️ 文件描述符与FILE*的关系)
      • [6.1 FILE* 是fd的上层封装](#6.1 FILE* 是fd的上层封装)
      • [6.2 互转函数](#6.2 互转函数)
    • [七、🔄 文件描述符的分配规则](#七、🔄 文件描述符的分配规则)
      • [7.1 最小可用规则](#7.1 最小可用规则)
      • [7.2 验证分配规则](#7.2 验证分配规则)
      • [7.3 查看进程的文件描述符](#7.3 查看进程的文件描述符)
    • [八、🤔 思考题](#八、🤔 思考题)
      • [1️⃣ 为什么文件描述符是一个整数而不是一个指针?](#1️⃣ 为什么文件描述符是一个整数而不是一个指针?)
      • [2️⃣ 同一个文件被 `open()` 两次,两个 fd 的读写偏移会互相影响吗?](#2️⃣ 同一个文件被 open() 两次,两个 fd 的读写偏移会互相影响吗?)
      • [3️⃣ `close(fd)` 之后,文件数据会立即写入磁盘吗?](#3️⃣ close(fd) 之后,文件数据会立即写入磁盘吗?)
    • 本节完

一、🌲 什么是文件

在正式讨论文件操作之前,我们先回归本质:文件到底是什么?

1.1 文件 = 文件内容 + 文件属性

一个文件由两部分组成:

组成部分 内容 存储位置
文件内容 文件中实际的数据字节 磁盘数据块
文件属性 文件名、大小、权限、时间戳、所有者等 inode(索引节点)

💡 很多人以为"文件就是一堆数据",但文件的属性信息(如权限、大小、修改时间)同样是文件不可或缺的一部分。操作文件不仅是读写内容,还包括修改属性(如 chmod 改权限)。

1.2 文件操作的本质

当我们在程序中读写文件时,看似是"程序在操作文件",但实际上:

用户对文件的操作,本质上是操作系统/进程对文件的操作。

因为用户程序运行在用户态,没有权限直接访问磁盘硬件。所有的文件操作都必须通过系统调用陷入内核态,由操作系统代为完成。这就是为什么我们需要理解系统调用层面的文件操作机制。


二、📂 回顾C语言的文件操作

在深入系统调用之前,先系统地回顾一下C语言的文件操作。C标准库提供了丰富的文件操作函数,它们都定义在 <stdio.h> 头文件中,围绕 文件指针(FILE * 进行操作。

2.1 三个经典文件流

每个C程序启动时,标准库会自动打开三个文件流:

文件流 文件描述符 默认指向
stdin 标准输入 0 键盘
stdout 标准输出 1 显示器
stderr 标准错误 2 显示器

💡 注意这里已经出现了"文件描述符"的概念------0、1、2 三个整数。这三个数字不是随便定的,后面会详细解释。

2.2 打开与关闭

文件操作的第一步和最后一步:

c 复制代码
// 打开文件
FILE *fopen(const char *filename, const char *mode);
// 常用模式:"r"(读), "w"(写), "a"(追加), "rb"(二进制读), "wb"(二进制写), "r+"(读写)

// 关闭文件并释放资源
int fclose(FILE *stream);
// 成功返回0,失败返回EOF

2.3 字符输入/输出

一次读写一个字符:

c 复制代码
int fgetc(FILE *stream);       // 从流中读取一个字符
int fputc(int c, FILE *stream); // 向流中写入一个字符

// 特殊变体
int getchar(void);  // 等价于 fgetc(stdin)
int putchar(int c); // 等价于 fputc(c, stdout)

💡 getc / putcfgetc / fputc 功能相同,但通常实现为宏,速度可能稍快。

2.4 字符串输入/输出

一次读写一行或一个字符串:

c 复制代码
char *fgets(char *str, int n, FILE *stream); // 安全读取(限制长度)
int   fputs(const char *str, FILE *stream);  // 写入字符串

⚠️ gets() 已被废弃!因为它不限制读取长度,存在缓冲区溢出风险。永远不要使用 gets()

2.5 格式化输入/输出

按照指定格式读写数据,类似于 printfscanf

c 复制代码
int fprintf(FILE *stream, const char *format, ...); // 格式化输出到文件
int fscanf(FILE *stream, const char *format, ...);  // 从文件格式化输入
int sprintf(char *str, const char *format, ...);    // 写入字符串缓冲区
int sscanf(const char *str, const char *format, ...); // 从字符串读取

2.6 二进制块输入/输出

直接读写内存块,常用于读写结构体或数组数据:

c 复制代码
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);

2.7 文件定位

随机访问文件中的特定位置:

c 复制代码
int fseek(FILE *stream, long int offset, int origin);  // 移动文件位置指针
// origin: SEEK_SET(文件头), SEEK_CUR(当前位置), SEEK_END(文件尾)

long int ftell(FILE *stream);  // 获取当前位置
void rewind(FILE *stream);     // 重置到文件开头

2.8 状态检查与错误处理

c 复制代码
int feof(FILE *stream);     // 是否到达文件末尾
int ferror(FILE *stream);   // 是否出错
void clearerr(FILE *stream); // 清除错误标记
void perror(const char *s);  // 输出错误描述到stderr

2.9 缓冲控制

c 复制代码
int fflush(FILE *stream); // 强制刷新输出缓冲区

💡 fflush 是一个非常重要的函数。它把C标准库缓冲区中的数据强制写入内核。在后面的缓冲区专题中,我们会深入讨论它的作用。

2.10 C文件操作函数总结

分类 函数 说明
打开/关闭 fopen / fclose 打开与关闭文件
字符IO fgetc / fputc / getchar / putchar 单字符读写
字符串IO fgets / fputs 字符串读写
格式化IO fprintf / fscanf / sprintf / sscanf 格式化读写
二进制IO fread / fwrite 内存块读写
文件定位 fseek / ftell / rewind 随机访问
状态检查 feof / ferror / clearerr / perror 错误处理
缓冲控制 fflush 强制刷新

三、🔍 C与系统调用的桥梁

3.1 fopen的底层是什么?

我们在C中调用 fopen("test.txt", "r") 时,"r" 这个模式字符串是怎么控制文件打开方式的?

答案在于:C标准库的文件操作函数,底层都是系统调用。

fopen 的底层实际上是 Linux 的 open 系统调用。"r""w""rb" 等模式字符串,最终被翻译成 open 系统调用中的 flags 参数

3.2 open系统调用

open 是 Linux 中打开文件的核心系统调用:

c 复制代码
#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);

参数说明:

  • pathname:文件路径
  • flags:打开方式(位图 + 宏定义)
  • mode:创建文件时的权限(仅当使用 O_CREAT 时需要)

3.3 flags参数------位图与宏

flags 参数使用位图和宏来定义标志位,通过按位或 | 运算组合不同功能:

标志宏 含义 对应C模式
O_RDONLY 只读 "r"
O_WRONLY 只写 "w"
O_RDWR 读写 "r+"
O_CREAT 不存在则创建 "w" 中隐含
O_TRUNC 存在则截断为0 "w" 中隐含
O_APPEND 追加写入 "a"
c 复制代码
// 示例:以读写方式打开,不存在则创建,存在则截断
int fd = open("test.txt", O_RDWR | O_CREAT | O_TRUNC, 0644);

// 等价于C语言的
FILE *fp = fopen("test.txt", "w+");

💡 这就是为什么 fopen 的模式字符串能控制打开方式------它只是把模式翻译成了对应的 flags 宏,再调用 open

3.4 open返回值------文件描述符

open 调用成功后,返回一个 非负整数,这就是文件描述符(File Descriptor,简称 fd)

这个整数是后续所有系统调用级别文件操作(readwritecloselseek)的"通行证"。


四、📁 文件描述符------用户态与内核态的桥梁

4.1 文件描述符是什么?

文件描述符(fd)是一个非负整数,本质是进程级"文件描述符表"(即 struct file * 指针数组)的数组下标。

用户进程通过这个整数索引,经由内核的层层指针,最终找到打开文件的全部状态与磁盘上的 inode。它把"文件资源"这一内核态对象,以一个最小代价的整数形式暴露给用户态,作为两者之间的隔离与桥梁。

4.2 从整数fd到磁盘数据------完整跳转路径

下面这张图直观展示了从整数 fd 到磁盘数据块的完整路径:

复制代码
用户态进程                      内核态
                                                    
持有整数 fd = 3                  
    │                            
    ▼  fd作为数组下标            
进程级文件描述符表                 
files_struct.fdt                 
(struct file *数组)              
    │                            
    ▼  数组元素是指针             
┌─────────────────────────────────────────┐
│ 系统级打开文件表                          │
│ struct file                              │
│   f_pos(当前读写偏移)                   │
│   f_flags(状态标志)                    │
│   f_count(引用计数)                    │
│   f_op → file_operations(函数指针表)   │
│   f_dentry → dentry(目录项/路径缓存)   │
│                 │                        │
│                 ▼                        │
│             inode(文件元数据)           │
│                 │                        │
│                 ▼                        │
│             磁盘数据块                    │
└─────────────────────────────────────────┘

4.3 文件描述符的核心作用

核心作用是用一个最小代价的整数,去操作内核中庞大而复杂的文件对象。

具体体现在三个层面:

① 统一I/O接口("一切皆文件"的落地)

在 Linux 中,普通文件、目录、设备、管道、套接字、终端......都被抽象成"可读可写的文件"。文件描述符是这套抽象对用户态的唯一入口:无论操作什么对象,用户进程都只需拿到一个整数 fd,然后用同一套 read() / write() / close() 系统调用去操作它,具体行为差异由内核中不同的 file_operations 决定。

② 进程隔离与资源共享

每个进程拥有自己独立的文件描述符表(同一 fd 数值在不同进程中可能指向完全不同的文件);而系统级的打开文件表 struct file 是共享的------fork() 后父子进程的 fd 指向同一个 struct file(共享读写偏移),dup() 后同一进程内多个 fd 也指向同一个 struct file,引用计数 f_count 控制真正的释放时机。

③ 资源限额控制

内核通过 RLIMIT_NOFILE / ulimit -n 限制一个进程可同时持有的 fd 数量(常见默认 1024,可调整),把 fd 这种"廉价的整数"作为可控的资源计量单位。


五、⚙️ 内核三级表结构------fd的本质

文件描述符之所以"形式上是整数、本质上是下标",关键在于内核维护了三级数据结构

层级 数据结构 作用域 关键成员 与fd的关系
① 进程级 struct files_struct 每进程私有 fd[]数组、close_on_exec位图 fd就是该数组的下标
② 系统级 struct file 全系统共享 f_posf_flagsf_countf_opf_dentry fd数组元素指向它
③ 文件系统级 struct inode 全系统共享 文件类型、权限、大小、磁盘块指针 struct file经dentry指向它

5.1 第一级:进程级文件描述符表

每个进程在内核中由 struct task_struct 表示,其中 files 字段指向 struct files_struct;后者又通过 fdt 指向 struct fdtablefdtable.fd 才是真正的 struct file * 数组。

c 复制代码
// 简化结构
struct task_struct {
    struct files_struct *files;  // 指向文件描述符表
    // ...
};

struct files_struct {
    struct fdtable *fdt;  // 指向fd表
    // ...
};

struct fdtable {
    struct file **fd;  // 真正的 struct file* 数组!
    // fd[0] → stdin对应的file
    // fd[1] → stdout对应的file
    // fd[2] → stderr对应的file
    // fd[3] → 用户打开的第一个文件...
};

💡 文件描述符就是这个 fd[] 数组的下标,因此 fd=0、1、2 对应数组的前三项,新打开文件分配"最小未使用下标"。

5.2 第二级:系统级打开文件表(struct file)

这是 fd 真正"指向"的对象,记录了一次"打开"行为的状态:

c 复制代码
struct file {
    struct file_operations *f_op;    // 操作函数表(read/write/lseek等函数指针)
    atomic_t              f_count;   // 引用计数:dup/fork时+1,close时-1
    unsigned int          f_flags;   // open()时传入的状态标志(O_NONBLOCK等)
    mode_t                f_mode;    // 访问模式(读/写)
    loff_t                f_pos;     // 当前读写偏移(read/write/lseek修改它)
    struct dentry        *f_dentry;  // 关联的目录项
    struct vfsmount      *f_vfsmnt;  // 所属挂载点
    void                 *private_data; // 驱动私有数据
    // ...
};

几个关键设计要点:

为什么 f_pos 偏移放在 struct file 而非 inode?

🔍 问题: 同一文件被两次 open() 会得到两个 struct file、各自独立偏移;但 fork() / dup() 复制 fd 时共享同一个 struct file,于是父子进程或 dup 后的 fd 共享偏移。

场景 偏移是否共享 原因
同一文件 open() 两次 ❌ 独立 两个 struct file,各自有 f_pos
fork() 后父子进程 ✅ 共享 同一个 struct filef_count +1
dup() 复制 fd ✅ 共享 同一个 struct filef_count +1

f_count 引用计数:

close(fd) 只是把 fd 数组项清空并把 f_count 减 1,真正释放 struct file 要等计数归零。这支撑了"多 fd 共享一个打开文件描述"的语义。

f_op 函数指针表------"一切皆文件"的底层机制:

用户态 read(fd, ...) 一进入内核,就靠 fd 找到 struct file,再通过 f_op->read 调到具体文件系统/驱动实现。普通文件、字符设备、套接字的 f_op 各不相同,从而表现出不同的读写行为。

💡 这其实就是C语言版的多态!上层接口统一(read/write/close),底层实现各异(不同的函数指针),和C++的虚函数异曲同工。

5.3 第三级:文件系统inode表

struct file 通过 f_dentry 指向目录项(路径缓存,加速路径查找),dentry 再指向 inode,inode 中存放着文件的真正元数据:

复制代码
struct file → f_dentry → struct dentry → d_inode → struct inode
                                                           │
                                                           ├── 文件类型
                                                           ├── 权限
                                                           ├── 大小
                                                           └── 磁盘块指针(i_data)
                                                                    │
                                                                    ▼
                                                                 磁盘数据块

💡 多个 struct file(多次打开同一文件、或硬链接)最终可能指向同一个 inode。


六、🛠️ 文件描述符与FILE*的关系

6.1 FILE* 是fd的上层封装

文件描述符是内核POSIX层的句柄(一个 int,直接对接系统调用);而 C 标准库的 FILE *用户态libc的封装结构指针,它内部持有一个 fd,并附加了用户态缓冲区、格式化读写、错误标志等。

二者的关系是:

复制代码
FILE *  ⊃  fd
(包含)
对比项 FILE *(文件流指针) int fd(文件描述符)
层级 用户态(C标准库) 内核态(系统调用)
类型 结构体指针 整数
缓冲 有用户态缓冲区 无缓冲(直接进内核)
可移植性 跨平台 POSIX专用
操作函数 fopen/fread/fwrite open/read/write

6.2 互转函数

c 复制代码
// FILE* → fd
int fileno(FILE *fp);

// fd → FILE*
FILE *fdopen(int fd, const char *mode);

💡 fopen() 返回 FILE *,其底层仍调用 open() 拿到 fd,然后构造 FILE 结构体把 fd 存入 _fileno 成员中。


七、🔄 文件描述符的分配规则

7.1 最小可用规则

Linux 分配文件描述符的规则非常简单:

分配最小未被使用的非负整数作为新的文件描述符。

由于标准输入(0)、标准输出(1)、标准错误(2)在进程启动时已被占用,所以用户打开的第一个文件通常获得 fd = 3。

7.2 验证分配规则

c 复制代码
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
    // 进程启动时:fd 0=stdin, 1=stdout, 2=stderr
    
    int fd1 = open("file1.txt", O_CREAT | O_WRONLY, 0644);
    int fd2 = open("file2.txt", O_CREAT | O_WRONLY, 0644);
    
    printf("fd1 = %d\n", fd1);  // 输出: fd1 = 3
    printf("fd2 = %d\n", fd2);  // 输出: fd2 = 4
    
    close(fd1);  // 释放 fd 3
    
    int fd3 = open("file3.txt", O_CREAT | O_WRONLY, 0644);
    printf("fd3 = %d\n", fd3);  // 输出: fd3 = 3(复用了最小可用!)
    
    close(fd2);
    close(fd3);
    return 0;
}

💡 这个"最小可用"规则非常重要,它是理解 重定向 原理的关键。如果你关闭了 fd=1(标准输出),再打开一个文件,这个文件就会拿到 fd=1,于是所有写到 stdout 的内容都跑到了这个文件里------这就是重定向的雏形!

7.3 查看进程的文件描述符

在 Linux 中,可以通过 /proc 文件系统查看进程打开的所有文件描述符:

bash 复制代码
# 查看当前进程的fd
ls -l /proc/self/fd

# 输出示例:
# 0 -> /dev/pts/0    (终端设备-stdin)
# 1 -> /dev/pts/0    (终端设备-stdout)
# 2 -> /dev/pts/0    (终端设备-stderr)
# 3 -> /home/user/file1.txt

💡 ulimit -n 可以查看当前进程的文件描述符上限(默认通常为 1024),lsof -p <pid> 可以列出某进程的全部 fd 及其指向。


八、🤔 思考题

1️⃣ 为什么文件描述符是一个整数而不是一个指针?

答: 这是出于安全性和隔离性的考虑。如果直接把内核指针暴露给用户态,用户程序就可以任意读写内核数据结构,破坏系统安全。文件描述符作为一个"句柄"(整数下标),用户态只能通过它间接访问内核对象,而内核在每次使用 fd 时都会进行合法性检查(范围检查、权限检查等),确保用户程序不会越权访问。

💡 换句话说,fd 就像酒店的房卡------你只拿着房卡(整数),不能直接知道房间(内核对象)的具体位置,但可以用房卡开门(系统调用),由酒店管理系统(内核)验证你的权限。

2️⃣ 同一个文件被 open() 两次,两个 fd 的读写偏移会互相影响吗?

答: 不会。因为两次 open() 会创建两个独立的 struct file 对象,各自有独立的 f_pos。一个 fd 的 lseek / read / write 不会影响另一个 fd 的偏移。但如果通过 fork()dup() 复制 fd,它们共享同一个 struct file,偏移就会互相影响。

3️⃣ close(fd) 之后,文件数据会立即写入磁盘吗?

答: 不一定。close(fd) 只是将 fd 数组项清空并将 struct file 的引用计数减 1。当引用计数归零时,内核会释放 struct file 结构。但数据的写入是由内核的页缓存(Page Cache)和延迟写入机制管理的,close 并不保证立即刷盘。如果需要确保数据落盘,应调用 fsync(fd)


本节完

✅ 本节完...

📝 作者:say-fall | 编辑:say-fall | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!

相关推荐
邪修king2 小时前
Re:Linux系统篇(六):动静态库 & 链接机制【完整透彻版】
linux·运维·服务器
严谨的麻辣烫8 小时前
Linux sysctl 网络参数调优实战:高并发与长连接场景下的内核配置
linux·网络·php
ii_best10 小时前
更新!移动端开发软件按键安卓版&手机助手v5.1.0上线!本地AI识别全面解锁,脚本开发再升级
android·人工智能·ios·按键精灵
方乐寺村11 小时前
s,使用libpng提升png图片的保存速度。接下来本文将阐述在Android中如何集成libpng,以及在使用过程中遇到的问题和最 ...
android
name好难取诶11 小时前
PHP 静态分析工具实战 PHPStan 和 Psalm 完全指南
android·开发语言·php
Lydia ,12 小时前
安卓基础-线性布局
android
阡陌..12 小时前
Ubuntu 22.04 离线环境完全配置指南:从 GCC 到 NVIDIA 驱动再到 Samba 共享
linux·运维·ubuntu
Lee_jerome13 小时前
《C/C++编译全家桶:g++四阶段、.a与.so区别、CMake构建、ARM交叉编译,这篇全讲透了》
c语言·开发语言·arm开发·c++·编译·cmake·cmakelists
toooooop814 小时前
如何用 ss + ps 精准定位本机 Redis 的“隐形”消费者?
linux·数据库·redis·缓存