The Page Cache and address_space

Every file’s cached data in Linux lives in a per-inode object called a struct address_space, reached through the inode’s i_mapping pointer. The address_space is that file’s slice of the page cache: it owns the XArray of cached folios for the file and a table of function pointers — address_space_operations (usually just a_ops) — through which generic Virtual File System (VFS) code talks to the concrete filesystem to fill, write back, dirty, and invalidate those folios. This note is the VFS-side view of the page cache: how an inode binds to its address_space, what every field of that structure is for, what each a_ops method’s contract is and which locks it runs under, how a filesystem installs its vtable, and how a buffered read() or write() dispatches through the generic filemap_* machinery into those per-filesystem hooks. It deliberately does not explain the cache’s memory behaviour — how much RAM it uses, when reclaim takes it back, why a writer stalls, what /proc/meminfo is saying — which is the subject of its sibling The Page Cache. Here the subject is the vtable seam between the generic page cache and the filesystem.

This note pins every claim to Linux 6.12, a maintained long-term-support (LTS) release (6.12 was released 2024-11-17 and the 6.12.y stable series is still shipping). Mainline has moved past it, so where something changed after 6.12 — and in this area several things did — it is called out with the release that changed it rather than silently folded in. All source was read at the v6.12 tag via raw.githubusercontent.com unless another tag is named, and version claims were established by fetching the same file at successive tags and diffing, not from release notes.

Scope — This Note and Its Three Siblings

Four notes in this vault describe the page cache, deliberately kept separate rather than merged. They are split by the question you are asking, not by topic, and the split that people trip over is the one between this note and The Page Cache.

flowchart TB
  Q{"What kind of noun is<br/>at the centre of<br/>your question?"}
  Q -->|"A function pointer, a lock,<br/>a filesystem method, a struct field"| B["<b>The Page Cache and address_space</b><br/>(this note — VFS view)<br/>i_mapping/i_data · a_ops vtable<br/>method contracts · locking matrix<br/>how ext4/XFS plug in"]
  Q -->|"A number of kilobytes, a threshold,<br/>an eviction, a stall"| A["<b>The Page Cache</b><br/>(MM view)<br/>meminfo · dirty limits · reclaim<br/>readahead policy · O_DIRECT · fadvise"]
  Q -->|"How an offset is looked up,<br/>where the dirty bits live"| C["<b>Address Space and<br/>the Page Cache XArray</b><br/>(data-structure view)<br/>i_pages internals · marks<br/>shadow entries · RCU lookup"]
  Q -->|"What exactly the stored<br/>object is"| D["<b>Folios and the Page Cache</b><br/>(unit-of-I/O view)<br/>filemap_get_folio · FGP flags<br/>large-folio sizing"]

Which of the four page-cache notes answers which question. What it shows: the same subsystem cut four ways — by the filesystem-facing interface (this note), by memory behaviour, by the indexing structure, and by the unit of storage. The insight to take: the boundary between this note and The Page Cache is simply code versus memory. This note answers “what must my filesystem implement, and what is it promised?”; the sibling answers “how much RAM is this using, and why did my process stall?” Neither re-derives the other. When a question spans both — say, “why does ->writepages get called at all?” — the trigger (dirty thresholds, reclaim pressure) is in the sibling and the contract (what writepages must do when it is called) is here.

If you want to know…Read
What ext4 must implement to participate in the page cachethis note, The address_space_operations Vtable and How a Filesystem Plugs In
Which lock is held when ->read_folio is calledthis note, Which Lock Is Held When Each Method Runs
Why i_mapping is a pointer when i_data is right therethis note, The inode-to-address_space Binding
What ->write_begin must guarantee about partial blocksthis note, What Each Method Must Guarantee
What the method used to be called before it was read_foliothis note, The Method Names Changed
Why XFS has no ->write_beginthis note, iomap filesystems bypass half the vtable
How swap can have an address_space with no inodethis note, address_spaces That Are Not Files
Why Cached is 40 GiB and whether that is a problemThe Page Cache
Why a writer is being paused mid-write()The Page Cache
Whether O_DIRECT really ignores the cacheThe Page Cache (policy) and Direct IO and O_DIRECT (mechanism)
How offset 8192 of a file is looked up in the XArrayAddress Space and the Page Cache XArray
Why a 64 KiB folio is cheaper than sixteen 4 KiB onesFolios and the Page Cache

Mental Model — The inode Owns a Cache, the Filesystem Lends It Methods

The page cache is not one global structure. It is per file: each cacheable struct inode carries its own struct address_space, and that address_space is the file’s private cache plus the hooks the filesystem must implement to service it. Think of it as a small object with two halves. One half is state — the XArray of cached folios (i_pages), counters, flags, the writeback error cursor. The other half is behaviour — a single pointer, a_ops, to a const vtable of function pointers that the filesystem supplies. Generic VFS code in mm/filemap.c never knows how ext4 or XFS lays bytes on disk; it only knows “to fill this folio, call a_ops->read_folio; to persist these dirty folios, call a_ops->writepages.” The address_space is the adapter that lets one generic read/write path drive dozens of filesystems.

flowchart TB
  syscall["read() / write() / mmap()"]
  vfs["Generic VFS / filemap.c<br/>(generic_file_read_iter,<br/>generic_perform_write)"]
  subgraph inode_obj["struct inode (one per file)"]
    imap["i_mapping  (pointer)"]
    idata["i_data  (embedded struct address_space)"]
    imap -->|"normally points at"| idata
  end
  subgraph as_obj["struct address_space"]
    state["state: i_pages (XArray of folios),<br/>nrpages, flags, wb_err, host"]
    aops["a_ops  ──►  address_space_operations<br/>(per-filesystem vtable)"]
  end
  subgraph fs["Concrete filesystem (ext4, XFS, …)"]
    rf["read_folio / readahead"]
    wb["write_begin / write_end / writepages"]
    df["dirty_folio / direct_IO"]
  end
  syscall --> vfs --> as_obj
  idata --> as_obj
  aops --> fs
  state -.->|"folios keyed by file offset"| pc["(page cache contents)"]

The address_space as the adapter between the generic page cache and a filesystem. What it shows: a syscall enters generic VFS code, which reaches the file’s address_space via inode->i_mapping; the address_space holds the cached folios (state) and the a_ops vtable (behaviour); generic code calls through a_ops into the concrete filesystem to do the actual I/O. The insight to take: the filesystem never sees the read/write loop — it only fills in vtable methods, and the VFS calls them at the right moments. Learn the methods’ contracts and any filesystem’s data path becomes legible.

The relationships are worth stating as a type diagram too, because the multiplicities are where confusion starts: many inodes, each with exactly one embedded address_space, all sharing one const vtable per filesystem mode.

classDiagram
  class inode {
    +umode_t i_mode
    +loff_t i_size
    +rw_semaphore i_rwsem
    +address_space* i_mapping
    +address_space i_data
    +inode_operations* i_op
    +file_operations* i_fop
  }
  class address_space {
    +inode* host
    +xarray i_pages
    +rw_semaphore invalidate_lock
    +gfp_t gfp_mask
    +rb_root_cached i_mmap
    +unsigned long nrpages
    +pgoff_t writeback_index
    +address_space_operations* a_ops
    +unsigned long flags
    +errseq_t wb_err
  }
  class address_space_operations {
    <<const vtable, shared>>
    +read_folio()
    +readahead()
    +write_begin()
    +write_end()
    +writepages()
    +dirty_folio()
    +direct_IO()
    +invalidate_folio()
    +release_folio()
    +migrate_folio()
  }
  class file {
    +address_space* f_mapping
    +errseq_t f_wb_err
  }
  inode "1" *-- "1" address_space : embeds as i_data
  inode "1" --> "1" address_space : points via i_mapping
  address_space "many" --> "1" address_space_operations : a_ops
  address_space "1" --> "1" inode : host (back-pointer)
  file "many" --> "1" address_space : f_mapping

The object graph around struct address_space, v6.12. What it shows: the inode both embeds an address_space (i_data) and points at one (i_mapping); the address_space points back at its owner (host); every open struct file carries its own f_mapping pointer; and the a_ops vtable is a shared, immutable object that many address_spaces reference. The insight to take: there are three separate paths to a file’s cache — inode->i_mapping, file->f_mapping, and &inode->i_data — and they are not interchangeable. Generic code uses file->f_mapping when it has a file and inode->i_mapping when it does not; &i_data is an implementation detail nothing outside the inode’s own lifecycle should touch. The next section is entirely about why.

The inode-to-address_space Binding: i_mapping, i_data, and f_mapping

The binding is the first thing to get exactly right, because it has a deliberate subtlety and because most published explanations of it describe a kernel older than 5.11. Looking at struct inode in include/linux/fs.h, v6.12, there are two address_space members:

struct inode {
	...
	struct address_space	*i_mapping;   /* line 646: a POINTER */
	...
	struct address_space	i_data;       /* line 722: an EMBEDDED object */
	...
};

i_data is the address_space embedded by value inside the inode — it is the file’s own cache, allocated and freed with the inode, so there is no separate lifetime to manage and no extra allocation on the inode fast path. i_mapping is a pointer, and for an ordinary regular file it simply points at the inode’s own i_data. The kernel wires this up in inode_init_always_gfp() in fs/inode.c, v6.12 (reached through the inode_init_always() inline wrapper declared in fs.h):

struct address_space *const mapping = &inode->i_data;   /* line 162 */
...
mapping->a_ops = &empty_aops;                           /* line 206 */
mapping->host = inode;                                  /* line 207 */
mapping->flags = 0;
mapping->wb_err = 0;
mapping_set_gfp_mask(mapping, GFP_HIGHUSER_MOVABLE);
mapping->writeback_index = 0;
init_rwsem(&mapping->invalidate_lock);
if (sb->s_iflags & SB_I_STABLE_WRITES)
	mapping_set_stable_writes(mapping);
...
inode->i_mapping = mapping;                             /* line 224 */

Read line by line: at birth inode->i_mapping == &inode->i_data; the a_ops vtable starts as empty_aops (an all-NULL static, which the filesystem replaces in its own inode constructor); mapping->host is the back-pointer from the address_space to its owning inode; the allocation mask starts at GFP_HIGHUSER_MOVABLE, meaning cached folios may come from highmem and are movable by compaction; and the invalidate_lock gets its own lockdep class per filesystem type. That host back-pointer is how, given a cached folio (which points at its address_space), the kernel walks back to “which inode does this byte belong to” — it is what makes writeback able to find the inode from a dirty folio.

Why a pointer and not just the embedded object?

