做Linux内核开发和文件系统调优的人迟早会跟struct xarray和struct address_space打交道。这两个结构一个管数据怎么按索引存取一个管文件页缓存怎么落地配合起来就是读写文件最核心的那条路。很多人第一次看源码时看到address_space里的i_pages字段是个struct xarray往往一头雾水radix tree好好的为什么换掉xarray里存的到底什么东西脏页标记和writeback又是怎么串起来的这篇文章把我翻源码和实际调存储性能时的经验和踩坑整理出来尽量讲清楚来龙去脉。内容偏内核文件系统和内存管理方向适合正在写内核模块、在调文件系统或存储驱动的人参考刚接触page cache的读者也可以顺着这条线把源码读顺。1. 先认识xarray从radix tree换来的索引容器1.1 为什么内核把radix tree换成了xarray在xarray进内核之前页缓存的索引结构叫基数树radix treeaddress_space里维护一棵radix_tree_root配合radix_tree_lookup、radix_tree_insert、radix_tree_tag_set这些API来查、插、打标记。这套东西用了十几年功能上没毛病但API相当“原始”锁的获取释放、条目编码、内存分配错误处理全都得调用方自己去操心。而且如果你想在遍历过程中插入或删除节点还得手动维护整棵树的遍历状态稍微一复杂就出错。2018年Matthew Wilcox把xarray合入内核4.17版本目标就是逐步取代radix tree。xarray底层仍然是类似基数树的多叉结构但对外接口高度统一你用xa_store存指针、xa_load取指针基本不用关心内部如何分叉锁通过内嵌的xa_lock管理内部条目、值条目、重试条目这些特殊状态都有现成的判断函数。当时看到这个改动做文件系统的同事第一反应就是“早该这么做了”原来在radix tree上要写十几行才能搞定的标记检查用xarray几行就干净收工。这里有个容易忽略的点xarray并不是radix tree的换皮。它把索引空间的语义统一成“64基数扇出”每个内部节点64个slot索引每向右移动6位进入下一层。对page cache这种以pgoff_t为索引的场景索引可以很大但实际使用的条目往往稀疏xarray会根据当前最大索引动态分配层数不会因为索引范围大就白白浪费内存。空树时xa_head直接是NULL只放一个条目时连内部节点都不需要。这个设计在生产环境很关键毕竟每个文件都挂一棵树如果每个文件都固定分配多层节点内存开销会非常恐怖。1.2 xarray的结构、标记与常用APIstruct xarray定义在include/linux/xarray.h核心就三样东西struct xarray { spinlock_t xa_lock; gfp_t xa_flags; void __rcu *xa_head; };xa_head指向根节点也可能直接指向一个条目xa_flags保存初始化时设置的标志例如XA_FLAGS_LOCK_IRQ表示这个xarray会在线程中断上下文里操作后续加锁会用irq安全版本xa_lock是保护整个数组的自旋锁。必须知道的概念是xarray支持三种标记对应底层的xa_set_mark、xa_get_mark、xa_clear_mark操作。标记位存放在内部节点的位图里也就是说只有当条目树的深度超过一层时才有标记位图。对页缓存来说这三个标记位就用来映射PAGECACHE_TAG_DIRTY、PAGECACHE_TAG_WRITEBACK、PAGECACHE_TAG_TOWRITE。常用API可以整理成一张表API作用xa_init/xa_init_flags初始化xarray后者可指定锁类型xa_store/xa_erase存指针 / 删除条目xa_load按索引取指针xa_cmpxchg比较并交换xa_set_mark/xa_get_mark/xa_clear_mark设置、查询、清除标记xa_for_each/xa_find遍历与查找xa_reserve预先占据索引位置避免后续分配失败另外还有底层的XA_STATE/xas_*系列接口适合需要持锁遍历、批量操作的高级场景。比如你写一个文件系统日志模块需要在同步时遍历所有脏页直接用高层API逐个查效率偏低这时候就该上xas_for_each。一开始可以先用高层API等需求复杂了再切到底层不会白学。2. address_space页缓存的“房东”与i_pages2.1 字段解构关键成员分别管什么struct address_space定义在include/linux/fs.h它把“一个文件的页缓存”和“这个文件的回写、mmap、异常页”全部整合在一起。关键字段如下struct address_space { struct inode *host; struct xarray i_pages; struct rw_semaphore i_mmap_rwsem; struct rb_root_cached i_mmap; unsigned long nrpages; unsigned long nrexceptional; pgoff_t writeback_index; const struct address_space_operations *a_ops; unsigned long flags; spinlock_t private_lock; struct list_head private_list; void *private_data; };host指回所属的inode每个打开的文件在内存里一般只有一个mappingi_pages就是那个xarray保存文件的缓存页索引到struct folio *的映射i_mmap是一棵红黑树记录哪些进程把文件的哪些区间映射到了自己的地址空间nrpages是当前缓存页数量nrexceptional是shadow、dax等特殊条目数量a_ops是操作方法表比如read_folio、writepages、write_begin等。特别要强调a_ops。你会在代码里看到某些文件系统不直接用通用路径而是自己实现一套读写页的逻辑比如老版本ext4在延迟分配路径就是这样。发展到iomap时代a_ops又跟iomap、dax这些新机制结合起来。不管上层怎么变最终都要落到i_pages这颗xarray上分配页后xa_store读页时xa_load页变脏后xa_set_mark。搞清楚了这层关系读具体文件系统代码就不容易迷路。2.2 页缓存索引从文件偏移到xarray索引page cache的索引规则很简单文件偏移右移PAGE_SHIFT位得到pgoff_t索引。pgoff_t index offset PAGE_SHIFT;一个4KB页对应一个pgoff_t索引xarray直接把这个索引作为树索引指针指向struct folio。内核里struct page和struct folio之间的转换通过page_folio、folio_page完成xarray里存的一定是folio指针。遍历时要注意这一点别拿struct page *当entry直接使用。这里常有人犯迷糊一个文件有连续2MB的页面为什么在xarray里逻辑上连续物理上却不一定连续因为页缓存可以部分命中、部分回写、部分被回收只有当整个文件都缓存了索引和物理页顺序才完全对应。address_space只保证逻辑索引上的连续性不保证物理页连续。所以用xarray组织很自然按索引查一个页是否缓存时间复杂度是O(log_64 n)比遍历链表高效太多。2.3 从xarray角度理解page cache的读写链路文件读写的整体链路大概是用户态调用read/write进入虚拟文件系统层最终落到具体文件系统的-read_iter/-write_iter通用路径会调用filemap_read和generic_perform_write前者负责从page cache取页后者负责在page cache里建页并填数据。无论哪条路径最后都要对mapping-i_pages做操作。写场景再展开一点。generic_perform_write对每个要写的页会调用grab_cache_page_write_begin内部就是__filemap_get_folio先在xarray里找找到就返回已有的folio找不到就分配新folio再通过xa_store放进树里。读场景类似filemap_get_folio内部会调用filemap_get_entry最终执行xa_load(mapping-i_pages, index)返回NULL就说明没缓存走readahead或磁盘读。3. 读一个文件时xarray发生了什么3.1 从read到filemap_get_folio再到xa_load我在追踪读路径源码时最值得注意的就是filemap_get_folio这条调用链。以ext4为例用户read -ext4_file_read_iter-generic_file_read_iter-filemap_read。filemap_read前面一般会先做readahead然后进入核心循环struct folio *folio filemap_get_folio(mapping, index); if (!folio) { /* 缺页需要从磁盘读 */ }filemap_get_folio实际上是对__filemap_get_folio的封装static inline struct folio *filemap_get_folio(struct address_space *mapping, pgoff_t index) { return __filemap_get_folio(mapping, index, FGP_ACCESSED, 0); }__filemap_get_folio先调用filemap_get_entry再判断entry到底是不是内部条目。filemap_get_entry内部关键就一句if (xa_is_value(entry)) goto out; entry xa_load(mapping-i_pages, index); if (!xa_is_value(entry) entry) folio page_folio(entry);这里有个细节值得留意xa_load可能返回xa_is_value的shadow条目代表这个位置最近释放过页此时应该视为缺页同时把shadow清掉再存真实folio。如果漏掉这个判断容易把影子条目当成真实页后面直接崩溃。我做过一次crash分析最后就确认是某驱动在filemap_get_entry里没做xa_is_value判断。3.2 readahead批量填充与xas接口readahead逻辑里page_cache_sync_readahead会按预测区间批量分配folio然后逐个添加到page cache。批量插入时如果用xa_store逐条调用每次都拿锁、释放锁吞吐会打折。内核做法是用XA_STATE和xas_lock_irqsave把整批量插入放在一次锁保护下XA_STATE(xas, mapping-i_pages, index); xa_lock_irqsave(mapping-i_pages, flags); xas_set(xas, index); xas_store(xas, folio); xa_unlock_irqrestore(mapping-i_pages, flags);实际源码里__filemap_add_folio的逻辑跟上面类似。批量readahead时xas会在一次锁内连续xas_set并xas_store多个folio减少锁竞争。如果你自己写模块不一定需要这个细节但理解它有助于看懂为什么page cache填充这么快一页只做一次xarray store而不是一次内存分配加一次锁。3.3 自己遍历i_pages的正确姿势很多调试场景需要把mapping里的页全部打印出来我推荐用底层的xas_for_eachstruct address_space *mapping ...; XA_STATE(xas, mapping-i_pages, 0); struct folio *folio; rcu_read_lock(); xas_for_each(xas, folio, ULONG_MAX) { if (xas_retry(xas, folio)) continue; if (xa_is_value(folio)) continue; /* 这里folio就是树里的一个缓存页 */ pr_info(idx%lu folio%p\n, xas.xa_index, folio); } rcu_read_unlock();三个关键点第一必须持有RCU读锁因为xarray支持RCU无锁读第二xas_retry必须检查xarray在并发修改时可能返回重试条目第三xa_is_value过滤掉shadow等内部值。我调试一个内存泄漏问题时就是靠这个循环打印出文件的所有缓存页索引再用page_count逐个判断谁持有引用没释放最后发现是一个驱动没调put_page。当时如果没有这个遍历能力想在几百万页里找出问题简直是大海捞针。4. writeback、脏页标记与截断xarray的标记机制4.1 脏页标记是如何通过xarray管理的文件被写入后页变成脏页内核不会立刻刷盘而是等到writeback时机统一处理。问题的关键是“怎么快速知道哪些页是脏的”这就要靠xarray的标记位。内核定义了三个页缓存标签在include/linux/pagemap.h里宏含义PAGECACHE_TAG_DIRTY页内容已修改需要写回PAGECACHE_TAG_WRITEBACK页正在写回中PAGECACHE_TAG_TOWRITE本次回写需要处理的候选页写路径里最终会把folio标记为脏并调用mapping_tag_setstatic inline void mapping_tag_set(struct address_space *mapping, pgoff_t index, int tag) { xa_set_mark(mapping-i_pages, index, tag); }回写路径write_cache_pages里则用mapping_tagged先快速判断这个mapping有没有脏页再用xas_find_marked找带标记的索引if (!mapping_tagged(mapping, PAGECACHE_TAG_DIRTY)) return 0; xas_for_each_marked(xas, folio, end, PAGECACHE_TAG_TOWRITE) { ... }xarray的标记是祖先节点加叶子节点的位图查找带标记的条目不需要遍历所有slot而是先看位图判断子树里有没有。这也是radix tree时代引以为傲的tag lookup能力xarray完整保留了下来。这里有个容易踩的坑写回完成的路径上内核会在clear_page_dirty_for_io等位置把PAGECACHE_TAG_DIRTY清掉同时置上PAGECACHE_TAG_WRITEBACK写回结束再清掉WRITEBACK。如果你在文件系统驱动里插桩发现页已经写入磁盘但tag还没清干净通常是writeback尚未收尾别急着判定bug。我在调试一块NVMe盘的存储性能时就见过误判这种情况导致重复写同一页的例子。4.2 truncate与shadow entrytruncate是文件疏密变化最剧烈的地方。truncate_inode_pages_range会把指定范围的页从i_pages里摘掉同时做两件事一是通过truncate_cleanup_folio清理页引用二是调用__filemap_remove_folio把slot存成shadow entry而不是完全删除。shadow entry是xarray支持的一种特殊内部值不是真实folio指针只记录“这个索引位置近期被释放过”。为什么内核不直接删掉索引因为后续内存回收需要知道“最近是否释放过页”从而判断要不要给这个文件更多缓存机会。workingset机制就是靠shadow entry推算aging信息的。清除过程在代码里大致是static void __filemap_remove_folio(struct folio *folio, void *shadow) { ... XA_STATE(xas, mapping-i_pages, folio-index); xa_lock_irqsave(mapping-i_pages, flags); xas_store(xas, shadow); ... }如果你调试时发现truncate后nrpages减小了但nrexceptional增加了这是shadow entry的正常计数不用惊慌。只有当下一次读到这个区间xarray才会把shadow替换成真实folio。4.3 关于“hook write做加密”的一点延展有朋友问过“怎么hook write做透明加密”顺着address_space的思路就很好理解。要么包一层a_ops把write_begin、writepages这些方法替换成自己的实现在数据落cache或落盘前做加解密要么用ftrace/kprobe监控generic_perform_write、ext4_writepages这些关键函数在入口和出口拿到folio再处理。无论哪种方案你都要精确知道当前正在处理哪个文件偏移、对应哪个folio索引这就是xarray的索引和address_space的i_pages帮上忙的地方。用户态策略下发通常走ioctl或netlink把算法、密钥ID、启用范围传给驱动驱动再把这些策略绑定到具体的mapping、inode上。想在内核里做数据面控制page cache这套索引和标记机制是绕不过去的基础。5. 调试内核时如何查看xarray与address_space5.1 用crash/gdb扒i_pages真碰到问题光看代码不够得能“看到”运行时的树。我常用的办法有三个内核模块打印。临时写一个ko遍历mapping-i_pages打印所有folio索引和标记状态最直接但要在目标环境加载模块。gdb调试。连接/proc/kcore或在内核虚拟机里调时可以执行p *(struct address_space *)0xffff8880xxxx看i_pages.xa_head指向什么。不过手动走节点比较痛苦一般只看状态字段。crash工具分析vmcore。crash里可以用struct address_space 0xffff8880xxxx直接展示mapping信息。推荐打开CONFIG_KALLSYMS_ALL否则符号不全时crash经常显示不出来。实际使用中最方便的其实是crash的tree命令crash tree -t radix -p 0xffff8880xxxx虽然名字是radix但xarray底层布局与radix tree兼容crash可以按树形结构打印每个slot的内容。没人提醒的话光看xa_head一个指针对树结构会一头雾水用tree -t radix能直接看到所有slot里的folio地址和标记位。5.2 排查实录一次写回死锁的定位过程分享一个真实案例。某个存储场景下内核线程在wb_workfn里卡住系统越来越慢最后触发soft lockup。我用crash看vmcore发现卡在write_cache_pages的xas_find_marked循环里某个folio的PAGECACHE_TAG_WRITEBACK一直被设置但对应的IO从来没完成。进一步看folio发现该folio的mapping属于一个已断连的文件系统a_ops指向的writepages因为文件系统冻结已经不再工作回写永远等不到IO完成。这个问题的根因不在xarray但定位过程中对PAGECACHE_TAG_WRITEBACK标记的检查非常关键。通过xa_get_mark能判断一页到底卡在哪个状态从而缩小排查范围。类似情况还有某模块在xa_store时用了普通xa_lock而不是xa_lock_irqsave在中断上下文触发自旋锁死锁或者遍历xarray时忘了xas_retry在CPU抢占时拿到重试条目导致空指针。这些坑在radix tree时代就有xarray接口之后更安全了但底层并发语义依然存在写代码时别掉以轻心。5.3 避坑清单最后把我实际项目里踩过的坑整理成一张速查表情境错误做法正确做法在遍历i_pages时修改树只在RCU读锁内直接xa_store用xas_pause暂停遍历或两阶段先收集再处理在中断上下文调用xa操作直接用普通xa_lock版本接口初始化时带XA_FLAGS_LOCK_IRQ并使用irq版本读路径拿到entry就当folio未做xa_is_value/xas_retry判断先过滤内部条目和重试条目认为nrpages就是缓存页数忽略nrexceptionalshadow/dax条目要单独统计替换a_ops做加密时忘记同步tag只改writepages不维护脏标记在写回完成后正确执行xa_clear_mark等再补充一个心得xarray的标记和address_space的nrpages统计不是原子的二者可能存在短暂不一致。比如truncate过程中某个线程读nrpages可能看到旧值但xa_load已经拿不到页。写代码时不要依赖“nrpages0 就一定没有缓存页”这种假设必要时用xa_load或mapping_tagged做最终判断。做内核这块xarray和address_space就像高速公路上的立交桥和收费站天天从那里经过但很少停下来看结构。真到了写文件系统、调存储性能、排查脏页回写卡死的时候才意识到把这两个结构弄明白能少走多少弯路。我个人的体会是先把i_pages的增删查和三种tag的流转理清楚再回头看filemap_*系列代码几乎一马平川。后续如果涉及用户态策略下发加密、容器场景里的页缓存回收这些基础也完全够用。希望这篇整理能帮你省点翻源码的时间。