Lecture 18: 系统级 I/O 与文件系统 (System-Level I/O and File Systems)

目录 · ← l17 · l19 →

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);
}

神秘的第三个参数flagsO_CREAT 时第三参数必须存在,否则被忽略。mode 是新建文件的默认权限,会被 umask 修正;讲义建议无特殊理由就用 sys/stat.hDEFFILEMODE0666)。

关闭文件不要关闭同一个 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 表示 EOFwrite 没有 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 / EOFContent-length 指定长度的响应体

关键约束:rio_readlinebrio_readnb 可在同一描述符上任意交错且线程安全(每个 fd 一个 rio_t);但不要与无缓冲的 rio_readn 混用(缓冲区里的预读字节会被越过)。而 rio_readnrio_writen 之间可任意交错。

二进制警告:处理二进制文件(图像、视频)时绝不能用文本导向函数(fgetsscanfrio_readlineb,会解释 EOL 字符),也绝不能用字符串函数(strlenstrcpy,把字节 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_ISREG0100000)、S_ISDIR0040000)、S_ISSOCK0140000)、S_ISLNK0120000)、S_ISFIFOS_ISCHRS_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 已指向同一打开文件表项refcntdup2 变成 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/fclosefread/fwritefgets/fputsfscanf/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/putcfgets 逐字符读到换行),而每次 read/write> 10,000 时钟周期。于是标准 I/O 用 read 一次抓一大块进缓冲区,用户函数逐字节取用,空了再补。缓冲区在遇到 "\n"fflushexit、或从 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_*libeventlibuv);③ 极少数需要绝对最高性能的场合。

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-nodest_ino 相同、st_nlink 变为 2),删掉任一名字数据仍在;ln -s a c 创建新文件,内容是目标路径串(st_ino 不同、st_nlink 为 1、st_size 等于串长),目标被删即成悬空链接。unlink 删除目录项,st_nlink 减到 0 时数据块才回收。
  • 目录操作 APIopendir/readdir/closedir 遍历目录,struct direntd_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 posls > 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 */
}

【代码做什么?】setvbufstdout 强制设为全缓冲(重定向到文件时这是默认行为)。② 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 行。 修复手段优先级:① forkfflush(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 符号链接lnln -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 posdup2(fd2, fd3) 让表项 3 与 4 指向同一打开文件表项,共享位置。于是 read(fd1) 从位置 0 读走 aread(fd2) 从它自己的位置 0 读走 aread(fd3) 与 fd2 共享位置故读走 b与讲义答案 c1 = a, c2 = a, c3 = b 完全一致。

fork 共享偏移量(讲义 ffiles2.csleep 使某进程必定先读):

$ 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 前就读好,被复制进两个地址空间),而 c2b/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_readlinebstrlenstrcpy 处理响应体。
  • 鲁棒性:忽略 SIGPIPE、优雅处理 EPIPEECONNRESET不要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 正常(可能短)、= 0 EOF(仅 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 ./progls -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 缓冲区被复制:只在重定向到文件时出现的”重复输出”。调试forkfflush(NULL)strace -f -e trace=write 会看到同一段字节被写两次。
  • open 忘记第三参数 / mode 错误O_CREATmode 时新文件权限取栈上垃圾值(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 两次得到两个独立偏移量,而 forkdup2 让多个描述符共享同一偏移量——这一条同时解释 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.txtf2.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 = 4dup2(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

fd1fork 之前 open,父子描述符表项指向同一个打开文件表项共享同一个 File posread(fd1, &c1, 1) 也在 fork 之前,所以 c1 = 'a' 是被复制进两个地址空间的普通变量,必为 a。第二个 readfork 之后:先执行者从共享位置 1 读走 b 并把位置推到 2,后执行者从位置 2 读走 csleep(s) 只决定先后。本机三次运行得到 Child=b/Parent=cParent=b/Child=cChild=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 字节,绝对次数远小于理论值。