10

系统级I/O

务必搞清楚你自己正在和整个系统的哪一层打交道

阅读量:7 · 预计 16 分钟读完

文件描述符RIO包I/O重定向
关联层级:L5 虚拟机代码
阅读进度3%

第10章:系统级I/O

务必搞清楚你自己正在和整个系统的哪一层打交道

导读

在本书的前面几章中,我们已经讨论了处理器、存储器层次结构、进程和虚拟内存。现在我们要面对的是另一个同样基础、但经常被忽视的主题:I/O(输入/输出)。

为什么说 I/O 经常被忽视?因为在现代编程中,我们习惯了使用高级的 I/O 库——C 标准库的 fopen/fread/fwrite、Java 的 InputStream、Python 的 open() 函数。这些库把底层的复杂性封装得干干净净,让我们可以专注于业务逻辑。然而,当你需要编写高性能的网络服务器、实现 I/O 重定向、理解管道的数据传输机制、或者调试一个"文件读不出来"的问题时,你必须回到最底层,理解 Unix 系统级 I/O 的工作原理。

本章的核心信息可以用一句话概括:在 Unix 的世界里,一切皆文件。 无论是磁盘上的普通文件、终端设备、网络套接字、还是管道,它们都通过统一的 I/O 接口来访问。这种统一性不仅简化了编程模型,还使得 I/O 重定向、管道组合等强大的功能成为可能。

我们将从 Unix I/O 模型的基本概念开始,逐步深入文件描述符、read/write 系统调用、RIO(Robust I/O)包的设计、I/O 重定向和管道。

核心概念详解

一、Unix I/O 模型

1.1 统一的 I/O 接口

Unix 系统对所有 I/O 设备(磁盘文件、终端、网络套接字、管道等)提供了统一的接口。无论是读一个文件还是从网络接收数据,程序员使用的系统调用都是相同的:open()read()write()close()

这种设计并非偶然。Unix 的创造者 Ken Thompson 和 Dennis Ritchie 在 1970 年代就认识到,如果为每种设备都设计一套不同的 I/O 接口,那么操作系统的复杂性将呈指数增长。通过抽象出一个统一的"文件"概念,所有设备都可以用相同的方式处理。这就是 Unix 哲学中"一切皆文件"的由来。

当然,"一切皆文件"是一种抽象,而非字面意义上的事实。不同类型的文件确实有不同的特性和行为——例如,终端文件不支持 lseek(),管道文件不支持 stat()。但在最基本的 I/O 操作(打开、读、写、关闭)层面,接口是统一的。

1.2 Unix I/O 的基本操作

Unix I/O 的核心操作只有四个:

`open()`:打开或创建一个文件,返回一个文件描述符(File Descriptor)。

`read()`:从文件描述符读取数据到用户缓冲区。

`write()`:从用户缓冲区向文件描述符写入数据。

`close()`:关闭文件描述符,释放相关资源。

这四个系统调用构成了所有 Unix I/O 的基础。即使是最高级的 I/O 库(如 C 标准库的 stdio),底层最终也是通过这四个系统调用来完成实际的 I/O 操作。

1.3 内核如何表示打开的文件

当进程打开一个文件时,内核会维护三个相关的数据结构:

描述符表(Descriptor Table):每个进程都有自己的描述符表,通过文件描述符(一个非负整数)索引。每个条目指向一个打开的文件。

文件表(File Table):所有打开的文件共享一个全局的文件表。每个条目包含文件偏移量、访问模式(读/写)、引用计数等信息。

v-node 表(v-node Table):每个文件对应一个 v-node(virtual node,类似于 inode)。v-node 包含文件的元信息(大小、类型、权限)和指向实际数据块的指针。

当两个进程打开同一个文件时,它们各自的文件描述符指向文件表中的不同条目,但这些条目最终都指向同一个 v-node。这意味着两个进程有各自独立的文件偏移量,但共享文件的元信息和数据。

1.4 文件偏移量

每个打开的文件都有一个当前的文件偏移量(File Position / File Offset),表示下一次 read()write() 操作将从文件的哪个位置开始。偏移量以字节为单位,初始值为 0(文件开头)。每次成功执行 read()write() 后,偏移量会自动增加读写的字节数。

可以使用 lseek() 系统调用来显式设置文件偏移量:

c
#include <unistd.h>
#include <stdio.h>