Because a pointer can be redirected, and historically it was. The canonical case is the block-special device file: an on-disk filesystem holds an inode for /dev/sda, but you do not want each filesystem’s copy of that special file to have its own page cache for the device — you want one shared cache for the block device no matter which /dev node was opened. Up to and including Linux 5.10, the VFS solved this by redirecting the special inode’s i_mapping. In fs/block_dev.c at v5.10, bd_acquire() contains literally inode->i_mapping = bdev->bd_inode->i_mapping;, and bd_forget() puts it back with inode->i_mapping = &inode->i_data;. The same two lines are present at v5.4 and v4.19.

That mechanism is gone. Fetching the same file at successive tags shows the i_mapping assignment present at v5.10 and absent at v5.11 and v5.12, which retain only filp->f_mapping = bdev->bd_inode->i_mapping;. At v6.12 the same shape survives, moved into block/bdev.c: the block device’s cache is its own pseudo-inode’s i_data (inode->i_data.a_ops = &def_blk_aops; at line 425, bdev->bd_mapping = &inode->i_data; at line 433, where the inode came from new_inode(blockdev_superblock)), and opening the device points the open file at it — bdev_file->f_mapping = bdev->bd_mapping; at line 948.

That last line is an override of a default set in fs/open.c, v6.12 line 916, where do_dentry_open() does f->f_mapping = inode->i_mapping; for every ordinary open (and, immediately after, f->f_wb_err = filemap_sample_wb_err(f->f_mapping); — the per-descriptor writeback-error cursor discussed in The Page Cache).

flowchart TB
  subgraph now["v6.12 (and since 5.11): redirection happens at f_mapping"]
    fA["struct file for /dev/sda<br/>(f_mapping)"] -->|"bdev_file->f_mapping =<br/>bdev->bd_mapping"| bdmap["bdev pseudo-inode's i_data<br/>a_ops = def_blk_aops<br/>(one per block device)"]
    inA["devtmpfs inode for /dev/sda<br/>i_mapping"] -->|"still points at its own"| idA["its own i_data<br/>(unused for device I/O)"]
  end
  subgraph then["v5.10 and earlier: redirection happened at i_mapping"]
    inB["filesystem inode for /dev/sda"] -->|"bd_acquire():<br/>inode->i_mapping =<br/>bdev->bd_inode->i_mapping"| bdmapB["bdev inode's mapping"]
    inB -.->|"bd_forget() restores<br/>i_mapping = &i_data"| idB["its own i_data"]
  end

Where the block-device cache redirection lives, before and after Linux 5.11. What it shows: the same architectural goal — one shared page cache per block device rather than one per /dev node — implemented at two different indirections in two eras. The insight to take: if you read that “i_mapping differs from &i_data for block devices”, check the kernel version; that was true through 5.10 and is not true at 6.12. Today the divergence a filesystem author actually meets is between file->f_mapping and inode->i_mapping, which is why generic code that has a struct file always uses f_mapping (filemap_read() starts with struct address_space *mapping = filp->f_mapping;) and never reaches through the inode.

Uncertain

Verify: the claim that no in-tree filesystem at v6.12 leaves inode->i_mapping != &inode->i_data for a live inode. Reason: this was established by reading fs/inode.c, fs/open.c, block/bdev.c and a sample of candidate filesystems (fs/coda/cnode.c, which explicitly re-asserts inode->i_mapping = &inode->i_data; at line 40, plus fs/hugetlbfs, fs/ecryptfs, fs/nilfs2), not by an exhaustive tree-wide grep — lore.kernel.org was unreachable (see the callout in Production Notes) and a whole-tree search was not performed. A stacking or network filesystem outside the sampled set could still redirect it. To resolve: git grep -n 'i_mapping\s*=' v6.12 -- fs/ drivers/ mm/ on a local checkout. The architectural point — that i_mapping exists as a pointer precisely so a cache can live somewhere other than the object’s own i_data — is unaffected either way. uncertain

The practical rule that survives all of this: reach a file’s cache through file->f_mapping if you have a file, through inode->i_mapping if you do not, and never through &inode->i_data. Writing &inode->i_data in filesystem code works today and is a latent bug the moment anyone reintroduces a redirection.

struct address_space Field by Field

The full v6.12 definition, with the kernel’s own kernel-doc comments, is short enough to read whole. What follows is the VFS-author’s reading of it — what each field means for code that implements a_ops. The indexing internals of i_pages (XArray nodes, marks, shadow entries, the RCU lookup protocol) are the subject of Address Space and the Page Cache XArray and are not re-derived here.

struct address_space {
	struct inode		*host;              /* Owner: the inode (or bdev pseudo-inode) */
	struct xarray		i_pages;            /* Cached folios, keyed by page index      */
	struct rw_semaphore	invalidate_lock;    /* Guards fill-vs-invalidate coherency     */
	gfp_t			gfp_mask;           /* Allocation flags for cache folios       */
	atomic_t		i_mmap_writable;    /* # of VM_SHARED|VM_MAYWRITE mappings     */
#ifdef CONFIG_READ_ONLY_THP_FOR_FS
	atomic_t		nr_thps;            /* # of THPs in the pagecache (non-shmem)  */
#endif
	struct rb_root_cached	i_mmap;             /* Tree of private and shared mappings     */
	unsigned long		nrpages;            /* Page entries; protected by i_pages lock */
	pgoff_t			writeback_index;    /* Writeback resumes here                  */
	const struct address_space_operations *a_ops;   /* Methods                         */
	unsigned long		flags;              /* AS_* error bits and folio-order bits    */
	errseq_t		wb_err;             /* Most recent writeback error             */
	spinlock_t		i_private_lock;     /* For the owner's use                     */
	struct list_head	i_private_list;     /* For the owner's use                     */
	struct rw_semaphore	i_mmap_rwsem;       /* Protects i_mmap and i_mmap_writable     */
	void *			i_private_data;     /* For the owner's use                     */
} __attribute__((aligned(sizeof(long))));
FieldTypeWhat it is for, from the VFS side
hoststruct inode *Back-pointer to the owner. Kernel-doc says “Owner, either the inode or the block_device” — at v6.12 it is always an inode, because a block device’s mapping is a pseudo-inode’s i_data. Set once by inode_init_always_gfp(). This is how writeback goes from a dirty folio to the inode it must mark dirty.
i_pagesstruct xarrayThe cache itself: folios indexed by pgoff_t (page index within the file), plus the DIRTY / WRITEBACK / TOWRITE marks and shadow entries. Replaced the radix tree in 4.20; the kernel’s XArray documentation names the page cache as “the most important user” (core-api XArray docs).
invalidate_lockstruct rw_semaphore“Guards coherency between page cache contents and file offset→disk block mappings in the filesystem during invalidates. It is also used to block modification of page cache contents through memory mappings.” Held shared across ->read_folio/->readahead, exclusive across truncate/hole-punch. This is the lock that makes fill-versus-truncate races impossible.
gfp_maskgfp_tAllocation flags for cache folios. Starts as GFP_HIGHUSER_MOVABLE; a filesystem that cannot tolerate movable or highmem folios narrows it with mapping_set_gfp_mask() (block devices use GFP_USER, per bdev_alloc()).
i_mmap / i_mmap_rwsem / i_mmap_writableinterval tree, rwsem, atomicThe reverse map: which VMAs map this file. Used by truncate to unmap pages, by rmap to find every PTE pointing at a cached folio, and by i_mmap_writable to answer “is anyone able to write to this via a shared mapping?” (which is how a filesystem refuses to swap-on or deny-write a mapped file).
nr_thpsatomic_tOnly compiled in under CONFIG_READ_ONLY_THP_FOR_FS; counts transparent huge pages in a non-shmem mapping. Largely superseded by the folio-order fields in flags.
nrpagesunsigned longNumber of page-sized entries currently cached — pages, not folios, so one order-4 folio contributes 16. Protected by the i_pages lock. mapping_empty() and every “does this inode have cache?” check reads it.
writeback_indexpgoff_tWhere the last WB_SYNC_NONE writeback pass stopped, so the next one resumes there rather than restarting at offset 0 and starving the tail of a large file. writepages implementations that use writeback_control->range_cyclic honour it.
a_opsconst address_space_operations *The vtable. const and shared: every ext4 inode in ordered-data mode points at the same ext4_aops object.
flagsunsigned longTwo unrelated things packed together: the AS_* error/behaviour bits in the low bits, and the minimum/maximum folio order in bits 16–25. See the two tables below.
wb_errerrseq_tThe most recent writeback error, as a sequence-stamped value so that each struct file can be told about it exactly once via its own f_wb_err cursor. The mechanism behind fsync()’s error semantics; the incident history is in The Page Cache.
i_private_lock / i_private_list / i_private_dataspinlock, list, void*Explicitly “for use by the owner of the address_space”. Buffer-head filesystems keep the inode’s private buffer list here; other filesystems use it for their own bookkeeping. Generic code never interprets it.

Every field of struct address_space at v6.12, read from include/linux/fs.h. What it shows: the split between the four groups — the identity (host, a_ops), the cache contents (i_pages, nrpages, gfp_mask), the mapping reverse-map (i_mmap*), and the per-owner scratch space. The insight to take: notice how few fields a filesystem is allowed to write. a_ops, gfp_mask, the folio-order bits in flags, and the three i_private_* members are the filesystem’s; everything else belongs to the VFS and the memory manager. A filesystem that touches nrpages or i_pages directly is doing something wrong.

The AS_* flag bits

The flags word carries behaviour and error state, declared as enum mapping_flags in include/linux/pagemap.h, v6.12:

BitNameMeaning
0AS_EIOAn I/O error occurred on an asynchronous write. Read destructively by filemap_check_errors().
1AS_ENOSPCAn ENOSPC occurred on an asynchronous write (distinguished so fsync() can return the right errno).
2AS_MM_ALL_LOCKSTransient: this mapping is currently held under mm_take_all_locks().
3AS_UNEVICTABLEThe mapping’s folios are unevictable — “e.g., ramdisk, SHM_LOCK”. Reclaim skips them; see The Unevictable LRU and mlock.
4AS_EXITINGA final truncate is in progress; the inode is going away.
5AS_NO_WRITEBACK_TAGSThis mapping does not use the writeback-related XArray marks at all. Set on the swap cache.
6AS_RELEASE_ALWAYS“Call ->release_folio(), even if no private data” — for filesystems whose release hook must run unconditionally.
7AS_STABLE_WRITES“Must wait for writeback before modifying folio contents” — required when the device computes checksums or DIF/DIX over the buffer, so the page must not change under an in-flight write. Set from the superblock’s SB_I_STABLE_WRITES at inode init, and on block devices whose queue reports stable writes (bdev_add() calls mapping_set_stable_writes()).
8AS_INACCESSIBLE“Do not attempt direct R/W access to the mapping” — for memory the host must not touch (confidential-computing guest memory).
16–20AS_FOLIO_ORDER_MINMinimum folio order this mapping supports.
21–25AS_FOLIO_ORDER_MAXMaximum folio order this mapping supports.

