🔥 本文定位 :面向已经掌握 Linux 基础命令与进程概念、准备进入系统编程的同学,从 C 标准 I/O 一直讲到系统调用、文件描述符、VFS、重定向和缓冲区。
💡 学习目标 :不仅会使用
fopen()、open()、read()、write()和dup2(),还要能解释 fd 为什么只是索引、父子进程为什么会共享文件偏移、2>&1为什么与顺序有关,以及fflush()为什么不等于数据落盘。

文章目录
- 前言
- [一、先建立 Linux I/O 的分层模型](#一、先建立 Linux I/O 的分层模型)
- 二、认识文件:内容、属性与路径
- [三、C 标准 I/O:FILE 指针与流](#三、C 标准 I/O:FILE 指针与流)
- [四、系统调用 I/O:open、read、write、close](#四、系统调用 I/O:open、read、write、close)
- [五、open 的 flags、mode 与 umask](#五、open 的 flags、mode 与 umask)
- 六、文件描述符到底是什么
- [七、重定向的本质:open + dup2 + close](#七、重定向的本质:open + dup2 + close)
- [八、理解"一切皆文件"与 VFS](#八、理解“一切皆文件”与 VFS)
- [九、缓冲区:fflush、fsync 与数据落盘](#九、缓冲区:fflush、fsync 与数据落盘)
- [十、fork 为什么会让 printf 输出多份](#十、fork 为什么会让 printf 输出多份)
- [十一、FILE 与 fd 怎样互相转换](#十一、FILE 与 fd 怎样互相转换)
- [十二、从零设计一个最小 stdio 封装](#十二、从零设计一个最小 stdio 封装)
- 十三、完整实战:实现一个重定向启动器
- 十四、常见误区与面试高频问题
- 总结
前言
学习 Linux I/O 时,最容易产生的错觉是:这些接口只是"不同写法"。
c
printf("hello\n");
fwrite(buf, 1, len, stdout);
write(STDOUT_FILENO, buf, len);
三段代码都能输出数据,但它们并不处在同一层:
printf()、fwrite()属于 C 标准库,操作的是FILE *,通常带有用户态缓冲;write()是系统调用接口,操作的是整数文件描述符fd;fd再通过进程的文件描述符表引用内核中的打开文件对象;- 内核最终可能把请求交给文件系统、管道、终端驱动或网络协议栈。
如果把这些层次混在一起,很多问题就无法解释:
- 为什么
printf()后文件里暂时没有内容,而write()的表现不同? - 为什么关闭 fd 1 后再
open(),新文件常常恰好拿到 fd 1? - 为什么
dup2(fd, 1)能改变一个完全不懂文件名的程序的输出方向? - 为什么同一路径
open()两次,两个 fd 的偏移量可以互不影响? - 为什么
fork()后父子各有 fd 表,却还能共享同一个文件偏移? - 为什么
fflush()成功后,突然断电仍可能丢数据?
本文用一条主线把它们串起来:
text
应用数据
↓
C 标准 I/O:FILE*、printf、fread、fwrite
↓
系统调用:fd、open、read、write、close、dup2
↓
内核对象:fd table、open file description、VFS
↓
普通文件 / 管道 / 终端 / 设备 / socket
⚠️ 文中的程序请在普通用户的独立实验目录中运行。带
O_TRUNC的程序会清空目标文件,测试时不要指向重要数据。
一、先建立 Linux I/O 的分层模型
1.1 四个最重要的对象
先把四个概念分清,后面的内容会顺很多。
| 对象 | 所在位置 | 典型形式 | 核心作用 |
|---|---|---|---|
FILE * |
用户态 C 库 | stdout、FILE *fp |
封装 fd、缓冲和流状态 |
文件描述符 fd |
进程可见的整数索引 | 0、1、2、3 |
索引进程的 fd table |
| open file description | 内核 | 概念上的"打开实例" | 保存文件偏移和状态标志 |
| inode / I/O 对象 | 内核及具体子系统 | 普通文件、管道、设备等 | 表示真正被操作的对象及其实现 |
一句话记忆:
FILE *是库层流对象,fd 是进程内索引,open file description 是内核打开实例,inode 或其他对象才是最终目标。
1.2 一次输出经过哪些层
以 printf("hello\n") 为例,可能经历:
text
格式化字符串
↓
写入 stdout 的 stdio 用户缓冲
↓ 缓冲满足刷新条件
底层 write(fd=1, ...)
↓
fd table 找到 open file description
↓
VFS / 驱动把操作分发到终端、文件或管道
这里的"可能"很重要:printf() 不保证每调用一次就执行一次 write()。缓冲机制可以把许多小输出合并成较少的系统调用。
1.3 库函数与系统调用不是对立关系
标准 I/O 通常构建在系统调用之上:
text
fopen ─┐
fread │
fwrite ├─ C 库封装 ─→ open / read / write / close
fclose ─┘
选择哪一层取决于问题:
- 需要格式化、按行读取、可移植流接口时,优先用 stdio;
- 需要重定向、非阻塞、管道、socket、精确 flags 或事件循环时,通常直接使用 fd 接口;
- 同一程序可以同时使用两层,但必须理解它们各自的缓冲和状态。
二、认识文件:内容、属性与路径
2.1 文件不只有内容
从使用角度看,一个文件至少包含两类信息:
text
文件 = 数据内容 + 元数据
元数据包括:
- 类型与权限;
- 所有者 UID、所属组 GID;
- 文件大小;
- 时间戳;
- inode 编号、链接计数等文件系统信息。
所以一个"0 字节文件"只表示数据长度为 0,不表示它在系统中完全不占任何资源。目录项和 inode 等元数据仍然需要保存。
2.2 路径名不是文件描述符
路径名用于找到文件,而 fd 用于引用一次已经建立的打开关系:
text
path ──open()──→ fd ──后续 read/write──→ 打开实例
open() 成功后,即使原路径后来被 unlink(),只要仍有 fd 引用相应打开实例,进程通常仍能继续访问该对象;最后一个引用关闭后,相关资源才可能真正释放。
2.3 相对路径依赖当前工作目录
c
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
这里的 log.txt 按进程当前工作目录解析。更复杂的服务程序中,可以使用 openat(dirfd, relative_path, ...) 相对一个已经打开的目录进行解析,减少对可变当前工作目录的依赖。
三、C 标准 I/O:FILE 指针与流
3.1 三个预定义流
程序启动时,C 运行环境提供三个标准流:
| 流 | 常用底层 fd | 用途 |
|---|---|---|
stdin |
0 | 标准输入 |
stdout |
1 | 标准输出 |
stderr |
2 | 标准错误 |
这里要注意两点:
stdin/stdout/stderr是FILE *,而 0/1/2 是 fd;- 0/1/2 是约定俗成的标准编号,但程序完全可以关闭或重新绑定它们。
3.2 fopen 模式怎样理解
| 模式 | 含义 | 文件不存在 | 文件已存在 |
|---|---|---|---|
"r" |
只读 | 失败 | 从头读取 |
"w" |
只写 | 创建 | 截断为 0 |
"a" |
追加写 | 创建 | 每次写到末尾 |
"r+" |
读写 | 失败 | 不截断 |
"w+" |
读写 | 创建 | 截断为 0 |
"a+" |
读与追加写 | 创建 | 写入仍追加 |
在 Linux/POSIX 上,文本文件和二进制文件没有 Windows 那样的换行转换差异,"b" 通常被忽略;写成 "rb"、"wb" 仍有利于跨平台代码表达意图。
3.3 fread 与 fwrite 的返回值
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);
返回值是成功处理的"元素个数",不是固定等于字节数。为了复制任意二进制文件,常把 size 设为 1,这样返回值就与字节数一致。
3.4 一个二进制安全的 stdio 复制程序
c
#include <stdio.h>
int main(int argc, char *argv[])
{
if (argc != 3) {
fprintf(stderr, "usage: %s <src> <dst>\n", argv[0]);
return 2;
}
FILE *in = fopen(argv[1], "rb");
if (in == NULL) {
perror("fopen src");
return 1;
}
FILE *out = fopen(argv[2], "wb");
if (out == NULL) {
perror("fopen dst");
fclose(in);
return 1;
}
unsigned char buf[8192];
int rc = 0;
for (;;) {
size_t n = fread(buf, 1, sizeof buf, in);
if (n > 0 && fwrite(buf, 1, n, out) != n) {
perror("fwrite");
rc = 1;
break;
}
if (n < sizeof buf) {
if (ferror(in)) {
perror("fread");
rc = 1;
}
break; /* EOF 或读取错误 */
}
}
if (fclose(out) == EOF) {
perror("fclose dst");
rc = 1;
}
if (fclose(in) == EOF) {
perror("fclose src");
rc = 1;
}
return rc;
}
这段代码有几个刻意保留的细节:
- 不用
strlen()计算文件块长度,因为二进制数据可以包含\0; fread()读到不足一个缓冲区不一定是错误,要用ferror()区分;fclose()也要检查,因为延迟写错误可能到刷新或关闭时才暴露。
四、系统调用 I/O:open、read、write、close
4.1 四个基础接口
c
#include <fcntl.h>
#include <unistd.h>
int open(const char *path, int flags, ... /* mode_t mode */);
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
int close(int fd);
典型生命周期:
text
open → 得到 fd → 多次 read/write → close
4.2 返回值不能只判断"成功或失败"
read() 的返回值:
| 返回值 | 含义 |
|---|---|
> 0 |
实际读到的字节数,可能小于请求值 |
== 0 |
到达 EOF |
== -1 |
失败,查看 errno |
write() 的返回值:
| 返回值 | 含义 |
|---|---|
> 0 |
实际写入的字节数,可能发生短写 |
== -1 |
失败,查看 errno |
短读并不是错误。读取终端、管道、socket 或接近文件尾时,都可能只得到当前可用的一部分数据。短写也合法,尤其在管道、socket、资源限制、信号中断等场景下。

4.3 write_all:把短写处理正确
c
#include <errno.h>
#include <stddef.h>
#include <unistd.h>
static int write_all(int fd, const void *data, size_t len)
{
const unsigned char *buf = data;
size_t off = 0;
while (off < len) {
ssize_t n = write(fd, buf + off, len - off);
if (n > 0) {
off += (size_t)n;
continue;
}
if (n < 0 && errno == EINTR) {
continue;
}
if (n == 0) {
errno = EIO; /* 避免循环永远不前进 */
}
return -1;
}
return 0;
}
关键不是"多写几次",而是每次都从 buf + off 继续,并把剩余长度改为 len - off。
4.4 一个健壮的 fd 复制程序
c
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
static int write_all(int fd, const void *data, size_t len)
{
const unsigned char *buf = data;
size_t off = 0;
while (off < len) {
ssize_t n = write(fd, buf + off, len - off);
if (n > 0) {
off += (size_t)n;
} else if (n < 0 && errno == EINTR) {
continue;
} else {
if (n == 0) errno = EIO;
return -1;
}
}
return 0;
}
int main(int argc, char *argv[])
{
if (argc != 3) {
dprintf(STDERR_FILENO, "usage: %s <src> <dst>\n", argv[0]);
return 2;
}
int in = open(argv[1], O_RDONLY | O_CLOEXEC);
if (in < 0) {
perror("open src");
return 1;
}
int out = open(argv[2],
O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC,
0666);
if (out < 0) {
perror("open dst");
close(in);
return 1;
}
unsigned char buf[8192];
int rc = 0;
for (;;) {
ssize_t n = read(in, buf, sizeof buf);
if (n > 0) {
if (write_all(out, buf, (size_t)n) < 0) {
perror("write");
rc = 1;
break;
}
} else if (n == 0) {
break;
} else if (errno != EINTR) {
perror("read");
rc = 1;
break;
}
}
if (close(out) < 0) {
perror("close dst");
rc = 1;
}
if (close(in) < 0) {
perror("close src");
rc = 1;
}
return rc;
}
⚠️ 不要在 Linux 上看到
close()返回错误就盲目重试。fd 编号可能已经被释放并复用,再次close()可能误关另一个对象。需要持久性保证时,应在关闭前显式调用并检查fsync()。
五、open 的 flags、mode 与 umask
5.1 open 的完整思维模型
c
int fd = open(path, flags, mode);
这里包含三件不同的事:
path:找到哪个对象;flags:怎样打开、后续 I/O 具有什么状态;mode:创建新文件时给出的候选权限。

5.2 访问模式必须选一个
flags 必须包含下列访问模式之一:
c
O_RDONLY /* 只读 */
O_WRONLY /* 只写 */
O_RDWR /* 读写 */
不能把 O_RDONLY | O_WRONLY 当成读写模式;需要读写时应明确使用 O_RDWR。
5.3 常用标志位
| 标志 | 作用 | 常见误区 |
|---|---|---|
O_CREAT |
不存在则创建 | 使用时必须提供 mode |
O_EXCL |
与 O_CREAT 配合,已存在则失败 |
常用于避免"先检查再创建"的竞态 |
O_TRUNC |
可写打开普通文件时截断为 0 | 会破坏原内容 |
O_APPEND |
每次 write() 前把偏移定位到末尾并原子结合 |
不是"整个业务记录一次性原子追加" |
O_CLOEXEC |
成功 exec 时自动关闭该 fd |
多线程程序中比事后 fcntl 更安全 |
O_NONBLOCK |
请求非阻塞语义 | 对普通文件通常没有预期中的效果 |
5.4 mode 最终还要经过 umask
创建普通数据文件时常写:
c
open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
假设进程 umask 是 0022,在没有默认 ACL 介入时:
text
requested mode = 0666
umask = 0022
effective = 0666 & ~0022 = 0644
mode 不是"强制最终权限",而是候选权限上限。umask 用于移除不希望默认授予的权限。
5.5 Shell 重定向对应哪些 flags
text
> O_WRONLY | O_CREAT | O_TRUNC
>> O_WRONLY | O_CREAT | O_APPEND
< O_RDONLY
所以 > 会清空旧内容,而 >> 追加到末尾。
六、文件描述符到底是什么
6.1 fd 是进程内的小整数索引
open() 成功时返回一个非负整数。它是当前进程 fd table 的索引,不是:
- 文件地址;
- inode 编号;
- 全系统唯一编号;
- 固定绑定某个物理设备的编号。
Linux 会为一次成功的 open() 创建新的 open file description,并让返回的 fd 引用它。新 fd 通常是当前进程中最小的未使用编号。
这解释了经典实验:
c
close(STDOUT_FILENO); /* 释放 fd 1 */
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
如果没有其他线程、信号处理器或并发代码抢先分配 fd,新 open() 很可能返回 1。但这不是实现重定向的稳健方式,真正的代码应使用 dup2()。
6.2 open file description 保存什么
概念上,它保存:
- 当前文件偏移;
O_APPEND、O_NONBLOCK等文件状态标志;- 指向底层文件或 I/O 对象的信息;
- 内核维护的引用关系。

6.3 三种容易混淆的共享关系
情况 A:对同一路径 open 两次
c
int a = open("data.txt", O_RDONLY);
int b = open("data.txt", O_RDONLY);
通常会得到两个不同的 open file description,因此 a 与 b 的文件偏移相互独立。
情况 B:dup 或 dup2
c
int b = dup(a);
a 与 b 引用同一个 open file description,因此共享:
- 文件偏移;
- 文件状态标志。
但 FD_CLOEXEC 属于单个 fd 的"描述符标志",并不按同样方式共享。
情况 C:fork
fork() 后父子进程各有自己的 fd table,但对应表项继承对相同 open file description 的引用。因此父子通过这些继承 fd 访问文件时,可能共享偏移和状态标志。
6.4 关闭 fd 关闭的是什么
close(fd) 移除当前进程该表项的引用。只有最后一个引用消失时,相应 open file description 才会被释放。
所以:
text
关闭一个 fd ≠ 立即销毁底层对象
dup() 产生的另一个 fd、fork() 后子进程继承的 fd,或者其他进程持有的引用都可能让对象继续存在。
七、重定向的本质:open + dup2 + close
7.1 dup2 的准确语义
c
#include <unistd.h>
int dup2(int oldfd, int newfd);
成功后,newfd 与 oldfd 引用同一个 open file description。
如果 newfd 原来已经打开,dup2() 会先关闭其旧引用,再让它引用 oldfd;这两个动作由内核原子完成。自己写成:
c
close(newfd);
dup(oldfd);
存在竞态窗口,期间 newfd 可能被信号处理器或其他线程占用。
如果 oldfd == newfd 且 oldfd 有效,dup2() 不做修改并返回 newfd。
7.2 标准输出重定向
c
int fd = open("out.txt",
O_WRONLY | O_CREAT | O_TRUNC,
0666);
if (fd < 0) {
perror("open");
return 1;
}
if (dup2(fd, STDOUT_FILENO) < 0) {
perror("dup2");
close(fd);
return 1;
}
if (fd != STDOUT_FILENO) {
close(fd);
}
printf("this line goes to out.txt\n");
应用仍然向 stdout / fd 1 写,变化的是 fd 1 指向的打开实例。
7.3 四种基础重定向

| Shell 写法 | 目标 fd | 典型动作 |
|---|---|---|
cmd > out |
1 | 打开并截断 out,dup2(fd, 1) |
cmd >> out |
1 | 追加打开 out,dup2(fd, 1) |
cmd < in |
0 | 只读打开 in,dup2(fd, 0) |
cmd 2> err |
2 | 打开并截断 err,dup2(fd, 2) |
7.4 2>&1 为什么与顺序有关
bash
cmd >all.log 2>&1
Shell 从左到右处理:
text
1. fd 1 → all.log
2. fd 2 复制"当前 fd 1" → all.log
因此标准输出与标准错误都进入 all.log。
而:
bash
cmd 2>&1 >out.log
对应:
text
1. fd 2 复制"旧 fd 1" → 终端
2. fd 1 → out.log
结果是 stdout 进入文件,stderr 仍去原终端。
2>&1不是"把 fd 2 复制到 fd 1",而是"让 fd 2 复制 fd 1 当前引用的去向"。
八、理解"一切皆文件"与 VFS
8.1 更准确的说法
"Linux 下一切皆文件"是一种非常有用的工程抽象,但不要按字面无限扩大。更准确地说:
Linux 尽可能让不同资源通过 fd 和一组相似的 I/O 接口被访问,内核再根据对象类型分派到不同实现。

8.2 统一入口,不同实现
应用可以对很多对象使用 read() / write():
- 普通文件:交给文件系统和页缓存;
- 管道/FIFO:操作内核管道缓冲区;
- 终端/字符设备:交给具体驱动;
- socket:进入网络协议栈;
eventfd、timerfd等:使用各自规定的数据格式与语义。
概念路径可以写成:
text
系统调用
↓
根据 fd 找到打开对象
↓
VFS / 对象操作表分发
↓
文件系统、驱动、管道、网络等具体实现
8.3 "统一"不代表"所有操作都支持"
例如:
- 普通文件通常支持
lseek(); - 管道和许多 socket 不支持有意义的随机定位;
- 设备可能需要
ioctl()完成专属控制; O_PATHfd 只能进行有限的描述符级和路径相关操作,普通read()/write()会失败。
所以真正应该记住的是:
text
接口形态统一,能力由对象实现决定。
8.4 不要把某一版 struct file 当永恒 ABI
内核源码中的 struct file、struct file_operations 会随内核版本演进;驱动接口成员、函数签名和内部字段都可能变化。学习时应抓住稳定概念:
- 内核需要表示一次打开实例;
- 需要保存状态与当前位置;
- 需要把通用操作分派到对象的具体实现。
除非正在针对指定内核版本开发模块,否则不要把旧内核源码截图中的字段列表当成跨版本承诺。
九、缓冲区:fflush、fsync 与数据落盘
9.1 缓冲区至少要分两层
标准 I/O 输出到普通文件时,常见路径是:
text
应用数据
↓
stdio 用户态缓冲
↓ write(2)
内核页缓存
↓ 回写 / 同步
存储设备

9.2 三种 stdio 缓冲模式
| 模式 | 常见行为 | 设置方式 |
|---|---|---|
| 全缓冲 | 缓冲区满等条件下刷新 | setvbuf(..., _IOFBF, ...) |
| 行缓冲 | 遇换行等条件下刷新 | setvbuf(..., _IOLBF, ...) |
| 无缓冲 | 尽量直接交给底层 | setvbuf(..., _IONBF, ...) |
常见 POSIX C 环境中:
stdout指向交互终端时默认行缓冲;stdout重定向到普通文件或管道时通常全缓冲;stderr不是全缓冲。
不要把经验表述写成不可移植的绝对句,例如"stderr 永远完全没有缓冲"。需要确定行为时,用 setvbuf() 显式设置。
9.3 哪些动作会刷新输出流
常见触发点:
- 缓冲区填满;
- 行缓冲流满足换行等刷新条件;
- 显式
fflush(stream); fclose(stream);- 正常
return离开main或调用exit()时关闭并刷新流。
_exit()、异常崩溃或突然断电不会替你正常刷新用户态流。
9.4 fflush 只推进到下一层
c
if (fflush(stdout) == EOF) {
perror("fflush");
}
fflush() 对输出流的作用,是把 C 库用户态缓冲交给底层写函数。此时数据通常已经进入内核,但不代表已经稳定写入物理介质。
如果需要崩溃或掉电后的持久性语义,通常还要:
c
if (fflush(fp) == EOF) {
/* 处理错误 */
}
int fd = fileno(fp);
if (fd < 0 || fsync(fd) < 0) {
/* 处理错误 */
}
对于新建、重命名等操作,文件内容同步成功也不一定表示包含它的目录项已经持久化;严格的崩溃一致性方案可能还要打开并同步目录。
9.5 fflush、fsync、close 的区别
| 接口 | 主要作用 | 不保证什么 |
|---|---|---|
fflush(FILE *) |
刷新 stdio 用户缓冲 | 不保证物理落盘 |
fsync(fd) |
同步文件数据及必要元数据到存储 | 不自动刷新仍停在 stdio 的数据 |
fdatasync(fd) |
侧重数据和读取所必需的元数据 | 不同步无关元数据 |
close(fd) |
释放当前 fd 引用 | 成功不等于数据已稳定落盘 |
正确顺序通常是:
text
FILE* 场景:fflush → fsync(fileno(fp)) → fclose
纯 fd 场景:write → fsync → close
是否真的需要 fsync(),取决于业务对持久性的要求。普通日志、缓存文件与事务提交记录的要求并不相同。
十、fork 为什么会让 printf 输出多份
10.1 现象
c
#include <stdio.h>
#include <unistd.h>
int main(void)
{
printf("hello");
fork();
return 0;
}
如果 stdout 连接终端,具体表现可能受缓冲策略影响;如果把输出重定向到文件,"hello" 很可能仍停在全缓冲的 stdio 用户缓冲里。fork() 复制地址空间后,父子进程各自拥有一份尚未刷新的缓冲内容,正常退出时就可能各写出一次。

10.2 为什么 write 不产生同样的用户缓冲复制
c
write(STDOUT_FILENO, "hello", 5);
fork();
write() 发生在 fork() 之前,数据已经越过 stdio 用户缓冲边界。fork() 不会在两个地址空间中复制一份"尚未提交的 stdio 输出"。
这里不是说内核没有缓存,而是说内核页缓存不属于父子各自的 FILE 用户态对象。
10.3 三种常见规避方式
c
fflush(NULL); /* fork 前刷新所有打开的输出流 */
pid_t pid = fork();
或者:
- 在需要可控语义的路径直接使用
write(); - 子进程
exec失败的清理路径常用_exit(127),避免再次刷新从父进程复制来的 stdio 缓冲。
多线程程序在
fork()后、exec()前可安全调用的函数还有额外限制,这里只讨论缓冲复制本身。
十一、FILE 与 fd 怎样互相转换
11.1 从 FILE* 取得 fd:fileno
c
int fd = fileno(stdout);
if (fd < 0) {
perror("fileno");
}
fileno() 返回流底层使用的 fd。不要擅自关闭它后继续使用原 FILE *,否则流内部状态与底层资源会失配。
11.2 从 fd 创建流:fdopen
c
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
if (fd < 0) {
perror("open");
return 1;
}
FILE *fp = fdopen(fd, "a");
if (fp == NULL) {
perror("fdopen");
close(fd);
return 1;
}
fprintf(fp, "record\n");
fclose(fp); /* 同时关闭底层 fd */
fdopen() 不会复制 fd,而是让流接管这个 fd。成功后通常由 fclose() 统一完成刷新和关闭,不要再额外 close(fd)。
11.3 混用 stdio 与 write 要谨慎
下面的代码可能让输出顺序与你想象的不一致:
c
printf("A");
write(STDOUT_FILENO, "B", 1);
printf("C\n");
原因是 printf() 可能先进入用户缓冲,而 write() 直接越过这一层。确需混用时,应在切换边界显式 fflush(stdout),并且避免多个独立流在没有同步策略时操作同一个打开实例。
十二、从零设计一个最小 stdio 封装
PDF 中的"手写 libc"实验非常适合理解 FILE 的职责,但教学实现如果只写:
c
memcpy(stream->buffer + stream->used, data, len);
stream->used += len;
write(stream->fd, stream->buffer, stream->used);
会遗漏几个关键问题:
len可能超过剩余缓冲区,造成越界;write()可能短写或被EINTR中断;- 刷新失败后必须保留错误状态;
fsync()不应被偷偷塞进每次普通fflush,否则语义和性能都发生变化;- 关闭时既要刷新、关闭 fd,也要释放流对象,并决定怎样向调用者报告错误。
12.1 一个最小输出流结构
c
#include <stddef.h>
#define MBUF_SIZE 4096
typedef struct {
int fd;
unsigned char buffer[MBUF_SIZE];
size_t used;
int error;
} MFILE;
12.2 正确的缓冲写入思路
c
#include <errno.h>
#include <string.h>
#include <unistd.h>
static int m_write_all(int fd, const unsigned char *buf, size_t len)
{
size_t off = 0;
while (off < len) {
ssize_t n = write(fd, buf + off, len - off);
if (n > 0) {
off += (size_t)n;
} else if (n < 0 && errno == EINTR) {
continue;
} else {
if (n == 0) errno = EIO;
return -1;
}
}
return 0;
}
static int mflush(MFILE *stream)
{
if (stream->error) return -1;
if (stream->used == 0) return 0;
if (m_write_all(stream->fd, stream->buffer, stream->used) < 0) {
stream->error = 1;
return -1;
}
stream->used = 0;
return 0;
}
static int mwrite(MFILE *stream, const void *data, size_t len)
{
const unsigned char *src = data;
while (len > 0) {
size_t space = MBUF_SIZE - stream->used;
if (space == 0 && mflush(stream) < 0) return -1;
space = MBUF_SIZE - stream->used;
size_t chunk = len < space ? len : space;
memcpy(stream->buffer + stream->used, src, chunk);
stream->used += chunk;
src += chunk;
len -= chunk;
}
return 0;
}
这还不是生产级 stdio:它没有输入缓冲、读写切换、线程安全、定位、行缓冲、宽字符、锁和完整错误接口。但它已经正确表达了两个核心思想:
FILE类对象负责维护 fd 之外的用户态状态;- 缓冲层必须认真处理容量、短写和错误传播。
十三、完整实战:实现一个重定向启动器
下面实现一个教学版程序:根据模式重定向一个标准 fd,然后执行目标命令。
支持:
text
in 标准输入来自文件
out 标准输出覆盖写文件
append 标准输出追加写文件
err 标准错误覆盖写文件
13.1 完整代码
c
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
static void usage(const char *program)
{
fprintf(stderr,
"usage: %s <in|out|append|err> <file> <command> [args...]\n",
program);
}
int main(int argc, char *argv[])
{
if (argc < 4) {
usage(argv[0]);
return 2;
}
const char *mode = argv[1];
const char *path = argv[2];
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
int flags;
int target_fd;
if (strcmp(mode, "in") == 0) {
flags = O_RDONLY;
target_fd = STDIN_FILENO;
} else if (strcmp(mode, "out") == 0) {
flags = O_WRONLY | O_CREAT | O_TRUNC;
target_fd = STDOUT_FILENO;
} else if (strcmp(mode, "append") == 0) {
flags = O_WRONLY | O_CREAT | O_APPEND;
target_fd = STDOUT_FILENO;
} else if (strcmp(mode, "err") == 0) {
flags = O_WRONLY | O_CREAT | O_TRUNC;
target_fd = STDERR_FILENO;
} else {
dprintf(STDERR_FILENO, "unknown mode: %s\n", mode);
_exit(2);
}
int fd;
if (target_fd == STDIN_FILENO) {
fd = open(path, flags);
} else {
fd = open(path, flags, 0666);
}
if (fd < 0) {
perror("open redirection target");
_exit(126);
}
if (dup2(fd, target_fd) < 0) {
perror("dup2");
close(fd);
_exit(126);
}
if (fd != target_fd) {
close(fd);
}
execvp(argv[3], &argv[3]);
perror("execvp");
_exit(127);
}
int status;
while (waitpid(pid, &status, 0) < 0) {
if (errno == EINTR) continue;
perror("waitpid");
return 1;
}
if (WIFEXITED(status)) {
return WEXITSTATUS(status);
}
if (WIFSIGNALED(status)) {
return 128 + WTERMSIG(status);
}
return 1;
}
13.2 编译与运行
bash
gcc -Wall -Wextra -O2 redirect_exec.c -o redirect_exec
# stdout 覆盖写
./redirect_exec out output.txt ls -l /tmp
# stdout 追加写
./redirect_exec append output.txt date
# stdin 来自文件
./redirect_exec in input.txt wc -l
# stderr 写入文件
./redirect_exec err error.txt ls /path/not/exist
13.3 这段程序为什么有代表性
它把多个章节连接成一个真实闭环:
text
fork
↓ 子进程
open 目标文件
↓
dup2 改写标准 fd
↓
close 临时 fd
↓
execvp 执行完全不懂重定向的普通程序
↓ 父进程
waitpid 回收并返回结果
临时 fd 在 dup2() 成功后立即关闭,因此新程序只继承需要的标准 fd。这里没有给临时 fd 设置 O_CLOEXEC,是为了正确覆盖一个少见但合法的边界:如果目标标准 fd 原本已关闭,open() 可能直接返回 0、1 或 2,此时不应让该 fd 在 exec 时被意外关闭。复杂或多线程程序若使用 O_CLOEXEC,还要专门处理 fd == target_fd 的情况。
十四、常见误区与面试高频问题
14.1 fd 0、1、2 永远存在吗?
不一定。它们是标准输入、输出、错误的惯用编号,但程序可以关闭它们。守护进程或沙箱环境也可能重新绑定这些编号。
14.2 open 为什么返回最小可用 fd?
这是接口规定的分配行为,便于紧凑复用编号,也解释了关闭 fd 1 后下一次 open() 常返回 1。但并发程序不能把"close + open 恰好拿到 1"当作可靠重定向方案,应使用 dup2()。
14.3 O_APPEND 与 lseek 到末尾等价吗?
不等价。lseek(fd, 0, SEEK_END) 与随后的 write() 是两个动作,中间可能被其他写入穿插;O_APPEND 把"定位到末尾"和单次 write() 结合为原子步骤。
但如果一条逻辑记录拆成多次 write(),O_APPEND 并不会自动把整组调用变成一个不可分割事务。
14.4 write 成功就表示落盘了吗?
不表示。成功说明相应数据被内核接受,通常还可能停在页缓存中。持久性要求要考虑 fsync() / fdatasync(),并处理相关错误。
14.5 fflush 后为什么还要 fsync?
两者处理不同层:
text
fflush:stdio 用户缓冲 → 内核
fsync:内核缓存 → 持久存储
顺序不能反过来;如果数据还停在用户缓冲,先 fsync() 并不能同步尚未交给内核的内容。
14.6 stdout 与 stderr 有什么关键区别?
用途不同,默认缓冲行为也常不同:stdout 用于正常结果,stderr 用于诊断;stderr 不是全缓冲,因此错误消息通常更及时。但不要依赖未经标准保证的具体"永远无缓冲"实现细节。
14.7 dup2(oldfd, newfd) 会复制文件内容吗?
不会。它复制的是 fd 引用关系,让 newfd 指向与 oldfd 相同的 open file description。
14.8 fork 后父子 fd 是同一个整数,是否就是同一个表项?
不是同一个进程表项。父子各有自己的 fd table,但继承表项可以引用同一个 open file description,所以会共享偏移和文件状态。
14.9 可以对所有 fd 调用 lseek 吗?
不能。普通文件通常可定位,管道和许多 socket 不支持,会返回错误。统一 fd 接口不等于每种对象具备相同能力。
14.10 close 报错后应该重试吗?
在 Linux 上一般不要盲目重试。错误可能是先前写入延迟报告的结果,而 fd 编号可能已被释放和复用。需要可靠保存时,在 close() 前检查 fsync(),并仍然记录 close() 错误用于诊断。
总结
Linux 基础 I/O 最重要的不是背一组函数,而是形成稳定的分层模型:
text
FILE*:用户态流对象,管理格式化、缓冲与状态
↓
fd:当前进程文件描述符表的整数索引
↓
open file description:内核打开实例,保存偏移和状态标志
↓
VFS / 驱动:把通用操作分派到具体对象
↓
文件、管道、终端、设备、socket 等 I/O 目标
再把全文压缩成 12 条结论:
- 文件既有内容,也有权限、大小、时间等元数据;
FILE *与 fd 不在同一抽象层;read()的短读合法,0 表示 EOF,-1 才是错误;write()可能短写,循环必须维护已完成偏移;open()返回当前进程最小的未使用 fd;mode只在创建文件时使用,最终权限还受umask等规则影响;- fd 引用 open file description,后者保存偏移和文件状态;
dup、dup2与fork都可能建立对同一打开实例的引用;- 重定向的核心是
open + dup2 + close; 2>&1复制当前 fd 1 的去向,因此处理顺序决定结果;fflush()只处理用户态缓冲,fsync()才处理持久化层;- "一切皆文件"表示统一 fd 抽象,不表示所有对象支持所有操作。
当你能沿着"数据当前在哪一层、fd 指向哪个打开实例、返回值推进了多少字节"这三个问题排查程序时,Linux I/O 就不再是一堆零散 API,而是一套可以推导的系统。
参考资料
- Linux man-pages:open(2)
- Linux man-pages:read(2)
- Linux man-pages:write(2)
- Linux man-pages:dup(2)
- Linux man-pages:close(2)
- Linux man-pages:stdio(3)
- Linux man-pages:fflush(3)
- Linux man-pages:fsync(2)
如果这篇文章帮助你真正串起了 Linux I/O 的层次,欢迎点赞、收藏,并在评论区留下你遇到过的 I/O、重定向或缓冲区问题。