struct page Anatomy

Every 4 KiB physical page frame in a Linux system is described by exactly one struct page — a small, fixed-size kernel object living in a giant array (the mem_map / vmemmap) that is the kernel’s model of physical RAM. Because there is one descriptor per frame across all of RAM, its size is sacred: on a 64-bit kernel it is about 64 bytes, so the descriptor array consumes on the order of 1.6 % of all memory it describes (Corbet, “The state of the page in 2024”, LWN). That brutal size budget is why struct page became one of the most heavily unioned, overloaded structures in the kernel — the same bytes mean completely different things depending on whether the frame is a page-cache page, an anonymous page, a slab, a compound tail page, a ZONE_DEVICE page, or a free buddy block. This note dissects the v6.12 LTS layout field by field, explains the reference counts and flags that every frame carries, and situates the ongoing effort to shrink the descriptor via folios and typed memory descriptors. (Definitions below are read from the pinned v6.12 mm_types.h; v6.18 deltas are called out explicitly.)

Mental Model — One Descriptor Per Frame, Bytes Reused By Role

The right way to think about struct page is not as a struct with named fields you read freely. It is a fixed-size slab of bytes (five “words” of union, plus a flags word, plus the reference counts) whose interpretation is chosen by the current role of the physical frame. The kernel maintains the invariant “one frame ↔ one descriptor”; what those bytes mean is decided by page flags and the allocator that owns the frame. Reading a field that does not belong to the frame’s current role is a bug.

flowchart TB
  PFN["Physical Frame Number (PFN)<br/>e.g. frame 0x12abc"]
  PFN -->|"pfn_to_page()"| SP["struct page (≈64 bytes)<br/>one per 4 KiB frame"]
  SP --> FL["flags word<br/>PG_* bits + packed<br/>node / zone / section / cpupid"]
  SP --> UN["5-word union<br/>(role-dependent)"]
  SP --> RC["_mapcount / page_type<br/>+ _refcount"]
  UN -->|"page-cache / anon"| A["lru, mapping, index, private"]
  UN -->|"slab/SLUB"| B["reinterpreted as struct slab"]
  UN -->|"compound tail"| C["compound_head (bit 0 = 1)"]
  UN -->|"ZONE_DEVICE"| D["pgmap, zone_device_data"]
  UN -->|"page_pool (net)"| E["pp, dma_addr, pp_ref_count"]
  UN -->|"free"| F["buddy_list / pcp_list"]

The descriptor and its role-dependent overlays. What it shows: a physical frame number maps one-to-one to a struct page; the always-present parts are the flags word and the reference counts (_refcount, and _mapcount/page_type), while the five-word central union is reinterpreted depending on what the frame is being used for. The insight: the same bytes are a page-cache mapping+index for a file page, a compound_head pointer for a huge-page tail, a struct slab for kernel-object storage, or a buddy_list link when free. You must know the frame’s role (from its flags and owning allocator) before you may read any union field.

Why It Is So Small — The mem_map Size Budget

The constraint that shapes everything is arithmetic. There is one struct page for every 4 KiB frame in the machine. If the descriptor is S bytes, the descriptor array costs S / 4096 of RAM as pure overhead. With S ≈ 64, that is 64/4096 ≈ 1.56 % — the “1.6 %” figure Matthew Wilcox cited at the 2024 Linux Storage, Filesystem, Memory-Management and BPF Summit (LWN 973565). On a 1 TiB machine that is ~16 GiB of struct page array; under virtualization the array exists in both host and guest, doubling the toll. The doc summarizes this overhead as the array of descriptors that the physical memory model arranges as mem_map (FLATMEM) or per-section maps walked through vmemmap (SPARSEMEM) (kernel.org memory-model).

This is the entire reason struct page is union-packed rather than a clean struct with one field per purpose: adding a field is multiplied by every frame in the system. The source comment is blunt about it — when the DMA-pin tracking work needed somewhere to store a pin count, the rule was “struct page may not be increased in size for this, and all fields are already used,” forcing the [[get_user_pages and Page Pinning|pin count to be overloaded onto _refcount]] (pin_user_pages.rst).

Uncertain

Verify: the exact sizeof(struct page) on x86-64 in 6.12/6.18. It is not a fixed constant — it depends on Kconfig (CONFIG_MEMCG adds memcg_data; WANT_PAGE_VIRTUAL, LAST_CPUPID_NOT_IN_PAGE_FLAGS, and CONFIG_KMSAN each add words). The “≈64 bytes” / “1.6 %” figures come from the LWN LSFMM 2024 report and back-of-envelope 64/4096; I did not compile a config to read sizeof. Reason: config-dependent, not directly measured. To resolve: build a 6.12 defconfig and check pahole/BUILD_BUG_ON on sizeof(struct page). uncertain

