【Linux 系统篇(二十六)】文件(三): 深入理解 “一切皆文件”、缓冲区


大家好,欢迎来到 huangjin007_ 的博客
⭐ 个人主页:huangjin007_
🔥 文章收录专栏:Linux 内功修炼手册(系统篇)
总会有一些坚持
能从冰封的土地里
培育出十万朵怒放的蔷薇


Linux 系统篇(二十六) ------ 一切皆文件、缓冲区

文章目录

  • [Linux 系统篇(二十六) ------ 一切皆文件、缓冲区](#Linux 系统篇(二十六) —— 一切皆文件、缓冲区)
    • [一、洞悉 Linux 的核心哲学------"一切皆文件"](#一、洞悉 Linux 的核心哲学——“一切皆文件”)
      • [1.1 什么是"一切皆文件"?](#1.1 什么是“一切皆文件”?)
      • [1.2 图解 VFS 与 C 语言的多态思想](#1.2 图解 VFS 与 C 语言的多态思想)
        • [1.2.1 架构分层:VFS 抽象层与驱动开发层](#1.2.1 架构分层:VFS 抽象层与驱动开发层)
        • [1.2.2 源码剖析:虚函数表的物理体现](#1.2.2 源码剖析:虚函数表的物理体现)
        • [1.2.3 多态的运行机制](#1.2.3 多态的运行机制)
        • [1.2.4 为什么这么设计?](#1.2.4 为什么这么设计?)
    • 二、为什么需要缓冲区机制?
      • [2.1 什么是缓冲区?](#2.1 什么是缓冲区?)
      • [2.2 为什么要引入缓冲区机制?(提高效率!)](#2.2 为什么要引入缓冲区机制?(提高效率!))
      • [2.3 缓冲区的三种类型](#2.3 缓冲区的三种类型)
      • [2.4 经典案例](#2.4 经典案例)
    • [三、深入 C 标准库------FILE 结构体与用户级缓冲区](#三、深入 C 标准库——FILE 结构体与用户级缓冲区)
      • [3.1 库函数 vs 系统调用](#3.1 库函数 vs 系统调用)
      • [3.2 解剖 FILE 结构体](#3.2 解剖 FILE 结构体)
    • [四、实战------手写一个简易的 libc 库(封装缓冲区)](#四、实战——手写一个简易的 libc 库(封装缓冲区))
      • [4.1 设计头文件 `mystdio.h`](#4.1 设计头文件 mystdio.h)
      • [4.2 实现 `mystdio.c`](#4.2 实现 mystdio.c)
      • [4.3 测试代码 `usercode.c`](#4.3 测试代码 usercode.c)
    • 结语:

一、洞悉 Linux 的核心哲学------"一切皆文件"

1.1 什么是"一切皆文件"?

  如果你有 Windows 的使用经验,你会发现"文件"就是硬盘上的 txt、exe、图片。但在 Linux 系统中,这个概念被无限扩大了。

  • Windows 中的文件,在 Linux 中当然也是文件。
  • Windows 中不是文件的东西 ,比如进程、磁盘、显示器、键盘等硬件设备,在 Linux 中通通被抽象成了文件。
  • 甚至在网络编程中使用的 Socket(套接字),以及用于进程间通信的管道(Pipe),本质上也是文件。

这样做的好处是什么?

  一句话总结:一套 API 走天下。

  开发者仅仅需要使用一套文件操作 API,就可以调取 Linux 系统中绝大部分的资源。

  • 几乎所有的读取 操作(读文件、读系统状态、读 PIPE),都可以用 read 函数。
  • 几乎所有的修改 操作(改文件、改系统参数、写 PIPE),都可以用 write 函数。

1.2 图解 VFS 与 C 语言的多态思想

  结合下面两张图,我们可以清晰地看到 Linux 架构中极其精妙的"多态"设计。Linux 内核虽然由纯 C 语言编写,缺乏 C++ 的 virtual 关键字,但它通过结构体嵌套函数指针的方式,完美手写了一套"虚函数表(vtable)",实现了面向对象中的多态特性。

1.2.1 架构分层:VFS 抽象层与驱动开发层

  从上面的架构图可以看出,Linux 将文件系统划分为上下两层,这正是多态中"基类"与"子类"的体现:

  1. OS 层(VFS 抽象层 ------ 基类)

    • 一切皆文件:VFS(虚拟文件系统)屏蔽了底层硬件的差异。无论是磁盘、显示器、键盘还是网卡,在 VFS 眼中都被抽象为"文件"。
    • 统一接口 :VFS 定义了统一的抽象接口(如 read、write、open、release),采用 struct file 结构体来描述一个打开的文件。
    • 多态的实现机制:在结构体中定义函数指针!VFS 层只认这些统一的函数签名。
  2. 设备层(驱动开发层 ------ 子类)

    • 各个硬件设备拥有自己的 struct device 结构体(包含 type、status、其他属性、list_head 等)。
    • 它们负责具体实现:磁盘驱动实现 read_disk / write_disk,显示器实现 read_screen / write_screen。它们各自处理自己的硬件逻辑(如磁盘寻道、显存刷新)。
1.2.2 源码剖析:虚函数表的物理体现

  当一个用户在 Linux 中打开一个文件时,操作系统为了管理它,会在内核中创建一个名为 struct file 的结构体(定义在 /usr/src/kernels/.../include/linux/fs.h 下)。这个结构体就是我们操作文件的"通行证"。

  我们先来看看这个结构体里几个关键成员:

c 复制代码
struct file {
    // ...
    struct inode *f_inode;   /* 缓存的值,指向具体的文件实体 */
    const struct file_operations *f_op; /* 指向操作函数的指针集合 */
    atomic_long_t f_count;   /* 引用计数,多个指针指向它时增加 */
    unsigned int f_flags;    /* 打开文件的权限标志,如 O_RDONLY */
    fmode_t f_mode;          /* 访问模式,如只读、只写 */
    loff_t f_pos;            /* 当前读写的偏移量 */
    // ...
} __attribute__((aligned(4)));

  重中之重是 f_op 指针! 它指向了一个名为 file_operations 的结构体。这行代码是整个多态机制的核心枢纽------每个打开的文件实例(struct file)都绑定了一个指向 file_operations 的指针,VFS 就是通过 file->f_op 找到具体应该调用哪个驱动的函数。

  这个 file_operations 结构体是整个 Linux 设备驱动的灵魂,里面除了 struct module *owner 外,其余全都是函数指针。这就是 C 语言版本的"虚函数表":

c 复制代码
struct file_operations {
    struct module *owner;
    loff_t (*llseek) (struct file *, loff_t, int);
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
    ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
    int (*readdir) (struct file *, void *, filldir_t);
    unsigned int (*poll) (struct file *, struct poll_table_struct *);
    int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
    int (*mmap) (struct file *, struct vm_area_struct *);
    int (*open) (struct inode *, struct file *);
    int (*flush) (struct file *, fl_owner_t id);
    int (*release) (struct inode *, struct file *);
    int (*fsync) (struct file *, struct dentry *, int datasync);
    // ... 还有非常多
};

  注意细节 :所有 read 和 write 的第一个参数都是 struct file *。这相当于面向对象里的 this 指针,让底层驱动能够知道自己当前在处理哪个打开的文件实例。

1.2.3 多态的运行机制

  假设我们在用户态调用 read(fd, buf, size),看看这套机制是如何动态运转的:

  1. 用户态 :应用程序发起 read() 调用。
  2. 系统调用 :陷入内核,触发 sys_read()。
  3. VFS 层 :根据文件描述符 fd,找到对应的 struct file 结构体。
  4. 多态分发(核心) :VFS 执行 file->f_op->read(...)。此时,f_op 指向的是具体设备在初始化时注册的驱动函数。
  5. 驱动层 :如果是磁盘设备,CPU 跳转到 read_disk();如果是屏幕设备,跳转到 write_screen()。
  6. 返回:驱动层操作硬件完成后,将结果原路返回给用户态。

  结论:这就是"同一个接口,不同的底层实现"。VFS 不需要关心底层是机械硬盘还是 SSD,它只负责按统一流程调用函数指针。

1.2.4 为什么这么设计?

  file_operations 就是把系统调用和驱动程序关联起来的关键数据结构。这种设计带来了巨大的优势:

  1. 解耦 :VFS 和具体驱动彻底分离,双方只需遵守 file_operations 这个"契约"。
  2. 极强的可扩展性 :当你要新增一个设备时,只需写一个驱动,实现对应函数并注册到 VFS 中,不需要修改 VFS 的任何一行代码。
  3. 统一生态 :开发者可以使用 open, read, write, close 这套标准 API 去操作几乎所有设备,大大降低了开发难度。

二、为什么需要缓冲区机制?

  了解了文件操作的底层逻辑后,我们把目光转向日常开发中最常见的一个概念:缓冲区(Buffer)。

2.1 什么是缓冲区?

  "内存的一段空间!" 缓冲区本质上是内存空间的一部分。我们在内存中预留了一定的存储空间,用来缓冲输入或输出的数据,这部分预留的空间就叫缓冲区。根据对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区。

2.2 为什么要引入缓冲区机制?(提高效率!)

  提高使用者的效率! 为什么直接操作不行?

  如果我们在读写文件时,不设缓冲区,直接通过系统调用(System Call)对磁盘进行操作。

  要知道,每一次系统调用都是有成本的! 执行一次系统调用,会涉及到:

  1. CPU 状态的切换(从用户态 User Mode 切换到内核态 Kernel Mode)。
  2. 进程上下文的切换。
  3. 频繁的磁盘物理访问。

  这些都会损耗大量的 CPU 时间。计算机对内存的操作速度远快于对磁盘的操作(可能相差几个数量级)。为了减少系统调用的次数,我们采用缓冲机制:一次性从磁盘读出大量数据到缓冲区,以后对这部分的访问直接读缓冲区,等缓冲区取完再去磁盘读。

  再比如打印机,打印机速度很慢,我们先把文档输出到打印机的缓冲区,CPU 就可以去干别的事,打印机自己慢慢打印。这样,低速的 IO 设备和高速的 CPU 就能协调工作,解放了 CPU。

2.3 缓冲区的三种类型

  标准 I/O 库(C 标准库)提供了 3 种类型的缓冲区:

  1. 全缓冲区(Full Buffering) :填满整个缓冲区后才进行 I/O 系统调用。对于磁盘文件的操作通常使用全缓冲。 默认大小一般为 4096 字节(内存页大小)或 1024。
  2. 行缓冲区(Line Buffering) :遇到换行符 \n 时,标准 I/O 库函数就会执行系统调用操作。当流涉及一个终端(如标准输入 stdin、标准输出 stdout)时,使用行缓冲。默认行缓冲区大小为 1024。
  3. 无缓冲区(No Buffering) :标准 I/O 库不对字符进行缓存,直接调用系统调用。标准出错流 stderr 通常是不带缓冲区的,这使得出错信息能够尽快地显示出来。

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

  1. 缓冲区满时。
  2. 执行 flush 语句 (如 fflush(stdout))。
  3. 进程结束(exit)。

2.4 经典案例

c 复制代码
close(1);
int fd = open("log.txt", O_CREAT | O_WRONLY | O_APPEND, 0666);
// 默认向显示器输出
printf("fd: %d\n", fd);
printf("hello world\n");
printf("hello world\n");
printf("hello world\n");
// 系统调用
const char *msg = "hello write\n";
write(fd, msg, strlen(msg));
// 没敢写close
// close(fd);

  我们本来想用重定向思维,让本来应该打印在显示器上的内容写到 log.txt 文件中,但运行结束后,会发现文件中并没有被写入内容!

为什么?

  因为我们将 1 号描述符(标准输出)重定向到磁盘文件后,缓冲区的刷新方式从行缓冲(显示器)变成了全缓冲(磁盘文件)。而我们写入的内容很少,并没有填满整个缓冲区,导致在进程结束前,内容一直待在用户态的缓冲区里,并没有刷新到磁盘文件中。

解决办法:

  1. 在代码里手动加上 fflush(stdout); 强制刷新。
  2. 或者验证一下 stderr(2号描述符)是无缓冲的:close(2); ... perror("hello world");。因为 stderr 没有缓冲区,hello world 不用 fflush 就可以直接写入文件。

三、深入 C 标准库------FILE 结构体与用户级缓冲区

3.1 库函数 vs 系统调用

c 复制代码
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
const char *s = "hello fwrite\n";
fwrite(s, strlen(s), 1, stdout);
// 系统调用
const char *ss = "hello write\n";
write(1, ss, strlen(ss));

  直接运行 ./a.out 输出到显示器时,四行代码正常打印。但如果我们运行 ./a.out > log.txt(重定向到文件),结果变成了:

text 复制代码
hello write
hello printf
hello fprintf
hello fwrite
hello printf
hello fprintf
hello fwrite

为什么 printf 和 fwrite 输出了 2 次,而 write 只输出了 1 次?

这里有两个核心原因:

  1. C库函数自带用户级缓冲区,write 系统调用没有。
  2. fork() 在 write 之后被调用了!

深度解析 :

  一般 C 库函数写入文件时是全缓冲,写入显示器是行缓冲。

  当重定向到普通文件时,数据的缓冲方式由行缓冲变成了全缓冲 。我们放在缓冲区中的数据(printf 等),就不会被立即刷新。

  此时调用 fork()。fork() 会创建子进程。在 fork 的瞬间,父进程的数据(包括用户态缓冲区里的数据)会发生写时拷贝(Copy-On-Write) 。

  当父进程准备刷新(退出时统一刷新)的时候,子进程也就有了同样的一份数据,随即产生了两份数据 写入文件。

  而 write 系统调用没有用户缓冲区,数据直接交给了内核,所以 fork 影响不到它,只输出一次。

结论:

  • printf / fwrite 是库函数,在系统调用的"上层",是对系统调用的"封装"。
  • 库函数在用户态二次加上了缓冲区(由 C 标准库提供)。
  • write 系统调用没有用户级缓冲区,直接进入内核。
  • 我们平时说的缓冲区,默认指用户级缓冲区。OS 为了提升整机性能,也有内核级缓冲区(如 Page Cache),但不在本次讨论范围内。

3.2 解剖 FILE 结构体

  既然库函数有缓冲区,那这个缓冲区在哪?就在 FILE 结构体里。

在 /usr/include/stdio.h 中:typedef struct _IO_FILE FILE;

在 /usr/include/libio.h 中,C 标准库定义了 struct _IO_FILE:

c 复制代码
struct _IO_FILE {
    int _flags;       /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags

    // 缓冲区相关指针 (C++ streambuf protocol)
    char* _IO_read_ptr;   /* Current read pointer */
    char* _IO_read_end;   /* End of get area. */
    char* _IO_read_base;  /* Start of putback+get area. */
    char* _IO_write_base; /* Start of put area. */
    char* _IO_write_ptr;  /* Current put pointer. */
    char* _IO_write_end;  /* End of put area. */
    char* _IO_buf_base;   /* Start of reserve area. */
    char* _IO_buf_end;    /* End of reserve area. */
    
    // 备份和撤销相关字段
    char *_IO_save_base; 
    char *_IO_backup_base; 
    char *_IO_save_end; 

    struct _IO_marker *_markers;
    struct _IO_FILE *_chain;

    int _fileno; // 核心!封装的文件描述符
    // ...
};

  C 语言中的 FILE 是一个结构体。它内部必定封装了文件描述符 fd(即 _fileno) 。因为 IO 相关函数与系统调用接口对应,库函数最终还是要通过 fd 去访问文件。同时,它内部包含了极其复杂的缓冲区指针(_IO_read_ptr、_IO_write_base 等),用于管理用户态缓冲区。


四、实战------手写一个简易的 libc 库(封装缓冲区)

  为了彻底弄懂 C 标准库是如何封装系统调用并加上缓冲区的,我们来模拟实现一个简单的 mystdio 库。

4.1 设计头文件 mystdio.h

  我们需要定义一个自己的 MyFile 结构体,它应该包含:文件描述符、打开标志、用户态缓冲区、缓冲区当前长度、刷新策略。

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

// 缓冲区最大容量
#define MAX 1024

// 刷新策略宏定义
#define NONE_FLUSH (1 << 0)
#define LINE_FLUSH (1 << 1)
#define FULL_FLUSH (1 << 2)

typedef struct IO_FILE
{
    int fileno;           // 文件描述符
    int flag;             // open时使用的标志
    char outbuffer[MAX];  // 用户态缓冲区
    int bufferlen;        // 缓冲区当前有效数据的字节数
    int flush_method;     // 刷新策略
} MyFile;

// 函数声明
MyFile* MyFopen(const char* path, const char* mode);
void MyFclose(MyFile* file);
int MyFwrite(MyFile* file, const void* str, int len);
void MyFFlush(MyFile* file);

4.2 实现 mystdio.c

  这里是核心逻辑。我们会用到 malloc 分配结构体,用系统调用 open、write、close 来操作底层。

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

// 辅助函数:创建并初始化 MyFile 对象
static MyFile* BuyFile(int fd, int flag)
{
    MyFile* f = (MyFile*)malloc(sizeof(MyFile));
    if (f == NULL) return NULL;

    f->fileno = fd;
    f->flag = flag;
    f->bufferlen = 0;
    f->flush_method = LINE_FLUSH; // 默认行缓冲

    // 将缓冲区清零,保证字符串打印时能正确找到结束符
    memset(f->outbuffer, 0, sizeof(f->outbuffer));
    return f;
}

// 打开文件
MyFile* MyFopen(const char* path, const char* mode)
{
    if (path == NULL || mode == NULL) return NULL;

    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);
}

// 强制刷新缓冲区:将用户态缓冲区的数据通过 write 系统调用写入内核
void MyFFlush(MyFile* file)
{
    if (file == NULL || file->bufferlen <= 0) return;

    int written = 0;
    while (written < file->bufferlen) 
    {
        ssize_t n = write(file->fileno, file->outbuffer + written, 
        				file->bufferlen - written);
        if (n < 0) 
        {
            perror("write");
            break;
        }
        written += n;
    }
    // 清空缓冲区有效长度
    file->bufferlen = 0;
}

// 写入数据(核心:先写入用户态缓冲区,根据策略决定是否刷新)
int MyFwrite(MyFile* file, const void* str, int len)
{
    if (file == NULL || str == NULL || len <= 0) return 0;

    const char* p = (const char*)str;
    int total_written = 0;

    while (len > 0) 
    {
        // 如果缓冲区满了,必须刷新
        if (file->bufferlen == MAX) 
        {
            MyFFlush(file);
        }
        
        int space = MAX - file->bufferlen;
        int n = (len < space) ? len : space;

        // 拷贝数据到用户态缓冲区
        memcpy(file->outbuffer + file->bufferlen, p, n);
        file->bufferlen += n;
        p += n;
        len -= n;
        total_written += n;

        // 行缓冲策略:遇到换行符立即刷新
        if ((file->flush_method & LINE_FLUSH) && file->bufferlen > 0 
            && file->outbuffer[file->bufferlen - 1] == '\n') 
        {
            MyFFlush(file);
        }

        // 全缓冲策略:缓冲区满立即刷新
        if ((file->flush_method & FULL_FLUSH) && file->bufferlen == MAX) 
        {
            MyFFlush(file);
        }
    }
    return total_written;
}

// 关闭文件:先刷新缓冲区,再关闭 fd,最后释放内存
void MyFclose(MyFile* file)
{
    if (file == NULL) return;
    MyFFlush(file);
    if (file->fileno >= 0) 
    {
        close(file->fileno);
    }
    free(file);
}

4.3 测试代码 usercode.c

  我们来看看这个简易库是如何运作的:

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

int main()
{
    MyFile* filep = MyFopen("./log.txt", "a");
    if (filep == NULL) {
        perror("MyFopen");
        return 1;
    }

    const char* msg1 = "hello myfile!"; // 没有换行符
    MyFwrite(filep, msg1, strlen(msg1));
    printf("写入不带换行的字符串,缓冲区长度 = %d,内容 = %.*s\n", filep->bufferlen, filep->bufferlen, filep->outbuffer);
    
    const char* msg2 = "hello line flush\n"; // 带有换行符
    MyFwrite(filep, msg2, strlen(msg2));
    printf("写入带换行的字符串,缓冲区长度 = %d\n", filep->bufferlen);

    const char* msg3 = "manual flush";
    MyFwrite(filep, msg3, strlen(msg3));
    MyFFlush(filep); // 手动刷新
    printf("手动刷新,缓冲区长度 = %d\n", filep->bufferlen);

    MyFclose(filep);
    return 0;
}

执行流程解析:

  1. 打开文件,初始化 MyFile。
  2. 写入 msg1(无 \n)。数据进入 outbuffer,bufferlen 增加,但不触发刷新。打印时能看到缓冲区里有数据。
  3. 写入 msg2(有 \n)。数据进入 outbuffer,因为末尾是 \n,触发 LINE_FLUSH,调用 write 系统调用将缓冲区内容写入磁盘文件,并清空缓冲区。所以打印时 bufferlen = 0。
  4. 写入 msg3,手动调用 MyFFlush,数据写入磁盘,缓冲区清空。
  5. MyFclose 再次确保刷新并释放资源。

结语:

  今天的内容到这里就结束了,希望你能有所收获~

干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _

相关推荐
2401_868534781 小时前
2024年11月软考网规
运维·服务器
谢亮_vipxieliang1 小时前
Go 并发安全性保障
服务器·网络·golang
杨云龙UP1 小时前
Oracle 19c RAC到RAC Active Data Guard标准搭建与巡检指南
运维·服务器·数据库·oracle·adg·data guard·rac到rac
我不会起名字3221 小时前
Redis 入门(一):Redis 是什么,为什么后端都离不开它
linux·redis·docker·安装·cli
Mikko71 小时前
JVM 线上排查实战(七):jps 看不到进程、jstack 报不允许的操作怎么办?attach 失败的六种情况实测
java·运维·jvm·后端
都适、隶仁ミ1 小时前
【等保加固】MySQL5.7.38
运维·服务器·mysql·安全·等保
沉下心来学鲁班2 小时前
DeepAgents 记忆机制:让 AI Agent 拥有“跨对话“的记忆
服务器·人工智能·python
Huangjin007_2 小时前
【Linux 系统篇(二十七)】文件(四):Ext 系列文件系统(上):从物理磁盘到逻辑抽象
linux·运维·服务器
鬼手点金2 小时前
opencode-隐私优先配置
服务器·前端·javascript·bug·openclaw