一、回顾C语言文件接口
1. 打开文件
cpp
#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 文件系统,可以查看运行中进程的信息:
cpp
ps ajx | grep myProc
ls /proc/533463 -l
重点关注两个符号链接:
-
cwd→ 当前进程的工作目录 -
exe→ 当前进程的可执行文件路径
✅ 打开文件,本质是进程打开文件 ,因此即使不带路径,进程也能通过
cwd知道文件应该放在哪里。
2. 写文件
cpp
const char *msg = "hello bit!\n";
int count = 5;
while (count--) {
fwrite(msg, strlen(msg), 1, fp);
}
-
fwrite是按二进制块写入,适合结构体、固定长度数据。 -
写入的是原始内存数据,不自动加
\0。
3. 读文件
cpp
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. 输出到显示器:三种标准方式
cpp
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。这种写法依赖位掩码技术:
cpp
#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接口
函数原型
cpp
#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 权限详解
cpp
// mode 格式:八进制数,三位分别表示:所有者、所属组、其他人
0644 // 二进制:110 100 100
// 所有者:rw- (6)
// 所属组:r-- (4)
// 其他人:r-- (4)
返回值
| 返回值 | 含义 |
|---|---|
>= 0 |
成功,返回文件描述符(非负小整数) |
-1 |
失败,设置 errno,可用 perror() 查看错误 |
完整示例
cpp
#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.2 close 接口
函数原型
cpp
#include <unistd.h>
int close(int fd);
参数
| 参数 | 说明 |
|---|---|
fd |
要关闭的文件描述符(由 open 返回) |
返回值
| 返回值 | 含义 |
|---|---|
0 |
成功关闭 |
-1 |
失败(如 fd 无效),设置 errno |
2.3 read 接口
cpp
#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接口
函数原型
cpp
#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,它管理进程打开的所有文件
cpp
进程 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. 重定向原理
cpp
【执行前 - 终端输出】
┌─────────────────┐
进程 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 可以让我们显式地"复制"一个文件描述符:
cpp
#include <unistd.h>
int dup2(int oldfd, int newfd);
它的作用:让 newfd 指向 oldfd 所指向的文件。如果 newfd 已经打开,会先关闭它。
输出重定向示例
cpp
int fd = open("output.txt", O_CREAT | O_WRONLY | O_TRUNC, 0644);
dup2(fd, 1); // 让 fd=1 指向 output.txt
printf("这条消息会写入文件,而不是屏幕");
fflush(stdout);
输入重定向示例
cpp
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:
cpp
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. 只重定向标准输出
bash
./stream > log.txt
//也可以写成
./stream 1>log.txt
-
>等价于1> -
仅将
stdout写入log.txt,stderr仍显示在终端
2. 有标准输出和标准错误,只重定向标准输出
bash
./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
bash
./stream 1>log.normal 2>log.error
-
1>:标准输出 →log.normal -
2>:标准错误 →log.error
4. 将 stdout 和 stderr 重定向到同一个文件(覆盖/追加)
bash
# 方法一:分别重定向但使用同一个文件(追加模式)
./stream 1>log.normal 2>>log.normal
# 方法二:将 stderr 合并到 stdout(常用)
./stream 1>log.normal 2>&1
-
2>>表示追加到文件,避免覆盖 -
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,其中包含文件的状态、位置、标志等。
cpp
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,这个结构体里几乎全是函数指针:
cpp
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找到真正属于该设备的读取方法 -
这就是 多态 在内核中的体现
尽管键盘、显示器、普通文件的读写方式完全不同,但开发者看到的接口是统一的。
一张图理解
cpp
进程调用 read()
↓
内核中的 struct file
↓
f_op 指向 struct file_operations
↓
调用该结构中的 read() 函数指针
↓
真正执行:普通文件 / 键盘 / 管道 / Socket 的读取逻辑
四、缓冲区
什么是缓冲区?
缓冲区是内存中预留的一片区域,用于暂存输入或输出数据。根据用途,可分为输入缓冲区 和输出缓冲区。它像是一个"中转站",在 CPU 与慢速设备(如磁盘、打印机)之间协调数据流动。
为什么要引入缓冲区?
如果没有缓冲区,每次读写文件都需要直接调用系统调用(如 read / write),这会频繁触发 CPU 从用户态切换到内核态,导致上下文切换开销巨大,严重影响程序性能。
引入缓冲区后,可以:
-
减少系统调用次数:一次读取大量数据到缓冲区,后续操作直接从缓冲区读取。
-
提高 CPU 利用率:CPU 无需等待慢速 I/O 设备,可以继续处理其他任务。
-
协调速度差异:缓冲区的读写速度远快于磁盘,显著提升整体效率。
💡 举个例子:打印文档时,数据先写入缓冲区,打印机慢慢处理,CPU 则可以继续执行其他任务。
缓冲区刷新触发条件:
-
强制刷新
-
刷新条件满足(满足这个条件有下面三种情况)
-
进程退出
缓冲区的三种类型(标准 I/O)
| 类型 | 刷新条件 | 典型场景 |
|---|---|---|
| 全缓冲 | 缓冲区满后才执行系统调用 | 磁盘文件操作 |
| 行缓冲 | 遇到换行符或缓冲区满(默认 1024 字节) | 终端输入输出(如 stdin、stdout) |
| 无缓冲 | 立即执行系统调用 | 标准错误流 stderr |
- 全缓冲的效率最高,普通文件一般用这种方式
- 行缓冲一般是显示器用
- 无缓冲就是立即刷新,写透模式WT
一个小实验:重定向带来的缓冲陷阱
cpp
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,由操作系统内核管理)
数据流向如下:
cpp
应用程序
↓
用户级缓冲区(printf/fwrite 写入)
↓
内核缓冲区(页缓存)
↓
磁盘设备
为什么需要两层缓冲?
| 层级 | 提供者 | 作用 | 刷新方式 |
|---|---|---|---|
| 用户级缓冲区 | C 标准库 | 减少系统调用次数 | 行缓冲/全缓冲/无缓冲,fflush 强制刷新 |
| 内核级缓冲区 | OS 内核 | 减少磁盘 I/O 次数,提高磁盘读写性能 | 由内核管理(如 fsync 强制落盘) |
💡 关键区别:
fflush只将数据从用户级缓冲区刷新到内核缓冲区,并不保证数据写入磁盘。
fsync/sync才会强制将内核缓冲区的数据写入磁盘。
图示:数据写入全流程
cpp
+-------------------+ +-------------------+
| 应用程序代码 | | |
| printf("hello") | | |
+--------+----------+ | 用户空间 |
| | |
v | |
+-------------------+ | |
| C 标准库 FILE 缓冲区 | | |
| (用户级缓冲) | | |
+--------+----------+ | |
| | |
| fflush() | |
v | |
+-------------------+ | |
| 系统调用 | | |
| write(fd, ...) | | |
+--------+----------+ +-------------------+
|
| 系统调用(用户态 → 内核态)
v
+-------------------+ +-------------------+
| 内核页缓存 | | 内核空间 |
| (Page Cache) | | |
+--------+----------+ | |
| | |
| fsync() | |
v | |
+-------------------+ | |
| 磁盘设备 | | |
| (物理存储) | | |
+-------------------+ +-------------------+
数据交给计算机,交给硬件本质全是拷贝
计算机数据流动的本质:一切皆拷贝
五、FILE 结构与用户级缓冲区
FILE 是什么?
FILE 是 C 标准库中定义的一个结构体,用于管理文件流。它封装了:
-
文件描述符
_fileno -
用户级缓冲区的指针(读/写指针、基址、边界等)
-
缓冲区大小、标志位、锁等
FILE 的形成过程(简化)
-
调用
fopencppFILE *fp = fopen("file.txt", "r"); -
fopen内部执行:-
调用
open系统调用,获得一个文件描述符fd -
动态分配
FILE结构体(通常在堆上) -
将
fd赋值给FILE的_fileno字段 -
根据打开模式(如行缓冲、全缓冲)初始化缓冲区(可选,默认可能分配 BUFSIZ 字节)
-
返回
FILE*指针
-
-
使用时 (如
fread/fwrite/printf):-
先操作
FILE内部的用户缓冲区 -
必要时调用
write/read系统调用
-
-
调用
fclose:-
刷新用户缓冲区(
fflush) -
调用
close系统调用关闭文件描述符 -
释放
FILE结构体内存
-
图示:
cpp
用户调用 fopen("log.txt", "w")
│
▼
┌──────────────────────────────────────────────┐
│ glibc 中的 fopen 实现 │
│ 1. 调用 open() 系统调用 │
│ └─→ 内核创建 struct file,分配 fd(如 3) │
│ 2. 分配 FILE 结构体(用户态堆内存) │
│ └─→ 填充 _fileno = fd │
│ └─→ 分配缓冲区(如 1024 字节) │
│ └─→ 设置缓冲类型(默认全缓冲或行缓冲) │
│ 3. 返回 FILE* 指针 │
└──────────────────────────────────────────────┘
FILE 结构体关键成员
cpp
// /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 现象
cpp
printf("hello printf\n");
fwrite("hello fwrite\n", ...);
write(1, "hello write\n", ...);
fork();
正常运行输出一次,但重定向到文件后,printf 和 fwrite 输出两次,write 只输出一次。
原因:
-
重定向到文件后,库函数缓冲变为全缓冲。
-
fork时,子进程复制了父进程的缓冲区(写时拷贝)。 -
进程退出时,父子进程各自刷新缓冲区,导致重复输出。
-
write无缓冲区,不受影响。
✅ 这说明:用户级缓冲区由 C 标准库提供,位于系统调用之上。