The Always-Present Fields

Three things are present regardless of role.

flags — the first word. In v6.12 it is unsigned long flags (atomic flags, “some possibly updated asynchronously”). It carries two distinct kinds of information packed into one machine word:

  1. The PG_* status bits (the low NR_PAGEFLAGS bits): PG_locked, PG_uptodate, PG_dirty, PG_lru, PG_active, PG_reserved, PG_writeback, PG_swapcache, PG_reclaim, and so on. These are the booleans that drive the page cache and reclaim.

  2. Packed location identifiers in the upper bits — the section, node, and zone the frame belongs to, plus (optionally) a last_cpupid field for NUMA balancing and a KASAN tag. include/linux/page-flags-layout.h defines the widths and documents the layouts explicitly (v6.12 page-flags-layout.h):

    No sparsemem / sparsemem-vmemmap: |       NODE     | ZONE |             ... | FLAGS |
         " plus space for last_cpupid: |       NODE     | ZONE | LAST_CPUPID ... | FLAGS |
    classic sparse with space for node:| SECTION | NODE | ZONE |             ... | FLAGS |
         " plus space for last_cpupid: | SECTION | NODE | ZONE | LAST_CPUPID ... | FLAGS |
    classic sparse no space for node:  | SECTION |     ZONE    | ... | FLAGS |

    The build enforces ZONES_WIDTH + LRU_GEN_WIDTH + SECTIONS_WIDTH + NODES_WIDTH + KASAN_TAG_WIDTH + LAST_CPUPID_WIDTH <= BITS_PER_LONG - NR_PAGEFLAGS, and #error "Not enough bits in page flags" if it overflows. This is why page_to_nid(page), page_zone(page), and (under SPARSEMEM-vmemmap) the section lookup are cheap bit-extractions from flags rather than separate fields — there is no room for separate fields. When the bits genuinely don’t fit (e.g. NUMA on classic-sparse without vmemmap), NODE_NOT_IN_PAGE_FLAGS is set and the node is looked up out-of-line instead. The header comment notes the zone bits double as the hint the allocator reads from the GFP zone modifiers (see GFP Flags and Allocation Contexts).

    In v6.18 this field changes type: it is memdesc_flags_t flags — a one-member wrapper typedef struct { unsigned long f; } memdesc_flags_t; (v6.18 mm_types.h). Semantically identical bits, but a distinct type so the compiler can police who touches the flags word as the memdesc refactor proceeds (see Folios and the Folio Conversion).

_refcountatomic_t _refcount, marked “Usage count. DO NOT USE DIRECTLY. See page_ref.h.” This is the elevated-reference count that keeps a frame from being freed or reclaimed while anyone holds a reference. It is taken by the page cache, by every mapping, by get_page()/get_user_pages(), by I/O in flight, and so on; when it hits zero the frame returns to the buddy allocator. Critically, _refcount is also where the DMA-pin count is overloaded for small folios (via GUP_PIN_COUNTING_BIAS, covered in the pinning note). On allocation from alloc_pages() the refcount is positive (v6.12 mm_types.h comment).

_mapcount / page_type — a 4-byte union sharing the same slot:

  • atomic_t _mapcount counts how many page-table entries map this page (for RMAP-tracked, non-typed folios). It is initialized to −1, not 0, so that the transitions both to −1 and from −1 can be detected atomically with atomic_add_negative(-1) / atomic_inc_and_test() — a value of 0 therefore means “mapped exactly once.”
  • unsigned int page_type is used instead for typed head pages (slab, page-table, etc.): the high bits encode “what kind of page is this,” with the convention that a typed page has _mapcount == page_type == -1 for its tail pages. Owners may reuse the low 16 bits of page_type after setting the type but must restore them to all-ones before clearing it.

The Central Five-Word Union — Role Overlays

The comment is explicit: “Five words (20/40 bytes) are available in this union. WARNING: bit 0 of the first word is used for PageTail().” That single reserved bit is the linchpin of the whole compound-page scheme: if bit 0 of the first union word is set, the page is a tail of a compound page and the word is compound_head (a pointer to the head, with the low bit as the tag). Every other overlay must keep that bit clear to avoid a false-positive PageTail(). The major overlays, straight from v6.12:

  • Page cache / anonymous pages (struct): lru (the reclaim list linkage, protected by lruvec->lru_lock), mapping (the struct address_space * for a file page, or the anon_vma for an anonymous page — the low bits encode which, per PAGE_MAPPING_FLAGS), index (pgoff_t offset within the mapping), and private (opaque per-page data: buffer_heads if PagePrivate, a swp_entry_t if in the swap cache, or the buddy order if PageBuddy). The lru slot is itself a sub-union — for the unevictable case it becomes { void *__filler; unsigned int mlock_count; } (the __filler is “always even, to negate PageTail”), and for a free page it is buddy_list / pcp_list.
  • page_pool (network stack): pp_magic, pp (the struct page_pool *), _pp_mapping_pad, dma_addr, pp_ref_count — the recycling metadata for the networking page pool.
  • Compound tail pages: just compound_head with bit 0 set.
  • ZONE_DEVICE pages: pgmap (the hosting struct dev_pagemap *) and zone_device_data — for device/PMEM memory that has descriptors but is not ordinary RAM.
  • rcu_head: lets a page be freed by RCU.

