Linux 文件 IO:缓冲区、重定向与一切皆文件

引言:

在第一篇中,我们掌握了 Linux 文件 IO 的系统调用------openreadwriteclosedup2,也理解了文件描述符 fd 背后的数据结构 struct filefiles_struct。现在我们要追问一个更根本的问题:为什么 Linux 要把键盘、显示器、磁盘、管道、套接字这些完全不同的东西,全部抽象成"文件"?这个设计背后的机制是什么?本章将给出答案

4. 理解"一切皆文件"

一、"一切皆文件"的两层含义

第一层:Windows 中是文件的东西,Linux 中也是文件。

普通文本文件、图片、可执行程序、目录------这些在 Windows 中就是"文件"的东西,在 Linux 中同样是文件。

第二层:Windows 中不是文件的东西,Linux 中也抽象成了文件。

进程信息、磁盘分区、显示器、键盘、网卡、管道、套接字------这些在 Windows 中各有各的操作接口,在 Linux 中全部被抽象成了"文件",可以用访问文件的方法来访问它们。


二、这样做的好处:一套 API,操作一切资源

开发者仅需要掌握一套 API,即可调取 Linux 系统中绝大部分的资源。

  • 读操作 :无论是读普通文件、读系统状态(/proc)、读管道、读 socket,都可以用 read 函数

  • 写操作 :无论是写普通文件、修改系统参数、写管道、写 socket,都可以用 write 函数

  • 打开/关闭openclose 适用于所有可被打开的资源

这就是"统一接口"的巨大优势------学习成本极低,代码复用性极高。


三、底层实现机制:struct file + file_operations

"一切皆文件"不是靠嘴实现的,而是靠内核中的两个核心数据结构:

3.1 struct file

之前学过,每当打开一个文件时,内核都会创建一个 struct file 对象来描述这次打开操作的状态(偏移量、打开模式、引用计数等)。这个结构体定义在 <linux/fs.h> 中。

3.2 file_operations ------ 多态的函数指针表

struct file 中有一个关键成员:f_op 指针,它指向一个 file_operations 结构体:

cpp 复制代码
struct file {
    ...
    struct inode *f_inode; /* cached value */
    const struct file_operations *f_op;
    ...
    atomic_long_t f_count; // 表⽰打开⽂件的引⽤计数,如果有多个⽂件指针指向它,就会增加f_count的值。
    unsigned int f_flags; // 表⽰打开⽂件的权限
    fmode_t f_mode; // 设置对⽂件的访问模式,例如:只读,只写等。所有的标志在头⽂件<fcntl.h> 中定义
    loff_t f_pos; // 表⽰当前读写⽂件的位置
    ...
} __attribute__((aligned(4))); /* lest something weird decides that 2 is OK */struct file {
    // ... 其他成员 ...
    const struct file_operations *f_op;  // ← 指向操作函数集
    // ...
};

struct file_operations {
    struct module *owner;
    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 *);
    loff_t (*llseek) (struct file *, loff_t, int);
    // ... 还有很多函数指针 ...
};

moduleIX/kernel_3.10.0_fs.h · 光电笑映/Linux - 码云 - 开源中国这里是更详细说明的链接

file_operations 中除了 owner 之外,其余成员全部是函数指针。每个函数指针对应一个系统调用:read 系统调用最终会调用 f_op->readwrite 系统调用最终会调用 f_op->write

3.3 不同设备,不同的实现

关键在于:不同的文件类型,其 file_operations 中这些函数指针指向的函数是不同的。

但对用户程序来说,调用接口始终只有 read(fd, buf, size)write(fd, buf, size)。内核会根据 fd 找到 struct file,再通过 f_op 调用对应的函数------这就是面向对象中的"多态"在 C 语言中的经典实现。

四、完整调用链

用户只调了一个 write,内核根据文件类型自动分派到不同的底层实现。这就是"一切皆文件"的核心机制。


