Lecture 18: 系统级 I/O 与文件系统 (System-Level I/O and File Systems)
Lecture 18: 系统级 I/O 与文件系统 (System-Level I/O and File Systems)
讲义对应:CMU 15-213 Lecture 18 — System Level I/O and File Systems(素材:
F25-18-system-io.txt) 教材对应:CS:APP3e 第 10 章 系统级 I/O(10.1–10.12) 关联 Lab:L6 Shell Lab(tsh)/ L7 Proxy Lab(缓存 Web 代理)
18.1 概述
本讲回答一个承前启后的核心问题:当你在 C 里写下 printf("hi") 时,内核究竟做了什么? 讲义从最底层的 Unix I/O(open/read/write/close/stat)出发,向上解释 C 标准库 FILE * 流的缓冲模型,横向讲清”什么时候该用哪一层”,再揭示内核用描述符表 → 打开文件表 → v-node 表三层结构支撑文件共享与重定向。它承接 Lecture 16–17 的进程与信号,是 Lecture 19–20 文件系统与网络编程的前提,也是 Shell Lab 的 > 重定向与 Proxy Lab 的 rio_readlineb/rio_writen 的技术底座。讲义始终强调:短计数(short count)不是错误而是常态,你必须处理它。
18.2 核心概念与底层机制图解
18.2.1 一切皆文件:字节序列模型(A File Is a Sequence of Bytes)
- 定义与目的:文件就是一个 $m$ 字节的序列 $B_0, B_1, \ldots, B_k, \ldots, B_{m-1}$。内核为所有文件提供同一套操作:打开/关闭、读写、查询元数据、改变当前位置(seek)。”当前文件位置 $k$”位于字节 $B_{k-1}$ 与 $B_k$ 之间。
- 直观解释:把文件想象成一条超长磁带,磁头停在某处;
read是”从磁头处复制一段到内存并把磁头右移”,write是”把内存里的一段覆写到磁头处并右移”,lseek则是用手把磁头挪到别处。 - 底层机制图解:讲义的”酷事实”是——所有 I/O 设备都是文件(
/dev/sda2磁盘分区、/dev/tty2终端、/dev/null丢弃写入),连内核数据结构也被暴露成文件(/proc/$$/status、/proc/$$/fd/)。统一抽象换来的可组合性是 Unix 最锋利的设计。 - 与硬件/机器码的对应:每次
read/write都是一条syscall指令(调用号放%eax)。讲义的close实现堪称最小内核入口:
.globl close
close:
mov $3, %eax # 系统调用号 3 = close
syscall # 陷入内核(用户态 → 内核态)
cmp $-4096, %rax # 返回值落在 [-4095, -1] 即为 -errno
jae __syscall_error
ret
一次 read/write 系统调用要花超过 10,000 个时钟周期(讲义原话),这正是标准 I/O 缓冲存在的全部理由。
18.2.2 文件类型(File Types)
- 普通文件(regular file):存放任意数据。应用层常分文本文件与二进制文件,但内核不在乎,全是字节。文本文件是”文本行的序列”,文本行以行结束符终止(不是分隔!);字符由编码定义(ASCII、UTF-8、EUC-JP…)。行结束符(EOL):Unix 系是单字节
0x0A(LF);DOS/Windows 是两字节0x0D 0x0A(CR+LF),许多 Internet 协议也如此,C 库自动翻译成'\n'。 - 目录(directory):由一组目录项(entry,也叫 link)构成的数组,每项把文件名映射到文件。每个目录至少含两项:
.指向自身、..指向父目录;mkdir建空目录、ls查看、rmdir删空目录。 - 套接字(socket):与另一个进程(可能跨机器)通信的文件——Proxy Lab 的核心对象。
- 超出范围的类型:命名管道(FIFO)、符号链接、字符/块设备。
18.2.3 打开文件与文件描述符(Opening Files and File Descriptors)
- 定义与目的:
open告知内核”我准备访问这个文件”,返回一个小整数 文件描述符(file descriptor, fd)。每个进程出生时有三个打开的文件:0 = 标准输入 stdin、1 = 标准输出 stdout、2 = 标准错误 stderr——它们可能是文件、管道、终端,甚至网络连接。 - 直观解释:fd 像医院挂号单的号码:不必知道诊室在哪,报号码即可,系统替你找到对应表项。而”返回最小可用 fd“意味着关掉 3 号窗口后下次就会拿到 3 号——这是
dup2与重定向能工作的前提。 - 出错处理:
fd == -1表示出错,用errno+perror/strerror报告。
open 的 flags 完整表(讲义口径;flags 中恰好取一个访问模式,其余用 \| 组合):
| flags | 含义 | 出现场合 |
|---|---|---|
O_RDONLY | 只读 | 打开已有文件时恰选一个 |
O_WRONLY | 只写 | 打开已有文件时恰选一个;创建文件时也可选 |
O_RDWR | 读写皆可 | 打开已有文件时恰选一个;创建文件时也可选 |
O_CREAT | 文件不存在则创建(必须提供第三参数 mode) | 创建文件时必须有 |
O_EXCL | 文件已存在则失败(与 O_CREAT 连用实现原子创建) | 可选附加位 |
O_TRUNC | 存在则先删除原有内容 | 可选附加位 |
O_APPEND | 所有写入都追加到文件末尾 | 可选附加位 |
O_CLOEXEC | 调用 execve() 时自动关闭该 fd | 可选附加位 |
int fd;
if ((fd = open("/etc/hosts", O_RDONLY)) < 0) { /* 两参数形式:打开已有文件 */
perror("open");
exit(1);
}
神秘的第三个参数:flags 含 O_CREAT 时第三参数必须存在,否则被忽略。mode 是新建文件的默认权限,会被 umask 修正;讲义建议无特殊理由就用 sys/stat.h 的 DEFFILEMODE(0666)。
关闭文件:不要关闭同一个 fd 两次(等价于对同一指针 free() 两次);close 可能”失败”——文件其实已关,但 OS 借此上报此前某次 write 的延迟错误,不检查返回值可能悄悄丢数据:
if (close(fd) < 0) {
fprintf(stderr, "%s: write error: %s", filename, strerror(errno));
exit(1);
}
18.2.4 读写文件与短计数(Reading/Writing Files, Short Counts)
ssize_t read(int fd, void *buf, size_t n); /* 从当前位置复制到 buf 并更新位置 */
ssize_t write(int fd, const void *buf, size_t n); /* 从 buf 复制到当前位置并更新位置 */
返回类型 ssize_t 是有符号整数:nbytes < 0 才是错误;短计数(nbytes < n)完全可能,且不是错误;read 返回 0 表示 EOF(write 没有 EOF 语义)。
短计数何时发生(讲义列举):读到 EOF 时、从终端读文本行时、读写网络套接字与管道时。何时不会发生:从磁盘文件读(除 EOF 外)、写磁盘文件。还有 EINTR:被信号中断时返回 -1 且 errno == EINTR,正确做法是重试而非报错退出。最佳实践:永远为短计数留出处理逻辑。
为什么必须用 rio:若直接写 while ((n = read(fd, buf, n)) > 0) write(out, buf, n);,当底层是套接字或管道时 write 很可能只写出一部分,剩下字节静默丢失——Proxy Lab 的批量大对象测试会因此损坏整个响应体。rio 包就是把”循环直到写满/读满”封装成函数。
| rio 函数 | 缓冲 | 语义 | 停止条件 | 典型用途 |
|---|---|---|---|---|
rio_readn(fd, buf, n) | 无缓冲 | 读恰好 n 字节 | 只在 EOF 时返回短计数 | 已知长度的二进制体读取 |
rio_writen(fd, buf, n) | 无缓冲 | 写恰好 n 字节 | 永不返回短计数 | 写 HTTP 响应、转发请求 |
rio_readinitb(&rp, fd) | — | 把 rio_t 与 fd 绑定并清空缓冲区 | — | 每个 fd 一个 rio_t |
rio_readlineb(&rp, buf, maxlen) | 有缓冲 | 读一行文本 | 满 maxlen / EOF / 遇 '\n' | 解析 HTTP 请求行与头部 |
rio_readnb(&rp, buf, n) | 有缓冲 | 读最多 n 字节二进制 | 满 n / EOF | 读 Content-length 指定长度的响应体 |
关键约束:rio_readlineb 与 rio_readnb 可在同一描述符上任意交错且线程安全(每个 fd 一个 rio_t);但不要与无缓冲的 rio_readn 混用(缓冲区里的预读字节会被越过)。而 rio_readn 与 rio_writen 之间可任意交错。
二进制警告:处理二进制文件(图像、视频)时绝不能用文本导向函数(fgets、scanf、rio_readlineb,会解释 EOL 字符),也绝不能用字符串函数(strlen、strcpy,把字节 0 当特殊字符)。请改用 rio_readn/rio_readnb。
18.2.5 文件元数据与 struct stat(File Metadata)
元数据是”关于数据的数据”,由内核按文件维护,通过 stat/fstat/lstat 访问。讲义给出的结构体:
/* Metadata returned by the stat and fstat functions */
struct stat {
dev_t st_dev; /* Device */
ino_t st_ino; /* inode */
mode_t st_mode; /* Protection and file type */
nlink_t st_nlink; /* Number of hard links */
uid_t st_uid; /* User ID of owner */
gid_t st_gid; /* Group ID of owner */
dev_t st_rdev; /* Device type (if inode device) */
off_t st_size; /* Total size, in bytes */
unsigned long st_blksize; /* Blocksize for filesystem I/O */
unsigned long st_blocks; /* Number of blocks allocated */
time_t st_atime; /* Time of last access */
time_t st_mtime; /* Time of last modification */
time_t st_ctime; /* Time of last change */
};
三函数区别:stat 跟随符号链接、lstat 不跟随(拿链接自身信息)、fstat 用已打开 fd 查询。字段要点:st_mode 同时编码类型与权限;st_ino 是 i-node 号(硬链接共享它);st_nlink 减到 0 才真正删除数据;时间戳区分 atime(访问)/mtime(改内容)/ctime(改 i-node)。判断类型必须用宏:S_ISREG(0100000)、S_ISDIR(0040000)、S_ISSOCK(0140000)、S_ISLNK(0120000)、S_ISFIFO、S_ISCHR、S_ISBLK。
18.2.6 内核如何表示打开的文件:三层结构
这是本讲最重要的图:
ASCII 图 1:描述符表 → 打开文件表 → v-node 表
进程 A 的描述符表 打开文件表(全系统共享) v-node 表(全系统共享)
[每进程一张] [含文件位置、引用计数] [含 stat 信息]
+------------------+ +------------------------+ +------------------------+
| fd 0 | *-----|------>| File pos = 12 | | v-node for File A |
+------------------+ | refcnt = 1 | | File access |
| fd 1 | *-----|--+ | v-node ptr ------------|----->| File size |
+------------------+ | +------------------------+ | File type |
| fd 2 | *-----|--|--->| File pos = 0 | +------------------------+
+------------------+ | | refcnt = 1 | ^
| fd 3 | (free) | | | v-node ptr ------------|----+ |
+------------------+ | +------------------------+ | +------------------------+
| fd 4 | *-----|--|----------------------------------+->| v-node for File B |
+------------------+ | | File access |
| | File size |
+---> 终端(File A) | File type |
+------------------------+
fd 0 = stdin fd 1 = stdout fd 2 = stderr
"File pos 按打开文件维护(per open file),不是按描述符!"
三层职责:描述符表每进程一张、表项只是指针;打开文件表全系统共享,含文件位置 k、引用计数 refcnt、v-node 指针;v-node 表全系统共享,含 stat 信息。”位置在中间层”是理解此后所有共享行为的关键。
两次 open 同一文件:得到两个不同的打开文件表项(各自 refcnt=1、各自独立的 File pos),但 v-node 指向同一个——”逻辑上不同、物理上相同的文件”。
fork 后的共享:子进程继承父进程的描述符表(exec 也不改变,可用 fcntl 修改),父子表内容相同,每个打开文件表项 refcnt 加 1。由于位置在打开文件表里,父子共享同一偏移量——这正是讲义 ffiles2.c 中”父读 c、子读 b“的原因。
18.2.7 dup2 与 I/O 重定向(dup2 and I/O Redirection)
linux> ls > foo.txt 如何实现?shell 在子进程里调用 dup2(oldfd, newfd),把(每进程的)描述符表项 oldfd 复制到表项 newfd。
ASCII 图 2:dup2(4, 1) 前后的结构对比
dup2(4, 1) 之前 dup2(4, 1) 之后
描述符表 描述符表
+--------+ +--------+
| fd 0 --|--> [打开文件表项: 终端 refcnt=1] | fd 0 --|--> [打开文件表项: 终端 refcnt=0] <-- 被回收
+--------+ +--------+
| fd 1 --|--> [打开文件表项: 终端 refcnt=1] | fd 1 --|--+
+--------+ +--------+ |
| fd 2 --|--> [打开文件表项: 终端 refcnt=1] | fd 2 --|--> [打开文件表项: 终端 refcnt=1]
+--------+ +--------+ |
| fd 3 | (free) | fd 3 | | (free)
+--------+ +--------+ |
| fd 4 --|--> [打开文件表项: foo.txt refcnt=1] | fd 4 --|--+
+--------+ File pos = 0 +--------+ |
^ |
| +-------------------------+
+----------------v
[打开文件表项: foo.txt refcnt=2] <-- 两个描述符指向同一个打开文件表项
File pos = 0 (共享同一个偏移量)
标准重定向三步曲(讲义”Step #1 / Step #2”):
int fd = open("foo.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644); /* Step 1 */
dup2(fd, 1); /* Step 2: 让 fd 1 指向该文件 */
close(fd); /* 消除多余描述符 */
为什么要 close(fd)?dup2 之后 fd 1 与 fd 已指向同一打开文件表项,refcnt 因 dup2 变成 2(同时原终端表项跌到 0 被回收);关掉多余 fd 让 refcnt 回到 1,避免文件描述符泄漏——Proxy Lab 明确把”无 fd 泄漏”列为鲁棒性要求。
18.2.8 标准 I/O 与缓冲(Standard I/O and Buffering)
C 标准库(libc.so)提供高层函数(K&R 附录 B):fopen/fclose、fread/fwrite、fgets/fputs、fscanf/fprintf。流(stream)= 一个文件描述符 + 一块内存缓冲区。程序出生时就打开三个流(stdio.h 定义):
extern FILE *stdin; /* standard input (descriptor 0) */
extern FILE *stdout; /* standard output (descriptor 1) */
extern FILE *stderr; /* standard error (descriptor 2) */
FILE * 与 fd 的关系:讲义揭开了实现——fopen 内部是 open(...) 后 fdopen(fd, mode);fputs 内部循环 write(fp->fd, s, n) 直到写完(它自己就在处理短计数);fclose 内部是 close(fp->fd)。从 FILE * 拿回 fd 用 fileno(FILE*),反向用 fdopen。
为什么需要缓冲:应用常一次读写一个字符(getc/putc、fgets 逐字符读到换行),而每次 read/write 要 > 10,000 时钟周期。于是标准 I/O 用 read 一次抓一大块进缓冲区,用户函数逐字节取用,空了再补。缓冲区在遇到 "\n"、fflush、exit、或从 main 返回时刷到输出 fd。 讲义的 strace 证据(六个 printf 只产生一次 write):
linux> strace ./hello
write(1, "hello\n", 6) = 6
exit_group(0) = ?
stderr 无缓冲(保证错误立刻可见);stdout 连终端时行缓冲、重定向到文件时全缓冲——这个差别正是 18.3.3 陷阱的根源。也可用 setvbuf(stream, NULL, _IOFBF\|_IOLBF\|_IONBF, size) 显式设定。
18.2.9 该用哪一层 I/O(Choosing I/O Functions)
| Unix I/O | 标准 I/O | |
|---|---|---|
| 控制力 / 便利性 | 最大 / 最小 | 较小 / 较大 |
| 元数据访问 | 有(stat/fstat/lstat) | 没有 |
| async-signal-safe | 是,可在信号处理函数里用 | 否 |
| 网络套接字 | 可以 | 不合适(流与套接字的限制交互恶劣) |
| 短计数 | 需自己处理(易错) | 自动处理 |
| 文本行效率 | 需自己缓冲(易错) | 缓冲带来效率 |
通用规则:能用高层就用高层。具体建议:用标准 I/O——处理”普通”文件;用裸 Unix I/O——① 在信号处理函数里(async-signal-safe);② 读写网络套接字时(此时请用专门的带缓冲网络 I/O 库,如 rio_*、libevent、libuv);③ 极少数需要绝对最高性能的场合。
18.2.10 文件系统概述:目录树与 i-node
ASCII 图 3:磁盘目录树与 i-node 的关系
目录树(逻辑视图) 磁盘上的目录项与 i-node(物理视图)
/ (root) 目录 / (一个"文件名→i-node号"的数组)
| +----------------+----------------+---------+
+---------+---------+ | name ino | name ino | ... |
| | | +----------------+----------------+---------+
bin/ dev/ home/ | "." 2 | ".." 2 |
| | "bin" 8 | "dev" 12 |
+-----+-----+ | "home" 21 | "usr" 30 |
| | +----------------+----------------+---------+
droh/ bryant/
| | 目录 /home/bryant
hello.c cat.c +----------------+----------------+
| "hello.c" 4471 | "cat.c" 4472 |
+----------------+----------------+
|
v
i-node 表(每个 i-node 含元数据 + 数据块指针)
+----------------------------------------------------------+
i-node 4471| st_mode | st_nlink=1 | st_size=68 | st_uid | st_mtime |
+----------------------------------------------------------+
| 直接块指针 0 --> [数据块 0] |
| 直接块指针 1 --> [数据块 1] |
| ... |
| 一级间接块 --> [指向数据块的指针数组] ──> [数据块 ...] |
| 二级间接块 --> [指向一级间接块的指针数组] ... |
+----------------------------------------------------------+
文件名在目录项里,其余全部信息在 i-node 里 —— 硬链接 = 多个目录项指向同一 i-node
- 目录层次与挂载:所有文件组织成以根目录
/为锚的层次结构;磁盘分区(partition)挂载(mounting)到目录树某点后内容才可见;内核为每个进程维护当前工作目录(cwd),用cd修改。 - 路径名:绝对路径以
/开头(/home/droh/hello.c),相对路径从 cwd 出发(../droh/hello.c)。 - i-node:目录项只管”文件名 → i-node 号”的映射;i-node 存元数据与数据块指针(直接块 + 一级/二级间接块),支持任意大的文件。目录项里没有大小、权限等信息——这正是硬链接能共享它们的原因。
- 硬链接 vs 符号链接:
ln a b让两个目录项指向同一 i-node(st_ino相同、st_nlink变为 2),删掉任一名字数据仍在;ln -s a c创建新文件,内容是目标路径串(st_ino不同、st_nlink为 1、st_size等于串长),目标被删即成悬空链接。unlink删除目录项,st_nlink减到 0 时数据块才回收。 - 目录操作 API:
opendir/readdir/closedir遍历目录,struct dirent的d_name是文件名、d_ino是 i-node 号;mkdir/rmdir/chdir/getcwd增删改查。
18.3 代码示例与底层机制分析
以下三个程序均已在 /tmp/csapp18/ 下用 gcc -g -Wall -std=c11 实际编译并运行,输出为真实结果。
18.3.1 示例 A:正确处理短计数的文件复制程序
代码 (C):
/* mycp.c - 用 Unix I/O 复制文件,正确处理短计数(short count) */
#define _DEFAULT_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#define BUFSIZE 4096
/* rio_writen 风格的健壮写:保证写满 n 字节,除非出错 */
static ssize_t writen(int fd, const char *buf, size_t n)
{
size_t nleft = n;
ssize_t nwritten;
const char *ptr = buf;
while (nleft > 0) {
if ((nwritten = write(fd, ptr, nleft)) < 0) {
if (errno == EINTR) { /* 被信号中断:重试,不算错误 */
nwritten = 0;
} else {
return -1; /* 真错误 */
}
} else if (nwritten == 0) {
break; /* 不应该发生在磁盘文件上 */
}
nleft -= (size_t)nwritten;
ptr += nwritten;
}
return (ssize_t)(n - nleft);
}
int main(int argc, char *argv[])
{
if (argc != 3) {
fprintf(stderr, "usage: %s <src> <dst>\n", argv[0]);
return 1;
}
int srcfd = open(argv[1], O_RDONLY);
if (srcfd < 0) {
fprintf(stderr, "open %s: %s\n", argv[1], strerror(errno));
return 1;
}
/* O_CREAT 必须给第三个参数 mode;受 umask 修正 */
int dstfd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, DEFFILEMODE);
if (dstfd < 0) {
fprintf(stderr, "open %s: %s\n", argv[2], strerror(errno));
close(srcfd);
return 1;
}
char buf[BUFSIZE];
ssize_t nread;
long total = 0;
/* read 返回 0 才是 EOF;短计数 nread < BUFSIZE 完全正常 */
while ((nread = read(srcfd, buf, sizeof(buf))) > 0) {
if (writen(dstfd, buf, (size_t)nread) != nread) {
fprintf(stderr, "write %s: %s\n", argv[2], strerror(errno));
close(srcfd);
close(dstfd);
return 1;
}
total += nread;
}
if (nread < 0) {
fprintf(stderr, "read %s: %s\n", argv[1], strerror(errno));
close(srcfd);
close(dstfd);
return 1;
}
/* close 也可能"失败":它在上报此前 write 延迟的错误 */
if (close(dstfd) < 0) {
fprintf(stderr, "close %s: %s\n", argv[2], strerror(errno));
close(srcfd);
return 1;
}
if (close(srcfd) < 0) {
fprintf(stderr, "close %s: %s\n", argv[1], strerror(errno));
return 1;
}
fprintf(stderr, "[mycp] copied %ld bytes from %s to %s\n",
total, argv[1], argv[2]);
return 0;
}
【代码做什么?】 ① O_RDONLY 打开源文件(本机常得 fd 3)。② O_WRONLY\|O_CREAT\|O_TRUNC + DEFFILEMODE 打开/截断目标文件,O_CREAT 触发三参数 open。③ 循环 read(fd, buf, 4096):> 0 就继续(小于 4096 是短计数,正常),0 是 EOF,< 0 才是错误。④ 每读到一块就调用自制的 writen,保证每个字节都写出去,EINTR 时重试。⑤ 先 close(dstfd) 并检查返回值(捕获延迟写错误)。⑥ 统计信息走 stderr,不污染数据流。
【底层机制透视】 read/write 各是一次 syscall:内核按 fd 查打开文件表项、取出 File pos,再去 v-node 找数据块。read 把数据从页缓存拷进用户缓冲区(未命中则触发缺页与磁盘 I/O,详见 Lecture 11–12),再把 File pos 前移 nread——位置按打开文件表项维护,故同一文件 open 两次得到两个独立偏移量。write 只把数据放进页缓存并标脏,落盘由回写(write-back)完成:write 成功不代表数据在盘上,close 报出的错误往往来自更早的写入。
【内存布局 / 数据结构图解】(用户栈放缓冲区;内核侧 fd 3/fd 4 各自一个打开文件表项,v-node 才共享)
用户栈(高地址)
0x7ffd_xxxx +------------------------+
| buf[4096] | <-- read/write 的用户缓冲区
+------------------------+
| srcfd=3, dstfd=4 |
| nread, total, ptr |
+------------------------+ <- %rsp
描述符表(内核,按进程)
fd 3 -> [打开文件表项: redirect.c File pos=0 refcnt=1] --\
fd 4 -> [打开文件表项: out.txt File pos=0 refcnt=1] --+--> v-node (st_size, 数据块指针)
【与汇编 / 硬件的对应】(gcc -S 风格片段,可见 syscall 前的参数搬运)
movl %ebx, %edi # fd -> %rdi(第 1 参数)
movq %r12, %rsi # buf -> %rsi(第 2 参数)
movl $4096, %edx # n -> %rdx(第 3 参数)
call read@plt # 进入 glibc 包装,内部 movl $0, %eax; syscall
testq %rax, %rax
jle .Ldone # <= 0 退出循环(0 = EOF, < 0 = 错误)
movq %rax, %rbp # 短计数:实际字节数留在 %rax
【实测验证】(真实输出)
$ ./mycp redirect.c copy-of-redirect.c
[mycp] copied 2079 bytes from redirect.c to copy-of-redirect.c
$ cmp redirect.c copy-of-redirect.c && echo identical
identical
用 strace 统计缓冲区大小对系统调用次数的影响:
BUFSIZE=1 -> read/write 系统调用次数 = 4161
BUFSIZE=512 -> read/write 系统调用次数 = 13
BUFSIZE=4096 -> read/write 系统调用次数 = 5
缓冲区从 1 字节调到 4096 字节,系统调用次数从 4161 降到 5——这就是标准 I/O 缓冲存在的理由。
18.3.2 示例 B:dup2 实现 stdout 重定向
代码 (C):
/* redirect.c - 用 dup2 把 stdout 重定向到文件,再恢复原 stdout */
#define _DEFAULT_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
/* 查看 fd 当前指向的对象(Linux: /proc/self/fd/N 是符号链接) */
static void show_fd(const char *tag, int fd)
{
char link[64], target[512];
snprintf(link, sizeof(link), "/proc/self/fd/%d", fd);
ssize_t n = readlink(link, target, sizeof(target) - 1);
if (n < 0) { target[0] = '?'; n = 1; }
target[n] = '\0';
fprintf(stderr, " [%-14s] fd %d -> %s\n", tag, fd, target);
}
int main(void)
{
if (setvbuf(stdout, NULL, _IOLBF, 0) != 0) { /* 行缓冲,顺序可预期 */
fprintf(stderr, "setvbuf failed\n");
return 1;
}
show_fd("before", STDOUT_FILENO);
printf("A: 这一行去终端(fd 1 指向终端/socket)\n");
fflush(stdout);
/* 备份原来的 stdout:dup 返回最小的可用 fd,这里应为 3 */
int saved_stdout = dup(STDOUT_FILENO);
if (saved_stdout < 0) { perror("dup"); return 1; }
show_fd("saved_stdout", saved_stdout);
int fd = open("redirect-out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) { perror("open"); return 1; }
fprintf(stderr, " opened redirect-out.txt -> fd %d\n", fd);
/* 关键一步:dup2(oldfd=fd, newfd=1) */
if (dup2(fd, STDOUT_FILENO) < 0) { perror("dup2"); return 1; }
if (close(fd) < 0) { perror("close"); return 1; } /* refcnt 回到 1 */
show_fd("after dup2", STDOUT_FILENO);
printf("B: 这一行去 redirect-out.txt\n");
fflush(stdout); /* 确认 B 已经写进文件 */
/* 恢复:把备份的描述符拷回 fd 1 */
if (dup2(saved_stdout, STDOUT_FILENO) < 0) { perror("dup2 restore"); return 1; }
if (close(saved_stdout) < 0) { perror("close saved"); return 1; }
show_fd("restored", STDOUT_FILENO);
printf("C: 这一行又回到终端\n");
return 0;
}
【代码做什么?】 ① dup(1) 拿到最小的可用 fd 作为备份(实测 3),此时表项 3 与 1 指向同一打开文件表项,refcnt = 2。② open 目标文件得 fd 4(3 已被占用)。③ dup2(4, 1):只改描述符表项 1,使其指向 fd 4 所指的表项——原终端表项 refcnt 从 2 降到 1(saved_stdout 撑着)。④ printf("B") 经 FILE *stdout 缓冲,最终 write(1,...) 落进文件。⑤ dup2(saved_stdout, 1) 把终端表项拷回 fd 1,printf("C") 回到终端。
【底层机制透视】 dup2 不复制打开文件表项,只复制描述符表里的指针——重定向前后两 fd 共享同一个 File pos。ls > foo.txt 之所以从文件头写起,正因 shell 在 fork 之后、execve 之前完成 dup2,而 execve 保留已打开描述符与 File pos。
【实测验证】(真实输出;本环境 stdout 是 socket,注意 fd 1 的指向变化)
$ rm -f redirect-out.txt && ./redirect
A: 这一行去终端(fd 1 指向终端/socket)
C: 这一行又回到终端
[before ] fd 1 -> socket:[3420704682]
[saved_stdout ] fd 3 -> socket:[3420704682] <-- dup 返回最小可用 fd = 3
opened redirect-out.txt -> fd 4 <-- open 拿到下一个可用 fd = 4
[after dup2 ] fd 1 -> /tmp/csapp18/redirect-out.txt
[restored ] fd 1 -> socket:[3420704682]
$ cat redirect-out.txt
B: 这一行去 redirect-out.txt
$ ls -l redirect-out.txt
-rw-r--r-- 1 runguoli mzhang 33 Sep 23 19:33 redirect-out.txt
(stdout 与 stderr 在此环境指向同一 socket,故终端行与 [...] 诊断行交错;内容归属由 redirect-out.txt 里只有 B 这一事实确证。)
18.3.3 示例 C:fork + 标准 I/O 缓冲区导致输出重复的陷阱
代码 (C):
/* forkbuf.c - 演示 fork 复制标准 I/O 缓冲区导致的重复输出 */
#define _DEFAULT_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
int main(int argc, char *argv[])
{
int use_fflush = (argc > 1 && strcmp(argv[1], "flush") == 0);
/* 让 stdout 变成全缓冲:重定向到文件时默认就是全缓冲 */
setvbuf(stdout, NULL, _IOFBF, 4096);
printf("PARENT-PREFIX: pid=%d\n", (int)getpid());
if (use_fflush) {
fflush(stdout); /* 修复:fork 前清空缓冲区 */
}
pid_t pid = fork();
if (pid < 0) {
fprintf(stderr, "fork: %s\n", strerror(errno));
return 1;
}
if (pid == 0) {
printf("CHILD: pid=%d\n", (int)getpid());
return 0; /* child 自己退出,也会 flush */
}
printf("PARENT: pid=%d\n", (int)getpid());
waitpid(pid, NULL, 0);
return 0; /* return from main 会 flush */
}
【代码做什么?】 ① setvbuf 把 stdout 强制设为全缓冲(重定向到文件时这是默认行为)。② printf("PARENT-PREFIX...") 只把字节写进用户态缓冲区,尚无任何 write 调用。③ fork():子进程复制整个地址空间,包括这块缓冲区。④ 子进程追加 CHILD、父进程追加 PARENT,各自在退出时刷缓冲区。⑤ 带 flush 参数时在 fork 前先 fflush,缓冲区为空,重复消失。
【底层机制透视】 关键在于缓冲区位于用户地址空间,而 fork 复制地址空间;File pos、页缓存等内核态资源不复制(只增加 refcnt)。所以”重复的是用户态缓冲的内容,共享的是内核态的文件位置“。stdout 连着终端时是行缓冲,每条 \n 立刻触发 write 清空缓冲区,fork 时缓冲区恰为空,陷阱不出现——这正是该 bug 只在重定向到文件或管道时冒出来的原因。
【内存布局 / 数据结构图解】
fork 之前 fork 之后
父进程地址空间 父进程 子进程(副本)
+---------------------+ +---------------------+ +---------------------+
| stdout FILE 结构 | | stdout FILE 结构 | | stdout FILE 结构 | <- 独立副本
| buf: "PARENT- | | buf: "PARENT- | | buf: "PARENT- |
| PREFIX...\n"| | PREFIX...\n"| | PREFIX...\n"|
| fd = 1 | | + "PARENT\n"| | + "CHILD\n" |
+---------------------+ +----------|----------+ +----------|----------+
| write(1,...) | write(1,...)
v v
[打开文件表项: 终端/文件 File pos 共享,refcnt=2]
两个进程的 write 都追加到同一个 File pos 之后
【实测验证】(真实输出)
$ ./forkbuf > noflush.out 2>&1 ; cat -A noflush.out
PARENT-PREFIX: pid=3591877$
CHILD: pid=3591878$
PARENT-PREFIX: pid=3591877$ <-- 重复!子进程刷出了 fork 前就存在的缓冲内容
PARENT: pid=3591877$
$ wc -l < noflush.out
4
$ ./forkbuf flush > flush.out 2>&1 ; cat flush.out
PARENT-PREFIX: pid=3591884
CHILD: pid=3591885
PARENT: pid=3591884
$ wc -l < flush.out
3
未 flush:4 行(PARENT-PREFIX 两次);fork 前 fflush:3 行。 修复手段优先级:① fork 前 fflush(NULL);② setvbuf(stdout, NULL, _IONBF, 0) 关掉缓冲;③ 只用 Unix I/O 的 write 做输出——这也正是 Shell Lab 中信号处理函数不能调 printf 的原因(既非 async-signal-safe,又引入缓冲困惑)。
18.3.4 补充实测:短计数、元数据、链接与目录
管道上的短计数(请求 4096 字节,实际只得到 5 字节):
$ ./shortread
requested 4096 bytes, got 5 -> short count? YES
second read returned 0 (0 means EOF, not error)
底层是管道时 read 给多少算多少,剩下的必须靠循环补齐;写端关闭后返回 0(EOF),不是 -1。
stat 元数据与文件类型(lstat + S_IS* 宏):
$ ./statinfo redirect.c
type : regular file (st_mode = 0100000)
perms : 644
st_size : 2429 bytes
st_ino : 504007666
st_nlink : 1
st_blksize/st_blocks: 4096 / 8
st_mtime : 2026-09-23 19:33:23
$ ./statinfo link-to-redirect.c # lstat 不跟随符号链接
type : symbolic link (st_mode = 0120000)
st_size : 10 bytes # 恰好是 "redirect.c" 的 10 个字符
硬链接 vs 符号链接(ln 与 ln -s):
$ stat -c '%n ino=%i nlink=%h' hard.txt hard2.txt sym.txt
hard.txt ino=504007647 nlink=2 <-- 同一 i-node,nlink 递增
hard2.txt ino=504007647 nlink=2 <-- 同一 i-node
sym.txt ino=504007648 nlink=1 <-- 独立的新文件,内容是路径串
目录遍历(opendir/readdir/closedir + mkdir/chdir/getcwd/rmdir):
$ ./listdir demo-dir
cwd = /tmp/csapp18
name d_ino size type
------------------------------------------------------
. 504007644 32 dir <-- "." 指向自身
.. 504007660 4096 dir <-- ".." 指向父目录
a.txt 504007645 0 file
b.bin 504007646 0 file
after chdir: cwd = /tmp/csapp18/demo-subdir
讲义 ffiles1.c 的复现(三次 open + dup2):
$ ./ffiles1
fd1=3 fd2=4 fd3=5 c1=a c2=a c3=b
三个独立 open 给出三个独立的 File pos;dup2(fd2, fd3) 让表项 3 与 4 指向同一打开文件表项,共享位置。于是 read(fd1) 从位置 0 读走 a,read(fd2) 从它自己的位置 0 读走 a,read(fd3) 与 fd2 共享位置故读走 b。与讲义答案 c1 = a, c2 = a, c3 = b 完全一致。
fork 共享偏移量(讲义 ffiles2.c,sleep 使某进程必定先读):
$ for i in 1 2 3; do ./ffiles2; done
Child: c1 = a, c2 = b <-- 子进程先读走 b
Parent: c1 = a, c2 = c <-- 父进程随后读走 c
Parent: c1 = a, c2 = b
Child: c1 = a, c2 = c
Child: c1 = a, c2 = b
Parent: c1 = a, c2 = c
两种次序都覆盖了讲义给出的答案;c1 总是 a(它在 fork 前就读好,被复制进两个地址空间),而 c2 在 b/c 之间取决于调度——共享 File pos 的铁证。
18.4 实验关联
L6 Shell Lab(tsh):
- tsh 官方规格写着”无需支持管道
\|或 I/O 重定向<>“,但重定向的机制正是本讲的dup2:真实 shell 在fork之后、execve之前完成open+dup2+close,因为execve会保留已打开的描述符。理解了”位置在打开文件表项里”,就能解释ls > foo.txt为何从文件头写起。 - Style 分要求检查每一个系统调用的返回值;信号处理函数里只能用 async-signal-safe 函数,
printf不安全,请用write。 - 隐性关联:
fork的输出重复(18.3.3)——父进程在fork前若留下未刷新的标准 I/O 缓冲,子进程会把它一起刷出来。
L7 Proxy Lab:本讲是它的直接技术前提。
- 解析 HTTP 请求:用
rio_readlineb(&rio, buf, MAXLINE)逐行读请求行与头部,因为 socket 上的read必然短计数,而”一行”多长事先未知。注意讲义的\r\n:HTTP 每行以 CR+LF 结束,整个请求以空行\r\n终止。 - 转发请求与响应:用
rio_writen(永不返回短计数),绝不要用fputs/fwrite(标准 I/O 不适合 socket 且非 async-signal-safe)。 - 判断本地文件是否存在、是否可读:用
stat+S_ISREG(Tiny 服务器判断静态文件),标准 I/O 根本不提供元数据接口。 - 响应体必须按
Content-length精确转发:这是 proxy 最容易出 bug 的地方。拿到Content-length: N后用rio_readnb(或循环rio_readn)恰好转发 $N$ 字节:少一字节客户端挂住或截断,多一字节污染下一个响应。 - 二进制内容:讲义警告”网上并非都是 ASCII 文本”,图片与视频都是二进制,不要用
rio_readlineb、strlen、strcpy处理响应体。 - 鲁棒性:忽略
SIGPIPE、优雅处理EPIPE与ECONNRESET;不要用csapp.c那套”出错即退出”的包装——代理一旦开始接受连接就不该终止(讲义:”Stevens 包装让代码变短,但不给你从错误中恢复的机会”)。 - fd 泄漏:
dup2后一定close多余 fd;每个连接处理完要关闭 clientfd 与 serverfd。
18.5 常见错误与调试技巧
- 忽略短计数:以为
write(fd, buf, n)一定写满n字节;磁盘上”碰巧”不出错,一上 socket/管道就丢数据。调试:strace -e trace=read,write -s 64 ./prog。 - 把 EOF 当成错误:写
while (read(fd, buf, n) != n)导致读到文件尾就报错退出。调试:记住三值语义——> 0正常(可能短)、= 0EOF(仅read)、< 0错误。 EINTR未重试:信号递达打断read/write,返回 -1 且errno == EINTR,被误判为致命错误。调试:加if (errno == EINTR) continue;;用strace -f -e trace=signal,read,write复现。- 双重 close / fd 泄漏:第二次
close可能关掉别的线程刚打开的同名 fd;dup2后忘close多余描述符则长运行进程耗尽 fd(ulimit -n通常 1024)。调试:valgrind --track-fds=yes ./prog、ls -l /proc/<pid>/fd/ \| wc -l观察是否随请求数单调增长。 - 混用标准 I/O 与 Unix I/O 导致错序:
printf的字节还在FILE*缓冲区,紧接的write已进内核,exit刷缓冲时前者反而后到。本讲实测顺序为2 → 1 → 3,每次printf后加fflush(stdout)即恢复1 → 2 → 3。调试:strace -e trace=write ./prog。 fork后标准 I/O 缓冲区被复制:只在重定向到文件时出现的”重复输出”。调试:fork前fflush(NULL);strace -f -e trace=write会看到同一段字节被写两次。open忘记第三参数 /mode错误:O_CREAT缺mode时新文件权限取栈上垃圾值(UB,实际结果依编译器/机器而异)。调试:stat -c %a检查权限;统一用DEFFILEMODE并注意umask。
18.6 关键要点
- 一切皆文件:文件就是字节序列 $B_0 \ldots B_{m-1}$,内核用同一套
open/read/write/close/stat/lseek服务磁盘、终端、管道、套接字乃至/proc。 - 短计数是常态而非错误:
read返回 0 才是 EOF,返回 -1(非EINTR)才是错误;rio_*四个函数就是为封装这些循环而生,网络 I/O 必须用它们。 - 文件位置在”打开文件表”里,不在描述符表里:因此
open两次得到两个独立偏移量,而fork与dup2让多个描述符共享同一偏移量——这一条同时解释ffiles1/ffiles2的输出与>重定向的语义。 dup2(oldfd, newfd)是重定向的全部秘密:它只改描述符表项,配合”open返回最小可用 fd”与”execve保留已打开 fd”,shell 才能实现ls > foo.txt。- 标准 I/O = fd + 用户态缓冲区:缓冲把上千次系统调用压成几次(实测 4161 → 5),代价是非 async-signal-safe、无元数据接口、不适合套接字,以及
fork后缓冲区被复制的重复输出。 FILE *与 fd 用fileno/fdopen互转;st_mode类型判断必须用S_ISREG/S_ISDIR/S_ISSOCK宏;硬链接共享 i-node,符号链接是独立的新文件。
18.7 思考题(带答案)
Q1(推演题):给定下面的程序,f1.txt、f2.txt 均初始不存在。请判断:两次 open 各返回什么 fd?最终哪些 fd 指向哪个文件?程序共输出几行、写到哪里?为什么?
#define _DEFAULT_SOURCE
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
int fd1 = open("f1.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644); /* 记为 a */
printf("hello from parent\n"); /* 未 fflush */
int fd2 = open("f2.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644); /* 记为 b */
dup2(fd2, STDOUT_FILENO);
close(fd2);
if (fork() == 0) {
printf("child\n");
return 0;
}
printf("parent\n");
wait(NULL);
return 0;
}
答:fd 从 3 开始分配,故 a = 3、b = 4。dup2(fd2, 1) 让描述符表项 1 改指向 f2.txt 的打开文件表项(refcnt 变 2),随后 close(fd2) 让 refcnt 回到 1;此时只有 fd 1 指向 f2.txt,fd 3 仍指向 f1.txt,但该文件上没有任何 write,故只是一具 0 字节空壳。printf("hello from parent\n") 的字节此时还在 stdout 用户态缓冲区里(fork 前未 fflush,stdout 指向磁盘文件故为全缓冲),而 fork 复制了这块缓冲区:子进程退出时刷出 hello from parent + child,父进程刷出 hello from parent + parent——共 4 行(hello 出现两次),全部写入 f2.txt,终端一行都没有。若在 fork 前加 fflush(NULL) 则只剩 3 行。(本机实测:终端无输出、f1.txt 恰 0 字节、f2.txt 恰上述 4 行。)
Q2(”直观但错误的想法”):有同学说:”我用 write(fd, buf, n) 写磁盘文件,返回值永远是 n,所以 Proxy Lab 里也可以直接对 socket 这么写,代码更简洁。”错在哪?
答:错在把”磁盘文件的实现特性”当成”write 的语义保证”。讲义明确区分:短计数不会发生在”写磁盘文件”和”读磁盘文件(除 EOF 外)”上,但会发生在”读写网络套接字、管道”以及”从终端读文本行、遇到 EOF”时。所以磁盘上”碰巧总是写满”是环境特性而非接口契约。转发大对象时 socket 发送缓冲区一满,write 就返回小于 n 的值,剩下字节静默丢失,响应体短于 Content-length。正确做法是 rio_writen(讲义保证它永不返回短计数)。
Q3(推演题):为什么讲义 ffiles2.c 中一定有一个进程读到 b、另一个读到 c,而 c1 却总是 a?
答:fd1 在 fork 之前 open,父子描述符表项指向同一个打开文件表项,共享同一个 File pos。read(fd1, &c1, 1) 也在 fork 之前,所以 c1 = 'a' 是被复制进两个地址空间的普通变量,必为 a。第二个 read 在 fork 之后:先执行者从共享位置 1 读走 b 并把位置推到 2,后执行者从位置 2 读走 c;sleep(s) 只决定先后。本机三次运行得到 Child=b/Parent=c、Parent=b/Child=c、Child=b/Parent=c,与讲义两种次序答案吻合。
Q4(计算题):某程序用 read(fd, buf, 1) 逐字节把 1 MiB($2^{20}$ 字节)文件复制到另一个磁盘文件,每个系统调用耗时 10,000 个时钟周期。改用 4096 字节缓冲区后,系统调用次数与耗时各降低多少倍?
答:逐字节方案共 $2^{20}$ 次 read 加 $2^{20}$ 次 write = $2{,}097{,}152$ 次系统调用,耗时约 $2^{21} \times 10^4 \approx 2.1 \times 10^{10}$ 个周期。4096 字节方案需 $2^{20}/2^{12} = 256$ 轮即 512 次调用,耗时约 $5.12 \times 10^{6}$ 个周期。两者都降低 $2^{12} = \mathbf{4096}$ 倍(即缓冲区增大的倍数)。18.3.1 的 strace 实测(4161 → 5)方向一致,只是文件仅 2079 字节,绝对次数远小于理论值。