int main() {
    int fd = open("test.txt", O_RDWR);
    
    // 移动到文件开头
    lseek(fd, 0, SEEK_SET);
    
    // 移动到文件末尾
    lseek(fd, 0, SEEK_END);
    
    // 获取当前偏移量
    off_t pos = lseek(fd, 0, SEEK_CUR);
    printf("当前偏移量: %ld\n", pos);
    
    close(fd);
    return 0;
}

二、文件描述符

2.1 文件描述符的本质

文件描述符(File Descriptor,FD)是一个非负整数,是进程用来标识一个打开的文件的句柄。在 Unix 中,文件描述符通常从 0 开始递增:

  • 0:标准输入(stdin)
  • 1:标准输出(stdout)
  • 2:标准错误(stderr)
  • 3, 4, 5, ...:其他打开的文件

当进程调用 open() 时,内核会在进程的描述符表中找到最小的未使用的描述符编号,将其分配给新打开的文件,并返回这个编号。这就是为什么文件描述符总是从 3 开始(因为 0、1、2 已经被标准流占用)。

文件描述符的作用域是进程级别的。一个进程的文件描述符对其他进程不可见。但是,通过 fork() 创建的子进程会继承父进程的文件描述符表(指向相同的文件表条目),这就是管道和 I/O 重定向的基础。

2.2 文件描述符与 fork

当进程调用 fork() 时,子进程获得父进程描述符表的一个副本。关键的是,子进程的描述符表条目指向与父进程相同的文件表条目。这意味着:

  • 父子进程共享文件偏移量。如果父进程读取了一些数据,子进程的文件偏移量也会改变。
  • 父子进程的文件模式(读/写)相同。
  • 文件表条目的引用计数加 1。只有当所有引用都被关闭(通过 close())时,文件才会真正被关闭。
c
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>

int main() {
    int fd = open("shared.txt", O_RDWR | O_CREAT, 0644);
    write(fd, "Hello, World!\n", 14);
    
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程
        char buf[20];
        lseek(fd, 0, SEEK_SET);  // 子进程重置偏移量
        read(fd, buf, 5);
        buf[5] = '\0';
        printf("子进程读到: %s\n", buf);
        close(fd);
    } else {
        // 父进程
        wait(NULL);
        char buf[20];
        // 注意:父进程的偏移量不受子进程 lseek 的影响
        // 因为它们共享同一个文件表条目,子进程的 lseek
        // 实际上会影响父进程的偏移量!
        read(fd, buf, 5);
        buf[5] = '\0';
        printf("父进程读到: %s\n", buf);
        close(fd);
    }
    return 0;
}

这个例子说明了 fork 后文件描述符的共享语义——父子进程共享文件偏移量,一个进程的 lseek 会影响另一个进程。

2.3 文件描述符与 exec

fork 不同,exec 系列函数(如 execve默认保留文件描述符。这意味着通过 exec 加载的新程序仍然可以访问之前打开的文件。但是,如果文件描述符被设置了 O_CLOEXEC(close-on-exec)标志,则在 exec 时会自动关闭。

这个特性对于 shell 实现 I/O 重定向至关重要:shell 先 fork 一个子进程,在子进程中打开目标文件、关闭标准输出、dup2 重定向,然后 exec 执行命令——命令的标准输出就被重定向到了文件。

三、read 和 write 系统调用

3.1 read 系统调用

c
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);

read() 从文件描述符 fd 指向的文件中,最多读取 count 字节的数据到缓冲区 buf 中。返回值是实际读取的字节数,可能小于 count(原因见下文)。返回 0 表示到达文件末尾(EOF)。返回 -1 表示出错。

为什么 `read` 返回的字节数可能小于请求的 `count`?

这是一个非常重要的问题,也是许多初学者的困惑。read 返回少于 count 字节的原因包括:

读取普通文件时到达 EOF:如果文件中从当前位置到末尾只有 50 字节,而你请求读取 100 字节,read 只返回 50。下一次 read 返回 0。

从终端读取:终端的 read 通常以行为单位,一次 read 最多返回一行数据。

从网络套接字读取:网络数据的到达是异步的,read 只返回当前已经到达的数据,可能远少于请求量。

信号中断:如果在 read 执行期间收到信号,可能只读取了部分数据就返回。

因此,一个健壮的读取循环应该是:

