《read/write的秘密:文件描述符、重定向与用户态缓冲区》

🌟 本文将从零开始,用最通俗的生活例子 + 最硬核的源码分析,带你彻底吃透Linux下的文件操作。

覆盖:C标准库文件接口、系统调用、文件描述符、重定向原理、VFS"一切皆文件"、用户态/内核态缓冲区,最后手写一个迷你stdio库并实现Shell中的重定向。
全文干货,建议收藏,反复阅读。


📌 目录

  1. 文件到底是什么?------狭义与广义
  2. C语言怎么操作文件?------生活例子回顾
  3. 操作系统怎么操作文件?------系统调用登场
  4. 文件描述符------那个神秘的小整数
  5. 重定向:偷梁换柱的艺术
  6. 一切皆文件------Linux最浪漫的谎言
  7. 缓冲区:性能背后的无名英雄(含fork血案)
  8. [手撕一个简易 stdio 库](#手撕一个简易 stdio 库)
  9. 造个简易Shell:亲手实现重定向
  10. 易错点大杂烩(表格总结)
  11. 思考与进阶

1. 文件到底是什么?------狭义与广义

1.1 狭义理解:磁盘上的数据

你在Windows里打开的 .txt.jpg,就是狭义文件。它们躺在硬盘里,即使关机也不会消失。

硬盘是一种外设(既是输入也是输出设备),读写文件就是对外设进行输入输出(IO)。

💡 生活例子 :文件就像是你的储物柜。你把东西(数据)放进去,关电也不会丢。你要拿东西,就得走到柜子前(IO操作)。

1.2 广义理解:Linux下一切皆文件

Linux 有个浪漫的说法:一切皆文件

  • 键盘 → 文件(/dev/input/...
  • 显示器 → 文件(/dev/fb0/dev/tty
  • 网卡 → 文件(/dev/net/...
  • 甚至进程信息 → 文件(/proc/

生活例子 :就像去行政服务中心办事。

不管你是办身份证(普通文件)、交水电费(键盘输入)、还是打印材料(打印机输出),都只需要到统一窗口 出示你的"需求单"。在Linux里,这个"统一窗口"就是 readwrite 系统调用。

1.3 文件 = 内容 + 属性

一个文件由两部分组成:

  • 内容:你写进去的 "hello world"
  • 属性(元数据):文件名、大小、创建时间、权限等

ls -l 显示的是属性,cat 看的是内容。


2. C语言怎么操作文件?------生活例子回顾

2.1 类比:图书馆借书

C函数 生活类比
fopen 走到书架前,拿到一本书
fread 翻开书,读取文字
fwrite 在书上写字
fclose 把书放回书架

C语言程序启动时,会自动打开三个"特殊书本":

文件描述符 生活类比
stdin 0 你的耳朵(听键盘输入)
stdout 1 你的嘴巴(说正常话)
stderr 2 另一张嘴巴(专门喊"救命")

2.2 常用函数一览

函数 作用 注意点
fopen 打开文件 模式"w"会清空文件,"a"追加
fread 读数据 返回值是成功读取的完整对象数,不是字节数!
fwrite 写数据 同样返回对象数,常用于二进制块
fclose 关闭文件 会自动刷新缓冲区
fseek 移动文件指针 可用于获取文件大小
fflush 强制刷新缓冲区 对输入流行为未定义,通常用于输出流

2.3 经典示例:实现简单的cat命令

c 复制代码
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <filename>\n", argv[0]);
        exit(1);
    }
    FILE *fp = fopen(argv[1], "r");
    if (!fp) {
        perror("fopen");
        exit(2);
    }
    char buf[1024];
    size_t n;
    // 关键:元素大小 = 1,元素个数 = 缓冲区大小
    while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) {
        fwrite(buf, 1, n, stdout);
    }
    fclose(fp);
    return 0;
}

⚠️ 易错点

  • fread(buf, 1, sizeof(buf), fp) 第二个参数是元素大小 ,第三个是元素个数
  • 千万不能写成 fread(buf, sizeof(buf), 1, fp),因为当读到不足一个完整缓冲区时,返回值会变成0,导致漏读末尾数据

3. 操作系统怎么操作文件?------系统调用登场

C标准库的 fopen 底层调用了操作系统的 open 系统调用。系统调用是内核提供的接口,只有通过它才能真正访问硬件。

3.1 核心API

c 复制代码
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int open(const char *pathname, int flags, mode_t mode);
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);
  • openflags 必须包含 O_RDONLY / O_WRONLY / O_RDWR 之一。
    O_CREAT 时需要提供 mode(权限,如0644),注意会受进程umask影响,一般先调用umask(0)
  • 返回值 :成功时返回一个非负整数 文件描述符(fd),失败返回-1。

3.2 标志位传递的艺术(位掩码)

系统调用常通过位掩码传递多个选项。就像你点奶茶:可以"加珍珠"+"加椰果"+"少冰"。

c 复制代码
#define READ   (1 << 0)   // 0001
#define WRITE  (1 << 1)   // 0010
#define APPEND (1 << 2)   // 0100

void func(int flags) {
    if (flags & READ)  printf("Read ");
    if (flags & WRITE) printf("Write ");
    if (flags & APPEND) printf("Append ");
}
// 调用:func(READ | APPEND);

3.3 完整示例:使用系统调用写文件

c 复制代码
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>

int main() {
    umask(0);   // 清除掩码,确保0644完全生效
    int fd = open("syscall.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror("open");
        return 1;
    }
    const char *msg = "Hello from syscall!\n";
    write(fd, msg, strlen(msg));   // write不会自动加'\0'
    close(fd);
    return 0;
}

🔐 权限的坑open 的第三个参数 mode 会与进程的 umaskmode & ~umask。比如 umask=00220644 & ~0022 = 0644 没问题;但如果 umask=0002,结果会变成 0662?不对,是 0644 & ~0002 = 0644 依然是 0644。但为了绝对安全,调用 umask(0) 最省心。


4. 文件描述符------那个神秘的小整数

open 成功会返回一个小整数,比如 3。这个整数叫做文件描述符(file descriptor, fd)

4.1 0、1、2 的秘密

每个进程启动时,内核已经帮它打开了三个文件描述符:

fd 符号常量 对应设备 生活类比
0 STDIN_FILENO 键盘 耳朵
1 STDOUT_FILENO 显示器 嘴巴(正常话)
2 STDERR_FILENO 显示器 嘴巴(喊救命)
c 复制代码
#include <unistd.h>
int main() {
    write(1, "Hello stdout\n", 13);  // 直接往标准输出写
    write(2, "Hello stderr\n", 13);  // 往标准错误写
    return 0;
}

4.2 描述符分配规则

规则 :在当前进程的文件描述符表中,寻找最小的未被使用的下标

c 复制代码
close(0);               // 关闭标准输入(耳朵暂时不要了)
int fd = open("test.txt", O_RDONLY);
printf("fd = %d\n", fd); // 输出 0 (因为0是第一个可用的)
close(fd);

4.3 内核视角:文件描述符表

每个进程在内核中有一个 task_struct(进程控制块),里面有一个 files_struct,指向一个数组 fd_array[],数组的每个元素指向一个 struct file(内核中代表打开的文件)。

文件描述符就是数组的下标 。所以 fd=3 就是让内核去取 fd_array[3]


5. 重定向:偷梁换柱的艺术

5.1 什么是重定向?

把本来应该输出到屏幕的内容,改道输出到文件。或者把本来从键盘读的内容,改成从文件读。

生活例子

  • 输出重定向 (>):你本来对女朋友说话,现在让你对着录音机说,话都录进了磁带。
  • 输入重定向 (<):你本来听女朋友讲话,现在让你听录音机放出来的话。

5.2 底层原理:修改文件描述符表

我们想要 printf 的内容写进文件,而 printf 默认往 fd=1 写。那我们只需让 fd_array[1] 指向目标文件的 struct file 即可。

系统调用dup2

c 复制代码
#include <unistd.h>
int dup2(int oldfd, int newfd);
// 让 newfd 也指向 oldfd 所指向的文件,然后关闭 newfd 原来的指向。

示例:输出重定向

c 复制代码
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main() {
    int fd = open("redirect.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror("open");
        return 1;
    }
    dup2(fd, 1);         // 把标准输出(1)重定向到文件
    printf("你看不见我,因为我去了文件里\n");
    close(fd);
    return 0;
}

运行后,屏幕上不会输出任何文字,但 redirect.txt 里会多出那一行。

💡 思考 :如果把 dup2(fd, 1) 换成 dup2(1, fd) 会怎样?

那就让 fd 指向了 stdout(显示器),对 fd 写也会输出到屏幕,这不是重定向,而是别名。


6. 一切皆文件------Linux最浪漫的谎言

6.1 核心思想

键盘、显示器、网卡、声卡......这些看似不是文件的东西,Linux 都把它们包装成了文件。你只需要用 openreadwrite 就能操作它们。

生活例子万能遥控器

不管你是空调、电视、机顶盒,万能遥控器都用同一套按键(电源、音量、频道)。在 Linux 里,read / write 就是那套万能按键。

6.2 内核如何实现:struct file_operations

内核为每个打开的文件准备了一个 struct file,里面有一个重要的成员 f_op,指向一张函数表 struct file_operations

c 复制代码
struct file_operations {
    ssize_t (*read)(struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *);
    int (*open)(struct inode *, struct file *);
    int (*release)(struct inode *, struct file *);
    // ... 还有几十个函数指针
};

具体设备各自实现自己的函数:

设备类型 read 函数 实际工作
普通磁盘文件 ext4_read 从磁盘读取数据块
键盘 keyboard_read 从键盘缓冲区读扫描码
显示器 console_write 把字符渲染到屏幕

当你调用 read(fd, buf, size) 时,内核:

  1. 根据 fd 找到 struct file
  2. 找到 f_op->read
  3. 调用真正属于这个设备的那段代码

这就是"一切皆文件"的底层魔法------函数指针多态


7. 缓冲区:性能背后的无名英雄

7.1 为什么需要缓冲区?

每次系统调用(read/write)都要陷入内核,CPU 从用户态切换到内核态,代价很大。如果写一个字节就调用一次 write,那效率低得可怕。

生活例子:搬砖。

  • 无缓冲:搬一块砖就从工地走到砖堆,再走回来。来回1万次,累死。
  • 有缓冲:先把100块砖搬上小推车(用户缓冲区),然后一次性推到工地(一次系统调用)。

7.2 三种缓冲类型

类型 刷新条件 典型应用 生活类比
无缓冲 立即输出 stderr 说话没有任何延迟
行缓冲 遇到\n或缓冲区满 终端stdout 说话按句子(遇到句号才发出去)
全缓冲 缓冲区满或主动fflush/关闭 普通磁盘文件 攒够一车才发

7.3 经典坑1:重定向后 printf 不输出?

c 复制代码
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main() {
    close(1);
    int fd = open("full.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    printf("Hello, full buffer!\n");   // 注意有换行符
    close(fd);
    return 0;
}

现象 :执行后 full.txt 为空。
原因

  • stdout 在未重定向时是行缓冲(因为连接的是终端)。
  • 一旦重定向到普通文件,C库会自动将缓冲模式改为全缓冲
  • 在全缓冲下,\n 不再触发刷新,只有当缓冲区写满(通常是4096字节)或主动 fflush 或进程正常退出时才会刷新。

解决方案:手动刷新

c 复制代码
printf("Hello, full buffer!\n");
fflush(stdout);   // 强制刷新
// 或者 fclose(stdout); 但会导致后续无法输出

7.4 经典坑2:fork 后缓冲区内容写了两遍

c 复制代码
#include <stdio.h>
#include <unistd.h>

int main() {
    printf("Hello ");
    write(1, "World ", 6);
    fork();
    return 0;
}
  • 直接运行 (输出到终端):Hello World 一次。
  • 重定向到文件./a.out > out):Hello World Hello World Hello 出现了两次,World 一次)。

