9. 缓冲区

一、理解"一切皆是文件"

已知:

  1. 虽然每个硬件的驱动程序都不一样,但是一定会有对应的 read write 方法,实现方式不一样的,但是一定有类似的方法

  2. os 不会跟硬件打交道,但是 os 需要保证 对于硬件设备,要先描述在组织的管理起来,因此这些硬件设备要被 os 知道,所以一定会有对应的数据结构,有对应的属性和方法

那么大体的轮廓已经有了,现在的问题就是:

这个 struct file 是怎么访问 硬件的呢?

也就是说个结构体,里面没有方法

但是可以证明 函数指针!!!

此时就可以通过 函数指针,去查找对应的方法,进而进行硬件的访问

那么数据在哪? 在 os 的文件缓冲区里面。

这样子逻辑就串起来了。

因此总体的流程是:

  • 通过文件描述符,访问特定的文件,
  • 例如,可以通过 write 系统调用 把用户的文件缓冲区拷贝到 os 内核的文件缓冲区,
  • 然后可以通过 函数指针,访问硬件驱动的函数,把 os 内核的文件缓冲区数据 交给硬件

为什么这样子设计呢?

是为了屏蔽底层硬件的差异

理解一切皆是文件的切入点:

在系统当中,访问任何设备,只要提供文件描述符,就不需要关注底层设备的差异,

而是通过 struct file 中的函数指针,就可以对文件进行访问

首先,在windows中是文件的东西,它们在linux中也是文件;其次一些在windows中不是文件的东西,比如进程、磁盘、显示器、键盘这样硬件设备也被抽象成了文件,你可以使用访问文件的方法访问它们获得信息;甚至管道,也是文件;将来我们要学习网络编程中的socket(套接字)这样的东西,使用的接口跟文件接口也是一致的。

这样做最明显的好处是,开发者仅需要使用一套API和开发工具,即可调取Linux系统中绝大部分的资源。举个简单的例子,Linux中几乎所有读(读文件,读系统状态,读PIPE)的操作都可以用read函数来进行;几乎所有更改(更改文件,更改系统参数,写PIPE)的操作都可以用write函数来进行。

之前我们讲过,当打开一个文件时,操作系统为了管理所打开的文件,都会为这个文件创建一个file结构体,该结构体定义在 /usr/src/kernels/3.10.0-1160.71.1.el7.x86_64/include/linux/fs.h 下,以下展示了该结构部分我们关系的内容:

我们看一下 struct file_operations 里面的内容:

里面就包含了各种操作的函数指针!

file_operation 就是把系统调用和驱动程序关联起来的关键数据结构,这个结构的每一个成员都对应着一个系统调用。读取 file_operation 中相应的函数指针,接着把控制权转交给函数,从而完成了 Linux 设备驱动程序的工作。

介绍完相关代码,一张图总结:


二、缓冲区

2.1. 什么是缓冲区

缓冲区是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区。

其中:

  • os 管理内存,会以4kb大小分为各种内存块
  • 内存4 GB是以4GB除以4kb个 数据页 构成的
  • 操作系统中存在很多很多的内存块,
  • 有的是被进程申请的,有的没有被占用,有的是正常使用,有的是要被清理的,
  • os 要管理这些内存块,先描述,在组织,叫做 struct_page
  • 一个内存块找到对应的 struct_page,就能找到内存块
  • 所以一个进程的文件缓冲区重点维护进程的 struct_file 以及 struct_page 之间的关系

2.2. close(fd) 的添加带来了什么?

cpp 复制代码
int main()
{
    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");

    // 不写close
    return 0;
}

此时写入成功,如果加入了 close 语句,看看情况:

cpp 复制代码
int main()
{
    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");

    // 不写close
    close(fd);
    return 0;
}

此时我们发现,没有写入成功。

上面代码的关键分歧点:close(fd) 的添加带来了什么?

情况 代码操作 底层发生了什么 最终结果
不写close(fd) 程序结束(return 0) C标准库在程序退出时,会自动刷新(fflush)stdout 的用户级缓冲区,将数据通过系统调用 write 写入内核,最终落到磁盘文件。 写入成功
添加close(fd) 显式关闭文件描述符 注意: close 是系统调用 ,它直接通知内核释放文件描述符 1。但此时 printf 写入的"hello world"还在 C 标准库的用户级缓冲区里(内存中),并没有被 wirte 刷进内核。一旦你提前 close了底层的 fd,缓冲区后续想刷的时候发现 fd 已经无效了(EBADF),数据就直接丢失了。 写入失败(数据丢失)