五、"先描述,再组织"在"一切皆文件"中的体现


六、总结

"一切皆文件"的本质不是把所有东西都存进磁盘,而是用统一的"文件"接口(open/read/write/close)来操作一切资源。内核通过 struct file 中的 file_operations 函数指针表,实现了"同一个接口,不同底层实现"的多态机制------对上提供统一的系统调用,对下分派到不同的设备驱动函数。这就是 Linux 用一套 API 管理所有资源的底层秘密。

5.缓冲区

上一篇 3.6 节提到的 page cache 是内核级缓冲区 ,由 Linux 内核管理。本章重点讨论的是用户级缓冲区,由 C 标准库管理。两层缓冲各司其职:用户级缓冲减少系统调用次数,内核级缓冲减少磁盘 IO 次数。

5.1 什么是缓冲区

缓冲区是内存空间中预留出来的一段存储空间,用来临时存放输入或输出的数据。

它不是磁盘上的区域,而是内存的一部分。数据在从源头到目的地的路上,先在缓冲区里"歇个脚",等时机合适再一次性搬走。

5.2 为什么需要缓冲区

一、没有缓冲区:每次读写都触发系统调用

如果读写文件时不使用缓冲区,每次对文件进行一次读写操作,都需要直接调用系统调用(read/write)来操作磁盘。

一次系统调用的代价:

  1. CPU 状态切换:从用户空间切换到内核空间

  2. 进程上下文切换:保存当前进程状态,加载内核执行环境

  3. 磁盘 IO 等待:磁盘的寻道和读写是毫秒级的,CPU 只能空等

如果每读写一个字节都走一遍这个流程,程序的大量 CPU 时间将消耗在"切换"和"等待"上,而不是真正的数据处理上。


二、缓冲区的两大核心优势

缓冲区的解决思路:批量搬运

核心思想:一次多搬点,减少搬运次数。

计算机对缓冲区的操作(内存读写)远快于对磁盘的操作,应用缓冲区可大幅提高计算机的运行速度。

C++ 的 allocator 和 C 库的缓冲区是同一个思想在不同领域的不同应用:缓冲区攒数据减少 write 次数,allocator 攒内存减少 brk/mmap 次数。都是"先向 OS 拿一大块,自己管理,按需分发",目的都是减少用户态与内核态的频繁切换。


四、生活类比:打印机

打印机是低速设备,打印速度远慢于 CPU 的处理速度。

  • **没有缓冲区:**CPU 把一页文档发给打印机,然后停下来等打印机一页页打完,才能继续处理其他任务。
  • **有缓冲区:**CPU 把整份文档一次性发送到打印机的缓冲区中,然后立刻返回处理其他任务。打印机从缓冲区中自行逐步打印,不再占用 CPU。

缓冲区使得低速的输入输出设备和高速的 CPU 能够协调工作,避免低速设备拖累 CPU,解放出 CPU 去处理其他任务。

总结:对于用户级缓冲区通过减少系统调用以提高程序效率,对于内核缓冲区, 对于内核缓冲区,通过减少磁盘 IO 次数以提高系统整体性能。

5.3 缓冲类型

一、标准 I/O 提供的三种缓冲模式

标准 I/O(C 标准库)为 FILE * 流提供了三种缓冲类型,控制数据何时从用户态缓冲区通过系统调用交给内核。

  1. 全缓冲

填满整个缓冲区后才执行 I/O 系统调用。

  • 典型场景:对磁盘文件的操作通常使用全缓冲

  • 刷新时机 :缓冲区满、手动 fflush、文件关闭(fclose)、程序正常退出

  1. 行缓冲

遇到换行符 \n 时,执行 I/O 系统调用。

  • 典型场景 :流涉及终端时(例如标准输入 stdin 和标准输出 stdout 连接到终端)

  • 刷新时机 :遇到 \n、缓冲区满、手动 fflush、程序正常退出

  • 注意:即使没遇到换行符,只要缓冲区满了,也会触发刷新

  1. 无缓冲