原因拆解

  • write 是系统调用,直接进内核,没有用户态缓冲区 → 只写一次。
  • printf 先写到用户缓冲区(stdout 的缓冲区)。重定向到文件后变成全缓冲,数据留在缓冲区里。
  • fork 时子进程复制了父进程的整个地址空间,也包括那份未刷新的缓冲区
  • 进程退出时,父子各自刷新缓冲区,导致同一份数据写了两遍。

教训 :在 fork 之前,务必 fflush(NULL) 刷新所有输出流。


8. 手撕一个简易 stdio 库

为了彻底搞懂 C 库与系统调用的关系,我们实现一个极简版的 stdio(仅支持输出和行缓冲)。

8.1 头文件 mystdio.h

c 复制代码
#pragma once

#define MAX 1024
#define LINE_FLUSH 1

typedef struct {
    int fileno;                // 文件描述符
    char outbuffer[MAX];       // 用户缓冲区
    int bufferlen;             // 当前缓冲区中有效字节数
    int flush_method;          // 刷新方式(这里只实现行缓冲)
} MyFile;

MyFile *MyFopen(const char *path, const char *mode);
void MyFclose(MyFile *fp);
int MyFwrite(MyFile *fp, void *data, int len);
void MyFFlush(MyFile *fp);

8.2 实现文件 mystdio.c

