计算机系统基础-05:系统级 I/O
系统级 I/O
I/O 接口
I/O 是主存与外部设备之间复制数据的过程。常见 I/O 接口分为三层:
| 层次 | 代表接口 | 特点 |
|---|---|---|
| Unix I/O | open, read, write, close |
内核提供,低层、通用、开销低 |
| Robust I/O | rio_readn, rio_writen, rio_readlineb |
对 Unix I/O 的健壮封装,处理 short count |
| Standard I/O | fopen, fread, fprintf, fgets |
C 标准库提供,带用户态缓冲 |
在 signal handler 或网络 socket 场景中,有时必须使用 Unix I/O 或 RIO。
Unix 文件抽象
Unix 文件是长度为 的字节序列:
1 | B0, B1, ..., Bk, ..., Bm-1 |
Unix 将 I/O 设备统一建模为文件,包括磁盘、终端、网络等。所有输入输出都可转化为对文件的读写。
文件类型
| 类型 | 含义 |
|---|---|
| Regular file | 普通文件,包含任意数据 |
| Directory | 目录文件,由文件名到文件的链接组成 |
| Socket | 用于跨网络与其他进程通信 |
| Named pipe | 命名管道 |
| Symbolic link | 符号链接 |
| Character device | 字符设备 |
| Block device | 块设备 |
普通文件从应用视角可分为:
- text file:只包含 ASCII 或 Unicode 字符。
- binary file:其他所有文件。
对内核而言,文本文件和二进制文件没有本质区别。Linux 文本文件由文本行组成,每行以换行符 \n 结束,其 ASCII 值为 0x0a。
目录
目录是由 link 组成的文件,每个 link 将文件名映射到文件。每个目录至少包含:
.:指向目录自身。..:指向父目录。

相关命令:
1 | mkdir dirname # 创建目录 |
Linux 内核将所有文件组织为以根目录 / 为根的单一目录树。每个文件都是根目录的直接或间接后代。
路径名
每个进程都有当前工作目录。路径名用于定位目录树中的文件:
- 绝对路径:从
/开始,例如/home/droh/hello.c。 - 相对路径:从当前工作目录开始,例如
./hello.c、../../home/droh/hello.c。
cd 命令用于改变 shell 的当前工作目录。
文件操作
open
应用访问 I/O 设备前,需要请求内核打开对应文件。内核返回一个非负整数文件描述符(file descriptor, fd)。之后对该文件的操作都通过 fd 完成。

