前言
Page Cache是Linux内核中用于缓存磁盘数据的关键机制,它通过将磁盘数据缓存在物理内存中,极大提升了文件系统的访问性能。本文会从内核开发者的角度,深入剖析Page Cache的核心工作原理,重点分析缓存查找算法、预读机制和回写策略的实现细节,结合内核源码(基于Linux 5.15版本)解析其内部数据结构和算法实现。
Page Cache概述与架构设计
Page Cache的基本概念
Page Cache是Linux内核中一个中心化的磁盘缓存系统,它使用空闲的物理内存来缓存从磁盘读取的数据。当应用程序读取文件时,内核首先检查所需数据是否已在Page Cache中,如果存在(缓存命中),则直接从内存返回数据,避免昂贵的磁盘I/O操作;如果不存在(缓存未命中),则从磁盘读取数据,并存入Page Cache以备后续使用。
Page Cache的缓存单元是内存页(通常为4KB),与虚拟内存管理中的页大小一致。这种设计使得Page Cache能够与虚拟内存系统紧密集成,共享相同的底层页管理机制。

核心数据结构
在 Linux 内核中,使用 file 对象来描述一个被打开的文件,其中有个名为 f_mapping 的字段,定义如下
struct file {
union {
struct llist_node fu_llist;
struct rcu_head fu_rcuhead;
} f_u;
struct path f_path;
struct inode *f_inode; /* cached value */ ///指向inode
//...
struct address_space *f_mapping; ///inode映射的地址空间
//...
}从上面代码可以看出f_mapping 字段的类型为 address_space 结构
address_space结构体
address_space 是 Linux 页缓存(page cache)的核心数据结构,每个打开的文件都有一个关联的 address_space,用于:
缓存文件数据页(通过
i_pages)管理文件的内存映射(通过
i_mmap)提供文件与页缓存之间的操作接口(通过
a_ops)
// include/linux/fs.h
struct address_space {
struct inode *host; /// 指向所属的文件inode(哪个文件拥有这个地址空间)
struct xarray i_pages; /// 使用xarray存储的页缓存页面(替代了原来的radix tree)
struct rw_semaphore invalidate_lock; /// 用于同步页失效操作的读写信号量
gfp_t gfp_mask; /// 分配页面时使用的GFP标志
atomic_t i_mmap_writable; /// 记录可写内存映射的数量(用于mlock计数)
#ifdef CONFIG_READ_ONLY_THP_FOR_FS
/* number of thp, only for non-shmem files */
atomic_t nr_thps; /// 透明大页的数量(仅用于非共享内存文件)
#endif
struct rb_root_cached i_mmap; /// 红黑树根,存储所有映射此页缓存的VMA(虚拟内存区域)
struct rw_semaphore i_mmap_rwsem; /// 保护i_mmap红黑树的读写信号量
unsigned long nrpages; /// 地址空间中页面的总数
pgoff_t writeback_index; /// 回写操作的起始偏移(用于writeback)
const struct address_space_operations *a_ops; /// 文件操作函数集合(读页、写页等)
unsigned long flags; /// 地址空间标志位
errseq_t wb_err; /// 写回错误状态序列
spinlock_t private_lock; /// 保护private_list的自旋锁
struct list_head private_list; /// 私有数据链表(由文件系统使用)
void *private_data; /// 私有数据指针(由文件系统使用)
} __attribute__((aligned(sizeof(long)))) __randomize_layout; /// 按long类型对齐,并随机化布局(安全增强)这是 Linux 内核中 address_space 结构体的定义,它用于管理文件页缓存和内存映射,下面介绍一下各个字段的作用:
关键字段说明:
host:指向拥有此地址空间的inode对象,建立了地址空间与文件的关联。i_pages:存储所有缓存页的容器(从 Linux 5.1 开始从 radix tree 改为 xarray),索引是文件偏移量对应的页号。i_mmap:红黑树,存储所有映射此文件页缓存的 VMA(虚拟内存区域),用于实现mmap()内存映射。a_ops:文件系统特定的操作函数,包括:readpage:从磁盘读取页到缓存writepage:将缓存页写回磁盘direct_IO:直接 I/O 操作等
nrpages:当前缓存的总页数,用于统计和管理。private_list/private_data:供文件系统存储私有数据(如 ext4 的延迟分配结构)。__randomize_layout:内核安全特性,随机化结构体字段布局,防止利用固定偏移的攻击。
address_space_operations 操作函数
/* address_space操作函数表 */
struct address_space_operations {
/* 读取页 */
int (*readpage)(struct file *, struct page *);
/* 写入页 */
int (*writepage)(struct page *, struct writeback_control *);
/* 设置页脏 */
int (*set_page_dirty)(struct page *);
/* 准备写入(用于延迟分配) */
int (*prepare_write)(struct file *, struct page *, unsigned, unsigned);
/* 提交写入 */
int (*commit_write)(struct file *, struct page *, unsigned, unsigned);
/* 回写完成通知 */
void (*write_end)(struct file *, struct address_space *mapping,
loff_t pos, unsigned len, unsigned copied,
struct page *page, void *fsdata);
/* 直接I/O */
ssize_t (*direct_IO)(struct kiocb *, struct iov_iter *iter);
/* 获取块映射 */
sector_t (*bmap)(struct address_space *, sector_t);
/* 使页无效(截断时使用) */
void (*invalidatepage)(struct page *, unsigned int, unsigned int);
/* 释放页 */
int (*releasepage)(struct page *, gfp_t);
/* 释放页(直接I/O) */
void (*freepage)(struct page *);
/* 迁移页 */
int (*migratepage)(struct address_space *,
struct page *, struct page *, enum migrate_mode);
};以ext4文件系统为例,
void ext4_set_aops(struct inode *inode)
{
switch (ext4_inode_journal_mode(inode)) {
case EXT4_INODE_ORDERED_DATA_MODE:
case EXT4_INODE_WRITEBACK_DATA_MODE:
break;
case EXT4_INODE_JOURNAL_DATA_MODE:
inode->i_mapping->a_ops = &ext4_journalled_aops;
return;
default:
BUG();
}
if (IS_DAX(inode))
inode->i_mapping->a_ops = &ext4_dax_aops;
else if (test_opt(inode->i_sb, DELALLOC))
inode->i_mapping->a_ops = &ext4_da_aops;
else
inode->i_mapping->a_ops = &ext4_aops; // 这里设置address_space_operations
}
static const struct address_space_operations ext4_aops = {
.readpage = ext4_readpage,
.readahead = ext4_readahead,
.writepage = ext4_writepage,
.writepages = ext4_writepages,
.write_begin = ext4_write_begin,
.write_end = ext4_write_end,
.set_page_dirty = ext4_set_page_dirty,
.bmap = ext4_bmap,
.invalidatepage = ext4_invalidatepage,
.releasepage = ext4_releasepage,
.direct_IO = noop_direct_IO,
.migratepage = buffer_migrate_page,
.is_partially_uptodate = block_is_partially_uptodate,
.error_remove_page = generic_error_remove_page,
.swap_activate = ext4_iomap_swap_activate,
};page结构体
struct page是内存页的元数据结构,它包含页的状态信息、引用计数以及与Page Cache相关的字段,这个结构太庞大了,内部使用了union,本章节只关注page cache相关