The AS_* mapping flags at v6.12. What it shows: three distinct kinds of state crammed into one word — sticky writeback errors (bits 0–1), per-mapping behaviour switches (bits 2–8), and the large-folio size range (bits 16–25). The insight to take: AS_STABLE_WRITES is the one that catches filesystem authors out. On a device that requires stable pages, a buffered write to a folio that is already under writeback must block until the writeback completes, or the checksum computed over the buffer will not match the bytes that land on the platter. That behaviour is a property of the mapping, not of the write path, which is why it lives here.

The folio-order fields — how a filesystem asks for large folios

Bits 16–25 of flags are the mapping’s supported folio-order range, and the accessors in pagemap.h are the interface a filesystem uses to opt in to large folios:

/* Indicate the file supports large folios: min order 0, max MAX_PAGECACHE_ORDER */
static inline void mapping_set_large_folios(struct address_space *mapping)
{
	mapping_set_folio_order_range(mapping, 0, MAX_PAGECACHE_ORDER);
}
 
/* Or set an explicit range, clamped to MAX_PAGECACHE_ORDER */
static inline void mapping_set_folio_order_range(struct address_space *mapping,
						 unsigned int min, unsigned int max);

The kernel-doc is explicit about when to call these: “The filesystem should call this function in its inode constructor to indicate which base size (min) and maximum size (max) of folio the VFS can use to cache the contents of the file… Context: This should not be called while the inode is active as it is non-atomic,” and, pointedly, “Do not tune it based on, eg, i_size.” Both helpers compile to nothing without CONFIG_TRANSPARENT_HUGEPAGE, and mapping_max_folio_order()/mapping_min_folio_order() read the bits back out — which is exactly what readahead consults when it decides how large a folio to allocate (see The Modern Shape in The Page Cache).

The minimum order matters for a specific and increasingly common case: a filesystem whose block size exceeds the machine’s page size. Setting a minimum folio order guarantees the page cache never hands the filesystem a folio smaller than one filesystem block, which is what makes large-block-size filesystems workable at all. The maximum is a ceiling for filesystems that cannot handle arbitrarily large contiguous folios.

A short history of the structure, for calibration

It helps to see how much of struct address_space is recent. The definition at v2.6.12 — the very first commit in the git history, from 2005 — was this, fetched from include/linux/fs.h at v2.6.12:

struct address_space {
	struct inode		*host;		/* owner: inode, block_device */
	struct radix_tree_root	page_tree;	/* radix tree of all pages */
	rwlock_t		tree_lock;	/* and rwlock protecting it */
	unsigned int		i_mmap_writable;/* count VM_SHARED mappings */
	struct prio_tree_root	i_mmap;		/* tree of private and shared mappings */
	struct list_head	i_mmap_nonlinear;/*list VM_NONLINEAR mappings */
	spinlock_t		i_mmap_lock;	/* protect tree, count, list */
	unsigned int		truncate_count;	/* Cover race condition with truncate */
	unsigned long		nrpages;	/* number of total pages */
	pgoff_t			writeback_index;/* writeback starts here */
	struct address_space_operations *a_ops;	/* methods */
	unsigned long		flags;		/* error bits/gfp mask */
	struct backing_dev_info *backing_dev_info; /* device readahead, etc */
	spinlock_t		private_lock;	/* for use by the address_space */
	struct list_head	private_list;	/* ditto */
	struct address_space	*assoc_mapping;	/* ditto */
} __attribute__((aligned(sizeof(long))));

Line them up against v6.12 and the differences are the whole modern story. page_tree, a radix tree guarded by an explicit rwlock_t, became i_pages, an XArray with its lock folded inside (kernel 4.20). The prio_tree_root i_mmap and its companion i_mmap_nonlinear list became a single rb_root_cached interval tree. truncate_count, a sequence counter used to detect racing truncates, was replaced by the far stronger invalidate_lock rw-semaphore in 5.15. backing_dev_info moved out to the superblock and the inode. a_ops gained its const. And wb_err — the sequence-stamped writeback error that gives fsync() its once-per-descriptor error reporting — did not exist at all.

If a description you are reading calls the page cache "a radix tree of pages," it is describing a kernel from before 4.20 (2018), and its account of the dirty/writeback tags, of locking, and of the unit of I/O will all be wrong in ways that matter.

At v6.12 the cache is an XArray of folios. A folio is a power-of-two-sized, page-aligned block of memory that the page cache treats as one entry, so a single “page” of cache can be 4 KiB, 64 KiB, or larger — which is why nrpages counts pages while lookups return folios, and why write_begin at this release hands back a struct folio * rather than a struct page *. See Folios and the Folio Conversion.

The address_space_operations Vtable

a_ops points at a const struct address_space_operations. The VFS documentation describes it as how “the VFS can manipulate mapping of a file to page cache in your filesystem” (vfs.rst, v6.12). The authoritative declaration is include/linux/fs.h, v6.12, lines 397–439 — twenty function pointers, reproduced verbatim with the kernel’s own comments:

struct address_space_operations {
	int (*writepage)(struct page *page, struct writeback_control *wbc);
	int (*read_folio)(struct file *, struct folio *);
 
	/* Write back some dirty pages from this mapping. */
	int (*writepages)(struct address_space *, struct writeback_control *);
 
	/* Mark a folio dirty.  Return true if this dirtied it */
	bool (*dirty_folio)(struct address_space *, struct folio *);
 
	void (*readahead)(struct readahead_control *);
 
	int (*write_begin)(struct file *, struct address_space *mapping,
				loff_t pos, unsigned len,
				struct folio **foliop, void **fsdata);
	int (*write_end)(struct file *, struct address_space *mapping,
				loff_t pos, unsigned len, unsigned copied,
				struct folio *folio, void *fsdata);
 
	/* Unfortunately this kludge is needed for FIBMAP. Don't use it */
	sector_t (*bmap)(struct address_space *, sector_t);
	void (*invalidate_folio) (struct folio *, size_t offset, size_t len);
	bool (*release_folio)(struct folio *, gfp_t);
	void (*free_folio)(struct folio *folio);
	ssize_t (*direct_IO)(struct kiocb *, struct iov_iter *iter);
	/*
	 * migrate the contents of a folio to the specified target. If
	 * migrate_mode is MIGRATE_ASYNC, it must not block.
	 */
	int (*migrate_folio)(struct address_space *, struct folio *dst,
			struct folio *src, enum migrate_mode);
	int (*launder_folio)(struct folio *);
	bool (*is_partially_uptodate) (struct folio *, size_t from,
			size_t count);
	void (*is_dirty_writeback) (struct folio *, bool *dirty, bool *wb);
	int (*error_remove_folio)(struct address_space *, struct folio *);
 
	/* swapfile support */
	int (*swap_activate)(struct swap_info_struct *sis, struct file *file,
				sector_t *span);
	void (*swap_deactivate)(struct file *file);
	int (*swap_rw)(struct kiocb *iocb, struct iov_iter *iter);
};

Twenty methods is intimidating until you notice that they are not twenty independent things: they are five small groups, each owning one phase of a cached folio’s life, and no filesystem implements all of them. ext4 implements thirteen; XFS eleven; tmpfs six; read-only squashfs exactly two.

flowchart LR
  subgraph FILL["① Fill — get bytes in"]
    ra["readahead()<br/><i>batch, the normal path</i>"]
    rf["read_folio()<br/><i>one folio, the fallback</i>"]
    ipu["is_partially_uptodate()<br/><i>sub-folio block validity</i>"]
  end
  subgraph WRITE["② Accept a write"]
    wb["write_begin()<br/><i>allocate + pre-read + lock</i>"]
    we["write_end()<br/><i>commit + i_size + unlock</i>"]
  end
  subgraph DIRTY["③ Dirty and persist"]
    df["dirty_folio()"]
    wps["writepages()"]
    wp["writepage()<br/><i>legacy, gone in 6.16</i>"]
    idw["is_dirty_writeback()"]
    lf["launder_folio()"]
  end
  subgraph TEAR["④ Detach and free"]
    inv["invalidate_folio()<br/><i>truncate / hole punch</i>"]
    rel["release_folio()<br/><i>may refuse</i>"]
    fre["free_folio()<br/><i>must not block</i>"]
    mig["migrate_folio()"]
    erf["error_remove_folio()"]
  end
  subgraph BYPASS["⑤ Bypass the cache"]
    dio["direct_IO()"]
    bm["bmap()"]
    sa["swap_activate()"]
    sd["swap_deactivate()"]
    sr["swap_rw()"]
  end
  FILL --> WRITE --> DIRTY --> TEAR
  BYPASS -.->|"never enters<br/>the page cache"| TEAR

The twenty address_space_operations methods grouped by the phase of a folio’s life they serve. What it shows: fill, write-accept, dirty/persist, and teardown form a cycle every cached folio walks; the fifth group deliberately steps around that cycle entirely. The insight to take: the vtable is not a grab-bag — it is a lifecycle. A filesystem that gets the fill group right can already be read from; adding group ② and ③ makes it writable; group ④ is only needed if the filesystem attaches private per-folio data (buffer heads, iomap state) that must be torn down. That is why fs/squashfs/file.c, v6.12, lines 662–665 defines its entire vtable as { .read_folio = squashfs_read_folio, .readahead = squashfs_readahead } — two lines, and the filesystem is fully readable and mappable.

What Each Method Must Guarantee

These are contracts, not suggestions. Generic code calls them at precise moments, holding precise locks, and a filesystem that returns without honouring the postcondition does not produce a wrong answer — it hangs the machine, because some other thread is waiting on the folio lock the method forgot to drop. The table below is the whole vtable condensed from the prose in vfs.rst, v6.12 and locking.rst, v6.12; the sections after it expand the five that carry the real weight.

