【Linux】一切皆文件和缓冲区

目录

一切皆文件的理解

[struct file和struct device的理解](#struct file和struct device的理解)

总结

缓冲区

缓冲区的基本作用情景

FILE

代码理解

一个简单的手戳C标准库


一切皆文件的理解

在 Linux 中,不仅仅是 txt、c 这样的普通文件, 键盘、鼠标、显示器、硬盘、甚至是网络连接、管道**,都被抽象成了"文件"。**

你不需要 去学"键盘读取函数"、"网络发送函数"、"硬盘写入函数",你只要学会 read 和 write,就能搞定 Linux 里的绝大部分资源。

VFS(Virtual File System,虚拟文件系统)。

  • 你在用户态调用 open、read、write 时,统统由 VFS 接管。

  • VFS 长什么样?看图中的 struct file 。无论是你的普通文本、磁盘、还是显示器,在内核里都会被抽象成一个 struct file 结构体。

  • 每个 struct file 都有 属性 (比如文件名、大小、权限),也都有一个 文件内核缓冲区(就是 Page Cache)。

void (*read)(int fd, char *, int); 和 void (*write)(...)。

在 C 语言里,(*read) 是函数指针。

  • 在 struct file 结构体里,并没有直接写死具体的读取代码,而是放了两个指针。

  • 当你在用户态执行 read(fd, buf, size) 时,内核根本不关心你读的是什么设备,它只知道:"顺着这个 fd 找到 struct file,然后调用它里面的 read 函数指针就行了"。

看图中下方的 struct device 。

这是 OS 真正用来管理硬件(磁盘、显示器、键盘、鼠标、网卡)的结构体。里面记录了设备的类型(type)、状态(status)等。

如果你写过 C++,你会知道 C++ 的多态 是通过**虚函数表(vtable)**实现的。基类指针指向派生类对象,调用同名函数时,会动态执行对应的派生类代码。

  1. 基类 :struct file (包含函数指针)。

  2. 派生类 :磁盘的 struct file、显示器的 struct file。

  3. 重写(覆盖) :把函数指针指向具体的方法(如 read_disk)。

  4. 多态调用 :上层统一调用 read(fd, ...),底层自动找到对应的设备去读取。

struct file和struct device的理解

  1. 初始化 struct file(面子) :

    OS 会分配一个 struct file,并且把里面的 f_op(或者图里的 void (*read) 指针),强行指向底层磁盘驱动的 read_disk 函数。

  2. 初始化 struct device(里子) :

    OS 同时也会分配并初始化一个 struct device,里面包含设备的具体信息(比如磁盘的型号、状态等)。

  3. 连接 :

    图中的红线,就是指示了那个函数指针的指向关系 。struct file 里的 read 指针,指向了 struct device 所在层(设备管理层)提供的真正干活的函数 read_disk。

总结

"一切皆文件"的本质是:内核把一切硬件设备、网络资源都封装成了 struct file 和 struct file_operations。用户只需用一个整数 fd,配合统一的方法(open/read/write/close),就能操控整个系统。

缓冲区

缓冲区的基本作用情景

缓冲区就是内存里预留的一块区域,用来临时存放数据,等攒够了再统一处理。

作用:

  1. 减少系统调用,降低开销 :每次调用 read/write 都要陷入内核(用户态切内核态),极其耗时。用缓冲区攒够了一次性写,效率极高。

  2. 协调速度差异:CPU 和内存极快,而磁盘和打印机极慢。如果不加缓冲,CPU 发出写指令后只能干等慢速设备。

  3. 支持部分读写:网络传输时,数据是一点一点到的。缓冲区能把这些碎片拼凑成完整的包。

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

// 没敢写close
close(fd); // ????

如果你直接运行这段代码,你会发现一个非常诡异的现象:

终端屏幕上什么都不会打印,而 log.txt 文件里面,只有一行 hello write\n。

前面那 4 句 printf 仿佛人间蒸发了!这个和缓冲区有关

我们平常的说的缓冲区很抽象,其实有两种缓冲区,一种是用户级的一种是内核级的。

看看下面的图

看图左上角:"用户级,语言层缓冲区"。

  • 在哪里? 在 C 标准库(glibc)里。

  • 长什么样? 它其实就是 stdout(也就是 FILE * 结构体)内部维护的一块内存空间(图里的 "aaaaaaa")。

  • 谁在用它? printf、fprintf、fputs、fwrite 这些库函数。

  • 什么时候刷新?

    1. 用户强制刷新(比如调用 fflush)。

    2. 缓冲区满了。

    3. 遇到换行符 \n(行缓冲)。

    4. 进程退出 (调用 exit)。

  • 三种模式:

    • 无缓冲(WT,Write-Through) :立刻刷新(比如 stderr)。

    • 全缓冲 :写满才刷新(普通文件默认用这种,效率最高)。

    • 行缓冲 :遇到换行刷新(显示器默认用这种)。

内核态

图下半部分的大方框:"文件内核缓冲区"。

  • 系统调用 write 把数据传进来后,数据并没有立刻飞到硬盘上,而是放在了操作系统的 Page Cache(页缓存) 里。

  • 图中有一句非常经典的话:"只要把数据交给OS,就相当于交给了硬件!"

    • 为什么?因为程序员(用户态)已经无权干涉了,数据丢进内核,就像寄出的快递,剩下的全凭 OS 调度。
  • 至于 OS 什么时候把页缓存真正刷入磁盘(硬件),"OS自主决定刷新方案!"(后台线程定期刷、内存不够了强制刷等)。

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

// 没敢写close
close(fd); // ????

因此这段代码就很好解释了,因为printf打印在了用户缓冲区里面,他一直没有刷新,刷新的时候就要等进程退出,但是进程退出之前就把fd关掉了,找不到文件描述符,就刷新不到内核的缓冲区里面,但是因为write是系统调用的,他可以直接写入内核的缓冲区里面,

在来说几个知识点:

无论你用printf攒了多少数据,最终都必须通过底层的系统调用 (如 write)跨越用户态和内核态的边界

每次跨越,CPU 都要从"用户态"切换到"内核态",保存寄存器、切换栈,这个过程极其耗时。这就是为什么我们要在用户态搞一个缓冲区攒一攒,不能每次写 1 个字节都去调一次系统调用。

因此每当用户级缓冲区满了,才刷新到内核缓冲区,这样效率是最高的,因为不用过多的系统调用

但是

数据交给系统,交给硬件 ------ 本质全是拷贝!计算机数据流动的本质:一切皆拷贝!!

FILE

之前说过用户级的函数fopen,fprintf这些,返回的都是FILE*,那么这个FILE是什么?------结构体

是 C 标准库在用户态建立的一个"高级包装盒"(结构体),它里面装着"底层 fd"和"用户级缓冲区"。

cpp 复制代码
struct _IO_FILE 
{
    int _flags;             /* 高位字是 _IO_MAGIC;其余位是标志(如读/写、是否到达EOF等) */
    
    // ...
    
    int _fileno;            /*  核心变量:底层对应的文件描述符 (fd) */
                            /* 比如 stdout 的 _fileno 就是 1,stderr 的是 2 */

    /* 缓冲区的基础边界指针 */
    char* _IO_buf_base;     /*  核心变量:整个缓冲区的起始地址 */
    char* _IO_buf_end;      /*  核心变量:整个缓冲区的结束地址 */
    // ... 后面还有锁、偏移量等其他成员
};


typedef _IO_FILE FILE;

代码理解

有了上述的理论知识的理解,来写一下代码加强一下理解

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

int main()
{
    // 库函数
    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));

    // ???
    fork();

    return 0;
}
bash 复制代码
[hzx@bite-alicloud lesson20]$ ./a.out
hello printf
hello fprintf
hello fwrite
hello write
[hzx@bite-alicloud lesson20]$ ./a.out > log.txt
[hzx@bite-alicloud lesson20]$ cat log.txt
hello write
hello printf
hello fprintf
hello fwrite
hello printf
hello fprintf
hello fwrite