我们在 close 后添加 系统调用 write ,看一下情况:

cpp 复制代码
using namespace std;
int main()
{
    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");
    const char *msg = "hello msg\n";
    write(fd, msg, strlen(msg));
    close(fd);
    return 0;
}

此时,我们发现 ,使用系统调用的 write,就写入到 log.txt 文件中,这个是为什么?

2.3. write为什么能"救回"数据?

关键区别在于数据存放的位置刷新的主动权

函数 数据存放位置 刷新由谁控制 受 close(fd) 影响吗?
printf C语言用户级缓冲区(进程内存中的 FILE 结构体) 由C库决定(缓冲区满、fflush、return 0 时触发 write 系统调用) 极大影响。close 先关掉了内核中的 fd,等C库想刷数据时,系统调用 write 找不到有效的 fd,直接报错 EBADF,数据丢失。
write 操作系统内核级缓冲区(文件页缓存 page cache) 由操作系统内核管理(异步刷盘) 不受影响 。write 是系统调用,它直接 把用户态的数据拷贝进内核缓冲区。只要 write 执行在 close 之前,数据已经交给内核了。之后你调用 close 只是释放文件描述符表项,内核已经持有的数据会继续正常落

我们画图来解释一下:

printf 是把货堆在"菜鸟驿站"(用户态缓冲区),等着快递员(C库刷新机制)来取;而 write 是你自己开着卡车,直接把货送进了"中转站"(内核缓冲区)。

调用 close 相当于把快递员进站的门给关了(关闭 fd),所以驿站里的货(printf数据)永远寄不出去;但你自己开车送的货(write数据)已经在中转站里了,门关不关都不影响它被发往目的地(磁盘)。

2.4. 问题

问题1:上面的代码怎么进行更改

在 close(fd) 之前,加入 新函数:

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

int fflush(FILE *stream);

fflush会强制将 C 库用户态缓冲区(FILE 结构体内部)里积压的所有数据,通过系统调用 write 立即"搬运"到操作系统内核的缓冲区中。 执行完 fflush后,该用户态缓冲区会被清空。

cpp 复制代码
#include <iostream>
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
using namespace std;
int main()
{
    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");
    fflush(stdout);
    const char *msg = "hello msg\n";
    write(fd, msg, strlen(msg));
    close(fd);
    return 0;
}

问题2:什么时候要刷新

  1. 强制刷新 如 fflush

  2. 刷新条件满足

  3. 进程退出

    满足其一就行

刷新方式分为三类:如 write

  1. 立即刷新 --- 无缓冲 --- 写透模式

  2. 满了 --- 全缓冲 --- 效率最高 --- 普通文件一般采用这种方式

  3. 行刷新 --- 行缓冲 --- 显示器用

注意:这些都是语言层面的刷新方式

在 os 内核的缓冲区的刷新方式会更加复杂,不做说明。

解决上一个博客中的一个问题:

cpp 复制代码
int main()
{
    int fd = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
    if (fd < 0)
        exit(1);

    dup2(fd, 1);
    // 默认向显示器输出
    printf("fd: %d\n", fd);
    printf("hello file\n");
    printf("hello file\n");
    printf("hello file\n");
    fprintf(stdout, "hello stdout\n");
    fprintf(stdout, "hello stdout\n");
    fprintf(stdout, "hello stdout\n");

    const char *msg = "hello world\n";
    write(fd, msg, strlen(msg));

    return 0;
}

为什么第一行是 hello world?

这是因为 是不同的 刷新策略导致的!

stdout 被重定向到了普通文件,缓冲模式发生了改变

  • 默认情况(显示器) :stdout 是行缓冲,遇到 \n 就会立即刷新(调用 write)。

  • 重定向到普通文件后 :C 库检测到 1 号文件描述符不再指向终端(isatty(1) 为假),会自动将 stdout 的缓冲模式切换为全缓冲

在全缓冲模式下,即便有 \n,也不会触发系统调用 write。数据会一直攒在用户态缓冲区里,直到:

  1. 缓冲区满了(默认 4KB 或 8KB);

  2. 程序正常退出(return 0);

  3. 手动调用 fflush。

因此,所有的 printf / fprintf 都老老实实地待在"菜鸟驿站(用户态缓冲区)"里等待,根本没过"收费站(内核)"。

为什么 write 能插队并占据第一行?

