Linux 内核之旅(二十一):page cache

内核中的page cache管理

Posted by pandaychen on September 2, 2025

0x00 前言

本文主要梳理下page cache与管理的若干知识,本文基于v4.11.6的源码

页高速缓存(page cache),它是一种对完整的数据页进行操作的磁盘高速缓存,即把磁盘的数据块缓存在页高速缓存中。page cache是内核为文件创建的内存缓存,用以加速相关的文件操作。当应用程序需要读取文件中的数据时,操作系统先分配一些内存,将数据从存储设备读入到这些内存中,然后再将数据分发给应用程序;当需要往文件中写数据时,操作系统先分配内存接收用户数据,然后再将数据从内存写到磁盘上

linux_page_cache

本文涉及到read/write讨论,不考虑O_DIRECT的情况(如MySQL)

0x01 内核如何描述物理内存页

内核物理内存管理(从 CPU 角度看FLATMEM物理内存模型)

以内核默认的平坦内存模型(FLATMEM )为例,来解释下物理内存与虚拟内存(地址)之间的映射关系:

  • 内核以页(page)为基本单位对物理内存进行管理,通过将物理内存划分为一页一页的内存块,每页大小为 4K。一页大小的内存块在内核中用 struct page 来进行管理,struct page 中封装了每页内存块的状态信息,比如组织结构、使用信息、统计信息以及与其他结构的关联映射信息等
  • 为了快速索引到具体的物理内存页,内核为每个物理页 struct page 结构体定义了一个索引编号,即PFN(Page Frame Number),其中PFN 与 struct page 是一一对应的关系
  • 内核提供了两个宏来完成 PFN 与 物理页结构体 struct page 之间的相互转换,分别是 page_to_pfnpfn_to_page

内核中如何组织管理这些物理内存页 struct page 的方式称之为做物理内存模型,不同的物理内存模型,应对的场景以及 page_to_pfnpfn_to_page 的计算逻辑都是不一样的,介绍下最简单的FLATMEM模型:

flat-mem

平坦内存模型 FLATMEM的架构如下:先把物理内存想象成一片地址连续的存储空间,在这一大片地址连续的内存空间中,内核将这块内存空间分为一页一页的内存块 struct page,由于这块物理内存是连续的,物理地址也是连续的,划分出来的这一页一页的物理页必然也是连续的,并且每页的大小都是固定的,所以很容易想到用一个数组来组织这些连续的物理内存页 struct page 结构,其在数组中对应的下标即为 PFN

  • mem_map是数组(虚拟内存地址):是虚拟地址空间中 struct page结构体的连续数组,此数组在内核的虚拟地址空间中连续存放,数组的每个元素对应一个物理页的元数据(状态信息)
  • PFN 是物理概念:代表物理页的编号

相关的代码如下,在FLATMEM模型下 ,page_to_pfnpfn_to_page 本质就是基于 mem_map 数组进行偏移操作,其中 mem_map 是全局数组,用来组织所有划分出来的物理内存页。mem_map 全局数组的下标就是相应物理页对应的 PFN

struct page *mem_map;  // 全局数组,指向 struct page结构体数组的指针

#if defined(CONFIG_FLATMEM)
#define __pfn_to_page(pfn) (mem_map + ((pfn)-ARCH_PFN_OFFSET))	//ARCH_PFN_OFFSET 是 PFN 的起始偏移量
#define __page_to_pfn(page) ((unsigned long)((page)-mem_map) + ARCH_PFN_OFFSET)
#endif

/*
__page_to_pfn(page):
((unsigned long)((page) - mem_map) + ARCH_PFN_OFFSET)

(page) - mem_map:计算给定 struct page指针在数组中的索引位置
加上 ARCH_PFN_OFFSET:得到实际的物理页帧号(因为物理内存可能不是从0开始)

__pfn_to_page(pfn):
(mem_map + ((pfn) - ARCH_PFN_OFFSET))
(pfn) - ARCH_PFN_OFFSET:从PFN中减去偏移得到数组索引
mem_map + index:通过数组索引找到对应的 struct page指针
*/