c
ssize_t rio_readn(int fd, void *usrbuf, size_t n) {
    char *bufp = usrbuf;
    size_t nleft = n;
    
    while (nleft > 0) {
        ssize_t nread = read(fd, bufp, nleft);
        if (nread < 0) {
            if (errno == EINTR) {
                continue;  // 被信号中断,重试
            }
            return -1;  // 真正的错误
        } else if (nread == 0) {
            break;  // EOF
        }
        nleft -= nread;
        bufp += nread;
    }
    return n - nleft;
}

3.2 write 系统调用

c
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);

write() 从缓冲区 buf 中取出 count 字节的数据,写入文件描述符 fd 指向的文件。返回值是实际写入的字节数。

read 类似,write 返回的字节数也可能小于 count。常见原因包括:

磁盘空间不足:当磁盘已满时,write 可能只写入部分数据。

进程资源限制:如 ulimit -f 设置的文件大小限制。

管道或套接字缓冲区满:当写入管道或套接字时,如果缓冲区已满,write 可能只写入部分数据(对于阻塞 I/O,也可能阻塞等待空间可用)。

健壮的写入循环:

c
ssize_t rio_writen(int fd, void *usrbuf, size_t n) {
    char *bufp = usrbuf;
    size_t nleft = n;
    
    while (nleft > 0) {
        ssize_t nwritten = write(fd, bufp, nleft);
        if (nwritten < 0) {
            if (errno == EINTR) {
                continue;
            }
            return -1;
        }
        nleft -= nwritten;
        bufp += nwritten;
    }
    return n;
}

注意:对于普通磁盘文件,write 通常写入所有请求的字节(除非磁盘满等异常情况),所以短写入主要发生在网络套接字和管道上。

3.3 系统调用的开销

每次 readwrite 系统调用都会导致一次上下文切换——从用户态切换到内核态,执行完系统调用后再切换回用户态。这个切换的开销虽然不大(大约几百到几千个时钟周期),但在高频 I/O 场景中会累积成显著的性能瓶颈。

这就是为什么用户级 I/O 缓冲(如 C 标准库的 stdio)如此重要:它在用户空间维护一个缓冲区,减少对底层系统调用的调用次数。例如,fgetc() 一次系统调用读取一大块数据到缓冲区,后续的 fgetc() 直接从缓冲区返回,无需系统调用。

四、RIO 包(Robust I/O)

4.1 为什么需要 RIO 包

如前所述,readwrite 系统调用可能返回少于请求的字节数(称为"短计数",short count)。在大多数应用程序中,我们需要确保读取或写入了完整的数据量。如果每次都手动编写循环来处理短计数,代码会变得冗长且容易出错。

CSAPP 教材引入了 RIO(Robust I/O)包来解决这个问题。RIO 包提供两组函数:

  • 无缓冲的 I/O:直接在用户缓冲区和文件之间传输数据,处理短计数。
  • 带缓冲的 I/O:在进程地址空间中维护一个缓冲区,减少系统调用次数,适用于从文件描述符读取文本行的场景。

4.2 无缓冲的 RIO 函数

c
#include <unistd.h>
#include <errno.h>

// 从 fd 读取恰好 n 字节到 usrbuf
// 返回实际读取的字节数(只有在 EOF 时才可能小于 n)
ssize_t rio_readn(int fd, void *usrbuf, size_t n) {
    char *bufp = usrbuf;
    size_t nleft = n;
    
    while (nleft > 0) {
        ssize_t nread = read(fd, bufp, nleft);
        if (nread < 0) {
            if (errno == EINTR) {
                nread = read(fd, bufp, nleft);
                continue;
            }
            return -1;
        } else if (nread == 0) {
            break;
        }
        nleft -= nread;
        bufp += nread;
    }
    return (n - nleft);
}

// 向 fd 写入恰好 n 字节
// 返回 n(如果成功)或 -1(如果出错)
ssize_t rio_writen(int fd, void *usrbuf, size_t n) {
    char *bufp = usrbuf;
    size_t nleft = n;
    
    while (nleft > 0) {
        ssize_t nwritten = write(fd, bufp, nleft);
        if (nwritten <= 0) {
            if (errno == EINTR) {
                continue;
            }
            return -1;
        }
        nleft -= nwritten;
        bufp += nwritten;
    }
    return n;
}

rio_readnrio_writen 的核心思想很简单:用一个循环不断调用 read/write,直到传输了所有请求的字节。唯一的特殊情况是 rio_readn 在遇到 EOF 时返回已读取的字节数(可能小于 n),而 rio_writen 在成功时总是返回 n。