不对字符进行缓存,直接调用系统调用。系统称为写透模式

  • 典型场景 :标准错误流 stderr

  • 目的:错误信息需要立即显示,不能被缓冲延迟

  • 每个字符都立即触发一次 write 系统调用

刷新模式在5.4五有详细的例子讲解,这里先引出概念


二、缓冲模式总结表


三、特殊情况也会触发缓冲区的刷新

除了上述默认刷新方式外,以下情况也会引发缓冲区刷新:

  1. 缓冲区满时

  2. 执行 fflush 语句时

  3. 调用 fclose 关闭文件时

  4. 程序正常退出(returnexit)时


四、验证实验:缓冲区刷新时机

cpp 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
    close(1);
    int fd = open("log.txt", O_CREAT | O_TRUNC | O_WRONLY, 0664);

    // 库函数:写入 C 库缓冲区
    printf("hello world\n");
    printf("hello world\n");
    printf("hello world\n");

    // 系统调用:直接写入内核 page cache
    const char *msg = "this is write\n";
    write(fd, msg, strlen(msg));

    // close(fd);  // 先注释掉
    return 0;
}

运行结果(注释 close(fd)):

bash 复制代码
log.txt 内容:
this is write
hello world
hello world
hello world

运行结果(保留 close(fd)):

bash 复制代码
log.txt 内容:
this is write

五、现象分析

为什么保留 close(fd) 后,printf 的三行数据消失了?

printf/fprintf/fputs 等库函数写入的是 C 库的用户态缓冲区,不是内核缓冲区。

  • 既没有手动 fflush

  • 也不满足行缓冲的刷新条件(stdout 已被重定向到普通文件,变成了全缓冲\n 不再触发刷新)

  • close(fd)return 0 之前执行,关闭了 fd=1 的通道

  • 程序退出时 C 库尝试 fflush,调用 write(1, ...),但 fd=1 已经无效,数据丢失

解决方法:在 close(fd) 之前强制刷新缓冲区。

cpp 复制代码
fflush(stdout);  // 强制将 C 库缓冲区的数据通过 write 交给内核
close(fd);

六、缓冲区在哪里?------ FILE 结构体

为什么任何一个被打开的文件都有一个缓冲区?

因为 C 库为每个打开的 FILE * 流在堆上 malloc 了一个 FILE 结构体,这个结构体内部维护了缓冲区及其管理指针。

FILE 结构体中的缓冲区相关字段:

cpp 复制代码
typedef struct _IO_FILE FILE;
struct _IO_FILE {
    int   _flags;           // 标志位
    char *_IO_read_ptr;     // 当前读指针
    char *_IO_read_end;     // 读区域末尾
    char *_IO_read_base;    // 读区域起始
    char *_IO_write_base;   // 写区域起始
    char *_IO_write_ptr;    // 当前写指针
    char *_IO_write_end;    // 写区域末尾
    char *_IO_buf_base;     // 缓冲区起始地址  ← 这就是缓冲区!
    char *_IO_buf_end;      // 缓冲区末尾
    int   _fileno;          // 封装的底层文件描述符 fd
    // ... 更多字段
};

缓冲区就是 _IO_buf_base_IO_buf_end 之间的这段内存空间。读写指针在这段空间内移动,决定当前读写位置。

fopen 的完整流程:

  1. 调用 open 系统调用,获取 fd

  2. 在堆上 malloc 一个 FILE 结构体

  3. 在堆上 malloc 一块内存作为缓冲区(默认 8192 字节),_IO_buf_base 指向它

  4. 将 fd 存入 _fileno

  5. 根据文件类型设置缓冲模式

  6. 返回 FILE * 指针给用户

5.4 FILE ------ C 库对 fd 的封装

一、核心结论