因为 write(fd, msg, strlen(msg)) 是系统调用 ,它根本不走 C 语言的 FILE 缓冲区

  • 它是直接带着数据去敲内核的门。

  • 此时内核中 log.txt 的文件偏移量还是 0(因为之前没有任何数据进入内核)。

  • 所以内核二话不说,把 hello world\n 写在了文件的第一行,然后把偏移量移动到 12。

时间顺序 谁在操作 数据去了哪里 文件偏移量位置 最终文件里的行序
第 1 步 printf / fprintf 用户态缓冲区(积压) 内核偏移量:0 还在内存里,未落盘
第 2 步 write 系统调用 直接进内核(page cache) 从 0 写入,后移到 12 占据第 1 行
第 3 步 return 0 触发刷新 用户态缓冲区数据进内核 从 12 往后写入 被挤到了第 2、3... 行

一句话总结

printf 的执行顺序在前,不等于写入文件的顺序在前。 因为 printf 只是把货放在了"用户态缓冲区"这个仓库里,而 write 是直接开着卡车冲进内核抢占了 0 号车位。等 printf 的货(return 0 时)慢悠悠运过来时,只能停在 hello world 后面的车位上了。

问题3:为什么要设计两个缓冲区?

系统调用是有成本的,节约成本

问题4:这个语言层面的缓冲区到底在哪?

在 FILE 结构体里面

我们在使用库函数的 printf 或者 fprintf snprintf 这些函数中,要向显示器 stdout 打印刷新的时候,传递的是 stdout, 而 stdout 是 FILE* 类型的。

问题5:刷新的本质是什么

拷贝,数据交给系统,交给硬件,都是拷贝

计算机数据流动的本质:一切皆是拷贝

怎么理解格式化输出

本质就是把 %d %s 这些格式化输出成字符串,

然后放到c语言提供的缓冲区里面

2.5. 用户态缓冲区、系统调用、进程复制(fork) 三者之间的交互关系

cpp 复制代码
#include <iostream>
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
using namespace std;
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));

    return 0;
}

正常输出没有问题。

cpp 复制代码
#include <iostream>
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
using namespace std;
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;
}

为什么此时打印出来的内容,有些是出现了一次,有些出现了好多次。

已知内容:

函数类型 函数举例 数据路径 是否经过用户态缓冲区
库函数(带缓冲) printf, fprintf, fwrite 数据先进入 stdout 的 FILE 结构体内部的用户态缓冲区
系统调用(无缓冲) write 数据直接越过 C 库,通过内核提供的接口进入内核态

阶段 1:程序执行库函数(数据堆积在用户态)

cpp 复制代码
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
fwrite(s, strlen(s), 1, stdout);
  • 这三行代码执行完后,数据并没有被系统调用 write 送入内核。

  • 它们只是被拷贝到了 stdout 所对应的用户态 FILE 结构体的缓冲区里。

  • 关键点:此时进程的用户态内存中,已经存放了这三行字符串数据。

阶段 2:执行系统调用(直接闯入内核)

cpp 复制代码
write(1, ss, strlen(ss));
  • 这行代码是系统调用,它不经过用户态缓冲区,直接把 "hello write\n "交给了内核。

  • 内核收到数据后,立即将其写入文件(或显示到终端)。

  • 关键点:write 的数据已经"发车"了,跟后续的 fork 没有任何关系。

阶段 3:调用 fork()(复制进程内存空间)

cpp 复制代码
fork();
  • fork() 系统调用会创建一个子进程,这个子进程是父进程的完整副本

  • 复制的内容 :包括代码、数据、堆栈,以及......用户态缓冲区里的内容

  • 此时,子进程的用户态缓冲区里,也完整复制了那三行库函数的数据("hello printf\n" 等)。

  • 父进程的用户态缓冲区里,依然保留着同样的三行数据。

最后:父子进程退出(各自刷新缓冲区)

  • 父进程退出:C 运行时环境会刷新 stdout 缓冲区,将里面的三行数据写入内核(输出 1 次)。

  • 子进程退出:C 运行时环境同样会刷新自己的 stdout 缓冲区,将里面复制的三行数据也写入内核(再输出 1 次)。

结果 :库函数的三行数据,被输出了 2 次

执行流程:

时间线 父进程行为 子进程行为 内核/文件状态
1. 执行库函数 三行数据写入用户态缓冲区 (尚未创建) 无数据
2. 执行 write "hello world\n"直接进入内核 (尚未创建) 写入 1 次
3. 执行 fork() 缓冲区有数据(三行) 复制父进程缓冲区(三行) 无变化
4. 父进程退出 刷新缓冲区 → 输出三行 (等待退出) 输出第 1 次
5. 子进程退出 (已退出) 刷新缓冲区 → 输出三行 输出第 2 次