第一步:正常运行(输出到屏幕)

当代码直接运行 ./a.out 时:

  • printf、fprintf、fwrite 都是库函数,输出目标是显示器。

  • 对于显示器,标准库默认是行缓冲 。因为字符串里都有 \n,所以每执行完一句,库函数立刻把数据通过系统调用交给了操作系统,并立刻打印到屏幕上。

  • 此时,用户级缓冲区是空的。

第二步:执行重定向(> log.txt)

当你敲下 ./a.out > log.txt 时,底层发生了什么?

  • 终端变成了普通文件,标准库检测到输出目标改变,把刷新模式从行缓冲 切换成了全缓冲。

  • 此时,\n 不管用了,必须等缓冲区塞满 4KB 才会交给操作系统。

第三步:数据在缓冲区里"憋着"

程序开始执行

  1. printf(...)

  2. fprintf(...)

  3. fwrite(...)

    这三句执行完,因为数据量太小,根本填不满 4KB 的缓冲区。所以,这三行数据全部憋在了 stdout 的用户级缓冲区内

  4. 紧接着,执行了 write(1, ss, strlen(ss))。

    write 是系统调用,没有用户级缓冲区,它直接把 "hello write\n" 交给了内核。内核顺手就把它写进了 log.txt。

    此时,log.txt 文件里有了第一行:hello write。而缓冲区里依然憋着前三行。

