在 Linux 中,文件操作并不只是“读写磁盘文件”。终端、管道、设备乃至网络套接字,都可以通过相似的接口访问。本文从 C 标准 I/O 出发,逐步深入系统调用、文件描述符、重定向与缓冲区,建立一套完整的 Linux IO 认知框架。

一、到底什么是文件

狭义上,文件是保存在磁盘等持久化介质上的数据集合;广义上,Linux 会把很多系统资源抽象成文件,让开发者通过统一接口访问。

一个普通文件通常包含两部分:

文件 = 文件内容 + 文件属性(元数据)

文件内容是我们实际保存的数据;元数据则包括文件大小、权限、所有者、时间戳和存储位置等信息。因此,即使一个文件内容为 0 字节,它也仍需要保存文件名、权限等元数据,会占用一定的文件系统空间。

从操作系统角度看,文件操作具有以下关系:

进程
  ↓ 发起系统调用
操作系统
  ↓ 调用文件系统或设备驱动
普通文件、终端、磁盘、管道、设备……

用户程序不能绕过操作系统直接随意操作硬件。fopen()fread() 等 C 库函数提供了易用接口,但真正进入内核执行文件操作的,是底层系统调用。

二、回顾 C 标准文件 IO

C 语言通过 FILE * 表示一个流。下面的程序向文件写入数据:

#include <stdio.h>

int main(void)
{
    FILE *fp = fopen("message.txt", "w");
    if (fp == NULL) {
        perror("fopen");
        return 1;
    }

    if (fputs("hello Linux IO\n", fp) == EOF) {
        perror("fputs");
        fclose(fp);
        return 1;
    }

    if (fclose(fp) == EOF) {
        perror("fclose");
        return 1;
    }
    return 0;
}

若只提供相对路径,文件会相对于进程的当前工作目录创建。可以通过 /proc 观察进程的运行信息:

# 查看进程的当前工作目录
readlink /proc/进程PID/cwd

# 查看进程对应的可执行文件
readlink /proc/进程PID/exe

# 查看进程打开的文件描述符
ls -l /proc/进程PID/fd

fopen 模式

模式 含义 文件不存在 文件已存在
r 只读 打开失败 从开头读取
r+ 读写 打开失败 从开头读写
w 只写 创建 清空原内容
w+ 读写 创建 清空原内容
a 追加写 创建 从末尾追加
a+ 读取并追加 创建 写操作追加到末尾

C 程序默认还会获得三个标准流:

stdin   // 标准输入
stdout  // 标准输出
stderr  // 标准错误

它们的类型都是 FILE *。一般情况下,标准输入连接键盘,标准输出和标准错误连接终端。

三、从库函数进入系统调用

语言层接口最终要借助操作系统完成 IO。Linux 中常用的底层接口包括:

int open(const char *pathname, int flags, ...);
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
int close(int fd);
off_t lseek(int fd, off_t offset, int whence);

下面使用系统调用复制文件,并正确处理短读和短写:

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

static int write_all(int fd, const char *buf, size_t size)
{
    size_t total = 0;
    while (total < size) {
        ssize_t n = write(fd, buf + total, size - total);
        if (n > 0) {
            total += (size_t)n;
        } else if (n < 0 && errno == EINTR) {
            continue;
        } else {
            return -1;
        }
    }
    return 0;
}

int main(int argc, char *argv[])
{
    if (argc != 3) {
        fprintf(stderr, "usage: %s SOURCE DEST\n", argv[0]);
        return 1;
    }

    int src = open(argv[1], O_RDONLY);
    if (src < 0) {
        perror("open source");
        return 1;
    }

    int dst = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (dst < 0) {
        perror("open destination");
        close(src);
        return 1;
    }

    char buf[4096];
    int status = 0;
    for (;;) {
        ssize_t n = read(src, buf, sizeof(buf));
        if (n > 0) {
            if (write_all(dst, buf, (size_t)n) < 0) {
                perror("write");
                status = 1;
                break;
            }
        } else if (n == 0) {
            break;  // 到达文件结尾
        } else if (errno != EINTR) {
            perror("read");
            status = 1;
            break;
        }
    }

    if (close(src) < 0) {
        perror("close source");
        status = 1;
    }
    if (close(dst) < 0) {
        perror("close destination");
        status = 1;
    }
    return status;
}

