【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 小时前
三类资质变更报告时限差在哪?军工 15 日、集成印制 10 日
大数据·服务器·网络
QMCY_jason1 小时前
x86 ubuntu2204 无gui 安装 openeuler 2203 aarch64版本
linux·运维·服务器
重生之小比特1 小时前
【Linux系统】12 进程程序替换 自定义Shell 命令行解释器
linux·运维·服务器
轻舟技术A1 小时前
Provider 不属于 Agent 核心:rpi 的分层理由与收益
服务器·网络
考拉讲支付1 小时前
母婴亲子服务平台分账架构设计:多场景多方分账与合规清结算实践
大数据·运维·人工智能·微信小程序·架构·微信公众平台
连山齐名1 小时前
ai应用开发的学习笔记
服务器·笔记·学习
LateFrames1 小时前
“场控软件” 如何容错与运维
运维·容错·led大屏·播控系统·场控软件
Shadow(⊙o⊙)1 小时前
仿muduo实现高并发服务器——事件就绪管理Channel.hpp
服务器·c++
あ-2 小时前
CubeSandBox配置CubeMaster CPU 超分
linux·运维·服务器