struct page {
unsigned long flags; /* Atomic flags, some possibly
* updated asynchronously */
/*
* Five words (20/40 bytes) are available in this union.
* WARNING: bit 0 of the first word is used for PageTail(). That
* means the other users of this union MUST NOT use the bit to
* avoid collision and false-positive PageTail().
*/
union {
struct { /* Page cache and anonymous pages */
/**
* @lru: Pageout list, eg. active_list protected by
* lruvec->lru_lock. Sometimes used as a generic list
* by the page owner.
*/
struct list_head lru;
/* See page-flags.h for PAGE_MAPPING_FLAGS */
struct address_space *mapping;
pgoff_t index; /* Our offset within mapping. */
/**
* @private: Mapping-private opaque data.
* Usually used for buffer_heads if PagePrivate.
* Used for swp_entry_t if PageSwapCache.
* Indicates order in the buddy system if PageBuddy.
*/
unsigned long private;
};
}flags字段记录了页的状态,如是否脏页(PG_dirty)、是否正在回写(PG_writeback)等。mapping指向页所属的address_space,index表示页在文件中的偏移(以页为单位)。
Page Cache与虚拟内存的集成
Page Cache与虚拟内存系统通过struct page紧密集成。当进程通过mmap()系统调用映射文件到其地址空间时,文件页同时存在于Page Cache和进程的页表中。这种共享机制意味着对内存映射文件的修改会自动反映到Page Cache中,反之亦然。
Page Cache的产生
在 Linux 中,Page Cache 主要通过两种机制产生:Buffered I/O(标准 I/O) 和 Memory-Mapped I/O(存储映射 I/O)。

Buffered I/O(标准 I/O)
这是最常见的文件操作方式,使用标准库函数(如 read(), write(),以及在用户态库中封装的 fread(), fwrite())。
工作原理:
当应用程序发起一个
read()系统调用时,内核首先检查请求的数据块是否已经在 Page Cache 中。如果命中,则直接从内存(Page Cache)中复制数据到用户缓冲区,过程极快,称为 Cache Hit。
如果未命中,则内核发起磁盘 I/O,将数据从磁盘读入 Page Cache,然后再复制到用户缓冲区。这个过程相对较慢,称为 Cache Miss。
对于
write()调用,数据通常先被写入 Page Cache 中的对应页面,并将其标记为“脏页”。此时写入操作就返回成功了,应用程序认为写入已完成。内核会在后台(通过
pdflush线程或现在的writeback机制)将“脏页”异步地刷新到磁盘上。
核心特点:
延迟写入:数据在内存中停留一段时间,便于合并多次小写操作,减少磁盘访问次数。
预读:内核会根据访问模式,预测并提前将后续可能读取的数据块加载到 Page Cache 中。
透明性:对应用程序透明,编程接口简单。
示例:用
cat查看一个文件、用编辑器保存文件、数据库的普通查询等。
标准IO触发page cache流程
Memory-Mapped I/O(存储映射 I/O)
这种方式通过 mmap() 系统调用实现,将文件的一部分或全部直接映射到进程的虚拟地址空间。
工作原理:
进程调用
mmap(),内核在进程的地址空间中创建一段映射区域,但此时并不实际加载文件数据。当进程首次访问该映射区域的某个地址时,会触发一个 缺页异常。
内核的缺页异常处理程序会分配一个物理内存页,并从磁盘读入对应的文件块到该页,然后将其插入进程的页表和 Page Cache。
此后,进程对该内存区域的读写操作,就直接等同于对 Page Cache 的读写。进程可以使用指针直接操作内存,而无需调用
read()/write()。对映射区域的修改也会被标记为“脏页”,最终由内核异步刷回磁盘。
核心特点:
零拷贝访问:消除了从内核 Page Cache 到用户缓冲区的一次数据拷贝,对于需要频繁、随机访问大文件的操作效率更高。
内存共享:多个进程可以映射同一个文件,从而实现共享内存式的通信(
MAP_SHARED)。编程模型:提供了像操作内存一样操作文件的方式,非常灵活。
示例:动态链接库的加载、大型数据文件的随机访问、进程间通过映射同一文件进行通信。
Page Cache 读取路径
文件读取路径
当应用程序通过read()系统调用读取文件时,内核的执行路径如下:
read()系统调用进入内核空间,调用vfs_read()vfs_read()调用文件系统特定的read方法对于基于Page Cache的文件系统(如ext4),调用
generic_file_read_iter()generic_file_read_iter()调用do_generic_file_read()do_generic_file_read()执行实际的读取逻辑,包括:调用
find_get_page()查找缓存页如果缓存命中,直接复制数据到用户空间
如果缓存未命中,调用
page_cache_sync_readahead()触发同步预读从磁盘读取数据并插入Page Cache
下面我们就完整的追踪一遍,以ext4文件系统为例
用户空间调用read文件
// 用户程序调用 read()
ssize_t read(int fd, void *buf, size_t count);vfs层处理
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
return ksys_read(fd, buf, count); //调用ksys_read
}
ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count)
{
struct fd f = fdget_pos(fd);
ssize_t ret = -EBADF;
if (f.file) {
loff_t pos, *ppos = file_ppos(f.file);
if (ppos) {
pos = *ppos;
ppos = &pos;
}
ret = vfs_read(f.file, buf, count, ppos); // 调用vfs_read
if (ret >= 0 && ppos)
f.file->f_pos = pos;
fdput_pos(f);
}
return ret;
}
ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos)
{
//...
if (file->f_op->read)
ret = file->f_op->read(file, buf, count, pos);
else if (file->f_op->read_iter) // 调用文件系统特定的 read_iter 操作
ret = new_sync_read(file, buf, count, pos);
else
ret = -EINVAL;
//...
}
ext4 文件系统层
const struct file_operations ext4_file_operations = {
.llseek = ext4_llseek,
.read_iter = ext4_file_read_iter, // 调用此函数
.write_iter = ext4_file_write_iter,
.iopoll = iomap_dio_iopoll,
.unlocked_ioctl = ext4_ioctl,
#ifdef CONFIG_COMPAT
.compat_ioctl = ext4_compat_ioctl,
#endif
.mmap = ext4_file_mmap,
.mmap_supported_flags = MAP_SYNC,
.open = ext4_file_open,
.release = ext4_release_file,
.fsync = ext4_sync_file,
.get_unmapped_area = thp_get_unmapped_area,
.splice_read = generic_file_splice_read,
.splice_write = iter_file_splice_write,
.fallocate = ext4_fallocate,
};
static ssize_t ext4_file_read_iter(struct kiocb *iocb, struct iov_iter *to)
{
struct inode *inode = file_inode(iocb->ki_filp);
if (unlikely(ext4_forced_shutdown(EXT4_SB(inode->i_sb))))
return -EIO;
if (!iov_iter_count(to))
return 0; /* skip atime */
#ifdef CONFIG_FS_DAX
if (IS_DAX(inode))
return ext4_dax_read_iter(iocb, to);
#endif
if (iocb->ki_flags & IOCB_DIRECT)
return ext4_dio_read_iter(iocb, to);
return generic_file_read_iter(iocb, to);
}直接I/O与缓冲I/O
ssize_t
generic_file_read_iter(struct kiocb *iocb, struct iov_iter *iter)
{
// 获取要读取的字节数
size_t count = iov_iter_count(iter);
ssize_t retval = 0;
// 如果要读取0字节,直接返回(避免更新访问时间)
if (!count)
return 0; /* skip atime */
// 检查是否是直接I/O(不经过页缓存)
if (iocb->ki_flags & IOCB_DIRECT) {
struct file *file = iocb->ki_filp;
struct address_space *mapping = file->f_mapping;
struct inode *inode = mapping->host;
loff_t size; // 文件大小
// 获取文件大小
size = i_size_read(inode);
// 处理IOCB_NOWAIT标志(非阻塞I/O)
if (iocb->ki_flags & IOCB_NOWAIT) {
// 检查指定范围是否需要写回,如果需要则返回EAGAIN
if (filemap_range_needs_writeback(mapping, iocb->ki_pos,
iocb->ki_pos + count - 1))
return -EAGAIN;
} else {
// 等待指定范围内的数据写回完成(确保读取的是最新数据)
retval = filemap_write_and_wait_range(mapping,
iocb->ki_pos,
iocb->ki_pos + count - 1);
if (retval < 0)
return retval;
}
// 更新文件的访问时间
file_accessed(file);
// 执行直接I/O读取
retval = mapping->a_ops->direct_IO(iocb, iter);
// 如果直接I/O读取成功,更新位置和剩余字节数
if (retval >= 0) {
iocb->ki_pos += retval; // 更新文件位置
count -= retval; // 减少剩余要读取的字节数
}
// 如果不是异步排队请求,回退迭代器到未读取的部分
if (retval != -EIOCBQUEUED)
iov_iter_revert(iter, count - iov_iter_count(iter));
/*
* Btrfs可能在遇到压缩extent时进行短DIO读取,
* 所以如果有错误,或者已经读取了所有想要的数据,
* 或者因为遇到EOF而进行了短读取,就直接返回。
* 否则继续使用缓冲I/O读取剩余部分。
* 缓冲读取对DAX文件无效,所以如果是DAX文件就不要尝试了。
*/
if (retval < 0 || !count || iocb->ki_pos >= size ||
IS_DAX(inode))
return retval;
// 如果直接I/O只读取了部分数据,剩余部分使用缓冲I/O
}
// 执行缓冲I/O读取(使用页缓存)
return filemap_read(iocb, iter, retval);
}
EXPORT_SYMBOL(generic_file_read_iter);这个函数实现了两种读取路径:
直接I/O路径:当设置了
IOCB_DIRECT标志时,数据直接从磁盘读取到用户缓冲区,绕过页缓存缓冲I/O路径:数据先读取到内核的页缓存,再复制到用户缓冲区
本篇因为介绍 page cache 所以,我们走缓冲I/O。
filemap_read读取页缓存
ssize_t filemap_read(struct kiocb *iocb, struct iov_iter *iter,
ssize_t already_read)
{
struct file *filp = iocb->ki_filp;
struct file_ra_state *ra = &filp->f_ra; // 文件预读状态
struct address_space *mapping = filp->f_mapping; // 文件的地址空间
struct inode *inode = mapping->host; // 文件对应的inode
struct pagevec pvec; // 页向量,用于批量处理页面
int i, error = 0;
bool writably_mapped; // 映射是否可写(用于缓存一致性)
loff_t isize, end_offset;
// 检查读取位置是否超过文件系统支持的最大字节数
if (unlikely(iocb->ki_pos >= inode->i_sb->s_maxbytes))
return 0;
// 检查迭代器中是否还有数据要读取
if (unlikely(!iov_iter_count(iter)))
return 0;
// 将迭代器的长度截断为文件系统支持的最大字节数
iov_iter_truncate(iter, inode->i_sb->s_maxbytes);
// 初始化页向量(批量处理页面,提高效率)
pagevec_init(&pvec);
do {
cond_resched(); // 主动让出CPU,避免长时间占用
/*
* 如果已经成功复制了一些数据,那么就不能安全地返回-EIOCBQUEUED。
* 因此在这种情况下,将异步读取标记为NOWAIT。
*/
if ((iocb->ki_flags & IOCB_WAITQ) && already_read)
iocb->ki_flags |= IOCB_NOWAIT;
// 从页缓存中获取要读取的页面
error = filemap_get_pages(iocb, iter, &pvec);
if (error < 0)
break;
/*
* 在确认页面是最新的之后,必须检查i_size(文件大小)。
*
* 在检查之后检查i_size允许我们计算正确的"nr"值,
* 这意味着页面的零填充部分不会复制回用户空间
*(除非另一个截断操作扩展了文件 - 但这是期望的行为)。
*/
isize = i_size_read(inode); // 获取当前文件大小
if (unlikely(iocb->ki_pos >= isize))
goto put_pages; // 读取位置已超过文件末尾
// 计算本次读取的结束位置(取文件大小和请求结束位置的最小值)
end_offset = min_t(loff_t, isize, iocb->ki_pos + iter->count);
/*
* 一旦我们开始复制数据,我们就不想触及任何可能被竞争的高速缓存行:
*/
writably_mapped = mapping_writably_mapped(mapping); // 检查是否有可写的内存映射
/*
* 当顺序读取多次访问同一页面时,只在第一次标记为已访问。
*/
if (iocb->ki_pos >> PAGE_SHIFT !=
ra->prev_pos >> PAGE_SHIFT)
mark_page_accessed(pvec.pages[0]); // 标记第一页为已访问
// 遍历页向量中的所有页面
for (i = 0; i < pagevec_count(&pvec); i++) {
struct page *page = pvec.pages[i];
size_t page_size = thp_size(page); // 页面大小(支持透明大页)
size_t offset = iocb->ki_pos & (page_size - 1); // 在页面内的偏移
size_t bytes = min_t(loff_t, end_offset - iocb->ki_pos,
page_size - offset); // 本次要复制的字节数
size_t copied; // 实际复制的字节数
// 如果结束偏移量小于页面的起始偏移,说明已经读完需要的部分
if (end_offset < page_offset(page))
break;
// 从第二页开始,标记为已访问
if (i > 0)
mark_page_accessed(page);
/*
* 如果用户可以使用任意虚拟地址写入此页面,
* 那么在内核端读取页面之前,请注意潜在的别名问题。
*/
if (writably_mapped) {
int j;
// 刷新数据缓存,确保读取的是最新数据
for (j = 0; j < thp_nr_pages(page); j++)
flush_dcache_page(page + j);
}
// 将页面数据复制到用户空间的迭代器中
copied = copy_page_to_iter(page, offset, bytes, iter);
// 更新统计信息
already_read += copied;
iocb->ki_pos += copied;
ra->prev_pos = iocb->ki_pos; // 更新预读位置
// 如果复制的字节数小于预期,说明出错
if (copied < bytes) {
error = -EFAULT;
break;
}
}
put_pages:
// 释放页向量中的所有页面(减少引用计数)
for (i = 0; i < pagevec_count(&pvec); i++)
put_page(pvec.pages[i]);
pagevec_reinit(&pvec); // 重新初始化页向量
} while (iov_iter_count(iter) && iocb->ki_pos < isize && !error);
// 更新文件的访问时间
file_accessed(filp);
// 返回已读取的字节数,如果没有读取到任何字节则返回错误码
return already_read ? already_read : error;
}
EXPORT_SYMBOL_GPL(filemap_read);filemap_get_pages从页缓存中获取要读取的页面
static int filemap_get_pages(struct kiocb *iocb, struct iov_iter *iter,
struct pagevec *pvec)
{
struct file *filp = iocb->ki_filp;
struct address_space *mapping = filp->f_mapping;
struct file_ra_state *ra = &filp->f_ra; // 文件预读状态
pgoff_t index = iocb->ki_pos >> PAGE_SHIFT; // 起始页面索引
pgoff_t last_index; // 结束页面索引(下一个页面)
struct page *page; // 当前页面指针
int err = 0; // 错误码
// 计算结束页面索引(向上取整)
last_index = DIV_ROUND_UP(iocb->ki_pos + iter->count, PAGE_SIZE);
retry: // 重试标签(用于处理页面被截断的情况)
// 检查是否有致命信号(如SIGKILL)等待处理
if (fatal_signal_pending(current))
return -EINTR;
// 第一次尝试批量获取页面(要求页面存在且是最新的)
filemap_get_read_batch(mapping, index, last_index, pvec);
// 如果第一次获取没有拿到任何页面
if (!pagevec_count(pvec)) {
// 如果设置了IOCB_NOIO标志(不允许I/O),返回-EAGAIN
if (iocb->ki_flags & IOCB_NOIO)
return -EAGAIN;
// 执行同步预读:触发磁盘读取,将页面预读到缓存
page_cache_sync_readahead(mapping, ra, filp, index,
last_index - index);
// 再次尝试批量获取页面
filemap_get_read_batch(mapping, index, last_index, pvec);
}
// 如果仍然没有获取到页面
if (!pagevec_count(pvec)) {
// 如果是非阻塞I/O(NOWAIT)或异步等待I/O(WAITQ),返回-EAGAIN
if (iocb->ki_flags & (IOCB_NOWAIT | IOCB_WAITQ))
return -EAGAIN;
// 创建新的页面(触发缺页异常,从磁盘读取)
err = filemap_create_page(filp, mapping,
iocb->ki_pos >> PAGE_SHIFT, pvec);
// 如果页面被截断(比如文件被并发修改),需要重试
if (err == AOP_TRUNCATED_PAGE)
goto retry;
return err;
}
// 获取页向量中的最后一个页面(用于检查是否需要预读)
page = pvec->pages[pagevec_count(pvec) - 1];
// 如果页面设置了Readahead标志(表示需要异步预读)
if (PageReadahead(page)) {
// 执行异步预读(不阻塞当前读取)
err = filemap_readahead(iocb, filp, mapping, page, last_index);
if (err)
goto err; // 预读失败,跳转到错误处理
}
// 如果页面不是最新的(数据可能过时)
if (!PageUptodate(page)) {
// 如果是异步等待I/O并且有多个页面,设置NOWAIT标志
if ((iocb->ki_flags & IOCB_WAITQ) && pagevec_count(pvec) > 1)
iocb->ki_flags |= IOCB_NOWAIT;
// 更新页面(可能触发磁盘I/O读取最新数据)
err = filemap_update_page(iocb, mapping, iter, page);
if (err)
goto err; // 更新失败,跳转到错误处理
}
// 成功获取到页面,返回0
return 0;
err: // 错误处理标签
if (err < 0)
put_page(page); // 释放页面引用
// 如果页向量中还有其他页面,返回0(部分成功)
if (likely(--pvec->nr))
return 0;
// 如果页面被截断,重试
if (err == AOP_TRUNCATED_PAGE)
goto retry;
// 返回错误码
return err;
}关键函数说明:
filemap_get_read_batch批量获取连续范围内的页面
只返回存在且是最新的页面
跳过缺失或过时的页面
page_cache_sync_readahead同步预读:阻塞等待预读完成
基于访问模式智能预读
尝试读取多个连续页面
filemap_create_page创建新页面并加入页缓存
触发磁盘I/O读取数据
处理缺页异常
filemap_update_page更新过时的页面
可能触发磁盘I/O
处理页面锁和同步
filemap_readahead异步预读:不阻塞当前操作
后台读取后续页面
提高后续读取的性能
这里我们看一下filemap_create_page ,这个在触发缺页异常,创建新页,然后把磁盘数据读到页缓存中
static int filemap_create_page(struct file *file,
struct address_space *mapping, pgoff_t index,
struct pagevec *pvec)
{
struct page *page; // 新分配的页面
int error; // 错误码
// 从页缓存分配一个新页面
page = page_cache_alloc(mapping);
if (!page)
return -ENOMEM; // 内存不足,返回错误
/*
* 防止与截断/空洞打洞操作竞争。在这里获取 invalidate_lock
* 确保我们在截断期间驱逐页缓存页面之后、实际释放块之前,
* 不能实例化并更新新的页缓存页面。
* 注意,我们可以在将页面插入页缓存后释放 invalidate_lock,
* 因为锁定的页面足以与空洞打洞操作同步。但是有一些代码路径,
* 例如 filemap_update_page() 填充部分更新的页面,
* 或者 ->readpages() 在映射块进行 I/O 时需要持有 invalidate_lock,
* 所以我们也在这里持有锁,以保持锁定规则简单。
*/
filemap_invalidate_lock_shared(mapping); // 获取共享的失效锁
// 将页面添加到页缓存和LRU列表中
error = add_to_page_cache_lru(page, mapping, index,
mapping_gfp_constraint(mapping, GFP_KERNEL));
// 如果页面已经存在(并发创建的情况)
if (error == -EEXIST)
error = AOP_TRUNCATED_PAGE; // 转换为截断页面错误
if (error)
goto error; // 添加失败,跳转到错误处理
// 从磁盘读取数据到页面
error = filemap_read_page(file, mapping, page);
if (error)
goto error; // 读取失败,跳转到错误处理
// 成功:释放锁,将页面添加到页向量,返回成功
filemap_invalidate_unlock_shared(mapping);
pagevec_add(pvec, page);
return 0;
error: // 错误处理路径
filemap_invalidate_unlock_shared(mapping); // 释放锁
put_page(page); // 释放页面引用
return error; // 返回错误码
}从磁盘读取数据到页面,使filemap_read_page 函数
static int filemap_read_page(struct file *file, struct address_space *mapping,
struct page *page)
{
int error; // 错误码
/*
* 之前的 I/O 错误可能是由于临时故障引起的,
* 例如多路径错误。如果 readpage 失败,将再次设置 PG_error。
*/
ClearPageError(page); // 清除页面错误标志
/* 开始实际的读取操作。读取操作会解锁页面。 */
error = mapping->a_ops->readpage(file, page); // 调用文件系统的 readpage 方法
if (error)
return error; // 如果立即出错,返回错误码
// 等待页面被解锁(即 I/O 完成),可被信号中断
error = wait_on_page_locked_killable(page);
if (error)
return error; // 等待过程中被信号中断或出错
// 检查页面是否已更新(数据已成功读取)
if (PageUptodate(page))
return 0; // 读取成功,返回 0
// 读取失败:减少预读大小,返回 I/O 错误
shrink_readahead_size_eio(&file->f_ra);
return -EIO;
}很明显的看到又调用了address_space的a_ops(address_space_operations 结构)的 readpage 函数。在本章节第2.2.2节,我们提到过ext4文件系统的address_space_operations对应的是 ext4_aops ,此处就是调ext4_aops 的 readpage 成员函数
static int ext4_readpage(struct file *file, struct page *page)
{
int ret = -EAGAIN; // 默认返回-EAGAIN,表示需要进一步处理
struct inode *inode = page->mapping->host; // 从页面的mapping获取inode
trace_ext4_readpage(page); // 跟踪点,用于调试和性能分析
// 首先检查是否为内联数据文件(数据存储在inode内部)
if (ext4_has_inline_data(inode))
ret = ext4_readpage_inline(inode, page); // 读取内联数据
// 如果不是内联数据,或者内联数据读取失败(返回-EAGAIN)
if (ret == -EAGAIN)
return ext4_mpage_readpages(inode, NULL, page); // 使用普通方式读取
return ret; // 返回内联数据读取的结果
}这就是完整的文件读取路径流程!
内存映射读取路径
其实这部分就是 [linux内存管理] 第040篇 文件映射与匿名映射,提到的文件映射流程部分。
对于通过mmap()映射的文件,当发生页错误(page fault)时,
发生页错误,进入
handle_mm_fault()对于文件映射,调用
do_fault()do_fault()调用文件系统特定的fault方法对于基于Page Cache的文件系统,调用
filemap_fault()filemap_fault()查找缓存页,如果未命中则从磁盘读取
也就是下面这个流程,这部分流程的细节,请查看那一篇文章,此处我们直接看一下