read()write() 返回的实际字节数可能小于请求值。健壮的程序不能默认一次调用就能完成全部传输,也要考虑被信号中断后返回 EINTR 的情况。

open 的 flags

open()flags 可以通过按位或组合:

  • O_RDONLY:只读;
  • O_WRONLY:只写;
  • O_RDWR:读写;
  • O_CREAT:不存在时创建,此时需要提供权限参数;
  • O_TRUNC:打开时清空已有内容;
  • O_APPEND:每次写入都追加到末尾。

创建文件时指定的权限还会受到进程 umask 的影响:

最终权限 = mode 中允许的权限去掉 umask 屏蔽的位

四、文件描述符 fd 到底是什么

open() 成功时返回一个非负小整数,这就是文件描述符(file descriptor,简称 fd)。Linux 进程通常预先打开:

fd 符号常量 默认含义
0 STDIN_FILENO 标准输入
1 STDOUT_FILENO 标准输出
2 STDERR_FILENO 标准错误

文件描述符可以理解为进程文件描述符表的索引:

进程的文件描述符表

下标 0 -> 标准输入对应的打开文件
下标 1 -> 标准输出对应的打开文件
下标 2 -> 标准错误对应的打开文件
下标 3 -> 第一次额外打开的文件
下标 4 -> 第二次额外打开的文件

内核通常分配当前可用的最小文件描述符。因此,标准描述符都存在时,首次 open() 常返回 3;如果先关闭 0,下一次 open() 就可能返回 0

需要注意,这只是用户理解模型。课件展示的 task_structfiles_structstruct file 等字段来自特定 Linux 内核版本,具体定义会随内核演进而变化,不应把旧版本源码布局当作永久 ABI。

五、重定向的本质

Shell 中常见的重定向包括:

command > output.txt    # 覆盖式输出重定向
command >> output.txt   # 追加式输出重定向
command < input.txt     # 输入重定向

它们并没有修改程序中 printf()scanf() 的代码,而是改变了标准文件描述符指向的打开文件。

使用 dup2 实现输出重定向

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

int main(void)
{
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd < 0) {
        perror("open");
        return 1;
    }

    if (dup2(fd, STDOUT_FILENO) < 0) {
        perror("dup2");
        close(fd);
        return 1;
    }

    close(fd);  // fd 与标准输出已指向同一打开文件,原描述符可以关闭
    printf("这行内容会写入 log.txt\n");

    if (fflush(stdout) == EOF) {
        perror("fflush");
        return 1;
    }
    return 0;
}

dup2(oldfd, newfd)newfd 引用 oldfd 对应的打开文件。若 newfd 原本已经打开,内核会先关闭其旧引用。因而重定向的核心不是“让 printf() 学会写文件”,而是让它继续使用 stdout 时,底层的 fd 1 已经指向文件。

在一个简化 Shell 中,通常由子进程在执行新程序前完成重定向:

父 Shell 解析命令
   ↓ fork
子进程 open 目标文件
   ↓ dup2 到 0、1 或 2
子进程 exec 执行目标程序

exec 会替换进程的代码和数据,但默认不会自动关闭未设置 close-on-exec 的文件描述符,所以重定向关系能够被新程序继承。

六、如何理解“一切皆文件”

“一切皆文件”并不是说所有对象都存放在磁盘上,而是强调 Linux 尽可能为不同资源提供统一的文件式访问模型。

例如,普通文件、终端、设备、管道和套接字都可以使用 fd 表示,并通过 read()write()close() 等接口访问。这样,上层程序不需要针对每种硬件重新设计一整套 IO API。

read/write 等统一接口
          ↓
      内核打开文件对象
          ↓
   对应文件系统或驱动操作
      ↙    ↓    ↘
   磁盘   终端   设备/管道/套接字

课件以 Linux 3.10 系列源码中的 struct filefile_operations 说明这种设计。不同对象可以提供各自的读写实现,内核再通过函数指针把统一系统调用分派到具体文件系统或设备驱动。现代内核中的结构成员可能不同,但“统一抽象 + 具体实现”的设计思想仍然重要。

七、为什么需要缓冲区

CPU 和内存速度远高于磁盘、终端、打印机等 IO 设备。如果每处理少量数据就进入内核甚至访问设备,会产生大量系统调用和设备等待开销。

缓冲区先在内存中积累或预取数据,可以:

  1. 减少系统调用次数;
  2. 合并许多小 IO;
  3. 缓解高速 CPU 与低速设备之间的速度差异;
  4. 提高整体吞吐量。

