笔记 | Re:Linux系统篇(三十一)文件篇·四:从 FILE 结构体到系统调用:全景拆解 Linux 缓冲区流转机制(学习笔记)

刷到一个挺有意思的话题,结合自己之前的经验,整理了一下核心要点。

◆ 博主名称: 小此方-

大家好,欢迎来到小此方的博客。

⭐️Linux系列个人专栏:
【主题曲】Linux

⭐️此方的GitHub:
github_此方

⭐️
Re系列专栏:我们思考 (Rethink) · 我们重建 (Rebuild) · 我们记录 (Record)


这篇目录

二、 缓冲区在哪里:深入 FILE 结构体 三、 用户层的缓冲区刷新方案 四、 从现象到原理:为什么重定向会改变打印行为? 4.4极端场景:不小心 close(fd) 会发生什么? 六、 计算机流动的本质:一切皆拷贝 6.1 进程退出与缓冲区 七、实现一个C语言的库封装

概要&序论

  Hello大家好,我是此方。 本文深入拆解 Linux 用户级缓冲区与内核级缓冲区的流转机制。从 struct _IO_FILE(FILE 结构体)的底层设计出发,剖析行/全缓冲的切换、fork 写时拷贝引发的二次刷新等核心原理,并最终手写封装一个 C 语言标准 I/O 库,打通系统级认知闭环。

一、 什么是缓冲区与为什么要引入它

1.1 什么是缓冲区

  既熟悉又陌生的名词——缓冲区。从本质上来说,缓冲区就是内存中预留出来的一段空间   在数据传输的过程中,无论是从内存写入磁盘,还是从流中读取数据,数据都会先存放到这段内存空间中,等到满足特定条件后再集中进行处理。

操作系统是“快递员”:它负责把底层的硬件数据(如磁盘文件、网络数据)源源不断地向下派送。内核缓冲区是“菜鸟驿站”:数据送到后不用催着用户立刻拿,而是先统一堆放在这个内存空间(驿站)里暂存。用户则是“收件人”:用户程序不需要频繁在门口等着接货,只需要在需要时,一次性去缓冲区里批量“取走”数据即可。

1.2 为什么要引入缓冲区机制

  引入缓冲区核心目的只有一个:提高效率(包含提高用户的效率、库函数的执行效率以及操作系统的整体运行效率)。

  • 系统调用的成本高昂:在Linux中,通过系统调用(如 write)向硬件写入数据是有很高的时间成本的。系统调用需要经历从用户态切换到内核态、执行内核逻辑、再切换回用户态的复杂过程。
  • 减少系统调用频次:操纵系统挺繁忙。如果程序每输出一个字符都直接调用一次系统调用,系统整体效率会大幅度降低。
语言层面中有哪些行为也是为了减少系统调用的频次? 内存池技术,STL中的扩容采用二倍扩容。

二、 缓冲区在哪里:深入 FILE 结构体

2.1 用户级缓冲区 vs 内核缓冲区

  首先需要明确一个核心结论:我们口中常说的、导致各种奇怪刷新现象的缓冲区,是用户级语言层面的缓冲区,由 C标准库维护,而并非操作系统内核缓冲区。
  为了搞清楚它究竟在哪里,我们需要去扒一扒 C 语言中 struct FILE 的底层源码。

2.2 探秘struct _IO_FILE

  在 <stdio.h> 中,所有标准输入输出函数(如 fopen, printf, fprintf)都是围绕 FILE 结构体展开的。通过查看 glibc 的底层源码可以注意到,FILE 实际上是 struct _IO_FILE 的别名。在这个结构体内部,包含了文件描述符(_fileno)以及大量的指针:

struct _IO_FILE {
  int _flags;                /* High-order word is _IO_MAGIC; rest is flags. */

  /* 下面的指针对应了缓冲区的边界与当前读写位置 */
  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. */

  /* ... 其他成员 ... */
  int _fileno;               /* 封装的底层系统底层文件描述符 fd */
};

  从源码中可以清晰地看出:

1. FILE 结构体内部封装了底层文件描述符 _fileno (即 fd)。
2. FILE 结构体内部还自带了该文件专属的缓冲区指针,来划定缓冲区范围(如 _IO_write_base 写入缓冲区的起始地址,_IO_write_ptr 当前写入到的位置等)。
3. 每一个被打开的文件,在标准库层面都有一个专属的 FILE 结构体,也就意味着每一个文件都有它自己独立的用户级缓冲区。
4. 一个进程可以有很多个文件,这些文件都需要有一个FILE结构体来描述,同时用链表把他们组织起来。当一个进程死亡,操作系统会遍历这张链表,把和这个进程有关的FILE释放掉。

