深入解析操作系统文件缓冲区:页缓存、基数树与f_pos的协同设计

本文整理自一系列关于操作系统文件 I/O 底层实现的探讨,包括但不限于回答以下核心问题:

  • 文件加载到内存后,内核如何管理文件缓冲区?缓冲区满了怎么办?如何知道哪些数据已经读过?
  • 不同进程打开同一个文件时,是否指向同一块缓冲区?struct file 如何各自记录读过/未读的区域?如何维护自己的读写位置?
  • 以读写方式(O_RDWR)打开文件时,是只维护一个 f_pos 吗?f_ops 又扮演什么角色?

若你已有一定操作系统基础,下文将从设计原理和内核机制出发,给出连贯的回答。


1. 文件缓冲区的本质:全局共享的页缓存

在操作系统中,每个文件都对应一个文件缓冲区,用来缓存从文件中加载的内容,提高读/写效率。无论多少个进程打开同一个文件,都共享同一份文件缓冲区,不存在“进程 A 的缓冲区”和“进程 B 的缓冲区”之分。这个文件缓冲区被保存在inode结构体中。

本质上,这些缓冲区本质上是一页一页的内存块的集合,这些内存块被称为页缓存。页缓存直接挂在 inodeaddress_space 结构上,通过以文件偏移为索引被管理在基数树上。

因此,“不同进程是否指向同一块文件缓冲区”的答案是:是,它们看到的底层缓存数据完全相同


2. 如何用基数树管理页缓存

在操作系统管理文件缓冲区(页高速缓存)时,基数树是一种核心数据结构。

举个直观的例子:文件在磁盘上是一串连续的字节,内核读取后会把它们切成固定大小的“页”(如4KB)缓存在内存里。如果要快速找到“文件偏移量第 8000 字节”对应的缓存页在不在内存、在哪个位置,就需要一种既能精确查找、又能高效范围扫描的索引结构——基数树便承担了这个角色。

它是如何工作的?
基数树本质上就是一个一维数组,只不过通过结合树形结构,使其能够“按需申请内存空间”,在兼顾数组稳定性强(也就是说结构不会改变。在一些业务场景下,比如tcmalloc,利用基数树可以实现无锁访问,而在文件缓冲区管理这一块,它搭配原子替换操作实现了无锁访问)、索引快、支持范围查找等优点的同时,还用按需加载避免了大量空间的浪费(树越高,一次性需要开辟的空间就越少,浪费越少)。不过基数树只适合于存放的值的位置比较紧密或者说存放的值很少的情况,不然,会浪费我们无法接受数量的空间。

在 Linux 的页高速缓存(page cache)里,每个打开的文件都有一个 address_space 结构,里面就维护了一棵基数树(现在已演进为 XArray,本质仍是基数树),用来管理该文件所有已缓存的页:

  • 键:文件的页索引(page->index,即偏移量除以页大小)。
  • 值:指向 struct page 的指针,代表缓存的数据页。
  • 操作:读写文件时,内核先用页索引在基数树中查找,找到就直接从内存返回数据(缓存命中);找不到就分配新页,插入基数树,再从磁盘读入数据。
    此外,基数树还支持脏页追踪和回写,通过额外的标记位(在叶子结点上加一个标记位即可)就能快速判断哪些页需要写回磁盘。

相比哈希表,它支持范围遍历,这对文件顺序读、预读(readahead)非常重要;相比红黑树等平衡树,它结构更稳定,锁粒度也可以做得更细,甚至无锁。

2. struct file 如何维护读写位置:一切围绕 f_pos

每个打开的文件描述符在内核中都对应一个 struct file 对象。该结构中没有“已读/未读”的记录,只有一个字段用于跟踪当前的读写位置,即:

loff_t f_pos;   // 当前文件偏移量(字节)
  • f_pos 是一个简单的 64 位整数,记录下一次读/写操作将从文件的哪个字节开始。
  • 不同进程(甚至同一进程内多次 open 同一文件)所得到的 struct file 各自独立,因此各自的 f_pos 也完全独立,互不干扰。

2.1 读取操作(read)如何利用 f_pos

  1. 根据 f_pos 计算出所需数据对应的缓存页索引(f_pos / PAGE_SIZE)。
  2. inode 的页缓存中查找该页:
    • 命中:直接从页缓存拷贝数据到用户缓冲区。
    • 缺失:触发磁盘读取,将文件对应块加载到页缓存,再拷贝。
  3. 操作完成后,f_pos 自动增加“实际读取的字节数”。

2.2 写入操作(write)如何利用 f_pos

  1. f_pos 定位到目标缓存页。
  2. 若写入对齐并覆盖整页,可能直接分配新页;若只写一部分,则需先将旧页读入缓存(Read-Modify-Write),然后将用户数据拷贝进页缓存,并标记该页为
  3. f_pos 同样自动增加“实际写入的字节数”。

如果文件以 O_APPEND 标志打开,每次 write 前内核会先将 f_pos 强制设置为文件当前大小(i_size),保证追加写入。

