本文要回到一个更基础、也更贴近日常开发的话题:操作系统中的基础 I/O。
在 C++ 里用 std::cout、fstream,在 C 里用 printf、fopen,它们背后到底发生了什么?打开文件时那些 O_RDONLY、O_CREAT 是什么意思?文件描述符又是什么东西?为什么有时候 printf 的输出没有立刻出现?缓冲区在其中扮演了什么角色?
本文就试着把这些问题一次讲透。
1. 从两个层面看 I/O
在 Linux 中,文件 I/O 可以分为两个层次:
| 层次 | 代表接口 | 特点 |
|---|---|---|
| 标准库 I/O | fopen/fread/fwrite/fclose、std::fstream、std::cout |
带用户态缓冲区,可移植,使用方便 |
| 系统调用 I/O | open/read/write/close/lseek |
直接进入内核,无用户态缓冲,更底层、更可控 |
两者不是对立的。标准库 I/O 最终也是通过系统调用完成的,只是它在用户态加了一层缓冲区来减少系统调用的次数。
理解基础 I/O,必须从系统调用这一层入手。
2. 文件描述符(File Descriptor)
2.1 什么是文件描述符?
当你用系统调用打开一个文件时,内核会返回一个非负整数 ,这个整数就是文件描述符(fd)。它是进程打开文件表中的一个索引。
Linux 进程启动时,默认打开三个文件描述符:
| fd | 名称 | 宏 | 默认指向 |
|---|---|---|---|
| 0 | 标准输入 | STDIN_FILENO |
键盘/终端 |
| 1 | 标准输出 | STDOUT_FILENO |
终端屏幕 |
| 2 | 标准错误 | STDERR_FILENO |
终端屏幕 |
之后打开的文件会分配最小的可用 fd。比如关闭了 fd 1,下一次 open 就可能返回 1。
cpp
#include <unistd.h>
#include <fcntl.h>
#include <iostream>
int main() {
int fd = open("test.txt", O_RDONLY);
std::cout << "打开的文件描述符: " << fd << "\n"; // 通常是 3
close(fd);
return 0;
}
2.2 文件描述符的本质
每个进程在内核中有一张打开文件描述符表 ,fd 就是这张表的索引。表项指向一个打开文件表项(open file description),其中记录了:
-
文件偏移量(offset)
-
打开标志(只读、只写、追加等)
-
引用计数
-
指向 inode 的指针
多个 fd 可以指向同一个打开文件表项(例如 fork 后父子进程共享偏移量,或 dup 复制 fd)。这一点对理解重定向和文件共享非常关键。
cpp
进程 A 的 fd 表 打开文件表项 inode
fd 0 ──────────────► offset, flags ──────► 文件元数据
fd 1 ──────────────► ...
fd 3 ──────────────► offset=0, O_RDONLY ──► test.txt
3. 打开文件:open 与打开方式
3.1 open 函数原型
cpp
#include <fcntl.h>
int open(const char* pathname, int flags);
int open(const char* pathname, int flags, mode_t mode);
-
pathname:文件路径。 -
flags:打开方式,多个标志用按位或|组合。 -
mode:仅在创建新文件时使用,指定权限(如0644)。 -
成功返回文件描述符,失败返回 -1 并设置
errno。
3.2 常用打开标志
| 标志 | 含义 |
|---|---|
O_RDONLY |
只读 |
O_WRONLY |
只写 |
O_RDWR |
读写 |
O_CREAT |
文件不存在时创建(需提供 mode) |
O_EXCL |
与 O_CREAT 合用,文件已存在则失败 |
O_TRUNC |
文件存在时截断为 0 长度 |
O_APPEND |
每次写入都追加到文件末尾 |
O_NONBLOCK |
非阻塞模式(对管道、设备等有用) |
O_SYNC |
同步写入,write 返回时数据已落盘 |
注意 :O_RDONLY、O_WRONLY、O_RDWR 三者是互斥的,只能选一个。
3.3 常见组合示例
cpp
// 只读打开
int fd1 = open("data.txt", O_RDONLY);
// 创建并写入(不存在则创建,存在则清空)
int fd2 = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
// 追加写入(不存在则创建)
int fd3 = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0644);
// 安全创建:文件已存在则失败,防止覆盖
int fd4 = open("lock.txt", O_WRONLY | O_CREAT | O_EXCL, 0644);
if (fd4 == -1) {
// errno == EEXIST 表示文件已存在
}
3.4 mode 与 umask
mode 指定新文件的权限,但实际权限是 mode & ~umask。例如 mode=0666、umask=022,实际创建的文件权限是 0644。
cpp
int fd = open("new.txt", O_WRONLY | O_CREAT, 0666); // 实际可能是 0644
4. 读写与定位:read / write / lseek / close
4.1 read
cpp
#include <unistd.h>
ssize_t read(int fd, void* buf, size_t count);
-
从
fd当前偏移处读取最多count字节到buf。 -
返回实际读取的字节数;返回 0 表示到达文件末尾;返回 -1 表示出错。
-
返回值可能小于 count,必须循环读取才能读完。
4.2 write
cpp
ssize_t write(int fd, const void* buf, size_t count);
-
将
buf中最多count字节写入fd。 -
返回实际写入的字节数,可能小于
count(如磁盘满、被信号中断),同样需要循环写。
4.3 lseek
cpp
off_t lseek(int fd, off_t offset, int whence);
调整文件偏移量:
-
SEEK_SET:从文件开头算起 -
SEEK_CUR:从当前位置算起 -
SEEK_END:从文件末尾算起
4.4 close
cpp
int close(int fd);
关闭文件描述符,释放相关资源。进程退出时内核会自动关闭,但显式关闭是好习惯,也能及时释放 fd 供后续使用。
5. 缓冲区:I/O 性能的关键
5.1 两层缓冲区
I/O 路径上通常有两层缓冲区:
cpp
应用程序缓冲区 → 内核页缓存 → 磁盘
(用户态) (内核态)
-
用户态缓冲区 :由标准库(如
FILE*、std::iostream)维护,用来减少系统调用次数。比如fwrite写 1 个字节,可能只是放进缓冲区,等缓冲区满了才真正调用write。 -
内核页缓存(page cache) :内核在内存中缓存磁盘数据,
write返回时数据通常只到了页缓存,由内核在合适时机刷盘。
5.2 标准库 I/O 的缓冲模式
| 模式 | 行为 | 典型场景 |
|---|---|---|
| 全缓冲 | 缓冲区满时才刷新 | 文件 |
| 行缓冲 | 遇到换行符或缓冲区满时刷新 | 终端 |
| 无缓冲 | 立即输出 | stderr |
这就是为什么 printf("hello") 不带换行时,在终端可能看不到输出,而 printf("hello\n") 能立即看到。
5.3 经典陷阱:fork 与缓冲区
cpp
#include <cstdio>
#include <unistd.h>
int main() {
printf("Hello"); // 没有换行,留在缓冲区
fork();
return 0;
}
输出会出现两次 Hello!因为 fork 复制了用户态缓冲区,父子进程各自在退出时刷新一次。
如果改成 printf("Hello\n"),终端是行缓冲,换行会立即刷新,fork 时缓冲区已空,就不会重复。
结论 :在 fork 之前应刷新缓冲区,或子进程使用 _exit 而非 exit。
5.4 手动刷新缓冲区
cpp
fflush(stdout); // C 标准库
std::cout << std::flush; // C++ 流
5.5 系统调用 I/O 没有用户态缓冲
write 是直接系统调用,数据直接交给内核。所以下面两种写法效果不同:
cpp
// 系统调用:立即进入内核
write(1, "hello", 5);
// 标准库:可能只是放入缓冲区
printf("hello");
如果需要控制刷新时机,系统调用 I/O 更直接。但频繁的 write 系统调用开销较大,标准库的缓冲正是为了优化这一点。
5.6 缓冲区与同步
write 返回成功,不代表数据已经写到磁盘,只表示数据进入了内核页缓存。要保证落盘,可以:
cpp
int fd = open("data.txt", O_WRONLY | O_CREAT | O_TRUNC | O_SYNC, 0644);
// 或者写完后调用
fsync(fd); // 将文件数据刷到磁盘
fdatasync(fd); // 只刷数据,不刷元数据
6. FILE* 与 fd 的关系
C 标准库的 FILE* 内部封装了一个文件描述符。可以用 fileno 获取:
cpp
#include <cstdio>
FILE* fp = fopen("test.txt", "r");
int fd = fileno(fp); // 获取底层 fd
常用流对应的 fd:
| 流 | fd |
|---|---|
stdin |
0 |
stdout |
1 |
stderr |
2 |
fopen 的 "r"、"w"、"a" 等模式,最终会转换为 open 的标志位。例如:
| fopen 模式 | 等价 open 标志 |
|---|---|
"r" |
O_RDONLY |
"w" |
`O_WRONLY |
"a" |
`O_WRONLY |
"r+" |
O_RDWR |
"w+" |
`O_RDWR |
7. 重定向与 dup2
7.1 dup / dup2
cpp
#include <unistd.h>
int dup(int oldfd); // 返回新的 fd,指向同一个打开文件表项
int dup2(int oldfd, int newfd); // 让 newfd 也指向 oldfd 的表项
dup2 常用于重定向:先打开一个文件,然后用 dup2 把它复制到标准输出/标准错误的位置。
7.2 示例:将标准输出重定向到文件
cpp
#include <fcntl.h>
#include <unistd.h>
#include <cstdio>
int main() {
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO); // 让 fd 1 指向 output.txt
close(fd); // 原来的 fd 可以关闭
printf("这行会写入 output.txt\n");
return 0;
}
7.3 示例:shell 中的重定向
bash
./program > out.txt # 等价于 open + dup2(fd, 1)
./program 2> err.txt # 重定向标准错误
./program < input.txt # 重定向标准输入
在代码层面,shell 就是通过 fork + open + dup2 + exec 实现重定向的。
7.4 恢复重定向
如果想临时重定向后恢复,可以先用 dup 保存原来的 fd:
cpp
int saved = dup(STDOUT_FILENO);
int fd = open("temp.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO);
printf("写入临时文件\n");
fflush(stdout);
dup2(saved, STDOUT_FILENO); // 恢复
close(saved);
close(fd);
printf("回到终端\n");
8. 常见误区与注意事项
-
read/write 返回值必须检查
它们可能返回小于请求字节数,甚至被信号中断返回 -1(
errno == EINTR)。必须循环处理。 -
文件描述符是进程级资源
fork 后父子进程共享打开文件表项,但 fd 表各自独立。
dup出的 fd 共享偏移量。 -
缓冲区可能导致输出"丢失"
程序异常终止(如
_exit、段错误)时,用户态缓冲区不会刷新,printf的内容可能丢失。 -
O_APPEND 与多进程写入
O_APPEND保证每次写入原子地追加到末尾,适合多进程写日志。但普通write配合手动lseek不是原子的。 -
不要混用标准库 I/O 和系统调用 I/O
对同一个文件既用
FILE*又用read/write,可能因缓冲区导致数据顺序错乱。 -
及时 close
fd 是有限资源(通常默认上限 1024),泄漏会导致"Too many open files"。
9. 小结
| 主题 | 核心要点 |
|---|---|
| 文件描述符 | 非负整数,进程打开文件表的索引;0/1/2 是标准输入/输出/错误 |
| 打开方式 | O_RDONLY/O_WRONLY/O_RDWR 互斥;O_CREAT、O_TRUNC、O_APPEND 等组合 |
| 基本操作 | open、read、write、lseek、close |
| 缓冲区 | 用户态缓冲区(标准库)+ 内核页缓存;全缓冲/行缓冲/无缓冲 |
| 重定向 | dup2 改变 fd 指向,实现输入输出重定向 |
| FILE 与 fd* | FILE* 封装了 fd,fileno 可取底层 fd |
-
系统调用 I/O 无用户态缓冲,直接与内核交互,适合需要精细控制的场景。
-
标准库 I/O 带缓冲,减少系统调用,使用方便,但要注意刷新时机。
-
理解 fd、打开文件表项和缓冲区,是排查 I/O 问题和实现重定向的基础。
基础 I/O 是系统编程的根基,也是理解文件、管道、网络套接字统一抽象(一切皆文件)的入口。掌握了 fd 和缓冲区,就能看清很多"奇怪"现象背后的原因。