第四步:万恶之源 fork()

程序执行到了第 19 行:fork();。

  • fork() 会创建一个子进程。

  • 操作系统为了效率,使用了写时拷贝(COW)。

  • 此时,父进程的 stdout 用户级缓冲区里还憋着 printf、fprintf、fwrite 的数据。子进程会完完整整地拷贝一份这个缓冲区(此时内存里有了两份相同的未刷新数据)。

第五步:程序结束,各自刷新

父进程和子进程分别执行到 return 0;(或 exit)。

  • 无论父子谁先退出,exit 都会触发标准库的清理动作:把用户级缓冲区的数据强行刷给内核。

  • 父进程:把缓冲区里的 3 行数据推给 fd 1(此时指向 log.txt)。

  • 子进程:也把拷贝来的 3 行数据推给 fd 1(指向同一个 log.txt)。

最终结果 :这 3 行库函数产生的数据,被写了两遍到文件里。

为什么 write 没有重复?

因为 write 是系统调用,没有用户态缓冲区 。它在 fork 之前就已经把数据交给了操作系统内核,根本就没有留在父进程的内存空间里,所以 fork 时子进程也无从拷贝它。

一个简单的手戳C标准库

mystdio.c

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

// 【内部辅助函数】用于 malloc 一个 MyFile 结构体并初始化
static MyFile *BuyFile(int fd, int flag)
{
    // 动态分配一块内存用于存放结构体(FILE 结构体实际上都在堆区)
    MyFile *f = (MyFile*)malloc(sizeof(MyFile));
    if(f == NULL) return NULL;
    
    f->bufferlen = 0;                     // 初始时缓冲区为空
    f->fileno = fd;                       // 将系统分配的内核 fd 存入结构体
    f->flag = flag;                       // 保存打开模式
    f->flush_method = LINE_FLUSH;         // 默认设置为行缓冲(遇到 \n 才刷新)
    memset(f->outbuffer, 0, sizeof(f->outbuffer)); // 将缓冲区清零
    
    return f;
}

// 模拟 fopen
MyFile *MyFopen(const char *path, const char *mode)
{
    int fd = -1;
    int flag = 0;
    
    // 1. 根据传入的 mode 字符串,拼接底层的 open 标志位
    if(strcmp(mode, "w") == 0)
    {
        // 写模式:不存在则创建,只写,清空原文件
        flag = O_CREAT | O_WRONLY | O_TRUNC;
        fd = open(path, flag, 0666); // 系统调用 open
    }
    else if(strcmp(mode, "a") == 0)
    {
        // 追加模式:不存在则创建,只写,追加到末尾
        flag = O_CREAT | O_WRONLY | O_APPEND;
        fd = open(path, flag, 0666);
    }
    else if(strcmp(mode, "r") == 0)
    {
        // 读模式:读写打开(原代码这里写了 O_RDWR,标准库 r 其实是 O_RDONLY)
        flag = O_RDWR;
        fd = open(path, flag);
    }
    else
    {
        // TODO: 支持 "w+", "a+" 等模式
    }
    
    // 2. 如果 open 失败,返回 NULL
    if(fd < 0) return NULL;
    
    // 3. open 成功,调用 BuyFile 包装成一个 MyFile 指针返回
    return BuyFile(fd, flag);
}

// 模拟 fclose
void MyFclose(MyFile *file)
{
    if(file->fileno < 0) return; // 防御性判断,防止重复关闭
    
    //  关键:关闭前必须把用户级缓冲区里剩余的数据刷入内核!
    // 如果这里不刷新,缓冲区里没满的数据就会随着 free 直接丢失。
    MyFFlush(file);
    
    close(file->fileno); // 关闭底层的内核文件描述符
    
    free(file);          // 释放结构体内存,防止内存泄漏
}