关键结论:进程无需自己记录“读过多少,写到哪了”。f_pos 就像一个随读写自动移动的指针,想要重读某段内容,只需通过 lseekf_pos 移回目标位置。


3. “缓冲区满了”怎么办?——内存回收而非固定上限

页缓存与传统固定大小的缓冲区有本质区别:它会尽可能占用所有空闲物理内存,没有“满”的硬性边界。当系统内存紧张时,由内核的内存回收机制动态处理:

  • 干净页(只读、未被修改的缓存):直接释放,对应物理页面归还给内存分配器。进程下次再访问该区域时会重新从磁盘加载。
  • 脏页(被写入过但尚未写回磁盘的页)必须先写回磁盘。内核通过后台线程(如 kswapd、回写线程)在脏页比例达到阈值时启动写回;若某个进程写入过快导致脏页激增,该进程会被直接限速(在内核中等待一部分脏页写回完成),以避免内存被脏页耗尽。

从进程视角看,“缓冲区永远不会满”,缓冲区满并不会导致 write 返回错误(如 ENOSPCEAGAIN),它只可能表现为写操作突然变慢或短暂阻塞(内存管理会进行一系列的操作)。这就是文件管理和内存管理解耦合的一种体现


4. 如何知道“哪些数据已经读过”?

struct file没有任何位图或列表来标记已读范围。“是否已读过”完全由页缓存的存在性隐式回答:

  • 如果某块数据对应的页仍在缓存中,就说明它曾经被读过且尚未回收;
  • 如果该页因内存回收而消失,那么下次访问时需要重新从磁盘读取。

无论是同一进程反复读取,还是多个进程先后访问同一文件区域,只要页缓存命中,就能直接复用,无需额外的“已读”跟踪。


5. 以读写方式打开(O_RDWR):仍然只有一个 f_pos,那 f_ops 呢?

这是对 struct file 内部机制的进一步追问:以读写方式打开文件时,内核是只维护一个 f_pos 还是读/写各有独立的位置?

答案是:只维护一个 f_pos,读与写共用同一偏移量。

  • 调用 read(fd, buf, 100) 后,f_pos 前进 100 字节;
  • 紧接着调用 write(fd, buf, 50),会从上一步更新后的 f_pos 位置开始写入,然后 f_pos 继续前进。

这符合 POSIX 语义:一个文件描述符只有一个“当前文件偏移量”。如果希望在一个文件描述符上独立地进行读和写而不互相干扰位置,应使用 preadpwrite 系统调用。它们接受显式的偏移参数,既不依赖也不修改 f_pos

至于你提到的 f_ops(正式名称为 struct file_operations *f_op):

  • 无论以只读、只写还是读写方式打开,f_op 都指向同一个文件操作函数集
  • 这个结构体是一个函数指针表,包含了 readwriteread_iterwrite_iter 等具体实现。内核根据操作类型调用对应函数。
  • f_op 与文件位置完全无关,它只定义“如何进行 I/O”,而位置状态全部由 f_pos 维护。

6. 完整场景串联

假设文件 data.bin 大小为 1 GB。进程 A 以 O_RDWR 打开,从头顺序读取;进程 B 以同样方式打开,从尾部开始分析数据。

  • A 的 f_pos = 0,B 的 f_pos ≈ 1GB - 4KB,页缓存初始为空。
  • A 读取前 100 KB:内核将对应 25 个页从磁盘加载到页缓存,A 的 f_pos 前进到 100 KB。
  • B 读取最后 4 KB:内核将文件尾部的一个页调入页缓存,B 的 f_pos 移动到文件末尾附近。
  • 此时若 A 执行 lseek 重回文件开头,再次读取前 100 KB,因为数据仍在页缓存中,会瞬间命中,无需磁盘 I/O。
  • 若后续系统内存紧张,回收了那些干净页,A 再次读取则需要重新访问磁盘。

整个过程中:

  • 文件缓冲区=页缓存,由 inode 统一管理,所有进程共享。
  • 每个 struct file 仅用 f_pos 独立维护自己的读/写位置。
  • “已读/未读”由缓存是否存在自然呈现,“缓冲区满”由内存回收动态处置,对进程透明。

7. 总结

  • 文件缓冲区 是全局的页缓存,挂在文件 inode 下,所有打开该文件的进程共享。
  • 读写位置 由每个 struct file 中的单一字段 f_pos 维护,读写共用;若需位置隔离,使用 pread/pwrite
  • 已读数据跟踪 由页缓存的存在性隐式完成,struct file 不保存任何历史读取记录。
  • 缓冲区满 并非固定容量耗尽,而是触发内存回收,脏页写回后回收干净页,可能影响性能但不会丢失数据或返回错误。
  • f_op 是统一的操作函数表,与打开模式和位置维护无关。

通过这套设计,操作系统在提供简洁文件读写接口的同时,实现了高效的数据共享与自动化的缓存管理。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