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

🌈 say-fall:个人主页 🚀 专栏:《手把手教你学会C++》 | 《系统深入Linux操作系统》 | 《数据结构与算法》 💪 格言:做好你自己,才能吸引更多人,与他们共赢,这才是最好的成长方式。
📝 前言
在C语言初学阶段,我们都写过 fopen、fread、fprintf 这些函数。但你有没有想过:这些函数底层到底是怎么和操作系统打交道的?为什么 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/putc与fgetc/fputc功能相同,但通常实现为宏,速度可能稍快。
2.4 字符串输入/输出
一次读写一行或一个字符串:
c
char *fgets(char *str, int n, FILE *stream); // 安全读取(限制长度)
int fputs(const char *str, FILE *stream); // 写入字符串
⚠️
gets()已被废弃!因为它不限制读取长度,存在缓冲区溢出风险。永远不要使用gets()。
2.5 格式化输入/输出
按照指定格式读写数据,类似于 printf 和 scanf:
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)。
这个整数是后续所有系统调用级别文件操作(read、write、close、lseek)的"通行证"。
四、📁 文件描述符------用户态与内核态的桥梁
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_pos、f_flags、f_count、f_op、f_dentry |
fd数组元素指向它 |
| ③ 文件系统级 | struct inode |
全系统共享 | 文件类型、权限、大小、磁盘块指针 | struct file经dentry指向它 |
5.1 第一级:进程级文件描述符表
每个进程在内核中由 struct task_struct 表示,其中 files 字段指向 struct files_struct;后者又通过 fdt 指向 struct fdtable,fdtable.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 file,f_count +1 |
dup() 复制 fd |
✅ 共享 | 同一个 struct file,f_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 | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!