PTE에 physical frame 번호만 들어간다면 CPU는 그 page를 읽어도 되는지, 써도 되는지, code로 실행해도 되는지 알 수 없습니다. 같은 PFN을 두 주소에 연결하더라도 한쪽은 read-only, 다른 쪽은 writable로 둘 수 있어야 합니다. page-table entry는 위치와 접근 규칙을 함께 표현합니다.
문제는 모든 bit가 언제나 같은 의미를 갖지는 않는다는 점입니다. nonpresent entry는 다른 encoding을 쓰고, 같은 bit도 leaf 크기나 software 문맥에 따라 해석이 달라집니다. x86-64와 upstream Linux v6.18을 기준으로 이 구분을 살펴보겠습니다.
address mask와 protection은 함께 해석합니다
4 KiB에 맞춘 physical page 시작 주소는 아래 12 bit가 0입니다. physical-address 폭 밖의 상위 bit에도 flag를 둘 수 있는 공간이 있습니다. 다만 이것이 아래 12 bit와 위쪽 bit를 아무 용도로 써도 된다는 뜻은 아닙니다. architecture가 지정한 address mask, reserved bit와 feature 지원 조건을 지켜야 합니다.
큰 leaf는 더 큰 alignment를 사용하며 PAT bit 위치도 달라집니다. 따라서 모든 level의 entry에서 단순히 같은 숫자 mask를 적용해 PFN과 flags를 분리하는 방식은 위험합니다. pte_pfn()이나 level에 맞는 helper를 쓰는 이유입니다.
pgprot_t는 protection 값을 표현하는 wrapper이고 pgprot_val()과 __pgprot() 계열은 raw 값과 wrapper 사이를 변환합니다. 이것은 live PTE를 쓰는 작업과 다릅니다. 기본 protection을 계산한 뒤 실제 entry를 만들고 설치하는 단계가 별도로 있습니다.
PGD·P4D는 보통 다음 table을 가리키며 leaf처럼 자주 permission을 바꾸지 않습니다. PUD·PMD는 table pointer와 huge leaf 두 역할을 가질 수 있고 PTE는 base-page entry입니다. helper가 level마다 모두 같은 수로 정의되지 않는 이유도 이 역할 차이입니다.
raw dump를 읽는 출발점은 bit 0의 PRESENT, 1의 RW, 2의 USER, 3의 PWT, 4의 PCD, 5의 ACCESSED, 6의 DIRTY입니다. 그러나 그 뒤부터는 위치만 보고 해석할 수 없습니다. bit 7은 4 KiB leaf에서는 PAT, PMD·PUD huge leaf에서는 PSE이며 큰 leaf의 PAT는 bit 12입니다. bit 8도 present leaf에서는 GLOBAL이지만 hardware-nonpresent entry에서는 PROTNONE으로 해석될 수 있습니다. 이 위치는 v6.18의 bit 정의와 entry 종류를 함께 대조해야 의미가 정해집니다.
level별 비교는 non-leaf와 leaf를 분리해야 정확합니다. 다음은 일반적인 x86-64 paging의 비교이며 EPT·HLAT의 별도 encoding은 포함하지 않습니다.
| bit 또는 field | PGD·P4D와 non-leaf PUD·PMD | 1 GiB PUD·2 MiB PMD leaf | 4 KiB PTE leaf |
|---|---|---|---|
| PRESENT·RW·USER·NX | 다음 level의 존재와 전체 하위 범위의 접근 제한 | 해당 큰 범위의 접근 제한 | 해당 page의 접근 제한 |
| PWT·PCD | 다음 page-table 저장소에 대한 cache 속성 선택에 관여 | 최종 data의 memory type 선택에 관여 | 최종 data의 memory type 선택에 관여 |
| ACCESSED | walk에 사용된 entry의 접근 흔적 | 큰 leaf의 접근 흔적 | base leaf의 접근 흔적 |
| DIRTY | 일반 data-write 흔적이라는 hardware 의미는 없습니다. | hardware write 상태가 있습니다. | hardware write 상태가 있습니다. |
| bit 7 | PGD·P4D에서는 PS를 세울 수 없습니다. PUD·PMD가 non-leaf이면 PS=0입니다. | PS=1로 walk를 종료합니다. | PAT 선택 bit입니다. |
| PAT_LARGE·GLOBAL·PKEY | final data leaf용 의미로 읽지 않습니다. | PAT는 bit 12이며 GLOBAL·PKEY도 leaf에 적용됩니다. | PAT는 bit 7이며 GLOBAL·PKEY가 적용됩니다. |
NX는 EFER.NXE, GLOBAL은 CR4.PGE, protection key는 해당 CPU feature와 enable 조건을 전제로 합니다. RW·USER·NX의 최종 판정에는 상위 entry와 CPU control 상태도 관여합니다. ACCESSED를 '마지막 data page를 읽었다'로만 정의하거나, non-leaf template에 DIRTY가 미리 들어간 것을 하위 data write의 관측으로 읽어서는 안 됩니다. software bit는 이 hardware 비교와 별도로 entry 역할·Linux configuration·version에 따라 해석합니다. Intel SDM Volume 3A, 253668-093US의 일반 4/5-level entry 정의와 Linux v6.18 bit 정의를 함께 근거로 삼았습니다.
읽기, 쓰기, 실행은 한 bit의 문제가 아닙니다
_PAGE_PRESENT는 CPU가 해당 entry를 present translation으로 사용할 수 있는지 나타냅니다. clear된 entry를 통한 접근은 fault를 일으킵니다. _PAGE_RW는 write 허용에, _PAGE_USER는 user mode의 접근에 관여합니다. 상위 entry의 제한도 walk 전체에 적용되므로 마지막 PTE만 writable이라고 모든 접근이 허용되지는 않습니다.
_PAGE_NX는 instruction fetch를 막습니다. data를 저장하는 stack·heap과 실행 가능한 code를 구분하는 데 중요합니다. 그러나 page table의 permission만으로 application protocol의 경쟁까지 해결되는 것은 아닙니다. 다른 thread가 사용하는 mapping의 권한을 바꾸려면 software 동기화도 필요합니다.
write fault를 볼 때는 VMA permission과 현재 PTE permission을 함께 확인해야 합니다. VMA는 write를 허용하지만 PTE만 read-only라면 CoW나 write notification일 수 있습니다. VMA 자체가 금지한 write를 private page 하나를 만들어 우회하는 것은 잘못입니다.
pte_write()와 write-protect helper는 특정 entry 상태를 읽고 바꾸는 도구입니다. 이런 helper 호출과 변경된 translation이 모든 CPU에 적용됐다는 사실도 다릅니다. live entry의 변경에는 맞는 lock과 필요에 따른 TLB invalidation이 따라야 합니다.
pte_access_permitted(pte, write)는 pte_present()와도 다릅니다. 현재 x86 구현은 hardware PRESENT와 USER를 모두 요구하며 write 요청에는 RW도 요구합니다. 이어서 protection key에 대한 PKRU 허용 여부를 검사합니다. pmd_access_permitted()·pud_access_permitted()도 같은 검사 본체를 사용합니다. 이것은 instruction fetch의 NX 검사까지 대신하는 범용 permission 판정기가 아닙니다. NX는 bit 63이며 지원 feature를 전제로 합니다. 실행을 허용하는 pte_mkexec()가 write까지 금지해 주지는 않으므로 code의 write·execute 분리에는 read-only protection도 따로 필요합니다. 검사와 변환 helper는 이 차이를 드러냅니다.
accessed와 dirty는 최근의 관찰 흔적입니다
_PAGE_ACCESSED는 hardware가 접근 시 설정하는 sticky 상태입니다. kernel이 이를 clear한 뒤 다시 설정되는지 보면 그 기간의 접근을 추정할 수 있습니다. young·old라는 이름은 page의 생성일이 아니라 이 접근 상태의 해석입니다. pte_young(), pte_mkyoung(), pte_mkold() 계열이 이 관계를 드러냅니다.
_PAGE_DIRTY 역시 write의 흔적이며 clean으로 돌린 뒤 다시 dirty가 되는지 관찰할 수 있습니다. file-backed memory에서는 writeback과 연결되지만 bit 하나를 지웠다고 data가 storage에 저장되는 것은 아닙니다. PTE의 dirty를 folio 상태로 옮기는 작업과 실제 I/O 완료는 다른 단계입니다.
진단할 때에는 hardware가 설정할 수 있는 bit와 kernel이 미리 설정하는 bit도 구분해야 합니다. fault handler가 writable target을 설치하며 dirty로 표시할 수 있으므로 dirty가 보인다는 사실이 faulting store가 이미 실행됐다는 실측 증거는 아닙니다.
현재 x86 header에는 shadow stack과 일반 read-only dirty 상태를 구분하는 SAVED_DIRTY도 있습니다. pte_dirty()는 해당 version의 helper 의미로 읽어야 하며 raw hardware dirty bit 하나만 직접 검사한 결과와 항상 같다고 가정해서는 안 됩니다. older kernel의 bit 그림을 현재 entry 해석에 그대로 적용하기 어려운 사례입니다.
검사·변환 helper에도 이 구분이 반영됩니다. young 계열은 ACCESSED를 검사하거나 설정·해제하고, pte_mkdirty()는 DIRTY와 설정상 유효한 SOFT_DIRTY를 함께 설정한 뒤 필요하면 saved-dirty 표현으로 옮깁니다. pte_mkclean()은 dirty 표현을 지우지만 soft-dirty 추적 자체를 초기화하는 helper는 아닙니다. PMD·PUD에도 대응하는 young·dirty 계열이 있습니다. helper가 반환하는 새 entry 값과 그 값을 live slot에 설치하는 작업 역시 별개입니다.
cache type은 PWT 하나로 결정하지 않습니다
_PAGE_PWT, _PAGE_PCD, PAT는 memory type 선택에 관여합니다. write-through, write-back, uncached, write-combining은 서로 다른 동작입니다. write-through를 아예 cache하지 않는다는 뜻으로 단순화하면 write-back과 uncached의 차이를 설명할 수 없습니다.
4 KiB leaf의 PAT bit는 bit 7이고 큰 leaf의 PAT는 다른 위치를 사용합니다. 같은 bit 7은 PMD·PUD의 _PAGE_PSE이기도 합니다. level과 leaf 여부를 먼저 정해야 그 bit가 table walk 종료를 뜻하는지 memory type 선택을 뜻하는지 알 수 있습니다.
실효 memory type에는 PAT와 MTRR의 관계 및 alias 제약도 있습니다. 같은 physical range를 서로 충돌하는 cache attribute로 연결하는 것은 안전한 임의 선택이 아닙니다. device register를 다루려면 ioremap*()와 subsystem의 계약을 따라야 하며 일반 RAM pointer에 PCD 하나만 덧붙이는 모델로 설명할 수 없습니다.
present의 hardware 의미와 kernel 의미
PROT_NONE은 mapping이 없는 hole과 다릅니다. 범위와 backing 관계를 남기되 접근을 금지할 수 있어야 합니다. x86 Linux는 hardware PRESENT가 clear된 entry에 _PAGE_PROTNONE을 사용하고, pte_present()는 PRESENT 또는 PROTNONE인 상태를 present로 판단합니다.
따라서 hardware가 fault한다는 사실과 kernel의 pte_present()가 false라는 사실은 같지 않습니다. pte_protnone()은 이 조합을 구별하며 NUMA hinting의 접근 제한도 관련된 경로를 사용합니다. 사용자에게 접근 권한을 주는 판단은 VMA와 fault 문맥에서 다시 해야 합니다.
PMD에서는 예외가 하나 더 있습니다. v6.18의 pmd_present()는 PRESENT·PROTNONE뿐 아니라 PSE도 검사합니다. huge-page split 중 PRESENT를 잠시 clear하더라도 PSE가 남는 전환 상태를 고려하기 때문입니다. 이 결과가 true라고 그 순간 CPU가 접근 가능한 2 MiB translation이 있다고 단정하면 안 됩니다. 또한 PROT_NONE guard는 범위의 접근 금지이지 처음부터 data page를 allocation하라는 요청이 아닙니다. backing이 이미 있는 entry의 encoding과 아직 fault하지 않은 빈 PTE를 나누어 보아야 합니다.
PRESENT가 꺼져 있으면 같은 bit 위치를 다른 목적으로 쓸 수 있습니다. 예를 들어 GLOBAL과 PROTNONE은 서로 다른 entry 상태에서 같은 위치를 활용합니다. swap entry의 soft-dirty나 userfaultfd 상태도 present PTE와 다른 encoding을 사용합니다. PRESENT만 clear한 뒤 나머지 모든 bit를 PFN으로 읽는 것은 잘못입니다.
PROTNONE 전환에는 speculative 접근을 줄이려는 PFN 반전도 있습니다. v6.18의 pte_modify()·pmd_modify()는 flip_protnone_guard()에 이전 값, 새 값과 해당 level의 PFN mask를 넘깁니다. 이 helper는 두 값의 반전 필요 여부가 달라졌을 때에만 mask 안의 PFN bit를 뒤집고 나머지 bit는 보존합니다. 반전 필요 여부는 값이 0이 아니고 hardware PRESENT가 꺼졌는지로 판단하므로, 완전히 비운 all-zero entry는 반전하지 않습니다. PAGE_NONE은 PROTNONE·ACCESSED bit가 있어 이 all-zero 예외와 다릅니다.
아래는 공식 v6.18 pgtable-invert.h 전체이며, 다운로드할 header의 세 함수·주석·configuration guard를 생략하지 않았습니다. header의 GPL-2.0 표기를 유지했으며 license text를 함께 제공합니다. kernel header 문맥이 필요한 실제 code이지 userspace 실행 예제는 아닙니다.
/* SPDX-License-Identifier: GPL-2.0 */
#ifndef _ASM_PGTABLE_INVERT_H
#define _ASM_PGTABLE_INVERT_H 1
#ifndef __ASSEMBLER__
/*
* A clear pte value is special, and doesn't get inverted.
*
* Note that even users that only pass a pgprot_t (rather
* than a full pte) won't trigger the special zero case,
* because even PAGE_NONE has _PAGE_PROTNONE | _PAGE_ACCESSED
* set. So the all zero case really is limited to just the
* cleared page table entry case.
*/
static inline bool __pte_needs_invert(u64 val)
{
return val && !(val & _PAGE_PRESENT);
}
/* Get a mask to xor with the page table entry to get the correct pfn. */
static inline u64 protnone_mask(u64 val)
{
return __pte_needs_invert(val) ? ~0ull : 0;
}
static inline u64 flip_protnone_guard(u64 oldval, u64 val, u64 mask)
{
/*
* When a PTE transitions from NONE to !NONE or vice-versa
* invert the PFN part to stop speculation.
* pte_pfn undoes this when needed.
*/
if (__pte_needs_invert(oldval) != __pte_needs_invert(val))
val = (val & ~mask) | (~val & mask);
return val;
}
#endif /* __ASSEMBLER__ */
#endif
반대 방향에서 pte_pfn()은 protnone_mask()를 XOR하여 반전을 되돌린 뒤 address mask와 shift를 적용합니다. raw address bit를 PFN으로 곧바로 읽으면 같은 backing도 다른 번호로 해석할 수 있는 이유입니다. 이 설명은 해당 helper가 허용하는 PFN-bearing entry의 encoding 경로에 한정하며, swap·migration 등 임의의 nonpresent encoding을 pte_pfn()으로 해석해도 된다는 뜻은 아닙니다. permission 변경은 flag의 논리만이 아니라 architecture의 entry encoding을 유지하는 작업입니다.