三、 用户层的缓冲区刷新方案

  对于用户级的 C 库缓冲区,其刷新方案主要分为以下三种:

1. 立即刷新(无缓冲,Unbuffered)

  • 特点:不对数据进行缓存,一有数据立刻调用系统调用刷出。
  • 典型代表:标准错误输出 stderr,或者写透模式(Write-Through, WT)。为了保证错误信息能第一时间呈现给用户,通常不设置缓冲。

2. 行刷新(行缓冲,Line Buffered)

  • 特点:当遇到换行符 \n 时,或者缓冲区满时,才会触发刷新。
  • 典型代表:显示器设备(stdout)。因为显示器是给人类阅读的,人类的阅读习惯是一行一行阅读,因此采用行刷新既尊重了人类的阅读习惯,又兼顾了效率。

3. 全刷新(全缓冲,Fully Buffered)

  • 特点:只有当整个缓冲区被写满了(通常是 4KB),或者进程退出、强制刷新时,才会真正触发写入。
  • 典型代表:普通磁盘文件。向文件中写入数据时,效率最高的操作就是写满缓冲区再统一写入,避免频繁磁头寻道或触发写入。
全缓冲是无视\n的,\n只能刷新“向显示器输出的缓冲区”。而不能刷新“向文件输出的缓冲区。”

输出目的地默认缓冲模式\n 的作用显示器 / 终端 (stdout)行缓冲 (Line Buffered)有效。一看到 \n,立刻把这行字刷新到屏幕上。普通文件 (重定向/写文件)全缓冲 (Fully Buffered)无效。\n 此时只是一个普通字符,只有等缓冲区满了(一般是 4KB)或者程序搞定了才会刷新。

2.3 触发刷新的特殊场景

  除了上述基本策略外,只要满足以下任意一个条件,用户层缓冲区也会被强制刷新:

1. 用户调用强制刷新接口:如手动调用 fflush(stdout)
2. 进程正常退出:当进程执行完毕退出时,C 库会自动刷新所有未关闭文件的缓冲区。

四、 从现象到原理:为什么重定向会改变打印行为?

  在 Linux 文件操作中,有一个挺经典的悖论实验。我们来看下面这段测试代码:

#include
#include
#include
#include

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

    // 创建子进程
    fork();

    return 0;
}

4.1 直接向显示器打印(./a.out)

  当我们在终端直接运行程序时,输出结果如下:

[whb@bite-alicloud lesson20]$ ./a.out
hello printf
hello fprintf
hello fwrite
hello write

  现象分析:此时标准输出 stdout 对应的是显示器,采用的刷新策略是行刷新。由于代码中的每个字符串结尾都带有 \n,这样一来在执行到 fork() 之前,所有的 C 库函数(printf, fprintf, fwrite)的数据已经全部通过换行符触发了行刷新,漏到了操作系统内部,用户级缓冲区此时已经清空。随后 fork() 创建子进程,程序搞定,啥事儿没有。

4.2 重定向到普通文件(./a.out > log.txt)

  当我们把输出重定向到一个普通文件,并查看文件内容时,诡异的事情发生了:

[whb@bite-alicloud lesson20]$ ./a.out > log.txt
[whb@bite-alicloud lesson20]$ cat log.txt
hello write
hello printf
hello fprintf
hello fwrite
hello printf
hello fprintf
hello fwrite

  为什么 write 只打印了一次,而 C 库函数却打印了两次?且 write 跑到了最前面?

4.3 缓冲区与父子进程写时拷贝

  这个神奇的现象由以下几个底层机制共同决定:

1. 刷新策略的彻底改变

  当发生了重定向 > log.txt,标准输出的承载介质从显示器(行缓冲)变成了普通文件(全缓冲)。全缓冲意味着此时字符串末尾的 \n 彻底失去了刷新缓冲区的功效!

2. 执行到 fork() 时的状态

  • 系统调用 write:绕过了 C 库的用户级缓冲区,直接将数据交给了操作系统内核(内核级缓冲区)。这样一来,它不受全缓冲影响,直接写入,并且由于执行最早而排在文件首位。
  • C 库函数:由于是全缓冲,数据虽然带有 \n,但并没有达到满缓冲(4KB)的条件,这样一来数据依然残留在 C 库封装的那个用户级缓冲区里面。