c 复制代码
#include "mystdio.h"
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>

static MyFile *BuyFile(int fd, int flag) {
    MyFile *fp = (MyFile*)malloc(sizeof(MyFile));
    if (!fp) return NULL;
    fp->fileno = fd;
    fp->bufferlen = 0;
    fp->flush_method = LINE_FLUSH;
    memset(fp->outbuffer, 0, MAX);
    return fp;
}

MyFile *MyFopen(const char *path, const char *mode) {
    int fd = -1;
    int flag = 0;
    if (strcmp(mode, "w") == 0) {
        flag = O_CREAT | O_WRONLY | O_TRUNC;
        fd = open(path, flag, 0666);
    } else if (strcmp(mode, "a") == 0) {
        flag = O_CREAT | O_WRONLY | O_APPEND;
        fd = open(path, flag, 0666);
    } else if (strcmp(mode, "r") == 0) {
        flag = O_RDONLY;
        fd = open(path, flag);
    } else {
        return NULL;
    }
    if (fd < 0) return NULL;
    return BuyFile(fd, flag);
}

void MyFFlush(MyFile *fp) {
    if (fp->bufferlen <= 0) return;
    write(fp->fileno, fp->outbuffer, fp->bufferlen);
    fsync(fp->fileno);      // 强制刷新到磁盘(可选)
    fp->bufferlen = 0;
}

