文件I/O基础:文件描述符、open/close、read/write
说实话,文件I/O是系统编程里最基础、也最绕不开的一块。你写任何服务端程序、任何工具链,最终都要跟文件打交道。我刚开始学的时候,总觉得不就是读写文件嘛,有什么好讲的?直到有一次线上排查问题,发现一个进程莫名其妙打不开文件了,才意识到——嗯,文件描述符这东西,真不能小看。
文件描述符:进程与内核之间的“门票”
文件描述符,简称 fd。说白了,就是一个非负整数。内核用它来标识一个进程打开的文件。
你想想看,进程怎么跟内核说“我要读那个文件”?总不能说“我要读 /home/user/data.txt”吧?太长了,而且每次都要解析路径,效率低。内核的做法是:你打开文件时,它给你一个整数,以后你就拿这个整数来操作。这个整数就是文件描述符。
每个进程都有自己的文件描述符表。默认情况下:
- 0 —— 标准输入(stdin)
- 1 —— 标准输出(stdout)
- 2 —— 标准错误(stderr)
这三个是进程启动时自动打开的。你新打开的文件,从 3 开始分配。
核心要点:文件描述符是进程级的资源。不同进程的 fd 3 可能指向完全不同的文件。内核通过进程的 fd 表来维护这个映射关系。
open 系统调用:打开文件的第一步
open 的原型长这样:
#include <fcntl.h>
#include <unistd.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
flags 是必填的,告诉内核你想怎么操作。常用的有:
| flags | 含义 |
|---|---|
| O_RDONLY | 只读打开 |
| O_WRONLY | 只写打开 |
| O_RDWR | 读写打开 |
| O_CREAT | 文件不存在则创建 |
| O_TRUNC | 打开时清空文件内容 |
| O_APPEND | 追加模式写入 |
mode 参数只在 O_CREAT 时需要,用来指定新文件的权限。比如 0644 表示所有者可读写,其他人只读。
个人习惯:我写 open 时,只要用了 O_CREAT,一定会带上 mode 参数。哪怕你传 0,也比不传强。因为不传的话,mode 是栈上的随机值,创建出来的文件权限可能乱七八糟的。
close 系统调用:用完记得关门
close 的原型很简单:
int close(int fd);
成功返回 0,失败返回 -1。
为什么要 close?因为每个进程能打开的文件描述符数量是有限的。你可以用 ulimit -n 看看,默认一般是 1024。如果你不关,fd 会一直占着,直到进程退出。
我曾经踩过的坑:有一次写一个日志收集程序,每收到一条日志就 open 一次文件,写完后忘了 close。跑了半天,进程突然打不开任何新文件了。排查了半天才发现是 fd 泄漏。从那以后,我写 open 的时候,一定会顺手把 close 的逻辑写好。要么用 RAII 封装,要么在 finally 里关。
read 系统调用:从文件里拿数据
read 的原型:
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
它从 fd 指向的文件中读取最多 count 个字节,放到 buf 里。返回值是实际读到的字节数。
这里有个细节:read 不一定能读到 count 个字节。比如文件只剩 10 个字节了,你传 count=1024,它只返回 10。再比如从管道读,数据还没到,它可能只返回当前已有的数据。
所以正确的写法是:
char buf[1024];
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) {
// 处理 n 个字节
} else if (n == 0) {
// 文件结束
} else {
// 出错
}
千万别假设 read 一次就能读完你要的数据。我见过太多新手直接写 read(fd, buf, 100) 然后假设 buf 里就有 100 个字节了。你想想看,万一网络延迟呢?万一文件只有 50 个字节呢?
write 系统调用:把数据写进去
write 的原型:
ssize_t write(int fd, const void *buf, size_t count);
它把 buf 中的 count 个字节写入 fd 指向的文件。返回值是实际写入的字节数。
跟 read 类似,write 也不保证一次写完。尤其是写网络 socket 或者管道时,可能只写了一半。所以正确的做法是循环写:
ssize_t written = 0;
while (written < count) {
ssize_t n = write(fd, buf + written, count - written);
if (n > 0) {
written += n;
} else if (n == -1) {
// 出错处理
break;
}
}
核心原则:read 和 write 的返回值一定要检查。不要假设它们一次就能完成。这是系统编程里最容易出 bug 的地方之一。
知识体系:一张图看懂文件 I/O
下面这张 SVG 图,把文件描述符、open、close、read、write 的关系画清楚了:
这张图展示了核心流程:进程通过 open 获取 fd,通过 fd 调用 read/write 与内核交互,内核再操作磁盘。最后用 close 释放 fd。整个过程,fd 就是那个“门票”。
一个完整的例子
把上面这些串起来,写一个完整的程序:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
int main() {
int fd;
char buf[64];
ssize_t n;
// 打开文件,不存在则创建,权限 0644
fd = open("/tmp/test.txt", O_RDWR | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
perror("open");
return 1;
}
// 写入数据
const char *msg = "Hello, file I/O!\n";
n = write(fd, msg, strlen(msg));
if (n == -1) {
perror("write");
close(fd);
return 1;
}
// 移动文件偏移到开头
lseek(fd, 0, SEEK_SET);
// 读取数据
n = read(fd, buf, sizeof(buf) - 1);
if (n == -1) {
perror("read");
close(fd);
return 1;
}
buf[n] = '\0';
printf("Read: %s", buf);
// 关闭文件
close(fd);
return 0;
}
这个例子把 open、write、read、close 全用上了。你编译运行一下,会在 /tmp/test.txt 里看到写入的内容,同时程序也会把它读出来打印到屏幕上。
一个小建议:刚开始学的时候,可以故意把 close 注释掉,然后用 lsof 命令看看进程的 fd 情况。你会直观地看到 fd 泄漏是什么样的。这种动手实验,比看十遍文档都管用。
避坑指南
- 忘记检查返回值:read/write 返回 -1 时,errno 会告诉你原因。不检查的话,程序可能静默地出错。
- 假设一次读写完成:尤其是网络编程里,read 可能只读到部分数据,write 可能只写了部分数据。一定要循环处理。
- fd 泄漏:open 了不 close,进程的 fd 表会慢慢被占满。轻则程序异常,重则系统资源耗尽。
- 权限问题:open 时用了 O_CREAT 却不传 mode,新文件的权限可能是随机的。这在生产环境里会引发安全问题。
文件 I/O 看起来简单,但它是所有系统编程的基石。把 open、close、read、write 这四个系统调用吃透,后面的管道、socket、epoll 学起来会顺畅很多。嗯,今天就先聊到这儿。
可可精品资料库