static const struct vm_operations_struct ext4_file_vm_ops = {
.fault = filemap_fault,
.map_pages = filemap_map_pages,
.page_mkwrite = ext4_page_mkwrite,
};
这是 Linux 内核中 filemap_fault 函数的实现,用于处理内存映射文件的缺页异常。当用户访问通过 mmap() 映射的文件区域时,如果对应页面不在内存中,就会触发这个函数。
vm_fault_t filemap_fault(struct vm_fault *vmf)
{
int error;
struct file *file = vmf->vma->vm_file; // 关联的文件
struct file *fpin = NULL; // 用于预读的文件指针
struct address_space *mapping = file->f_mapping; // 文件的地址空间
struct inode *inode = mapping->host; // 文件inode
pgoff_t offset = vmf->pgoff; // 页面偏移(页索引)
pgoff_t max_off; // 文件最大页面偏移
struct page *page; // 页面指针
vm_fault_t ret = 0; // 返回值
bool mapping_locked = false; // 是否已锁住mapping的invalidate_lock
// 计算文件最大页面偏移(文件大小除以页面大小,向上取整)
max_off = DIV_ROUND_UP(i_size_read(inode), PAGE_SIZE);
// 如果访问偏移超过文件大小,返回总线错误
if (unlikely(offset >= max_off))
return VM_FAULT_SIGBUS;
/*
* 页面缓存中是否已经有这个页面了?
*/
page = find_get_page(mapping, offset); // 查找页面,并增加引用计数
if (likely(page)) { // 页面已经在缓存中
/*
* 我们找到了页面,所以在等待锁之前尝试异步预读。
*/
if (!(vmf->flags & FAULT_FLAG_TRIED)) // 第一次尝试时才预读
fpin = do_async_mmap_readahead(vmf, page); // 异步预读
// 如果页面数据不是最新的(可能正从磁盘读取)
if (unlikely(!PageUptodate(page))) {
filemap_invalidate_lock_shared(mapping); // 获取共享锁
mapping_locked = true;
}
} else { // 页面不在缓存中(缺页)
/* 页面缓存中完全没有这个页面 */
count_vm_event(PGMAJFAULT); // 统计主要缺页
count_memcg_event_mm(vmf->vma->vm_mm, PGMAJFAULT);
ret = VM_FAULT_MAJOR; // 标记为主要缺页(需要磁盘I/O)
fpin = do_sync_mmap_readahead(vmf); // 同步预读
retry_find: // 重试标签
/*
* 查看filemap_create_page()中的注释,了解为什么我们需要invalidate_lock
*/
if (!mapping_locked) {
filemap_invalidate_lock_shared(mapping); // 获取共享锁
mapping_locked = true;
}
// 获取或创建页面
page = pagecache_get_page(mapping, offset,
FGP_CREAT|FGP_FOR_MMAP, // 如果不存在则创建,用于mmap
vmf->gfp_mask); // 分配掩码
if (!page) { // 创建页面失败
if (fpin)
goto out_retry; // 有预读文件,重试
filemap_invalidate_unlock_shared(mapping); // 释放锁
return VM_FAULT_OOM; // 内存不足
}
}
// 尝试锁定页面,可能释放mmap锁
if (!lock_page_maybe_drop_mmap(vmf, page, &fpin))
goto out_retry; // 需要重试
/* 页面是否被截断了? */
// 检查页面映射是否仍然有效(文件可能被截断)
if (unlikely(compound_head(page)->mapping != mapping)) {
unlock_page(page); // 解锁页面
put_page(page); // 释放引用
goto retry_find; // 重新查找
}
VM_BUG_ON_PAGE(page_to_pgoff(page) != offset, page); // 调试检查
/*
* 我们在页面缓存中有一个锁定的页面,现在需要检查它是否是最新的。
* 如果不是,那可能是因为错误。
*/
if (unlikely(!PageUptodate(page))) { // 页面不是最新的
/*
* 页面之前在缓存中并且是最新的,但现在不是了。
* 这很奇怪但可能发生,因为我们没有一直持有页面锁。
* 让我们放弃一切,获取invalidate锁,然后重试。
*/
if (!mapping_locked) { // 还没有锁
unlock_page(page);
put_page(page);
goto retry_find; // 重新开始
}
goto page_not_uptodate; // 跳转到处理非最新页面的代码
}
/*
* 我们已经走到这一步,并且我们不得不放弃mmap_lock,
* 现在是时候返回到上层,让它重新找到vma并重新执行fault。
*/
if (fpin) { // 有预读文件,需要重试
unlock_page(page);
goto out_retry;
}
if (mapping_locked)
filemap_invalidate_unlock_shared(mapping); // 释放锁
/*
* 找到了页面并持有引用。
* 我们必须在页面锁下重新检查i_size。
*/
max_off = DIV_ROUND_UP(i_size_read(inode), PAGE_SIZE); // 重新计算最大偏移
if (unlikely(offset >= max_off)) { // 再次检查是否越界
unlock_page(page);
put_page(page);
return VM_FAULT_SIGBUS; // 总线错误
}
// 成功:将页面设置到vmf中,返回已锁定状态
vmf->page = page;
return ret | VM_FAULT_LOCKED; // 返回状态(可能包含VM_FAULT_MAJOR)
page_not_uptodate: // 处理页面不是最新的情况
/*
* 嗯,处理页面不是最新的错误。
* 尝试重新读取一次。我们同步执行这个操作,
* 因为这里真的没有性能问题,我们需要检查错误。
*/
fpin = maybe_unlock_mmap_for_io(vmf, fpin); // 可能需要释放mmap锁以便I/O
error = filemap_read_page(file, mapping, page); // 从磁盘读取页面
if (fpin) // 如果有预读文件,需要重试
goto out_retry;
put_page(page); // 释放页面引用
if (!error || error == AOP_TRUNCATED_PAGE) // 读取成功或页面被截断
goto retry_find; // 重试
filemap_invalidate_unlock_shared(mapping); // 释放锁
return VM_FAULT_SIGBUS; // I/O错误,返回总线错误
out_retry: // 重试路径
/*
* 我们放弃了mmap_lock,需要返回到fault处理程序,
* 重新找到vma,然后再回来找到我们(希望仍然存在的)页面。
*/
if (page)
put_page(page); // 释放页面引用
if (mapping_locked)
filemap_invalidate_unlock_shared(mapping); // 释放锁
if (fpin)
fput(fpin); // 释放预读文件引用
return ret | VM_FAULT_RETRY; // 返回重试标志
}
EXPORT_SYMBOL(filemap_fault);函数处理两种主要情况:
小缺页(Minor Fault) - 页面在缓存中
页面已在缓存 → find_get_page() 成功
↓
尝试异步预读 → do_async_mmap_readahead()
↓
检查页面是否最新 → PageUptodate()
↓
是 → 返回 VM_FAULT_LOCKED
↓
否 → 需要从磁盘读取大缺页(Major Fault) - 页面不在缓存中
页面不在缓存 → find_get_page() 失败
↓
统计主要缺页 → count_vm_event(PGMAJFAULT)
↓
同步预读 → do_sync_mmap_readahead()
↓
创建新页面 → pagecache_get_page()
↓
从磁盘读取 → filemap_read_page()回写机制全面剖析
脏页与回写的基本概念
当Page Cache中的页被修改后,它成为"脏页"(dirty page)。脏页需要被写回磁盘以保持数据持久性,这个过程称为"回写"(writeback)。Linux内核实现了复杂的回写机制,在数据持久性和I/O性能之间取得平衡。
一不小心又写的太多了,有时候收不住,导致整篇文章的篇幅太长了,影响观看。回写机制后面单独出一篇吧。。。