4.3 带缓冲的 RIO 函数

带缓冲的 RIO 函数适用于需要逐行读取文本的场景(如 HTTP 请求头解析)。它们在进程地址空间中维护一个读缓冲区,每次系统调用读取一大块数据到缓冲区,后续的读取操作直接从缓冲区返回,直到缓冲区耗尽才再次调用系统调用。

c
#define RIO_BUFSIZE 8192

typedef struct {
    int rio_fd;                  // 文件描述符
    int rio_cnt;                 // 缓冲区中未读的字节数
    char *rio_bufptr;            // 指向下一个未读字节
    char rio_buf[RIO_BUFSIZE];   // 读缓冲区
} rio_t;

void rio_readinitb(rio_t *rp, int fd) {
    rp->rio_fd = fd;
    rp->rio_cnt = 0;
    rp->rio_bufptr = rp->rio_buf;
}

// 内部函数:从底层文件描述符填充缓冲区
static ssize_t rio_read(rio_t *rp, char *usrbuf, size_t n) {
    int cnt;
    
    while (rp->rio_cnt <= 0) {
        // 缓冲区为空,重新填充
        rp->rio_cnt = read(rp->rio_fd, rp->rio_buf, sizeof(rp->rio_buf));
        if (rp->rio_cnt < 0) {
            if (errno != EINTR) return -1;
        } else if (rp->rio_cnt == 0) {
            return 0;  // EOF
        } else {
            rp->rio_bufptr = rp->rio_buf;
        }
    }
    
    // 从缓冲区拷贝数据到用户缓冲区
    cnt = n;
    if (rp->rio_cnt < (int)n) {
        cnt = rp->rio_cnt;
    }
    memcpy(usrbuf, rp->rio_bufptr, cnt);
    rp->rio_bufptr += cnt;
    rp->rio_cnt -= cnt;
    return cnt;
}

// 从 rp 读取一行文本(以 '\n' 结尾)到 usrbuf
// 最多读取 maxlen-1 个字符,自动添加 '\0'
ssize_t rio_readlineb(rio_t *rp, char *usrbuf, int maxlen) {
    int n, rc;
    char c, *bufp = usrbuf;
    
    for (n = 1; n < maxlen; n++) {
        if ((rc = rio_read(rp, &c, 1)) == 1) {
            *bufp++ = c;
            if (c == '\n') {
                n++;
                break;
            }
        } else if (rc == 0) {
            if (n == 1) return 0;  // EOF,没有数据
            else break;
        } else {
            return -1;  // 错误
        }
    }
    *bufp = '\0';
    return n - 1;
}

// 从 rp 读取恰好 n 字节
ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n) {
    size_t nleft = n;
    char *bufp = usrbuf;
    
    while (nleft > 0) {
        ssize_t rc = rio_read(rp, bufp, nleft);
        if (rc < 0) return -1;
        else if (rc == 0) break;
        nleft -= rc;
        bufp += rc;
    }
    return n - nleft;
}

RIO 缓冲区的核心思想与 C 标准库的 FILE 缓冲区类似,但 RIO 包更简单、更透明,适合教学和理解底层原理。在实际的网络服务器编程中,这种带缓冲的读取模式非常常见——例如,HTTP 服务器需要从套接字逐行读取请求头,直到遇到空行。

4.4 RIO 包的使用示例

c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>

// 假设 RIO 函数已在其他地方定义

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "用法: %s <文件路径>\n", argv[0]);
        return 1;
    }
    
    int fd = open(argv[1], O_RDONLY);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    
    rio_t rio;
    rio_readinitb(&rio, fd);
    
    char line[1024];
    int line_num = 1;
    
    while (rio_readlineb(&rio, line, sizeof(line)) > 0) {
        printf("%4d: %s", line_num++, line);
    }
    
    close(fd);
    return 0;
}

这个程序实现了一个简单的文件行号打印工具(类似 cat -n),展示了 RIO 带缓冲读取的典型用法。

五、I/O 重定向

5.1 dup 和 dup2

I/O 重定向的核心是 dup()dup2() 系统调用:

c
#include <unistd.h>

int dup(int oldfd);
int dup2(int oldfd, int newfd);
  • dup(oldfd):创建 oldfd 的一个副本,返回新的文件描述符(使用最小的未使用编号)。
  • dup2(oldfd, newfd):将 newfd 变成 oldfd 的副本。如果 newfd 已经打开,先关闭它。返回 newfd