关于__pfn_to_page__page_to_pfn,需要注意的一点是,这两个操作不直接操作物理内存地址,而是在虚拟地址空间中完成 struct page指针 与 PFN 之间的转换,本质是在虚拟地址空间的 mem_map 数组和物理页帧号 PFN 之间建立转换关系,如下:

虚拟地址空间中的 mem_map 数组:
+-----+-----+-----+-----+-----+
| pg0 | pg1 | pg2 | pg3 | ... |  <- struct page 结构体数组
+-----+-----+-----+-----+-----+
  ^     ^     ^     ^
  |     |     |     |
物理页:
+-----+-----+-----+-----+-----+
| PFN0| PFN1| PFN2| PFN3| ... |  <- 实际的物理内存页
+-----+-----+-----+-----+-----+

为什么说是虚拟地址空间中的转换呢?mem_map数组本身存在于内核的虚拟地址空间,每个 struct page是虚拟地址空间中的一个对象,PFN 代表的是物理地址的页帧号,上面两个宏实际上是建立了虚拟地址(struct page指针)<-> 物理页编号(PFN)的映射关系

实际使用时的地址转换流程如下:

// 从 struct page 获取物理地址
struct page *page = ...;
unsigned long pfn = page_to_pfn(page);     // 1. 得到 PFN
phys_addr_t phys = pfn << PAGE_SHIFT;      // 2. PFN 转为物理地址
void *virt = phys_to_virt(phys);           // 3. 物理地址转虚拟地址

// 从虚拟内存地址转struct page
pfn = virt_to_pfn(virt);                   // 虚拟地址转 PFN
page = pfn_to_page(pfn);                   // PFN 转 struct page

内核对物理内存页的描述

linux-mem

struct page 分为两种:

内核中的物理内存页有两种类型,分别用于不同的场景:

  • 匿名页:匿名页背后并没有一个磁盘中的文件作为数据来源,匿名页中的数据都是通过进程运行过程中产生的,匿名页直接和进程虚拟地址空间建立映射供进程使用
  • 文件页(内存文件映射):文件页中的数据来自于磁盘中的文件,文件页需要先关联一个磁盘中的文件,然后再和进程虚拟地址空间建立映射供进程使用,使得进程可以通过操作虚拟内存实现对文件的操作

0x02 基础数据结构

前面在介绍VFS的时候提到了struct inode中的一个重要成员:address_spaceaddress_space对象是文件系统中管理内存页page cache的核心数据结构

//https://elixir.bootlin.com/linux/v4.11.6/source/include/linux/fs.h#L554
struct inode {
	umode_t			i_mode;
	unsigned short		i_opflags;
	kuid_t			i_uid;
	kgid_t			i_gid;
	unsigned int		i_flags;
    ......
	const struct inode_operations	*i_op;
	struct super_block	*i_sb;
	struct address_space	*i_mapping;
    ......
}

//https://elixir.bootlin.com/linux/v4.11.6/source/include/linux/fs.h#L379
struct address_space {
	struct inode		*host;		/* owner: inode, block_device */
	struct radix_tree_root	page_tree;	/* radix tree of all pages */
	spinlock_t		tree_lock;	/* and lock protecting it */
	atomic_t		i_mmap_writable;/* count VM_SHARED mappings */
	struct rb_root		i_mmap;		/* tree of private and shared mappings */
	struct rw_semaphore	i_mmap_rwsem;	/* protect tree, count, list */
	/* Protected by tree_lock together with the radix tree */
	unsigned long		nrpages;	/* number of total pages */
	/* number of shadow or DAX exceptional entries */
	unsigned long		nrexceptional;
	pgoff_t			writeback_index;/* writeback starts here */
	const struct address_space_operations *a_ops;	/* methods */
	unsigned long		flags;		/* error bits */
	spinlock_t		private_lock;	/* for use by the address_space */
	gfp_t			gfp_mask;	/* implicit gfp mask for allocations */
	struct list_head	private_list;	/* ditto */
	void			*private_data;	/* ditto */
} __attribute__((aligned(sizeof(long))));