special과 soft-dirty는 다른 종류의 정보입니다
_PAGE_SPECIAL은 generic VM이 일반 struct page mapping처럼 다루면 안 되는 translation을 구분하는 데 쓰입니다. zero page나 일부 PFN mapping에서 special일 수 있지만, special을 physical page 객체가 전혀 없다는 뜻으로만 정의할 수는 없습니다. mapping의 reference·mapcount 계약을 구별하는 정보입니다.
_PAGE_SOFT_DIRTY는 hardware dirty와 목적이 다릅니다. process의 어떤 page가 기준 시점 뒤에 write됐는지 userspace가 추적하는 기능에 사용합니다. /proc/PID/clear_refs로 추적 상태를 재설정하고 /proc/PID/pagemap에서 관찰하는 절차는 단순히 storage의 dirty page 목록을 읽는 일이 아닙니다.
구체적으로는 clear_refs에 4를 쓰고 이후 pagemap entry의 bit 55를 관찰합니다. kernel은 soft-dirty를 지울 때 writable 상태도 제한하여 다음 write fault에서 추적 상태를 다시 세울 수 있게 합니다. unmap 뒤 같은 주소에 새 mapping을 만들거나 영역을 확장한 경우에도 새 영역을 soft-dirty로 표시할 수 있으므로, bit 하나를 특정한 store instruction의 횟수나 시각으로 읽을 수는 없습니다. pte_soft_dirty()·pte_mksoft_dirty()·pte_clear_soft_dirty()는 entry의 해당 상태를 다루는 helper이며 PMD·PUD에도 대응 계열이 있습니다. Soft-Dirty 문서는 관찰 절차와 새 mapping의 예외를 함께 설명합니다.
bit 9의 SPECIAL과 같은 위치를 쓰는 _PAGE_CPA_TEST는 PAT self-test 문맥의 이름입니다. bit 10의 _PAGE_UFFD_WP는 userfaultfd에 등록된 모든 page라는 뜻이 아니라 userfaultfd write-protection 상태를 표현합니다. 지원 configuration이 없으면 해당 mask가 0인 정의도 있습니다. bit 11의 SOFT_DIRTY 역시 CONFIG_MEM_SOFT_DIRTY 조건을 받습니다. 번호가 있다는 사실과 실행 중 그 기능을 사용할 수 있다는 사실은 다릅니다.
software-defined bit도 version별로 배치가 달라질 수 있습니다. v6.18의 x86-64 header는 SOFTW4를 bit 57, SOFTW5를 58로 정의하고 leaf·root 용도에 따라 다시 이름을 붙입니다. protection key, userfaultfd write protection과 encrypted-memory attribute에는 별도 조건도 있습니다. bit 번호를 외우기 전에 지금 어떤 kind의 entry를 해석하는지 정해야 합니다.
예를 들어 과거 x86 header의 _PAGE_DEVMAP을 SOFTW4와 연결한 설명을 현재 bit 57에 그대로 이식해서는 안 됩니다. 확인한 v6.18의 해당 header에는 그 정의가 없습니다. 반면 x86-64의 SOFTW5는 leaf의 SAVED_DIRTY와 root PGD의 NOPTISHADOW에 다시 쓰입니다. protection key 네 bit는 59~62에 있지만 관련 configuration과 CPU 지원, PKRU 문맥을 함께 보아야 합니다. device mapping이나 encrypted memory를 이 일반 bit 목록만으로 판별하지 않는 이유입니다.
protection template은 최종 PTE가 아닙니다
_PAGE_TABLE과 _KERNPG_TABLE은 하위 table을 가리키는 entry의 기본 조합입니다. user data의 PAGE_NONE, PAGE_SHARED, PAGE_COPY, PAGE_READONLY 계열과 kernel의 PAGE_KERNEL, RO·EXEC·ROX·NOCACHE·LARGE 계열은 대상과 정책이 다릅니다. 이름에 비슷한 단어가 있다고 서로 교환해서는 안 됩니다.
두 table template은 PRESENT·RW·ACCESSED·DIRTY를 포함하지만 USER는 _PAGE_TABLE 쪽에 있습니다. 상위 entry의 RW를 끄면 하위 writable leaf도 제한되므로, table pointer의 RW를 그 table 자체에 application이 write해도 된다는 뜻으로 읽어서는 안 됩니다. kernel data의 기본 template은 writable·non-executable이고 RO는 write를, EXEC는 NX를 제거하며 ROX는 write와 execute 정책을 함께 구별합니다. VVAR는 userspace에서 읽는 특수 data용으로 USER를 포함하고 write는 허용하지 않습니다. LARGE 계열은 huge leaf용 PSE를 더합니다.
이 비교는 permission의 차이이며 모든 상태 bit를 그대로 둔다는 뜻은 아닙니다. 현재 raw kernel RO·ROX·VVAR 조합은 기본 writable kernel 조합과 달리 DIRTY도 미리 설정하지 않습니다. user의 PAGE_NONE에는 ACCESSED와 PROTNONE 위치의 bit가 있지만 hardware PRESENT는 없습니다. 반면 READONLY와 COPY의 기본 bit 조합은 같을 수 있습니다. 두 이름을 구별하는 최종 정책은 VMA와 fault 경로에 있습니다.
NOCACHE와 WP는 cache type의 이름입니다. 특히 __PAGE_KERNEL_WP의 WP를 RW clear와 같은 뜻으로 읽으면 안 됩니다. 현재 정의는 RW를 유지하며 _PAGE_CACHE_WP를 사용합니다. IO 계열은 기본 raw 조합을 공유하더라도 최종 encryption attribute 적용에서 일반 PAGE_KERNEL과 다를 수 있습니다. __PAGE_KERNEL_* raw 조합과 PAGE_KERNEL_*의 최종 __default_kernel_pte_mask 적용을 구별하면, 이름이 같은 조합도 CPU가 지원하지 않는 flag까지 강제로 설치하는 값이 아님을 알 수 있습니다.
user template 선택은 16개 행을 외우기보다 세 갈림길로 읽을 수 있습니다. read·write·execute가 모두 없으면 shared 여부와 무관하게 PAGE_NONE입니다. write가 없으면 read-only 계열이며 execute 유무가 NX를 가릅니다. write가 있으면 private는 PAGE_COPY, shared는 PAGE_SHARED 계열이고 역시 execute 유무로 나뉩니다. x86의 이 기본 표에서는 READ 추가만으로 결과가 달라지지 않는 조합도 있습니다. 실제 vm_get_page_prot()는 해당 VM flag 조합을 선택한 뒤 protection key, encryption과 supported mask를 반영합니다. syscall의 권한 해석 전체와 이 template lookup을 동일시해서는 안 됩니다.
private writable VMA는 기본적으로 copy가 필요한 write를 fault로 받도록 protection을 구성할 수 있습니다. 반면 이미 exclusive인 anonymous folio를 설치할 때에는 writable PTE를 만들 수 있습니다. vm_page_prot라는 template, VMA의 허용 권한, 현재 PTE의 실제 bit를 한 값처럼 취급하면 CoW가 모순으로 보입니다.
_PAGE_GLOBAL은 context switch와 TLB 유지에 관계합니다. kernel mapping의 성능에 유리할 수 있지만 PTI에서 user root에 남지 않는 mapping에는 제한이 적용됩니다. GLOBAL 여부를 meltdown hardware mitigation 하나의 단순 boolean 공식으로 고정할 수는 없습니다. build된 feature와 runtime PTI, 실제 mapping이 양쪽 root에 존재하는지를 구별해야 합니다.
결국 PTE는 PFN과 permission 몇 개를 붙인 불변 record가 아닙니다. hardware가 읽는 translation이면서 kernel이 fault·tracking·reclaim을 표현하는 상태입니다. level, PRESENT의 의미와 helper의 전제를 먼저 확인해야 같은 숫자의 bit도 올바르게 읽을 수 있습니다.
참고 자료
- Lorenzo Stoakes, The Linux Memory Manager, Early Access 2025, Chapter 3 §3.1.2~§3.1.3.
- Linux v6.18 pgtable_types.h, pgtable.h: bit와 helper의 전제.
- Linux v6.18 PAT 문서: memory type와 alias 규칙.
- Linux v6.18 PTI 문서: kernel mapping과 GLOBAL·CR3의 관계.
- Linux v6.18 pgprot.c: protection_map과 vm_get_page_prot().
- Linux v6.18 Soft-Dirty 문서: 추적 초기화, write fault와 새 영역의 예외.