dup2 是 I/O 重定向的关键。通过将文件描述符 1(标准输出)重定向到一个文件,后续所有对标准输出的写入都会被写入该文件。

5.2 Shell 的 I/O 重定向实现

当你在 shell 中执行 ls > output.txt 时,shell 内部做了以下操作:

c
pid_t pid = Fork();
if (pid == 0) {
    // 子进程
    int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    dup2(fd, 1);    // 将 stdout (fd=1) 重定向到文件
    close(fd);       // 关闭不再需要的原始 fd
    execve("/bin/ls", args, envp);  // 执行 ls 命令
}
waitpid(pid, NULL, 0);

关键步骤是 dup2(fd, 1):它将文件描述符 1(标准输出)关闭,然后用 fd(指向 output.txt)的副本替换它。此后,ls 命令向标准输出写入的所有数据都会被写入 output.txt

类似地,< input.txt 使用 dup2(fd, 0) 将标准输入重定向到文件,2> error.log 使用 dup2(fd, 2) 将标准错误重定向到文件。

5.3 管道(pipe)

管道是 Unix 中进程间通信(IPC)的基本机制。管道允许一个进程将数据发送给另一个进程,就像将一根水管连接两个进程一样。

c
#include <unistd.h>

int pipe(int fd[2]);

pipe() 创建一个管道,返回两个文件描述符:fd[0] 用于读取(管道读端),fd[1] 用于写入(管道写端)。写入 fd[1] 的数据可以从 fd[0] 读取。

管道通常与 fork() 结合使用,实现父子进程之间的通信:

c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>

int main() {
    int pipefd[2];
    char buf[128];
    
    if (pipe(pipefd) < 0) {
        perror("pipe");
        return 1;
    }
    
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:写入管道
        close(pipefd[0]);  // 关闭读端
        const char *msg = "Hello from child process!";
        write(pipefd[1], msg, strlen(msg));
        close(pipefd[1]);  // 关闭写端,通知读进程 EOF
        exit(0);
    } else {
        // 父进程:从管道读取
        close(pipefd[1]);  // 关闭写端
        int n = read(pipefd[0], buf, sizeof(buf) - 1);
        if (n > 0) {
            buf[n] = '\0';
            printf("父进程收到: %s\n", buf);
        }
        close(pipefd[0]);
        waitpid(pid, NULL, 0);
    }
    return 0;
}

5.4 Shell 管道的实现

当你在 shell 中执行 cmd1 | cmd2 时,shell 创建了一个管道来连接两个命令的标准输出和标准输入:

c
int pipefd[2];
pipe(pipefd);

pid_t pid1 = fork();
if (pid1 == 0) {
    // cmd1 的子进程
    dup2(pipefd[1], 1);   // stdout → 管道写端
    close(pipefd[0]);      // 关闭不需要的读端
    close(pipefd[1]);      // 关闭原始写端
    execve("/path/to/cmd1", ...);
}

pid_t pid2 = fork();
if (pid2 == 0) {
    // cmd2 的子进程
    dup2(pipefd[0], 0);   // stdin → 管道读端
    close(pipefd[1]);      // 关闭不需要的写端
    close(pipefd[0]);      // 关闭原始读端
    execve("/path/to/cmd2", ...);
}

// 父进程关闭两端,等待子进程
close(pipefd[0]);
close(pipefd[1]);
waitpid(pid1, NULL, 0);
waitpid(pid2, NULL, 0);

这就是 Unix 管道 | 操作符的底层实现。cmd1 的标准输出被重定向到管道的写端,cmd2 的标准输入被重定向到管道的读端。数据从 cmd1 的输出流入管道,再从管道流入 cmd2 的输入。

5.5 管道的特性

管道有几个重要的特性:

单向通信:数据只能从写端流向读端。如果需要双向通信,需要创建两个管道。

有限容量:管道在内核中有一个固定大小的缓冲区(在 Linux 上通常为 64KB)。如果管道已满,write 会阻塞;如果管道为空,read 会阻塞。

EOF 检测:当所有写端都被关闭后,read 返回 0(EOF)。这就是为什么在管道操作中,不使用的管道端必须关闭——否则读端永远不会收到 EOF。

原子性:对于小于 PIPE_BUF(Linux 上为 4096 字节)的写入,管道保证原子性——数据不会被其他进程的写入穿插。