C 标准 IO 的三种典型缓冲方式

类型 常见对象 典型刷新时机
全缓冲 普通磁盘文件 缓冲区填满、显式刷新或正常结束
行缓冲 连接终端的标准输出 遇到换行、缓冲区填满或显式刷新
无缓冲 标准错误常见 尽快交给底层系统调用

fflush() 刷新的是 C 标准库的用户级输出缓冲区。它不等同于 fsync():后者要求内核把与文件相关的数据同步到存储设备,语义和成本都不同。

八、FILE、fd 与缓冲区的关系

FILE * 是 C 标准库提供的流对象,fd 是 Linux 系统调用使用的进程级句柄。二者处在不同抽象层:

printf / fread / fwrite
          ↓
 FILE 流对象与用户级缓冲区
          ↓
       文件描述符 fd
          ↓
   read / write 等系统调用
          ↓
      内核与具体设备

可以通过 fileno() 获取一个 C 流对应的文件描述符:

#include <stdio.h>

int main(void)
{
    printf("stdout fd = %d\n", fileno(stdout));
    fprintf(stderr, "stderr fd = %d\n", fileno(stderr));
    return 0;
}

不要随意混用同一文件上的标准 IO 和底层系统调用,因为两套接口的缓冲状态和文件位置管理可能导致意外结果。如果确实需要混用,必须理解并正确同步缓冲区和文件偏移。

九、fork 后为什么可能重复输出

下面的现象是理解用户级缓冲区的经典案例:

#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void)
{
    printf("buffered message");

    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {
        return 0;
    }

    if (waitpid(pid, NULL, 0) < 0) {
        perror("waitpid");
        return 1;
    }
    return 0;
}

当标准输出连接终端时,是否立即显示会受到行缓冲和换行的影响;重定向到普通文件后,标准输出通常变为全缓冲。若 fork() 发生时消息仍停留在用户级缓冲区,父子进程会各自获得缓冲状态的副本,正常退出时可能分别刷新,于是文件中出现两份内容。

常见处理方式是在 fork() 前刷新:

fflush(NULL);  // 刷新所有打开的输出流

或者在逻辑允许时使用不经过 stdio 用户缓冲区的 write()。子进程发生错误并且不能安全刷新继承的 stdio 缓冲区时,还需要根据程序设计考虑 _exit()exit() 的差异。

十、从零理解一个简化的 stdio

一个教学用途的简化流对象至少需要保存:

typedef struct {
    int fd;             // 底层文件描述符
    int flush_mode;     // 全缓冲、行缓冲等策略
    size_t size;        // 当前已缓存字节数
    size_t capacity;    // 缓冲区容量
    char buffer[1024];  // 用户级输出缓冲区
} MyFile;

简化的写入流程可以概括为:

  1. my_fopen() 根据模式调用 open(),再创建并初始化流对象;
  2. my_fwrite() 把用户数据复制到缓冲区;
  3. 缓冲区满或满足行缓冲条件时调用底层 write()
  4. my_fflush() 循环处理短写,清空用户级缓冲状态;
  5. my_fclose() 先刷新,再关闭 fd,最后释放流对象。

真实的 glibc stdio 还要处理输入缓冲、并发锁、错误标志、文件位置、宽字符、模式切换和线程安全等问题,远比教学模型复杂。这个模型的价值在于揭示:标准 IO 库是在系统调用之上增加了流抽象和用户级缓冲策略。

十一、总结

本文可以归纳为以下几点:

  1. 文件由内容和元数据组成,文件操作最终由操作系统管理;
  2. fopen() 等库函数在系统调用之上提供了更易用的流式接口;
  3. open() 返回的 fd 是进程文件描述符表中的索引;
  4. dup2() 通过改变描述符引用关系实现输入输出重定向;
  5. “一切皆文件”强调统一的文件式接口,而非所有对象都保存在磁盘;
  6. FILE * 封装底层 fd,并通常带有用户级缓冲区;
  7. 缓冲可以减少系统调用和设备访问次数,但也会影响输出时机;
  8. fork() 会复制用户空间中的缓冲状态,未刷新数据可能被重复输出;
  9. 内核结构属于版本相关实现,学习时应抓住抽象关系,而不是死记字段布局。

理解这些内容后,再学习管道、进程间通信、网络套接字和文件系统,就能自然地把它们统一到 Linux IO 模型中。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