int MyFwrite(MyFile *fp, void *data, int len) {
    // 如果缓冲区放不下,先刷新
    if (fp->bufferlen + len > MAX) {
        MyFFlush(fp);
    }
    memcpy(fp->outbuffer + fp->bufferlen, data, len);
    fp->bufferlen += len;

    // 行缓冲检测:如果最后一个字符是 '\n',刷新
    if ((fp->flush_method & LINE_FLUSH) && 
        fp->outbuffer[fp->bufferlen - 1] == '\n') {
        MyFFlush(fp);
    }
    return len;
}

void MyFclose(MyFile *fp) {
    if (!fp) return;
    MyFFlush(fp);
    close(fp->fileno);
    free(fp);
}

8.3 测试代码

c 复制代码
#include "mystdio.h"
#include <string.h>

int main() {
    MyFile *fp = MyFopen("./mylog.txt", "w");
    if (!fp) return 1;
    
    MyFwrite(fp, "Hello ", 6);
    MyFwrite(fp, "World!\n", 7);   // 换行触发刷新
    MyFwrite(fp, "No newline here, will be flushed on close.", 42);
    
    MyFclose(fp);                   // 关闭时刷新剩余数据
    return 0;
}

关键领悟

  • MyFwrite 并没有直接调用 write,而是先存入用户缓冲区。
  • 只有在遇到 \n 或缓冲区满或主动 fflush 时,才真正系统调用。
  • 这就是标准 printf 默认行缓冲的内核机制。

9. 造个简易Shell:亲手实现重定向

一个命令解释器(shell)必须能处理重定向。下面给出核心代码片段(基于用户提供的 minishell 简化)。

9.1 识别重定向

解析命令行字符串,找出 >>>< 符号及后面的文件名。假设我们已经解析出了 redir_typefilename

9.2 子进程执行重定向

c 复制代码
void do_redirect(int redir_type, char *filename) {
    int fd = -1;
    if (redir_type == INPUT_REDIR) {      // <
        fd = open(filename, O_RDONLY);
        dup2(fd, 0);
    } else if (redir_type == OUTPUT_REDIR) { // >
        fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0644);
        dup2(fd, 1);
    } else if (redir_type == APPEND_REDIR) { // >>
        fd = open(filename, O_WRONLY | O_CREAT | O_APPEND, 0644);
        dup2(fd, 1);
    }
    if (fd > 0) close(fd);   // dup2后可以关闭原fd
}