六、标准 I/O 与系统级 I/O 的关系

6.1 C 标准 I/O 库

C 标准 I/O 库(stdio.h)在 Unix 系统级 I/O 之上提供了一个更高级的缓冲层。FILE * 类型的流(stream)封装了文件描述符和一个用户级缓冲区:

应用程序
  ↓
stdio 缓冲层(用户空间)
  ↓
Unix I/O 系统调用(内核空间)
  ↓
VFS → 文件系统 → 设备驱动
  ↓
磁盘/网络/终端

标准 I/O 库的主要优势在于:

  • 缓冲:减少了系统调用的次数,提高性能。
  • 跨平台:相同的 API 可以在不同的操作系统上使用。
  • 格式化 I/Oprintf/scanf 等函数提供了强大的格式化功能。

6.2 标准 I/O 的缓冲策略

标准 I/O 库支持三种缓冲策略:

  • 全缓冲(Full Buffering):只有当缓冲区满时才执行系统调用。适用于磁盘文件。默认缓冲区大小通常为 4KB 或 8KB。
  • 行缓冲(Line Buffering):当遇到换行符或缓冲区满时执行系统调用。适用于终端(标准输出连接到终端时默认使用行缓冲)。
  • 无缓冲(No Buffering):每次写操作都立即执行系统调用。适用于标准错误(stderr),确保错误信息立即显示。

可以使用 setvbuf() 函数修改缓冲策略:

c
char buf[8192];
setvbuf(stdout, buf, _IOFBF, sizeof(buf));  // 全缓冲,8KB
setvbuf(stdout, NULL, _IOLBF, 0);           // 行缓冲
setvbuf(stderr, NULL, _IONBF, 0);           // 无缓冲

6.3 混合使用标准 I/O 和 Unix I/O 的危险

一个常见的错误是在同一个文件描述符上混合使用标准 I/O 函数(如 fread)和 Unix I/O 函数(如 read)。这样做可能导致数据丢失或混乱,因为标准 I/O 库维护了自己的缓冲区,与内核的文件偏移量可能不同步。

例如:

c
int fd = open("data.txt", O_RDONLY);
FILE *fp = fdopen(fd, "r");

char buf1[100];
fread(buf1, 1, 100, fp);   // stdio 可能读取了 4KB 到内部缓冲区
                             // 但只返回 100 字节给用户
                             // 文件偏移量已经被推进了 4KB

char buf2[100];
read(fd, buf2, 100);       // 从文件偏移量 4KB 处开始读取
                             // 跳过了 buf1 之后到 4KB 之间的数据!

规则:永远不要在同一个文件上混合使用标准 I/O 和 Unix I/O。 如果必须混用,使用 fileno(fp) 获取文件描述符后,只用一种方式操作。

代码示例

示例一:实现简单的 Shell,支持管道和重定向

c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <errno.h>

#define MAXLINE 1024
#define MAXARGS 128

// 解析命令行,处理管道
void parse_and_execute(char *cmdline) {
    char *pipe_pos = strchr(cmdline, '|');
    
    if (pipe_pos == NULL) {
        // 没有管道,直接执行
        execute_simple(cmdline);
    } else {
        // 有管道,分割命令
        *pipe_pos = '\0';
        char *cmd1 = cmdline;
        char *cmd2 = pipe_pos + 1;
        
        // 去除首尾空格
        while (*cmd2 == ' ') cmd2++;
        
        int pipefd[2];
        pipe(pipefd);
        
        pid_t pid1 = fork();
        if (pid1 == 0) {
            dup2(pipefd[1], STDOUT_FILENO);
            close(pipefd[0]);
            close(pipefd[1]);
            execute_simple(cmd1);
        }
        
        pid_t pid2 = fork();
        if (pid2 == 0) {
            dup2(pipefd[0], STDIN_FILENO);
            close(pipefd[1]);
            close(pipefd[0]);
            execute_simple(cmd2);
        }
        
        close(pipefd[0]);
        close(pipefd[1]);
        waitpid(pid1, NULL, 0);
        waitpid(pid2, NULL, 0);
    }
}