三、模拟实现 FILE

cpp 复制代码
#pragma once
#include <cstdio>
#define MAX 1024
#define NONE_FLUSH (1 << 0)
#define FULL_FLUSH (1 << 1)
#define LINE_FLUSH (1 << 2)
typedef struct IO_FILE
{
    int _fileno;
    int _flag;
    char _outbuffer[MAX];
    int _bufferlen;
    int _flush_method;
} MyFile;
MyFile *MyFopen(const char *path, const char *mode);
void MyFclose(MyFile *file);
void Myfflush(MyFile *file);
int MyWrite(MyFile *file, void *str, int len);
cpp 复制代码
#include "mystdio.h"
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#define MODE 0666
MyFile *CreateFile(int fd, int flag)
{
    MyFile *file = (MyFile *)malloc(sizeof(MyFile));
    if (NULL == file)
    {
        perror("Create File error");
        exit(1);
    }
    file->_fileno = fd;
    file->_flag = flag;
    file->_bufferlen = 0;
    file->_flush_method = LINE_FLUSH;
    memset(file->_outbuffer, 0, sizeof(file->_outbuffer));
    return file;
}
MyFile *MyFopen(const char *path, const char *mode)
{
    int flag = 0, fd = -1;
    // 1. 判断是什么类型的
    if (strcmp(mode, "w") == 0)
    {
        flag = O_CREAT | O_TRUNC | O_WRONLY;
        fd = open(path, flag, MODE);
    }
    else if (strcmp(mode, "a") == 0)
    {
        flag = O_CREAT | O_APPEND | O_WRONLY;
        fd = open(path, flag, MODE);
    }
    else if (strcmp(mode, "r") == 0)
    {
        flag = O_RDONLY;
        fd = open(path, flag);
    }
    if (fd < 0)
    {
        perror("MyFopen failed!");
        exit(1);
    }
    return CreateFile(fd, flag);
}

void MyFclose(MyFile *file)
{
    if (file->_fileno < 0)
        return;
    Myfflush(file);
    close(file->_fileno);
    file = NULL;
}

void Myfflush(MyFile *file)
{
    //  把用户的缓冲区拷贝到系统中
    int n = write(file->_fileno, file->_outbuffer, file->_bufferlen);
    // 把系统的缓冲区拷贝到磁盘外设上
    fsync(file->_fileno);
    file->_bufferlen = 0;
}

int MyWrite(MyFile *file, void *str, int len)
{
    // 1. 把文件里面的缓冲区的内容 拷贝到 str
    memcpy(file->_outbuffer + file->_bufferlen, str, len);
    file->_bufferlen += len;
    // 2. 判断刷新条件
    if ((file->_flush_method & LINE_FLUSH) && file->_outbuffer[file->_bufferlen - 1] == '\n')
    {
        Myfflush(file);
    }
    return 0;
}
cpp 复制代码
#include "mystdio.h"
#include <string.h>
#include <unistd.h>
int main()
{
    MyFile *fp = MyFopen("./log.txt", "a");
    char *msg = (char *)"hello myFile!!!";
    int cnt = 3;
    while (cnt--)
    {
        MyWrite(fp, msg, strlen(msg));
        printf("buffer:%s\n", fp->_outbuffer);
        sleep(1);
    }
    MyFclose(fp);
    return 0;
}
相关推荐
dok121 小时前
Johannes 《Linux内核模块与设备驱动开发:编写Linux驱动程序》(4) devicefile 设备号相关
linux·运维·服务器
古道青阳2 小时前
Duilib 技术全景剖析
c++·windows
毛驴赶鹿2 小时前
低配置服务器怎么搭建监控?Komari Docker部署与公网访问完整教程
运维·服务器·docker
Fu2067212 小时前
结课项目考试
linux·运维·服务器
Rabitebla2 小时前
C++ 内存管理全面复习:从内存分布到 operator new/delete
java·c语言·开发语言·c++·算法·leetcode
Albert·Lin2 小时前
LVS相关知识点总结-一
linux·服务器·lvs
SilentSlot2 小时前
【C/C++】手写 DPDK 协议栈(八):用五元组 Hash 加速连接定位
c语言·c++·哈希算法
jing.wang_20253 小时前
NVIDIA CUDA C++编程环境搭建--Windows + Ubuntu 22.04
c++·windows·ubuntu·gpu算力
公众号:fuwuqiBMC3 小时前
(转自“服务器BMC”)服务器BMC芯片——AST1840
运维·服务器·fpga开发