3. fork() 与写时拷贝

  当执行到 fork() 时,子进程被创建。子进程会继承父进程的绝大部分数据,其中也包含了 FILE 结构体及其内部的用户级缓冲区。此时,父子进程的缓冲区里都有一份相同的临时文本数据。

4. 进程退出引发的二次刷新

  当父进程(或子进程)准备退出时,由于程序搞定,会自动触发缓冲区的强制刷新。刷新缓冲区本质上是一种写入(修改)操作。
  由于父子进程共享内存,当其中一个进程尝试刷新(修改)缓冲区的数据时,操作系统会瞬间触发写时拷贝机制,为该进程开辟一段新内存单独存放其缓冲区数据,随后执行刷新写入。另一个进程退出时,同样也会刷新它自己独立的那份缓冲区。
  最终结果:父进程刷新了一次用户缓冲区,子进程也刷新了一次用户缓冲区,导致 printf/fprintf/fwrite 的内容在文件中倒映出了两份!

4.4极端场景:不小心 close(fd) 会发生什么?

  在搞懂了 C 库缓冲区的底层逻辑后,我们可以解释另一个挺隐蔽的 Bug。

#include
#include
#include
#include
#include
#include

int main()
{
    close(1); // 关闭标准输出
    int fd = open("log.txt", O_CREAT | O_WRONLY | O_APPEND, 0666); // fd == 1

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

    // 危险操作:在 return 之前关闭 fd
    close(fd);

    return 0;
}

4.4.1 运行现象:文件里竟然没有 C 库的内容!

  运行上述程序后,查看 log.txt,你会注意到:文件里只有 write 系统调用的输出,而前面所有的 printf 内容全部凭空消失了!

4.4.2 消失的原因剖析

1. 重定向与全缓冲:由于关闭了 fd=1 并打开了新文件,printf 实际上是向文件写入,刷新策略转变为了全缓冲。
2. 数据滞留:连续调用多次 printf,数据只是被拷贝到了 C 库位于用户层的缓冲区里,并没有触发底层系统调用。
3. 纽带被切断:在程序执行到 return 0 之前,代码中提前执行了 close(fd)。这一步直接把底层的系统文件通道给彻底关闭了!
4. 刷新失败:当程序最终走到 return 准备自动刷新 C 库缓冲区时,底层的 printf 内部逻辑本质上是要调用 write(1, ...) 的。但此时底层的文件描述符 1 已经被你提前销毁了,C 库根本找不到对应的文件目标。

  最终代价:残留在用户级缓冲区中的数据因为找不到输出通道,直接被系统丢弃了,造成了数据丢失。
  解决方案:如果非要提前关闭 fd,务必在关闭前手动调用 fflush(stdout) 强行把数据塞给内核,或者不要提前执行 close(fd)

六、 计算机流动的本质:一切皆拷贝

  通过对整个 Linux 文件缓冲机制的推导,我们可以总结出计算机中数据流动的硬核本质:所谓的加载、流动、刷新,其底层本质全都是拷贝!

1. 第一步:语言层拷贝 —— 我们调用 printf("aaaa"),是将用户自定义区域(字面量常量区)的数据,拷贝到 C 标准库封装的 FILE 结构体专属的用户级语言层缓冲区中。

//内部构造大概样子:
if(缓冲区刷新条件1 | 缓冲区刷新条件2 | 缓冲区刷新条件3){
	write();//刷新缓冲区
}

1. 第二步:系统调用拷贝 —— 满足刷新条件后,C 库内部调用系统接口 write(fd, ...),将数据从用户级缓冲区拷贝到操作系统管理的文件内核缓冲区中。一旦数据交给了 OS,对于用户程序来说,就已经相当于交付给硬件了(OS 会自主决定最终的硬件刷新方案)。
2. 第三步:硬件级拷贝 —— 操作系统内核根据自身的调度算法、内存富余状态以及排队策略,将内核缓冲区的数据通过驱动真正拷贝、写入到外设/磁盘/显示器中。

  操作系统刷新内核级缓冲区要比刷新用户层缓冲区要复杂的多得多得多,包含三种情况以外还有别的需要考量,像是内存不足的时候必须立刻刷新缓冲区等等。   我们只需要相信操作系统就行了,至于它怎么刷新,我们不用管。