// 执行简单命令(无管道)
void execute_simple(char *cmdline) {
    char *argv[MAXARGS];
    char *infile = NULL, *outfile = NULL;
    
    // 解析参数和重定向
    int argc = 0;
    char *token = strtok(cmdline, " \t\n");
    while (token != NULL) {
        if (strcmp(token, "<") == 0) {
            infile = strtok(NULL, " \t\n");
        } else if (strcmp(token, ">") == 0) {
            outfile = strtok(NULL, " \t\n");
        } else {
            argv[argc++] = token;
        }
        token = strtok(NULL, " \t\n");
    }
    argv[argc] = NULL;
    
    if (argc == 0) return;
    
    pid_t pid = fork();
    if (pid == 0) {
        // I/O 重定向
        if (infile) {
            int fd = open(infile, O_RDONLY);
            if (fd < 0) { perror("open"); exit(1); }
            dup2(fd, STDIN_FILENO);
            close(fd);
        }
        if (outfile) {
            int fd = open(outfile, O_WRONLY | O_CREAT | O_TRUNC, 0644);
            if (fd < 0) { perror("open"); exit(1); }
            dup2(fd, STDOUT_FILENO);
            close(fd);
        }
        
        if (execvp(argv[0], argv) < 0) {
            fprintf(stderr, "%s: command not found\n", argv[0]);
            exit(127);
        }
    }
    waitpid(pid, NULL, 0);
}

int main() {
    char cmdline[MAXLINE];
    
    while (1) {
        printf("myshell> ");
        fflush(stdout);
        
        if (fgets(cmdline, MAXLINE, stdin) == NULL)
            break;
        
        cmdline[strlen(cmdline) - 1] = '\0';  // 去掉换行符
        
        if (strcmp(cmdline, "quit") == 0)
            break;
        
        parse_and_execute(cmdline);
    }
    return 0;
}

这个简化的 shell 实现了命令执行、I/O 重定向(<>)和管道(|),展示了系统级 I/O 在实际应用中的综合运用。

示例二:使用 RIO 包实现文件拷贝

c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>

#define RIO_BUFSIZE 8192

typedef struct {
    int rio_fd;
    int rio_cnt;
    char *rio_bufptr;
    char rio_buf[RIO_BUFSIZE];
} rio_t;

void rio_readinitb(rio_t *rp, int fd) {
    rp->rio_fd = fd;
    rp->rio_cnt = 0;
    rp->rio_bufptr = rp->rio_buf;
}

static ssize_t rio_read(rio_t *rp, char *usrbuf, size_t n) {
    int cnt;
    while (rp->rio_cnt <= 0) {
        rp->rio_cnt = read(rp->rio_fd, rp->rio_buf, sizeof(rp->rio_buf));
        if (rp->rio_cnt < 0) {
            if (errno != EINTR) return -1;
        } else if (rp->rio_cnt == 0) {
            return 0;
        } else {
            rp->rio_bufptr = rp->rio_buf;
        }
    }
    cnt = n;
    if (rp->rio_cnt < (int)n) cnt = rp->rio_cnt;
    memcpy(usrbuf, rp->rio_bufptr, cnt);
    rp->rio_bufptr += cnt;
    rp->rio_cnt -= cnt;
    return cnt;
}

ssize_t rio_readlineb(rio_t *rp, char *usrbuf, int maxlen) {
    int n, rc;
    char c, *bufp = usrbuf;
    for (n = 1; n < maxlen; n++) {
        if ((rc = rio_read(rp, &c, 1)) == 1) {
            *bufp++ = c;
            if (c == '\n') { n++; break; }
        } else if (rc == 0) {
            if (n == 1) return 0;
            else break;
        } else {
            return -1;
        }
    }
    *bufp = 0;
    return n - 1;
}

ssize_t rio_writen(int fd, void *usrbuf, size_t n) {
    char *bufp = usrbuf;
    size_t nleft = n;
    while (nleft > 0) {
        ssize_t nwritten = write(fd, bufp, nleft);
        if (nwritten <= 0) {
            if (errno == EINTR) continue;
            return -1;
        }
        nleft -= nwritten;
        bufp += nwritten;
    }
    return n;
}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        fprintf(stderr, "用法: %s <源文件> <目标文件>\n", argv[0]);
        return 1;
    }
    
    int src_fd = open(argv[1], O_RDONLY);
    if (src_fd < 0) { perror("open source"); return 1; }
    
    int dst_fd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (dst_fd < 0) { perror("open dest"); close(src_fd); return 1; }
    
    rio_t rio;
    rio_readinitb(&rio, src_fd);
    
    char line[MAXLINE];
    while (rio_readlineb(&rio, line, sizeof(line)) > 0) {
        rio_writen(dst_fd, line, strlen(line));
    }
    
    close(src_fd);
    close(dst_fd);
    printf("文件拷贝完成: %s -> %s\n", argv[1], argv[2]);
    return 0;
}