The slab overlay is special: it is not a member of this union. Instead, struct slab is a separate type that reinterprets the bytes of struct page (which is why the comment notes “struct slab currently just reinterprets the bits of struct page,” and why all struct pages are double-word aligned via CONFIG_HAVE_ALIGNED_STRUCT_PAGE so SLUB’s cmpxchg_double() on its freelist+counters works). See The Slab Allocator and SLUB and Kmem Caches and Object Slabs.

struct folio — The Typed Head-Page Handle

Layered directly on top of struct page is struct folio, “a physically, virtually and logically contiguous set of bytes… a power-of-two in size, aligned to that same power-of-two, at least as large as PAGE_SIZE.” A folio is guaranteed not to be a tail page — it is the head — which untangles the head/tail confusion that plagued compound-page code. In v6.12 a folio is a union of either named fields (flags, lru, mapping, index, private/swap, _mapcount, _refcount, memcg_data, …) or an embedded struct page page — and a battery of FOLIO_MATCH() static_asserts enforce that each named folio field sits at exactly the same offset as the corresponding struct page field (v6.12 mm_types.h). For multi-page folios, the tail pages’ bytes are reused to store extra accounting the head needs: _large_mapcount, _entire_mapcount, _nr_pages_mapped, _pincount (the dedicated DMA-pin counter that large folios use instead of the _refcount bias trick), _folio_nr_pages, and the hugetlb/deferred-split fields. Every transitional member is annotated /* the union with struct page is transitional */. This note covers the page side; the folio refactor itself, including the spread of large folios through the page cache, lives in Folios and the Folio Conversion.

The Shrinking Effort — Memory Descriptors (“memdesc”)

The long-term plan, presented by Wilcox at LSFMM 2024 (LWN 973565), is to shrink struct page down to a single 8-byte memory descriptor whose bottom few bits say what type of page it is, with the real metadata moved into type-specific descriptors (a slab gets a slab descriptor, an anonymous folio gets a folio, a page-table page gets a page-table descriptor). Some typed descriptors already exist (slab and page-table descriptors). The payoff is the memory-map overhead dropping from ~1.6 % to ~0.2 % — multiple gigabytes saved on large systems — because one folio covers many base pages instead of one fat struct page per frame. The v6.18 memdesc_flags_t type is an early footprint of this work. The goal is explicitly not to delete struct page: it remains the granularity at which memory is mapped into user space.

Uncertain

The memdesc end-state (single 8-byte descriptor, ~0.2 % overhead) is a roadmap presented in May 2024, not a shipped 6.12/6.18 reality. As of v6.18 struct page is still the ~5-word-union descriptor described above; only memdesc_flags_t and a couple of typed descriptors (slab, page-table) have landed. Reason: dated to a conference report; I read the v6.12/v6.18 source for current layout but not the full memdesc patch series. To resolve: track the memdesc series merge status in later kernels. Treat as plan, in progress, never “done.” uncertain

Common Misunderstandings

  • “You can read any field of struct page.” No. The union means a field is valid only for the frame’s current role. Reading page->mapping on a slab page, or page->index on a ZONE_DEVICE page, returns garbage. The flags and the owning allocator tell you which overlay is live.
  • _refcount and _mapcount are the same kind of count.” They are not. _mapcount counts page-table mappings (how many processes map it); _refcount counts all elevated references (mappings contribute, but so does I/O, the page cache holding it, GUP pins, etc.). A page can have _mapcount == -1 (unmapped) yet _refcount > 0 (e.g. clean page-cache page nobody has mapped).
  • _mapcount starts at 0.” It starts at −1 so that the first map (→0) and the last unmap (→−1) are both detectable atomically.
  • “folios replaced struct page.” They have not — covered in Folios and the Folio Conversion. A folio is a typed handle to the head page; the struct page array still exists in 6.18.
  • “Each frame has lots of room for metadata.” The opposite: the per-frame size budget is the binding constraint behind every overload, and the whole memdesc effort exists to reduce it.

See Also