Tips:直接刷新内核缓冲区的接口和命令

  系统调用接口 方面:

   fsync(int fd):强制将指定文件描述符 fd 的内核缓冲区数据和文件元数据(如修改时间、大小等)全部同步刷新到磁盘等外部存储设备中。    fdatasync(int fd):功能类似 fsync,但只强制刷新文件数据,只有当元数据影响到数据读取时(如文件大小改变)才会刷新元数据,效率比 fsync 稍高。

  Shell命令 方面:

   sync:Linux 命令行工具。运行该命令会强制将操作系统中所有未写入磁盘的内核缓冲区数据(包含所有进程的文件缓存)全部刷新到外部设备中。

6.1 进程退出与缓冲区

   如果你在代码里调用的是 _exit(0);,它会直接杀掉进程。因为它是内核级的,它根本不认识标准 C 库的 FILE 结构体,更不会去管用户态的缓冲区。
   如果你在 printf(“Hello”);(不带换行)后面写了 _exit(0);缓冲区的数据会被直接丢弃,屏幕上什么都打印不出来!因此说,exit()里面封装了刷新语言层缓冲区+_exit()

七、实现一个C语言的库封装

1. 头文件 (MyGlibc.h)

#pragma once

#include
#include
#include
#include
#include
#include
#include

// 刷新策略宏定义(通过位图方式实现,方便后续位运算组合扩展)
#define NODE_FLUSH (1size_filebuf + size > MAX_SIZE) {
        // 简易处理:写满前强制刷新一次
        Fflush(file);
    }

    // 1. 将数据从用户传入的指针拷贝到 C 库维护的“语言层缓冲区”中,而不是直接触发系统调用
    memcpy(file->filebuf + file->size_filebuf, ptr, size);
    file->size_filebuf += size; // 更新缓冲区已有字节计数

    // 2. 判断刷新策略:如果是行缓冲,且写入的最后一个字符是换行符 '\n',则立刻刷新
    if ((file->Strategy_flush & LINE_FLUSH) && file->filebuf[file->size_filebuf - 1] == '\n')
    {
        Fflush(file);
    }

    return size; // 返回成功写入的字节数
}

/**
 * 模拟 fflush:强行把语言层缓冲区刷入内核缓冲区
 */
void Fflush(MyFILE* file)
{
    // 缓冲区如果没有数据,直接返回
    if (file->size_filebuf == 0)
        return;

    // 调用 Linux 系统调用 write,用文件描述符找到对应文件,把语言层数据刷新到内核缓冲区
    write(file->fileno, file->filebuf, file->size_filebuf);

    // 刷新成功后,重置语言层缓冲区计数
    file->size_filebuf = 0;
}

/**
 * 模拟 fclose:负责进程退出前的清理与冲刷工作
 */
void Fclose(MyFILE* file)
{
    // 防御性编程:检查 fd 是否有效
    if (file->fileno < 0)
        return;

    // 1. 关闭前必须强制刷新,防止语言层缓冲区遗留数据丢失
    Fflush(file);

    // 2. 关掉系统底层的内核文件描述符
    close(file->fileno);

    // 3. 释放用户态堆上分配的 MyFILE 结构体资源
    free(file);
}

3. 用户测试代码 (MyCode.cpp)

#include "MyGlibc.h"

void func()
{
    // 以写模式打开文件
    MyFILE* mf = Fopen("Test.txt", "w");
    if (mf == NULL) return;

    // 待写入的测试字符串(未带 \n,测试行缓冲不会立即刷新)
    const char* msg = "aabbccddeeffgg";

    // 此时数据被保存在用户态的 mf->filebuf 中
    Fwrite(mf, strlen(msg), msg);

    // 显式调用刷新,将数据通过 write 送入内核,若此处不调用,接下来的 Fclose 内部也会自动触发刷新
    Fflush(mf);

    // 资源清理
    Fclose(mf);
}

int main()
{
    func();
    return 0;
}

4. 自动化构建文件 (Makefile)

# 最终生成的可执行程序名称
BIN = proc
# 参与编译的所有源文件
SRC = MyGlibc.cpp MyCode.cpp

# 核心编译规则
$(BIN) : $(SRC)
	g++ -o $@ $^

# 伪目标:清理生成的中间及目标文件
.PHONY: clean
clean :
	rm -f $(BIN)


好的本期内容就到这里,
这篇笔记就先到这里,后面用到新的思路或者发现有问题再补充。

评论 (0)

暂无评论