因为 I/O 相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过 fd 访问的 。因此 C 库中的 FILE 结构体内部,必定封装了 fd

_fileno 字段就是 open 返回的那个文件描述符。


二、printf/fwritewrite 的本质区别

printf/fwrite 的用户态缓冲区是 C 标准库在系统调用之上"二次加上"的,目的是减少系统调用次数,提高性能。


三、缓冲区不属于内核,属于 C 标准库

计算机数据流动本质就是拷贝,所以计算机运行效率发展,如更快的 CPU 主频多级缓存(L1/L2/L3) 更快的内存,本质就是增加拷贝效率,所有性能优化,归根结底就两个方向:提高单次拷贝速度,减少不必要拷贝次数。


四、强制将内核缓冲区刷新到文件方法

核心系统调用

注意: fdatasyncfsync 少刷一些 inode 信息,适合只关心数据内容、不关心修改时间的场景。性能也比较高


函数原型

cpp 复制代码
#include <unistd.h>

int fsync(int fd);          // 刷数据 + 元数据
int fdatasync(int fd);      // 只刷数据
void sync(void);            // 全系统刷盘
int syncfs(int fd);         // 刷 fd 所在的文件系统

O_SYNC:打开文件时强制每次写入都同步

cpp 复制代码
int fd = open("file.txt", O_WRONLY | O_CREAT | O_TRUNC | O_SYNC, 0644);
write(fd, buf, len);  // 这次 write 等价于 write + fsync,自动等磁盘确认

一般只对数据库的 WAL 日志这类关键文件使用 O_SYNC


fsync(fd)fflush 的对比


五、完整数据流

六、经典验证实验:fork 复制缓冲区

cpp 复制代码
int main()
{
    const char *msg0="hello printf\n";
    const char *msg1="hello fprintf\n";
    const char *msg2="hello fwrite\n";
    const char *msg3="hello write\n";
    //库函数
    printf("%s", msg0);
    fprintf(stdout,"%s",msg1);
    fwrite(msg2,strlen(msg2),1,stdout);
    //系统调用
    write(1, msg3, strlen(msg3));

    //fork();
    return 0;
}

运行结果:

cpp 复制代码
#无fork
xqq@ubuntu-server:~/linux/moduleIX/testcache$ ./file
hello printf #涉及终端,行缓冲,立即刷新
hello fprintf
hello fwrite
hello write
xqq@ubuntu-server:~/linux/moduleIX/testcache$ ./file >log.txt #文件操作,全缓冲
xqq@ubuntu-server:~/linux/moduleIX/testcache$ cat log.txt
hello write
hello printf
hello fprintf
hello fwrite

#加入fork之后
xqq@ubuntu-server:~/linux/moduleIX/testcache$ ./file
hello printf
hello fprintf
hello fwrite
hello write
xqq@ubuntu-server:~/linux/moduleIX/testcache$ ./file >log.txt
xqq@ubuntu-server:~/linux/moduleIX/testcache$ cat log.txt
hello write#系统调用打印一次
hello printf#库函数打印两次
hello fprintf
hello fwrite
hello printf
hello fprintf
hello fwrite

场景一:没有 fork

直接输出到终端(标准输出为终端)

  • 此时 stdout行缓冲printffprintffwrite 输出的字符串都以 \n 结尾,所以每调用一个函数,缓冲区被换行符触发刷新,立即写入终端。

  • write 是系统调用,直接写入文件描述符 1。由于四个函数依次调用并立即输出,顺序严格按照代码逻辑。

