프로세스의 mapping을 지웠는데도 physical page가 반환되지 않는 경우가 있습니다. page cache가 page를 보유할 수도 있고, 진행 중인 I/O나 다른 kernel 사용자가 참조를 잡고 있을 수도 있습니다. 반대로 PFN을 계산할 수 있다는 사실만으로 그 page를 읽거나 해제할 권한이 생기지는 않습니다.
이 차이를 설명하는 출발점이 struct page입니다. descriptor에는 data 자체가 아니라 그 data를 관리하는 정보가 들어갑니다. Linux v6.1의 배치를 기준으로, 같은 descriptor가 용도에 따라 어떻게 달라지는지 살펴보겠습니다.
Physical address, PFN, order는 서로 다른 단위입니다
physical address는 CPU가 접근하는 물리 주소 공간에서 byte의 위치를 나타냅니다. DRAM에서는 memory controller가 이를 channel, bank, row, column 등의 선택으로 해석합니다. 주소의 연속성이 특정 DIMM의 단순한 배치와 일대일로 대응한다고 가정하면 안 됩니다. interleaving이나 controller의 주소 해석이 개입하기 때문입니다. kernel의 phys_addr_t도 CPU word 크기와 항상 같지는 않습니다. 예를 들어 더 넓은 physical address를 지원하는 32-bit configuration을 고려해야 합니다.
Linux는 byte마다 소유권 장부를 두는 대신 base page 단위로 관리합니다. PAGE_SIZE는 architecture와 configuration에 의존합니다. 4 KiB base page를 가정하면 physical address를 4,096으로 나눈 몫이 PFN이고 나머지가 page 내부 offset입니다. PHYS_PFN()과 PFN_PHYS()는 이런 단위 변환에 사용합니다. PFN으로 바꾸면서 offset 정보는 사라지므로 두 변환이 임의의 byte 주소를 그대로 왕복시키지는 않습니다.
order는 page 개수의 지수입니다. order-0은 한 page, order-3은 연속한 8 page입니다. 크기는 PAGE_SIZE << order이며 buddy allocator가 다루는 block의 시작점에도 그 크기에 맞는 정렬 조건이 있습니다. base page, higher-order block, pageblock은 같은 단어가 아닙니다. pageblock은 mobility grouping 등에서 사용하는 범위이며, 예를 들어 pageblock order가 9인 configuration에서는 512 base page를 묶습니다.
물리 주소 공간 전체가 RAM인 것도 아닙니다. hole, firmware 예약 영역, device memory가 섞일 수 있습니다. NUMA node와 zone은 이런 주소를 관리하는 별도의 분류입니다. 한 page의 node·zone 소속과 그 page가 현재 free인지 여부는 다른 정보입니다. 주소 범위가 존재한다는 사실만으로 ordinary allocator의 공급원이 되지는 않습니다.
v6.1에서는 이 소속 정보 일부를 flags의 높은 영역에 압축해 둡니다. set_page_zone()·set_page_node()는 초기화 경로에서 소속을 기록하는 helper이고, page_zonenum()·page_to_nid()는 이를 읽는 인터페이스입니다. 다만 node 정보가 반드시 flags 안에 있다는 보장은 없습니다. NODE_NOT_IN_PAGE_FLAGS 구성의 page_to_nid()는 section별 node 장부를 이용합니다. helper를 통하면 이런 저장 방식의 차이를 호출 지점에서 분리할 수 있습니다.
Descriptor의 크기를 줄이기 위해 같은 공간을 재사용합니다
struct page는 많은 configuration에서 수십 byte의 작은 구조체입니다. 흔히 보는 x86-64 배치에서는 64 byte이지만, 이를 고정 ABI나 모든 build의 크기로 받아들이면 안 됩니다. 선택적인 memcg 정보, architecture별 필드와 정렬 조건이 영향을 줍니다. 64 byte descriptor와 4 KiB page라는 가정이라면 metadata만으로 data 크기의 약 1.56%가 필요하므로, 필드를 무제한으로 추가할 수 없는 이유도 분명합니다.
v6.1의 중심에는 다섯 word 크기의 union이 있습니다. 64-bit 환경에서는 40 byte입니다. anonymous page와 page cache, compound tail, page table, network page_pool, device page가 이 공간을 서로 다른 형태로 사용합니다. 그 밖에 flags, _mapcount와 page_type이 공유하는 공간, _refcount, 선택적인 memcg_data 등이 놓입니다. union에 여러 이름이 보인다고 모든 필드가 동시에 유효한 것은 아닙니다.
memcg_data는 이 page의 Memory Control Group 관리 관계를 담는 word이며 CONFIG_MEMCG일 때만 선언에 들어갑니다. 일반적인 data pointer와 같은 방식으로 임의 해석하는 공간은 아닙니다. memory cgroup의 accounting·소유권을 다루는 helper의 표현 규칙에 따라 사용하며, 이 field가 있는지와 page의 ordinary reference 수는 별개의 질문입니다.
첫 word의 bit 0은 특히 중요합니다. compound tail을 판별하는 표시에 쓰이므로 다른 사용자가 임의로 설정하면 ordinary page가 tail처럼 보일 수 있습니다. allocator에서 받은 page의 metadata를 임시로 사용하는 subsystem도 이 제약을 지켜야 합니다. mapping 자리를 사용했다면 반환 전에 요구되는 상태로 복구해야 하며, mapcount나 reference protocol까지 마음대로 재정의할 수는 없습니다.
정렬도 단순한 성능 취향이 아닙니다. SLUB는 freelist와 counter를 함께 갱신하는 연산을 사용할 수 있어 두 필드의 인접성과 double-word alignment가 필요합니다. CONFIG_HAVE_ALIGNED_STRUCT_PAGE와 _struct_page_alignment는 이 요구와 연결됩니다. 구조체를 덤프할 때 offset을 다른 build에서 가져오면 단순한 숫자 차이를 넘어 전혀 다른 상태를 읽을 수 있습니다.
정확히는 v6.1에서 이 configuration이 켜지면 _struct_page_alignment가 2 * sizeof(unsigned long) 정렬을 요구하고, 꺼지면 macro는 비어 있습니다. 64-bit에서 double-word alignment가 16 byte라는 조건과 descriptor 크기가 특정 build에서 64 byte라는 조건은 별개입니다. 이 macro 하나가 모든 architecture에 64-byte cache-line alignment를 보장하지는 않습니다.
Anonymous page와 page cache는 mapping을 다르게 해석합니다
일반적인 cache·anonymous 용도에서는 union에 lru, mapping, index, private가 나타납니다. lru는 객체 안에 연결 고리를 두는 intrusive list의 list_head입니다. next·prev pointer로 원형 doubly-linked list를 만들고, 연결 member의 offset을 이용해 바깥 descriptor를 찾습니다. 별도 list node가 page를 가리키는 방식이 아니라 descriptor 자체가 list에 연결됩니다. 사용 문맥에 따라 같은 저장 공간을 다른 연결이나 mlock 관련 상태에 재사용하기도 하므로, 이름만 보고 항상 LRU list에 들어 있다고 판단하면 안 됩니다.
예를 들어 pageout의 active list를 연결할 때는 lruvec->lru_lock의 보호를 받지만, page owner가 같은 연결을 자기 list에 쓰는 문맥도 있습니다. unevictable 용도의 같은 slot에는 __filler와 mlock_count가 놓이며, filler는 짝수 값을 유지해 tail tag와 충돌하지 않아야 합니다. free page에서는 이 공간을 buddy_list나 pcp_list로 해석합니다. LRU, buddy, PCP에 동시에 독립된 연결 세 개가 있는 것이 아니라 page의 상태 전이에 따라 유효한 해석이 바뀝니다.
file-backed page의 mapping은 보통 address_space와 연결되고 index는 그 mapping 안에서의 page 위치를 나타냅니다. anonymous 쪽에서는 mapping의 낮은 bit를 tag로 사용하여 anon_vma 계열의 의미를 구별합니다. PAGE_MAPPING_ANON, PAGE_MAPPING_MOVABLE과 그 조합으로 판별하는 KSM 같은 경우를 고려해야 하므로, raw 값을 무조건 address_space *로 역참조하면 안 됩니다.
private 역시 단일한 보편적 포인터가 아닙니다. buffer 관련 자료나 swap entry 등 소유 subsystem의 의미를 담을 수 있습니다. free buddy에서는 order를 담는 용도로도 쓰입니다. 먼저 page의 역할을 확인하고 그 역할의 helper와 lock 규칙으로 읽어야 합니다. pointer tag나 flag가 맞다는 것만으로 대상 객체의 lifetime이 보장되는 것도 아닙니다.
이 배치에서 mlock_count는 list 연결과 공간을 공유하며 mlock 관련 count를 다루는 용도입니다. mapping tag도 더 구체적으로 구별할 필요가 있습니다. non-LRU movable page의 mapping은 movable_operations와 연결될 수 있고, 두 tag가 결합된 KSM 표현은 동일한 내용을 가진 page를 공유하기 위한 KSM 관리 자료와 연결됩니다. file·swap cache의 index는 그 cache 안의 위치를 나타냅니다. 같은 word의 숫자를 모든 역할에서 file offset이라고 읽으면 안 됩니다.
Mapcount와 refcount는 다른 질문에 답합니다
mapcount는 page table mapping을 추적하는 장부이고 refcount는 page의 수명을 붙잡는 참조 장부입니다. raw _mapcount는 초기값이 -1인 표현을 사용하므로 덤프의 숫자를 그대로 mapping 개수로 읽으면 안 됩니다. compound와 anonymous의 세부 규칙까지 포함하면 개별 helper가 무엇을 세는지도 확인해야 합니다.
조회할 때도 page_mapcount()와 page_ref_count()를 구별합니다. 전자는 mapping 장부의 표현을 해석하고, 후자는 현재 reference 값을 읽는 helper입니다. 값을 읽는 일 자체가 새 reference를 확보하는 것은 아니므로 소유권이 필요할 때의 get_page()·put_page() protocol을 대체하지 않습니다.
_mapcount와 같은 위치를 page_type으로 해석하는 page도 있습니다. buddy나 특정 관리 용도의 page를 판별하는 type 표현과 일반 사용자 mapping의 count는 동시에 독립된 필드가 아닙니다. PageBuddy()를 일반 flags의 bit 하나라고 단정해서도 안 됩니다.

