底层文件I/O(一):open()、close()、read()、write() 系统调用的使用

说到文件操作,很多同学第一反应就是 fopenfread 这些标准C库函数。嗯,没错,它们确实好用。但今天我想跟你聊聊更底层的东西——系统调用。

说白了,标准C库函数是站在巨人的肩膀上,而系统调用才是那个「巨人」本身。我刚开始做嵌入式开发时,总觉得用库函数就够了,直到有一次在RTOS上调试一个文件系统驱动……嗯,那次经历让我彻底明白了底层接口的重要性。

核心概念:系统调用是操作系统内核提供给用户程序的接口。open()、close()、read()、write() 这四位,就是文件操作中最基础的系统调用。

1. 为什么需要底层文件I/O?

你想想看,标准C库函数带缓冲区,读写效率高,用起来也方便。那为什么还要学底层接口?

我在项目中遇到过这样一个场景:一个数据采集系统需要实时写入传感器数据,每次写入量很小但频率极高。用 fwrite 的话,数据会先积在缓冲区里,如果这时候系统突然掉电……嗯,缓冲区里的数据就全丢了。

底层系统调用没有用户态缓冲区,每次调用直接跟内核打交道。虽然单次调用开销大一点,但胜在实时性和可控性。说白了,就是「我要写,现在就要写进磁盘」,不跟你玩缓存那一套。

2. 四个核心系统调用

咱们一个一个来看。我习惯把这四个函数称为「文件操作的四大金刚」。

2.1 open() —— 打开或创建文件

#include <fcntl.h>
#include <unistd.h>

int fd = open(const char *pathname, int flags, mode_t mode);

pathname 是文件路径,flags 是打开方式,mode 是创建文件时的权限。

flags 参数常用的有:

  • O_RDONLY —— 只读打开
  • O_WRONLY —— 只写打开
  • O_RDWR —— 读写打开
  • O_CREAT —— 文件不存在则创建
  • O_TRUNC —— 打开时清空文件内容
  • O_APPEND —— 追加模式写入

个人经验:我习惯在 flags 里加上 O_CLOEXEC,防止子进程继承文件描述符。这个坑我踩过——有一次 fork 子进程后,父进程的文件描述符被子进程继承了,导致文件锁混乱,查了两天才找到原因。

2.2 close() —— 关闭文件

#include <unistd.h>

int close(int fd);

关闭文件描述符,释放内核资源。成功返回0,失败返回-1。

注意:忘记 close() 会导致文件描述符泄漏。嵌入式系统资源有限,泄漏多了就再也打不开新文件了。我曾经在一个长期运行的服务器程序里,就是因为少写了一个 close(),跑了三天后程序突然报「Too many open files」……嗯,那次教训很深刻。

2.3 read() —— 从文件读取数据

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);

从文件描述符 fd 中读取最多 count 字节到 buf 中。返回值是实际读取的字节数,返回0表示读到文件末尾,返回-1表示出错。

这里有个细节:read() 不一定能一次读满你要求的 count 字节。比如你要求读1024字节,但文件只剩500字节了,那 read() 就只返回500。所以,写代码时一定要检查返回值。

2.4 write() —— 向文件写入数据

#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);

向文件描述符 fd 写入 buf 中的 count 字节。返回值是实际写入的字节数。同样,write() 也可能没有写完你要求的全部数据。

避坑指南:我曾经在写日志系统时,直接用 write() 写入一个结构体。结果结构体里有指针,写入文件的是指针地址,而不是指针指向的数据。重启程序后,读出来的指针地址早就失效了。记住:write() 写入的是内存中的二进制数据,不会帮你做序列化。

3. 完整示例:拷贝文件

光说不练假把式。咱们写一个用底层接口拷贝文件的例子:

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

#define BUFFER_SIZE 4096