然后在 fork 后的子进程中:

c 复制代码
pid_t pid = fork();
if (pid == 0) {
    // 子进程
    do_redirect(redir_type, filename);
    execvp(gargv[0], gargv);
    exit(1);
}
wait(NULL);

⚠️ 关键点 :重定向必须在 exec 之前执行,因为 exec 会替换进程代码,但文件描述符表不变。这样新程序一启动,它的 stdin/stdout 就已经指向文件了。


10. 易错点大杂烩(表格总结)

🔥 易错场景 ❌ 错误示例 ✅ 正确做法
fread 返回值误判 if(fread(buf, sizeof(buf), 1, fp) == 0) while(fread(buf,1,sizeof(buf),fp) > 0)
创建文件权限不对 open(path, O_CREAT, 0644) 忽略 umask umask(0) 或后续 chmod
重定向后 printf 不输出 以为有 \n 就会刷新,忘文件是全缓冲 手动 fflush(stdout)
fork 前有未刷新缓冲区 子进程复制缓冲区,导致重复输出 fflush(NULL)fork
write 字符串忘记长度 write(fd, "hi", sizeof("hi")) 多写了 \0 strlen 或明确长度
dup2 参数顺序颠倒 dup2(1, fd) 想重定向 stdout 记住 dup2(old, new),old 是源,new 是目标
关闭文件描述符后还使用 close(fd); read(fd,...) 确保不再使用了再关闭
在父进程执行重定向 重定向后父进程也受影响,shell 乱套 必须在子进程中做重定向
误认为 close(fd) 会刷新 stdout 缓冲区 见7.3 关闭底层 fd 不影响 C 库缓冲区,应 fflushfclose

11. 思考与进阶

  1. 为什么 stderr 通常不带缓冲?

    为了让错误信息能够立即输出,即使在程序崩溃时也能看到。

  2. 全缓冲和行缓冲如何抉择?

    磁盘文件用全缓冲提升性能;交互式终端用行缓冲平衡实时性与效率。

  3. 如何查看进程打开的文件描述符?
    ls -l /proc/<PID>/fd/

  4. fsyncfflush 的区别?

    • fflush:将 C 库用户缓冲区数据刷新到内核缓冲区(page cache)。
    • fsync:强制将内核缓冲区数据写入磁盘,确保持久化。
  5. 你能实现一个带缓冲区的 getchar 吗?

    尝试自己实现一个 MyFgetc,利用读缓冲区减少 read 调用。

  6. FILE 结构体内部长什么样?

    C 库的 FILE 结构体至少包含一个文件描述符 _fileno 和各种缓冲区指针。你可以 /usr/include/libio.h 中查看 struct _IO_FILE


📚 参考资料

  • 《深入理解Linux内核》第三版
  • Linux man pages: open, dup2, fopen, setbuf

本文从生活例子入手,逐步深入到内核源码,最后手写代码验证。希望你能真正理解 Linux 文件 IO 的每一层抽象,写出高效、健壮的系统程序。

如果觉得本文对你有帮助,请点赞、收藏、分享~
有任何疑问,欢迎评论区留言讨论。

相关推荐
fish_xk2 小时前
Linux操作系统
linux
zh路西法2 小时前
【udev重命名详细教程】放弃硬编码,从重命名开始
linux·机器人
studytosky2 小时前
【高并发内存池】线程缓存核心原理与实现
linux·服务器·git·缓存
lihao lihao2 小时前
Linux文件与fd
java·linux·算法
X7x52 小时前
网络守护者:STP端口角色与状态转换深度解析
运维·网络·网络协议·信息与通信·stp
墨者阳2 小时前
可观・可控・可治:DB运维平台架构设计与实践
运维·数据库·架构·自动化·数据可视化
奇妙之二进制2 小时前
fastdds源码分析之EDP协议
运维·服务器·网络
treacle田2 小时前
达梦数据库-DMDIS安装与基本使用-记录总结
linux·运维·服务器·达梦dmdis
我星期八休息2 小时前
Linux 进程核心原理全解:从冯诺依曼体系到进程控制全链路深度剖析
大数据·linux·服务器·开发语言·数据结构·c++·散列表