refcount가 양수여도 metadata의 모든 내용을 lock 없이 일관되게 읽을 수 있다는 뜻은 아닙니다. reference는 lifetime을, page lock·LRU lock·page-table lock 등은 해당 자료의 동시 변경을 다룹니다. get_page()와 put_page()의 계약을 지키는 일과 mapping 변경을 동기화하는 일은 별도로 필요합니다. mapping을 지운 뒤에도 page가 남는 현상은 이 두 장부를 구별하면 자연스럽게 설명됩니다.
Configuration 분기를 포함한 선언을 읽습니다
다음은 Linux v6.1의 struct page와 alignment 정의의 전문입니다. SPDX는 GPL-2.0이며 들여쓰기를 정리했습니다. 다른 build에서 측정한 offset을 대입하는 대신, 각 #if가 실제 크기와 배치를 어떻게 바꾸는지 확인하는 기준으로 읽습니다.
#ifdef CONFIG_HAVE_ALIGNED_STRUCT_PAGE
#define _struct_page_alignment __aligned(2 * sizeof(unsigned long))
#else
#define _struct_page_alignment
#endif
struct page {
unsigned long flags; /* Atomic flags, some possibly
* updated asynchronously */
/*
* Five words (20/40 bytes) are available in this union.
* WARNING: bit 0 of the first word is used for PageTail(). That
* means the other users of this union MUST NOT use the bit to
* avoid collision and false-positive PageTail().
*/
union {
struct { /* Page cache and anonymous pages */
/**
* @lru: Pageout list, eg. active_list protected by
* lruvec->lru_lock. Sometimes used as a generic list
* by the page owner.
*/
union {
struct list_head lru;
/* Or, for the Unevictable "LRU list" slot */
struct {
/* Always even, to negate PageTail */
void *__filler;
/* Count page's or folio's mlocks */
unsigned int mlock_count;
};
/* Or, free page */
struct list_head buddy_list;
struct list_head pcp_list;
};
/* See page-flags.h for PAGE_MAPPING_FLAGS */
struct address_space *mapping;
pgoff_t index; /* Our offset within mapping. */
/**
* @private: Mapping-private opaque data.
* Usually used for buffer_heads if PagePrivate.
* Used for swp_entry_t if PageSwapCache.
* Indicates order in the buddy system if PageBuddy.
*/
unsigned long private;
};
struct { /* page_pool used by netstack */
/**
* @pp_magic: magic value to avoid recycling non
* page_pool allocated pages.
*/
unsigned long pp_magic;
struct page_pool *pp;
unsigned long _pp_mapping_pad;
unsigned long dma_addr;
union {
/**
* dma_addr_upper: might require a 64-bit
* value on 32-bit architectures.
*/
unsigned long dma_addr_upper;
/**
* For frag page support, not supported in
* 32-bit architectures with 64-bit DMA.
*/
atomic_long_t pp_frag_count;
};
};
struct { /* Tail pages of compound page */
unsigned long compound_head; /* Bit zero is set */
/* First tail page only */
unsigned char compound_dtor;
unsigned char compound_order;
atomic_t compound_mapcount;
atomic_t compound_pincount;
#ifdef CONFIG_64BIT
unsigned int compound_nr; /* 1 << compound_order */
#endif
};
struct { /* Second tail page of compound page */
unsigned long _compound_pad_1; /* compound_head */
unsigned long _compound_pad_2;
/* For both global and memcg */
struct list_head deferred_list;
};
struct { /* Page table pages */
unsigned long _pt_pad_1; /* compound_head */
pgtable_t pmd_huge_pte; /* protected by page->ptl */
unsigned long _pt_pad_2; /* mapping */
union {
struct mm_struct *pt_mm; /* x86 pgds only */
atomic_t pt_frag_refcount; /* powerpc */
};
#if ALLOC_SPLIT_PTLOCKS
spinlock_t *ptl;
#else
spinlock_t ptl;
#endif
};
struct { /* ZONE_DEVICE pages */
/** @pgmap: Points to the hosting device page map. */
struct dev_pagemap *pgmap;
void *zone_device_data;
/*
* ZONE_DEVICE private pages are counted as being
* mapped so the next 3 words hold the mapping, index,
* and private fields from the source anonymous or
* page cache page while the page is migrated to device
* private memory.
* ZONE_DEVICE MEMORY_DEVICE_FS_DAX pages also
* use the mapping, index, and private fields when
* pmem backed DAX files are mapped.
*/
};
/** @rcu_head: You can use this to free a page by RCU. */
struct rcu_head rcu_head;
};
union { /* This union is 4 bytes in size. */
/*
* If the page can be mapped to userspace, encodes the number
* of times this page is referenced by a page table.
*/
atomic_t _mapcount;
/*
* If the page is neither PageSlab nor mappable to userspace,
* the value stored here may help determine what this page
* is used for. See page-flags.h for a list of page types
* which are currently stored here.
*/
unsigned int page_type;
};
/* Usage count. *DO NOT USE DIRECTLY*. See page_ref.h */
atomic_t _refcount;
#ifdef CONFIG_MEMCG
unsigned long memcg_data;
#endif
/*
* On machines where all RAM is mapped into kernel address space,
* we can simply calculate the virtual address. On machines with
* highmem some memory is mapped into kernel virtual memory
* dynamically, so we need a place to store that address.
* Note that this field could be 16 bits on x86 ... ;)
*
* Architectures with slow multiplication can define
* WANT_PAGE_VIRTUAL in asm/page.h
*/
#if defined(WANT_PAGE_VIRTUAL)
void *virtual; /* Kernel virtual address (NULL if
not kmapped, ie. highmem) */
#endif /* WANT_PAGE_VIRTUAL */
#ifdef CONFIG_KMSAN
/*
* KMSAN metadata for this page:
* - shadow page: every bit indicates whether the corresponding
* bit of the original page is initialized (0) or not (1);
* - origin page: every 4 bytes contain an id of the stack trace
* where the uninitialized value was created.
*/
struct page *kmsan_shadow;
struct page *kmsan_origin;
#endif
#ifdef LAST_CPUPID_NOT_IN_PAGE_FLAGS
int _last_cpupid;
#endif
} _struct_page_alignment;
앞에서 설명한 다섯 word의 역할별 해석 외에도 선언 끝에는 선택적인 field가 더 있습니다. WANT_PAGE_VIRTUAL의 virtual은 필요한 architecture에서 kernel virtual mapping 주소를 보관합니다. direct map으로 주소를 계산할 수 있는 구성과 highmem을 동적으로 mapping하는 구성을 구별하기 위한 자리이지 user virtual address가 아닙니다.
CONFIG_KMSAN은 초기화 여부를 추적하는 shadow page와 origin stack 정보를 추적하는 page pointer를 더합니다. LAST_CPUPID_NOT_IN_PAGE_FLAGS이면 마지막 CPU·PID 관련 encoding을 flags 밖에 둡니다. 이처럼 선택적인 metadata가 존재하므로 특정 x86-64 build의 크기와 offset을 보편적인 ABI로 제시할 수 없습니다. tail·page-table·network·device field의 구체적인 사용 조건은 역할별 설명에서 이어집니다.
참고 자료
- Lorenzo Stoakes, The Linux Memory Manager, Early Access, 2025, Physical Memory 장.
- Linux
v6.1: struct page와 필드의 사용 제약, page reference helper, mapping과 page helper. - Linux
v6.1: flags 밖에 node 정보를 두는 sparse memory 경로. - Linux kernel documentation: Physical Memory.