int main(int argc, char *argv[]) {
    if (argc != 3) {
        write(STDERR_FILENO, "Usage: ./copy src dst\n", 22);
        exit(EXIT_FAILURE);
    }

    int src_fd = open(argv[1], O_RDONLY);
    if (src_fd == -1) {
        perror("open source file");
        exit(EXIT_FAILURE);
    }

    int dst_fd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (dst_fd == -1) {
        perror("open destination file");
        close(src_fd);
        exit(EXIT_FAILURE);
    }

    char buf[BUFFER_SIZE];
    ssize_t bytes_read, bytes_written;

    while ((bytes_read = read(src_fd, buf, BUFFER_SIZE)) > 0) {
        char *p = buf;
        ssize_t remaining = bytes_read;

        while (remaining > 0) {
            bytes_written = write(dst_fd, p, remaining);
            if (bytes_written == -1) {
                perror("write");
                close(src_fd);
                close(dst_fd);
                exit(EXIT_FAILURE);
            }
            p += bytes_written;
            remaining -= bytes_written;
        }
    }

    if (bytes_read == -1) {
        perror("read");
    }

    close(src_fd);
    close(dst_fd);

    return 0;
}

这个例子有几个要点:

  • O_CREAT | O_TRUNC 确保目标文件被创建或清空
  • read() 和 write() 的返回值一定要检查
  • write() 可能没写完,所以用了一个内层循环确保全部写入
  • 出错时记得关闭已经打开的文件描述符

4. 知识体系总览

下面这张图帮你理清这四个系统调用的关系:

底层文件I/O 四大系统调用 用户空间(User Space) 应用程序调用 open() / close() / read() / write() 系统调用接口(System Call Interface) 内核空间(Kernel Space) VFS(虚拟文件系统)→ 具体文件系统驱动 → 块设备层 硬件设备(磁盘 / Flash / 存储卡) 调用层次 关键特点 • 无用户态缓冲区 • 直接与内核交互 • 实时性强 • 适合小数据量高频写 • 需要手动管理缓冲区

5. 文件描述符的本质

open() 返回的 int 叫文件描述符(file descriptor)。它本质上是一个数组的索引,这个数组在内核里维护,记录了当前进程打开的所有文件。

标准输入、标准输出、标准错误分别对应文件描述符 0、1、2。所以你在例子里看到我用 STDERR_FILENO(就是2)来输出错误信息。

文件描述符 名称 符号常量
0 标准输入 STDIN_FILENO
1 标准输出 STDOUT_FILENO
2 标准错误 STDERR_FILENO

新打开的文件,描述符从3开始分配。close() 之后,这个描述符可以被重用。

6. 常见错误与处理

系统调用出错时返回 -1,并设置全局变量 errno。用 perror() 可以打印出可读的错误信息。

常见的 errno 值:

  • EACCES —— 权限不足
  • EINTR —— 调用被信号中断
  • EAGAIN —— 非阻塞模式下暂时无数据可读
  • ENOSPC —— 磁盘空间不足

我的习惯:每次调用系统调用后,我都会检查返回值。如果返回 -1,立刻用 perror() 打印错误信息。这在调试阶段能省下大量时间。别问我怎么知道的——我曾经花了一整个下午,才发现是磁盘写满了,而我的代码里根本没有检查 write() 的返回值。

7. 什么时候用底层接口?

说了这么多,到底什么时候该用底层接口?我总结了几条:

  • 需要实时写入,不能容忍数据在缓冲区里积压
  • 操作特殊文件(如设备文件、管道、socket)
  • 需要精细控制文件描述符(如 dup2、fcntl)
  • 在信号处理函数中(标准库函数大多不可重入)
  • 内存极度受限的嵌入式环境

反过来,如果只是读写普通文件,对实时性没要求,用标准C库函数更省心。毕竟人家帮你做了缓冲、做了格式化,用起来确实方便。

嗯,底层文件I/O就聊到这里。这四个系统调用是基础中的基础,理解了它们,后面的文件锁、多路复用、异步I/O学起来就轻松多了。


公众号:蓝海资料掘金营,微信 deep3321