MethodCalled byMust guarantee on returnDefault if NULL
readaheadread_pages(), mm/readahead.c:159Start I/O on the sync folios; drop one refcount per folio after starting its I/O; completion handler sets uptodate and unlocks. May stop at the async boundary — caller cleans up the rest.Fall back to a read_folio loop (mm/readahead.c:180)
read_foliofilemap_read_folio(), via mm/filemap.c:2451, :2498, :3435, :3783Folio arrives locked; mark it uptodate on success and unlock it either way. Do not change its refcount. May return AOP_TRUNCATED_PAGE after unlocking, meaning “retry the lookup”.Mapping is unreadable — shmem_mapping()-style special cases only
is_partially_uptodatemm/filemap.c:2392Answer whether the requested sub-range is valid, so a read can complete without faulting in the whole folio.Read the entire folio
write_begingeneric_perform_write(), mm/filemap.c:4054Allocate blocks; pre-read any partial block the write only partly overwrites; return the locked target folio in *foliop. Tolerate a short copy. On < 0, write_end is not called.No buffered write path
write_endgeneric_perform_write(), mm/filemap.c:4069Commit copied bytes; update i_size if the file grew; unlock and put the folio. Return bytes accepted (<= copied).
dirty_foliofolio_mark_dirty(), mm/page-writeback.c:2909Set the folio dirty flag and the PAGECACHE_TAG_DIRTY mark in i_pages. Must not block.Mandatoryfolio_mark_dirty() dereferences it unconditionally
writepagesdo_writepages(), mm/page-writeback.c:2683Start I/O on at least *nr_to_write pages, decrementing it per page written; if nr_to_write is NULL, write all dirty folios.Fall back to a writepage loop (writeback_use_writepage())
writepage (legacy)writeback_use_writepage(), :2660; pageout(), mm/vmscan.c:689Run set_page_writeback() then unlock, or redirty and return 0. Failing to do one of the two leaves the folio clean in the flags but DIRTY-marked in the XArray — “all sorts of hard-to-debug problems… like having dirty inodes at umount and losing written data.”Special files write nothing
is_dirty_writebackmm/vmscan.c:950Report the filesystem’s view of dirty/writeback so reclaim knows whether to stall — for state the standard flags do not capture (NFS unstable folios).Use folio_test_dirty / folio_test_writeback
invalidate_foliofolio_invalidate(), mm/truncate.c:141Update or drop private data for the invalidated range. If the range is the whole folio, private data must be released — the folio has to become discardable.Nothing to detach
release_foliofilemap_release_folio(), mm/filemap.c:4218Remove private data and clear the private flag, or return false to refuse. If it returns true the data is already gone.Assume buffer heads; call try_to_free_buffers()
free_foliomm/filemap.c:241, :817; mm/vmscan.c:771Final cleanup once the folio is out of the cache. Must not block, and must not assume the mapping still exists.Nothing
launder_folioinvalidate_inode_pages2_range(), mm/truncate.c:584Write back a still-dirty folio that is about to be dropped, keeping it locked throughout so it cannot be redirtied.Dirty folios block the invalidation
migrate_folioCompaction / memory hot-unplugMove private data and references to the destination folio. Must not block under MIGRATE_ASYNC.Folio is not migratable
error_remove_folioMemory-failure handlingSignals that truncating this folio away is an acceptable response to hardware memory corruption. Usually generic_error_remove_folio.The kernel must kill mappers instead
direct_IOmm/filemap.c:2810 (read), :3977 (write)Transfer between storage and user memory without touching the cache. See Direct IO and O_DIRECT.O_DIRECT opens fail with -EINVAL
bmapFIBMAP ioctl, swapfile setupMap a logical block to a physical block number. The header calls it “this kludge… Don’t use it”; locking.rst adds “keep it that way and don’t breed new callers.”FIBMAP unsupported
swap_activateswapon(2)Validate the file and register its extents (add_swap_extent() or iomap_swapfile_activate()); set SWP_FS_OPS to route I/O through swap_rw, else I/O goes straight to sis->bdev.Cannot swap to this filesystem
swap_deactivateswapoff(2)Undo swap_activate.
swap_rwSwap I/O when SWP_FS_OPS is setRead or write swap pages through the filesystem.I/O bypasses the filesystem

The complete v6.12 vtable with each method’s postcondition and its fallback. What it shows: the call site in generic code (with file and line, so the claim is checkable), the obligation, and — the column most tables omit — what the kernel does when the method is absent. The insight to take: read the last column first. Almost every method has a graceful default, which is why filesystems are small; the two that do not are dirty_folio, which folio_mark_dirty() calls without a NULL check and so is effectively mandatory for any writable mapping, and read_folio, whose absence is a signal used elsewhere — mm/vmscan.c:3264 literally tests !mapping->a_ops->read_folio to decide that a mapping cannot be paged in from a filesystem.

Which Lock Is Held When Each Method Runs

The single most important table for a filesystem author is the locking matrix in locking.rst, v6.12, lines 275–297. It states, per method, whether the folio arrives locked, what state i_rwsem (the inode’s rw-semaphore, which serialises writes against the file) is in, and what state invalidate_lock (the address_space’s own rw-semaphore, from the field table above) is in. Reproduced complete, because the blank cells carry information too — a blank means no such lock is held, and you must not assume one:

MethodFolio locked on entry?i_rwseminvalidate_lock
writepageyes, unlocks
read_folioyes, unlocksshared
writepagesno
dirty_foliomaybe
readaheadyes, unlocksshared
write_beginlocks the folioexclusive
write_endyes, unlocksexclusive
bmapno
invalidate_folioyesexclusive
release_folioyes
free_folioyes
direct_IOno
migrate_folioyes (both src and dst)
launder_folioyes
is_partially_uptodateyes
error_remove_folioyes
swap_activateno
swap_deactivateno
swap_rwyes, unlocks

The v6.12 address_space_operations locking matrix, verbatim from locking.rst. What it shows: the exact synchronisation state each method may assume. The insight to take: three rules fall straight out of the shape of this table. First, the header rule — “All except dirty_folio and free_folio may block” — means those two run in contexts where sleeping is forbidden, so no allocation that can wait, no mutex, no I/O submission that blocks. Second, read_folio and readahead hold invalidate_lock shared while invalidate_folio holds it exclusive: that single pairing is what makes the fill-versus-truncate race impossible, because a read cannot be instantiating a folio for a block that a concurrent truncate is freeing. Third, dirty_folio’s “maybe” is the trap — it is called both from the write path (folio locked) and from the page-fault path where, as locking.rst puts it, “the caller has found the folio while holding the page table lock which will block truncation.” Your dirty_folio must be correct without knowing which of the two it is.

The prose that follows the matrix adds constraints the grid cannot express, and two are worth quoting because they are the ones people get wrong. On release_folio: “The folio is locked and not under writeback. It may be dirty. The gfp parameter is not usually used for allocation, but rather to indicate what the filesystem may do to attempt to free the private data” — that is, gfp here is a permission argument (“may you sleep? may you do I/O?”), not an allocation request. And on the consequence of a buggy writepage: failing to run either redirty_page_for_writepage() or the set_page_writeback()/end_page_writeback() pair “will leave the page itself marked clean but it will be tagged as dirty in the radix tree. This incoherency can lead to all sorts of hard-to-debug problems in the filesystem like having dirty inodes at umount and losing written data.”

The Method Names Changed — Reading Older Material Safely

Almost everything written about address_space_operations before 2022 uses names that no longer exist. This is not cosmetic renaming: the folio conversion changed the type each method receives, from struct page * (always exactly one hardware page) to struct folio * (a power-of-two run of pages treated as one cache entry), and with it the arithmetic inside every implementation.

The table below was established by fetching include/linux/fs.h at successive release tags and recording the first tag at which each name appears — the same existence-check technique used for the block-device section above, not from changelogs.

