AI智能摘要
Page Cache作为Linux内核提升文件访问性能的核心机制,通过将磁盘数据缓存于内存,显著减少磁盘I/O次数。文章深度解析了Page Cache的架构及其关键数据结构,系统梳理address_space在文件和内存页间的桥梁作用。详细讲解address_space及page结构体的核心字段,阐明它们如何精准映射文件数据,实现高效的数据定位与状态管理,同时详细描述了读流程下的文件映射和内存映射的代码流程。
此摘要由AI分析文章内容生成,仅供参考。

前言

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 结构体的定义,它用于管理文件页缓存和内存映射,下面介绍一下各个字段的作用:

关键字段说明:

  1. host:指向拥有此地址空间的 inode 对象,建立了地址空间与文件的关联。

  2. i_pages:存储所有缓存页的容器(从 Linux 5.1 开始从 radix tree 改为 xarray),索引是文件偏移量对应的页号。

  3. i_mmap:红黑树,存储所有映射此文件页缓存的 VMA(虚拟内存区域),用于实现 mmap() 内存映射。

  4. a_ops:文件系统特定的操作函数,包括:

    • readpage:从磁盘读取页到缓存

    • writepage:将缓存页写回磁盘

    • direct_IO:直接 I/O 操作等

  5. nrpages:当前缓存的总页数,用于统计和管理。

  6. private_list/private_data:供文件系统存储私有数据(如 ext4 的延迟分配结构)。

  7. __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_spaceindex表示页在文件中的偏移(以页为单位)。

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())。

  • 工作原理

    1. 当应用程序发起一个 read() 系统调用时,内核首先检查请求的数据块是否已经在 Page Cache 中。

    2. 如果命中,则直接从内存(Page Cache)中复制数据到用户缓冲区,过程极快,称为 Cache Hit

    3. 如果未命中,则内核发起磁盘 I/O,将数据从磁盘读入 Page Cache,然后再复制到用户缓冲区。这个过程相对较慢,称为 Cache Miss

    4. 对于 write() 调用,数据通常先被写入 Page Cache 中的对应页面,并将其标记为“脏页”。此时写入操作就返回成功了,应用程序认为写入已完成。

    5. 内核会在后台(通过 pdflush 线程或现在的 writeback 机制)将“脏页”异步地刷新到磁盘上。

  • 核心特点

    • 延迟写入:数据在内存中停留一段时间,便于合并多次小写操作,减少磁盘访问次数。

    • 预读:内核会根据访问模式,预测并提前将后续可能读取的数据块加载到 Page Cache 中。

    • 透明性:对应用程序透明,编程接口简单。

  • 示例:用 cat 查看一个文件、用编辑器保存文件、数据库的普通查询等。

    标准IO触发page cache流程
    标准IO触发page cache流程

Memory-Mapped I/O(存储映射 I/O)

这种方式通过 mmap() 系统调用实现,将文件的一部分或全部直接映射到进程的虚拟地址空间。

  • 工作原理

    1. 进程调用 mmap(),内核在进程的地址空间中创建一段映射区域,但此时并不实际加载文件数据。

    2. 当进程首次访问该映射区域的某个地址时,会触发一个 缺页异常

    3. 内核的缺页异常处理程序会分配一个物理内存页,并从磁盘读入对应的文件块到该页,然后将其插入进程的页表和 Page Cache

    4. 此后,进程对该内存区域的读写操作,就直接等同于对 Page Cache 的读写。进程可以使用指针直接操作内存,而无需调用 read()/write()

    5. 对映射区域的修改也会被标记为“脏页”,最终由内核异步刷回磁盘。

  • 核心特点

    • 零拷贝访问:消除了从内核 Page Cache 到用户缓冲区的一次数据拷贝,对于需要频繁、随机访问大文件的操作效率更高。

    • 内存共享:多个进程可以映射同一个文件,从而实现共享内存式的通信(MAP_SHARED)。

    • 编程模型:提供了像操作内存一样操作文件的方式,非常灵活。

  • 示例:动态链接库的加载、大型数据文件的随机访问、进程间通过映射同一文件进行通信。

Page Cache 读取路径

文件读取路径

当应用程序通过read()系统调用读取文件时,内核的执行路径如下:

  1. read()系统调用进入内核空间,调用vfs_read()

  2. vfs_read()调用文件系统特定的read方法

  3. 对于基于Page Cache的文件系统(如ext4),调用generic_file_read_iter()

  4. generic_file_read_iter()调用do_generic_file_read()

  5. 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;
}

关键函数说明:

  1. filemap_get_read_batch

    • 批量获取连续范围内的页面

    • 只返回存在且是最新的页面

    • 跳过缺失或过时的页面

  2. page_cache_sync_readahead

    • 同步预读:阻塞等待预读完成

    • 基于访问模式智能预读

    • 尝试读取多个连续页面

  3. filemap_create_page

    • 创建新页面并加入页缓存

    • 触发磁盘I/O读取数据

    • 处理缺页异常

  4. filemap_update_page

    • 更新过时的页面

    • 可能触发磁盘I/O

    • 处理页面锁和同步

  5. 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)时,

  1. 发生页错误,进入handle_mm_fault()

  2. 对于文件映射,调用do_fault()

  3. do_fault()调用文件系统特定的fault方法

  4. 对于基于Page Cache的文件系统,调用filemap_fault()

  5. 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性能之间取得平衡。

一不小心又写的太多了,有时候收不住,导致整篇文章的篇幅太长了,影响观看。回写机制后面单独出一篇吧。。。