第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() 系统调用来显式设置文件偏移量:
#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())时,文件才会真正被关闭。
#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 系统调用
#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 执行期间收到信号,可能只读取了部分数据就返回。
因此,一个健壮的读取循环应该是:
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 系统调用
#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,也可能阻塞等待空间可用)。
健壮的写入循环:
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 系统调用的开销
每次 read 或 write 系统调用都会导致一次上下文切换——从用户态切换到内核态,执行完系统调用后再切换回用户态。这个切换的开销虽然不大(大约几百到几千个时钟周期),但在高频 I/O 场景中会累积成显著的性能瓶颈。
这就是为什么用户级 I/O 缓冲(如 C 标准库的 stdio)如此重要:它在用户空间维护一个缓冲区,减少对底层系统调用的调用次数。例如,fgetc() 一次系统调用读取一大块数据到缓冲区,后续的 fgetc() 直接从缓冲区返回,无需系统调用。
四、RIO 包(Robust I/O)
4.1 为什么需要 RIO 包
如前所述,read 和 write 系统调用可能返回少于请求的字节数(称为"短计数",short count)。在大多数应用程序中,我们需要确保读取或写入了完整的数据量。如果每次都手动编写循环来处理短计数,代码会变得冗长且容易出错。
CSAPP 教材引入了 RIO(Robust I/O)包来解决这个问题。RIO 包提供两组函数:
- 无缓冲的 I/O:直接在用户缓冲区和文件之间传输数据,处理短计数。
- 带缓冲的 I/O:在进程地址空间中维护一个缓冲区,减少系统调用次数,适用于从文件描述符读取文本行的场景。
4.2 无缓冲的 RIO 函数
#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_readn 和 rio_writen 的核心思想很简单:用一个循环不断调用 read/write,直到传输了所有请求的字节。唯一的特殊情况是 rio_readn 在遇到 EOF 时返回已读取的字节数(可能小于 n),而 rio_writen 在成功时总是返回 n。
4.3 带缓冲的 RIO 函数
带缓冲的 RIO 函数适用于需要逐行读取文本的场景(如 HTTP 请求头解析)。它们在进程地址空间中维护一个读缓冲区,每次系统调用读取一大块数据到缓冲区,后续的读取操作直接从缓冲区返回,直到缓冲区耗尽才再次调用系统调用。
#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 包的使用示例
#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() 系统调用:
#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 内部做了以下操作:
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)的基本机制。管道允许一个进程将数据发送给另一个进程,就像将一根水管连接两个进程一样。
#include <unistd.h>
int pipe(int fd[2]);pipe() 创建一个管道,返回两个文件描述符:fd[0] 用于读取(管道读端),fd[1] 用于写入(管道写端)。写入 fd[1] 的数据可以从 fd[0] 读取。
管道通常与 fork() 结合使用,实现父子进程之间的通信:
#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 创建了一个管道来连接两个命令的标准输出和标准输入:
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/O:
printf/scanf等函数提供了强大的格式化功能。
6.2 标准 I/O 的缓冲策略
标准 I/O 库支持三种缓冲策略:
- 全缓冲(Full Buffering):只有当缓冲区满时才执行系统调用。适用于磁盘文件。默认缓冲区大小通常为 4KB 或 8KB。
- 行缓冲(Line Buffering):当遇到换行符或缓冲区满时执行系统调用。适用于终端(标准输出连接到终端时默认使用行缓冲)。
- 无缓冲(No Buffering):每次写操作都立即执行系统调用。适用于标准错误(stderr),确保错误信息立即显示。
可以使用 setvbuf() 函数修改缓冲策略:
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 库维护了自己的缓冲区,与内核的文件偏移量可能不同步。
例如:
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,支持管道和重定向
#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 包实现文件拷贝
#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;
}示例三:观察文件描述符的共享
#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。这个实验的核心挑战在于:
进程管理:使用 fork、exec、waitpid 创建和管理子进程。
信号处理:正确处理 SIGINT(Ctrl+C)和 SIGTSTP(Ctrl+Z),将它们转发给前台进程组。
I/O 重定向:实现 < 和 > 重定向,这需要使用 dup2 系统调用。
作业控制:支持前台和后台作业,实现 jobs、fg、bg 等内置命令。
这个实验让你亲身体验到 shell 是如何利用系统级 I/O 来实现命令行接口的。特别是 I/O 重定向的实现,让你深刻理解文件描述符表、dup2 和 exec 之间的协作关系。
实验: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`:read 和 write 系统调用的内核实现。阅读源码可以理解 VFS 层如何将系统调用路由到具体的文件系统。
Brendan Gregg 的 "Systems Performance" 第 3 章:从性能工程的角度分析 I/O 子系统,包括文件系统、块设备层、I/O 调度器的性能特征。
论文 "An Inquiry Into Sequential File Access" (Teorey & Pinkerton, 1976):关于顺序文件访问性能优化的经典论文,是理解操作系统文件缓冲和预读策略的起点。