目录
[struct file和struct device的理解](#struct file和struct device的理解)
一切皆文件的理解
在 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)**实现的。基类指针指向派生类对象,调用同名函数时,会动态执行对应的派生类代码。
-
基类 :
struct file(包含函数指针)。 -
派生类 :磁盘的
struct file、显示器的struct file。 -
重写(覆盖) :把函数指针指向具体的方法(如
read_disk)。 -
多态调用 :上层统一调用
read(fd, ...),底层自动找到对应的设备去读取。
struct file和struct device的理解
-
初始化
struct file(面子) :OS 会分配一个
struct file,并且把里面的f_op(或者图里的void (*read)指针),强行指向底层磁盘驱动的read_disk函数。 -
初始化
struct device(里子) :OS 同时也会分配并初始化一个
struct device,里面包含设备的具体信息(比如磁盘的型号、状态等)。 -
连接 :
图中的红线,就是指示了那个函数指针的指向关系 。
struct file里的read指针,指向了struct device所在层(设备管理层)提供的真正干活的函数read_disk。
总结
"一切皆文件"的本质是:内核把一切硬件设备、网络资源都封装成了 struct file 和 struct file_operations。用户只需用一个整数 fd,配合统一的方法(open/read/write/close),就能操控整个系统。
缓冲区
缓冲区的基本作用情景
缓冲区就是内存里预留的一块区域,用来临时存放数据,等攒够了再统一处理。
作用:
-
减少系统调用,降低开销 :每次调用
read/write都要陷入内核(用户态切内核态),极其耗时。用缓冲区攒够了一次性写,效率极高。 -
协调速度差异:CPU 和内存极快,而磁盘和打印机极慢。如果不加缓冲,CPU 发出写指令后只能干等慢速设备。
-
支持部分读写:网络传输时,数据是一点一点到的。缓冲区能把这些碎片拼凑成完整的包。
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这些库函数。 -
什么时候刷新?
-
用户强制刷新(比如调用
fflush)。 -
缓冲区满了。
-
遇到换行符
\n(行缓冲)。 -
进程退出 (调用
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 才会交给操作系统。
第三步:数据在缓冲区里"憋着"
程序开始执行
-
printf(...) -
fprintf(...) -
fwrite(...)这三句执行完,因为数据量太小,根本填不满 4KB 的缓冲区。所以,这三行数据全部憋在了
stdout的用户级缓冲区内 -
紧接着,执行了
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 *);