//https://elixir.bootlin.com/linux/v4.11.6/source/include/linux/mm_types.h#L40
struct page {
	/* First double word block */
	unsigned long flags;		/* Atomic flags, some possibly
					 * updated asynchronously */
	union {
		struct address_space *mapping;	/* If low bit clear, points to
						 * inode address_space, or NULL.
						 * If page mapped as anonymous
						 * memory, low bit is set, and
						 * it points to anon_vma object:
						 * see PAGE_MAPPING_ANON below.
						 */
		void *s_mem;			/* slab first object */
		atomic_t compound_mapcount;	/* first tail page */
		/* page_deferred_list().next	 -- second tail page */
	};

	/* Second double word */
	union {
		pgoff_t index;		/* Our offset within mapping. */
		void *freelist;		/* sl[aou]b first free object */
	};
    ......
}

每一个文件(一个inode指向的文件)都对应着一个address_space对象,inode中有一个i_mmaping成员,该成员即指向该文件对应的address_space对象,而address_space中的成员page_tree,这个指针指向的就是文件对应的基数树的根,而这棵radix树的叶子节点就是page cache

注意到每个struct page 描述符都包括把page链接到page cache的两个重要字段mappingindex。其中mapping成员指向拥有该页的索引节点的address_space对象,index成员表示在所有者的地址空间中以页大小为单位的偏移量,也就是在所有者的磁盘映射中页中数据的位置

address_space中的host成员指向其所属的struct inode对象,也就是address_space中的host字段与对应inode中的 i_data成员互相指向。对于普通文件而言,inode和address_space的相应指针的指向关系如下:

inode-to-address_space

内核中的radix tree

因为文件位于慢速的块设备上,如果没有缓存,每一次对文件的读写都要走到块设备,这样的访问速度是无法容忍的。在Linux上操作,如果一次读某个文件慢的话,紧接着读这个文件第二次,速度会有明显的提升。原因是Linux已经将文件的部分内容或全部内容缓存到了page cache,先列举几个问题:

  1. page cache在内核是通过radix树管理的,为何要使用该数据结构?
  2. 从应用层的文件描述符fd,到struct file,从struct filestruct dentry,再从struct dentrystruct inode,再struct inodestruct address_space, 只要知道文件的偏移量offset(如系统调用read参数),就能从radix_tree中查找对应的页面是否在页高速缓存,这里的整体过程是如何的?
  3. 若A用户的a进程操作文件,将文件带入缓存,那么稍后B用户的b进程操作通一个文件时,同样可以享受文件内容在页高速缓存带来的福利
  4. page cache预读的原理是什么?
  5. 要读某文件的第N个页面,内核是如何判断该页面是否在页高速缓存?如果在,如何找到该页的内容?如果不在,内核是如何处理的?

ext4支持到PB级文件存储,如此页的量级是巨大的。访问大文件时,page cache中存在着有关该文件巨大数量的页,所以内核提供了radix树来加快查找(一个address_space对象对应一个radix树)。struct address_space中成员page_tree指向是基树的根radix_tree_root,基树根中的struct radix_tree_node *rnode指向基树的最高层节点radix_tree_node,radix树节点都是radix_tree_node结构,节点中存放的都是指针,叶子节点的指针指向page描述符,radix树中上层节点指向存放其他节点的指针。一般一个radix_tree_node最多可以有64个指针,字段count表示该radix_tree_node已用节点数

//https://elixir.bootlin.com/linux/v4.11.6/source/include/linux/radix-tree.h#L93
struct radix_tree_node {
	unsigned char	shift;		/* Bits remaining in each slot */
	unsigned char	offset;		/* Slot offset in parent */
	unsigned char	count;		/* Total entry count */
	unsigned char	exceptional;	/* Exceptional entry count */
	struct radix_tree_node *parent;		/* Used when ascending tree */
	struct radix_tree_root *root;		/* The tree we belong to */
	union {
		struct list_head private_list;	/* For tree user */
		struct rcu_head	rcu_head;	/* Used when freeing node */
	};
	void __rcu	*slots[RADIX_TREE_MAP_SIZE];
	unsigned long	tags[RADIX_TREE_MAX_TAGS][RADIX_TREE_TAG_LONGS];
};