示例三:观察文件描述符的共享

c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>

int main() {
    int fd = open("test.txt", O_RDWR | O_CREAT | O_TRUNC, 0644);
    if (fd < 0) { perror("open"); return 1; }
    
    write(fd, "ABCDEFGHIJ", 10);
    
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程
        char buf[5];
        sleep(1);  // 等待父进程先写
        read(fd, buf, 5);
        buf[5] = '\0';
        printf("子进程读取: %s\n", buf);
        
        off_t pos = lseek(fd, 0, SEEK_CUR);
        printf("子进程偏移量: %ld\n", pos);
        close(fd);
    } else {
        // 父进程
        write(fd, "KLMNO", 5);
        
        off_t pos = lseek(fd, 0, SEEK_CUR);
        printf("父进程偏移量: %ld\n", pos);
        
        waitpid(pid, NULL, 0);
        
        // 验证:父子进程共享偏移量
        pos = lseek(fd, 0, SEEK_CUR);
        printf("父进程最终偏移量: %ld\n", pos);
        close(fd);
    }
    return 0;
}

这个程序清晰地展示了 fork 后父子进程共享文件偏移量的行为。父进程写入数据后,偏移量前进;子进程读取数据时,从共享的偏移量位置开始。

实验解读

实验:Shell Lab

CSAPP 的 Shell Lab 要求学生实现一个支持作业控制的小型 Unix shell。这个实验的核心挑战在于:

进程管理:使用 forkexecwaitpid 创建和管理子进程。

信号处理:正确处理 SIGINT(Ctrl+C)和 SIGTSTP(Ctrl+Z),将它们转发给前台进程组。

I/O 重定向:实现 <> 重定向,这需要使用 dup2 系统调用。

作业控制:支持前台和后台作业,实现 jobsfgbg 等内置命令。

这个实验让你亲身体验到 shell 是如何利用系统级 I/O 来实现命令行接口的。特别是 I/O 重定向的实现,让你深刻理解文件描述符表、dup2exec 之间的协作关系。

实验:Proxy Lab

Proxy Lab 要求学生实现一个简单的 HTTP 代理服务器。这个实验的核心在于:

网络 I/O:使用 read/write 在客户端和服务器之间转发数据。

RIO 包:使用 RIO 带缓冲的函数逐行读取 HTTP 请求头。

并发处理:处理多个客户端连接(虽然基础版本可能是串行的)。

这个实验展示了系统级 I/O 在网络编程中的应用。你会发现,网络套接字的 I/O 和文件的 I/O 使用完全相同的系统调用,这正是 Unix "一切皆文件" 哲学的体现。同时,你也会深刻体会到短计数(short count)在网络 I/O 中的普遍性——网络数据的到达是异步的、不可预测的,read 返回的字节数经常少于请求量。

延伸阅读

《UNIX 环境高级编程》(APUE)第 1-3 章:对 Unix I/O 模型有最权威、最详细的讲解,包括文件描述符、文件共享、原子操作、dup/fcntl 等。

《Linux/UNIX 系统编程手册》(TLPI)第 4-5 章:详细讲解了 Unix I/O 系统调用,包括 open/read/write/close 的完整语义、文件偏移量、O_APPEND 的原子性等。

《UNIX 网络编程 卷1》(UNP)第 1-6 章:虽然主题是网络编程,但前几章对 I/O 模型(阻塞/非阻塞、I/O 多路复用)有深入讨论。

Rob Pike 的论文 "The UNIX System: Streams":介绍了 System V Streams 机制,是理解 Unix I/O 演进的重要历史文献。

Linux 内核源码 `fs/read_write.c`readwrite 系统调用的内核实现。阅读源码可以理解 VFS 层如何将系统调用路由到具体的文件系统。

Brendan Gregg 的 "Systems Performance" 第 3 章:从性能工程的角度分析 I/O 子系统,包括文件系统、块设备层、I/O 调度器的性能特征。

论文 "An Inquiry Into Sequential File Access" (Teorey & Pinkerton, 1976):关于顺序文件访问性能优化的经典论文,是理解操作系统文件缓冲和预读策略的起点。