文件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 的关系画清楚了:

进程 A 文件描述符表 0 → stdin 1 → stdout 2 → stderr 3 → file1 4 → file2 ... open() read/write close() 内核 打开文件表 file1: /tmp/a.txt file2: /tmp/b.txt ... 磁盘 磁盘文件

这张图展示了核心流程:进程通过 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 学起来会顺畅很多。嗯,今天就先聊到这儿。

可可精品资料库