// 模拟 fwrite
int MyFwrite(MyFile *file, void *str, int len)
{
    //  原代码这里没做越界判断,是个隐藏 Bug。
    // 如果 file->bufferlen + len > MAX,直接 memcpy 会造成数组越界。
    // 正确的做法应该是在这里判断,如果装不下,先 MyFFlush 再去拷贝。
    
    // 1. 拷贝:把用户传入的 str 拷贝到用户级缓冲区 outbuffer 的末尾
    memcpy(file->outbuffer + file->bufferlen, str, len);
    file->bufferlen += len; // 更新缓冲区内的数据长度
    
    // 2. 尝试判断是否满足刷新条件
    // 如果当前是行缓冲模式 (LINE_FLUSH),且刚写进来的数据的最后一个字符是 '\n'
    if((file->flush_method & LINE_FLUSH) && file->outbuffer[file->bufferlen-1] == '\n')
    {
        // 满足行缓冲刷新条件,立刻刷新!
        MyFFlush(file);
    }
    return 0;
}

// 模拟 fflush
void MyFFlush(MyFile *file)
{
    if(file->bufferlen <= 0) return; // 如果缓冲区没数据,直接返回
    
    // 1. 调用系统调用 write
    // 把用户级缓冲区 outbuffer 里的数据,一口气交给内核(Page Cache)
    int n = write(file->fileno, file->outbuffer, file->bufferlen);
    (void)n; // 忽略返回值,防止编译器警告
    
    // 2. 调用系统调用 fsync
    // 强制把内核里的数据真正刷入物理磁盘(防止断电丢失)
    fsync(file->fileno);
    
    // 3. 清空用户级缓冲区,准备迎接下一波数据
    file->bufferlen = 0;
}

mystdio.h

cpp 复制代码
#pragma once // 防止头文件被重复包含

#include <stdio.h>

#define MAX 1024 // 定义缓冲区的大小(模拟标准库的4KB,这里为了测试用1KB)

// 定义三种刷新策略的标志位(使用位运算,1<<0, 1<<1, 1<<2 分别是 1, 2, 4)
// 使用位运算的好处是可以通过按位或(|)组合,比如 (NONE_FLUSH | LINE_FLUSH)
#define NONE_FLUSH (1<<0) // 无缓冲
#define LINE_FLUSH (1<<1) // 行缓冲
#define FULL_FLUSH (1<<2) // 全缓冲

// 自定义的 FILE 结构体,完美模拟了 C 标准库的 FILE
typedef struct IO_FILE
{
    int fileno;          // 核心1:底层真实的系统调用文件描述符 (fd)
    int flag;            // 记录文件的打开模式(O_CREAT, O_RDONLY 等)
    char outbuffer[MAX]; // 核心2:用户级缓冲区!数据会先被拷贝到这里
    int bufferlen;       // 记录当前缓冲区里已经存了多少个字节的数据
    int flush_method;    // 记录当前的刷新策略(行缓冲、全缓冲等)
} MyFile;

// 声明自定义的文件操作接口,对应标准库的 fopen, fclose, fwrite, fflush
MyFile *MyFopen(const char *path, const char *mode);
void MyFclose(MyFile *);
int MyFwrite(MyFile *, void *str, int len);
void MyFFlush(MyFile *);
相关推荐
且从容.1 小时前
Ubuntu安装flume
linux·ubuntu·flume
承渊政道1 小时前
Linux网络学习【Reactor反应堆模式】
linux·网络·学习·ubuntu·reactor
讯捷蓝达3 小时前
戴尔R720xd服务器卡死在 “Configuring Memory...Done“,换了内存还是卡?问题根本不在内存
运维·服务器
且从容.4 小时前
ubuntu安装flink
linux·ubuntu·flink
数字新视界6 小时前
动环监控信创适配在智能机房管理中的应用价值与未来展望
大数据·服务器·数据库·动环监控系统·动力与环境监控系统
爱和冰阔落6 小时前
【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork
linux·运维·服务器·网络
程序员沉梦听雨6 小时前
WSL2的安装与使用
linux·wsl
微小的鱼6 小时前
Linux的渲染与显示系统链路
android·java·linux
lisanmengmeng6 小时前
alertmanger 标签 rules 打上分组标签
linux·运维·服务器
Android系统攻城狮7 小时前
Linux FFmpeg深度解析之av_write_frame调用流程与实战(三十三)
linux·运维·服务器·ffmpeg·音视频进阶·ffmpeg音视频