一、回顾C语言文件接口
1. 打开文件
#include <stdio.h> int main() { FILE *fp = fopen("myfile", "w"); if (!fp) { printf("fopen error!\n"); return 1; } fclose(fp); return 0; }问题:myfile会被创建在哪里?
在当前进程的“当前工作目录”下。
那么系统如何知道进程的当前路径?
Linux 提供了/proc文件系统,可以查看运行中进程的信息:
ps ajx | grep myProc ls /proc/533463 -l重点关注两个符号链接:
cwd→ 当前进程的工作目录exe→ 当前进程的可执行文件路径
✅ 打开文件,本质是进程打开文件,因此即使不带路径,进程也能通过
cwd知道文件应该放在哪里。
2. 写文件
const char *msg = "hello bit!\n"; int count = 5; while (count--) { fwrite(msg, strlen(msg), 1, fp); }fwrite是按二进制块写入,适合结构体、固定长度数据。写入的是原始内存数据,不自动加
\0。
3. 读文件
char buf[1024]; while (1) { size_t s = fread(buf, 1, sizeof(buf), fp); if (s > 0) { buf[s] = 0; printf("%s", buf); } if (feof(fp)) break; }fread返回实际读取的字节数。feof判断是否读到文件末尾。
4. 输出到显示器:三种标准方式
fwrite(msg, strlen(msg), 1, stdout); // 二进制写 printf("hello printf\n"); // 格式化输出 fprintf(stdout, "hello fprintf\n"); // 文件流方式stdin/stdout/stderr都是FILE*类型,默认已打开。
5. 打开方式总结
| 模式 | 含义 |
|---|---|
r | 只读,位置在文件开头 |
r+ | 读写,位置在文件开头 |
w | 只写,文件长度截断或创建 |
w+ | 读写,截断或创建 |
a | 追加,位置在文件末尾 |
a+ | 读写追加,读从开头,写从末尾 |
用w文件会先被清空然后再进行写入
二、系统文件I/O
1. 标志位的传递方式
系统调用中经常需要传递多个选项,比如O_WRONLY | O_CREAT。这种写法依赖位掩码技术:
#define ONE 0001 // 二进制 0001 #define TWO 0002 // 二进制 0010 #define THREE 0004 // 二进制 0100 void func(int flags) { if (flags & ONE) printf("ONE "); if (flags & TWO) printf("TWO "); if (flags & THREE) printf("THREE "); } func(ONE | TWO); // 输出:ONE TWO每个标志位独占一个 bit,通过|组合,通过&检测,高效且直观。
2. 接口介绍
2.1 open接口
函数原型
#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 | const char* | 要打开或创建的文件路径(相对或绝对路径) |
flags | int | 文件打开方式标志位(见下表) |
mode | mode_t | 创建文件时的权限(仅当flags包含O_CREAT时使用) |
flags 常用标志位
| 标志 | 含义 | 说明 |
|---|---|---|
| 访问模式(三选一) | ||
O_RDONLY | 只读打开 | 不能写 |
O_WRONLY | 只写打开 | 不能读 |
O_RDWR | 读写打开 | 可读可写 |
| 可选标志(可组合) | ||
O_CREAT | 文件不存在则创建 | 需提供mode参数 |
O_APPEND | 追加模式 | 每次写入自动到文件末尾 |
O_TRUNC | 截断 | 打开文件时清空内容 |
O_EXCL | 排他创建 | 与O_CREAT一起用,文件已存在则失败 |
O_SYNC | 同步写入 | write等待数据落盘才返回 |
O_NONBLOCK | 非阻塞模式 | 用于设备文件或管道 |
O_CLOEXEC | 执行时关闭 | fork后exec时自动关闭 |
mode 权限详解
// mode 格式:八进制数,三位分别表示:所有者、所属组、其他人 0644 // 二进制:110 100 100 // 所有者:rw- (6) // 所属组:r-- (4) // 其他人:r-- (4)返回值
| 返回值 | 含义 |
|---|---|
>= 0 | 成功,返回文件描述符(非负小整数) |
-1 | 失败,设置errno,可用perror()查看错误 |
完整示例
#include <stdio.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <errno.h> #include <string.h> int main() { // 读写打开,不存在则创建,存在则清空 int fd = open("test.txt", O_RDWR | O_CREAT | O_TRUNC, 0644); if (fd == -1) { fprintf(stderr, "open failed: %s\n", strerror(errno)); return 1; } printf("open success, fd = %d\n", fd); // ... 使用 fd 进行读写 ... close(fd); return 0; }2.2close接口
函数原型
#include <unistd.h> int close(int fd);参数
| 参数 | 说明 |
|---|---|
fd | 要关闭的文件描述符(由open返回) |
返回值
| 返回值 | 含义 |
|---|---|
0 | 成功关闭 |
-1 | 失败(如 fd 无效),设置errno |
2.3read接口
#include <unistd.h> ssize_t read(int fd, void *buf, size_t count);参数详解
| 参数 | 类型 | 说明 |
|---|---|---|
fd | int | 已打开的文件描述符 |
buf | void* | 用户缓冲区指针,用于存放读取的数据 |
count | size_t | 要读取的字节数(不能超过buf大小) |
返回值详解
| 返回值 | 含义 |
|---|---|
> 0 | 成功读取的字节数(可能小于count) |
0 | 读到文件末尾(EOF) |
-1 | 失败,设置errno |
2.4 write接口
函数原型
#include <unistd.h> ssize_t write(int fd, const void *buf, size_t count);参数详解
| 参数 | 类型 | 说明 |
|---|---|---|
fd | int | 已打开的文件描述符(必须是写模式打开) |
buf | const void* | 要写入的用户数据缓冲区 |
count | size_t | 要写入的字节数 |
返回值
| 返回值 | 含义 |
|---|---|
> 0 | 实际写入的字节数(可能小于count) |
-1 | 失败,设置errno |
3. files_struct文件管理
核心结构:每个进程的task_struct中包含files_struct,它管理进程打开的所有文件
进程 task_struct │ └── files_struct │ └── fd_array[] (文件描述符表) │ ┌─────────────┼─────────────┬─────────────┐ │ │ │ │ fd[0] fd[1] fd[2] fd[3] │ │ │ │ ▼ ▼ ▼ ▼ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │struct│ │struct│ │struct│ │struct│ │ file │ │ file │ │ file │ │ file │ │ (终端│ │ (终端│ │ (终端│ │(文件 │ │ 输入)│ │ 输出)│ │ 错误)│ │ out) │ └──────┘ └──────┘ └──────┘ └──────┘ │ │ │ │ ▼ ▼ ▼ ▼ 键盘 屏幕 屏幕 磁盘文件说明:
每个
fd是数组下标(0,1,2,3...)数组元素指向内核中的
struct file对象struct file再指向具体的物理设备或文件
磁盘文件会将数据放在文件缓冲区中,一个struct file对应一个文件缓冲区,对文件内容做任何操作,都必须先把文件加载到内核对应的文件缓冲区内。
4. 重定向原理
【执行前 - 终端输出】 ┌─────────────────┐ 进程 files_struct │ 内核文件表 │ │ │ fd[0] ────────────→│ stdin (键盘) │ fd[1] ────────────→│ stdout (终端) │ ← ls 写入这里 fd[2] ────────────→│ stderr (终端) │ fd[3] ────────────→│ out.txt (文件) │ └─────────────────┘ 【执行 dup2(3, 1) 后】 ┌─────────────────┐ 进程 files_struct │ 内核文件表 │ │ │ fd[0] ────────────→│ stdin (键盘) │ fd[1] ────────────→│ out.txt (文件) │ ← ls 重定向写到这里 fd[2] ────────────→│ stderr (终端) │ fd[3] ────────────→│ out.txt (文件) │ └─────────────────┘ 【结果】ls 写入 fd[1] → 实际写入 out.txt 文件重定向的本质是:改变文件描述符与文件之间的映射关系。
5. dup2操作
dup2可以让我们显式地“复制”一个文件描述符:
#include <unistd.h> int dup2(int oldfd, int newfd);它的作用:让newfd指向oldfd所指向的文件。如果newfd已经打开,会先关闭它。
输出重定向示例
int fd = open("output.txt", O_CREAT | O_WRONLY | O_TRUNC, 0644); dup2(fd, 1); // 让 fd=1 指向 output.txt printf("这条消息会写入文件,而不是屏幕"); fflush(stdout);输入重定向示例
int fd = open("input.txt", O_RDONLY); dup2(fd, 0); // 让 fd=0 指向 input.txt char buf[256]; fgets(buf, sizeof(buf), stdin); // 实际从文件读取追加重定向
只需在open时加上O_APPEND标志,再用dup2重定向1:
int fd = open("log.txt", O_CREAT | O_WRONLY | O_APPEND, 0644); dup2(fd, 1);6. 重定向操作
在 Linux 中,每个程序运行时默认打开三个特殊文件:
标准输入 (stdin)→ 文件描述符
0标准输出 (stdout)→ 文件描述符
1(cout、printf)标准错误 (stderr)→ 文件描述符
2(cerr、perror)
通过重定向,我们可以把它们分别导向文件或合并。
1. 只重定向标准输出
./stream > log.txt //也可以写成 ./stream 1>log.txt>等价于1>仅将
stdout写入log.txt,stderr仍显示在终端
2. 有标准输出和标准错误,只重定向标准输出
./stream hello cout hello printf hello cerr hello stderr ./stream > log.txt hello cerr hello stderr./stream正常运行时,cout/printf(标准输出)和cerr/stderr(标准错误)都打印到终端。
./stream > log.txt只把标准输出(1>)重定向到log.txt文件,没有重定向标准错误(2>),所以:
hello cout和hello printf写入log.txt(不在屏幕显示)
hello cerr和hello stderr仍然直接输出到终端(屏幕)
3. 分别重定向 stdout 和 stderr
./stream 1>log.normal 2>log.error1>:标准输出 →log.normal2>:标准错误 →log.error
4. 将 stdout 和 stderr 重定向到同一个文件(覆盖/追加)
# 方法一:分别重定向但使用同一个文件(追加模式) ./stream 1>log.normal 2>>log.normal # 方法二:将 stderr 合并到 stdout(常用) ./stream 1>log.normal 2>&12>>表示追加到文件,避免覆盖2>&1表示将stderr重定向到stdout当前指向的位置
三、理解“一切皆文件”
什么是“一切皆文件”?
在 Windows 中,只有常规文件才是“文件”。
但在 Linux 中:
普通文件是文件(如文本、二进制)
目录是文件
硬件设备(如磁盘、显示器、键盘)也被抽象为文件
进程信息(如
/proc下的内容)是文件管道、Socket也是文件
你能用
read()读取一个文件,也能用read()读取键盘输入、管道数据、甚至网络数据。
统一接口带来的巨大好处
开发者只需掌握一套 API(如open、read、write、close),就能操作 Linux 中绝大部分资源。
几乎所有读取操作:
read()几乎所有写入操作:
write()
这一统一抽象极大降低了学习成本和开发复杂性。
内核中的关键数据结构:struct file与struct file_operations
Linux 内核为每个打开的文件维护一个struct file,其中包含文件的状态、位置、标志等。
struct file { struct inode *f_inode; const struct file_operations *f_op; unsigned int f_flags; fmode_t f_mode; loff_t f_pos; // ... };其中最关键的成员是f_op,它指向一个struct file_operations,这个结构体里几乎全是函数指针:
struct file_operations { ssize_t (*read)(struct file *, char __user *, size_t, loff_t *); ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *); int (*open)(struct inode *, struct file *); int (*release)(struct inode *, struct file *); // ... };系统调用与驱动的桥梁
每个设备、文件类型都可以自定义自己的
read/write实现当你调用
read()时,内核会通过file->f_op->read找到真正属于该设备的读取方法这就是多态在内核中的体现
尽管键盘、显示器、普通文件的读写方式完全不同,但开发者看到的接口是统一的。
一张图理解
进程调用 read() ↓ 内核中的 struct file ↓ f_op 指向 struct file_operations ↓ 调用该结构中的 read() 函数指针 ↓ 真正执行:普通文件 / 键盘 / 管道 / Socket 的读取逻辑四、缓冲区
什么是缓冲区?
缓冲区是内存中预留的一片区域,用于暂存输入或输出数据。根据用途,可分为输入缓冲区和输出缓冲区。它像是一个“中转站”,在 CPU 与慢速设备(如磁盘、打印机)之间协调数据流动。
为什么要引入缓冲区?
如果没有缓冲区,每次读写文件都需要直接调用系统调用(如read/write),这会频繁触发 CPU 从用户态切换到内核态,导致上下文切换开销巨大,严重影响程序性能。
引入缓冲区后,可以:
减少系统调用次数:一次读取大量数据到缓冲区,后续操作直接从缓冲区读取。
提高 CPU 利用率:CPU 无需等待慢速 I/O 设备,可以继续处理其他任务。
协调速度差异:缓冲区的读写速度远快于磁盘,显著提升整体效率。
💡 举个例子:打印文档时,数据先写入缓冲区,打印机慢慢处理,CPU 则可以继续执行其他任务。
缓冲区刷新触发条件:
1. 强制刷新
2. 刷新条件满足(满足这个条件有下面三种情况)
3. 进程退出
缓冲区的三种类型(标准 I/O)
| 类型 | 刷新条件 | 典型场景 |
|---|---|---|
| 全缓冲 | 缓冲区满后才执行系统调用 | 磁盘文件操作 |
| 行缓冲 | 遇到换行符或缓冲区满(默认 1024 字节) | 终端输入输出(如 stdin、stdout) |
| 无缓冲 | 立即执行系统调用 | 标准错误流stderr |
- 全缓冲的效率最高,普通文件一般用这种方式
- 行缓冲一般是显示器用
- 无缓冲就是立即刷新,写透模式WT
一个小实验:重定向带来的缓冲陷阱
close(1); int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666); printf("hello world: %d\n", fd); close(fd);现象:log.txt中没有任何内容。
原因:文件描述符 1(stdout)被重定向到磁盘文件后,缓冲方式从行缓冲变为全缓冲。printf的内容未填满缓冲区,因此未写入文件。
解决方法:调用fflush(stdout)强制刷新。
而stderr是无缓冲的,因此重定向 2 号描述符后,perror的内容会立即写入文件。
两级缓冲机制
在 Linux 系统中,数据从应用程序写入磁盘通常要经过两层缓冲区:
用户级缓冲区(C 标准库提供,如
FILE结构中的缓冲区)内核级缓冲区(页缓存 Page Cache,由操作系统内核管理)
数据流向如下:
应用程序 ↓ 用户级缓冲区(printf/fwrite 写入) ↓ 内核缓冲区(页缓存) ↓ 磁盘设备为什么需要两层缓冲?
| 层级 | 提供者 | 作用 | 刷新方式 |
|---|---|---|---|
| 用户级缓冲区 | C 标准库 | 减少系统调用次数 | 行缓冲/全缓冲/无缓冲,fflush强制刷新 |
| 内核级缓冲区 | OS 内核 | 减少磁盘 I/O 次数,提高磁盘读写性能 | 由内核管理(如fsync强制落盘) |
💡关键区别:
fflush只将数据从用户级缓冲区刷新到内核缓冲区,并不保证数据写入磁盘。
fsync/sync才会强制将内核缓冲区的数据写入磁盘。
图示:数据写入全流程
+-------------------+ +-------------------+ | 应用程序代码 | | | | printf("hello") | | | +--------+----------+ | 用户空间 | | | | v | | +-------------------+ | | | C 标准库 FILE 缓冲区 | | | | (用户级缓冲) | | | +--------+----------+ | | | | | | fflush() | | v | | +-------------------+ | | | 系统调用 | | | | write(fd, ...) | | | +--------+----------+ +-------------------+ | | 系统调用(用户态 → 内核态) v +-------------------+ +-------------------+ | 内核页缓存 | | 内核空间 | | (Page Cache) | | | +--------+----------+ | | | | | | fsync() | | v | | +-------------------+ | | | 磁盘设备 | | | | (物理存储) | | | +-------------------+ +-------------------+数据交给计算机,交给硬件本质全是拷贝
计算机数据流动的本质:一切皆拷贝
五、FILE 结构与用户级缓冲区
FILE 是什么?
FILE是 C 标准库中定义的一个结构体,用于管理文件流。它封装了:
文件描述符
_fileno用户级缓冲区的指针(读/写指针、基址、边界等)
缓冲区大小、标志位、锁等
FILE 的形成过程(简化)
调用
fopenFILE *fp = fopen("file.txt", "r");fopen内部执行:调用
open系统调用,获得一个文件描述符fd动态分配
FILE结构体(通常在堆上)将
fd赋值给FILE的_fileno字段根据打开模式(如行缓冲、全缓冲)初始化缓冲区(可选,默认可能分配 BUFSIZ 字节)
返回
FILE*指针
使用时(如
fread/fwrite/printf):先操作
FILE内部的用户缓冲区必要时调用
write/read系统调用
调用
fclose:刷新用户缓冲区(
fflush)调用
close系统调用关闭文件描述符释放
FILE结构体内存
图示:
用户调用 fopen("log.txt", "w") │ ▼ ┌──────────────────────────────────────────────┐ │ glibc 中的 fopen 实现 │ │ 1. 调用 open() 系统调用 │ │ └─→ 内核创建 struct file,分配 fd(如 3) │ │ 2. 分配 FILE 结构体(用户态堆内存) │ │ └─→ 填充 _fileno = fd │ │ └─→ 分配缓冲区(如 1024 字节) │ │ └─→ 设置缓冲类型(默认全缓冲或行缓冲) │ │ 3. 返回 FILE* 指针 │ └──────────────────────────────────────────────┘FILE 结构体关键成员
// /usr/include/libio.h struct _IO_FILE { int _flags; // 文件状态标志 char* _IO_read_ptr; // 读缓冲区当前指针 char* _IO_read_end; // 读缓冲区结束位置 char* _IO_read_base; // 读缓冲区基址 char* _IO_write_base; // 写缓冲区基址 char* _IO_write_ptr; // 写缓冲区当前指针 char* _IO_write_end; // 写缓冲区结束位置 char* _IO_buf_base; // 缓冲区基址 char* _IO_buf_end; // 缓冲区结束位置 int _fileno; // ← 封装的文件描述符(核心!) // ... 其他成员 };一个有趣的 fork 现象
printf("hello printf\n"); fwrite("hello fwrite\n", ...); write(1, "hello write\n", ...); fork();正常运行输出一次,但重定向到文件后,printf和fwrite输出两次,write只输出一次。
原因:
重定向到文件后,库函数缓冲变为全缓冲。
fork时,子进程复制了父进程的缓冲区(写时拷贝)。进程退出时,父子进程各自刷新缓冲区,导致重复输出。
write无缓冲区,不受影响。
✅ 这说明:用户级缓冲区由 C 标准库提供,位于系统调用之上。