Old name (struct page)Current name (struct folio)First release with the new name
readpagesreadahead5.8 added readahead; readpages deleted in 5.18
set_page_dirtydirty_folio5.18
invalidatepageinvalidate_folio5.18
launder_pagelaunder_folio5.18
readpageread_folio5.19
releasepagerelease_folio5.19
freepagefree_folio5.19
migratepagemigrate_folio6.0
error_remove_pageerror_remove_folio6.8
write_begin(… struct page **pagep …)write_begin(… struct folio **foliop …)6.12
write_end(… struct page *page …)write_end(… struct folio *folio …)6.12
timeline
    title address_space_operations, 5.8 → 7.x (verified by fetching fs.h at each tag)
    5.8  : readahead() added alongside readpages()
    5.18 : readpages removed : set_page_dirty→dirty_folio : invalidatepage→invalidate_folio : launder_page→launder_folio
    5.19 : readpage→read_folio : releasepage→release_folio : freepage→free_folio
    6.0  : migratepage→migrate_folio
    6.8  : error_remove_page→error_remove_folio
    6.12 : write_begin/write_end take struct folio (LTS — this note's pin)
    6.16 : writepage REMOVED from the vtable entirely
    6.17 : write_begin/write_end take const struct kiocb * instead of struct file *

The vtable’s rename and removal timeline. What it shows: a four-year conversion in which every page-typed method became folio-typed, one or two per release, finishing with the removal of ->writepage itself. The insight to take: v6.12 is the release where the write half finally crossed over — at 6.11 write_begin still handed back a struct page **, at 6.12 a struct folio **. That makes 6.12 an unusually good version to learn from: it is the first release where every surviving method speaks folios, while the legacy ->writepage is still present so you can see what it was for.

Two changes after this note’s 6.12 pin are worth knowing, because they are the ones that will date this material:

  • ->writepage was removed from address_space_operations in 6.16. It is present in include/linux/fs.h at v6.15 and absent at v6.16 (verified by fetching both). At 6.12 it survives as a legacy path with exactly two callers — writeback_use_writepage() in mm/page-writeback.c for filesystems that never implemented writepages, and pageout() in mm/vmscan.c:689 for reclaim-driven writeback — and among the vtables read for this note the only remaining implementers are shmem_aops (mm/shmem.c:4808) and swap_aops (mm/swap_state.c:34) — neither of which is a disk filesystem. Writing a ->writepage today is writing for a vtable slot that no longer exists.
  • write_begin and write_end take const struct kiocb * instead of struct file * as of 6.17 (verified: struct file * at v6.16, const struct kiocb * at v6.17, and still so in mainline v7.2 checked 2026-09-04). The struct file was only ever authentication context for network filesystems; the kiocb carries the same file plus the I/O flags, which is what a filesystem needs to honour IOCB_NOWAIT.

Where the Documentation Lags the Code

This matters practically, because vfs.rst is where filesystem authors go first and at v6.12 it is not fully in step with fs.h. Three concrete discrepancies, all found by diffing the documentation against the header at the same tag:

  1. vfs.rst’s code block is a release behind its own prose. The struct listing shows int (*write_begin)(…, struct page **pagep, void **fsdata) while the prose two pages later correctly says “The filesystem must return the locked pagecache folio for the specified offset, in *foliop”. fs.h at the same tag has struct folio **foliop. Trust the header.
  2. vfs.rst names a type that does not exist. Its prototypes for migrate_folio and error_remove_folio take struct mapping *; there is no such type in Linux. The real parameter is struct address_space *.
  3. locking.rst states a contract in terms of a field that has never existed. It says “writepages should only write pages which are present on mapping->io_pages.” There is no io_pages member of struct address_space at v6.12 — and there never has been: the field is absent from include/linux/fs.h at v2.6.12 (the first commit in the git history), v2.6.32, v2.6.39, v3.16, v4.19, v5.4, v5.10, v5.12, v5.14, and every tag from v5.15 through v6.12, all checked directly. The similarly-named field that does exist is bdi->io_pages on struct backing_dev_info — an unrelated readahead cap, read at mm/readahead.c:342 and :533. The sentence appears to be a fossil of a structure that predates git. The same file also still describes the DIRTY mark as living “in the radix tree,” which has been an XArray since 4.20.

The lesson is the one the vault’s research contract states generally and this file demonstrates specifically: for a struct definition or a method signature, read the header at the tag you care about; use the documentation for the prose contract, which is maintained more carefully than its code blocks.

Mechanical Walk-through — How a Buffered read() Reaches the Vtable

The point of this note is the dispatch: the journey from the syscall to the a_ops call. What follows traces only as far as the vtable boundary — the readahead window-sizing heuristic belongs to Readahead and Read Path, and the XArray lookup itself to Address Space and the Page Cache XArray.

A read() on a page-cache-backed file lands in the filesystem’s file_operations->read_iter, which for most filesystems is generic_file_read_iter() in mm/filemap.c, v6.12. For a buffered (non-O_DIRECT) read that calls filemap_read(iocb, iter, 0), which begins by reaching the file’s cache the way the binding section prescribed — struct address_space *mapping = filp->f_mapping; and struct inode *inode = mapping->host; — and then loops until the user’s buffer is full.

sequenceDiagram
    autonumber
    participant U as User process
    participant VFS as generic_file_read_iter<br/>filemap_read()
    participant PC as Page cache<br/>mapping i_pages XArray
    participant RA as read_pages()<br/>mm-readahead.c line 146
    participant FS as a_ops<br/>(ext4 / XFS / …)
    participant D as Block layer

    U->>VFS: read(fd, buf, n)
    VFS->>PC: filemap_get_pages() then filemap_get_read_batch()<br/>lockless XArray lookup
    alt Folios already cached and uptodate (cache hit)
        PC-->>VFS: folio batch
    else Cache miss
        VFS->>RA: page_cache_sync_readahead()
        RA->>PC: allocate + insert locked folios
        alt batch method present
            RA->>FS: aops readahead(rac)
            Note over FS: start I/O on the sync folios,<br/>folio_put() each after submitting
        else only single-folio method
            loop each folio
                RA->>FS: aops read_folio(file, folio)
            end
        end
        FS->>D: submit_bio()
        D-->>FS: I/O completion
        Note over FS: set folio uptodate,<br/>folio_unlock()
        opt readahead declined some folios
            VFS->>PC: filemap_create_folio() then filemap_read_folio()
            VFS->>FS: aops read_folio(file, folio) at filemap.c line 2498
            Note over VFS: synchronous — waits for uptodate
        end
    end
    VFS->>VFS: filemap_range_uptodate()?<br/>maybe a_ops is_partially_uptodate()
    VFS->>U: copy_folio_to_iter() — the copy is done by<br/>GENERIC code, never by the filesystem

A buffered read() descending through the address_space_operations vtable, v6.12. What it shows: the lookup, the allocation, the waiting and the user-space copy are all generic; the filesystem is entered at exactly two points, ->readahead and ->read_folio. The insight to take: the two are not alternatives chosen by the caller — they are a primary and a fallback. vfs.rst states it flatly: “In normal operation, folios are read through the ->readahead() method. Only if this fails, or if the caller needs to wait for the read to complete will the page cache call ->read_folio().” That is why read_pages() at mm/readahead.c:159 tests if (aops->readahead) first and only loops on aops->read_folio when the batch method is absent, and why a filesystem that implements only read_folio still works — it is just slower, one folio and one round trip at a time.

Three details in that flow repay attention, because each is a contract the filesystem must not break.

The refcount asymmetry between the two read methods. ->read_folio receives a folio the cache already holds a reference on, and “does not need to modify the refcount on the folio.” ->readahead is the opposite: it “should decrement the page refcount after starting I/O on each page.” The reason is visible in read_pages() — after aops->readahead(rac) returns, generic code walks whatever folios the filesystem declined with readahead_folio(), and that helper hands over the reference. A filesystem that forgets the folio_put() after submitting leaks a reference per folio and the file’s cache becomes unfreeable.

AOP_TRUNCATED_PAGE is a retry protocol, not an error. If ->read_folio cannot service the read right now — a network filesystem needing to re-establish credentials, say — it unlocks the folio and returns AOP_TRUNCATED_PAGE. The caller must then look the folio up again, lock it again, and call again. filemap_update_page() (mm/filemap.c around line 2440) generates the same value itself when it finds !folio->mapping, meaning the folio was truncated out from under the read. Callers that would rather not implement the retry loop use read_mapping_folio(), which, per vfs.rst, “will take care of locking, waiting for the read to complete and handle cases such as AOP_TRUNCATED_PAGE.”

invalidate_lock is taken by the caller, not the method. filemap_create_folio() takes filemap_invalidate_lock_shared(mapping) before inserting the folio and holds it across the ->read_folio call. The comment in the source explains exactly why, and it is the clearest statement of the lock’s purpose in the tree: “Grabbing invalidate_lock here assures we cannot instantiate and bring uptodate new pagecache folios after evicting page cache during truncate and before actually freeing blocks.”

Mechanical Walk-through — How a Buffered write() Reaches the Vtable

A write() lands in generic_file_write_iter()__generic_file_write_iter()generic_perform_write(), all in mm/filemap.c at v6.12. The core loop, one chunk of user data at a time, is the cleanest possible illustration of the write contract:

do {
	...
	balance_dirty_pages_ratelimited(mapping);       /* (1) throttle */
	...
	status = a_ops->write_begin(file, mapping, pos, bytes,
					&folio, &fsdata);       /* (2) mm/filemap.c:4054 */
	if (unlikely(status < 0))
		break;
 
	offset = offset_in_folio(folio, pos);
	if (bytes > folio_size(folio) - offset)
		bytes = folio_size(folio) - offset;     /* (3) clamp to THIS folio */
 
	copied = copy_folio_from_iter_atomic(folio, offset, bytes, i);  /* (4) */
	flush_dcache_folio(folio);
 
	status = a_ops->write_end(file, mapping, pos, bytes, copied,
					folio, fsdata);         /* (5) mm/filemap.c:4069 */
	...
	pos += status;
	written += status;
} while (iov_iter_count(i));

Line by line. (1) balance_dirty_pages_ratelimited() is the entry into dirty throttling — a writer producing dirty folios faster than the disk drains them is paused right here, before any filesystem code runs. The policy (the 20%/10% vm.dirty_ratio thresholds, the per-BDI bandwidth estimator) lives in Dirty Pages and Writeback and its symptoms in The Page Cache; what belongs here is only that the throttle sits outside the vtable, so a filesystem cannot opt out of it. (2) write_begin is where the filesystem allocates on-disk blocks and pre-reads any block the write only partially covers — skip that pre-read and the untouched bytes of a shared block become garbage. (3) The generic code clamps the chunk to the folio it actually got back, which is why write_begin is free to return a larger folio than asked for. (4) Generic code performs the copy into the folio the filesystem handed over; the filesystem never touches user memory. (5) write_end commits, marks dirty, extends i_size, unlocks, and returns how many bytes were accepted.

sequenceDiagram
    autonumber
    participant U as User process
    participant VFS as generic_perform_write()<br/>mm/filemap.c
    participant BDP as balance_dirty_pages_<br/>ratelimited()
    participant FS as a_ops (ext4)
    participant PC as Page cache + XArray
    participant WB as Writeback thread<br/>(later, asynchronous)

    U->>VFS: write(fd, buf, n)
    VFS->>BDP: throttle check
    BDP-->>VFS: proceed (or sleep here if over dirty limit)
    VFS->>FS: a_ops write_begin(file, mapping, pos, len, foliop, fsdata)
    FS->>PC: __filemap_get_folio(FGP_WRITEBEGIN) - allocate or find, then LOCK
    FS->>FS: allocate blocks; pre-read partial blocks
    FS-->>VFS: locked folio in *foliop (+ optional fsdata cookie)
    VFS->>PC: copy_folio_from_iter_atomic()  ← generic code copies
    VFS->>FS: a_ops write_end(..., copied, folio, fsdata)
    FS->>PC: folio_mark_dirty() then a_ops dirty_folio()
    Note over PC: sets folio dirty flag AND<br/>PAGECACHE_TAG_DIRTY in i_pages
    FS->>FS: i_size_write() if the file grew
    FS-->>VFS: bytes accepted; folio unlocked and put
    VFS-->>U: write() RETURNS — data is in RAM, not on disk
    rect rgb(240,230,215)
    Note over WB,PC: minutes later, or at fsync()
    WB->>FS: do_writepages() then a_ops writepages(mapping, wbc)
    FS->>PC: find PAGECACHE_TAG_DIRTY folios, submit bios
    end

A buffered write() through the vtable, and the deferred writeback that eventually persists it. What it shows: the write path enters the filesystem twice on the synchronous side (write_begin, write_end) and once more, much later and on a different thread, through writepages. The insight to take: the shaded box is a separate transaction from the syscall. write() returning means the bytes reached a dirty folio, nothing more — the ->writepages call that actually reaches storage may be minutes away, may be on a flusher thread the application never sees, and may fail with an error the application can only learn about through fsync() and the wb_err/f_wb_err cursor pair. This is the mechanism behind fsync fdatasync and Durability.

Where writeback re-enters the vtable

The other end of the write path is do_writepages() in mm/page-writeback.c, v6.12, line 2672, and it is short enough to read in full:

	while (1) {
		if (mapping->a_ops->writepages) {
			ret = mapping->a_ops->writepages(mapping, wbc);
		} else if (mapping->a_ops->writepage) {
			ret = writeback_use_writepage(mapping, wbc);
		} else {
			/* deal with chardevs and other special files */
			ret = 0;
		}
		if (ret != -ENOMEM || wbc->sync_mode != WB_SYNC_ALL)
			break;
		...
		reclaim_throttle(NODE_DATA(numa_node_id()), VMSCAN_THROTTLE_WRITEBACK);
	}

Three branches, and each says something. The first is the modern path. The second is the v6.12 legacy shim: a filesystem with no ->writepages gets writeback_use_writepage(), which iterates DIRTY-marked folios with writeback_iter() and calls ->writepage(&folio->page, wbc) on each — note the &folio->page, the folio being coerced back to its first page because the legacy signature predates folios. That branch is what disappeared in 6.16. The third branch, returning success without writing anything, is how character devices and other cacheless mappings survive being handed to sync().

The retry loop around all three is worth noticing: on -ENOMEM during an integrity writeback (WB_SYNC_ALL, i.e. someone called fsync()), do_writepages does not give up. It throttles on writeback and tries again, because failing an fsync() for want of memory would mean silently losing data the application believes is safe.

The writeback_control structure the filesystem receives is the whole of the policy input, and the two modes have genuinely different obligations, per locking.rst: under WB_SYNC_ALL “the writeback_control will specify a range of pages that must be written out”; under WB_SYNC_NONE “a nr_to_write is given and that many pages should be written if possible,” and *nr_to_write “must be decremented for each page which is written.”

WB_SYNC_NONEWB_SYNC_ALL
Triggered byPeriodic flusher, dirty-threshold pressure, reclaimfsync, fdatasync, sync, syncfs, sync_file_range
ObligationBest effort: write about nr_to_write pages, then returnIntegrity: every dirty folio in the range must be written
May skip a busy folio?Yes — skip it and move onNo — must wait for it
-ENOMEM handlingGive up, return the errorThrottle and retry (the loop above)
Uses writeback_index?Yes, when range_cyclic is set — resumes where the last pass stoppedNo — the range is explicit

The two writeback modes as seen from inside ->writepages. What it shows: the same method serving two callers with opposite priorities. The insight to take: ->writepages must read wbc->sync_mode before deciding anything. A filesystem that treats every call as best-effort will silently break fsync() durability; one that treats every call as integrity will stall the periodic flusher on the first folio that is already under I/O.

The Folio’s Life Through the Vtable

Putting the read path, the write path and the teardown methods on one state machine shows what the twenty methods are collectively for: they are the transitions of a single object between five states. The states are properties of the folio (its flags and its XArray marks); the labels on the arrows are the a_ops methods that cause each transition.

stateDiagram-v2
    [*] --> Absent
    Absent --> Locked_NotUptodate : filemap_alloc_folio()<br/>+ filemap_add_folio()
    Locked_NotUptodate --> Clean : a_ops→readahead()<br/>or a_ops→read_folio()<br/>(sets uptodate, unlocks)
    Locked_NotUptodate --> Absent : read error —<br/>folio removed
    Absent --> Locked_Prepared : a_ops→write_begin()<br/>(allocates blocks, pre-reads,<br/>returns LOCKED)
    Clean --> Locked_Prepared : a_ops→write_begin()<br/>on an already-cached folio
    Locked_Prepared --> Dirty : copy_folio_from_iter_atomic()<br/>then a_ops→write_end()<br/>→ a_ops→dirty_folio()
    Clean --> Dirty : mmap store + page fault<br/>→ a_ops→dirty_folio()
    Dirty --> Writeback : a_ops→writepages()<br/>clears DIRTY mark,<br/>sets WRITEBACK mark
    Writeback --> Clean : I/O completion —<br/>folio_end_writeback()
    Writeback --> Dirty : redirtied while<br/>under writeback
    Clean --> Absent : reclaim: a_ops→release_folio()<br/>then a_ops→free_folio()
    Dirty --> Absent : truncate: a_ops→invalidate_folio()<br/>(or a_ops→launder_folio() first)
    Clean --> Clean : a_ops→migrate_folio()<br/>(compaction moves it,<br/>state preserved)
    Absent --> [*]

The life of one page-cache folio, with the address_space_operations method that drives each transition, v6.12. What it shows: every arrow into or out of the cache is a vtable call; the states themselves are just flag and XArray-mark combinations. The insight to take: notice that Dirty → Absent is the only edge that can lose data, and it is guarded — truncate is supposed to discard dirty data, but invalidate_inode_pages2_range() (mm/truncate.c) will first call ->launder_folio to write a dirty folio out rather than drop it, which is exactly what a network filesystem needs when it invalidates a cache it believes is stale but that also holds unsent writes. Notice also that Clean → Absent is the only place release_folio may refuse: it returns false and the folio stays, which is how a filesystem pins a folio whose private data it cannot currently detach.

Two states in that machine are worth naming precisely, because the terminology collides with everyday English. Uptodate means “the folio’s contents match what is on disk or are newer than it” — that is, the folio may be read from. A folio that has just been allocated is not uptodate, which is why it must stay locked until ->read_folio fills it. Dirty means “newer than the backing store, must be written.” Uptodate and dirty are independent bits; a freshly written folio is both, a freshly read one is uptodate and clean, and a just-allocated one is neither. The WRITEBACK mark is a third, orthogonal state — folios under writeback are not locked (since 2.5.12, as locking.rst notes), so a reader may read one while its bytes are travelling to the disk.

How a Filesystem Plugs In

Installing a vtable is one assignment, made in the filesystem’s inode constructor after it has decided what kind of inode it is looking at. ext4 does this in ext4_set_aops(), fs/ext4/inode.c, v6.12, lines 3621–3639, and the function is a compact lesson in why the vtable is a per-inode pointer rather than a per-filesystem constant:

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

Read it as a decision tree. data=journal mode — where file data, not just metadata, goes through the journal — gets ext4_journalled_aops, whose write_end and dirty_folio and invalidate_folio are all different because every data page must be attached to a running transaction. Direct Access (DAX) inodes, backed by persistent memory addressable by the CPU, get ext4_dax_aops — four methods, no read or write path at all, because DAX bypasses the page cache entirely and the only thing left for the vtable to do is flush CPU cache lines on fsync. Delayed allocation (the default) gets ext4_da_aops, differing from the plain set only in write_begin/write_end, which reserve space without choosing blocks. Everything else gets ext4_aops. One filesystem, four vtables, chosen per inode at inode-read time — and note the assignment goes through inode->i_mapping, not &inode->i_data, exactly as the binding section argued it should.

The four sets side by side, all read at v6.12, make the shape of a real a_ops concrete:

Methodext4_aopsext4_da_aopsext4_journalled_aopsext4_dax_aops
read_folioext4_read_folioext4_read_folioext4_read_folio
readaheadext4_readaheadext4_readaheadext4_readahead
writepagesext4_writepagesext4_writepagesext4_writepagesext4_dax_writepages
write_beginext4_write_beginext4_da_write_beginext4_write_begin
write_endext4_write_endext4_da_write_endext4_journalled_write_end
dirty_folioext4_dirty_folioext4_dirty_folioext4_journalled_dirty_folionoop_dirty_folio
invalidate_folioext4_invalidate_folioext4_invalidate_folioext4_journalled_invalidate_folio
release_folioext4_release_folioext4_release_folioext4_release_folio
migrate_foliobuffer_migrate_foliobuffer_migrate_foliobuffer_migrate_folio_norefs
is_partially_uptodateblock_is_partially_uptodate← same← same
bmap, error_remove_folio, swap_activatepresentpresentpresentbmap, swap_activate only
writepage, direct_IOabsentabsentabsentabsent

ext4’s four address_space_operations at v6.12. What it shows: how much of a real vtable is shared generic helpers (buffer_migrate_folio, block_is_partially_uptodate, generic_error_remove_folio, noop_dirty_folio) rather than filesystem-specific code, and how the journal mode changes only the four methods that touch transactions. The insight to take: the last row is the surprise. ext4 defines no ->writepage and no ->direct_IO at 6.12. The first because ext4 has had ->writepages for years and the legacy slot is dead weight; the second because ext4’s O_DIRECT goes through iomap_dio_rw() from its file_operations->read_iter/write_iter, never through the address_space vtable. If you go looking for O_DIRECT in a modern filesystem’s a_ops you will not find it — see the next section.

The minimum viable vtable

At the other extreme, fs/squashfs/file.c, v6.12, lines 662–665 is an entire filesystem’s page-cache interface:

const struct address_space_operations squashfs_aops = {
	.read_folio = squashfs_read_folio,
	.readahead = squashfs_readahead
};

Two methods, and squashfs files are readable, mmap-able, and executable. There is no dirty_folio because nothing can dirty a read-only filesystem’s folios; no invalidate_folio or release_folio because squashfs attaches no private data to its folios, so the generic teardown (which assumes buffer heads and calls try_to_free_buffers(), harmlessly finding none) is correct; no writepages because do_writepages’s third branch returns 0.

A writable minimum is ext2, at fs/ext2/inode.c, v6.12, lines 960–972 — and what is striking is how much of it is generic:

const struct address_space_operations ext2_aops = {
	.dirty_folio		= block_dirty_folio,      /* generic, fs/buffer.c   */
	.invalidate_folio	= block_invalidate_folio, /* generic, fs/buffer.c   */
	.read_folio		= ext2_read_folio,        /* → mpage_read_folio()   */
	.readahead		= ext2_readahead,         /* → mpage_readahead()    */
	.write_begin		= ext2_write_begin,       /* → block_write_begin()  */
	.write_end		= ext2_write_end,         /* → generic_write_end()  */
	.bmap			= ext2_bmap,
	.writepages		= ext2_writepages,        /* → mpage_writepages()   */
	.migrate_folio		= buffer_migrate_folio,   /* generic               */
	.is_partially_uptodate	= block_is_partially_uptodate,
	.error_remove_folio	= generic_error_remove_folio,
};

Six of the eleven entries are library functions the filesystem did not write. The five ext2_* wrappers are mostly one line each, forwarding to an mpage_* or block_* helper with the filesystem’s own get_block callback attached. This is the actual porting effort for a simple block-based filesystem: supply a function that maps a file block number to a device block number, and let fs/buffer.c and fs/mpage.c build the vtable around it.

The exception that matters: iomap filesystems bypass half the vtable

XFS’s vtable, fs/xfs/xfs_aops.c, v6.12, lines 541–553, has a hole in it:

const struct address_space_operations xfs_address_space_operations = {
	.read_folio		= xfs_vm_read_folio,
	.readahead		= xfs_vm_readahead,
	.writepages		= xfs_vm_writepages,
	.dirty_folio		= iomap_dirty_folio,
	.release_folio		= iomap_release_folio,
	.invalidate_folio	= iomap_invalidate_folio,
	.bmap			= xfs_vm_bmap,
	.migrate_folio		= filemap_migrate_folio,
	.is_partially_uptodate  = iomap_is_partially_uptodate,
	.error_remove_folio	= generic_error_remove_folio,
	.swap_activate		= xfs_iomap_swapfile_activate,
};

There is no write_begin and no write_end. XFS is not a read-only filesystem; its buffered writes simply do not go through the a_ops vtable at all. xfs_file_buffered_write() calls iomap_file_buffered_write(), whose inner loop iomap_write_iter() at fs/iomap/buffered-io.c, v6.12, line 910 is a near-twin of generic_perform_write() — same balance_dirty_pages_ratelimited_flags(), same copy_folio_from_iter_atomic() — except that where the generic loop calls a_ops->write_begin, iomap calls its own static, non-virtual iomap_write_begin() at line 779 and iomap_write_end() at line 888.

The filesystem-specific behaviour has moved to a second, much smaller vtable (the framework itself is The iomap Library), struct iomap_ops in include/linux/iomap.h, v6.12, lines 182–200:

struct iomap_ops {
	/* Return the existing mapping at pos, or reserve space starting at
	 * pos for up to length, as long as we can do it as a single mapping. */
	int (*iomap_begin)(struct inode *inode, loff_t pos, loff_t length,
			unsigned flags, struct iomap *iomap, struct iomap *srcmap);
	/* Commit and/or unreserve space previously allocated using iomap_begin. */
	int (*iomap_end)(struct inode *inode, loff_t pos, loff_t length,
			ssize_t written, unsigned flags, struct iomap *iomap);
};
flowchart TB
  W["write() → vfs_write()<br/>→ file_operations->write_iter"]
  W --> Q{"Which write_iter<br/>did the filesystem install?"}
  Q -->|"ext4_buffered_write_iter<br/>ext2 via generic_file_write_iter<br/>→ generic_perform_write()"| G["generic_perform_write()<br/>mm/filemap.c:4054"]
  Q -->|"xfs_file_buffered_write<br/>→ iomap_file_buffered_write()"| I["iomap_write_iter()<br/>fs/iomap/buffered-io.c:910"]
  Q -->|"btrfs_buffered_write()<br/>fs/btrfs/file.c:1189"| Bt["btrfs's own loop<br/>(no vtable hop at all)"]
  G --> GA["a_ops->write_begin()<br/>a_ops->write_end()"]
  GA --> GF["per-block callback<br/>(get_block + buffer heads)"]
  I --> IA["STATIC iomap_write_begin()<br/>STATIC iomap_write_end()"]
  IA --> IO["iomap_ops->iomap_begin()<br/>iomap_ops->iomap_end()<br/>(extent-at-a-time)"]
  GF --> PC["the same page cache,<br/>the same folios, the same<br/>a_ops->writepages for flushing"]
  IO --> PC
  Bt --> PC

The three buffered-write architectures in Linux 6.12 and where each dispatches to the filesystem. What it shows: a_ops->write_begin/write_end is one write architecture of three, not the only one. ext4 takes it (ext4_buffered_write_iter() calls generic_perform_write() at fs/ext4/file.c:299); XFS routes through iomap and struct iomap_ops; btrfs writes its own loop entirely (btrfs_aops at fs/btrfs/inode.c:10171 has no write_begin either). The insight to take: all three converge on the same page cache and the same a_ops->writepages for flushing, so the read and writeback halves of the vtable stay universal — only the write-accept half forks. The reason for the fork is granularity: write_begin is called once per folio and must answer “where does this folio’s data live?” folio by folio, whereas iomap_begin is called once per extent and can describe megabytes of contiguous file in one call, which is what an extent-based filesystem wanted all along. So when you read that “every filesystem implements write_begin”, check the a_ops: at v6.12, two of the three major Linux filesystems do not.

address_spaces That Are Not Files

The structure is named for what it is, not for what it usually holds: the kernel-doc calls it “Contents of a cacheable, mappable object.” Three important address_spaces in a running kernel are not files on a filesystem, and each bends a different assumption.

flowchart TB
  subgraph REG["Regular file — ext4, XFS"]
    r1["inode->i_mapping = &inode->i_data"]
    r2["host = the inode"]
    r3["a_ops = ext4_aops (13 methods)"]
    r4["backed by disk blocks"]
  end
  subgraph TMP["tmpfs / shmem"]
    t1["inode->i_mapping = &inode->i_data"]
    t2["host = the shmem inode"]
    t3["a_ops = shmem_aops (6 methods)<br/>no read_folio, no writepages"]
    t4["backed by RAM; writepage pushes<br/>to SWAP, not to a filesystem"]
  end
  subgraph BDEV["Block device — /dev/sda"]
    b1["file->f_mapping = bdev->bd_mapping"]
    b2["host = a bdev PSEUDO-inode<br/>(blockdev_superblock)"]
    b3["a_ops = def_blk_aops"]
    b4["one cache per device,<br/>shared by every /dev node"]
  end
  subgraph SWP["Swap cache — swapper_space"]
    s1["reached by swap_address_space(entry),<br/>never from an inode"]
    s2["host = NULL"]
    s3["a_ops = swap_aops (3 methods)"]
    s4["AS_NO_WRITEBACK_TAGS;<br/>one address_space per 64 MiB of swap"]
  end

Four kinds of address_space at v6.12 and how each differs from the textbook case. What it shows: the same structure serving a disk file, RAM-backed storage, a raw device, and the swap cache — with the binding, the host back-pointer, the vtable size, and the backing store all varying. The insight to take: the invariants you are tempted to rely on are weaker than they look. host is not always an inode (it is NULL for the swap cache). a_ops need not include a read method (shmem has none — its folios are created, never read from a backing store). And “one address_space per inode” fails at both ends: a block device has one per device shared across inodes, and swap has one per 64 MiB of swap space, which is neither per-inode nor per-file.

tmpfs. shmem_aops, at mm/shmem.c, v6.12, lines 4807–4818, is six methods: writepage, noop_dirty_folio, write_begin/write_end (only under CONFIG_TMPFS), migrate_folio, and error_remove_folio. The two absences are the interesting part. There is no read_folio — a tmpfs folio has no backing store to read from; a read of a hole simply gets zeroes and a read of written data finds the folio already there. And there is no writepages — writeback for tmpfs means swapping, and that goes through the legacy ->writepage (shmem_writepage) driven from reclaim’s pageout(), not through the periodic flusher. noop_dirty_folio completes the picture: a tmpfs folio is dirty in the sense that it differs from nothing, so there is no PAGECACHE_TAG_DIRTY bookkeeping to do. The kernel identifies these mappings by pointer comparison — bool shmem_mapping(struct address_space *mapping) { return mapping->a_ops == &shmem_aops; } at mm/shmem.c:273 — which is a small but telling idiom: the vtable pointer doubles as a type tag.

The swap cache. This is the furthest the structure is stretched, and the source says so in a comment at mm/swap_state.c, v6.12, lines 30–31: “swapper_space is a fiction, retained to simplify the path through vmscan’s shrink_folio_list.” Anonymous memory being swapped out has no inode and no file, but reclaim’s inner loop is written in terms of “folio, its mapping, its a_ops” — so swap manufactures an address_space so that loop does not need a special case.

The construction is worth reading, because it is not one address_space but an array of them:

int init_swap_address_space(unsigned int type, unsigned long nr_pages)
{
	nr = DIV_ROUND_UP(nr_pages, SWAP_ADDRESS_SPACE_PAGES);
	spaces = kvcalloc(nr, sizeof(struct address_space), GFP_KERNEL);
	...
	for (i = 0; i < nr; i++) {
		space = spaces + i;
		xa_init_flags(&space->i_pages, XA_FLAGS_LOCK_IRQ);
		atomic_set(&space->i_mmap_writable, 0);
		space->a_ops = &swap_aops;
		/* swap cache doesn't use writeback related tags */
		mapping_set_no_writeback_tags(space);
	}
	nr_swapper_spaces[type] = nr;
	swapper_spaces[type] = spaces;
	return 0;
}

SWAP_ADDRESS_SPACE_SHIFT is 14 (mm/swap.h, v6.12, line 26, commented “One swap address space for each 64M swap space”), so each address_space covers 2^14 = 16,384 pages = 64 MiB, and a 4 GiB swap partition gets sixty-four of them. The reason is contention: one XArray for all of swap would be a global lock, so the swap cache is sharded by address_space, indexed by the arithmetic in the swap_address_space(entry) macro — &swapper_spaces[swp_type(entry)][swp_offset(entry) >> SWAP_ADDRESS_SPACE_SHIFT].

Three properties fall out of that initialiser, and each contradicts something the field-by-field table would lead you to expect:

  • host is never set. kvcalloc zeroes the memory and the loop assigns only i_pages, i_mmap_writable, a_ops and the flags. So mapping->host == NULL for every swap address_space, despite the kernel-doc calling host the “Owner, either the inode or the block_device.” Any generic code that dereferences mapping->host unconditionally would fault on the swap cache; the swap paths avoid it.
  • AS_NO_WRITEBACK_TAGS is set via mapping_set_no_writeback_tags() — the swap cache does not maintain the DIRTY and WRITEBACK XArray marks at all, because nothing ever walks it looking for dirty entries the way ->writepages walks a file’s.
  • i_pages is initialised with XA_FLAGS_LOCK_IRQ, not the plain lock a file mapping uses, because swap cache entries are manipulated from interrupt context on I/O completion.

The one place the fiction shows through is folio membership. The comment at mm/swap_state.c:87 states it: “add_to_swap_cache resembles filemap_add_folio on swapper_space, but sets SwapCache flag and private instead of mapping and index.” A file folio points back at its address_space through folio->mapping and carries its offset in folio->index; a swap-cache folio does neither. So a swap folio is in an address_space that it does not point back to — which is why folio_mapping() in mm/util.c, v6.12, lines 852–853 has to special-case it before reading folio->mapping at all:

	if (unlikely(folio_test_swapcache(folio)))
		return swap_address_space(folio->swap);
 
	mapping = folio->mapping;

That is: for a swap folio the mapping is computed from the swap entry, not stored. The kernel-doc above the function spells out the consequence — “Folios in the swap cache return the swap mapping this page is stored in (which is different from the mapping for the swap file or swap device where the data is stored).”

The add_to_swap_cache comment is itself slightly behind the code, and it is worth catching because it would send you looking in the wrong field. The function body at v6.12 does folio_set_swapcache(folio); folio->swap = entry; — the swap entry lives in a dedicated folio->swap member, not in folio->private as the comment implies. This is the same class of drift as the mapping->io_pages sentence in locking.rst: in-tree comments age too, so confirm against the statement, not the sentence above it.

Block devices. Covered in the binding section above: the cache belongs to a pseudo-inode allocated from blockdev_superblock, bdev->bd_mapping points at that pseudo-inode’s i_data, and every open() of a /dev node sets f_mapping to it. The vtable is def_blk_aops in block/fops.c, v6.12 — and there are two of them, selected at compile time: a buffer-head version at lines 473–483 (block_dirty_folio, blkdev_write_begin/write_end) and an iomap version at lines 521–531 (iomap_release_folio, filemap_migrate_folio, and no write_begin at all), chosen by #ifdef CONFIG_BUFFER_HEAD. A kernel built without buffer heads still has a working block-device page cache; it just reaches the device through iomap instead.

Failure Modes and Common Misunderstandings

These are the mistakes that this specific seam produces — reading errors and implementation errors, not memory-pressure symptoms (for those, see The Page Cache).

i_mapping points somewhere other than i_data for device files”

This was true through Linux 5.10 and is not true at 6.12, and it is the single most commonly repeated stale fact about struct inode. The redirection moved from inode->i_mapping to file->f_mapping in 5.11, verified above by fetching fs/block_dev.c at v5.10, v5.11 and v5.12. Code written on the old assumption — “to find the cache, follow i_mapping, it might have been redirected” — still works, but code written on the inverse assumption (“i_mapping is always &i_data, so I can use &inode->i_data directly”) is a latent bug the moment any redirection is reintroduced. The rule that is correct in every kernel: f_mapping if you have a file, i_mapping if you do not, never &i_data.

Implementing ->read_folio and forgetting to unlock on the error path

vfs.rst is emphatic — the filesystem “should unlock the folio once the read has completed, whether it was successful or not.” The failure mode is not a returned error; it is a permanent hang. Every subsequent reader of that offset blocks in folio_wait_locked() forever, the process becomes unkillable in D state, and hung_task eventually prints a stack trace pointing at filemap_read rather than at the filesystem that caused it. The same applies to ->readahead, whose folios are unlocked by the I/O completion handler — a submission path that returns early without either submitting or unlocking leaves the same landmine.

Blocking in dirty_folio or free_folio

locking.rst’s header line for the whole vtable is “All except dirty_folio and free_folio may block.” Those two run where sleeping is forbidden: dirty_folio may be reached from a page fault holding the page-table lock, and free_folio may be called from the memory reclaimer, which vfs.rst says explicitly “should not assume that the original address_space mapping still exists, and it should not block.” A GFP_KERNEL allocation inside either is a scheduling-while-atomic bug that appears only under memory pressure, which is to say in production and not in testing.

Marking a folio dirty without setting the XArray mark

vfs.rst on dirty_folio: “If defined, it should set the folio dirty flag, and the PAGECACHE_TAG_DIRTY search mark in i_pages.” Setting only the flag produces a folio that is dirty but invisible to writeback, because ->writepages finds work by searching the XArray for the DIRTY mark, not by walking every folio. The data sits in RAM until something else happens to touch that offset. locking.rst describes the mirror-image bug from the writepage side — a folio “marked clean but tagged as dirty in the radix tree” — and names the symptoms: “having dirty inodes at umount and losing written data.” The general rule: the folio flag and the XArray mark must move together; the flag is what the folio knows, the mark is how writeback finds it.

Expecting ->direct_IO to be where O_DIRECT happens

At v6.12 neither ext4 nor XFS defines ->direct_IO. Both route O_DIRECT from their file_operations->read_iter/write_iter into iomap_dio_rw() — ext4 does so at fs/ext4/file.c, v6.12, lines 94 and 577. The a_ops->direct_IO slot is still called from mm/filemap.c:2810 and :3977, but only for filesystems that still use the older generic_file_direct_write() scaffolding. Grepping a modern filesystem’s a_ops for direct_IO and concluding that it does not support O_DIRECT is exactly backwards.

Assuming write_begin is the universal buffered-write hook

Two of the three major Linux filesystems do not implement it, as the previous section showed. If you are instrumenting the write path — a tracepoint, an eBPF probe, an LSM hook — attaching to write_begin will see ext4 and miss XFS and btrfs entirely. The universal points are higher (vfs_write, ->write_iter) or lower (->writepages, submit_bio).

Reading the struct definition out of vfs.rst

Documented above: at v6.12 the vfs.rst code block still shows struct page **pagep for write_begin, names a nonexistent struct mapping *, and locking.rst states a rule about a mapping->io_pages field that has never existed in the git history. Read the prose there; read the types from include/linux/fs.h at your tag.

Treating nrpages as a folio count

nrpages is documented as “Number of page entries, protected by the i_pages lock” — page-sized entries. On a mapping with large folios enabled, one order-4 folio contributes 16. Code that computes an average folio size, or that iterates expecting nrpages insertions, gets it wrong by the folio order. This trap did not exist before large folios and is a direct consequence of the change described in the Modern shape box above.

Production Notes

The vtable pointer is a type tag, and production code uses it that way. shmem_mapping() is mapping->a_ops == &shmem_aops (mm/shmem.c:273, exported as EXPORT_SYMBOL_GPL). This is not a hack the way it first looks: because a_ops is const and there is exactly one static instance per filesystem mode, pointer equality is a cheap, exact test for “what kind of thing is this mapping?” — cheaper than following host to an inode to a superblock to a file_system_type. The cost is that a filesystem cannot build its vtable at runtime.

Adding a method is an ABI event for out-of-tree filesystems. Because a_ops is a struct of function pointers with a fixed layout, and because designated initialisers mean an out-of-tree filesystem compiles cleanly against a newer header while silently leaving new slots NULL, a rename like readpageread_folio does not produce a compile error at the assignment — it produces a filesystem with no read method, which fails at the first read(). This is why the folio conversion moved one or two methods per release over four years (the timeline above) rather than in one flag day: each rename is mechanical, but each also breaks every out-of-tree filesystem that has not been updated, and spreading them out means each such filesystem breaks in a small, obvious way rather than all at once.

Where to look when file I/O behaves strangely. The vtable seam has good observability, because mm/filemap.c carries tracepoints on both sides of the dispatch. trace_mm_filemap_add_to_page_cache and trace_mm_filemap_delete_from_page_cache fire on insertion and removal, and mm_filemap_get_pages, mm_filemap_map_pages and mm_filemap_fault cover the read paths (declared in include/trace/events/filemap.h). Enabling them under /sys/kernel/debug/tracing/events/filemap/ shows you exactly which offsets of which inode are entering and leaving the cache, which is the fastest way to answer “is my workload actually hitting the page cache?” without inferring it from /proc/meminfo. For the vtable side specifically, funcgraph tracing on filemap_read shows the ->readahead versus ->read_folio split directly. Two further tracepoints in the same header, filemap_set_wb_err and file_check_and_advance_wb_err, instrument the wb_err/f_wb_err pair — enabling them answers “did a writeback error actually get recorded on this mapping, and did this descriptor consume it?” without guessing from fsync()’s return value.

The DAX vtables tell you where a filesystem is going. ext4_dax_aops and xfs_dax_aops are four and three methods: writepages, noop_dirty_folio, and (for ext4) bmap and swap_activate. Everything else is absent because DAX maps persistent memory straight into user address space and there is no page cache to manage. Looking at those two vtables is the clearest available statement of which parts of address_space_operations exist only because storage is slow and far away — and which parts (writepages, for cache-line flushing on fsync) survive even when it is not.

Uncertain

Verify: the claim that shmem_aops and swap_aops are the only remaining ->writepage implementers at v6.12, and the per-filesystem method counts quoted throughout (“ext4 thirteen, XFS eleven, tmpfs six”). Reason: these were established by reading a sample of vtables — ext2, ext4 (all four), XFS (both), btrfs, squashfs, shmem, swap, and both def_blk_aops variants — not by an exhaustive tree-wide grep, which is not possible without a local checkout; fs/ at v6.12 contains roughly seventy filesystems. To resolve: git grep -n '\.writepage\s*=' v6.12 -- fs/ mm/ on a checkout of the tag. Each individually cited vtable was read at v6.12 and its line numbers verified; only the “these are the only ones” generalisation is unproven. The dated facts it rests on — ->writepage present at v6.15 and absent at v6.16 — were verified directly by fetching include/linux/fs.h at both tags. uncertain

See Also

  • The Page Cachethe sibling, and the one to read next. The memory-management view of the same subsystem: how much RAM the cache uses, the dirty thresholds that pause your writer, how reclaim takes pages back, reading /proc/meminfo, O_DIRECT and posix_fadvise as policy. Read it when your question is about kilobytes, thresholds, evictions or stalls; read this note when it is about a function pointer, a lock, or a filesystem method.
  • Address Space and the Page Cache XArray — the indexing structure inside i_pages: XArray nodes, the DIRTY / WRITEBACK / TOWRITE marks, shadow entries, and the RCU lookup protocol.
  • Folios and the Page Cache — the unit of storage: filemap_get_folio, the FGP_* flags (including FGP_WRITEBEGIN used by write_begin implementations), and large-folio sizing.
  • Folios and the Folio Conversion — why struct page became struct folio, and the multi-year conversion that produced the method renames tabulated above.
  • The Virtual File System Layer, VFS Inode Object and VFS Operation Tables — the objects address_space hangs off, and the vault’s general treatment of the VFS’s other vtables (inode_operations, file_operations, super_operations), of which address_space_operations is one.
  • VFS File Objectstruct file and its f_mapping/f_wb_err pair, the third path to a file’s cache.
  • Readahead and Read Path — the window-sizing heuristic that decides how many folios to hand to ->readahead.
  • Dirty Pages and Writeback — what drives ->writepages: dirty thresholds, the flusher threads, writeback_control, and per-BDI bandwidth estimation.
  • fsync fdatasync and Durability — why write() returning is not durability, and how wb_err/f_wb_err report a writeback error exactly once per descriptor.
  • Direct IO and O_DIRECT — the ->direct_IO method and the iomap_dio_rw() path that has largely replaced it.
  • The Block IO Submission Path — where ->readahead and ->writepages end up: submit_bio() and below. The per-folio private data (buffer heads, iomap state) that invalidate_folio, release_folio and free_folio exist to tear down is created on this path.
  • The iomap Library — the second vtable, struct iomap_ops, and why extent-based filesystems left write_begin behind.
  • Linux Filesystems and VFS MOC — the parent Map of Content.
  • Linux Memory Management MOC — the parent MOC for the memory-side sibling.