重定向到文件(> log.txt

  • stdout 被重定向到普通文件时,标准 IO 变为全缓冲

  • printffprintffwrite 的数据并没有立刻写入文件,而是暂存在 stdout 的缓冲区中。

  • write 是系统调用,没有用户态缓冲,直接写入内核并最终落入文件。所以它第一个到达文件。

  • main 结束(return 0)时,C 运行时调用 exit,它会冲刷所有标准 IO 流,此时 stdout 缓冲区中的三条消息才被写入文件,顺序就是它们进入缓冲区的顺序。


场景二:加入 fork

直接输出到终端

输出与没有 fork 完全一样,四条消息各出现一次,顺序正确。

原因:行缓冲时,fork 执行前缓冲区已经被 \n 刷空,父子进程都没有残留的缓冲区数据,fork 不影响终端输出。

重定向到文件(> log.txt

重定向到文件,即输出到文件里,对磁盘文件的操作通常使用全缓冲,所以缓冲模式从原本向stdout的行缓重变成了全缓冲

这里发生了关键变化:write(系统调用) 只出现一次,而库函数的三条消息全部重复了一次。

原因分析:

  1. 重定向后 stdout 全缓冲。

  2. printffprintffwrite 三条消息进入缓冲区,尚未写入文件。

  3. write 是系统调用,无缓冲,此时直接写入文件,所以它最先出现在文件里,并且只出现一次。

  4. 随后执行 fork()

    • fork 会完整复制父进程的地址空间,包括 stdout用户态缓冲区

    • 此时父子进程各自拥有一份相同的缓冲区,里面都包含了那三条库函数消息。

  5. 父进程、子进程各自最终执行 return 0exit → 冲刷 stdout

    • 于是两份完全相同的缓冲区内容被先后写入同一个文件。

    • 文件里就出现了两组 hello printf / hello fprintf / hello fwrite

重定向的底层原理(dup2 替换 fd[1] 的指向)已在上一篇 3.7 节详细讲解,此处不再展开。

5.5 简单设计⼀下libc库

实现代码:

mystdio.c

cpp 复制代码
#include"mystdio.h"
//C 语言只给用户头文件(.h,声明)和编译好的库文件(.so/.a,二进制实现)
//。源码(.c)不公开。用户拿着头文件编译自己的代码,链接器把库的机器码和用户的机器码合并成可执行文件


//static 让 BuyFile 成为 mystdio.c 的私有函数,
//用户只能通过 MyFopen 间接使用它,不能直接调用。
//这实现了封装,避免了符号冲突,和面向对象的 private 是一个道理
static MYFILE* BuyFile(int fd,int mode)
{
    MYFILE*file=(MYFILE*)malloc(sizeof(MYFILE));
    if(file==NULL)
    {
        perror("malloc fail!");
        return NULL;
    }
    memset(file->out,0,sizeof(file->out));
    file->bufflen=0;
    file->fileno=fd;
    file->flag=mode;
    // 如果是终端设备,用行缓冲;否则用全缓冲
    if (isatty(fd))
        file->flush_method = LINE_FLUSH;
    else
        file->flush_method = FULL_FLUSH;
    return file;
}

MYFILE* MyFopen(const char *pathname, const char *mode)
{
    int fd=-1;
    int _mode=0;
    if(strcmp(mode,"w")==0)
    {
        _mode=O_WRONLY|O_CREAT|O_TRUNC;
        fd=open(pathname,_mode,0666);
    }
    else if(strcmp(mode,"r")==0)
    {
        _mode=O_RDONLY;
        fd=open(pathname,_mode);
    }
    else if(strcmp(mode,"a")==0)
    {
        _mode=O_WRONLY|O_CREAT|O_APPEND;
        fd=open(pathname,_mode,0666);
    }
    else if(strcmp(mode,"w+")==0)
    {
        _mode=O_RDWR|O_CREAT|O_TRUNC;
        fd=open(pathname,_mode,0666);
    }
    else if(strcmp(mode,"r+")==0)
    {
        _mode=O_RDWR;
        fd=open(pathname,_mode);
    }
    else if(strcmp(mode,"a+")==0)
    {
        _mode=O_RDWR|O_CREAT|O_APPEND;
        fd=open(pathname,_mode,0666);
    }
    else
    {
        //...
    }
    if(fd<0)
        return NULL;
    return BuyFile(fd,_mode);
}

void MyFclose(MYFILE *stream)
{
    if(stream==NULL||stream->fileno<0) return;
    MyFFlush(stream);
    close(stream->fileno);
    free(stream);
}

//传入 onst char * 类型的字符串,不需要强制类型转换
int MyFwrite(MYFILE*file,const void* src,int len)
{//写入就是拷贝
    memcpy(file->out+file->bufflen,src,len);
    file->bufflen+=len;
//写入尝试刷新
    if((file->flush_method & LINE_FLUSH)&&file->out[file->bufflen-1]=='\n')
    {
        MyFFlush(file);
    }
    return 0;
}

void MyFFlush(MYFILE*file)
{
    if(file==NULL||file->bufflen<=0) return;
    //将缓冲区里的数据写到内核缓冲区里
    int n=write(file->fileno,file->out,file->bufflen);
    if(n<0)
        perror("write");
    file->bufflen=0;
    memset(file->out,0,MAX);
}

mystdio.h

cpp 复制代码
#pragma once
#include<stdio.h>
#include<unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include<string.h>
#include<stdlib.h>
#define MAX 1024

//定义刷新模式
#define NONE_FLUSH (1<<0)
#define LINE_FLUSH (1<<1)
#define FULL_FLUSH (1<<2)

typedef struct IO_FIILE
{
    int fileno;//文件描述符
    int flag;//打开方式
    char out[MAX];//缓冲区
    int bufflen;//有效元素
    int flush_method;
}MYFILE;

MYFILE* MyFopen(const char *pathname, const char *mode);
void MyFclose(MYFILE *stream);
int MyFwrite(MYFILE*,const void* str,int len);
void MyFFlush(MYFILE*);

usercode.c

cpp 复制代码
#include"mystdio.h"

int main()
{
    const char* msg="hello world\n";
    MYFILE* fp=MyFopen("test.txt","a");
    if(!fp)
    {
        perror("MyFopen fail");
        return 1;
    }
    int cnt=10;
    while(cnt--)
    {
        MyFwrite(fp,msg,strlen(msg));
        printf("缓冲区:%s\n",fp->out);
        //MyFFlush(fp);//下面进行讲解
        sleep(1);
    }
    MyFclose(fp);
    return 0;
} 

验证缓冲区:

监测脚本:

bash 复制代码
while :; do cat test.txt;sleep 1;echo "#########################";done

可以看到,缓冲区越来越满,此时文件是空的,直到循环结束调用MyFclose(fp),强制刷新缓冲区,这样一次性全部刷到了系统内核缓冲区,写入磁盘文件

加上MyFFlush(fp);后:

相关推荐
淼淼爱喝水11 小时前
【Ansible 入门实战】三种变量详解
java·linux·数据库·ansible·playbook
Languorous.11 小时前
Linux mkdir、rmdir 命令详解——目录的创建与删除(新手零踩坑)
linux·运维·服务器
樱桃花下的小猫11 小时前
腐蚀Rust-EAC 及官方验证关闭教程
服务器·rust·云鸢互联·零门槛一键开服·腐蚀rust服务器
酷道11 小时前
CentOS 7 安装 Docker
linux·docker·centos
Python-AI Xenon11 小时前
双网卡双网关服务器策略路由配置与持久化完全指南
linux·运维·计算机网络·网络故障排查
最后一个bug11 小时前
ubuntu24.04在docker下迁移gitlab16
linux·运维·docker
yyyyy_abc11 小时前
负载均衡与高可用
运维·负载均衡
遇印记11 小时前
软考知识点(windows系统管理与命令)
运维·服务器·网络·windows·ddos
风曦Kisaki11 小时前
# Linux运维Day03:Nginx 反向代理(服务集群)、负载均衡、四层调度与优化
linux·运维·nginx