radix-tree

如图,radix树的叶子节点对应的就是struct page

再回顾下radix树的查询过程,

0x0 struct page的本质

前文已经描述了内核中虚拟内存主要分为两种类型的页,即匿名页与文件页,此外还介绍了虚拟内存地址到物理内存地址的翻译过程、页表体系等

基础结构


需要注意的一点是:struct page中是不包含存储数据的,其成员仅包含元数据,每个page对应的实际的页面内容放在物理内存中,需要通过虚拟地址访问

文件页与匿名页

类别 内存来源 常用场景
匿名页 从伙伴系统分配的新页面 进程堆、栈、通过brk/sbrk分配的内存,mmap匿名映射如mmap(MAP_ANONYMOUS)
文件页 Page Cache中的页面 mmap文件映射,文件读写缓存等

如何区分struct page是哪种类型?

struct page {
    union {
        struct address_space *mapping;  // 文件页:指向address_space
        //void *s_mem;
        void *anon_vma;                  // 匿名页:反向映射
    };
    pgoff_t index;  // 偏移量
    // ...
};

文件页(File-backed Pages)

TODO

匿名页

struct page 存储的位置

struct page结构体本身也需要内存存储,这些内存位于内核的虚拟地址空间,即直接映射区域(线性映射)。mem_map是全局数组,该存储在内核的虚拟地址空间中,位于直接映射区域(Direct Map)

struct page *mem_map;  // 全局数组,指向所有struct page

// 系统启动时,内核计算需要多少内存来存储struct page
unsigned long nr_pages = total_physical_pages;
size_t page_struct_size = sizeof(struct page) * nr_pages;

// 为struct page数组分配内存
// 注意:这个内存本身也是物理内存,也需要用struct page描述!
//https://elixir.bootlin.com/linux/v4.11.6/source/mm/page_alloc.c#L6094
TODO

以x86_64为例,虚拟内存地址布局如下,这里直接映射区域是物理地址 + 固定偏移 = 虚拟地址,偏移量是PAGE_OFFSET(x86_64通常是0xffff880000000000),这种1:1映射使得物理地址和虚拟地址可以快速转换

0xffff 8000 0000 0000 ┬───────────────────┐
                      │  vmalloc区域      │
0xffff 8800 0000 0000 ┼───────────────────┤ <- 这里是struct page数组所在
                      │  直接映射区域      │   (PAGE_OFFSET = 0xffff880000000000)
                      │  1:1映射物理内存   │
0xffff 8800 0000 0000 ┼───────────────────┤
                      │  struct page数组  │
                      │  物理页描述符      │
0xffff 8800 0010 0000 ┼───────────────────┤
                      │  其他内核数据      │
0xffff c900 0000 0000 ┴───────────────────┘

page cache相关的操作函数

0x0 内核的预读机制

0x0 文件读与page cache

0x0 文件写与page cache

0x0 总结

page cache

  • page cache的内存在内核中是匿名的物理页(不与用户进程的逻辑地址进行映射),由struct page表示,在内核中page cache使用 LRU 管理,当用户进行mmap映射文件时,内核创建对应的vma,在访问到mmap的内存区域时,触发page fault,在page fault回调中page cache内存所属的物理页与用户进程的虚拟地址vma进行映射
  • 每个文件的page cache元数据存储于对应的struct inode->address_space中,因此进程之间可以共享同一个文件的page cache,同一个文件多次open不会影响其page cache
  • 文件的page cache是延时分配的,当有读写命令时,才会按需创建缓存页
  • page cache的脏页是单线程回写的,因此当一个文件大量写入时,写入的性能与单 CPU 的性能有相当的关系
  • 对于struct page结构体,其存储在直接映射区域,虚拟地址=物理地址+固定偏移,而struct page数组占用的(物理)物理页,也用struct page描述。通过简单的加减运算就能在struct page和物理地址间转换

0x0 参考