每个打开文件都有当前文件位置 ,初始为 0。应用可以通过 seek 操作显式修改当前位置。
1 |
|
返回值:
- 成功:返回新文件描述符。
- 失败:返回
-1。
flags 必须包含一种访问模式;mode 指定新建文件权限:
基本 flag |
含义 | 可选 flag |
含义 | 权限位 mode |
含义 |
|---|---|---|---|---|---|
O_RDONLY |
只读 | O_CREAT |
文件不存在则创建 | S_IRUSR, S_IWUSR, S_IXUSR |
owner 读、写、执行 |
O_WRONLY |
只写 | O_TRUNC |
打开时截断文件 | S_IRGRP, S_IWGRP, S_IXGRP |
group 读、写、执行 |
O_RDWR |
读写 | O_APPEND |
写操作追加到文件末尾 | S_IROTH, S_IWOTH, S_IXOTH |
others 读、写、执行 |
umask 会屏蔽部分权限:
1 | /* 默认创建权限:用户、组、其他用户均可读写。 */ |
实际权限为 mode & ~umask,所以上例会去掉 group 和 others 的写权限。
close
1 |
|
close(fd) 关闭文件描述符。成功返回 0,失败返回 -1。
关闭文件时:
- 应用不再访问该 fd。
- 内核释放打开文件时创建的相关结构。
- 内核把该 fd 放回可用描述符池。
- 下次打开文件会获得当前最小可用 fd。
进程终止时,内核会关闭所有打开文件并释放相关资源。
示例:
1 | int main() |
若 fd1 被关闭,则 fd2 可能复用相同的最小可用描述符。
read 与 write
1 |
|
read 从当前文件位置 开始,最多复制 count 字节到内存 buf,并将 增加实际读取字节数。
返回值:
- 成功:返回实际读取字节数。
- EOF:返回
0。 - 出错:返回
-1。
write 从内存 buf 复制 count 字节到文件当前写入位置。
返回值:
- 成功:返回实际写入字节数。
- 出错:返回
-1。
文件末尾没有显式 EOF 字符。EOF 是读操作遇到文件末尾时返回 0 的条件。
标准描述符
进程启动时通常有三个打开描述符:
| 描述符 | 宏 | 含义 |
|---|---|---|
| 0 | STDIN_FILENO |
标准输入 |
| 1 | STDOUT_FILENO |
标准输出 |
| 2 | STDERR_FILENO |
标准错误 |
复制标准输入到标准输出:
1 |
|
Short Count:
short count 指read或write实际传输字节数少于请求字节数。 常见原因:
- 读时遇到 EOF。
- 从终端读取文本行。
- 读写网络 socket。
- 被信号中断。
对磁盘文件,short count 不常见;对终端和网络,必须正确处理。
stat 与 fstat
1 |
|
成功返回 0,失败返回 -1。
struct stat 包含文件元数据:
1 | struct stat { |
判断文件类型和权限:
1 | struct stat statbuf; |
常用宏:
S_ISREG(mode):是否普通文件。S_ISDIR(mode):是否目录。S_IRUSR:owner 是否可读。
v-node table 中保存了许多 stat 结构中的信息,例如 st_mode 和 st_size。
读取目录
1 |
|
opendir成功返回目录流指针,失败返回NULL。readdir成功返回下一条目录项指针;若无更多条目或出错,返回NULL。closedir成功返回0,失败返回-1。
目录项结构:
1 | struct dirent { |
遍历目录:
1 | int main(int argc, char **argv) |
内核文件数据结构
内核通过三类结构管理打开文件。

-
Descriptor Table
每个进程有独立的 descriptor table。表项由 fd 索引,每个打开描述符指向 file table 中的一个 entry。
-
File Table
file table 由所有进程共享。每个 entry 表示一个打开文件实例,包含:
- 当前文件位置。
- 引用计数
refcnt。 - 指向 v-node table entry 的指针。
当
refcnt变为 0,内核删除该 file table entry。 -
V-node Table
v-node table 由所有进程共享。每个 entry 包含文件的大部分元数据,例如访问权限、大小、类型等。
区分:
i_node是磁盘上的元数据对象,v_node是内核内存中的文件对象抽象。i_node是文件元数据,可以通过inode number获取磁盘上的i_node结构。Virtual File System 引入v_node对不同文件系统的文件做统一抽象:
1
2
3
4
5
6
7
8
9
10
11
12 struct v_node {
/* 文件系统对象类型。 */
int v_type;
/* 引用计数,表示有多少内核对象引用该 v-node。 */
int refcnt;
/* 指向底层 inode,保存具体文件元数据和磁盘位置。 */
struct i_node *i_node;
/* 文件系统相关操作函数表。 */
struct v_node_ops *ops;
};
v_node中包含有i_node的指针,指向真实的inode。
结构关系:
1 | process descriptor table -> open file table -> v-node table |
文件共享
多次 open 同一文件
同一进程两次 open 同一文件,会得到两个不同 file table entry,因此文件位置独立。

1 | int fd1, fd2; |
若文件内容为 foobar,两次读取都从各自位置 0 开始,因此最终打印 f。
fork 后共享打开文件
父进程在 fork 前打开文件,子进程会继承父进程的 descriptor table 副本。父子进程的描述符指向同一个 file table entry,因此共享当前文件位置。

1 | int fd; |
若子进程先读取 f,共享文件位置变为 1,父进程随后读取 o。
I/O 重定向
dup2
dup2 复制文件描述符表项:
1 |
|

- 将
newfd对应 descriptor table entry 替换为oldfd的 entry。 - 若
newfd原来打开,先关闭它。 - 成功返回非负描述符,失败返回
-1。
shell 中的输出重定向:
1 | ls > foo.txt # 将 ls 的标准输出重定向到 foo.txt |
可理解为:
1 | /* 打开输出文件,并清空原有内容。 */ |
因为子进程继承父进程的 fd table,shell 通常在子进程中设置重定向,再执行 execve。
共享文件位置
1 | int fdA, fdB; |
dup2(fdB, fdA) 后,fdA 和 fdB 指向同一个 file table entry,共享同一文件位置。若第一次从 fdB 读到 f,第二次从 fdA 读到 o。
恢复 stdout
dup 与 dup2 类似,但由内核选择新的 fd(通常是最小可用 fd),让它指向和原 fd 完全相同的打开文件对象。
1 | /* 复制 oldfd,由内核选择最小可用描述符编号作为返回值。 */ |
shell 保存并恢复标准输出:
1 | /* 保存当前标准输出,便于命令执行后恢复。 */ |
stdin、stdout、stderr 是 FILE * 类型。若需要获取对应 fd,可使用:
1 | /* 从 stdio 流对象取得底层 Unix 文件描述符。 */ |
例如 fileno(stdout) == 1。
fork 与 printf
标准 I/O 有用户态缓冲。若在 fork 前用 printf 写入缓冲区但未刷新,缓冲区会被复制到子进程,父子进程后续都可能刷新同一内容,导致输出次数多于预期。
处理方法:
- 在
fork前调用fflush(stdout)。 - 在重定向前后明确刷新标准 I/O 缓冲。
- 对需要精确控制的输出,使用 Unix I/O 的
write。
Robust I/O
RIO 是 CSAPP 提供的一组 I/O 封装,目标是高效、健壮地处理 short count,适用于网络程序等场景。
RIO 分为两类:
| 类别 | 函数 | 用途 |
|---|---|---|
| 无缓冲 I/O | rio_readn, rio_writen |
读写二进制数据 |
| 带缓冲输入 | rio_readlineb, rio_readnb |
读取文本行或二进制数据 |
无缓冲 RIO
无缓冲 RIO 直接在用户内存和文件之间传输数据,不使用应用级内部缓冲。
1 |
|
返回值:
rio_readn:实际读取字节数,EOF 时可小于count,出错返回-1。rio_writen:成功写入count字节,出错返回-1。
rio_readn 核心逻辑:
1 | ssize_t rio_readn(int fd, void *buf, size_t count) |
rio_writen 核心逻辑:
1 | ssize_t rio_writen(int fd, const void *buf, size_t count) |
处理原则:
- 若被信号中断并返回
EINTR,继续重试。 - 若发生其他错误,返回
-1。 - 循环直到传输完请求字节数或遇到 EOF。
带缓冲 RIO
逐字符读取文本行时,若每次都调用 Unix read,系统调用开销较高。一次 read 系统调用可能需要超过 10,000 个时钟周期。
缓冲输入的思路:
- 使用 Unix
read一次读取一块数据到内部缓冲区。 - 用户函数从缓冲区逐字节取数据。
- 缓冲区空时再重新填充。
rio_t
1 |
|
字段含义:
| 字段 | 含义 |
|---|---|
rio_fd |
绑定的文件描述符 |
rio_cnt |
内部缓冲区中未读字节数 |
rio_bufptr |
指向下一个未读字节 |
rio_buf |
内部缓冲区 |
初始化:
1 | void rio_readinitb(rio_t *rp, int fd) |
rio_read
rio_read 是带缓冲 RIO 的内部函数。若内部缓冲为空,则调用 Unix read 填充缓冲;随后从内部缓冲复制数据到用户缓冲区。
1 | static ssize_t rio_read(rio_t *rp, char *usrbuf, size_t n) |
rio_readnb
rio_readnb 从带缓冲 reader 中读取最多 n 个字节到用户缓冲区。
1 | ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n) |
rio_readlineb
rio_readlineb 按行读取文本,最多读取 maxlen-1 个字符,遇到换行或 EOF 停止,并以 '\0' 结尾。
1 | ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen) |
RIO 示例
按行复制标准输入到标准输出:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main(int argc, char **argv)
{
int n;
rio_t rio;
char buf[MAXLINE];
/* 将标准输入绑定到 RIO 缓冲区。 */
Rio_readinitb(&rio, STDIN_FILENO);
while ((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0)
/* 每读到一行,就按实际字节数写到标准输出。 */
Rio_writen(STDOUT_FILENO, buf, n);
}
标准 I/O
C 标准库提供更高层的标准 I/O 函数,包含在 libc.so 中。
常见函数:
| 功能 | 函数 |
|---|---|
| 打开/关闭文件 | fopen, fclose |
| 读写字节 | fread, fwrite |
| 读写文本行 | fgets, fputs |
| 格式化读写 | fscanf, fprintf |
| 刷新缓冲 | fflush |
| 定位 | fseek |
标准 I/O 将打开文件建模为 stream。stream 是文件描述符和内存缓冲区的抽象,类似带缓冲 RIO。
C 程序启动时有三个打开 stream:
| stream | 对应 fd |
|---|---|
stdin |
0 |
stdout |
1 |
stderr |
2 |
标准 I/O 缓冲
标准 I/O 使用用户态缓冲区。以 stdout 为例,多个 printf 调用可先写入缓冲区,遇到换行或显式 fflush(stdout) 时再调用 write。
1 | printf("h"); |
缓冲减少系统调用次数,但也引入与 fork、dup2、socket 相关的限制。
Unix I/O & RIO & Standard I/O
对比
Standard I/O 和 RIO 都建立在底层 Unix I/O 之上。

| I/O 类型 | 优点 | 缺点 / 适用限制 |
|---|---|---|
| Unix I/O | 最通用;开销低;其他 I/O 包都基于它实现;提供文件元数据访问函数;async-signal-safe,可在 signal handler 中使用 | short count 处理容易出错;高效按行读取需要手动实现缓冲 |
| Standard I/O | 缓冲减少 read / write 系统调用次数,提高效率;自动处理许多 short count 情况;接口适合磁盘文件和终端文件 |
不提供访问文件元数据的函数;不是 async-signal-safe,不适合 signal handler;不适合直接用于网络 socket |
| RIO | 适合网络 socket 场景;处理 short count;提供按行读取;避免标准 I/O stream 在 socket 上的限制 | 主要面向网络健壮 I/O 场景,相比 Standard I/O 更偏底层,需要显式使用 RIO 接口 |
Socket 限制
标准 I/O 对同一个 stream 的读写顺序有约束:
- 输出函数后不能直接调用输入函数,除非中间调用
fflush、fseek、fsetpos或rewind。 - 输入函数后不能直接调用输出函数,除非中间调用
fseek、fsetpos或rewind,或输入遇到 EOF。
原因:
- 标准 I/O 使用用户态缓冲。
- 一个 stream 维护单一当前位置。
- 缓冲区通常在读状态或写状态之一工作。
fflush、fseek、fsetpos、rewind用于重置缓冲状态。
socket 是顺序流,不是随机访问文件,因此 fseek 在 socket 上会失败。
若必须在 socket 上使用标准 I/O,可使用 dual streams:
1 | FILE *fpin, *fpout; |
- 读写使用不同 stream。
- 写后仍需
fflush,否则可能导致通信双方互相等待。 - 两个 stream 都必须关闭。
总体原则:使用满足需求的最高层 I/O 接口。
场景 推荐接口 磁盘文件或终端文件 Standard I/O signal handler 内部 Unix I/O 需要访问文件元数据 Unix I/O 追求最低开销的少数场景 Unix I/O 网络 socket 读写 RIO 需要健壮处理 short count RIO
- 普通文件处理优先使用标准 I/O。
- socket 程序优先使用 RIO。
- signal handler 中只使用 async-signal-safe 的 Unix I/O。
- 不要在 socket 上直接混用标准 I/O 的读写 stream,除非明确处理缓冲限制。
