Linux(操作系统)文件系统-->对打开文件的管理(C语言层面)
前文(Linux(操作系统)文件系统--对打开文件的管理-CSDN博客)我们讲诉了:操作系统内核是如何管理已经打开了的文件的。
本文续接上文,我们这一节来讲讲C语言是如何辅佐操作系统的,聊一聊C语言除了封装系统调用外,还做了什么?C语言做的这些是如何减轻操作系统内核负担的?
这里我就直说了:(我本来是想通过源码来给大家引出缓冲区的概念的,但是由于新版的Linux版C语言标准库的设计太复杂了,看不太懂,所以这里我就直说了,不然后续就很难开展了)
在Linux系统中,文件操作是程序与外部世界(磁盘文件)交互的重要方式。操作系统内核负责管理打开的文件,但频繁的系统调用会带来显著的性能开销 。C语言标准库通过实现一套缓冲区机制,在用户空间对文件操作进行优化,有效减少了系统调用的次数,从而减轻了内核的负担。
频繁的系统调用的代价是什么?/ 缓冲区存在的原因
调用系统接口(系统调用)的开销远大于普通的函数调用,其根本原因在于系统调用需要从用户态(user mode)切换到内核态(kernel mode)。
这种切换是一项非常重量级的操作,其开销具体体现在以下几个方面:
1. 上下文切换 (Context Switch)
这是最直接的开销。一次系统调用涉及两次完整的上下文切换:
- 从用户态切换到内核态 :当应用程序执行
int 0x80
(传统)、syscall
/sysenter
(现代)等指令触发系统调用时,CPU必须:- 保存当前用户进程的"上下文"(Context)。这包括几乎所有通用寄存器的状态、程序计数器(PC)、栈指针(SP)、段寄存器等。这些状态需要被妥善保存,以便系统调用返回后能完全恢复现场,让用户进程无缝继续执行。
- 加载内核的上下文。CPU切换到特权模式(Ring 0),并开始执行内核预设的系统调用处理程序。
- 从内核态切换回用户态 :系统调用执行完毕后,CPU需要:
- 保存内核的上下文。
- 恢复之前保存的用户进程的上下文。
- 切换回非特权模式(Ring 3),并跳回到用户进程中系统调用之后的那条指令继续执行。
类比:做一个不严谨的类比:调用系统接口我觉得有点类似:叫一个正在做手术的医生停下当前的手术去开会(仅仅是做类比而已)。
医生要先记录当前手术的进度,以及开完会之后手术的下一步是什么(保存当前用户进程的"上下文"),然后脱掉做手术的工服,换上正常的衣服(加载内核上下文),紧接着就去开会。
开完会后,医生要记录开会的内容(保存内核的上下文),然后穿上手术的时候的工服,带上面罩头套(切换回非特权模式),然后打开 开会前的手术记录,按照记录上写的步骤进行手术的下一步(恢复之前保存的用户进程上下文)。
我个人觉得还比较贴切的说明了这个事情的麻烦程度,要是频繁这样做,医生应该比病人先挂掉。
2. 内核态与用户态的内存空间隔离
现代操作系统通过内存管理单元(MMU)为每个用户进程提供了独立的虚拟地址空间,并与内核空间严格隔离。
- 用户态:进程无法直接访问内核空间的内存。
- 内核态:可以访问整个内存空间。
当系统调用需要处理用户空间的数据(例如,write
系统调用要写入的数据在用户空间的缓冲区里)时,内核不能直接解引用用户提供的指针,因为那是一个用户空间的地址。
- 额外数据拷贝 :内核必须使用像
copy_from_user()
和copy_to_user()
这样的特殊函数,来显式地将数据从用户空间拷贝到内核空间,或者反过来。这次拷贝操作本身需要CPU周期和内存带宽。 - 安全检查:在这些拷贝过程中,内核还会严格检查用户提供的地址是否有效、可读写,防止恶意程序传递非法指针导致内核崩溃或安全漏洞。这些检查也增加了开销。
对比普通函数调用:函数调用传递指针时,双方都在同一个地址空间,可以直接访问,无需拷贝和复杂的安全检查。
3. 特权级别检查与陷阱处理
- 陷阱(Trap) :系统调用是通过软中断(软件触发的中断)实现的。CPU收到中断信号后,需要中断当前执行流,去查找中断描述符表(IDT),找到对应的系统调用处理程序的入口地址。这个查表和跳转的过程本身就有开销。
- 特权级别检查:CPU在执行任何特权指令(如操作IO端口、修改CR3寄存器等)前都会进行安全检查,确保当前处于内核态(Ring 0)。虽然系统调用本身不一定会触发很多这类检查,但整个内核执行路径上充满了这种隐性开销。
4. 缓存与TLB的失效
CPU高度依赖缓存(Cache)和转址旁路缓存(TLB)来加速内存访问。
- 缓存失效 :当CPU从执行用户代码(用户态)切换到执行内核代码(内核态)时,原本缓存中热乎的用户代码和数据很可能被内核代码和数据所覆盖。当系统调用返回时,又需要重新加载用户进程的代码和数据到缓存中。这种缓存上下文切换(Cache Context Switch) 会导致缓存命中率下降,增加内存访问延迟。
- TLB失效:TLB缓存了虚拟地址到物理地址的映射。用户进程和内核拥有不同的页表(内存映射关系)。切换模式可能导致TLB被刷新或部分失效,需要重新载入新的地址映射条目,这会使后续的内存访问变慢。
5. 调度器与中断的影响
系统调用执行过程中,内核代码可能会被更高优先级的中断所打断。此外,如果系统调用需要等待某些资源(如磁盘IO),内核可能会挂起当前进程,触发进程调度,切换到另一个就绪进程去执行。这种睡眠和唤醒的过程伴随着复杂的上下文切换和调度决策,开销巨大。
总结与类比
为了让你更直观地理解,我们用一个比喻:
- 普通函数调用:就像你在自己的工位上,伸手从自己的笔筒里拿一支笔。动作非常快,无需任何审批。
- 系统调用 :就像你需要一支公司仓库里的特种笔。
- 你需要填写申请单(准备参数,触发软中断)。
- 把申请单交给前台行政(切换至内核态)。
- 行政人员审核你的申请(安全检查)。
- 行政去仓库找到笔(内核执行具体操作,可能涉及磁盘IO等慢速设备)。
- 行政登记备案(更新内核数据结构)。
- 行政把笔交给你 (
copy_to_user
,切换回用户态)。 - 你拿到笔开始用(返回用户进程)。
这个过程显然比直接从自己笔筒拿笔要慢得多。
回到C标准库的缓冲区
正是因为系统调用的开销如此巨大,C标准库才要引入缓冲区
它的核心目的就是减少系统调用的次数:
- 写操作 :频繁的
fprintf
/fwrite
调用只是将数据填入用户空间的缓冲区 ,不会立即触发write
系统调用 。只有当缓冲区满了,或遇到换行符(行缓冲模式),或主动调用fflush
时,才将缓冲区中的所有数据一次性通过一个系统调用写入内核。这将对100次小写操作的开销,降低为1次大批量写操作的开销。 - 读操作 :当使用
fread
时,库函数可能会一次性通过一个read
系统调用预读4KB的数据到用户缓冲区,然后后续的读取请求直接从缓冲区获取,直到缓冲区读空为止。这避免了一次读1个字节就调一次系统调用的极端情况。
因此,C标准库的缓冲区是在用户层 对系统调用进行的一次重要优化,它通过批处理 和预读策略,极大地减少了模式切换的次数,从而提升了I/O效率,并间接减轻了内核的负担。
其次,用C语言提供的函数真的比系统调用好用吧,这样一来也极大地方便了程序员,提高了开发效率和使用安全性。
观察源码
C语言标准库的缓冲区是如何维护的呢?
C语言FILE结构的缓冲区设计
c
//FILE.h
#ifndef __FILE_defined
#define __FILE_defined 1
struct _IO_FILE;
/* The opaque type of streams. This is the definition used elsewhere. */
typedef struct _IO_FILE FILE;//FILE是_IO_FILE的重命名
#endif
c
//struct_FILE.h
struct _IO_FILE
{
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
/* The following pointers correspond to the C++ streambuf protocol. */
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. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
int _flags2:24;
/* Fallback buffer to use when malloc fails to allocate one. */
char _short_backupbuf[1];
__off_t _old_offset; /* This used to be _offset but it's too small. */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};
这个 struct _IO_FILE
结构体,是 Glibc(GNU C Library)中标准 I/O(stdio
)实现的基石,我们平时使用的 FILE*
(例如 stdin
, stdout
, stderr
)在底层其实就是指向这个结构或其扩展形式的指针。
它的核心作用是管理一个文件流的用户态缓冲区,通过批处理操作来极大减少昂贵的系统调用次数,从而提高 I/O 效率。
整个结构体的字段可以划分为几个功能模块:
1. 核心缓冲区管理指针(最重要的部分)
这是实现缓冲机制的核心。想象一个数组(缓冲区),这些指针标记了其中的不同区域。
c
/* The following pointers correspond to the C++ streambuf protocol. */
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. */
// 指向整个缓冲区的起始地址(通常是malloc分配的)。
char *_IO_buf_end; /* End of reserve area. */
// 指向整个缓冲区的结束地址。
它们之间的关系和工作流程:
- 读取缓冲 :
_IO_read_base
到_IO_buf_end
是整个缓冲区。_IO_read_base
到_IO_read_end
是当前从文件中读取到缓冲区的有效数据。_IO_read_ptr
到_IO_read_end
是尚未被用户程序读取的数据。- 当
_IO_read_ptr
>=_IO_read_end
,意味着缓冲区数据已读完,需要调用read
系统调用重新填满缓冲区(从_IO_read_base
开始填充)。
- 写入缓冲 :
_IO_buf_base
到_IO_buf_end
是整个缓冲区。_IO_write_base
到_IO_write_end
是当前可供写入的缓冲区范围。_IO_write_ptr
指向下一个要写入数据的位置。- 当用户程序调用
fwrite
或fprintf
,数据被复制到_IO_write_ptr
指向的位置,然后_IO_write_ptr
向后移动。 - 当
_IO_write_ptr
>=_IO_write_end
,意味着缓冲区已满,需要调用write
系统调用将缓冲区数据刷入文件(从_IO_write_base
到_IO_write_ptr
),然后重置指针。
- 注意 :读写有时会共用同一个缓冲区,通过
_flags
中的位来控制模式。
2. 备份与撤销支持 (Backup & Undo)
这组指针主要用于输入流,实现 ungetc
之类的功能,即把读出去的字符"退回"到流中。
c
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
当程序从缓冲区读取数据后,_IO_read_ptr
前移。如果此时执行了 ungetc(c, fp)
,库函数可能会调整这些指针,将字符 c
放回缓冲区,并修改 _IO_read_ptr
使其指回这个字符,以便下次读取时能再次读到它。
3. 链式管理与文件描述符
c
struct _IO_FILE *_chain; // 将所有打开的 FILE 结构体链接成一个链表,便于内部管理。
int _fileno; // 这是最关键的字段之一!它存储了与该 FILE 流关联的【底层文件描述符】。
// 例如,stdin 的 _fileno 是 0,stdout 是 1,stderr 是 2。
// 当需要执行实际系统调用时(如 read/write),最终使用的就是这个 fd。
4. 标志与控制字段
c
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
这是一个多功能字段:
- 魔数 (Magic Number) :高位字是一个特定的值(如
_IO_MAGIC
),用于验证这是一个有效的_IO_FILE
结构,防止误用或内存损坏。 - 标志位 (Flags) :低位字包含一系列位掩码,用于精确控制流的属性和状态。例如:
_IO_NO_READS
/_IO_NO_WRITES
:标识流是只写或只读。_IO_UNBUFFERED
:无缓冲(如 stderr)。_IO_LINE_BUF
:行缓冲(如连接到终端时的 stdout)。_IO_EOF_SEEN
:已遇到文件结束符(EOF)。_IO_ERR_SEEN
:发生了错误。_IO_CURRENTLY_PUTTING
:当前正在输出操作中。
c
int _flags2:24; // 扩展的标志位字段,使用了位域。
5. 锁与线程安全
c
_IO_lock_t *_lock; // 指向一个锁对象的指针,用于实现多线程(MT-Safe)环境下的流操作同步。
// 这就是为什么多个线程同时操作同一个 FILE* 是安全的。
6. 其他实用字段
c
// 用于窄字符IO的微小备份缓冲区,当主缓冲区无法分配时使用。
char _short_backupbuf[1];
// 当主缓冲区不可用时,使用的极小内置缓冲区。
char _shortbuf[1];
// 文件偏移量相关(旧版和新版)
__off_t _old_offset; // 旧式的偏移量,可能已过时。
__off64_t _offset; // 当前的文件偏移量(在 _IO_FILE_complete 中)。
// 这用于跟踪文件位置,是实现 fseek、ftell 的基础。
// 宽字符(wchar_t)支持
struct _IO_codecvt *_codecvt; // 字符转换规则,用于宽字符流。
struct _IO_wide_data *_wide_data; // 宽字符流专用的缓冲区和管理数据。
// 用于 fclose() 时释放资源的管理链表
struct _IO_FILE *_freeres_list;
void *_freeres_buf;
// 记录总共写入的字节数,用于错误处理和精确计数
__uint64_t _total_written;
总结与核心思想
struct _IO_FILE
是一个极其复杂和精细的结构体,它的设计体现了软件工程中的"批处理"和"缓存"思想。
- 抽象层 :它在低级的、基于文件描述符的系统调用(
read
,write
,lseek
)之上,构建了一个高级的、带缓冲的、格式化的 I/O 层(fread
,fwrite
,fprintf
,fscanf
)。 - 性能核心 :通过维护用户空间的缓冲区,它将多次小的 I/O 请求合并为一次大的系统调用。例如,多次
fputc('a', stdout)
调用只会将字符填入缓冲区,直到缓冲区满或遇到换行符(行缓冲模式)时,才调用一次write(1, buffer, size)
。这极大地减少了用户态-内核态上下文切换的开销。 - 功能丰富 :它不仅支持简单的缓冲,还支持定位(
fseek
)、撤回(ungetc
)、宽字符、线程安全以及复杂的错误处理。
当你使用 fopen
时,库会创建一个 _IO_FILE
结构并分配缓冲区;当你使用 fclose
时,它会刷新缓冲区并释放所有这些资源。
缓冲区运作机制
写入操作的缓冲区管理
c
//iofwrite.c
#include "libioP.h"
size_t
_IO_fwrite (const void *buf, size_t size, size_t count, FILE *fp)
{
size_t request = size * count;
size_t written = 0;
CHECK_FILE (fp, 0);
if (request == 0)
return 0;
_IO_acquire_lock (fp);
if (_IO_vtable_offset (fp) != 0 || _IO_fwide (fp, -1) == -1)
{
/* Compute actually written bytes plus pending buffer
contents. */
uint64_t original_total_written
= fp->_total_written + (fp->_IO_write_ptr - fp->_IO_write_base);
written = _IO_sputn (fp, (const char *) buf, request);
if (written == EOF)
{
/* An error happened and we need to find the appropriate return
value. There 3 possible scenarios:
1. If the number of bytes written is between 0..[buffer content],
we need to return 0 because none of the bytes from this
request have been written;
2. If the number of bytes written is between
[buffer content]+1..request-1, that means we managed to write
data requested in this fwrite call;
3. We might have written all the requested data and got an error
anyway. We can't return success, which means we still have to
return less than request. */
if (fp->_total_written > original_total_written)
{
written = fp->_total_written - original_total_written;
/* If everything was reported as written and somehow an
error occurred afterwards, avoid reporting success. */
if (written == request)
--written;
}
else
/* Only already-pending buffer contents was written. */
written = 0;
}
}
_IO_release_lock (fp);
/* We have written all of the input in case the return value indicates
this. */
if (written == request)
return count;
else
return written / size;
}
libc_hidden_def (_IO_fwrite)
# include <stdio.h>
weak_alias (_IO_fwrite, fwrite)
libc_hidden_weak (fwrite)
# ifndef _IO_MTSAFE_IO
weak_alias (_IO_fwrite, fwrite_unlocked)
libc_hidden_weak (fwrite_unlocked)
# endif
_IO_fwrite
是 C 标准库函数 fwrite
的核心。结合之前分析的 struct _IO_FILE
缓冲区结构,我们可以清晰地看到其工作原理。
函数原型与宏观逻辑
c
size_t _IO_fwrite (const void *buf, size_t size, size_t count, FILE *fp)
- 目标 :将
count
个大小为size
的数据元素,从用户提供的缓冲区buf
写入到文件流fp
中。 - 返回值 :成功写入的元素个数(item count) ,而非字节数。如果发生错误,返回值会小于
count
。
执行流程详解
1. 前期准备与检查
c
size_t request = size * count; // 计算需要写入的总字节数
size_t written = 0;
CHECK_FILE (fp, 0); // 宏:检查 FILE* 指针 fp 是否有效,无效则返回 0
if (request == 0) // 如果请求写入 0 字节,直接返回 0(元素个数)
return 0;
- 首先计算出总共需要写入的字节数
request
。 CHECK_FILE
是一个安全宏,确保fp
是一个合法的文件流指针。
2. 获取锁(线程安全)
c
_IO_acquire_lock (fp);
- 这是多线程安全的关键 。在操作文件流之前,必须先获取与该流关联的锁(
fp->_lock
)。这确保了即使多个线程同时调用fwrite
操作同一个FILE*
,它们的操作也会被序列化,避免数据竞争和缓冲区状态混乱。
3. 核心分支:选择操作路径
c
if (_IO_vtable_offset (fp) != 0 || _IO_fwide (fp, -1) == -1)
{
// ... 主要逻辑(窄字符流)
}
_IO_fwide (fp, -1)
:这个函数检查并设置流的朝向 (orientation)。参数-1
表示"尝试将流设置为字节取向(窄字符)"。- 如果流尚未设置朝向,则将其设置为窄字符朝向。
- 如果流已经是窄字符朝向,返回
-1
。 - 如果流已经是宽字符朝向,返回
1
(这是一个错误,因为不能混用窄和宽操作)。
- 条件判断 :这个
if
条件的意思是:"如果这个文件流是支持窄字符操作的(无论是已经是窄字符还是成功设置为窄字符)"。 - 因此,整个核心逻辑只在流为窄字符取向时执行 。如果流是宽字符取向,
written
会保持为 0,函数最终会返回 0。
4. 核心写入逻辑与缓冲区机制
这是整个函数最精华的部分,它完美体现了用户态缓冲区的设计。
c
uint64_t original_total_written = fp->_total_written + (fp->_IO_write_ptr - fp->_IO_write_base);
written = _IO_sputn (fp, (const char *) buf, request);
a) 记录初始状态 (original_total_written
)
fp->_total_written
:这是一个累计值,表示已经从该流缓冲区成功刷新到内核(通过系统调用)的总字节数。(fp->_IO_write_ptr - fp->_IO_write_base)
:这表示当前用户态缓冲区中尚未刷新到内核的待写入字节数。original_total_written
的值就是:到目前为止,理论上应该已经成功写入内核的总字节数(包括已在缓冲区的)。这是一个基准值,用于后续的错误处理。
b) 执行写入操作 (_IO_sputn
)
_IO_sputn
是真正执行写入操作的函数 。它不是一个简单的系统调用封装,而是缓冲区管理的核心。- 它的内部逻辑大致如下 :
- 检查用户态缓冲区剩余空间 :计算
fp->_IO_write_end - fp->_IO_write_ptr
。 - 如果用户请求的数据量 (
request
) 小于缓冲区剩余空间 :- 简单地将数据从用户缓冲区
buf
拷贝(memcpy) 到文件流的用户态缓冲区(fp->_IO_write_ptr
指向的位置)。 - 然后前进
fp->_IO_write_ptr
。 - 操作完成,返回
request
。整个过程没有任何系统调用!
- 简单地将数据从用户缓冲区
- 如果用户请求的数据量很大,或者缓冲区已满 :
- 首先将缓冲区中已有的数据(
fp->_IO_write_base
到fp->_IO_write_ptr
)通过系统调用(如write
)刷新(flush) 到内核。这会更新fp->_total_written
。 - 如果剩下的数据量仍然很大,可能会绕过用户态缓冲区,直接使用系统调用将数据从
buf
写入内核(称为"一次大写入")。 - 如果剩下的数据量不大,则将其拷贝到刚清空的用户态缓冲区中。
- 首先将缓冲区中已有的数据(
- 检查用户态缓冲区剩余空间 :计算
- 所以,
_IO_sputn
的返回值written
表示本次调用成功处理(不一定已到磁盘)的字节数。
5. 复杂的错误处理
c
if (written == EOF) {
if (fp->_total_written > original_total_written) {
written = fp->_total_written - original_total_written;
if (written == request) --written;
} else {
written = 0;
}
}
- 如果
_IO_sputn
返回EOF
,表示底层发生了错误(如磁盘已满)。 - 但错误可能发生在任何阶段,因此需要精确计算到底有多少本次请求的数据成功写入。
- 场景 1 :
fp->_total_written > original_total_written
- 这说明在本次
fwrite
调用过程中,有部分数据(可能是缓冲区原有的,也可能是本次请求的)已经被成功刷新到内核。 written = fp->_total_written - original_total_written
计算出新增的、已确认写入内核的字节数。这些字节很可能来自本次请求。- 即使这个数等于
request
,也不能返回成功(因为错误确实发生了),所以--written
确保返回值小于请求值。
- 这说明在本次
- 场景 2 :
fp->_total_written == original_total_written
- 这说明在错误发生前,没有任何数据被刷新到内核。本次请求的数据可能还在用户缓冲区里,但因为它们没有被真正提交到内核,所以视为全部失败 ,返回
written = 0
。
- 这说明在错误发生前,没有任何数据被刷新到内核。本次请求的数据可能还在用户缓冲区里,但因为它们没有被真正提交到内核,所以视为全部失败 ,返回
这个复杂的逻辑确保了 fwrite
的语义:只有在数据真正交付给系统内核后,才被认为是成功写入的。仅仅在用户态缓冲区里是不算数的。
6. 释放锁与返回
c
_IO_release_lock (fp); // 释放锁,其他线程可以操作这个流了
if (written == request)
return count; // 所有请求的字节都成功处理,返回元素个数 count
else
return written / size; // 计算出成功写入的完整元素个数
- 释放锁是必须的,否则会导致死锁。
- 最终返回值转换:因为函数需要返回成功写入的元素个数 ,所以用字节数
written
除以每个元素的大小size
。整数除法会自动截断,如果最后一个元素没写全,它就不会被计入。这符合fwrite
的语义。
总结:缓冲区机制如何工作
通过分析 _IO_fwrite
,我们可以看到标准库的缓冲区机制是如何工作的:
- 批处理/聚合 :多次小的
fwrite
调用会将数据累积在用户态缓冲区中,只有满足条件(缓冲区满、 explicitlyfflush
、 遇到换行符且为行缓冲)时,才发起一次昂贵的系统调用。这极大地减少了用户态-内核态切换的次数。 - 内存操作 vs. 系统调用 :在最佳情况下(缓冲区空间足够),
fwrite
仅仅是一次memcpy
,速度极快。只有在刷新缓冲区时,才涉及慢速的系统调用。 - 线程安全:通过锁机制,保证了对缓冲区状态操作的一致性。
- 可靠的错误语义:复杂的错误处理逻辑确保了返回值能准确反映到底有多少数据被系统确认接收,而不是仅仅存放在用户空间的缓冲区里。
- 透明的接口 :所有这些复杂的机制都对用户是透明的。用户只需要调用
fwrite
,标准库就会自动、高效地管理好一切。
读取操作的缓冲管理
c
//iofread.c
#include "libioP.h"
size_t
_IO_fread (void *buf, size_t size, size_t count, FILE *fp)
{
size_t bytes_requested = size * count;
size_t bytes_read;
CHECK_FILE (fp, 0);
if (bytes_requested == 0)
return 0;
_IO_acquire_lock (fp);
bytes_read = _IO_sgetn (fp, (char *) buf, bytes_requested);
_IO_release_lock (fp);
return bytes_requested == bytes_read ? count : bytes_read / size;
}
libc_hidden_def (_IO_fread)
weak_alias (_IO_fread, fread)
# ifndef _IO_MTSAFE_IO
strong_alias (_IO_fread, __fread_unlocked)
libc_hidden_def (__fread_unlocked)
weak_alias (_IO_fread, fread_unlocked)
# endif
_IO_fread
是 C 标准库函数 fread
的核心。与 fwrite
对称,它完美体现了用户态缓冲区在读取操作中的应用。
函数原型与宏观逻辑
c
size_t _IO_fread (void *buf, size_t size, size_t count, FILE *fp)
- 目标 :从文件流
fp
中读取count
个大小为size
的数据元素,存入用户提供的缓冲区buf
。 - 返回值 :成功读取的元素个数(item count) 。如果发生错误或遇到文件结尾(EOF),返回值会小于
count
。
执行流程详解
1. 前期准备与检查
c
size_t bytes_requested = size * count; // 计算用户希望读取的总字节数
size_t bytes_read;
CHECK_FILE (fp, 0); // 宏:检查 FILE* 指针 fp 是否有效,无效则返回 0
if (bytes_requested == 0) // 如果请求读取 0 字节,直接返回 0(元素个数)
return 0;
- 首先计算出用户希望读取的总字节数
bytes_requested
。 CHECK_FILE
是一个安全宏,确保fp
是一个合法的文件流指针。
2. 获取锁(线程安全)
c
_IO_acquire_lock (fp);
- 多线程安全的关键 。在操作文件流之前,必须先获取与该流关联的锁(
fp->_lock
)。这确保了即使多个线程同时调用fread
操作同一个FILE*
,它们的操作也会被序列化,避免数据竞争和缓冲区状态混乱。
3. 核心读取操作
c
bytes_read = _IO_sgetn (fp, (char *) buf, bytes_requested);
_IO_sgetn
是真正执行读取操作的函数。它是缓冲区读取机制的核心。- 它的内部逻辑大致如下 :
- 检查用户态缓冲区是否有足够数据 :
- 计算
fp->_IO_read_end - fp->_IO_read_ptr
。这个值表示当前用户态缓冲区中还有多少字节的有效数据未被用户程序读取。 - 如果缓冲区中剩余的数据
>= bytes_requested
,那么:- 简单地将数据从文件流的用户态缓冲区(
fp->_IO_read_ptr
指向的位置)拷贝(memcpy) 到用户提供的缓冲区buf
。 - 然后前进
fp->_IO_read_ptr
。 - 操作完成,返回
bytes_requested
。整个过程没有任何系统调用!
- 简单地将数据从文件流的用户态缓冲区(
- 计算
- 如果用户态缓冲区数据不足 :
- 先将缓冲区里剩余的所有数据拷贝到用户提供的
buf
中。 - 然后,库函数会发起一次系统调用(如
read
) ,试图从内核中读取一大块数据(例如 4KB 或 8KB)来填满整个用户态缓冲区。这个操作称为"预读"或"填充缓冲区"。 - 填充完成后,
fp->_IO_read_ptr
被重置到缓冲区的开始(_IO_read_base
),fp->_IO_read_end
被设置为缓冲区开始加上实际读取到的字节数。 - 最后,从这块刚填充好的缓冲区中,将剩下的 、用户请求的数据拷贝到
buf
的相应位置。
- 先将缓冲区里剩余的所有数据拷贝到用户提供的
- 如果在填充缓冲区时遇到文件结尾(EOF)或错误 :
- 系统调用返回的字节数会小于请求的缓冲区大小。
fp->_IO_read_end
会被正确设置,反映出实际读入了多少数据。_IO_sgetn
的返回值bytes_read
将是实际从内核获取的总字节数(之前缓冲区剩余的 + 本次新读取的),这个值会小于bytes_requested
。
- 检查用户态缓冲区是否有足够数据 :
4. 释放锁
c
_IO_release_lock (fp);
- 释放锁,其他线程可以操作这个流了。
5. 计算并返回结果
c
return bytes_requested == bytes_read ? count : bytes_read / size;
- 返回值转换 :因为函数需要返回成功读取的元素个数。
- 理想情况 :如果实际读取的字节数
bytes_read
等于请求的字节数bytes_requested
,说明完全满足用户需求,直接返回用户请求的元素个数count
。 - 非理想情况 :如果
bytes_read < bytes_requested
(因为遇到 EOF 或错误),则用字节数bytes_read
除以每个元素的大小size
。- 整数除法会自动截断 。这意味着如果最后一个元素没有被完整读取(例如,请求 10 个
int
,但只读到了 37 个字节),那么这个不完整的元素不会被计入返回值。 - 例如:
size = sizeof(int) = 4
,count = 10
,bytes_requested = 40
。如果bytes_read = 37
,则返回值是37 / 4 = 9
。这符合fread
的语义:只返回成功读取的完整元素的个数。
- 整数除法会自动截断 。这意味着如果最后一个元素没有被完整读取(例如,请求 10 个
总结:读取缓冲区机制如何工作
通过分析 _IO_fread
,我们可以看到标准库的读取缓冲区机制是如何工作的:
- 预读(Read-Ahead)与批处理 :这是与
fwrite
对称但策略相反的核心机制。fwrite
是累积-刷新(Aggregate-Flush):累积多次小写入,一次大刷新。fread
是预读-分发(Prefetch-Distribute):一次大读取(预读)填充缓冲区,然后多次小读取直接从缓冲区分发数据。- 它极大地减少了系统调用的次数 。一次系统调用可以读入 4KB 数据,后续几十甚至上百次小的
fread
调用都可能不再需要切换内核态。
- 内存操作 vs. 系统调用 :在最佳情况下(缓冲区数据充足),
fread
仅仅是一次memcpy
,速度极快。只有在缓冲区被掏空时,才涉及昂贵的系统调用。 - 线程安全 :通过锁机制,保证了对缓冲区状态操作(如修改
_IO_read_ptr
)的一致性。 - 透明的接口:用户无需关心数据是来自标准库的缓冲区还是直接从磁盘读取。库函数会自动、高效地管理好一切。
- 可靠的语义:返回值精确反映了读取到的完整元素个数,处理 EOF 和部分读取的场景非常合理。
对比 fwrite
和 fread
的缓冲区策略
特性 | fwrite (写入) |
fread (读取) |
---|---|---|
核心策略 | 延迟写入(Lazy Write) | 预读(Read-Ahead) |
缓冲目的 | 聚合多次小写入,减少系统调用 | 提前获取大数据块,减少系统调用 |
触发系统调用的条件 | 缓冲区满、显式刷新(fflush )、行缓冲换行 |
缓冲区空 |
用户态主要操作 | memcpy 到库缓冲区 |
memcpy 到用户缓冲区 |
错误处理 | 复杂(需区分数据在缓冲区还是已提交) | 相对简单(数据未从内核读出则不算数) |
标准文件流的初始化
这里顺便把标准文件流的初始化也给大家看一眼:
c
//glibc\libio\stdio.h
/* Standard streams. */
extern FILE *stdin; /* Standard input stream. */
extern FILE *stdout; /* Standard output stream. */
extern FILE *stderr; /* Standard error output stream. */
/* C89/C99 say they're macros. Make them happy. */
#define stdin stdin
#define stdout stdout
#define stderr stderr
C语言预定义了三个标准流,它们使用不同的缓冲策略:
c
//glibc\libio\stdfiles.c
DEF_STDFILE(_IO_2_1_stdin_, 0, 0, _IO_NO_WRITES);
DEF_STDFILE(_IO_2_1_stdout_, 1, &_IO_2_1_stdin_, _IO_NO_READS);
DEF_STDFILE(_IO_2_1_stderr_, 2, &_IO_2_1_stdout_, _IO_NO_READS+_IO_UNBUFFERED);
特别是stderr使用了无缓冲模式(_IO_UNBUFFERED),这保证了错误信息能够立即输出,而stdout通常使用行缓冲或全缓冲模式。
c
//glibc\libio\libio.h
struct _IO_FILE_plus;
extern struct _IO_FILE_plus _IO_2_1_stdin_;
extern struct _IO_FILE_plus _IO_2_1_stdout_;
extern struct _IO_FILE_plus _IO_2_1_stderr_;
C标准库通过_IO_jump_t
结构实现了类似面向对象的多态机制:
c
//glibc\libio\libioP.h
struct _IO_jump_t
{
JUMP_FIELD(size_t, __dummy);
JUMP_FIELD(size_t, __dummy2);
JUMP_FIELD(_IO_finish_t, __finish);
JUMP_FIELD(_IO_overflow_t, __overflow);
JUMP_FIELD(_IO_underflow_t, __underflow);
JUMP_FIELD(_IO_underflow_t, __uflow);
JUMP_FIELD(_IO_pbackfail_t, __pbackfail);
/* showmany */
JUMP_FIELD(_IO_xsputn_t, __xsputn);
JUMP_FIELD(_IO_xsgetn_t, __xsgetn);
JUMP_FIELD(_IO_seekoff_t, __seekoff);
JUMP_FIELD(_IO_seekpos_t, __seekpos);
JUMP_FIELD(_IO_setbuf_t, __setbuf);
JUMP_FIELD(_IO_sync_t, __sync);
JUMP_FIELD(_IO_doallocate_t, __doallocate);
JUMP_FIELD(_IO_read_t, __read);
JUMP_FIELD(_IO_write_t, __write);
JUMP_FIELD(_IO_seek_t, __seek);
JUMP_FIELD(_IO_close_t, __close);
JUMP_FIELD(_IO_stat_t, __stat);
JUMP_FIELD(_IO_showmanyc_t, __showmanyc);
JUMP_FIELD(_IO_imbue_t, __imbue);
};
/* We always allocate an extra word following an _IO_FILE.
This contains a pointer to the function jump table used.
This is for compatibility with C++ streambuf; the word can
be used to smash to a pointer to a virtual function table. */
struct _IO_FILE_plus
{
FILE file;
const struct _IO_jump_t *vtable;
};
这种设计允许不同类型的文件(普通文件、内存流、字符串流等)共享相同的接口但有不同的实现,提高了代码的灵活性和可维护性。
缓冲区刷新策略
缓冲区的刷新(Flushing)是指将累积在用户态缓冲区中的数据真正写入内核(对于输出流),或者丢弃无效数据、重新定位(对于输入流)的过程。这是标准库性能优化的核心机制之一。
缓冲区的刷新策略主要分为三种模式,其触发条件和管理逻辑如下:
一、 三种缓冲模式
标准库为每个 FILE
流指定了一种缓冲模式,这决定了其基本的刷新策略。
1. 全缓冲(Fully Buffered / Block Buffered)
- 行为:在用户态缓冲区被填满之前,不会发起系统调用进行实际的 I/O 操作。
- 典型应用 :这是默认模式 ,通常用于操作普通磁盘文件 (如用
fopen
打开的文件)。 - 优点 :最大程度地聚合数据,系统调用次数最少,对磁盘这类块设备最友好,性能最高。
- 示例 :写入一个 4KB 缓冲区的数据,即使你调用了 100 次
fwrite
,也只在第 100 次调用填满缓冲区后,才执行一次write
系统调用。
2. 行缓冲(Line Buffered)
- 行为 :在以下两种情况下会刷新缓冲区:
- 缓冲区被填满。
- 遇到一个换行符 (
\n
)。
- 典型应用 :用于标准输出(
stdout
) 和标准错误(stderr
) (但当且仅当它们被连接到交互式设备,如终端(terminal)时)。 - 优点 :在交互式设备上,保证了输出的即时性和可读性 。用户输入回车后,能立刻看到上一行的输出结果。同时,对于一行的多次输出(如多个
printf
),也能被聚合,避免为每个字符都调用一次系统调用。 - 示例 :
printf("Hello "); printf("World!\n");
第二个printf
中的\n
会触发刷新,将"Hello World!\n"
整行一起输出到屏幕。
大家可以通过下面的代码来感受一下:
c
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("hello 77 !");
sleep(3);
printf("hello 44 !\n");
return 0;
}
3. 无缓冲(Unbuffered)
- 行为:不使用用户态缓冲区。每次 I/O 操作都立即尝试进行系统调用。
- 典型应用 :标准错误(
stderr
) 默认通常是无缓冲的。这是为了确保错误信息能立即、无条件地显示给用户,即使程序随后崩溃了。 - 优点:即时性最高。
- 缺点:性能最差,因为每次操作都有系统调用的开销。
- 示例 :
fprintf(stderr, "Error: ...");
这条信息会立刻出现在屏幕上。
二、 触发缓冲区刷新的条件
无论处于哪种缓冲模式,以下操作都会导致缓冲区被刷新:
1. 缓冲区已满(对于输出流)
这是全缓冲和行缓冲最基本的触发条件。当 _IO_write_ptr
到达 _IO_write_end
时,缓冲区满,必须调用 write
将其清空才能接收新数据。
2. 遇到换行符(对于行缓冲的输出流)
这是行缓冲模式的特有规则。库函数在将数据拷贝到缓冲区时,会检查是否有 \n
。一旦发现,就会在完成拷贝后触发刷新。
3. 手动刷新:fflush(FILE *stream)
- 这是最直接的控制方式。
- 对于输出流 ,
fflush
会将用户态缓冲区中的所有数据立即强制写入内核,无论缓冲区是否已满。 - 对于输入流 ,
fflush
的行为是未定义的(C 标准)。在某些实现中(如 Glibc),它会丢弃输入缓冲区中所有尚未被读取的数据(非常有用,比如清空标准输入的多余输入)。
4. 线程正常结束
当 main
函数使用 return
语句返回,或调用 exit()
函数退出时,所有打开的输出流都会被自动刷新。这是标准库在程序退出前执行的清理工作之一。
注意 :如果程序因为调用 _exit()
或因为信号(如 SIGKILL
)而异常终止,缓冲区不会被刷新 !这意味着用户态缓冲区中的数据会丢失。这是为什么错误信息通常使用无缓冲的 stderr
的原因。
5. 从无缓冲切换到缓冲(对于输入流)
这是一个不太直观但很重要的触发条件。当对一个输入流 执行输出操作(如 fseek
, fsetpos
, rewind
)时,需要先刷新其输出缓冲区(如果存在的话)。这是因为文件位置指针即将被改变,缓冲区中尚未写入的数据必须被写到它原本该去的位置,否则会导致数据错乱。
例如:
c
FILE *fp = fopen("file.txt", "r+"); // 读写模式
fwrite(data, 1, 100, fp); // 数据写入用户态缓冲区
fseek(fp, 0, SEEK_SET); // 在调用 fseek 定位到文件开头之前,
// 库会自动调用 fflush(fp) 将100字节数据写入文件,
// 否则这些数据会在覆盖文件开头时丢失。
三、 如何设置缓冲模式
我们可以主动控制缓冲策略:
c
#include <stdio.h>
void setbuf(FILE *stream, char *buf);
int setvbuf(FILE *stream, char *buf, int mode, size_t size);
setvbuf
更强大,可以精确设置模式:mode
参数:_IOFBF
(全缓冲),_IOLBF
(行缓冲),_IONBF
(无缓冲)。buf
和size
:可以传入自己分配的缓冲区。如果buf
为NULL
,库会自动分配大小为size
的缓冲区。
setbuf(fp, buf)
大致等价于setvbuf(fp, buf, (buf != NULL) ? _IOFBF : _IONBF, BUFSIZ)
。
总结与比喻
可以将缓冲区刷新策略比喻成一个快递集散中心:
- 全缓冲 :像海运集装箱。一定要等到整个集装箱都装满了货物(缓冲区满),才发一趟船(系统调用)。单件运输成本最低,但发货延迟高。
- 行缓冲 :像同城快递。凑满一车(缓冲区满)就发车,或者遇到一个特别着急的加急件(换行符)也立刻发车。平衡了成本和时效。
- 无缓冲 :像闪送。有一件发一件,速度最快,但成本最高。
fflush()
:像立刻发货指令。无论集散中心里积压了多少货物,接到指令就立刻发车。- 程序正常退出 :像每日营业结束。关门之前,必须把今天所有收到的货物都发出去。
模仿编写简单的文件操作函数
为了大家能更好的理解,可以看一下下面简单版的文件操作函数:
c
//mystdio.h
#pragma once
#include <stdio.h>
#define SIZE 4096
#define NONE_FLUSH (1<<1)
#define LINE_FLUSH (1<<2)
#define FULL_FLUSH (1<<3)
typedef struct _myFILE
{
//char inbuffer[];
char outbuffer[SIZE];//为了简单,将缓冲区设计为一个静态数组
int pos;//用于标记缓冲区内容结尾的下标
int cap;//缓冲区容量
int fileno;//文件描述符
int flush_mode;//刷新策略
}myFILE;
myFILE *my_fopen(const char *pathname, const char *mode);
int my_fwrite(myFILE *fp, const char *s, int size);
//int my_fread();
void my_fflush(myFILE *fp);
void my_fclose(myFILE *fp);
void DebugPrint(myFILE *fp);//测试用的
c
//mystdio.c
#include "mystdio.h"
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
const char *toString(int flag)
{
//判断刷新策略
if(flag & NONE_FLUSH) return "None";
else if(flag & LINE_FLUSH) return "Line";
else if(flag & FULL_FLUSH) return "FULL";
return "Unknow";
}
void DebugPrint(myFILE *fp)
{
//测试用的而已
printf("outbufer: %s\n", fp->outbuffer);
printf("fd: %d\n", fp->fileno);
printf("pos: %d\n", fp->pos);
printf("cap: %d\n", fp->cap);
printf("flush_mode: %s\n", toString(fp->flush_mode));
}
myFILE *my_fopen(const char *pathname, const char *mode)
{
//判断文件打开的模式
int flag = 0;
if(strcmp(mode, "r") == 0)
{
flag |= O_RDONLY;
}
else if(strcmp(mode, "w") == 0)
{
flag |= (O_CREAT|O_WRONLY|O_TRUNC);
}
else if(strcmp(mode, "a") == 0)
{
flag |= (O_CREAT|O_WRONLY|O_APPEND);
}
else
{
return NULL;
}
//调用系统接口open函数
int fd = 0;
if(flag & O_WRONLY)//如果是以写方式打开,要防止文件不存在,需要创建文件
{
umask(0);//让当前进程的权限掩码为0
fd = open(pathname, flag, 0666);
}
else//读方式自然是不需要创建文件的
{
fd = open(pathname, flag);
}
if(fd < 0) return NULL;//打开失败
//创建FILE结构体
myFILE *fp = (myFILE*)malloc(sizeof(myFILE));
if(fp == NULL) return NULL;
fp->fileno = fd;
fp->cap = SIZE;
fp->pos = 0;
fp->flush_mode = LINE_FLUSH;//此处说明缓冲区刷新策略默认是行缓冲,这里只是做测试这样写,这里大家更改为别的缓存刷新策略观察现象。
return fp;
}
void my_fflush(myFILE *fp)
{
if(fp->pos == 0) return;//说明缓冲区没有内容,不需要刷新
write(fp->fileno, fp->outbuffer, fp->pos);//调用系统接口write函数,写入内核缓冲区
fp->pos = 0;
}
int my_fwrite(myFILE *fp, const char *s, int size)
{
// 1. 将内容写入缓冲区
memcpy(fp->outbuffer+fp->pos, s, size);
fp->pos += size;
// 2. 判断,是否要刷新
//行缓冲刷新策略
if((fp->flush_mode & LINE_FLUSH) && fp->outbuffer[fp->pos-1] == '\n')
{
my_fflush(fp);
}
//全缓冲刷新策略
else if((fp->flush_mode & FULL_FLUSH) && fp->pos == fp->cap)
{
my_fflush(fp);
}
return size;
}
void my_fclose(myFILE *fp)
{
my_fflush(fp);//关闭文件前,将缓冲区的内容刷新出来
close(fp->fileno);//调用系统调用关闭文件
free(fp);//释放动态开辟的空间(FILE结构体)
}
c
//filetest.c
#include "mystdio.h"
#include <string.h>
#include <unistd.h>
const char *filename = "./log.txt";//当前目录下的log.txt,没有则创建
int main()
{
myFILE *fp = my_fopen(filename, "w");//写方式打开
if(fp == NULL) return 1;
int cnt = 5;
char buffer[64];
while(cnt)
{
//先用snprintf像buffer写入内容
snprintf(buffer, sizeof(buffer), "helloworld,hellobit,%d ", cnt--);
//然后使用my_write写入文件
my_fwrite(fp, buffer, strlen(buffer));
DebugPrint(fp);//打印状态用来观察
sleep(2);
//my_fflush(fp);
}
my_fclose(fp);//关闭文件
return 0;
}
语言缓冲区与系统缓冲区的协同工作
Linux系统的缓冲区机制
Linux内核自身也维护了一套缓冲区(页缓存),用于缓存磁盘数据,减少实际物理磁盘访问次数。当数据从C语言缓冲区通过系统调用写入内核时,它首先被放入内核的页缓存中,而不是立即写入磁盘。
两级缓冲区的配合策略
- C语言用户空间缓冲区 :
- 累积小量读写操作
- 减少系统调用次数
- 提供标准化的IO接口
- 内核空间缓冲区 :
- 缓存磁盘数据,加速后续读取
- 合并多次写入操作,优化磁盘访问模式
- 负责实际与硬件设备交互
当用户调用fflush()
或文件关闭时,C语言缓冲区中的数据会被写入内核缓冲区;当内核缓冲区需要腾空或达到特定条件时,数据才会真正写入物理设备。
性能优势与内核负担减轻
这种两级缓冲区设计带来了显著性能优势:
- 减少上下文切换:每次系统调用都需要从用户态切换到内核态,这是一个相对昂贵的操作。通过缓冲,多次IO操作可以合并为一次系统调用。
- 减少磁盘访问次数:磁盘访问比内存访问慢几个数量级,缓冲机制可以将多次小量访问合并为少量批量访问。
- 优化磁盘写入模式:内核可以合理安排缓冲区数据的写入时机和顺序,减少磁盘寻道时间。
总结
下面这张图展示了 C 语言标准 I/O 库(stdio)与 Linux 操作系统内核在文件 I/O 操作中的协作关系、数据流以及核心的缓冲区机制。
内核空间 Kernel Space 用户空间 User Space Linux 内核 C 标准库 stdio 数据拷贝至用户态缓冲区 缓冲区满或满足刷新条件 定期由内核线程写回 磁盘中断通知完成 数据已在缓存时直接提供 数据不在缓存时从磁盘读取 通过 _fileno 关联 调用库函数操作文件 执行系统调用
切换至内核态 物理磁盘 文件描述符表 页缓存 Page Cache 应用程序 FILE 结构体
包含缓冲区指针和文件描述符 库函数
fwrite/fread/fprintf/scanf 用户态缓冲区 系统调用接口
read/write/open/close
图解说明:
这张图描绘了从用户程序到物理磁盘的完整 I/O 路径,核心是两级缓冲区策略。
1. 用户空间 (User Space) - C 标准库的职责
- 应用程序 :调用标准的 I/O 库函数(如
fwrite
,fread
,fprintf
,fscanf
)。 - C 标准库 (stdio) :
- 管理
FILE
结构体 ,其中包含指向用户态缓冲区 的指针(如_IO_read_ptr
,_IO_write_ptr
)和关联的文件描述符 (_fileno
)。 - 实现缓冲策略(全缓冲、行缓冲、无缓冲)。对于写操作,数据首先被快速拷贝到用户态缓冲区;对于读操作,数据从内核预读到用户态缓冲区后再提供给程序。
- 其目的是将多次小的 I/O 请求聚合为一次大的系统调用,极大减少开销巨大的模式切换次数。
- 管理
2. 内核空间 (Kernel Space) - Linux 内核的职责
- 系统调用 (syscall) :这是用户程序与内核交互的唯一方式。当用户态缓冲区需要刷新或填充时,C 库会调用
write
或read
等系统调用。这会触发 CPU 从用户态(Ring 3)切换到内核态(Ring 0)。 - 内核 :
- 管理文件描述符表,将其与具体的文件、设备、socket等关联起来。
- 维护页缓存 (Page Cache) :这是内核在内存中创建的磁盘数据的缓存。从磁盘读出的数据会缓存于此,要写回磁盘的数据也先暂存于此。这是Linux系统的第二级缓冲区。
- 它的目的是减少直接访问物理磁盘的次数,通过在内存中缓存数据来加速读写。
3. 物理硬件 - 最终目的地
- 物理磁盘 :数据的最终持久化存储位置。内核会通过特定的线程(如
pdflush
)或响应sync
调用,将页缓存中已修改的数据异步地写回磁盘。
核心要点总结:
- 双重缓冲 :C 库的用户态缓冲区 和内核的页缓存共同构成了一个两级缓存系统,共同目标是减少对慢速磁盘的访问。
- 职责分离 :
- C 库 :负责进程内的 I/O 优化(聚合请求,减少系统调用)。
- 内核 :负责系统级的 I/O 优化(缓存磁盘数据,合并写入请求,管理硬件)。
- 性能关键 :最昂贵的操作是用户态与内核态的上下文切换 (系统调用)和物理磁盘 I/O。两级缓冲机制有效地减少了这两种操作的频率。
- 数据一致性 :需要注意的是,调用
fflush()
只能将数据从用户态缓冲区推到内核页缓存,并不能保证数据已落盘。要确保数据持久化,需要调用fsync()
来通知内核将页缓存中的数据立即写入磁盘。
这种分层协作的架构,使得在 Linux 上使用 C 语言进行文件操作既高效又相对简单。